@ponchia/ui 0.6.0 → 0.6.4

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 (162) hide show
  1. package/CHANGELOG.md +82 -4
  2. package/README.md +1 -1
  3. package/annotations/index.d.ts.map +1 -1
  4. package/annotations/index.js +36 -33
  5. package/behaviors/carousel.d.ts +28 -0
  6. package/behaviors/carousel.d.ts.map +1 -0
  7. package/behaviors/carousel.js +3 -0
  8. package/behaviors/combobox.d.ts +40 -0
  9. package/behaviors/combobox.d.ts.map +1 -0
  10. package/behaviors/combobox.js +71 -20
  11. package/behaviors/command.d.ts +41 -0
  12. package/behaviors/command.d.ts.map +1 -0
  13. package/behaviors/command.js +9 -0
  14. package/behaviors/connectors.d.ts +17 -0
  15. package/behaviors/connectors.d.ts.map +1 -0
  16. package/behaviors/connectors.js +3 -0
  17. package/behaviors/crosshair.d.ts +42 -0
  18. package/behaviors/crosshair.d.ts.map +1 -0
  19. package/behaviors/crosshair.js +19 -1
  20. package/behaviors/dialog.d.ts +20 -0
  21. package/behaviors/dialog.d.ts.map +1 -0
  22. package/behaviors/dialog.js +3 -0
  23. package/behaviors/disclosure.d.ts +10 -0
  24. package/behaviors/disclosure.d.ts.map +1 -0
  25. package/behaviors/disclosure.js +3 -0
  26. package/behaviors/dismissible.d.ts +10 -0
  27. package/behaviors/dismissible.d.ts.map +1 -0
  28. package/behaviors/dismissible.js +3 -0
  29. package/behaviors/forms.d.ts +27 -0
  30. package/behaviors/forms.d.ts.map +1 -0
  31. package/behaviors/forms.js +18 -5
  32. package/behaviors/glyph.d.ts +21 -0
  33. package/behaviors/glyph.d.ts.map +1 -0
  34. package/behaviors/glyph.js +82 -4
  35. package/behaviors/index.d.ts +31 -237
  36. package/behaviors/index.d.ts.map +1 -0
  37. package/behaviors/index.js +17 -0
  38. package/behaviors/inert.d.ts +20 -0
  39. package/behaviors/inert.d.ts.map +1 -0
  40. package/behaviors/inert.js +46 -0
  41. package/behaviors/internal.d.ts +25 -0
  42. package/behaviors/internal.d.ts.map +1 -0
  43. package/behaviors/internal.js +30 -1
  44. package/behaviors/legend.d.ts +35 -0
  45. package/behaviors/legend.d.ts.map +1 -0
  46. package/behaviors/legend.js +9 -0
  47. package/behaviors/menu.d.ts +16 -0
  48. package/behaviors/menu.d.ts.map +1 -0
  49. package/behaviors/menu.js +3 -0
  50. package/behaviors/modal.d.ts +41 -0
  51. package/behaviors/modal.d.ts.map +1 -0
  52. package/behaviors/modal.js +124 -0
  53. package/behaviors/popover.d.ts +28 -0
  54. package/behaviors/popover.d.ts.map +1 -0
  55. package/behaviors/popover.js +17 -17
  56. package/behaviors/spotlight.d.ts +17 -0
  57. package/behaviors/spotlight.d.ts.map +1 -0
  58. package/behaviors/spotlight.js +3 -0
  59. package/behaviors/table.d.ts +36 -0
  60. package/behaviors/table.d.ts.map +1 -0
  61. package/behaviors/table.js +48 -8
  62. package/behaviors/tabs.d.ts +20 -0
  63. package/behaviors/tabs.d.ts.map +1 -0
  64. package/behaviors/tabs.js +3 -0
  65. package/behaviors/theme.d.ts +54 -0
  66. package/behaviors/theme.d.ts.map +1 -0
  67. package/behaviors/theme.js +17 -0
  68. package/behaviors/toast.d.ts +49 -0
  69. package/behaviors/toast.d.ts.map +1 -0
  70. package/behaviors/toast.js +34 -2
  71. package/classes/classes.json +747 -15
  72. package/classes/index.d.ts +118 -3
  73. package/classes/index.js +264 -66
  74. package/connectors/index.d.ts +12 -0
  75. package/connectors/index.d.ts.map +1 -1
  76. package/connectors/index.js +23 -2
  77. package/css/app.css +26 -0
  78. package/css/bullet.css +108 -0
  79. package/css/code.css +98 -0
  80. package/css/content.css +15 -2
  81. package/css/crosshair.css +7 -7
  82. package/css/diff.css +153 -0
  83. package/css/disclosure.css +18 -4
  84. package/css/dots.css +246 -9
  85. package/css/feedback.css +39 -7
  86. package/css/forms.css +71 -3
  87. package/css/legend.css +5 -2
  88. package/css/motion.css +79 -14
  89. package/css/overlay.css +59 -2
  90. package/css/primitives.css +67 -8
  91. package/css/report.css +43 -4
  92. package/css/sidenote.css +67 -0
  93. package/css/skins.css +9 -0
  94. package/css/spark.css +76 -0
  95. package/css/table.css +16 -3
  96. package/css/term.css +110 -0
  97. package/css/textref.css +63 -0
  98. package/css/toc.css +91 -0
  99. package/css/tokens.css +14 -1
  100. package/css/tree.css +134 -0
  101. package/dist/bronto.css +1 -1
  102. package/dist/css/analytical.css +1 -1
  103. package/dist/css/app.css +1 -1
  104. package/dist/css/bullet.css +1 -0
  105. package/dist/css/code.css +1 -0
  106. package/dist/css/content.css +1 -1
  107. package/dist/css/crosshair.css +1 -1
  108. package/dist/css/diff.css +1 -0
  109. package/dist/css/disclosure.css +1 -1
  110. package/dist/css/dots.css +1 -1
  111. package/dist/css/feedback.css +1 -1
  112. package/dist/css/forms.css +1 -1
  113. package/dist/css/legend.css +1 -1
  114. package/dist/css/motion.css +1 -1
  115. package/dist/css/overlay.css +1 -1
  116. package/dist/css/primitives.css +1 -1
  117. package/dist/css/report.css +1 -1
  118. package/dist/css/sidenote.css +1 -0
  119. package/dist/css/skins.css +1 -1
  120. package/dist/css/spark.css +1 -0
  121. package/dist/css/table.css +1 -1
  122. package/dist/css/term.css +1 -0
  123. package/dist/css/textref.css +1 -0
  124. package/dist/css/toc.css +1 -0
  125. package/dist/css/tokens.css +1 -1
  126. package/dist/css/tree.css +1 -0
  127. package/docs/annotations.md +39 -0
  128. package/docs/architecture.md +2 -3
  129. package/docs/bullet.md +78 -0
  130. package/docs/code.md +76 -0
  131. package/docs/d2.md +4 -3
  132. package/docs/diff.md +146 -0
  133. package/docs/dots.md +146 -0
  134. package/docs/glyphs.md +114 -0
  135. package/docs/legends.md +8 -4
  136. package/docs/mermaid.md +21 -4
  137. package/docs/reference.md +168 -8
  138. package/docs/reporting.md +49 -17
  139. package/docs/sidenote.md +64 -0
  140. package/docs/spark.md +78 -0
  141. package/docs/stability.md +1 -0
  142. package/docs/term.md +81 -0
  143. package/docs/textref.md +78 -0
  144. package/docs/theming.md +44 -5
  145. package/docs/toc.md +83 -0
  146. package/docs/tree.md +74 -0
  147. package/docs/usage.md +264 -23
  148. package/docs/vega.md +22 -3
  149. package/glyphs/glyphs.d.ts +61 -0
  150. package/glyphs/glyphs.js +600 -31
  151. package/llms.txt +169 -15
  152. package/package.json +51 -7
  153. package/qwik/index.d.ts +4 -2
  154. package/qwik/index.d.ts.map +1 -1
  155. package/qwik/index.js +10 -0
  156. package/react/index.d.ts +4 -2
  157. package/react/index.d.ts.map +1 -1
  158. package/react/index.js +6 -0
  159. package/solid/index.d.ts +6 -2
  160. package/solid/index.d.ts.map +1 -1
  161. package/solid/index.js +6 -0
  162. package/tokens/skins.js +22 -9
