@titan-design/react-ui 0.9.3 → 0.11.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 (120) hide show
  1. package/dist/bodymap.js +78 -24
  2. package/dist/bodymap.js.map +1 -1
  3. package/dist/bodymap.mjs +78 -24
  4. package/dist/bodymap.mjs.map +1 -1
  5. package/dist/index.d.mts +429 -11
  6. package/dist/index.d.ts +429 -11
  7. package/dist/index.js +2386 -885
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2306 -836
  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 +1900 -1609
  16. package/dist/pages.js.map +1 -1
  17. package/dist/pages.mjs +1901 -1610
  18. package/dist/pages.mjs.map +1 -1
  19. package/dist/theme/index.d.mts +129 -34
  20. package/dist/theme/index.d.ts +129 -34
  21. package/dist/theme/index.js +95 -19
  22. package/dist/theme/index.js.map +1 -1
  23. package/dist/theme/index.mjs +92 -20
  24. package/dist/theme/index.mjs.map +1 -1
  25. package/dist/theme/tokens-css.js +88 -24
  26. package/dist/theme/tokens-css.js.map +1 -1
  27. package/dist/theme/tokens-css.mjs +88 -24
  28. package/dist/theme/tokens-css.mjs.map +1 -1
  29. package/package.json +8 -3
  30. package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
  31. package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
  32. package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
  33. package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
  34. package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
  35. package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
  36. package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
  37. package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
  38. package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
  39. package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
  40. package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
  41. package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
  42. package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
  43. package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
  44. package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
  45. package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
  46. package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
  47. package/src/components/custom/Fatigue/README.md +93 -0
  48. package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
  49. package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
  50. package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
  51. package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
  52. package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
  53. package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
  54. package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
  55. package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
  56. package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
  57. package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
  58. package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
  59. package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
  60. package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
  61. package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
  62. package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
  63. package/src/components/custom/Fatigue/index.ts +41 -0
  64. package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
  65. package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
  66. package/src/components/custom/Workout/BaseBadge.tsx +5 -5
  67. package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
  68. package/src/components/custom/Workout/ExerciseCard.test.tsx +4 -4
  69. package/src/components/custom/Workout/ExerciseCard.tsx +3 -1
  70. package/src/components/custom/Workout/ExerciseDetailPage.tsx +7 -1
  71. package/src/components/custom/Workout/README.md +62 -16
  72. package/src/components/custom/Workout/RowInventory.stories.tsx +5 -2
  73. package/src/components/custom/Workout/S3FullRail.stories.tsx +13 -1
  74. package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
  75. package/src/components/custom/Workout/SessionHeader.tsx +2 -2
  76. package/src/components/custom/Workout/SessionRail.stories.tsx +4 -1
  77. package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
  78. package/src/components/custom/Workout/SessionRail.tsx +55 -18
  79. package/src/components/custom/Workout/SetRow.test.tsx +6 -6
  80. package/src/components/custom/Workout/SetRow.tsx +9 -2
  81. package/src/components/custom/Workout/SupersetWrapper.stories.tsx +16 -25
  82. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -1
  83. package/src/components/custom/Workout/TempoDisplay.tsx +3 -7
  84. package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
  85. package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
  86. package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
  87. package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
  88. package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
  89. package/src/components/custom/Workout/VelocityStrip.test.tsx +196 -79
  90. package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
  91. package/src/components/custom/Workout/VolumeLandmarkBar.stories.tsx +4 -1
  92. package/src/components/custom/Workout/WorkoutPill.tsx +2 -2
  93. package/src/components/custom/Workout/index.ts +3 -0
  94. package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
  95. package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
  96. package/src/components/custom/charts/SetBarChart.tsx +651 -0
  97. package/src/components/custom/charts/live-rep-growth.ts +91 -0
  98. package/src/components/custom/index.ts +1 -0
  99. package/src/components/ui/surface/Surface.stories.tsx +195 -1
  100. package/src/components/ui/surface/Surface.test.tsx +148 -9
  101. package/src/components/ui/surface/Surface.tsx +37 -9
  102. package/src/components/ui/surface/SurfaceContext.ts +48 -8
  103. package/src/components/ui/surface/index.ts +1 -0
  104. package/src/components/ui/surface/surface.contract.test.ts +283 -0
  105. package/src/test/raw-color-patterns.test.ts +71 -0
  106. package/src/theme/ColorPalettes.stories.tsx +871 -0
  107. package/src/theme/ColorPrimitives.stories.tsx +300 -0
  108. package/src/theme/bar-paper.ts +59 -0
  109. package/src/theme/color-stories.coverage.test.ts +130 -0
  110. package/src/theme/color-story-kit.tsx +170 -0
  111. package/src/theme/config.ts +12 -0
  112. package/src/theme/elevation.ts +90 -67
  113. package/src/theme/global.css +35 -9
  114. package/src/theme/tokens/primitives.ts +40 -0
  115. package/src/theme/tokens/semantic.ts +63 -14
  116. package/tailwind.config.js +8 -0
  117. package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
  118. package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
  119. package/src/theme/ColorSystem.stories.tsx +0 -323
  120. package/src/theme/Colors.stories.tsx +0 -323
