@ponchia/ui 0.5.0 → 0.6.3

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 (196) hide show
  1. package/CHANGELOG.md +386 -4
  2. package/MIGRATIONS.json +14 -0
  3. package/README.md +29 -6
  4. package/annotations/index.d.ts +398 -276
  5. package/annotations/index.d.ts.map +1 -0
  6. package/annotations/index.js +350 -77
  7. package/behaviors/carousel.d.ts +28 -0
  8. package/behaviors/carousel.d.ts.map +1 -0
  9. package/behaviors/carousel.js +20 -16
  10. package/behaviors/combobox.d.ts +40 -0
  11. package/behaviors/combobox.d.ts.map +1 -0
  12. package/behaviors/combobox.js +111 -29
  13. package/behaviors/command.d.ts +41 -0
  14. package/behaviors/command.d.ts.map +1 -0
  15. package/behaviors/command.js +27 -15
  16. package/behaviors/connectors.d.ts +17 -0
  17. package/behaviors/connectors.d.ts.map +1 -0
  18. package/behaviors/connectors.js +7 -5
  19. package/behaviors/crosshair.d.ts +42 -0
  20. package/behaviors/crosshair.d.ts.map +1 -0
  21. package/behaviors/crosshair.js +23 -6
  22. package/behaviors/dialog.d.ts +20 -0
  23. package/behaviors/dialog.d.ts.map +1 -0
  24. package/behaviors/dialog.js +6 -2
  25. package/behaviors/disclosure.d.ts +10 -0
  26. package/behaviors/disclosure.d.ts.map +1 -0
  27. package/behaviors/disclosure.js +6 -2
  28. package/behaviors/dismissible.d.ts +10 -0
  29. package/behaviors/dismissible.d.ts.map +1 -0
  30. package/behaviors/dismissible.js +6 -2
  31. package/behaviors/forms.d.ts +27 -0
  32. package/behaviors/forms.d.ts.map +1 -0
  33. package/behaviors/forms.js +54 -13
  34. package/behaviors/glyph.d.ts +14 -0
  35. package/behaviors/glyph.d.ts.map +1 -0
  36. package/behaviors/glyph.js +28 -5
  37. package/behaviors/index.d.ts +31 -237
  38. package/behaviors/index.d.ts.map +1 -0
  39. package/behaviors/index.js +17 -0
  40. package/behaviors/inert.d.ts +20 -0
  41. package/behaviors/inert.d.ts.map +1 -0
  42. package/behaviors/inert.js +46 -0
  43. package/behaviors/internal.d.ts +25 -0
  44. package/behaviors/internal.d.ts.map +1 -0
  45. package/behaviors/internal.js +77 -1
  46. package/behaviors/legend.d.ts +35 -0
  47. package/behaviors/legend.d.ts.map +1 -0
  48. package/behaviors/legend.js +32 -2
  49. package/behaviors/menu.d.ts +16 -0
  50. package/behaviors/menu.d.ts.map +1 -0
  51. package/behaviors/menu.js +6 -2
  52. package/behaviors/modal.d.ts +41 -0
  53. package/behaviors/modal.d.ts.map +1 -0
  54. package/behaviors/modal.js +124 -0
  55. package/behaviors/popover.d.ts +28 -0
  56. package/behaviors/popover.d.ts.map +1 -0
  57. package/behaviors/popover.js +78 -7
  58. package/behaviors/spotlight.d.ts +17 -0
  59. package/behaviors/spotlight.d.ts.map +1 -0
  60. package/behaviors/spotlight.js +7 -5
  61. package/behaviors/table.d.ts +36 -0
  62. package/behaviors/table.d.ts.map +1 -0
  63. package/behaviors/table.js +84 -17
  64. package/behaviors/tabs.d.ts +20 -0
  65. package/behaviors/tabs.d.ts.map +1 -0
  66. package/behaviors/tabs.js +17 -14
  67. package/behaviors/theme.d.ts +54 -0
  68. package/behaviors/theme.d.ts.map +1 -0
  69. package/behaviors/theme.js +22 -3
  70. package/behaviors/toast.d.ts +49 -0
  71. package/behaviors/toast.d.ts.map +1 -0
  72. package/behaviors/toast.js +47 -3
  73. package/classes/classes.json +2527 -0
  74. package/classes/index.d.ts +134 -15
  75. package/classes/index.js +280 -80
  76. package/classes/vscode.css-custom-data.json +12 -0
  77. package/connectors/index.d.ts +201 -69
  78. package/connectors/index.d.ts.map +1 -0
  79. package/connectors/index.js +142 -25
  80. package/css/app.css +69 -13
  81. package/css/base.css +15 -10
  82. package/css/bullet.css +108 -0
  83. package/css/code.css +98 -0
  84. package/css/connectors.css +17 -0
  85. package/css/content.css +22 -3
  86. package/css/crosshair.css +7 -7
  87. package/css/dataviz.css +5 -1
  88. package/css/diff.css +153 -0
  89. package/css/disclosure.css +53 -7
  90. package/css/dots.css +94 -7
  91. package/css/feedback.css +97 -7
  92. package/css/forms.css +113 -4
  93. package/css/legend.css +16 -9
  94. package/css/marks.css +38 -8
  95. package/css/motion.css +98 -53
  96. package/css/navigation.css +7 -0
  97. package/css/overlay.css +90 -3
  98. package/css/primitives.css +158 -13
  99. package/css/report.css +73 -56
  100. package/css/sidenote.css +67 -0
  101. package/css/site.css +16 -2
  102. package/css/sources.css +43 -1
  103. package/css/spark.css +62 -0
  104. package/css/spotlight.css +1 -1
  105. package/css/table.css +9 -2
  106. package/css/term.css +110 -0
  107. package/css/textref.css +63 -0
  108. package/css/toc.css +91 -0
  109. package/css/tokens.css +49 -1
  110. package/css/tree.css +134 -0
  111. package/css/workbench.css +1 -1
  112. package/dist/bronto.css +1 -1
  113. package/dist/css/analytical.css +1 -1
  114. package/dist/css/app.css +1 -1
  115. package/dist/css/base.css +1 -1
  116. package/dist/css/bullet.css +1 -0
  117. package/dist/css/code.css +1 -0
  118. package/dist/css/connectors.css +1 -1
  119. package/dist/css/content.css +1 -1
  120. package/dist/css/crosshair.css +1 -1
  121. package/dist/css/diff.css +1 -0
  122. package/dist/css/disclosure.css +1 -1
  123. package/dist/css/dots.css +1 -1
  124. package/dist/css/feedback.css +1 -1
  125. package/dist/css/forms.css +1 -1
  126. package/dist/css/legend.css +1 -1
  127. package/dist/css/marks.css +1 -1
  128. package/dist/css/motion.css +1 -1
  129. package/dist/css/navigation.css +1 -1
  130. package/dist/css/overlay.css +1 -1
  131. package/dist/css/primitives.css +1 -1
  132. package/dist/css/report.css +1 -1
  133. package/dist/css/sidenote.css +1 -0
  134. package/dist/css/site.css +1 -1
  135. package/dist/css/sources.css +1 -1
  136. package/dist/css/spark.css +1 -0
  137. package/dist/css/spotlight.css +1 -1
  138. package/dist/css/table.css +1 -1
  139. package/dist/css/term.css +1 -0
  140. package/dist/css/textref.css +1 -0
  141. package/dist/css/toc.css +1 -0
  142. package/dist/css/tokens.css +1 -1
  143. package/dist/css/tree.css +1 -0
  144. package/dist/css/workbench.css +1 -1
  145. package/docs/adr/0003-theme-model.md +1 -1
  146. package/docs/annotations.md +133 -14
  147. package/docs/architecture.md +49 -6
  148. package/docs/bullet.md +78 -0
  149. package/docs/code.md +76 -0
  150. package/docs/contrast.md +116 -92
  151. package/docs/d2.md +196 -0
  152. package/docs/diff.md +146 -0
  153. package/docs/legends.md +23 -3
  154. package/docs/marks.md +9 -2
  155. package/docs/mermaid.md +169 -0
  156. package/docs/reference.md +201 -26
  157. package/docs/reporting.md +416 -57
  158. package/docs/sidenote.md +64 -0
  159. package/docs/sources.md +27 -0
  160. package/docs/spark.md +78 -0
  161. package/docs/stability.md +10 -2
  162. package/docs/term.md +81 -0
  163. package/docs/textref.md +78 -0
  164. package/docs/theming.md +44 -5
  165. package/docs/toc.md +83 -0
  166. package/docs/tree.md +74 -0
  167. package/docs/usage.md +354 -16
  168. package/docs/vega.md +244 -0
  169. package/docs/workbench.md +7 -1
  170. package/glyphs/glyphs.js +13 -5
  171. package/llms.txt +285 -14
  172. package/package.json +95 -17
  173. package/qwik/index.d.ts +44 -59
  174. package/qwik/index.d.ts.map +1 -0
  175. package/qwik/index.js +65 -3
  176. package/react/index.d.ts +41 -61
  177. package/react/index.d.ts.map +1 -0
  178. package/react/index.js +63 -3
  179. package/solid/index.d.ts +68 -61
  180. package/solid/index.d.ts.map +1 -0
  181. package/solid/index.js +66 -3
  182. package/tokens/d2.d.ts +38 -0
  183. package/tokens/d2.js +71 -0
  184. package/tokens/d2.json +43 -0
  185. package/tokens/index.d.ts +5 -5
  186. package/tokens/index.js +15 -1
  187. package/tokens/index.json +9 -0
  188. package/tokens/mermaid.d.ts +23 -0
  189. package/tokens/mermaid.js +181 -0
  190. package/tokens/mermaid.json +163 -0
  191. package/tokens/resolved.json +45 -1
  192. package/tokens/skins.js +3 -2
  193. package/tokens/tokens.dtcg.json +26 -0
  194. package/tokens/vega.d.ts +34 -0
  195. package/tokens/vega.js +155 -0
  196. package/tokens/vega.json +179 -0
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,9 +43,38 @@ 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.)
52
+
53
+ **Tone vocabulary varies by family — by design.** Colour is rationed, so not
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
67
+ [`@ponchia/ui/classes.json`](../classes/classes.json) — read it rather than
68
+ extrapolating a tone you saw on one component onto another.
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.
41
78
 
