@titan-design/react-ui 0.11.0 → 0.12.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 (142) hide show
  1. package/dist/bodymap.js +173 -343
  2. package/dist/bodymap.js.map +1 -1
  3. package/dist/bodymap.mjs +173 -343
  4. package/dist/bodymap.mjs.map +1 -1
  5. package/dist/index.d.mts +24 -21
  6. package/dist/index.d.ts +24 -21
  7. package/dist/index.js +366 -561
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +360 -553
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/pages.js +242 -407
  12. package/dist/pages.js.map +1 -1
  13. package/dist/pages.mjs +242 -407
  14. package/dist/pages.mjs.map +1 -1
  15. package/dist/theme/index.d.mts +409 -415
  16. package/dist/theme/index.d.ts +409 -415
  17. package/dist/theme/index.js +234 -408
  18. package/dist/theme/index.js.map +1 -1
  19. package/dist/theme/index.mjs +227 -399
  20. package/dist/theme/index.mjs.map +1 -1
  21. package/dist/theme/tokens-css.js +141 -140
  22. package/dist/theme/tokens-css.js.map +1 -1
  23. package/dist/theme/tokens-css.mjs +141 -140
  24. package/dist/theme/tokens-css.mjs.map +1 -1
  25. package/package.json +1 -1
  26. package/src/arch/Architecture.stories.tsx +9 -9
  27. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  28. package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +3 -3
  29. package/src/components/custom/Fatigue/FatigueLights.stories.tsx +2 -2
  30. package/src/components/custom/Fatigue/GhostSpark.stories.tsx +3 -3
  31. package/src/components/custom/Fatigue/LiveFatigueCard.tsx +1 -1
  32. package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +2 -2
  33. package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +2 -2
  34. package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +4 -4
  35. package/src/components/custom/Fatigue/VelocityHero.stories.tsx +3 -3
  36. package/src/components/custom/Fatigue/VerdictHero.stories.tsx +2 -2
  37. package/src/components/custom/Fatigue/fatigue-tokens.ts +12 -12
  38. package/src/components/custom/Sidebar/Sidebar.tsx +4 -4
  39. package/src/components/custom/Table/Table.tsx +2 -2
  40. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +3 -3
  41. package/src/components/custom/Workout/BaseBadge.test.tsx +6 -1
  42. package/src/components/custom/Workout/BaseBadge.tsx +1 -1
  43. package/src/components/custom/Workout/BodyMap.tsx +2 -2
  44. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +2 -2
  45. package/src/components/custom/Workout/DeviationBar.test.tsx +2 -1
  46. package/src/components/custom/Workout/DeviationBar.tsx +3 -2
  47. package/src/components/custom/Workout/ExerciseCard.test.tsx +3 -3
  48. package/src/components/custom/Workout/ExerciseCard.tsx +2 -2
  49. package/src/components/custom/Workout/ExerciseDetailPage.tsx +4 -4
  50. package/src/components/custom/Workout/InputBar.tsx +2 -2
  51. package/src/components/custom/Workout/LiveAuraFrame.stories.tsx +1 -1
  52. package/src/components/custom/Workout/LiveAuraFrame.test.tsx +1 -1
  53. package/src/components/custom/Workout/LiveAuraFrame.tsx +3 -3
  54. package/src/components/custom/Workout/MesoCard.tsx +3 -1
  55. package/src/components/custom/Workout/MuscleGroupChip.tsx +4 -3
  56. package/src/components/custom/Workout/PrHistoryModal.tsx +2 -2
  57. package/src/components/custom/Workout/ProgramPlanningPage.tsx +1 -1
  58. package/src/components/custom/Workout/ReadinessCheck.tsx +2 -2
  59. package/src/components/custom/Workout/RestTimer.tsx +2 -2
  60. package/src/components/custom/Workout/S3SetTypes.stories.tsx +3 -3
  61. package/src/components/custom/Workout/SessionHeader.stories.tsx +2 -2
  62. package/src/components/custom/Workout/SessionHeader.tsx +1 -1
  63. package/src/components/custom/Workout/SessionRail.stories.tsx +2 -2
  64. package/src/components/custom/Workout/SessionRail.tsx +13 -5
  65. package/src/components/custom/Workout/SetBar.tsx +3 -3
  66. package/src/components/custom/Workout/SetRow.test.tsx +6 -3
  67. package/src/components/custom/Workout/StatusDot.tsx +5 -4
  68. package/src/components/custom/Workout/StrengthTrendChart.tsx +1 -1
  69. package/src/components/custom/Workout/TrainingStatusPage.tsx +2 -2
  70. package/src/components/custom/Workout/VelocityStrip.test.tsx +5 -2
  71. package/src/components/custom/Workout/VelocityStrip.tsx +4 -4
  72. package/src/components/custom/Workout/VolumeLandmarkBar.tsx +3 -3
  73. package/src/components/custom/Workout/WeightBadge.test.tsx +6 -1
  74. package/src/components/custom/Workout/WeightBadge.tsx +2 -1
  75. package/src/components/custom/Workout/WorkoutCard.tsx +3 -1
  76. package/src/components/custom/Workout/WorkoutPill.tsx +5 -2
  77. package/src/components/custom/Workout/ZoneTrack.tsx +4 -4
  78. package/src/components/custom/Workout/setHeadingKit.tsx +14 -8
  79. package/src/components/custom/Workout/velocity-story-kit.tsx +2 -2
  80. package/src/components/custom/charts/SetBarChart.tsx +3 -3
  81. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  82. package/src/components/custom/stepper/Stepper.tsx +3 -3
  83. package/src/components/shell/DeviceMenu.tsx +2 -2
  84. package/src/components/shell/SideNav.tsx +1 -1
  85. package/src/components/shell/TopBar.tsx +1 -1
  86. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  87. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  88. package/src/components/ui/avatar/Avatar.tsx +2 -2
  89. package/src/components/ui/badge/Badge.tsx +2 -2
  90. package/src/components/ui/card/Card.tsx +3 -3
  91. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  92. package/src/components/ui/collapse/Collapse.tsx +1 -1
  93. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  94. package/src/components/ui/drawer/Drawer.tsx +3 -3
  95. package/src/components/ui/form-field/FormField.tsx +1 -1
  96. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  97. package/src/components/ui/menu/Menu.tsx +2 -2
  98. package/src/components/ui/pill/Pill.tsx +2 -2
  99. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  100. package/src/components/ui/popover/Popover.tsx +1 -1
  101. package/src/components/ui/progress/Progress.tsx +3 -3
  102. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  103. package/src/components/ui/radio/Radio.tsx +4 -4
  104. package/src/components/ui/select/Select.tsx +3 -3
  105. package/src/components/ui/surface/Surface.stories.tsx +3 -3
  106. package/src/components/ui/surface/Surface.test.tsx +32 -19
  107. package/src/components/ui/surface/Surface.tsx +3 -3
  108. package/src/components/ui/surface/SurfaceContext.ts +19 -29
  109. package/src/components/ui/surface/surface.contract.test.ts +57 -44
  110. package/src/components/ui/switch/Switch.tsx +1 -1
  111. package/src/components/ui/tabs/Tabs.tsx +2 -2
  112. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +5 -5
  113. package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +1 -1
  114. package/src/components/ui/toolbar-button/ToolbarButton.tsx +36 -22
  115. package/src/stories/PresetShowcase.stories.tsx +21 -21
  116. package/src/theme/ColorPalettes.stories.tsx +41 -38
  117. package/src/theme/ColorPrimitives.stories.tsx +89 -41
  118. package/src/theme/Depth.stories.tsx +312 -0
  119. package/src/theme/DepthCalibration.stories.tsx +655 -0
  120. package/src/theme/ThemeProvider.tsx +1 -1
  121. package/src/theme/barrel.ts +7 -14
  122. package/src/theme/color-stories.coverage.test.ts +15 -20
  123. package/src/theme/color-story-kit.tsx +1 -1
  124. package/src/theme/color-utils.ts +197 -0
  125. package/src/theme/config.ts +0 -8
  126. package/src/theme/depth-calibration.test.tsx +97 -0
  127. package/src/theme/elevation.test.ts +62 -1
  128. package/src/theme/elevation.ts +34 -8
  129. package/src/theme/global.css +47 -53
  130. package/src/theme/grey-ramp.test.ts +166 -0
  131. package/src/theme/materials.test.ts +106 -0
  132. package/src/theme/materials.ts +211 -0
  133. package/src/theme/presets/audiobook.ts +4 -4
  134. package/src/theme/presets/types.ts +3 -3
  135. package/src/theme/semantic-contrast.test.ts +83 -0
  136. package/src/theme/tokens/primitives.ts +62 -115
  137. package/src/theme/tokens/semantic.ts +130 -82
  138. package/src/utils/colors.ts +1 -1
  139. package/tailwind.config.js +2 -5
  140. package/src/theme/bar-paper.ts +0 -59
  141. package/src/theme/shadows.stories.tsx +0 -523
  142. package/src/theme/shadows.ts +0 -439
