@pieai/swimmer-ui-kit 1.6.0 → 1.8.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,60 @@
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.8.0
7
+
8
+ ### Added
9
+
10
+ - **`<LiquidMetalButton>`**, a decision-surface CTA with two renderers and
11
+ one set of tokens. Default is CSS (zero WebGL contexts). It upgrades to
12
+ the ThreeUI dispersion shader (MIT, inlined — not the iframe wrapper)
13
+ only when webgl2 is available, `prefers-reduced-motion` is not `reduce`,
14
+ the button is on screen, and a process-wide budget (default **2**,
15
+ `setLiquidMetalContextBudget`) still has a slot. Exhausted budget, a
16
+ missing webgl2, or reduced motion stays on CSS without throwing. Hidden
17
+ tabs pause RAF rather than destroying the context.
18
+
19
+ Use it on checkout, sign-up, unlock, landing CTA. Do not use it on
20
+ reading, queues, forms, or the map. More than two on a page means the
21
+ page is the wrong place.
22
+
23
+ Colours are `--game-ui-liquid-metal-face` / `--ink` (defined on both
24
+ light and night so they cannot inherit an inverting ink) plus the
25
+ existing accent; the effect knobs are `--game-ui-liquid-metal-dispersion`,
26
+ `--sweep-speed`, and `--rest`.
27
+
28
+ ## 1.7.0
29
+
30
+ ### Fixed
31
+
32
+ - **Every tooltip was invisible on the night theme.** `.game-ui-tooltip
33
+ [role='tooltip']` painted `background: var(--game-ui-text)` with
34
+ `color: var(--game-ui-text-on-dark)`. On light that is a dark chip over a
35
+ pale page and reads correctly. On night `--game-ui-text` flips to cream, so
36
+ the chip became cream-on-cream at **1.15:1**. The light theme looking right
37
+ is exactly why this survived. There is now a dedicated
38
+ `--game-ui-tooltip-surface` / `--game-ui-tooltip-ink` pair, defined per
39
+ theme, keeping the inverted look on light and readable on night.
40
+
41
+ ### Added
42
+
43
+ - **`swimmer-ui-check` reports references to tokens this kit does not define.**
44
+ `var()` takes a fallback, so `var(--game-ui-border, #253048)` renders forever
45
+ against a token that does not exist — the rule looks token-driven while the
46
+ fallback does all the work, and no brand change can reach it. A consumer had
47
+ six borders in that state, in a cold blue-grey, in a warm brown app. Another
48
+ was the reason `--game-ui-font-mono` got added in 1.6.0.
49
+
50
+ Two things are exempt and neither is a typo: tokens the scanned files define
51
+ themselves (downstream theming), and tokens written from code
52
+ (`style={{ '--game-ui-card-offset': n }}`), found by scanning sibling
53
+ sources. Anything else is indistinguishable from a misspelling, and the way
54
+ to declare a deliberate knob is to give it a default in `:root`.
55
+
56
+ - **`pnpm check:styles`, and `pnpm verify` now runs it.** The kit shipped a
57
+ contrast checker in 1.5.0 and never pointed it at itself, which is how the
58
+ tooltip above survived a release that was specifically about contrast.
59
+
6
60
  ## 1.6.0
7
61
 
8
62
  ### Added
