@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@titan-design/react-ui",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Cross-platform design system built on Gluestack UI",
5
5
  "author": "Henry Jewkes",
6
6
  "license": "MIT",
@@ -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: number
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
- return `${name}, ${sets}×${reps} @ ${roundWeight(load)} ${unit}`
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 { primitiveColors } from '../../../theme/tokens/primitives'
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
- const t = getSemanticColors('dark')
13
-
14
- // The raised charcoal heading plane (charcoal 400) reads as one flat surface with
15
- // the nav; depth lives on the sunk list below it, not here.
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
- ? t['text-secondary']
80
+ ? onSurface.secondary
82
81
  : paceToneColor(paceTone(totalSets > 0 ? setsDone / totalSets : 0, target))
83
82
 
84
83
  return (
85
- <View
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: t['text-primary'],
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
- </View>
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
- // read as ONE flat raised surface; the exercise list is sunk. Depth comes only from
13
- // the list's subtle inset shadow (top cast by the heading, faint left edge by the nav).
14
- const INSET = primitiveColors.charcoal[800] // #131313 sunk exercise list
15
- const DIVIDER = primitiveColors.charcoal[500] // #1C1C1C row divider
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
- summary: { sets: number; reps: number | string; weight: number; unit: 'lbs' | 'kg' }
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
- <View
90
+ <Surface
91
+ level="elevated"
80
92
  className={className}
81
- style={[{ width, flexDirection: 'column', backgroundColor: INSET }, style]}
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
- <View
97
- style={[{ flex: 1, backgroundColor: INSET }, neumorphicShadows.charcoal.pressed.subtle]}
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
- </View>
124
- </View>
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: number
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
- <View className={cn('flex-1 flex-row bg-surface-base', className)}>
62
- <SideNav activeKey={activeKey} items={navItems} liveKey={liveKey} onNavigate={onNavigate} />
63
- <View className="flex-1">
64
- <TopBar
65
- state={state}
66
- devices={devices}
67
- subtitle={subtitle}
68
- onSelectDevice={onSelectDevice}
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
- </View>
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
- className={cn('text-[8.5px] font-bold uppercase tracking-[0.4px]', labelColor)}
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=&quot;{level}&quot;</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
- describe('Surface', () => {
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
- theme?: 'light' | 'dark'
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: React.ReactNode
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 = 'dark',
64
+ theme,
65
+ rounded,
27
66
  className,
28
67
  style,
29
68
  children,
30
69
  ...props
31
70
  }: SurfaceProps) {
32
- const baseColor = getBaseSurfaceColor(theme)
33
- const surfaceColor = getElevationSurface(baseColor, elevation, theme)
34
- const shadowStyle = getElevationShadow(baseColor, elevation, theme)
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
- <View
39
- className={cn('rounded-2xl', className)}
40
- style={[
41
- { backgroundColor: surfaceColor },
42
- shadowStyle,
43
- glowStyle,
44
- style,
45
- ]}
46
- {...props}
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
+ }