@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.
Files changed (53) hide show
  1. package/dist/bodymap.js +78 -24
  2. package/dist/bodymap.js.map +1 -1
  3. package/dist/bodymap.mjs +78 -24
  4. package/dist/bodymap.mjs.map +1 -1
  5. package/dist/index.d.mts +215 -123
  6. package/dist/index.d.ts +215 -123
  7. package/dist/index.js +408 -259
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +258 -121
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/pages.js +214 -157
  12. package/dist/pages.js.map +1 -1
  13. package/dist/pages.mjs +214 -157
  14. package/dist/pages.mjs.map +1 -1
  15. package/dist/theme/index.d.mts +895 -795
  16. package/dist/theme/index.d.ts +895 -795
  17. package/dist/theme/index.js +101 -20
  18. package/dist/theme/index.js.map +1 -1
  19. package/dist/theme/index.mjs +99 -22
  20. package/dist/theme/index.mjs.map +1 -1
  21. package/dist/theme/tokens-css.js +88 -24
  22. package/dist/theme/tokens-css.js.map +1 -1
  23. package/dist/theme/tokens-css.mjs +88 -24
  24. package/dist/theme/tokens-css.mjs.map +1 -1
  25. package/package.json +1 -1
  26. package/src/components/custom/Workout/BaseBadge.tsx +5 -5
  27. package/src/components/custom/Workout/RowInventory.stories.tsx +5 -2
  28. package/src/components/custom/Workout/S3FullRail.stories.tsx +13 -1
  29. package/src/components/custom/Workout/SessionHeader.tsx +11 -13
  30. package/src/components/custom/Workout/SessionRail.stories.tsx +4 -1
  31. package/src/components/custom/Workout/SessionRail.tsx +16 -14
  32. package/src/components/custom/Workout/SupersetWrapper.stories.tsx +16 -25
  33. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -1
  34. package/src/components/custom/Workout/TempoDisplay.tsx +3 -7
  35. package/src/components/custom/Workout/VolumeLandmarkBar.stories.tsx +4 -1
  36. package/src/components/custom/Workout/WorkoutPill.tsx +2 -2
  37. package/src/components/shell/DashboardShell.tsx +6 -3
  38. package/src/components/ui/surface/Surface.stories.tsx +244 -1
  39. package/src/components/ui/surface/Surface.test.tsx +244 -1
  40. package/src/components/ui/surface/Surface.tsx +98 -20
  41. package/src/components/ui/surface/SurfaceContext.ts +126 -0
  42. package/src/components/ui/surface/index.ts +13 -0
  43. package/src/components/ui/surface/surface.contract.test.ts +283 -0
  44. package/src/test/nativewind-stub.ts +13 -0
  45. package/src/theme/ColorSystem.stories.tsx +232 -47
  46. package/src/theme/ThemeProvider.test.tsx +24 -0
  47. package/src/theme/ThemeProvider.tsx +23 -6
  48. package/src/theme/config.ts +12 -0
  49. package/src/theme/elevation.ts +90 -67
  50. package/src/theme/global.css +35 -9
  51. package/src/theme/tokens/primitives.ts +40 -0
  52. package/src/theme/tokens/semantic.ts +63 -14
  53. package/tailwind.config.js +8 -0
@@ -11,6 +11,9 @@ import {
11
11
  sequentialEffort,
12
12
  bestTextColor,
13
13
  } from './tokens/primitives'
14
+ import { getSemanticColors } from './tokens/semantic'
15
+
16
+ const t = getSemanticColors('dark')
14
17
 
15
18
  const meta: Meta = {
16
19
  title: 'Foundations/Color/System',
@@ -23,9 +26,13 @@ const hexToRgb = (hex: string): [number, number, number] => {
23
26
  const h = hex.replace('#', '')
24
27
  return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255) as [number, number, number]
25
28
  }
26
- const toHex = (v: number) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, '0')
29
+ const toHex = (v: number) =>
30
+ Math.round(Math.min(1, Math.max(0, v)) * 255)
31
+ .toString(16)
32
+ .padStart(2, '0')
27
33
  const lin = (c: number) => (c >= 0.04045 ? ((c + 0.055) / 1.055) ** 2.4 : c / 12.92)