42
79
  ## Numbers: `ui-num` vs the table state classes
43
80
 
@@ -49,6 +86,21 @@ Rule of thumb: state → dot, classification → badge, user-controlled value
49
86
  freed from the table. Do not hand-roll right-align + `text-green`;
50
87
  that's the duplication `ui-num` exists to kill.
51
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
+
52
104
  ## Prose vs primitives, and prose inside a card
53
105
 
54
106
  - `ui-prose` styles **raw, unclassed semantic HTML** (MDX / CMS / LLM
@@ -60,6 +112,42 @@ Rule of thumb: state → dot, classification → badge, user-controlled value
60
112
  `.ui-card` itself, so card padding/border stays the card's and prose
61
113
  rhythm stays the content's. One responsibility per element.
62
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
+
63
151
  ## Static reports
64
152
 
65
153
  Use the opt-in `@ponchia/ui/css/report.css` layer for static, PDF-first
@@ -70,10 +158,11 @@ for narrative body content you do not fully control.
70
158
 
71
159
  Do not turn every report block into a card. Use `ui-report__summary`,
72
160
  `ui-report__finding`, and `ui-report__evidence` for document structure; use
73
- `ui-card` only when the block is genuinely a repeated card item. Charts use
74
- the report `ui-chart*` wrappers plus the opt-in data-viz tokens; simple static
75
- bar charts can use `ui-chart__plot`, `ui-chart__bar`, and `ui-chart__fill`.
76
- Always include a caption and fallback data. Full LLM/static report cookbook:
161
+ `ui-card` only when the block is genuinely a repeated card item. bronto ships
162
+ **no chart component**: for a chart, theme Vega-Lite (`@ponchia/ui/vega`, see
163
+ [vega.md](vega.md)) or hand-author a token-themed inline SVG painted from the
164
+ data-viz palette tokens. Always wrap it in a `ui-report__figure` with a caption,
165
+ a `.ui-legend` key, and fallback data. Full LLM/static report cookbook:
77
166
  [reporting.md](reporting.md).
78
167
 
79
168
  ## Buttons: variant and size
@@ -102,6 +191,13 @@ destructive action).
102
191
  | toast | transient, out-of-flow, system-initiated. Danger toasts route to an assertive live region; everything else polite. |
