@titan-design/react-ui 0.13.0 → 0.14.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 (146) hide show
  1. package/README.md +14 -0
  2. package/dist/bodymap.js +129 -76
  3. package/dist/bodymap.js.map +1 -1
  4. package/dist/bodymap.mjs +129 -76
  5. package/dist/bodymap.mjs.map +1 -1
  6. package/dist/index.d.mts +117 -26
  7. package/dist/index.d.ts +117 -26
  8. package/dist/index.js +677 -450
  9. package/dist/index.js.map +1 -1
  10. package/dist/index.mjs +673 -451
  11. package/dist/index.mjs.map +1 -1
  12. package/dist/{pages-_3Ajpiqu.d.ts → pages-CsjsUn0G.d.ts} +30 -13
  13. package/dist/{pages-CpW3OB3g.d.mts → pages-ZbT_pD_U.d.mts} +30 -13
  14. package/dist/pages.d.mts +1 -1
  15. package/dist/pages.d.ts +1 -1
  16. package/dist/pages.js +676 -701
  17. package/dist/pages.js.map +1 -1
  18. package/dist/pages.mjs +676 -701
  19. package/dist/pages.mjs.map +1 -1
  20. package/dist/theme/index.d.mts +31 -7
  21. package/dist/theme/index.d.ts +31 -7
  22. package/dist/theme/index.js +84 -20
  23. package/dist/theme/index.js.map +1 -1
  24. package/dist/theme/index.mjs +83 -21
  25. package/dist/theme/index.mjs.map +1 -1
  26. package/dist/theme/tokens-css.js +84 -20
  27. package/dist/theme/tokens-css.js.map +1 -1
  28. package/dist/theme/tokens-css.mjs +84 -20
  29. package/dist/theme/tokens-css.mjs.map +1 -1
  30. package/dist/theme/tokens.d.mts +56 -7
  31. package/dist/theme/tokens.d.ts +56 -7
  32. package/dist/theme/tokens.js +72 -20
  33. package/dist/theme/tokens.js.map +1 -1
  34. package/dist/theme/tokens.mjs +71 -21
  35. package/dist/theme/tokens.mjs.map +1 -1
  36. package/docs/render-testing-pattern.md +130 -0
  37. package/package.json +1 -1
  38. package/src/arch/Architecture.stories.tsx +3 -1
  39. package/src/arch/arch-graph.json +74 -72
  40. package/src/components/custom/ActiveWork/InitiativeBrief.tsx +2 -1
  41. package/src/components/custom/CircularTimer/CircularTimer.stories.tsx +8 -3
  42. package/src/components/custom/EmptyState/EmptyState.stories.tsx +1 -1
  43. package/src/components/custom/Gauge/Gauge.stories.tsx +4 -1
  44. package/src/components/custom/Gauge/Gauge.tsx +3 -1
  45. package/src/components/custom/Scatter/Scatter.stories.tsx +10 -7
  46. package/src/components/custom/Scatter/Scatter.tsx +6 -4
  47. package/src/components/custom/Sidebar/Sidebar.stories.tsx +1 -1
  48. package/src/components/custom/Table/Table.stories.tsx +1 -1
  49. package/src/components/custom/Table/Table.tsx +2 -2
  50. package/src/components/custom/Treemap/Treemap.stories.tsx +13 -10
  51. package/src/components/custom/Treemap/Treemap.tsx +4 -1
  52. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +2 -1
  53. package/src/components/custom/Workout/BaseBadge.stories.tsx +11 -7
  54. package/src/components/custom/Workout/BaseBadge.tsx +3 -2
  55. package/src/components/custom/Workout/BodyMap.tsx +5 -3
  56. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +13 -3
  57. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +5 -3
  58. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +2 -1
  59. package/src/components/custom/Workout/CapacityBandChart.tsx +5 -3
  60. package/src/components/custom/Workout/DeviationBar.test.tsx +5 -2
  61. package/src/components/custom/Workout/DeviationBar.tsx +5 -2
  62. package/src/components/custom/Workout/DualVelocityStrip.stories.tsx +7 -6
  63. package/src/components/custom/Workout/DualVelocityStrip.test.tsx +29 -16
  64. package/src/components/custom/Workout/ExerciseCard.stories.tsx +8 -8
  65. package/src/components/custom/Workout/ExerciseCard.test.tsx +39 -19
  66. package/src/components/custom/Workout/ExerciseCard.tsx +92 -167
  67. package/src/components/custom/Workout/ExerciseCardHeading.stories.tsx +89 -6
  68. package/src/components/custom/Workout/ExerciseCardHeading.test.tsx +189 -1
  69. package/src/components/custom/Workout/ExerciseCardHeading.tsx +167 -18
  70. package/src/components/custom/Workout/ExerciseDetailPage.tsx +3 -2
  71. package/src/components/custom/Workout/ExerciseHeading.tsx +192 -47
  72. package/src/components/custom/Workout/ExerciseIndicator.stories.tsx +6 -5
  73. package/src/components/custom/Workout/InputBar.tsx +4 -2
  74. package/src/components/custom/Workout/IntensityBar.tsx +4 -1
  75. package/src/components/custom/Workout/MesoCard.tsx +2 -1
  76. package/src/components/custom/Workout/MesoStatusCard.tsx +11 -10
  77. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +8 -3
  78. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +12 -2
  79. package/src/components/custom/Workout/README.md +58 -12
  80. package/src/components/custom/Workout/ReadinessCheck.tsx +4 -2
  81. package/src/components/custom/Workout/RestTimer.stories.tsx +13 -2
  82. package/src/components/custom/Workout/RestTimer.tsx +4 -2
  83. package/src/components/custom/Workout/SegmentedBar.stories.tsx +16 -12
  84. package/src/components/custom/Workout/SegmentedBar.tsx +4 -3
  85. package/src/components/custom/Workout/SetStrip.stories.tsx +3 -2
  86. package/src/components/custom/Workout/SetsRepsLoad.tsx +23 -9
  87. package/src/components/custom/Workout/Sparkline.stories.tsx +13 -10
  88. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +2 -1
  89. package/src/components/custom/Workout/StrengthTrendChart.tsx +8 -6
  90. package/src/components/custom/Workout/TempoDisplay.stories.tsx +4 -3
  91. package/src/components/custom/Workout/TempoDisplay.tsx +2 -0
  92. package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +3 -2
  93. package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +1 -1
  94. package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +40 -0
  95. package/src/components/custom/Workout/VelocityStrip.test.tsx +48 -6
  96. package/src/components/custom/Workout/VelocityStrip.tsx +23 -14
  97. package/src/components/custom/Workout/ZoneTrack.stories.tsx +3 -1
  98. package/src/components/custom/Workout/exerciseRowState.ts +29 -0
  99. package/src/components/custom/Workout/index.ts +12 -2
  100. package/src/components/custom/Workout/metricText.stories.tsx +9 -8
  101. package/src/components/custom/Workout/metricText.tsx +4 -2
  102. package/src/components/custom/Workout/setHeadingKit.tsx +12 -10
  103. package/src/components/custom/Workout/velocity-story-kit.tsx +2 -2
  104. package/src/components/custom/charts/README.md +5 -0
  105. package/src/components/custom/charts/SetBarChart.test.tsx +16 -0
  106. package/src/components/custom/charts/SetBarChart.tsx +14 -5
  107. package/src/components/custom/charts/flatBarGeometry.test.ts +41 -0
  108. package/src/components/custom/charts/flatBarGeometry.ts +32 -0
  109. package/src/components/custom/stepper/Stepper.tsx +8 -6
  110. package/src/components/ui/alert/Alert.stories.tsx +14 -4
  111. package/src/components/ui/alert/Alert.tsx +24 -3
  112. package/src/components/ui/button/Button.tsx +12 -12
  113. package/src/components/ui/checkbox/Checkbox.tsx +2 -2
  114. package/src/components/ui/data-row/DataRow.stories.tsx +6 -2
  115. package/src/components/ui/drawer/Drawer.tsx +4 -1
  116. package/src/components/ui/icon-box/IconBox.tsx +8 -6
  117. package/src/components/ui/indicator/Indicator.stories.tsx +4 -3
  118. package/src/components/ui/list-item/ListItem.stories.tsx +7 -4
  119. package/src/components/ui/modal/Modal.tsx +2 -0
  120. package/src/components/ui/pill/Pill.stories.tsx +53 -0
  121. package/src/components/ui/pill/Pill.tsx +10 -6
  122. package/src/components/ui/popover/Popover.stories.tsx +1 -1
  123. package/src/components/ui/progress/Progress.tsx +9 -6
  124. package/src/components/ui/radio/Radio.tsx +1 -1
  125. package/src/components/ui/section/Section.stories.tsx +10 -10
  126. package/src/components/ui/select/Select.tsx +2 -1
  127. package/src/components/ui/spinner/Spinner.tsx +3 -1
  128. package/src/components/ui/stack/Stack.stories.tsx +7 -7
  129. package/src/components/ui/surface/Surface.stories.tsx +75 -28
  130. package/src/components/ui/switch/Switch.tsx +1 -1
  131. package/src/components/ui/tabs/Tabs.tsx +4 -2
  132. package/src/components/ui/toast/Toast.tsx +6 -4
  133. package/src/components/ui/toolbar-button/ToolbarButton.tsx +13 -6
  134. package/src/stories/PresetShowcase.stories.tsx +1 -1
  135. package/src/test/no-raw-color.test.ts +39 -0
  136. package/src/theme/Alpha.stories.tsx +103 -0
  137. package/src/theme/ProposedTokensVW82.stories.tsx +414 -0
  138. package/src/theme/config.ts +12 -0
  139. package/src/theme/global.css +15 -3
  140. package/src/theme/subtle-tone-weight.test.ts +163 -0
  141. package/src/theme/tailwind-var-opacity.test.ts +163 -0
  142. package/src/theme/tokens/primitives.ts +33 -0
  143. package/src/theme/tokens/semantic-hex-guard.test.ts +322 -0
  144. package/src/theme/tokens/semantic.ts +56 -20
  145. package/src/utils/colors.alpha-adoption.test.ts +408 -0
  146. package/tailwind.config.js +50 -9
