@titan-design/react-ui 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +399 -4
- package/dist/index.d.ts +399 -4
- package/dist/index.js +2295 -895
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2279 -905
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-DyEx-lBf.d.ts → pages-B1hqnGIC.d.ts} +149 -15
- package/dist/{pages-D7hOD4Vj.d.mts → pages-CvsFqNtx.d.mts} +149 -15
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +1974 -1740
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +1975 -1741
- package/dist/pages.mjs.map +1 -1
- package/package.json +8 -3
- package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
- package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
- package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
- package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
- package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
- package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
- package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
- package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
- package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
- package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
- package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
- package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
- package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
- package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
- package/src/components/custom/Fatigue/README.md +93 -0
- package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
- package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
- package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
- package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
- package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
- package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
- package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
- package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
- package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
- package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
- package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
- package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
- package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
- package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
- package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
- package/src/components/custom/Fatigue/index.ts +41 -0
- package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
- package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
- package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +4 -4
- package/src/components/custom/Workout/ExerciseCard.tsx +3 -1
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +7 -1
- package/src/components/custom/Workout/README.md +62 -16
- package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
- package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
- package/src/components/custom/Workout/SessionRail.tsx +50 -13
- package/src/components/custom/Workout/SetRow.test.tsx +6 -6
- package/src/components/custom/Workout/SetRow.tsx +9 -2
- package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
- package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
- package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
- package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
- package/src/components/custom/Workout/VelocityStrip.test.tsx +196 -79
- package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
- package/src/components/custom/Workout/index.ts +3 -0
- package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
- package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
- package/src/components/custom/charts/SetBarChart.tsx +651 -0
- package/src/components/custom/charts/live-rep-growth.ts +91 -0
- package/src/components/custom/index.ts +1 -0
- package/src/test/raw-color-patterns.test.ts +71 -0
- package/src/theme/ColorPalettes.stories.tsx +871 -0
- package/src/theme/ColorPrimitives.stories.tsx +300 -0
- package/src/theme/bar-paper.ts +59 -0
- package/src/theme/color-stories.coverage.test.ts +130 -0
- package/src/theme/color-story-kit.tsx +170 -0
- package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
- package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
- package/src/theme/ColorSystem.stories.tsx +0 -508
- package/src/theme/Colors.stories.tsx +0 -323
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View, Text } from 'react-native'
|
|
3
|
+
import {
|
|
4
|
+
primitiveColors,
|
|
5
|
+
primitiveRamps,
|
|
6
|
+
surfaceRampDark,
|
|
7
|
+
backgroundFrameDark,
|
|
8
|
+
categoricalPalette,
|
|
9
|
+
CATEGORICAL_CVD_SAFE_MAX,
|
|
10
|
+
} from './tokens/primitives'
|
|
11
|
+
import {
|
|
12
|
+
SWATCH_BORDER,
|
|
13
|
+
SectionIntro,
|
|
14
|
+
SectionTitle,
|
|
15
|
+
ScaleRow,
|
|
16
|
+
contrast,
|
|
17
|
+
CVD_MATRICES,
|
|
18
|
+
simulateCvd,
|
|
19
|
+
} from './color-story-kit'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Foundations/Color/Primitives — the raw material, and how it is validated.
|
|
23
|
+
*
|
|
24
|
+
* This half holds the scales themselves and nothing about what they mean. The
|
|
25
|
+
* organisations OF these scales — surfaces, categorical, status, brand,
|
|
26
|
+
* fatigue and the rest — live in the sibling `Foundations/Color/Palettes`.
|
|
27
|
+
*
|
|
28
|
+
* This page is a MENU: everything on it is something you may legitimately
|
|
29
|
+
* reach for today and that survives the migration ahead. The older
|
|
30
|
+
* `primitiveColors` scales — `charcoal`, `neutral`, `redVivid`, and the support
|
|
31
|
+
* `blue`/`red` — are deliberately NOT rendered here even though they still have
|
|
32
|
+
* call sites in `semantic.ts`, because TD-07.14 deletes them. A swatch on a
|
|
33
|
+
* menu is an invitation, and no annotation is loud enough to cancel that; the
|
|
34
|
+
* intro carries a one-line pointer instead, so a reader who comes looking for
|
|
35
|
+
* charcoal learns where it went rather than assuming an oversight.
|
|
36
|
+
*
|
|
37
|
+
* The semantic tokens those scales currently back are still documented, with
|
|
38
|
+
* swatches, in `Foundations/Color/Palettes` — which is what the coverage guard
|
|
39
|
+
* checks, and why removing the scale rows here costs no coverage.
|
|
40
|
+
*/
|
|
41
|
+
const meta: Meta = {
|
|
42
|
+
title: 'Foundations/Color/Primitives',
|
|
43
|
+
tags: ['autodocs'],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default meta
|
|
47
|
+
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// 1. Ramps
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
const CHROMATIC_RAMPS: Array<[keyof typeof primitiveRamps, string]> = [
|
|
53
|
+
['red', 'Red'],
|
|
54
|
+
['orange', 'Orange'],
|
|
55
|
+
['amber', 'Amber'],
|
|
56
|
+
['green', 'Green'],
|
|
57
|
+
['cyan', 'Cyan'],
|
|
58
|
+
['blue', 'Blue'],
|
|
59
|
+
['magenta', 'Magenta'],
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
const KEYWORDS = [
|
|
63
|
+
{ name: 'white', value: primitiveColors.white },
|
|
64
|
+
{ name: 'black', value: primitiveColors.black },
|
|
65
|
+
] as const
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The dark surface planes, darkest first — the greys you may actually pick.
|
|
69
|
+
*
|
|
70
|
+
* Shown here as a ramp (values only) because a menu of raw scales with no greys
|
|
71
|
+
* on it would be a lie. The depth semantics, the L* spacing rationale and the
|
|
72
|
+
* per-plane semantic aliases live in the full `Foundations/Color/Palettes`
|
|
73
|
+
* surface story, and are deliberately not duplicated.
|
|
74
|
+
*/
|
|
75
|
+
const SURFACE_STEPS: Record<string, string> = {
|
|
76
|
+
frame: backgroundFrameDark,
|
|
77
|
+
inset: surfaceRampDark.inset,
|
|
78
|
+
background: surfaceRampDark.background,
|
|
79
|
+
base: surfaceRampDark.base,
|
|
80
|
+
elevated: surfaceRampDark.elevated,
|
|
81
|
+
raised: surfaceRampDark.raised,
|
|
82
|
+
overlay: surfaceRampDark.overlay,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const Ramps: StoryObj = {
|
|
86
|
+
name: '1. Ramps — Source of Truth',
|
|
87
|
+
render: () => (
|
|
88
|
+
<View style={{ padding: 24 }}>
|
|
89
|
+
<Text className="text-2xl font-bold text-text-primary mb-2">Ramps</Text>
|
|
90
|
+
<SectionIntro>
|
|
91
|
+
Every raw colour value titan ships, as ordered scales. Nothing here carries meaning — a
|
|
92
|
+
ramp step becomes a border or a status only when a semantic token or a palette references
|
|
93
|
+
it, which is what{' '}
|
|
94
|
+
<Text className="font-semibold">Foundations/Color/Palettes</Text> documents. Everything on
|
|
95
|
+
this page is current and safe to pick.
|
|
96
|
+
</SectionIntro>
|
|
97
|
+
|
|
98
|
+
<Text className="text-text-tertiary text-xs mb-6">
|
|
99
|
+
Looking for <Text className="font-semibold">charcoal</Text>,{' '}
|
|
100
|
+
<Text className="font-semibold">neutral</Text>, <Text className="font-semibold">redVivid</Text>{' '}
|
|
101
|
+
or the support <Text className="font-semibold">blue</Text>/
|
|
102
|
+
<Text className="font-semibold">red</Text> scales? They are intentionally not shown — all
|
|
103
|
+
five retire into a unified warm grey ramp plus the OKLCH ramps under TD-07.14. They still
|
|
104
|
+
have call sites in `semantic.ts`, so the semantic tokens they back are real and documented
|
|
105
|
+
in Palettes; the raw scales are not on the menu because you should not be picking from
|
|
106
|
+
them. Spec: coordination/design-explorations/foundations/warm-grey-ramp/.
|
|
107
|
+
</Text>
|
|
108
|
+
|
|
109
|
+
<SectionTitle>Chromatic — OKLCH tonal ramps</SectionTitle>
|
|
110
|
+
<Text className="text-text-secondary text-xs mb-4">
|
|
111
|
+
Seven OKLCH-generated hue ramps, 11 perceptual steps each (50 → 950), built with
|
|
112
|
+
hue-torsion and a chroma arc anchored through the brand/semantic hexes. These are the
|
|
113
|
+
source of truth for chromatic hexes: the categorical, diverging, effort and fatigue
|
|
114
|
+
palettes are all references into these steps rather than duplicated values, so re-tuning a
|
|
115
|
+
ramp moves them together. Amber absorbs the former yellow (lemon → warm body) and cyan
|
|
116
|
+
absorbs the former steel.
|
|
117
|
+
</Text>
|
|
118
|
+
{CHROMATIC_RAMPS.map(([key, name]) => (
|
|
119
|
+
<ScaleRow key={key} name={name} scale={primitiveRamps[key]} />
|
|
120
|
+
))}
|
|
121
|
+
|
|
122
|
+
<SectionTitle>Achromatic — the dark surface ramp</SectionTitle>
|
|
123
|
+
<Text className="text-text-secondary text-xs mb-4">
|
|
124
|
+
The greys. A derived depth ladder rather than a generated hue ramp — spaced by perceptual
|
|
125
|
+
lightness so one step is the same visual distance anywhere in the stack. Shown here as
|
|
126
|
+
values; the depth semantics and the semantic tokens aliasing each plane are in the
|
|
127
|
+
Palettes surface story, and are not repeated. Apply them with{' '}
|
|
128
|
+
<Text className="font-semibold"><Surface level></Text>, not by reading a step.
|
|
129
|
+
</Text>
|
|
130
|
+
<ScaleRow name="Surface (dark)" scale={SURFACE_STEPS} />
|
|
131
|
+
|
|
132
|
+
<SectionTitle>Keywords</SectionTitle>
|
|
133
|
+
<Text className="text-text-secondary text-xs mb-4">
|
|
134
|
+
Absolute values with no scale. `transparent` is the third and encodes absence — it has no
|
|
135
|
+
swatch because there is nothing to show.
|
|
136
|
+
</Text>
|
|
137
|
+
<View style={{ flexDirection: 'row', gap: 12 }}>
|
|
138
|
+
{KEYWORDS.map(({ name, value }) => (
|
|
139
|
+
<View key={name} style={{ alignItems: 'center' }}>
|
|
140
|
+
<View
|
|
141
|
+
style={{
|
|
142
|
+
width: 62,
|
|
143
|
+
height: 46,
|
|
144
|
+
borderRadius: 6,
|
|
145
|
+
backgroundColor: value,
|
|
146
|
+
borderWidth: 1,
|
|
147
|
+
borderColor: SWATCH_BORDER,
|
|
148
|
+
}}
|
|
149
|
+
/>
|
|
150
|
+
<Text className="text-text-secondary text-xs mt-1">{name}</Text>
|
|
151
|
+
<Text className="text-text-tertiary" style={{ fontSize: 8 }}>
|
|
152
|
+
{value.toUpperCase()}
|
|
153
|
+
</Text>
|
|
154
|
+
</View>
|
|
155
|
+
))}
|
|
156
|
+
</View>
|
|
157
|
+
</View>
|
|
158
|
+
),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ============================================================================
|
|
162
|
+
// 2. Accessibility — contrast + CVD
|
|
163
|
+
// ============================================================================
|
|
164
|
+
|
|
165
|
+
/** The mid-step of each chromatic ramp — the band most fills and strokes land on. */
|
|
166
|
+
const RAMP_MIDS = CHROMATIC_RAMPS.map(([key, name]) => ({
|
|
167
|
+
name,
|
|
168
|
+
hex: primitiveRamps[key][500],
|
|
169
|
+
}))
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Width of the mode-label gutter. The swatch rows are indented by the same
|
|
173
|
+
* amount so each simulation strip sits directly under the colour it simulates —
|
|
174
|
+
* the comparison is worthless if the reader has to count across to pair them.
|
|
175
|
+
*/
|
|
176
|
+
const CVD_LABEL_WIDTH = 96
|
|
177
|
+
|
|
178
|
+
function CvdStrips({ colors, width }: { colors: readonly string[]; width: number }) {
|
|
179
|
+
return (
|
|
180
|
+
<>
|
|
181
|
+
{(['deuteranopia', 'protanopia'] as const).map((mode) => (
|
|
182
|
+
<View key={mode} style={{ flexDirection: 'row', alignItems: 'center', marginTop: 3 }}>
|
|
183
|
+
<Text className="text-text-secondary" style={{ fontSize: 9, width: CVD_LABEL_WIDTH }}>
|
|
184
|
+
{mode}
|
|
185
|
+
</Text>
|
|
186
|
+
<View style={{ flexDirection: 'row', gap: 6 }}>
|
|
187
|
+
{colors.map((hex, i) => (
|
|
188
|
+
<View
|
|
189
|
+
key={i}
|
|
190
|
+
style={{
|
|
191
|
+
width,
|
|
192
|
+
height: 14,
|
|
193
|
+
borderRadius: 3,
|
|
194
|
+
backgroundColor: simulateCvd(CVD_MATRICES[mode], hex),
|
|
195
|
+
}}
|
|
196
|
+
/>
|
|
197
|
+
))}
|
|
198
|
+
</View>
|
|
199
|
+
</View>
|
|
200
|
+
))}
|
|
201
|
+
</>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export const Accessibility: StoryObj = {
|
|
206
|
+
name: '2. Accessibility — Contrast + CVD',
|
|
207
|
+
render: () => (
|
|
208
|
+
<View style={{ padding: 24 }}>
|
|
209
|
+
<Text className="text-2xl font-bold text-text-primary mb-2">Accessibility</Text>
|
|
210
|
+
<SectionIntro>
|
|
211
|
+
How the raw material behaves for viewers who cannot separate the hues, and under text.
|
|
212
|
+
Dichromacy is simulated with the Machado-2009 matrices at severity 1.0. Converging strips
|
|
213
|
+
across two swatches mean those two are the same colour to that viewer — which is a
|
|
214
|
+
problem only where hue is carrying the meaning on its own.
|
|
215
|
+
</SectionIntro>
|
|
216
|
+
|
|
217
|
+
<SectionTitle>Ramp mid-steps (500)</SectionTitle>
|
|
218
|
+
<Text className="text-text-secondary text-xs mb-3">
|
|
219
|
+
The seven hues at the step most fills land on. Red/orange/amber and green/cyan compress
|
|
220
|
+
toward each other under both dichromacies — which is exactly why the categorical palette
|
|
221
|
+
below fans its picks across lightness instead of taking all seven at 500, and why the
|
|
222
|
+
ordered scales in Palettes encode magnitude in lightness rather than hue.
|
|
223
|
+
</Text>
|
|
224
|
+
<View
|
|
225
|
+
style={{ flexDirection: 'row', gap: 6, marginBottom: 6, paddingLeft: CVD_LABEL_WIDTH }}
|
|
226
|
+
>
|
|
227
|
+
{RAMP_MIDS.map(({ name, hex }) => (
|
|
228
|
+
<View key={name} style={{ alignItems: 'center' }}>
|
|
229
|
+
<View
|
|
230
|
+
style={{
|
|
231
|
+
width: 76,
|
|
232
|
+
height: 40,
|
|
233
|
+
borderRadius: 6,
|
|
234
|
+
backgroundColor: hex,
|
|
235
|
+
borderWidth: 1,
|
|
236
|
+
borderColor: SWATCH_BORDER,
|
|
237
|
+
}}
|
|
238
|
+
/>
|
|
239
|
+
<Text className="text-text-secondary" style={{ fontSize: 9, marginTop: 2 }}>
|
|
240
|
+
{name}
|
|
241
|
+
</Text>
|
|
242
|
+
</View>
|
|
243
|
+
))}
|
|
244
|
+
</View>
|
|
245
|
+
<CvdStrips colors={RAMP_MIDS.map((r) => r.hex)} width={76} />
|
|
246
|
+
|
|
247
|
+
<View style={{ height: 28 }} />
|
|
248
|
+
|
|
249
|
+
<SectionTitle>Categorical series — in-place text contrast</SectionTitle>
|
|
250
|
+
<Text className="text-text-secondary text-xs mb-3">
|
|
251
|
+
The palette that has to survive this test in production. All pairs hold ΔE ≥ 8 through the
|
|
252
|
+
first {CATEGORICAL_CVD_SAFE_MAX}; the 7th is extended and wants a legend. The palette
|
|
253
|
+
itself is documented in Palettes — this is the validation of it.
|
|
254
|
+
</Text>
|
|
255
|
+
{(['default', 'dark'] as const).map((variant) => {
|
|
256
|
+
const colors = categoricalPalette[variant]
|
|
257
|
+
const textColor = variant === 'dark' ? primitiveColors.white : primitiveColors.black
|
|
258
|
+
return (
|
|
259
|
+
<View key={variant} style={{ marginBottom: 22 }}>
|
|
260
|
+
<Text
|
|
261
|
+
className="font-semibold text-text-primary text-sm mb-2"
|
|
262
|
+
style={{ textTransform: 'capitalize' }}
|
|
263
|
+
>
|
|
264
|
+
{variant}
|
|
265
|
+
</Text>
|
|
266
|
+
<View
|
|
267
|
+
style={{
|
|
268
|
+
flexDirection: 'row',
|
|
269
|
+
gap: 6,
|
|
270
|
+
marginBottom: 6,
|
|
271
|
+
paddingLeft: CVD_LABEL_WIDTH,
|
|
272
|
+
}}
|
|
273
|
+
>
|
|
274
|
+
{colors.map((hex, i) => (
|
|
275
|
+
<View key={i} style={{ alignItems: 'center' }}>
|
|
276
|
+
<View
|
|
277
|
+
style={{
|
|
278
|
+
width: 56,
|
|
279
|
+
height: 40,
|
|
280
|
+
borderRadius: 6,
|
|
281
|
+
backgroundColor: hex,
|
|
282
|
+
alignItems: 'center',
|
|
283
|
+
justifyContent: 'center',
|
|
284
|
+
}}
|
|
285
|
+
>
|
|
286
|
+
<Text style={{ color: textColor, fontSize: 11, fontWeight: '700' }}>Aa</Text>
|
|
287
|
+
</View>
|
|
288
|
+
<Text className="text-text-secondary" style={{ fontSize: 8, marginTop: 2 }}>
|
|
289
|
+
{contrast(hex, textColor).toFixed(1)}:1
|
|
290
|
+
</Text>
|
|
291
|
+
</View>
|
|
292
|
+
))}
|
|
293
|
+
</View>
|
|
294
|
+
<CvdStrips colors={colors} width={56} />
|
|
295
|
+
</View>
|
|
296
|
+
)
|
|
297
|
+
})}
|
|
298
|
+
</View>
|
|
299
|
+
),
|
|
300
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The matte "paper" treatment for a colored bar — a brightness-scaled fractal-noise
|
|
2
|
+
// grain + a crisp top rim-light + a soft contact shadow, so filled bars read as one
|
|
3
|
+
// tactile material across the system (velocity bars, ROM bars, …). Web-only CSS
|
|
4
|
+
// (backgroundImage + boxShadow) — harmless on native (ignored), styled for RNW/web.
|
|
5
|
+
//
|
|
6
|
+
// Promoted out of VelocityStrip so every colored-bar family composes ONE treatment
|
|
7
|
+
// instead of re-rolling it. Consumers spread it onto a bar's style: `...barPaper(hex)`.
|
|
8
|
+
import type { ViewStyle } from 'react-native'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Grain opacity scaled by a color's perceived brightness: a bright bar keeps full
|
|
12
|
+
* texture, a darker bar carries proportionally less so the grain never reads hot on a
|
|
13
|
+
* dark tone. Anchored so lum ≈ 0.13 → ≈ 0.06 and lum ≈ 0.58 → ≈ 0.20.
|
|
14
|
+
*/
|
|
15
|
+
function grainOpacityForColor(hex: string): number {
|
|
16
|
+
const h = hex.replace('#', '')
|
|
17
|
+
const full =
|
|
18
|
+
h.length === 3
|
|
19
|
+
? h
|
|
20
|
+
.split('')
|
|
21
|
+
.map((ch) => ch + ch)
|
|
22
|
+
.join('')
|
|
23
|
+
: h
|
|
24
|
+
const r = parseInt(full.slice(0, 2), 16)
|
|
25
|
+
const g = parseInt(full.slice(2, 4), 16)
|
|
26
|
+
const b = parseInt(full.slice(4, 6), 16)
|
|
27
|
+
if ([r, g, b].some(Number.isNaN)) return 0.14
|
|
28
|
+
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
|
29
|
+
return Math.min(0.24, Math.max(0.04, 0.02 + 0.31 * lum))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The matte paper grain tile (fractalNoise), opacity brightness-scaled to `color`. */
|
|
33
|
+
function barGrain(color: string): string {
|
|
34
|
+
const op = grainOpacityForColor(color).toFixed(3)
|
|
35
|
+
return (
|
|
36
|
+
`url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E` +
|
|
37
|
+
`%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E` +
|
|
38
|
+
`%3Crect width='120' height='120' filter='url(%23n)' opacity='${op}'/%3E%3C/svg%3E")`
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Paper/raised treatment for a colored bar of the given `color`: brightness-scaled
|
|
44
|
+
* matte grain + a crisp top rim-light + a defined contact shadow. Spread onto a bar's
|
|
45
|
+
* style. Presentation-only; scoped to filled bars (not shared strips/dividers).
|
|
46
|
+
*
|
|
47
|
+
* `flip` = a vertically-mirrored (`scaleY(-1)`) plot — a `down` wing of the diverging
|
|
48
|
+
* chart. The contact shadow's y-offset is pre-inverted so the mirror lands it pointing
|
|
49
|
+
* AWAY from the shared axis (not up across it), and the top rim-light is DROPPED (mirrored
|
|
50
|
+
* it would sit on the axis edge and read wrong; grain + shadow alone carry the material).
|
|
51
|
+
*/
|
|
52
|
+
export function barPaper(color: string, flip = false): ViewStyle {
|
|
53
|
+
return {
|
|
54
|
+
backgroundImage: barGrain(color),
|
|
55
|
+
boxShadow: flip
|
|
56
|
+
? '0 -6px 16px rgba(0,0,0,0.45)'
|
|
57
|
+
: 'inset 0 1.5px 0 rgba(255,255,255,0.22), 0 6px 16px rgba(0,0,0,0.45)',
|
|
58
|
+
} as unknown as ViewStyle
|
|
59
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { surfaceRampDark, backgroundFrameDark } from './tokens/primitives'
|
|
6
|
+
import { semanticColorsDark } from './tokens/semantic'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Drift guard for the Foundations/Color stories (VW-33).
|
|
10
|
+
*
|
|
11
|
+
* The color stories are the documentation of record for the token layer, and
|
|
12
|
+
* they have drifted twice: once when the categorical palette replaced the
|
|
13
|
+
* Paul-Tol scale, and again when the v0.10.0 surface redesign added four
|
|
14
|
+
* planes (`surface-inset`, `surface-overlay`, `background-frame`,
|
|
15
|
+
* `background-subtle`) that no story rendered. Both times the values stayed
|
|
16
|
+
* correct while the *coverage* silently fell behind — which a screenshot test
|
|
17
|
+
* cannot catch, because a story that was never written renders nothing.
|
|
18
|
+
*
|
|
19
|
+
* Two invariants, deliberately different in kind:
|
|
20
|
+
*
|
|
21
|
+
* 1. STRUCTURAL — every `surface-*`/`background-*` token resolves to a plane
|
|
22
|
+
* in the shipped ramp. Catches a token added off-ramp (an arbitrary fill),
|
|
23
|
+
* which is the failure the ordered-ramp story exists to prevent.
|
|
24
|
+
* 2. COVERAGE — every other semantic token is named somewhere in the color
|
|
25
|
+
* stories, so a new token cannot ship without a swatch.
|
|
26
|
+
*
|
|
27
|
+
* The story files are read as TEXT, never imported: they pull in react-native
|
|
28
|
+
* and nativewind, which throw under the node test environment. (They are also
|
|
29
|
+
* outside the `stories-smoke` glob, which only composes `components/**` — so
|
|
30
|
+
* this text guard is the only automated check on the color stories.)
|
|
31
|
+
*
|
|
32
|
+
* Both halves of Foundations/Color are scanned as one corpus: `Primitives`
|
|
33
|
+
* documents the raw scales, `Palettes` the assignments of them. A token is
|
|
34
|
+
* covered if EITHER names it — which is right, because the sidebar split is an
|
|
35
|
+
* organising choice and coverage is about the token having a swatch at all.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const themeDir = path.dirname(fileURLToPath(import.meta.url))
|
|
39
|
+
const storySources = ['ColorPrimitives.stories.tsx', 'ColorPalettes.stories.tsx']
|
|
40
|
+
.map((f) => readFileSync(path.join(themeDir, f), 'utf8'))
|
|
41
|
+
.join('\n')
|
|
42
|
+
|
|
43
|
+
/** Every plane a surface/background token is allowed to land on. */
|
|
44
|
+
const RAMP_PLANES = new Set<string>([...Object.values(surfaceRampDark), backgroundFrameDark])
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Token families the color stories are NOT the documentation of record for,
|
|
48
|
+
* with the story that owns them instead. Add here only with a real owner —
|
|
49
|
+
* the point of this list is that nothing falls through unowned.
|
|
50
|
+
*/
|
|
51
|
+
const DOCUMENTED_ELSEWHERE: Record<string, string> = {
|
|
52
|
+
hairline: 'Components/Atoms/Surface (rendered as separators, not swatches)',
|
|
53
|
+
on: 'Components/Atoms/Surface — OnSurfaceText (paired with their surface)',
|
|
54
|
+
interactive: 'Components/* (state colors are shown on the components themselves)',
|
|
55
|
+
avatar: 'Components/Atoms/Avatar',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const isSurfaceToken = (name: string) => /^(surface|background)-/.test(name)
|
|
59
|
+
|
|
60
|
+
/** Must mirror VARIANT_SUFFIXES in ColorPalettes.stories.tsx (asserted below). */
|
|
61
|
+
const VARIANT_SUFFIXES = ['light', 'dark', 'subtle', 'hover', 'active', 'muted'] as const
|
|
62
|
+
|
|
63
|
+
describe('Foundations/Color story coverage', () => {
|
|
64
|
+
it('every surface/background token resolves to a plane in the shipped ramp', () => {
|
|
65
|
+
const offRamp = Object.entries(semanticColorsDark)
|
|
66
|
+
.filter(([name]) => isSurfaceToken(name))
|
|
67
|
+
.filter(([, value]) => !RAMP_PLANES.has(value as string))
|
|
68
|
+
.map(([name, value]) => `${name}=${value as string}`)
|
|
69
|
+
|
|
70
|
+
expect(
|
|
71
|
+
offRamp,
|
|
72
|
+
'surface/background tokens whose value is not a step of surfaceRampDark ' +
|
|
73
|
+
'(or the backgroundFrameDark bezel). Either fold the value into the ramp, ' +
|
|
74
|
+
'or add the plane to SURFACE_PLANES in ColorPalettes.stories.tsx and document why ' +
|
|
75
|
+
'it sits outside the ramp.'
|
|
76
|
+
).toEqual([])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("the test's VARIANT_SUFFIXES mirror still matches the story", () => {
|
|
80
|
+
// The coverage rule below depends on this list matching the one the story
|
|
81
|
+
// renders from; if they drift, coverage silently over- or under-reports.
|
|
82
|
+
const declared = storySources.match(/const VARIANT_SUFFIXES = \[([^\]]*)\]/)
|
|
83
|
+
expect(declared, 'VARIANT_SUFFIXES not found in ColorPalettes.stories.tsx').not.toBeNull()
|
|
84
|
+
const fromStory = [...declared![1].matchAll(/'([a-z]+)'/g)].map((m) => m[1])
|
|
85
|
+
expect(fromStory).toEqual([...VARIANT_SUFFIXES])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('the surface story renders every ramp plane', () => {
|
|
89
|
+
// SURFACE_PLANES is built from the ramp exports, so covering the exports
|
|
90
|
+
// is what proves no plane is missing from the rendered ladder.
|
|
91
|
+
const referenced = [
|
|
92
|
+
'surfaceRampDark.inset',
|
|
93
|
+
'surfaceRampDark.background',
|
|
94
|
+
'surfaceRampDark.base',
|
|
95
|
+
'surfaceRampDark.elevated',
|
|
96
|
+
'surfaceRampDark.raised',
|
|
97
|
+
'surfaceRampDark.overlay',
|
|
98
|
+
'backgroundFrameDark',
|
|
99
|
+
]
|
|
100
|
+
const missing = referenced.filter((ref) => !storySources.includes(ref))
|
|
101
|
+
|
|
102
|
+
expect(missing, 'ramp planes absent from the SURFACE_PLANES ladder').toEqual([])
|
|
103
|
+
// Guard the count too: a step added to the ramp must gain a row.
|
|
104
|
+
expect(Object.keys(surfaceRampDark)).toHaveLength(6)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('every other semantic token is named in a color story', () => {
|
|
108
|
+
// A token is covered if the story names it literally, or if it is the
|
|
109
|
+
// `-light`/`-subtle`/… variant of a base the story feeds to VariantMatrix
|
|
110
|
+
// (which builds those names at runtime, so they never appear as literals).
|
|
111
|
+
const namedLiterally = (token: string) => storySources.includes(`'${token}'`)
|
|
112
|
+
const coveredByMatrix = (token: string) =>
|
|
113
|
+
VARIANT_SUFFIXES.some(
|
|
114
|
+
(suffix) =>
|
|
115
|
+
token.endsWith(`-${suffix}`) && namedLiterally(token.slice(0, -(suffix.length + 1)))
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const undocumented = Object.keys(semanticColorsDark)
|
|
119
|
+
.filter((name) => !isSurfaceToken(name))
|
|
120
|
+
.filter((name) => !DOCUMENTED_ELSEWHERE[name.split('-')[0]])
|
|
121
|
+
.filter((name) => !namedLiterally(name) && !coveredByMatrix(name))
|
|
122
|
+
|
|
123
|
+
expect(
|
|
124
|
+
undocumented,
|
|
125
|
+
'semantic tokens with no swatch in Foundations/Color. Add one (a plain ' +
|
|
126
|
+
'ColorSwatch, or the base to a VariantMatrix), or add the family to ' +
|
|
127
|
+
'DOCUMENTED_ELSEWHERE with the story that owns it.'
|
|
128
|
+
).toEqual([])
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared render parts for the Foundations/Color stories.
|
|
3
|
+
*
|
|
4
|
+
* The colour documentation spans two sibling titles — `Foundations/Color/
|
|
5
|
+
* Primitives` (the raw scales and their validation) and `Foundations/Color/
|
|
6
|
+
* Palettes` (the organisations of those scales into meaning). Both draw the
|
|
7
|
+
* same swatch, scale and section furniture, so it lives here rather than being
|
|
8
|
+
* copied into each.
|
|
9
|
+
*
|
|
10
|
+
* NOT a `.stories.tsx`, so Storybook's glob skips it. Note that
|
|
11
|
+
* `color-stories.coverage.test.ts` scans the STORY files as text for token
|
|
12
|
+
* names — a token whose only mention is in this file would not count as
|
|
13
|
+
* documented, which is correct: the swatch has to be in a story to be seen.
|
|
14
|
+
*/
|
|
15
|
+
import type { ReactNode } from 'react'
|
|
16
|
+
import { View, Text } from 'react-native'
|
|
17
|
+
import { bestTextColor } from './tokens/primitives'
|
|
18
|
+
import { semanticColorsDark } from './tokens/semantic'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Hairline around every swatch, so a swatch whose fill matches the page still
|
|
22
|
+
* reads as one. Sourced from the token layer rather than a literal — the color
|
|
23
|
+
* stories should not be where raw hexes creep back in.
|
|
24
|
+
*/
|
|
25
|
+
export const SWATCH_BORDER = semanticColorsDark['border-default']
|
|
26
|
+
|
|
27
|
+
export function SectionIntro({ children }: { children: ReactNode }) {
|
|
28
|
+
return <Text className="text-text-secondary mb-6">{children}</Text>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function SectionTitle({ children }: { children: string }) {
|
|
32
|
+
return <Text className="text-lg font-bold text-text-primary mb-3 mt-2">{children}</Text>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function ColorSwatch({ name, value }: { name: string; value: string }) {
|
|
36
|
+
const displayValue = value.startsWith('rgba') ? value : value.toUpperCase()
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
|
|
40
|
+
<View
|
|
41
|
+
style={{
|
|
42
|
+
width: 48,
|
|
43
|
+
height: 48,
|
|
44
|
+
borderRadius: 8,
|
|
45
|
+
backgroundColor: value,
|
|
46
|
+
borderWidth: 1,
|
|
47
|
+
borderColor: SWATCH_BORDER,
|
|
48
|
+
}}
|
|
49
|
+
/>
|
|
50
|
+
<View>
|
|
51
|
+
<Text className="font-semibold text-text-primary text-sm">{name}</Text>
|
|
52
|
+
<Text className="text-text-secondary text-xs">{displayValue}</Text>
|
|
53
|
+
</View>
|
|
54
|
+
</View>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One scale as a labelled row of steps with the hex under each.
|
|
60
|
+
*
|
|
61
|
+
* Steps come from the object itself rather than a fixed list, so a scale with
|
|
62
|
+
* the 11-step OKLCH shape and one with the legacy 10-step shape both render
|
|
63
|
+
* without the story knowing which it has.
|
|
64
|
+
*/
|
|
65
|
+
export function ScaleRow({
|
|
66
|
+
name,
|
|
67
|
+
scale,
|
|
68
|
+
note,
|
|
69
|
+
}: {
|
|
70
|
+
name: string
|
|
71
|
+
scale: Record<string | number, string>
|
|
72
|
+
note?: string
|
|
73
|
+
}) {
|
|
74
|
+
return (
|
|
75
|
+
<View style={{ marginBottom: 14 }}>
|
|
76
|
+
<Text className="font-semibold text-text-primary text-sm">{name}</Text>
|
|
77
|
+
{note ? <Text className="text-text-tertiary text-xs mb-1">{note}</Text> : null}
|
|
78
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
|
|
79
|
+
{Object.entries(scale).map(([step, hex]) => (
|
|
80
|
+
<View key={step} style={{ alignItems: 'center', width: 62 }}>
|
|
81
|
+
<View
|
|
82
|
+
style={{
|
|
83
|
+
width: 62,
|
|
84
|
+
height: 46,
|
|
85
|
+
borderRadius: 6,
|
|
86
|
+
backgroundColor: hex,
|
|
87
|
+
borderWidth: 1,
|
|
88
|
+
borderColor: SWATCH_BORDER,
|
|
89
|
+
alignItems: 'center',
|
|
90
|
+
justifyContent: 'center',
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
<Text style={{ color: bestTextColor(hex), fontSize: 10, fontWeight: '700' }}>
|
|
94
|
+
{step}
|
|
95
|
+
</Text>
|
|
96
|
+
</View>
|
|
97
|
+
<Text className="text-text-tertiary" style={{ fontSize: 8, marginTop: 2 }}>
|
|
98
|
+
{hex.toUpperCase()}
|
|
99
|
+
</Text>
|
|
100
|
+
</View>
|
|
101
|
+
))}
|
|
102
|
+
</View>
|
|
103
|
+
</View>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function Demo({ label, children }: { label: string; children: ReactNode }) {
|
|
108
|
+
return (
|
|
109
|
+
<View
|
|
110
|
+
style={{
|
|
111
|
+
padding: 10,
|
|
112
|
+
backgroundColor: semanticColorsDark['surface-raised'],
|
|
113
|
+
borderRadius: 8,
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
<Text className="text-text-secondary" style={{ fontSize: 9, marginBottom: 6 }}>
|
|
117
|
+
{label}
|
|
118
|
+
</Text>
|
|
119
|
+
{children}
|
|
120
|
+
</View>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// colour maths — drives the accessibility / CVD panel
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
const hexToRgb = (hex: string): [number, number, number] => {
|
|
129
|
+
const h = hex.replace('#', '')
|
|
130
|
+
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255) as [number, number, number]
|
|
131
|
+
}
|
|
132
|
+
const toHex = (v: number) =>
|
|
133
|
+
Math.round(Math.min(1, Math.max(0, v)) * 255)
|
|
134
|
+
.toString(16)
|
|
135
|
+
.padStart(2, '0')
|
|
136
|
+
const lin = (c: number) => (c >= 0.04045 ? ((c + 0.055) / 1.055) ** 2.4 : c / 12.92)
|
|
137
|
+
const gamma = (c: number) =>
|
|
138
|
+
c >= 0.0031308 ? 1.055 * Math.max(0, c) ** (1 / 2.4) - 0.055 : 12.92 * c
|
|
139
|
+
|
|
140
|
+
const relLum = (hex: string) => {
|
|
141
|
+
const [r, g, b] = hexToRgb(hex).map(lin)
|
|
142
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** WCAG contrast ratio between two solid colours. */
|
|
146
|
+
export function contrast(a: string, b: string): number {
|
|
147
|
+
const [l1, l2] = [relLum(a), relLum(b)].sort((x, y) => y - x)
|
|
148
|
+
return (l1 + 0.05) / (l2 + 0.05)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Machado-2009 dichromacy simulation matrices, severity 1.0. */
|
|
152
|
+
export const CVD_MATRICES = {
|
|
153
|
+
deuteranopia: [
|
|
154
|
+
0.367322, 0.860646, -0.227968, 0.280085, 0.672501, 0.047413, -0.01182, 0.04294, 0.968881,
|
|
155
|
+
],
|
|
156
|
+
protanopia: [
|
|
157
|
+
0.152286, 1.052583, -0.204868, 0.114503, 0.786281, 0.099216, -0.003882, -0.048116, 1.051998,
|
|
158
|
+
],
|
|
159
|
+
} as const
|
|
160
|
+
|
|
161
|
+
/** How `hex` appears to a viewer with the dichromacy described by `matrix`. */
|
|
162
|
+
export function simulateCvd(matrix: readonly number[], hex: string): string {
|
|
163
|
+
const [r, g, b] = hexToRgb(hex).map(lin)
|
|
164
|
+
const out = [
|
|
165
|
+
matrix[0] * r + matrix[1] * g + matrix[2] * b,
|
|
166
|
+
matrix[3] * r + matrix[4] * g + matrix[5] * b,
|
|
167
|
+
matrix[6] * r + matrix[7] * g + matrix[8] * b,
|
|
168
|
+
].map(gamma)
|
|
169
|
+
return '#' + out.map(toHex).join('')
|
|
170
|
+
}
|