@@ -41,12 +41,12 @@
41
41
  --color-status-error-light: #E05254;
42
42
  --color-status-error-dark: #A4221C;
43
43
  --color-status-error-subtle: rgba(209, 67, 67, 0.12);
44
- --color-status-error-vivid: #FF4757;
45
- --color-status-error-vivid-light: #FF6070;
46
- --color-status-error-vivid-dark: #C42539;
44
+ --color-status-error-vivid: #D14343;
45
+ --color-status-error-vivid-light: #E05254;
46
+ --color-status-error-vivid-dark: #A4221C;
47
47
  --color-status-error-vivid-subtle: rgba(255, 71, 87, 0.12);
48
48
  --color-status-error-vivid-rgb: 255, 71, 87;
49
- --color-border-prominent: #3C3C3C;
49
+ --color-border-prominent: #424140;
50
50
  --color-status-warning: #F9B415;
51
51
  --color-status-warning-light: #FFD352;
52
52
  --color-status-warning-dark: #C27400;
@@ -69,7 +69,7 @@
69
69
  --color-result-degrade-dark: #b30000;
70
70
  --color-result-inconclusive: #9E9A97;
71
71
  --color-result-inconclusive-light: rgba(158, 154, 151, 0.16);
72
- --color-result-neutral: #9CA3AF;
72
+ --color-result-neutral: #A29F9D;
73
73
 
