@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
@@ -30,19 +30,39 @@
30
30
  font-size: var(--ct-tabs-trigger-font-size);
31
31
  line-height: var(--ct-tabs-trigger-line-height);
32
32
  font-weight: var(--font-weight-medium);
33
+ text-decoration: none;
33
34
  cursor: pointer;
34
35
  transition: color var(--duration-fast) var(--easing-standard),
35
36
  background var(--duration-fast) var(--easing-standard);
36
37
  }
37
38
 
39
+ /* Triggers may be rendered as <a> for router-driven tabs. Neutralize the
40
+ global link styling (underline + link colors) so anchor triggers read
41
+ identically to buttons — the active-tab signal is the indicator, never
42
+ an underline. Deliberately excludes :hover so the shared trigger hover
43
+ rule above keeps winning. */
44
+ a.ct-tabs__trigger,
45
+ a.ct-tabs__trigger:active {
46
+ color: var(--color-text-secondary);
47
+ text-decoration: none;
48
+ }
49
+
38
50
  .ct-tabs__trigger::after {
39
51
  content: '';
40
52
  position: absolute;
41
53
  inset-inline: 0;
42
54
  inset-block-end: 0;
43
55
  height: var(--ct-tabs-indicator-size);
44
- background: transparent;
45
- transition: background var(--duration-fast) var(--easing-standard);
56
+ background: var(--ct-tabs-indicator-color);
57
+ /* The datum draws itself: the indicator grows from its origin on
58
+ activation instead of popping in. Collapsed to zero at rest. */
59
+ transform: scaleX(0);
60
+ transform-origin: 0 50%;
61
+ transition: transform var(--duration-fast) var(--easing-standard);
62
+ }
63
+
64
+ .ct-tabs__trigger:is(:dir(rtl))::after {
65
+ transform-origin: 100% 50%;
46
66
  }
47
67
 
48
68
  @media (hover: hover) {
@@ -65,7 +85,7 @@
65
85
 
66
86
  .ct-tabs__trigger[aria-selected='true']::after,
67
87
  .ct-tabs__trigger[data-state='active']::after {
68
- background: var(--ct-tabs-indicator-color);
88
+ transform: scale(1);
69
89
  }
70
90
 
71
91
  .ct-tabs__trigger:disabled,
@@ -114,6 +134,13 @@
114
134
  inset-block: 0;
115
135
  width: var(--ct-tabs-indicator-size);
116
136
  height: auto;
137
+ /* Vertical tabs draw the indicator from the block start. */
138
+ transform: scaleY(0);
139
+ transform-origin: 50% 0;
140
+ }
141
+
142
+ .ct-tabs--vertical .ct-tabs__trigger:is([aria-selected='true'], [data-state='active'])::after {
143
+ transform: scale(1);
117
144
  }
118
145
 
119
146
  /* Pill variant */
@@ -139,9 +166,34 @@
139
166
  box-shadow: var(--shadow-sm);
140
167
  }
141
168
 
169
+ /* Construct 44×44 target policy (WCAG 2.2 SC 2.5.5 is AAA) */
170
+
171
+ @media (pointer: coarse) {
172
+ .ct-tabs__trigger {
173
+ min-block-size: 44px;
174
+ }
175
+ }
176
+
142
177
  @media (prefers-reduced-motion: reduce) {
143
178
  .ct-tabs__trigger,
144
179
  .ct-tabs__trigger::after {
145
180
  transition: none;
146
181
  }
147
182
  }
183
+
184
+ @media (forced-colors: active) {
185
+ /* The active indicator is background-drawn and must survive forced
186
+ backgrounds. */
187
+ .ct-tabs__trigger[aria-selected='true']::after,
188
+ .ct-tabs__trigger[data-state='active']::after {
189
+ background: SelectedItem;
190
+ forced-color-adjust: none;
191
+ }
192
+
193
+ .ct-tabs--pill .ct-tabs__trigger[aria-selected='true'],
194
+ .ct-tabs--pill .ct-tabs__trigger[data-state='active'] {
195
+ background: SelectedItem;
196
+ color: SelectedItemText;
197
+ forced-color-adjust: none;
198
+ }
199
+ }
@@ -23,12 +23,21 @@
23
23
  box-shadow: var(--shadow-md);
24
24
  color: var(--color-text-primary);
25
25
  transition: opacity var(--duration-fast) var(--easing-standard),
26
- transform var(--duration-fast) var(--easing-standard);
26
+ transform var(--duration-fast) var(--easing-standard),
27
+ visibility 0s linear 0s;
27
28
  }
