@ponchia/ui 0.7.0 → 0.8.0

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 (52) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +3 -3
  3. package/classes/classes.json +79 -7
  4. package/classes/index.d.ts +53 -1
  5. package/classes/index.js +60 -0
  6. package/classes/vscode.css-custom-data.json +24 -0
  7. package/css/app.css +14 -3
  8. package/css/disclosure.css +7 -7
  9. package/css/feedback.css +11 -8
  10. package/css/forms.css +3 -3
  11. package/css/navigation.css +1 -1
  12. package/css/overlay.css +19 -5
  13. package/css/primitives.css +101 -6
  14. package/css/site.css +11 -5
  15. package/css/skins.css +97 -3
  16. package/css/state.css +161 -0
  17. package/css/table.css +1 -1
  18. package/css/tokens.css +6 -0
  19. package/css/workbench.css +151 -0
  20. package/dist/bronto.css +1 -1
  21. package/dist/css/app.css +1 -1
  22. package/dist/css/disclosure.css +1 -1
  23. package/dist/css/feedback.css +1 -1
  24. package/dist/css/forms.css +1 -1
  25. package/dist/css/navigation.css +1 -1
  26. package/dist/css/overlay.css +1 -1
  27. package/dist/css/primitives.css +1 -1
  28. package/dist/css/report-kit.css +1 -1
  29. package/dist/css/site.css +1 -1
  30. package/dist/css/skins.css +1 -1
  31. package/dist/css/state.css +1 -1
  32. package/dist/css/table.css +1 -1
  33. package/dist/css/tokens.css +1 -1
  34. package/dist/css/workbench.css +1 -1
  35. package/docs/adr/0001-color-system.md +32 -3
  36. package/docs/contrast.md +102 -18
  37. package/docs/reference.md +52 -1
  38. package/docs/reporting.md +8 -8
  39. package/docs/stability.md +31 -2
  40. package/docs/state.md +51 -1
  41. package/docs/theming.md +18 -0
  42. package/docs/usage.md +62 -2
  43. package/docs/workbench.md +83 -4
  44. package/llms.txt +1 -1
  45. package/package.json +1 -1
  46. package/tokens/figma.variables.json +84 -0
  47. package/tokens/index.d.ts +2 -2
  48. package/tokens/index.js +23 -0
  49. package/tokens/index.json +12 -0
  50. package/tokens/resolved.json +6 -0
  51. package/tokens/skins.js +117 -7
  52. package/tokens/tokens.dtcg.json +7 -1
@@ -44,11 +44,11 @@
44
44
  border-color var(--duration-fast) var(--ease-standard);
45
45
  }
46
46
 
47
- /* Coarse-pointer tap-target floor (~2.9rem ≈ 44px), matching inputs/sitenav —
47
+ /* Coarse-pointer tap-target floor (`--tap-target`), matching inputs/sitenav —
48
48
  tabs are easy to mis-tap on touch (WCAG 2.5.8 — C24). */
49
49
  @media (pointer: coarse) {
50
50
  .ui-tab {
51
- min-block-size: 2.9rem;
51
+ min-block-size: var(--tap-target);
52
52
  }
53
53
  }
54
54
 
@@ -277,8 +277,8 @@
277
277
  align-items: center;
278
278
  display: inline-flex;
279
279
  justify-content: center;
280
- min-block-size: max(24px, 1.6rem);
281
- min-inline-size: max(24px, 1.6rem);
280
+ min-block-size: var(--tap-target-min);
281
+ min-inline-size: var(--tap-target-min);
282
282
  }
283
283
  }
284
284
 
@@ -349,12 +349,12 @@
349
349
  }
350
350
  }
351
351
 
352
- /* Coarse-pointer tap-target floor, matching inputs/sitenav (~2.9rem ≈ 44px)
352
+ /* Coarse-pointer tap-target floor (`--tap-target`), matching inputs/sitenav —
353
353
  pagination controls are easy to mis-tap on touch (WCAG 2.5.8 — C24). */
354
354
  @media (pointer: coarse) {
355
355
  .ui-pagination__item {
356
- min-block-size: 2.9rem;
357
- min-inline-size: 2.9rem;
356
+ min-block-size: var(--tap-target);
357
+ min-inline-size: var(--tap-target);
358
358
  }
359
359
  }
360
360
 
package/css/feedback.css CHANGED
@@ -130,9 +130,12 @@
130
130
  display: grid;
131
131
  gap: 0.5rem;
132
132
 
