@titan-design/react-ui 0.9.1 → 0.9.3
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/index.d.mts +205 -126
- package/dist/index.d.ts +205 -126
- package/dist/index.js +302 -255
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +157 -117
- package/dist/index.mjs.map +1 -1
- package/dist/pages.js +2 -1
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +2 -1
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +774 -769
- package/dist/theme/index.d.ts +774 -769
- package/dist/theme/index.js +6 -1
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +7 -2
- package/dist/theme/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/custom/Workout/ExerciseHeading.tsx +5 -3
- package/src/components/custom/Workout/SessionHeader.tsx +11 -12
- package/src/components/custom/Workout/SessionRail.tsx +26 -13
- package/src/components/custom/Workout/SetsRepsLoad.tsx +2 -1
- package/src/components/shell/DashboardShell.tsx +11 -10
- package/src/components/shell/NavItem.tsx +6 -1
- package/src/components/ui/surface/Surface.stories.tsx +50 -1
- package/src/components/ui/surface/Surface.test.tsx +105 -1
- package/src/components/ui/surface/Surface.tsx +69 -19
- package/src/components/ui/surface/SurfaceContext.ts +86 -0
- package/src/components/ui/surface/index.ts +12 -0
- package/src/test/nativewind-stub.ts +13 -0
- package/src/theme/ThemeProvider.test.tsx +24 -0
- package/src/theme/ThemeProvider.tsx +23 -6
package/package.json
CHANGED
|
@@ -10,7 +10,8 @@ export interface ExerciseHeadingProps {
|
|
|
10
10
|
/** Prescription line values (sets × reps @ load). */
|
|
11
11
|
sets: number
|
|
12
12
|
reps: number | string
|
|
13
|
-
load
|
|
13
|
+
/** Load value; a string (e.g. "—") passes through verbatim for an unset/discovery load. */
|
|
14
|
+
load: number | string
|
|
14
15
|
unit?: 'lbs' | 'kg'
|
|
15
16
|
/** Tempo tuple [eccentric, pauseBottom, concentric, pauseTop]; hidden when absent. */
|
|
16
17
|
tempo?: [number, number, number, number]
|
|
@@ -26,10 +27,11 @@ function headingLabel(
|
|
|
26
27
|
name: string,
|
|
27
28
|
sets: number,
|
|
28
29
|
reps: number | string,
|
|
29
|
-
load: number,
|
|
30
|
+
load: number | string,
|
|
30
31
|
unit: string
|
|
31
32
|
) {
|
|
32
|
-
|
|
33
|
+
const loadLabel = typeof load === 'number' ? roundWeight(load) : load
|
|
34
|
+
return `${name}, ${sets}×${reps} @ ${loadLabel} ${unit}`
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
/**
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import { View, Text, type ViewProps } from 'react-native'
|
|
3
|
-
import {
|
|
4
|
-
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
3
|
+
import { Surface, onSurfaceColors, useSurfaceMode } from '../../ui/surface'
|
|
5
4
|
import { Typography } from '../Typography'
|
|
6
5
|
import { TimerReadout } from '../TimerReadout'
|
|
7
6
|
import { SegmentedProgressBar } from './SegmentedProgressBar'
|
|
@@ -9,11 +8,10 @@ import { MetricTiles, type MetricTileData } from './MetricTiles'
|
|
|
9
8
|
import { ScheduleTiles } from './ScheduleTiles'
|
|
10
9
|
import { paceTone, paceToneColor } from './paceTone'
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
const RAISED = primitiveColors.charcoal[400] // #1F1F1F
|
|
11
|
+
// The header shares the SideNav's `background-base` (charcoal 900, #101010) so the nav and
|
|
12
|
+
// the rail header read as ONE continuous dark plane on the left — the sunk exercise list
|
|
13
|
+
// sits raised off it. A `<Surface level="background">` owns that plane so the header no
|
|
14
|
+
// longer hand-sets it, and its title/label text resolve on-surface colours from context.
|
|
17
15
|
|
|
18
16
|
// The chunked pace bar matches the SetStrip language but sits tighter than the default
|
|
19
17
|
// SetStrip gap (5) — the locked design uses a 3px gap and a 9px track.
|
|
@@ -72,21 +70,22 @@ export function SessionHeader({
|
|
|
72
70
|
}: SessionHeaderProps) {
|
|
73
71
|
const upcoming = next != null
|
|
74
72
|
const totalSets = plan.reduce((sum, e) => sum + e.sets, 0)
|
|
73
|
+
const onSurface = onSurfaceColors(useSurfaceMode())
|
|
75
74
|
|
|
76
75
|
const hasPace = !upcoming && budgetMs != null && elapsedMs != null
|
|
77
76
|
const target = hasPace ? (elapsedMs as number) / (budgetMs as number) : undefined
|
|
78
77
|
|
|
79
78
|
const setsLabel = upcoming ? `${totalSets} sets` : `${Math.floor(setsDone)}/${totalSets} sets`
|
|
80
79
|
const setsLabelColor = upcoming
|
|
81
|
-
?
|
|
80
|
+
? onSurface.secondary
|
|
82
81
|
: paceToneColor(paceTone(totalSets > 0 ? setsDone / totalSets : 0, target))
|
|
83
82
|
|
|
84
83
|
return (
|
|
85
|
-
<
|
|
84
|
+
<Surface
|
|
85
|
+
level="background"
|
|
86
86
|
className={className}
|
|
87
87
|
style={[
|
|
88
88
|
{
|
|
89
|
-
backgroundColor: RAISED,
|
|
90
89
|
paddingTop: 11,
|
|
91
90
|
paddingRight: 12,
|
|
92
91
|
paddingBottom: 12,
|
|
@@ -105,7 +104,7 @@ export function SessionHeader({
|
|
|
105
104
|
lineHeight: 18,
|
|
106
105
|
fontWeight: '700',
|
|
107
106
|
fontFamily: '"Space Grotesk", sans-serif',
|
|
108
|
-
color:
|
|
107
|
+
color: onSurface.primary,
|
|
109
108
|
marginBottom: 10,
|
|
110
109
|
}}
|
|
111
110
|
testID="session-rail-title"
|
|
@@ -147,6 +146,6 @@ export function SessionHeader({
|
|
|
147
146
|
/>
|
|
148
147
|
</View>
|
|
149
148
|
</View>
|
|
150
|
-
</
|
|
149
|
+
</Surface>
|
|
151
150
|
)
|
|
152
151
|
}
|
|
@@ -2,17 +2,20 @@
|
|
|
2
2
|
import { View, type ViewProps } from 'react-native'
|
|
3
3
|
import { primitiveColors } from '../../../theme/tokens/primitives'
|
|
4
4
|
import { neumorphicShadows } from '../../../theme/shadows'
|
|
5
|
+
import { Surface } from '../../ui/surface'
|
|
5
6
|
import { ExerciseCardHeading } from './ExerciseCardHeading'
|
|
6
7
|
import { SessionHeader } from './SessionHeader'
|
|
7
8
|
import type { MetricTileData } from './MetricTiles'
|
|
8
9
|
import type { SetStripSet } from './SetStrip'
|
|
9
10
|
import type { ExerciseIndicatorKind } from './ExerciseIndicator'
|
|
10
11
|
|
|
11
|
-
// Charcoal-ramp surfaces (the locked S3 shell shades): the nav + the heading plane
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
// Charcoal-ramp surfaces (the locked S3 shell shades): the nav + the heading plane read as
|
|
13
|
+
// ONE dark plane (charcoal 900, #101010, owned by SessionHeader's `<Surface level="background">`).
|
|
14
|
+
// The exercise list is a LIGHTER inset panel — `<Surface level="elevated">` (charcoal 600,
|
|
15
|
+
// #191919) — recessed via its inner shadow yet paler than the header, so the transparent
|
|
16
|
+
// exercise headings on it never blend into the header plane. Surface owns both backgrounds,
|
|
17
|
+
// so the rail no longer hand-sets them.
|
|
18
|
+
const DIVIDER = primitiveColors.charcoal[300] // #2C2C2C — row divider (raised to read on #191919)
|
|
16
19
|
|
|
17
20
|
const DEFAULT_WIDTH = 246
|
|
18
21
|
|
|
@@ -20,7 +23,15 @@ export interface SessionRailExercise {
|
|
|
20
23
|
/** Stable key + press payload. Falls back to list index when absent. */
|
|
21
24
|
id?: string
|
|
22
25
|
name: string
|
|
23
|
-
|
|
26
|
+
/** `weight` accepts a string (e.g. "—") for an unset/discovery load — never a faked 0. */
|
|
27
|
+
summary: { sets: number; reps: number | string; weight: number | string; unit: 'lbs' | 'kg' }
|
|
28
|
+
/**
|
|
29
|
+
* Prescribed set count for the header total + pace-bar segment width. Distinct from
|
|
30
|
+
* `summary.sets`, which is the row's live DONE count (0 for the active exercise before
|
|
31
|
+
* its first set) — using that for the total under-counts by the active exercise's
|
|
32
|
+
* remaining sets. Falls back to `summary.sets` when absent.
|
|
33
|
+
*/
|
|
34
|
+
plannedSets?: number
|
|
24
35
|
tempo?: [number, number, number, number]
|
|
25
36
|
indicator?: ExerciseIndicatorKind
|
|
26
37
|
/** Per-set performance data driving the heading strip. */
|
|
@@ -76,15 +87,16 @@ export function SessionRail({
|
|
|
76
87
|
...props
|
|
77
88
|
}: SessionRailProps) {
|
|
78
89
|
return (
|
|
79
|
-
<
|
|
90
|
+
<Surface
|
|
91
|
+
level="elevated"
|
|
80
92
|
className={className}
|
|
81
|
-
style={[{ width, flexDirection: 'column'
|
|
93
|
+
style={[{ width, flexDirection: 'column' }, style]}
|
|
82
94
|
testID="session-rail"
|
|
83
95
|
{...props}
|
|
84
96
|
>
|
|
85
97
|
<SessionHeader
|
|
86
98
|
title={title}
|
|
87
|
-
plan={exercises.map((ex) => ({ name: ex.name, sets: ex.summary.sets }))}
|
|
99
|
+
plan={exercises.map((ex) => ({ name: ex.name, sets: ex.plannedSets ?? ex.summary.sets }))}
|
|
88
100
|
setsDone={setsDone}
|
|
89
101
|
elapsedMs={elapsedMs}
|
|
90
102
|
budgetMs={budgetMs}
|
|
@@ -93,8 +105,9 @@ export function SessionRail({
|
|
|
93
105
|
next={next}
|
|
94
106
|
/>
|
|
95
107
|
|
|
96
|
-
<
|
|
97
|
-
|
|
108
|
+
<Surface
|
|
109
|
+
level="elevated"
|
|
110
|
+
style={[{ flex: 1 }, neumorphicShadows.charcoal.pressed.subtle]}
|
|
98
111
|
testID="session-rail-list"
|
|
99
112
|
>
|
|
100
113
|
{exercises.map((ex, i) => (
|
|
@@ -120,7 +133,7 @@ export function SessionRail({
|
|
|
120
133
|
/>
|
|
121
134
|
</View>
|
|
122
135
|
))}
|
|
123
|
-
</
|
|
124
|
-
</
|
|
136
|
+
</Surface>
|
|
137
|
+
</Surface>
|
|
125
138
|
)
|
|
126
139
|
}
|
|
@@ -11,7 +11,8 @@ const AT = '@'
|
|
|
11
11
|
export interface SetsRepsLoadProps extends ViewProps {
|
|
12
12
|
sets: number
|
|
13
13
|
reps: number | string
|
|
14
|
-
load
|
|
14
|
+
/** Load value; a string (e.g. "—") passes through verbatim for an unset/discovery load. */
|
|
15
|
+
load: number | string
|
|
15
16
|
/** Displayed load unit label (e.g. "lb", "kg"). */
|
|
16
17
|
unit?: string
|
|
17
18
|
/** Cell font size (px). Default 11 — the compact rail/card scale. Raise for a wall read-out. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
import { View, Text } from 'react-native'
|
|
3
3
|
import { cn } from '../../utils/cn'
|
|
4
|
+
import { Surface } from '../ui/surface'
|
|
4
5
|
import { SideNav, type SideNavItem } from './SideNav'
|
|
5
6
|
import { TopBar } from './TopBar'
|
|
6
7
|
import { type Device } from './DeviceRow'
|
|
@@ -58,17 +59,17 @@ export function DashboardShell({
|
|
|
58
59
|
className,
|
|
59
60
|
}: DashboardShellProps) {
|
|
60
61
|
return (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
/>
|
|
62
|
+
// Column: the TopBar spans the FULL width across the top, and the SideNav sits BELOW it
|
|
63
|
+
// in the content row (not a full-height left rail). This keeps the brand/status band
|
|
64
|
+
// unbroken edge-to-edge and lets the nav align under it. The shell is the outermost
|
|
65
|
+
// Surface — it owns the base plane and seeds the on-surface colour context (mode) for
|
|
66
|
+
// the whole dashboard tree.
|
|
67
|
+
<Surface level="base" className={cn('flex-1', className)}>
|
|
68
|
+
<TopBar state={state} devices={devices} subtitle={subtitle} onSelectDevice={onSelectDevice} />
|
|
69
|
+
<View className="flex-1 flex-row">
|
|
70
|
+
<SideNav activeKey={activeKey} items={navItems} liveKey={liveKey} onNavigate={onNavigate} />
|
|
70
71
|
<View className="flex-1">{children ?? <ContentPlaceholder />}</View>
|
|
71
72
|
</View>
|
|
72
|
-
</
|
|
73
|
+
</Surface>
|
|
73
74
|
)
|
|
74
75
|
}
|
|
@@ -65,7 +65,12 @@ export function NavItem({
|
|
|
65
65
|
<Typography
|
|
66
66
|
variant="button"
|
|
67
67
|
color="inherit"
|
|
68
|
-
|
|
68
|
+
// fontSize inline, not `text-[8.5px]`: the `button` variant's `text-sm` (14px) and the
|
|
69
|
+
// arbitrary `text-[8.5px]` are a same-property class conflict that the consumer's
|
|
70
|
+
// Tailwind build resolved the wrong way (labels rendered ~14px on the wall). Inline
|
|
71
|
+
// size wins unambiguously; className keeps weight/case/tracking.
|
|
72
|
+
style={{ fontSize: 8.5, lineHeight: 11 }}
|
|
73
|
+
className={cn('font-bold uppercase tracking-[0.4px]', labelColor)}
|
|
69
74
|
>
|
|
70
75
|
{label}
|
|
71
76
|
</Typography>
|
|
@@ -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,15 @@ 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: ['background', 'base', 'elevated', 'raised', 'overlay'],
|
|
19
|
+
description: 'Named charcoal plane (flat background from a semantic token)',
|
|
20
|
+
},
|
|
15
21
|
theme: {
|
|
16
22
|
control: 'select',
|
|
17
23
|
options: ['light', 'dark'],
|
|
18
|
-
description: 'Color theme',
|
|
24
|
+
description: 'Color theme (also seeds the on-surface colour context)',
|
|
19
25
|
},
|
|
20
26
|
glowColor: {
|
|
21
27
|
control: 'color',
|
|
@@ -78,6 +84,49 @@ export const WithGlow: Story = {
|
|
|
78
84
|
),
|
|
79
85
|
}
|
|
80
86
|
|
|
87
|
+
// The named-plane model: flat, full-bleed charcoal planes straight from the
|
|
88
|
+
// semantic surface tokens — reaching the darker nav/page shades the numeric
|
|
89
|
+
// lighten model can't. These back the shell / rail / stage.
|
|
90
|
+
export const NamedPlanes: Story = {
|
|
91
|
+
render: () => (
|
|
92
|
+
<View style={{ gap: 12, padding: 24 }}>
|
|
93
|
+
{(['background', 'base', 'elevated', 'raised', 'overlay'] as SurfaceLevel[]).map((level) => (
|
|
94
|
+
<Surface key={level} level={level} style={{ padding: 16 }}>
|
|
95
|
+
<Text style={{ color: '#fff' }}>level="{level}"</Text>
|
|
96
|
+
</Surface>
|
|
97
|
+
))}
|
|
98
|
+
</View>
|
|
99
|
+
),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// On-surface colour context: descendant text reads its colour from the Surface
|
|
103
|
+
// via `useOnSurfaceColor` — no hard-coded hex, so it never renders black when the
|
|
104
|
+
// tree mounts as raw RN in the standalone wall SPA.
|
|
105
|
+
function OnSurfaceLabels() {
|
|
106
|
+
return (
|
|
107
|
+
<View style={{ gap: 4 }}>
|
|
108
|
+
<Text style={{ color: useOnSurfaceColor('primary'), fontSize: 16, fontWeight: '700' }}>
|
|
109
|
+
Primary on this surface
|
|
110
|
+
</Text>
|
|
111
|
+
<Text style={{ color: useOnSurfaceColor('secondary') }}>Secondary on this surface</Text>
|
|
112
|
+
<Text style={{ color: useOnSurfaceColor('tertiary') }}>Tertiary on this surface</Text>
|
|
113
|
+
</View>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const OnSurfaceText: Story = {
|
|
118
|
+
render: () => (
|
|
119
|
+
<View style={{ gap: 16, padding: 24 }}>
|
|
120
|
+
<Surface level="background" style={{ padding: 16 }}>
|
|
121
|
+
<OnSurfaceLabels />
|
|
122
|
+
</Surface>
|
|
123
|
+
<Surface theme="light" level="base" style={{ padding: 16 }}>
|
|
124
|
+
<OnSurfaceLabels />
|
|
125
|
+
</Surface>
|
|
126
|
+
</View>
|
|
127
|
+
),
|
|
128
|
+
}
|
|
129
|
+
|
|
81
130
|
export const LightTheme: Story = {
|
|
82
131
|
render: () => (
|
|
83
132
|
<View style={{ gap: 16, padding: 24, backgroundColor: '#F3F4F6' }}>
|
|
@@ -1,9 +1,28 @@
|
|
|
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
|
+
useOnSurfaceColor,
|
|
10
|
+
useSurfaceMode,
|
|
11
|
+
} from './SurfaceContext'
|
|
5
12
|
|
|
6
|
-
|
|
13
|
+
// A descendant probe that renders the on-surface colour + mode it resolves from
|
|
14
|
+
// context, so tests can assert what a nested consumer would actually paint.
|
|
15
|
+
function Probe({ role = 'primary' as const, label = 'probe' }) {
|
|
16
|
+
const color = useOnSurfaceColor(role)
|
|
17
|
+
const mode = useSurfaceMode()
|
|
18
|
+
return (
|
|
19
|
+
<Text testID={label} style={{ color }}>
|
|
20
|
+
{mode}
|
|
21
|
+
</Text>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('Surface (card model)', () => {
|
|
7
26
|
it('renders children', () => {
|
|
8
27
|
render(
|
|
9
28
|
<Surface>
|
|
@@ -49,6 +68,12 @@ describe('Surface', () => {
|
|
|
49
68
|
expect(screen.getByTestId('my-surface')).toBeInTheDocument()
|
|
50
69
|
})
|
|
51
70
|
|
|
71
|
+
it('paints the default flat dark card surface', () => {
|
|
72
|
+
render(<Surface testID="s" />)
|
|
73
|
+
// elevation 0 → base surface (surface-elevated, charcoal 600) with no lightening.
|
|
74
|
+
expect(screen.getByTestId('s')).toHaveStyle({ backgroundColor: '#191919' })
|
|
75
|
+
})
|
|
76
|
+
|
|
52
77
|
describe('accessibility', () => {
|
|
53
78
|
it('has no accessibility violations', async () => {
|
|
54
79
|
const { container } = render(
|
|
@@ -61,3 +86,82 @@ describe('Surface', () => {
|
|
|
61
86
|
})
|
|
62
87
|
})
|
|
63
88
|
})
|
|
89
|
+
|
|
90
|
+
describe('Surface (named plane)', () => {
|
|
91
|
+
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) => {
|
|
97
|
+
render(<Surface level={level} testID="s" />)
|
|
98
|
+
expect(screen.getByTestId('s')).toHaveStyle({ backgroundColor: hex })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('resolves the light-mode background when theme is overridden', () => {
|
|
102
|
+
render(<Surface level="base" theme="light" testID="s" />)
|
|
103
|
+
expect(screen.getByTestId('s')).toHaveStyle({ backgroundColor: '#FFFFFF' })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('lets a caller style override the owned background', () => {
|
|
107
|
+
render(<Surface level="base" testID="s" style={{ backgroundColor: '#000000' }} />)
|
|
108
|
+
expect(screen.getByTestId('s')).toHaveStyle({ backgroundColor: '#000000' })
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('Surface on-surface colour context', () => {
|
|
113
|
+
it('gives descendants literal-hex dark text colours', () => {
|
|
114
|
+
render(
|
|
115
|
+
<Surface level="background">
|
|
116
|
+
<Probe role="primary" label="p" />
|
|
117
|
+
<Probe role="secondary" label="s" />
|
|
118
|
+
<Probe role="tertiary" label="t" />
|
|
119
|
+
</Surface>
|
|
120
|
+
)
|
|
121
|
+
expect(screen.getByTestId('p')).toHaveStyle({ color: '#F3F4F6' })
|
|
122
|
+
expect(screen.getByTestId('s')).toHaveStyle({ color: '#9CA3AF' })
|
|
123
|
+
expect(screen.getByTestId('t')).toHaveStyle({ color: '#6B7280' })
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('resolves dark on-surface colours even with no enclosing Surface', () => {
|
|
127
|
+
render(<Probe role="primary" label="p" />)
|
|
128
|
+
expect(screen.getByTestId('p')).toHaveStyle({ color: '#F3F4F6' })
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('flows an overridden theme to descendant text', () => {
|
|
132
|
+
render(
|
|
133
|
+
<Surface theme="light">
|
|
134
|
+
<Probe role="primary" label="p" />
|
|
135
|
+
</Surface>
|
|
136
|
+
)
|
|
137
|
+
expect(screen.getByTestId('p')).toHaveStyle({ color: '#121828' })
|
|
138
|
+
expect(screen.getByTestId('p')).toHaveTextContent('light')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('inherits mode through a nested Surface that sets only a level', () => {
|
|
142
|
+
render(
|
|
143
|
+
<Surface theme="light" level="background">
|
|
144
|
+
<Surface level="raised">
|
|
145
|
+
<Probe role="primary" label="p" />
|
|
146
|
+
</Surface>
|
|
147
|
+
</Surface>
|
|
148
|
+
)
|
|
149
|
+
expect(screen.getByTestId('p')).toHaveStyle({ color: '#121828' })
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('surface colour helpers', () => {
|
|
154
|
+
it('surfaceBackground returns literal hex per level + mode', () => {
|
|
155
|
+
expect(surfaceBackground('elevated', 'dark')).toBe('#191919')
|
|
156
|
+
expect(surfaceBackground('background', 'dark')).toBe('#101010')
|
|
157
|
+
expect(surfaceBackground('base', 'light')).toBe('#FFFFFF')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('onSurfaceColors returns the neutral text ramp as literal hex', () => {
|
|
161
|
+
expect(onSurfaceColors('dark')).toEqual({
|
|
162
|
+
primary: '#F3F4F6',
|
|
163
|
+
secondary: '#9CA3AF',
|
|
164
|
+
tertiary: '#6B7280',
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import React from 'react'
|
|
1
|
+
import React, { useMemo } from 'react'
|
|
2
2
|
import { View, type ViewProps } 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,
|
|
@@ -9,43 +10,92 @@ import {
|
|
|
9
10
|
type GlowIntensity,
|
|
10
11
|
getGlowShadow,
|
|
11
12
|
} from '../../../theme/elevation'
|
|
13
|
+
import {
|
|
14
|
+
SurfaceContext,
|
|
15
|
+
surfaceBackground,
|
|
16
|
+
useSurface,
|
|
17
|
+
type SurfaceContextValue,
|
|
18
|
+
type SurfaceLevel,
|
|
19
|
+
} from './SurfaceContext'
|
|
12
20
|
|
|
13
21
|
export interface SurfaceProps extends ViewProps {
|
|
22
|
+
/**
|
|
23
|
+
* Numeric depth → a computed lighten + shadow (the raised-card model). The
|
|
24
|
+
* background is derived from the base surface by lightening. Ignored for the
|
|
25
|
+
* background when `level` is set. Default 0 (flat card).
|
|
26
|
+
*/
|
|
14
27
|
elevation?: ElevationLevel
|
|
28
|
+
/**
|
|
29
|
+
* Named charcoal plane (shell / rail / stage). Sets the background directly
|
|
30
|
+
* from a semantic surface token — reaching the darker nav/page planes the
|
|
31
|
+
* lighten model can't — and drops the default rounding + auto shadow so the
|
|
32
|
+
* plane reads full-bleed. Composable with `elevation`'s glow.
|
|
33
|
+
*/
|
|
34
|
+
level?: SurfaceLevel
|
|
15
35
|
glowColor?: string
|
|
16
36
|
glowIntensity?: GlowIntensity
|
|
17
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Theme mode. Also seeds the on-surface colour CONTEXT so descendant text /
|
|
39
|
+
* icons resolve their colour from this surface instead of a `text-*` className
|
|
40
|
+
* that silently fails to black on raw RN. Inherits the nearest Surface /
|
|
41
|
+
* ThemeProvider when omitted (default dark — the wall).
|
|
42
|
+
*/
|
|
43
|
+
theme?: ThemeMode
|
|
44
|
+
/** Round the corners. Defaults on for the card model, off for a `level` plane. */
|
|
45
|
+
rounded?: boolean
|
|
18
46
|
className?: string
|
|
19
|
-
children
|
|
47
|
+
children?: React.ReactNode
|
|
20
48
|
}
|
|
21
49
|
|
|
50
|
+
/**
|
|
51
|
+
* A container that OWNS its background and establishes an on-surface colour
|
|
52
|
+
* context. Two grounded ways to pick the background:
|
|
53
|
+
* - `elevation` (numeric): the raised-card model — lighten-from-base + shadow.
|
|
54
|
+
* - `level` (named): a flat charcoal plane straight from a semantic token,
|
|
55
|
+
* for the shell / rail / stage backgrounds.
|
|
56
|
+
* Either way, descendants read the surface via {@link useOnSurfaceColor} /
|
|
57
|
+
* {@link useSurfaceMode} rather than hard-coding `getSemanticColors('dark')`.
|
|
58
|
+
*/
|
|
22
59
|
export function Surface({
|
|
23
60
|
elevation = 0,
|
|
61
|
+
level,
|
|
24
62
|
glowColor,
|
|
25
63
|
glowIntensity,
|
|
26
|
-
theme
|
|
64
|
+
theme,
|
|
65
|
+
rounded,
|
|
27
66
|
className,
|
|
28
67
|
style,
|
|
29
68
|
children,
|
|
30
69
|
...props
|
|
31
70
|
}: SurfaceProps) {
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const
|
|
71
|
+
const inherited = useSurface()
|
|
72
|
+
const mode = theme ?? inherited.mode
|
|
73
|
+
const isPlane = level != null
|
|
74
|
+
|
|
75
|
+
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)
|
|
35
81
|
const glowStyle = glowColor ? getGlowShadow(glowColor, glowIntensity) : {}
|
|
82
|
+
const applyRounded = rounded ?? !isPlane
|
|
83
|
+
|
|
84
|
+
const value = useMemo<SurfaceContextValue>(
|
|
85
|
+
() => ({ mode, level: level ?? inherited.level }),
|
|
86
|
+
[mode, level, inherited.level]
|
|
87
|
+
)
|
|
36
88
|
|
|
37
89
|
return (
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
shadowStyle,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
>
|
|
48
|
-
{children}
|
|
49
|
-
</View>
|
|
90
|
+
<SurfaceContext.Provider value={value}>
|
|
91
|
+
<View
|
|
92
|
+
className={cn(applyRounded && 'rounded-2xl', className)}
|
|
93
|
+
// backgroundColor first so a caller `style` can still override it.
|
|
94
|
+
style={[{ backgroundColor }, shadowStyle, glowStyle, style]}
|
|
95
|
+
{...props}
|
|
96
|
+
>
|
|
97
|
+
{children}
|
|
98
|
+
</View>
|
|
99
|
+
</SurfaceContext.Provider>
|
|
50
100
|
)
|
|
51
101
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// On-surface colour context (TD-05.12).
|
|
2
|
+
//
|
|
3
|
+
// A <Surface> owns a background from a small elevation scale AND publishes the
|
|
4
|
+
// current theme mode so descendant text/icons resolve their colour from "what
|
|
5
|
+
// surface am I on" instead of a `text-*` className. Those classNames silently
|
|
6
|
+
// fail to black when the tree renders as raw RN in the standalone wall SPA
|
|
7
|
+
// (no global.css, no nativewind) — the bug class this primitive retires.
|
|
8
|
+
import { createContext, useContext } from 'react'
|
|
9
|
+
import { getSemanticColors, type ThemeMode } from '../../../theme/tokens/semantic'
|
|
10
|
+
|
|
11
|
+
type ColorToken = keyof ReturnType<typeof getSemanticColors>
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A Surface's depth on the dark charcoal ramp. Each level maps to an EXISTING
|
|
15
|
+
* semantic surface/background token (already grounded in the charcoal ramp and
|
|
16
|
+
* theme-aware) — Surface introduces no new hexes. Darkest → lightest:
|
|
17
|
+
* background (#101010) < base (#161616) < elevated (#191919) < raised (#1C1C1C)
|
|
18
|
+
*/
|
|
19
|
+
export type SurfaceLevel = 'background' | 'base' | 'elevated' | 'raised' | 'overlay'
|
|
20
|
+
|
|
21
|
+
/** On-surface neutral text roles, resolved for the current surface + theme. */
|
|
22
|
+
export type OnSurfaceRole = 'primary' | 'secondary' | 'tertiary'
|
|
23
|
+
|
|
24
|
+
// Which semantic token backs each level. Values live in `semantic.ts` and stay
|
|
25
|
+
// in sync with the charcoal ramp — this map never carries literal hexes.
|
|
26
|
+
export const SURFACE_LEVEL_TOKEN = {
|
|
27
|
+
background: 'background-base',
|
|
28
|
+
base: 'surface-base',
|
|
29
|
+
elevated: 'surface-elevated',
|
|
30
|
+
raised: 'surface-raised',
|
|
31
|
+
overlay: 'surface-overlay',
|
|
32
|
+
} as const satisfies Record<SurfaceLevel, ColorToken>
|
|
33
|
+
|
|
34
|
+
const ON_SURFACE_TOKEN = {
|
|
35
|
+
primary: 'text-primary',
|
|
36
|
+
secondary: 'text-secondary',
|
|
37
|
+
tertiary: 'text-tertiary',
|
|
38
|
+
} as const satisfies Record<OnSurfaceRole, ColorToken>
|
|
39
|
+
|
|
40
|
+
export interface SurfaceContextValue {
|
|
41
|
+
/** Active theme mode. Defaults to dark (the wall). */
|
|
42
|
+
mode: ThemeMode
|
|
43
|
+
/** Depth of the nearest enclosing Surface. */
|
|
44
|
+
level: SurfaceLevel
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Default context: dark 'base' plane, so descendants OUTSIDE any Surface still
|
|
48
|
+
// resolve to real dark colours (never black) on the dark-only wall.
|
|
49
|
+
const DEFAULT_CONTEXT: SurfaceContextValue = { mode: 'dark', level: 'base' }
|
|
50
|
+
|
|
51
|
+
export const SurfaceContext = createContext<SurfaceContextValue>(DEFAULT_CONTEXT)
|
|
52
|
+
|
|
53
|
+
/** The nearest surface context ({ mode, level }); default dark 'base'. */
|
|
54
|
+
export function useSurface(): SurfaceContextValue {
|
|
55
|
+
return useContext(SurfaceContext)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The active theme mode from the nearest Surface/provider (default dark). */
|
|
59
|
+
export function useSurfaceMode(): ThemeMode {
|
|
60
|
+
return useContext(SurfaceContext).mode
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Literal-hex on-surface neutral text colours for a theme mode. Uses
|
|
65
|
+
* `getSemanticColors` (literal hex — the TempoBar pattern), never `resolveColor`
|
|
66
|
+
* (which returns `var()` under the RNW vitest alias), so values stay real in
|
|
67
|
+
* tested component code and on raw-RN surfaces alike.
|
|
68
|
+
*/
|
|
69
|
+
export function onSurfaceColors(mode: ThemeMode): Record<OnSurfaceRole, string> {
|
|
70
|
+
const c = getSemanticColors(mode)
|
|
71
|
+
return {
|
|
72
|
+
primary: c[ON_SURFACE_TOKEN.primary],
|
|
73
|
+
secondary: c[ON_SURFACE_TOKEN.secondary],
|
|
74
|
+
tertiary: c[ON_SURFACE_TOKEN.tertiary],
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Resolve one on-surface text colour for descendants of a Surface. */
|
|
79
|
+
export function useOnSurfaceColor(role: OnSurfaceRole = 'primary'): string {
|
|
80
|
+
return getSemanticColors(useSurfaceMode())[ON_SURFACE_TOKEN[role]]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The background hex for a surface level under a theme mode (literal hex). */
|
|
84
|
+
export function surfaceBackground(level: SurfaceLevel, mode: ThemeMode): string {
|
|
85
|
+
return getSemanticColors(mode)[SURFACE_LEVEL_TOKEN[level]]
|
|
86
|
+
}
|