@ponchia/ui 0.6.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 (156) hide show
  1. package/CHANGELOG.md +64 -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 +14 -0
  33. package/behaviors/glyph.d.ts.map +1 -0
  34. package/behaviors/glyph.js +24 -0
  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 +683 -13
  72. package/classes/index.d.ts +106 -2
  73. package/classes/index.js +249 -65
  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 +37 -7
  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 +40 -0
  92. package/css/sidenote.css +67 -0
  93. package/css/spark.css +62 -0
  94. package/css/table.css +9 -2
  95. package/css/term.css +110 -0
  96. package/css/textref.css +63 -0
  97. package/css/toc.css +91 -0
  98. package/css/tokens.css +14 -1
  99. package/css/tree.css +134 -0
  100. package/dist/bronto.css +1 -1
  101. package/dist/css/analytical.css +1 -1
  102. package/dist/css/app.css +1 -1
  103. package/dist/css/bullet.css +1 -0
  104. package/dist/css/code.css +1 -0
  105. package/dist/css/content.css +1 -1
  106. package/dist/css/crosshair.css +1 -1
  107. package/dist/css/diff.css +1 -0
  108. package/dist/css/disclosure.css +1 -1
  109. package/dist/css/dots.css +1 -1
  110. package/dist/css/feedback.css +1 -1
  111. package/dist/css/forms.css +1 -1
  112. package/dist/css/legend.css +1 -1
  113. package/dist/css/motion.css +1 -1
  114. package/dist/css/overlay.css +1 -1
  115. package/dist/css/primitives.css +1 -1
  116. package/dist/css/report.css +1 -1
  117. package/dist/css/sidenote.css +1 -0
  118. package/dist/css/spark.css +1 -0
  119. package/dist/css/table.css +1 -1
  120. package/dist/css/term.css +1 -0
  121. package/dist/css/textref.css +1 -0
  122. package/dist/css/toc.css +1 -0
  123. package/dist/css/tokens.css +1 -1
  124. package/dist/css/tree.css +1 -0
  125. package/docs/annotations.md +39 -0
  126. package/docs/architecture.md +2 -3
  127. package/docs/bullet.md +78 -0
  128. package/docs/code.md +76 -0
  129. package/docs/d2.md +4 -3
  130. package/docs/diff.md +146 -0
  131. package/docs/legends.md +8 -4
  132. package/docs/mermaid.md +21 -4
  133. package/docs/reference.md +127 -8
  134. package/docs/reporting.md +35 -14
  135. package/docs/sidenote.md +64 -0
  136. package/docs/spark.md +78 -0
  137. package/docs/stability.md +1 -0
  138. package/docs/term.md +81 -0
  139. package/docs/textref.md +78 -0
  140. package/docs/theming.md +44 -5
  141. package/docs/toc.md +83 -0
  142. package/docs/tree.md +74 -0
  143. package/docs/usage.md +264 -23
  144. package/docs/vega.md +22 -3
  145. package/glyphs/glyphs.js +7 -1
  146. package/llms.txt +159 -13
  147. package/package.json +47 -7
  148. package/qwik/index.d.ts +4 -2
  149. package/qwik/index.d.ts.map +1 -1
  150. package/qwik/index.js +10 -0
  151. package/react/index.d.ts +4 -2
  152. package/react/index.d.ts.map +1 -1
  153. package/react/index.js +6 -0
  154. package/solid/index.d.ts +6 -2
  155. package/solid/index.d.ts.map +1 -1
  156. package/solid/index.js +6 -0
package/css/forms.css CHANGED
@@ -77,6 +77,16 @@
77
77
  opacity: 0.5;
78
78
  }
79
79
 
80
+ /* Read-only is editable-looking but not editable; give it a distinct, quieter
81
+ cue (muted fill + default cursor) so it doesn't read as a live field. Not
82
+ disabled — value still submits and the field stays focusable/selectable.
83
+ (component audit C28.) */
84
+ .ui-input:read-only:not(:disabled),
85
+ .ui-textarea:read-only:not(:disabled) {
86
+ background: var(--panel-soft);
87
+ cursor: default;
88
+ }
89
+
80
90
  /* Disabled affordance parity. The text inputs above style :disabled directly;
81
91
  the controls that WRAP a native input (switch/check/segmented) showed no
82
92
  disabled cue and their label kept cursor:pointer — a lie. Mirror the cue via
@@ -94,6 +104,8 @@
94
104
  /* Keep autofilled fields on-theme — the UA's yellow fill otherwise paints over
95
105
  the monochrome surface and breaks the contrast story. (forms review C24.) */
