Docs Interfaces & theming

Theming & branding contract

@ponchia/ui is one framework meant to dress several different projects. This is the stable, supported surface for re-branding without forking. Anything not listed here is internal and may change between minor versions.

Theme selection, persistence, and OS synchronization

Declare both supported schemes in the document head so browser-owned controls and chrome follow the active theme:

<meta name="color-scheme" content="light dark" />

Call initThemeToggle() for any [data-bronto-theme-toggle] controls. It persists an explicit light/dark choice in localStorage['bronto-theme'], keeps aria-pressed current, and emits bronto:themechange on <html>. When there is no explicit data-theme, an OS preference change emits the same event, so canvas, SVG, MapLibre, and other non-CSS renderers can redraw without a separate media-query listener.

import { initThemeToggle } from '@ponchia/ui/behaviors';

const stopTheme = initThemeToggle();
document.documentElement.addEventListener('bronto:themechange', (event) => {
  redrawNonCssSurface(event.detail.theme);
});

For a stored preference without first-paint flash, put this render-blocking script in <head> before the stylesheet/module code:

<script>
  try {
    var theme = localStorage.getItem('bronto-theme');
    if (theme === 'light' || theme === 'dark') document.documentElement.dataset.theme = theme;
  } catch (error) {}
</script>

To return to automatic OS mode, remove both the storage item and data-theme, then reinitialize the theme behavior so it can subscribe to OS changes again.

The brand knob: --accent

The accent family derives from --accent via color-mix() and the theme-owned neutral ramp endpoint:

Token Derivation (light / dark) Role
--accent-strong --accent mixed 83% with black / 80% white darker/lighter accent for hover, emphasis
--accent-ramp-end white / black neutral endpoint for the low-chroma OKLCH ramp
--accent-text var(--accent-strong) (alias) accent used as foreground text — the on-surface, AA-safe one
--accent-soft --accent at 10% / 14% over transparent tinted fills
--bg-accent --accent at 6% / 8% faint accent backgrounds
--field-dot-accent --accent at 78% / 82% form dot indicators
--focus-ring var(--accent) (solid) every focus outline — override to tune the ring alone

So a full re-brand is one declaration at :root (or a theme root):