package/docs/usage.md CHANGED
@@ -26,6 +26,14 @@ into a preset on `<html>` or any subtree:
26
26
  Scope it, don't globalize blindly: a dashboard with one marketing-style
27
27
  hero can set `compact` on `<html>` and `comfortable` on the hero section.
28
28
 
29
+ > **`data-density` is the global preset; per-component density verbs differ by
30
+ > family.** Some components carry their own local density modifier, and the verb
31
+ > is **not** uniform: it's `--dense` on `ui-table`/`ui-dotgrid` but `--compact`
32
+ > on `ui-prose`/`ui-legend`/`ui-report`. So `ui-prose--dense` and
33
+ > `ui-table--compact` both no-op. When in doubt, reach for the global
34
+ > `data-density` preset; use the local modifier only where it exists (check the
35
+ > base's `modifiers` in classes.json).
36
+
29
37
  ## Badge vs chip vs status dot
30
38
 
31
39
  All three are small. They are **not** interchangeable:
@@ -35,18 +43,39 @@ All three are small. They are **not** interchangeable:
35
43
  | **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. |
36
44
  | **badge** | a label *classifying* the thing it sits on (count, tone, "BETA"). Static, not actionable. `ui.badge({ tone })`. |
37
45
  | **chip** | a discrete, often removable/selectable token the user manipulated (a filter, a tag input value). Interactive affordance implied. |
46
+ | **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. |
38
47
 
39
48
  Rule of thumb: state → dot, classification → badge, user-controlled value
40
- → chip.
49
+ → chip, fixed keyword → tag. (`ui-property` is a **workbench** primitive — a
50
+ key/value spec row scoped to `@ponchia/ui/css/workbench.css`, not a general
51
+ content label; don't reach for it to tag prose.)
41
52
 
42
53
  **Tone vocabulary varies by family — by design.** Colour is rationed, so not
43
- every component carries every tone: `--info`/`--muted` exist on some families
44
- (badge, dot, state) and not others (alert/toast/meter lead with
45
- `--success`/`--warning`/`--danger`). The authoritative per-component tone list is
46
- each base's `modifiers` array in
54
+ every component carries every tone. The status tones
55
+ (`--success`/`--warning`/`--danger`) plus `--info` ride the status families
56
+ `ui-alert`, `ui-toast`, `ui-meter`, `ui-dot`, and `ui-badge` all carry `--info`.
57
+ The neutral `--muted` is **not** a status tone: it rides several *neutral*
58
+ bases (`ui-badge`, `ui-num`, `ui-eyebrow`, `ui-mark`, `ui-annotation`,
59
+ `ui-connector`, `ui-crosshair`) but is deliberately absent from the status
60
+ families `ui-dot`/`ui-alert`/`ui-toast`/`ui-meter`. Because the CSS is a
61
+ plain class list, a hand-written unknown modifier (e.g. `ui-dot--muted`,
62
+ `ui-meter--muted`) **silently no-ops** — CSS can't warn. The `ui.*` builders are
63
+ safer on both ends: TypeScript rejects an out-of-set tone at author time, and at
64
+ runtime they `console.warn` (then drop the tone) so a JS caller sees the mistake
65
+ rather than shipping a bare, untoned element. The authoritative
66
+ per-component tone list is each base's `modifiers` array in
47
67
  [`@ponchia/ui/classes.json`](../classes/classes.json) — read it rather than
48
68
  extrapolating a tone you saw on one component onto another.
49
69
 
70
+ **Tone is a colour channel, so it collapses under Windows High-Contrast Mode.**
71
+ A `ui-badge--success` and a `ui-badge--danger` render identically once HCM
72
+ remaps colours (the tinted background + tone border are flattened to one system
73
+ colour). Status dots and the meter fill re-assert a distinct system colour, but
74
+ the badge/chip/tag tints cannot carry five differentiable tones — so treat badge
75
+ tone as **decorative emphasis** and make sure the badge's text label carries the
76
+ status word ("Failed", not a bare red pill). Same WCAG 1.4.1 reasoning the dots
77
+ follow.
78
+
50
79
  ## Numbers: `ui-num` vs the table state classes
51
80
 
52
81
  - Inside `.ui-table`, a numeric cell is `.is-num` (+ `.is-pos` /
@@ -57,6 +86,21 @@ extrapolating a tone you saw on one component onto another.
57
86
  freed from the table. Do not hand-roll right-align + `text-green`;
58
87
  that's the duplication `ui-num` exists to kill.
59
88
 
89
+ **The positive/negative vocabulary is spelled three ways — match the mechanism
90
+ to the host, they are not interchangeable:**
91
+
92
+ | Where | Positive / negative | Kind |
93
+ | --- | --- | --- |
94
+ | `ui-num` primitive (anywhere) | `ui-num--pos` / `ui-num--neg` (`ui.num({ tone: 'pos' })`) | **modifier** |
95
+ | Inside `.ui-table` | `is-pos` / `is-neg` | **state hook** (table-scoped) |
96
+ | `ui-delta` trend | `ui-delta--up` / `ui-delta--down` (`ui.delta({ dir })`) | **direction** |
97
+
98
+ They share the same tone tokens but **don't cross over**: `ui-num is-pos`
99
+ no-ops (the `is-pos` rule is scoped to table cells), and `ui-delta--pos` doesn't
100
+ exist. Use the modifier on `ui-num`, the `is-*` state inside a table, and
101
+ `--up`/`--down` on `ui-delta` (which also flips with `ui.delta({ invert })` when
102
+ up is the *bad* direction — latency, error rate, cost).
103
+
60
104
  ## Prose vs primitives, and prose inside a card
61
105
 
62
106
  - `ui-prose` styles **raw, unclassed semantic HTML** (MDX / CMS / LLM
@@ -68,6 +112,42 @@ extrapolating a tone you saw on one component onto another.
68
112
  `.ui-card` itself, so card padding/border stays the card's and prose
69
113
  rhythm stays the content's. One responsibility per element.
70
114
 
115
+ ## Centred width: `ui-center` vs `ui-container`
116
+
117
+ Both cap a centred column, but they are **different primitives with different
118
+ box models** — pick by intent:
119
+
120
+ - **`ui-center`** — a *reading measure*. `--center-max` is the **inner** content
121
+ width (content-box); the `--center-gutter` padding adds *outside* it. Use it to
122
+ hold prose/body to a comfortable line length.
123
+ - **`ui-container`** — a *page frame*. `--container` (/`--container-narrow`
124
+ /`--container-wide`) is the **total** max width (border-box). Use it as the
125
+ outer wrapper that aligns a page's sections to a shared edge.
126
+
127
+ Rule of thumb: measure of text → `ui-center`; page-level frame → `ui-container`.
128
+ Don't nest one inside the other expecting the caps to compose — they measure
129
+ different boxes.
130
+
131
+ ## Container queries: `ui-cq`
132
+
133
+ `ui-cq` is the one primitive that changes how *other* primitives respond. Add it
134
+ to a wrapper and its descendants adapt to **that box's** inline size, not the
135
+ viewport — so the same `ui-grid` / `ui-statgrid` / `ui-app-metrics` collapses to
136
+ one column inside a slim panel even when the window is wide (island-safe; it
137
+ nests). Two thresholds are built in: `ui-grid` drops to a single column at
138
+ **34rem** and `ui-statgrid`/`ui-app-metrics` at **30rem**, measured on the `ui-cq`
139
+ box. Note `rem` in a container query resolves against the **root** font size, not
140
+ 16px — at Bronto's 15px root that's ≈**510px** and ≈**450px**, ~6% tighter than a
141
+ 16px mental model. And be aware `ui-grid` already collapses on its own via an
142
+ intrinsic `auto-fit` minmax, so `ui-cq` barely changes it — the primitive that
143
+ genuinely *needs* `ui-cq` to collapse by container (not viewport) is
144
+ `ui-statgrid`/`ui-app-metrics`. The container is named `bronto` (hardcoded — there
145
+ is no `--cq-name` knob; an author-set one is ignored), so an outer query never
146
+ accidentally matches an inner grid. `ui-cq` is inert until applied, so adding it
147
+ never shifts an existing layout. Reach for it whenever a layout must respond to
148
+ its container (a resizable pane, a sidebar widget, an embedded card) rather than
149
+ the page.
150
+
71
151
  ## Static reports
72
152
 
73
153
  Use the opt-in `@ponchia/ui/css/report.css` layer for static, PDF-first
@@ -126,10 +206,22 @@ Both are a thin horizontal bar; they mean different things.
126
206
  indeterminate (`ui-progress--indeterminate`). The fill is always accent.
127
207
  - **`ui-meter`** — a *measured static value*: coverage, disk, capacity, a
128
208
  KPI against a target. Never indeterminate. Tone the fill by threshold
129
- (`ui.meter({ tone })` → accent/success/warning/danger); the unset
130
- default is neutral. Drive the width with the shared `--value` knob
131
- (`style="--value: 72"`, 0–100) and author `role="meter"` +
132
- `aria-valuenow/min/max` for AT.
209
+ (`ui.meter({ tone })` → accent/success/warning/danger/info); the unset
210
+ default is neutral. Drive the width with the shared `--value` knob — a
211
+ **unitless number 0–100** (`style="--value: 72"`, *not* `72%`: a `%` is
212
+ invalid against the registered `<number>` type and the fill drops to empty).
213
+ The class string paints a 0-width, unannounced bar on its own, so set the
214
+ value **and** its ARIA together with `attrs.meter(72)` (or `attrs.progress`)
215
+ from `@ponchia/ui/classes` — it returns `role="meter"` +
216
+ `aria-valuenow/min/max` + the `--value` style, normalized to your
217
+ `{ min, max }`. Spread it: `<div class={ui.meter({ tone })} {...attrs.meter(72)}>`.
218
+
219
+ **Indeterminate progress** is the one exception: call `attrs.progress()` with
220
+ **no argument**. ARIA requires `aria-valuenow` be *omitted* for an indeterminate
221
+ bar — emitting `0` would announce "0%", indistinguishable from a real stalled-at-
222
+ zero bar — so the helper returns just `role="progressbar"` + `aria-busy="true"`
223
+ (no `aria-valuenow`, no `--value`). Pair it with the class:
224
+ `<div class={ui.progress({ indeterminate: true })} {...attrs.progress()}>`.
133
225
 
134
226
  Rule of thumb: *something is happening* → progress; *something measures
135
227
  this much* → meter.
@@ -139,7 +231,10 @@ this much* → meter.
139
231
  - **`ui-steps`** — a stepper for a multi-step flow. Use an `<ol>`. State is
140
232
  ARIA-driven (the framework rule): the active step is `aria-current="step"`
141
233
  (no class); completed steps take `ui-steps__item--done`. Markers are
142
- auto-numbered by CSS counter.
234
+ auto-numbered by CSS counter. `--done` is a **visual** state only — it isn't
235
+ announced, so if "completed" must reach AT, add visually-hidden text
236
+ (e.g. `<span class="ui-visually-hidden">completed</span>`) or an `aria-label`
237
+ on the step.
143
238
  - **`ui-timeline`** — a vertical event list on a hairline spine (`<ol>` of
144
239
  `ui-timeline__item`, optional `ui-timeline__time`). `aria-current` on an
145
240
  item marks the live/most-recent event.
@@ -161,18 +256,76 @@ without it these widgets are unlabelled or unannounced:
161
256
  - **`ui-pagination`** — wrap it in `<nav aria-label="Pagination">`; give the
162
257
  current page `aria-current="page"`; label icon-only prev/next controls
163
258
  (`aria-label="Previous page"`). Disable a control with native `disabled`
164
- (a `<button>`) **or** `aria-disabled="true"` both now render disabled and
165
- are non-interactive; don't ship an `aria-disabled` control that still acts.
259
+ (a `<button>`) for full inertness, **or** `aria-disabled="true"` for a control
260
+ that stays focusable/announced (e.g. a disabled `<a>`). CSS dims both and makes
261
+ `aria-disabled` pointer-inert, but only native `disabled` is keyboard-inert on
262
+ its own — to stop an `aria-disabled` control from activating on Enter/Space,
263
+ wire `initDisabledGuard()` once near your root (see Behaviors).
166
264
  - **`ui-tabs`** — `initTabs` adds the full APG wiring (roles, roving tabindex,
167
265
  `aria-selected`, panel `hidden`, focusable panel). If you wire tabs yourself,
168
266
  name the `ui-tabs__list` (`role="tablist"` + an `aria-label`) and pair each
169
- tab with its panel via `aria-controls`/`aria-labelledby`.
267
+ tab with its panel via `aria-controls`/`aria-labelledby`. Every tab needs a
268
+ matching panel — a tab with no `aria-controls` target is an orphan that
269
+ announces as selected but reveals nothing.
270
+ - **Icon-only buttons** (`ui-button--icon` and any glyph-only control) carry no
271
+ text node, so they're nameless to AT — give them an `aria-label`
272
+ (`<button class="ui-button ui-button--icon" aria-label="Delete">`).
170
273
  - **`ui-sitenav` / `ui-app-nav`** — signal the current link with
171
274
  `aria-current="page"` (both honour it; `ui-app-nav` also accepts the
172
275
  visual-only `.is-active`, but prefer `aria-current`).
173
276
  - **`ui-skiplink`** — keep it the first focusable element and point its `href`
174
277
  at the `id` of your main landmark.
175
278
 
279
+ ## App shell: the admin dashboard frame
280
+
281
+ `ui-app-shell` is a CSS-only two-column admin frame (sidebar rail + main
282
+ column) that collapses to a single column with a horizontal rail below 880px —
283
+ no behavior required. The nesting matters; the rail is `ui-app-rail` and the
284
+ content side is `ui-app-main`:
285
+
286
+ ```html
287
+ <div class="ui-app-shell">
288
+ <aside class="ui-app-rail">
289
+ <span class="ui-app-rail__brand">Acme</span>
290
+ <nav class="ui-app-nav" aria-label="Primary">
291
+ <span class="ui-app-nav__section">Main</span>
292
+ <a href="/overview" aria-current="page">Overview</a>
293
+ <a href="/reports">Reports</a>
294
+ </nav>
295
+ <div class="ui-app-rail__account">…</div>
296
+ </aside>
297
+ <main class="ui-app-main">
298
+ <header class="ui-app-topbar"><h1 class="ui-app-topbar__title">Overview</h1></header>
299
+ <div class="ui-app-content">
300
+ <section class="ui-app-panel">
301
+ <div class="ui-app-panel__head"><h2 class="ui-app-panel__title">KPIs</h2></div>
302
+ <div class="ui-app-metrics">…<div class="ui-app-metric">…</div></div>
303
+ </section>
304
+ </div>
305
+ </main>
306
+ </div>
307
+ ```
308
+
309
+ Knobs: `--app-rail` sets the rail width (default 14rem); `ui-app-shell--full`
310
+ drops the rail for a single-column app. `ui-app-nav` honours `aria-current="page"`
311
+ (preferred) and the visual-only `.is-active`.
312
+
313
+ ## Menus: `data-bronto-menu` + `initMenu`
314
+
315
+ A dropdown menu is a native `<details data-bronto-menu>` styled as
316
+ `ui-menu-host` → `ui-menu` (with `ui-menu__item` / `ui-menu__sep` /
317
+ `ui-menu__label`). It opens/closes natively, but `initMenu()` adds the close
318
+ affordances a menu needs: outside-click close, Escape, and closing on item
319
+ activation. Without the behavior the menu opens but never dismisses itself.
320
+
321
+ **Clipping limitation.** The menu panel is positioned in normal flow (not the
322
+ top layer), so an ancestor with `overflow: hidden`/`auto` or a transform
323
+ **clips** it — the same edge case the tooltip has. If your menu lives inside a
324
+ scroll container or a clipped card and the panel gets cut off, either lift the
325
+ `ui-menu-host` out of the clipping ancestor, or reach for `initPopover` (which
326
+ escapes to the browser top layer via the native `popover` attribute) for that
327
+ control instead.
328
+
176
329
  ## Avatar: it's an unlabelled blob until you name it
177
330
 
178
331
  `ui-avatar` is a presentation box. Give it an accessible name yourself: an
@@ -185,11 +338,24 @@ convey identity to AT. Keep initials to ~2 characters — the box is
185
338
  ## Modal: native `<dialog>` vs `is-open`
186
339
 
187
340
  Prefer the **native `<dialog>`** path — you get top-layer, backdrop and
188
- focus-trap free. Only use `ui-modal.is-open` (`ui.modal({ open: true })`)
189
- when a portal/React modal genuinely can't be a `<dialog>`; then the
190
- backdrop and focus-trap are **yours** to provide. A drawer is a modal
341
+ focus-trap free (wire it with `initDialog` for open-triggers + focus-return).
342
+ Only use `ui-modal.is-open` (`ui.modal({ open: true })`) when a portal/React
343
+ modal genuinely can't be a `<dialog>`. The **backdrop and top-layer stacking
344
+ stay yours**, but you no longer have to hand-roll the focus trap: mark the
345
+ overlay `data-bronto-modal` and call `initModal()`. While `is-open` it traps
346
+ focus with `inert` (the rest of the page goes non-interactive), returns focus to
347
+ the opener on close, and dispatches a cancelable `bronto:modal:close` on Escape —
348
+ you still own the `is-open` class, so drop it in response. On bind `initModal`
349
+ also gives the overlay `role="dialog"` + `aria-modal="true"` and dev-warns if it
350
+ has no accessible name (add `aria-label`/`aria-labelledby`). A drawer is a modal
191
351
  that enters from an edge — same rule.
192
352
 
353
+ **Scroll-lock is not automatic on either path.** Neither the native `<dialog>`
354
+ nor the `is-open` path freezes the background — the page behind an open modal can
355
+ still scroll. If that matters, toggle a lock yourself while the modal is open
356
+ (`document.documentElement.style.overflow = 'hidden'`, restored on close), or add
357
+ `html:has(dialog[open]) { overflow: hidden }` for the native path.
358
+
193
359
  ## Carousel & lightbox: one primitive, two skins
194
360
 
195
361
  `ui-carousel` is a scroll-snap track of `__slide`s wired by `initCarousel`
@@ -347,11 +513,25 @@ authoring engine.
347
513
  `ui-button`): the browser greys it, blocks activation, and skips it in tab
348
514
  order, and bronto styles the disabled cue. Use `aria-disabled="true"` **only**
349
515
  when the control must stay focusable/announced — bronto then adds
350
- `pointer-events: none` to `ui-button`/`ui-link` so it can't be activated, but
351
- you still own removing it from the submit logic.
516
+ `pointer-events: none` to `ui-button`/`ui-link` so the pointer can't activate
517
+ it. That is **pointer-inert, not keyboard-inert**: CSS can't stop Enter/Space,
518
+ so wire `initDisabledGuard()` (Behaviors) to block keyboard activation across
519
+ every `aria-disabled` control. Either way you still own removing it from the
520
+ submit logic.
521
+ - **Read-only ≠ disabled.** A `readonly` input keeps its value in form submission
522
+ and stays focusable/selectable; `disabled` does neither. Bronto gives a
523
+ read-only field a quiet muted fill so it doesn't read as a live editable field —
524
+ reach for `readonly` when the value matters but mustn't be edited, `disabled`
525
+ when it should be inert and skipped.
352
526
  - **Combobox** (`data-bronto-combobox`) reads its options from the DOM at
353
- `initCombobox()` time — re-run it after you replace the option list. The
354
- `<input>` owns the value; the listbox is a view.
527
+ `initCombobox()` time — re-run it after you replace the option list (or add
528
+ `data-bronto-combobox-live`). The selected **option's text label** is shown in
529
+ the input while the `bronto:change` event carries the option's `data-value`
530
+ code — so put the human label in the `<li>` text and the code in `data-value`.
531
+ The `.ui-combobox__empty` ("No matches") is hidden until a filter empties the
532
+ list. Two intentional single-select APG deviations: ArrowDown on a closed list
533
+ filters rather than pre-selecting the first option, and Tab closes without
534
+ committing a merely-highlighted option (Enter/click commits).
355
535
  - **Validation** is opt-in via `data-bronto-validate` on the form plus
356
536
  `initFormValidation()`; it surfaces messages into a `ui-error-summary` you
357
537
  provide. The summary's title is the legible sans, not the display face — it's
@@ -366,6 +546,16 @@ With scripting disabled it degrades to fully visible, but if scripting is *on*
366
546
  and nothing toggles `is-visible`, the content stays hidden — so only use
367
547
  `ui-reveal` when you are wiring that toggle.
368
548
 
549
+ **View transitions (`ui-vt`).** `ui-vt` names an element via `--ui-vt-name` so it
550
+ morphs across a `document.startViewTransition()` or a cross-document navigation.
551
+ The name must be **unique per document** at the moment a transition runs: applying
552
+ `ui-vt` with one shared `--ui-vt-name` across every card in a list (the obvious
553
+ loop) makes the browser skip the transition — and it is **not** silent: it logs a
554
+ console error and **rejects `vt.ready`** (with an `InvalidStateError`). If you
555
+ don't `vt.ready.catch(…)`, that surfaces as an unhandled promise rejection. Give
556
+ each element its own name (`--ui-vt-name: card-7`) or only mark the single element
557
+ that actually morphs.
558
+
369
559
  ## Loading affordances need a role you supply
370
560
 
371
561
  `ui-spinner`, `ui-dotspinner`, `ui-skeleton`, and an indeterminate `ui-progress`
@@ -382,13 +572,64 @@ without it, it falls back to an `is-open` class that a clipping ancestor can cut
382
572
  off. Add `popover` to the panel for the robust path — the `is-open` form is a
383
573
  fallback, not the default to copy.
384
574
 
575
+ It is a **non-modal** dialog by design: the panel gets `role="dialog"` and focus
576
+ moves into it, but there is **no focus trap** and the rest of the page stays
577
+ interactive — Tab moves *out* of the panel (it does not cycle), and it closes on
578
+ Escape or outside-click. Don't assume `<dialog>`-modal semantics; if you need a
579
+ trap and an inert backdrop, use a real modal (`<dialog>` + `initDialog`, or
580
+ `initModal`). And the `is-open` fallback is a plain stacked element, so it sits
581
+ *under* any open native `<dialog>`'s top layer — another reason to prefer the
582
+ native `popover` attribute when a popover and a dialog can be open together.
583
+
584
+ ## Two tiers: CSS-native vs behavior-required
585
+
586
+ Not every component works with JavaScript off — know which tier you are shipping
587
+ before you rely on the no-JS path.
588
+
589
+ **CSS-native — fully operable with JS off.** Safe in static or LLM-authored
590
+ HTML, print/PDF, and before any hydration:
591
+
592
+ | Component | How it works without JS |
593
+ | --- | --- |
594
+ | Tooltip (`ui-tooltip`) | `:hover` / `:focus-within` (+ anchor positioning where supported) |
595
+ | Accordion | native `<details>` / `<summary>` |
596
+ | Segmented control (`ui-segmented`) | `:has(input:checked)` over a radio group |
597
+ | Scroll-reveal (`ui-scroll-reveal`) | scroll-driven animation, zero JS |
598
+ | Modal via native `<dialog>` | the element brings focus-trap + Escape; `initDialog` only adds open-triggers + focus-return |
599
+
600
+ **Behavior-required — a CSS skin that needs its `init*` to be interactive.**
601
+ These are JS widgets wearing the Bronto look; without the behavior they are inert
602
+ (and a couple are *worse* than inert — see the tabs row):
603
+
604
+ | Component | Behavior | With the behavior absent |
605
+ | --- | --- | --- |
606
+ | Tabs (`ui-tabs`) | `initTabs` | **author panels visible** — ship `hidden` panels and if `initTabs` never runs the content is unreachable |
607
+ | Combobox (`ui-combobox`) | `initCombobox` | a plain text input beside an unfiltered list |
608
+ | Command palette (`ui-command`) | `initCommand` | a static, unfiltered list |
609
+ | Table sort/select (`[data-bronto-sortable]`) | `initTableSort` | a static table (still readable) |
610
+ | Popover (`ui-popover`) | `initPopover` | no placement/ARIA — prefer the native `popover` attribute |
611
+ | Carousel (`ui-carousel`) | `initCarousel` | a native scroll-snap track (usable, no controls) |
612
+ | Controlled modal (`ui-modal.is-open`) | `initModal` | no focus trap — provide one or use native `<dialog>` |
613
+ | Menu (`data-bronto-menu`) | `initMenu` | a button next to a list with no open/close, outside-click, or Escape |
614
+ | Toast | `toast()` | nothing — it is imperative-only |
615
+
616
+ One cross-cutting guard, not tied to a single component:
617
+
618
+ | Concern | Behavior | With the behavior absent |
619
+ | --- | --- | --- |
620
+ | `aria-disabled="true"` controls | `initDisabledGuard` | dimmed + pointer-inert via CSS, but still **keyboard**-activatable on Enter/Space (native `disabled` is already fully inert) |
621
+
622
+ Rule of thumb: if a component needs ARIA-state sync, focus management, a keyboard
623
+ model, or persisted/dynamic state, it is behavior-required — that is the exact
624
+ boundary of what CSS alone cannot do.
625
+
385
626
  ## When to add a behavior
386
627
 
387
628
  The CSS is the framework; `@ponchia/ui/behaviors` is the *sanctioned*
388
629
  home for the little JS that genuinely needs scripting (theme persistence,
389
- disclosure, dialog glue, toast, combobox, form-validation, table-sort).
390
- Reach for it instead of reimplementing — every initializer is SSR-safe,
391
- idempotent, and returns a cleanup. If you find yourself writing focus
630
+ disclosure, dialog glue, modal focus-trap, toast, combobox, form-validation,
631
+ table-sort). Reach for it instead of reimplementing — every initializer is
632
+ SSR-safe, idempotent, and returns a cleanup. If you find yourself writing focus
392
633
  management or `aria-expanded` toggling by hand, there is probably already
393
634
  a behavior for it.
394
635
 
package/docs/vega.md CHANGED
@@ -65,8 +65,9 @@ vega-embed 7), so don't mix a Vega-Lite 6 with a Vega 5 runtime:
65
65
  <script src="https://cdn.jsdelivr.net/npm/vega-lite@6.4.3/build/vega-lite.min.js"></script>
66
66
  <script src="https://cdn.jsdelivr.net/npm/vega-embed@7.1.0/build/vega-embed.min.js"></script>
67
67
  <script>
68
- // INLINE the config (copy the object for your theme from @ponchia/ui/vega.json).
69
- // This is the only path that also works from a file:// report — see below.
68
+ // INLINE the config generate the paste-ready literal with
69
+ // `npm run emit:theme vega light` (mirrors the annotations author-time-copy
70
+ // pattern). This is the only path that also works from a file:// report.
70
71
  const brontoLight = {
71
72
  /* …paste tokens/vega.json → light here… */
72
73
  };
@@ -78,12 +79,30 @@ vega-embed 7), so don't mix a Vega-Lite 6 with a Vega 5 runtime:
78
79
  > `import` the `@ponchia/ui/vega` module **nor** `fetch('…/vega.json')` — the
