@titan-design/react-ui 0.10.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 (190) 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 +416 -18
  6. package/dist/index.d.ts +416 -18
  7. package/dist/index.js +2512 -1307
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2480 -1299
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/{pages-DyEx-lBf.d.ts → pages-B1hqnGIC.d.ts} +149 -15
  12. package/dist/{pages-D7hOD4Vj.d.mts → pages-CvsFqNtx.d.mts} +149 -15
  13. package/dist/pages.d.mts +1 -1
  14. package/dist/pages.d.ts +1 -1
  15. package/dist/pages.js +1644 -1575
  16. package/dist/pages.js.map +1 -1
  17. package/dist/pages.mjs +1645 -1576
  18. package/dist/pages.mjs.map +1 -1
  19. package/dist/theme/index.d.mts +409 -415
  20. package/dist/theme/index.d.ts +409 -415
  21. package/dist/theme/index.js +234 -408
  22. package/dist/theme/index.js.map +1 -1
  23. package/dist/theme/index.mjs +227 -399
  24. package/dist/theme/index.mjs.map +1 -1
  25. package/dist/theme/tokens-css.js +141 -140
  26. package/dist/theme/tokens-css.js.map +1 -1
  27. package/dist/theme/tokens-css.mjs +141 -140
  28. package/dist/theme/tokens-css.mjs.map +1 -1
  29. package/package.json +8 -3
  30. package/src/arch/Architecture.stories.tsx +9 -9
  31. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  32. package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
  33. package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
  34. package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
  35. package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
  36. package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
  37. package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
  38. package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
  39. package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
  40. package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
  41. package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
  42. package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
  43. package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
  44. package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
  45. package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
  46. package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
  47. package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
  48. package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
  49. package/src/components/custom/Fatigue/README.md +93 -0
  50. package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
  51. package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
  52. package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
  53. package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
  54. package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
  55. package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
  56. package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
  57. package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
  58. package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
  59. package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
  60. package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
  61. package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
  62. package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
  63. package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
  64. package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
  65. package/src/components/custom/Fatigue/index.ts +41 -0
  66. package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
  67. package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
  68. package/src/components/custom/Sidebar/Sidebar.tsx +4 -4
  69. package/src/components/custom/Table/Table.tsx +2 -2
  70. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +3 -3
  71. package/src/components/custom/Workout/BaseBadge.test.tsx +6 -1
  72. package/src/components/custom/Workout/BaseBadge.tsx +1 -1
  73. package/src/components/custom/Workout/BodyMap.tsx +2 -2
  74. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +2 -2
  75. package/src/components/custom/Workout/DeviationBar.test.tsx +2 -1
  76. package/src/components/custom/Workout/DeviationBar.tsx +3 -2
  77. package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
  78. package/src/components/custom/Workout/ExerciseCard.test.tsx +7 -7
  79. package/src/components/custom/Workout/ExerciseCard.tsx +5 -3
  80. package/src/components/custom/Workout/ExerciseDetailPage.tsx +11 -5
  81. package/src/components/custom/Workout/InputBar.tsx +2 -2
  82. package/src/components/custom/Workout/LiveAuraFrame.stories.tsx +1 -1
  83. package/src/components/custom/Workout/LiveAuraFrame.test.tsx +1 -1
  84. package/src/components/custom/Workout/LiveAuraFrame.tsx +3 -3
  85. package/src/components/custom/Workout/MesoCard.tsx +3 -1
  86. package/src/components/custom/Workout/MuscleGroupChip.tsx +4 -3
  87. package/src/components/custom/Workout/PrHistoryModal.tsx +2 -2
  88. package/src/components/custom/Workout/ProgramPlanningPage.tsx +1 -1
  89. package/src/components/custom/Workout/README.md +62 -16
  90. package/src/components/custom/Workout/ReadinessCheck.tsx +2 -2
  91. package/src/components/custom/Workout/RestTimer.tsx +2 -2
  92. package/src/components/custom/Workout/S3SetTypes.stories.tsx +3 -3
  93. package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
  94. package/src/components/custom/Workout/SessionHeader.stories.tsx +2 -2
  95. package/src/components/custom/Workout/SessionHeader.tsx +1 -1
  96. package/src/components/custom/Workout/SessionRail.stories.tsx +2 -2
  97. package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
  98. package/src/components/custom/Workout/SessionRail.tsx +63 -18
  99. package/src/components/custom/Workout/SetBar.tsx +3 -3
  100. package/src/components/custom/Workout/SetRow.test.tsx +12 -9
  101. package/src/components/custom/Workout/SetRow.tsx +9 -2
  102. package/src/components/custom/Workout/StatusDot.tsx +5 -4
  103. package/src/components/custom/Workout/StrengthTrendChart.tsx +1 -1
  104. package/src/components/custom/Workout/TrainingStatusPage.tsx +2 -2
  105. package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
  106. package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
  107. package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
  108. package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
  109. package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
  110. package/src/components/custom/Workout/VelocityStrip.test.tsx +199 -79
  111. package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
  112. package/src/components/custom/Workout/VolumeLandmarkBar.tsx +3 -3
  113. package/src/components/custom/Workout/WeightBadge.test.tsx +6 -1
  114. package/src/components/custom/Workout/WeightBadge.tsx +2 -1
  115. package/src/components/custom/Workout/WorkoutCard.tsx +3 -1
  116. package/src/components/custom/Workout/WorkoutPill.tsx +5 -2
  117. package/src/components/custom/Workout/ZoneTrack.tsx +4 -4
  118. package/src/components/custom/Workout/index.ts +3 -0
  119. package/src/components/custom/Workout/setHeadingKit.tsx +14 -8
  120. package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
  121. package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
  122. package/src/components/custom/charts/SetBarChart.tsx +651 -0
  123. package/src/components/custom/charts/live-rep-growth.ts +91 -0
  124. package/src/components/custom/index.ts +1 -0
  125. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  126. package/src/components/custom/stepper/Stepper.tsx +3 -3
  127. package/src/components/shell/DeviceMenu.tsx +2 -2
  128. package/src/components/shell/SideNav.tsx +1 -1
  129. package/src/components/shell/TopBar.tsx +1 -1
  130. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  131. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  132. package/src/components/ui/avatar/Avatar.tsx +2 -2
  133. package/src/components/ui/badge/Badge.tsx +2 -2
  134. package/src/components/ui/card/Card.tsx +3 -3
  135. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  136. package/src/components/ui/collapse/Collapse.tsx +1 -1
  137. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  138. package/src/components/ui/drawer/Drawer.tsx +3 -3
  139. package/src/components/ui/form-field/FormField.tsx +1 -1
  140. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  141. package/src/components/ui/menu/Menu.tsx +2 -2
  142. package/src/components/ui/pill/Pill.tsx +2 -2
  143. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  144. package/src/components/ui/popover/Popover.tsx +1 -1
  145. package/src/components/ui/progress/Progress.tsx +3 -3
  146. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  147. package/src/components/ui/radio/Radio.tsx +4 -4
  148. package/src/components/ui/select/Select.tsx +3 -3
  149. package/src/components/ui/surface/Surface.stories.tsx +3 -3
  150. package/src/components/ui/surface/Surface.test.tsx +32 -19
  151. package/src/components/ui/surface/Surface.tsx +3 -3
  152. package/src/components/ui/surface/SurfaceContext.ts +19 -29
  153. package/src/components/ui/surface/surface.contract.test.ts +57 -44
  154. package/src/components/ui/switch/Switch.tsx +1 -1
  155. package/src/components/ui/tabs/Tabs.tsx +2 -2
  156. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +5 -5
  157. package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +1 -1
  158. package/src/components/ui/toolbar-button/ToolbarButton.tsx +36 -22
  159. package/src/stories/PresetShowcase.stories.tsx +21 -21
  160. package/src/test/raw-color-patterns.test.ts +71 -0
  161. package/src/theme/ColorPalettes.stories.tsx +874 -0
  162. package/src/theme/ColorPrimitives.stories.tsx +348 -0
  163. package/src/theme/Depth.stories.tsx +312 -0
  164. package/src/theme/DepthCalibration.stories.tsx +655 -0
  165. package/src/theme/ThemeProvider.tsx +1 -1
  166. package/src/theme/barrel.ts +7 -14
  167. package/src/theme/color-stories.coverage.test.ts +125 -0
  168. package/src/theme/color-story-kit.tsx +170 -0
  169. package/src/theme/color-utils.ts +197 -0
  170. package/src/theme/config.ts +0 -8
  171. package/src/theme/depth-calibration.test.tsx +97 -0
  172. package/src/theme/elevation.test.ts +62 -1
  173. package/src/theme/elevation.ts +34 -8
  174. package/src/theme/global.css +47 -53
  175. package/src/theme/grey-ramp.test.ts +166 -0
  176. package/src/theme/materials.test.ts +106 -0
  177. package/src/theme/materials.ts +211 -0
  178. package/src/theme/presets/audiobook.ts +4 -4
  179. package/src/theme/presets/types.ts +3 -3
  180. package/src/theme/semantic-contrast.test.ts +83 -0
  181. package/src/theme/tokens/primitives.ts +62 -115
  182. package/src/theme/tokens/semantic.ts +130 -82
  183. package/src/utils/colors.ts +1 -1
  184. package/tailwind.config.js +2 -5
  185. package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
  186. package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
  187. package/src/theme/ColorSystem.stories.tsx +0 -508
  188. package/src/theme/Colors.stories.tsx +0 -323
  189. package/src/theme/shadows.stories.tsx +0 -523
  190. package/src/theme/shadows.ts +0 -439
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { getGlowShadow } from './elevation'
2
+ import { getGlowShadow, getElevationSurface, getElevationShadow, getBaseSurfaceColor, FLOATING_ELEVATION_MIN } from './elevation'
3
3
 