28
29
 
30
+ /* Delayed visibility (drawer.css pattern) removes the faded-out toast
31
+ from the accessibility tree and tab order once the fade completes;
32
+ reopening restores it instantly via the base transition. */
29
33
  .ct-toast[data-state='closed'] {
30
34
  opacity: 0;
31
35
  transform: translateY(8px);
36
+ visibility: hidden;
37
+ pointer-events: none;
38
+ transition: opacity var(--duration-fast) var(--easing-standard),
39
+ transform var(--duration-fast) var(--easing-standard),
40
+ visibility 0s linear var(--duration-fast);
32
41
  }
33
42
 
34
43
  .ct-toast__title {
@@ -24,11 +24,7 @@
24
24
  overflow-x: auto;
25
25
  overflow-y: hidden;
26
26
  -webkit-overflow-scrolling: touch;
27
- scrollbar-width: none;
28
- }
29
-
30
- .ct-toggle-group::-webkit-scrollbar {
31
- display: none;
27
+ scrollbar-width: thin;
32
28
  }
33
29
 
34
30
  /* ── Item ── */
@@ -194,3 +190,18 @@
194
190
  transition: none;
195
191
  }
196
192
  }
193
+
194
+ /* ── Forced colors ── */
195
+
196
+ @media (forced-colors: active) {
197
+ /* The pressed fill must survive forced backgrounds; the outlined
198
+ selectors match the specificity of that variant's override. */
199
+ .ct-toggle-group__item[aria-pressed='true'],
200
+ .ct-toggle-group__item[aria-checked='true'],
201
+ .ct-toggle-group--outlined .ct-toggle-group__item[aria-pressed='true'],
202
+ .ct-toggle-group--outlined .ct-toggle-group__item[aria-checked='true'] {
203
+ background: SelectedItem;
204
+ color: SelectedItemText;
205
+ forced-color-adjust: none;
206
+ }
207
+ }
@@ -15,7 +15,8 @@
15
15
  display: flex;
16
16
  align-items: center;
17
17
  gap: var(--ct-toolbar-gap);
18
- height: var(--ct-toolbar-height);
18
+ /* min-height, not height: the bar must grow with text scaling. */
19
+ min-height: var(--ct-toolbar-height);
19
20
  padding: 0 var(--ct-toolbar-padding-x);
20
21
  background: var(--color-bg-elevated);
21
22
  border-bottom: var(--border-thin) solid var(--color-border-subtle);
@@ -153,8 +154,15 @@
153
154
  --ct-toolbar-actions-gap: var(--space-2);
154
155
  }
155
156
 
157
+ /* WCAG 2.2 SC 2.5.8: dense padding alone cannot guarantee 24px links. */
158
+ .ct-toolbar--dense .ct-toolbar__nav-link {
159
+ min-height: 24px;
160
+ }
161
+
156
162
  /* ── Sticky ── */
157
163
 
164
+ /* Consumers: set --ct-scroll-offset-top on :root (see foundations.css) so
165
+ scrolled-to content is not obscured behind the bar (WCAG 2.4.11). */
158
166
  .ct-toolbar--sticky {
159
167
  position: sticky;
160
168
  inset-block-start: 0;
@@ -213,6 +221,14 @@
213
221
  }
214
222
  }
215
223
 
224
+ /* ── Construct 44×44 target policy (WCAG 2.2 SC 2.5.5 is AAA) ── */
225
+
226
+ @media (pointer: coarse) {
227
+ .ct-toolbar__nav-link {
228
+ min-height: 44px;
229
+ }
230
+ }
231
+
216
232
  /* ── Reduced Motion ── */
217
233
 
218
234
  @media (prefers-reduced-motion: reduce) {
@@ -118,10 +118,25 @@
118
118
  border-radius: var(--ct-tree-toggle-radius);
119
119
  color: var(--ct-tree-toggle-color);
120
120
  cursor: pointer;
121
+ position: relative;
121
122
  transition: color var(--duration-fast) var(--easing-standard),
122
123
  background var(--duration-fast) var(--easing-standard);
123
124
  }
124
125
 