@@ -0,0 +1,91 @@
1
+ // The newest-rep grow-from-bottom entrance, shared by SetBarChart (hero / dual / ROM)
2
+ // and VelocityStrip's framed + bare expanded charts. Promoted out of VelocityStrip so
3
+ // every value-height bar family animates the live rep identically instead of re-rolling
4
+ // the hook.
5
+ import { useEffect, useState } from 'react'
6
+ import { Animated, Easing } from 'react-native'
7
+
8
+ const ANIMATION_EASING = Easing.bezier(0.22, 1, 0.36, 1)
9
+
10
+ /**
11
+ * Growth-factor overshoot for a new-peak bar: it grows past its full height then
12
+ * settles back to 1 (a small bounce), rather than the plain 0→1 grow every other
13
+ * bar gets. Modest (12%) since it stretches actual bar HEIGHT, not a uniform scale
14
+ * — a large overshoot here reads as a much bigger jump than the same factor did
15
+ * against the old whole-bar scale pop.
16
+ */
17
+ const PEAK_OVERSHOOT = 1.12
18
+
19
+ function getReducedMotionPreference(): boolean {
20
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
21
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches
22
+ }
23
+
24
+ /** Track the OS "reduce motion" preference; falls back to `false` (jsdom/SSR). */
25
+ export function usePrefersReducedMotion(): boolean {
26
+ const [reduced, setReduced] = useState(getReducedMotionPreference)
27
+ useEffect(() => {
28
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
29
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
30
+ const handler = () => setReduced(mq.matches)
31
+ handler()
32
+ mq.addEventListener?.('change', handler)
33
+ return () => mq.removeEventListener?.('change', handler)
34
+ }, [])
35
+ return reduced
36
+ }
37
+
38
+ /**
39
+ * The newest-rep entrance shared by SetBarChart and the framed / bare `expanded` charts:
40
+ * the bar GROWS UP FROM THE BASELINE (0 → full height), reading as though it's tracking
41
+ * the rep as it lands. A new set peak overshoots past full height then settles (a small
42
+ * bounce) instead of the plain grow. Honors reduced motion (jumps straight to full
43
+ * height, no animation). Returns a 0→1(+overshoot) growth-factor `Animated.Value`;
44
+ * callers interpolate it against their own target height. Inert while `active` is
45
+ * false or no live rep is present.
46
+ */
47
+ export function useLiveRepGrowth(
48
+ active: boolean,
49
+ liveRepIndex: number | undefined,
50
+ liveVelocity: number | undefined,
51
+ isNewPeak: boolean
52
+ ): Animated.Value {
53
+ const prefersReducedMotion = usePrefersReducedMotion()
54
+ const [liveGrowth] = useState(() => new Animated.Value(1))
55
+ useEffect(() => {
56
+ if (!active || liveRepIndex == null || liveVelocity == null) return
57
+ if (prefersReducedMotion) {
58
+ liveGrowth.setValue(1)
59
+ return
60
+ }
61
+ liveGrowth.setValue(0)
62
+ if (isNewPeak) {
63
+ Animated.sequence([
64
+ Animated.timing(liveGrowth, {
65
+ toValue: PEAK_OVERSHOOT,
66
+ duration: 220,
67
+ easing: Easing.out(Easing.quad),
68
+ useNativeDriver: false,
69
+ }),
70
+ Animated.spring(liveGrowth, {
71
+ toValue: 1,
72
+ friction: 5,
73
+ tension: 140,
74
+ useNativeDriver: false,
75
+ }),
76
+ ]).start()
77
+ } else {
78
+ // Plain entrance: a clean ease-out grow with no overshoot (ANIMATION_EASING has
79
+ // no control point above y=1) — it reads as tracking the rep, not bouncing.
80
+ Animated.timing(liveGrowth, {
81
+ toValue: 1,
82
+ duration: 300,
83
+ easing: ANIMATION_EASING,
84
+ useNativeDriver: false,
85
+ }).start()
86
+ }
87
+ }, [active, liveRepIndex, liveVelocity, isNewPeak, prefersReducedMotion, liveGrowth])
88
+ return liveGrowth
89
+ }
90
+
91
+ export { ANIMATION_EASING }
@@ -9,6 +9,7 @@ export * from './TimerReadout'
9
9
  export * from './CircularTimer'
