@neuravision/construct 2.1.1 → 2.1.2

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 (66) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +13 -0
  3. package/behaviors/combobox.js +43 -4
  4. package/behaviors/dropdown.js +16 -2
  5. package/behaviors/index.d.ts +2 -0
  6. package/behaviors/internal/dialog-controller.js +79 -9
  7. package/behaviors/internal/dom.js +38 -7
  8. package/behaviors/internal/overlay.js +4 -4
  9. package/behaviors/internal/popup-layer.js +2 -0
  10. package/behaviors/popover.js +21 -12
  11. package/behaviors/select-menu.js +20 -2
  12. package/behaviors/tabs.js +13 -2
  13. package/behaviors/toggle-group.js +37 -5
  14. package/behaviors/tooltip.js +26 -2
  15. package/components/README.md +40 -0
  16. package/components/_shared.css +11 -0
  17. package/components/accordion.css +8 -0
  18. package/components/app-shell-v2.css +7 -0
  19. package/components/app-shell.css +7 -0
  20. package/components/banner.css +32 -1
  21. package/components/card.css +23 -9
  22. package/components/chart.css +26 -0
  23. package/components/checkbox.css +32 -0
  24. package/components/chip.css +29 -1
  25. package/components/combobox.css +21 -0
  26. package/components/datepicker.css +55 -8
  27. package/components/datum.css +84 -11
  28. package/components/dropdown.css +23 -3
  29. package/components/field.css +6 -4
  30. package/components/file-upload.css +1 -1
  31. package/components/list.css +28 -2
  32. package/components/navbar.css +14 -1
  33. package/components/pagination.css +18 -0
  34. package/components/progress-bar.css +22 -1
  35. package/components/select-menu.css +8 -0
  36. package/components/select.css +8 -0
  37. package/components/sidebar.css +15 -2
  38. package/components/skip-link.css +6 -2
  39. package/components/slider.css +5 -0
  40. package/components/switch.css +62 -2
  41. package/components/table.css +53 -4
  42. package/components/tabs.css +55 -3
  43. package/components/toast.css +10 -1
  44. package/components/toggle-group.css +16 -5
  45. package/components/toolbar.css +17 -1
  46. package/components/tree.css +30 -0
  47. package/docs/behaviors.md +26 -5
  48. package/docs/guidelines.md +30 -2
  49. package/foundations.css +6 -0
  50. package/package.json +7 -3
  51. package/schemas/components.schema.json +16 -1
  52. package/schemas/semantic.schema.json +2 -1
  53. package/schemas/theme.schema.json +1 -1
  54. package/scripts/build-tokens.mjs +87 -5
  55. package/themes/README.md +37 -0
  56. package/themes/walnut.theme.json +106 -0
  57. package/tokens/README.md +10 -2
  58. package/tokens/components.json +10 -0
  59. package/tokens/semantic.dark.json +4 -3
  60. package/tokens/semantic.high-contrast-dark.json +94 -0
  61. package/tokens/semantic.high-contrast.json +3 -2
  62. package/tokens/semantic.light.json +2 -1
  63. package/tokens/tokens.css +431 -33
  64. package/tokens/tokens.js +432 -133
  65. package/tokens/tokens.json +432 -133
  66. package/tokens/tokens.ts +432 -133
@@ -6,9 +6,12 @@
6
6
  promote that signature from focus rings and the intro page into
7
7
  reusable structural vocabulary every surface can speak.
8
8
 
9
- Accessibility contract (must hold across light/dark/high-contrast):
10
- · orange as TEXT → --color-brand-accent-active (orange.700, ≥4.5:1)
11
- · orange as EDGE/RULE (non-text) → --color-brand-accent (orange.500)
9
+ Accessibility contract (must hold across all built-in themes):
10
+ · orange as TEXT → never; use --color-text-* neutrals (orange fails
11
+ 4.5:1 as small text on several theme surfaces)
12
+ · orange as FILL vs muted tracks → --color-brand-accent-strong
13
+ (validated ≥3:1 against bg.muted/canvas/surface per theme)
14
+ · orange as EDGE/RULE (decorative, on canvas/surface) → --color-brand-accent
12
15
  ============================================================ */
13
16
 