126
+ /* WCAG 2.2 SC 2.5.8: the visual chevron stays 1rem; the pointer target
127
+ does not. The hit area overlaps the row, mirroring checkbox.css. */
128
+ .ct-tree__toggle::before {
129
+ content: '';
130
+ position: absolute;
131
+ inset: calc((var(--ct-tree-toggle-size) - 24px) / 2);
132
+ }
133
+
134
+ @media (pointer: coarse) {
135
+ .ct-tree__toggle::before {
136
+ inset: calc((var(--ct-tree-toggle-size) - 44px) / 2);
137
+ }
138
+ }
139
+
125
140
  @media (hover: hover) {
126
141
  .ct-tree__toggle:hover {
127
142
  color: var(--ct-tree-toggle-color-hover);
@@ -194,6 +209,13 @@
194
209
  --ct-tree-actions-opacity: 1;
195
210
  }
196
211
 
212
+ /* Touch surfaces have no hover to reveal row actions — keep them visible. */
213
+ @media (hover: none) {
214
+ .ct-tree {
215
+ --ct-tree-actions-opacity: 1;
216
+ }
217
+ }
218
+
197
219
  /* ── Selection ── */
198
220
 
199
221
  .ct-tree__node[aria-selected='true'] > .ct-tree__row,
@@ -277,6 +299,14 @@
277
299
  background: var(--color-bg-elevated);
278
300
  }
279
301
 
302
+ /* ── Construct 44×44 target policy (WCAG 2.2 SC 2.5.5 is AAA) ── */
303
+
304
+ @media (pointer: coarse) {
305
+ .ct-tree__row {
306
+ min-height: 44px;
307
+ }
308
+ }
309
+
280
310
  /* ── Reduced motion ── */
281
311
 
