Docs Getting started

Usage — when to reach for what

docs/reference.md is the catalog (every class, generated). This is the decision guide: the rules a kitchen-sink demo can't tell you. It is hand-written and stable — treated as contract like theming.md, not auto-generated.

The one principle everything below follows:

Color is rationed. Structure carries meaning. Reach for layout, type weight, and the hairline before reaching for a hue. The accent is a spotlight, not a paint bucket.

Density: the unset default and its two presets

data-density has two presets over an unset middle default. There is no data-density="default" value — unset is the design target; you opt into a preset on <html> or any subtree:

Value Use for
(unset) general app & content — the design target
compact data-dense admin: tables, dashboards, toolbars
comfortable marketing / landing / reading-first pages

Scope it, don't globalize blindly: a dashboard with one marketing-style hero can set compact on <html> and comfortable on the hero section.

data-density is the global preset; per-component density verbs differ by family. Some components carry their own local density modifier, and the verb is not uniform: it's --dense on ui-table/ui-dotgrid but --compact on ui-prose/ui-legend/ui-report. So ui-prose--dense and ui-table--compact both no-op. When in doubt, reach for the global data-density preset; use the local modifier only where it exists (check the base's modifiers in classes.json).

Badge vs chip vs status dot

All three are small. They are not interchangeable:

Use When
status dot a single piece of state on something else (row online, build ok). Smallest possible signal; pair with text for a11y, never color-only.
badge a label classifying the thing it sits on (count, tone, "BETA"). Static, not actionable. ui.badge({ tone }).
chip a discrete, often removable/selectable token the user manipulated (a filter, a tag input value). Interactive affordance implied.
tag a static keyword/category label (ui.tag), like a badge but reads as a non-interactive tag. Use chip for anything the user selects/removes; tag for a fixed label.