14
17
  /* ── Eyebrow ──
@@ -32,27 +35,76 @@
32
35
 
33
36
  .ct-eyebrow::before {
34
37
  content: '';
35
- width: 24px;
36
- height: 3px;
38
+ width: var(--component-datum-tick-width);
39
+ height: var(--component-datum-tick-height);
37
40
  border-radius: var(--radius-xs);
38
41
  background: var(--color-brand-accent);
39
42
  }
40
43
 
44
+ /* ── Numbered eyebrow ──
45
+ Sheet numbers, like an engineering drawing set: `--numbered` inserts
46
+ an auto-incremented, zero-padded counter between tick and label.
47
+ Scope numbering with a `.ct-eyebrow-set` ancestor (or any element
48
+ that declares `counter-reset: ct-eyebrow`). */
49
+ .ct-eyebrow-set {
50
+ counter-reset: ct-eyebrow;
51
+ }
52
+
53
+ .ct-eyebrow--numbered {
54
+ counter-increment: ct-eyebrow;
55
+ }
56
+
57
+ .ct-eyebrow--numbered::before {
58
+ order: -2;
59
+ }
60
+
61
+ .ct-eyebrow--numbered::after {
62
+ content: counter(ct-eyebrow, decimal-leading-zero);
63
+ order: -1;
64
+ color: var(--color-text-muted);
65
+ font-variant-numeric: tabular-nums;
66
+ }
67
+
41
68
  /* ── Datum scale ──
42
69
  A measured reference line: a hairline baseline with hanging ruler
43
70
  ticks (minor every 12px, major every 60px) and an orange "origin"
44
71
  segment marking zero. Decorative — mark it role="presentation". */
45
72
  .ct-datum-scale {
46
- height: 9px;
73
+ --ct-datum-scale-accent: var(--color-brand-accent);
74
+ --ct-datum-scale-origin: var(--component-datum-origin-length);
75
+
76
+ height: var(--component-datum-scale-height);
47
77
  background:
48
78
  /* orange origin segment — the datum's zero */
49
- linear-gradient(var(--color-brand-accent), var(--color-brand-accent)) 0 0 / 48px 2px no-repeat,
79
+ linear-gradient(var(--ct-datum-scale-accent), var(--ct-datum-scale-accent)) 0 0 / var(--ct-datum-scale-origin) 2px no-repeat,
50
80
  /* full-width baseline hairline */
51
81
  linear-gradient(var(--color-border-strong), var(--color-border-strong)) 0 0 / 100% 1px no-repeat,
52
82
  /* major ticks */
53
- repeating-linear-gradient(90deg, var(--color-border-strong) 0 1px, transparent 1px 60px) 0 0 / 100% 8px no-repeat,
83
+ repeating-linear-gradient(90deg, var(--color-border-strong) 0 1px, transparent 1px var(--component-datum-scale-major-step)) 0 0 / 100% 8px no-repeat,
54
84
  /* minor ticks */
55
- repeating-linear-gradient(90deg, var(--color-border-default) 0 1px, transparent 1px 12px) 0 0 / 100% 5px no-repeat;
85
+ repeating-linear-gradient(90deg, var(--color-border-default) 0 1px, transparent 1px var(--component-datum-scale-minor-step)) 0 0 / 100% 5px no-repeat;
86
+ }
87
+
88
+ /* RTL: the origin and tick phase anchor to the inline start. */
89
+ .ct-datum-scale:is([dir='rtl'] *, :dir(rtl)) {
90
+ background:
91
+ linear-gradient(var(--ct-datum-scale-accent), var(--ct-datum-scale-accent)) 100% 0 / var(--ct-datum-scale-origin) 2px no-repeat,
92
+ linear-gradient(var(--color-border-strong), var(--color-border-strong)) 0 0 / 100% 1px no-repeat,
93
+ repeating-linear-gradient(270deg, var(--color-border-strong) 0 1px, transparent 1px var(--component-datum-scale-major-step)) 0 0 / 100% 8px no-repeat,
94
+ repeating-linear-gradient(270deg, var(--color-border-default) 0 1px, transparent 1px var(--component-datum-scale-minor-step)) 0 0 / 100% 5px no-repeat;
95
+ }
96
+
97
+ /* ── Progress variant ──
98
+ The origin segment becomes meaning-bearing: its length reports
99
+ progress. Set `--ct-datum-scale-progress` (a percentage) on the
100
+ element and give it `role="progressbar"` with the matching
101
+ `aria-valuenow/min/max`. Meaning-bearing fill uses accent-strong,
102
+ validated ≥3:1 against muted/canvas/surface tracks per theme. */
103
+ .ct-datum-scale--progress {
104
+ --ct-datum-scale-accent: var(--color-brand-accent-strong);
105
+ --ct-datum-scale-origin: var(--ct-datum-scale-progress, 0%);
106
+
107
+ transition: background-size var(--duration-normal) var(--easing-standard);
56
108
  }
