@titan-design/react-ui 0.10.0 → 0.11.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 (84) hide show
  1. package/dist/index.d.mts +399 -4
  2. package/dist/index.d.ts +399 -4
  3. package/dist/index.js +2295 -895
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +2279 -905
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/{pages-DyEx-lBf.d.ts → pages-B1hqnGIC.d.ts} +149 -15
  8. package/dist/{pages-D7hOD4Vj.d.mts → pages-CvsFqNtx.d.mts} +149 -15
  9. package/dist/pages.d.mts +1 -1
  10. package/dist/pages.d.ts +1 -1
  11. package/dist/pages.js +1974 -1740
  12. package/dist/pages.js.map +1 -1
  13. package/dist/pages.mjs +1975 -1741
  14. package/dist/pages.mjs.map +1 -1
  15. package/package.json +8 -3
  16. package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
  17. package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
  18. package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
  19. package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
  20. package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
  21. package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
  22. package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
  23. package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
  24. package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
  25. package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
  26. package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
  27. package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
  28. package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
  29. package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
  30. package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
  31. package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
  32. package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
  33. package/src/components/custom/Fatigue/README.md +93 -0
  34. package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
  35. package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
  36. package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
  37. package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
  38. package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
  39. package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
  40. package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
  41. package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
  42. package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
  43. package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
  44. package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
  45. package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
  46. package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
  47. package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
  48. package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
  49. package/src/components/custom/Fatigue/index.ts +41 -0
  50. package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
  51. package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
  52. package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
  53. package/src/components/custom/Workout/ExerciseCard.test.tsx +4 -4
  54. package/src/components/custom/Workout/ExerciseCard.tsx +3 -1
  55. package/src/components/custom/Workout/ExerciseDetailPage.tsx +7 -1
  56. package/src/components/custom/Workout/README.md +62 -16
  57. package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
  58. package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
  59. package/src/components/custom/Workout/SessionRail.tsx +50 -13
  60. package/src/components/custom/Workout/SetRow.test.tsx +6 -6
  61. package/src/components/custom/Workout/SetRow.tsx +9 -2
  62. package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
  63. package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
  64. package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
  65. package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
  66. package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
  67. package/src/components/custom/Workout/VelocityStrip.test.tsx +196 -79
  68. package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
  69. package/src/components/custom/Workout/index.ts +3 -0
  70. package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
  71. package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
  72. package/src/components/custom/charts/SetBarChart.tsx +651 -0
  73. package/src/components/custom/charts/live-rep-growth.ts +91 -0
  74. package/src/components/custom/index.ts +1 -0
  75. package/src/test/raw-color-patterns.test.ts +71 -0
  76. package/src/theme/ColorPalettes.stories.tsx +871 -0
  77. package/src/theme/ColorPrimitives.stories.tsx +300 -0
  78. package/src/theme/bar-paper.ts +59 -0
  79. package/src/theme/color-stories.coverage.test.ts +130 -0
  80. package/src/theme/color-story-kit.tsx +170 -0
  81. package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
  82. package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
  83. package/src/theme/ColorSystem.stories.tsx +0 -508
  84. package/src/theme/Colors.stories.tsx +0 -323
