@titan-design/react-ui 0.9.2 → 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.2",
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",
@@ -1,6 +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 { getSemanticColors } from '../../../theme/tokens/semantic'
3
+ import { Surface, onSurfaceColors, useSurfaceMode } from '../../ui/surface'
4
4
  import { Typography } from '../Typography'
5
5
  import { TimerReadout } from '../TimerReadout'
6
6
  import { SegmentedProgressBar } from './SegmentedProgressBar'
@@ -8,13 +8,10 @@ import { MetricTiles, type MetricTileData } from './MetricTiles'
8
8
  import { ScheduleTiles } from './ScheduleTiles'
9
9
  import { paceTone, paceToneColor } from './paceTone'
10
10
 
11
- const t = getSemanticColors('dark')
12
-
13
11
  // The header shares the SideNav's `background-base` (charcoal 900, #101010) so the nav and
14
12
  // the rail header read as ONE continuous dark plane on the left — the sunk exercise list
15
- // (charcoal 800) sits raised off it. Previously charcoal 400 (#1F1F1F), which made the header
16
- // the lightest rail surface and let it blend with the rows instead of anchoring to the nav.
17
- const HEADER_BG = t['background-base'] // #101010 — matches SideNav bg-background-base
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.
18
15
 
19
16
  // The chunked pace bar matches the SetStrip language but sits tighter than the default
20
17
  // SetStrip gap (5) — the locked design uses a 3px gap and a 9px track.