10
10
  export * from './Metric'
11
11
  export * from './Workout'
12
+ export * from './Fatigue'
12
13
  export * from './Treemap'
13
14
  export * from './Scatter'
14
15
  export * from './Gauge'
@@ -15,9 +15,13 @@ const meta: Meta<typeof Surface> = {
15
15
  },
16
16
  level: {
17
17
  control: 'select',
18
- options: ['background', 'base', 'elevated', 'raised', 'overlay'],
18
+ options: ['inset', 'background', 'base', 'elevated', 'raised', 'overlay'],
19
19
  description: 'Named charcoal plane (flat background from a semantic token)',
20
20
  },
21
+ pressed: {
22
+ control: 'boolean',
23
+ description: 'Sunken well: one ramp step DOWN from the parent level + inner-shadow recess',
24
+ },
21
25
  theme: {
22
26
  control: 'select',
23
27
  options: ['light', 'dark'],
@@ -99,6 +103,62 @@ export const NamedPlanes: Story = {
99
103
  ),
100
104
  }
101
105
 
106
+ // The pressed (sunken well) model: a `<Surface pressed>` renders ONE ramp step
107
+ // DOWN from its parent's level — the symmetric twin of a raised surface stepping
108
+ // up — plus an inner-shadow recess. It's RELATIVE: the same `pressed` well reads
109
+ // as a consistent recess on every host plane (base → background, raised →
110
+ // elevated, overlay → raised), not a single fixed black hole. It clamps at the
111
+ // `inset` floor so a well in `background` bottoms out at the ramp's pit.
112
+ function WellCard({ level }: { level: SurfaceLevel }) {
113
+ return (
114
+ <Surface level={level} style={{ padding: 16, gap: 12, flex: 1, borderRadius: 14 }}>
115
+ <Eyebrow>HOST · LEVEL = {level.toUpperCase()}</Eyebrow>
116
+ <Surface pressed style={{ padding: 16, gap: 4 }}>
117
+ <Eyebrow>PRESSED WELL</Eyebrow>
118
+ <OnSurfaceBody title="Sunken panel" sub="one step down + inner recess" />
119
+ </Surface>
120
+ </Surface>
121
+ )
122
+ }
123
+
124
+ export const PressedWells: Story = {
125
+ render: () => (
126
+ <Surface level="background" style={{ flexDirection: 'row', gap: 16, padding: 24 }}>
127
+ {(['base', 'raised', 'overlay'] as SurfaceLevel[]).map((level) => (
128
+ <WellCard key={level} level={level} />
129
+ ))}
130
+ </Surface>
131
+ ),
132
+ }
133
+
134
+ // Clamp + nesting — a well in `background` bottoms out at the inset floor
135
+ // (#13100D), and pressing again inside it stays at the floor (no underflow),
136
+ // while a well in a lighter plane still steps down normally.
137
+ export const PressedClampAndNesting: Story = {
138
+ render: () => (
139
+ <View style={{ flexDirection: 'row', gap: 16, padding: 24 }}>
140
+ <Surface level="background" style={{ padding: 16, gap: 12, flex: 1, borderRadius: 14 }}>
141
+ <Eyebrow>HOST · LEVEL = BACKGROUND</Eyebrow>
142
+ <Surface pressed style={{ padding: 16, gap: 10 }}>
143
+ <Eyebrow>PRESSED → INSET FLOOR</Eyebrow>
144
+ <Surface pressed style={{ padding: 12 }}>
145
+ <Eyebrow>PRESSED AGAIN → CLAMPED</Eyebrow>
146
+ </Surface>
147
+ </Surface>
148
+ </Surface>
149
+ <Surface level="raised" style={{ padding: 16, gap: 12, flex: 1, borderRadius: 14 }}>
150
+ <Eyebrow>HOST · LEVEL = RAISED</Eyebrow>
151
+ <Surface pressed style={{ padding: 16, gap: 10 }}>
152
+ <Eyebrow>PRESSED → ELEVATED</Eyebrow>
153
+ <Surface pressed style={{ padding: 12 }}>
154
+ <Eyebrow>PRESSED AGAIN → BASE</Eyebrow>
155
+ </Surface>
156
+ </Surface>
157
+ </Surface>
158
+ </View>
159
+ ),
160
+ }
161
+
102
162
  // On-surface colour context: descendant text reads its colour from the Surface
