@ponchia/ui 0.3.6 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +1063 -0
  2. package/README.md +31 -14
  3. package/behaviors/index.d.ts +3 -1
  4. package/behaviors/index.js +179 -106
  5. package/classes/index.d.ts +41 -0
  6. package/classes/index.js +42 -0
  7. package/classes/vscode.css-custom-data.json +18 -22
  8. package/css/dataviz.css +96 -0
  9. package/css/disclosure.css +29 -0
  10. package/css/dots.css +32 -2
  11. package/css/feedback.css +53 -0
  12. package/css/motion.css +88 -0
  13. package/css/overlay.css +52 -1
  14. package/css/report.css +382 -0
  15. package/css/skins.css +54 -0
  16. package/css/tokens.css +56 -32
  17. package/dist/bronto.css +1 -1
  18. package/dist/css/dataviz.css +1 -0
  19. package/dist/css/disclosure.css +1 -1
  20. package/dist/css/dots.css +1 -1
  21. package/dist/css/feedback.css +1 -1
  22. package/dist/css/motion.css +1 -1
  23. package/dist/css/overlay.css +1 -1
  24. package/dist/css/report.css +1 -0
  25. package/dist/css/skins.css +1 -0
  26. package/dist/css/tokens.css +1 -1
  27. package/docs/adr/0001-color-system.md +271 -0
  28. package/docs/adr/0002-scope-and-2026-baseline.md +104 -0
  29. package/docs/adr/0003-theme-model.md +94 -0
  30. package/docs/contrast.md +170 -51
  31. package/docs/reference.md +118 -19
  32. package/docs/reporting.md +270 -0
  33. package/docs/stability.md +37 -0
  34. package/docs/theming.md +84 -7
  35. package/docs/usage.md +67 -8
  36. package/glyphs/glyphs.d.ts +14 -1
  37. package/glyphs/glyphs.js +143 -2
  38. package/llms.txt +101 -5
  39. package/package.json +79 -5
  40. package/qwik/index.d.ts +55 -0
  41. package/qwik/index.js +129 -0
  42. package/react/index.d.ts +57 -0
  43. package/react/index.js +84 -0
  44. package/solid/index.d.ts +57 -0
  45. package/solid/index.js +85 -0
  46. package/tokens/charts.d.ts +37 -0
  47. package/tokens/charts.js +96 -0
  48. package/tokens/charts.json +61 -0
  49. package/tokens/index.d.ts +3 -3
  50. package/tokens/index.js +16 -18
  51. package/tokens/index.json +32 -36
  52. package/tokens/resolved.json +39 -41
  53. package/tokens/skins.d.ts +27 -0
  54. package/tokens/skins.js +62 -0
  55. package/tokens/tokens.dtcg.json +22 -34