282
312
  @media (prefers-reduced-motion: reduce) {
package/docs/behaviors.md CHANGED
@@ -15,18 +15,37 @@ Every factory validates its DOM contract, returns the existing controller when i
15
15
 
16
16
  | Factory | Behavior contract |
17
17
  | --- | --- |
18
- | `createModalController` | Dialog semantics, initial focus, focus trap, Escape/backdrop dismissal, nested overlay ordering, scroll lock, focus return |
18
+ | `createModalController` | Dialog semantics, initial focus, focus trap, Escape/backdrop dismissal, nested overlay ordering, scroll lock, focus return, optional `inertBackground` |
19
19
  | `createDrawerController` | Modal drawer behavior with the same focus/dismissal invariants and toggle trigger support |
20
20
  | `createTabsController` | Roving tabindex, horizontal/vertical arrows, Home/End, automatic or manual activation |
21
- | `createToggleGroupController` | Single/multiple pressed state, roving tabindex, orientation-aware arrows, controlled programmatic value |
22
- | `createDropdownController` | ARIA menu button, first/last focus, arrows, Home/End, typeahead, outside/Escape dismissal, menu item selection |
23
- | `createSelectMenuController` | Button/listbox selection, active option, typeahead, keyboard opening and dismissal, value synchronization |
24
- | `createComboboxController` | Editable combobox, virtual listbox focus, optional local filtering, active descendant, announcements, selection |
21
+ | `createToggleGroupController` | Single/multiple pressed state (`aria-pressed`, or `aria-checked` radio semantics for single groups authored with `role="radiogroup"`/`role="radio"`), roving tabindex, orientation-aware arrows, controlled programmatic value |
22
+ | `createDropdownController` | ARIA menu button, first/last focus, arrows, Home/End, typeahead (open and closed), outside/Escape dismissal, menu item selection |
23
+ | `createSelectMenuController` | Button/listbox selection, active option, typeahead, PageUp/PageDown, Tab commits the active option, keyboard opening and dismissal, value synchronization |
24
+ | `createComboboxController` | Editable combobox, virtual listbox focus, optional local filtering, active descendant, Alt+Arrow open/close, IME-safe key handling, announcements, selection |
25
25
  | `createTooltipController` | Focus and hover timing, touch toggle, Escape/outside dismissal, described-by wiring |
26
26
  | `createPopoverController` | Non-modal popup disclosure, optional focus entry, hover/focus modes, outside/Escape dismissal, focus return |
27
27
 
28
28
  The package includes TypeScript declarations for options, callbacks, controller methods, and event-detail types.
29
29
 
30
+ ### What the caller still owns (accessible names and live regions)
31
+
32
+ Controllers wire state attributes, but accessible names and announcement targets come from your markup. Missing ones are reported as console warnings in the browser:
33
+
34
+ | Controller | You must provide |
35
+ | --- | --- |
36
+ | Modal / Drawer | `aria-labelledby` or `aria-label` on the dialog element |
37
+ | Dropdown | `aria-label` or `aria-labelledby` on the `role="menu"` element |
38
+ | Toggle group | `aria-label` or `aria-labelledby` on the group/radiogroup/toolbar root |
39
+ | Select menu | A visible label or `aria-labelledby` for the trigger, and labels on options |
40
+ | Combobox | A `<label>` for the input, an `aria-label` on the listbox, and a visually hidden live-region element passed as `options.status` — without it filter results are never announced |
41
+ | Tooltip | A keyboard-focusable trigger (natively focusable or `tabindex="0"`) and text content in the tooltip element |
42
+ | Popover | An accessible name when using `role="dialog"` content |
43
+
44
+ ### Documented deviations from APG
45
+
46
+ - Select menu and dropdown arrow navigation wraps at the ends (APG select-only combobox specifies no wrap); wrapping matches Construct's menu behavior and is intentional.
47
+ - Typing on a closed select menu commits the matching value immediately, mirroring native `<select>` on Windows, instead of opening the listbox first.
48
+
30
49
  ## Modal example
31
50
 
32
51
  ```html
@@ -64,6 +83,8 @@ controller.destroy();
64
83
 
65
84
  Buttons with `data-ct-dismiss` are discovered automatically. The caller still owns the accessible name through `aria-labelledby` or `aria-label` and owns the action performed after confirmation.
66
85
 
86
+ Pass `inertBackground: true` to additionally set `inert` on the container's DOM siblings while the dialog is open — a belt-and-suspenders layer on top of `aria-modal` for legacy screen-reader virtual cursors. Content portaled into the DOM after opening (for example popup layers) is unaffected.
87
+
67
88
  ## Tabs example
68
89
 
69
90
  ```js
@@ -20,6 +20,7 @@ A consistent, accessible, professional design system for modern web applications
20
20
  ### Don't
21
21
 
22
22
  - Don't rely on color alone for status information without text or icons
23
+ - Don't rely on hue alone to distinguish chart series — use direct labels, ordered legends, and the always-present data-table fallback; several series pairs are near-identical in luminance for color-blind users
23
24
  - Don't create custom controls without ARIA roles and keyboard support
24
25
  - Don't use fixed pixel layouts for content that can grow
25
26
  - Don't use low-contrast text or disabled states that are unreadable
@@ -28,7 +29,12 @@ A consistent, accessible, professional design system for modern web applications
28
29
  ## Accessibility Rules (Minimum)
29
30
 
30
31
  - **Visible Focus**: All interactive elements must have visible focus indication
31
- - **Labels**: Every form input must have a label (explicit `<label>` or `aria-label`)
32
+ - **Labels**: Every form input must have a label. Prefer a visible `<label>`; use `aria-label` only when a visible label is impossible, and never let it differ from visible text (2.5.3 Label in Name — note that `aria-label` overrides an associated `<label>` when both are present)
33
+ - **Headings**: Keep `h1`–`h6` in order without skipping levels, including inside cards, modals, and shells
34
+ - **Landmarks**: Wrap navigation lists in `<nav>` (also inside `<aside>` sidebars), label repeated landmarks (`aria-label`), and provide a skip link to `main` (see `skip-link.css`)
35
+ - **Truncation**: `nowrap` + ellipsis on essential text (titles, values, labels) needs a way to reach the full text — a tooltip, a wrapping variant, or full text elsewhere on the page
36
+ - **Status colors**: Semantic color (toast, banner, alert, badge) must be paired with an icon or explicit wording; the tinted border alone is decorative and intentionally not contrast-gated
37
+ - **Sticky chrome**: Products using `--sticky`/`--fixed` navbar, toolbar, or banner variants must set `--ct-scroll-offset-top`/`--ct-scroll-offset-bottom` on `:root` so focused elements are never scrolled underneath (2.4.11); the app shells set their own scroll padding automatically
32
38
  - **Keyboard Navigation**: Use the tested headless controller for supported composite widgets; framework adapters must implement the documented pattern for others such as Datepicker and Tree
33
39
  - **ARIA**: Use ARIA attributes only where semantic HTML isn't sufficient
34
40
  - **Links**: Links must be distinguishable without color alone (e.g., underline)
@@ -86,9 +92,10 @@ framework adapter with equivalent tested semantics; CSS state selectors alone do
86
92
  - **Backdrop**: Click closes drawer
87
93
 
88
94
  ### Tooltip
89
- - **Open**: On hover and focus
95
+ - **Open**: On hover and keyboard focus (the trigger must be focusable)
90
96
  - **Close**: On blur and Esc
91
97
  - **Role**: `role="tooltip"` with `aria-describedby`
98
+ - **Implementation**: The CSS `:hover`/`:focus-within` styles alone do not satisfy WCAG 1.4.13 — Esc dismissal and hover persistence require `createTooltipController` or an equivalent adapter
92
99
 
93
100
  ### Tree (`ct-tree`)
94
101
 
@@ -164,6 +171,27 @@ Use these attributes for state management:
164
171
  Never use `aria-disabled="true"` alone to block a native button: it announces state but does not suppress
165
172
  activation. Use the native `disabled` attribute whenever the HTML element supports it.
166
173
 
174
+ ## The Datum
175
+
176
+ Construct's signature is one orange reference line — "the datum" — that marks what is focused, active,
177
+ current, or the reference value. The grammar lives in `components/datum.css` (eyebrow, scale, frame,
178
+ metrics), in active leading edges (list, sidebar, card, table rows), in the tab indicator and sorted
179
+ column, and in the chart reference line. Its dimensions are component tokens (`--component-datum-*`).
180
+
181
+ Rules that keep the signature strong and accessible:
182
+
183
+ - **One datum per surface.** Orange marks exactly one thing per view region — the current, active, or
184
+ reference element. Never let two orange edges compete; if everything is highlighted, nothing is.
185
+ - **Orange is never text.** Labels and values stay on `--color-text-*` neutrals; orange is carried by
186
+ non-text ticks, edges, rules, and lines.
187
+ - **Edges are `--color-brand-accent`; meaning-bearing fills are `--color-brand-accent-strong`**
188
+ (validated ≥3:1 against muted/canvas/surface tracks per theme). A decorative rule may use plain
189
+ accent; anything that encodes a value (progress, chart reference) uses strong.
190
+ - **The datum enters, it never pops.** State changes draw the edge in from its origin
191
+ (`--duration-fast` / `--easing-standard`); `prefers-reduced-motion` disables this globally.
192
+ - **Forced colors re-encode edges as borders.** Inset box-shadow edges are dropped by forced colors —
193
+ every new edge needs a `SelectedItem` border fallback.
194
+
167
195
  ## Breakpoints & Media Queries
168
196
 
169
197
  Construct defines breakpoint tokens in `tokens/primitives.json`:
package/foundations.css CHANGED
@@ -18,6 +18,12 @@
18
18
  html {
19
19
  height: 100%;
20
20
  text-size-adjust: 100%;
21
+ /* WCAG 2.4.11 Focus Not Obscured contract: sticky/fixed chrome (navbar,
22
+ toolbar, banner) does not know the document, so products publish its
23
+ height by setting --ct-scroll-offset-top/-bottom on :root/html. Scroll
24
+ operations then keep focused or anchored content clear of the bars. */
25
+ scroll-padding-block-start: var(--ct-scroll-offset-top, 0px);
26
+ scroll-padding-block-end: var(--ct-scroll-offset-bottom, 0px);
21
27
  }
22
28
 
23
29
  body {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neuravision/construct",
3
- "version": "2.1.1",
3
+ "version": "2.1.2",
4
4
  "description": "Construct Design System - Accessible, token-based design system for modern web applications",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,8 +33,9 @@
33
33
  "audit": "npm audit --audit-level=high",
34
34
  "tokens:validate": "node scripts/validate-tokens.mjs",
35
35
  "tokens:test": "node scripts/test-token-pipeline.mjs",
36
+ "themes:validate": "node scripts/construct-theme.mjs --theme themes/walnut.theme.json --validate-only",
36
37
  "check": "npm run check:tokens && npm run check:standalone && npm run check:css && npm run check:exports && npm run check:version",
37
- "check:tokens": "node scripts/build-tokens.mjs --check && npm run tokens:validate && npm run tokens:test",
38
+ "check:tokens": "node scripts/build-tokens.mjs --check && npm run tokens:validate && npm run tokens:test && npm run themes:validate",
38
39
  "check:standalone": "node scripts/build-standalone-components.mjs --check",
39
40
  "check:css": "node scripts/validate-css-vars.mjs",
40
41
  "check:exports": "node scripts/validate-package-exports.mjs",
@@ -78,7 +79,8 @@
78
79
  "default": "./tokens/tokens.js"
79
80
  },
80
81
  "./schemas/*.json": "./schemas/*.json",
81
- "./schemas/examples/*.json": "./schemas/examples/*.json"
82
+ "./schemas/examples/*.json": "./schemas/examples/*.json",
83
+ "./themes/*.json": "./themes/*.json"
82
84
  },