74
74
  --color-on-result-improve: #FFFFFF;
75
75
  --color-on-result-degrade: #FFFFFF;
@@ -86,25 +86,24 @@
86
86
  --color-data-9: #CAACCB;
87
87
  --color-data-10: #EE8026;
88
88
 
89
- --color-text-primary: #F3F4F6;
90
- --color-text-secondary: #9CA3AF;
91
- --color-text-tertiary: #6B7280;
89
+ --color-text-primary: #F9F6F3;
90
+ --color-text-secondary: #A29F9D;
91
+ --color-text-tertiary: #888684;
92
92
  --color-text-disabled: rgba(255, 255, 255, 0.38);
93
- --color-text-inverse: #111827;
93
+ --color-text-inverse: #1C1916;
94
94
  --color-text-link: #828DF8;
95
- --color-text-link-hover: #60A5FA;
95
+ --color-text-link-hover: #3CA8FF;
96
96
 
97
97
  /* Surface ramp — warm-tapered DERIVED, shipped verbatim from deriveSurfaceRamp()
98
98
  (TD-surface-tokens, S-1, re-spaced S-3). surface-overlay and surface-elevated
99
99
  are distinct (were both #191919 pre-S-1); see surfaceRampDark in
100
- tokens/primitives.ts. surface-inset is the ramp's floor, promoted to a real
101
- token at S-3 (was unwired). */
100
+ tokens/primitives.ts. The ramp's floor is background-frame; the old
101
+ surface-inset plane was retired in TD-07.14 (delta-E 1.10 from frame). */
102
102
  --color-surface-base: #252321;
103
103
  --color-surface-elevated: #2C2A28;
104
104
  --color-surface-raised: #31302F;
105
105
  --color-surface-overlay: #373635;
106
106
  --color-surface-input: #2C2A28;
107
- --color-surface-inset: #13100D;
108
107
 
109
108
  --color-background-base: #1C1916;
110
109
  --color-background-default: #252321;
@@ -114,19 +113,20 @@
114
113
  in tokens/primitives.ts. */
115
114
  --color-background-frame: #100D0A;
116
115
 
117
- --color-border-default: #1F1F1F;
118
- --color-border-subtle: #1C1C1C;
119
- --color-border-strong: #2C2C2C;
120
116
  --color-border-focus: #828DF8;
121
- --color-border-input: #4B5563;
122
- --color-border-input-hover: #6B7280;
117
+ --color-border-input: #5A5958;
118
+ --color-border-input-hover: #72716F;
123
119
  --color-border-input-focus: #828DF8;
124
- --color-border-input-error: #EF4444;
120
+ --color-border-input-error: #E05254;
125
121
 