133
- /* Logical so the stack anchors bottom inline-end and mirrors in RTL. */
134
- inset-block: auto 1.25rem;
135
- inset-inline: auto 1.25rem;
133
+ /* Logical so the stack anchors bottom inline-end and mirrors in RTL. The
134
+ bottom offset clears the home-indicator gesture area: a toast landing there
135
+ is both hard to read and hard to dismiss, because the system swallows the
136
+ swipe. `max()` leaves the desktop offset untouched. */
137
+ inset-block: auto max(1.25rem, var(--safe-area-bottom));
138
+ inset-inline: auto max(1.25rem, var(--safe-area-right));
136
139
  justify-items: end;
137
140
  max-inline-size: min(22rem, calc(100vw - 2rem));
138
141
  pointer-events: none;
@@ -144,7 +147,7 @@
144
147
  overlaps the polite bottom stack and reads as a distinct, urgent
145
148
  channel. */
146
149
  .ui-toast-stack--assertive {
147
- inset-block: 1.25rem auto;
150
+ inset-block: max(1.25rem, var(--safe-area-top)) auto;
148
151
  }
149
152
 
150
153
  .ui-toast {
@@ -225,15 +228,15 @@
225
228
  }
226
229
 
227
230
  /* Touch: enlarge the dismiss/close hit areas to the project's coarse-pointer
228
- tap-target floor (2.9rem, as in primitives.css / forms.css) without changing
229
- the desktop glyph size. The box is centred so the glyph stays put. */
231
+ tap-target floor (`--tap-target`, as in primitives.css / forms.css) without
232
+ changing the desktop glyph size. The box is centred so the glyph stays put. */
230
233
  @media (pointer: coarse) {
231
234
  .ui-alert__close,
232
235
  .ui-toast__close {
233
236
  display: inline-grid;
234
237
  place-items: center;
235
- min-block-size: 2.9rem;
236
- min-inline-size: 2.9rem;
238
+ min-block-size: var(--tap-target);
239
+ min-inline-size: var(--tap-target);
237
240
  }
238
241
 
239
242
  .ui-alert__close {
package/css/forms.css CHANGED
@@ -476,18 +476,18 @@
476
476
  .ui-input,
477
477
  .ui-select,
478
478
  .ui-textarea {
479
- min-block-size: 2.9rem;
479
+ min-block-size: var(--tap-target);
480
480
  }
481
481
 
482
482
  .ui-search {
483
- min-block-size: 2.9rem;
483
+ min-block-size: var(--tap-target);
484
484
  }
485
485
 
486
486
  /* The whole label is the toggle target — float it to the ~44px floor so the
487
487
  small track/box isn't a sub-target on touch (WCAG 2.5.8 — C24). */
488
488
  .ui-switch,
489
489
  .ui-check {
490
- min-block-size: 2.9rem;
490
+ min-block-size: var(--tap-target);
491
491
  }
492
492
 
493
493
  .ui-check input {
@@ -95,7 +95,7 @@
95
95
 
96
96
  @media (pointer: coarse) {
97
97
  .ui-themetoggle__button {
98
- min-block-size: 2.9rem;
98
+ min-block-size: var(--tap-target);
99
99
  padding-inline: 0.9rem;
100
100
  }
101
101
  }
package/css/overlay.css CHANGED
@@ -149,6 +149,15 @@ html:has(.ui-modal.is-open) {
149
149
  margin-inline-start: auto;
150
150
  max-block-size: 100vh;
151
151
  max-inline-size: min(26rem, 100vw);
152
+
153
+ /* Full-height by design, so the panel's edges are the screen's edges: its
154
+ surface should still reach them, but its content must clear the cutout and
155
+ the home indicator. Add the INSET ONLY — `.ui-modal` is deliberately
156
+ `padding: 0` (its head/body parts own their spacing), so a `max(space, …)`
157
+ floor here would silently pad the drawer on every device, not just a
158
+ notched one. */
159
+ padding-block: var(--safe-area-top) var(--safe-area-bottom);
160
+ padding-inline-end: var(--safe-area-right);
152
161
  border-radius: 0;
153
162
  border-width: 0;
154
163
  border-inline-start-width: 1px;
@@ -162,16 +171,16 @@ html:has(.ui-modal.is-open) {
162
171
  }
163
172
 
164
173
  /* 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
174
+ ~26px on touch, below the `--tap-target` floor the rest of the button family
175
+ meets. Scoped to coarse so the fine-pointer (mouse) rendering, which
167
176
  already clears 24×24, is unchanged; centre the glyph in the enlarged box. */
168
177
  @media (pointer: coarse) {
169
178
  .ui-modal__close {
170
179
  align-items: center;
171
180
  display: inline-flex;
172
181
  justify-content: center;
173
- min-block-size: 2.9rem;
174
- min-inline-size: 2.9rem;
182
+ min-block-size: var(--tap-target);
183
+ min-inline-size: var(--tap-target);
175
184
  }
176
185
  }
177
186
 
@@ -187,7 +196,12 @@ html:has(.ui-modal.is-open) {
187
196
  color: var(--text);
188
197
  max-block-size: 100vh;
189
198
  max-inline-size: 100vw;
190
- padding: 0;
199
+
200
+ /* Deliberately full-bleed — the dimmed ground SHOULD cover the whole screen,
201
+ including the cutout. Only the insets keep the carousel's own controls out
202
+ from under it. */
203
+ padding: var(--safe-area-top) var(--safe-area-right) var(--safe-area-bottom)
204
+ var(--safe-area-left);
191
205
  inline-size: 100vw;
192
206
  }
193
207
 
@@ -423,6 +423,53 @@
423
423
  padding: 0.85rem 1.4rem;
424
424
  }
425
425
 
426
+ /* Dense — for bars whose HEIGHT is the constraint: a pane/node title bar, a
427
+ packed toolbar, a table row's actions. It lowers only the *visual* floor, to
428
+ the WCAG 2.5.8 minimum, and deliberately declares no coarse-pointer opinion:
429
+ the `@media (pointer: coarse)` block below matches at equal specificity and
430
+ later in the file, so it still floats a dense control to the full
431
+ `--tap-target`. Shrinking a control for a mouse therefore never shrinks it
432
+ for a finger — which is the reason this is a separate tier and not just a
433
+ smaller `--sm`. */
434
+ .ui-button--dense {
435
+ gap: 0.25rem;
436
+ min-block-size: var(--tap-target-min);
437
+ min-inline-size: var(--tap-target-min);
438
+ padding: 0.2rem 0.35rem;
439
+ }
440
+
441
+ /* Keep the square icon well; padding is safe to raise specificity on because
442
+ it is not the tap floor. */
443
+ .ui-button--dense.ui-button--icon {
444
+ padding: 0;
445
+ }
446
+
447
+ /* The label slot. An icon button still needs its words — for the accessible
448
+ name, and for the test/automation selector that reads the button by its
449
+ text — but not the pixels. Wrapping the text in this slot lets ONE markup
450
+ shape serve both: the button keeps a real text node, and `--icon` is what
451
+ decides whether it is painted. Without it, an icon-only button has to choose
452
+ between an empty accessible name and a title-bar-inflating word.
453
+
454
+ The hiding technique matches `.ui-visually-hidden` (base.css) rather than
455
+ `display: none`, which would take the text out of the accessible name too —
456
+ the exact opposite of the point. */
457
+ .ui-button__label {
458
+ min-inline-size: 0;
459
+ overflow: hidden;
460
+ text-overflow: ellipsis;
461
+ white-space: nowrap;
462
+ }
463
+
464
+ .ui-button--icon .ui-button__label {
465
+ clip-path: inset(50%);
466
+ block-size: 1px;
467
+ overflow: hidden;
468
+ position: absolute;
469
+ white-space: nowrap;
470
+ inline-size: 1px;
471
+ }
472
+
426
473
  .ui-button:disabled,
427
474
  .ui-button[aria-disabled='true'] {
428
475
  cursor: not-allowed;
@@ -539,11 +586,11 @@
539
586
  /* Standalone CTA links are tap targets, not inline prose links: on a coarse
540
587
  pointer float them to the WCAG 2.5.8 AA 24px floor (the 2.5.8 inline-link
541
588
  exception doesn't cover a block-level call-to-action, which is what these
542
- are). Buttons already auto-grow to ~44px on coarse pointers. */
589
+ are). Buttons already float to the full `--tap-target` on coarse pointers. */
543
590
  @media (pointer: coarse) {
544
591
  .ui-link--arrow,
545
592
  .ui-link--cta {
546
- min-block-size: max(24px, 1.6rem);
593
+ min-block-size: var(--tap-target-min);
547
594
  }
548
595
  }
549
596
 
@@ -743,16 +790,23 @@
743
790
  }
744
791
  }
745
792
 
746
- /* --- Touch: comfortable hit targets on coarse pointers --- */
793
+ /* --- Touch: comfortable hit targets on coarse pointers.
794
+
795
+ The floor is `--tap-target`, not a bare rem. Bronto's own base sets
796
+ `html { font-size: 0.9375rem }`, so the 2.9rem this family used to carry
797
+ resolved to 43.5px — half a pixel under the 44px WCAG 2.5.5 / platform-HIG
798
+ target, and under any host that shrinks the root further it drifted further
799
+ still. The token clamps in px against the rem so the standard holds however
800
+ the root is re-pointed. --- */
747
801
 
748
802
  @media (pointer: coarse) {
749
803
  .ui-button {
750
- min-block-size: 2.9rem;
751
- min-inline-size: 2.9rem;
804
+ min-block-size: var(--tap-target);
805
+ min-inline-size: var(--tap-target);
752
806
  }
753
807
 
754
808
  .ui-button--icon {
755
- min-inline-size: 2.9rem;
809
+ min-inline-size: var(--tap-target);
756
810
  }
757
811
  }
758
812
 
@@ -774,6 +828,47 @@
774
828
  margin: 0.3rem 0 0;
775
829
  }