103
192
  | tooltip | supplemental, hover/focus, never essential info (it's not announced reliably; don't hide required content in it). |
104
193
 
194
+ The CSS `ui-tooltip` is hover/focus-only and CSS can't wire it to assistive
195
+ tech for you — associate the bubble with its trigger yourself, or it conveys
196
+ nothing to a screen reader: give `.ui-tooltip__bubble` an `id`, point the
197
+ trigger's `aria-describedby` at it, and keep the bubble `role="tooltip"`. For a
198
+ tooltip that must stay visible near a viewport edge or inside a scroll
199
+ container, use `initPopover` (a real focus-managed panel) instead.
200
+
105
201
  ## Meter vs progress
106
202
 
107
203
  Both are a thin horizontal bar; they mean different things.
@@ -110,10 +206,22 @@ Both are a thin horizontal bar; they mean different things.
110
206
  indeterminate (`ui-progress--indeterminate`). The fill is always accent.
111
207
  - **`ui-meter`** — a *measured static value*: coverage, disk, capacity, a
112
208
  KPI against a target. Never indeterminate. Tone the fill by threshold
113
- (`ui.meter({ tone })` → accent/success/warning/danger); the unset
114
- default is neutral. Drive the width with the shared `--value` knob
115
- (`style="--value: 72"`, 0–100) and author `role="meter"` +
116
- `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()}>`.
117
225
 