57
109
 
58
110
  /* ── Spec datasheet ──
@@ -103,7 +155,7 @@
103
155
  :is(.ct-metric--active, .ct-metric[data-state='active'], .ct-spec--origin) {
104
156
  --ct-metric-accent: var(--color-brand-accent);
105
157
 
106
- box-shadow: inset 0 var(--border-thick) 0 0 var(--ct-metric-accent);
158
+ box-shadow: inset 0 var(--component-datum-edge-width) 0 0 var(--ct-metric-accent);
107
159
  }
108
160
 
109
161
  :is(.ct-metric__value, .ct-spec__value) {
@@ -186,7 +238,7 @@
186
238
 
187
239
  .ct-metric[data-status],
188
240
  .ct-metric[data-state='error'] {
189
- box-shadow: inset var(--border-thick) 0 0 0 var(--ct-metric-accent);
241
+ box-shadow: inset var(--component-datum-edge-width) 0 0 0 var(--ct-metric-accent);
190
242
  }
191
243
 
192
244
  .ct-metric:is([aria-busy='true'], [data-state='loading']) {
@@ -197,6 +249,27 @@
197
249
  opacity: var(--opacity-disabled);
198
250
  }
199
251
 
252
+ /* ── Datum frame ──
253
+ The corner registration mark generalised beyond cards: an opt-in
254
+ "exhibit" treatment for figures, code samples, and media. Decorative;
255
+ forced colors keeps the borders via CanvasText automatically. */
256
+ .ct-datum-frame {
257
+ position: relative;
258
+ }
259
+
260
+ .ct-datum-frame::before {
261
+ content: '';
262
+ position: absolute;
263
+ inset-block-start: 0;
264
+ inset-inline-start: 0;
265
+ width: var(--component-datum-mark-size);
266
+ height: var(--component-datum-mark-size);
267
+ border-block-start: var(--border-medium) solid var(--color-brand-accent);
268
+ border-inline-start: var(--border-medium) solid var(--color-brand-accent);
269
+ border-start-start-radius: var(--radius-lg);
270
+ pointer-events: none;
271
+ }
272
+
200
273
  @media (forced-colors: active) {
201
274
  :is(.ct-metric--active, .ct-metric[data-state='active'], .ct-metric[data-status], .ct-metric[data-state='error'], .ct-spec--origin) {
202
275
  border-block-start: var(--border-medium) solid currentColor;
@@ -146,7 +146,7 @@
146
146
  /* ── Danger item ─────────────────────────────────────────────────── */
147
147
 
148
148
  .ct-dropdown__item--danger {
149
- color: var(--color-state-danger);
149
+ color: var(--color-state-danger-text);
150
150
  }
151
151
 
152
152
  @media (hover: hover) {
@@ -158,11 +158,11 @@
158
158
  .ct-dropdown__item--danger:focus-visible,
159
159
  .ct-dropdown__item--danger[data-highlighted] {
160
160
  background: var(--color-state-danger-surface);
161
- outline-color: var(--color-state-danger);
161
+ outline-color: var(--color-state-danger-text);
162
162
  }
163
163
 
164
164
  .ct-dropdown__item--danger .ct-dropdown__item-icon {
165
- color: var(--color-state-danger);
165
+ color: var(--color-state-danger-text);
166
166
  }
167
167
 
168
168
  .ct-dropdown__item--danger .ct-dropdown__item-shortcut {
@@ -389,6 +389,15 @@
389
389
  --ct-dropdown-item-gap: var(--space-4);
390
390
  }
391
391
 
392
+ /* ── Construct 44×44 target policy (WCAG 2.2 SC 2.5.5 is AAA) ────── */
393
+
394
+ @media (pointer: coarse) {
395
+ .ct-dropdown__item,
396
+ .ct-dropdown__sub-trigger {
397
+ min-block-size: 44px;
398
+ }
399
+ }
400
+
392
401
  /* ── Reduced motion ──────────────────────────────────────────────── */
393
402
 
394
403
  @media (prefers-reduced-motion: reduce) {
@@ -399,3 +408,14 @@
399
408
  transition: none;
400
409
  }
401
410
  }
411
+
412
+ /* ── Forced colors ───────────────────────────────────────────────── */
413
+
414
+ @media (forced-colors: active) {
415
+ /* The radio-item dot is background-drawn and must survive forced
416
+ backgrounds. */
417
+ .ct-dropdown__item[aria-checked='true'] .ct-dropdown__item-radio::before {
418
+ background: SelectedItem;
419
+ forced-color-adjust: none;
420
+ }
421
+ }
@@ -16,12 +16,14 @@
16
16
 
17
17
  .ct-field__error {
18
18
  font-size: var(--font-size-sm);
19
- color: var(--color-state-danger);
19
+ color: var(--color-state-danger-text);
20
20
  }
21
21
 
22
22
  .ct-field__label--required::after {
23
- content: ' *';
24
- color: var(--color-state-danger);
23
+ /* Alt-text syntax hides the decorative asterisk from assistive tech;
24
+ the `required` attribute is the programmatic signal. */
25
+ content: ' *' / '';
26
+ color: var(--color-state-danger-text);
25
27
  }
26
28
 
27
29
  .ct-field__counter {
@@ -31,7 +33,7 @@
31
33
  }
32
34
 
33
35
  .ct-field__counter--limit {
34
- color: var(--color-state-danger);
36
+ color: var(--color-state-danger-text);
35
37
  }
36
38
 
37
39
  .ct-field__row {
@@ -106,7 +106,7 @@
106
106
  }
107
107
 
108
108
  .ct-file-upload__error {
109
- color: var(--color-state-danger);
109
+ color: var(--color-state-danger-text);
110
110
  font-size: var(--font-size-sm);
111
111
  }
112
112
 
@@ -90,6 +90,9 @@
90
90
  min-height: 0;
91
91
  text-decoration: none;
92
92
  color: inherit;
93
+ /* The datum edge slides in on selection instead of popping. */
94
+ transition: background var(--duration-fast) var(--easing-standard),
95
+ box-shadow var(--duration-fast) var(--easing-standard);
93
96
  }
94
97
 
95
98
  /* ── Item Slots ── */
@@ -222,7 +225,7 @@
222
225
  .ct-list__item[aria-selected='true'] {
223
226
  background: var(--ct-list-item-bg-active);
224
227
  /* The datum: an orange leading edge marks the active/selected item. */
225
- box-shadow: inset var(--border-thick) 0 0 0 var(--color-brand-accent);
228
+ box-shadow: inset var(--component-datum-edge-width) 0 0 0 var(--color-brand-accent);
226
229
  }
227
230
 
228
231
  .ct-list__item--active .ct-list__item-title,
@@ -351,7 +354,8 @@
351
354
  .ct-list--nav .ct-list__item {
352
355
  cursor: pointer;
353
356
  transition: background var(--duration-fast) var(--easing-standard),
354
- color var(--duration-fast) var(--easing-standard);
357
+ color var(--duration-fast) var(--easing-standard),
358
+ box-shadow var(--duration-fast) var(--easing-standard);
355
359
  }
356
360
 
357
361
  @media (hover: hover) {
@@ -475,3 +479,25 @@
475
479
  animation: none;
476
480
  }
477
481
  }