96
106
  .ui-input:autofill,
107
+ .ui-select:autofill,
108
+ .ui-textarea:autofill,
97
109
  .ui-search input:autofill {
98
110
  -webkit-text-fill-color: var(--text);
99
111
  box-shadow: inset 0 0 0 100rem var(--bg-elevated);
@@ -106,6 +118,58 @@
106
118
  border-color: var(--danger);
107
119
  }
108
120
 
121
+ /* Wrapper controls (switch / check / segmented) hide their native <input>, so
122
+ the `[aria-invalid]` the validator sets on it paints nothing — a sighted,
123
+ non-AT user couldn't see the error (WCAG 1.4.1). Mirror the invalid cue onto
124
+ the visible surface via :has(), the same way the disabled cue is mirrored.
125
+ (component audit C7.) */
126
+ .ui-check:has(input[aria-invalid='true']) input {
127
+ outline: 2px solid var(--danger);
128
+ outline-offset: 1px;
129
+ }
130
+
131
+ .ui-switch:has(input[aria-invalid='true']) .ui-switch__track {
132
+ border-color: var(--danger);
133
+ }
134
+
135
+ .ui-segmented:has(input[aria-invalid='true']) {
136
+ border-color: var(--danger);
137
+ }
138
+
139
+ /* Forced colours flatten `var(--danger)` to a system colour identical to the
140
+ resting border, so the invalid border becomes indistinguishable from a valid
141
+ one and sighted HCM users lose the only error cue (WCAG 1.4.1). The switch got
142
+ a forced-colors block; the error family did not. Re-assert the state on a
143
+ NON-colour channel — a thicker, doubled border — that survives HCM, and prefix
144
+ the error hint with a glyph so the message itself carries the error.
145
+ (component audit C5.) */
146
+ @media (forced-colors: active) {
147
+ .ui-input[aria-invalid='true'],
148
+ .ui-select[aria-invalid='true'],
149
+ .ui-textarea[aria-invalid='true'] {
150
+ border-style: double;
151
+ border-width: 3px;
152
+ }
153
+
154
+ /* Same NON-colour re-assertion for the wrapper controls (C7): a thicker,
155
+ doubled outline/border survives the HCM colour flattening. */
156
+ .ui-check:has(input[aria-invalid='true']) input {
157
+ outline-width: 3px;
158
+ outline-style: double;
159
+ }
160
+
161
+ .ui-switch:has(input[aria-invalid='true']) .ui-switch__track,
162
+ .ui-segmented:has(input[aria-invalid='true']) {
163
+ border-style: double;
164
+ border-width: 3px;
165
+ }
166
+
167
+ .ui-hint--error::before,
168
+ .ui-error-summary__title::before {
169
+ content: '⚠ ';
170
+ }
171
+ }
172
+
109
173
  .ui-hint {
110
174
  color: var(--text-dim);
111
175
  font-size: var(--text-2xs);
@@ -139,14 +203,18 @@
139
203
  border-end-end-radius: var(--radius-md);
140
204
  }
141
205
 
142
- .ui-input-group > .ui-input:focus-visible {
143
- z-index: 1; /* keep the focus ring above the adjacent addon border */
206
+ .ui-input-group > .ui-input:focus-visible,
207
+ .ui-input-group > .ui-select:focus-visible {
208
+ z-index: 1; /* keep the focus ring above the adjacent addon border (select too — audit C29) */
144
209
  }
145
210
 
146
211
  .ui-input-group__addon {
147
212
  align-items: center;
148
213
  background: var(--panel-soft);
149
- border: 1px solid var(--line);
214
+
215
+ /* Match the wrapped control's `--line-strong` border so the prefix/suffix seam
216
+ isn't a fainter cap than the field it abuts. (component audit C33.) */
217
+ border: 1px solid var(--line-strong);
150
218
  color: var(--text-dim);
151
219
  display: flex;
152
220
  font-size: var(--text-sm);
package/css/legend.css CHANGED
@@ -90,10 +90,13 @@
90
90
  inline-size: 1.1rem;
91
91
  }
92
92
 
93
- /* Glyph/symbol swatch — fill an `.ui-icon` mask with the series colour. */
93
+ /* Glyph/symbol swatch — fill an `.ui-icon` mask with the series colour. Match the
94
+ __swatch fallback chain exactly (--chart-color → --chart-1 → --accent): without
95
+ the --chart-1 layer, a symbol and a swatch for the SAME series diverge to two
96
+ colours whenever a theme overrides --chart-1. (audit C22.) */
94
97
  .ui-legend__symbol {
95
98
  block-size: 0.95rem;
96
- color: var(--chart-color, var(--accent));
99
+ color: var(--chart-color, var(--chart-1, var(--accent)));
97
100
  flex: 0 0 auto;
98
101
  inline-size: 0.95rem;
99
102
  }
package/css/motion.css CHANGED
@@ -52,6 +52,28 @@
52
52
  }