package/README.md CHANGED
@@ -3,12 +3,12 @@
3
3
  [![npm](https://img.shields.io/npm/v/@ponchia/ui?logo=npm)](https://www.npmjs.com/package/@ponchia/ui)
4
4
  [![npm provenance](https://img.shields.io/badge/npm-provenance-blue?logo=npm)](https://www.npmjs.com/package/@ponchia/ui#provenance)
5
5
  [![runtime deps](https://img.shields.io/badge/runtime%20deps-0-brightgreen)](https://github.com/Ponchia/bronto-ui/blob/main/package.json)
6
- [![dist](https://img.shields.io/badge/dist-~73kB%20%2F%20~13kB%20gzip-informational)](https://github.com/Ponchia/bronto-ui/blob/main/scripts/check-dist.mjs)
6
+ [![dist](https://img.shields.io/badge/dist-~75kB%20%2F%20~13kB%20gzip-informational)](https://github.com/Ponchia/bronto-ui/blob/main/scripts/check-dist.mjs)
7
7
  [![CI](https://github.com/Ponchia/bronto-ui/actions/workflows/ci.yml/badge.svg)](https://github.com/Ponchia/bronto-ui/actions/workflows/ci.yml)
8
8
  [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/Ponchia/bronto-ui/badge)](https://scorecard.dev/viewer/?uri=github.com/Ponchia/bronto-ui)
9
9
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/Ponchia/bronto-ui/blob/main/LICENSE)
10
10
 
11
- **A CSS-first, framework-agnostic UI framework with a "Nothing"-inspired look — monochrome surfaces, one rationed red accent, dot-matrix display type, hairline borders, restrained motion. Zero runtime dependencies. Re-brand the whole thing with one CSS variable.**
11
+ **A CSS-first, framework-agnostic UI framework built on token-driven restraint — monochrome by default, one rationed accent, dot-matrix display type, hairline borders. Color is _tiered_: we don't decorate with it, we signal with it. The "Nothing"-inspired look is the default skin, not the architecture — opt-in colorways (amber CRT · phosphor green · e-ink), a dot-matrix icon set, and a colourblind-safe data-viz palette prove the one knob is real. Zero runtime dependencies; re-brand the whole thing with one CSS variable.**
12
12
 
13
13
  ### [Live demo →](https://ponchia.github.io/bronto-ui/)  ·  [Theme playground →](https://ponchia.github.io/bronto-ui/demo/theme-playground.html)
14
14
 
@@ -34,7 +34,7 @@ Or drop it in with no build step, straight from a CDN:
34
34
 
35
35
  ## Quick start
36
36
 
37
- **1. Load the CSS.** One flattened, minified bundle — the whole framework, one request (~73 kB raw / ~13 kB gzip):
37
+ **1. Load the CSS.** One flattened, minified bundle — the whole framework, one request (~75 kB raw / ~13 kB gzip):
38
38
 
39
39
  ```css
40
40
  @import '@ponchia/ui'; /* via a bundler */
@@ -79,7 +79,7 @@ toast('Saved', { tone: 'success' }); // body-anchored stack, no markup needed
79
79
 
80
80
  Behaviors cover theme persistence, disclosure, dropdown menus, native-`<dialog>` modals/drawers, tabs, combobox, form validation, table sort, carousel and toasts — wired by `data-bronto-*` attributes.
81
81
 
82
- **5. (Optional) display glyphs — a 43-glyph dot-matrix icon set:**
82
+ **5. (Optional) display glyphs — a 48-glyph dot-matrix icon set:**
83
83
 
84
84
  ```js
85
85
  import { renderGlyph } from '@ponchia/ui/glyphs';
@@ -89,7 +89,7 @@ el.innerHTML = renderGlyph('search', { label: 'Search' }); // role="img"
89
89
  // import { initDotGlyph } from '@ponchia/ui/behaviors'; initDotGlyph();
90
90
  ```
91
91
 
92
- Arrows, chevrons, check/close/plus/minus, search/menu/gear, info/warning/bell/lock, home/user/heart/star/spark rendered on the same `.ui-dotmatrix` primitive and re-skinned by the `--field-dot*` tokens (no SVG, no font). Default dot look is for **display** sizes; pass **`{ solid: true }`** to fuse the cells into a crisp pixel glyph that works as an **inline icon down to ~16px**.
92
+ Arrows, chevrons, check/close/plus/minus, search/menu/gear, info/warning/bell/lock, home/user/heart/star/spark and circle-family marks are rendered on the same `.ui-dotmatrix` primitive and re-skinned by the `--field-dot*` tokens. Default dot look is for **display** sizes; pass **`{ solid: true }`** to fuse the cells into a crisp pixel glyph that works as an **inline icon down to ~16px**. For dense lists/tables, `renderGlyph(name, { render: 'mask' })` emits one `.ui-icon` element backed by an internal CSS mask.
93
93
 
94
94
  ## What's in the box
95
95
 
@@ -100,7 +100,11 @@ Arrows, chevrons, check/close/plus/minus, search/menu/gear, info/warning/bell/lo
100
100
  - **Disclosure & nav** — tabs, accordion, segmented, breadcrumb, pagination, `ui-steps`, `ui-timeline`, `ui-pagehead`, `ui-kbd`.
101
101
  - **Shells** — an admin dashboard shell (`ui-app-*`) and a content/marketing site shell (`ui-site*`, `ui-container`).
102
102
  - **Prose** — `.ui-prose` styles raw, unclassed semantic HTML (Markdown / CMS / LLM output) with zero classes.
103
+ - **Reports** _(opt-in)_ — `@ponchia/ui/css/report.css`, a static/PDF-first report grammar for LLM-authored HTML: covers, sections, findings, evidence, figures, chart wrappers and print utilities.
103
104
  - **Motion & dots** — the dot-matrix motif kit: dot grid, status dots, dot loaders, the orbital spinner, matrix reveal — all reduced-motion aware.
105
+ - **Glyphs** — `@ponchia/ui/glyphs`, a 48-glyph dot-matrix icon set on the `.ui-dotmatrix` primitive (display marks + crisp `solid` inline icons + one-node `.ui-icon` mask rendering).
106
+ - **Colorways** _(opt-in)_ — `data-bronto-skin="amber-crt | phosphor-green | e-ink"`: a root-level recolour of the one accent (OKLCH, per-theme, contrast-gated), never in the default bundle.
107
+ - **Data-viz** _(opt-in)_ — a colourblind-safe chart palette (`--chart-*` + dot-matrix pattern fills, resolved hex in `charts.json`), gated under simulated protan/deutan/tritan vision. Charts only, never UI chrome.
104
108
 
105
109
  Full generated catalog of every class: **[docs/reference.md](https://github.com/Ponchia/bronto-ui/blob/main/docs/reference.md)**. The decision guide (which primitive when): **[docs/usage.md](https://github.com/Ponchia/bronto-ui/blob/main/docs/usage.md)**.
106
110
 
@@ -115,11 +119,17 @@ Everything accent-colored derives from a single `--accent` variable via `color-m
115
119
 
116
120
  Buttons, focus rings, dot motifs, accent borders and soft fills all follow automatically. Light/dark is `data-theme="light"` / `"dark"` on `<html>` (defaults to `prefers-color-scheme`); `data-density` and `data-contrast` give density and contrast presets. A full re-skin (radius, display face, dot density, surfaces) is a handful more token overrides — the "Nothing" look is the **default skin, not the architecture**.
117
121
 
122
+ **One system, many skins.** That the knob is real isn't a claim — it ships: drop in `@ponchia/ui/css/skins.css` and set `data-bronto-skin="amber-crt | phosphor-green | e-ink"` on `<html>` for a complete, contrast-gated recolour (the derived accent family, focus ring, dot-matrix and glyphs all follow). Colour is governed in **tiers** — neutral canvas · one accent · locked status · display expression · opt-in data-viz — so it always earns its place; the full constitution is **[ADR-0001](https://github.com/Ponchia/bronto-ui/blob/main/docs/adr/0001-color-system.md)**.
123
+
118
124
  > When you change `--accent`, contrast becomes yours: verify your hue in the **[theme playground](https://ponchia.github.io/bronto-ui/demo/theme-playground.html)** (it shows the derived family and computed WCAG ratios). Full contract: **[docs/theming.md](https://github.com/Ponchia/bronto-ui/blob/main/docs/theming.md)**.
119
125
 
126
+ ## Accessibility
127
+
128
+ Not an afterthought — a gate. Every contractual token pairing has a declared WCAG 2.1 conformance level the build **fails** below (`docs/contrast.md`, regenerated + CI-checked), and the shipped colorways are held to the same floors. The data-viz palette is gated for distinctness under simulated protanopia/deuteranopia/tritanopia, and never relies on colour alone (dot-matrix pattern fills). APCA `Lc` is published advisory alongside WCAG. Components ship ARIA-correct markup and SSR-safe keyboard behaviors; `prefers-reduced-motion`, `prefers-contrast` and `forced-colors` are all honored.
129
+
120
130
  ## Works with anything
121
131
 
122
- The CSS is the framework, so it works with React, Svelte/SvelteKit, Astro, Vue, Solid or plain HTML — there's no component runtime to adopt. The optional `classes` and `behaviors` entrypoints pull in **no** UI framework and are SSR-safe.
132
+ The CSS is the framework, so it works with React, Svelte/SvelteKit, Astro, Vue, Solid, Qwik or plain HTML — there's no component runtime to adopt. The optional `classes` and `behaviors` entrypoints pull in **no** UI framework and are SSR-safe. For React, Solid and Qwik there are also **optional thin bindings** — `@ponchia/ui/react`, `@ponchia/ui/solid` and `@ponchia/ui/qwik` wrap the behaviors as hooks (`useDialog`, `useToast`, …); `react`/`solid-js`/`@builder.io/qwik` are optional peer deps, so the core stays zero-dependency.
123
133
 
124
134
  Per-framework getting-started guides + runnable example apps live in the repo:
125
135
 
@@ -128,36 +138,43 @@ Per-framework getting-started guides + runnable example apps live in the repo:
128
138
  | Vanilla / Vite / plain HTML | [vanilla.md](https://github.com/Ponchia/bronto-ui/blob/main/docs/getting-started/vanilla.md) | [`examples/vanilla-vite`](https://github.com/Ponchia/bronto-ui/tree/main/examples/vanilla-vite) |
129
139
  | Astro | [astro.md](https://github.com/Ponchia/bronto-ui/blob/main/docs/getting-started/astro.md) | [`examples/astro`](https://github.com/Ponchia/bronto-ui/tree/main/examples/astro) |
130
140
  | SvelteKit | [sveltekit.md](https://github.com/Ponchia/bronto-ui/blob/main/docs/getting-started/sveltekit.md) | [`examples/sveltekit`](https://github.com/Ponchia/bronto-ui/tree/main/examples/sveltekit) |
131
- | React / Solid (snippet) | [react-solid.md](https://github.com/Ponchia/bronto-ui/blob/main/docs/getting-started/react-solid.md) | |
141
+ | React | [react-solid.md](https://github.com/Ponchia/bronto-ui/blob/main/docs/getting-started/react-solid.md) | [`examples/react-vite`](https://github.com/Ponchia/bronto-ui/tree/main/examples/react-vite) |
142
+ | Solid | [react-solid.md](https://github.com/Ponchia/bronto-ui/blob/main/docs/getting-started/react-solid.md) | [`examples/solid-vite`](https://github.com/Ponchia/bronto-ui/tree/main/examples/solid-vite) |
143
+ | Qwik | [react-solid.md](https://github.com/Ponchia/bronto-ui/blob/main/docs/getting-started/react-solid.md) | [`examples/qwik-vite`](https://github.com/Ponchia/bronto-ui/tree/main/examples/qwik-vite) |
132
144
  | Tailwind / cascade-layer interop | [tailwind.md](https://github.com/Ponchia/bronto-ui/blob/main/docs/interop/tailwind.md) | — |
133
145
 
134
146
  ## Extras
135
147
 
136
- - **Tokens as data** — `import tokens, { themeColor, cssVars } from '@ponchia/ui/tokens'` (plus `tokens.json`, W3C DTCG `tokens.dtcg.json`, and `tokens/resolved.json` for charts/data-viz).
148
+ - **Tokens as data** — `import tokens, { themeColor, cssVars } from '@ponchia/ui/tokens'` (plus `tokens.json`, W3C DTCG `tokens.dtcg.json`, and `tokens/resolved.json` — concrete hex per theme for canvas/SVG/MapLibre).
149
+ - **Chart colours for dashboards** — `import charts from '@ponchia/ui/charts.json' with { type: 'json' }` in Node ESM, or the same path through a bundler JSON import (resolved hex per theme; series 1 = your accent) plus the opt-in `@ponchia/ui/css/dataviz.css`.
150
+ - **Static reports for LLMs** — add `@ponchia/ui/css/report.css` for report structure and print utilities; pair with `@ponchia/ui/css/dataviz.css` only when the report contains charts. Full cookbook: `docs/reporting.md`.
151
+ - **Modern-platform motion** — overlays (modal/drawer/popover), toasts and the `<details>` accordion animate **in and out** with zero JS (`@starting-style` + `allow-discrete`, `::details-content` + `interpolate-size`). Progressive-enhancement extras: `.ui-scroll-progress` / `.ui-scroll-reveal` (scroll-driven, no JS) and `.ui-vt` for View Transitions. All degrade to a static end-state and respect `prefers-reduced-motion`. For smooth **cross-document** navigations, add the document-global one-liner to your own top-level (unlayered) CSS: `@view-transition { navigation: auto; }`.
137
152
  - **Editor IntelliSense** — point VS Code at the shipped custom-data file so every token autocompletes in `var(--…)`:
138
153
  ```json
139
154
  { "css.customData": ["node_modules/@ponchia/ui/classes/vscode.css-custom-data.json"] }
140
155
  ```
141
- - **For AI coding agents** — the package ships `llms.txt` at its root plus `docs/reference.md`, `docs/usage.md` and `docs/theming.md` inside the tarball, so an offline agent has the full API without guessing.
156
+ - **For AI coding agents** — the package ships `llms.txt` at its root plus `docs/reference.md`, `docs/usage.md`, `docs/reporting.md`, `docs/theming.md`, `docs/contrast.md`, `docs/stability.md`, the color constitution `docs/adr/0001-color-system.md` and the `CHANGELOG` inside the tarball, so an offline agent has the full API and rationale without guessing.
142
157
 
143
- > The package root is **CSS-only**: `@import '@ponchia/ui'` in CSS, never `import '@ponchia/ui'` in JS. The JS entrypoints are the explicit subpaths `/tokens`, `/classes`, `/behaviors`, `/glyphs`.
158
+ > The package root is **CSS-only**. Use `@import '@ponchia/ui'` in CSS, or `import '@ponchia/ui'` only as a CSS side-effect import in a CSS-aware bundler (Vite, Astro, SvelteKit, webpack). Do not import the package root from Node/runtime JS. JS entrypoints are explicit subpaths: `/tokens`, `/classes`, `/behaviors`, `/glyphs`, `/react`, `/solid`, `/qwik`, `/skins`, and `/charts`.
159
+ > JS subpaths are **ESM-only**. CommonJS consumers should use dynamic
160
+ > `import('@ponchia/ui/behaviors')`.
144
161
 
145
162
  ## Browser support
146
163
 
147
- Evergreen only. The framework relies on cascade layers, `:has()`, `color-mix()`, CSS logical properties and native `<dialog>`. Floor: **Chrome/Edge 111+, Safari 16.4+, Firefox 121+** (early 2023 onward). No build-time fallback ships; pin an older tag if you need below this.
164
+ Recent-evergreen, by design. The framework targets the modern web platform — cascade layers, `:has()`, `color-mix()`/`oklch()`, logical properties, native `<dialog>`, and the 2026 interaction primitives (`@starting-style`, `transition-behavior: allow-discrete`, `light-dark()`). Floor: **Chrome/Edge 125+, Safari 18+, Firefox 129+** (early–mid 2025). No fallback ships below this — pin an older tag if you need it. See [ADR-0002](https://github.com/Ponchia/bronto-ui/blob/main/docs/adr/0002-scope-and-2026-baseline.md) for the scope/greenfield rationale.
148
165
 
149
166
  ## Versioning
150
167
 
151
- Pre-1.0 and deliberately so. **Until `1.0.0`, breaking changes ship in the _minor_** (`0.x.0`); patches (`0.x.y`) are always non-breaking. Pin with the patch range — at `0.x`, `~0.3.0` (and equivalently `^0.3.0`) resolves to `>=0.3.0 <0.4.0`, giving you safe patches while holding back the next breaking minor. Every breaking change is called out under a **BREAKING** heading in the **[CHANGELOG](https://github.com/Ponchia/bronto-ui/blob/main/CHANGELOG.md)** with a migration note.
168
+ Pre-1.0 and deliberately so. **Until `1.0.0`, breaking changes ship in the _minor_** (`0.x.0`); patches (`0.x.y`) are always non-breaking. Pin with the patch range — at `0.x`, `~0.4.0` (and equivalently `^0.4.0`) resolves to `>=0.4.0 <0.5.0`, giving you safe patches while holding back the next breaking minor. Every breaking change is called out under a **BREAKING** heading in the **[CHANGELOG](https://github.com/Ponchia/bronto-ui/blob/main/CHANGELOG.md)** with a migration note.
152
169
 
153
- Contractual (changes are breaking): the `--accent` derivation and token **names**, the `.ui-*` class and recipe names, the `data-bronto-*` attributes, and each behavior's cleanup contract. Not contractual (may change any release): token **values** (visual tuning) and internal leaf-file / `@layer` boundaries.
170
+ Contractual (changes are breaking): token **names** and documented token roles, `.ui-*` class and recipe names, `data-bronto-*` attributes, exported behavior/glyph/binding function names and each behavior's cleanup contract. Not contractual (may change any release): exact token **values** and generated colour math outputs (visual tuning) unless a doc explicitly says the value is stable, plus internal leaf-file / `@layer` boundaries. See **[docs/stability.md](https://github.com/Ponchia/bronto-ui/blob/main/docs/stability.md)** for the full public-surface matrix.
154
171
 
155
172
  Release candidates publish to the `next` dist-tag, never to `latest` — opt in with `npm i @ponchia/ui@next` to try an upcoming version early. A plain `npm i @ponchia/ui` only ever resolves a stable release.
156
173
 
157
174
  ## Links
158
175
 
159
176
  - **[Live demo](https://ponchia.github.io/bronto-ui/)** · **[Theme playground](https://ponchia.github.io/bronto-ui/demo/theme-playground.html)**
160
- - **[Class reference](https://github.com/Ponchia/bronto-ui/blob/main/docs/reference.md)** · **[Usage guide](https://github.com/Ponchia/bronto-ui/blob/main/docs/usage.md)** · **[Theming](https://github.com/Ponchia/bronto-ui/blob/main/docs/theming.md)** · **[Contrast](https://github.com/Ponchia/bronto-ui/blob/main/docs/contrast.md)**
177
+ - **[Class reference](https://github.com/Ponchia/bronto-ui/blob/main/docs/reference.md)** · **[Usage guide](https://github.com/Ponchia/bronto-ui/blob/main/docs/usage.md)** · **[Theming](https://github.com/Ponchia/bronto-ui/blob/main/docs/theming.md)** · **[Contrast](https://github.com/Ponchia/bronto-ui/blob/main/docs/contrast.md)** · **[Color system (ADR-0001)](https://github.com/Ponchia/bronto-ui/blob/main/docs/adr/0001-color-system.md)** · **[Scope & 2026 baseline (ADR-0002)](https://github.com/Ponchia/bronto-ui/blob/main/docs/adr/0002-scope-and-2026-baseline.md)**
161
178
  - **[CHANGELOG](https://github.com/Ponchia/bronto-ui/blob/main/CHANGELOG.md)** · **[Roadmap](https://github.com/Ponchia/bronto-ui/blob/main/ROADMAP.md)** · **[Contributing](https://github.com/Ponchia/bronto-ui/blob/main/CONTRIBUTING.md)**
162
179
 
163
180
  ## License
@@ -102,7 +102,9 @@ export declare function initTabs(opts?: DelegateOpts): Cleanup;
102
102
  /**
103
103
  * Wire native <dialog> open/close glue: `[data-bronto-open="id"]`,
104
104
  * `[data-bronto-close]`, and backdrop light-dismiss for dialogs marked
105
- * `[data-bronto-dialog-light]`. Returns a cleanup function.
105
+ * `[data-bronto-dialog-light]`. `root` scopes delegated controls; dialog ids
106
+ * resolve root-first, then document-wide for body/portal-mounted overlays.
107
+ * Returns a cleanup function.
106
108
  */
107
109
  export declare function initDialog(opts?: DelegateOpts): Cleanup;
108
110
 
@@ -54,6 +54,24 @@ function bindOnce(target, key, add) {
54
54
  return cleanup;
55
55
  }
56
56
 
57
+ function byIdInHost(host, id) {
58
+ if (!id) return null;
59
+ if (host === document) return document.getElementById(id);
60
+ if (host.id === id) return host;
61
+ return (
62
+ Array.from(host.querySelectorAll?.('[id]') || []).find((el) => el.id === id) ||
63
+ document.getElementById(id)
64
+ );
65
+ }
66
+
67
+ function closestSafe(el, selector) {
68
+ try {
69
+ return el.closest(selector);
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
57
75
  /**
58
76
  * Apply the persisted theme to <html data-theme>. Call as early as
59
77
  * possible (an inline module in <head>) to avoid a flash before the
@@ -145,7 +163,7 @@ export function dismissible({ root } = {}) {
145
163
  const btn = e.target.closest('[data-bronto-dismiss]');
146
164
  if (!btn || !host.contains(btn)) return;
147
165
  const sel = btn.getAttribute('data-bronto-dismiss');
148
- const target = sel ? btn.closest(sel) : btn.closest('[data-bronto-dismissible]');
166
+ const target = sel ? closestSafe(btn, sel) : btn.closest('[data-bronto-dismissible]');
149
167
  if (!target) return;
150
168
  const ev = new CustomEvent('bronto:dismiss', { bubbles: true, cancelable: true });
151
169
  if (target.dispatchEvent(ev)) target.remove();
@@ -264,81 +282,73 @@ export function initTabs({ root } = {}) {
264
282
  * `close` event, so keyboard/SR users are never dropped at `<body>`.
265
283
  * SSR-safe and idempotent; returns cleanup.
266
284
  *
267
- * `root` scopes which triggers are delegated (default `document`); the
268
- * dialog itself is still resolved by id document-wide, because a modal
269
- * <dialog> is promoted to the top layer and is inherently document-global
270
- * (same model as `initThemeToggle`, where `root` scopes controls but the
271
- * theme applies to <html>).
285
+ * `root` scopes delegated triggers (default `document`). Controlled targets are
286
+ * resolved root-first, then document-wide, so scoped islands win duplicate-id
287
+ * conflicts without breaking body/portal-mounted overlays.
272
288
  */
273
289
  export function initDialog({ root } = {}) {
274
290
  if (!hasDom()) return noop;
275
291
  const host = root || document;
292
+ const managedDialogs = new WeakSet();
293
+ const canManageDialog = (dlg, origin) => host.contains(origin) || managedDialogs.has(dlg);
294
+
295
+ const openFrom = (opener) => {
296
+ const dlg = byIdInHost(host, opener.getAttribute('data-bronto-open'));
297
+ if (!dlg || typeof dlg.showModal !== 'function' || dlg.open) return;
298
+ managedDialogs.add(dlg);
299
+ dlg.addEventListener(
300
+ 'close',
301
+ () => {
302
+ if (opener.isConnected && typeof opener.focus === 'function') opener.focus();
303
+ },
304
+ { once: true },
305
+ );
306
+ dlg.showModal();
307
+ };
308
+
309
+ const closeFrom = (closer) => {
310
+ const dlg = closer.closest('dialog');
311
+ if (dlg && dlg.open && canManageDialog(dlg, closer)) dlg.close();
312
+ };
313
+
314
+ const lightDismiss = (dlg) => {
315
+ if (
316
+ dlg.tagName === 'DIALOG' &&
317
+ dlg.open &&
318
+ dlg.hasAttribute('data-bronto-dialog-light') &&
319
+ canManageDialog(dlg, dlg)
320
+ ) {
321
+ dlg.close();
322
+ }
323
+ };
324
+
276
325
  const onClick = (e) => {
277
326
  const opener = e.target.closest('[data-bronto-open]');
278
327
  if (opener && host.contains(opener)) {
279
- const dlg = document.getElementById(opener.getAttribute('data-bronto-open'));
280
- if (dlg && typeof dlg.showModal === 'function' && !dlg.open) {
281
- dlg.addEventListener(
282
- 'close',
283
- () => {
284
- if (opener.isConnected && typeof opener.focus === 'function') opener.focus();
285
- },
286
- { once: true },
287
- );
288
- dlg.showModal();
289
- }
328
+ openFrom(opener);
290
329
  return;
291
330
  }
292
331
  const closer = e.target.closest('[data-bronto-close]');
293
- if (closer && host.contains(closer)) {
294
- const dlg = closer.closest('dialog');
295
- if (dlg && dlg.open) dlg.close();
332
+ if (closer) {
333
+ closeFrom(closer);
296
334
  return;
297
335
  }
298
336
  // Light-dismiss: a click whose target is the <dialog> itself is the
299
337
  // backdrop (content sits in child elements).
300
- const dlg = e.target;
301
- if (
302
- dlg.tagName === 'DIALOG' &&
303
- dlg.open &&
304
- dlg.hasAttribute('data-bronto-dialog-light') &&
305
- host.contains(dlg)
306
- ) {
307
- dlg.close();
308
- }
338
+ lightDismiss(e.target);
309
339
  };
310
340
  return bindOnce(host, 'dialog', () => {
311
- host.addEventListener('click', onClick);
312
- return () => host.removeEventListener('click', onClick);
341
+ document.addEventListener('click', onClick);
342
+ return () => document.removeEventListener('click', onClick);
313
343
  });
314
344
  }
315
345
 
316
- /**
317
- * Push a transient toast into a shared, screen-anchored stack. The stack
318
- * is the `aria-live="polite"` region: it is created once, appended to
319
- * <body>, and **kept resident even when empty** so the live region is
320
- * always present before content is inserted (a freshly created region
321
- * that receives its first child in the same tick is not reliably
322
- * announced by VoiceOver/NVDA). On first creation the empty region is
323
- * inserted and the toast is appended on the next frame for the same
324
- * reason. `tone` is accent/success/warning/danger/info; `title` is an
325
- * optional uppercase label; `duration` ms before auto-dismiss (0 keeps
326
- * it until dismissed). Returns a function that dismisses the toast
327
- * early. SSR-safe (no-op).
328
- */
329
- export function toast(message, { tone, title, duration = 4000, assertive, closable } = {}) {
330
- if (!hasDom()) return noop;
331
- // Errors must interrupt: danger toasts (or an explicit `assertive`)
332
- // go to a SEPARATE assertive region so they announce immediately,
333
- // while status toasts stay polite. Two regions — not a per-item
334
- // role=alert nested in a polite parent — avoids the double
335
- // announcement that nesting causes in some screen readers.
336
- const isAssertive = assertive ?? tone === 'danger';
346
+ function toastStack(isAssertive) {
337
347
  const stackSel = isAssertive
338
348
  ? '.ui-toast-stack--assertive'
339
349
  : '.ui-toast-stack:not(.ui-toast-stack--assertive)';
340
350
  let stack = document.querySelector(stackSel);
341
- const freshStack = !stack;
351
+ const fresh = !stack;
342
352
  if (!stack) {
343
353
  stack = document.createElement('div');
344
354
  stack.className = isAssertive ? 'ui-toast-stack ui-toast-stack--assertive' : 'ui-toast-stack';
@@ -346,6 +356,26 @@ export function toast(message, { tone, title, duration = 4000, assertive, closab
346
356
  if (isAssertive) stack.setAttribute('role', 'alert');
347
357
  document.body.appendChild(stack);
348
358
  }
359
+ return { stack, fresh };
360
+ }
361
+
362
+ function enqueueToast(place, freshStack) {
363
+ const canDefer = typeof requestAnimationFrame === 'function';
364
+ if (freshStack && canDefer) {
365
+ toastQueue.push(place);
366
+ toastFlushScheduled = true;
367
+ requestAnimationFrame(() => {
368
+ toastFlushScheduled = false;
369
+ for (const fn of toastQueue.splice(0)) fn();
370
+ });
371
+ } else if (toastFlushScheduled) {
372
+ toastQueue.push(place);
373
+ } else {
374
+ place();
375
+ }
376
+ }
377
+
378
+ function toastElement(message, { tone, title }) {
349
379
  const el = document.createElement('div');
350
380
  el.className = tone ? `ui-toast ui-toast--${tone}` : 'ui-toast';
351
381
  // No per-item role: the stack itself is the live region; a nested
@@ -359,6 +389,72 @@ export function toast(message, { tone, title, duration = 4000, assertive, closab
359
389
  const body = document.createElement('div');
360
390
  body.textContent = message;
361
391
  el.appendChild(body);
392
+ return el;
393
+ }
394
+
395
+ // Remove a toast, animating its exit when — and only when — a transition
396
+ // is actually in effect. Detached nodes, reduced-motion, and the no-CSS
397
+ // test/SSR env all resolve to instant removal, so the dismiss contract
398
+ // (toast gone now, the aria-live stack stays resident) is unchanged there;
399
+ // a real browser with motion gets the CSS `.is-leaving` fade-out, with a
400
+ // timeout fallback so an interrupted/never-firing transitionend can't strand
401
+ // a toast in the live region.
402
+ function removeToast(el) {
403
+ const reduce =
404
+ typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
405
+ const cs =
406
+ !reduce && el.isConnected && typeof getComputedStyle === 'function'
407
+ ? getComputedStyle(el)
408
+ : null;
409
+ const dur = cs ? parseFloat(cs.transitionDuration) || 0 : 0;
410
+ if (dur <= 0) {
411
+ el.remove();
412
+ return;
413
+ }
414
+ el.classList.add('is-leaving');
415
+ let done = false;
416
+ const finish = () => {
417
+ if (done) return;
418
+ done = true;
419
+ el.remove();
420
+ };
421
+ el.addEventListener('transitionend', finish, { once: true });
422
+ const timer = setTimeout(finish, dur * 1000 + 120);
423
+ timer?.unref?.(); // don't keep a Node test process alive
424
+ }
425
+
426
+ function addToastClose(el, dismiss) {
427
+ const close = document.createElement('button');
428
+ close.type = 'button';
429
+ close.className = 'ui-toast__close';
430
+ close.setAttribute('aria-label', 'Dismiss');
431
+ close.addEventListener('click', dismiss);
432
+ el.appendChild(close);
433
+ }
434
+
435
+ /**
436
+ * Push a transient toast into a shared, screen-anchored stack. The stack
437
+ * is the `aria-live="polite"` region: it is created once, appended to
438
+ * <body>, and **kept resident even when empty** so the live region is
439
+ * always present before content is inserted (a freshly created region
440
+ * that receives its first child in the same tick is not reliably
441
+ * announced by VoiceOver/NVDA). On first creation the empty region is
442
+ * inserted and the toast is appended on the next frame for the same
443
+ * reason. `tone` is accent/success/warning/danger/info; `title` is an
444
+ * optional uppercase label; `duration` ms before auto-dismiss (0 keeps
445
+ * it until dismissed). Returns a function that dismisses the toast
446
+ * early. SSR-safe (no-op).
447
+ */
448
+ export function toast(message, { tone, title, duration = 4000, assertive, closable } = {}) {
449
+ if (!hasDom()) return noop;
450
+ // Errors must interrupt: danger toasts (or an explicit `assertive`)
451
+ // go to a SEPARATE assertive region so they announce immediately,
452
+ // while status toasts stay polite. Two regions — not a per-item
453
+ // role=alert nested in a polite parent — avoids the double
454
+ // announcement that nesting causes in some screen readers.
455
+ const isAssertive = assertive ?? tone === 'danger';
456
+ const { stack, fresh: freshStack } = toastStack(isAssertive);
457
+ const el = toastElement(message, { tone, title });
362
458
  // Append after a frame the *first* time so the empty live region is
363
459
  // observed by AT before its first child arrives; once the region has
364
460
  // been observed, later toasts append synchronously.
@@ -369,28 +465,14 @@ export function toast(message, { tone, title, duration = 4000, assertive, closab
369
465
  const place = () => {
370
466
  if (!dismissed) stack.appendChild(el);
371
467
  };
372
- const canDefer = typeof requestAnimationFrame === 'function';
373
- if (freshStack && canDefer) {
374
- toastQueue.push(place);
375
- toastFlushScheduled = true;
376
- requestAnimationFrame(() => {
377
- toastFlushScheduled = false;
378
- for (const fn of toastQueue.splice(0)) fn();
379
- });
380
- } else if (toastFlushScheduled) {
381
- // A first-frame deferral is in flight — queue behind it so FIFO
382
- // order holds and the region still isn't populated synchronously.
383
- toastQueue.push(place);
384
- } else {
385
- place();
386
- }
468
+ enqueueToast(place, freshStack);
387
469
 
388
470
  let timer;
389
471
  const dismiss = () => {
390
472
  if (dismissed) return;
391
473
  dismissed = true;
392
474
  if (timer) clearTimeout(timer);
393
- el.remove();
475
+ removeToast(el);
394
476
  // The stack is a persistent live region — never removed on drain, so
395
477
  // the next toast does not recreate (and thus mis-announce) it.
396
478
  };
@@ -398,14 +480,7 @@ export function toast(message, { tone, title, duration = 4000, assertive, closab
398
480
  // it gets a dismiss affordance by default; any toast can opt in via
399
481
  // `closable`. The button carries no text node (glyph is a CSS
400
482
  // ::before) so the toast's announced/textContent stays the message.
401
- if (closable ?? duration === 0) {
402
- const close = document.createElement('button');
403
- close.type = 'button';
404
- close.className = 'ui-toast__close';
405
- close.setAttribute('aria-label', 'Dismiss');
406
- close.addEventListener('click', dismiss);
407
- el.appendChild(close);
408
- }
483
+ if (closable ?? duration === 0) addToastClose(el, dismiss);
409
484
  if (duration > 0) timer = setTimeout(dismiss, duration);
410
485
  return dismiss;
411
486
  }
@@ -422,7 +497,7 @@ export function initDisclosure({ root } = {}) {
422
497
  const trigger = e.target.closest('[data-bronto-disclosure]');
423
498
  if (!trigger || !host.contains(trigger)) return;
424
499
  const id = trigger.getAttribute('aria-controls');
425
- const panel = id && document.getElementById(id);
500
+ const panel = byIdInHost(host, id);
426
501
  if (!panel) return;
427
502
  const open = trigger.getAttribute('aria-expanded') === 'true';
428
503
  trigger.setAttribute('aria-expanded', String(!open));
@@ -755,6 +830,24 @@ export function initCombobox({ root } = {}) {
755
830
  active = options.indexOf(vis[next]);
756
831
  setActive(options[active]);
757
832
  };
833
+ const activateEdge = (which) => {
834
+ if (list.hidden) return false;
835
+ const v = visible();
836
+ if (!v.length) return true;
837
+ active = options.indexOf(which === 'first' ? v[0] : v[v.length - 1]);
838
+ setActive(options[active]);
839
+ return true;
840
+ };
841
+ const selectActive = () => {
842
+ if (list.hidden || active < 0 || options[active].hidden) return false;
843
+ select(options[active]);
844
+ return true;
845
+ };
846
+ const closeIfOpen = () => {
847
+ if (list.hidden) return false;
848
+ close();
849
+ return true;
850
+ };
758
851
 
759
852
  const onInput = () => filter();
760
853
  const onKey = (e) => {
@@ -768,36 +861,16 @@ export function initCombobox({ root } = {}) {
768
861
  move(-1);
769
862
  break;
770
863
  case 'Home':
771
- if (!list.hidden) {
772
- e.preventDefault();
773
- const v = visible();
774
- if (v.length) {
775
- active = options.indexOf(v[0]);
776
- setActive(options[active]);
777
- }
778
- }
864
+ if (activateEdge('first')) e.preventDefault();
779
865
  break;
780
866
  case 'End':
781
- if (!list.hidden) {
782
- e.preventDefault();
783
- const v = visible();
784
- if (v.length) {
785
- active = options.indexOf(v[v.length - 1]);
786
- setActive(options[active]);
787
- }
788
- }
867
+ if (activateEdge('last')) e.preventDefault();
789
868
  break;
790
869
  case 'Enter':
791
- if (!list.hidden && active >= 0 && !options[active].hidden) {
792
- e.preventDefault();
793
- select(options[active]);
794
- }
870
+ if (selectActive()) e.preventDefault();
795
871
  break;
796
872
  case 'Escape':
797
- if (!list.hidden) {
798
- e.preventDefault();
799
- close();
800
- }
873
+ if (closeIfOpen()) e.preventDefault();
801
874
  break;
802
875
  case 'Tab':
803
876
  close();
@@ -904,8 +977,8 @@ export function initPopover({ root } = {}) {
904
977
 
905
978
  const onClick = (e) => {
906
979
  const trigger = e.target.closest?.('[data-bronto-popover]');
907
- if (trigger) {
908
- const panel = document.getElementById(trigger.getAttribute('data-bronto-popover'));
980
+ if (trigger && host.contains(trigger)) {
981
+ const panel = byIdInHost(host, trigger.getAttribute('data-bronto-popover'));
909
982
  if (!panel) return;
910
983
  e.preventDefault();
911
984
  if (openPanel === panel) close();
@@ -926,12 +999,12 @@ export function initPopover({ root } = {}) {
926
999
  };
927
1000
 
928
1001
  return bindOnce(host, 'popover', () => {
929
- host.addEventListener('click', onClick);
1002
+ document.addEventListener('click', onClick);
930
1003
  document.addEventListener('keydown', onKey);
931
1004
  view?.addEventListener('scroll', onReflow, true);
932
1005
  view?.addEventListener('resize', onReflow);
933
1006
  return () => {
934
- host.removeEventListener('click', onClick);
1007
+ document.removeEventListener('click', onClick);
935
1008
  document.removeEventListener('keydown', onKey);
936
1009
  view?.removeEventListener('scroll', onReflow, true);
937
1010
  view?.removeEventListener('resize', onReflow);
@@ -65,6 +65,7 @@ export declare const cls: {
65
65
  readonly dotmatrixCellAccent: 'ui-dotmatrix__cell--accent';
66
66
  readonly dotmatrixReveal: 'ui-dotmatrix--reveal';
67
67
  readonly dotmatrixPulse: 'ui-dotmatrix--pulse';
68
+ readonly icon: 'ui-icon';
68
69
  readonly dotfield: 'ui-dotfield';
69
70
  readonly dotrule: 'ui-dotrule';
70
71
  readonly dotbar: 'ui-dotbar';
@@ -231,6 +232,43 @@ export declare const cls: {
231
232
  readonly timeline: 'ui-timeline';
232
233
  readonly timelineItem: 'ui-timeline__item';
233
234
  readonly timelineTime: 'ui-timeline__time';
235
+ readonly report: 'ui-report';
236
+ readonly reportCompact: 'ui-report--compact';
237
+ readonly reportNumbered: 'ui-report--numbered';
238
+ readonly reportCover: 'ui-report__cover';
239
+ readonly reportCoverCompact: 'ui-report__cover--compact';
240
+ readonly reportHeader: 'ui-report__header';
241
+ readonly reportTitle: 'ui-report__title';
242
+ readonly reportSubtitle: 'ui-report__subtitle';
243
+ readonly reportMeta: 'ui-report__meta';
244
+ readonly reportToc: 'ui-report__toc';
245
+ readonly reportSummary: 'ui-report__summary';
246
+ readonly reportSection: 'ui-report__section';
247
+ readonly reportSectionUnnumbered: 'ui-report__section--unnumbered';
248
+ readonly reportSectionHead: 'ui-report__section-head';
249
+ readonly reportFinding: 'ui-report__finding';
250
+ readonly reportEvidence: 'ui-report__evidence';
251
+ readonly reportFigure: 'ui-report__figure';
252
+ readonly reportCaption: 'ui-report__caption';
253
+ readonly reportSources: 'ui-report__sources';
254
+ readonly reportAppendix: 'ui-report__appendix';
255
+ readonly reportFootnotes: 'ui-report__footnotes';
256
+ readonly chart: 'ui-chart';
257
+ readonly chartLegend: 'ui-chart__legend';
258
+ readonly chartSwatch: 'ui-chart__swatch';
259
+ readonly chartCaption: 'ui-chart__caption';
260
+ readonly chartPlot: 'ui-chart__plot';
261
+ readonly chartBar: 'ui-chart__bar';
262
+ readonly chartLabel: 'ui-chart__label';
263
+ readonly chartTrack: 'ui-chart__track';
264
+ readonly chartFill: 'ui-chart__fill';
265
+ readonly chartFallback: 'ui-chart__fallback';
266
+ readonly printOnly: 'ui-print-only';
267
+ readonly screenOnly: 'ui-screen-only';
268
+ readonly breakBefore: 'ui-break-before';
269
+ readonly breakAfter: 'ui-break-after';
270
+ readonly keep: 'ui-keep';
271
+ readonly printExact: 'ui-print-exact';
234
272
  readonly kbd: 'ui-kbd';
235
273
  readonly display: 'ui-display';
236
274
  readonly mono: 'ui-mono';
@@ -247,6 +285,9 @@ export declare const cls: {
247
285
  readonly animateFade: 'ui-animate-fade';
248
286
  readonly animateDot: 'ui-animate-dot';
249
287
  readonly animateMatrix: 'ui-animate-matrix';
288
+ readonly scrollProgress: 'ui-scroll-progress';
289
+ readonly scrollReveal: 'ui-scroll-reveal';
290
+ readonly vt: 'ui-vt';
250
291
  readonly appShell: 'ui-app-shell';
251
292
  readonly appShellFull: 'ui-app-shell--full';
252
293
  readonly appRail: 'ui-app-rail';