482
+
483
+ /* ── Forced Colors ── */
484
+
485
+ @media (forced-colors: active) {
486
+ /* The datum edge is an inset box-shadow, which forced colors drops —
487
+ re-encode it as a border. */
488
+ .ct-list__item--active,
489
+ .ct-list__item[data-state='active'],
490
+ .ct-list__item[aria-selected='true'] {
491
+ border-inline-start: var(--component-datum-edge-width) solid SelectedItem;
492
+ }
493
+
494
+ /* Skeleton rows hide their placeholder text with `color: transparent`;
495
+ keep the decorative loading state hidden when forced colors would
496
+ otherwise reveal it. */
497
+ .ct-list__item--skeleton .ct-list__item-leading,
498
+ .ct-list__item--skeleton .ct-list__item-title,
499
+ .ct-list__item--skeleton .ct-list__item-description,
500
+ .ct-list__item--skeleton .ct-list__item-trailing {
501
+ forced-color-adjust: none;
502
+ }
503
+ }
@@ -31,7 +31,8 @@
31
31
  gap: var(--ct-navbar-gap);
32
32
  width: 100%;
33
33
  min-width: 0;
34
- height: var(--ct-navbar-height);
34
+ /* min-height, not height: the bar must grow with text scaling. */
35
+ min-height: var(--ct-navbar-height);
35
36
  padding: var(--ct-navbar-padding-y) var(--ct-navbar-padding-x);
