@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 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
@@ -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 (violationCount > 0) {
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.4.0",
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.1",
75
- "@pieai/pro-gov": "0.9.1",
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
- "packageManager": "pnpm@11.22.0"
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
+ }