118
226
  Rule of thumb: *something is happening* → progress; *something measures
119
227
  this much* → meter.
@@ -123,7 +231,10 @@ this much* → meter.
123
231
  - **`ui-steps`** — a stepper for a multi-step flow. Use an `<ol>`. State is
124
232
  ARIA-driven (the framework rule): the active step is `aria-current="step"`
125
233
  (no class); completed steps take `ui-steps__item--done`. Markers are
126
- 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.
127
238
  - **`ui-timeline`** — a vertical event list on a hairline spine (`<ol>` of
128
239
  `ui-timeline__item`, optional `ui-timeline__time`). `aria-current` on an
129
240
  item marks the live/most-recent event.
@@ -135,14 +246,116 @@ this much* → meter.
135
246
  (`aria-hidden`) and the input keeps its full width. Don't hand-roll an
136
247
  absolute overlay.
137
248
 
249
+ ## Navigation: the landmarks and names the classes don't carry
250
+
251
+ The navigation classes are styling only — the ARIA scaffolding is yours, and
252
+ without it these widgets are unlabelled or unannounced:
253
+
254
+ - **`ui-breadcrumb`** — wrap it in `<nav aria-label="Breadcrumb">` and mark the
255
+ last (current) crumb with `aria-current="page"`.
256
+ - **`ui-pagination`** — wrap it in `<nav aria-label="Pagination">`; give the
257
+ current page `aria-current="page"`; label icon-only prev/next controls
258
+ (`aria-label="Previous page"`). Disable a control with native `disabled`
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).
264
+ - **`ui-tabs`** — `initTabs` adds the full APG wiring (roles, roving tabindex,
265
+ `aria-selected`, panel `hidden`, focusable panel). If you wire tabs yourself,
266
+ name the `ui-tabs__list` (`role="tablist"` + an `aria-label`) and pair each
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">`).
273
+ - **`ui-sitenav` / `ui-app-nav`** — signal the current link with
274
+ `aria-current="page"` (both honour it; `ui-app-nav` also accepts the
275
+ visual-only `.is-active`, but prefer `aria-current`).
276
+ - **`ui-skiplink`** — keep it the first focusable element and point its `href`
277
+ at the `id` of your main landmark.
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
+
329
+ ## Avatar: it's an unlabelled blob until you name it
330
+
331
+ `ui-avatar` is a presentation box. Give it an accessible name yourself: an
332
+ image avatar needs real `alt` text (`alt=""` only if it's purely decorative
333
+ beside a visible name); an initials avatar needs an accessible name on the
334
+ element (e.g. `aria-label="Ada Lovelace"`) because the initials alone don't
335
+ convey identity to AT. Keep initials to ~2 characters — the box is
336
+ `overflow: hidden` and silently clips a third.
337
+
138
338
  ## Modal: native `<dialog>` vs `is-open`
139
339
 
140
340
  Prefer the **native `<dialog>`** path — you get top-layer, backdrop and
141
- focus-trap free. Only use `ui-modal.is-open` (`ui.modal({ open: true })`)
142
- when a portal/React modal genuinely can't be a `<dialog>`; then the
143
- 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
144
351
  that enters from an edge — same rule.
145
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
+
146
359
  ## Carousel & lightbox: one primitive, two skins
147
360
 
148
361
  `ui-carousel` is a scroll-snap track of `__slide`s wired by `initCarousel`