776
830
 
831
+ /* Slots. Without them every empty surface in an app re-invents the same three
832
+ parts under a different name, and they drift: one has a glyph, the next has
833
+ two sentences in the same weight, a third is a bare <p>. The shape is always
834
+ mark → one sentence in full ink → a quieter follow-up. */
835
+ .ui-empty-state__glyph {
836
+ display: block;
837
+ font-size: 1.5rem;
838
+ line-height: 1;
839
+ opacity: 0.5;
840
+ }
841
+
842
+ /* The one sentence that says what is missing. Full ink — it is the message,
843
+ not a caption, and the container's --text-dim would bury it. */
844
+ .ui-empty-state__lead {
845
+ color: var(--text);
846
+ margin: 0;
847
+ }
848
+
849
+ .ui-empty-state__hint {
850
+ font-size: var(--text-2xs);
851
+ margin: 0;
852
+ opacity: 0.8;
853
+ }
854
+
855
+ /* An empty state REPORTS absence; an invite OFFERS the next action. That is a
856
+ different job, so it gets a different frame: no dashed box (there is nothing
857
+ to outline — the surface itself is the thing you are being invited into) and
858
+ it centres in whatever block space it is given rather than sitting as a
859
+ card. Use it for a surface the user is meant to fill, and keep the plain
860
+ `.ui-empty-state` for a region that simply has no rows today. */
861
+ .ui-empty-state--invite {
862
+ align-items: center;
863
+ border: 0;
864
+ display: flex;
865
+ flex-direction: column;
866
+ gap: var(--space-2xs);
867
+ justify-content: center;
868
+ min-block-size: 100%;
869
+ padding: var(--space-sm);
870
+ }
871
+
777
872
  /* --- Keyboard key — inline glyph for shortcut hints. Wrap a <kbd>. --- */