126
- /* Alpha hairline separators — self-normalizing on any plane (§4/S-2) */
127
- --color-hairline-subtle: rgba(255, 255, 255, 0.06);
128
- --color-hairline-default: rgba(255, 255, 255, 0.09);
129
- --color-hairline-strong: rgba(255, 255, 255, 0.14);
122
+ /* Alpha hairline separators — self-normalizing on any plane (§4/S-2).
123
+ Retuned on the wall (VW-99): each tier steps onto the previous tier's
124
+ measured-good value, then +.01 across the family on run 2 for comfort.
125
+ Two decimals only RNW quantises alpha to 8 bits.
126
+ Dark only; the light family below is unverified. */
127
+ --color-hairline-subtle: rgba(255, 255, 255, 0.10);
128
+ --color-hairline-default: rgba(255, 255, 255, 0.15);
129
+ --color-hairline-strong: rgba(255, 255, 255, 0.22);
130
130
 
131
131
  --color-interactive-hover: rgba(255, 255, 255, 0.04);
132
132
  --color-interactive-focus: rgba(255, 255, 255, 0.12);
@@ -135,9 +135,9 @@
135
135
  --color-interactive-disabled: rgba(255, 255, 255, 0.12);
136
136
  --color-interactive-disabled-text: rgba(255, 255, 255, 0.26);
137
137
 
138
- --color-divider: #1F1F1F;
138
+ --color-divider: rgba(255, 255, 255, 0.09);
139
139
 
140
- --color-avatar-background: #4B5563;
140
+ --color-avatar-background: #5A5958;
141
141
  --color-avatar-text: #FFFFFF;
142
142
 
143
143
  /* RGB decomposed values for glow shadows */
@@ -193,12 +193,12 @@
193
193
  --color-status-error-light: #E05254;
194
194
  --color-status-error-dark: #A4221C;
195
195
  --color-status-error-subtle: #FFF4F4;
196
- --color-status-error-vivid: #FF4757;
197
- --color-status-error-vivid-light: #FF6070;
198
- --color-status-error-vivid-dark: #C42539;
196
+ --color-status-error-vivid: #D14343;
197
+ --color-status-error-vivid-light: #E05254;
198
+ --color-status-error-vivid-dark: #A4221C;
199
199
  --color-status-error-vivid-subtle: rgba(255, 71, 87, 0.12);
200
200
  --color-status-error-vivid-rgb: 255, 71, 87;
201
- --color-border-prominent: #9CA3AF;
201
+ --color-border-prominent: #A29F9D;
202
202
  --color-status-warning: #F9B415;
203
203
  --color-status-warning-light: #FFD352;
204
204
  --color-status-warning-dark: #C27400;
@@ -221,7 +221,7 @@
221
221
  --color-result-degrade-dark: #b30000;
222
222
  --color-result-inconclusive: #9E9A97;
223
223
  --color-result-inconclusive-light: rgba(158, 154, 151, 0.12);
224
- --color-result-neutral: #6B7280;
224
+ --color-result-neutral: #72716F;
225
225
 
226
226
  --color-on-result-improve: #FFFFFF;
227
227
  --color-on-result-degrade: #FFFFFF;
@@ -240,34 +240,28 @@
240
240
 
241
241
  --color-text-primary: #121828;
242
242
  --color-text-secondary: #65748B;
243
- --color-text-tertiary: #9CA3AF;
243
+ --color-text-tertiary: #A29F9D;
244
244
  --color-text-disabled: rgba(55, 65, 81, 0.48);
245
245
  --color-text-inverse: #FFFFFF;
246
- --color-text-link: #5048E5;
247
- --color-text-link-hover: #3832A0;
246
+ --color-text-link: #1072CB;
247
+ --color-text-link-hover: #135AA8;
248
248
 
249
249
  --color-surface-base: #FFFFFF;
250
- --color-surface-elevated: #FAFAFA;
251
- --color-surface-raised: #F3F4F6;
250
+ --color-surface-elevated: #F9F6F3;
251
+ --color-surface-raised: #EDEAE7;
252
252
  --color-surface-overlay: #FFFFFF;
253
- --color-surface-input: #FAFAFA;
254
- /* Placeholder pairing (S-3) — light-mode counterpart of the dark ramp's
255
- inset plane; not part of the dark-ramp investigation. */
256
- --color-surface-inset: #D1D5DB;
253
+ --color-surface-input: #F9F6F3;
257
254
 
