@pieai/swimmer-ui-kit 1.3.2 → 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,85 @@
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
+
44
+ ## 1.4.0 — 2026-08-22
45
+
46
+ The kit shipped an icon set that no consumer could see. Nothing was broken and
47
+ nothing errored, which was the problem: with no setup the kit draws lettered
48
+ placeholder squares, and a placeholder that looks deliberate gets shipped. A
49
+ product could put eight identical coloured squares in its navigation and never
50
+ file a bug, because it reads as a design choice rather than a missing step.
51
+
52
+ The sculpted PNGs were never missing — 350 files travel inside the package, in
53
+ `dist/assets`. What was missing was any route from there to a path the host
54
+ actually serves. `CLAY_ASSET_BASE_PATH` names an absolute URL on the
55
+ *consumer's* origin, and nothing copied, exported or explained it. The kit's own
56
+ Storybook worked throughout, because its assets sit in `public/`.
57
+
58
+ ### Added
59
+
60
+ - `swimmer-ui-assets [dir] [--base=…] [--force]` — a `bin` that copies the
61
+ sculpted set into a host's static root. Bundler-agnostic and explicit; no
62
+ postinstall, because a package that writes into your repo on install is worse
63
+ than the problem it solves.
64
+ - `"./assets/*"` export, so bundler-based consumers can import an individual
65
+ file and get a content-hashed URL instead of copying the whole tree.
66
+ - `setClayAssetBasePath()` / `getClayAssetBasePath()` for a CDN or a sub-path
67
+ deploy. Resolution rebases on the way out, so the variant table stays a
68
+ single source of truth.
69
+ - A one-time `console.warn` the first time a placeholder is actually drawn,
70
+ naming the two commands that fix it, and `acknowledgeClayPlaceholders()` for
71
+ anyone using them on purpose.
72
+
73
+ ### Changed
74
+
75
+ - README states plainly that the default is placeholders rather than the icon
76
+ set, and records that the sculpted family is 96px art which cannot take a
77
+ `currentColor` tint — so it is the wrong family for a dense nav rail or
78
+ toolbar however correctly it is served.
79
+
80
+ ### Compatibility
81
+
82
+ Additive only. Defaults are unchanged, so every existing consumer renders
83
+ exactly what it rendered before and gains one console line telling it why.
84
+
6
85
  ## 1.3.2 — 2026-08-17
7
86
 
8
87
  Follows 1.3.1 with the contrast work that release only half did. The new off
package/README.md CHANGED
@@ -71,10 +71,39 @@ 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
- - **Clay assets**: inline SVG mode by default (no asset hosting needed);
75
- `setClayAssetMode('source')` switches to the sculpted PNG set, which
76
- ships in `dist/assets/` for hosts to copy under
77
- `/assets/game/ui/clay/…`.
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
+
84
+ - **Clay assets**: two lines of setup, and **skipping them is not a no-op**.
85
+ Out of the box the kit draws *placeholders* — one rounded square per icon
86
+ with a letter in it — not the icon set. They exist so a fresh install
87
+ renders something instead of a broken image, and they are not shippable.
88
+ The real sculpted PNGs travel inside the package and need serving:
89
+
90
+ ```bash
91
+ npx swimmer-ui-assets public # copies dist/assets into your static root
92
+ ```
93
+ ```ts
94
+ import { setClayAssetMode } from '@pieai/swimmer-ui-kit';
95
+ setClayAssetMode('source'); // once, at your entry
96
+ ```
97
+
98
+ Serving them somewhere else — a CDN, a sub-path deploy — is
99
+ `setClayAssetBasePath('/my/path')`, and `swimmer-ui-assets public --base=/my/path`
100
+ mirrors the layout to match. If you *want* placeholders, say so with
101
+ `acknowledgeClayPlaceholders()` and the console notice goes quiet.
102
+
103
+ Note on sizing: the sculpted family is 96px art. Below roughly 24px it turns
104
+ to mud, and being PNG it cannot take a `currentColor` tint, so it is the
105
+ wrong family for a dense navigation rail or toolbar. Use the `line` style
106
+ there, or your own glyphs.
78
107
  - **Audio helper**: `playGameInteractionSound` (SSR-safe, opt-in via the
79
108
  `sound` prop on `GameButton`).