778
873
 
779
874
  .ui-kbd {
package/css/site.css CHANGED
@@ -41,8 +41,10 @@
41
41
  z-index: var(--z-toast);
42
42
  }
43
43
 
44
+ /* The skip link is the FIRST thing a keyboard or screen-reader user reaches, so
45
+ landing it under a display cutout is the worst place to lose a control. */
44
46
  .ui-skiplink:focus {
45
- inset-block-start: var(--space-md);
47
+ inset-block-start: max(var(--space-md), var(--safe-area-top));
46
48
  }
47
49
 
48
50
  /* --- Header — brand · nav · actions --- */
@@ -60,6 +62,10 @@
60
62
  /* Structural sticky only — the floating-card skin (radius/shadow/panel
61
63
  bg) is consumer identity, not a framework concern; layer it yourself. */
62
64
  .ui-siteheader--sticky {
65
+ /* Only the sticky variant needs this: a header in normal flow already sits
66
+ below the cutout. Pad rather than offset, so the header's own border and
67
+ any consumer background still reach the top of the screen. */
68
+ padding-block-start: max(var(--space-sm), var(--safe-area-top));
63
69
  position: sticky;
64
70
  inset-block-start: 0;
65
71
  z-index: var(--z-sticky);
@@ -184,7 +190,7 @@
184
190
 
185
191
  @media (pointer: coarse) {
186
192
  .ui-sitenav a {
187
- min-block-size: 2.9rem;
193
+ min-block-size: var(--tap-target);
188
194
  padding-inline: 0.9rem;
189
195
  }
190
196
  }
@@ -256,7 +262,7 @@
256
262
 
257
263
  @media (pointer: coarse) {
258
264
  .ui-sitemenu > summary {
259
- min-block-size: 2.9rem;
265
+ min-block-size: var(--tap-target);
260
266
  padding-inline: 0.9rem;
261
267
  }
262
268
  }
@@ -301,8 +307,8 @@
301
307
  align-items: center;
302
308
  display: inline-flex;
303
309
  justify-content: center;
304
- min-block-size: max(24px, 1.6rem);
305
- min-inline-size: max(24px, 1.6rem);
310
+ min-block-size: var(--tap-target-min);
311
+ min-inline-size: var(--tap-target-min);
306
312
  }
