@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
@@ -8,7 +8,8 @@
8
8
  // top for the native consumers that deliberately import it.
9
9
  export * from './tokens'
10
10
  export * from './config'
11
- export * from './shadows'
11
+ export * from './materials'
12
+ export * from './color-utils'
12
13
  export * from './elevation'
13
14
  export * from './manifest'
14
15
  export { resolveColor } from './resolve-color'
@@ -22,24 +23,16 @@ export type { ThemeProviderProps, ThemeProviderMode } from './ThemeProvider'
22
23
  export { getSemanticColors, type ThemeMode } from './tokens/semantic'
23
24
  export { getThemeCSSVars, gluestackConfig } from './config'
24
25
  export {
25
- neumorphicShadows,
26
- // Color math utilities (HSV-based)
26
+ hexToRgb,
27
+ rgbToHsv,
28
+ hsvToRgb,
29
+ rgbToHex,
27
30
  lighten,
28
31
  darken,
29
32
  getHoverColors,
30
33
  isDark,
31
- hexToRgb,
32
- rgbToHsv,
33
- // Shadow creation functions
34
- createRaisedShadow,
35
- createRaisedHoverShadow,
36
- createPressedShadow,
37
- createPressedHoverShadow,
38
- createFlatShadow,
39
- shadowColors,
40
34
  type ShadowIntensity,
41
- type ShadowSurface,
42
- } from './shadows'
35
+ } from './color-utils'
43
36
  export {
44
37
  getElevationConfig,
45
38
  getElevationSurface,
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { greyRamp, SURFACE_PLANE_STEPS } from './tokens/primitives'
6
+ import { semanticColorsDark } from './tokens/semantic'
7
+
8
+ /**
9
+ * Drift guard for the Foundations/Color stories (VW-33).
10
+ *
11
+ * The color stories are the documentation of record for the token layer, and
12
+ * they have drifted twice: once when the categorical palette replaced the
13
+ * Paul-Tol scale, and again when the v0.10.0 surface redesign added four
14
+ * planes (`surface-overlay`, `background-frame`,
15
+ * `background-subtle`) that no story rendered. Both times the values stayed
16
+ * correct while the *coverage* silently fell behind — which a screenshot test
17
+ * cannot catch, because a story that was never written renders nothing.
18
+ *
19
+ * Two invariants, deliberately different in kind:
20
+ *
21
+ * 1. STRUCTURAL — every `surface-*`/`background-*` token resolves to a plane
22
+ * in the shipped ramp. Catches a token added off-ramp (an arbitrary fill),
23
+ * which is the failure the ordered-ramp story exists to prevent.
24
+ * 2. COVERAGE — every other semantic token is named somewhere in the color
25
+ * stories, so a new token cannot ship without a swatch.
26
+ *
27
+ * The story files are read as TEXT, never imported: they pull in react-native
28
+ * and nativewind, which throw under the node test environment. (They are also
29
+ * outside the `stories-smoke` glob, which only composes `components/**` — so
30
+ * this text guard is the only automated check on the color stories.)
31
+ *
32
+ * Both halves of Foundations/Color are scanned as one corpus: `Primitives`
33
+ * documents the raw scales, `Palettes` the assignments of them. A token is
34
+ * covered if EITHER names it — which is right, because the sidebar split is an
35
+ * organising choice and coverage is about the token having a swatch at all.
36
+ */
37
+
38
+ const themeDir = path.dirname(fileURLToPath(import.meta.url))
39
+ const storySources = ['ColorPrimitives.stories.tsx', 'ColorPalettes.stories.tsx']
40
+ .map((f) => readFileSync(path.join(themeDir, f), 'utf8'))
41
+ .join('\n')
42
+
43
+ /** Every plane a surface/background token is allowed to land on. */
44
+ const RAMP_PLANES = new Set<string>(
45
+ Object.values(SURFACE_PLANE_STEPS).map((step) => greyRamp[step])
46
+ )
47
+
48
+ /**
49
+ * Token families the color stories are NOT the documentation of record for,
50
+ * with the story that owns them instead. Add here only with a real owner —
51
+ * the point of this list is that nothing falls through unowned.
52
+ */
53
+ const DOCUMENTED_ELSEWHERE: Record<string, string> = {
54
+ hairline: 'Components/Atoms/Surface (rendered as separators, not swatches)',
55
+ on: 'Components/Atoms/Surface — OnSurfaceText (paired with their surface)',
56
+ interactive: 'Components/* (state colors are shown on the components themselves)',
57
+ avatar: 'Components/Atoms/Avatar',
58
+ }
59
+
60
+ const isSurfaceToken = (name: string) => /^(surface|background)-/.test(name)
61
+
62
+ /** Must mirror VARIANT_SUFFIXES in ColorPalettes.stories.tsx (asserted below). */
63
+ const VARIANT_SUFFIXES = ['light', 'dark', 'subtle', 'hover', 'active', 'muted'] as const
64
+
65
+ describe('Foundations/Color story coverage', () => {
66
+ it('every surface/background token resolves to a plane in the shipped ramp', () => {
67
+ const offRamp = Object.entries(semanticColorsDark)
68
+ .filter(([name]) => isSurfaceToken(name))
69
+ .filter(([, value]) => !RAMP_PLANES.has(value as string))
70
+ .map(([name, value]) => `${name}=${value as string}`)
71
+
72
+ expect(
73
+ offRamp,
74
+ 'surface/background tokens whose value is not one of the greyRamp steps ' +
75
+ 'named in SURFACE_PLANE_STEPS. Either fold the value into the ramp, or add ' +
76
+ 'the plane to SURFACE_PLANES in ColorPalettes.stories.tsx and document why ' +
77
+ 'it sits outside the ramp.'
78
+ ).toEqual([])
79
+ })
80
+
81
+ it("the test's VARIANT_SUFFIXES mirror still matches the story", () => {
82
+ // The coverage rule below depends on this list matching the one the story
83
+ // renders from; if they drift, coverage silently over- or under-reports.
84
+ const declared = storySources.match(/const VARIANT_SUFFIXES = \[([^\]]*)\]/)
85
+ expect(declared, 'VARIANT_SUFFIXES not found in ColorPalettes.stories.tsx').not.toBeNull()
86
+ const fromStory = [...declared![1].matchAll(/'([a-z]+)'/g)].map((m) => m[1])
87
+ expect(fromStory).toEqual([...VARIANT_SUFFIXES])
88
+ })
89
+
90
+ it('the surface story renders every ramp plane', () => {
91
+ // Each plane is a greyRamp step now, so the ladder must reference the step
92
+ // rather than a named member of a separate ramp object.
93
+ const missing = Object.values(SURFACE_PLANE_STEPS)
94
+ .map((step) => `greyRamp[${step}]`)
95
+ .filter((ref) => !storySources.includes(ref))
96
+
97
+ expect(missing, 'ramp planes absent from the SURFACE_PLANES ladder').toEqual([])
98
+ // Guard the count too: a plane added to the ramp must gain a row.
99
+ expect(Object.keys(SURFACE_PLANE_STEPS)).toHaveLength(6)
100
+ })
101
+
102
+ it('every other semantic token is named in a color story', () => {
103
+ // A token is covered if the story names it literally, or if it is the
104
+ // `-light`/`-subtle`/… variant of a base the story feeds to VariantMatrix
105
+ // (which builds those names at runtime, so they never appear as literals).
106
+ const namedLiterally = (token: string) => storySources.includes(`'${token}'`)
107
+ const coveredByMatrix = (token: string) =>
108
+ VARIANT_SUFFIXES.some(
109
+ (suffix) =>
110
+ token.endsWith(`-${suffix}`) && namedLiterally(token.slice(0, -(suffix.length + 1)))
111
+ )
112
+
113
+ const undocumented = Object.keys(semanticColorsDark)
114
+ .filter((name) => !isSurfaceToken(name))
115
+ .filter((name) => !DOCUMENTED_ELSEWHERE[name.split('-')[0]])
116
+ .filter((name) => !namedLiterally(name) && !coveredByMatrix(name))
117
+
118
+ expect(
119
+ undocumented,
120
+ 'semantic tokens with no swatch in Foundations/Color. Add one (a plain ' +
121
+ 'ColorSwatch, or the base to a VariantMatrix), or add the family to ' +
122
+ 'DOCUMENTED_ELSEWHERE with the story that owns it.'
123
+ ).toEqual([])
124
+ })
125
+ })
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Shared render parts for the Foundations/Color stories.
3
+ *
4
+ * The colour documentation spans two sibling titles — `Foundations/Color/
5
+ * Primitives` (the raw scales and their validation) and `Foundations/Color/
6
+ * Palettes` (the organisations of those scales into meaning). Both draw the
7
+ * same swatch, scale and section furniture, so it lives here rather than being
8
+ * copied into each.
9
+ *
10
+ * NOT a `.stories.tsx`, so Storybook's glob skips it. Note that
11
+ * `color-stories.coverage.test.ts` scans the STORY files as text for token
12
+ * names — a token whose only mention is in this file would not count as
13
+ * documented, which is correct: the swatch has to be in a story to be seen.
14
+ */
15
+ import type { ReactNode } from 'react'
16
+ import { View, Text } from 'react-native'
17
+ import { bestTextColor } from './tokens/primitives'
18
+ import { semanticColorsDark } from './tokens/semantic'
19
+
20
+ /**
21
+ * Hairline around every swatch, so a swatch whose fill matches the page still
22
+ * reads as one. Sourced from the token layer rather than a literal — the color
23
+ * stories should not be where raw hexes creep back in.
24
+ */
25
+ export const SWATCH_BORDER = semanticColorsDark['hairline-default']
26
+
27
+ export function SectionIntro({ children }: { children: ReactNode }) {
28
+ return <Text className="text-text-secondary mb-6">{children}</Text>
29
+ }
30
+
31
+ export function SectionTitle({ children }: { children: string }) {
32
+ return <Text className="text-lg font-bold text-text-primary mb-3 mt-2">{children}</Text>
33
+ }
34
+
35
+ export function ColorSwatch({ name, value }: { name: string; value: string }) {
36
+ const displayValue = value.startsWith('rgba') ? value : value.toUpperCase()
37
+
38
+ return (
39
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
40
+ <View
41
+ style={{
42
+ width: 48,
43
+ height: 48,
44
+ borderRadius: 8,
45
+ backgroundColor: value,
46
+ borderWidth: 1,
47
+ borderColor: SWATCH_BORDER,
48
+ }}
49
+ />
50
+ <View>
51
+ <Text className="font-semibold text-text-primary text-sm">{name}</Text>
52
+ <Text className="text-text-secondary text-xs">{displayValue}</Text>
53
+ </View>
54
+ </View>
55
+ )
56
+ }
57
+
58
+ /**
59
+ * One scale as a labelled row of steps with the hex under each.
60
+ *
61
+ * Steps come from the object itself rather than a fixed list, so a scale with
62
+ * the 11-step OKLCH shape and one with the legacy 10-step shape both render
63
+ * without the story knowing which it has.
64
+ */
65
+ export function ScaleRow({
66
+ name,
67
+ scale,
68
+ note,
69
+ }: {
70
+ name: string
71
+ scale: Record<string | number, string>
72
+ note?: string
73
+ }) {
74
+ return (
75
+ <View style={{ marginBottom: 14 }}>
76
+ <Text className="font-semibold text-text-primary text-sm">{name}</Text>
77
+ {note ? <Text className="text-text-tertiary text-xs mb-1">{note}</Text> : null}
78
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
79
+ {Object.entries(scale).map(([step, hex]) => (
80
+ <View key={step} style={{ alignItems: 'center', width: 62 }}>
81
+ <View
82
+ style={{
83
+ width: 62,
84
+ height: 46,
85
+ borderRadius: 6,
86
+ backgroundColor: hex,
87
+ borderWidth: 1,
88
+ borderColor: SWATCH_BORDER,
89
+ alignItems: 'center',
90
+ justifyContent: 'center',
91
+ }}
92
+ >
93
+ <Text style={{ color: bestTextColor(hex), fontSize: 10, fontWeight: '700' }}>
94
+ {step}
95
+ </Text>
96
+ </View>
97
+ <Text className="text-text-tertiary" style={{ fontSize: 8, marginTop: 2 }}>
98
+ {hex.toUpperCase()}
99
+ </Text>
100
+ </View>
101
+ ))}
102
+ </View>
103
+ </View>
104
+ )
105
+ }
106
+
107
+ export function Demo({ label, children }: { label: string; children: ReactNode }) {
108
+ return (
109
+ <View
110
+ style={{
111
+ padding: 10,
112
+ backgroundColor: semanticColorsDark['surface-raised'],
113
+ borderRadius: 8,
114
+ }}
115
+ >
116
+ <Text className="text-text-secondary" style={{ fontSize: 9, marginBottom: 6 }}>
117
+ {label}
118
+ </Text>
119
+ {children}
120
+ </View>
121
+ )
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // colour maths — drives the accessibility / CVD panel
126
+ // ---------------------------------------------------------------------------
127
+
128
+ const hexToRgb = (hex: string): [number, number, number] => {
129
+ const h = hex.replace('#', '')
130
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255) as [number, number, number]
131
+ }
132
+ const toHex = (v: number) =>
133
+ Math.round(Math.min(1, Math.max(0, v)) * 255)
134
+ .toString(16)
135
+ .padStart(2, '0')
136
+ const lin = (c: number) => (c >= 0.04045 ? ((c + 0.055) / 1.055) ** 2.4 : c / 12.92)
137
+ const gamma = (c: number) =>
138
+ c >= 0.0031308 ? 1.055 * Math.max(0, c) ** (1 / 2.4) - 0.055 : 12.92 * c
139
+
140
+ const relLum = (hex: string) => {
141
+ const [r, g, b] = hexToRgb(hex).map(lin)
142
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
143
+ }
144
+
145
+ /** WCAG contrast ratio between two solid colours. */
146
+ export function contrast(a: string, b: string): number {
147
+ const [l1, l2] = [relLum(a), relLum(b)].sort((x, y) => y - x)
148
+ return (l1 + 0.05) / (l2 + 0.05)
149
+ }
150
+
151
+ /** Machado-2009 dichromacy simulation matrices, severity 1.0. */
152
+ export const CVD_MATRICES = {
153
+ deuteranopia: [
154
+ 0.367322, 0.860646, -0.227968, 0.280085, 0.672501, 0.047413, -0.01182, 0.04294, 0.968881,
155
+ ],
156
+ protanopia: [
157
+ 0.152286, 1.052583, -0.204868, 0.114503, 0.786281, 0.099216, -0.003882, -0.048116, 1.051998,
158
+ ],
159
+ } as const
160
+
161
+ /** How `hex` appears to a viewer with the dichromacy described by `matrix`. */
162
+ export function simulateCvd(matrix: readonly number[], hex: string): string {
163
+ const [r, g, b] = hexToRgb(hex).map(lin)
164
+ const out = [
165
+ matrix[0] * r + matrix[1] * g + matrix[2] * b,
166
+ matrix[3] * r + matrix[4] * g + matrix[5] * b,
167
+ matrix[6] * r + matrix[7] * g + matrix[8] * b,
168
+ ].map(gamma)
169
+ return '#' + out.map(toHex).join('')
170
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Colour math shared by the depth system.
3
+ *
4
+ * This file used to be `shadows.ts` and shipped a neumorphic shadow builder —
5
+ * dual opposing shadows, dark bottom-right and light top-left. That was removed
6
+ * in TD-07.16. Neumorphism needs a mid-tone background (roughly 75-90%
7
+ * lightness) so BOTH shadows have somewhere to fall; our surfaces sit near 10%,
8
+ * which leaves the dark half nothing to fall onto. It collapsed to a one-sided
9
+ * rim light and read as noise on the wall.
10
+ *
11
+ * Inline depth is a hairline now (`hairline-*`, self-normalising alpha), texture
12
+ * is `materials.ts`, and drop-shadow survives only for genuinely FLOATING
13
+ * things — see `elevation.ts`.
14
+ *
15
+ * What is left here is the HSV/hex maths that `elevation.ts` still uses to
16
+ * derive rim and shadow tints from whatever surface colour it is handed.
17
+ */
18
+
19
+ // ============================================================================
20
+ // Color Math Utilities
21
+ // ============================================================================
22
+
23
+ /**
24
+ * Parse a hex color to RGB values (0-255).
25
+ * Supports both #RGB and #RRGGBB formats.
26
+ */
27
+ export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
28
+ const cleaned = hex.replace('#', '')
29
+
30
+ let r: number, g: number, b: number
31
+
32
+ if (cleaned.length === 3) {
33
+ r = parseInt(cleaned[0] + cleaned[0], 16)
34
+ g = parseInt(cleaned[1] + cleaned[1], 16)
35
+ b = parseInt(cleaned[2] + cleaned[2], 16)
36
+ } else if (cleaned.length === 6) {
37
+ r = parseInt(cleaned.slice(0, 2), 16)
38
+ g = parseInt(cleaned.slice(2, 4), 16)
39
+ b = parseInt(cleaned.slice(4, 6), 16)
40
+ } else {
41
+ return null
42
+ }
43
+
44
+ return { r, g, b }
45
+ }
46
+
47
+ /**
48
+ * Convert RGB (0-255) to HSV (h: 0-360, s: 0-1, v: 0-1).
49
+ */
50
+ export function rgbToHsv(r: number, g: number, b: number): { h: number; s: number; v: number } {
51
+ const rNorm = r / 255
52
+ const gNorm = g / 255
53
+ const bNorm = b / 255
54
+
55
+ const max = Math.max(rNorm, gNorm, bNorm)
56
+ const min = Math.min(rNorm, gNorm, bNorm)
57
+ const delta = max - min
58
+
59
+ // Value
60
+ const v = max
61
+
62
+ // Saturation
63
+ const s = max === 0 ? 0 : delta / max
64
+
65
+ // Hue
66
+ let h = 0
67
+ if (delta !== 0) {
68
+ if (max === rNorm) {
69
+ h = 60 * (((gNorm - bNorm) / delta) % 6)
70
+ } else if (max === gNorm) {
71
+ h = 60 * ((bNorm - rNorm) / delta + 2)
72
+ } else {
73
+ h = 60 * ((rNorm - gNorm) / delta + 4)
74
+ }
75
+ }
76
+ if (h < 0) h += 360
77
+
78
+ return { h, s, v }
79
+ }
80
+
81
+ /**
82
+ * Convert HSV (h: 0-360, s: 0-1, v: 0-1) to RGB (0-255).
83
+ */
84
+ export function hsvToRgb(h: number, s: number, v: number): { r: number; g: number; b: number } {
85
+ const c = v * s
86
+ const x = c * (1 - Math.abs((h / 60) % 2 - 1))
87
+ const m = v - c
88
+
89
+ let rPrime: number, gPrime: number, bPrime: number
90
+
91
+ if (h >= 0 && h < 60) {
92
+ rPrime = c; gPrime = x; bPrime = 0
93
+ } else if (h >= 60 && h < 120) {
94
+ rPrime = x; gPrime = c; bPrime = 0
95
+ } else if (h >= 120 && h < 180) {
96
+ rPrime = 0; gPrime = c; bPrime = x
97
+ } else if (h >= 180 && h < 240) {
98
+ rPrime = 0; gPrime = x; bPrime = c
99
+ } else if (h >= 240 && h < 300) {
100
+ rPrime = x; gPrime = 0; bPrime = c
101
+ } else {
102
+ rPrime = c; gPrime = 0; bPrime = x
103
+ }
104
+
105
+ return {
106
+ r: Math.round((rPrime + m) * 255),
107
+ g: Math.round((gPrime + m) * 255),
108
+ b: Math.round((bPrime + m) * 255),
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Convert RGB values to hex string.
114
+ */
115
+ export function rgbToHex(r: number, g: number, b: number): string {
116
+ const toHex = (n: number) => {
117
+ const clamped = Math.max(0, Math.min(255, Math.round(n)))
118
+ return clamped.toString(16).padStart(2, '0')
119
+ }
120
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`
121
+ }
122
+
123
+ /**
124
+ * Lighten a color by increasing its HSV Value component.
125
+ *
126
+ * @param hex - The hex color to lighten
127
+ * @param amount - Amount to increase V by (0-1)
128
+ */
129
+ export function lighten(hex: string, amount: number = 0.1): string {
130
+ const rgb = hexToRgb(hex)
131
+ if (!rgb) return hex
132
+
133
+ const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b)
134
+ const newV = Math.min(1, hsv.v + amount)
135
+
136
+ const newRgb = hsvToRgb(hsv.h, hsv.s, newV)
137
+ return rgbToHex(newRgb.r, newRgb.g, newRgb.b)
138
+ }
139
+
140
+ /**
141
+ * Darken a color by decreasing its HSV Value component.
142
+ *
143
+ * @param hex - The hex color to darken
144
+ * @param amount - Amount to decrease V by (0-1)
145
+ */
146
+ export function darken(hex: string, amount: number = 0.1): string {
147
+ const rgb = hexToRgb(hex)
148
+ if (!rgb) return hex
149
+
150
+ const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b)
151
+ const newV = Math.max(0, hsv.v - amount)
152
+
153
+ const newRgb = hsvToRgb(hsv.h, hsv.s, newV)
154
+ return rgbToHex(newRgb.r, newRgb.g, newRgb.b)
155
+ }
156
+
157
+ /**
158
+ * Get hover colors for a button based on its background color.
159
+ * Automatically adapts intensity based on the color's luminance.
160
+ *
161
+ * @param bgColor - The button's background color (hex)
162
+ * @param intensity - How strong the hover effect should be ('subtle' | 'medium' | 'strong')
163
+ */
164
+ export function getHoverColors(
165
+ bgColor: string,
166
+ intensity: 'subtle' | 'medium' | 'strong' = 'medium'
167
+ ): { raised: string; pressed: string } {
168
+ const amounts = {
169
+ subtle: 0.01,
170
+ medium: 0.02,
171
+ strong: 0.03,
172
+ }
173
+
174
+ const amount = amounts[intensity]
175
+
176
+ return {
177
+ raised: lighten(bgColor, amount), // Raised hover: lighten (lifting up)
178
+ pressed: darken(bgColor, amount), // Pressed hover: darken (pressing down)
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Check if a color is considered "dark" (for choosing text color).
184
+ * Uses HSV Value component for simplicity.
185
+ */
186
+ export function isDark(hex: string): boolean {
187
+ const rgb = hexToRgb(hex)
188
+ if (!rgb) return true
189
+ const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b)
190
+ return hsv.v < 0.6
191
+ }
192
+
193
+ /**
194
+ * Shadow strength tiers. Retained because `elevation.ts` keys its level table on
195
+ * them; the neumorphic shadow BUILDERS that used to live here are gone.
196
+ */
197
+ export type ShadowIntensity = 'subtle' | 'medium' | 'strong'
@@ -77,16 +77,12 @@ export const lightThemeCSSVars = {
77
77
  '--color-surface-base': semanticColorsLight['surface-base'],
78
78
  '--color-surface-elevated': semanticColorsLight['surface-elevated'],
79
79
  '--color-surface-raised': semanticColorsLight['surface-raised'],
80
- '--color-surface-inset': semanticColorsLight['surface-inset'],
81
80
 
82
81
  '--color-background-base': semanticColorsLight['background-base'],
83
82
  '--color-background-default': semanticColorsLight['background-default'],
84
83
  '--color-background-subtle': semanticColorsLight['background-subtle'],
85
84
  '--color-background-frame': semanticColorsLight['background-frame'],
86
85
 
87
- '--color-border-default': semanticColorsLight['border-default'],
88
- '--color-border-subtle': semanticColorsLight['border-subtle'],
89
- '--color-border-strong': semanticColorsLight['border-strong'],
90
86
  '--color-border-prominent': semanticColorsLight['border-prominent'],
91
87
  '--color-border-focus': semanticColorsLight['border-focus'],
92
88
 
@@ -204,16 +200,12 @@ export const darkThemeCSSVars = {
204
200
  '--color-surface-base': semanticColorsDark['surface-base'],
205
201
  '--color-surface-elevated': semanticColorsDark['surface-elevated'],
206
202
  '--color-surface-raised': semanticColorsDark['surface-raised'],
207
- '--color-surface-inset': semanticColorsDark['surface-inset'],
208
203
 
209
204
  '--color-background-base': semanticColorsDark['background-base'],
210
205
  '--color-background-default': semanticColorsDark['background-default'],
211
206
  '--color-background-subtle': semanticColorsDark['background-subtle'],
212
207
  '--color-background-frame': semanticColorsDark['background-frame'],
213
208
 
214
- '--color-border-default': semanticColorsDark['border-default'],
215
- '--color-border-subtle': semanticColorsDark['border-subtle'],
216
- '--color-border-strong': semanticColorsDark['border-strong'],
217
209
  '--color-border-prominent': semanticColorsDark['border-prominent'],
218
210
  '--color-border-focus': semanticColorsDark['border-focus'],
219
211
 
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { composeStories } from '@storybook/react'
4
+ import * as stories from './DepthCalibration.stories'
5
+
6
+ /**
7
+ * The calibration rig is the gate on a release tag, and its whole value rests on
8
+ * one property: the answer key is NOT on screen until the operator asks for it.
9
+ * A leaked key does not make the panels look broken — it makes them look fine,
10
+ * and silently converts a measurement into a confirmation.
11
+ *
12
+ * The theme stories are outside `stories-smoke.test.tsx`'s
13
+ * `../components/**` glob, so this file also carries the render smoke for them.
14
+ */
15
+ const { Hairlines, ToneSteps, PaperMaterial, Banding } = composeStories(stories)
16
+ const ALL = { Hairlines, ToneSteps, PaperMaterial, Banding }
17
+
18
+ describe('depth calibration rig', () => {
19
+ for (const [name, Story] of Object.entries(ALL)) {
20
+ it(`${name} renders`, () => {
21
+ const result = render(<Story />)
22
+ expect(result.container.firstChild).toBeTruthy()
23
+ result.unmount()
24
+ })
25
+ }
26
+
27
+ it('hides every answer until Reveal is pressed, then shows them', () => {
28
+ render(<Hairlines />)
29
+
30
+ // grey-975 is the ground of row 1; naming a ground names an answer.
31
+ expect(screen.queryByText(/grey-975/)).toBeNull()
32
+ expect(screen.queryByText(/decoy/)).toBeNull()
33
+
34
+ fireEvent.click(screen.getByText('Reveal key'))
35
+
36
+ expect(screen.getAllByText(/grey-975/).length).toBeGreaterThan(0)
37
+ expect(screen.getAllByText(/decoy/).length).toBe(4)
38
+ })
39
+
40
+ it('seeds exactly four blank cells across four distinct grounds', () => {
41
+ render(<Hairlines />)
42
+ fireEvent.click(screen.getByText('Reveal key'))
43
+
44
+ // One decoy per row, so no row can be answered by "the blank one is here again".
45
+ const decoys = screen.getAllByText(/NONE \(decoy\)/)
46
+ expect(decoys).toHaveLength(4)
47
+
48
+ const rows = decoys.map((n) => n.textContent?.match(/^[A-D](\d)/)?.[1])
49
+ expect(new Set(rows).size).toBe(4)
50
+ })
51
+
52
+ it('seeds an identical pair among the paper sheets', () => {
53
+ render(<PaperMaterial />)
54
+ expect(screen.queryByText(/FLAT \(decoy\)/)).toBeNull()
55
+
56
+ // Two byte-identical flats, so "all three look the same" stays an available
57
+ // answer rather than an admission — the material has to be picked OUT.
58
+ fireEvent.click(screen.getByText('Reveal key'))
59
+ expect(screen.getAllByText(/FLAT \(decoy\)/)).toHaveLength(2)
60
+ })
61
+
62
+ it('pairs the smooth banding control with a hand-quantised reference', () => {
63
+ // Run 1 was VOID because nothing could band. Run 2 then found the smooth
64
+ // control ALSO reading clean, which is ambiguous: a blind panel and a render
65
+ // path that simply does not band both look like "no steps". The hard-edged
66
+ // reference disambiguates them, so the panel needs BOTH gradients — and
67
+ // neither may be one of the shipped-material fields.
68
+ const { container } = render(<Banding />)
69
+ expect(container.querySelectorAll('[style*="linear-gradient"]')).toHaveLength(2)
70
+ })
71
+
72
+ it('labels the control and the reference distinctly, and only on reveal', () => {
73
+ render(<Banding />)
74
+ expect(screen.queryByText(/CONTROL/)).toBeNull()
75
+ expect(screen.queryByText(/REFERENCE/)).toBeNull()
76
+
77
+ fireEvent.click(screen.getByText('Reveal key'))
78
+ expect(screen.getAllByText(/CONTROL/)).toHaveLength(1)
79
+ expect(screen.getAllByText(/REFERENCE/)).toHaveLength(1)
80
+ })
81
+
82
+ it('seeds a no-floor-line decoy among the inset wells', () => {
83
+ render(<PaperMaterial />)
84
+ expect(screen.queryByText(/NONE \(decoy\)/)).toBeNull()
85
+
86
+ fireEvent.click(screen.getByText('Reveal key'))
87
+ expect(screen.getAllByText(/NONE \(decoy\)/)).toHaveLength(1)
88
+ })
89
+
90
+ it('seeds an identical pair among the tone-step pairs', () => {
91
+ render(<ToneSteps />)
92
+ expect(screen.queryByText(/SAME \(decoy\)/)).toBeNull()
93
+
94
+ fireEvent.click(screen.getByText('Reveal key'))
95
+ expect(screen.getAllByText(/SAME \(decoy\)/)).toHaveLength(1)
96
+ })
97
+ })