package/NOTICE ADDED
@@ -0,0 +1,38 @@
1
+ Third-party notices
2
+ ===================
3
+
4
+ Liquid-metal dispersion shader
5
+ ------------------------------
6
+ Portions of the WebGL2 renderer in `src/liquidMetalWebGL.ts` are adapted
7
+ from ThreeUI (https://github.com/MengTo/threeui), Copyright (c) 2026
8
+ Meng To, licensed under the MIT License.
9
+
10
+ The upstream ships the shader inside a standalone HTML page and embeds
11
+ that page in an iframe. This package inlines the shader so colours can
12
+ follow `--game-ui-*` tokens, adds a CSS renderer as the default, and
13
+ caps simultaneous WebGL contexts.
14
+
15
+ The MIT License text for that material:
16
+
17
+ Permission is hereby granted, free of charge, to any person obtaining a copy
18
+ of this software and associated documentation files (the "Software"), to deal
19
+ in the Software without restriction, including without limitation the rights
20
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
21
+ copies of the Software, and to permit persons to whom the Software is
22
+ furnished to do so, subject to the following conditions:
23
+
24
+ The above copyright notice and this permission notice shall be included in all
25
+ copies or substantial portions of the Software.
26
+
27
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
28
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
29
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
30
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
31
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
32
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
33
+ SOFTWARE.
34
+
35
+ Fonts
36
+ -----
37
+ Baloo 2 and Geist Variable are licensed under the SIL Open Font License.
38
+ See `src/fonts/OFL-Baloo2.txt` and `src/fonts/OFL-Geist.txt`.
package/README.md CHANGED
@@ -48,7 +48,8 @@ import '@pieai/swimmer-ui-kit/tailwind.css';
48
48
 
49
49
  ## What's inside
50
50
 
51
- - **~60 components** across: core controls (`GameButton`, `GameTabs`,
51
+ - **~60 components** across: core controls (`GameButton`,
52
+ `LiquidMetalButton`, `GameTabs`,
52
53
  `GameSlider`, `GameToggle`, `GameForms` inputs…), panels and windows
53
54
  (`GamePanel`, `GameCollapsiblePanel`, `GameWindowPanel`, `GameModal` on
54
55
  native `<dialog>`), HUD/shell surfaces (`GameShell`, `GameHud`,
@@ -183,6 +183,61 @@ function themeTokens() {
183
183
  return themes;
184
184
  }
185
185
 
186
+ /**
187
+ * Every `--game-ui-*` name this package defines, in any theme block.
188
+ *
189
+ * Colours only is not enough here: the gap this catches was a *font* token.
190
+ */
191
+ function definedTokenNames() {
192
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
193
+ const sources = [join(packageRoot, 'dist', 'styles.css'), join(packageRoot, 'src', 'theme.css')];
194
+ for (const candidate of sources) {
195
+ try {
196
+ const css = readFileSync(candidate, 'utf8');
197
+ return new Set([...css.matchAll(/(--game-ui-[\w-]+)\s*:/g)].map((m) => m[1]));
198
+ } catch {
199
+ // try the next one
200
+ }
201
+ }
202
+ return null;
203
+ }
204
+
205
+ /**
206
+ * A `var(--game-ui-…)` naming a token this kit does not define.
207
+ *
208
+ * `var()` takes a fallback, so this never fails at runtime — the rule renders
209
+ * from the fallback and looks token-driven while being nothing of the sort. A
210
+ * consumer had six borders reading `var(--game-ui-border, #253048)` against a
211
+ * token that does not exist, so those borders were a cold blue-grey in an app
212
+ * whose theme is warm brown, for as long as the file had existed, and no brand
213
+ * change could ever have reached them. Another was writing
214
+ * `var(--game-ui-font-mono, …)` — that one turned out to be a real gap in this
215
+ * kit, which is the other reason to surface it rather than let the fallback
216
+ * hide it.
217
+ *
218
+ * Two things are not typos and are not flagged. A token the scanned files
219
+ * define themselves is a downstream theme override, which is how theming
220
+ * works. And a token written from code — `style={{ '--game-ui-card-offset': n }}`
221
+ * — is a per-element channel that cannot have a stylesheet default; those are
222
+ * found by scanning the sibling sources.
223
+ *
224
+ * Everything else is indistinguishable from a misspelling, and the way to say
225
+ * "this is a deliberate knob" is to declare it in `:root` with its default.
226
+ * That makes it discoverable and themeable instead of a secret only the
227
+ * fallback knows.
228
+ */
229
+ function findUndefinedTokens(css, defined, ownTokens) {
230
+ if (!defined) return [];
231
+ const at = lineFinder(css);
232
+ const out = [];
233
+ for (const match of css.matchAll(/var\(\s*(--game-ui-[\w-]+)/g)) {
234
+ const name = match[1];
235
+ if (defined.has(name) || ownTokens.has(name)) continue;
236
+ out.push({ line: at(match.index ?? 0), name });
237
+ }
238
+ return out;
239
+ }
240
+
186
241
  /**
187
242
  * A bare token reference and nothing else: `var(--game-ui-x)` or
188
243
  * `var(--game-ui-x, #fallback)`.
@@ -246,6 +301,21 @@ try {
246
301
  }
247
302
 
248
303
  const themes = themeTokens();
304
+ const definedTokens = definedTokenNames();
305
+ const ownTokens = new Set();
306
+ for (const file of files) {
307
+ for (const match of readFileSync(file, 'utf8').matchAll(/^\s*(--game-ui-[\w-]+)\s*:/gm)) {
308
+ ownTokens.add(match[1]);
309
+ }
310
+ }
311
+ // Tokens written from code are a per-element channel, not a theme value.
312
+ const codeFiles = [];
313
+ walk(target, new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs']), codeFiles);
314
+ for (const file of codeFiles) {
315
+ for (const match of readFileSync(file, 'utf8').matchAll(/['"`](--game-ui-[\w-]+)['"`]/g)) {
316
+ ownTokens.add(match[1]);
317
+ }
318
+ }
249
319
  if (!themes || themes.size === 0) {
250
320
  console.error(
251
321
  "swimmer-ui-check: could not read this package's theme tokens, so contrast was NOT checked. " +
@@ -253,6 +323,7 @@ if (!themes || themes.size === 0) {
253
323
  );
254
324
  }
255
325
  let contrastCount = 0;
326
+ let undefinedCount = 0;
256
327
  let violationCount = 0;
257
328
  for (const file of files) {
258
329
  const text = readFileSync(file, 'utf8');
@@ -263,6 +334,13 @@ for (const file of files) {
263
334
  );
264
335
  contrastCount += 1;
265
336
  }
337
+ for (const undef of findUndefinedTokens(text, definedTokens, ownTokens)) {
338
+ console.log(
339
+ `${relative(process.cwd(), file)}:${undef.line}: ${undef.name} is not a token this kit ` +
340
+ 'defines — the var() fallback is doing all the work',
341
+ );
342
+ undefinedCount += 1;
343
+ }
266
344
  for (const violation of findViolations(text)) {
267
345
  console.log(
268
346
  `${relative(process.cwd(), file)}:${violation.line}: raw color literal "${violation.text}" — use var(--game-ui-*) instead`,
@@ -279,7 +357,15 @@ if (contrastCount > 0) {
279
357
  );
280
358
  }
281
359
 
282
- if (violationCount > 0 || contrastCount > 0) {
360
+ if (undefinedCount > 0) {
361
+ console.error(
362
+ `\nswimmer-ui-check: ${undefinedCount} reference(s) to a token this kit does not define. ` +
363
+ 'These never fail at runtime, because var() falls back — which is exactly why they survive. ' +
364
+ 'Either the name is wrong, or the token is missing from the kit and should be proposed there.',
365
+ );
366
+ }
367
+
368
+ if (violationCount > 0 || contrastCount > 0 || undefinedCount > 0) {
283
369
  console.error(
284
370
  `\nswimmer-ui-check: ${violationCount} raw color literal(s) in ${files.length} file(s) under "${target}". ` +
285
371
  'Raw colors are expected inside :root / [data-*theme*=...] / [data-*tone*=...] token blocks ' +
package/dist/index.d.ts CHANGED
@@ -78,6 +78,9 @@ export declare const CLAY_COLOR_TOKENS: {
78
78
  /** Official HUD-on-scene glass fill (matches SupaLuv stage chrome). */
79
79
  readonly overlayGlass: "rgba(12, 14, 20, 0.72)";
80
80
  readonly overlayGlassText: "#fff6ee";
81
+ /** Liquid-metal plate fill (light). Dedicated so it cannot inherit ink-deep, which inverts on night. */
82
+ readonly liquidMetalFace: "#3a2518";
83
+ readonly liquidMetalInk: "#fff8ec";
81
84
  };
82
85
 
83
86
  export declare const CLAY_ELEVATION_TOKENS: {
@@ -265,6 +268,23 @@ export declare const CLAY_LAYER_TOKENS: {
265
268
  readonly toast: "var(--game-ui-z-toast)";
266
269
  };
267
270
 
271
+ /**
272
+ * Effect knobs for `<LiquidMetalButton>`. Face/ink are real colours (both
273
+ * themes must set them); accent aliases `--game-ui-accent` so a host can
274
+ * retint just this button without rebranding every primary.
275
+ */
276
+ declare const CLAY_LIQUID_METAL_TOKENS: {
277
+ readonly face: "var(--game-ui-liquid-metal-face)";
278
+ readonly ink: "var(--game-ui-liquid-metal-ink)";
279
+ readonly accent: "var(--game-ui-liquid-metal-accent)";
280
+ readonly dispersion: "var(--game-ui-liquid-metal-dispersion)";
281
+ readonly sweepSpeed: "var(--game-ui-liquid-metal-sweep-speed)";
282
+ readonly rest: "var(--game-ui-liquid-metal-rest)";
283
+ readonly bloom: "var(--game-ui-liquid-metal-bloom)";
284
+ };
285
+ export { CLAY_LIQUID_METAL_TOKENS }
286
+ export { CLAY_LIQUID_METAL_TOKENS as GAME_UI_LIQUID_METAL_TOKENS }
287
+
268
288
  export declare const CLAY_MOTION_TOKENS: {
269
289
  readonly fast: "var(--game-ui-motion-fast)";
270
290
  readonly base: "var(--game-ui-motion-base)";
@@ -396,6 +416,9 @@ export declare const CLAY_UI_TOKENS: {
396
416
  /** Official HUD-on-scene glass fill (matches SupaLuv stage chrome). */
397
417
  readonly overlayGlass: "rgba(12, 14, 20, 0.72)";
398
418
  readonly overlayGlassText: "#fff6ee";
419
+ /** Liquid-metal plate fill (light). Dedicated so it cannot inherit ink-deep, which inverts on night. */
420
+ readonly liquidMetalFace: "#3a2518";
421
+ readonly liquidMetalInk: "#fff8ec";
399
422
  };
400
423
  readonly semantic: {
401
424
  readonly background: "var(--game-ui-bg)";
@@ -520,6 +543,15 @@ export declare const CLAY_UI_TOKENS: {
520
543
  readonly focusRing: "var(--game-ui-overlay-glass-focus-ring)";
521
544
  readonly primaryFill: "var(--game-ui-overlay-glass-primary-fill)";
522
545
  };
546
+ readonly liquidMetal: {
547
+ readonly face: "var(--game-ui-liquid-metal-face)";
548
+ readonly ink: "var(--game-ui-liquid-metal-ink)";
549
+ readonly accent: "var(--game-ui-liquid-metal-accent)";
550
+ readonly dispersion: "var(--game-ui-liquid-metal-dispersion)";
551
+ readonly sweepSpeed: "var(--game-ui-liquid-metal-sweep-speed)";
552
+ readonly rest: "var(--game-ui-liquid-metal-rest)";
553
+ readonly bloom: "var(--game-ui-liquid-metal-bloom)";
554
+ };
523
555
  };
524
556
 
525
557
  export declare type ClayAssetGroup = keyof typeof CLAY_ASSETS;
@@ -557,6 +589,18 @@ export declare type ClayIconStyle = 'game' | 'line';
557
589
 
558
590
  export declare type ClayTokenCategory = keyof typeof CLAY_UI_TOKENS;
559
591
 
592
+ /**
593
+ * Process-wide ledger for liquid-metal WebGL2 contexts.
594
+ *
595
+ * Chrome gives a tab about 16 contexts (Android about 8) and silently
596
+ * kills the oldest when the cap is hit. In University that oldest context
597
+ * is the 3D world map. These buttons are allowed to light up a shader,
598
+ * but they are not allowed to evict the map — so the default budget is
599
+ * 2, hosts can raise or lower it, and a button that cannot get a slot
600
+ * stays on the CSS renderer without throwing.
601
+ */
602
+ export declare const DEFAULT_LIQUID_METAL_CONTEXT_BUDGET = 2;
603
+
560
604
  export declare function FirstSessionHud({ authenticated, batteryCount, className, dailyStreak, iconSlots, labels, onHistory, onSettings, onWardrobe, playerName, }: FirstSessionHudProps): ReactNode;
561
605
 
562
606
  export declare interface FirstSessionHudIconSlots {
@@ -661,7 +705,7 @@ export declare const GAME_UI_TARGETS: {
661
705
  * to check its own CSS for the same completeness instead of eyeballing it —
662
706
  * see "自定义第三主题" in design-system-guide.md for a usage example.
663
707
  */
664
- export declare const GAME_UI_THEME_CONTRACT: readonly ["--game-ui-bg", "--game-ui-surface", "--game-ui-surface-raised", "--game-ui-panel", "--game-ui-panel-strong", "--game-ui-panel-deep", "--game-ui-text", "--game-ui-text-muted", "--game-ui-accent", "--game-ui-accent-bright", "--game-ui-secondary", "--game-ui-success", "--game-ui-success-bright", "--game-ui-danger", "--game-ui-danger-bright", "--game-ui-warning", "--game-ui-focus-ring", "--game-ui-border-subtle", "--game-ui-border-strong", "--game-ui-disabled", "--game-ui-ink-deep", "--game-ui-border-ink", "--game-ui-wood", "--game-ui-ink-title", "--game-ui-ink-heading"];
708
+ export declare const GAME_UI_THEME_CONTRACT: readonly ["--game-ui-bg", "--game-ui-surface", "--game-ui-surface-raised", "--game-ui-panel", "--game-ui-panel-strong", "--game-ui-panel-deep", "--game-ui-text", "--game-ui-text-muted", "--game-ui-accent", "--game-ui-accent-bright", "--game-ui-secondary", "--game-ui-success", "--game-ui-success-bright", "--game-ui-danger", "--game-ui-danger-bright", "--game-ui-warning", "--game-ui-focus-ring", "--game-ui-border-subtle", "--game-ui-border-strong", "--game-ui-disabled", "--game-ui-ink-deep", "--game-ui-border-ink", "--game-ui-wood", "--game-ui-ink-title", "--game-ui-ink-heading", "--game-ui-liquid-metal-face", "--game-ui-liquid-metal-ink"];
665
709
 
666
710
  export declare const GAME_UI_TOKENS: {
667
711
  readonly iconSm: "var(--game-ui-asset-icon-sm)";
@@ -1880,6 +1924,28 @@ export declare function getClayIconStyles(icon: ClayIconName): ClayIconStyle[];
1880
1924
 
1881
1925
  export declare function getClaySourceAssetPath(icon: ClayIconName, style?: ClayIconStyle): string;
1882
1926
 
1927
+ export declare function getLiquidMetalContextBudget(): {
1928
+ limit: number;
1929
+ used: number;
1930
+ };
1931
+
1932
+ export declare function LiquidMetalButton({ children, className, onClick, sound, type, renderer, ...props }: LiquidMetalButtonProps): ReactNode;
1933
+
1934
+ export declare interface LiquidMetalButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
1935
+ children: ReactNode;
1936
+ sound?: GameInteractionSoundOptions | false;
1937
+ /**
1938
+ * `auto` (default) starts on CSS and upgrades to WebGL when the four
1939
+ * gates pass. `css` never spends a context — needed so a side-by-side
1940
+ * demo can show the fallback without the CSS column taking the slot
1941
+ * the WebGL column needs. `webgl` still respects the gates; it is not
1942
+ * a way to bypass reduced-motion or the budget.
1943
+ */
1944
+ renderer?: LiquidMetalRendererMode;
1945
+ }
1946
+
1947
+ export declare type LiquidMetalRendererMode = 'auto' | 'css' | 'webgl';
1948
+
1883
1949
  export declare function playGameInteractionSound(options?: GameInteractionSoundOptions): boolean;
1884
1950
 
1885
1951
  export declare function playGameInteractionSoundForContext(audioContext: GameInteractionAudioContext, options?: GameInteractionSoundOptions): boolean;
@@ -1904,4 +1970,6 @@ export declare function setClayAssetBasePath(basePath: string): void;
1904
1970
  */
1905
1971
  export declare function setClayAssetMode(mode: ClayAssetMode): void;
1906
1972
 
1973
+ export declare function setLiquidMetalContextBudget(nextLimit: number): void;
1974
+
1907
1975
  export { }