307
313
  }
308
314
 
package/css/skins.css CHANGED
@@ -4,43 +4,107 @@
4
4
  * Optional display colorways (ADR-0001). OPT-IN: imported on demand via
5
5
  * `@ponchia/ui/css/skins.css`, never part of the default bundle. Apply with
6
6
  * `data-bronto-skin="amber-crt | e-ink | phosphor-green"` on :root / <html> (a
7
- * root-level choice like data-theme), re-pointing the one accent. The accent's
8
- * derived family recomputes from the live var(--accent); status colours + the
9
- * neutral canvas are untouched. Every accent below is gated by check-contrast.mjs. */
7
+ * root-level choice like data-theme), re-pointing the one accent and the
8
+ * neutral canvas (ADR-0001 step 4, amended in 0.8.0). The accent's derived
9
+ * family recomputes from the live var(--accent); STATUS COLOURS ARE UNTOUCHED,
10
+ * because a warning must look like a warning in every skin. The canvas keeps
11
+ * each core neutral's OKLCH lightness and only moves its hue, so contrast is
12
+ * preserved by construction — and check-contrast.mjs re-measures the FULL
13
+ * pairing table per skin per theme rather than trusting that. */
10
14
 
11
15
  /* Amber CRT */
12
16
  :root[data-bronto-skin='amber-crt'] {
13
17
  --accent: oklch(52% 0.11 67deg);
14
18
  --dotmatrix-pulse-min: 0.35;
19
+ --bg: oklch(96.66% 0.02 67deg);
20
+ --bg-elevated: oklch(98.781% 0.022 67deg);
21
+ --panel: oklch(100% 0.024 67deg);
22
+ --panel-strong: oklch(100% 0.026 67deg);
23
+ --panel-soft: oklch(94.253% 0.026 67deg);
24
+ --line: oklch(88.11% 0.035 67deg);
25
+ --line-strong: oklch(72.983% 0.04 67deg);
26
+ --text: oklch(14.479% 0.03 67deg);
27
+ --text-soft: oklch(32.825% 0.03 67deg);
28
+ --text-dim: oklch(51.571% 0.028 67deg);
15
29
  }
16
30
 
17
31
  :root[data-theme='dark'][data-bronto-skin='amber-crt'] {
18
32
  --accent: oklch(82% 0.15 82deg);
19
33
  --dotmatrix-glow: 0.4em;
20
34
  --dotmatrix-pulse-min: 0.3;
35
+ --bg: oklch(18.22% 0.02 82deg);
36
+ --bg-elevated: oklch(20.904% 0.022 82deg);
37
+ --panel: oklch(22.645% 0.024 82deg);
38
+ --panel-strong: oklch(25.196% 0.026 82deg);
39
+ --panel-soft: oklch(26.032% 0.026 82deg);
40
+ --line: oklch(34.07% 0.035 82deg);
41
+ --line-strong: oklch(44.953% 0.04 82deg);
42
+ --text: oklch(92.494% 0.03 82deg);
43
+ --text-soft: oklch(83.279% 0.03 82deg);
44
+ --text-dim: oklch(70.576% 0.028 82deg);
21
45
  }
22
46
 
23
47
  /* E-ink */
24
48
  :root[data-bronto-skin='e-ink'] {
25
49
  --accent: oklch(34% 0.012 250deg);
26
50
  --dotmatrix-reveal-step: 0ms;
51
+ --bg: oklch(96.66% 0.005 250deg);
52
+ --bg-elevated: oklch(98.781% 0.006 250deg);
53
+ --panel: oklch(100% 0.006 250deg);
54
+ --panel-strong: oklch(100% 0.007 250deg);
55
+ --panel-soft: oklch(94.253% 0.007 250deg);
56
+ --line: oklch(88.11% 0.009 250deg);
57
+ --line-strong: oklch(72.983% 0.01 250deg);
58
+ --text: oklch(14.479% 0.008 250deg);
59
+ --text-soft: oklch(32.825% 0.008 250deg);
60
+ --text-dim: oklch(51.571% 0.007 250deg);
27
61
  }