28
- const gamma = (c: number) => (c >= 0.0031308 ? 1.055 * Math.max(0, c) ** (1 / 2.4) - 0.055 : 12.92 * c)
34
+ const gamma = (c: number) =>
35
+ c >= 0.0031308 ? 1.055 * Math.max(0, c) ** (1 / 2.4) - 0.055 : 12.92 * c
29
36
  const relLum = (hex: string) => {
30
37
  const [r, g, b] = hexToRgb(hex).map(lin)
31
38
  return 0.2126 * r + 0.7152 * g + 0.0722 * b
@@ -34,11 +41,16 @@ const contrast = (a: string, b: string) => {
34
41
  const [l1, l2] = [relLum(a), relLum(b)].sort((x, y) => y - x)
35
42
  return (l1 + 0.05) / (l2 + 0.05)
36
43
  }
37
- const bestText = (hex: string) => (contrast(hex, '#000000') >= contrast(hex, '#FFFFFF') ? '#000000' : '#FFFFFF')
44
+ const bestText = (hex: string) =>
45
+ contrast(hex, '#000000') >= contrast(hex, '#FFFFFF') ? '#000000' : '#FFFFFF'
38
46
  // Machado-2009 dichromacy simulation (severity 1.0)
39
47
  const CVD = {
40
- deuteranopia: [0.367322, 0.860646, -0.227968, 0.280085, 0.672501, 0.047413, -0.01182, 0.04294, 0.968881],
41
- protanopia: [0.152286, 1.052583, -0.204868, 0.114503, 0.786281, 0.099216, -0.003882, -0.048116, 1.051998],
48
+ deuteranopia: [
49
+ 0.367322, 0.860646, -0.227968, 0.280085, 0.672501, 0.047413, -0.01182, 0.04294, 0.968881,
50
+ ],
51
+ protanopia: [
52
+ 0.152286, 1.052583, -0.204868, 0.114503, 0.786281, 0.099216, -0.003882, -0.048116, 1.051998,
53
+ ],
42
54
  } as const
43
55
  const simulate = (M: readonly number[], hex: string) => {
44
56
  const [r, g, b] = hexToRgb(hex).map(lin)
@@ -62,7 +74,16 @@ function Ramp({ name, ramp }: { name: string; ramp: Record<number, string> }) {
62
74
  <Text className="text-text-secondary text-xs" style={{ width: 150 }}>
63
75
  {name}
64
76
  </Text>
65
- <View style={{ flexDirection: 'row', flex: 1, borderRadius: 6, overflow: 'hidden', borderWidth: 1, borderColor: '#2C2C2C' }}>
77
+ <View
78
+ style={{
79
+ flexDirection: 'row',
80
+ flex: 1,
81
+ borderRadius: 6,
82
+ overflow: 'hidden',
83
+ borderWidth: 1,
84
+ borderColor: '#2C2C2C',
85
+ }}
86
+ >
66
87
  {STEPS.map((s) => (
67
88
  <View key={s} style={{ flex: 1, height: 30, backgroundColor: ramp[s] }} />
68
89
  ))}
@@ -86,9 +107,15 @@ function Chip({ hex, label, sub }: { hex: string; label?: string; sub?: string }
86
107
  justifyContent: 'center',
87
108
  }}
88
109
  >
89
- {label ? <Text style={{ color: bestText(hex), fontSize: 11, fontWeight: '700' }}>{label}</Text> : null}
110
+ {label ? (
111
+ <Text style={{ color: bestText(hex), fontSize: 11, fontWeight: '700' }}>{label}</Text>
112
+ ) : null}
90
113
  </View>
91
- {sub ? <Text className="text-text-secondary" style={{ fontSize: 8, marginTop: 2 }}>{sub}</Text> : null}
114
+ {sub ? (
115
+ <Text className="text-text-secondary" style={{ fontSize: 8, marginTop: 2 }}>
116
+ {sub}
117
+ </Text>
118
+ ) : null}
92
119
  </View>
93
120
  )
94
121
  }