36
37
  background: var(--ct-navbar-bg);
37
38
  border-bottom: var(--border-thin) solid var(--ct-navbar-border-color);
@@ -462,6 +463,8 @@
462
463
 
463
464
  /* ── Sticky ────────────────────────────────────────────────────── */
464
465
 
466
+ /* Consumers: set --ct-scroll-offset-top on :root (see foundations.css) so
467
+ scrolled-to content is not obscured behind the bar (WCAG 2.4.11). */
465
468
  .ct-navbar--sticky {
466
469
  position: sticky;
467
470
  inset-block-start: 0;
@@ -469,6 +472,8 @@
469
472
 
470
473
  /* ── Fixed ─────────────────────────────────────────────────────── */
471
474
 
475
+ /* Consumers: set --ct-scroll-offset-top on :root (see foundations.css) so
476
+ scrolled-to content is not obscured behind the bar (WCAG 2.4.11). */
472
477
  .ct-navbar--fixed {
473
478
  position: fixed;
474
479
  inset-block-start: 0;
@@ -673,6 +678,14 @@
673
678
  }
674
679
  }
675
680
 
681
+ /* ── Construct 44×44 target policy (WCAG 2.2 SC 2.5.5 is AAA) ──── */
682
+
683
+ @media (pointer: coarse) {
684
+ .ct-navbar__link {
685
+ min-height: 44px;
686
+ }
687
+ }
688
+
676
689
  /* ── Reduced Motion ────────────────────────────────────────────── */
677
690
 
678
691
  @media (prefers-reduced-motion: reduce) {
@@ -110,6 +110,15 @@
110
110
  --ct-pagination-line-height: var(--line-height-md);
111
111
  }
112
112
 
113
+ /* Construct 44×44 target policy: --control-height-xs is not coarse-remapped
114
+ in _shared.css, so the small variant is bumped explicitly. */
115
+ @media (pointer: coarse) {
116
+ .ct-pagination--sm {
117
+ --ct-pagination-height: 44px;
118
+ --ct-pagination-min-width: 44px;
119
+ }
120
+ }
121
+
113
122
  /* ── Pagination: Responsive ───────────────────────────────── */
114
123
 
115
124
  @media (max-width: 599px) { /* < sm breakpoint (600px) */
@@ -129,3 +138,12 @@
129
138
  transition: none;
130
139
  }
131
140
  }
141
+
142
+ /* ── Pagination: Forced Colors ────────────────────────────── */
143
+
144
+ @media (forced-colors: active) {
145
+ .ct-pagination__link[aria-current='page'] {
146
+ outline: var(--border-medium) solid currentColor;
147
+ outline-offset: calc(-1 * var(--border-medium));
148
+ }
149
+ }
@@ -4,12 +4,16 @@
4
4
  --ct-progress-height: 8px;
5
5
  --ct-progress-radius: var(--radius-pill);
6
6
  --ct-progress-bg: var(--color-bg-muted);
7
- --ct-progress-fill: var(--color-brand-accent);
7
+ /* accent-strong, not accent: the fill must hold 3:1 against the muted
8
+ track (1.4.11) — validated as "strong accent on muted" per theme. */
9
+ --ct-progress-fill: var(--color-brand-accent-strong);
8
10
 
9
11
  width: 100%;
10
12
  height: var(--ct-progress-height);
11
13
  border-radius: var(--ct-progress-radius);
12
14
  background: var(--ct-progress-bg);
15
+ /* the track boundary, so the component is identifiable on any surface */
16
+ border: var(--border-thin) solid var(--color-border-default);
13
17
  overflow: hidden;
14
18
  }
15
19
 