28
62
 
29
63
  :root[data-theme='dark'][data-bronto-skin='e-ink'] {
30
64
  --accent: oklch(84% 0.008 250deg);
31
65
  --dotmatrix-reveal-step: 0ms;
66
+ --bg: oklch(18.22% 0.005 250deg);
67
+ --bg-elevated: oklch(20.904% 0.006 250deg);
68
+ --panel: oklch(22.645% 0.006 250deg);
69
+ --panel-strong: oklch(25.196% 0.007 250deg);
70
+ --panel-soft: oklch(26.032% 0.007 250deg);
71
+ --line: oklch(34.07% 0.009 250deg);
72
+ --line-strong: oklch(44.953% 0.01 250deg);
73
+ --text: oklch(92.494% 0.008 250deg);
74
+ --text-soft: oklch(83.279% 0.008 250deg);
75
+ --text-dim: oklch(70.576% 0.007 250deg);
32
76
  }
33
77
 
34
78
  /* Phosphor Green */
35
79
  :root[data-bronto-skin='phosphor-green'] {
36
80
  --accent: oklch(52% 0.13 150deg);
37
81
  --dotmatrix-pulse-min: 0.35;
82
+ --bg: oklch(96.66% 0.02 150deg);
83
+ --bg-elevated: oklch(98.781% 0.022 150deg);
84
+ --panel: oklch(100% 0.024 150deg);
85
+ --panel-strong: oklch(100% 0.026 150deg);
86
+ --panel-soft: oklch(94.253% 0.026 150deg);
87
+ --line: oklch(88.11% 0.035 150deg);
88
+ --line-strong: oklch(72.983% 0.04 150deg);
89
+ --text: oklch(14.479% 0.03 150deg);
90
+ --text-soft: oklch(32.825% 0.03 150deg);
91
+ --text-dim: oklch(51.571% 0.028 150deg);
38
92
  }
39
93
 
40
94
  :root[data-theme='dark'][data-bronto-skin='phosphor-green'] {
41
95
  --accent: oklch(84% 0.19 150deg);
42
96
  --dotmatrix-glow: 0.4em;
43
97
  --dotmatrix-pulse-min: 0.3;
98
+ --bg: oklch(18.22% 0.02 150deg);
99
+ --bg-elevated: oklch(20.904% 0.022 150deg);
100
+ --panel: oklch(22.645% 0.024 150deg);
101
+ --panel-strong: oklch(25.196% 0.026 150deg);
102
+ --panel-soft: oklch(26.032% 0.026 150deg);
103
+ --line: oklch(34.07% 0.035 150deg);
104
+ --line-strong: oklch(44.953% 0.04 150deg);
105
+ --text: oklch(92.494% 0.03 150deg);
106
+ --text-soft: oklch(83.279% 0.03 150deg);
107
+ --text-dim: oklch(70.576% 0.028 150deg);
44
108
  }
45
109
 
46
110
  @media (prefers-color-scheme: dark) {
@@ -48,16 +112,46 @@
48
112
  --accent: oklch(82% 0.15 82deg);
49
113
  --dotmatrix-glow: 0.4em;
50
114
  --dotmatrix-pulse-min: 0.3;
115
+ --bg: oklch(18.22% 0.02 82deg);
116
+ --bg-elevated: oklch(20.904% 0.022 82deg);
117
+ --panel: oklch(22.645% 0.024 82deg);
118
+ --panel-strong: oklch(25.196% 0.026 82deg);
119
+ --panel-soft: oklch(26.032% 0.026 82deg);
120
+ --line: oklch(34.07% 0.035 82deg);
121
+ --line-strong: oklch(44.953% 0.04 82deg);
122
+ --text: oklch(92.494% 0.03 82deg);
123
+ --text-soft: oklch(83.279% 0.03 82deg);
124
+ --text-dim: oklch(70.576% 0.028 82deg);
51
125
  }
52
126
 