@@ -181,6 +394,13 @@ that fuses the cells into a square, gapless pixel glyph that stays crisp and
181
394
  legible down to **~16px** — so the same set doubles as real inline UI icons,
182
395
  not just decoration. (Below the dot fragments into dot-soup; solid does not.)
183
396
 
397
+ One caveat on ink: `solid` cells inherit the dot palette (`--field-dot-hot`,
398
+ ~40% alpha), so at small sizes a solid glyph reads as a soft grey, not full
399
+ ink. When you want a crisp, full-strength small icon (toolbar, button affordance),
400
+ use the one-node mask renderer instead — `renderGlyph(name, { render: 'mask' })`
401
+ paints the glyph in `currentColor` on a `.ui-icon`, so it tracks text colour at
402
+ any size.
403
+
184
404
  `renderGlyph(name, { label })` returns an SSR-safe string: decorative
185
405
  (`aria-hidden`) by default, or `role="img"` + `aria-label` when you pass a
186
406
  `label` — which is how it conveys meaning to assistive tech. Prefer the
@@ -285,13 +505,131 @@ authoring engine.
285
505
  - Annotation text must be visible or represented in the figure caption, SVG
286
506
  `<desc>`, or fallback table. Full detail in [annotations.md](annotations.md).
287
507
 
508
+ ## Forms: the contracts the markup alone won't tell you
509
+
510
+ - **Disabled — pick one mechanism.** Use the native `disabled` attribute for a
511
+ genuinely inert control (`ui-input`, `ui-select`, `ui-textarea`, `ui-switch`
512
+ /`ui-check`/`ui-segmented` wrapping a native input, `ui-range`, `ui-file`,
513
+ `ui-button`): the browser greys it, blocks activation, and skips it in tab
514
+ order, and bronto styles the disabled cue. Use `aria-disabled="true"` **only**
515
+ when the control must stay focusable/announced — bronto then adds
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.
526
+ - **Combobox** (`data-bronto-combobox`) reads its options from the DOM at
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).
535
+ - **Validation** is opt-in via `data-bronto-validate` on the form plus
536
+ `initFormValidation()`; it surfaces messages into a `ui-error-summary` you
537
+ provide. The summary's title is the legible sans, not the display face — it's
538
+ meant to be read.
539
+
540
+ ## Reveal: `ui-reveal` needs JS, `ui-scroll-reveal` doesn't
541
+
542
+ `ui-scroll-reveal` is scroll-driven and **zero-JS** — reach for it in a static
543
+ or LLM-authored report. `ui-reveal` is the JS variant: it starts hidden and you
544
+ toggle `is-visible` (e.g. from an `IntersectionObserver` you own) to play it in.
545
+ With scripting disabled it degrades to fully visible, but if scripting is *on*
546
+ and nothing toggles `is-visible`, the content stays hidden — so only use
547
+ `ui-reveal` when you are wiring that toggle.
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
+
559
+ ## Loading affordances need a role you supply
560
+
561
+ `ui-spinner`, `ui-dotspinner`, `ui-skeleton`, and an indeterminate `ui-progress`
562
+ are decorative animations — bronto can't know their semantics. Give the busy
563
+ region `aria-busy="true"` (or `role="status"` with an `aria-live` text label like
564
+ "Loading…"), and mark a purely decorative spinner `aria-hidden="true"`. Without
565
+ one of these a screen reader announces nothing while the user waits.
566
+
567
+ ## Popover: prefer the native top layer
568
+
569
+ `initPopover()` shows a `.ui-popover` in the browser **top layer** when the panel
570
+ carries the native `popover` attribute (never clipped by `overflow`/stacking);
571
+ without it, it falls back to an `is-open` class that a clipping ancestor can cut
572
+ off. Add `popover` to the panel for the robust path — the `is-open` form is a
573
+ fallback, not the default to copy.
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
+
288
626
  ## When to add a behavior
289
627
 
290
628
  The CSS is the framework; `@ponchia/ui/behaviors` is the *sanctioned*
291
629
  home for the little JS that genuinely needs scripting (theme persistence,
292
- disclosure, dialog glue, toast, combobox, form-validation, table-sort).
293
- Reach for it instead of reimplementing — every initializer is SSR-safe,
294
- 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
295
633
  management or `aria-expanded` toggling by hand, there is probably already
296
634
  a behavior for it.
297
635