83
85
  "files": [
84
86
  "foundations.css",
@@ -89,6 +91,8 @@
89
91
  "tokens/*.ts",
90
92
  "tokens/README.md",
91
93
  "schemas/**/*.json",
94
+ "themes/*.json",
95
+ "themes/README.md",
92
96
  "components/*.css",
93
97
  "components/README.md",
94
98
  "standalone/*.css",
@@ -3,7 +3,7 @@
3
3
  "$id": "https://neuravision.dev/construct/schemas/components.schema.json",
4
4
  "title": "Construct component token contract",
5
5
  "type": "object",
6
- "required": ["button", "control", "card", "status", "metric", "meter"],
6
+ "required": ["button", "control", "card", "status", "metric", "meter", "datum"],
7
7
  "additionalProperties": false,
8
8
  "properties": {
9
9
  "button": {
@@ -87,6 +87,21 @@
87
87
  "radius": { "$ref": "#/$defs/token" },
88
88
  "track": { "$ref": "#/$defs/token" }
89
89
  }
90
+ },
91
+ "datum": {
92
+ "type": "object",
93
+ "additionalProperties": false,
94
+ "required": ["edgeWidth", "tickWidth", "tickHeight", "originLength", "markSize", "scaleHeight", "scaleMinorStep", "scaleMajorStep"],
95
+ "properties": {
96
+ "edgeWidth": { "$ref": "#/$defs/token" },
97
+ "tickWidth": { "$ref": "#/$defs/token" },
98
+ "tickHeight": { "$ref": "#/$defs/token" },
99
+ "originLength": { "$ref": "#/$defs/token" },
100
+ "markSize": { "$ref": "#/$defs/token" },
101
+ "scaleHeight": { "$ref": "#/$defs/token" },
102
+ "scaleMinorStep": { "$ref": "#/$defs/token" },
103
+ "scaleMajorStep": { "$ref": "#/$defs/token" }
104
+ }
90
105
  }
91
106
  },