53
127
  :root:not([data-theme='light'])[data-bronto-skin='e-ink'] {
54
128
  --accent: oklch(84% 0.008 250deg);
55
129
  --dotmatrix-reveal-step: 0ms;
130
+ --bg: oklch(18.22% 0.005 250deg);
131
+ --bg-elevated: oklch(20.904% 0.006 250deg);
132
+ --panel: oklch(22.645% 0.006 250deg);
133
+ --panel-strong: oklch(25.196% 0.007 250deg);
134
+ --panel-soft: oklch(26.032% 0.007 250deg);
135
+ --line: oklch(34.07% 0.009 250deg);
136
+ --line-strong: oklch(44.953% 0.01 250deg);
137
+ --text: oklch(92.494% 0.008 250deg);
138
+ --text-soft: oklch(83.279% 0.008 250deg);
139
+ --text-dim: oklch(70.576% 0.007 250deg);
56
140
  }
57
141
 
58
142
  :root:not([data-theme='light'])[data-bronto-skin='phosphor-green'] {
59
143
  --accent: oklch(84% 0.19 150deg);
60
144
  --dotmatrix-glow: 0.4em;
61
145
  --dotmatrix-pulse-min: 0.3;
146
+ --bg: oklch(18.22% 0.02 150deg);
147
+ --bg-elevated: oklch(20.904% 0.022 150deg);
148
+ --panel: oklch(22.645% 0.024 150deg);
149
+ --panel-strong: oklch(25.196% 0.026 150deg);
150
+ --panel-soft: oklch(26.032% 0.026 150deg);
151
+ --line: oklch(34.07% 0.035 150deg);
152
+ --line-strong: oklch(44.953% 0.04 150deg);
153
+ --text: oklch(92.494% 0.03 150deg);
154
+ --text-soft: oklch(83.279% 0.03 150deg);
155
+ --text-dim: oklch(70.576% 0.028 150deg);
62
156
  }
63
157
  }
package/css/state.css CHANGED
@@ -223,6 +223,153 @@
223
223
  }
224
224
  }
225
225
 
