@pieai/swimmer-ui-kit 1.4.0 → 1.5.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/CHANGELOG.md +38 -0
- package/README.md +10 -0
- package/bin/swimmer-ui-check.mjs +159 -2
- package/package.json +22 -23
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,44 @@
|
|
|
3
3
|
All notable changes to `@pieai/swimmer-ui-kit`.
|
|
4
4
|
Format: [Keep a Changelog](https://keepachangelog.com); versioning: semver.
|
|
5
5
|
|
|
6
|
+
## 1.5.0 — 2026-08-22
|
|
7
|
+
|
|
8
|
+
`swimmer-ui-check` now also fails on token pairs that cannot be read.
|
|
9
|
+
|
|
10
|
+
The raw-colour rule kept consumers on tokens. It never stopped them choosing
|
|
11
|
+
two tokens that do not contrast, and one pairing is genuinely inviting:
|
|
12
|
+
`--game-ui-accent-ink` reads like "the ink for accent things" and means the
|
|
13
|
+
opposite — accent-COLOURED ink, for a surface. Painted on `--game-ui-accent`
|
|
14
|
+
it measures 1.48:1 on night and 1.91:1 on light. It reached a shipping
|
|
15
|
+
product's primary button, the one control every user has to find, and every
|
|
16
|
+
test that product had was green.
|
|
17
|
+
|
|
18
|
+
PRODUCT.md promises contrast-safe token combinations. That promise only ever
|
|
19
|
+
covered the pairs the kit uses itself; nothing checked the pairs a consumer
|
|
20
|
+
built. Now something does.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- Contrast checking in `swimmer-ui-check`. Rules that set both a background
|
|
25
|
+
and a colour from bare tokens are resolved against this package's own
|
|
26
|
+
`dist/styles.css`, per theme, and reported below WCAG AA (4.5:1) with the
|
|
27
|
+
ratio and the theme named.
|
|
28
|
+
|
|
29
|
+
### Notes on what it deliberately does not do
|
|
30
|
+
|
|
31
|
+
- Only bare `var(--game-ui-x)` values are judged. `color-mix`, gradients and
|
|
32
|
+
anything composited are skipped: a tint of the accent behind accent-coloured
|
|
33
|
+
text is readable, and reading the first token out of the expression scores it
|
|
34
|
+
1.00:1. The first draft did that and flagged four rules that were fine — a
|
|
35
|
+
linter that cries wolf gets the next real finding skimmed too.
|
|
36
|
+
- Tokens carrying alpha are skipped for the same reason. What they composite
|
|
37
|
+
against is not knowable from a stylesheet.
|
|
38
|
+
|
|
39
|
+
### Compatibility
|
|
40
|
+
|
|
41
|
+
Additive. Existing raw-colour behaviour is unchanged; a project that was clean
|
|
42
|
+
stays clean unless it genuinely has an unreadable pair.
|
|
43
|
+
|
|
6
44
|
## 1.4.0 — 2026-08-22
|
|
7
45
|
|
|
8
46
|
The kit shipped an icon set that no consumer could see. Nothing was broken and
|
package/README.md
CHANGED
|
@@ -71,6 +71,16 @@ import '@pieai/swimmer-ui-kit/tailwind.css';
|
|
|
71
71
|
- **Official themes**: light (default) and `night`
|
|
72
72
|
(`<html data-game-ui-theme="night">`). Downstream theming = overriding
|
|
73
73
|
semantic tokens; see the design system guide.
|
|
74
|
+
- **`swimmer-ui-check`**: lints your CSS for raw colour literals in component
|
|
75
|
+
rules *and* for token pairs that cannot be read. Two tokens are not
|
|
76
|
+
automatically safe together — `--game-ui-accent-ink` is accent-COLOURED ink
|
|
77
|
+
for a surface, `--game-ui-accent-contrast` is the ink meant to sit **on**
|
|
78
|
+
`--game-ui-accent`. Pairing the first with the accent measures 1.48:1.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npx swimmer-ui-check src
|
|
82
|
+
```
|
|
83
|
+
|
|
74
84
|
- **Clay assets**: two lines of setup, and **skipping them is not a no-op**.
|
|
75
85
|
Out of the box the kit draws *placeholders* — one rounded square per icon
|
|
76
86
|
with a letter in it — not the icon set. They exist so a fresh install
|
package/bin/swimmer-ui-check.mjs
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
// dir directory to scan, default "src"
|
|
17
17
|
// --ext comma-separated extensions to scan, default "css"
|
|
18
18
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
19
|
-
import { extname, join, relative } from 'node:path';
|
|
19
|
+
import { dirname, extname, join, relative, resolve } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
20
21
|
|
|
21
22
|
const RAW_COLOR = /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(|\boklch\(/g;
|
|
22
23
|
const TOKEN_BLOCK_SELECTOR = /:root\b|\[data-[\w-]*(?:theme|tone)[\w-]*\s*=/i;
|
|
@@ -94,6 +95,139 @@ function findViolations(css) {
|
|
|
94
95
|
return violations;
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
/* ---------------------------------------------------------------------------
|
|
99
|
+
* Token pairs that cannot be read.
|
|
100
|
+
*
|
|
101
|
+
* The raw-colour rule above keeps consumers on tokens. It does not stop them
|
|
102
|
+
* choosing two tokens that do not contrast, and the names make one pairing
|
|
103
|
+
* genuinely inviting: `--game-ui-accent-ink` sounds like "the ink for accent
|
|
104
|
+
* things" and means the opposite — accent-COLOURED ink for a surface. Painted
|
|
105
|
+
* on `--game-ui-accent` it measured 1.48:1 in a shipping product, on that
|
|
106
|
+
* product's primary button, and every test it had was green.
|
|
107
|
+
*
|
|
108
|
+
* PRODUCT.md promises "contrast-safe token combinations". That promise only
|
|
109
|
+
* covers the pairs the kit uses itself unless something checks the consumer's.
|
|
110
|
+
* So: read the theme values out of the kit's own styles.css, find rules that
|
|
111
|
+
* set both a token background and a token colour, and compute the ratio.
|
|
112
|
+
* ------------------------------------------------------------------------- */
|
|
113
|
+
|
|
114
|
+
const AA_NORMAL = 4.5;
|
|
115
|
+
|
|
116
|
+
function parseHex(value) {
|
|
117
|
+
const hex = value.trim().replace(/^#/, '');
|
|
118
|
+
if (!/^[0-9a-fA-F]{3,8}$/.test(hex)) return null;
|
|
119
|
+
const full =
|
|
120
|
+
hex.length === 3 || hex.length === 4
|
|
121
|
+
? hex
|
|
122
|
+
.slice(0, 3)
|
|
123
|
+
.split('')
|
|
124
|
+
.map((c) => c + c)
|
|
125
|
+
.join('')
|
|
126
|
+
: hex.slice(0, 6);
|
|
127
|
+
if (full.length !== 6) return null;
|
|
128
|
+
return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function relativeLuminance([r, g, b]) {
|
|
132
|
+
const channel = (v) => {
|
|
133
|
+
const s = v / 255;
|
|
134
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
135
|
+
};
|
|
136
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function contrastRatio(a, b) {
|
|
140
|
+
const la = relativeLuminance(a);
|
|
141
|
+
const lb = relativeLuminance(b);
|
|
142
|
+
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Token values per theme, read from the kit's shipped stylesheet.
|
|
147
|
+
*
|
|
148
|
+
* Only fully opaque hex values are kept. A token carrying alpha composites
|
|
149
|
+
* against whatever is behind it, and guessing that would produce confident
|
|
150
|
+
* numbers about a colour nobody can know from here.
|
|
151
|
+
*/
|
|
152
|
+
function themeTokens() {
|
|
153
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
154
|
+
// dist/ is what a consumer installs. src/theme.css is what exists in this
|
|
155
|
+
// repository before a build — and `pnpm test` runs before `pnpm build`, so
|
|
156
|
+
// reading only dist made the check a silent no-op in its own CI while
|
|
157
|
+
// passing locally off a stale dist. Silence is the failure mode this whole
|
|
158
|
+
// check exists to remove, so it must not be the failure mode of the check.
|
|
159
|
+
const sources = [join(packageRoot, 'dist', 'styles.css'), join(packageRoot, 'src', 'theme.css')];
|
|
160
|
+
let css = null;
|
|
161
|
+
for (const candidate of sources) {
|
|
162
|
+
try {
|
|
163
|
+
css = readFileSync(candidate, 'utf8');
|
|
164
|
+
break;
|
|
165
|
+
} catch {
|
|
166
|
+
// try the next one
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (css === null) return null;
|
|
170
|
+
const themes = new Map();
|
|
171
|
+
// The built stylesheet is minified and the attribute value loses its quotes,
|
|
172
|
+
// so both forms have to match or every theme but the default is invisible.
|
|
173
|
+
const blockRe = /(:root|\[data-game-ui-theme=['"]?([\w-]+)['"]?\])\s*\{([^}]*)\}/g;
|
|
174
|
+
for (const match of css.matchAll(blockRe)) {
|
|
175
|
+
const name = match[2] ?? 'light';
|
|
176
|
+
const values = themes.get(name) ?? new Map(themes.get('light') ?? []);
|
|
177
|
+
for (const decl of match[3].matchAll(/(--game-ui-[\w-]+)\s*:\s*(#[0-9a-fA-F]{3,8})/g)) {
|
|
178
|
+
const rgb = decl[2].length === 9 || decl[2].length === 5 ? null : parseHex(decl[2]);
|
|
179
|
+
if (rgb) values.set(decl[1], rgb);
|
|
180
|
+
}
|
|
181
|
+
themes.set(name, values);
|
|
182
|
+
}
|
|
183
|
+
return themes;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* A bare token reference and nothing else: `var(--game-ui-x)` or
|
|
188
|
+
* `var(--game-ui-x, #fallback)`.
|
|
189
|
+
*
|
|
190
|
+
* Deliberately refuses `color-mix(in srgb, var(--game-ui-accent) 18%,
|
|
191
|
+
* transparent)` and gradients. A tint of the accent behind accent-coloured
|
|
192
|
+
* text is perfectly readable, and reading the first token out of the
|
|
193
|
+
* expression would call it 1.00:1 — the first draft of this check did exactly
|
|
194
|
+
* that and flagged four rules that were fine. A linter that cries wolf is
|
|
195
|
+
* worse than no linter, because the next real finding gets skimmed too.
|
|
196
|
+
*/
|
|
197
|
+
const BARE_TOKEN = /^\s*var\(\s*(--game-ui-[\w-]+)\s*(?:,[^()]*)?\)\s*$/;
|
|
198
|
+
|
|
199
|
+
function findContrastViolations(css, themes) {
|
|
200
|
+
if (!themes || themes.size === 0) return [];
|
|
201
|
+
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
|
|
202
|
+
const lineOf = lineFinder(withoutComments);
|
|
203
|
+
const out = [];
|
|
204
|
+
const ruleRe = /\{([^{}]*)\}/g;
|
|
205
|
+
for (const rule of withoutComments.matchAll(ruleRe)) {
|
|
206
|
+
const body = rule[1];
|
|
207
|
+
const bg = /(?:^|[;\s])background(?:-color)?\s*:\s*([^;]+)/.exec(body);
|
|
208
|
+
const fg = /(?:^|[;\s])color\s*:\s*([^;]+)/.exec(body);
|
|
209
|
+
if (!bg || !fg) continue;
|
|
210
|
+
const bgToken = BARE_TOKEN.exec(bg[1])?.[1];
|
|
211
|
+
const fgToken = BARE_TOKEN.exec(fg[1])?.[1];
|
|
212
|
+
if (!bgToken || !fgToken) continue;
|
|
213
|
+
for (const [theme, values] of themes) {
|
|
214
|
+
const bgRgb = values.get(bgToken);
|
|
215
|
+
const fgRgb = values.get(fgToken);
|
|
216
|
+
if (!bgRgb || !fgRgb) continue;
|
|
217
|
+
const ratio = contrastRatio(bgRgb, fgRgb);
|
|
218
|
+
if (ratio >= AA_NORMAL) continue;
|
|
219
|
+
out.push({
|
|
220
|
+
line: lineOf(rule.index + 1),
|
|
221
|
+
theme,
|
|
222
|
+
fgToken,
|
|
223
|
+
bgToken,
|
|
224
|
+
ratio: ratio.toFixed(2),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
97
231
|
const args = process.argv.slice(2);
|
|
98
232
|
const target = args.find((a) => !a.startsWith('--')) ?? 'src';
|
|
99
233
|
const extArg = args.find((a) => a.startsWith('--ext='));
|
|
@@ -111,9 +245,24 @@ try {
|
|
|
111
245
|
process.exit(2);
|
|
112
246
|
}
|
|
113
247
|
|
|
248
|
+
const themes = themeTokens();
|
|
249
|
+
if (!themes || themes.size === 0) {
|
|
250
|
+
console.error(
|
|
251
|
+
"swimmer-ui-check: could not read this package's theme tokens, so contrast was NOT checked. " +
|
|
252
|
+
'Raw-colour linting below still ran.',
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
let contrastCount = 0;
|
|
114
256
|
let violationCount = 0;
|
|
115
257
|
for (const file of files) {
|
|
116
258
|
const text = readFileSync(file, 'utf8');
|
|
259
|
+
for (const pair of findContrastViolations(text, themes)) {
|
|
260
|
+
console.log(
|
|
261
|
+
`${relative(process.cwd(), file)}:${pair.line}: ${pair.fgToken} on ${pair.bgToken} is ` +
|
|
262
|
+
`${pair.ratio}:1 on the ${pair.theme} theme — below AA (${AA_NORMAL}:1)`,
|
|
263
|
+
);
|
|
264
|
+
contrastCount += 1;
|
|
265
|
+
}
|
|
117
266
|
for (const violation of findViolations(text)) {
|
|
118
267
|
console.log(
|
|
119
268
|
`${relative(process.cwd(), file)}:${violation.line}: raw color literal "${violation.text}" — use var(--game-ui-*) instead`,
|
|
@@ -122,7 +271,15 @@ for (const file of files) {
|
|
|
122
271
|
}
|
|
123
272
|
}
|
|
124
273
|
|
|
125
|
-
if (
|
|
274
|
+
if (contrastCount > 0) {
|
|
275
|
+
console.error(
|
|
276
|
+
`\nswimmer-ui-check: ${contrastCount} unreadable token pair(s). Two tokens are not ` +
|
|
277
|
+
'automatically safe together: --game-ui-accent-ink is accent-COLOURED ink for a surface, ' +
|
|
278
|
+
'while --game-ui-accent-contrast is the ink meant to sit on --game-ui-accent.',
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (violationCount > 0 || contrastCount > 0) {
|
|
126
283
|
console.error(
|
|
127
284
|
`\nswimmer-ui-check: ${violationCount} raw color literal(s) in ${files.length} file(s) under "${target}". ` +
|
|
128
285
|
'Raw colors are expected inside :root / [data-*theme*=...] / [data-*tone*=...] token blocks ' +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pieai/swimmer-ui-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Token-driven React game UI kit for web and wrapped PieAI game surfaces.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"design-system",
|
|
@@ -50,29 +50,11 @@
|
|
|
50
50
|
"access": "public",
|
|
51
51
|
"registry": "https://registry.npmjs.org"
|
|
52
52
|
},
|
|
53
|
-
"scripts": {
|
|
54
|
-
"dev": "vite --host 127.0.0.1 --port 5174",
|
|
55
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
56
|
-
"lint": "oxlint src preview .storybook bin scripts vite.config.ts vite.config.site.ts vitest.config.ts -c ./.oxlintrc.json",
|
|
57
|
-
"format": "oxfmt package.json tsconfig.json tsconfig.build.json src preview .storybook bin scripts vite.config.ts vite.config.site.ts vitest.config.ts --write --ignore-path ./.oxfmtignore",
|
|
58
|
-
"format:check": "oxfmt package.json tsconfig.json tsconfig.build.json src preview .storybook bin scripts vite.config.ts vite.config.site.ts vitest.config.ts --check --ignore-path ./.oxfmtignore",
|
|
59
|
-
"verify": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm test && pnpm build",
|
|
60
|
-
"build": "vite build && node scripts/build-css.mjs && cp src/tailwind-bridge.css dist/tailwind.css && find dist -name '.DS_Store' -delete",
|
|
61
|
-
"build:site": "vite build --config vite.config.site.ts",
|
|
62
|
-
"preview:site": "vite preview --config vite.config.site.ts --host 127.0.0.1 --port 4175",
|
|
63
|
-
"test": "vitest run",
|
|
64
|
-
"storybook": "storybook dev -p 6006",
|
|
65
|
-
"build-storybook": "storybook build",
|
|
66
|
-
"build:deploy": "pnpm run build:site && pnpm run build-storybook && rm -rf site-dist/storybook && cp -r storybook-static site-dist/storybook",
|
|
67
|
-
"doc-gov": "doc-gov",
|
|
68
|
-
"pro-gov": "pro-gov",
|
|
69
|
-
"docs:check": "pnpm pro-gov doctor --strict-hooks && pnpm doc-gov router-check && pnpm doc-gov check && pnpm doc-gov scan --check && pnpm doc-gov links && pnpm doc-gov audit && pnpm doc-gov doctor"
|
|
70
|
-
},
|
|
71
53
|
"devDependencies": {
|
|
72
54
|
"@chromatic-com/storybook": "^5.2.1",
|
|
73
55
|
"@microsoft/api-extractor": "^7.58.9",
|
|
74
|
-
"@pieai/doc-gov": "0.9.
|
|
75
|
-
"@pieai/pro-gov": "0.9.
|
|
56
|
+
"@pieai/doc-gov": "0.9.3",
|
|
57
|
+
"@pieai/pro-gov": "0.9.3",
|
|
76
58
|
"@storybook/addon-a11y": "10.5.0",
|
|
77
59
|
"@storybook/addon-docs": "10.5.0",
|
|
78
60
|
"@storybook/addon-mcp": "^0.6.0",
|
|
@@ -104,5 +86,22 @@
|
|
|
104
86
|
"engines": {
|
|
105
87
|
"node": ">=24 <25"
|
|
106
88
|
},
|
|
107
|
-
"
|
|
108
|
-
|
|
89
|
+
"scripts": {
|
|
90
|
+
"dev": "vite --host 127.0.0.1 --port 5174",
|
|
91
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
92
|
+
"lint": "oxlint src preview .storybook bin scripts vite.config.ts vite.config.site.ts vitest.config.ts -c ./.oxlintrc.json",
|
|
93
|
+
"format": "oxfmt package.json tsconfig.json tsconfig.build.json src preview .storybook bin scripts vite.config.ts vite.config.site.ts vitest.config.ts --write --ignore-path ./.oxfmtignore",
|
|
94
|
+
"format:check": "oxfmt package.json tsconfig.json tsconfig.build.json src preview .storybook bin scripts vite.config.ts vite.config.site.ts vitest.config.ts --check --ignore-path ./.oxfmtignore",
|
|
95
|
+
"verify": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm test && pnpm build",
|
|
96
|
+
"build": "vite build && node scripts/build-css.mjs && cp src/tailwind-bridge.css dist/tailwind.css && find dist -name '.DS_Store' -delete",
|
|
97
|
+
"build:site": "vite build --config vite.config.site.ts",
|
|
98
|
+
"preview:site": "vite preview --config vite.config.site.ts --host 127.0.0.1 --port 4175",
|
|
99
|
+
"test": "vitest run",
|
|
100
|
+
"storybook": "storybook dev -p 6006",
|
|
101
|
+
"build-storybook": "storybook build",
|
|
102
|
+
"build:deploy": "pnpm run build:site && pnpm run build-storybook && rm -rf site-dist/storybook && cp -r storybook-static site-dist/storybook",
|
|
103
|
+
"doc-gov": "doc-gov",
|
|
104
|
+
"pro-gov": "pro-gov",
|
|
105
|
+
"docs:check": "pnpm pro-gov doctor --strict-hooks && pnpm doc-gov router-check && pnpm doc-gov check && pnpm doc-gov scan --check && pnpm doc-gov links && pnpm doc-gov audit && pnpm doc-gov doctor"
|
|
106
|
+
}
|
|
107
|
+
}
|