79
80
  > browser blocks both across the `null`/file origin (CORS). So for a
80
81
  > double-clickable or PDF-bound report, **inline the resolved config object**
81
- > (as above) rather than fetching it. Over an `http(s)` origin (a dev server, a
82
+ > (as above) rather than fetching it. Generate the paste-ready, sentinel-tagged
83
+ > literal with `npm run emit:theme vega light` (or `dark`); re-running
84
+ > `npm run emit:theme:check <file>` re-derives every tagged block and fails if a
85
+ > token change has left an inlined copy stale. Over an `http(s)` origin (a dev server, a
82
86
  > static host, a bundler), the `import { brontoVegaConfig }` form and a
83
87
  > `fetch('https://cdn.jsdelivr.net/npm/@ponchia/ui@VERSION/tokens/vega.json')`
84
88
  > both work — pin the package version in the URL, since the unversioned latest
85
89
  > may predate this target.
86
90
 
91
+ **Over `http(s)`, skip the inline copy — import the helper as an ES module.**
92
+ `tokens/vega.js` (the `@ponchia/ui/vega` entry) has **zero dependencies**, so it
93
+ loads straight from a CDN as a browser ES module with no bundler and no
94
+ import-map. You get `brontoVegaConfig(theme)` itself (live theme switching), not
95
+ a frozen object to keep in sync. Pin the package version; this needs a real
96
+ origin (it does **not** work from `file://` — use the inline form above there):
97
+
98
+ ```html
99
+ <script type="module">
100
+ import { brontoVegaConfig } from 'https://cdn.jsdelivr.net/npm/@ponchia/ui@VERSION/tokens/vega.js';
101
+ // vegaEmbed loaded from its UMD bundle above (window.vegaEmbed), or import it as ESM too.
102
+ vegaEmbed('#chart', spec, { config: brontoVegaConfig('light'), renderer: 'svg', actions: false });
103
+ </script>
104
+ ```
105
+
87
106
  For a build step or non-JS host, read `@ponchia/ui/vega.json` directly
88
107
  (`{ light, dark }`, each a ready Vega-Lite `config`).
89
108
 
@@ -8,7 +8,9 @@ export type GlyphName =
8
8
  | 'arrow-left'
9
9
  | 'arrow-right'
10
10
  | 'arrow-up'
11
+ | 'bar-chart'
11
12
  | 'bell'
13
+ | 'calendar'
12
14
  | 'check'
13
15
  | 'check-circle'
14
16
  | 'chevron-down'
@@ -18,14 +20,30 @@ export type GlyphName =
18
20
  | 'circle'
19
21
  | 'clock'
20
22
  | 'close'
23
+ | 'colon'
24
+ | 'comma'
25
+ | 'copy'
26
+ | 'database'
27
+ | 'digit-0'
28
+ | 'digit-1'
29
+ | 'digit-2'
30
+ | 'digit-3'
31
+ | 'digit-4'
32
+ | 'digit-5'
33
+ | 'digit-6'
34
+ | 'digit-7'
35
+ | 'digit-8'
36
+ | 'digit-9'
21
37
  | 'download'
22
38
  | 'edit'
23
39
  | 'eye'
24
40
  | 'eye-off'
25
41
  | 'file'
42
+ | 'filter'
26
43
  | 'folder'
27
44
  | 'gear'
28
45
  | 'grid'
46
+ | 'hash'
29
47
  | 'heart'
30
48
  | 'home'
31
49
  | 'info'
@@ -39,11 +57,16 @@ export type GlyphName =
39
57
  | 'more-horizontal'
40
58
  | 'more-vertical'
41
59
  | 'pause'
60
+ | 'percent'
61
+ | 'period'
42
62
  | 'play'
43
63
  | 'plus'
44
64
  | 'plus-circle'
45
65
  | 'refresh'
46
66
  | 'search'
67
+ | 'share'
68
+ | 'sliders'
69
+ | 'sort'
47
70
  | 'spark'
48
71
  | 'star'
49
72
  | 'sun'
@@ -111,5 +134,43 @@ export declare function glyph(name: GlyphNameInput): Glyph | undefined;
111
134
  /** 256 cell descriptors (row-major), or `[]` if unknown. */
112
135
  export declare function glyphCells(name: GlyphNameInput): GlyphCell[];
113
136
 
137
+ /** The CSS `mask-image` `url()` for a glyph (the `--icon-mask` value on a
138
+ * `.ui-icon`), or `''` if unknown. Single-tone. */
139
+ export declare function glyphMask(name: GlyphNameInput): string;
140
+
114
141
  /** A full `.ui-dotmatrix` HTML string for a glyph (`''` if unknown). */
115
142
  export declare function renderGlyph(name: GlyphNameInput, options?: RenderGlyphOptions): string;
143
+
144
+ /** Hand-curated intent→glyph search aliases (e.g. `trash`→`delete`/`remove`).
145
+ * Keys are real glyph names; values are extra search terms. */
146
+ export declare const GLYPH_TAGS: Readonly<Partial<Record<GlyphName, readonly string[]>>>;
147
+
148
+ /** Glyph names whose name OR a search alias contains `query` (case-insensitive),
149
+ * sorted. `findGlyphs('')` returns every name. */
150
+ export declare function findGlyphs(query: string): GlyphName[];
151
+
152
+ /** Options for renderReadout. The per-glyph fields pass through to renderGlyph
153
+ * for each character; `gap` sets `--readout-gap` on the row. */
154
+ export interface RenderReadoutOptions {
155
+ /** Accessible name for the whole readout (defaults to the raw text). The dot
156
+ * digits are decorative; this carries the real value. */
157
+ label?: string;
158
+ /** CSS length between characters (sets `--readout-gap`; sanitized). */
159
+ gap?: string;
160
+ /** Render each character as square gapless pixels (legible small). */
161
+ solid?: boolean;
162
+ /** Show the unlit panel dots behind each character (default true). */
163
+ grid?: boolean;
164
+ /** Decorative per-character animation (reduced-motion-safe). */
165
+ anim?: 'reveal' | 'pulse';
166
+ /** CSS length for one dot of each character (sets `--dotmatrix-dot`). */
167
+ dot?: string;
168
+ /** `'mask'` renders each character as a single `.ui-icon` node (lightest). */
169
+ render?: 'mask';
170
+ /** With `render: 'mask'`, the per-character icon size. */
171
+ size?: string;
172
+ }
173
+
174
+ /** A row of dot-matrix glyphs for a numeric string (digits + `: , . % - +` and
175
+ * space) — the big Nothing-style readout. `''` for empty input. */
176
+ export declare function renderReadout(text: string, options?: RenderReadoutOptions): string;