@titan-design/react-ui 0.11.0 → 0.12.1

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 (152) 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 +27 -22
  6. package/dist/index.d.ts +27 -22
  7. package/dist/index.js +366 -561
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +360 -553
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/pages.js +242 -407
  12. package/dist/pages.js.map +1 -1
  13. package/dist/pages.mjs +242 -407
  14. package/dist/pages.mjs.map +1 -1
  15. package/dist/theme/index.d.mts +250 -1157
  16. package/dist/theme/index.d.ts +250 -1157
  17. package/dist/theme/index.js +234 -408
  18. package/dist/theme/index.js.map +1 -1
  19. package/dist/theme/index.mjs +227 -399
  20. package/dist/theme/index.mjs.map +1 -1
  21. package/dist/theme/tokens-css.js +141 -140
  22. package/dist/theme/tokens-css.js.map +1 -1
  23. package/dist/theme/tokens-css.mjs +141 -140
  24. package/dist/theme/tokens-css.mjs.map +1 -1
  25. package/dist/theme/tokens.d.mts +904 -0
  26. package/dist/theme/tokens.d.ts +904 -0
  27. package/dist/theme/tokens.js +856 -0
  28. package/dist/theme/tokens.js.map +1 -0
  29. package/dist/theme/tokens.mjs +833 -0
  30. package/dist/theme/tokens.mjs.map +1 -0
  31. package/package.json +7 -1
  32. package/src/arch/Architecture.stories.tsx +9 -9
  33. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  34. package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +3 -3
  35. package/src/components/custom/Fatigue/FatigueLights.stories.tsx +2 -2
  36. package/src/components/custom/Fatigue/GhostSpark.stories.tsx +3 -3
  37. package/src/components/custom/Fatigue/LiveFatigueCard.tsx +1 -1
  38. package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +2 -2
  39. package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +2 -2
  40. package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +4 -4
  41. package/src/components/custom/Fatigue/VelocityHero.stories.tsx +3 -3
  42. package/src/components/custom/Fatigue/VerdictHero.stories.tsx +2 -2
  43. package/src/components/custom/Fatigue/fatigue-tokens.ts +12 -12
  44. package/src/components/custom/Sidebar/Sidebar.tsx +4 -4
  45. package/src/components/custom/Table/Table.tsx +2 -2
  46. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +3 -3
  47. package/src/components/custom/Workout/BaseBadge.test.tsx +6 -1
  48. package/src/components/custom/Workout/BaseBadge.tsx +1 -1
  49. package/src/components/custom/Workout/BodyMap.tsx +2 -2
  50. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +2 -2
  51. package/src/components/custom/Workout/DeviationBar.test.tsx +2 -1
  52. package/src/components/custom/Workout/DeviationBar.tsx +3 -2
  53. package/src/components/custom/Workout/ExerciseCard.test.tsx +3 -3
  54. package/src/components/custom/Workout/ExerciseCard.tsx +2 -2
  55. package/src/components/custom/Workout/ExerciseDetailPage.tsx +4 -4
  56. package/src/components/custom/Workout/InputBar.tsx +2 -2
  57. package/src/components/custom/Workout/LiveAuraFrame.stories.tsx +1 -1
  58. package/src/components/custom/Workout/LiveAuraFrame.test.tsx +1 -1
  59. package/src/components/custom/Workout/LiveAuraFrame.tsx +3 -3
  60. package/src/components/custom/Workout/MesoCard.tsx +3 -1
  61. package/src/components/custom/Workout/MuscleGroupChip.tsx +4 -3
  62. package/src/components/custom/Workout/PrHistoryModal.tsx +2 -2
  63. package/src/components/custom/Workout/ProgramPlanningPage.tsx +1 -1
  64. package/src/components/custom/Workout/README.md +1 -1
  65. package/src/components/custom/Workout/ReadinessCheck.tsx +2 -2
  66. package/src/components/custom/Workout/RestTimer.tsx +2 -2
  67. package/src/components/custom/Workout/S3SetTypes.stories.tsx +3 -3
  68. package/src/components/custom/Workout/SessionHeader.stories.tsx +2 -2
  69. package/src/components/custom/Workout/SessionHeader.tsx +1 -1
  70. package/src/components/custom/Workout/SessionRail.stories.tsx +2 -2
  71. package/src/components/custom/Workout/SessionRail.tsx +13 -5
  72. package/src/components/custom/Workout/SetBar.tsx +3 -3
  73. package/src/components/custom/Workout/SetRow.test.tsx +6 -3
  74. package/src/components/custom/Workout/StatusDot.tsx +5 -4
  75. package/src/components/custom/Workout/StrengthTrendChart.tsx +1 -1
  76. package/src/components/custom/Workout/TrainingStatusPage.tsx +2 -2
  77. package/src/components/custom/Workout/VelocityStrip.test.tsx +5 -2
  78. package/src/components/custom/Workout/VelocityStrip.tsx +4 -4
  79. package/src/components/custom/Workout/VolumeLandmarkBar.tsx +3 -3
  80. package/src/components/custom/Workout/WeightBadge.test.tsx +6 -1
  81. package/src/components/custom/Workout/WeightBadge.tsx +2 -1
  82. package/src/components/custom/Workout/WorkoutCard.tsx +3 -1
  83. package/src/components/custom/Workout/WorkoutPill.tsx +5 -2
  84. package/src/components/custom/Workout/ZoneTrack.tsx +4 -4
  85. package/src/components/custom/Workout/setHeadingKit.tsx +14 -8
  86. package/src/components/custom/Workout/velocity-story-kit.tsx +2 -2
  87. package/src/components/custom/charts/SetBarChart.tsx +3 -3
  88. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  89. package/src/components/custom/stepper/Stepper.tsx +3 -3
  90. package/src/components/shell/DeviceMenu.tsx +2 -2
  91. package/src/components/shell/README.md +1 -1
  92. package/src/components/shell/SessionRailSpecimen.stories.tsx +1 -1
  93. package/src/components/shell/SideNav.tsx +1 -1
  94. package/src/components/shell/TopBar.tsx +1 -1
  95. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  96. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  97. package/src/components/ui/avatar/Avatar.tsx +2 -2
  98. package/src/components/ui/badge/Badge.tsx +2 -2
  99. package/src/components/ui/card/Card.tsx +3 -3
  100. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  101. package/src/components/ui/collapse/Collapse.tsx +1 -1
  102. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  103. package/src/components/ui/drawer/Drawer.tsx +3 -3
  104. package/src/components/ui/form-field/FormField.tsx +1 -1
  105. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  106. package/src/components/ui/menu/Menu.tsx +2 -2
  107. package/src/components/ui/pill/Pill.tsx +2 -2
  108. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  109. package/src/components/ui/popover/Popover.tsx +1 -1
  110. package/src/components/ui/progress/Progress.tsx +3 -3
  111. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  112. package/src/components/ui/radio/Radio.tsx +4 -4
  113. package/src/components/ui/select/Select.tsx +3 -3
  114. package/src/components/ui/surface/Surface.stories.tsx +3 -3
  115. package/src/components/ui/surface/Surface.test.tsx +32 -19
  116. package/src/components/ui/surface/Surface.tsx +3 -3
  117. package/src/components/ui/surface/SurfaceContext.ts +19 -29
  118. package/src/components/ui/surface/surface.contract.test.ts +58 -45
  119. package/src/components/ui/switch/Switch.tsx +1 -1
  120. package/src/components/ui/tabs/Tabs.tsx +2 -2
  121. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +5 -5
  122. package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +1 -1
  123. package/src/components/ui/toolbar-button/ToolbarButton.tsx +36 -22
  124. package/src/stories/PresetShowcase.stories.tsx +21 -21
  125. package/src/theme/ColorPalettes.stories.tsx +41 -38
  126. package/src/theme/ColorPrimitives.stories.tsx +89 -41
  127. package/src/theme/Depth.stories.tsx +312 -0
  128. package/src/theme/DepthCalibration.stories.tsx +655 -0
  129. package/src/theme/ThemeProvider.tsx +1 -1
  130. package/src/theme/barrel.ts +7 -14
  131. package/src/theme/color-stories.coverage.test.ts +15 -20
  132. package/src/theme/color-story-kit.tsx +1 -1
  133. package/src/theme/color-utils.ts +197 -0
  134. package/src/theme/config.ts +0 -8
  135. package/src/theme/depth-calibration.test.tsx +97 -0
  136. package/src/theme/elevation.test.ts +62 -1
  137. package/src/theme/elevation.ts +34 -8
  138. package/src/theme/global.css +47 -53
  139. package/src/theme/grey-ramp.test.ts +166 -0
  140. package/src/theme/materials.test.ts +106 -0
  141. package/src/theme/materials.ts +211 -0
  142. package/src/theme/presets/audiobook.ts +4 -4
  143. package/src/theme/presets/types.ts +3 -3
  144. package/src/theme/semantic-contrast.test.ts +83 -0
  145. package/src/theme/token-export-guard.test.ts +104 -0
  146. package/src/theme/tokens/primitives.ts +63 -116
  147. package/src/theme/tokens/semantic.ts +130 -82
  148. package/src/utils/colors.ts +1 -1
  149. package/tailwind.config.js +2 -5
  150. package/src/theme/bar-paper.ts +0 -59
  151. package/src/theme/shadows.stories.tsx +0 -523
  152. package/src/theme/shadows.ts +0 -439
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Surface MATERIALS — the treatments layered on top of the flat grey ramp.
3
+ *
4
+ * The ramp says how deep a plane sits. This says what it is made of. Two
5
+ * materials, deliberately: a matte PAPER sheet for hero surfaces, and a recessed
6
+ * INSET WELL for grouped or awaiting-data regions.
7
+ *
8
+ * SCOPE, and it is a hard rule rather than a preference: paper goes on hero
9
+ * surfaces only — the live card, the LOAD/verdict chip, the rest hero. The
10
+ * "everything is a paper card" look is explicitly NOT the model; it was an
11
+ * over-application built to find the ceiling. There are no LEVELS of paper. One
12
+ * treatment, used sparingly, or the texture stops meaning anything.
13
+ *
14
+ * Second hard rule: paper fills take DEEP/MUTED tones only (a ramp step, or a
15
+ * categorical `dark` variant). Vivid and neon colours are ink ON paper — never
16
+ * the paper itself.
17
+ *
18
+ * PLATFORM: `backgroundImage` and multi-layer `boxShadow` are web/RNW only. On
19
+ * native these are ignored and the surface falls back to its flat fill, which is
20
+ * why every material here keeps `backgroundColor` load-bearing on its own. A
21
+ * material must never be the only thing carrying meaning.
22
+ *
23
+ * ── THE TONAL FILL IS GONE (VW-99, S-6 wall calibration) ───────────────────
24
+ *
25
+ * `paperSheet` used to carry a vertical ΔL* 3 gradient, sized "just under seen
26
+ * as a gradient". On the wall panel at ~3 m it could not be told from a flat
27
+ * sheet of the same tone, and the full material still read as — the operator's
28
+ * words — "a flat digital rectangle". It failed its own stated purpose, which
29
+ * was precisely to stop that.
30
+ *
31
+ * Raising it was not available. The design ceiling is ~ΔL* 4 (past that it reads
32
+ * decorative, which the exploration rejected), and the same run showed why the
33
+ * headroom would not have helped: the 0.025 ELEVATION step — a comparable ΔL*
34
+ * — was called instantly, because it sits at a hard edge between two flat
35
+ * fields. The fill spread the same amount smoothly over ~300 px, where vision is
36
+ * genuinely poor. It is a spatial-frequency problem, not an amplitude one, so
37
+ * the band between "invisible" and "decorative" may simply not exist here.
38
+ *
39
+ * ── AND THE DITHER WENT WITH IT (VW-99 run 2) ──────────────────────────────
40
+ *
41
+ * `ditherTile` was held back one run on the grounds that "probably dead weight"
42
+ * is not a measurement. Run 1 could not measure it: with the fill invisible
43
+ * there was no gradient to band, so the panel returned a guaranteed clean sheet.
44
+ *
45
+ * Run 2 settled it by adding a hand-quantised REFERENCE beside the smooth
46
+ * control — the same tone span drawn as five hard-edged 1-level steps, which is
47
+ * what the artefact looks like when it happens. The reference banded plainly and
48
+ * the smooth gradient did not. So the panel can resolve banding; this render
49
+ * path simply does not produce it. A mitigation for an artefact that does not
50
+ * occur is cost with no benefit, and it is gone.
51
+ *
52
+ * That is a claim about THIS display, which is the one the product ships on and
53
+ * the reason `paperSheet` is hero-surface-only. A phone is a separate question.
54
+ *
55
+ * Kept: grain, rim-light, contact shadow.
56
+ *
57
+ * See sources/design/surface-system-north-star.md §4 and
58
+ * sources/runbooks/VW-99-depth-wall-calibration.md.
59
+ */
60
+ import type { ViewStyle } from 'react-native'
61
+ import { getSemanticColors } from './tokens/semantic'
62
+
63
+ const c = getSemanticColors('dark')
64
+
65
+ // ── colour math ─────────────────────────────────────────────────────────────
66
+
67
+ function parseHex(hex: string): [number, number, number] | null {
68
+ const h = hex.replace('#', '')
69
+ const full = h.length === 3 ? h.split('').map((ch) => ch + ch).join('') : h
70
+ const rgb: [number, number, number] = [
71
+ parseInt(full.slice(0, 2), 16),
72
+ parseInt(full.slice(2, 4), 16),
73
+ parseInt(full.slice(4, 6), 16),
74
+ ]
75
+ return rgb.some(Number.isNaN) ? null : rgb
76
+ }
77
+
78
+ /** sRGB perceived luminance (0 dark … 1 bright), Rec.601 weights. */
79
+ function perceivedLuminance(hex: string): number {
80
+ const rgb = parseHex(hex)
81
+ if (!rgb) return 0.5
82
+ const [r, g, b] = rgb
83
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255
84
+ }
85
+
86
+ // The CIELAB conversion chain (srgb2lin/lin2srgb/fLab/fInv, hexToLab/labToHex,
87
+ // shiftL) lived here so the tonal fill could be specified in ΔL*. It had no
88
+ // other caller, so it went with the fill — see the module header.
89
+
90
+ // ── grain ───────────────────────────────────────────────────────────────────
91
+
92
+ /**
93
+ * Grain opacity for a tone, scaled by perceived brightness.
94
+ *
95
+ * A fixed opacity reads hot on a dark plane and vanishes on a bright one, so
96
+ * darker tones get a whisper and brighter tones keep full texture. Anchored so a
97
+ * dark chip (lum ≈ 0.13) lands ≈ 0.06 and a bright bar (lum ≈ 0.58) lands ≈ 0.20.
98
+ * Above roughly 0.05α on a mid tone it starts reading as "dirty" rather than
99
+ * matte, which is what the clamp is protecting.
100
+ *
101
+ * This is the ONE curve all grain goes through — a card and a bar carry
102
+ * proportional, not identical, texture. That is what makes them read as the same
103
+ * material.
104
+ */
105
+ export function grainOpacityForTone(baseColor: string): number {
106
+ return Math.min(0.24, Math.max(0.04, 0.02 + 0.31 * perceivedLuminance(baseColor)))
107
+ }
108
+
109
+ /**
110
+ * The matte grain tile as a `backgroundImage` url — one shared `feTurbulence`
111
+ * fractal-noise tile, painted once, NEVER animated. Pair with
112
+ * `backgroundColor: baseColor`.
113
+ */
114
+ export function grainForTone(baseColor: string): string {
115
+ const op = grainOpacityForTone(baseColor).toFixed(3)
116
+ return (
117
+ `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E` +
118
+ `%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E` +
119
+ `%3Crect width='120' height='120' filter='url(%23n)' opacity='${op}'/%3E%3C/svg%3E")`
120
+ )
121
+ }
122
+
123
+ // `tonalFill` and `ditherTile` both lived here until VW-99 measured them on the
124
+ // wall. Deleted rather than left exported-but-unused; the reasoning is in the
125
+ // module header, which is the part worth keeping.
126
+
127
+ // ── the materials ───────────────────────────────────────────────────────────
128
+
129
+ /**
130
+ * A matte PAPER hero sheet: brightness-scaled grain + a crisp top rim-light +
131
+ * one soft contact shadow.
132
+ *
133
+ * The contact shadow is the ONE place a drop-shadow is still correct on a dark
134
+ * surface: it is large, soft, and describes a sheet lying on a plane rather than
135
+ * trying to encode hierarchy. Inline depth is the hairline's job.
136
+ *
137
+ * Defaults to `surface-raised` so a same-toned card laid inside reads as one
138
+ * continuous sheet rather than a seam.
139
+ *
140
+ * THE RIM-LIGHT IS A HAIRLINE. Run 2 of VW-99 called it "weak at distance" on
141
+ * both the card and the hero sheet, and the reason is visible in the number: it
142
+ * was `rgba(255,255,255,0.10)`, a 1px white line on a dark plane — physically
143
+ * the same cue as `hairline-*`, but set BELOW `hairline-default`, which the same
144
+ * run had just had to raise twice. It is not a subtler species of edge, it was
145
+ * simply mistuned. Raised to `0.20`, between `hairline-default` and
146
+ * `hairline-strong`: this edge defines a hero surface, so it earns more than the
147
+ * default separator, and unlike a separator there is no fallback behind it.
148
+ *
149
+ * Deliberately NOT wired to the token, though the values now agree in spirit: a
150
+ * box-shadow rim and a border hairline are retuned by different evidence, and
151
+ * coupling them means a hairline run silently restyles every hero sheet.
152
+ *
153
+ * HERO SURFACES ONLY, MUTED TONES ONLY — see the module header.
154
+ */
155
+ export function paperSheet(tone: string = c['surface-raised']): ViewStyle {
156
+ return {
157
+ backgroundColor: tone,
158
+ // Grain alone. The dither went with the gradient it protected — see header.
159
+ backgroundImage: grainForTone(tone),
160
+ boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.20), 0 8px 22px rgba(0,0,0,0.50)',
161
+ } as unknown as ViewStyle
162
+ }
163
+
164
+ /**
165
+ * A recessed INSET WELL: a tone pressed BELOW the surrounding plane, cut in by an
166
+ * inner top shadow with a faint light line on the floor.
167
+ *
168
+ * The bottom rim is the load-bearing half. A dark inset alone just reads as a
169
+ * darker rectangle; it is the light catching the floor that says "this is below
170
+ * the surface" — which is why the treatment survives at 16px where a full paper
171
+ * treatment does not.
172
+ *
173
+ * The floor light was `0.04` and is now `0.12`, chosen on the wall in VW-99 run
174
+ * 2 against a `.04`-vs-`.12`-vs-none forced choice. Worth recording that `.04`
175
+ * was NOT invisible — the no-line decoy was correctly rejected and both real
176
+ * values were seen, so this is a preference between two working values, not a
177
+ * rescue. The rim-light next door was a genuine miss; this one was only thin.
178
+ *
179
+ * Note this is a MATERIAL, and independent of the surface LEVEL named `inset`
180
+ * that was retired in TD-07.14. A well is something you apply; a level is
181
+ * somewhere you sit.
182
+ */
183
+ export function insetWell(tone: string = c['surface-input']): ViewStyle {
184
+ return {
185
+ backgroundColor: tone,
186
+ boxShadow: 'inset 0 2px 6px rgba(0,0,0,0.55), inset 0 -1px 0 rgba(255,255,255,0.12)',
187
+ } as unknown as ViewStyle
188
+ }
189
+
190
+ /**
191
+ * Paper for a COLORED BAR — the same material as {@link paperSheet}, tuned for a
192
+ * small saturated element: grain + top rim-light + a contact shadow cast past the
193
+ * bar's leading edge, so a filled bar reads as a physical block that has travelled.
194
+ *
195
+ * No tonal fill or dither here: a bar is too narrow for a ΔL* 3 gradient to read,
196
+ * and too narrow to band.
197
+ *
198
+ * `flip` = a vertically mirrored (`scaleY(-1)`) plot, i.e. the `down` wing of a
199
+ * diverging chart. The contact shadow's y-offset is pre-inverted so the mirror
200
+ * lands it pointing AWAY from the shared axis rather than up across it, and the
201
+ * top rim-light is DROPPED — mirrored it would sit on the axis edge and read
202
+ * wrong, so grain and shadow carry the material alone.
203
+ */
204
+ export function barPaper(color: string, flip = false): ViewStyle {
205
+ return {
206
+ backgroundImage: grainForTone(color),
207
+ boxShadow: flip
208
+ ? '0 -6px 16px rgba(0,0,0,0.45)'
209
+ : 'inset 0 1.5px 0 rgba(255,255,255,0.22), 0 6px 16px rgba(0,0,0,0.45)',
210
+ } as unknown as ViewStyle
211
+ }
@@ -49,7 +49,7 @@ export const audiobookPreset: ThemePreset = {
49
49
  'text-secondary': '#b5b3af',
50
50
  'text-tertiary': '#7a7875',
51
51
 
52
- // Surfaces: warm charcoals (not pure gray)
52
+ // Surfaces: warm greys (not pure gray)
53
53
  'surface-base': '#101114',
54
54
  'surface-elevated': '#15171c',
55
55
  'surface-raised': '#1a1d24',
@@ -62,9 +62,9 @@ export const audiobookPreset: ThemePreset = {
62
62
  'background-subtle': '#1a1d24',
63
63
 
64
64
  // Borders
65
- 'border-default': 'rgba(255, 255, 255, 0.06)',
66
- 'border-subtle': 'rgba(255, 255, 255, 0.03)',
67
- 'border-strong': '#2d323d',
65
+ 'hairline-default': 'rgba(255, 255, 255, 0.06)',
66
+ 'hairline-subtle': 'rgba(255, 255, 255, 0.03)',
67
+ 'hairline-strong': '#2d323d',
68
68
  'border-focus': '#d4782a',
69
69
  'border-input': '#2d323d',
70
70
  'border-input-hover': '#454c5c',
@@ -28,9 +28,9 @@ export interface ThemePresetColors {
28
28
  'background-base'?: string
29
29
  'background-default'?: string
30
30
  'background-subtle'?: string
31
- 'border-default'?: string
32
- 'border-subtle'?: string
33
- 'border-strong'?: string
31
+ 'hairline-default'?: string
32
+ 'hairline-subtle'?: string
33
+ 'hairline-strong'?: string
34
34
  'border-focus'?: string
35
35
  'divider'?: string
36
36
  [key: `${string}`]: string | undefined
@@ -0,0 +1,83 @@
1
+ /**
2
+ * TD-07.15 — on-surface text contrast, as a gate rather than a document.
3
+ *
4
+ * The warm grey migration was validated for ΔE, L* and warmth long before it
5
+ * was validated for CONTRAST, and the contrast check is the one that found a
6
+ * real defect: `text-tertiary` was failing WCAG large-text on three of the six
7
+ * dark planes and had been for as long as the surface ramp had existed. Nobody
8
+ * had run the numbers because the greys "looked fine".
9
+ *
10
+ * So the numbers run in CI now. If a text token is retuned into a failure, this
11
+ * fails first.
12
+ */
13
+ import { describe, it, expect } from 'vitest'
14
+ import { greyRamp } from './tokens/primitives'
15
+ import { semanticColorsDark } from './tokens/semantic'
16
+
17
+ const hex2rgb = (h: string): [number, number, number] => {
18
+ const n = parseInt(h.slice(1), 16)
19
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
20
+ }
21
+ const srgb2lin = (c: number) => {
22
+ const s = c / 255
23
+ return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
24
+ }
25
+ const relLum = (hex: string) => {
26
+ const [r, g, b] = hex2rgb(hex).map(srgb2lin)
27
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
28
+ }
29
+ const contrast = (a: string, b: string) => {
30
+ const [hi, lo] = [relLum(a), relLum(b)].sort((x, y) => y - x)
31
+ return (hi + 0.05) / (lo + 0.05)
32
+ }
33
+
34
+ /** Every plane text can land on, darkest first. */
35
+ const PLANES = [975, 950, 925, 900, 875, 850] as const
36
+
37
+ /**
38
+ * Floors per role, not one blanket 4.5. `text-tertiary` is by definition the
39
+ * de-emphasised role — captions, units, disabled labels — so WCAG's large-text
40
+ * 3:1 is the honest bar for it. Holding it to 4.5 would either be a lie we
41
+ * suppress or a change that erases the distinction between the three roles.
42
+ */
43
+ const TEXT_ROLES = [
44
+ { role: 'text-primary', min: 7, note: 'body copy — AAA' },
45
+ { role: 'text-secondary', min: 4.5, note: 'supporting copy — AA' },
46
+ { role: 'text-tertiary', min: 3, note: 'de-emphasised — AA large' },
47
+ ] as const
48
+
49
+ describe('semantic text on the surface planes (dark)', () => {
50
+ for (const { role, min, note } of TEXT_ROLES) {
51
+ describe(`${role} (${note})`, () => {
52
+ it.each(PLANES)(`clears ${min}:1 on grey-%s`, (plane) => {
53
+ const fg = semanticColorsDark[role as keyof typeof semanticColorsDark] as string
54
+ const ratio = contrast(fg, greyRamp[plane])
55
+ expect(
56
+ ratio,
57
+ `${role} ${fg} on grey-${plane} ${greyRamp[plane]} is ${ratio.toFixed(2)}:1, needs ${min}`,
58
+ ).toBeGreaterThanOrEqual(min)
59
+ })
60
+ })
61
+ }
62
+
63
+ /**
64
+ * The roles must stay ORDERED as well as legible. A migration that lifted
65
+ * tertiary until it out-contrasted secondary would pass every check above
66
+ * while destroying the hierarchy the three roles exist to express.
67
+ */
68
+ it('keeps primary > secondary > tertiary in contrast on every plane', () => {
69
+ for (const plane of PLANES) {
70
+ const [pri, sec, ter] = TEXT_ROLES.map(({ role }) =>
71
+ contrast(semanticColorsDark[role as keyof typeof semanticColorsDark] as string, greyRamp[plane]),
72
+ )
73
+ expect(pri, `on grey-${plane}: primary ${pri.toFixed(2)} vs secondary ${sec.toFixed(2)}`).toBeGreaterThan(sec)
74
+ expect(sec, `on grey-${plane}: secondary ${sec.toFixed(2)} vs tertiary ${ter.toFixed(2)}`).toBeGreaterThan(ter)
75
+ }
76
+ })
77
+
78
+ // Border visibility is NOT checked here. The separators are alpha now, and a
79
+ // WCAG ratio needs two opaque colours — feeding `rgba(…)` to a hex parser
80
+ // silently yields black, so such a check passes without measuring anything.
81
+ // Alpha separators are validated by compositing them over each plane in
82
+ // `surface.contract.test.ts` (R3 for the hairlines, R4 for border-prominent).
83
+ })
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ /**
7
+ * Token-export guard (TP-16).
8
+ *
9
+ * `@titan-design/react-ui/theme/tokens` is the token-only subpath for consumers
10
+ * that are NOT react-native apps — plain web builds that want `getSemanticColors`
11
+ * and the primitive/semantic token maps and nothing else.
12
+ *
13
+ * The bug this guards: before TP-16 the only subpath carrying `getSemanticColors`
14
+ * was `./theme`, whose entry re-exports the `ThemeProvider` VALUE. That drags in
15
+ * nativewind -> react-native-css-interop, which ships raw JSX in a `.js` file that
16
+ * neither plain Node ESM nor a Vite/rollup build can parse — a web consumer just
17
+ * got a build failure at `react-native-css-interop/dist/doctor.js`. A type-check
18
+ * would never catch it, so this guard is static and structural instead.
19
+ *
20
+ * It walks the SOURCE import graph rooted at the subpath entry and asserts nothing
21
+ * reachable imports react, react-native, nativewind, or gluestack. Source-level
22
+ * (not dist-level) so it runs in the fast project with no build step.
23
+ */
24
+
25
+ const themeDir = path.dirname(fileURLToPath(import.meta.url))
26
+ const uiRoot = path.resolve(themeDir, '..', '..')
27
+
28
+ const TOKEN_ENTRY = path.join(themeDir, 'tokens', 'index.ts')
29
+ const FORBIDDEN = ['react', 'react-dom', 'react-native', 'react-native-web', 'nativewind']
30
+
31
+ function resolveRelative(fromFile: string, specifier: string): string | null {
32
+ const base = path.resolve(path.dirname(fromFile), specifier)
33
+ for (const candidate of [`${base}.ts`, `${base}.tsx`, path.join(base, 'index.ts')]) {
34
+ if (fs.existsSync(candidate)) return candidate
35
+ }
36
+ return null
37
+ }
38
+
39
+ /** Every `from '...'` specifier in a module, ignoring type-only imports (erased at build). */
40
+ function importSpecifiers(source: string): string[] {
41
+ const stripped = source.replace(/^\s*(import|export)\s+type\s[^;]*;?$/gm, '')
42
+ const matches = stripped.matchAll(/(?:import|export)[^'"]*?from\s*['"]([^'"]+)['"]/g)
43
+ return [...matches].map((m) => m[1])
44
+ }
45
+
46
+ /** Walk the source import graph from `entry`, returning every reachable file + bare specifier. */
47
+ function collectGraph(entry: string): { files: string[]; bare: string[] } {
48
+ const seen = new Set<string>()
49
+ const bare = new Set<string>()
50
+ const queue = [entry]
51
+ while (queue.length > 0) {
52
+ const file = queue.pop()!
53
+ if (seen.has(file)) continue
54
+ seen.add(file)
55
+ for (const specifier of importSpecifiers(fs.readFileSync(file, 'utf8'))) {
56
+ if (!specifier.startsWith('.')) {
57
+ bare.add(specifier)
58
+ continue
59
+ }
60
+ const resolved = resolveRelative(file, specifier)
61
+ if (resolved) queue.push(resolved)
62
+ }
63
+ }
64
+ return { files: [...seen], bare: [...bare] }
65
+ }
66
+
67
+ describe('token-only subpath export (TP-16)', () => {
68
+ it('is declared in the package exports map', () => {
69
+ const pkg = JSON.parse(fs.readFileSync(path.join(uiRoot, 'package.json'), 'utf8'))
70
+ expect(pkg.exports['./theme/tokens']).toEqual({
71
+ 'react-native': './src/theme/tokens/index.ts',
72
+ types: './dist/theme/tokens.d.ts',
73
+ import: './dist/theme/tokens.mjs',
74
+ require: './dist/theme/tokens.js',
75
+ })
76
+ })
77
+
78
+ it('has a matching tsup build entry so the dist files actually exist', () => {
79
+ const tsupConfig = fs.readFileSync(path.join(uiRoot, 'tsup.config.ts'), 'utf8')
80
+ expect(tsupConfig).toContain(`'theme/tokens': 'src/theme/tokens/index.ts'`)
81
+ })
82
+
83
+ it('reaches no react / react-native / nativewind module in its import graph', () => {
84
+ const { bare } = collectGraph(TOKEN_ENTRY)
85
+ const leaked = bare.filter((s) =>
86
+ FORBIDDEN.some((f) => s === f || s.startsWith(`${f}/`)),
87
+ )
88
+ expect(leaked).toEqual([])
89
+ })
90
+
91
+ it('reaches no gluestack module in its import graph', () => {
92
+ const { bare } = collectGraph(TOKEN_ENTRY)
93
+ expect(bare.filter((s) => s.includes('gluestack'))).toEqual([])
94
+ })
95
+
96
+ it('exports getSemanticColors returning real token values in both modes', async () => {
97
+ const tokens = await import('./tokens/index')
98
+ for (const mode of ['dark', 'light'] as const) {
99
+ const colors = tokens.getSemanticColors(mode)
100
+ expect(Object.keys(colors).length).toBeGreaterThan(0)
101
+ expect(colors['brand-primary']).toMatch(/^#|^rgba?\(/)
102
+ }
103
+ })
104
+ })
@@ -5,124 +5,82 @@
5
5
  * and should rarely be used directly in components. Use semantic tokens instead.
6
6
  */
7
7
 
8
+ /**
9
+ * Absolute colour keywords.
10
+ *
11
+ * This used to hold five scales — `blue`, `red`, `redVivid`, `neutral` and
12
+ * `charcoal`. All five are gone (TD-07.14): the greys folded into `greyRamp`
13
+ * below and the chromatics into the OKLCH `primitiveRamps`. What is left has no
14
+ * scale because these three are not colours you tune, they are keywords.
15
+ */
8
16
  export const primitiveColors = {
9
- // Blue scale
10
- blue: {
11
- 50: '#EFF6FF',
12
- 100: '#DBEAFE',
13
- 200: '#BFDBFE',
14
- 300: '#93C5FD',
15
- 400: '#60A5FA',
16
- 500: '#3B82F6',
17
- 600: '#5048E5',
18
- 700: '#3832A0',
19
- 800: '#1E40AF',
20
- 900: '#1E3A8A',
21
- },
22
-
23
- // Red scale
24
- red: {
25
- 50: '#FEF2F2',
26
- 100: '#FEE2E2',
27
- 200: '#FECACA',
28
- 300: '#FCA5A5',
29
- 400: '#DA6868',
30
- 500: '#EF4444',
31
- 600: '#D14343',
32
- 700: '#922E2E',
33
- 800: '#991B1B',
34
- 900: '#7F1D1D',
35
- },
36
-
37
- // Vivid red scale
38
- redVivid: {
39
- 50: '#FFECEE',
40
- 100: '#FFD6DB',
41
- 200: '#FFB3BC',
42
- 300: '#FF8593',
43
- 400: '#FF6070',
44
- 500: '#FF4757',
45
- 600: '#E63548',
46
- 700: '#C42539',
47
- 800: '#9E1C2C',
48
- 900: '#7A1520',
49
- },
50
-
51
- // Neutral/Gray scale
52
- neutral: {
53
- 50: '#FAFAFA',
54
- 100: '#F3F4F6',
55
- 200: '#E5E7EB',
56
- 300: '#D1D5DB',
57
- 400: '#9CA3AF',
58
- 500: '#6B7280',
59
- 600: '#4B5563',
60
- 700: '#374151',
61
- 800: '#1F2937',
62
- 900: '#111827',
63
- 950: '#030712',
64
- },
65
-
66
- // Charcoal scale (dark backgrounds)
67
- charcoal: {
68
- 0: '#6E6E6E',
69
- 50: '#5D5D5D',
70
- 100: '#4C4C4C',
71
- 200: '#3C3C3C',
72
- 300: '#2C2C2C',
73
- 400: '#1F1F1F',
74
- 500: '#1C1C1C',
75
- 600: '#191919',
76
- 700: '#161616',
77
- 800: '#131313',
78
- 900: '#101010',
79
- },
80
-
81
- // Pure colors
82
17
  white: '#FFFFFF',
83
18
  black: '#000000',
84
19
  transparent: 'transparent',
85
20
  } as const
86
21
 
87
22
  /**
88
- * Surface ramp dark mode (TD-surface-tokens, S-1, re-spaced S-3)
23
+ * Unified warm-neutral grey ramp (TD-07.14)
24
+ *
25
+ * The ONE achromatic scale. Regenerate with `scripts/generate-grey-ramp.mjs` —
26
+ * preserve the generator, not just the hexes.
27
+ *
28
+ * The surface planes ARE steps here rather than a parallel object: `850` is
29
+ * overlay, `975` is the bezel. That is the whole point of the ramp. `grey`,
30
+ * `neutral`, `surfaceRampDark` and `backgroundFrameDark` all resolve into it,
31
+ * which is why every plane step below is byte-identical to the value that
32
+ * shipped in v0.10.0 — the surfaces do not move, only their names.
33
+ *
34
+ * TWO THINGS ABOUT THE NUMBERING, both deliberate and both previously re-derived
35
+ * from scratch more than once because they were never written down:
89
36
  *
90
- * Warm-tapered, DERIVED ramp NOT hand-picked. Shipped verbatim as the output of
91
- * `deriveSurfaceRamp(shellL=9, steps=[4.5,3.5,3,2.5], rbShadow=6, rbHilite=1.5)`
92
- * (see `packages/ui/src/components/ui/surface/surface-lab-shared.tsx` on branch
93
- * `feat/TD-unified-lab`, story `Lab/Surface Exploration -> Surface Ramp System`
94
- * — that lab default is `[4.5,2.5,2,1.5]` as of this writing and should be
95
- * updated to match on that branch, follow-up, not done here).
96
- * Lightness is CIELAB L* off the shell, with DIMINISHING steps (4.5/3.5/3/2.5)
97
- * the frame->content jump is biggest, each plane above adds less, but the top
98
- * three planes (elevated/raised/overlay) were re-spaced WIDER (S-3) than the
99
- * original 2.5/2/1.5 taper so they read as distinct planes without leaning on
100
- * the hairline alone. `background`/`base`/`inset` are unchanged by the re-space.
101
- * Warmth (R-B) tapers 6 -> 1.5 from frame to hero so bright content planes stay
102
- * near-neutral.
103
- * Kept separate from the `charcoal` scale above (which several existing
104
- * components reference directly for unrelated shadow/track tints) so this
105
- * change is additive and doesn't ripple into those consumers.
106
- * See coordination/design-explorations/surface-system-north-star.md.
37
+ * 1. `975` sits BELOW the standard grid. `canonL` the median L* of the seven
38
+ * chromatic `primitiveRamps` at each step — ends at L*10.3, which is step
39
+ * `950`. The bezel is L*3.8, darker than the grid's last rung, so there is
40
+ * simply no step number left for it and `975` extends past the end.
41
+ * 2. `850`/`875` are quarter-steps because overlay/raised/elevated are only
42
+ * ~2.7 L* apart and all three collide on `900` at normal resolution. `800`
43
+ * is a generated bridge between `700` (L*37.9) and overlay (L*22.7) so the
44
+ * spacing stays even across the join.
45
+ *
46
+ * There is no `inset`. It was retired, not renamed: at ΔE 1.10 from `975` it was
47
+ * an imperceptible duplicate of the frame, which is exactly why `<Surface pressed>`
48
+ * (surface 1) kept collapsing into it. `975` is the floor.
49
+ *
50
+ * Spec, generator and the rejected v1/v2 cuts:
51
+ * sources/design/foundations/warm-grey-ramp/
107
52
  */
108
- export const surfaceRampDark = {
109
- inset: '#13100D', // L*4.5 — sub-shell well / pressed
110
- background: '#1C1916', // L*9 — shell / frame
111
- base: '#252321', // L*13.5 main content plane
112
- elevated: '#2C2A28', // L*17 — nav / rail
113
- raised: '#31302F', // L*20 — cards
114
- overlay: '#373635', // L*22.5 — hero / popover
53
+ export const greyRamp = {
54
+ 50: '#F9F6F3', // L*97.0 W6
55
+ 100: '#EDEAE7', // L*92.8 W6
56
+ 200: '#D4D1CE', // L*84.0 W6 warm silver
57
+ 300: '#BDBAB7', // L*75.7 W6
58
+ 400: '#A29F9D', // L*65.7 W5
59
+ 500: '#888684', // L*56.0 W4
60
+ 600: '#72716F', // L*47.7 W3
61
+ 700: '#5A5958', // L*37.9 W2
62
+ 800: '#424140', // L*27.6 W2 — generated bridge
63
+ 850: '#373635', // L*22.7 W2 — surface overlay
64
+ 875: '#31302F', // L*19.9 W2 — surface raised
65
+ 900: '#2C2A28', // L*17.2 W4 — surface elevated
66
+ 925: '#252321', // L*13.9 W4 — surface base
67
+ 950: '#1C1916', // L*9.0 W6 — background base / shell
68
+ 975: '#100D0A', // L*3.8 W6 — background frame / bezel
115
69
  } as const
116
70
 
117
71
  /**
118
- * Frame/bezel primitivedark mode (TD-surface-tokens, S-3)
72
+ * The surface planes, as ramp steps the mapping `<Surface level>` resolves through.
119
73
  *
120
- * The chrome that "exits the content ramp" entirely: the top bar + side nav
121
- * shell. Sits ONE STEP BELOW `surfaceRampDark.background` (L*9), darker even
122
- * than `surfaceRampDark.inset` (L*4.5) — it isn't a plane IN the ramp, it's
123
- * the bezel the ramp sits inside. Literal, not `deriveSurfaceRamp()` output.
74
+ * Ordered darkest -> lightest, which is the order `pressedLevel()` walks.
124
75
  */
125
- export const backgroundFrameDark = '#100D0A' // L*3.79
76
+ export const SURFACE_PLANE_STEPS = {
77
+ frame: 975,
78
+ background: 950,
79
+ base: 925,
80
+ elevated: 900,
81
+ raised: 875,
82
+ overlay: 850,
83
+ } as const
126
84
 
127
85
  /**
128
86
  * Derived tonal ramps (TD-05.09 color foundations)
@@ -132,7 +90,7 @@ export const backgroundFrameDark = '#100D0A' // L*3.79
132
90
  * hexes. `pin` marks the step each ramp flows through its anchor.
133
91
  * This is the single source of truth for chromatic hexes — the categorical palette
134
92
  * below references these steps rather than duplicating values.
135
- * See coordination/design-explorations/foundations.
93
+ * See sources/design/foundations.
136
94
  */
137
95
  export const primitiveRamps = {
138
96
  red: {
@@ -507,17 +465,6 @@ export const primitiveBorderRadius = {
507
465
  full: '9999px',
508
466
  } as const
509
467
 
510
- export const primitiveShadows = {
511
- none: 'none',
512
- sm: '0px 1px 2px rgba(100, 116, 139, 0.12)',
513
- DEFAULT: '0px 1px 3px rgba(100, 116, 139, 0.12), 0px 1px 2px rgba(100, 116, 139, 0.24)',
514
- md: '0px 4px 6px rgba(100, 116, 139, 0.12)',
515
- lg: '0px 10px 15px rgba(100, 116, 139, 0.12)',
516
- xl: '0px 20px 25px rgba(100, 116, 139, 0.12)',
517
- '2xl': '0px 25px 50px rgba(100, 116, 139, 0.25)',
518
- inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.06)',
519
- } as const
520
-
521
468
  export const primitiveBreakpoints = {
522
469
  xs: 0,
523
470
  sm: 600,