@titan-design/react-ui 0.10.0 → 0.12.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 (190) hide show
  1. package/dist/bodymap.js +173 -343
  2. package/dist/bodymap.js.map +1 -1
  3. package/dist/bodymap.mjs +173 -343
  4. package/dist/bodymap.mjs.map +1 -1
  5. package/dist/index.d.mts +416 -18
  6. package/dist/index.d.ts +416 -18
  7. package/dist/index.js +2512 -1307
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2480 -1299
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/{pages-DyEx-lBf.d.ts → pages-B1hqnGIC.d.ts} +149 -15
  12. package/dist/{pages-D7hOD4Vj.d.mts → pages-CvsFqNtx.d.mts} +149 -15
  13. package/dist/pages.d.mts +1 -1
  14. package/dist/pages.d.ts +1 -1
  15. package/dist/pages.js +1644 -1575
  16. package/dist/pages.js.map +1 -1
  17. package/dist/pages.mjs +1645 -1576
  18. package/dist/pages.mjs.map +1 -1
  19. package/dist/theme/index.d.mts +409 -415
  20. package/dist/theme/index.d.ts +409 -415
  21. package/dist/theme/index.js +234 -408
  22. package/dist/theme/index.js.map +1 -1
  23. package/dist/theme/index.mjs +227 -399
  24. package/dist/theme/index.mjs.map +1 -1
  25. package/dist/theme/tokens-css.js +141 -140
  26. package/dist/theme/tokens-css.js.map +1 -1
  27. package/dist/theme/tokens-css.mjs +141 -140
  28. package/dist/theme/tokens-css.mjs.map +1 -1
  29. package/package.json +8 -3
  30. package/src/arch/Architecture.stories.tsx +9 -9
  31. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  32. package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
  33. package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
  34. package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
  35. package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
  36. package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
  37. package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
  38. package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
  39. package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
  40. package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
  41. package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
  42. package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
  43. package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
  44. package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
  45. package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
  46. package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
  47. package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
  48. package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
  49. package/src/components/custom/Fatigue/README.md +93 -0
  50. package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
  51. package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
  52. package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
  53. package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
  54. package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
  55. package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
  56. package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
  57. package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
  58. package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
  59. package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
  60. package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
  61. package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
  62. package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
  63. package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
  64. package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
  65. package/src/components/custom/Fatigue/index.ts +41 -0
  66. package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
  67. package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
  68. package/src/components/custom/Sidebar/Sidebar.tsx +4 -4
  69. package/src/components/custom/Table/Table.tsx +2 -2
  70. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +3 -3
  71. package/src/components/custom/Workout/BaseBadge.test.tsx +6 -1
  72. package/src/components/custom/Workout/BaseBadge.tsx +1 -1
  73. package/src/components/custom/Workout/BodyMap.tsx +2 -2
  74. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +2 -2
  75. package/src/components/custom/Workout/DeviationBar.test.tsx +2 -1
  76. package/src/components/custom/Workout/DeviationBar.tsx +3 -2
  77. package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
  78. package/src/components/custom/Workout/ExerciseCard.test.tsx +7 -7
  79. package/src/components/custom/Workout/ExerciseCard.tsx +5 -3
  80. package/src/components/custom/Workout/ExerciseDetailPage.tsx +11 -5
  81. package/src/components/custom/Workout/InputBar.tsx +2 -2
  82. package/src/components/custom/Workout/LiveAuraFrame.stories.tsx +1 -1
  83. package/src/components/custom/Workout/LiveAuraFrame.test.tsx +1 -1
  84. package/src/components/custom/Workout/LiveAuraFrame.tsx +3 -3
  85. package/src/components/custom/Workout/MesoCard.tsx +3 -1
  86. package/src/components/custom/Workout/MuscleGroupChip.tsx +4 -3
  87. package/src/components/custom/Workout/PrHistoryModal.tsx +2 -2
  88. package/src/components/custom/Workout/ProgramPlanningPage.tsx +1 -1
  89. package/src/components/custom/Workout/README.md +62 -16
  90. package/src/components/custom/Workout/ReadinessCheck.tsx +2 -2
  91. package/src/components/custom/Workout/RestTimer.tsx +2 -2
  92. package/src/components/custom/Workout/S3SetTypes.stories.tsx +3 -3
  93. package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
  94. package/src/components/custom/Workout/SessionHeader.stories.tsx +2 -2
  95. package/src/components/custom/Workout/SessionHeader.tsx +1 -1
  96. package/src/components/custom/Workout/SessionRail.stories.tsx +2 -2
  97. package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
  98. package/src/components/custom/Workout/SessionRail.tsx +63 -18
  99. package/src/components/custom/Workout/SetBar.tsx +3 -3
  100. package/src/components/custom/Workout/SetRow.test.tsx +12 -9
  101. package/src/components/custom/Workout/SetRow.tsx +9 -2
  102. package/src/components/custom/Workout/StatusDot.tsx +5 -4
  103. package/src/components/custom/Workout/StrengthTrendChart.tsx +1 -1
  104. package/src/components/custom/Workout/TrainingStatusPage.tsx +2 -2
  105. package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
  106. package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
  107. package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
  108. package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
  109. package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
  110. package/src/components/custom/Workout/VelocityStrip.test.tsx +199 -79
  111. package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
  112. package/src/components/custom/Workout/VolumeLandmarkBar.tsx +3 -3
  113. package/src/components/custom/Workout/WeightBadge.test.tsx +6 -1
  114. package/src/components/custom/Workout/WeightBadge.tsx +2 -1
  115. package/src/components/custom/Workout/WorkoutCard.tsx +3 -1
  116. package/src/components/custom/Workout/WorkoutPill.tsx +5 -2
  117. package/src/components/custom/Workout/ZoneTrack.tsx +4 -4
  118. package/src/components/custom/Workout/index.ts +3 -0
  119. package/src/components/custom/Workout/setHeadingKit.tsx +14 -8
  120. package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
  121. package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
  122. package/src/components/custom/charts/SetBarChart.tsx +651 -0
  123. package/src/components/custom/charts/live-rep-growth.ts +91 -0
  124. package/src/components/custom/index.ts +1 -0
  125. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  126. package/src/components/custom/stepper/Stepper.tsx +3 -3
  127. package/src/components/shell/DeviceMenu.tsx +2 -2
  128. package/src/components/shell/SideNav.tsx +1 -1
  129. package/src/components/shell/TopBar.tsx +1 -1
  130. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  131. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  132. package/src/components/ui/avatar/Avatar.tsx +2 -2
  133. package/src/components/ui/badge/Badge.tsx +2 -2
  134. package/src/components/ui/card/Card.tsx +3 -3
  135. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  136. package/src/components/ui/collapse/Collapse.tsx +1 -1
  137. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  138. package/src/components/ui/drawer/Drawer.tsx +3 -3
  139. package/src/components/ui/form-field/FormField.tsx +1 -1
  140. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  141. package/src/components/ui/menu/Menu.tsx +2 -2
  142. package/src/components/ui/pill/Pill.tsx +2 -2
  143. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  144. package/src/components/ui/popover/Popover.tsx +1 -1
  145. package/src/components/ui/progress/Progress.tsx +3 -3
  146. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  147. package/src/components/ui/radio/Radio.tsx +4 -4
  148. package/src/components/ui/select/Select.tsx +3 -3
  149. package/src/components/ui/surface/Surface.stories.tsx +3 -3
  150. package/src/components/ui/surface/Surface.test.tsx +32 -19
  151. package/src/components/ui/surface/Surface.tsx +3 -3
  152. package/src/components/ui/surface/SurfaceContext.ts +19 -29
  153. package/src/components/ui/surface/surface.contract.test.ts +57 -44
  154. package/src/components/ui/switch/Switch.tsx +1 -1
  155. package/src/components/ui/tabs/Tabs.tsx +2 -2
  156. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +5 -5
  157. package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +1 -1
  158. package/src/components/ui/toolbar-button/ToolbarButton.tsx +36 -22
  159. package/src/stories/PresetShowcase.stories.tsx +21 -21
  160. package/src/test/raw-color-patterns.test.ts +71 -0
  161. package/src/theme/ColorPalettes.stories.tsx +874 -0
  162. package/src/theme/ColorPrimitives.stories.tsx +348 -0
  163. package/src/theme/Depth.stories.tsx +312 -0
  164. package/src/theme/DepthCalibration.stories.tsx +655 -0
  165. package/src/theme/ThemeProvider.tsx +1 -1
  166. package/src/theme/barrel.ts +7 -14
  167. package/src/theme/color-stories.coverage.test.ts +125 -0
  168. package/src/theme/color-story-kit.tsx +170 -0
  169. package/src/theme/color-utils.ts +197 -0
  170. package/src/theme/config.ts +0 -8
  171. package/src/theme/depth-calibration.test.tsx +97 -0
  172. package/src/theme/elevation.test.ts +62 -1
  173. package/src/theme/elevation.ts +34 -8
  174. package/src/theme/global.css +47 -53
  175. package/src/theme/grey-ramp.test.ts +166 -0
  176. package/src/theme/materials.test.ts +106 -0
  177. package/src/theme/materials.ts +211 -0
  178. package/src/theme/presets/audiobook.ts +4 -4
  179. package/src/theme/presets/types.ts +3 -3
  180. package/src/theme/semantic-contrast.test.ts +83 -0
  181. package/src/theme/tokens/primitives.ts +62 -115
  182. package/src/theme/tokens/semantic.ts +130 -82
  183. package/src/utils/colors.ts +1 -1
  184. package/tailwind.config.js +2 -5
  185. package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
  186. package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
  187. package/src/theme/ColorSystem.stories.tsx +0 -508
  188. package/src/theme/Colors.stories.tsx +0 -323
  189. package/src/theme/shadows.stories.tsx +0 -523
  190. package/src/theme/shadows.ts +0 -439