@@ -0,0 +1,163 @@
1
+ /**
2
+ * AW-133 — the `-subtle` fill and the text on it, as a gate.
3
+ *
4
+ * The status family had one token doing two incompatible jobs. `status-error`
5
+ * and `brand-secondary` sit at ramp rung 600 so a white label reads on them as a
6
+ * solid fill; that same depth made them unreadable as TEXT on a dark plane, and a
7
+ * subtle Pill in those tones measured 2.9-3.1 against its own fill. The four
8
+ * lighter tones had the mirror problem. Splitting the roles fixed it: `on-*-subtle`
9
+ * is text on the subtle fill, levelled at rung 300.
10
+ *
11
+ * The numbers run in CI so a future retune cannot quietly reopen it.
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 = (rgb: number[]) => {
26
+ const [r, g, b] = rgb.map(srgb2lin)
27
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
28
+ }
29
+ const contrast = (a: number[], b: number[]) => {
30
+ const [hi, lo] = [relLum(a), relLum(b)].sort((x, y) => y - x)
31
+ return (hi + 0.05) / (lo + 0.05)
32
+ }
33
+ /** OKLab lightness — the perceptual axis the ramps were generated on. */
34
+ const oklabL = (rgb: number[]) => {
35
+ const [r, g, b] = rgb.map(srgb2lin)
36
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
37
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
38
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
39
+ return 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s
40
+ }
41
+ /** OKLCH chroma — how saturated, independent of how light. */
42
+ const oklabChroma = (rgb: number[]) => {
43
+ const [r, g, b] = rgb.map(srgb2lin)
44
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
45
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
46
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
47
+ const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s
48
+ const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
49
+ return Math.sqrt(a * a + bb * bb)
50
+ }
51
+
52
+ const parseRgba = (v: string): { rgb: number[]; alpha: number } => {
53
+ const m = v.match(/rgba?\(([^)]+)\)/)
54
+ if (!m) throw new Error(`expected an rgba() subtle fill, got "${v}"`)
55
+ const parts = m[1].split(',').map((n) => parseFloat(n.trim()))
56
+ return { rgb: parts.slice(0, 3), alpha: parts[3] ?? 1 }
57
+ }
58
+ const composite = (fill: string, plane: string) => {
59
+ const { rgb, alpha } = parseRgba(fill)
60
+ const bg = hex2rgb(plane)
61
+ return rgb.map((c, i) => Math.round(c * alpha + bg[i] * (1 - alpha)))
62
+ }
63
+
64
+ /** The two planes a subtle pill actually lands on. */
65
+ const PAGE = greyRamp[925]
66
+ const CARD = greyRamp[875]
67
+
68
+ /**
69
+ * Four tones level at ramp rung 300. Two are deliberate exceptions, both operator
70
+ * calls during the AW-133 review, and both are held here rather than quietly
71
+ * widening the band:
72
+ *
73
+ * - `brand` keeps the exact `orange[400]` brand hue instead of levelling, so the
74
+ * Voltras tone never drifts. It is the darkest label and the lowest contrast.
75
+ * - `error` sits at `red[400]`, a rung darker than its siblings. red[300] levelled
76
+ * perfectly but read PINK: a red that light can only hold ~0.121 OKLCH chroma.
77
+ * red[400] carries ~0.165 and reads red. Its fill is thinned to 0.08 to buy back
78
+ * the contrast that darkness costs, which is why it still beats brand's floor.
79
+ */
80
+ const TONES = [
81
+ {
82
+ name: 'brand',
83
+ fill: 'brand-primary-subtle',
84
+ text: 'on-brand-primary-subtle',
85
+ min: 4.0,
86
+ levelled: false,
87
+ },
88
+ {
89
+ name: 'accent',
90
+ fill: 'brand-secondary-subtle',
91
+ text: 'on-brand-secondary-subtle',
92
+ min: 4.5,
93
+ levelled: true,
94
+ },
95
+ {
96
+ name: 'success',
97
+ fill: 'status-success-subtle',
98
+ text: 'on-status-success-subtle',
99
+ min: 4.5,
100
+ levelled: true,
101
+ },
102
+ {
103
+ name: 'warning',
104
+ fill: 'status-warning-subtle',
105
+ text: 'on-status-warning-subtle',
106
+ min: 4.5,
107
+ levelled: true,
108
+ },
109
+ {
110
+ name: 'error',
111
+ fill: 'status-error-subtle',
112
+ text: 'on-status-error-subtle',
113
+ min: 4.2,
114
+ levelled: false,
115
+ },
116
+ {
117
+ name: 'info',
118
+ fill: 'status-info-subtle',
119
+ text: 'on-status-info-subtle',
120
+ min: 4.5,
121
+ levelled: true,
122
+ },
123
+ ] as const
124
+
125
+ describe('subtle tone weight (dark)', () => {
126
+ it.each(TONES)('$name text clears its floor on both planes', ({ fill, text, min }) => {
127
+ const label = hex2rgb(semanticColorsDark[text])
128
+ for (const plane of [PAGE, CARD]) {
129
+ const composited = composite(semanticColorsDark[fill], plane)
130
+ expect(contrast(label, composited)).toBeGreaterThanOrEqual(min)
131
+ }
132
+ })
133
+
134
+ // The original defect was a WEIGHT mismatch, not only a contrast one: the family
135
+ // spanned OKLCH L 0.550-0.813, so accent and error read as a different rung of
136
+ // the system. Hold the levelled tones in a tight band; the two exceptions are
137
+ // measured by their own floors above.
138
+ it('levels the unexceptional tones to one weight', () => {
139
+ const ls = TONES.filter((t) => t.levelled).map((t) =>
140
+ oklabL(hex2rgb(semanticColorsDark[t.text]))
141
+ )
142
+ expect(Math.max(...ls) - Math.min(...ls)).toBeLessThanOrEqual(0.06)
143
+ })
144
+
145
+ // Guards the operator's actual complaint. Levelling error back onto rung 300 would
146
+ // satisfy every other assertion in this file — tighter band, higher contrast — and
147
+ // reintroduce the pink. Chroma is the thing that made it wrong, so chroma is what
148
+ // gets asserted.
149
+ it('keeps error saturated enough to read red rather than pink', () => {
150
+ const c = oklabChroma(hex2rgb(semanticColorsDark['on-status-error-subtle']))
151
+ expect(c).toBeGreaterThanOrEqual(0.15)
152
+ })
153
+
154
+ // A fill whose hue drifts from its label's re-opens the split this fixed.
155
+ it('draws each fill from the same hue family as its label', () => {
156
+ for (const { fill, text, name } of TONES) {
157
+ if (name === 'brand') continue
158
+ const fillRgb = parseRgba(semanticColorsDark[fill]).rgb
159
+ const labelRgb = hex2rgb(semanticColorsDark[text])
160
+ expect(fillRgb).toEqual(labelRgb)
161
+ }
162
+ })
163
+ })
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest'
2
+ import { createRequire } from 'node:module'
3
+ import { readdirSync, readFileSync } from 'node:fs'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ /**
8
+ * Tailwind opacity modifiers on var()-backed colours (VW-308).
9
+ *
10
+ * Tailwind v3 cannot apply `/<n>` to a colour whose value is a `var(--…)`
11
+ * reference. It does not warn and does not fall back: it emits NO RULE, and the
12
+ * class is dead CSS on web. Every colour in `tailwind.config.js` is var()-backed
13
+ * (that is what makes light/dark switching work), so `bg-brand-primary/10` never
14
+ * painted anything — while the literal-valued `text-white/70` compiles fine.
15
+ * That asymmetry is why it sat unnoticed in four components.
16
+ *
17
+ * This test compiles the REAL `tailwind.config.js` against a fixture, which is
18
+ * how the finding was reproduced. It pins three things:
19
+ * 1. the defect itself, so nobody "fixes" a call site back to a modifier;
20
+ * 2. a POSITIVE CONTROL — `text-white/70` and `bg-black/50` still compile, so
21
+ * an empty stylesheet from a broken harness can't pass as proof;
22
+ * 3. that no var()-backed opacity modifier survives anywhere in `src/`.
23
+ *
24
+ * The chosen fix is (3), not making the modifier work: the `<alpha-value>`
25
+ * pattern needs channel-triplet CSS vars, a hand-aligned rewrite of the whole
26
+ * token layer, and `color-mix()` — the config-only alternative — is not a value
27
+ * React Native understands. The wash ladder (`-subtle`/`-muted`/`-strong`)
28
+ * already expresses translucency on both platforms.
29
+ */
30
+
31
+ const require = createRequire(import.meta.url)
32
+ const uiRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
33
+
34
+ // postcss is a declared dependency of tailwindcss, not of this package; resolve
35
+ // it from there rather than relying on hoisting.
36
+ const tailwindEntry = require.resolve('tailwindcss', { paths: [uiRoot] })
37
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
38
+ const tailwind = require(tailwindEntry) as any
39
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
+ const postcss = createRequire(tailwindEntry)('postcss') as any
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ const tailwindConfig = require(path.join(uiRoot, 'tailwind.config.js')) as any
43
+
44
+ /** `brand.primary.DEFAULT` -> the `brand-primary` class suffix. */
45
+ function flattenColors(
46
+ node: Record<string, unknown>,
47
+ prefix: string[] = []
48
+ ): Record<string, string> {
49
+ const out: Record<string, string> = {}
50
+ for (const [key, value] of Object.entries(node)) {
51
+ const nextPrefix = key === 'DEFAULT' ? prefix : [...prefix, key]
52
+ if (value && typeof value === 'object') {
53
+ Object.assign(out, flattenColors(value as Record<string, unknown>, nextPrefix))
54
+ } else if (typeof value === 'string') {
55
+ out[nextPrefix.join('-')] = value
56
+ }
57
+ }
58
+ return out
59
+ }
60
+
61
+ const varBackedTokens = new Set(
62
+ Object.entries(flattenColors(tailwindConfig.theme?.extend?.colors ?? {}))
63
+ .filter(([, value]) => /^var\(--color-/.test(value))
64
+ .map(([name]) => name)
65
+ )
66
+
67
+ const PROBE_CLASSES = [
68
+ // The finding, verbatim.
69
+ 'bg-brand-primary/10',
70
+ 'text-on-status-success/70',
71
+ // The four call sites' original classes.
72
+ 'bg-status-success/10',
73
+ 'bg-brand-primary/20',
74
+ 'text-text-tertiary/40',
75
+ // Positive control: literal-valued colours, which DO support the modifier.
76
+ 'text-white/70',
77
+ 'bg-black/50',
78
+ // The replacements the call sites now use.
79
+ 'bg-brand-primary-subtle',
80
+ 'bg-status-success-subtle',
81
+ 'bg-brand-primary-muted',
82
+ 'bg-status-info-muted',
83
+ 'text-text-disabled',
84
+ ]
85
+
86
+ let emitted: Set<string>
87
+
88
+ beforeAll(async () => {
89
+ const raw = `<div class="${PROBE_CLASSES.join(' ')}"></div>`
90
+ const result = await postcss([
91
+ tailwind({ ...tailwindConfig, content: { files: [{ raw, extension: 'html' }] } }),
92
+ ]).process('@tailwind utilities;', { from: undefined })
93
+
94
+ emitted = new Set(
95
+ [...(result.css as string).matchAll(/^\.([^\s{]+)\s*\{/gm)].map((m) => m[1].replace(/\\/g, ''))
96
+ )
97
+ }, 30_000)
98
+
99
+ describe('tailwind opacity modifiers on var()-backed colours (VW-308)', () => {
100
+ it('compiles the real config against the fixture at all', () => {
101
+ // Guards the harness: without this, every "emits no rule" assertion below
102
+ // would also pass against a stylesheet that failed to build.
103
+ expect(emitted.size).toBeGreaterThan(0)
104
+ })
105
+
106
+ // POSITIVE CONTROL. These are literal colours in tailwind's own palette, so
107
+ // the modifier works. If one of these ever stops compiling, the harness is
108
+ // broken, not the design system.
109
+ it.each(['text-white/70', 'bg-black/50'])('%s compiles (positive control)', (className) => {
110
+ expect(emitted).toContain(className)
111
+ })
112
+
113
+ it.each([
114
+ 'bg-brand-primary/10',
115
+ 'text-on-status-success/70',
116
+ 'bg-status-success/10',
117
+ 'bg-brand-primary/20',
118
+ 'text-text-tertiary/40',
119
+ ])('%s emits NO rule — this is the defect, do not use it', (className) => {
120
+ expect(emitted).not.toContain(className)
121
+ })
122
+
123
+ it.each([
124
+ 'bg-brand-primary-subtle',
125
+ 'bg-status-success-subtle',
126
+ 'bg-brand-primary-muted',
127
+ 'bg-status-info-muted',
128
+ 'text-text-disabled',
129
+ ])('%s compiles — the replacement the call sites use', (className) => {
130
+ expect(emitted).toContain(className)
131
+ })
132
+ })
133
+
134
+ const OPACITY_UTILITY =
135
+ /\b(?:[a-z][a-z0-9-]*:)*(?:bg|text|border|ring|fill|stroke|from|to|via|shadow|outline|decoration|divide|accent|caret|placeholder)-([a-z][a-zA-Z0-9-]*)\/(?:\[[^\]]+\]|\d+)/g
136
+
137
+ function sourceFiles(dir: string, found: string[] = []): string[] {
138
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
139
+ const full = path.join(dir, entry.name)
140
+ if (entry.isDirectory()) sourceFiles(full, found)
141
+ else if (/\.(ts|tsx)$/.test(entry.name)) found.push(full)
142
+ }
143
+ return found
144
+ }
145
+
146
+ describe('no var()-backed opacity modifier survives in src/', () => {
147
+ it('finds no dead colour class', () => {
148
+ const srcRoot = path.join(uiRoot, 'src')
149
+ const violations: string[] = []
150
+
151
+ for (const file of sourceFiles(srcRoot)) {
152
+ // This file quotes the dead classes on purpose, as the probe list above.
153
+ if (file === fileURLToPath(import.meta.url)) continue
154
+ for (const match of readFileSync(file, 'utf8').matchAll(OPACITY_UTILITY)) {
155
+ if (varBackedTokens.has(match[1])) {
156
+ violations.push(`${path.relative(uiRoot, file)}: ${match[0]}`)
157
+ }
158
+ }
159
+ }
160
+
161
+ expect(violations, `dead colour classes (VW-308):\n${violations.join('\n')}`).toEqual([])
162
+ })
163
+ })
@@ -31,6 +31,39 @@ export const primitiveColors = {
31
31
  */
32
32
  export const alertRedVivid = '#FF4757' as const
33
33
 
34
+ /**
35
+ * Result/outcome palette anchors (VW-83): Material-style green/red/grey chosen
36
+ * for the result-improve/-degrade/-inconclusive tokens. No `primitiveRamps` step
37
+ * matches any of these exactly (the ramps are OKLCH-generated with a different
38
+ * hue/chroma arc), so they are pinned by name here rather than left as raw hex
39
+ * in semantic.ts — same pattern as {@link alertRedVivid}.
40
+ */
41
+ export const resultPaletteColors = {
42
+ improve: '#4caf50',
43
+ improveDark: '#248a24',
44
+ degrade: '#ef5350',
45
+ degradeDark: '#b30000',
46
+ inconclusive: '#9E9A97',
47
+ } as const
48
+
49
+ /**
50
+ * One-off semantic literals (VW-83) with no exact `greyRamp`/`primitiveRamps`
51
+ * match, pinned by name instead of left as raw hex in semantic.ts.
52
+ */
53
+ export const semanticPins = {
54
+ // Cool navy-black; darker and more blue-shifted than any warm greyRamp step.
55
+ textPrimaryLight: '#121828',
56
+ // Cool blue-grey; outside greyRamp's warm (R>=G>=B) scale.
57
+ textSecondaryLight: '#65748B',
58
+ // Pure neutral grey (R=G=B); no warm greyRamp step lands on it exactly.
59
+ backgroundBaseLight: '#EBEBEB',
60
+ // Near-neutral grey, kept one step off backgroundBaseLight by design.
61
+ dividerLight: '#E8E9EB',
62
+ // Periwinkle/indigo; more purple than any primitiveRamps.blue step. Shared by
63
+ // dark-mode text-link, border-focus and border-input-focus.
64
+ focusIndigoDark: '#828DF8',
65
+ } as const
66
+
34
67
  /**
35
68
  * Unified warm-neutral grey ramp (TD-07.14)
36
69
  *