4
4
  describe('getGlowShadow', () => {
5
5
  it('returns shadow style for valid hex color', () => {
@@ -25,3 +25,64 @@ describe('getGlowShadow', () => {
25
25
  expect(defaultStyle).toEqual(mediumStyle)
26
26
  })
27
27
  })
28
+
29
+ // ── TD-07.16: the depth model ───────────────────────────────────────────────
30
+
31
+ describe('elevation depth model', () => {
32
+ const hex2rgb = (h: string): [number, number, number] => {
33
+ const n = parseInt(h.slice(1), 16)
34
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
35
+ }
36
+ const srgb2lin = (c: number) => {
37
+ const s = c / 255
38
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
39
+ }
40
+ const fl = (t: number) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116)
41
+ const Lstar = (hex: string) => {
42
+ const [r, g, b] = hex2rgb(hex).map(srgb2lin)
43
+ return 116 * fl(r * 0.2126729 + g * 0.7151522 + b * 0.072175) - 16
44
+ }
45
+
46
+ const base = getBaseSurfaceColor('dark')
47
+
48
+ /**
49
+ * With drop-shadow gated off below FLOATING_ELEVATION_MIN, TONE is the only
50
+ * thing separating levels 0-3. Level 1 previously moved 0.01 (~2 RGB), which
51
+ * is under the perceptual floor — so the level existed in the type system and
52
+ * nowhere on screen.
53
+ */
54
+ it('separates every adjacent content level by a visible tonal step', () => {
55
+ const levels = [0, 1, 2, 3] as const
56
+ for (let i = 1; i < levels.length; i++) {
57
+ const prev = Lstar(getElevationSurface(base, levels[i - 1], 'dark'))
58
+ const cur = Lstar(getElevationSurface(base, levels[i], 'dark'))
59
+ const step = cur - prev
60
+ expect(step, `L${levels[i - 1]}→L${levels[i]} moves ΔL* ${step.toFixed(2)}`).toBeGreaterThan(1)
61
+ }
62
+ })
63
+
64
+ it('gets lighter as it rises, never darker', () => {
65
+ const ls = ([0, 1, 2, 3, 4, 5] as const).map((lv) => Lstar(getElevationSurface(base, lv, 'dark')))
66
+ for (let i = 1; i < ls.length; i++) expect(ls[i]).toBeGreaterThan(ls[i - 1])
67
+ })
68
+
69
+ it('casts NO outward shadow below the floating threshold', () => {
70
+ // Dark-on-dark drop shadows are inert — they cost a composite layer and
71
+ // show nothing. Inline hierarchy is the hairline's job.
72
+ for (const lv of [1, 2, 3] as const) {
73
+ expect(getElevationShadow(base, lv, 'dark'), `level ${lv}`).toEqual({})
74
+ }
75
+ })
76
+
77
+ it('does cast one at and above the floating threshold', () => {
78
+ for (const lv of [FLOATING_ELEVATION_MIN, 5] as const) {
79
+ expect(Object.keys(getElevationShadow(base, lv, 'dark')).length, `level ${lv}`).toBeGreaterThan(0)
80
+ }
81
+ })
82
+
83
+ it('keeps the recess on inset levels — a well is not a cast shadow', () => {
84
+ for (const lv of [-1, -2] as const) {
85
+ expect(Object.keys(getElevationShadow(base, lv, 'dark')).length, `level ${lv}`).toBeGreaterThan(0)
86
+ }
87
+ })
88
+ })
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  import { Platform, type ViewStyle } from 'react-native'
25
- import { lighten, darken, hexToRgb, rgbToHsv, hsvToRgb, rgbToHex } from './shadows'
25
+ import { lighten, darken, hexToRgb, rgbToHsv, hsvToRgb, rgbToHex } from './color-utils'
26
26
  import { semanticColorsLight, semanticColorsDark } from './tokens/semantic'
27
27
 
28
28
  export type ElevationLevel = -2 | -1 | 0 | 1 | 2 | 3 | 4 | 5
@@ -71,7 +71,13 @@ export const elevationSystem: Record<ElevationLevel, ElevationConfig> = {
71
71
  // Level 1: Subtle elevation (outline cards, subtle containers)
72
72
  [1]: {
73
73
  name: 'subtle',
74
- colorAdjustment: 0.01,
74
+ // The whole ladder was re-spaced in TD-07.16. It used to run
75
+ // 0.01/0.02/0.04/0.06/0.08, where the first step was ~2 RGB values — under
76
+ // the perceptual floor, so level 1 existed in the type system and nowhere on
77
+ // screen. With drop-shadow now gated off below FLOATING_ELEVATION_MIN, TONE
78
+ // is the only thing separating 0-3, so every step has to actually land.
79
+ // Even 0.025 spacing, verified in elevation.test.ts as ΔL* > 1 per step.
80
+ colorAdjustment: 0.025,
75
81
  shadowIntensity: 'subtle',
76
82
  shadowStyle: 'raised',
77
83
  description: 'Subtle elevation (outline cards, subtle containers)',
@@ -80,7 +86,7 @@ export const elevationSystem: Record<ElevationLevel, ElevationConfig> = {
80
86
  // Level 2: Standard elevation (default cards, buttons)
81
87
  [2]: {
82
88
  name: 'standard',
83
- colorAdjustment: 0.02,
89
+ colorAdjustment: 0.05,
84
90
  shadowIntensity: 'medium',
85
91
  shadowStyle: 'raised',
86
92
  description: 'Standard elevation (default cards, raised buttons)',
@@ -89,7 +95,7 @@ export const elevationSystem: Record<ElevationLevel, ElevationConfig> = {
89
95
  // Level 3: Prominent elevation (important cards, modals)
90
96
  [3]: {
91
97
  name: 'prominent',
92
- colorAdjustment: 0.04,
98
+ colorAdjustment: 0.075,
93
99
  shadowIntensity: 'strong',
94
100
  shadowStyle: 'raised',
95
101
  description: 'Prominent elevation (important cards, modals, dropdowns)',
@@ -98,7 +104,7 @@ export const elevationSystem: Record<ElevationLevel, ElevationConfig> = {
98
104
  // Level 4: High elevation (overlays, popovers)
99
105
  [4]: {
100
106
  name: 'high',
101
- colorAdjustment: 0.06,
107
+ colorAdjustment: 0.10,
102
108
  shadowIntensity: 'strong',
103
109
  shadowStyle: 'raised',
104
110
  description: 'High elevation (overlays, popovers, tooltips)',
@@ -107,7 +113,7 @@ export const elevationSystem: Record<ElevationLevel, ElevationConfig> = {
107
113
  // Level 5: Floating (toasts, floating action buttons)
108
114
  [5]: {
109
115
  name: 'floating',
110
- colorAdjustment: 0.08,
116
+ colorAdjustment: 0.13,
111
117
  shadowIntensity: 'strong',
112
118
  shadowStyle: 'raised',
113
119
  description: 'Floating elevation (toasts, floating action buttons, highest z-index)',
@@ -425,6 +431,21 @@ function calculateElevationShadow(
425
431
  * @param isHovered - Whether element is hovered
426
432
  * @returns ViewStyle with shadow properties
427
433
  */
434
+ /**
435
+ * The lowest elevation that still casts an outward shadow.
436
+ *
437
+ * On a near-black surface a drop-shadow is inert: at levels 1-3 the shadow is
438
+ * dark-on-dark and contributes nothing a viewer can see, while still costing a
439
+ * composite layer. Material dropped shadow-as-hierarchy for the same reason.
440
+ * Inline hierarchy is the hairline's job (`hairline-*`), which composites by a
441
+ * constant amount over any plane.
442
+ *
443
+ * At 4+ the element is genuinely FLOATING over a backdrop — menu, popover,
444
+ * toast — where a large soft shadow describes separation from the page rather
445
+ * than a rank in a stack. Those should also carry a hairline ring.
446
+ */
447
+ export const FLOATING_ELEVATION_MIN: ElevationLevel = 4
448
+
428
449
  export function getElevationShadow(
429
450
  baseColor: string,
430
451
  level: ElevationLevel,
@@ -438,8 +459,13 @@ export function getElevationShadow(
438
459
  return elevationShadowCache.get(cacheKey)!
439
460
  }
440
461
 
441
- // Calculate shadow
442
- const result = calculateElevationShadow(baseColor, level, theme, isHovered)
462
+ // Calculate shadow. Outward drop-shadow is gated to FLOATING levels only —
463
+ // see FLOATING_ELEVATION_MIN. Inset levels keep theirs; a recess reads from
464
+ // the inner shadow, which is a different mechanism from a cast shadow.
465
+ const result =
466
+ level > 0 && level < FLOATING_ELEVATION_MIN
467
+ ? {}
468
+ : calculateElevationShadow(baseColor, level, theme, isHovered)
443
469
 
444
470
  // Cache result
445
471
  if (elevationShadowCache.size >= MAX_SHADOW_CACHE_SIZE) {
@@ -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
+ })