:root      { --accent: #2f6df6; }   /* brand the whole app blue   */
:root[data-theme='dark'] { --accent: #6ea8ff; } /* per-theme tuning */

Core DOM accent surfaces — buttons, focus rings, dot motifs, accent borders, soft fills — follow automatically, in both light and dark.

That scope matters. --accent is the core action/emphasis hue, not the whole color system. Status colors (--success, --warning, --danger, --info), neutral surface tiers, display-expression tokens, and the opt-in data-viz palette stay separate governed tiers. Mermaid, D2, and Vega bridges emit resolved renderer theme data; a rendered SVG or canvas does not live-reskin when you change CSS --accent later.

Re-branding a subtree (not :root) is only a partial re-brand. The derived family (--accent-soft, --accent-strong, --accent-text, --bg-accent, the --accent-1…6 ramp) is computed from --accent via color-mix() at :root, where it resolves once. A custom property's value is substituted where it's declared, so overriding only --accent on a .promo subtree re-brands the surfaces that read raw var(--accent) (focus rings, dot motifs, some borders) but leaves every derived surface (soft fills, accent-as-text, the ramp) at the root hue — a visibly broken half-rebrand. To re-brand a subtree fully, set the derived tokens you use too, e.g.:

.promo {
  --accent: #16a34a;
  --accent-strong: color-mix(in srgb, var(--accent) 83%, #000);
  --accent-text: var(--accent-strong);
  --accent-soft: color-mix(in srgb, var(--accent) 10%, transparent);
  --bg-accent: color-mix(in srgb, var(--accent) 6%, transparent);
}

When in doubt, re-brand at :root/[data-theme] — that path re-derives the whole family for you.

Re-brand obligations

Two contrast obligations when you change --accent:

  1. Buttons — pick an --accent with ≥ 4.5:1 against --button-text (white in light, black in dark) for accessible primary buttons.
  2. Accent-as-text — anywhere the accent is foreground text (links, active nav/tabs, eyebrows, chips) the framework uses --accent-text, not raw --accent, so it stays AA on surfaces. --accent-text defaults to --accent-strong (a darkened/​lightened accent). If you re-brand to a pale hue, raw --accent would fail as text — override --accent-text to a sufficiently dark/light value rather than relying on the 83%/80% mix.

The defaults are tuned for both; verify if you deviate hard. The focus ring is solid --accent (≥ 3:1 non-text) — re-brand to a near---bg hue and you must also raise --focus-ring (it's an independent knob).

Re-inking accent fills: use --button-text, not --on-accent. In-DOM components (buttons, chips, accent fills) paint their on-accent ink from --button-text. --on-accent is a read-only export of that resolved ink for foreign renderers (charts, canvas, a non-DOM target reading the token) — overriding it in CSS validates but does not change any in-DOM component. To change the ink on a re-brand, set --button-text.

Other supported knobs

  • Spacing — override the --space-2xs … --space-2xl scale, or use a preset: data-density="compact" / data-density="comfortable" on any element (defaults to the middle scale). Dense tool chrome also gets four half steps on the 0.25rem unit that the t-shirt scale skips: --space-0-5, --space-0-75, --space-1-5 and --space-2-5 (2, 3, 6 and 10px at a 16px root). The density presets scale them with the rest.

    Read this before relying on the preset. It re-points the --space-* scale, and only components whose padding is expressed in that scale move with it — around 40 of them, including ui-panel, ui-modal__body, ui-evidence-item, ui-claim, ui-job, ui-code__body and the report surfaces. The rest carry tuned padding pairs like 0.5rem 0.55rem, which the seven-step scale cannot express, so they do not respond at all — ui-alert and ui-menu__item are the two most likely to surprise you.

    That is a real limit, not an oversight to work around: flattening a tuned pair onto the nearest scale step would change how those components look at the default density, which is the one nearly everyone uses. If you need a denser variant of a component that does not respond, override its padding directly — and if you find yourself doing that repeatedly for the same component, that is worth reporting, because it is evidence for a real --dense modifier rather than a preset that half-works.

  • Dark surface — the dark theme's base is a deliberately elevated near-black (--bg: #121212) for readability: pure black + bright text causes halation, and near-black-on-black surface steps are imperceptible. For OLED power-saving or the original true-black "Nothing" look, opt in with data-surface="oled" on :root (a root-level attribute like data-theme). It only affects the dark theme and is a CSS-only preset (not in the JS token model), so it never blacks out the light theme. See ADR-0003 for the theme-model rationale.

  • Radius — --radius-sm … --radius-xl, --radius-pill. The Nothing default is near-sharp; raise these for a softer brand.

  • Tap targets — --tap-target (44px, WCAG 2.5.5 / iOS HIG / Material) is the floor every control floats to inside @media (pointer: coarse); --tap-target-min (24px) is the WCAG 2.5.8 AA minimum used by controls that only have to clear the smaller bar. Both are authored as max(px, rem) on purpose: they scale up with a larger root font but cannot shrink below the standard if you re-point html { font-size }. If you override them, keep the clamp — a bare rem is how a 44px floor quietly becomes 43.5px. Raise them for a glove-friendly or kiosk build; do not lower them.

  • Safe areas — --safe-area-top / -right / -bottom / -left default to env(safe-area-inset-*, 0px), so they are 0 everywhere except a device with a display cutout or a gesture bar. Every viewport-anchored surface Bronto ships reads them: the app rail and topbar, a sticky site header, the skip link, both toast stacks, the drawer modal and the lightbox. Override them when your host supplies its own insets — an embedded webview, a kiosk frame, or a test runner that cannot emulate env(), which is the reason the values are indirected through custom properties rather than called at the point of use. Consumers positioning their own floating chrome should follow the same convention: inset-block-end: max(<your offset>, var(--safe-area-bottom)).

  • Type — --display (dot-matrix face), --mono, --sans. Override to drop Doto or swap the body face; the token layer keeps working even if you self-host fonts (see the fonts.css note in the README). The default bundle ships Doto only. --sans names Inter first and --mono JetBrains Mono, so without those faces each OS draws its own fallback. Import @ponchia/ui/css/fonts-inter.css and @ponchia/ui/css/fonts-jetbrains-mono.css to ship both (Inter 4.1 as one variable face per style; JetBrains Mono 2.304 in regular, bold and their italics; SIL OFL 1.1, licenses in fonts/). A browser downloads a face only when text needs it.

  • Surfaces / lines / text — the --bg*, --panel*, --line*, --text* tokens are overridable for a bespoke palette, but you then own their contrast. Prefer just --accent unless you need a full re-skin.

  • Native controls — checkbox/radio/range tick marks use the CSS accent-color: var(--accent) (browser-rendered). The check glyph colour is the UA's choice and not in our control, so a very light --accent (e.g. a pale yellow) can make native checkmarks low- contrast. If you re-brand to a light hue, verify native controls or set accent-color yourself on them — this is the one accent surface the framework can't tune for you.

A tool over a canvas: layers and zoom

Layers. A page needs six stacking layers (--z-base, --z-raised, --z-sticky, --z-overlay, --z-popover, --z-toast). A tool drawn over a canvas needs named ones, lowest first:

Token Value For
--z-canvas --z-base the canvas plane; its nodes stack locally inside it
--z-chrome --z-sticky toolbars, headers and rails over the canvas
--z-panel 25 docked and floating panels
--z-modal --z-overlay dialogs and their scrim
--z-menu --z-popover menus and popovers, including those a dialog opens
--z-toast 60 toasts
--z-tooltip 70 tooltips
--z-navigation 80 presentation and tour chrome that drives the whole surface

Where a workspace layer means the same as a page layer it is an alias, so the two scales cannot disagree.

Zoom. A host that draws bronto UI inside a scaled surface, such as a zoomable canvas, marks the scaled element with data-ui-zoom and sets --ui-zoom to its scale there:

.canvas-viewport {
  --ui-zoom: var(--my-canvas-zoom);
}
<div class="canvas-viewport" data-ui-zoom>…</div>

Inside it --ui-px is one screen pixel (1px divided by the zoom, with the zoom floored at 0.15), and --hairline, --focus-ring-width and --focus-ring-offset are re-declared in it. Every bronto focus ring uses those tokens, so a control inside a zoomed-out canvas keeps a 2px ring on screen. Size your own canvas overlays the same way: width: calc(1.5 * var(--ui-px)).

The scope is an attribute rather than an inherited value on purpose. A custom property that reads --ui-zoom resolves where it is declared, so a value set on :root could not follow a zoomed subtree.

Beyond accent: full re-skins

The "Nothing" look is the default skin, not the architecture. It is a handful of token declarations deep — no selector hardcodes the identity. An audit of css/ found radius (every rounded element goes through var(--radius-*)), the display face, dot density, motion easings and the colour scale are all token-driven; the only hardcoded geometry is the deliberate border-radius: 0 on a few sharp elements and 50% on dots/avatars (correct to hardcode — they are circles, not skin).

The framework derives its accent per theme (the shipped red is #d71921 light / #ff3b41 dark) precisely so each side stays contrast-safe. A serious re-skin does the same — one override block, no fork:

/* "Warm" — softer, rounder, no dot-matrix. Per-theme so the primary
   button label stays AA on both sides (measured: see below). */
:root,
:root[data-theme='light'] {
  --accent: #a8431a;            /* terracotta — #fff label = 6.03:1 */
  --radius-md: 10px;            /* the system rounds everywhere at once */
  --radius-lg: 14px;
  --radius-xl: 20px;
  --display: var(--sans);       /* retire the Doto dot-matrix face */
  --dot-size: 0;                /* mute the decorative dot-grid motif */
}
:root[data-theme='dark'] {
  --accent: #e8a06a;            /* lighter warm — #000 label = 9.67:1 */
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) { --accent: #e8a06a; }
}

That changes buttons, cards, inputs, badges, focus rings, the display type and the dot motif together, because those surfaces consume the token contract — not because there is a per-component theme file. It does not rewrite status semantics or chart palettes; those are governed by their own tiers. This is the difference between a system and a skin: the skin is swappable, the system (rationed colour, density, classless prose, minimal JS) is what you are actually adopting.

Caveat, restated: a custom --accent is your contrast obligation — the shipped palettes are CI-gated (contrast.md), a re-skin is not. The values above are measured (white label on #a8431a = 6.03:1, black on #e8a06a = 9.67:1, both ≥ 4.5:1 AA); pick your own with the same check. Don't set one global --accent and hope — that is why this example is split per theme.

Token tiers

Three additive, non-breaking tiers sit on top of the primitives. The short legacy names (--panel, --line, --accent, …) keep working forever as aliases — the tiers are about giving consumers stabler, coarser-grained handles.

  • Semantic tier — --bronto-color-*. Role-named aliases: --bronto-color-surface, -surface-raised, -border, -border-strong, -text, -text-muted, -action, -on-action, -focus, -success, -warning, -danger, -bg. Target these in new consumer code: re-skinning a role is now one override instead of chasing component internals. They resolve through the per-theme primitives, so light/dark still Just Works.
  • Accent ramp — --accent-1 … --accent-6. A stepped family (subtle → bold) derived from the single --accent knob via color-mix(in oklch, …) against a per-theme white/black endpoint (--accent-ramp-end). Re-brands and theme-adapts automatically. Steps 1–4 are subtle surfaces; steps 5–6 are the accent and strong accent. Exact hex outputs are visual tuning, but the token names and roles are stable.
  • Neutral ramp — --surface-1 … --surface-6 (low → high contrast against --bg) for layered surfaces without hand-picking greys.
  • Stacking scale — --z-base / -raised / -sticky / -overlay / -popover / -toast. Every framework z-index now resolves through these; override one to slot your app's own layers around the framework's without specificity/z-index wars.

All four tiers are in the DTCG export and the JS token model. The full five-tier colour model and its rules live in ADR-0001.

Display colorways (data-bronto-skin)

Opt-in single-hue colorways, shipped as a separate entrypoint (never in the default bundle):

<link rel="stylesheet" href="@ponchia/ui/css/skins.css" />
<html data-theme="dark" data-bronto-skin="phosphor-green">
  …
</html>

data-bronto-skin="amber-crt | phosphor-green | e-ink" is a root-level choice, like data-theme — put it on :root/<html>. It re-points the one --accent (per theme, authored in OKLCH); the derived family, focus ring, dot-matrix and glyphs follow automatically, and status colours + the neutral canvas are untouched. A colorway is not a second accent — it swaps the one you have, so the one-accent discipline holds.

  • Root-level only. The accent's derived family (--accent-strong/-text, --field-dot-accent, --accent-1..6, …) is color-mix(… var(--accent) …) declared on :root; it only re-evaluates on the element that carries it. A skin on a subtree would leave that family stale, so the selectors are :root-anchored and a subtree skin simply no-ops.
  • Phosphor bloom. The amber-crt / phosphor-green skins set --dotmatrix-glow in dark, so the dot-matrix gains a CRT-style glow. It is a Tier-3 display knob — a --dotmatrix-* CSS custom property with an inline default of 0 (off), like --dotmatrix-dot/--dotmatrix-gap, not a token in the palette export (Tier-3 display expression lives as --dotmatrix-* knobs in css/dots.css, by ADR-0001). Set it yourself on any .ui-dotmatrix to tune the bloom.
  • Contrast-gated. Every shipped skin accent meets the same WCAG AA / 3:1 floors as the core palette — see contrast.md → "Display colorways". (Your own --accent re-brand is still your obligation; the guarantee covers the shipped palettes and skins.)

Data-viz palette

Opt-in Tier-4 categorical colour — never UI chrome (a build gate fails on var(--chart-*) or var(--cat-*) in component CSS), and never in the default bundle. One leaf carries eight fixed hues in two namespaces: --chart-* for data-viz series and ramps, and --cat-* for categorical identity — a tag, a participant, a user-chosen tint.

<link rel="stylesheet" href="@ponchia/ui/css/dataviz.css" />
// resolved hex for canvas / SVG / Chart.js etc.
import charts from '@ponchia/ui/charts.json' with { type: 'json' };
const series = charts.dark.categorical; // ['#3987e5', '#d95926', …] — blue first

For a page that switches theme, skin, contrast or the OLED surface at runtime, read the live values with @ponchia/ui/renderer instead of the static JSON.

  • Categorical --chart-1..8 = --cat-1..8 — blue, orange, aqua, yellow, magenta, green, violet, red, in that fixed order (CATEGORICAL_HUES names them). No slot is the accent, so an ordinary first series never reads as an alert. check:charts measures each theme against the panel, the page and the OLED surfaces: OKLCH lightness inside the theme's band, chroma above the grey floor, adjacent slots separated under simulated protanopia and deuteranopia and in normal vision. Slots under 3:1 against a surface are reported in the gate output; relief is the pattern fill or a direct label. Any two slots can meet in a scatter or a map, so pair colour with pattern there.
  • Identity --cat-N-tint / --cat-N-ink — a 16% wash of the hue over --panel (it follows a skin's or OLED's panel) and a text colour that holds 4.5:1 on the panel, the page and its own tint. Use them for a tag chip, a participant's name, or a user-chosen highlight — never for status.
  • Sequential --chart-seq-1..5 — one blue hue; step 1 sits nearest the surface (pale in light, deep in dark), for heatmaps/intensity. Diverging --chart-div-1..7 — blue↔neutral↔orange, for ±/gains-losses.
  • Pattern fills --chart-pattern-1..8 — a dot-matrix second channel so colour is never the sole signal (WCAG 1.4.1). Pair colour N with pattern N: background: var(--chart-2); background-image: var(--chart-pattern-2); background-size: var(--chart-pattern-size); --chart-pattern-ink: rgb(0 0 0 / .34);
  • A chart colour's WCAG ratio vs the background is published advisory in contrast.md (a fill is not body text) — for thin lines or points use the slot's --cat-N-ink, or lean on the pattern.

Accessibility markup contracts

A few components are styled but need the consumer to author the right semantics — the CSS can't add ARIA for you:

  • .ui-switch — put role="switch" on the <input type="checkbox">. It then announces "switch, on/off" and the native checked drives aria-checked (no JS). Forced-colors state cues ship in forms.css.
  • .ui-tab / tabs — operability requires initTabs(); don't server-render panels hidden unless it's guaranteed to run (see the initTabs doc comment).
  • .ui-combobox — use initCombobox(); it owns the APG ARIA.
  • .ui-tooltip — fine for short labels; for edge-critical or rich content use .ui-popover + initPopover() (collision-aware).

Verify a rebrand: open the theme playground — paste your --accent, see the derived family and the computed WCAG ratios for --accent-text / --accent against the surface, and copy the CSS

  • DTCG override. This is the instrument for the "verify your hue" obligation below.

Contrast

  • Lines that carry meaning use --edge, not a hairline. --line and --line-strong are decorative borders, which WCAG 1.4.11 exempts and the contrast gate reports without enforcing. A relationship between nodes, a connector or annotation leader, or a bracket mark is a graphical object the reader needs, so it must hold 3:1 against its surface. --edge is the dim-text ink (var(--text-dim)), gated at 3:1 on the page and on a card in every theme and colorway. Connectors, annotations, the bracket note, Mermaid edges and D2 connections draw in it; renderers read it as edge.
  • data-contrast="high" on any element, and the OS prefers-contrast: more signal, collapse the soft greys toward the strong end (hairlines → --line-strong, dim text → --text-soft, solid focus ring). Theme-agnostic — they reference the per-theme *-strong tokens, so they work under light and dark. --edge follows the dim text up with them.
  • Windows High Contrast / forced-colors: active is handled in base.css: state that was signalled only by a fill (progress, status dots, switch, segmented) is re-asserted with system colors.

Design-token interop (DTCG)

@ponchia/ui/tokens.dtcg.json is the token model in the W3C Design Tokens Community Group format, for Style Dictionary / Figma / other tooling. Generated from tokens/index.js and drift-checked by npm run check. From 0.7 it is a resolved DTCG 2025.10 projection: colours use structured sRGB values, dimensions use { value, unit }, durations use { value, unit }, and every typed token has a non-null portable value. Derived var() / color-mix() colours are resolved independently in the light and dark groups. The authored CSS remains in $extensions["com.ponchia.css"].authoredValue. CSS-only expressions that cannot be represented portably, including shadows, and em-based letter-spacing remain in @ponchia/ui/tokens.json rather than becoming fake DTCG values. The root extension lists every deliberately omitted CSS variable.

@ponchia/ui/tokens/figma.variables.json is the resolved local handoff for Figma Variables import/sync scripts. It is generated from tokens/resolved.json and keeps a Bronto / Color collection with Light and Dark modes plus a Bronto / Scale collection for spacing, radius, type, z-index and motion. Colour values are exported as Figma-style RGBA objects; non-colour values keep their original CSS value under $extensions["com.ponchia.css"] so importer scripts do not lose units.

Reading tokens from JS

@ponchia/ui/tokens exposes the model as data. The ergonomic view only strips the -- prefix — keys stay kebab-case, so they must be bracket-accessed: themeColor('dark')['accent-soft'], not .accentSoft. (themeColor('dark').accent works only because accent is a single word.) --accent and the non-derived colors resolve to literal hex; the derived members (accent-strong, accent-soft, focus-ring, …) are color-mix(…) / alias strings — resolve them in the DOM via getComputedStyle if you need the final value, or just read/​set --accent itself. The .d.ts now types these keys as literal unions (ColorKey/ScaleKey), so a mistyped key is a compile error and autocomplete lists the real names.

Stability

The token names and the --accent derivation are the contract and are covered by npm run check (the tokens.css ⇄ tokens/index.js ⇄ index.json drift check). Token values may be tuned within a theme; treat a value change as visual, a name/derivation change as breaking.