@@ -52,5 +56,22 @@
52
56
  @media (prefers-reduced-motion: reduce) {
53
57
  .ct-progress-bar--indeterminate .ct-progress-bar__track {
54
58
  animation: none;
59
+ /* A frozen 30% sweep reads as determinate-and-stuck; a full-width
60
+ dimmed fill reads as "busy, extent unknown". */
61
+ width: 100%;
62
+ opacity: 0.4;
63
+ }
64
+ }
65
+
66
+ @media (forced-colors: active) {
67
+ /* Backgrounds are forced to Canvas — without these the whole bar
68
+ disappears (mirrors the meter's forced-colors strategy). */
69
+ .ct-progress-bar {
70
+ border: 1px solid ButtonText;
71
+ }
72
+
73
+ .ct-progress-bar__track {
74
+ background: SelectedItem;
75
+ forced-color-adjust: none;
55
76
  }
56
77
  }
@@ -257,6 +257,14 @@
257
257
  --ct-select-menu-option-padding-y: var(--space-4);
258
258
  }
259
259
 
260
+ /* ── Construct 44×44 target policy (WCAG 2.2 SC 2.5.5 is AAA) ───── */
261
+
262
+ @media (pointer: coarse) {
263
+ .ct-select-menu__option {
264
+ min-block-size: 44px;
265
+ }
266
+ }
267
+
260
268
  /* ── Reduced motion ─────────────────────────────────────────────── */
261
269
 
262
270
  @media (prefers-reduced-motion: reduce) {
@@ -28,3 +28,11 @@
28
28
  appearance: none;
29
29
  padding-inline-end: calc(var(--space-4) + 0.75em + var(--space-4));
30
30
  }
31
+
32
+ @media (forced-colors: active) {
33
+ /* The native arrow is removed via appearance: none, so the mask-drawn
34
+ chevron must be repainted with system ink. */
35
+ .ct-select-wrap::after {
36
+ background-color: ButtonText;
37
+ }
38
+ }
@@ -40,6 +40,8 @@
40
40
  box-shadow: var(--shadow-lg);
41
41
  /* LTR: slide in from the left */
42
42
  translate: -100% 0;
43
+ /* keep off-canvas content out of tab order and the accessibility tree */
44
+ visibility: hidden;
43
45
  }
44
46
 
45
47
  /* RTL: overlay sidebar starts at inline-start (right edge) — slide in from the right */
@@ -49,6 +51,7 @@
49
51
 
50
52
  .ct-sidebar--over[data-state='open'] {
51
53
  translate: 0 0;
54
+ visibility: visible;
52
55
  }
53
56
 
54
57
  /* Push variant: slides off-screen and collapses flex space via negative margin */
@@ -129,7 +132,8 @@
129
132
  font-size: var(--font-size-sm);
130
133
  font-weight: var(--font-weight-medium);
131
134
  transition: background var(--duration-fast) var(--easing-standard),
132
- color var(--duration-fast) var(--easing-standard);
135
+ color var(--duration-fast) var(--easing-standard),
136
+ box-shadow var(--duration-fast) var(--easing-standard);
133
137
  cursor: pointer;
134
138
  }
135
139
 
@@ -151,7 +155,7 @@
151
155
  color: var(--color-brand-primary);
152
156
  font-weight: var(--font-weight-semibold);
153
157
  /* The datum: an orange leading edge marks the current location. */
154
- box-shadow: inset var(--border-thick) 0 0 0 var(--color-brand-accent);
158
+ box-shadow: inset var(--component-datum-edge-width) 0 0 0 var(--color-brand-accent);
155
159
  }
156
160
 
157
161
  .ct-nav-item__icon {
@@ -183,3 +187,12 @@
183
187
  background: var(--color-brand-accent);
184
188
  color: var(--color-brand-on-accent);
185
189
  }
190
+
191
+ @media (forced-colors: active) {
192
+ /* The datum edge is an inset box-shadow, which forced colors drops —
193
+ re-encode it as a border. */
194
+ .ct-nav-item--active,
195
+ .ct-nav-item[aria-current='page'] {
196
+ border-inline-start: var(--component-datum-edge-width) solid SelectedItem;
197
+ }
198
+ }
@@ -12,7 +12,8 @@
12
12
  z-index: var(--z-tooltip);
13
13
  padding: var(--space-4) var(--space-6);
14
14
  background: var(--color-brand-primary);
15
- color: var(--color-text-inverse);
15
+ /* on-primary, not inverse: inverse is near-invisible on the light primary of the dark theme */
16
+ color: var(--color-text-on-primary);
16
17
  font-family: var(--font-family-body);
