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…6ramp) is computed from--accentviacolor-mix()at:root, where it resolves once. A custom property's value is substituted where it's declared, so overriding only--accenton a.promosubtree re-brands the surfaces that read rawvar(--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:
- Buttons — pick an
--accentwith ≥ 4.5:1 against--button-text(white in light, black in dark) for accessible primary buttons.- 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-textdefaults to--accent-strong(a darkened/lightened accent). If you re-brand to a pale hue, raw--accentwould fail as text — override--accent-textto 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---bghue 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-accentis 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-2xlscale, 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-5and--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, includingui-panel,ui-modal__body,ui-evidence-item,ui-claim,ui-job,ui-code__bodyand the report surfaces. The rest carry tuned padding pairs like0.5rem 0.55rem, which the seven-step scale cannot express, so they do not respond at all —ui-alertandui-menu__itemare 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
--densemodifier 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 withdata-surface="oled"on:root(a root-level attribute likedata-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 asmax(px, rem)on purpose: they scale up with a larger root font but cannot shrink below the standard if you re-pointhtml { 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 / -leftdefault toenv(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 emulateenv(), 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 thefonts.cssnote in the README). The default bundle ships Doto only.--sansnames Inter first and--monoJetBrains Mono, so without those faces each OS draws its own fallback. Import@ponchia/ui/css/fonts-inter.cssand@ponchia/ui/css/fonts-jetbrains-mono.cssto 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 infonts/). 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--accentunless 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 setaccent-coloryourself 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--accentknob viacolor-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 frameworkz-indexnow resolves through these; override one to slot your app's own layers around the framework's without specificity/z-indexwars.
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, …) iscolor-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-greenskins set--dotmatrix-glowin 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 of0(off), like--dotmatrix-dot/--dotmatrix-gap, not a token in the palette export (Tier-3 display expression lives as--dotmatrix-*knobs incss/dots.css, by ADR-0001). Set it yourself on any.ui-dotmatrixto 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
--accentre-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_HUESnames them). No slot is the accent, so an ordinary first series never reads as an alert.check:chartsmeasures 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— putrole="switch"on the<input type="checkbox">. It then announces "switch, on/off" and the nativecheckeddrivesaria-checked(no JS). Forced-colors state cues ship informs.css..ui-tab/ tabs — operability requiresinitTabs(); don't server-render panelshiddenunless it's guaranteed to run (see theinitTabsdoc comment)..ui-combobox— useinitCombobox(); 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/--accentagainst 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.--lineand--line-strongare 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.--edgeis 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 asedge. data-contrast="high"on any element, and the OSprefers-contrast: moresignal, 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*-strongtokens, so they work under light and dark.--edgefollows the dim text up with them.- Windows High Contrast /
forced-colors: activeis handled inbase.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.