92
107
  "$defs": {
@@ -27,7 +27,7 @@
27
27
  "brand": {
28
28
  "type": "object",
29
29
  "additionalProperties": false,
30
- "required": ["primary", "primaryHover", "primaryActive", "accent", "accentHover", "accentActive", "onAccent"],
30
+ "required": ["primary", "primaryHover", "primaryActive", "accent", "accentHover", "accentActive", "accentStrong", "onAccent"],
31
31
  "properties": {
32
32
  "primary": { "$ref": "#/$defs/token" },
33
33
  "primaryHover": { "$ref": "#/$defs/token" },
@@ -35,6 +35,7 @@
35
35
  "accent": { "$ref": "#/$defs/token" },
36
36
  "accentHover": { "$ref": "#/$defs/token" },
37
37
  "accentActive": { "$ref": "#/$defs/token" },
38
+ "accentStrong": { "$ref": "#/$defs/token" },
38
39
  "onAccent": { "$ref": "#/$defs/token" }
39
40
  }
40
41
  },
@@ -16,7 +16,7 @@
16
16
  "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"
17
17
  },
18
18
  "extends": {
19
- "enum": ["light", "dark", "high-contrast"]
19
+ "enum": ["light", "dark", "high-contrast", "high-contrast-dark"]
20
20
  },