Rule of thumb: state → dot, classification → badge, user-controlled value → chip, fixed keyword → tag. (ui-property is a workbench primitive — a key/value spec row scoped to @ponchia/ui/css/workbench.css, not a general content label; don't reach for it to tag prose.)

Tone vocabulary varies by family — by design. Colour is rationed, so not every component carries every tone. The status tones (--success/--warning/--danger) plus --info ride the status families — ui-alert, ui-toast, ui-meter, ui-dot, and ui-badge all carry --info. The neutral --muted is not a status tone: it rides several neutral bases (ui-badge, ui-num, ui-eyebrow, ui-mark, ui-annotation, ui-connector, ui-crosshair) but is deliberately absent from the status families ui-dot/ui-alert/ui-toast/ui-meter. Because the CSS is a plain class list, a hand-written unknown modifier (e.g. ui-dot--muted, ui-meter--muted) silently no-ops — CSS can't warn. The ui.* builders are safer on both ends: TypeScript rejects an out-of-set tone at author time, and at runtime they console.warn (then drop the tone) so a JS caller sees the mistake rather than shipping a bare, untoned element. The authoritative per-component tone list is each base's modifiers array in @ponchia/ui/classes.json — read it rather than extrapolating a tone you saw on one component onto another.

Tone is a colour channel, so it collapses under Windows High-Contrast Mode. A ui-badge--success and a ui-badge--danger render identically once HCM remaps colours (the tinted background + tone border are flattened to one system colour). Status dots and the meter fill re-assert a distinct system colour, but the badge/chip/tag tints cannot carry five differentiable tones — so treat badge tone as decorative emphasis and make sure the badge's text label carries the status word ("Failed", not a bare red pill). Same WCAG 1.4.1 reasoning the dots follow.

Numbers: ui-num vs the table state classes

  • Inside .ui-table, a numeric cell is .is-num (+ .is-pos / .is-neg for P&L tone). These are table-local is-* hooks, not in cls by design.
  • Anywhere else (a card, a stat, inline figures) use the ui-num primitive — ui.num({ tone }). Same tabular/aligned/tone intent, freed from the table. Do not hand-roll right-align + text-green; that's the duplication ui-num exists to kill.

The positive/negative vocabulary is spelled three ways — match the mechanism to the host, they are not interchangeable:

Where Positive / negative Kind
ui-num primitive (anywhere) ui-num--pos / ui-num--neg (ui.num({ tone: 'pos' })) modifier
Inside .ui-table is-pos / is-neg state hook (table-scoped)
ui-delta trend ui-delta--up / ui-delta--down (ui.delta({ dir })) direction

They share the same tone tokens but don't cross over: ui-num is-pos no-ops (the is-pos rule is scoped to table cells), and ui-delta--pos doesn't exist. Use the modifier on ui-num, the is-* state inside a table, and --up/--down on ui-delta (which also flips with ui.delta({ invert }) when up is the bad direction — latency, error rate, cost).

Prose vs primitives, and prose inside a card

  • ui-prose styles raw, unclassed semantic HTML (MDX / CMS / LLM output). Use it for body content you don't control the markup of.
  • Do not wrap app UI in ui-prose to "get nice spacing" — compose primitives instead; prose deliberately restyles bare <h2>, <table>, <a> and will fight your components.
  • Prose inside a card: put ui-prose on an inner wrapper, not on .ui-card itself, so card padding/border stays the card's and prose rhythm stays the content's. One responsibility per element.

Centred width: ui-center vs ui-container

Both cap a centred column, but they are different primitives with different box models — pick by intent:

  • ui-center — a reading measure. --center-max is the inner content width (content-box); the --center-gutter padding adds outside it. Use it to hold prose/body to a comfortable line length.
  • ui-container — a page frame. --container (/--container-narrow /--container-wide) is the total max width (border-box). Use it as the outer wrapper that aligns a page's sections to a shared edge.

Rule of thumb: measure of text → ui-center; page-level frame → ui-container. Don't nest one inside the other expecting the caps to compose — they measure different boxes.

Container queries: ui-cq

ui-cq is the one primitive that changes how other primitives respond. Add it to a wrapper and its descendants adapt to that box's inline size, not the viewport — so the same ui-grid / ui-statgrid / ui-app-metrics collapses to one column inside a slim panel even when the window is wide (island-safe; it nests). Two thresholds are built in: ui-grid drops to a single column at 34rem and ui-statgrid/ui-app-metrics at 30rem, measured on the ui-cq box. Note rem in a container query resolves against the root font size, not a fixed device pixel size. At the default 16px root these are 544px and 480px; they follow the browser text-size preference. And be aware ui-grid already collapses on its own via an intrinsic auto-fit minmax, so ui-cq barely changes it — the primitive that genuinely needs ui-cq to collapse by container (not viewport) is ui-statgrid/ui-app-metrics. The container is named bronto (hardcoded — there is no --cq-name knob; an author-set one is ignored), so an outer query never accidentally matches an inner grid. ui-cq is inert until applied, so adding it never shifts an existing layout. Reach for it whenever a layout must respond to its container (a resizable pane, a sidebar widget, an embedded card) rather than the page.

Static reports

Use the opt-in @ponchia/ui/css/report.css layer for static, PDF-first reports. A report composes ui-report + existing primitives: ui-statgrid for KPIs, ui-alert for persistent notices, ui-table for evidence, ui-timeline for events, ui-meter for measured values, and ui-prose only for narrative body content you do not fully control.

Do not turn every report block into a card. Use ui-report__summary, ui-report__finding, and ui-report__evidence for document structure; use ui-card only when the block is genuinely a repeated card item. bronto ships no chart component: for a chart, theme Vega-Lite (@ponchia/ui/vega, see vega.md) or hand-author a token-themed inline SVG painted from the data-viz palette tokens. Always wrap it in a ui-report__figure with a caption, a .ui-legend key, and fallback data. Full LLM/static report cookbook: reporting.md.

A tool: @ponchia/ui/css/tool.css instead of the default bundle

An application that draws on its own surface (a canvas, an editor, a workbench) renders none of the site and app chrome: the theme toggle, the content-site shell, data tables and the admin service shell. Import @ponchia/ui/css/tool.css instead of @ponchia/ui. It is the default bundle in the same cascade order without the navigation, site, table and app leaves. Everything else, including the dot-matrix glyphs, motion and the feedback, overlay and disclosure primitives, is unchanged. Add one of the four back as its own leaf (@ponchia/ui/css/table.css) if a screen needs it.

Buttons: variant and size

  • primary is the bare ui-button. There is no --primary and no --accent: the unmodified class already paints the accent fill, and the variants below all step down from it. Aim for one per view. Consumers reach for a --primary or --accent modifier often enough that it is worth stating plainly: neither exists. Writing one is harmless and invisible — the unknown class does nothing and the button still looks right, which is why the mistake survives review. bronto-ui-check is what catches it.
  • ghost — secondary actions; the default for "another button here".
  • subtle — tertiary / low-stakes (toolbar, inline).
  • danger — destructive confirmation.
  • Size: default everywhere; --sm for dense tooling (toolbars, pagination, table row actions), --lg for a hero CTA only, --dense when a bar's height is the constraint (a pane title bar). --dense lowers only the visual floor — coarse pointers still get the full --tap-target, so a control you shrink for a mouse is never shrunk for a finger.
  • Loading is not a class: set aria-busy="true" (+ disabled); the spinner is CSS. This is the ARIA-driven contract — see reference.md → "Composition & state".

Rows: ui-row vs table vs menu item

Three shapes look alike and are not interchangeable:

  • ui-table when the data has columns and a header. A table promises that the third cell means the same thing on every line.
  • ui-menu__item when the list is a menu: it dismisses on choice, and it is reached through ui-menu-host.
  • ui-row for everything else — a search result, a file in an explorer, an outline entry, a backlink, a commit. A full-width clickable line that persists.
<button class="ui-row ui-row--ruled" type="button" aria-current="true">
  <span class="ui-row__mark" aria-hidden="true">◆</span>
  <span class="ui-row__title">apps/server/src/collab/room.ts</span>
  <span class="ui-row__meta">4m</span>
</button>

The one rule worth knowing: __title is what truncates. It takes the slack and gives it back first; __meta never shrinks, because a half-rendered number is worse than no number.

Pick the right attribute, and it is probably not aria-selected. That one is only valid on a row whose role accepts it — option inside a listbox, or row / tab / gridcell / treeitem. On a bare <button> it is invalid ARIA and axe rates it critical; this project shipped that mistake in its own demo and the a11y gate caught it before release.

  • aria-current="true" — the row is the current one. The common case, and valid on any element.
  • aria-selected="true" — only when the row really is an option in a listbox, or another role that accepts it.
  • .is-selected — when neither fits.

All three paint the same, so the visual state cannot disagree with the announced one. Rows carrying a severity should use ui-severity-row (css/state.css) instead, which adds the tone gutter.

ui-menu__item composes ui-row, which is why they cannot drift.

Empty state vs invite

Both use ui-empty-state, and the slots are the same three parts — a quiet __glyph, one sentence of full ink in __lead, a quieter __hint. What differs is the job:

  • Plain ui-empty-state reports absence: a dashed card saying this region has no rows today. Use it for a list, a table, a results pane.
  • --invite offers the next action: no dashed box (there is nothing to outline — the surface itself is what you are being invited into) and it centres in whatever block space it is given. Use it for a surface the user is meant to fill.
<div class="ui-empty-state ui-empty-state--invite">
  <span class="ui-empty-state__glyph" aria-hidden="true">+</span>
  <p class="ui-empty-state__lead">Nothing pinned yet</p>
  <p class="ui-empty-state__hint">Drop a file here, or press ⌘K</p>
</div>

Without the slots, every empty surface in an app re-invents these three parts under a different name and they drift — one has a glyph, the next has two sentences at the same weight, a third is a bare <p>.

Small bodies: body state and band

A node, panel or card body of 200–400px has four things to say besides its content: nothing is here, it is loading, it failed, it is out of date. A page-level empty state or a boxed alert spends a third of such a body on its frame, so a small body uses two compact shapes:

  • ui-body-state replaces the content. It fills the body and centres one short sentence, with an optional ui-empty-state__hint or an action. Use ui.bodyState() for empty and loading, the plain state (put aria-busy="true" on a loading region, and a ui-dotspinner or ui-skeleton inside if you like); ui.bodyState({ state: 'error' | 'stale' }) leads with a tone dot.
  • ui-alert--band sits above content the body still shows: a full-bleed line of small type, the tone as a tint. Use ui.alert({ tone, band: true }) as the body's first child.
State Content gone Content still shown
empty ui.bodyState() —
loading ui.bodyState() + aria-busy ui-skeleton rows in place
error ui.bodyState({ state: 'error' }) ui.alert({ tone: 'danger', band: true })
stale ui.bodyState({ state: 'stale' }) ui.alert({ tone: 'warning', band: true })
<div class="node-body ui-cq">
  <p class="ui-alert ui-alert--warning ui-alert--band" role="status">
    Last synced 2 h ago
  </p>
  …the content, still readable…
</div>

<div class="node-body ui-cq">
  <div class="ui-body-state ui-body-state--error" role="alert">
    <p>Could not load the run</p>
    <button class="ui-button ui-button--subtle ui-button--dense" type="button">Retry</button>
  </div>
</div>

Make the body a ui-cq container: below 15rem the body state tightens to the smallest type and padding. The body itself should be a flex column or have a height, so the state can fill it.

Link vs link--cta

Plain ui-link for in-flow links. ui-link--cta is the accent action link with an arrow — a navigational call to action, not a substitute for a button (no form submit, no destructive action).

Feedback: alert vs toast vs tooltip

Surface Lifetime / trigger
alert / callout persistent, in-flow, part of the page (form errors, page-level notice).
toast transient, out-of-flow, system-initiated. Danger toasts route to an assertive live region; everything else polite.
tooltip supplemental, hover/focus, never essential info (it's not announced reliably; don't hide required content in it).

For dismissible in-flow alerts, mark the host with data-bronto-dismissible, put data-bronto-dismiss on the close button, and wire dismissible() from @ponchia/ui/behaviors. With no attribute value, the button removes the nearest dismissible host; with a selector value, it removes the nearest matching ancestor and dispatches a cancelable bronto:dismiss event first.

The CSS ui-tooltip is hover/focus-only and CSS can't wire it to assistive tech for you — associate the bubble with its trigger yourself, or it conveys nothing to a screen reader: give .ui-tooltip__bubble an id, point the trigger's aria-describedby at it, and keep the bubble role="tooltip". For a tooltip that must stay visible near a viewport edge or inside a scroll container, use initPopover (a real focus-managed panel) instead.

Meter vs progress

Both are a thin horizontal bar; they mean different things.

  • ui-progress — task progress: how far an operation has run. Can be indeterminate (ui-progress--indeterminate). The fill is always accent.

  • ui-meter — a measured static value: coverage, disk, capacity, a KPI against a target. Never indeterminate. Tone the fill by threshold (ui.meter({ tone }) → accent/success/warning/danger/info); the unset default is neutral. Drive the width with the shared --value knob — a unitless number 0–100 (style="--value: 72", not 72%: a % is invalid against the registered <number> type and the fill drops to empty). The class string paints a 0-width, unannounced bar on its own, so set the value and its ARIA together with attrs.meter(72) (or attrs.progress) from @ponchia/ui/classes — it returns role="meter" + aria-valuenow/min/max + the --value style, normalized to your { min, max }. Spread it: <div class={ui.meter({ tone })} {...attrs.meter(72)}>.

    Indeterminate progress is the one exception: call attrs.progress() with no argument. ARIA requires aria-valuenow be omitted for an indeterminate bar — emitting 0 would announce "0%", indistinguishable from a real stalled-at- zero bar — so the helper returns just role="progressbar" + aria-busy="true" (no aria-valuenow, no --value). Pair it with the class: <div class={ui.progress({ indeterminate: true })} {...attrs.progress()}>. The segmented .ui-dotbar uses the same accessibility contract through attrs.dotbar(value) or attrs.dotbar() for the indeterminate sweep.

Rule of thumb: something is happening → progress; something measures this much → meter.

Steps, timeline, kbd, input icons

  • ui-steps — a stepper for a multi-step flow. Use an <ol>. State is ARIA-driven (the framework rule): the active step is aria-current="step" (no class); completed steps take ui-steps__item--done. Markers are auto-numbered by CSS counter. --done is a visual state only — it isn't announced, so if "completed" must reach AT, add visually-hidden text (e.g. <span class="ui-visually-hidden">completed</span>) or an aria-label on the step.
  • ui-timeline — a vertical event list on a hairline spine (<ol> of ui-timeline__item, optional ui-timeline__time). aria-current on an item marks the live/most-recent event.
  • ui-kbd — an inline keyboard-key glyph. Wrap a <kbd>; for a shortcut, use one per key (<kbd>⌘</kbd> <kbd>K</kbd>).
  • ui-input-icon — a leading (or --end trailing) icon inside one control. This is distinct from ui-input-group, whose addon sits adjacent to the control. Wrap the input; the icon is decorative (aria-hidden) and the input keeps its full width. Don't hand-roll an absolute overlay.

Navigation: the landmarks and names the classes don't carry

The navigation classes are styling only — the ARIA scaffolding is yours, and without it these widgets are unlabelled or unannounced:

  • ui-breadcrumb — wrap it in <nav aria-label="Breadcrumb"> and mark the last (current) crumb with aria-current="page".

  • ui-pagination — wrap it in <nav aria-label="Pagination">; give the current page aria-current="page"; label icon-only prev/next controls (aria-label="Previous page"). Disable a control with native disabled (a <button>) for full inertness, or aria-disabled="true" for a control that stays focusable/announced (e.g. a disabled <a>). CSS dims both and makes aria-disabled pointer-inert, but only native disabled is keyboard-inert on its own — to stop an aria-disabled control from activating on Enter/Space, wire initDisabledGuard() once near your root (see Behaviors).

  • ui-tabs — initTabs adds the full APG wiring (roles, roving tabindex, aria-selected, panel hidden, focusable panel). If you wire tabs yourself, name the ui-tabs__list (role="tablist" + an aria-label) and pair each tab with its panel via aria-controls/aria-labelledby. Every tab needs a matching panel — a tab with no aria-controls target is an orphan that announces as selected but reveals nothing.

  • Icon-only buttons (ui-button--icon and any glyph-only control) carry no text node, so they're nameless to AT — give them an aria-label (<button class="ui-button ui-button--icon" aria-label="Delete">). Prefer ui-button__label when the same control also appears with its word visible, or when a test reads the button by its text. Wrap the text in the slot and let --icon decide whether it is painted — the markup does not change, the accessible name survives, and no aria-label can drift out of sync with the visible wording:

    // The mask comes from renderGlyph(..., { render: 'mask' }) — there is no
    // --glyph-* token.
    const mask = renderGlyph('trash', { render: 'mask' });
    el.innerHTML =
      `<button class="ui-button ui-button--icon">` +
      `<span class="ui-icon" style="--icon-mask: ${mask}"></span>` +
      `<span class="ui-button__label">Delete</span>` +
      `</button>`;
    

    Drop --icon and the same markup renders glyph + word. The slot also ellipsis rather than wrapping, so a labelled button in a width-constrained bar shrinks instead of pushing its neighbours out.

  • ui-button--dense is for bars whose height is the constraint — a pane title bar, a packed toolbar, a table row's actions. It lowers only the visual floor, to the WCAG 2.5.8 24px minimum. The coarse-pointer block still floats it to the full --tap-target, so a control you shrink for a mouse is never shrunk for a finger.

  • ui-sitenav / ui-app-nav — signal the current link with aria-current="page" (both honour it; ui-app-nav also accepts the visual-only .is-active, but prefer aria-current).

  • ui-skiplink — keep it the first focusable element and point its href at the id of your main landmark.

App shell: the service frame

ui-app-shell is the default cross-service identity frame: a CSS-only two-column app shell (sidebar rail + main column) that collapses to a single column with a horizontal rail below 880px — no behavior required. Use it for ops tools, admin apps, internal services, generated dashboards, and any workflow surface that should feel like part of the same system. The nesting matters; the rail is ui-app-rail and the content side is ui-app-main:

<div class="ui-app-shell">
  <aside class="ui-app-rail">
    <span class="ui-app-rail__brand">Acme</span>
    <nav class="ui-app-nav" aria-label="Primary">
      <span class="ui-app-nav__section">Main</span>
      <a href="/overview" aria-current="page">Overview</a>
      <a href="/jobs">Jobs</a>
      <a href="/integrations">Integrations</a>
      <a href="/settings">Settings</a>
    </nav>
    <div class="ui-app-rail__account">…</div>
  </aside>
  <main class="ui-app-main">
    <header class="ui-app-topbar"><h1 class="ui-app-topbar__title">Overview</h1></header>
    <div class="ui-app-content">
      <section class="ui-app-panel">
        <div class="ui-app-panel__head"><h2 class="ui-app-panel__title">KPIs</h2></div>
        <div class="ui-app-metrics">…<div class="ui-app-metric">…</div></div>
      </section>
    </div>
  </main>
</div>

Knobs: --app-rail sets the rail width (default 14rem); ui-app-shell--full drops the rail for a single-column app. ui-app-nav honours aria-current="page" (preferred) and the visual-only .is-active.

Service composition checklist:

  • Start with ui-app-shell, ui-app-rail, ui-app-nav, ui-app-main, ui-app-topbar, and ui-app-content.
  • Put brand and account identity in the rail; do not rebuild that frame per app.
  • Use ui-app-toolbar for filters/actions above a workflow surface.
  • Use ui-app-panel for grouped operational sections, not nested cards.
  • Use ui-statgrid/ui-stat, ui-table, ui-field, ui-alert, and ui-progress inside panels; add opt-in state.css for ui-state lifecycle labels.
  • Keep nav current state on aria-current="page" so the visual cue and AT cue match.
  • Reach for opt-in workbench.css only when the service needs panes, inspectors, or selected-object bulk actions.

Menus: data-bronto-menu + initMenu

A dropdown menu is a native <details data-bronto-menu> styled as ui-menu-host → ui-menu (with ui-menu__item / ui-menu__sep / ui-menu__label). It opens/closes natively, but initMenu() adds the close affordances a menu needs: outside-click close, Escape, and closing on item activation. Without the behavior the menu opens but never dismisses itself.

Clipping limitation. The menu panel is positioned in normal flow (not the top layer), so an ancestor with overflow: hidden/auto or a transform clips it — the same edge case the tooltip has. If your menu lives inside a scroll container or a clipped card and the panel gets cut off, either lift the ui-menu-host out of the clipping ancestor, or reach for initPopover (which escapes to the browser top layer via the native popover attribute) for that control instead.

Avatar: it's an unlabelled blob until you name it

ui-avatar is a presentation box. Give it an accessible name yourself: an image avatar needs real alt text (alt="" only if it's purely decorative beside a visible name); an initials avatar needs an accessible name on the element (e.g. aria-label="Ada Lovelace") because the initials alone don't convey identity to AT. Keep initials to ~2 characters — the box is overflow: hidden and silently clips a third.

Modal: use native <dialog>

Prefer the native <dialog> path — you get top-layer, backdrop and focus-trap free (wire it with initDialog for open-triggers + focus-return). Use a native <dialog> with initDialog() for focus, stacking, and close behavior. Application frameworks own the mount/cleanup lifecycle. The removed controlled-modal path is covered by the 0.10 migration.

Carousel & lightbox: one primitive, two skins

ui-carousel is a scroll-snap track of __slides wired by initCarousel (prev/next, keyboard, a __thumb strip, the __status counter, ARIA). Because the track is native horizontal scroll, touch and trackpad swipe — with momentum — are the browser's; the behavior only keeps the JS index in sync with the scroll both ways. Add data-bronto-carousel-loop to wrap at the ends.

A lightbox is the same carousel inside a native <dialog class="ui-lightbox">, opened with data-bronto-open (wired by initDialog). Do not hand-roll an overlay: the <dialog> gives the top layer, the backdrop, the focus-trap, Escape, and focus-return for free — exactly the parts a from-scratch lightbox gets wrong. The inline carousel crops (cover); the lightbox shows the whole image (contain).

This is deliberately not an auto-playing marketing slider (no timers, no infinite-clone track). It's a gallery: the user drives it.

Display glyphs: when (and when not)

@ponchia/ui/glyphs is a 71-glyph dot-matrix icon set — navigation (arrow-*, chevron-*), actions (check, close, plus, minus, search, menu, gear), status (info, warning, bell, lock) and common marks (home, user, heart, star, spark, circle-family marks) — rendered on the .ui-dotmatrix primitive, so they re-skin with the same --field-dot* tokens as every other dot surface. The default and solid renderers emit dot-matrix DOM, not an icon font; the dense .ui-icon renderer uses an internal SVG data URL as a CSS mask so it can stay one DOM node.

Two rendering modes — pick by size. The dots need physical room to read, so the default dot look is for display sizes (~40px up: hero marks, empty states, status bursts, section headers, large buttons). For small/inline use pass solid: true (or data-bronto-glyph-solid): that fuses the cells into a square, gapless pixel glyph that stays crisp and legible down to ~16px — so the same set doubles as real inline UI icons, not just decoration. (Below the dot fragments into dot-soup; solid does not.)

One caveat on ink: solid cells inherit the dot palette (--field-dot-hot, ~40% alpha), so at small sizes a solid glyph reads as a soft grey, not full ink. When you want a crisp, full-strength small icon (toolbar, button affordance), use the one-node mask renderer instead — renderGlyph(name, { render: 'mask' }) paints the glyph in currentColor on a .ui-icon, so it tracks text colour at any size.

renderGlyph(name, { label }) returns an SSR-safe string: decorative (aria-hidden) by default, or role="img" + aria-label when you pass a label — which is how it conveys meaning to assistive tech. Prefer the data-bronto-glyph placeholder + initDotGlyph() when the markup is easier dropped than inlined. Size with --dotmatrix-dot (and a tight --dotmatrix-gap) for an intrinsic dot, or let it stretch to its container. It is still a pixel-grid aesthetic, not a hairline vector set — but it now spans both inline-icon and display use from one source.

Animation is opt-in via anim (renderGlyph(name, { anim: 'reveal' })) or data-bronto-glyph-anim: reveal powers the cells on in a scan (a dot-matrix booting up), pulse makes the glyph breathe for a live/attention state. It's decorative only — disabled under prefers-reduced-motion, and the meaning still lives in the static frame + label, never in the motion. Don't animate to convey information a reduced-motion user would miss. Tune the scan speed with --dotmatrix-reveal-step (delay per cell, default 3ms). Use pulse sparingly — one attention target per view: it loops indefinitely, and animation that runs in parallel with other content is the consumer's WCAG 2.2.2 (Pause/Stop/Hide) responsibility (reduced-motion is not a substitute).

A few sharp edges to know:

  • Inline icon recipe. renderGlyph returns a <span>, so it's valid inline and inside a <button>. For an inline UI icon, render solid and size the dot, e.g. inside a button: `<button class="ui-button">${renderGlyph('search', { solid: true, dot: '1.2px', label: 'Search' })}<span>Search</span></button>` — the button's display: inline-flex; gap aligns icon + text. For icon-in-prose, set --dotmatrix-dot to ~`0.08emandvertical-align: -0.15em` on the span.
  • solid wins. solid: true implies glyph-only and forces --dotmatrix-gap: 0 / square cells, so a grid: true or gap passed alongside it is ignored.
  • Directional glyphs are physical, not logical. arrow-left/right, chevron-left/right are fixed bitmaps; in an RTL context flip them yourself (e.g. swap the name, or transform: scaleX(-1)), the framework won't.
  • Cost / icon-at-scale. The default (cell) render is a 16×16 grid = 256 cells (DOM nodes), regardless of solid/grid; anim: 'reveal' adds a per-cell --i. That's the dot-matrix display look — but for many icons (one in every row of a long table) use renderGlyph(name, { render: 'mask' }): it returns a single .ui-icon element masked by the bitmap (one node, not 256), sizes to size / --icon-size (default 1em) and inherits currentColor — a normal inline icon. Pick render: 'mask' for icons, the cell render for display marks/animation.

Colorways: when to reach for a skin

@ponchia/ui/css/skins.css adds data-bronto-skin="amber-crt | phosphor-green | e-ink" — a root-level colorway (apply on <html>, like data-theme) that re-points the one accent to a different single hue.

  • Use a skin when you want a distinct, on-brand look (a phosphor/CRT or e-ink feel) for the whole page or app — it's the supported, contrast-gated way to recolour without leaving the design system.
  • Use a raw --accent override when you just need your brand hue: it's one declaration (see "Re-brand obligations" below) — but then contrast is yours, whereas the shipped skins are pre-gated.
  • Don't put data-bronto-skin on a subtree — it's root-level by design (the derived accent family only recomputes at :root); a subtree skin no-ops. For a one-section recolour, scope a raw --accent override instead.
  • It's opt-in: a separate stylesheet, never in dist/bronto.css. No skin imported → zero cost. Full detail in theming.md → "Display colorways".

Data-viz colours: charts, not chrome

@ponchia/ui/css/dataviz.css (opt-in) adds a Tier-4 chart palette for dashboards: --chart-1..8 (categorical), --chart-seq-* (sequential), --chart-div-* (diverging), and --chart-pattern-1..8 (dot-matrix fills), plus the same eight hues as categorical identity: --cat-N, --cat-N-tint and --cat-N-ink for tags, participants and user-chosen tints.

  • Use it for categories, never for chrome or status. A build gate fails if var(--chart-*) or var(--cat-*) appears in component CSS. Style buttons and badges with the accent/status tiers.
  • No slot is the accent. The order is fixed (blue, orange, aqua, yellow, magenta, green, violet, red) and adjacent slots are gated for separation under simulated protanopia/deuteranopia and in normal vision.
  • Always pair colour with pattern (--chart-pattern-N) and/or a direct label — never colour alone (WCAG 1.4.1): background: var(--chart-3); background-image: var(--chart-pattern-3); background-size: var(--chart-pattern-size);
  • In JS (Chart.js, canvas, SVG): import resolved hex from @ponchia/ui/charts.json ({ hues, light, dark }), or read the live page with @ponchia/ui/renderer when it can change skin or theme. Cap a chart at ~8 series. Full detail in theming.md → "Data-viz palette".

SVG annotations: subject, connector, note

@ponchia/ui/css/annotations.css (opt-in) adds Bronto-styled SVG annotations for reports and chart figures. It is a visual grammar, not a charting or authoring engine.

  • Compose each callout from ui-annotation plus a subject (ui-annotation__subject), connector (ui-annotation__connector), and note (ui-annotation__note, ui-annotation__title, ui-annotation__label).
  • Use ui.annotation({ variant, tone, motion }) when building class strings in JS. The default is a callout in the accent tone; motion is always opt-in.
  • Use @ponchia/ui/annotations when you want deterministic SVG path strings for circle, rect, threshold, bracket, band, slope, comparison, cluster, axis, timeline, line, elbow, or curve annotations. Use notePlacement() for a single bounded note when you need a conservative first placement pass.
  • Status tones are only for status-bearing callouts; otherwise use accent for the main insight and muted for secondary labels.
  • Keep annotated charts sparse. Dense figures need a scrollable SVG, a simplified mobile SVG, or complete caption/fallback text.
  • Annotation text must be visible or represented in the figure caption, SVG <desc>, or fallback table. Full detail in annotations.md.

Forms: the contracts the markup alone won't tell you

  • Disabled — pick one mechanism. Use the native disabled attribute for a genuinely inert control (ui-input, ui-select, ui-textarea, ui-switch /ui-check/ui-segmented wrapping a native input, ui-range, ui-file, ui-button): the browser greys it, blocks activation, and skips it in tab order, and bronto styles the disabled cue. Use aria-disabled="true" only when the control must stay focusable/announced — bronto then adds pointer-events: none to ui-button/ui-link so the pointer can't activate it. That is pointer-inert, not keyboard-inert: CSS can't stop Enter/Space, so wire initDisabledGuard() (Behaviors) to block keyboard activation across every aria-disabled control. Either way you still own removing it from the submit logic.
  • Read-only ≠ disabled. A readonly input keeps its value in form submission and stays focusable/selectable; disabled does neither. Bronto gives a read-only field a quiet muted fill so it doesn't read as a live editable field — reach for readonly when the value matters but mustn't be edited, disabled when it should be inert and skipped.
  • Combobox (data-bronto-combobox) reads its options from the DOM at initCombobox() time — re-run it after you replace the option list (or add data-bronto-combobox-live). The selected option's text label is shown in the input while the bronto:change event carries the option's data-value code — so put the human label in the <li> text and the code in data-value. The .ui-combobox__empty ("No matches") is hidden until a filter empties the list. Two intentional single-select APG deviations: ArrowDown on a closed list filters rather than preselecting the first option, and Tab closes without committing a merely-highlighted option (Enter/click commits).
  • Validation is opt-in via data-bronto-validate on the form plus initFormValidation(); it surfaces messages into a ui-error-summary you provide. The summary's title is the legible sans, not the display face — it's meant to be read.

Branded file input: keep the native control operable

Prefer the visible native control: <input class="ui-file" type="file"> styles its file-selector button without hiding the input. If the product needs a button-shaped label, keep the native input focusable inside the label and expose its focus on the visible wrapper. Never use display: none on the input: that removes it from keyboard navigation.

<label class="ui-button upload-button">
  Choose file
  <input class="ui-visually-hidden" type="file" name="document" />
</label>
.upload-button:focus-within {
  outline: 3px solid var(--focus-ring);
  outline-offset: var(--focus-offset);
}

Sortable table: the button is part of the contract

Put a real button in each sortable header, mark the table, then initialize the behavior. Use data-sort="num" for numeric columns and data-sort-value on a cell when its displayed text is not a canonical sortable value.

<table class="ui-table" data-bronto-sortable>
  <thead><tr>
    <th><button class="ui-table__sort" data-sort type="button">Name</button></th>
    <th class="is-num"><button class="ui-table__sort" data-sort="num" type="button">Score</button></th>
  </tr></thead>
  <tbody><tr><td>Ada</td><td class="is-num" data-sort-value="9.5">9,5</td></tr></tbody>
</table>
import { initTableSort } from '@ponchia/ui/behaviors';
const cleanup = initTableSort();

Reveal: ui-reveal needs JS, ui-scroll-reveal doesn't

ui-scroll-reveal is scroll-driven and zero-JS — reach for it in a static or LLM-authored report. ui-reveal is the JS variant: it starts hidden and you toggle is-visible (e.g. from an IntersectionObserver you own) to play it in. With scripting disabled it degrades to fully visible, but if scripting is on and nothing toggles is-visible, the content stays hidden — so only use ui-reveal when you are wiring that toggle.

View transitions (ui-vt). ui-vt names an element via --ui-vt-name so it morphs across a document.startViewTransition() or a cross-document navigation. The name must be unique per document at the moment a transition runs: applying ui-vt with one shared --ui-vt-name across every card in a list (the obvious loop) makes the browser skip the transition — and it is not silent: it logs a console error and rejects vt.ready (with an InvalidStateError). If you don't vt.ready.catch(…), that surfaces as an unhandled promise rejection. Give each element its own name (--ui-vt-name: card-7) or only mark the single element that actually morphs.

Loading affordances need a role you supply

ui-spinner, ui-dotspinner, ui-skeleton, and an indeterminate ui-progress are decorative animations — bronto can't know their semantics. Give the busy region aria-busy="true" (or role="status" with an aria-live text label like "Loading…"), and mark a purely decorative spinner aria-hidden="true". Without one of these a screen reader announces nothing while the user waits.

Popover: prefer the native top layer

initPopover() shows a .ui-popover in the browser top layer when the panel carries the native popover attribute (never clipped by overflow/stacking); without it, it falls back to an is-open class that a clipping ancestor can cut off. Add popover to the panel for the robust path — the is-open form is a fallback, not the default to copy. Placement flips to the roomier vertical side, keeps the panel on-screen, and scrolls tall content inside the available viewport space.

It is a non-modal dialog by design: the panel gets role="dialog" and focus moves into it, but there is no focus trap and the rest of the page stays interactive — Tab moves out of the panel (it does not cycle), and it closes on Escape or outside-click. Don't assume <dialog>-modal semantics; if you need a trap and an inert backdrop, use a real modal (<dialog> + initDialog). The controlled-modal path was removed in 0.10. And the is-open fallback is a plain stacked element, so it sits under any open native <dialog>'s top layer — another reason to prefer the native popover attribute when a popover and a dialog can be open together.

Two tiers: CSS-native vs behavior-required

Not every component works with JavaScript off — know which tier you are shipping before you rely on the no-JS path.

CSS-native — fully operable with JS off. Safe in static or LLM-authored HTML, print/PDF, and before any hydration:

Component How it works without JS
Tooltip (ui-tooltip) :hover / :focus-within for short local labels
Accordion native <details> / <summary>
Segmented control (ui-segmented) :has(input:checked) over a radio group
Scroll-reveal (ui-scroll-reveal) scroll-driven animation, zero JS

Behavior-required — a CSS skin that needs its init* to be interactive. These are JS widgets wearing the Bronto look; without the behavior they are inert (and a couple are worse than inert — see the tabs row):

Component Behavior With the behavior absent
Disclosure trigger ([data-bronto-disclosure]) initDisclosure a button with stale aria-expanded; the controlled panel never toggles
Tabs (ui-tabs) initTabs author panels visible — ship hidden panels and if initTabs never runs the content is unreachable
Combobox (ui-combobox) initCombobox a plain text input beside an unfiltered list
Command palette (ui-command) initCommand a static, unfiltered list
Splitter (ui-splitter) initSplitter fixed panes at the authored --splitter-pos; no keyboard/pointer resize or aria-valuenow updates
Table sort/select ([data-bronto-sortable]) initTableSort a static table (still readable)
Popover (ui-popover) initPopover no placement/ARIA — prefer the native popover attribute
Carousel (ui-carousel) initCarousel a native scroll-snap track (usable, no controls)
Native dialog/lightbox (<dialog>, ui-lightbox) initDialog closed markup stays closed; data-bronto-open/close buttons do nothing. Do not use open as a modal fallback: it is non-modal and has no trigger/focus-return path
Menu (data-bronto-menu) initMenu a button next to a list with no open/close, outside-click, or Escape
Dismissible alert/callout (data-bronto-dismissible) dismissible the close affordance is just a button; nothing is removed
Toast toast() nothing — it is imperative-only

One cross-cutting guard, not tied to a single component:

Concern Behavior With the behavior absent
Theme persistence before first paint applyStoredTheme the token layer follows OS preference until client code sets data-theme, so a stored preference can flash
Theme toggle controls initThemeToggle the button is inert and does not persist data-theme
aria-disabled="true" controls initDisabledGuard dimmed + pointer-inert via CSS, but still keyboard-activatable on Enter/Space (native disabled is already fully inert)

Rule of thumb: if a component needs ARIA-state sync, focus management, a keyboard model, or persisted/dynamic state, it is behavior-required — that is the exact boundary of what CSS alone cannot do.

When to add a behavior

The CSS is the framework; @ponchia/ui/behaviors is the sanctioned home for the little JS that genuinely needs scripting (theme persistence, disclosure, native-dialog glue, toast, combobox, form-validation, table-sort, splitter resizing). Reach for it instead of reimplementing — every initializer is SSR-safe, idempotent, and returns a cleanup. If you find yourself writing focus management, ARIA value sync, or aria-expanded toggling by hand, there is probably already a behavior for it.

Re-brand obligations (the short version)

Changing --accent is one declaration, but contrast is then yours: the shipped palettes are gated (see contrast.md); your custom accent is not. Verify primary-button label, --accent-text, and the focus ring against their backgrounds. Full contract: theming.md.

Dense labels

Use ui-chip--dense for a static label in a short pane header. ui.chip({ dense: true }) returns that class. Buttons and links carrying it retain pointer target floors; use an actual button for an action.

For complete tool/report layouts, use composition recipes.