@@ -0,0 +1,655 @@
1
+ import { useState } from 'react'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+ import { View, Text, Pressable } from 'react-native'
4
+ import { greyRamp } from './tokens/primitives'
5
+ import { getSemanticColors } from './tokens/semantic'
6
+ import { getBaseSurfaceColor, getElevationSurface, type ElevationLevel } from './elevation'
7
+ import { grainForTone, paperSheet } from './materials'
8
+
9
+ /**
10
+ * Foundations/Depth Calibration — the S-6 wall gate (VW-99).
11
+ *
12
+ * `Foundations/Depth` explains the depth model. This file MEASURES it, and the
13
+ * two are deliberately shaped differently:
14
+ *
15
+ * Depth small labelled swatches, captions that give the answer
16
+ * Depth Calibration full-bleed fields, no labels, decoys, a hidden key
17
+ *
18
+ * Every threshold in the depth model (TD-07.16) is desk-verified only. The
19
+ * north-star doc set its floors explicitly "to survive glare + a mediocre
20
+ * panel", and none of them have been looked at on the panel. That matters more
21
+ * than the numbers suggest: solid dark borders were DELETED, so if the hairlines
22
+ * wash out there is no fallback, and elevation levels 1-3 are separated by tone
23
+ * alone now that shadow is gated behind FLOATING_ELEVATION_MIN.
24
+ *
25
+ * WHY FORCED-CHOICE. "Does this look OK?" is close to unfalsifiable at
26
+ * threshold — you are asking someone to report the absence of a faint thing they
27
+ * already know is supposed to be there. So each panel asks a question with a
28
+ * checkable answer, hides the key behind a toggle, and seeds decoys (cells with
29
+ * NO hairline, pairs that are genuinely identical). A run that "sees" a decoy is
30
+ * a run whose PASSes mean nothing — re-run it at the real viewing distance.
31
+ *
32
+ * HOW TO RUN. On the wall panel, at demo brightness, at demo viewing distance
33
+ * (~3 m), with the room lit the way it will be lit. Answer every panel BEFORE
34
+ * touching Reveal. Record on the sheet:
35
+ * `voltras-workspace/coordination/validation-runbooks/VW-99-depth-wall-calibration.md`
36
+ */
37
+ const meta: Meta = {
38
+ title: 'Foundations/Depth Calibration',
39
+ parameters: {
40
+ // Global preview layout is 'centered'; every panel here needs the full
41
+ // panel width, because half the question is whether a thing survives at size.
42
+ layout: 'fullscreen',
43
+ },
44
+ }
45
+ export default meta
46
+
47
+ const t = getSemanticColors('dark')
48
+ const base = getBaseSurfaceColor('dark')
49
+
50
+ /**
51
+ * The rig's ground sits BELOW the ramp, at true black.
52
+ *
53
+ * Not a style choice. `grey-975` is a plane the hairlines have to work on, and
54
+ * if the page were also 975 those cells would be invisible against it — you
55
+ * would be judging a line with no plane around it. Nothing in the product ships
56
+ * on true black; this is a test surface, and it is deliberately off the ramp so
57
+ * that every step OF the ramp is itself testable.
58
+ */
59
+ const GROUND = '#000000'
60
+
61
+ // ── furniture ───────────────────────────────────────────────────────────────
62
+
63
+ /**
64
+ * Panel chrome: the task prompt, and a Reveal toggle that is the only thing
65
+ * standing between the operator and a primed judgement. Children receive
66
+ * `revealed` so each panel decides what its key looks like.
67
+ */
68
+ function Panel({
69
+ title,
70
+ task,
71
+ children,
72
+ }: {
73
+ title: string
74
+ task: string
75
+ children: (revealed: boolean) => React.ReactNode
76
+ }) {
77
+ const [revealed, setRevealed] = useState(false)
78
+ return (
79
+ <View style={{ backgroundColor: GROUND, padding: 28, minHeight: '100vh' as never }}>
80
+ <View
81
+ style={{
82
+ flexDirection: 'row',
83
+ alignItems: 'flex-start',
84
+ justifyContent: 'space-between',
85
+ gap: 24,
86
+ marginBottom: 18,
87
+ }}
88
+ >
89
+ <View style={{ flex: 1 }}>
90
+ <Text className="text-text-primary" style={{ fontSize: 22, fontWeight: '700' }}>
91
+ {title}
92
+ </Text>
93
+ <Text
94
+ className="text-text-secondary"
95
+ style={{ fontSize: 15, marginTop: 6, maxWidth: 900 }}
96
+ >
97
+ {task}
98
+ </Text>
99
+ </View>
100
+ <Pressable
101
+ onPress={() => setRevealed((r) => !r)}
102
+ style={{
103
+ paddingVertical: 10,
104
+ paddingHorizontal: 18,
105
+ borderRadius: 8,
106
+ borderWidth: 1,
107
+ borderColor: t['hairline-strong'],
108
+ backgroundColor: getElevationSurface(base, 2, 'dark'),
109
+ }}
110
+ >
111
+ <Text className="text-text-primary" style={{ fontSize: 14, fontWeight: '600' }}>
112
+ {revealed ? 'Hide key' : 'Reveal key'}
113
+ </Text>
114
+ </Pressable>
115
+ </View>
116
+ {children(revealed)}
117
+ </View>
118
+ )
119
+ }
120
+
121
+ function KeyLine({ children }: { children: string }) {
122
+ return (
123
+ <Text className="text-text-secondary" style={{ fontSize: 13, marginTop: 10, lineHeight: 19 }}>
124
+ {children}
125
+ </Text>
126
+ )
127
+ }
128
+
129
+ // ── C1 · hairlines ──────────────────────────────────────────────────────────
130
+
131
+ type HairlineToken = 'hairline-subtle' | 'hairline-default' | 'hairline-strong'
132
+ type HairlineCondition = HairlineToken | 'none'
133
+
134
+ /**
135
+ * A fixed 4×4 assignment, not a random one — a story that shuffles on each
136
+ * render cannot be re-run, screenshotted, or visually regression-tested. It is
137
+ * unprimed because the KEY is hidden, not because the layout is unpredictable.
138
+ *
139
+ * Rows are the ground planes a hairline actually lands on in production. Each
140
+ * row contains all four conditions in a different order, so no column is "the
141
+ * blank column" and no row can be answered by pattern rather than by looking.
142
+ */
143
+ const HAIRLINE_GROUNDS = [975, 950, 925, 900] as const
144
+ const HAIRLINE_GRID: HairlineCondition[][] = [
145
+ ['hairline-default', 'none', 'hairline-strong', 'hairline-subtle'],
146
+ ['hairline-subtle', 'hairline-strong', 'none', 'hairline-default'],
147
+ ['none', 'hairline-default', 'hairline-subtle', 'hairline-strong'],
148
+ ['hairline-strong', 'hairline-subtle', 'hairline-default', 'none'],
149
+ ]
150
+ const CELL_LABELS = ['A', 'B', 'C', 'D']
151
+
152
+ export const Hairlines: StoryObj = {
153
+ name: 'C1 · Hairlines — the only separator left',
154
+ render: () => (
155
+ <Panel
156
+ title="C1 · Hairlines"
157
+ task={
158
+ 'Each cell is one flat plane cut in half by a horizontal divider — or by nothing at all. ' +
159
+ 'Four of the sixteen have NO divider. Write down every cell where you can see a line, ' +
160
+ 'from the real viewing distance, without leaning in. Then reveal.'
161
+ }
162
+ >
163
+ {(revealed) => (
164
+ <View>
165
+ {HAIRLINE_GRID.map((row, r) => (
166
+ <View
167
+ key={HAIRLINE_GROUNDS[r]}
168
+ style={{ flexDirection: 'row', gap: 14, marginBottom: 22, alignItems: 'flex-start' }}
169
+ >
170
+ {/* Row gutter. Labels used to sit UNDER their tiles, which made every
171
+ row number ambiguous the moment one row was hard to see — the
172
+ first real-operator run misnumbered the whole grid because of it.
173
+ The label now leads its tile, and the row has a fixed marker. */}
174
+ <View style={{ width: 30, paddingTop: 22 }}>
175
+ <Text className="text-text-tertiary" style={{ fontSize: 15, fontWeight: '700' }}>
176
+ {r + 1}
177
+ </Text>
178
+ </View>
179
+ {row.map((condition, cIdx) => {
180
+ const ground = greyRamp[HAIRLINE_GROUNDS[r]]
181
+ return (
182
+ <View key={cIdx} style={{ flex: 1 }}>
183
+ <Text
184
+ className="text-text-tertiary"
185
+ style={{ fontSize: 13, marginBottom: 5, fontWeight: '600' }}
186
+ >
187
+ {`${CELL_LABELS[cIdx]}${r + 1}`}
188
+ {revealed
189
+ ? condition === 'none'
190
+ ? ' · NONE (decoy)'
191
+ : ` · ${condition.replace('hairline-', '')} on grey-${HAIRLINE_GROUNDS[r]}`
192
+ : ''}
193
+ </Text>
194
+ <View style={{ height: 128, backgroundColor: ground, borderRadius: 6 }}>
195
+ <View style={{ flex: 1 }} />
196
+ <View
197
+ style={{
198
+ height: 1,
199
+ backgroundColor: condition === 'none' ? 'transparent' : t[condition],
200
+ }}
201
+ />
202
+ <View style={{ flex: 1 }} />
203
+ </View>
204
+ </View>
205
+ )
206
+ })}
207
+ </View>
208
+ ))}
209
+
210
+ {revealed ? (
211
+ <View style={{ marginTop: 6, maxWidth: 1000 }}>
212
+ <KeyLine>
213
+ {'Decoys: B1, C2, A3, D4. Reporting a line in any of those invalidates the run — ' +
214
+ 'move back to the real distance and go again.'}
215
+ </KeyLine>
216
+ <KeyLine>
217
+ {'PASS: every `default` and `strong` cell seen on every ground. `subtle` is allowed ' +
218
+ 'to disappear on grey-975 — it is a whisper by design — but if `default` is ' +
219
+ 'missing anywhere, that is the failure that has no fallback.'}
220
+ </KeyLine>
221
+ <KeyLine>
222
+ {'Retune lever: raise the alphas in tokens/semantic.ts (`hairline-*`, currently ' +
223
+ '.06 / .09 / .14 white). Raise the whole family together — the ratio between the ' +
224
+ 'three is what makes them read as one scale.'}
225
+ </KeyLine>
226
+ </View>
227
+ ) : null}
228
+ </View>
229
+ )}
230
+ </Panel>
231
+ ),
232
+ }
233
+
234
+ // ── C2 · tone steps ─────────────────────────────────────────────────────────
235
+
236
+ const STAIRCASE: ElevationLevel[] = [0, 1, 2, 3, 4, 5]
237
+
238
+ /**
239
+ * Adjacent-step pairs, with the lighter side pre-assigned to a fixed side and
240
+ * one deliberately IDENTICAL pair seeded in. Without that decoy, "I can see a
241
+ * difference" is a question nobody answers no to.
242
+ */
243
+ const STEP_PAIRS: { left: ElevationLevel; right: ElevationLevel; label: string }[] = [
244
+ { left: 1, right: 0, label: '1' },
245
+ { left: 2, right: 3, label: '2' },
246
+ { left: 2, right: 2, label: '3' },
247
+ { left: 3, right: 4, label: '4' },
248
+ { left: 1, right: 2, label: '5' },
249
+ { left: 5, right: 4, label: '6' },
250
+ ]
251
+
252
+ export const ToneSteps: StoryObj = {
253
+ name: 'C2 · Tone steps — levels 1-3 have nothing else',
254
+ render: () => (
255
+ <Panel
256
+ title="C2 · Tone steps"
257
+ task={
258
+ 'TOP: one continuous field with no borders anywhere. Count the vertical edges you can see. ' +
259
+ 'BOTTOM: six split fields. For each, say LEFT lighter, RIGHT lighter, or SAME — one pair ' +
260
+ 'genuinely is the same. Answer both before revealing.'
261
+ }
262
+ >
263
+ {(revealed) => (
264
+ <View>
265
+ <View style={{ flexDirection: 'row', height: 200, borderRadius: 6, overflow: 'hidden' }}>
266
+ {STAIRCASE.map((lv) => (
267
+ <View
268
+ key={lv}
269
+ style={{ flex: 1, backgroundColor: getElevationSurface(base, lv, 'dark') }}
270
+ />
271
+ ))}
272
+ </View>
273
+ <Text className="text-text-tertiary" style={{ fontSize: 13, marginTop: 8 }}>
274
+ {revealed
275
+ ? `6 bands / 5 edges — elevation 0…5, colorAdjustment 0 · .025 · .05 · .075 · .10 · .13`
276
+ : 'Edges visible: ______'}
277
+ </Text>
278
+
279
+ <View style={{ flexDirection: 'row', gap: 14, marginTop: 26 }}>
280
+ {STEP_PAIRS.map((pair) => (
281
+ <View key={pair.label} style={{ flex: 1 }}>
282
+ <View
283
+ style={{ flexDirection: 'row', height: 150, borderRadius: 6, overflow: 'hidden' }}
284
+ >
285
+ <View
286
+ style={{
287
+ flex: 1,
288
+ backgroundColor: getElevationSurface(base, pair.left, 'dark'),
289
+ }}
290
+ />
291
+ <View
292
+ style={{
293
+ flex: 1,
294
+ backgroundColor: getElevationSurface(base, pair.right, 'dark'),
295
+ }}
296
+ />
297
+ </View>
298
+ <Text className="text-text-tertiary" style={{ fontSize: 12, marginTop: 5 }}>
299
+ {pair.label}
300
+ {revealed
301
+ ? pair.left === pair.right
302
+ ? ' · SAME (decoy)'
303
+ : ` · ${pair.left > pair.right ? 'LEFT' : 'RIGHT'} (lv ${pair.left}|${pair.right})`
304
+ : ''}
305
+ </Text>
306
+ </View>
307
+ ))}
308
+ </View>
309
+
310
+ {revealed ? (
311
+ <View style={{ marginTop: 14, maxWidth: 1000 }}>
312
+ <KeyLine>
313
+ {'Pair 1 is the one that matters: it is the 0.025 step from level 0 to level 1, the ' +
314
+ 'smallest in the ladder and the one carrying the most UI. Pair 3 is the decoy.'}
315
+ </KeyLine>
316
+ <KeyLine>
317
+ {'PASS: all five edges counted on the staircase, pairs 1/2/4/5/6 called correctly, ' +
318
+ 'pair 3 called SAME. Missing pair 1 means level 1 is not a level — it is level 0 ' +
319
+ 'with extra bookkeeping, and no shadow is coming to save it below level 4.'}
320
+ </KeyLine>
321
+ <KeyLine>
322
+ {'Retune lever: widen `colorAdjustment` on elevation levels 1-3 in elevation.ts. ' +
323
+ 'Level 5 is the ceiling the ladder has to fit under, so widening the bottom ' +
324
+ 'compresses the top — re-space the whole ladder rather than nudging one entry.'}
325
+ </KeyLine>
326
+ </View>
327
+ ) : null}
328
+ </View>
329
+ )}
330
+ </Panel>
331
+ ),
332
+ }
333
+
334
+ // ── C3 · paper material ─────────────────────────────────────────────────────
335
+
336
+ const PAPER_TONE = greyRamp[875]
337
+
338
+ /**
339
+ * REWRITTEN FOR RUN 2. Run 1 asked "is the tonal fill visible?" and the answer
340
+ * was no, so the fill was deleted — which left this panel with nothing to
341
+ * compare. The question that survives the deletion is broader and more useful:
342
+ * with the fill gone, does what REMAINS of the paper material read at all?
343
+ *
344
+ * `paperSheet` is now grain + dither + a top rim-light + a contact shadow. If
345
+ * that stack cannot be told from a flat `backgroundColor` at 3 m, it is three
346
+ * paint layers buying nothing and it should follow the fill out.
347
+ *
348
+ * The forced choice is odd-one-out with a genuine identical pair, rather than
349
+ * "can you see it" — the operator has to say WHICH and HOW, and "they are all
350
+ * the same" is an available, unembarrassing answer. That matters here more than
351
+ * on C1: grain lifts apparent lightness, so a two-way comparison can be won on
352
+ * a brightness cue without the material ever reading as a material.
353
+ */
354
+ const PAPER_SHEETS = ['flat', 'paper', 'flat'] as const
355
+
356
+ /**
357
+ * ADDED IN RUN 2. `insetWell` is the other material TD-07.16 shipped, and it has
358
+ * never been on a panel. Run 2 flagged `paperSheet`'s rim-light as weak at .10 —
359
+ * and the well's floor light, which its own docstring calls "the load-bearing
360
+ * half", is at .04. That is below every hairline value the same run rejected as
361
+ * too faint, so it is very likely inert on the wall and nobody has looked.
362
+ *
363
+ * Forced choice rather than "can you see it": the SHIPPED value, a candidate,
364
+ * and a well with no floor line at all. Reporting a line on the decoy invalidates
365
+ * the row. Answering it here costs one glance and saves a whole extra sitting.
366
+ */
367
+ const WELL_TONE = greyRamp[950]
368
+ const WELL_FLOORS: { label: string; floorAlpha: number | null; answer: string }[] = [
369
+ { label: '1', floorAlpha: 0.12, answer: 'CANDIDATE — floor light raised to .12' },
370
+ { label: '2', floorAlpha: null, answer: 'NONE (decoy) — inner top shadow only, no floor line' },
371
+ { label: '3', floorAlpha: 0.04, answer: 'SHIPPED — floor light at .04' },
372
+ ]
373
+
374
+ export const PaperMaterial: StoryObj = {
375
+ name: 'C3 · Paper material — what is left after the fill',
376
+ render: () => (
377
+ <Panel
378
+ title="C3 · Paper material"
379
+ task={
380
+ 'TOP: three sheets on the same tone. Two are identical. Which one differs, and in what way ' +
381
+ '— lighter, textured, edged? "All three the same" is a real answer. BOTTOM: the same ' +
382
+ 'material at hero size. Does it read as a sheet with light on it, as visible noise, or as ' +
383
+ 'nothing at all?'
384
+ }
385
+ >
386
+ {(revealed) => (
387
+ <View>
388
+ <View style={{ flexDirection: 'row', gap: 22 }}>
389
+ {PAPER_SHEETS.map((kind, i) => (
390
+ <Card
391
+ key={i}
392
+ label={String(i + 1)}
393
+ revealed={revealed}
394
+ answer={
395
+ kind === 'paper'
396
+ ? 'paperSheet() — grain + dither + rim-light + contact shadow'
397
+ : 'FLAT (decoy) — backgroundColor alone'
398
+ }
399
+ style={
400
+ kind === 'paper'
401
+ ? { ...(paperSheet(PAPER_TONE) as Record<string, unknown>) }
402
+ : { backgroundColor: PAPER_TONE }
403
+ }
404
+ />
405
+ ))}
406
+ </View>
407
+
408
+ <Text
409
+ className="text-text-secondary"
410
+ style={{ fontSize: 15, marginTop: 26, marginBottom: 10 }}
411
+ >
412
+ The same material, at hero size — lit sheet, visible noise, or nothing?
413
+ </Text>
414
+ <View
415
+ style={{
416
+ height: 260,
417
+ borderRadius: 12,
418
+ ...(paperSheet(PAPER_TONE) as Record<string, unknown>),
419
+ }}
420
+ />
421
+ <Text className="text-text-tertiary" style={{ fontSize: 12, marginTop: 8 }}>
422
+ {revealed ? 'paperSheet() at hero width' : 'reads as: ______'}
423
+ </Text>
424
+
425
+ <Text
426
+ className="text-text-secondary"
427
+ style={{ fontSize: 15, marginTop: 26, marginBottom: 10 }}
428
+ >
429
+ Inset wells — which of these have a light line along the FLOOR (bottom inner edge)?
430
+ </Text>
431
+ <View style={{ flexDirection: 'row', gap: 22 }}>
432
+ {WELL_FLOORS.map((w) => (
433
+ <View key={w.label} style={{ flex: 1 }}>
434
+ <View
435
+ style={
436
+ {
437
+ height: 120,
438
+ borderRadius: 10,
439
+ backgroundColor: WELL_TONE,
440
+ boxShadow: w.floorAlpha
441
+ ? `inset 0 2px 6px rgba(0,0,0,0.55), inset 0 -1px 0 rgba(255,255,255,${w.floorAlpha})`
442
+ : 'inset 0 2px 6px rgba(0,0,0,0.55)',
443
+ } as Record<string, unknown>
444
+ }
445
+ />
446
+ <Text className="text-text-tertiary" style={{ fontSize: 12, marginTop: 6 }}>
447
+ {w.label}
448
+ {revealed ? ` · ${w.answer}` : ' · floor line? ______'}
449
+ </Text>
450
+ </View>
451
+ ))}
452
+ </View>
453
+
454
+ {revealed ? (
455
+ <View style={{ marginTop: 14, maxWidth: 1000 }}>
456
+ <KeyLine>
457
+ {'Sheet 2 is the material; 1 and 3 are the identical decoy pair. PASS: 2 picked out, ' +
458
+ 'AND the hero sheet described as a surface rather than as noise. Invisible and ' +
459
+ 'obvious are both failures — the same two-sided bar the fill was held to.'}
460
+ </KeyLine>
461
+ <KeyLine>
462
+ {'Levers. Reads as NOTHING: the honest move is to drop grain and the dither from ' +
463
+ 'paperSheet and keep only the rim-light and contact shadow, which are edge cues ' +
464
+ 'and sit at a spatial frequency the wall demonstrably resolves — that is the ' +
465
+ 'lesson C2 taught and the fill failed. Reads as NOISE: lower the grain opacity in ' +
466
+ 'grainForTone (materials.ts) before touching its frequency.'}
467
+ </KeyLine>
468
+ <KeyLine>
469
+ {'If 1 and 3 were reported as different from each other, the run is invalid at this ' +
470
+ 'distance — they are byte-identical. Step back to 3 m and go again.'}
471
+ </KeyLine>
472
+ </View>
473
+ ) : null}
474
+ </View>
475
+ )}
476
+ </Panel>
477
+ ),
478
+ }
479
+
480
+ function Card({
481
+ label,
482
+ answer,
483
+ revealed,
484
+ style,
485
+ }: {
486
+ label: string
487
+ answer: string
488
+ revealed: boolean
489
+ style: Record<string, unknown>
490
+ }) {
491
+ return (
492
+ <View style={{ flex: 1 }}>
493
+ <View style={{ height: 300, borderRadius: 12, ...style }} />
494
+ <Text className="text-text-tertiary" style={{ fontSize: 12, marginTop: 8, lineHeight: 17 }}>
495
+ {label}
496
+ {revealed ? ` · ${answer}` : ''}
497
+ </Text>
498
+ </View>
499
+ )
500
+ }
501
+
502
+ // ── C4 · banding ────────────────────────────────────────────────────────────
503
+
504
+ const BAND_TONE = greyRamp[900]
505
+
506
+ /**
507
+ * REWRITTEN FOR RUN 2, and this panel is why the rewrite could not be skipped.
508
+ *
509
+ * Run 1 compared dither-on against dither-off over the tonal fill. Neither field
510
+ * banded — but the fill was already established as invisible, so there was no
511
+ * gradient to band and the panel could not have failed. That is a VOID result,
512
+ * not a pass, and it is the exact failure mode a calibration rig exists to catch
513
+ * in itself.
514
+ *
515
+ * Two changes:
516
+ *
517
+ * 1. Both material fields are now the SHIPPED stack ± dither, derived from
518
+ * `paperSheet` rather than reassembled by hand, so they cannot drift from
519
+ * what actually ships.
520
+ * 2. A POSITIVE CONTROL is seeded: a bare low-contrast vertical gradient with no
521
+ * dither and no grain, which on an 8-bit panel must band. If it does not, the
522
+ * panel cannot detect banding under these viewing conditions and the row is
523
+ * VOID AGAIN — now with evidence, instead of being mistaken for a pass.
524
+ *
525
+ * The control cannot be disguised as one of the material fields, and does not
526
+ * need to be: it is not part of a forced choice. Which field it is stays hidden
527
+ * with the rest of the key, so the operator still reports what they see rather
528
+ * than what they expect.
529
+ */
530
+ const SHIPPED_PAPER = paperSheet(BAND_TONE) as Record<string, unknown>
531
+
532
+ /**
533
+ * ADDED IN RUN 2, and it is the whole reason this panel is worth re-running.
534
+ *
535
+ * Run 2 reported NO banding anywhere — including on the control that was
536
+ * supposed to guarantee it. By the sheet that is another VOID, but "the control
537
+ * did not fire" has two very different causes and they demand opposite actions:
538
+ *
539
+ * a. the panel/viewing setup cannot resolve banding -> VOID, fix the setup
540
+ * b. the render path genuinely does not band here -> ditherTile is dead
541
+ * (10-bit panel, or the browser dithers gradients itself)
542
+ *
543
+ * A smooth control cannot tell those apart, because both produce "no steps".
544
+ * So: the same tone span, quantised BY HAND into five hard-edged stripes. This
545
+ * is banding, drawn deliberately. C2 already proved hard tone edges at this
546
+ * spacing are visible on the wall, so if these stripes read and the smooth
547
+ * gradient beside them does not, cause (b) is established — the pipeline is not
548
+ * producing the artefact, and the mitigation for it is unearned.
549
+ */
550
+ const BAND_STEPS = (() => {
551
+ const from = [0x2c, 0x2a, 0x28] // grey-900
552
+ const to = [0x31, 0x30, 0x2f] // grey-875
553
+ return Array.from({ length: 5 }, (_, i) => {
554
+ const t = i / 4
555
+ const ch = from.map((f, k) => Math.round(f + (to[k] - f) * t))
556
+ return `#${ch.map((v) => v.toString(16).padStart(2, '0')).join('')}`
557
+ })
558
+ })()
559
+
560
+ const BAND_FIELDS: { key: string; answer: string; style: Record<string, unknown> }[] = [
561
+ {
562
+ key: '1',
563
+ answer: 'the SHIPPED stack — dither over grain',
564
+ style: SHIPPED_PAPER,
565
+ },
566
+ {
567
+ key: '2',
568
+ answer: 'CONTROL — a bare grey-900 → grey-875 gradient, no dither, no grain',
569
+ style: {
570
+ backgroundColor: BAND_TONE,
571
+ backgroundImage: `linear-gradient(180deg, ${greyRamp[900]} 0%, ${greyRamp[875]} 100%)`,
572
+ },
573
+ },
574
+ {
575
+ key: '2b',
576
+ answer:
577
+ 'REFERENCE — hard-edged stripes, grey-900 → grey-875 in 5 steps. This is what banding LOOKS like.',
578
+ style: {
579
+ backgroundImage: `linear-gradient(180deg, ${BAND_STEPS.map(
580
+ (hex, i) =>
581
+ `${hex} ${(i / BAND_STEPS.length) * 100}%, ${hex} ${((i + 1) / BAND_STEPS.length) * 100}%`,
582
+ ).join(', ')})`,
583
+ },
584
+ },
585
+ {
586
+ key: '3',
587
+ answer: 'the shipped stack MINUS the dither — grain alone',
588
+ style: { ...SHIPPED_PAPER, backgroundImage: grainForTone(BAND_TONE) },
589
+ },
590
+ ]
591
+
592
+ export const Banding: StoryObj = {
593
+ name: 'C4 · Banding — does the dither earn its place',
594
+ render: () => (
595
+ <Panel
596
+ title="C4 · Banding"
597
+ task={
598
+ 'Four wide fields. Look for horizontal STEPS — flat stripes with a visible edge between ' +
599
+ 'them, rather than a smooth fall. Say BANDS or SMOOTH for each. This is the one check that ' +
600
+ 'cannot be done anywhere but on the panel: banding is an 8-bit artefact of this display.'
601
+ }
602
+ >
603
+ {(revealed) => (
604
+ <View>
605
+ {BAND_FIELDS.map((field) => (
606
+ <View key={field.key}>
607
+ <View
608
+ style={
609
+ {
610
+ height: 240,
611
+ borderRadius: 8,
612
+ marginTop: field.key === '1' ? 0 : 20,
613
+ ...field.style,
614
+ } as Record<string, unknown>
615
+ }
616
+ />
617
+ <Text className="text-text-tertiary" style={{ fontSize: 12, marginTop: 6 }}>
618
+ {`Field ${field.key}`}
619
+ {revealed ? ` · ${field.answer}` : ' · bands? ______'}
620
+ </Text>
621
+ </View>
622
+ ))}
623
+
624
+ {revealed ? (
625
+ <View style={{ marginTop: 14, maxWidth: 1000 }}>
626
+ <KeyLine>
627
+ {'READ FIELDS 2 AND 2b FIRST — they decide whether the rest of the panel means ' +
628
+ 'anything. 2b is banding drawn by hand: the same tone span quantised into five ' +
629
+ 'hard-edged 1-level steps, which is exactly what the artefact looks like when it ' +
630
+ 'occurs. Sanity-check the setup (brightness, no browser zoom) before calling it.'}
631
+ </KeyLine>
632
+ <KeyLine>
633
+ {'2b reads, 2 (smooth) does not -> this render path does not band: 10-bit panel, or ' +
634
+ 'the browser dithers gradients itself. 2b ALSO invisible -> the artefact is below ' +
635
+ 'threshold here at this tone span. Either way ditherTile buys nothing on this ' +
636
+ 'display and comes out of paperSheet — a real result, not a null one. The one ' +
637
+ 'caveat is that it is a claim about THIS panel; the same recipe on a phone is a ' +
638
+ 'separate question, and paperSheet is hero-surface-only for that reason.'}
639
+ </KeyLine>
640
+ <KeyLine>
641
+ {'If 2 DOES band: compare 1 (shipped) against 3 (no dither). 3 banding and 1 not is ' +
642
+ 'the dither earning its place — keep it.'}
643
+ </KeyLine>
644
+ <KeyLine>
645
+ {'If field 1 bands too, raise the 0.02α in ditherTile (materials.ts) until it stops; ' +
646
+ 'past ~0.03 it stops being invisible and becomes texture, at which point it is ' +
647
+ 'grain, not dither.'}
648
+ </KeyLine>
649
+ </View>
650
+ ) : null}
651
+ </View>
652
+ )}
653
+ </Panel>
654
+ ),
655
+ }
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Registers titan's semantic color tokens as runtime CSS variables via
4
4
  // nativewind's `vars()` so that className color tokens (`text-text-secondary`,
5
- // `bg-surface-raised`, `border-border`, …) resolve on NATIVE React Native — the
5
+ // `bg-surface-raised`, `border-hairline`, …) resolve on NATIVE React Native — the
6
6
  // native equivalent of the `:root {}` block `global.css` provides on web.
7
7
  //
8
8
  // Why this exists: titan's Workout organisms reference semantic colors as CSS