21
21
  "selector": {
22
22
  "type": "string",
@@ -13,6 +13,7 @@ const sourcePaths = {
13
13
  semanticLight: path.join(tokensDir, 'semantic.light.json'),
14
14
  semanticDark: path.join(tokensDir, 'semantic.dark.json'),
15
15
  semanticHighContrast: path.join(tokensDir, 'semantic.high-contrast.json'),
16
+ semanticHighContrastDark: path.join(tokensDir, 'semantic.high-contrast-dark.json'),
16
17
  components: path.join(tokensDir, 'components.json'),
17
18
  };
18
19
 
@@ -23,7 +24,7 @@ const schemaPaths = {
23
24
  theme: path.join(schemasDir, 'theme.schema.json'),
24
25
  };
25
26
 
26
- const builtInThemeNames = new Set(['light', 'dark', 'high-contrast']);
27
+ const builtInThemeNames = new Set(['light', 'dark', 'high-contrast', 'high-contrast-dark']);
27
28
 
28
29
  const defaultContrastPairs = [
29
30
  ...['primary', 'secondary', 'muted'].flatMap((text) =>
@@ -58,6 +59,40 @@ const defaultContrastPairs = [
58
59
  background: `semantic.color.stateSurface.${state}`,
59
60
  minRatio: 4.5,
60
61
  })),
62
+ ...['info', 'success', 'warning', 'danger'].flatMap((state) =>
63
+ ['canvas', 'surface', 'elevated'].map((background) => ({
64
+ name: `${state} text on ${background}`,
65
+ foreground: `semantic.color.stateText.${state}`,
66
+ background: `semantic.color.background.${background}`,
67
+ minRatio: 4.5,
68
+ }))
69
+ ),
70
+ ...['primary', 'secondary'].flatMap((text) =>
71
+ ['info', 'success', 'warning', 'danger'].map((state) => ({
72
+ name: `${text} text on ${state} surface`,
73
+ foreground: `semantic.color.text.${text}`,
74
+ background: `semantic.color.stateSurface.${state}`,
75
+ minRatio: 4.5,
76
+ }))
77
+ ),
78
+ ...['info', 'success', 'warning', 'danger'].map((state) => ({
79
+ name: `${state} fill on muted track`,
80
+ foreground: `semantic.color.state.${state}`,
81
+ background: 'semantic.color.background.muted',
82
+ minRatio: 3,
83
+ })),
84
+ ...['canvas', 'surface', 'muted'].map((background) => ({
85
+ name: `strong accent on ${background}`,
86
+ foreground: 'semantic.color.brand.accentStrong',
87
+ background: `semantic.color.background.${background}`,
88
+ minRatio: 3,
89
+ })),
90
+ ...['canvas', 'surface', 'elevated'].map((background) => ({
91
+ name: `strong border on ${background}`,
92
+ foreground: 'semantic.color.border.strong',
93
+ background: `semantic.color.background.${background}`,
94
+ minRatio: 3,
95
+ })),
61
96
  ...['info', 'success', 'warning', 'danger'].map((state) => ({
62
97
  name: `text on solid ${state}`,
63
98
  foreground: 'semantic.color.state.onSolid',
@@ -96,6 +131,12 @@ const defaultContrastPairs = [
96
131
  background: 'components.control.background',
97
132
  minRatio: 3,
98
133
  },
134
+ {
135
+ name: 'placeholder on control background',
136
+ foreground: 'components.control.placeholder',
137
+ background: 'components.control.background',
138
+ minRatio: 4.5,
139
+ },
99
140
  ];
100
141
 
101
142
  const fontFallbacks = {
@@ -533,9 +574,10 @@ const componentVarName = (tokenPath) =>
533
574
  const inferPrimitiveUnit = (tokenPath) => {
534
575
  const [group, subgroup] = tokenPath;
535
576
  if (group === 'font') {
536
- if (subgroup === 'size' || subgroup === 'lineHeight' || subgroup === 'letterSpacing') {
537
- return 'px';
538
- }
577
+ /* Type scale in rem so user browser font-size preferences are honored
578
+ (1.4.4); source values stay authored in px and are divided by 16. */
579
+ if (subgroup === 'size' || subgroup === 'lineHeight') return 'rem';
580
+ if (subgroup === 'letterSpacing') return 'px';
539
581
  return '';
540
582
  }
541
583
  if (['space', 'radius', 'border', 'size', 'layout', 'breakpoint'].includes(group)) {
@@ -548,6 +590,7 @@ const inferPrimitiveUnit = (tokenPath) => {
548
590
  const formatPrimitiveValue = (tokenPath, value) => {
549
591
  if (typeof value === 'number') {
550
592
  const unit = inferPrimitiveUnit(tokenPath);
593
+ if (unit === 'rem') return `${Number((value / 16).toFixed(4))}rem`;
551
594
  return unit ? `${value}${unit}` : String(value);
552
595
  }
553
596
  if (tokenPath[0] === 'font' && tokenPath[1] === 'family') {
@@ -1035,6 +1078,7 @@ const buildTokens = (options) => {
1035
1078
  const semanticLightRaw = readJson(sourcePaths.semanticLight);
1036
1079
  const semanticDarkOverride = readJson(sourcePaths.semanticDark);
1037
1080
  const semanticHighContrastOverride = readJson(sourcePaths.semanticHighContrast);
1081
+ const semanticHighContrastDarkOverride = readJson(sourcePaths.semanticHighContrastDark);
1038
1082
  const componentsRaw = readJson(sourcePaths.components);
1039
1083
 
1040
1084
  const schemas = Object.fromEntries(
@@ -1055,11 +1099,17 @@ const buildTokens = (options) => {
1055
1099
  semanticLightRaw,
1056
1100
  'tokens/semantic.high-contrast.json'
1057
1101
  );
1102
+ assertKnownOverride(
1103
+ semanticHighContrastDarkOverride,
1104
+ semanticLightRaw,
1105
+ 'tokens/semantic.high-contrast-dark.json'
1106
+ );
1058
1107
 
1059
1108
  const semanticBuiltInRaw = {
1060
1109
  light: semanticLightRaw,
1061
1110
  dark: mergeDeep(semanticLightRaw, semanticDarkOverride),
1062
1111
  'high-contrast': mergeDeep(semanticLightRaw, semanticHighContrastOverride),
1112
+ 'high-contrast-dark': mergeDeep(semanticLightRaw, semanticHighContrastDarkOverride),
1063
1113
  };
1064
1114
 
1065
1115
  for (const [name, semanticRaw] of Object.entries(semanticBuiltInRaw)) {
@@ -1112,6 +1162,17 @@ const buildTokens = (options) => {
1112
1162
  componentRaw: componentsRaw,
1113
1163
  ...buildThemeLayers('high-contrast', semanticBuiltInRaw['high-contrast'], componentsRaw),
1114
1164
  },
1165
+ 'high-contrast-dark': {
1166
+ name: 'high-contrast-dark',
1167
+ selector: '[data-theme="high-contrast-dark"]',
1168
+ semanticRaw: semanticBuiltInRaw['high-contrast-dark'],
1169
+ componentRaw: componentsRaw,
1170
+ ...buildThemeLayers(
1171
+ 'high-contrast-dark',
1172
+ semanticBuiltInRaw['high-contrast-dark'],
1173
+ componentsRaw
1174
+ ),
1175
+ },
1115
1176
  };
1116
1177
 
1117
1178
  const customThemes = options.themes.map((themePath) => {
@@ -1178,6 +1239,10 @@ const buildTokens = (options) => {
1178
1239
  const highContrastSemanticEntries =
1179
1240
  builtInThemes['high-contrast'].semanticLayer.cssEntries;
1180
1241
  const highContrastComponentEntries = builtInThemes['high-contrast'].componentLayer.cssEntries;
1242
+ const highContrastDarkSemanticEntries =
1243
+ builtInThemes['high-contrast-dark'].semanticLayer.cssEntries;
1244
+ const highContrastDarkComponentEntries =
1245
+ builtInThemes['high-contrast-dark'].componentLayer.cssEntries;
1181
1246
 
1182
1247
  const primitiveEntries = primitiveLayer.cssEntries;
1183
1248
  const semanticEntries = lightTheme.semanticLayer.cssEntries;
@@ -1287,6 +1352,12 @@ const buildTokens = (options) => {
1287
1352
  highContrastSemanticEntries,
1288
1353
  highContrastComponentEntries
1289
1354
  ),
1355
+ ...themeBlock(
1356
+ builtInThemes['high-contrast-dark'].selector,
1357
+ 'high-contrast-dark',
1358
+ highContrastDarkSemanticEntries,
1359
+ highContrastDarkComponentEntries
1360
+ ),
1290
1361
  ...customThemeBlocks,
1291
1362
  ...mediaBlock(
1292
1363
  '(prefers-color-scheme: dark)',
@@ -1302,12 +1373,23 @@ const buildTokens = (options) => {
1302
1373
  highContrastSemanticEntries,
1303
1374
  highContrastComponentEntries
1304
1375
  ),
1376
+ /* Emitted after the light high-contrast block so it wins the cascade
1377
+ when both prefers-contrast: more and a dark color scheme match —
1378
+ dark-mode users asking for more contrast keep a dark UI. */
1379
+ ...mediaBlock(
1380
+ '(prefers-contrast: more) and (prefers-color-scheme: dark)',
1381
+ ':root:not([data-theme])',
1382
+ 'high-contrast-dark (system)',
1383
+ highContrastDarkSemanticEntries,
1384
+ highContrastDarkComponentEntries
1385
+ ),
1305
1386
  ];
1306
1387
 
1307
1388
  const semanticThemes = {};
1308
1389
  const componentThemes = {};
1309
1390
  for (const theme of allThemes) {
1310
- const exportName = theme.name === 'high-contrast' ? 'highContrast' : theme.name;
1391
+ const exportNames = { 'high-contrast': 'highContrast', 'high-contrast-dark': 'highContrastDark' };
1392
+ const exportName = exportNames[theme.name] ?? theme.name;
1311
1393
  semanticThemes[exportName] = theme.semanticLayer.resolved;
1312
1394
  componentThemes[exportName] = theme.componentLayer.resolved;
1313
1395
  }