@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.
- package/README.md +14 -0
- package/dist/bodymap.js +129 -76
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +129 -76
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +117 -26
- package/dist/index.d.ts +117 -26
- package/dist/index.js +677 -450
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +673 -451
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-_3Ajpiqu.d.ts → pages-CsjsUn0G.d.ts} +30 -13
- package/dist/{pages-CpW3OB3g.d.mts → pages-ZbT_pD_U.d.mts} +30 -13
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +676 -701
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +676 -701
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +31 -7
- package/dist/theme/index.d.ts +31 -7
- package/dist/theme/index.js +84 -20
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +83 -21
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.js +84 -20
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +84 -20
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/dist/theme/tokens.d.mts +56 -7
- package/dist/theme/tokens.d.ts +56 -7
- package/dist/theme/tokens.js +72 -20
- package/dist/theme/tokens.js.map +1 -1
- package/dist/theme/tokens.mjs +71 -21
- package/dist/theme/tokens.mjs.map +1 -1
- package/docs/render-testing-pattern.md +130 -0
- package/package.json +1 -1
- package/src/arch/Architecture.stories.tsx +3 -1
- package/src/arch/arch-graph.json +74 -72
- package/src/components/custom/ActiveWork/InitiativeBrief.tsx +2 -1
- package/src/components/custom/CircularTimer/CircularTimer.stories.tsx +8 -3
- package/src/components/custom/EmptyState/EmptyState.stories.tsx +1 -1
- package/src/components/custom/Gauge/Gauge.stories.tsx +4 -1
- package/src/components/custom/Gauge/Gauge.tsx +3 -1
- package/src/components/custom/Scatter/Scatter.stories.tsx +10 -7
- package/src/components/custom/Scatter/Scatter.tsx +6 -4
- package/src/components/custom/Sidebar/Sidebar.stories.tsx +1 -1
- package/src/components/custom/Table/Table.stories.tsx +1 -1
- package/src/components/custom/Table/Table.tsx +2 -2
- package/src/components/custom/Treemap/Treemap.stories.tsx +13 -10
- package/src/components/custom/Treemap/Treemap.tsx +4 -1
- package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +2 -1
- package/src/components/custom/Workout/BaseBadge.stories.tsx +11 -7
- package/src/components/custom/Workout/BaseBadge.tsx +3 -2
- package/src/components/custom/Workout/BodyMap.tsx +5 -3
- package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +13 -3
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +5 -3
- package/src/components/custom/Workout/CapacityBandChart.stories.tsx +2 -1
- package/src/components/custom/Workout/CapacityBandChart.tsx +5 -3
- package/src/components/custom/Workout/DeviationBar.test.tsx +5 -2
- package/src/components/custom/Workout/DeviationBar.tsx +5 -2
- package/src/components/custom/Workout/DualVelocityStrip.stories.tsx +7 -6
- package/src/components/custom/Workout/DualVelocityStrip.test.tsx +29 -16
- package/src/components/custom/Workout/ExerciseCard.stories.tsx +8 -8
- package/src/components/custom/Workout/ExerciseCard.test.tsx +39 -19
- package/src/components/custom/Workout/ExerciseCard.tsx +92 -167
- package/src/components/custom/Workout/ExerciseCardHeading.stories.tsx +89 -6
- package/src/components/custom/Workout/ExerciseCardHeading.test.tsx +189 -1
- package/src/components/custom/Workout/ExerciseCardHeading.tsx +167 -18
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +3 -2
- package/src/components/custom/Workout/ExerciseHeading.tsx +192 -47
- package/src/components/custom/Workout/ExerciseIndicator.stories.tsx +6 -5
- package/src/components/custom/Workout/InputBar.tsx +4 -2
- package/src/components/custom/Workout/IntensityBar.tsx +4 -1
- package/src/components/custom/Workout/MesoCard.tsx +2 -1
- package/src/components/custom/Workout/MesoStatusCard.tsx +11 -10
- package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +8 -3
- package/src/components/custom/Workout/PrHistoryModal.stories.tsx +12 -2
- package/src/components/custom/Workout/README.md +58 -12
- package/src/components/custom/Workout/ReadinessCheck.tsx +4 -2
- package/src/components/custom/Workout/RestTimer.stories.tsx +13 -2
- package/src/components/custom/Workout/RestTimer.tsx +4 -2
- package/src/components/custom/Workout/SegmentedBar.stories.tsx +16 -12
- package/src/components/custom/Workout/SegmentedBar.tsx +4 -3
- package/src/components/custom/Workout/SetStrip.stories.tsx +3 -2
- package/src/components/custom/Workout/SetsRepsLoad.tsx +23 -9
- package/src/components/custom/Workout/Sparkline.stories.tsx +13 -10
- package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +2 -1
- package/src/components/custom/Workout/StrengthTrendChart.tsx +8 -6
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +4 -3
- package/src/components/custom/Workout/TempoDisplay.tsx +2 -0
- package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +3 -2
- package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +1 -1
- package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +40 -0
- package/src/components/custom/Workout/VelocityStrip.test.tsx +48 -6
- package/src/components/custom/Workout/VelocityStrip.tsx +23 -14
- package/src/components/custom/Workout/ZoneTrack.stories.tsx +3 -1
- package/src/components/custom/Workout/exerciseRowState.ts +29 -0
- package/src/components/custom/Workout/index.ts +12 -2
- package/src/components/custom/Workout/metricText.stories.tsx +9 -8
- package/src/components/custom/Workout/metricText.tsx +4 -2
- package/src/components/custom/Workout/setHeadingKit.tsx +12 -10
- package/src/components/custom/Workout/velocity-story-kit.tsx +2 -2
- package/src/components/custom/charts/README.md +5 -0
- package/src/components/custom/charts/SetBarChart.test.tsx +16 -0
- package/src/components/custom/charts/SetBarChart.tsx +14 -5
- package/src/components/custom/charts/flatBarGeometry.test.ts +41 -0
- package/src/components/custom/charts/flatBarGeometry.ts +32 -0
- package/src/components/custom/stepper/Stepper.tsx +8 -6
- package/src/components/ui/alert/Alert.stories.tsx +14 -4
- package/src/components/ui/alert/Alert.tsx +24 -3
- package/src/components/ui/button/Button.tsx +12 -12
- package/src/components/ui/checkbox/Checkbox.tsx +2 -2
- package/src/components/ui/data-row/DataRow.stories.tsx +6 -2
- package/src/components/ui/drawer/Drawer.tsx +4 -1
- package/src/components/ui/icon-box/IconBox.tsx +8 -6
- package/src/components/ui/indicator/Indicator.stories.tsx +4 -3
- package/src/components/ui/list-item/ListItem.stories.tsx +7 -4
- package/src/components/ui/modal/Modal.tsx +2 -0
- package/src/components/ui/pill/Pill.stories.tsx +53 -0
- package/src/components/ui/pill/Pill.tsx +10 -6
- package/src/components/ui/popover/Popover.stories.tsx +1 -1
- package/src/components/ui/progress/Progress.tsx +9 -6
- package/src/components/ui/radio/Radio.tsx +1 -1
- package/src/components/ui/section/Section.stories.tsx +10 -10
- package/src/components/ui/select/Select.tsx +2 -1
- package/src/components/ui/spinner/Spinner.tsx +3 -1
- package/src/components/ui/stack/Stack.stories.tsx +7 -7
- package/src/components/ui/surface/Surface.stories.tsx +75 -28
- package/src/components/ui/switch/Switch.tsx +1 -1
- package/src/components/ui/tabs/Tabs.tsx +4 -2
- package/src/components/ui/toast/Toast.tsx +6 -4
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +13 -6
- package/src/stories/PresetShowcase.stories.tsx +1 -1
- package/src/test/no-raw-color.test.ts +39 -0
- package/src/theme/Alpha.stories.tsx +103 -0
- package/src/theme/ProposedTokensVW82.stories.tsx +414 -0
- package/src/theme/config.ts +12 -0
- package/src/theme/global.css +15 -3
- package/src/theme/subtle-tone-weight.test.ts +163 -0
- package/src/theme/tailwind-var-opacity.test.ts +163 -0
- package/src/theme/tokens/primitives.ts +33 -0
- package/src/theme/tokens/semantic-hex-guard.test.ts +322 -0
- package/src/theme/tokens/semantic.ts +56 -20
- package/src/utils/colors.alpha-adoption.test.ts +408 -0
- 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
|
*
|