80
109
 
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ // Copy the sculpted clay asset set out of the package and into a directory the
3
+ // consuming app actually serves.
4
+ //
5
+ // The kit resolves sculpted icons to absolute URLs under CLAY_ASSET_BASE_PATH
6
+ // ('/assets/game/ui/clay/…'), which is a path on the *host's* origin. The files
7
+ // themselves ship inside the package, in dist/assets. Nothing was closing that
8
+ // gap, so every consumer had to discover it, and the default placeholder mode
9
+ // meant the failure looked like a design choice rather than a missing step.
10
+ //
11
+ // Usage: swimmer-ui-assets [targetDir] [--base=/assets/game/ui/clay/...] [--force]
12
+ // targetDir where the host serves static files from. Default "public".
13
+ // --base the URL path to mirror under targetDir. Defaults to the kit's
14
+ // CLAY_ASSET_BASE_PATH, which is what setClayAssetMode('source')
15
+ // expects with no further configuration.
16
+ // --force overwrite files that already exist (default: skip them).
17
+ //
18
+ // After running this, call setClayAssetMode('source') once at your app entry.
19
+ import { cpSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
20
+ import { dirname, join, resolve } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const DEFAULT_BASE = '/assets/game/ui/clay/phase03-clay-kit';
24
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
25
+
26
+ const args = process.argv.slice(2);
27
+ const flags = new Set(args.filter((a) => a.startsWith('--')));
28
+ const positional = args.filter((a) => !a.startsWith('--'));
29
+
30
+ // Two different paths that are easy to conflate: where the files sit inside
31
+ // this package (fixed, mirrors the default URL under dist/) and where the host
32
+ // will serve them from (--base, defaults to the same thing so the common case
33
+ // needs no configuration).
34
+ const trim = (p) => p.replace(/^\/+|\/+$/g, '');
35
+ const baseFlag = args.find((a) => a.startsWith('--base='));
36
+ const servedBase = trim(baseFlag ? baseFlag.slice('--base='.length) : DEFAULT_BASE);
37
+ const targetRoot = resolve(process.cwd(), positional[0] ?? 'public');
38
+ const force = flags.has('--force');
39
+
40
+ const source = join(packageRoot, 'dist', trim(DEFAULT_BASE));
41
+ if (!existsSync(source)) {
42
+ console.error(`swimmer-ui-assets: nothing to copy — ${source} does not exist.`);
43
+ console.error('This package may have been installed without its dist/assets tree.');
44
+ process.exit(1);
45
+ }
46
+
47
+ const destination = join(targetRoot, servedBase);
48
+
49
+ function count(dir) {
50
+ let files = 0;
51
+ let bytes = 0;
52
+ for (const entry of readdirSync(dir)) {
53
+ const full = join(dir, entry);
54
+ const stat = statSync(full);
55
+ if (stat.isDirectory()) {
56
+ const inner = count(full);
57
+ files += inner.files;
58
+ bytes += inner.bytes;
59
+ } else {
60
+ files += 1;
61
+ bytes += stat.size;
62
+ }
63
+ }
64
+ return { files, bytes };
65
+ }
66
+
67
+ const { files, bytes } = count(source);
68
+
69
+ mkdirSync(dirname(destination), { recursive: true });
70
+ cpSync(source, destination, { recursive: true, force, errorOnExist: false });
71
+
72
+ const megabytes = (bytes / 1024 / 1024).toFixed(1);
73
+ console.log(`swimmer-ui-assets: copied ${files} files (${megabytes} MB)`);
74
+ console.log(` from ${source}`);
75
+ console.log(` to ${destination}`);
76
+ console.log('');
77
+ console.log('Now call this once at your app entry, or the kit keeps drawing placeholders:');
78
+ console.log(" import { setClayAssetMode } from '@pieai/swimmer-ui-kit';");
79
+ console.log(" setClayAssetMode('source');");
80
+ if (!force) {
81
+ console.log('');
82
+ console.log('Existing files were left alone. Re-run with --force to overwrite.');
83
+ }
@@ -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/dist/index.d.ts CHANGED
@@ -6,6 +6,9 @@ import { ReactNode } from 'react';
6
6
  import { RefAttributes } from 'react';
7
7
  import { TextareaHTMLAttributes } from 'react';
8
8
 
9
+ /** Opt in to placeholders on purpose, and stop being told about it. */
10
+ export declare function acknowledgeClayPlaceholders(): void;
11
+
9
12
  export declare const CLAY_ASSET_BASE_PATH: "/assets/game/ui/clay/phase03-clay-kit";
10
13
 
11
14
  export declare const CLAY_ASSET_SIZE_TOKENS: {
@@ -1852,6 +1855,8 @@ export declare interface GameWindowPanelProps extends Omit<HTMLAttributes<HTMLEl
1852
1855
 
1853
1856
  export declare type GameWindowState = 'normal' | 'minimized' | 'maximized';
1854
1857
 
1858
+ export declare function getClayAssetBasePath(): string;
1859
+
1855
1860
  export declare function getClayAssetMode(): ClayAssetMode;
1856
1861
 
1857
1862
  export declare function getClayCatalogPaths(): typeof CLAY_ASSETS.catalog;
@@ -1867,11 +1872,23 @@ export declare function playGameInteractionSound(options?: GameInteractionSoundO
1867
1872
 
1868
1873
  export declare function playGameInteractionSoundForContext(audioContext: GameInteractionAudioContext, options?: GameInteractionSoundOptions): boolean;
1869
1874
 
1875
+ /**
1876
+ * Point the sculpted set at somewhere other than the default path — a CDN, a
1877
+ * sub-path deploy, a host whose static root is not `/`. Pass no trailing slash.
1878
+ *
1879
+ * The copy command mirrors the default layout, so a consumer that runs
1880
+ * `swimmer-ui-assets public` never needs this.
1881
+ */
1882
+ export declare function setClayAssetBasePath(basePath: string): void;
1883
+
1870
1884
  /**
1871
1885
  * Switch how clay icons resolve globally.
1872
- * - 'inline' (default): zero-dependency SVG placeholders, no assets required.
1873
- * - 'source': the real clay game-icon PNG paths under CLAY_ASSET_BASE_PATH.
1874
- * The host app must serve those assets (e.g. copy them into its public/).
1886
+ * - 'inline' (default): lettered placeholders. They are not the icon set —
1887
+ * every one is the same rounded square with a different glyph and colour.
1888
+ * They exist so a fresh install renders *something* rather than a broken
1889
+ * image, and shipping them is a bug.
1890
+ * - 'source': the real sculpted PNGs under the base path. Get the files there
1891
+ * with `npx swimmer-ui-assets <publicDir>`.
1875
1892
  */
1876
1893
  export declare function setClayAssetMode(mode: ClayAssetMode): void;
1877
1894