@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
@@ -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,
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
2
2
  import { readFileSync } from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
- import { surfaceRampDark, backgroundFrameDark } from './tokens/primitives'
5
+ import { greyRamp, SURFACE_PLANE_STEPS } from './tokens/primitives'
6
6
  import { semanticColorsDark } from './tokens/semantic'
7
7
 
8
8
  /**
@@ -11,7 +11,7 @@ import { semanticColorsDark } from './tokens/semantic'
11
11
  * The color stories are the documentation of record for the token layer, and
12
12
  * they have drifted twice: once when the categorical palette replaced the
13
13
  * Paul-Tol scale, and again when the v0.10.0 surface redesign added four
14
- * planes (`surface-inset`, `surface-overlay`, `background-frame`,
14
+ * planes (`surface-overlay`, `background-frame`,
15
15
  * `background-subtle`) that no story rendered. Both times the values stayed
16
16
  * correct while the *coverage* silently fell behind — which a screenshot test
17
17
  * cannot catch, because a story that was never written renders nothing.
@@ -41,7 +41,9 @@ const storySources = ['ColorPrimitives.stories.tsx', 'ColorPalettes.stories.tsx'
41
41
  .join('\n')
42
42
 
43
43
  /** Every plane a surface/background token is allowed to land on. */
44
- const RAMP_PLANES = new Set<string>([...Object.values(surfaceRampDark), backgroundFrameDark])
44
+ const RAMP_PLANES = new Set<string>(
45
+ Object.values(SURFACE_PLANE_STEPS).map((step) => greyRamp[step])
46
+ )
45
47
 
46
48
  /**
47
49
  * Token families the color stories are NOT the documentation of record for,
@@ -69,9 +71,9 @@ describe('Foundations/Color story coverage', () => {
69
71
 
70
72
  expect(
71
73
  offRamp,
72
- 'surface/background tokens whose value is not a step of surfaceRampDark ' +
73
- '(or the backgroundFrameDark bezel). Either fold the value into the ramp, ' +
74
- 'or add the plane to SURFACE_PLANES in ColorPalettes.stories.tsx and document why ' +
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 ' +
75
77
  'it sits outside the ramp.'
76
78
  ).toEqual([])
77
79
  })
@@ -86,22 +88,15 @@ describe('Foundations/Color story coverage', () => {
86
88
  })
87
89
 
88
90
  it('the surface story renders every ramp plane', () => {
89
- // SURFACE_PLANES is built from the ramp exports, so covering the exports
90
- // is what proves no plane is missing from the rendered ladder.
91
- const referenced = [
92
- 'surfaceRampDark.inset',
93
- 'surfaceRampDark.background',
94
- 'surfaceRampDark.base',
95
- 'surfaceRampDark.elevated',
96
- 'surfaceRampDark.raised',
97
- 'surfaceRampDark.overlay',
98
- 'backgroundFrameDark',
99
- ]
100
- const missing = referenced.filter((ref) => !storySources.includes(ref))
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))
101
96
 
102
97
  expect(missing, 'ramp planes absent from the SURFACE_PLANES ladder').toEqual([])
103
- // Guard the count too: a step added to the ramp must gain a row.
104
- expect(Object.keys(surfaceRampDark)).toHaveLength(6)
98
+ // Guard the count too: a plane added to the ramp must gain a row.
99
+ expect(Object.keys(SURFACE_PLANE_STEPS)).toHaveLength(6)
105
100
  })
106
101
 
107
102
  it('every other semantic token is named in a color story', () => {
@@ -22,7 +22,7 @@ import { semanticColorsDark } from './tokens/semantic'
22
22
  * reads as one. Sourced from the token layer rather than a literal — the color
23
23
  * stories should not be where raw hexes creep back in.
24
24
  */
25
- export const SWATCH_BORDER = semanticColorsDark['border-default']
25
+ export const SWATCH_BORDER = semanticColorsDark['hairline-default']
26
26
 
27
27
  export function SectionIntro({ children }: { children: ReactNode }) {
28
28
  return <Text className="text-text-secondary mb-6">{children}</Text>
@@ -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
+ })
@@ -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) {