17
18
  font-size: var(--font-size-sm);
18
19
  font-weight: var(--font-weight-semibold);
@@ -27,7 +28,10 @@
27
28
  .ct-skip-link:focus,
28
29
  .ct-skip-link:focus-visible {
29
30
  transform: translateY(0);
30
- outline: 2px solid var(--color-focus-ring);
31
+ /* on-primary, not the focus ring: the inset outline draws on the primary
32
+ background, and on-primary is the pairing validated against it in every
33
+ theme (the orange ring fails 3:1 on the dark theme's light primary). */
34
+ outline: 2px solid var(--color-text-on-primary);
31
35
  outline-offset: -2px;
32
36
  }
33
37
 
@@ -23,6 +23,9 @@
23
23
  appearance: none;
24
24
  width: 100%;
25
25
  height: var(--ct-slider-thumb-size);
26
+ /* WCAG 2.2 SC 2.5.8: small thumbs shrink the strip below 24px — keep
27
+ the pointer target at least that tall in fine-pointer contexts. */
28
+ min-height: 24px;
26
29
  background: transparent;
27
30
  cursor: pointer;
28
31
  margin: 0;
@@ -199,6 +202,8 @@
199
202
  position: relative;
200
203
  flex: 1;
201
204
  height: var(--ct-slider-thumb-size);
205
+ /* Same 24px pointer-target floor as the single-thumb input strip. */
206
+ min-height: 24px;
202
207
  }
203
208
 
204
209
  .ct-slider--range .ct-slider__track::after {
@@ -54,6 +54,9 @@
54
54
  .ct-switch__input:disabled {
55
55
  background: var(--color-border-subtle);
56
56
  cursor: not-allowed;
57
+ /* disabled is encoded via the muted track color; opting out of the global
58
+ :disabled opacity avoids double dimming (same pattern as the slider) */
59
+ opacity: 1;
57
60
  }
58
61
 
59
62
  .ct-switch__input:focus-visible {
@@ -61,12 +64,19 @@
61
64
  outline-offset: 2px;
62
65
  }
63
66
 
67
+ /* WCAG 2.2 SC 2.5.8: the small track is only 20px tall, so every pointer
68
+ context gets at least a 24px-tall hit area. */
69
+ .ct-switch__input::before {
70
+ content: '';
71
+ position: absolute;
72
+ inset-block: calc((var(--ct-switch-height) - 24px) / 2);
73
+ inset-inline: 0;
74
+ }
75
+
64
76
  /* Construct 44×44 target policy: meets the size in WCAG 2.2 SC 2.5.5
65
77
  (AAA) and exceeds the AA minimum in SC 2.5.8. */
66
78
  @media (pointer: coarse) {
67
79
  .ct-switch__input::before {
68
- content: '';
69
- position: absolute;
70
80
  inset-block: calc((var(--ct-switch-height) - 44px) / 2);
71
81
  inset-inline: calc((var(--ct-switch-width) - 44px) / 2);
72
82
  }
@@ -81,3 +91,53 @@
81
91
  --ct-switch-width: 52px;
82
92
  --ct-switch-height: 28px;
83
93
  }
94
+
95
+ @media (prefers-reduced-motion: reduce) {
96
+ .ct-switch__input,
97
+ .ct-switch__input::after {
98
+ transition: none;
99
+ }
100
+ }
101
+
102
+ @media (forced-colors: active) {
103
+ /* Track and thumb are shadow/background-drawn; redraw both with
104
+ system-palette borders so on/off state survives without shadows. */
105
+ .ct-switch__input {
106
+ border: 1px solid ButtonText;
107
+ background: Canvas;
108
+ }
109
+
110
+ .ct-switch__input::after {
111
+ border: 1px solid ButtonText;
112
+ background: Canvas;
113
+ box-shadow: none;
114
+ }
115
+
116
+ /* The checked fill must survive forced backgrounds; the extra hover
117
+ selector matches the specificity of the base hover rule. */
118
+ .ct-switch__input:checked,
119
+ .ct-switch__input:checked:not(:disabled):hover {
120
+ background: SelectedItem;
121
+ border-color: ButtonText;
122
+ forced-color-adjust: none;
123
+ }
124
+
125
+ .ct-switch__input:checked::after {
126
+ background: SelectedItemText;
127
+ border-color: SelectedItemText;
128
+ }
129
+
130
+ .ct-switch__input:disabled,
131
+ .ct-switch__input:disabled::after {
132
+ border-color: GrayText;
133
+ }
134
+
135
+ .ct-switch__input:checked:disabled {
136
+ background: GrayText;
137
+ }
138
+
139
+ .ct-switch__input:checked:disabled::after {
140
+ background: Canvas;
141
+ border-color: GrayText;
142
+ }
143
+ }
@@ -97,14 +97,18 @@
97
97
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 5'%3E%3Cpath d='M4 5L0 0h8z'/%3E%3C/svg%3E");
98
98
  }