53
53
  }
54
54
 
55
+ /* Scroll-driven reveal (`.ui-scroll-reveal`). Unlike the time-based uiRise, a
56
+ scroll timeline can FREEZE part-way: an element near the document bottom can't
57
+ scroll far enough to finish its range, so a fade-to-opacity-1 would strand the
58
+ content permanently transparent. Reach full opacity early (35%) and hold it,
59
+ so even a partially-driven reveal is fully legible — only the last few px of
60
+ the rise are left unfinished. (component audit C9.) */
61
+ @keyframes uiScrollReveal {
62
+ 0% {
63
+ opacity: 0;
64
+ transform: translateY(10px);
65
+ }
66
+
67
+ 35% {
68
+ opacity: 1;
69
+ }
70
+
71
+ 100% {
72
+ opacity: 1;
73
+ transform: translateY(0);
74
+ }
75
+ }
76
+
55
77
  @keyframes uiDotIn {
56
78
  0% {
57
79
  opacity: 0;
@@ -158,10 +180,13 @@
158
180
  animation: uiMatrixReveal var(--duration-slow) var(--ease-out) both;
159
181
  }
160
182
 
161
- /* Stagger children: set --i on each child (or use nth-child cap). */
183
+ /* Stagger children: set --i on each child (or use nth-child cap). Cap the manual
184
+ --i path at index 6 (360ms) to match the .ui-stagger--auto ceiling — a long
185
+ list with --i:30 would otherwise hold the last child at opacity:0 for 1.8s
186
+ before popping in. (audit C32.) */
162
187
  .ui-stagger > * {
163
188
  animation: uiRise var(--duration-slow) var(--ease-spring) both;
164
- animation-delay: calc(var(--i, 0) * 60ms);
189
+ animation-delay: calc(min(var(--i, 0), 6) * 60ms);
165
190
  }
166
191
 
167
192
  .ui-stagger--auto > *:nth-child(1) {
@@ -193,11 +218,17 @@
193
218
  }
194
219
 
195
220
  /* Reveal-on-scroll: add .ui-reveal, then toggle .is-visible (e.g. from an
196
- IntersectionObserver you own) to play it in. For a ZERO-JS reveal, use
197
- `.ui-scroll-reveal` (scroll-driven, CSS-only) instead — that is the path for
198
- an LLM-authored or no-build report. The hidden initial state below is gated
199
- on `scripting: enabled`, so with scripting OFF the content is fully visible
200
- and never silently hidden behind a script that will never run. */
221
+ IntersectionObserver you own) to play it in.
222
+
223
+ `.ui-reveal` requires you to wire that observer. The hidden initial state is
224
+ gated only on `scripting: enabled` it can't detect whether an observer is
225
+ actually attached, so merely loading bronto.js (or any script) hides every
226
+ `.ui-reveal` until YOUR code adds `.is-visible`. If you add the class and never
227
+ wire the toggle, the content stays invisible. For an LLM-authored or no-build
228
+ report, DON'T use `.ui-reveal` — use `.ui-scroll-reveal` below (scroll-driven,
229
+ CSS-only, no observer, and it can never strand content invisible). With
230
+ scripting OFF, `.ui-reveal` content is fully visible and never silently hidden
231
+ behind a script that will never run. */
201
232
  @media (prefers-reduced-motion: no-preference) and (scripting: enabled) {
202
233
  .ui-reveal {
203
234
  opacity: 0;
@@ -246,10 +277,12 @@
246
277
 
247
278
  /* --- Scroll-driven (progressive enhancement) — the scroll/view timeline
248
279
  IS the engine, no JS. Everything is gated on `@supports
249
- (animation-timeline: …)` so engines without it (today, Firefox/Safari)
250
- keep the static end state, and on `prefers-reduced-motion: no-preference`
251
- (a scroll timeline ignores animation-duration, so the global reduced-motion
252
- reset below can't neutralise it — it must be gated here). --- */
280
+ (animation-timeline: …)` so an engine without it keeps the static end state
281
+ (as of 2026 Chrome 115+ and Safari 18.4+ drive scroll()/view(); Firefox is
282
+ the remaining holdout, still graceful via the @supports gate), and on
283
+ `prefers-reduced-motion: no-preference` (a scroll timeline ignores
284
+ animation-duration, so the global reduced-motion reset below can't neutralise
285
+ it — it must be gated here). --- */
253
286
 
254
287
  /* Reading-progress bar. Fixed hairline that fills with document scroll;
255
288
  unsupported → a static, empty (scaleX(0)) bar. Pair with role="progressbar"
@@ -270,6 +303,30 @@
270
303
  transform-origin: 100% 50%;
271
304
  }
272
305
 
306
+ /* Forced colours (HCM) flatten the spinner's accent top-border and its --line
307
+ rest to one system colour, so the ring looks uniform with no visible sweep,
308
+ and the scroll-progress bar can vanish into the canvas. Re-assert distinct
309
+ system colours so each keeps a visible channel; AT is already covered via
310
+ aria-busy / role="status" / role="progressbar". (component audit C35.) */
311
+ @media (forced-colors: active) {
312
+ .ui-spinner {
313
+ border-color: CanvasText;
314
+ border-block-start-color: Highlight;
315
+ }
316
+
317
+ .ui-scroll-progress {
318
+ background: Highlight;
319
+ }
320
+
321
+ /* HCM strips the shimmer gradient, leaving an invisible box where a loading
322
+ placeholder should be. Give it a system-colour border so the skeleton
323
+ still reads as present. Same decorative-loading family as the spinner +
324
+ scroll-progress above. (audit C15.) */
325
+ .ui-skeleton {
326
+ border: 1px solid CanvasText;
327
+ }
328
+ }
329
+
273
330
  @supports (animation-timeline: scroll()) {
274
331
  @media (prefers-reduced-motion: no-preference) {
275
332
  .ui-scroll-progress {
@@ -284,13 +341,21 @@
284
341
 
285
342
  /* Reveal-on-scroll with no JS and no IntersectionObserver: the element
286
343
  rises + fades as it scrolls into view. Unsupported → fully visible
287
- (the `uiRise` end state), same graceful default as `.ui-reveal`. */
344
+ (the keyframe end state), same graceful default as `.ui-reveal`.
345
+
346
+ The range is `entry 0% entry 100%`, NOT the old `cover 40%`: `cover` only
347
+ completes once the element has scrolled most of the way THROUGH the viewport,
348
+ which an element near the document bottom can never do — it froze part-way,
349
+ leaving a conclusion section permanently semi-transparent (C9). `entry`
350
+ completes the moment the element is fully in view, which any scroll-to-bottom
351
+ reaches. Paired with the early-opacity uiScrollReveal keyframe, an element
352
+ taller than the viewport is legible even if its range never fully completes. */
288
353
  @supports (animation-timeline: view()) {
289
354
  @media (prefers-reduced-motion: no-preference) {
290
355
  .ui-scroll-reveal {
291
- animation: uiRise linear both;
356
+ animation: uiScrollReveal linear both;
292
357
  animation-timeline: view();
293
- animation-range: entry 0% cover 40%;
358
+ animation-range: entry 0% entry 100%;
294
359
  }
295
360
  }
296
361
  }
package/css/overlay.css CHANGED
@@ -78,14 +78,25 @@ dialog.ui-modal[open]::backdrop {
78
78
 
79
79
  /* Controlled (non-<dialog>) usage. A portal/React modal that can't be a
80
80
  native <dialog> wears the same skin + open layout via `.is-open`.
81
- Backdrop, top-layer stacking and focus-trapping are then the
82
- consumer's responsibility (the native <dialog> path gets them free). */
81
+ Backdrop and top-layer stacking are then the consumer's responsibility
82
+ (the native <dialog> path gets them free); focus-trapping no longer is —
83
+ mark the overlay `data-bronto-modal` and run `initModal` for an
84
+ inert-based trap + focus-return + Escape signal. */
83
85
  .ui-modal.is-open {
84
86
  animation: uiToastIn var(--duration-base) var(--ease-spring) both;
85
87
  display: grid;
86
88
  grid-template-rows: auto 1fr auto;
87
89
  }
88
90
 
91
+ /* Background scroll-lock. A native <dialog> top layer does NOT stop the page
92
+ behind it scrolling, and the no-JS native path can't lock it in script — so
93
+ lock it in CSS, for both the native [open] dialog and the controlled .is-open
94
+ overlay (incl. the drawer modifier, same base). (component audit C13.) */
95
+ html:has(dialog.ui-modal[open]),
96
+ html:has(.ui-modal.is-open) {
97
+ overflow: hidden;
98
+ }
99
+
89
100
  .ui-modal__head {
90
101
  align-items: flex-start;
91
102
  border-block-end: 1px solid var(--line);
@@ -150,6 +161,21 @@ dialog.ui-modal[open]::backdrop {
150
161
  }
151
162
  }
152
163
 
164
+ /* Comfortable hit target on coarse pointers — this bespoke close button measured
165
+ ~26px on touch, below the 2.9rem floor the rest of the button family meets
166
+ (WCAG 2.5.8). Scoped to coarse so the fine-pointer (mouse) rendering, which
167
+ already clears 24×24, is unchanged; centre the glyph in the enlarged box.
168
+ (audit C3.) */
169
+ @media (pointer: coarse) {
170
+ .ui-modal__close {
171
+ align-items: center;
172
+ display: inline-flex;
173
+ justify-content: center;
174
+ min-block-size: 2.9rem;
175
+ min-inline-size: 2.9rem;
176
+ }
177
+ }
178
+
153
179
  /* --- Lightbox — a full-viewport <dialog> wrapping a .ui-carousel. The
154
180
  native <dialog> brings the top layer, focus-trap and Escape; initDialog
155
181
  adds open (`data-bronto-open`) + focus-return; initCarousel drives the
@@ -284,6 +310,25 @@ dialog.ui-modal[open]::backdrop {
284
310
  opacity: 1;
285
311
  }
286
312
 
313
+ /* Menu hover/focus paints var(--bg-accent), which HCM strips, so the hovered row
314
+ is indistinguishable from its siblings (focus keeps the UA ring; hover has no
315
+ such fallback). Re-assert a system Highlight like the combobox/command rows,
316
+ for parity with those sibling surfaces. (component audit C22.) */
317
+ @media (forced-colors: active) {
318
+ .ui-menu__item:hover,
319
+ .ui-menu__item:focus-visible {
320
+ forced-color-adjust: none;
321
+ background: Highlight;
322
+ color: HighlightText;
323
+ }
324
+
325
+ .ui-menu__item:hover::before,
326
+ .ui-menu__item:focus-visible::before {
327
+ background: HighlightText;
328
+ opacity: 1;
329
+ }
330
+ }
331
+
287
332
  /* --- Combobox: an input with a filtered listbox popup (APG pattern,
288
333
  wired by initCombobox). Reuses the menu surface tokens. --- */
289
334
  .ui-combobox {
@@ -337,6 +382,18 @@ dialog.ui-modal[open]::backdrop {
337
382
  color: var(--text);
338
383
  }
339
384
 
385
+ /* The active option is tracked by aria-activedescendant — it is NEVER DOM-focused,
386
+ so it gets no UA focus ring under HCM, and its only cue (--bg-accent) is stripped.
387
+ Re-assert a system Highlight like the command palette does. (audit C2.) */
388
+ @media (forced-colors: active) {
389
+ .ui-combobox__option.is-active,
390
+ .ui-combobox__option[aria-selected='true'] {
391
+ forced-color-adjust: none;
392
+ background: Highlight;
393
+ color: HighlightText;
394
+ }
395
+ }
396
+
340
397
  .ui-combobox__empty {
341
398
  color: var(--text-dim);
342
399
  font-size: var(--text-2xs);
@@ -71,12 +71,15 @@
71
71
  padding-inline: var(--center-gutter, var(--space-md));
72
72
  }
73
73
 
74
- /* Intrinsic aspect-ratio box; the media child fills it. */
74
+ /* Intrinsic aspect-ratio box; the media child fills it. The contract is ONE
75
+ child (a single <img>/<video>/<iframe>). Scope the fill to :first-child rather
76
+ than every child: a second child would otherwise be forced to 100%/100% +
77
+ object-fit and stack on top, silently breaking the ratio. (audit C34.) */
75
78
  .ui-ratio {
76
79
  aspect-ratio: var(--ratio, 16 / 9);
77
80
  }
78
81
 
79
- .ui-ratio > * {
82
+ .ui-ratio > :first-child {
80
83
  block-size: 100%;
81
84
  inline-size: 100%;
82
85
  object-fit: cover;
@@ -94,7 +97,11 @@
94
97
  container: bronto / inline-size;
95
98
  }
96
99
 
97
- @container bronto (max-width: 34rem) {
100
+ /* Logical `max-inline-size`, not physical `max-width`: the container is typed
101
+ `inline-size`, so the inline axis is the one actually tracked — the logical
102
+ query matches it in any writing mode (a physical `width` query silently
103
+ misses in a vertical WM). (component audit C34.) */
104
+ @container bronto (max-inline-size: 34rem) {
98
105
  .ui-grid {
99
106
  --grid-min: 100%;
100
107
  }
@@ -168,7 +175,7 @@
168
175
  /* Inside an opt-in .ui-cq container, collapse to one column when the
169
176
  container (not the viewport) is narrow — keeps tiles usable in a slim
170
177
  panel. Inert without .ui-cq, so baselines are unaffected. */
171
- @container bronto (max-width: 30rem) {
178
+ @container bronto (max-inline-size: 30rem) {
172
179
  .ui-statgrid,
173
180
  .ui-app-metrics {
174
181
  grid-template-columns: 1fr;
@@ -182,6 +189,12 @@
182
189
  border-radius: var(--radius-md);
183
190
  display: grid;
184
191
  gap: 0.4rem;
192
+
193
+ /* These tiles hold IDs / hashes / big numbers — the unbreakable-token case is
194
+ the common one. A grid item defaults to min-inline-size:auto, so a long
195
+ value pushes the whole statgrid track wider; allow the tile to shrink so the
196
+ value can wrap instead (paired with overflow-wrap on __value). (audit C5.) */
197
+ min-inline-size: 0;
185
198
  padding: var(--space-md);
186
199
  }
187
200
 
@@ -203,6 +216,7 @@
203
216
  font-weight: var(--display-weight-strong);
204
217
  letter-spacing: 0.02em;
205
218
  line-height: 1.05;
219
+ overflow-wrap: anywhere; /* break an unspaced ID/hash rather than overflow (audit C5) */
206
220
  }
207
221
 
208
222
  .ui-stat__delta,
@@ -243,6 +257,10 @@
243
257
  share one P&L vocabulary. Token-identical to the table's is-num/
244
258
  is-pos/is-neg (which stay table-local). */
245
259
  .ui-num {
260
+ /* inline-block so `text-align: end` actually applies: on a bare inline element
261
+ it computes but never paints (the box is shrink-wrapped), so an author who
262
+ followed the docs to right-align an inline figure saw no effect. (audit C17.) */
263
+ display: inline-block;
246
264
  font-variant-numeric: tabular-nums;
247
265
  text-align: end;
248
266
  }
@@ -413,13 +431,26 @@
413
431
 
414
432
  /* aria-disabled keeps the element in the a11y tree but the browser does NOT
415
433
  block activation (a real <a class="ui-button" aria-disabled> still navigates,
416
- a <button> still fires). Looking dead while staying live is the defect — kill
417
- pointer activation. (a11y review C3; native :disabled already inert.) */
434
+ a <button> still fires). `pointer-events: none` kills POINTER activation only
435
+ a focused control still fires on Enter/Space, which CSS cannot intercept. So
436
+ this looks-dead state is NOT keyboard-inert: for that, prefer native
437
+ `<button disabled>`, run `initDisabledGuard()` (it intercepts Enter/Space on
438
+ aria-disabled controls), or add `tabindex="-1"` (and drop `href` on a link).
439
+ See docs/usage.md "Disabled vs aria-disabled". (a11y review C3 / audit C4;
440
+ native :disabled already inert.) */
418
441
  .ui-button[aria-disabled='true'],
419
442
  .ui-link[aria-disabled='true'] {
420
443
  pointer-events: none;
421
444
  }
422
445
 
446
+ /* The button family dims + shows not-allowed via its `:disabled` rule above, but
447
+ a disabled LINK got only pointer-events:none — it looked fully live. Give it
448
+ the same visual disabled cue. (component audit C30.) */
449
+ .ui-link[aria-disabled='true'] {
450
+ cursor: not-allowed;
451
+ opacity: 0.45;
452
+ }
453
+
423
454
  .ui-button:active {
424
455
  transform: translateY(1px);
425
456
  }
@@ -445,8 +476,13 @@
445
476
  }
446
477
 
447
478
  @media (prefers-reduced-motion: reduce) {
479
+ /* The global reduced-motion reset freezes the spin on a single frame with an
480
+ !important `animation-duration`, so the old non-important `1.4s` slow-spin
481
+ here was dead code AND left a broken, transparent-topped ring looking like a
482
+ rendering bug. Drop the dead rule; show a STATIC complete ring instead — a
483
+ still busy cue with no implied motion. (component audit C15.) */
448
484
  .ui-button[aria-busy='true']::before {
449
- animation-duration: 1.4s;
485
+ border-block-start-color: currentcolor;
450
486
  }
451
487
  }
452
488
 
@@ -492,6 +528,27 @@
492
528
  inline-size: 0.42rem;
493
529
  }
494
530
 
531
+ /* RTL: the logical borders flip sides, but a fixed `rotate(45deg)` then points
532
+ the chevron UP rather than toward the inline-end (the reading-forward way).
533
+ Mirror the rotation so the resting affordance points forward in RTL too.
534
+ (component audit C14.) */
535
+ [dir='rtl'] .ui-link--arrow::after,
536
+ [dir='rtl'] .ui-link--cta::after {
537
+ transform: rotate(-45deg);
538
+ }
539
+
540
+ /* Standalone CTA links are tap targets, not inline prose links: on a coarse
541
+ pointer float them to the WCAG 2.5.8 AA 24px floor (the 2.5.8 inline-link
542
+ exception doesn't cover a block-level call-to-action, which is what these
543
+ are). Buttons already auto-grow to ~44px on coarse pointers. (component
544
+ audit C14.) */
545
+ @media (pointer: coarse) {
546
+ .ui-link--arrow,
547
+ .ui-link--cta {
548
+ min-block-size: 1.5rem;
549
+ }
550
+ }
551
+
495
552
  /* --- Chip / tag --- */
496
553
 
497
554
  .ui-chip {
@@ -637,6 +694,8 @@
637
694
  color: var(--text);
638
695
  font-family: var(--mono);
639
696
  margin: 0;
697
+ min-inline-size: 0;
698
+ overflow-wrap: anywhere; /* IDs/hashes/paths are the common value — break, don't overflow (audit C5) */
640
699
  }
641
700
 
642
701
  /* --- Hover (pointer only) --- */
@@ -679,7 +738,7 @@
679
738
 
680
739
  [dir='rtl'] .ui-link--arrow:hover::after,
681
740
  [dir='rtl'] .ui-link--cta:hover::after {
682
- transform: translateX(-0.14rem) rotate(45deg);
741
+ transform: translateX(-0.14rem) rotate(-45deg);
683
742
  }
684
743
 
685
744
  .ui-chip--accent:hover {
package/css/report.css CHANGED
@@ -257,6 +257,46 @@
257
257
  text-transform: uppercase;
258
258
  }
259
259
 
260
+ /* --- Labelled meter row ---
261
+ A multi-meter block (SLO burn, error budgets, capacity) lays out as
262
+ label | bar | value. The bare `ui-meter` base lives in feedback.css; this is
263
+ the report-document grammar around it so authors stop hand-rolling the grid.
264
+ The bar NEVER carries the reading alone (WCAG 1.4.1) — `ui-meter__value` is
265
+ the data of record, and the bar clamps at 100 so an over-target figure still
266
+ reads correctly in the value text. Collapses to a stack on a narrow screen. */
267
+ .ui-meter__row {
268
+ align-items: center;
269
+ display: grid;
270
+ gap: var(--space-2xs) var(--space-md);
271
+ grid-template-columns: minmax(9rem, 14rem) 1fr auto;
272
+ margin-block: var(--space-2xs);
273
+ }
274
+
275
+ .ui-meter__row .ui-meter {
276
+ min-inline-size: 8rem;
277
+ }
278
+
279
+ .ui-meter__label {
280
+ color: var(--text-soft);
281
+ }
282
+
283
+ .ui-meter__value {
284
+ color: var(--text);
285
+ font-family: var(--mono);
286
+ font-variant-numeric: tabular-nums;
287
+ text-align: end;
288
+ }
289
+
290
+ @media (max-width: 32rem) {
291
+ .ui-meter__row {
292
+ grid-template-columns: 1fr;
293
+ }
294
+
295
+ .ui-meter__value {
296
+ text-align: start;
297
+ }
298
+ }
299
+
260
300
  /* A chart is NOT a bronto component — it needs scales + data binding, which the
261
301
  analytical layer refuses to own. Theme Vega-Lite with `@ponchia/ui/vega`
262
302
  (docs/vega.md), or hand-author a token-themed inline `<svg>`, and drop it in a
@@ -0,0 +1,67 @@
1
+ /* ==========================================================================
2
+ sidenote — opt-in Tufte-style margin notes.
3
+
4
+ A numbered `ui-sidenote` and an unnumbered `ui-marginnote` for evidence,
5
+ caveats, and provenance asides that belong beside the text, not in it. Wide
6
+ viewports float the note into the inline-end margin; narrow viewports collapse
7
+ it to an indented inline block. CSS counters number the sidenotes. Not
8
+ imported by core.css.
9
+
10
+ Boundary / wiring: the HOST owns where numbering restarts — set
11
+ `counter-reset: ui-sidenote` on the article (or a section) — and reserves the
12
+ margin gutter by giving that container
13
+ `padding-inline-end: calc(var(--sidenote-width) + var(--sidenote-gap))` at the
14
+ same breakpoint. Place each note in the DOM right after its `.ui-sidenote__ref`.
15
+ ========================================================================== */
16
+
17
+ .ui-sidenote,
18
+ .ui-marginnote {
19
+ --sidenote-width: 12rem;
20
+ --sidenote-gap: 2rem;
21
+
22
+ border-inline-start: 2px solid var(--line);
23
+ color: var(--text-dim);
24
+ display: block;
25
+ font-size: var(--text-2xs);
26
+ line-height: 1.5;
27
+ margin-block: var(--space-2xs);
28
+ padding-inline-start: var(--space-md);
29
+ }
30
+
31
+ /* The inline superscript that anchors a numbered sidenote. Use --accent-text,
32
+ not raw --accent: this is readable text and must clear WCAG AA 4.5:1 even
33
+ after a one-knob re-brand to a paler --accent (raw --accent drops to ~1.5:1).
34
+ Same accent-as-text contract as .ui-eyebrow / .ui-link--cta. (audit C6.) */
35
+ .ui-sidenote__ref {
36
+ color: var(--accent-text);
37
+ counter-increment: ui-sidenote;
38
+ font-size: 0.75em;
39
+ vertical-align: super;
40
+ }
41
+
42
+ .ui-sidenote__ref::after {
43
+ content: counter(ui-sidenote);
44
+ }
45
+
46
+ /* The note repeats its number (display only — the ref already incremented).
47
+ --accent-text for the same WCAG-AA reason as the ref above. (audit C6.) */
48
+ .ui-sidenote::before {
49
+ color: var(--accent-text);
50
+ content: counter(ui-sidenote) '. ';
51
+ }
52
+
53
+ /* Wide viewports: float the note into the inline-end margin. The container must
54
+ reserve the gutter (see header). */
55
+ @media (min-width: 60rem) {
56
+ .ui-sidenote,
57
+ .ui-marginnote {
58
+ border-inline-start: 0;
59
+ clear: inline-end;
60
+ float: inline-end;
61
+ inline-size: var(--sidenote-width);
62
+ margin-block: 0;
63
+ margin-inline-end: calc(-1 * (var(--sidenote-width) + var(--sidenote-gap)));
64
+ padding-inline-start: 0;
65
+ text-align: start;
66
+ }
67
+ }