@@ -0,0 +1,871 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View, Text } from 'react-native'
3
+ import {
4
+ primitiveColors,
5
+ surfaceRampDark,
6
+ backgroundFrameDark,
7
+ categoricalPalette,
8
+ getCategoricalColor,
9
+ CATEGORICAL_CVD_SAFE_MAX,
10
+ divergingScale,
11
+ sequentialEffort,
12
+ discreteRainbow,
13
+ bestTextColor,
14
+ } from './tokens/primitives'
15
+ import { semanticColorsLight, semanticColorsDark } from './tokens/semantic'
16
+ import { WORKOUT_TOKENS } from './workout-tokens'
17
+ import { WORKOUT_PILL_DELOAD } from './extracted-colors-dataviz'
18
+ import { SPINNER_PRIMARY } from './extracted-colors-ui'
19
+ import {
20
+ SILVER,
21
+ RED_LIGHT,
22
+ RED_MID,
23
+ RED_DEEP,
24
+ DRIFT_GREY,
25
+ GRIND_THRESHOLD,
26
+ ghostLineColor,
27
+ } from '../components/custom/Fatigue/fatigue-tokens'
28
+ import { SWATCH_BORDER, SectionIntro, SectionTitle, ColorSwatch, Demo } from './color-story-kit'
29
+
30
+ /**
31
+ * Foundations/Color/Palettes — the organisations of the primitives into meaning.
32
+ *
33
+ * The raw scales and their accessibility validation live in the sibling
34
+ * `Foundations/Color/Primitives`. Everything here is an ASSIGNMENT: a depth
35
+ * ladder, a qualitative series, an ordered magnitude scale, a semantic role.
36
+ * Ordered so the assignments that the most surfaces depend on come first.
37
+ *
38
+ * `color-stories.coverage.test.ts` reads this file as text (together with the
39
+ * Primitives story) and fails if a semantic token ships without a swatch, or
40
+ * if a surface token lands off the ramp.
41
+ */
42
+ const meta: Meta = {
43
+ title: 'Foundations/Color/Palettes',
44
+ tags: ['autodocs'],
45
+ }
46
+
47
+ export default meta
48
+
49
+ // ============================================================================
50
+ // 1. Surface ramp
51
+ // ============================================================================
52
+
53
+ /**
54
+ * The dark surface ramp, darkest plane first.
55
+ *
56
+ * Order and `lStar` mirror `surfaceRampDark` in tokens/primitives.ts, plus the
57
+ * out-of-ramp `backgroundFrameDark` bezel that sits below it. Written as an
58
+ * explicit ordered list because object key order is not a contract and depth
59
+ * order is the whole point of this story. `color-stories.coverage.test.ts`
60
+ * asserts every ramp step appears here.
61
+ */
62
+ const SURFACE_PLANES = [
63
+ { hex: backgroundFrameDark, lStar: 3.79, role: 'Frame / bezel — chrome outside the ramp' },
64
+ { hex: surfaceRampDark.inset, lStar: 4.5, role: 'Sub-shell well / pressed' },
65
+ { hex: surfaceRampDark.background, lStar: 9, role: 'Shell' },
66
+ { hex: surfaceRampDark.base, lStar: 13.5, role: 'Main content plane' },
67
+ { hex: surfaceRampDark.elevated, lStar: 17, role: 'Nav / rail' },
68
+ { hex: surfaceRampDark.raised, lStar: 20, role: 'Cards' },
69
+ { hex: surfaceRampDark.overlay, lStar: 22.5, role: 'Hero / popover' },
70
+ ] as const
71
+
72
+ /** Semantic tokens resolving to `hex`, derived at render so the mapping can't drift. */
73
+ function tokensResolvingTo(hex: string): string[] {
74
+ return Object.entries(semanticColorsDark)
75
+ .filter(([name, value]) => /^(surface|background)-/.test(name) && value === hex)
76
+ .map(([name]) => name)
77
+ .sort()
78
+ }
79
+
80
+ function SurfacePlaneRow({ hex, lStar, role }: { hex: string; lStar: number; role: string }) {
81
+ const tokens = tokensResolvingTo(hex)
82
+ return (
83
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
84
+ <View
85
+ style={{
86
+ width: 96,
87
+ height: 56,
88
+ borderRadius: 8,
89
+ backgroundColor: hex,
90
+ borderWidth: 1,
91
+ borderColor: SWATCH_BORDER,
92
+ }}
93
+ />
94
+ <View style={{ flex: 1 }}>
95
+ <Text className="font-semibold text-text-primary text-sm">
96
+ {hex.toUpperCase()}{' '}
97
+ <Text className="text-text-tertiary text-xs font-normal">L*{lStar}</Text>
98
+ </Text>
99
+ <Text className="text-text-secondary text-xs">{role}</Text>
100
+ <Text className="text-text-tertiary text-xs mt-1">
101
+ {tokens.length > 0 ? tokens.join(' · ') : '— no semantic alias'}
102
+ </Text>
103
+ </View>
104
+ </View>
105
+ )
106
+ }
107
+
108
+ export const SurfaceRamp: StoryObj = {
109
+ name: '1. Surface Ramp (Dark)',
110
+ render: () => (
111
+ <View style={{ padding: 24 }}>
112
+ <Text className="text-2xl font-bold text-text-primary mb-2">Surface Ramp (Dark Mode)</Text>
113
+ <SectionIntro>
114
+ The v0.10.0 surface foundation, and the assignment the most surfaces depend on. Depth is
115
+ an <Text className="font-semibold">ordered ramp</Text>, not a set of unrelated fills —
116
+ planes are spaced by perceptual lightness (L*) on a diminishing taper, so &quot;one step
117
+ up&quot; is the same visual distance anywhere in the stack. Listed darkest first. Several
118
+ semantic tokens deliberately share a plane (shown per row); that aliasing is what lets a
119
+ component say what it <Text className="italic">is</Text> rather than how deep it sits.
120
+ </SectionIntro>
121
+
122
+ <View style={{ gap: 14 }}>
123
+ {SURFACE_PLANES.map((plane) => (
124
+ <SurfacePlaneRow key={plane.hex} {...plane} />
125
+ ))}
126
+ </View>
127
+
128
+ <Text className="text-text-secondary text-xs mt-8">
129
+ This ramp is derived, not hand-picked, which is why it sits here rather than with the raw
130
+ scales — the greys it is built from are in Foundations/Color/Primitives. To apply it at
131
+ runtime use <Text className="font-semibold text-text-primary">&lt;Surface level&gt;</Text>{' '}
132
+ and <Text className="font-semibold text-text-primary">useOnSurfaceColor</Text> rather than
133
+ reading tokens directly — see Components/Atoms/Surface. Shadow and glow treatments layered
134
+ on top of these planes are in Foundations/Shadows; the legacy numeric elevation scale is
135
+ in Foundations/Elevation.
136
+ </Text>
137
+ </View>
138
+ ),
139
+ }
140
+
141
+ // ============================================================================
142
+ // 2. Categorical palette
143
+ // ============================================================================
144
+
145
+ function CategoricalRow({ name, colors }: { name: string; colors: readonly string[] }) {
146
+ return (
147
+ <View style={{ marginBottom: 24 }}>
148
+ <Text className="text-lg font-bold text-text-primary mb-3">
149
+ {name} <Text className="text-text-secondary text-sm">({colors.length})</Text>
150
+ </Text>
151
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
152
+ {colors.map((color, index) => (
153
+ <View key={index} style={{ alignItems: 'center' }}>
154
+ <View
155
+ style={{
156
+ width: 56,
157
+ height: 56,
158
+ borderRadius: 8,
159
+ backgroundColor: color,
160
+ borderWidth: 1,
161
+ borderColor: SWATCH_BORDER,
162
+ alignItems: 'center',
163
+ justifyContent: 'center',
164
+ }}
165
+ >
166
+ <Text style={{ color: bestTextColor(color), fontSize: 12, fontWeight: '700' }}>
167
+ {index + 1}
168
+ </Text>
169
+ </View>
170
+ <Text className="text-text-secondary text-xs mt-1">{color.toUpperCase()}</Text>
171
+ <Text className="text-text-tertiary" style={{ fontSize: 9 }}>
172
+ {index < CATEGORICAL_CVD_SAFE_MAX ? 'CVD-safe' : 'extended'}
173
+ </Text>
174
+ </View>
175
+ ))}
176
+ </View>
177
+ </View>
178
+ )
179
+ }
180
+
181
+ export const CategoricalPalette: StoryObj = {
182
+ name: '2. Categorical Palette',
183
+ render: () => (
184
+ <View style={{ padding: 24 }}>
185
+ <Text className="text-2xl font-bold text-text-primary mb-2">Categorical Palette</Text>
186
+ <SectionIntro>
187
+ Seven hues in one canonical order —{' '}
188
+ <Text className="font-semibold">blue → magenta → red → orange → green → cyan → amber</Text>{' '}
189
+ — expressed as references into the tonal ramps. The sequence is nested-stable: a chart
190
+ with N series takes the first N colors and a series&apos; index does not shift as N
191
+ changes. The worst-case deuteranopia/protanopia ΔE floor of 8 holds through the first{' '}
192
+ {CATEGORICAL_CVD_SAFE_MAX} (
193
+ <Text className="font-semibold">CATEGORICAL_CVD_SAFE_MAX</Text>); the 7th is extended and
194
+ wants a legend or a second encoding. Two variants ship:{' '}
195
+ <Text className="font-semibold">default</Text> (vivid, for neutral/light surfaces, legible
196
+ under black text) and <Text className="font-semibold">dark</Text> (deeper, for white text
197
+ on a filled swatch). A third &quot;light&quot; variant was dropped — `default` doubles as
198
+ it.
199
+ </SectionIntro>
200
+
201
+ <CategoricalRow name="Default (black text)" colors={categoricalPalette.default} />
202
+ <CategoricalRow name="Dark (white text)" colors={categoricalPalette.dark} />
203
+
204
+ <Text className="text-text-secondary text-xs">
205
+ Read a series color with{' '}
206
+ <Text className="font-semibold text-text-primary">getCategoricalColor(index, variant)</Text>{' '}
207
+ rather than indexing the array — it wraps past the end. The contrast and colorblind
208
+ validation for this palette is in Foundations/Color/Primitives → Accessibility.
209
+ </Text>
210
+ </View>
211
+ ),
212
+ }
213
+
214
+ // ============================================================================
215
+ // 3. Diverging scale
216
+ // ============================================================================
217
+
218
+ const DIVERGING_LABELS = ['under', 'maintenance', 'optimal', 'approaching', 'over'] as const
219
+
220
+ export const DivergingScale: StoryObj = {
221
+ name: '3. Diverging Scale — Training Status',
222
+ render: () => (
223
+ <View style={{ padding: 24 }}>
224
+ <Text className="text-2xl font-bold text-text-primary mb-2">Diverging Scale</Text>
225
+ <SectionIntro>
226
+ The BodyMap training-status scale (under → optimal → over), as ramp references. A true
227
+ diverging shape — `optimal` is the lightest center and the extremes go darker — so the
228
+ signal reads in <Text className="font-semibold">lightness</Text> (colorblind-robust,
229
+ worst-case ΔE ≈ 10.9) and the direction reads on the blue↔red axis, the CVD-safe hue pair.
230
+ Every stop clears 4.5:1 under black text, so there is no per-cell white/black flipping.
231
+ </SectionIntro>
232
+
233
+ <View style={{ flexDirection: 'row', gap: 6, marginBottom: 12 }}>
234
+ {DIVERGING_LABELS.map((label, i) => (
235
+ <View
236
+ key={label}
237
+ style={{
238
+ flex: 1,
239
+ height: 56,
240
+ borderRadius: 6,
241
+ backgroundColor: divergingScale[i],
242
+ alignItems: 'center',
243
+ justifyContent: 'center',
244
+ }}
245
+ >
246
+ <Text
247
+ style={{ color: bestTextColor(divergingScale[i]), fontSize: 11, fontWeight: '700' }}
248
+ >
249
+ {label}
250
+ </Text>
251
+ <Text style={{ color: bestTextColor(divergingScale[i]), fontSize: 9 }}>
252
+ {divergingScale[i].toUpperCase()}
253
+ </Text>
254
+ </View>
255
+ ))}
256
+ </View>
257
+ </View>
258
+ ),
259
+ }
260
+
261
+ // ============================================================================
262
+ // 4. Sequential effort scale
263
+ // ============================================================================
264
+
265
+ export const SequentialEffortScale: StoryObj = {
266
+ name: '4. Sequential Effort Scale',
267
+ render: () => (
268
+ <View style={{ padding: 24 }}>
269
+ <Text className="text-2xl font-bold text-text-primary mb-2">Sequential Effort Scale</Text>
270
+ <SectionIntro>
271
+ Ordinal low → high intensity (green → amber → orange → red), as ramp references.
272
+ IntensityBar uses all six; VelocityStrip/RPE sub-samples it. Adjacent pairs clear a
273
+ deut/protan ΔE ≥ 4.5 while the hue walk stays smooth — the amber-200 → amber-300 →
274
+ orange-400 run steps the yellow→orange transition gradually rather than lurching. Order
275
+ encodes magnitude, which is what lets it tolerate the green↔red CVD pair.
276
+ </SectionIntro>
277
+
278
+ <View style={{ flexDirection: 'row', gap: 4, marginBottom: 8 }}>
279
+ {sequentialEffort.map((hex, i) => (
280
+ <View
281
+ key={i}
282
+ style={{
283
+ flex: 1,
284
+ height: 48,
285
+ borderRadius: 5,
286
+ backgroundColor: hex,
287
+ alignItems: 'center',
288
+ justifyContent: 'center',
289
+ }}
290
+ >
291
+ <Text style={{ color: bestTextColor(hex), fontSize: 11, fontWeight: '700' }}>
292
+ {i + 1}
293
+ </Text>
294
+ <Text style={{ color: bestTextColor(hex), fontSize: 9 }}>{hex.toUpperCase()}</Text>
295
+ </View>
296
+ ))}
297
+ </View>
298
+
299
+ <SectionTitle>Production 4-band sub-sample (VelocityStrip / RPE)</SectionTitle>
300
+ <View style={{ flexDirection: 'row', gap: 6 }}>
301
+ {Object.entries(WORKOUT_TOKENS.scale).map(([band, hex]) => (
302
+ <View key={band} style={{ alignItems: 'center' }}>
303
+ <View
304
+ style={{
305
+ width: 78,
306
+ height: 40,
307
+ borderRadius: 5,
308
+ backgroundColor: hex,
309
+ alignItems: 'center',
310
+ justifyContent: 'center',
311
+ }}
312
+ >
313
+ <Text style={{ color: bestTextColor(hex), fontSize: 10, fontWeight: '700' }}>
314
+ {band}
315
+ </Text>
316
+ </View>
317
+ </View>
318
+ ))}
319
+ </View>
320
+ </View>
321
+ ),
322
+ }
323
+
324
+ // ============================================================================
325
+ // 5. Fatigue / ROM palette — silver → red
326
+ // ============================================================================
327
+
328
+ /**
329
+ * Sourced from the SHIPPED `fatigue-tokens.ts` exports, not re-derived here: the
330
+ * ROM chart and the ghost-spark line share one scheme, and a story that copied
331
+ * the values would be free to drift from the components it documents.
332
+ */
333
+ const FATIGUE_SWATCHES = [
334
+ { name: 'SILVER — neutral[300]', value: SILVER, role: 'on-track / at-or-above working range' },
335
+ { name: 'DRIFT_GREY — neutral[600]', value: DRIFT_GREY, role: 'controlled but drifting' },
336
+ { name: 'RED_LIGHT — red[400]', value: RED_LIGHT, role: 'below working range' },
337
+ { name: 'RED_MID — red[600]', value: RED_MID, role: 'collapsing' },
338
+ { name: 'RED_DEEP — red[800]', value: RED_DEEP, role: 'below the short threshold' },
339
+ ] as const
340
+
341
+ /** The real `ghostLineColor()` output sampled across the two control signals. */
342
+ const GHOST_CONTROLLED = [0, 0.25, 0.5, 0.75, 1].map((deviation) => ({
343
+ label: `drift ${deviation.toFixed(2)}`,
344
+ hex: ghostLineColor(deviation, 0),
345
+ }))
346
+ const GHOST_COLLAPSING = [0.35, 0.5, 0.65, 0.8, 1].map((grind) => ({
347
+ label: `grind ${grind.toFixed(2)}`,
348
+ hex: ghostLineColor(0, grind),
349
+ }))
350
+
351
+ function FatigueRampStrip({
352
+ title,
353
+ stops,
354
+ }: {
355
+ title: string
356
+ stops: Array<{ label: string; hex: string }>
357
+ }) {
358
+ return (
359
+ <View style={{ flex: 1 }}>
360
+ <Text className="text-text-secondary text-xs mb-2">{title}</Text>
361
+ <View style={{ flexDirection: 'row', gap: 4 }}>
362
+ {stops.map(({ label, hex }) => (
363
+ <View key={label} style={{ alignItems: 'center' }}>
364
+ <View
365
+ style={{
366
+ width: 64,
367
+ height: 40,
368
+ borderRadius: 6,
369
+ backgroundColor: hex,
370
+ borderWidth: 1,
371
+ borderColor: SWATCH_BORDER,
372
+ }}
373
+ />
374
+ <Text className="text-text-tertiary" style={{ fontSize: 8, marginTop: 2 }}>
375
+ {label}
376
+ </Text>
377
+ </View>
378
+ ))}
379
+ </View>
380
+ </View>
381
+ )
382
+ }
383
+
384
+ export const FatigueRomPalette: StoryObj = {
385
+ name: '5. Fatigue / ROM Palette — Silver → Red',
386
+ render: () => (
387
+ <View style={{ padding: 24 }}>
388
+ <Text className="text-2xl font-bold text-text-primary mb-2">Fatigue / ROM Palette</Text>
389
+ <SectionIntro>
390
+ <Text className="font-semibold">
391
+ Silver when the rep is right, shades of red when there is an issue.
392
+ </Text>{' '}
393
+ One scheme shared by the two live-quality readouts — the ROM progression bars and the
394
+ ghost-spark current line. Deliberately no greens and no ambers: those languages belong to
395
+ the verdict tones and the velocity-loss bands (stories 3 and 4), and reusing them here
396
+ would make &quot;fine&quot; and &quot;fast&quot; look like the same claim. Every stop is a
397
+ reference into the tonal ramps.
398
+ </SectionIntro>
399
+
400
+ <View style={{ gap: 14, marginBottom: 28 }}>
401
+ {FATIGUE_SWATCHES.map(({ name, value, role }) => (
402
+ <View key={name} style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
403
+ <View
404
+ style={{
405
+ width: 48,
406
+ height: 48,
407
+ borderRadius: 8,
408
+ backgroundColor: value,
409
+ borderWidth: 1,
410
+ borderColor: SWATCH_BORDER,
411
+ }}
412
+ />
413
+ <View>
414
+ <Text className="font-semibold text-text-primary text-sm">{name}</Text>
415
+ <Text className="text-text-secondary text-xs">
416
+ {value.toUpperCase()} · {role}
417
+ </Text>
418
+ </View>
419
+ </View>
420
+ ))}
421
+ </View>
422
+
423
+ <SectionTitle>ghostLineColor() — the shipped mix, sampled</SectionTitle>
424
+ <Text className="text-text-secondary text-xs mb-3">
425
+ A controlled rep (grind signature below GRIND_THRESHOLD = {GRIND_THRESHOLD}) stays in the
426
+ silver family, dimming toward DRIFT_GREY with tempo deviation — a &quot;drifting but not
427
+ failing&quot; cue that never becomes a color. At or above the threshold the line runs
428
+ light → mid → deep by severity.
429
+ </Text>
430
+ <View style={{ flexDirection: 'row', gap: 32 }}>
431
+ <FatigueRampStrip
432
+ title="controlled — silver dimming toward drift"
433
+ stops={GHOST_CONTROLLED}
434
+ />
435
+ <FatigueRampStrip title="collapsing — light → mid → deep" stops={GHOST_COLLAPSING} />
436
+ </View>
437
+
438
+ <Text className="text-text-secondary text-xs mt-8">
439
+ <Text className="font-semibold text-text-primary">Open question (TD-07.14):</Text>{' '}
440
+ DRIFT_GREY is `neutral[600]` #4B5563, and the unified warm grey ramp assigns it no
441
+ destination — the 15-row fold maps the other greys but skips this one. It needs an
442
+ assignment before that migration lands. Note that the recovered spec records DRIFT_GREY as
443
+ `charcoal[500]` #1C1C1C; that is stale, `fatigue-tokens.ts` on main reads `neutral[600]`,
444
+ which puts it in the expensive half of the fold rather than the cheap one.
445
+ </Text>
446
+ </View>
447
+ ),
448
+ }
449
+
450
+ // ============================================================================
451
+ // 6–9. Semantic roles
452
+ // ============================================================================
453
+
454
+ /**
455
+ * Modifier suffixes a semantic token family can carry.
456
+ *
457
+ * Rendered as a matrix rather than a flat swatch list: the useful question is
458
+ * almost always "what is the subtle/dark form of THIS role", which a flat list
459
+ * buries. Columns are derived from the shipped tokens, so a new modifier shows
460
+ * up here without editing the story.
461
+ */
462
+ const VARIANT_SUFFIXES = ['light', 'dark', 'subtle', 'hover', 'active', 'muted'] as const
463
+
464
+ function variantsOf(base: string, palette: Record<string, string>) {
465
+ return [
466
+ { suffix: 'base', name: base },
467
+ ...VARIANT_SUFFIXES.map((s) => ({ suffix: s, name: `${base}-${s}` })),
468
+ ].filter(({ name }) => name in palette)
469
+ }
470
+
471
+ function VariantMatrix({
472
+ bases,
473
+ palette,
474
+ }: {
475
+ bases: readonly string[]
476
+ palette: Record<string, string>
477
+ }) {
478
+ return (
479
+ <View style={{ gap: 20 }}>
480
+ {bases.map((base) => (
481
+ <View key={base}>
482
+ <Text className="font-semibold text-text-primary text-sm mb-2">{base}</Text>
483
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10 }}>
484
+ {variantsOf(base, palette).map(({ suffix, name }) => (
485
+ <View key={name} style={{ alignItems: 'center', width: 76 }}>
486
+ <View
487
+ style={{
488
+ width: 76,
489
+ height: 44,
490
+ borderRadius: 6,
491
+ backgroundColor: palette[name],
492
+ borderWidth: 1,
493
+ borderColor: SWATCH_BORDER,
494
+ }}
495
+ />
496
+ <Text className="text-text-secondary text-xs mt-1">{suffix}</Text>
497
+ <Text className="text-text-tertiary text-xs">{palette[name].toUpperCase()}</Text>
498
+ </View>
499
+ ))}
500
+ </View>
501
+ </View>
502
+ ))}
503
+ </View>
504
+ )
505
+ }
506
+
507
+ export const StatusColors: StoryObj = {
508
+ name: '6. Status Colors',
509
+ render: () => (
510
+ <View style={{ padding: 24 }}>
511
+ <Text className="text-2xl font-bold text-text-primary mb-2">Status Colors</Text>
512
+ <SectionIntro>
513
+ Status roles and their modifiers. <Text className="font-semibold">status-error-vivid</Text>{' '}
514
+ is a separate, higher-chroma role (not a modifier of status-error) reserved for
515
+ destructive emphasis. It is the one status family still sourced from the redVivid support
516
+ scale rather than the OKLCH ramps, and it is on its way out: under TD-07.14 redVivid
517
+ retires, status-error-vivid collapses onto status-error&apos;s red[600], and the
518
+ Indicator&apos;s emphasis moves to a glow derived from red[500].
519
+ </SectionIntro>
520
+ <VariantMatrix
521
+ bases={[
522
+ 'status-success',
523
+ 'status-error',
524
+ 'status-error-vivid',
525
+ 'status-warning',
526
+ 'status-info',
527
+ 'status-live',
528
+ ]}
529
+ palette={semanticColorsLight}
530
+ />
531
+ </View>
532
+ ),
533
+ }
534
+
535
+ export const BrandColors: StoryObj = {
536
+ name: '7. Brand Colors',
537
+ render: () => (
538
+ <View style={{ padding: 24 }}>
539
+ <Text className="text-2xl font-bold text-text-primary mb-2">Brand Colors</Text>
540
+ <SectionIntro>
541
+ Each brand role and its modifiers. <Text className="font-semibold">hover</Text> and{' '}
542
+ <Text className="font-semibold">active</Text> are interaction states —{' '}
543
+ <Text className="font-semibold">subtle</Text> is a tint for fills behind text.
544
+ </SectionIntro>
545
+ <VariantMatrix bases={['brand-primary', 'brand-secondary']} palette={semanticColorsLight} />
546
+ </View>
547
+ ),
548
+ }
549
+
550
+ export const ResultColors: StoryObj = {
551
+ name: '8. Result / Outcome Colors',
552
+ render: () => (
553
+ <View style={{ padding: 24 }}>
554
+ <Text className="text-2xl font-bold text-text-primary mb-2">Result / Outcome Colors</Text>
555
+ <SectionIntro>
556
+ Colors for indicating positive, negative, or neutral outcomes. Distinct from status: these
557
+ encode the <Text className="italic">direction of a change</Text> (did this get better?),
558
+ not a system state.
559
+ </SectionIntro>
560
+ <VariantMatrix
561
+ bases={['result-improve', 'result-degrade', 'result-inconclusive', 'result-neutral']}
562
+ palette={semanticColorsLight}
563
+ />
564
+ </View>
565
+ ),
566
+ }
567
+
568
+ export const TextAndBorderColors: StoryObj = {
569
+ name: '9. Text & Border Colors (Dark)',
570
+ render: () => (
571
+ <View style={{ padding: 24 }}>
572
+ <Text className="text-2xl font-bold text-text-primary mb-2">Text & Border Colors</Text>
573
+ <SectionIntro>
574
+ The dark-mode text and border roles. Today the border family is the charcoal grey ramp
575
+ assigned to line-work, and text-tertiary and the link colors come from the neutral and
576
+ blue support scales — all three of which are pending migration (TD-07.14): the greys fold
577
+ into one warm grey ramp and the links move to the OKLCH blue. These are the roles that
578
+ will move most, because the mid-grey text steps shift furthest. Prefer{' '}
579
+ <Text className="font-semibold">useOnSurfaceColor</Text> for text sitting on a{' '}
580
+ <Text className="font-semibold">&lt;Surface&gt;</Text> — it picks the right text role for
581
+ the plane instead of hardcoding one, which is also what makes the migration survivable.
582
+ </SectionIntro>
583
+
584
+ <SectionTitle>Text</SectionTitle>
585
+ <View style={{ gap: 16, marginBottom: 28 }}>
586
+ <ColorSwatch name="text-primary" value={semanticColorsDark['text-primary']} />
587
+ <ColorSwatch name="text-secondary" value={semanticColorsDark['text-secondary']} />
588
+ <ColorSwatch name="text-tertiary" value={semanticColorsDark['text-tertiary']} />
589
+ <ColorSwatch name="text-disabled" value={semanticColorsDark['text-disabled']} />
590
+ <ColorSwatch name="text-link" value={semanticColorsDark['text-link']} />
591
+ <ColorSwatch name="text-link-hover" value={semanticColorsDark['text-link-hover']} />
592
+ <ColorSwatch name="text-inverse" value={semanticColorsDark['text-inverse']} />
593
+ </View>
594
+
595
+ <SectionTitle>Border</SectionTitle>
596
+ <View style={{ gap: 16 }}>
597
+ <ColorSwatch name="border-default" value={semanticColorsDark['border-default']} />
598
+ <ColorSwatch name="border-subtle" value={semanticColorsDark['border-subtle']} />
599
+ <ColorSwatch name="border-strong" value={semanticColorsDark['border-strong']} />
600
+ <ColorSwatch name="border-prominent" value={semanticColorsDark['border-prominent']} />
601
+ <ColorSwatch name="border-focus" value={semanticColorsDark['border-focus']} />
602
+ <ColorSwatch name="border-input" value={semanticColorsDark['border-input']} />
603
+ <ColorSwatch name="border-input-hover" value={semanticColorsDark['border-input-hover']} />
604
+ <ColorSwatch name="border-input-focus" value={semanticColorsDark['border-input-focus']} />
605
+ <ColorSwatch name="border-input-error" value={semanticColorsDark['border-input-error']} />
606
+ <ColorSwatch name="divider" value={semanticColorsDark['divider']} />
607
+ </View>
608
+ </View>
609
+ ),
610
+ }
611
+
612
+ // ============================================================================
613
+ // 10. Roles in use
614
+ // ============================================================================
615
+
616
+ // Sourced from the real semantic-token layer (not re-derived) so the story can't
617
+ // silently desync if semantic.ts changes. deload/spinner come from the same
618
+ // extracted-colors modules the components consume.
619
+ const SEM = {
620
+ success: semanticColorsDark['status-success'],
621
+ warning: semanticColorsDark['status-warning'],
622
+ error: semanticColorsDark['status-error'],
623
+ info: semanticColorsDark['status-info'],
624
+ deload: WORKOUT_PILL_DELOAD,
625
+ brand: semanticColorsDark['brand-primary'],
626
+ brandDark: semanticColorsDark['brand-primary-dark'],
627
+ brandSecondary: semanticColorsDark['brand-secondary'],
628
+ spinner: SPINNER_PRIMARY,
629
+ neutral: primitiveColors.neutral[500],
630
+ } as const
631
+
632
+ export const RolesInUse: StoryObj = {
633
+ name: '10. Roles in Use — Component Examples',
634
+ render: () => (
635
+ <View style={{ padding: 24 }}>
636
+ <Text className="text-2xl font-bold text-text-primary mb-2">Roles in Use</Text>
637
+ <SectionIntro>
638
+ The same palettes as they land in shipped components — which palette a surface reaches for
639
+ is as much of the system as the values themselves.
640
+ </SectionIntro>
641
+
642
+ <View style={{ flexDirection: 'row', gap: 20, flexWrap: 'wrap', alignItems: 'flex-start' }}>
643
+ {/* BodyMap muscle heatmap — training-status diverging */}
644
+ <Demo label="BodyMap · training status (diverging)">
645
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', width: 132, gap: 4 }}>
646
+ {(
647
+ [
648
+ ['Chest', 4],
649
+ ['Quads', 2],
650
+ ['Back', 3],
651
+ ['Delts', 0],
652
+ ['Biceps', 4],
653
+ ['Calves', 1],
654
+ ] as const
655
+ ).map(([muscle, idx]) => (
656
+ <View
657
+ key={muscle}
658
+ style={{
659
+ width: 40,
660
+ backgroundColor: divergingScale[idx],
661
+ borderRadius: 5,
662
+ paddingVertical: 6,
663
+ }}
664
+ >
665
+ <Text
666
+ style={{
667
+ color: bestTextColor(divergingScale[idx]),
668
+ fontSize: 8,
669
+ fontWeight: '700',
670
+ textAlign: 'center',
671
+ }}
672
+ >
673
+ {muscle}
674
+ </Text>
675
+ </View>
676
+ ))}
677
+ </View>
678
+ </Demo>
679
+ {/* StatusDot — semantic */}
680
+ <Demo label="StatusDot · semantic">
681
+ <View style={{ gap: 6 }}>
682
+ {(
683
+ [
684
+ ['ok', SEM.success],
685
+ ['warn', SEM.warning],
686
+ ['err', SEM.error],
687
+ ['info', SEM.info],
688
+ ['neutral', SEM.neutral],
689
+ ] as const
690
+ ).map(([label, color]) => (
691
+ <View key={label} style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
692
+ <View style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: color }} />
693
+ <Text className="text-text-secondary" style={{ fontSize: 11 }}>
694
+ {label}
695
+ </Text>
696
+ </View>
697
+ ))}
698
+ </View>
699
+ </Demo>
700
+ {/* WorkoutPill — status */}
701
+ <Demo label="WorkoutPill · status">
702
+ <View style={{ flexDirection: 'row', gap: 6, flexWrap: 'wrap', maxWidth: 220 }}>
703
+ {(
704
+ [
705
+ ['Done', SEM.success],
706
+ ['Now', SEM.brand],
707
+ ['Next', SEM.neutral],
708
+ ['Deload', SEM.deload],
709
+ ] as const
710
+ ).map(([label, color]) => (
711
+ <View
712
+ key={label}
713
+ style={{
714
+ borderRadius: 99,
715
+ borderWidth: 1,
716
+ borderColor: color,
717
+ paddingHorizontal: 10,
718
+ paddingVertical: 3,
719
+ }}
720
+ >
721
+ <Text style={{ color, fontSize: 11, fontWeight: '700' }}>{label}</Text>
722
+ </View>
723
+ ))}
724
+ </View>
725
+ </Demo>
726
+ {/* StrengthTrend legend — series → semantic */}
727
+ <Demo label="StrengthTrend · series">
728
+ <View style={{ flexDirection: 'row', gap: 12, flexWrap: 'wrap' }}>
729
+ {(
730
+ [
731
+ ['1RM', SEM.brand],
732
+ ['vel', SEM.success],
733
+ ['miss', SEM.error],
734
+ ['RPE', SEM.warning],
735
+ ['alt', SEM.brandSecondary],
736
+ ['prev', SEM.brandDark],
737
+ ] as const
738
+ ).map(([label, color]) => (
739
+ <View key={label} style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
740
+ <View style={{ width: 14, height: 3, backgroundColor: color, borderRadius: 2 }} />
741
+ <Text className="text-text-secondary" style={{ fontSize: 10 }}>
742
+ {label}
743
+ </Text>
744
+ </View>
745
+ ))}
746
+ </View>
747
+ </Demo>
748
+ {/* Treemap — ordered categorical */}
749
+ <Demo label="Treemap · categorical">
750
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', width: 140, gap: 2 }}>
751
+ {[46, 30, 30, 24, 24, 24, 20].map((h, i) => (
752
+ <View
753
+ key={i}
754
+ style={{
755
+ height: h,
756
+ flexGrow: 1,
757
+ flexBasis: h > 40 ? 60 : 40,
758
+ backgroundColor: getCategoricalColor(i),
759
+ borderRadius: 3,
760
+ }}
761
+ />
762
+ ))}
763
+ </View>
764
+ </Demo>
765
+ {/* Spinner */}
766
+ <Demo label="Spinner">
767
+ <View
768
+ style={{
769
+ width: 24,
770
+ height: 24,
771
+ borderRadius: 12,
772
+ borderWidth: 3,
773
+ borderColor: SWATCH_BORDER,
774
+ borderTopColor: SEM.spinner,
775
+ }}
776
+ />
777
+ </Demo>
778
+ {/* IntensityBar — full effort scale */}
779
+ <Demo label="IntensityBar · effort">
780
+ <View style={{ flexDirection: 'row', gap: 2, width: 132 }}>
781
+ {sequentialEffort.map((hex, i) => (
782
+ <View key={i} style={{ flex: 1, height: 20, backgroundColor: hex, borderRadius: 2 }} />
783
+ ))}
784
+ </View>
785
+ </Demo>
786
+ {/* ROM bars — the fatigue silver/red scheme */}
787
+ <Demo label="RomProgressionChart · fatigue">
788
+ <View style={{ flexDirection: 'row', gap: 3, alignItems: 'flex-end', height: 40 }}>
789
+ {(
790
+ [
791
+ [34, SILVER],
792
+ [32, SILVER],
793
+ [33, SILVER],
794
+ [26, RED_LIGHT],
795
+ [22, RED_LIGHT],
796
+ [14, RED_DEEP],
797
+ ] as const
798
+ ).map(([h, color], i) => (
799
+ <View
800
+ key={i}
801
+ style={{ width: 14, height: h, backgroundColor: color, borderRadius: 2 }}
802
+ />
803
+ ))}
804
+ </View>
805
+ </Demo>
806
+ </View>
807
+ </View>
808
+ ),
809
+ }
810
+
811
+ // ============================================================================
812
+ // 11. Data-viz rainbow — superseded assignment, tokens still defined
813
+ // ============================================================================
814
+
815
+ /**
816
+ * SUPERSEDED ASSIGNMENT — kept because the `data-1..10` semantic tokens are
817
+ * still defined and still emit `--color-data-N` CSS vars, and an undocumented
818
+ * shipped token is exactly what `color-stories.coverage.test.ts` exists to
819
+ * prevent. This is not a legacy PRIMITIVE shelf — it is a live token family
820
+ * whose assignment has been replaced by {@link CategoricalPalette} for all new
821
+ * work. Retiring the `data-N` tokens would let this story go.
822
+ */
823
+ export const LegacyDataVisualizationColors: StoryObj = {
824
+ name: '11. Data Visualization (Superseded)',
825
+ render: () => (
826
+ <View style={{ padding: 24 }}>
827
+ <Text className="text-2xl font-bold text-text-primary mb-2">
828
+ Data Visualization — Superseded
829
+ </Text>
830
+ <SectionIntro>
831
+ <Text className="font-semibold">Do not use for new work.</Text> Paul Tol&apos;s
832
+ discrete-rainbow scale behind the `data-1..10` tokens — no longer consumed by any
833
+ component. Use the Categorical Palette (story 2) for charts and qualitative series. This
834
+ story exists because the tokens are still shipped, and a shipped token without a swatch is
835
+ the drift the coverage test guards against; it goes away when the tokens do.
836
+ </SectionIntro>
837
+
838
+ <View style={{ gap: 16 }}>
839
+ <ColorSwatch name="data-1" value={semanticColorsLight['data-1']} />
840
+ <ColorSwatch name="data-2" value={semanticColorsLight['data-2']} />
841
+ <ColorSwatch name="data-3" value={semanticColorsLight['data-3']} />
842
+ <ColorSwatch name="data-4" value={semanticColorsLight['data-4']} />
843
+ <ColorSwatch name="data-5" value={semanticColorsLight['data-5']} />
844
+ <ColorSwatch name="data-6" value={semanticColorsLight['data-6']} />
845
+ <ColorSwatch name="data-7" value={semanticColorsLight['data-7']} />
846
+ <ColorSwatch name="data-8" value={semanticColorsLight['data-8']} />
847
+ <ColorSwatch name="data-9" value={semanticColorsLight['data-9']} />
848
+ <ColorSwatch name="data-10" value={semanticColorsLight['data-10']} />
849
+ </View>
850
+
851
+ <Text className="text-lg font-bold text-text-primary mt-8 mb-3">
852
+ Full Discrete Rainbow Palette
853
+ </Text>
854
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 4 }}>
855
+ {discreteRainbow.map((color, index) => (
856
+ <View
857
+ key={index}
858
+ style={{
859
+ width: 32,
860
+ height: 32,
861
+ borderRadius: 4,
862
+ backgroundColor: color,
863
+ borderWidth: 1,
864
+ borderColor: SWATCH_BORDER,
865
+ }}
866
+ />
867
+ ))}
868
+ </View>
869
+ </View>
870
+ ),
871
+ }