103
163
  // via `useOnSurfaceColor` — no hard-coded hex, so it never renders black when the
104
164
  // tree mounts as raw RN in the standalone wall SPA.
@@ -138,3 +198,137 @@ export const LightTheme: Story = {
138
198
  </View>
139
199
  ),
140
200
  }
201
+
202
+ // ===========================================================================
203
+ // The Surface WORK — composition, context inheritance, and the real wall lockup.
204
+ // The stories above exercise the primitive in isolation; these show it doing the
205
+ // job it was built for (TD-05.12): stacking planes, propagating the on-surface
206
+ // colour context, and backing the migrated shell / rail / stage.
207
+ // ===========================================================================
208
+
209
+ /** An all-caps eyebrow whose colour resolves on whatever Surface encloses it. */
210
+ function Eyebrow({ children }: { children: string }) {
211
+ return (
212
+ <Text
213
+ style={{
214
+ color: useOnSurfaceColor('tertiary'),
215
+ fontSize: 10,
216
+ fontWeight: '700',
217
+ letterSpacing: 1,
218
+ }}
219
+ >
220
+ {children}
221
+ </Text>
222
+ )
223
+ }
224
+
225
+ /** Primary + secondary body text, both on-surface — no hard-coded hex. */
226
+ function OnSurfaceBody({ title, sub }: { title: string; sub: string }) {
227
+ return (
228
+ <View style={{ gap: 2 }}>
229
+ <Text style={{ color: useOnSurfaceColor('primary'), fontSize: 14, fontWeight: '600' }}>
230
+ {title}
231
+ </Text>
232
+ <Text style={{ color: useOnSurfaceColor('secondary'), fontSize: 12 }}>{sub}</Text>
233
+ </View>
234
+ )
235
+ }
236
+
237
+ // Nesting + context inheritance — the core mechanic. Each Surface publishes its
238
+ // level; the planes read darkest → lightest as they nest (background < base <
239
+ // elevated < raised). The labels call `useOnSurfaceColor` and stay legible on
240
+ // every plane WITHOUT a hard-coded hex — on-surface text resolves from the mode,
241
+ // so one set of roles reads correctly across all the dark planes.
242
+ export const NestedPlanes: Story = {
243
+ render: () => (
244
+ <Surface level="background" style={{ padding: 20, gap: 14 }}>
245
+ <Eyebrow>LEVEL = BACKGROUND</Eyebrow>
246
+ <Surface level="base" style={{ padding: 16, gap: 14, borderRadius: 14 }}>
247
+ <Eyebrow>LEVEL = BASE</Eyebrow>
248
+ <Surface level="elevated" style={{ padding: 16, gap: 14, borderRadius: 12 }}>
249
+ <Eyebrow>LEVEL = ELEVATED</Eyebrow>
250
+ <Surface level="raised" style={{ padding: 16, gap: 6, borderRadius: 10 }}>
251
+ <Eyebrow>LEVEL = RAISED</Eyebrow>
252
+ <OnSurfaceBody title="On-surface text" sub="resolves from the enclosing Surface" />
253
+ </Surface>
254
+ </Surface>
255
+ </Surface>
256
+ </Surface>
257
+ ),
258
+ }
259
+
260
+ // Theme inheritance — a Surface seeds the mode; the nested Surfaces and text with
261
+ // NO explicit `theme` inherit it. The SAME subtree renders on both grounds and
262
+ // stays legible, because on-surface colours resolve from the inherited mode
263
+ // rather than a fixed hex. (A numeric `elevation` Surface inherits mode too.)
264
+ function InheritingCard() {
265
+ return (
266
+ <Surface level="base" style={{ padding: 14, gap: 10, borderRadius: 12 }}>
267
+ <OnSurfaceBody title="Inherited mode" sub="no theme prop on this card" />
268
+ <Surface level="raised" style={{ padding: 12, borderRadius: 8 }}>
269
+ <Eyebrow>NESTED · STILL INHERITS</Eyebrow>
270
+ </Surface>
271
+ </Surface>
272
+ )
273
+ }
274
+
275
+ export const ThemeInheritance: Story = {
276
+ render: () => (
277
+ <View style={{ flexDirection: 'row', gap: 16, padding: 24 }}>
278
+ <Surface theme="dark" level="background" style={{ padding: 16, gap: 10, flex: 1 }}>
279
+ <Eyebrow>THEME = DARK (SEEDS CONTEXT)</Eyebrow>
280
+ <InheritingCard />
281
+ </Surface>
282
+ <Surface theme="light" level="background" style={{ padding: 16, gap: 10, flex: 1 }}>
283
+ <Eyebrow>THEME = LIGHT (SEEDS CONTEXT)</Eyebrow>
284
+ <InheritingCard />
285
+ </Surface>
286
+ </View>
287
+ ),
288
+ }
289
+
290
+ // The wall lockup, as migrated (DashboardShell / SessionRail / stage). The shell
291
+ // is a `background` plane; the page a `base` plane; the session rail an
292
+ // `elevated` inset; the live stage a `raised` panel. Every eyebrow/label reads
293
+ // on-surface — this composition is what replaced the inline `getSemanticColors`
294
+ // text patches the standalone wall SPA used to carry.
295
+ function RailRow({ name, sets }: { name: string; sets: string }) {
296
+ return (
297
+ <View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
298
+ <Text style={{ color: useOnSurfaceColor('primary'), fontSize: 13 }}>{name}</Text>
299
+ <Text style={{ color: useOnSurfaceColor('tertiary'), fontSize: 13 }}>{sets}</Text>
300
+ </View>
301
+ )
302
+ }
303
+
304
+ export const WallLockup: Story = {
305
+ render: () => (
306
+ <Surface level="background" style={{ flexDirection: 'row', height: 320, padding: 12, gap: 12 }}>
307
+ {/* left nav — flush to the shell edge, on the same background plane */}
308
+ <View style={{ width: 56, alignItems: 'center', paddingTop: 10 }}>
309
+ <Eyebrow>NAV</Eyebrow>
310
+ </View>
311
+ {/* page — a base plane holding the rail + stage */}
312
+ <Surface
313
+ level="base"
314
+ style={{ flex: 1, flexDirection: 'row', padding: 12, gap: 12, borderRadius: 14 }}
315
+ >
316
+ {/* session rail — an elevated inset */}
317
+ <Surface level="elevated" style={{ width: 150, padding: 12, gap: 10, borderRadius: 12 }}>
318
+ <Eyebrow>SESSION</Eyebrow>
319
+ <RailRow name="Bench" sets="3/3" />
320
+ <RailRow name="Cable Row" sets="1/3" />
321
+ <RailRow name="Curl" sets="0/3" />
322
+ </Surface>
323
+ {/* live stage — a raised panel */}
324
+ <Surface
325
+ level="raised"
326
+ style={{ flex: 1, padding: 16, gap: 6, justifyContent: 'center', borderRadius: 12 }}
327
+ >
328
+ <Eyebrow>LIVE</Eyebrow>
329
+ <OnSurfaceBody title="Seated Cable Row" sub="set 2 · 8 reps · 0.42 m/s" />
330
+ </Surface>
331
+ </Surface>
332
+ </Surface>
333
+ ),
334
+ }
@@ -6,9 +6,12 @@ import { Surface } from './Surface'
6
6
  import {
7
7
  onSurfaceColors,
8
8
  surfaceBackground,
9
+ pressedLevel,
9
10
  useOnSurfaceColor,
11
+ useSurface,
10
12
  useSurfaceMode,
11
13
  } from './SurfaceContext'
