@titan-design/react-ui 0.9.2 → 0.10.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 +215 -123
- package/dist/index.d.ts +215 -123
- package/dist/index.js +408 -259
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +258 -121
- package/dist/index.mjs.map +1 -1
- package/dist/pages.js +214 -157
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +214 -157
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +895 -795
- package/dist/theme/index.d.ts +895 -795
- package/dist/theme/index.js +101 -20
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +99 -22
- 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 +1 -1
- package/src/components/custom/Workout/BaseBadge.tsx +5 -5
- 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/SessionHeader.tsx +11 -13
- package/src/components/custom/Workout/SessionRail.stories.tsx +4 -1
- package/src/components/custom/Workout/SessionRail.tsx +16 -14
- 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/VolumeLandmarkBar.stories.tsx +4 -1
- package/src/components/custom/Workout/WorkoutPill.tsx +2 -2
- package/src/components/shell/DashboardShell.tsx +6 -3
- package/src/components/ui/surface/Surface.stories.tsx +244 -1
- package/src/components/ui/surface/Surface.test.tsx +244 -1
- package/src/components/ui/surface/Surface.tsx +98 -20
- package/src/components/ui/surface/SurfaceContext.ts +126 -0
- package/src/components/ui/surface/index.ts +13 -0
- package/src/components/ui/surface/surface.contract.test.ts +283 -0
- package/src/test/nativewind-stub.ts +13 -0
- package/src/theme/ColorSystem.stories.tsx +232 -47
- package/src/theme/ThemeProvider.test.tsx +24 -0
- package/src/theme/ThemeProvider.tsx +23 -6
- 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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
2
|
import { View, Text } from 'react-native'
|
|
3
3
|
import { Surface } from './Surface'
|
|
4
|
+
import { useOnSurfaceColor, type SurfaceLevel } from './SurfaceContext'
|
|
4
5
|
import type { ElevationLevel } from '../../../theme/elevation'
|
|
5
6
|
|
|
6
7
|
const meta: Meta<typeof Surface> = {
|
|
@@ -12,10 +13,19 @@ const meta: Meta<typeof Surface> = {
|
|
|
12
13
|
control: { type: 'range', min: -2, max: 5, step: 1 },
|
|
13
14
|
description: 'Elevation level (-2 to 5)',
|
|
14
15
|
},
|
|
16
|
+
level: {
|
|
17
|
+
control: 'select',
|
|
18
|
+
options: ['inset', 'background', 'base', 'elevated', 'raised', 'overlay'],
|
|
19
|
+
description: 'Named charcoal plane (flat background from a semantic token)',
|
|
20
|
+
},
|
|
21
|
+
pressed: {
|
|
22
|
+
control: 'boolean',
|
|
23
|
+
description: 'Sunken well: one ramp step DOWN from the parent level + inner-shadow recess',
|
|
24
|
+
},
|
|
15
25
|
theme: {
|
|
16
26
|
control: 'select',
|
|
17
27
|
options: ['light', 'dark'],
|
|
18
|
-
description: 'Color theme',
|
|
28
|
+
description: 'Color theme (also seeds the on-surface colour context)',
|
|
19
29
|
},
|
|
20
30
|
glowColor: {
|
|
21
31
|
control: 'color',
|
|
@@ -78,6 +88,105 @@ export const WithGlow: Story = {
|
|
|
78
88
|
),
|
|
79
89
|
}
|
|
80
90
|
|
|
91
|
+
// The named-plane model: flat, full-bleed charcoal planes straight from the
|
|
92
|
+
// semantic surface tokens — reaching the darker nav/page shades the numeric
|
|
93
|
+
// lighten model can't. These back the shell / rail / stage.
|
|
94
|
+
export const NamedPlanes: Story = {
|
|
95
|
+
render: () => (
|
|
96
|
+
<View style={{ gap: 12, padding: 24 }}>
|
|
97
|
+
{(['background', 'base', 'elevated', 'raised', 'overlay'] as SurfaceLevel[]).map((level) => (
|
|
98
|
+
<Surface key={level} level={level} style={{ padding: 16 }}>
|
|
99
|
+
<Text style={{ color: '#fff' }}>level="{level}"</Text>
|
|
100
|
+
</Surface>
|
|
101
|
+
))}
|
|
102
|
+
</View>
|
|
103
|
+
),
|
|
104
|
+
}
|
|
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
|
+
|
|
162
|
+
// On-surface colour context: descendant text reads its colour from the Surface
|
|
163
|
+
// via `useOnSurfaceColor` — no hard-coded hex, so it never renders black when the
|
|
164
|
+
// tree mounts as raw RN in the standalone wall SPA.
|
|
165
|
+
function OnSurfaceLabels() {
|
|
166
|
+
return (
|
|
167
|
+
<View style={{ gap: 4 }}>
|
|
168
|
+
<Text style={{ color: useOnSurfaceColor('primary'), fontSize: 16, fontWeight: '700' }}>
|
|
169
|
+
Primary on this surface
|
|
170
|
+
</Text>
|
|
171
|
+
<Text style={{ color: useOnSurfaceColor('secondary') }}>Secondary on this surface</Text>
|
|
172
|
+
<Text style={{ color: useOnSurfaceColor('tertiary') }}>Tertiary on this surface</Text>
|
|
173
|
+
</View>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export const OnSurfaceText: Story = {
|
|
178
|
+
render: () => (
|
|
179
|
+
<View style={{ gap: 16, padding: 24 }}>
|
|
180
|
+
<Surface level="background" style={{ padding: 16 }}>
|
|
181
|
+
<OnSurfaceLabels />
|
|
182
|
+
</Surface>
|
|
183
|
+
<Surface theme="light" level="base" style={{ padding: 16 }}>
|
|
184
|
+
<OnSurfaceLabels />
|
|
185
|
+
</Surface>
|
|
186
|
+
</View>
|
|
187
|
+
),
|
|
188
|
+
}
|
|
189
|
+
|
|
81
190
|
export const LightTheme: Story = {
|
|
82
191
|
render: () => (
|
|
83
192
|
<View style={{ gap: 16, padding: 24, backgroundColor: '#F3F4F6' }}>
|
|
@@ -89,3 +198,137 @@ export const LightTheme: Story = {
|
|
|
89
198
|
</View>
|
|
90
199
|
),
|
|
91
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
|
+
}
|
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { Text } from 'react-native'
|
|
3
4
|
import { axe } from 'jest-axe'
|
|
4
5
|
import { Surface } from './Surface'
|
|
6
|
+
import {
|
|
7
|
+
onSurfaceColors,
|
|
8
|
+
surfaceBackground,
|
|
9
|
+
pressedLevel,
|
|
10
|
+
useOnSurfaceColor,
|
|
11
|
+
useSurface,
|
|
12
|
+
useSurfaceMode,
|
|
13
|
+
} from './SurfaceContext'
|
|
14
|
+
import { getPressedRecessShadow } from '../../../theme/elevation'
|
|
5
15
|
|
|
6
|
-
|
|
16
|
+
// A descendant probe that renders the on-surface colour + mode it resolves from
|
|
17
|
+
// context, so tests can assert what a nested consumer would actually paint.
|
|
18
|
+
function Probe({ role = 'primary' as const, label = 'probe' }) {
|
|
19
|
+
const color = useOnSurfaceColor(role)
|
|
20
|
+
const mode = useSurfaceMode()
|
|
21
|
+
return (
|
|
22
|
+
<Text testID={label} style={{ color }}>
|
|
23
|
+
{mode}
|
|
24
|
+
</Text>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('Surface (card model)', () => {
|
|
7
29
|
it('renders children', () => {
|
|
8
30
|
render(
|
|
9
31
|
<Surface>
|
|
@@ -49,6 +71,13 @@ describe('Surface', () => {
|
|
|
49
71
|
expect(screen.getByTestId('my-surface')).toBeInTheDocument()
|
|
50
72
|
})
|
|
51
73
|
|
|
74
|
+
it('paints the default flat dark card surface', () => {
|
|
75
|
+
render(<Surface testID="s" />)
|
|
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' })
|
|
79
|
+
})
|
|
80
|
+
|
|
52
81
|
describe('accessibility', () => {
|
|
53
82
|
it('has no accessibility violations', async () => {
|
|
54
83
|
const { container } = render(
|
|
@@ -61,3 +90,217 @@ describe('Surface', () => {
|
|
|
61
90
|
})
|
|
62
91
|
})
|
|
63
92
|
})
|
|
93
|
+
|
|
94
|
+
describe('Surface (named plane)', () => {
|
|
95
|
+
it.each([
|
|
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) => {
|
|
101
|
+
render(<Surface level={level} testID="s" />)
|
|
102
|
+
expect(screen.getByTestId('s')).toHaveStyle({ backgroundColor: hex })
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('resolves the light-mode background when theme is overridden', () => {
|
|
106
|
+
render(<Surface level="base" theme="light" testID="s" />)
|
|
107
|
+
expect(screen.getByTestId('s')).toHaveStyle({ backgroundColor: '#FFFFFF' })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('lets a caller style override the owned background', () => {
|
|
111
|
+
render(<Surface level="base" testID="s" style={{ backgroundColor: '#000000' }} />)
|
|
112
|
+
expect(screen.getByTestId('s')).toHaveStyle({ backgroundColor: '#000000' })
|
|
113
|
+
})
|
|
114
|
+
})
|
|
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
|
+
|
|
247
|
+
describe('Surface on-surface colour context', () => {
|
|
248
|
+
it('gives descendants literal-hex dark text colours', () => {
|
|
249
|
+
render(
|
|
250
|
+
<Surface level="background">
|
|
251
|
+
<Probe role="primary" label="p" />
|
|
252
|
+
<Probe role="secondary" label="s" />
|
|
253
|
+
<Probe role="tertiary" label="t" />
|
|
254
|
+
</Surface>
|
|
255
|
+
)
|
|
256
|
+
expect(screen.getByTestId('p')).toHaveStyle({ color: '#F3F4F6' })
|
|
257
|
+
expect(screen.getByTestId('s')).toHaveStyle({ color: '#9CA3AF' })
|
|
258
|
+
expect(screen.getByTestId('t')).toHaveStyle({ color: '#6B7280' })
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('resolves dark on-surface colours even with no enclosing Surface', () => {
|
|
262
|
+
render(<Probe role="primary" label="p" />)
|
|
263
|
+
expect(screen.getByTestId('p')).toHaveStyle({ color: '#F3F4F6' })
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('flows an overridden theme to descendant text', () => {
|
|
267
|
+
render(
|
|
268
|
+
<Surface theme="light">
|
|
269
|
+
<Probe role="primary" label="p" />
|
|
270
|
+
</Surface>
|
|
271
|
+
)
|
|
272
|
+
expect(screen.getByTestId('p')).toHaveStyle({ color: '#121828' })
|
|
273
|
+
expect(screen.getByTestId('p')).toHaveTextContent('light')
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('inherits mode through a nested Surface that sets only a level', () => {
|
|
277
|
+
render(
|
|
278
|
+
<Surface theme="light" level="background">
|
|
279
|
+
<Surface level="raised">
|
|
280
|
+
<Probe role="primary" label="p" />
|
|
281
|
+
</Surface>
|
|
282
|
+
</Surface>
|
|
283
|
+
)
|
|
284
|
+
expect(screen.getByTestId('p')).toHaveStyle({ color: '#121828' })
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
describe('surface colour helpers', () => {
|
|
289
|
+
it('surfaceBackground returns literal hex per level + mode', () => {
|
|
290
|
+
expect(surfaceBackground('elevated', 'dark')).toBe('#2C2A28')
|
|
291
|
+
expect(surfaceBackground('background', 'dark')).toBe('#1C1916')
|
|
292
|
+
expect(surfaceBackground('base', 'light')).toBe('#FFFFFF')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('resolves the inset floor from the surfaceRampDark.inset primitive (no token yet)', () => {
|
|
296
|
+
expect(surfaceBackground('inset', 'dark')).toBe('#13100D')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('onSurfaceColors returns the neutral text ramp as literal hex', () => {
|
|
300
|
+
expect(onSurfaceColors('dark')).toEqual({
|
|
301
|
+
primary: '#F3F4F6',
|
|
302
|
+
secondary: '#9CA3AF',
|
|
303
|
+
tertiary: '#6B7280',
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
})
|
|
@@ -1,51 +1,129 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import { View, type ViewProps } from 'react-native'
|
|
1
|
+
import React, { useMemo } from 'react'
|
|
2
|
+
import { View, type ViewProps, type ViewStyle } from 'react-native'
|
|
3
3
|
import { cn } from '../../../utils/cn'
|
|
4
|
+
import { type ThemeMode } from '../../../theme/tokens/semantic'
|
|
4
5
|
import {
|
|
5
6
|
type ElevationLevel,
|
|
6
7
|
getElevationSurface,
|
|
7
8
|
getElevationShadow,
|
|
9
|
+
getPressedRecessShadow,
|
|
8
10
|
getBaseSurfaceColor,
|
|
9
11
|
type GlowIntensity,
|
|
10
12
|
getGlowShadow,
|
|
11
13
|
} from '../../../theme/elevation'
|
|
14
|
+
import {
|
|
15
|
+
SurfaceContext,
|
|
16
|
+
surfaceBackground,
|
|
17
|
+
pressedLevel,
|
|
18
|
+
useSurface,
|
|
19
|
+
type SurfaceContextValue,
|
|
20
|
+
type SurfaceLevel,
|
|
21
|
+
} from './SurfaceContext'
|
|
12
22
|
|
|
13
23
|
export interface SurfaceProps extends ViewProps {
|
|
24
|
+
/**
|
|
25
|
+
* Numeric depth → a computed lighten + shadow (the raised-card model). The
|
|
26
|
+
* background is derived from the base surface by lightening. Ignored for the
|
|
27
|
+
* background when `level` is set. Default 0 (flat card).
|
|
28
|
+
*/
|
|
14
29
|
elevation?: ElevationLevel
|
|
30
|
+
/**
|
|
31
|
+
* Named charcoal plane (shell / rail / stage). Sets the background directly
|
|
32
|
+
* from a semantic surface token — reaching the darker nav/page planes the
|
|
33
|
+
* lighten model can't — and drops the default rounding + auto shadow so the
|
|
34
|
+
* plane reads full-bleed. Composable with `elevation`'s glow.
|
|
35
|
+
*/
|
|
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
|
|
15
45
|
glowColor?: string
|
|
16
46
|
glowIntensity?: GlowIntensity
|
|
17
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Theme mode. Also seeds the on-surface colour CONTEXT so descendant text /
|
|
49
|
+
* icons resolve their colour from this surface instead of a `text-*` className
|
|
50
|
+
* that silently fails to black on raw RN. Inherits the nearest Surface /
|
|
51
|
+
* ThemeProvider when omitted (default dark — the wall).
|
|
52
|
+
*/
|
|
53
|
+
theme?: ThemeMode
|
|
54
|
+
/** Round the corners. Defaults on for the card model, off for a `level` plane. */
|
|
55
|
+
rounded?: boolean
|
|
18
56
|
className?: string
|
|
19
|
-
children
|
|
57
|
+
children?: React.ReactNode
|
|
20
58
|
}
|
|
21
59
|
|
|
60
|
+
/**
|
|
61
|
+
* A container that OWNS its background and establishes an on-surface colour
|
|
62
|
+
* context. Two grounded ways to pick the background:
|
|
63
|
+
* - `elevation` (numeric): the raised-card model — lighten-from-base + shadow.
|
|
64
|
+
* - `level` (named): a flat charcoal plane straight from a semantic token,
|
|
65
|
+
* for the shell / rail / stage backgrounds.
|
|
66
|
+
* Either way, descendants read the surface via {@link useOnSurfaceColor} /
|
|
67
|
+
* {@link useSurfaceMode} rather than hard-coding `getSemanticColors('dark')`.
|
|
68
|
+
*/
|
|
22
69
|
export function Surface({
|
|
23
70
|
elevation = 0,
|
|
71
|
+
level,
|
|
72
|
+
pressed = false,
|
|
24
73
|
glowColor,
|
|
25
74
|
glowIntensity,
|
|
26
|
-
theme
|
|
75
|
+
theme,
|
|
76
|
+
rounded,
|
|
27
77
|
className,
|
|
28
78
|
style,
|
|
29
79
|
children,
|
|
30
80
|
...props
|
|
31
81
|
}: SurfaceProps) {
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const
|
|
82
|
+
const inherited = useSurface()
|
|
83
|
+
const mode = theme ?? inherited.mode
|
|
84
|
+
const isPlane = level != null
|
|
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.
|
|
91
|
+
const baseColor = getBaseSurfaceColor(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
|
+
|
|
35
108
|
const glowStyle = glowColor ? getGlowShadow(glowColor, glowIntensity) : {}
|
|
109
|
+
// A pressed well and the card model round by default; a flat plane doesn't.
|
|
110
|
+
const applyRounded = rounded ?? (pressed || !isPlane)
|
|
111
|
+
|
|
112
|
+
const value = useMemo<SurfaceContextValue>(
|
|
113
|
+
() => ({ mode, level: resolvedLevel }),
|
|
114
|
+
[mode, resolvedLevel]
|
|
115
|
+
)
|
|
36
116
|
|
|
37
117
|
return (
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
shadowStyle,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
>
|
|
48
|
-
{children}
|
|
49
|
-
</View>
|
|
118
|
+
<SurfaceContext.Provider value={value}>
|
|
119
|
+
<View
|
|
120
|
+
className={cn(applyRounded && 'rounded-2xl', className)}
|
|
121
|
+
// backgroundColor first so a caller `style` can still override it.
|
|
122
|
+
style={[{ backgroundColor }, shadowStyle, glowStyle, style]}
|
|
123
|
+
{...props}
|
|
124
|
+
>
|
|
125
|
+
{children}
|
|
126
|
+
</View>
|
|
127
|
+
</SurfaceContext.Provider>
|
|
50
128
|
)
|
|
51
129
|
}
|