@@ -73,21 +70,22 @@ export function SessionHeader({
73
70
  }: SessionHeaderProps) {
74
71
  const upcoming = next != null
75
72
  const totalSets = plan.reduce((sum, e) => sum + e.sets, 0)
73
+ const onSurface = onSurfaceColors(useSurfaceMode())
76
74
 
77
75
  const hasPace = !upcoming && budgetMs != null && elapsedMs != null
78
76
  const target = hasPace ? (elapsedMs as number) / (budgetMs as number) : undefined
79
77
 
80
78
  const setsLabel = upcoming ? `${totalSets} sets` : `${Math.floor(setsDone)}/${totalSets} sets`
81
79
  const setsLabelColor = upcoming
82
- ? t['text-secondary']
80
+ ? onSurface.secondary
83
81
  : paceToneColor(paceTone(totalSets > 0 ? setsDone / totalSets : 0, target))
84
82
 
85
83
  return (
86
- <View
84
+ <Surface
85
+ level="background"
87
86
  className={className}
88
87
  style={[
89
88
  {
90
- backgroundColor: HEADER_BG,
91
89
  paddingTop: 11,
92
90
  paddingRight: 12,
93
91
  paddingBottom: 12,
@@ -106,7 +104,7 @@ export function SessionHeader({
106
104
  lineHeight: 18,
107
105
  fontWeight: '700',
108
106
  fontFamily: '"Space Grotesk", sans-serif',
109
- color: t['text-primary'],
107
+ color: onSurface.primary,
110
108
  marginBottom: 10,
111
109
  }}
112
110
  testID="session-rail-title"
@@ -148,6 +146,6 @@ export function SessionHeader({
148
146
  />
149
147
  </View>
150
148
  </View>
151
- </View>
149
+ </Surface>
152
150
  )
153
151
  }
@@ -2,6 +2,7 @@
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'
@@ -9,12 +10,11 @@ import type { SetStripSet } from './SetStrip'
9
10
  import type { ExerciseIndicatorKind } from './ExerciseIndicator'
10
11
 
11
12
  // Charcoal-ramp surfaces (the locked S3 shell shades): the nav + the heading plane read as
12
- // ONE dark plane (charcoal 900, #101010, set in SessionHeader). The exercise list is a
13
- // LIGHTER inset panel (charcoal 600) recessed via its inner shadow yet paler than the
14
- // header, so the transparent exercise headings on it never blend into the header plane.
15
- // (Band-aid: the list is picked to clear the components; the durable fix is a `<Surface>`
16
- // primitive whose cards own their own raised background — see the rail's PR notes.)
17
- const INSET = primitiveColors.charcoal[600] // #191919 — lighter sunk exercise-list panel
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
18
  const DIVIDER = primitiveColors.charcoal[300] // #2C2C2C — row divider (raised to read on #191919)
19
19
 
20
20
  const DEFAULT_WIDTH = 246
@@ -87,9 +87,10 @@ export function SessionRail({
87
87
  ...props
88
88
  }: SessionRailProps) {
89
89
  return (
90
- <View
90
+ <Surface
91
+ level="elevated"
91
92
  className={className}
92
- style={[{ width, flexDirection: 'column', backgroundColor: INSET }, style]}
93
+ style={[{ width, flexDirection: 'column' }, style]}
93
94
  testID="session-rail"
94
95
  {...props}
95
96
  >
@@ -104,8 +105,9 @@ export function SessionRail({
104
105
  next={next}
105
106
  />
106
107
 
107
- <View
108
- style={[{ flex: 1, backgroundColor: INSET }, neumorphicShadows.charcoal.pressed.subtle]}
108
+ <Surface
109
+ level="elevated"
110
+ style={[{ flex: 1 }, neumorphicShadows.charcoal.pressed.subtle]}
109
111
  testID="session-rail-list"
110
112
  >
111
113
  {exercises.map((ex, i) => (
@@ -131,7 +133,7 @@ export function SessionRail({
131
133
  />
132
134
  </View>
133
135
  ))}
134
- </View>
135
- </View>
136
+ </Surface>
137
+ </Surface>
136
138
  )
137
139
  }
@@ -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'
@@ -60,13 +61,15 @@ export function DashboardShell({
60
61
  return (
61
62
  // Column: the TopBar spans the FULL width across the top, and the SideNav sits BELOW it
62
63
  // in the content row (not a full-height left rail). This keeps the brand/status band
63
- // unbroken edge-to-edge and lets the nav align under it.
64
- <View className={cn('flex-1 bg-surface-base', className)}>
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)}>
65
68
  <TopBar state={state} devices={devices} subtitle={subtitle} onSelectDevice={onSelectDevice} />
66
69
  <View className="flex-1 flex-row">
67
70
  <SideNav activeKey={activeKey} items={navItems} liveKey={liveKey} onNavigate={onNavigate} />
68
71
  <View className="flex-1">{children ?? <ContentPlaceholder />}</View>
69
72
  </View>
70
- </View>
73
+ </Surface>
71
74
  )
72
75
  }
@@ -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
+ }
@@ -1 +1,13 @@
1
1
  export { Surface, type SurfaceProps } from './Surface'
2
+ export {
3
+ SurfaceContext,
4
+ useSurface,
5
+ useSurfaceMode,
6
+ useOnSurfaceColor,
7
+ onSurfaceColors,
8
+ surfaceBackground,
9
+ SURFACE_LEVEL_TOKEN,
10
+ type SurfaceLevel,
11
+ type OnSurfaceRole,
12
+ type SurfaceContextValue,
13
+ } from './SurfaceContext'
@@ -11,3 +11,16 @@
11
11
  export function vars(values: Record<string, string>): Record<string, string> {
12
12
  return values
13
13
  }
14
+
15
+ /**
16
+ * Stand-in for nativewind's `useColorScheme()`. Under the web test env there is
17
+ * no nativewind theme applied, so it reports `undefined` (the "no explicit
18
+ * scheme" signal) — matching ThemeProvider's dark fallback for `mode="system"`.
19
+ */
20
+ export function useColorScheme(): {
21
+ colorScheme: 'light' | 'dark' | undefined
22
+ setColorScheme: (scheme: 'light' | 'dark' | 'system') => void
23
+ toggleColorScheme: () => void
24
+ } {
25
+ return { colorScheme: undefined, setColorScheme: () => {}, toggleColorScheme: () => {} }
26
+ }
@@ -2,6 +2,12 @@ import { describe, it, expect } from 'vitest'
2
2
  import { render, screen } from '@testing-library/react'
3
3
  import { Text } from 'react-native'
4
4
  import { ThemeProvider } from './ThemeProvider'
5
+ import { useSurfaceMode } from '../components/ui/surface'
6
+
7
+ // Reports the surface mode ThemeProvider seeds into the on-surface context.
8
+ function ModeProbe() {
9
+ return <Text>{useSurfaceMode()}</Text>
10
+ }
5
11
 
6
12
  describe('ThemeProvider', () => {
7
13
  it('renders its children', () => {
@@ -21,4 +27,22 @@ describe('ThemeProvider', () => {
21
27
  )
22
28
  expect(screen.getByText('light')).toBeInTheDocument()
23
29
  })
30
+
31
+ it('seeds the on-surface context with an explicit mode', () => {
32
+ render(
33
+ <ThemeProvider mode="light">
34
+ <ModeProbe />
35
+ </ThemeProvider>,
36
+ )
37
+ expect(screen.getByText('light')).toBeInTheDocument()
38
+ })
39
+
40
+ it('falls back to dark for mode="system" when no scheme is applied', () => {
41
+ render(
42
+ <ThemeProvider mode="system">
43
+ <ModeProbe />
44
+ </ThemeProvider>,
45
+ )
46
+ expect(screen.getByText('dark')).toBeInTheDocument()
47
+ })
24
48
  })