258
255
  --color-background-base: #EBEBEB;
259
256
  --color-background-default: #FFFFFF;
260
- --color-background-subtle: #FAFAFA;
261
- /* Placeholder pairing (S-3) see --color-surface-inset note above. */
262
- --color-background-frame: #9CA3AF;
263
-
264
- --color-border-default: #E8E9EB;
265
- --color-border-subtle: #F3F4F6;
266
- --color-border-strong: #D1D5DB;
267
- --color-border-focus: #5048E5;
268
- --color-border-input: #D1D5DB;
269
- --color-border-input-hover: #9CA3AF;
270
- --color-border-input-focus: #5048E5;
257
+ --color-background-subtle: #F9F6F3;
258
+ /* Placeholder pairing — light mode is deferred. */
259
+ --color-background-frame: #A29F9D;
260
+
261
+ --color-border-focus: #1072CB;
262
+ --color-border-input: #D4D1CE;
263
+ --color-border-input-hover: #A29F9D;
264
+ --color-border-input-focus: #1072CB;
271
265
  --color-border-input-error: #D14343;
272
266
 
273
267
  /* Alpha hairline separators — mirrors the dark-mode white-alpha family,
@@ -285,7 +279,7 @@
285
279
 
286
280
  --color-divider: #E8E9EB;
287
281
 
288
- --color-avatar-background: #6B7280;
282
+ --color-avatar-background: #72716F;
289
283
  --color-avatar-text: #FFFFFF;
290
284
 
291
285
  /* RGB decomposed values for glow shadows */
@@ -349,7 +343,7 @@
349
343
  }
350
344
 
351
345
  .scrollbar-styled::-webkit-scrollbar-thumb {
352
- background: var(--color-border-strong);
346
+ background: var(--color-hairline-strong);
353
347
  border-radius: 4px;
354
348
  }
355
349
 
@@ -368,7 +362,7 @@
368
362
  }
369
363
 
370
364
  .scrollbar-thin::-webkit-scrollbar-thumb {
371
- background: var(--color-border-strong);
365
+ background: var(--color-hairline-strong);
372
366
  border-radius: 2px;
373
367
  }
374
368
 
