@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.
- package/dist/bodymap.js +78 -24
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +78 -24
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +429 -11
- package/dist/index.d.ts +429 -11
- package/dist/index.js +2386 -885
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2306 -836
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-DyEx-lBf.d.ts → pages-B1hqnGIC.d.ts} +149 -15
- package/dist/{pages-D7hOD4Vj.d.mts → pages-CvsFqNtx.d.mts} +149 -15
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +1900 -1609
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +1901 -1610
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +129 -34
- package/dist/theme/index.d.ts +129 -34
- package/dist/theme/index.js +95 -19
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +92 -20
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.js +88 -24
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +88 -24
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +8 -3
- package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
- package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
- package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
- package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
- package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
- package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
- package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
- package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
- package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
- package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
- package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
- package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
- package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
- package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
- package/src/components/custom/Fatigue/README.md +93 -0
- package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
- package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
- package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
- package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
- package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
- package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
- package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
- package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
- package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
- package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
- package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
- package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
- package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
- package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
- package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
- package/src/components/custom/Fatigue/index.ts +41 -0
- package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
- package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
- package/src/components/custom/Workout/BaseBadge.tsx +5 -5
- package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +4 -4
- package/src/components/custom/Workout/ExerciseCard.tsx +3 -1
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +7 -1
- package/src/components/custom/Workout/README.md +62 -16
- package/src/components/custom/Workout/RowInventory.stories.tsx +5 -2
- package/src/components/custom/Workout/S3FullRail.stories.tsx +13 -1
- package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
- package/src/components/custom/Workout/SessionHeader.tsx +2 -2
- package/src/components/custom/Workout/SessionRail.stories.tsx +4 -1
- package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
- package/src/components/custom/Workout/SessionRail.tsx +55 -18
- package/src/components/custom/Workout/SetRow.test.tsx +6 -6
- package/src/components/custom/Workout/SetRow.tsx +9 -2
- package/src/components/custom/Workout/SupersetWrapper.stories.tsx +16 -25
- package/src/components/custom/Workout/SupersetWrapper.tsx +1 -1
- package/src/components/custom/Workout/TempoDisplay.tsx +3 -7
- package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
- package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
- package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
- package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
- package/src/components/custom/Workout/VelocityStrip.test.tsx +196 -79
- package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
- package/src/components/custom/Workout/VolumeLandmarkBar.stories.tsx +4 -1
- package/src/components/custom/Workout/WorkoutPill.tsx +2 -2
- package/src/components/custom/Workout/index.ts +3 -0
- package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
- package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
- package/src/components/custom/charts/SetBarChart.tsx +651 -0
- package/src/components/custom/charts/live-rep-growth.ts +91 -0
- package/src/components/custom/index.ts +1 -0
- package/src/components/ui/surface/Surface.stories.tsx +195 -1
- package/src/components/ui/surface/Surface.test.tsx +148 -9
- package/src/components/ui/surface/Surface.tsx +37 -9
- package/src/components/ui/surface/SurfaceContext.ts +48 -8
- package/src/components/ui/surface/index.ts +1 -0
- package/src/components/ui/surface/surface.contract.test.ts +283 -0
- package/src/test/raw-color-patterns.test.ts +71 -0
- package/src/theme/ColorPalettes.stories.tsx +871 -0
- package/src/theme/ColorPrimitives.stories.tsx +300 -0
- package/src/theme/bar-paper.ts +59 -0
- package/src/theme/color-stories.coverage.test.ts +130 -0
- package/src/theme/color-story-kit.tsx +170 -0
- package/src/theme/config.ts +12 -0
- package/src/theme/elevation.ts +90 -67
- package/src/theme/global.css +35 -9
- package/src/theme/tokens/primitives.ts +40 -0
- package/src/theme/tokens/semantic.ts +63 -14
- package/tailwind.config.js +8 -0
- package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
- package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
- package/src/theme/ColorSystem.stories.tsx +0 -323
- 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 }
|
|
@@ -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
|
|
74
|
-
|
|
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', '#
|
|
93
|
-
['base', '#
|
|
94
|
-
['elevated', '#
|
|
95
|
-
['raised', '#
|
|
96
|
-
] as const)('maps level %s to the
|
|
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('#
|
|
156
|
-
expect(surfaceBackground('background', 'dark')).toBe('#
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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:
|
|
86
|
-
[mode,
|
|
113
|
+
() => ({ mode, level: resolvedLevel }),
|
|
114
|
+
[mode, resolvedLevel]
|
|
87
115
|
)
|
|
88
116
|
|
|
89
117
|
return (
|