99
99
 
100
- th[aria-sort='ascending'] .ct-table__sort-indicator::before {
100
+ th[aria-sort='ascending'] .ct-table__sort-indicator::before,
101
+ th[aria-sort='descending'] .ct-table__sort-indicator::after {
101
102
  background-color: var(--color-text-secondary);
102
103
  opacity: 1;
103
104
  }
104
105
 
105
- th[aria-sort='descending'] .ct-table__sort-indicator::after {
106
- background-color: var(--color-text-secondary);
107
- opacity: 1;
106
+ /* Direction is shape-encoded: the unsorted state keeps the dimmed
107
+ both-ways glyph, while a sorted column shows only its own arrow so the
108
+ state never relies on colour or opacity alone. */
109
+ th[aria-sort='ascending'] .ct-table__sort-indicator::after,
110
+ th[aria-sort='descending'] .ct-table__sort-indicator::before {
111
+ display: none;
108
112
  }
109
113
 
110
114
  .ct-table__cell--numeric .ct-table__sort {
@@ -137,3 +141,48 @@ th[aria-sort='descending'] .ct-table__sort-indicator::after {
137
141
  font-size: var(--font-size-xs);
138
142
  color: var(--color-text-muted);
139
143
  }
144
+
145
+ /* ── Selected / current rows ──
146
+ The datum: an orange leading edge marks the selected or current row,
147
+ consistent with list, sidebar, and card active states. The edge sits
148
+ on the row's first cell — box-shadow on <tr> is unreliable with
149
+ collapsed borders. Row text stays neutral; only the edge is orange. */
150
+ .ct-table tbody tr > :first-child {
151
+ transition: box-shadow var(--duration-fast) var(--easing-standard);
152
+ }
153
+
154
+ .ct-table tbody tr:is([aria-selected='true'], [aria-current='true'], [data-state='selected']) {
155
+ background: var(--color-bg-muted);
156
+ }
157
+
158
+ .ct-table tbody tr:is([aria-selected='true'], [aria-current='true'], [data-state='selected']) > :first-child {
159
+ box-shadow: inset var(--component-datum-edge-width) 0 0 0 var(--color-brand-accent);
160
+ }
161
+
162
+ /* ── Sorted column ──
163
+ The datum underlines the actively sorted column header, like the tab
164
+ indicator. Direction stays shape-encoded via the arrow glyphs. */
165
+ .ct-table thead th:is([aria-sort='ascending'], [aria-sort='descending']) {
166
+ box-shadow: inset 0 calc(-1 * var(--component-datum-edge-width)) 0 0 var(--color-brand-accent);
167
+ }
168
+
169
+ @media (forced-colors: active) {
170
+ /* Mask-drawn arrows paint via background-color; the sorted selectors
171
+ match the specificity of the full-strength rules above. */
172
+ .ct-table__sort-indicator::before,
173
+ .ct-table__sort-indicator::after,
174
+ th[aria-sort='ascending'] .ct-table__sort-indicator::before,
175
+ th[aria-sort='descending'] .ct-table__sort-indicator::after {
176
+ background-color: CanvasText;
177
+ }
178
+
179
+ /* The datum edges are inset box-shadows, which forced colors drops —
180
+ re-encode them as borders. */
181
+ .ct-table tbody tr:is([aria-selected='true'], [aria-current='true'], [data-state='selected']) > :first-child {
182
+ border-inline-start: var(--component-datum-edge-width) solid SelectedItem;
183
+ }
184
+
185
+ .ct-table thead th:is([aria-sort='ascending'], [aria-sort='descending']) {
186
+ border-block-end: var(--component-datum-edge-width) solid SelectedItem;
187
+ }
188
+ }