226
+ /* ==========================================================================
227
+ Severity — the second axis of system state.
228
+
229
+ `.ui-state` answers "what is this thing doing". This answers "how bad is
230
+ it". Bronto already shipped the TONES (`--danger`, `--warning`, `--info`,
231
+ `--success`) as per-component modifiers, but never the SCALE: the tier names,
232
+ their order, and the attribute that carries them. So every consumer invents
233
+ the ladder, and inside a single app it drifts — one surface saying
234
+ `critical|error|warning|note`, the next `bad|warn`, a third
235
+ `critical|warning|info|ok`, under two different attribute names. Findings
236
+ then do not sort against alerts, and a filter written for one list silently
237
+ misses the other.
238
+
239
+ THE LADDER, worst to best:
240
+
241
+ critical broken now, and still losing something
242
+ error something failed; it is not currently getting worse
243
+ warning a threshold was crossed; nothing has failed yet
244
+ notice worth reading, no action implied
245
+ ok checked and healthy — an ASSERTION, not the absence of news
246
+ unknown not measured, stale, or the check itself failed
247
+
248
+ `unknown` sits OUTSIDE the ordering deliberately. It is not "slightly worse
249
+ than ok", it is "we do not know", and collapsing it into `ok` is how a dead
250
+ collector reads as a healthy system.
251
+
252
+ The level is carried by `data-level` — one attribute name, so the same
253
+ selector works on a chip, a row, a dot, or the host's own element:
254
+
255
+ <span class="ui-severity" data-level="critical">Critical</span>
256
+ <li class="ui-severity-row" data-level="warning">…</li>
257
+
258
+ BOUNDARY: the host owns thresholds, ranking, filtering, and the wording of
259
+ every label. Colour is never the only channel (WCAG 1.4.1) — `.ui-severity`
260
+ carries an author-written label, and `.ui-severity-dot` is only for rows that
261
+ ALSO name their level in text.
262
+ ========================================================================== */
263
+
264
+ /* Declared once so a host putting the level on its own element gets the same
265
+ mapping from `data-level` + `var(--severity-tone)`, without copying a table. */
266
+ .ui-severity,
267
+ .ui-severity-row,
268
+ .ui-severity-dot {
269
+ --severity-tone: var(--text-dim);
270
+ }
271
+
272
+ :is(.ui-severity, .ui-severity-row, .ui-severity-dot):is(
273
+ [data-level='critical'],
274
+ [data-level='error']
275
+ ) {
276
+ --severity-tone: var(--danger);
277
+ }
278
+
279
+ :is(.ui-severity, .ui-severity-row, .ui-severity-dot)[data-level='warning'] {
280
+ --severity-tone: var(--warning);
281
+ }
282
+
283
+ :is(.ui-severity, .ui-severity-row, .ui-severity-dot)[data-level='notice'] {
284
+ --severity-tone: var(--info);
285
+ }
286
+
287
+ :is(.ui-severity, .ui-severity-row, .ui-severity-dot)[data-level='ok'] {
288
+ --severity-tone: var(--success);
289
+ }
290
+
291
+ /* `unknown` keeps the neutral default tone — it must not read as a verdict. */
292
+
293
+ /* The chip — a short uppercase tier label, tinted rather than filled, because
294
+ a list of findings is mostly chips and filled ones turn it into stripes.
295
+ `critical` is the exception and fills: in a scrolling list the worst tier has
296
+ to survive peripheral vision. */
297
+ .ui-severity {
298
+ background: color-mix(in oklch, var(--severity-tone) 16%, transparent);
299
+ border-radius: var(--radius-pill);
300
+ color: var(--severity-tone);
301
+ display: inline-block;
302
+ flex: none;
303
+ font-family: var(--mono);
304
+ font-size: var(--text-2xs);
305
+ font-weight: 700;
306
+ letter-spacing: var(--tracking-wide);
307
+ line-height: 1.2;
308
+ padding: 0.1rem 0.4rem;
309
+ text-transform: uppercase;
310
+ }
311
+
312
+ .ui-severity[data-level='critical'] {
313
+ background: var(--danger);
314
+ color: var(--button-text);
315
+ }
316
+
317
+ /* A tint over an unknown host surface cannot be contrast-gated, so the neutral
318
+ tier keeps a border instead and stays legible anywhere. */
319
+ .ui-severity[data-level='unknown'] {
320
+ background: none;
321
+ border: 1px solid var(--line-strong);
322
+ padding-block: calc(0.1rem - 1px);
323
+ }
324
+
325
+ /* The dot — for dense rows where a chip costs more width than the level is
326
+ worth. An accelerator for scanning, not the label. */
327
+ .ui-severity-dot {
328
+ background: var(--severity-tone);
329
+ border-radius: 50%;
330
+ block-size: 0.5rem;
331
+ display: inline-block;
332
+ flex: none;
333
+ inline-size: 0.5rem;
334
+ print-color-adjust: exact;
335
+ }
336
+
337
+ .ui-severity-dot[data-level='unknown'] {
338
+ background: none;
339
+ box-shadow: inset 0 0 0 1px var(--line-strong);
340
+ }
341
+
342
+ /* The row — a list item that carries a level. The inline-start gutter is what
343
+ makes a long list readable without reading it. */
344
+ .ui-severity-row {
345
+ align-items: baseline;
346
+ border-block-end: 1px solid var(--line);
347
+ border-inline-start: 2px solid var(--severity-tone);
348
+ display: flex;
349
+ gap: var(--space-xs);
350
+ padding: var(--space-2xs) var(--space-xs);
351
+ }
352
+
353
+ .ui-severity-row:last-child {
354
+ border-block-end: 0;
355
+ }
356
+
357
+ /* Gives up space first, so the level and any trailing metadata stay put. */
358
+ .ui-severity-row__title {
359
+ flex: 1 1 auto;
360
+ min-inline-size: 0;
361
+ overflow: hidden;
362
+ text-overflow: ellipsis;
363
+ white-space: nowrap;
364
+ }
365
+
366
+ .ui-severity-row__meta {
367
+ color: var(--text-dim);
368
+ flex: none;
369
+ font-family: var(--mono);
370
+ font-size: var(--text-2xs);
371
+ }
372
+
226
373
  /* Forced colours: the tone dot collapses to a system colour, so the
227
374
  author-written label remains the state channel (it already must be). */
228
375
  @media (forced-colors: active) {
@@ -237,4 +384,18 @@
237
384
  .ui-job__bar {
238
385
  background: CanvasText;
239
386
  }
387
+
388
+ /* Same contract for severity: the tint and the gutter both vanish under a
389
+ replaced palette, so keep the structure and let the label carry meaning. */
390
+ .ui-severity {
391
+ border: 1px solid currentColor;
392
+ }
393
+
394
+ .ui-severity-row {
395
+ border-inline-start-color: CanvasText;
396
+ }
397
+
398
+ .ui-severity-dot {
399
+ background: CanvasText;
400
+ }
240
401
  }