14
+ import { getPressedRecessShadow } from '../../../theme/elevation'
12
15
 
13
16
  // A descendant probe that renders the on-surface colour + mode it resolves from
14
17
  // context, so tests can assert what a nested consumer would actually paint.
@@ -70,8 +73,9 @@ describe('Surface (card model)', () => {
70
73
 
71
74
  it('paints the default flat dark card surface', () => {
72
75
  render(<Surface testID="s" />)
73
- // elevation 0 → base surface (surface-elevated, charcoal 600) with no lightening.
74
- expect(screen.getByTestId('s')).toHaveStyle({ backgroundColor: '#191919' })
76
+ // elevation 0 → base surface (surface-elevated) with no lightening.
77
+ // TD-surface-tokens S-3 re-space: was #2A2827.
78
+ expect(screen.getByTestId('s')).toHaveStyle({ backgroundColor: '#2C2A28' })
75
79
  })
76
80
 
77
81
  describe('accessibility', () => {
@@ -89,11 +93,11 @@ describe('Surface (card model)', () => {
89
93
 
90
94
  describe('Surface (named plane)', () => {
91
95
  it.each([
92
- ['background', '#101010'],
93
- ['base', '#161616'],
94
- ['elevated', '#191919'],
95
- ['raised', '#1C1C1C'],
96
- ] as const)('maps level %s to the charcoal token %s', (level, hex) => {
96
+ ['background', '#1C1916'],
97
+ ['base', '#252321'],
98
+ ['elevated', '#2C2A28'],
99
+ ['raised', '#31302F'],
100
+ ] as const)('maps level %s to the surface-ramp token %s', (level, hex) => {
97
101
  render(<Surface level={level} testID="s" />)
98
102
  expect(screen.getByTestId('s')).toHaveStyle({ backgroundColor: hex })
99
103
  })
@@ -109,6 +113,137 @@ describe('Surface (named plane)', () => {
109
113
  })
110
114
  })
111
115
 
116
+ // A descendant probe that reports the surface LEVEL it resolves from context,
117
+ // so tests can assert what a nested Surface publishes to its children.
118
+ function LevelProbe({ label = 'lvl' }) {
119
+ const { level } = useSurface()
120
+ return <Text testID={label}>{level}</Text>
121
+ }
122
+
123
+ // CIELAB L* (perceptual lightness) — same calc as surface.contract.test, so the
124
+ // "darker" assertions measure the metric the ramp was designed against.
125
+ function lstar(hex: string): number {
126
+ const h = hex.replace('#', '')
127
+ const lin = [0, 2, 4]
128
+ .map((i) => parseInt(h.slice(i, i + 2), 16) / 255)
129
+ .map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4))
130
+ const y = 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
131
+ return y <= 0.008856 ? y * 903.3 : 116 * Math.cbrt(y) - 16
132
+ }
133
+
134
+ describe('Surface (pressed well)', () => {
135
+ it.each([
136
+ // TD-surface-tokens S-3 re-space: top steps widened (elevated #2C2A28,
137
+ // raised #31302F, overlay #373635); base/background unchanged.
138
+ ['overlay', '#373635', '#31302F'], // → raised
139
+ ['raised', '#31302F', '#2C2A28'], // → elevated
140
+ ['elevated', '#2C2A28', '#252321'], // → base
141
+ ['base', '#252321', '#1C1916'], // → background
142
+ ] as const)(
143
+ 'in a %s parent renders one ramp step down (%s → %s), darker than its parent',
144
+ (parent, parentHex, pressedHex) => {
145
+ render(
146
+ <Surface level={parent}>
147
+ <Surface pressed testID="well" />
148
+ </Surface>
149
+ )
150
+ expect(screen.getByTestId('well')).toHaveStyle({ backgroundColor: pressedHex })
151
+ expect(lstar(pressedHex)).toBeLessThan(lstar(parentHex))
152
+ }
153
+ )
154
+
155
+ it('with no enclosing Surface (default base) presses to the background plane', () => {
156
+ render(<Surface pressed testID="well" />)
157
+ expect(screen.getByTestId('well')).toHaveStyle({ backgroundColor: '#1C1916' })
158
+ })
159
+
160
+ it('clamps at the inset floor: pressed directly in background does not underflow', () => {
161
+ render(
162
+ <Surface level="background">
163
+ <Surface pressed testID="well" />
164
+ </Surface>
165
+ )
166
+ // background (#1C1916) steps down to the inset floor (#13100D), the pit.
167
+ expect(screen.getByTestId('well')).toHaveStyle({ backgroundColor: '#13100D' })
168
+ })
169
+
170
+ it('does not step below the floor: pressed within a floor-pressed well stays at inset', () => {
171
+ render(
172
+ <Surface level="background">
173
+ <Surface pressed>
174
+ <Surface pressed testID="deeper" />
175
+ </Surface>
176
+ </Surface>
177
+ )
178
+ expect(screen.getByTestId('deeper')).toHaveStyle({ backgroundColor: '#13100D' })
179
+ })
180
+
181
+ it('publishes the stepped-down level to descendants so a nested press steps again', () => {
182
+ render(
183
+ <Surface level="raised">
184
+ <Surface pressed>
185
+ <LevelProbe label="lvl" />
186
+ </Surface>
187
+ </Surface>
188
+ )
189
+ // raised → elevated; descendants read the well's own level.
190
+ expect(screen.getByTestId('lvl')).toHaveTextContent('elevated')
191
+ })
192
+
193
+ it('adds an inner-shadow recess composed with the darker fill (web path)', () => {
194
+ render(
195
+ <Surface level="base">
196
+ <Surface pressed testID="well" />
197
+ </Surface>
198
+ )
199
+ const boxShadow = screen.getByTestId('well').style.boxShadow
200
+ expect(boxShadow).toContain('inset')
201
+ })
202
+
203
+ it('reads recessed via the darker fill alone when the shadow path is absent (no-shadow/native)', () => {
204
+ // The recess is carried by BOTH fill + shadow; strip the shadow and the fill
205
+ // still darkens one ramp step, so a pressed surface never becomes invisible.
206
+ render(
207
+ <Surface level="base">
208
+ <Surface pressed testID="well" style={{ boxShadow: undefined }} />
209
+ </Surface>
210
+ )
211
+ expect(screen.getByTestId('well')).toHaveStyle({ backgroundColor: '#1C1916' })
212
+ expect(lstar('#1C1916')).toBeLessThan(lstar('#252321'))
213
+ })
214
+
215
+ it('rounds the well by default and honours an explicit rounded override', () => {
216
+ render(
217
+ <Surface level="base">
218
+ <Surface pressed testID="rounded" />
219
+ <Surface pressed rounded={false} testID="flat" />
220
+ </Surface>
221
+ )
222
+ expect(screen.getByTestId('rounded')).toBeInTheDocument()
223
+ expect(screen.getByTestId('flat')).toBeInTheDocument()
224
+ })
225
+ })
226
+
227
+ describe('pressedLevel helper', () => {
228
+ it.each([
229
+ ['overlay', 'raised'],
230
+ ['raised', 'elevated'],
231
+ ['elevated', 'base'],
232
+ ['base', 'background'],
233
+ ['background', 'inset'],
234
+ ['inset', 'inset'],
235
+ ] as const)('steps %s down to %s (clamped at inset)', (parent, expected) => {
236
+ expect(pressedLevel(parent)).toBe(expected)
237
+ })
238
+ })
239
+
240
+ describe('getPressedRecessShadow', () => {
241
+ it('returns an inset recess tuned to the fill colour on the web path', () => {
242
+ const style = getPressedRecessShadow('#1C1916', 'dark') as { boxShadow?: string }
243
+ expect(style.boxShadow).toContain('inset')
244
+ })
245
+ })
246
+
112
247
  describe('Surface on-surface colour context', () => {
113
248
  it('gives descendants literal-hex dark text colours', () => {
114
249
  render(
@@ -152,11 +287,15 @@ describe('Surface on-surface colour context', () => {
152
287
 
153
288
  describe('surface colour helpers', () => {
154
289
  it('surfaceBackground returns literal hex per level + mode', () => {
155
- expect(surfaceBackground('elevated', 'dark')).toBe('#191919')
156
- expect(surfaceBackground('background', 'dark')).toBe('#101010')
290
+ expect(surfaceBackground('elevated', 'dark')).toBe('#2C2A28')
291
+ expect(surfaceBackground('background', 'dark')).toBe('#1C1916')
157
292
  expect(surfaceBackground('base', 'light')).toBe('#FFFFFF')
158
293
  })
159
294
 
295
+ it('resolves the inset floor from the surfaceRampDark.inset primitive (no token yet)', () => {
296
+ expect(surfaceBackground('inset', 'dark')).toBe('#13100D')
297
+ })
298
+
160
299
  it('onSurfaceColors returns the neutral text ramp as literal hex', () => {
161
300
  expect(onSurfaceColors('dark')).toEqual({
162
301
  primary: '#F3F4F6',
@@ -1,11 +1,12 @@
1
1
  import React, { useMemo } from 'react'
2
- import { View, type ViewProps } from 'react-native'
2
+ import { View, type ViewProps, type ViewStyle } from 'react-native'
3
3
  import { cn } from '../../../utils/cn'
4
4
  import { type ThemeMode } from '../../../theme/tokens/semantic'
5
5
  import {
6
6
  type ElevationLevel,
7
7
  getElevationSurface,
8
8
  getElevationShadow,
9
+ getPressedRecessShadow,
9
10
  getBaseSurfaceColor,
10
11
  type GlowIntensity,
11
12
  getGlowShadow,
@@ -13,6 +14,7 @@ import {
13
14
  import {
14
15
  SurfaceContext,
15
16
  surfaceBackground,
17
+ pressedLevel,
16
18
  useSurface,
17
19
  type SurfaceContextValue,
18
20
  type SurfaceLevel,
@@ -32,6 +34,14 @@ export interface SurfaceProps extends ViewProps {
32
34
  * plane reads full-bleed. Composable with `elevation`'s glow.
33
35
  */
34
36
  level?: SurfaceLevel
37
+ /**
38
+ * Sunken "well": render ONE ramp step DOWN from the parent surface's level —
39
+ * the symmetric twin of a raised surface stepping up — and add an inner-shadow
40
+ * recess so the surface reads pressed (darker fill + inset shadow). Relative to
41
+ * the enclosing Surface's level (not this surface's own `level`), and clamped
42
+ * at the ramp's `inset` floor so it never underflows. One level of depth only.
43
+ */
44
+ pressed?: boolean
35
45
  glowColor?: string
36
46
  glowIntensity?: GlowIntensity
37
47
  /**
@@ -59,6 +69,7 @@ export interface SurfaceProps extends ViewProps {
59
69
  export function Surface({
60
70
  elevation = 0,
61
71
  level,
72
+ pressed = false,
62
73
  glowColor,
63
74
  glowIntensity,
64
75
  theme,
@@ -72,18 +83,35 @@ export function Surface({
72
83
  const mode = theme ?? inherited.mode
73
84
  const isPlane = level != null
74
85
 
86
+ // Three grounded ways to own a background, in precedence order:
87
+ // - pressed: a well ONE ramp step DOWN from the parent level (relative),
88
+ // with an inner-shadow recess — clamps at the `inset` floor.
89
+ // - level (named plane): a flat charcoal plane straight from a token.
90
+ // - elevation (numeric): the raised-card model — lighten-from-base + shadow.
75
91
  const baseColor = getBaseSurfaceColor(mode)
76
- const backgroundColor = isPlane
77
- ? surfaceBackground(level, mode)
78
- : getElevationSurface(baseColor, elevation, mode)
79
- // Planes own a flat full-bleed background; the card model keeps its depth shadow.
80
- const shadowStyle = isPlane ? {} : getElevationShadow(baseColor, elevation, mode)
92
+ const resolvedLevel = pressed ? pressedLevel(inherited.level) : (level ?? inherited.level)
93
+
94
+ let backgroundColor: string
95
+ let shadowStyle: ViewStyle
96
+ if (pressed) {
97
+ backgroundColor = surfaceBackground(resolvedLevel, mode)
98
+ shadowStyle = getPressedRecessShadow(backgroundColor, mode)
99
+ } else if (isPlane) {
100
+ backgroundColor = surfaceBackground(level, mode)
101
+ // Planes own a flat full-bleed background — no depth shadow.
102
+ shadowStyle = {}
103
+ } else {
104
+ backgroundColor = getElevationSurface(baseColor, elevation, mode)
105
+ shadowStyle = getElevationShadow(baseColor, elevation, mode)
106
+ }
107
+
81
108
  const glowStyle = glowColor ? getGlowShadow(glowColor, glowIntensity) : {}
82
- const applyRounded = rounded ?? !isPlane
109
+ // A pressed well and the card model round by default; a flat plane doesn't.
110
+ const applyRounded = rounded ?? (pressed || !isPlane)
83
111
 
84
112
  const value = useMemo<SurfaceContextValue>(
85
- () => ({ mode, level: level ?? inherited.level }),
86
- [mode, level, inherited.level]
113
+ () => ({ mode, level: resolvedLevel }),
114
+ [mode, resolvedLevel]
87
115
  )
88
116
 
89
117
  return (