@@ -0,0 +1,166 @@
1
+ /**
2
+ * TD-07.14 / TD-07.15 — the warm grey ramp, and the gate it had to clear.
3
+ *
4
+ * This ramp was lost and re-derived four times before it was committed, so these
5
+ * tests exist to make the properties that were argued about EXPENSIVE to break,
6
+ * rather than leaving them in a document nobody reads. Each block corresponds to
7
+ * a decision someone had to make twice.
8
+ */
9
+ import { describe, it, expect } from 'vitest'
10
+ import { execFileSync } from 'node:child_process'
11
+ import { join } from 'node:path'
12
+ import { greyRamp, SURFACE_PLANE_STEPS, primitiveRamps } from './tokens/primitives'
13
+
14
+
15
+ // ── colour math (CIELAB D65 + WCAG relative luminance) ──────────────────────
16
+ const hex2rgb = (h: string): [number, number, number] => {
17
+ const n = parseInt(h.slice(1), 16)
18
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
19
+ }
20
+ const srgb2lin = (c: number) => {
21
+ const s = c / 255
22
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
23
+ }
24
+ const f = (t: number) => (t > 216 / 24389 ? Math.cbrt(t) : (841 / 108) * t + 4 / 29)
25
+ function lab(hex: string): [number, number, number] {
26
+ const [r, g, b] = hex2rgb(hex).map(srgb2lin)
27
+ const X = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047
28
+ const Y = 0.2126729 * r + 0.7151522 * g + 0.072175 * b
29
+ const Z = (0.0193339 * r + 0.119192 * g + 0.9503041 * b) / 1.08883
30
+ const [fx, fy, fz] = [f(X), f(Y), f(Z)]
31
+ return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)]
32
+ }
33
+ const dE76 = (a: string, b: string) => {
34
+ const [p, q] = [lab(a), lab(b)]
35
+ return Math.hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2])
36
+ }
37
+ const Lstar = (hex: string) => lab(hex)[0]
38
+ const warmth = (hex: string) => {
39
+ const [r, , b] = hex2rgb(hex)
40
+ return r - b
41
+ }
42
+
43
+ /** Machado-2009 dichromacy, severity 1.0. */
44
+ const CVD = {
45
+ deuteranopia: [0.367322, 0.860646, -0.227968, 0.280085, 0.672501, 0.047413, -0.01182, 0.04294, 0.968881],
46
+ protanopia: [0.152286, 1.052583, -0.204868, 0.114503, 0.786281, 0.099216, -0.003882, -0.048116, 1.051998],
47
+ tritanopia: [1.255528, -0.076749, -0.178779, -0.078411, 0.930809, 0.147602, 0.004733, 0.691367, 0.3039],
48
+ } as const
49
+ function simulateCvd(m: readonly number[], hex: string): string {
50
+ const [r, g, b] = hex2rgb(hex)
51
+ const out = [
52
+ m[0] * r + m[1] * g + m[2] * b,
53
+ m[3] * r + m[4] * g + m[5] * b,
54
+ m[6] * r + m[7] * g + m[8] * b,
55
+ ]
56
+ return (
57
+ '#' +
58
+ out
59
+ .map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0'))
60
+ .join('')
61
+ .toUpperCase()
62
+ )
63
+ }
64
+
65
+ const STEPS = Object.keys(greyRamp).map(Number).sort((a, b) => a - b)
66
+ const hexAt = (step: number) => greyRamp[step as keyof typeof greyRamp]
67
+
68
+ describe('greyRamp — shape', () => {
69
+ it('is monotonically darker as the step number rises', () => {
70
+ const ls = STEPS.map((s) => Lstar(hexAt(s)))
71
+ for (let i = 1; i < ls.length; i++) {
72
+ expect(ls[i], `step ${STEPS[i]} is not darker than ${STEPS[i - 1]}`).toBeLessThan(ls[i - 1])
73
+ }
74
+ })
75
+
76
+ it('reads warm at every step — R−B never drops below the +2 the generator guards', () => {
77
+ for (const s of STEPS) {
78
+ expect(warmth(hexAt(s)), `step ${s} (${hexAt(s)}) is not warm`).toBeGreaterThanOrEqual(2)
79
+ }
80
+ })
81
+
82
+ it('has no two steps a reader could not tell apart', () => {
83
+ for (let i = 1; i < STEPS.length; i++) {
84
+ const d = dE76(hexAt(STEPS[i - 1]), hexAt(STEPS[i]))
85
+ expect(d, `steps ${STEPS[i - 1]}/${STEPS[i]} collapse at ΔE ${d.toFixed(2)}`).toBeGreaterThan(1.5)
86
+ }
87
+ })
88
+
89
+ /**
90
+ * The reason `975` exists at all. If a chromatic ramp is ever re-tuned so its
91
+ * darkest step drops below the bezel, the justification for the extra step
92
+ * evaporates and this ramp should be renumbered rather than left as folklore.
93
+ */
94
+ it('extends below the chromatic grid — which is why 975 is not 950', () => {
95
+ const floor = [...Object.values(primitiveRamps)]
96
+ .map((r) => Lstar(r[950 as keyof typeof r] as string))
97
+ .sort((a, b) => a - b)
98
+ const median = floor[Math.floor(floor.length / 2)]
99
+ expect(Lstar(greyRamp[975])).toBeLessThan(median)
100
+ expect(median).toBeCloseTo(10.3, 0)
101
+ })
102
+ })
103
+
104
+ describe('greyRamp — anchors are load-bearing', () => {
105
+ /**
106
+ * These six are byte-identical to the values that shipped in v0.10.0 as
107
+ * `surfaceRampDark` + `backgroundFrameDark`. That is what makes TD-07.14 a
108
+ * rename rather than a restyle: every surface keeps its exact pixel value.
109
+ * Changing one here silently restyles every surface in the system.
110
+ */
111
+ const ANCHORS: Array<[keyof typeof greyRamp, string, string]> = [
112
+ [850, '#373635', 'surface overlay'],
113
+ [875, '#31302F', 'surface raised'],
114
+ [900, '#2C2A28', 'surface elevated'],
115
+ [925, '#252321', 'surface base'],
116
+ [950, '#1C1916', 'background base'],
117
+ [975, '#100D0A', 'background frame / bezel'],
118
+ ]
119
+
120
+ it.each(ANCHORS)('step %s is %s (%s) — unchanged from v0.10.0', (step, hex) => {
121
+ expect(greyRamp[step]).toBe(hex)
122
+ })
123
+
124
+ it('maps every surface plane to a real step, darkest first', () => {
125
+ const steps = Object.values(SURFACE_PLANE_STEPS)
126
+ for (const s of steps) expect(greyRamp[s]).toBeDefined()
127
+ expect([...steps].sort((a, b) => b - a)).toEqual(steps)
128
+ })
129
+
130
+ it('has no `inset` — it was retired for being imperceptible from the frame', () => {
131
+ // The reason, kept as an assertion so nobody reintroduces it: the old inset
132
+ // #13100D sat ΔE 1.10 from the bezel, well under the ~2.3 JND.
133
+ expect(dE76('#13100D', greyRamp[975])).toBeLessThan(2.3)
134
+ expect(Object.keys(greyRamp)).not.toContain('inset')
135
+ })
136
+ })
137
+
138
+ describe('greyRamp — accessibility gate (TD-07.15)', () => {
139
+ // NOTE: the on-surface TEXT-pairing gate lives in `semantic-contrast.test.ts`,
140
+ // added with the token repoint. It cannot pass until the text tokens actually
141
+ // move onto this ramp — on the shipped cool `neutral` scale, text-tertiary
142
+ // fails WCAG outright on the three lightest planes (2.96 / 2.72 / 2.49).
143
+
144
+ it('stays legible under all three dichromacies', () => {
145
+ for (const [mode, matrix] of Object.entries(CVD)) {
146
+ for (const s of STEPS) {
147
+ const shift = dE76(hexAt(s), simulateCvd(matrix, hexAt(s)))
148
+ expect(shift, `${mode} shifts grey-${s} by ΔE ${shift.toFixed(2)}`).toBeLessThan(3)
149
+ }
150
+ // Adjacent steps must stay separable AFTER simulation, or the depth
151
+ // ordering of the surface stack stops reading for those viewers.
152
+ for (let i = 1; i < STEPS.length; i++) {
153
+ const d = dE76(simulateCvd(matrix, hexAt(STEPS[i - 1])), simulateCvd(matrix, hexAt(STEPS[i])))
154
+ expect(d, `${mode}: grey-${STEPS[i - 1]}/${STEPS[i]} merge`).toBeGreaterThan(1.5)
155
+ }
156
+ }
157
+ })
158
+ })
159
+
160
+ describe('greyRamp — provenance', () => {
161
+ it('still matches the generator that derived it', () => {
162
+ const script = join(__dirname, '../../scripts/generate-grey-ramp.mjs')
163
+ // Throws (non-zero exit) if primitives.ts has drifted from the derivation.
164
+ expect(() => execFileSync('node', [script, '--check'], { encoding: 'utf8' })).not.toThrow()
165
+ })
166
+ })
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Surface materials — the properties that make paper read as ONE material.
3
+ *
4
+ * These treatments are subtle by design (grain is a whisper at dark tones, the
5
+ * rim is one pixel), which makes them exactly the kind of thing that can be
6
+ * broken without anyone noticing until it looks wrong on the wall. So the
7
+ * numbers that define them are asserted rather than left as comments.
8
+ *
9
+ * The `tonal fill` and `dither` blocks that used to lead this file went with
10
+ * `tonalFill` and `ditherTile` in VW-99: the wall found the ΔL* 3 gradient
11
+ * indiscernible, and then found that this render path does not band at all, so
12
+ * the anti-banding layer was mitigating nothing. See the materials.ts header.
13
+ */
14
+ import { describe, it, expect } from 'vitest'
15
+ import { grainOpacityForTone, grainForTone, paperSheet, insetWell, barPaper } from './materials'
16
+ import { greyRamp } from './tokens/primitives'
17
+
18
+ const styleOf = (s: Record<string, unknown>) => s as { backgroundImage?: string; boxShadow?: string; backgroundColor?: string }
19
+
20
+ describe('grain', () => {
21
+ it('scales opacity with the tone it sits on', () => {
22
+ // A fixed opacity reads hot on a dark plane and vanishes on a bright one.
23
+ const dark = grainOpacityForTone(greyRamp[975])
24
+ const mid = grainOpacityForTone(greyRamp[500])
25
+ const light = grainOpacityForTone(greyRamp[200])
26
+ expect(dark).toBeLessThan(mid)
27
+ expect(mid).toBeLessThan(light)
28
+ })
29
+
30
+ it('stays inside the band where grain reads matte rather than dirty', () => {
31
+ for (const step of Object.keys(greyRamp).map(Number)) {
32
+ const op = grainOpacityForTone(greyRamp[step as keyof typeof greyRamp])
33
+ expect(op, `grey-${step}`).toBeGreaterThanOrEqual(0.04)
34
+ expect(op, `grey-${step}`).toBeLessThanOrEqual(0.24)
35
+ }
36
+ })
37
+
38
+ it('survives malformed input instead of emitting NaN into a style', () => {
39
+ expect(grainOpacityForTone('not-a-colour')).toBeGreaterThan(0)
40
+ expect(grainForTone('not-a-colour')).not.toContain('NaN')
41
+ })
42
+ })
43
+
44
+ describe('paperSheet', () => {
45
+ it('carries grain ALONE — no dither, no gradient', () => {
46
+ const bg = styleOf(paperSheet(greyRamp[875])).backgroundImage!
47
+ // Count data URIs, not 'url(' — each SVG tile contains a `filter='url(#n)'`
48
+ // internally, so counting the token double-reports.
49
+ //
50
+ // Both of the layers this used to have were measured off the sheet in VW-99:
51
+ // the tonal fill was indiscernible from flat, and the dither was mitigating
52
+ // a banding artefact that a hand-drawn reference proved this render path
53
+ // does not produce. Asserted rather than merely deleted, so putting either
54
+ // back is a deliberate act with a wall run behind it, not a quiet revert.
55
+ expect(bg.split('data:image/svg+xml').length - 1, 'grain only, no dither tile').toBe(1)
56
+ expect(bg, 'the tonal fill was removed in VW-99').not.toContain('linear-gradient')
57
+ })
58
+
59
+ it('keeps a solid backgroundColor so native does not fall through', () => {
60
+ // backgroundImage and multi-layer boxShadow are web-only. Without a real
61
+ // fill underneath, a paper surface renders as nothing on native.
62
+ expect(styleOf(paperSheet(greyRamp[875])).backgroundColor).toBe(greyRamp[875])
63
+ })
64
+
65
+ it('carries a top rim and one soft contact shadow', () => {
66
+ const shadow = styleOf(paperSheet()).boxShadow!
67
+ expect(shadow).toContain('inset 0 1px 0')
68
+ expect(shadow.split(',').filter((s) => s.includes('px')).length).toBeGreaterThan(1)
69
+ })
70
+ })
71
+
72
+ describe('insetWell', () => {
73
+ it('cuts in from the top AND lights the floor', () => {
74
+ // The floor rim is the load-bearing half — a dark inset alone just reads as
75
+ // a darker rectangle. It is the light on the floor that says "below".
76
+ const shadow = styleOf(insetWell(greyRamp[950])).boxShadow!
77
+ expect(shadow).toContain('inset 0 2px')
78
+ expect(shadow, 'missing the bottom floor rim').toContain('inset 0 -1px 0 rgba(255,255,255,0.12)')
79
+ })
80
+
81
+ it('is entirely inset — a well must not cast outward', () => {
82
+ const shadow = styleOf(insetWell()).boxShadow!
83
+ for (const layer of shadow.split(/,(?![^(]*\))/)) {
84
+ expect(layer.trim().startsWith('inset'), `outward layer: ${layer.trim()}`).toBe(true)
85
+ }
86
+ })
87
+ })
88
+
89
+ describe('barPaper', () => {
90
+ it('drops the rim light when mirrored', () => {
91
+ // Mirrored, a top rim would land on the shared axis of a diverging chart.
92
+ expect(styleOf(barPaper('#D14343', true)).boxShadow).not.toContain('inset')
93
+ expect(styleOf(barPaper('#D14343', false)).boxShadow).toContain('inset')
94
+ })
95
+
96
+ it('throws its contact shadow away from the axis when mirrored', () => {
97
+ expect(styleOf(barPaper('#D14343', true)).boxShadow).toContain('0 -6px')
98
+ expect(styleOf(barPaper('#D14343', false)).boxShadow).toContain('0 6px')
99
+ })
100
+
101
+ it('shares the one grain curve with paperSheet', () => {
102
+ // Same material, proportional texture — that is what makes a card and a bar
103
+ // look like they belong to the same system.
104
+ expect(styleOf(barPaper(greyRamp[500])).backgroundImage).toBe(grainForTone(greyRamp[500]))
105
+ })
106
+ })