@@ -124,8 +151,8 @@ export const Overview: StoryObj = {
124
151
  <View style={{ padding: 24, gap: 6 }}>
125
152
  <Text className="text-2xl font-bold text-text-primary mb-1">Color System</Text>
126
153
  <Text className="text-text-secondary mb-4">
127
- Seven OKLCH tonal ramps and an ordered, colorblind-safe categorical palette. Ramps are the single
128
- source of truth; the categorical references ramp steps.
154
+ Seven OKLCH tonal ramps and an ordered, colorblind-safe categorical palette. Ramps are the
155
+ single source of truth; the categorical references ramp steps.
129
156
  </Text>
130
157
 
131
158
  <SectionTitle>Tonal ramps</SectionTitle>
@@ -148,12 +175,20 @@ export const Overview: StoryObj = {
148
175
  <SectionTitle>Categorical — ordered, nested, CVD-safe to 6</SectionTitle>
149
176
  {(['default', 'dark'] as const).map((variant) => (
150
177
  <View key={variant} style={{ marginBottom: 10 }}>
151
- <Text className="text-text-secondary text-xs mb-1" style={{ textTransform: 'capitalize' }}>
178
+ <Text
179
+ className="text-text-secondary text-xs mb-1"
180
+ style={{ textTransform: 'capitalize' }}
181
+ >
152
182
  {variant}
153
183
  </Text>
154
184
  <View style={{ flexDirection: 'row', gap: 6 }}>
155
185
  {categoricalPalette[variant].map((hex, i) => (
156
- <Chip key={i} hex={hex} label={`${i + 1}`} sub={i < CATEGORICAL_CVD_SAFE_MAX ? undefined : 'ext'} />
186
+ <Chip
187
+ key={i}
188
+ hex={hex}
189
+ label={`${i + 1}`}
190
+ sub={i < CATEGORICAL_CVD_SAFE_MAX ? undefined : 'ext'}
191
+ />
157
192
  ))}
158
193
  </View>
159
194
  </View>
@@ -166,19 +201,45 @@ export const Overview: StoryObj = {
166
201
  </Text>
167
202
  <View style={{ flexDirection: 'row', gap: 6, marginBottom: 12 }}>
168
203
  {['under', 'maint', 'optimal', 'approach', 'over'].map((l, i) => (
169
- <View key={l} style={{ flex: 1, height: 40, borderRadius: 6, backgroundColor: divergingScale[i], alignItems: 'center', justifyContent: 'center' }}>
170
- <Text style={{ color: bestTextColor(divergingScale[i]), fontSize: 10, fontWeight: '700' }}>{l}</Text>
204
+ <View
205
+ key={l}
206
+ style={{
207
+ flex: 1,
208
+ height: 40,
209
+ borderRadius: 6,
210
+ backgroundColor: divergingScale[i],
211
+ alignItems: 'center',
212
+ justifyContent: 'center',
213
+ }}
214
+ >
215
+ <Text
216
+ style={{ color: bestTextColor(divergingScale[i]), fontSize: 10, fontWeight: '700' }}
217
+ >
218
+ {l}
219
+ </Text>
171
220
  </View>
172
221
  ))}
173
222
  </View>
174
223
  <Text className="text-text-secondary text-xs mb-2">
175
- Sequential effort (low → high) — an ordinal green→yellow→orange→red scale that IntensityBar uses
176
- in full and VelocityStrip/RPE sub-samples.
224
+ Sequential effort (low → high) — an ordinal green→yellow→orange→red scale that IntensityBar
225
+ uses in full and VelocityStrip/RPE sub-samples.
177
226
  </Text>
178
227
  <View style={{ flexDirection: 'row', gap: 4, marginBottom: 4 }}>
179
228
  {sequentialEffort.map((hex, i) => (
180
- <View key={i} style={{ flex: 1, height: 34, borderRadius: 5, backgroundColor: hex, alignItems: 'center', justifyContent: 'center' }}>
181
- <Text style={{ color: bestTextColor(hex), fontSize: 10, fontWeight: '700' }}>{i + 1}</Text>
229
+ <View
230
+ key={i}
231
+ style={{
232
+ flex: 1,
233
+ height: 34,
234
+ borderRadius: 5,
235
+ backgroundColor: hex,
236
+ alignItems: 'center',
237
+ justifyContent: 'center',
238
+ }}
239
+ >
240
+ <Text style={{ color: bestTextColor(hex), fontSize: 10, fontWeight: '700' }}>
241
+ {i + 1}
242
+ </Text>
182
243
  </View>
183
244
  ))}
184
245
  </View>
@@ -189,11 +250,32 @@ export const Overview: StoryObj = {
189
250
  <Demo label="BodyMap · training status">
190
251
  <View style={{ flexDirection: 'row', flexWrap: 'wrap', width: 132, gap: 4 }}>
191
252
  {[
192
- ['Chest', 4], ['Quads', 2], ['Back', 3],
193
- ['Delts', 0], ['Biceps', 4], ['Calves', 1],
253
+ ['Chest', 4],
254
+ ['Quads', 2],
255
+ ['Back', 3],
256
+ ['Delts', 0],
257
+ ['Biceps', 4],
258
+ ['Calves', 1],
194
259
  ].map(([m, idx]) => (
195
- <View key={m as string} style={{ width: 40, backgroundColor: D3_STATUS[idx as number], borderRadius: 5, paddingVertical: 6 }}>
196
- <Text style={{ color: bestTextColor(D3_STATUS[idx as number]), fontSize: 8, fontWeight: '700', textAlign: 'center' }}>{m as string}</Text>
260
+ <View
261
+ key={m as string}
262
+ style={{
263
+ width: 40,
264
+ backgroundColor: D3_STATUS[idx as number],
265
+ borderRadius: 5,
266
+ paddingVertical: 6,
267
+ }}
268
+ >
269
+ <Text
270
+ style={{
271
+ color: bestTextColor(D3_STATUS[idx as number]),
272
+ fontSize: 8,
273
+ fontWeight: '700',
274
+ textAlign: 'center',
275
+ }}
276
+ >
277
+ {m as string}
278
+ </Text>
197
279
  </View>
198
280
  ))}
199
281
  </View>
@@ -201,10 +283,22 @@ export const Overview: StoryObj = {
201
283
  {/* StatusDot — semantic */}
202
284
  <Demo label="StatusDot · semantic">
203
285
  <View style={{ gap: 6 }}>
204
- {[['ok', SEM.success], ['warn', SEM.warning], ['err', SEM.error], ['neutral', SEM.neutral]].map(([l, c]) => (
205
- <View key={l as string} style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
206
- <View style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: c as string }} />
207
- <Text className="text-text-secondary" style={{ fontSize: 11 }}>{l as string}</Text>
286
+ {[
287
+ ['ok', SEM.success],
288
+ ['warn', SEM.warning],
289
+ ['err', SEM.error],
290
+ ['neutral', SEM.neutral],
291
+ ].map(([l, c]) => (
292
+ <View
293
+ key={l as string}
294
+ style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}
295
+ >
296
+ <View
297
+ style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: c as string }}
298
+ />
299
+ <Text className="text-text-secondary" style={{ fontSize: 11 }}>
300
+ {l as string}
301
+ </Text>
208
302
  </View>
209
303
  ))}
210
304
  </View>
@@ -212,9 +306,25 @@ export const Overview: StoryObj = {
212
306
  {/* WorkoutPill — status */}
213
307
  <Demo label="WorkoutPill">
214
308
  <View style={{ flexDirection: 'row', gap: 6, flexWrap: 'wrap', maxWidth: 220 }}>
215
- {[['Done', SEM.success], ['Now', SEM.brand], ['Next', SEM.neutral], ['Deload', SEM.deload]].map(([t, c]) => (
216
- <View key={t as string} style={{ borderRadius: 99, borderWidth: 1, borderColor: c as string, paddingHorizontal: 10, paddingVertical: 3 }}>
217
- <Text style={{ color: c as string, fontSize: 11, fontWeight: '700' }}>{t as string}</Text>
309
+ {[
310
+ ['Done', SEM.success],
311
+ ['Now', SEM.brand],
312
+ ['Next', SEM.neutral],
313
+ ['Deload', SEM.deload],
314
+ ].map(([t, c]) => (
315
+ <View
316
+ key={t as string}
317
+ style={{
318
+ borderRadius: 99,
319
+ borderWidth: 1,
320
+ borderColor: c as string,
321
+ paddingHorizontal: 10,
322
+ paddingVertical: 3,
323
+ }}
324
+ >
325
+ <Text style={{ color: c as string, fontSize: 11, fontWeight: '700' }}>
326
+ {t as string}
327
+ </Text>
218
328
  </View>
219
329
  ))}
220
330
  </View>
@@ -222,10 +332,22 @@ export const Overview: StoryObj = {
222
332
  {/* StrengthTrend legend — series → semantic */}
223
333
  <Demo label="StrengthTrend · series">
224
334
  <View style={{ flexDirection: 'row', gap: 12, flexWrap: 'wrap' }}>
225
- {[['1RM', SEM.brand], ['vel', SEM.success], ['miss', SEM.error], ['RPE', SEM.warning]].map(([l, c]) => (
226
- <View key={l as string} style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
227
- <View style={{ width: 14, height: 3, backgroundColor: c as string, borderRadius: 2 }} />
228
- <Text className="text-text-secondary" style={{ fontSize: 10 }}>{l as string}</Text>
335
+ {[
336
+ ['1RM', SEM.brand],
337
+ ['vel', SEM.success],
338
+ ['miss', SEM.error],
339
+ ['RPE', SEM.warning],
340
+ ].map(([l, c]) => (
341
+ <View
342
+ key={l as string}
343
+ style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}
344
+ >
345
+ <View
346
+ style={{ width: 14, height: 3, backgroundColor: c as string, borderRadius: 2 }}
347
+ />
348
+ <Text className="text-text-secondary" style={{ fontSize: 10 }}>
349
+ {l as string}
350
+ </Text>
229
351
  </View>
230
352
  ))}
231
353
  </View>
@@ -234,28 +356,66 @@ export const Overview: StoryObj = {
234
356
  <Demo label="Treemap · categorical">
235
357
  <View style={{ flexDirection: 'row', flexWrap: 'wrap', width: 140, gap: 2 }}>
236
358
  {[46, 30, 30, 24, 24, 24, 20].map((h, i) => (
237
- <View key={i} style={{ height: h, flexGrow: 1, flexBasis: h > 40 ? 60 : 40, backgroundColor: getCategoricalColor(i), borderRadius: 3 }} />
359
+ <View
360
+ key={i}
361
+ style={{
362
+ height: h,
363
+ flexGrow: 1,
364
+ flexBasis: h > 40 ? 60 : 40,
365
+ backgroundColor: getCategoricalColor(i),
366
+ borderRadius: 3,
367
+ }}
368
+ />
238
369
  ))}
239
370
  </View>
240
371
  </Demo>
241
372
  {/* Spinner */}
242
373
  <Demo label="Spinner">
243
- <View style={{ width: 24, height: 24, borderRadius: 12, borderWidth: 3, borderColor: '#2C2C2C', borderTopColor: SEM.spinner }} />
374
+ <View
375
+ style={{
376
+ width: 24,
377
+ height: 24,
378
+ borderRadius: 12,
379
+ borderWidth: 3,
380
+ borderColor: '#2C2C2C',
381
+ borderTopColor: SEM.spinner,
382
+ }}
383
+ />
244
384
  </Demo>
245
385
  {/* IntensityBar — full effort scale */}
246
386
  <Demo label="IntensityBar · effort">
247
387
  <View style={{ flexDirection: 'row', gap: 2, width: 132 }}>
248
388
  {sequentialEffort.map((hex, i) => (
249
- <View key={i} style={{ flex: 1, height: 20, backgroundColor: hex, borderRadius: 2 }} />
389
+ <View
390
+ key={i}
391
+ style={{ flex: 1, height: 20, backgroundColor: hex, borderRadius: 2 }}
392
+ />
250
393
  ))}
251
394
  </View>
252
395
  </Demo>
253
396
  {/* VelocityStrip / RPE — effort sub-sample */}
254
397
  <Demo label="VelocityStrip · RPE">
255
398
  <View style={{ flexDirection: 'row', gap: 3 }}>
256
- {[sequentialEffort[0], sequentialEffort[2], sequentialEffort[3], sequentialEffort[5]].map((hex, i) => (
257
- <View key={i} style={{ width: 30, height: 26, borderRadius: 4, backgroundColor: hex, alignItems: 'center', justifyContent: 'center' }}>
258
- <Text style={{ color: bestTextColor(hex), fontSize: 9, fontWeight: '700' }}>{['S', 'M', 'F', 'X'][i]}</Text>
399
+ {[
400
+ sequentialEffort[0],
401
+ sequentialEffort[2],
402
+ sequentialEffort[3],
403
+ sequentialEffort[5],
404
+ ].map((hex, i) => (
405
+ <View
406
+ key={i}
407
+ style={{
408
+ width: 30,
409
+ height: 26,
410
+ borderRadius: 4,
411
+ backgroundColor: hex,
412
+ alignItems: 'center',
413
+ justifyContent: 'center',
414
+ }}
415
+ >
416
+ <Text style={{ color: bestTextColor(hex), fontSize: 9, fontWeight: '700' }}>
417
+ {['S', 'M', 'F', 'X'][i]}
418
+ </Text>
259
419
  </View>
260
420
  ))}
261
421
  </View>
@@ -267,8 +427,10 @@ export const Overview: StoryObj = {
267
427
 
268
428
  function Demo({ label, children }: { label: string; children: ReactNode }) {
269
429
  return (
270
- <View style={{ padding: 10, backgroundColor: '#1A1A1A', borderRadius: 8 }}>
271
- <Text className="text-text-secondary" style={{ fontSize: 9, marginBottom: 6 }}>{label}</Text>
430
+ <View style={{ padding: 10, backgroundColor: t['surface-raised'], borderRadius: 8 }}>
431
+ <Text className="text-text-secondary" style={{ fontSize: 9, marginBottom: 6 }}>
432
+ {label}
433
+ </Text>
272
434
  {children}
273
435
  </View>
274
436
  )
@@ -279,8 +441,9 @@ export const Accessibility: StoryObj = {
279
441
  <View style={{ padding: 24 }}>
280
442
  <Text className="text-2xl font-bold text-text-primary mb-1">Categorical Accessibility</Text>
281
443
  <Text className="text-text-secondary mb-5">
282
- In-place text contrast and colorblind simulation for the categorical series. Converging strips across
283
- two swatches signal a collision for that viewer; all pairs hold ΔE ≥ 8 through the first {CATEGORICAL_CVD_SAFE_MAX}.
444
+ In-place text contrast and colorblind simulation for the categorical series. Converging
445
+ strips across two swatches signal a collision for that viewer; all pairs hold ΔE ≥ 8 through
446
+ the first {CATEGORICAL_CVD_SAFE_MAX}.
284
447
  </Text>
285
448
 
286
449
  {(['default', 'dark'] as const).map((variant) => {
@@ -288,14 +451,26 @@ export const Accessibility: StoryObj = {
288
451
  const textColor = variant === 'dark' ? '#FFFFFF' : '#000000'
289
452
  return (
290
453
  <View key={variant} style={{ marginBottom: 22 }}>
291
- <Text className="text-lg font-bold text-text-primary mb-2" style={{ textTransform: 'capitalize' }}>
454
+ <Text
455
+ className="text-lg font-bold text-text-primary mb-2"
456
+ style={{ textTransform: 'capitalize' }}
457
+ >
292
458
  {variant}
293
459
  </Text>
294
460
  {/* swatches with in-place text + contrast ratio */}
295
461
  <View style={{ flexDirection: 'row', gap: 6, marginBottom: 6 }}>
296
462
  {colors.map((hex, i) => (
297
463
  <View key={i} style={{ alignItems: 'center' }}>
298
- <View style={{ width: 56, height: 40, borderRadius: 6, backgroundColor: hex, alignItems: 'center', justifyContent: 'center' }}>
464
+ <View
465
+ style={{
466
+ width: 56,
467
+ height: 40,
468
+ borderRadius: 6,
469
+ backgroundColor: hex,
470
+ alignItems: 'center',
471
+ justifyContent: 'center',
472
+ }}
473
+ >
299
474
  <Text style={{ color: textColor, fontSize: 11, fontWeight: '700' }}>Aa</Text>
300
475
  </View>
301
476
  <Text className="text-text-secondary" style={{ fontSize: 8, marginTop: 2 }}>
@@ -307,10 +482,20 @@ export const Accessibility: StoryObj = {
307
482
  {/* CVD simulation strips */}
308
483
  {(['deuteranopia', 'protanopia'] as const).map((mode) => (
309
484
  <View key={mode} style={{ flexDirection: 'row', alignItems: 'center', marginTop: 3 }}>
310
- <Text className="text-text-secondary" style={{ fontSize: 9, width: 96 }}>{mode}</Text>
485
+ <Text className="text-text-secondary" style={{ fontSize: 9, width: 96 }}>
486
+ {mode}
487
+ </Text>
311
488
  <View style={{ flexDirection: 'row', gap: 6 }}>
312
489
  {colors.map((hex, i) => (
313
- <View key={i} style={{ width: 56, height: 14, borderRadius: 3, backgroundColor: simulate(CVD[mode], hex) }} />
490
+ <View
491
+ key={i}
492
+ style={{
493
+ width: 56,
494
+ height: 14,
495
+ borderRadius: 3,
496
+ backgroundColor: simulate(CVD[mode], hex),
497
+ }}
498
+ />
314
499
  ))}
315
500
  </View>
316
501
  </View>
@@ -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
  })
@@ -13,11 +13,16 @@
13
13
  //
14
14
  // The var maps are the SAME canonical `darkThemeCSSVars` / `lightThemeCSSVars`
15
15
  // that generate `global.css`, so native and web resolve to identical values.
16
- import { vars } from 'nativewind'
16
+ //
17
+ // It also seeds the on-surface colour context (`<Surface>`'s mode), so titan's
18
+ // JS-resolved on-surface colours track the same theme as the className tokens.
19
+ import { vars, useColorScheme } from 'nativewind'
17
20
  import { View, type ViewProps } from 'react-native'
21
+ import { SurfaceContext } from '../components/ui/surface/SurfaceContext'
18
22
  import { darkThemeCSSVars, lightThemeCSSVars } from './config'
19
23
 
20
- export type ThemeProviderMode = 'dark' | 'light'
24
+ /** `'system'` follows nativewind's `useColorScheme()` (mobile light/dark flip). */
25
+ export type ThemeProviderMode = 'dark' | 'light' | 'system'
21
26
 
22
27
  // Precomputed once — `vars()` is pure, so the same style object is reused across
23
28
  // renders. Native == web because these are the maps `tokens-css` emits.
@@ -27,7 +32,11 @@ const MODE_VARS = {
27
32
  }
28
33
 
29
34
  export interface ThemeProviderProps extends ViewProps {
30
- /** Theme mode to register. Mobile is dark-only today; defaults to `dark`. */
35
+ /**
36
+ * Theme mode to register. `'system'` bridges nativewind's `useColorScheme()`
37
+ * so mobile follows the OS light/dark flip; `'dark'`/`'light'` pin it. Mobile
38
+ * is dark-only today, so this defaults to `dark`.
39
+ */
31
40
  mode?: ThemeProviderMode
32
41
  }
33
42
 
@@ -42,9 +51,17 @@ export function ThemeProvider({
42
51
  children,
43
52
  ...props
44
53
  }: ThemeProviderProps): React.JSX.Element {
54
+ // `'system'` → follow nativewind's applied scheme, falling back to dark when
55
+ // no scheme is set. `useColorScheme` runs unconditionally (rules of hooks);
56
+ // its result is only used in the `'system'` branch.
57
+ const { colorScheme } = useColorScheme()
58
+ const resolved = mode === 'system' ? (colorScheme ?? 'dark') : mode
59
+
45
60
  return (
46
- <View style={[MODE_VARS[mode], { flex: 1 }, style]} {...props}>
47
- {children}
48
- </View>
61
+ <SurfaceContext.Provider value={{ mode: resolved, level: 'base' }}>
62
+ <View style={[MODE_VARS[resolved], { flex: 1 }, style]} {...props}>
63
+ {children}
64
+ </View>
65
+ </SurfaceContext.Provider>
49
66
  )
50
67
  }
@@ -77,10 +77,12 @@ export const lightThemeCSSVars = {
77
77
  '--color-surface-base': semanticColorsLight['surface-base'],
78
78
  '--color-surface-elevated': semanticColorsLight['surface-elevated'],
79
79
  '--color-surface-raised': semanticColorsLight['surface-raised'],
80
+ '--color-surface-inset': semanticColorsLight['surface-inset'],
80
81
 
81
82
  '--color-background-base': semanticColorsLight['background-base'],
82
83
  '--color-background-default': semanticColorsLight['background-default'],
83
84
  '--color-background-subtle': semanticColorsLight['background-subtle'],
85
+ '--color-background-frame': semanticColorsLight['background-frame'],
84
86
 
85
87
  '--color-border-default': semanticColorsLight['border-default'],
86
88
  '--color-border-subtle': semanticColorsLight['border-subtle'],
@@ -150,6 +152,10 @@ export const lightThemeCSSVars = {
150
152
  '--color-border-input-focus': semanticColorsLight['border-input-focus'],
151
153
  '--color-border-input-error': semanticColorsLight['border-input-error'],
152
154
 
155
+ '--color-hairline-subtle': semanticColorsLight['hairline-subtle'],
156
+ '--color-hairline-default': semanticColorsLight['hairline-default'],
157
+ '--color-hairline-strong': semanticColorsLight['hairline-strong'],
158
+
153
159
  '--color-interactive-disabled-text': semanticColorsLight['interactive-disabled-text'],
154
160
 
155
161
  '--color-avatar-background': semanticColorsLight['avatar-background'],
@@ -198,10 +204,12 @@ export const darkThemeCSSVars = {
198
204
  '--color-surface-base': semanticColorsDark['surface-base'],
199
205
  '--color-surface-elevated': semanticColorsDark['surface-elevated'],
200
206
  '--color-surface-raised': semanticColorsDark['surface-raised'],
207
+ '--color-surface-inset': semanticColorsDark['surface-inset'],
201
208
 
202
209
  '--color-background-base': semanticColorsDark['background-base'],
203
210
  '--color-background-default': semanticColorsDark['background-default'],
204
211
  '--color-background-subtle': semanticColorsDark['background-subtle'],
212
+ '--color-background-frame': semanticColorsDark['background-frame'],
205
213
 
206
214
  '--color-border-default': semanticColorsDark['border-default'],
207
215
  '--color-border-subtle': semanticColorsDark['border-subtle'],
@@ -271,6 +279,10 @@ export const darkThemeCSSVars = {
271
279
  '--color-border-input-focus': semanticColorsDark['border-input-focus'],
272
280
  '--color-border-input-error': semanticColorsDark['border-input-error'],
273
281
 
282
+ '--color-hairline-subtle': semanticColorsDark['hairline-subtle'],
283
+ '--color-hairline-default': semanticColorsDark['hairline-default'],
284
+ '--color-hairline-strong': semanticColorsDark['hairline-strong'],
285
+
274
286
  '--color-interactive-disabled-text': semanticColorsDark['interactive-disabled-text'],
275
287
 
276
288
  '--color-avatar-background': semanticColorsDark['avatar-background'],