@energy8platform/shell 0.6.1 → 0.6.3

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.
@@ -28,20 +28,13 @@ export const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
28
28
  #${SHELL_ROOT_ID} .ge-iconbtn:active { transform:scale(.92); }
29
29
  #${SHELL_ROOT_ID} .ge-iconbtn[disabled] { opacity:.35; cursor:default; }
30
30
  #${SHELL_ROOT_ID} .ge-iconbtn.ge-active { color:var(--shell-icon-active); }
31
- /* Turbo ONE bolt glyph (turbo1); the level is shown by colour, not by swapping glyphs:
32
- off (ge-turbo-0) = muted grey · L1 (ge-turbo-1) = white bolt + 2px accent OUTER outline ·
33
- L2 (ge-turbo-2) = accent-filled bolt + 2px accent outer outline.
34
- Each level is a full-size bolt with a 2px OUTER ring; only the fill + ring colour change:
35
- off = white fill + BLACK ring · L1 = white fill + accent ring · L2 = accent fill + accent ring.
36
- The outer ring uses the paint-order:stroke + double-width trick (the stroke straddles the path, so
37
- painting it BEHIND the fill hides the inner half, leaving only the outer ~2px). Hover follows the
38
- standard bar-button behaviour (line ~400: white disc + accent) — the bolt + ring tint accent. */
39
- #${SHELL_ROOT_ID} .ge-iconbtn[data-ge="turbo"] svg { overflow:visible; }
40
- #${SHELL_ROOT_ID} .ge-iconbtn[data-ge="turbo"] svg path { paint-order:stroke; stroke:var(--shell-accent); stroke-width:4; stroke-linejoin:round; }
41
- #${SHELL_ROOT_ID} .ge-iconbtn.ge-turbo-0 svg path { fill:#fff; stroke:#000; }
42
- #${SHELL_ROOT_ID} .ge-iconbtn.ge-turbo-1 svg path { fill:#fff; }
43
- #${SHELL_ROOT_ID} .ge-iconbtn.ge-turbo-2 svg path { fill:var(--shell-accent); }
44
- #${SHELL_ROOT_ID} .ge-bar-panel .ge-iconbtn[data-ge="turbo"]:hover svg path { fill:var(--shell-accent); stroke:var(--shell-accent); }
31
+ /* Turbo shows its level by SWAPPING the glyph (off single bolt turboOff, L1 single bolt
32
+ turbo1, L2 bolt-with-speed-lines turbo2). Colour just marks state no outline:
33
+ off (ge-turbo-0) = #ccc (the svg turbo-off grey) · engaged (L1/L2) = accent · hover = accent. */
34
+ #${SHELL_ROOT_ID} .ge-iconbtn[data-ge="turbo"].ge-turbo-0 { color:#ccc; }
35
+ #${SHELL_ROOT_ID} .ge-iconbtn[data-ge="turbo"].ge-turbo-1,
36
+ #${SHELL_ROOT_ID} .ge-iconbtn[data-ge="turbo"].ge-turbo-2 { color:var(--shell-accent); }
37
+ #${SHELL_ROOT_ID} .ge-bar-panel .ge-iconbtn[data-ge="turbo"]:hover { color:var(--shell-accent); }
45
38
 
46
39
  /* SPIN — white disc by default, big glyph reaching for the rim; lights up accent on hover */
47
40
  #${SHELL_ROOT_ID} .ge-shell-spin { pointer-events:auto; cursor:pointer; border:3px solid #000; border-radius:50%;
@@ -61,8 +54,9 @@ export const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
61
54
  #${SHELL_ROOT_ID} .ge-spin-stop { display:flex; } /* STOP glyph at the disc's full size, like SPIN */
62
55
  /* no entrance animation: the bar re-renders many times per spin, so any animation here
63
56
  would replay each time and make the counter visibly jump. tabular-nums keeps it steady. */
57
+ /* the STOP glyph is a solid dark square, so the autoplay count is always pure white to read on it. */
64
58
  #${SHELL_ROOT_ID} .ge-spin-count { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
65
- font-size:22px; font-weight:800; line-height:1; font-variant-numeric:tabular-nums; }
59
+ font-size:22px; font-weight:800; line-height:1; font-variant-numeric:tabular-nums; color:#fff; }
66
60
 
67
61
  /* BUY BONUS — round accent badge, 2-line label, text pulses + accent glow on hover */
68
62
  #${SHELL_ROOT_ID} .ge-shell-buybonus { pointer-events:auto; cursor:pointer; box-sizing:border-box;
@@ -72,7 +66,14 @@ export const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
72
66
  #${SHELL_ROOT_ID} .ge-shell-buybonus span { display:inline-block; will-change:transform; }
73
67
  /* the ticket glyph fills the badge (em-sized off the button's font-size, so it scales per context) */
74
68
  #${SHELL_ROOT_ID} .ge-shell-buybonus .ge-bb-tk { display:flex; font-size:3.5em; color:#0b0e16; }
69
+ /* coin variant (BUY state): the full-colour coin IS the button — drop the accent disc + border,
70
+ keep the circular hit/glow. The coin art fills the button and is the pulsed node on hover. */
71
+ #${SHELL_ROOT_ID} .ge-shell-buybonus.ge-bb-coin { background:none; border:none; }
72
+ #${SHELL_ROOT_ID} .ge-shell-buybonus .ge-bb-coin-art { display:flex; width:100%; height:100%; }
73
+ #${SHELL_ROOT_ID} .ge-shell-buybonus .ge-bb-coin-art svg { width:100%; height:100%; display:block; }
75
74
  #${SHELL_ROOT_ID} .ge-shell-buybonus:hover { box-shadow:0 0 11px 1px var(--shell-accent); }
75
+ /* the coin needs no accent halo on hover/active — it carries its own gold look */
76
+ #${SHELL_ROOT_ID} .ge-shell-buybonus.ge-bb-coin:hover { box-shadow:none; }
76
77
  #${SHELL_ROOT_ID} .ge-shell-buybonus:hover span { animation:ge-bb-pulse .7s ease-in-out infinite; }
77
78
  #${SHELL_ROOT_ID} .ge-shell-buybonus:active { transform:scale(.96); }
78
79
  #${SHELL_ROOT_ID} .ge-shell-buybonus[disabled] { filter:grayscale(.5) brightness(.72); box-shadow:none; cursor:default; }
@@ -138,7 +139,7 @@ export const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
138
139
  #${SHELL_ROOT_ID}.ge-mobile .ge-shell-spin { width:84px; height:84px; font-size:66px; border-width:4px;
139
140
  background:var(--shell-btn); color:var(--shell-btn-ink); }
140
141
  #${SHELL_ROOT_ID}.ge-mobile .ge-shell-spin:hover { background:var(--shell-btn); color:var(--shell-accent); box-shadow:none; }
141
- #${SHELL_ROOT_ID}.ge-mobile .ge-shell-buybonus { width:50px; height:50px; font-size:9px; border-width:2px; }
142
+ #${SHELL_ROOT_ID}.ge-mobile .ge-shell-buybonus { width:58px; height:58px; font-size:9px; border-width:2px; }
142
143
  /* landscape overflow — size the column to content, centre it; JS scales the whole stack to fit */
143
144
  #${SHELL_ROOT_ID} .ge-shell-barhost.ge-fit { left:50%; right:auto; width:max-content; max-width:none; }
144
145
  #${SHELL_ROOT_ID} .ge-shell-barhost.ge-fit .ge-shell-bottom { width:max-content; }
@@ -349,12 +350,10 @@ export const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
349
350
  #${SHELL_ROOT_ID} .ge-bonus-vol { display:flex; justify-content:center; align-items:center; gap:0;
350
351
  font-size:2.2em; line-height:1; margin-bottom:.55em; }
351
352
  #${SHELL_ROOT_ID} .ge-bonus-vol-on, #${SHELL_ROOT_ID} .ge-bonus-vol-off { display:inline-flex; gap:0; }
352
- /* Volatility bolts get the same outline+fill treatment as turbo: white fill + a 2px OUTER ring
353
- (paint-order:stroke + double width). Active = accent ring; inactive = black ring, dimmed. */
354
- #${SHELL_ROOT_ID} .ge-bonus-vol svg { overflow:visible; }
355
- #${SHELL_ROOT_ID} .ge-bonus-vol svg path { paint-order:stroke; fill:#fff; stroke-width:1; stroke-linejoin:round; }
356
- #${SHELL_ROOT_ID} .ge-bonus-vol-on svg path { stroke:var(--card-acc); fill:var(--card-acc); }
357
- #${SHELL_ROOT_ID} .ge-bonus-vol-off svg path { stroke:#000; fill:#000; }
353
+ /* Volatility = the turbo bolt (turbo1) repeated: active bolts filled accent, inactive filled black
354
+ and dimmed. Solid fill, no outline. */
355
+ #${SHELL_ROOT_ID} .ge-bonus-vol-on svg path { fill:var(--card-acc); }
356
+ #${SHELL_ROOT_ID} .ge-bonus-vol-off svg path { fill:#000; }
358
357
  #${SHELL_ROOT_ID} .ge-bonus-vol-off { opacity:.5; }
359
358
  #${SHELL_ROOT_ID} .ge-bonus-price { font-size:1.6em; font-weight:800; color:#fff; font-variant-numeric:tabular-nums; white-space:nowrap; }
360
359
  #${SHELL_ROOT_ID} .ge-bonus-cta { width:100%; border:none; padding:1.15em; font-weight:800; font-size:1.05em;
@@ -488,7 +487,7 @@ export const SHELL_CSS = SHELL_FONT_CSS + SHELL_DIGIT_FONT_CSS + `
488
487
 
489
488
  /* BUY BONUS — floats outside-left of the bar; keeps its accent disc + hover ring (the one exception).
490
489
  relative/z-index so it stacks predictably under the full-screen overlays (see overlay-stacking test). */
491
- #${SHELL_ROOT_ID} .ge-shell-bottom > .ge-shell-buybonus { flex:0 0 auto; width:62px; height:62px;
490
+ #${SHELL_ROOT_ID} .ge-shell-bottom > .ge-shell-buybonus { flex:0 0 auto; width:71px; height:71px;
492
491
  font-size:11px; border-width:3px; position:relative; z-index:3; }
493
492
 
494
493
  /* WIN — a plain readout (same as balance/bet), grouped on the left with the other info */
@@ -1,6 +1,7 @@
1
- import { Container, Graphics, Rectangle, Text } from 'pixi.js';
1
+ import { Container, Graphics, Text } from 'pixi.js';
2
2
  import type { PixiComponentContext } from '../context';
3
- import { effectiveAccent, contrastText } from '@/core/colors';
3
+ import { effectiveAccent } from '@/core/colors';
4
+ import { BUY_BONUS_ART, BUY_BONUS_SOCIAL_ART, BUY_BONUS_DISABLED_ART } from '../../buy-bonus-art';
4
5
  import { FlexBox } from '../primitives/flex';
5
6
  import { roundedPath } from '../primitives/flex';
6
7
  import {
@@ -10,10 +11,8 @@ import {
10
11
  BuyBonusBadge,
11
12
  FsHero,
12
13
  divider,
13
- attachHover,
14
- attachPress,
15
14
  } from '../primitives/widgets';
16
- import { IconView, makeRingedIcon } from '../pixi-icon';
15
+ import type { IconName } from '../icons';
17
16
 
18
17
  // ── design constants (mirror the DOM `.ge-bar-panel` / mobile rules) ──────────
19
18
  const BAR_H = 68; // continuous dark panel height
@@ -30,7 +29,7 @@ const BAR_MIN_SCALE = 0.5;
30
29
  const WIDE_PAD_BOTTOM = 8;
31
30
  export const WIDE_BAR_H = WIDE_PAD_BOTTOM + SPIN; // ≈92
32
31
 
33
- const BUY_W = 62; // buy-bonus disc on desktop
32
+ const BUY_W = 71; // buy-bonus coin on desktop (+15% over the old 62 disc)
34
33
  const DISC = 38; // white-disc icon button box (auto/turbo)
35
34
  const FS_BOX = 90; // fixed bet-value box width
36
35
 
@@ -40,87 +39,36 @@ const M_CTRL_H = 62,
40
39
  M_GAP = 10,
41
40
  M_PAD_BOTTOM = 8,
42
41
  M_SIDE = 10;
43
- const M_BUY = 50;
42
+ const M_BUY = 58; // mobile buy-bonus coin (+15%)
44
43
  export const MOBILE_BAR_H = M_PAD_BOTTOM + SPIN + M_GAP + M_INFO_H; // spin pops above the controls
45
44
 
46
- /** Three-state turbo button: single `turbo1` bolt on a white disc, with fill+ring treatment
47
- * matching the DOM redesign. Level is conveyed by fill colour + ring colour (not glyph swap):
48
- * - off (0): bolt fill #fff + BLACK ring, container alpha .5
49
- * - L1: bolt fill #fff + ACCENT ring, alpha 1
50
- * - L≥2: bolt fill ACCENT + ACCENT ring, alpha 1
51
- * Hover at any level: bolt fill+ring accent, disc border → accent (standard bar-button hover). */
52
- class TurboButton extends Container {
53
- private box: number;
54
- private view: IconView;
55
- private discG: Graphics;
56
- private discBorder: number;
57
- private level: number;
58
- private accent: string;
59
-
60
- constructor(opts: {
61
- size: number;
62
- glyph: number;
63
- discFill: string;
64
- discBorder?: number;
65
- accent: string;
66
- level: number;
67
- onTap: () => void;
68
- }) {
69
- super();
70
- this.box = opts.size;
71
- this.accent = opts.accent;
72
- this.level = opts.level;
73
- this.discBorder = opts.discBorder ?? 2;
74
- this.discG = new Graphics();
75
- this.addChild(this.discG);
76
- const { fill, ring } = this._colors(false);
77
- this.view = makeRingedIcon('turbo1', opts.glyph, fill, ring);
78
- this.view.position.set((this.box - opts.glyph) / 2, (this.box - opts.glyph) / 2);
79
- this.addChild(this.view);
80
- // off is no longer dimmed — it reads as off via the black ring (DOM dropped the opacity:.5 rule)
81
- this.eventMode = 'static';
82
- this.cursor = 'pointer';
83
- this.hitArea = new Rectangle(0, 0, this.box, this.box);
84
- this._paint(false);
85
- attachHover(
86
- this,
87
- () => this._paint(true),
88
- () => this._paint(false),
89
- );
90
- attachPress(this, 0.92, () => opts.onTap());
91
- }
92
-
93
- private _colors(hovering: boolean): { fill: string; ring: string } {
94
- if (hovering) return { fill: this.accent, ring: this.accent };
95
- if (this.level === 0) return { fill: '#ffffff', ring: '#000000' };
96
- if (this.level === 1) return { fill: '#ffffff', ring: this.accent };
97
- return { fill: this.accent, ring: this.accent };
98
- }
99
-
100
- private _paint(hovering: boolean): void {
101
- const { fill, ring } = this._colors(hovering);
102
- this.view.setColors(fill, ring);
103
- const r = this.box / 2 - this.discBorder / 2;
104
- this.discG.clear();
105
- this.discG.circle(this.box / 2, this.box / 2, r).fill('#ffffff');
106
- // border lights accent on hover OR when engaged (level>0) — mirrors DOM .ge-iconbtn.ge-active
107
- const ringActive = hovering || this.level > 0;
108
- this.discG.stroke({ color: ringActive ? this.accent : '#000000', width: this.discBorder });
109
- }
110
-
111
- /** Update turbo level and repaint (called when the shell re-renders the bar). */
112
- setLevel(level: number, accent: string): void {
113
- this.level = level;
114
- this.accent = accent;
115
- this._paint(false);
116
- }
45
+ /** Three-state turbo glyph: the level is shown by SWAPPING the glyph (off → single bolt `turboOff`,
46
+ * L1 single bolt `turbo1`, L≥2 bolt-with-speed-lines `turbo2`). off = svg turbo-off grey (#ccc),
47
+ * engaged/hover = accent. On desktop it sits on the white disc (like auto); mobile is disc-less. */
48
+ function turboGlyph(level: number): IconName {
49
+ if (level <= 0) return 'turboOff';
50
+ return level >= 2 ? 'turbo2' : 'turbo1';
51
+ }
117
52
 
118
- measureSize(): { w: number; h: number } {
119
- return { w: this.box, h: this.box };
120
- }
121
- setLayoutSize(): void {
122
- /* fixed */
123
- }
53
+ function makeTurboButton(
54
+ host: PixiComponentContext,
55
+ level: number,
56
+ onTap: () => void,
57
+ size = DISC,
58
+ glyph = 19,
59
+ disc = false,
60
+ ): IconButton {
61
+ const { tokens } = host;
62
+ return new IconButton(turboGlyph(level), {
63
+ size,
64
+ glyph,
65
+ ...(disc ? { disc: tokens.btn, discBorder: 2 } : {}),
66
+ color: '#cccccc', // svg turbo-off grey (off); engaged/hover switch to accent
67
+ hover: tokens.accent,
68
+ activeColor: tokens.accent,
69
+ active: level > 0,
70
+ onTap,
71
+ });
124
72
  }
125
73
 
126
74
  /** A readout in the bar (white Oswald value, plaque-label caption, no shadow). */
@@ -169,7 +117,7 @@ export class BottomBar extends Container {
169
117
  private betDown?: IconButton;
170
118
  private spin?: SpinDisc;
171
119
  private autoBtn?: IconButton;
172
- private turboBtn?: TurboButton;
120
+ private turboBtn?: IconButton;
173
121
 
174
122
  constructor(host: PixiComponentContext) {
175
123
  super();
@@ -310,15 +258,7 @@ export class BottomBar extends Container {
310
258
  );
311
259
  }
312
260
  if (config.features.turbo > 0) {
313
- this.turboBtn = new TurboButton({
314
- size: DISC,
315
- glyph: 19,
316
- discFill: tokens.btn,
317
- discBorder: 2,
318
- accent: tokens.accent,
319
- level: state.turbo,
320
- onTap: () => this.onTurbo(),
321
- });
261
+ this.turboBtn = makeTurboButton(this.host, state.turbo, () => this.onTurbo(), DISC, 19, true);
322
262
  spinWrap.add(this.turboBtn);
323
263
  }
324
264
 
@@ -335,14 +275,13 @@ export class BottomBar extends Container {
335
275
  if (config.features.buyBonus === false && !config.onBonusBuy) return null;
336
276
  const feature = state.activeFeature;
337
277
  if (feature) {
338
- const accent = effectiveAccent(feature);
278
+ // a feature is active → the coin becomes the "deactivate" variant
339
279
  return new BuyBonusBadge({
340
280
  size,
341
- fontSize,
342
281
  border,
343
- bg: accent,
344
- fg: contrastText(accent),
345
- label: this.host.t('DISABLE'),
282
+ bg: effectiveAccent(feature),
283
+ coinArt: BUY_BONUS_DISABLED_ART,
284
+ label: '',
346
285
  tokens,
347
286
  ticker: this.host.ticker,
348
287
  onTap: () => this.host.actions.deactivateFeature(),
@@ -352,9 +291,7 @@ export class BottomBar extends Container {
352
291
  size,
353
292
  border,
354
293
  bg: tokens.accent,
355
- icon: 'ticket',
356
- iconSize: size * 0.55,
357
- iconColor: tokens.btnInk,
294
+ coinArt: config.isSocial ? BUY_BONUS_SOCIAL_ART : BUY_BONUS_ART,
358
295
  label: '',
359
296
  tokens,
360
297
  ticker: this.host.ticker,
@@ -417,15 +354,7 @@ export class BottomBar extends Container {
417
354
  if (state.autoplay.active) this.autoBtn.setGlow(true);
418
355
  }
419
356
  if (config.features.turbo > 0) {
420
- this.turboBtn = new TurboButton({
421
- size: 40,
422
- glyph: 22,
423
- discFill: tokens.btn,
424
- discBorder: 2,
425
- accent: tokens.accent,
426
- level: state.turbo,
427
- onTap: () => this.onTurbo(),
428
- });
357
+ this.turboBtn = makeTurboButton(this.host, state.turbo, () => this.onTurbo(), 40, 22);
429
358
  }
430
359
  const buy = isBase ? (this.buildBuy(M_BUY, 9, 2) ?? undefined) : undefined;
431
360
 
@@ -654,7 +583,11 @@ export class BottomBar extends Container {
654
583
  );
655
584
 
656
585
  this.inner.scale.set(s);
657
- this.inner.position.set((W - barW * s) / 2, H - WIDE_PAD_BOTTOM - SPIN * s);
586
+ // Scale the bottom gap WITH the bar (…* s), not a flat 8px. On a small/short popout the bar is
587
+ // fit-scaled down but a constant 8px gap would look like too much air — so the bar floated off the
588
+ // bottom edge. Scaling matches the HTML shell, whose whole barhost scales from transform-origin
589
+ // bottom center (gap → 8·s). Desktop (s≈1) is unchanged.
590
+ this.inner.position.set((W - barW * s) / 2, H - (WIDE_PAD_BOTTOM + SPIN) * s);
658
591
  this.measureFootprint();
659
592
  }
660
593
 
@@ -4,7 +4,7 @@ import type { BonusOption } from '@/core/types';
4
4
  import { betDir } from '@/core/keyboard';
5
5
  import { effectiveAccent, contrastText } from '@/core/colors';
6
6
  import { makeText } from '../text';
7
- import { makeIcon, makeRingedIcon } from '../pixi-icon';
7
+ import { makeIcon } from '../pixi-icon';
8
8
  import { navButton } from '../primitives/overlay';
9
9
  import { clamp } from '../primitives/overlay';
10
10
  import { attachHover } from '../primitives/widgets';
@@ -700,11 +700,11 @@ function volatilityRow(_host: PixiComponentContext, level: number, accent: strin
700
700
  const n = Math.max(0, Math.min(5, level));
701
701
  let x = 0;
702
702
  for (let i = 0; i < 5; i++) {
703
- // active = solid accent bolt; inactive = solid black bolt, dimmed (fill==ring, 1px hairline ring
704
- // per the DOM: .ge-bonus-vol svg path { stroke-width:1; fill/stroke = accent (on) / #000 (off) })
703
+ // active = solid accent bolt; inactive = solid black bolt, dimmed
704
+ // (DOM: .ge-bonus-vol svg path fill = accent (on) / #000 (off))
705
705
  const active = i < n;
706
706
  const col = active ? accent : '#000000';
707
- const bolt = makeRingedIcon('turbo1', size, col, col, 1);
707
+ const bolt = makeIcon('turbo1', size, col);
708
708
  bolt.alpha = active ? 1 : 0.5;
709
709
  bolt.position.set(x, 0);
710
710
  c.addChild(bolt);
@@ -18,6 +18,7 @@ import { makeIcon } from '../pixi-icon';
18
18
  import { FlexBox } from '../primitives/flex';
19
19
  import { section, paragraph, Spacer } from '../primitives/controls';
20
20
  import { BuyBonusBadge } from '../primitives/widgets';
21
+ import { BUY_BONUS_ART, BUY_BONUS_SOCIAL_ART } from '../../buy-bonus-art';
21
22
  import { PACKAGE_VERSION } from '@/core/version';
22
23
 
23
24
  /** Game info overlay — modes, controls, paytable, win illustrations, custom sections. */
@@ -190,11 +191,10 @@ function sectionControls(host: PixiComponentContext, title: string, width: numbe
190
191
  const box = new Container();
191
192
  const badge = new BuyBonusBadge({
192
193
  size: 46,
193
- fontSize: 8,
194
194
  border: 2,
195
195
  bg: host.tokens.accent,
196
- fg: '#ffffff',
197
- label: host.t('BUY BONUS').split(/\s+/).join('\n'),
196
+ coinArt: host.config.isSocial ? BUY_BONUS_SOCIAL_ART : BUY_BONUS_ART,
197
+ label: '',
198
198
  tokens: host.tokens,
199
199
  ticker: host.ticker,
200
200
  onTap: () => {},
@@ -523,7 +523,7 @@ function sectionCustom(host: PixiComponentContext, s: Extract<GameInfoSection, {
523
523
  const sec = section(host, s.title != null ? host.t(s.title) : undefined);
524
524
  const inner = width - SECTION_PAD;
525
525
  if (s.node) {
526
- sec.add(s.node as Container); // game-supplied Pixi content owns its own layout
526
+ sec.add(persistentSlot(s.node as Container)); // game-supplied Pixi content owns its own layout
527
527
  } else if (s.html) {
528
528
  const text = s.html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
529
529
  if (text) sec.add(paragraph(host, text, inner, { size: 15 }));
@@ -579,6 +579,23 @@ function spacerBox(w: number, h: number): Graphics {
579
579
  return g;
580
580
  }
581
581
 
582
+ /** Wrap a GAME-supplied custom `node` so the shell BORROWS it instead of owning it. The overlay is
583
+ * torn down with `destroy({ children: true })` on every close; that would recursively destroy the
584
+ * game's node, so the section renders blank on reopen. This slot's `destroy` DETACHES the node
585
+ * (leaving it intact) and destroys only the empty wrapper — the SAME node instance is re-mounted,
586
+ * with all its drawing, the next time Game info opens. Mirrors the HTML shell, where the game's DOM
587
+ * node simply persists in config across open/close. The game owns the node's lifetime, not us. */
588
+ function persistentSlot(node: Container): Container {
589
+ const slot = new Container();
590
+ slot.addChild(node);
591
+ const destroySelf = Container.prototype.destroy.bind(slot);
592
+ slot.destroy = () => {
593
+ if (node.parent === slot) slot.removeChild(node); // keep the game node alive for the next open
594
+ destroySelf({ children: false });
595
+ };
596
+ return slot;
597
+ }
598
+
582
599
  /** A fixed box that loads an image into it (object-fit: contain) once the texture resolves. */
583
600
  function imageBox(url: string, w: number, h: number): Container {
584
601
  const c = new Container();
@@ -1,25 +1,29 @@
1
- // AUTO-GENERATED from ./icons-preview.svg by scripts/gen-icons.mjs — do not edit by hand.
1
+ // AUTO-GENERATED from ../../icons.svg by scripts/gen-icons-from-svg.mjs — do not edit by hand.
2
2
  // Sharp monochrome icon set: each glyph is a 24×24 viewBox fragment using currentColor
3
- // (hollow shapes use fill-rule="evenodd"; the few stroked glyphs use stroke="currentColor").
4
- // Coordinates are baked into the 24×24 space (no <g transform>), so the same fragment renders
5
- // identically in the DOM <svg> and in Pixi's GraphicsContext.svg.
6
- // To change an icon: edit icons-preview.svg, then run `node scripts/gen-icons.mjs`.
3
+ // (hollow shapes use fill-rule="evenodd"). Coordinates are baked into the 24×24 space (no
4
+ // <g transform>), so the same fragment renders identically in the DOM <svg> and in Pixi's
5
+ // GraphicsContext.svg. To change an icon: edit icons.svg, then run
6
+ // `node scripts/gen-icons-from-svg.mjs`.
7
7
  const SVGS: Record<string, string> = {
8
- spin: `<g fill="currentColor" fill-rule="evenodd"><path d="M17.72 4.86c-1.77 -0.89 -5.48 -2.51 -7.37 -3.08l1.4 1.96h0c-5.36 0.16 -8.6 5.47 -9.04 10.35l1.58 2.61l0.19 0.1l1.86 -1.68c-0.21 -0.42 -0.56 -1.87 -0.56 -1.87h0c-0.41 -4.55 3.28 -7.88 7.75 -7.37h0l2.33 0.75l1.86 -1.59v-0.18Z"/><path d="M6.29 19.38c1.79 0.86 5.54 2.39 7.43 2.92l-1.44 -1.93h0c5.36 -0.27 8.48 -5.65 8.81 -10.54l-1.64 -2.57l-0.19 -0.1l-1.82 1.72c0.22 0.42 0.6 1.86 0.6 1.86h0c0.51 4.54 -3.12 7.95 -7.59 7.53h0l-2.35 -0.7l-1.83 1.63v0.18Z"/></g>`,
9
- turbo1: `<g fill="currentColor" fill-rule="evenodd"><path d="M11.77 2.71l-4.62 10.63h4.39c-2.08 3.86 -4.74 8.67 -4.74 8.67h0.23s11.95 -12.16 11.95 -12.16l-5.79 0.03l5.63 -8.67l-7.05 1.5Z"/></g>`,
10
- autoplay: `<g fill="currentColor" fill-rule="evenodd"><path d="M9.61 8.97v6.44h0.1c0.82 -0.65 4.19 -2.6 5.15 -3.17v-0.1c-0.99 -0.55 -4.32 -2.68 -5.25 -3.17Z"/><path d="M20.32 8.33l-1.5 1.31v7.33l-2.08 1.69s-12.68 0 -12.68 0v0.1l4.36 3.76l-0.59 -2.18h10.1l2.48 -2.57h0l-0.09 -9.44Z"/><path d="M2.88 15.89l1.51 -1.3l0.05 -7.33l2.09 -1.67s12.68 0.08 12.68 0.08v-0.1s-4.34 -3.79 -4.34 -3.79l0.58 2.18h0l-10.1 -0.07l-2.5 2.55h0l0.03 9.44Z"/></g>`,
11
- stop: `<g fill="currentColor" fill-rule="evenodd"><path d="M20.96 1.6h-17.58l-1.78 1.85v17.51l1.3 -1.84v-14.37l1.64 -1.64h14.58l1.84 -1.51Z"/><path d="M3.09 22.37l17.58 -0.04l1.78 -1.85l-0.04 -17.51l-1.3 1.84l0.03 14.37l-1.64 1.64l-14.58 0.03h0l-1.84 1.52Z"/></g>`,
12
- menu: `<g fill="currentColor" fill-rule="evenodd"><path d="M1.6 13.52h18.01l2.79 -3.17h-18.01l-2.79 3.17h0Z"/><path d="M1.6 6.04h18.01l2.79 -3.17h-18.01l-2.79 3.17h0Z"/><path d="M1.64 21.02h18.01l2.79 -3.17h-18.01l-2.79 3.17h0Z"/></g>`,
13
- minus: `<g fill="currentColor" fill-rule="evenodd"><path d="M19.81 13.26l2.59 -2.44l-18.13 -0.08l-2.67 2.52"/></g>`,
14
- plus: `<g fill="currentColor" fill-rule="evenodd"><path d="M13.16 1.6h0l-2.39 3.82v5.31l-0.08 0.07h-6.36l-2.54 2.4h8.9l0.08 0.07v9.13l2.39 -3.74h0v-5.39l0.07 -0.07h6.36l2.62 -2.4h-8.98l-0.07 -0.07v-9.13Z"/></g>`,
8
+ spin: `<g fill="currentColor" fill-rule="evenodd"><path d="M13.57 8.41C13.3 8.31 13.51 7.09 13.59 6.65C13.61 6.56 13.55 6.48 13.46 6.48C12.32 6.45 5.74 6.61 4.16 14.85C4.15 14.9 4 15.16 3.8 14.91C3.27 14.23 2.43 12.08 3.46 9.16C6.01 1.92 13 3.07 13.79 3.23C13.83 3.23 13.86 3.2 13.86 3.17C13.85 2.86 13.82 1.68 14.17 1.64C14.51 1.6 19.04 5.61 18.96 5.95C18.86 6.36 13.83 8.51 13.57 8.41Z"/><path d="M10.43 15.59C10.7 15.69 10.49 16.91 10.41 17.35C10.39 17.44 10.45 17.52 10.54 17.52C11.68 17.55 18.26 17.39 19.84 9.15C19.85 9.1 20 8.84 20.2 9.09C20.73 9.77 21.57 11.92 20.54 14.85C17.99 22.08 11 20.93 10.21 20.77C10.17 20.77 10.13 20.8 10.14 20.83C10.15 21.14 10.18 22.32 9.83 22.36C9.49 22.4 4.96 18.39 5.04 18.05C5.14 17.64 10.17 15.49 10.43 15.59Z"/></g>`,
9
+ turbo1: `<g fill="currentColor" fill-rule="evenodd"><path d="M17.81 2.62L14.41 8.21C14.15 8.64 14.46 9.2 14.97 9.2L18.84 9.21C19.42 9.21 19.72 9.9 19.32 10.31L8.02 22.19C7.89 22.33 7.72 22.4 7.53 22.4L7.53 22.4C7.08 22.39 6.77 21.93 6.93 21.5L9.91 13.91C10.08 13.48 9.76 13.01 9.29 13.01L5.11 13.06C4.61 13.06 4.28 12.53 4.53 12.09L10.07 1.94C10.19 1.73 10.41 1.6 10.65 1.6L17.26 1.62C17.77 1.62 18.08 2.18 17.81 2.62Z"/></g>`,
10
+ autoplay: `<g fill="currentColor" fill-rule="evenodd"><path d="M19.97 12.41C20.22 11.94 20.95 11.88 20.97 12.47C21.14 16.4 18.71 19.92 14.44 20.97C8.59 22.4 5.29 18.55 5.28 18.52C5.2 18.33 4.41 19.77 4.19 19.59C3.81 19.28 3.78 14.9 3.9 14.78C4.01 14.67 8.39 14.95 8.48 15.31C8.58 15.64 7.2 16.1 7.4 16.38C9.14 18.67 15.82 20.29 19.97 12.41Z"/><path d="M9.3 15.36L9.3 8.67C9.3 8.32 9.97 7.74 10.58 8.14C15.83 11.56 16 11.46 16.01 12.04C16.02 12.61 15.12 12.98 10.63 15.91C10.32 16.11 9.29 15.72 9.29 15.36Z"/><path d="M4.03 11.59C3.78 12.06 3.05 12.12 3.03 11.53C2.86 7.6 5.29 4.08 9.56 3.03C15.41 1.6 18.71 5.45 18.72 5.48C18.8 5.67 19.59 4.23 19.81 4.41C20.19 4.72 20.22 9.1 20.1 9.22C19.99 9.33 15.61 9.05 15.52 8.69C15.42 8.36 16.8 7.9 16.6 7.62C14.86 5.33 8.18 3.71 4.03 11.59Z"/></g>`,
11
+ stop: `<g fill="currentColor" fill-rule="evenodd"><rect x="1.6" y="1.6" width="20.8" height="20.8" rx="2.9"/></g>`,
12
+ menu: `<g fill="currentColor" fill-rule="evenodd"><rect x="1.61" y="3.81" width="20.79" height="4.47" rx="2.23"/><rect x="1.6" y="9.77" width="20.79" height="4.46" rx="2.23"/><rect x="1.6" y="15.72" width="20.79" height="4.47" rx="2.23"/></g>`,
13
+ minus: `<g fill="currentColor" fill-rule="evenodd"><rect x="1.6" y="9.86" width="20.8" height="4.28" rx="2.11"/></g>`,
14
+ plus: `<g fill="currentColor" fill-rule="evenodd"><path d="M19.72 14.37L13.84 14.37L13.84 20.33C13.84 21.48 12.89 22.4 11.74 22.36C10.61 22.36 9.7 21.45 9.7 20.33L9.7 14.37L3.67 14.37C2.52 14.37 1.6 13.42 1.63 12.27C1.63 11.15 2.55 10.23 3.67 10.23L9.7 10.23L9.7 4.28C9.71 1.61 13.83 1.6 13.84 4.28L13.84 10.23L19.72 10.23C22.39 10.24 22.4 14.36 19.72 14.37Z"/></g>`,
15
15
  gift: `<rect x="4" y="9" width="16" height="11" rx="2" fill="currentColor"/><path d="M9 9c-1.35 -0.31 -2.18 -1.65 -1.87 -3s1.65 -2.18 3 -1.87c0.93 0.22 1.65 0.94 1.87 1.87c0.31 -1.35 1.65 -2.18 3 -1.87c1.35 0.31 2.18 1.65 1.87 3c-0.22 0.93 -0.94 1.65 -1.87 1.87h-6Z" fill="currentColor"/><rect x="11" y="9" width="2" height="11" fill="rgba(0,0,0,.35)"/>`,
16
- info: `<g fill="currentColor" fill-rule="evenodd"><path d="M14.22 7.78l-3.09 0.08s-1.82 1.49 -1.96 1.43h0.98l-2.03 13.03h0l5.5 -4.37h-1.66"/><path d="M15.96 1.6h-3.02c-0.86 1.04 -2.06 2.98 -2.86 4.07l2.71 -0.08c0.84 -0.95 2.43 -2.96 3.17 -3.99Z"/></g>`,
17
- soundOn: `<g fill="currentColor" fill-rule="evenodd"><path d="M18.88 10.12l-2.27 -3.44l0.55 1.72l0.24 1.57v3.91l-0.24 1.56l-0.55 1.72l2.27 -3.44"/><path d="M20.91 13.33c0.08 1.91 -0.87 4.39 -1.64 6.1c1.12 -1.38 2.35 -3.64 3.13 -5.24c0 0 0 -4.61 0 -4.61c-0.75 -1.65 -2 -3.83 -3.13 -5.24c0.85 1.78 1.69 4.08 1.64 6.1"/><path d="M13.17 1.76c-1.7 1.45 -5.26 4.82 -6.88 6.41l-4.69 0.08v7.35c0.14 0.14 4.37 0.65 4.69 0.7c1.67 1.52 5.13 4.54 6.88 5.94v-20.48Z"/><path d="M11.69 5.59l0.23 0.08v13.37l-0.23 0.08l-4.62 -4.3l-2.5 -0.32l-0.15 -0.15v-3.84l0.15 -0.15l2.5 -0.08l4.62 -4.69Z"/></g>`,
18
- soundOff: `<g fill="currentColor" fill-rule="evenodd"><path d="M13.68 13.18l-1.15 1.22v4.04l-0.23 0.07l-1.9 -1.75l-1.07 1.07l3.51 3.05l0.91 0.68v-8.38h-0.07Z"/><path d="M17.71 8.69l-0.22 0.22l0.07 0.99s0.22 4.19 -0.53 5.49l1.75 -2.51v-2.59l-1.07 -1.6Z"/><path d="M19.47 6.63l1.06 3.5v2.9l-1.22 3.88l2.21 -3.73v-3.28l-2.05 -3.27Z"/><path d="M20.91 2.21c-1.64 1.63 -5.41 5.42 -6.93 7.09l-0.23 -0.08v-7.62c-1.67 1.43 -5.11 4.69 -6.7 6.25l-4.65 0.07l0.08 0.08v7.16l4.03 0.54l1.22 -1.38l-2.43 -0.3l-0.08 -0.08v-3.81l2.59 -0.23l4.49 -4.57l0.23 0.08v5.49c-3.03 3.13 -7.21 8.09 -9.98 11.5c6.55 -5.77 13.23 -13.13 18.36 -20.19Z"/></g>`,
19
- close: `<g fill="currentColor" fill-rule="evenodd"><path d="M16.89 5.27l-4.77 5.02l-4.77 -4.9l-5.14 -3.3l3.67 5.14l4.53 4.77l-5.05 5.19l-3.52 5.21l5.39 -3.67l4.77 -5.02h0.12l4.9 5.02l5.14 3.43l-3.5 -4.99l-4.95 -5.05v-0.24l4.82 -5.08l3.63 -5.19"/></g>`,
20
- back: `<path d="M15 6l-6 6l6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
21
- chevronRight: `<path d="M9 6l6 6l-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
16
+ info: `<g fill="currentColor" fill-rule="evenodd"><circle cx="11.98" cy="4.27" r="2.67"/><path d="M16.05 21.09L16.05 21.54C16.05 22.02 15.67 22.4 15.19 22.4L8.89 22.4C8.42 22.4 8.04 22.02 8.04 21.54L8.04 20.98C8.04 20.5 8.42 20.12 8.89 20.12L8.89 20.12C9.13 20.12 9.32 19.93 9.32 19.7L9.32 11.59C9.32 11.29 9.08 11.05 8.8 11.05C8.33 11.05 7.95 10.67 7.95 10.19L7.95 9.18C7.95 8.7 8.33 8.32 8.8 8.32L9.32 8.32C9.32 8.32 13.8 8.32 13.8 8.32C14.27 8.32 14.65 8.71 14.65 9.18L14.65 19.7C14.65 19.93 14.84 20.12 15.07 20.12L15.07 20.12C15.61 20.12 16.05 20.56 16.05 21.09Z"/></g>`,
17
+ soundOn: `<g fill="currentColor" fill-rule="evenodd"><path d="M1.6 15.04L1.6 9.18C1.6 8.75 1.95 8.4 2.38 8.4L5.03 8.4C5.21 8.4 5.39 8.36 5.56 8.28L11.6 5.26C12.12 5 12.73 5.38 12.73 5.96L12.73 18.27C12.73 18.85 12.12 19.22 11.6 18.96L5.56 15.94C5.39 15.86 5.22 15.82 5.03 15.82L2.38 15.82C1.95 15.82 1.6 15.47 1.6 15.04Z"/><path d="M16.01 12.11C16.04 12.36 15.67 14.7 14.7 15.23C13.92 15.42 13.54 14.77 13.86 14.1C14.89 12.56 14.88 11.66 13.86 10.13C13.54 9.46 13.92 8.8 14.7 8.99C15.67 9.53 16.06 11.81 16.01 12.11Z"/><path d="M19.15 12.12C19.2 12.51 18.6 16.4 17.3 17.05C16.32 17.54 15.99 16.87 16.42 15.81C17.8 13.4 17.59 9.93 16.42 8.41C15.73 7.5 16.34 6.58 17.26 7.18C18.74 8.15 19.21 11.64 19.15 12.12Z"/><path d="M22.4 12.16C22.16 15.18 21 17.95 19.71 18.82C18.73 19.48 18.22 18.42 18.93 17.32C20.96 14.17 21.92 9.95 18.9 6.29C18.13 5.35 19.35 4.52 20.11 5.48C21.43 7.14 22.33 9.14 22.4 12.16Z"/></g>`,
18
+ soundOff: `<g fill="currentColor" fill-rule="evenodd"><path d="M1.6 14.77L1.6 9.23C1.6 8.82 1.93 8.49 2.34 8.49L4.85 8.49C5.02 8.49 5.19 8.45 5.34 8.38L11.06 5.52C11.55 5.27 12.12 5.63 12.12 6.18L12.12 17.82C12.12 18.37 11.55 18.73 11.06 18.48L5.34 15.62C5.19 15.55 5.02 15.51 4.85 15.51L2.34 15.51C1.93 15.51 1.6 15.18 1.6 14.77Z"/><path d="M21.95 16.53C21.5 16.98 20.78 16.98 20.33 16.53L17.79 13.99L15.25 16.54C14.8 16.99 14.07 16.99 13.62 16.54C13.17 16.09 13.17 15.36 13.62 14.92L16.17 12.37L13.63 9.83C13.18 9.38 13.18 8.65 13.63 8.21C13.85 7.98 14.14 7.87 14.44 7.87C14.73 7.87 15.02 7.98 15.25 8.21L17.79 10.75L20.33 8.21C20.77 7.76 21.5 7.76 21.95 8.21C22.17 8.44 22.28 8.73 22.28 9.02C22.28 9.32 22.17 9.61 21.95 9.83L19.41 12.37L21.95 14.91C22.4 15.36 22.4 16.08 21.95 16.53Z"/></g>`,
19
+ close: `<g fill="currentColor" fill-rule="evenodd"><path d="M21.39 21.37C20.38 22.38 18.74 22.38 17.73 21.37L12.01 15.64L6.27 21.38C5.26 22.39 3.62 22.39 2.61 21.38C1.6 20.38 1.6 18.74 2.61 17.73L8.35 11.99L2.62 6.25C1.61 5.24 1.61 3.61 2.62 2.6C3.12 2.09 3.79 1.84 4.45 1.84C5.11 1.84 5.77 2.09 6.28 2.6L12.01 8.33L17.72 2.62C18.73 1.61 20.37 1.61 21.38 2.62C21.89 3.12 22.14 3.78 22.14 4.44C22.14 5.11 21.89 5.77 21.38 6.27L15.67 11.99L21.39 17.71C22.4 18.72 22.4 20.36 21.39 21.37Z"/></g>`,
20
+ back: `<g fill="currentColor" fill-rule="evenodd"><path d="M17.49 2.32L17.49 2.32C18.2 3.03 18.2 4.19 17.49 4.9L11.68 10.71C10.97 11.42 10.97 12.58 11.68 13.29L17.49 19.1C18.21 19.81 18.21 20.97 17.49 21.69L17.49 21.69C16.78 22.4 15.62 22.4 14.91 21.69L7.8 14.58L7.8 14.58C7.8 14.58 6.51 13.29 6.51 13.29C5.79 12.58 5.79 11.42 6.51 10.71L14.9 2.31C15.62 1.6 16.77 1.6 17.49 2.31Z"/></g>`,
21
+ chevronRight: `<g fill="currentColor" fill-rule="evenodd"><path d="M6.51 21.68L6.51 21.68C5.8 20.97 5.8 19.81 6.51 19.1L12.32 13.29C13.03 12.58 13.03 11.42 12.32 10.71L6.51 4.9C5.79 4.19 5.79 3.03 6.51 2.31L6.51 2.31C7.22 1.6 8.38 1.6 9.09 2.31L16.2 9.42L16.2 9.42C16.2 9.42 17.49 10.71 17.49 10.71C18.21 11.42 18.21 12.58 17.49 13.29L9.1 21.69C8.38 22.4 7.23 22.4 6.51 21.69Z"/></g>`,
22
22
  ticket: `<path d="M1.72 9.48L19.64 4.67L20.28 7.07C19.19 7.8 18.82 9 19.11 10.09C19.41 11.19 20.33 12.04 21.64 12.13L22.28 14.52L4.36 19.32L3.72 16.93C4.81 16.2 5.18 15 4.89 13.91C4.59 12.81 3.67 11.96 2.36 11.87L1.72 9.48Z" fill="none" stroke="currentColor" stroke-width="1.84" stroke-linejoin="miter"/><path d="M12.57 7.17L9.75 13.26L11.92 12.4L11.84 16.74L14.85 10.06L12.38 11.1Z" fill="currentColor"/>`,
23
+ turbo2: `<g fill="currentColor" fill-rule="evenodd"><path d="M8.24 8.81L3.92 7.68C3.88 7.67 3.88 7.61 3.92 7.61L9.66 6.45C9.8 6.43 9.89 6.56 9.83 6.68L8.86 8.35C8.67 8.73 8.33 8.83 8.24 8.81Z"/><path d="M20.58 3.04L17.34 8.38C17.09 8.8 17.39 9.33 17.87 9.33L21.57 9.33C22.12 9.33 22.4 9.99 22.02 10.39L11.24 21.73C11.12 21.86 10.95 21.93 10.77 21.92L10.77 21.92C10.34 21.92 10.04 21.47 10.2 21.07L13.04 13.82C13.21 13.41 12.9 12.96 12.45 12.97L8.46 13.01C7.98 13.01 7.67 12.5 7.9 12.08L13.2 2.4C13.31 2.2 13.52 2.07 13.75 2.07L20.05 2.09C20.54 2.1 20.84 2.63 20.58 3.04Z"/><path d="M5.96 12.68L1.64 11.54C1.6 11.54 1.6 11.47 1.64 11.47L7.38 10.31C7.52 10.3 7.61 10.42 7.55 10.55L6.58 12.22C6.39 12.59 6.05 12.69 5.96 12.68Z"/><path d="M9.67 16.66L5.35 15.53C5.31 15.52 5.31 15.46 5.35 15.46L11.09 14.3C11.22 14.28 11.32 14.41 11.26 14.53L10.29 16.2C10.1 16.58 9.76 16.68 9.67 16.66Z"/></g>`,
24
+ turboOff: `<g fill="currentColor" fill-rule="evenodd"><path d="M17.81 2.62L14.41 8.21C14.15 8.64 14.46 9.2 14.97 9.2L18.84 9.21C19.42 9.21 19.72 9.9 19.32 10.31L8.02 22.19C7.89 22.33 7.72 22.4 7.53 22.4L7.53 22.4C7.08 22.39 6.77 21.93 6.93 21.5L9.91 13.91C10.08 13.48 9.76 13.01 9.29 13.01L5.11 13.06C4.61 13.06 4.28 12.53 4.53 12.09L10.07 1.94C10.19 1.73 10.41 1.6 10.65 1.6L17.26 1.62C17.77 1.62 18.08 2.18 17.81 2.62Z"/></g>`,
25
+ chevronUp: `<g fill="currentColor" fill-rule="evenodd"><path d="M21.68 17.49L21.68 17.49C20.97 18.2 19.81 18.2 19.1 17.49L13.29 11.68C12.58 10.97 11.42 10.97 10.71 11.68L4.9 17.49C4.19 18.21 3.03 18.21 2.31 17.49L2.31 17.49C1.6 16.78 1.6 15.62 2.31 14.91L9.42 7.8L9.42 7.8C9.42 7.8 10.71 6.51 10.71 6.51C11.42 5.79 12.58 5.79 13.29 6.51L21.69 14.9C22.4 15.62 22.4 16.77 21.69 17.49Z"/></g>`,
26
+ chevronDown: `<g fill="currentColor" fill-rule="evenodd"><path d="M2.32 6.51L2.32 6.51C3.03 5.8 4.19 5.8 4.9 6.51L10.71 12.32C11.42 13.03 12.58 13.03 13.29 12.32L19.1 6.51C19.82 5.79 20.97 5.79 21.69 6.51L21.69 6.51C22.4 7.22 22.4 8.38 21.69 9.09L14.58 16.19L14.58 16.2C14.58 16.2 13.29 17.49 13.29 17.49C12.58 18.21 11.42 18.21 10.71 17.49L2.31 9.1C1.6 8.38 1.6 7.23 2.31 6.51Z"/></g>`,
23
27
  };
24
28
 
25
29
  export type IconName = keyof typeof SVGS;
@@ -35,11 +39,3 @@ export function icon(name: IconName): string {
35
39
  export function iconSVG(name: IconName, color = '#ffffff'): string {
36
40
  return `<svg viewBox="0 0 24 24">${SVGS[name].split('currentColor').join(color)}</svg>`;
37
41
  }
38
-
39
- /** A standalone SVG with the glyph rendered as a stroke-only outline (fill:none).
40
- * Used to build the outer-ring effect in Pixi: draw this first (under), then iconSVG on top;
41
- * only the outer half of the stroke is visible — the paint-order:stroke CSS equivalent. */
42
- export function iconStrokeSVG(name: IconName, stroke: string, width = 4): string {
43
- const frag = SVGS[name].split('fill="currentColor"').join(`fill="none" stroke="${stroke}" stroke-width="${width}" stroke-linejoin="round"`);
44
- return `<svg viewBox="0 0 24 24">${frag}</svg>`;
45
- }
@@ -1,7 +1,13 @@
1
- import { Container, Graphics, GraphicsContext } from 'pixi.js';
2
- import { iconSVG, iconStrokeSVG, type IconName } from './icons';
1
+ import { Container, Graphics, GraphicsContext, GraphicsContextSystem } from 'pixi.js';
2
+ import { iconSVG, type IconName } from './icons';
3
3
  import type { Sizable } from './primitives/flex';
4
4
 
5
+ // Icons/coins are parsed from small-viewBox SVGs (24-unit glyphs, ~260-unit coin). Pixi's default
6
+ // bezierSmoothness (0.5) flattens curves with a ~1-unit path-space tolerance — coarse facets at
7
+ // that scale that magnify when the glyph is scaled to button size. Max it out (capped at 0.99) so
8
+ // every SVG-parsed curve stays smooth. Global default; set once, before any GraphicsContext builds.
9
+ GraphicsContextSystem.defaultOptions.bezierSmoothness = 0.99;
10
+
5
11
  // The DOM shell renders each icon as an inline <svg> sized to 1em and recoloured via
6
12
  // `currentColor`. The Pixi shell rebuilds the same 24×24 vector paths through Pixi's SVG
7
13
  // parser (GraphicsContext.svg) and scales them to the requested pixel size — same shapes,
@@ -10,49 +16,33 @@ import type { Sizable } from './primitives/flex';
10
16
 
11
17
  const VIEWBOX = 24;
12
18
 
13
- // Cache parsed contexts by `name|fill|ring|width` — many icons repeat across the bar at the same colour.
19
+ // Cache parsed contexts by `name|fill` — many icons repeat across the bar at the same colour.
14
20
  const cache = new Map<string, GraphicsContext>();
15
21
 
16
- function context(name: IconName, color: string, ring?: string, ringWidth = 4): GraphicsContext {
17
- const key = `${name}|${color}|${ring ?? ''}|${ring ? ringWidth : 0}`;
22
+ function context(name: IconName, color: string): GraphicsContext {
23
+ const key = `${name}|${color}`;
18
24
  let ctx = cache.get(key);
19
25
  if (!ctx) {
20
- if (ring) {
21
- // Two-pass layering: stroke context first (under), then fill on top.
22
- // Only the outer half of the stroke shows — the paint-order:stroke CSS equivalent.
23
- ctx = new GraphicsContext()
24
- .svg(iconStrokeSVG(name, ring, ringWidth))
25
- .svg(iconSVG(name, color));
26
- } else {
27
- ctx = new GraphicsContext().svg(iconSVG(name, color));
28
- }
26
+ ctx = new GraphicsContext().svg(iconSVG(name, color));
29
27
  cache.set(key, ctx);
30
28
  }
31
29
  return ctx;
32
30
  }
33
31
 
34
32
  /** A recolourable, scalable icon. Its content is centred on the local origin's box so the view's
35
- * bounds are `size × size` and `rotation` spins around the glyph centre (like CSS 50% 50%).
36
- *
37
- * Optional `ring` and `ringWidth` enable the outer-ring treatment (fill-on-top-of-stroke): the
38
- * stroke is drawn first (underneath), then the fill path is drawn on top — only the outer half
39
- * of the stroke is visible, mirroring the DOM `paint-order:stroke` technique. */
33
+ * bounds are `size × size` and `rotation` spins around the glyph centre (like CSS 50% 50%). */
40
34
  export class IconView extends Container implements Sizable {
41
35
  private gfx: Graphics;
42
36
  private _size: number;
43
37
  private _color: string;
44
- private _ring: string | undefined;
45
- private _ringWidth: number;
46
38
  readonly iconName: IconName;
47
39
 
48
- constructor(name: IconName, size: number, color = '#ffffff', ring?: string, ringWidth = 4) {
40
+ constructor(name: IconName, size: number, color = '#ffffff') {
49
41
  super();
50
42
  this.iconName = name;
51
43
  this._size = size;
52
44
  this._color = color;
53
- this._ring = ring;
54
- this._ringWidth = ringWidth;
55
- this.gfx = new Graphics(context(name, color, ring, ringWidth));
45
+ this.gfx = new Graphics(context(name, color));
56
46
  this.addChild(this.gfx);
57
47
  this.layout();
58
48
  }
@@ -69,20 +59,11 @@ export class IconView extends Container implements Sizable {
69
59
  }
70
60
 
71
61
  setColor(color: string): void {
72
- if (color === this._color && !this._ring) return;
62
+ if (color === this._color) return;
73
63
  this._color = color;
74
- this._ring = undefined;
75
64
  this.gfx.context = context(this.iconName, color);
76
65
  }
77
66
 
78
- /** Update fill colour and ring colour simultaneously (ring is preserved if provided). */
79
- setColors(fill: string, ring?: string): void {
80
- if (fill === this._color && ring === this._ring) return;
81
- this._color = fill;
82
- this._ring = ring;
83
- this.gfx.context = context(this.iconName, fill, ring, this._ringWidth);
84
- }
85
-
86
67
  setSize(size: number): void {
87
68
  if (size === this._size) return;
88
69
  this._size = size;
@@ -111,9 +92,3 @@ export class IconView extends Container implements Sizable {
111
92
  export function makeIcon(name: IconName, size: number, color = '#ffffff'): IconView {
112
93
  return new IconView(name, size, color);
113
94
  }
114
-
115
- /** Create a ringed icon — glyph filled with `fill`, outer ring coloured `ring` (width `ringWidth`).
116
- * Uses the two-pass stroke-under-fill technique to show only the outer half of the stroke. */
117
- export function makeRingedIcon(name: IconName, size: number, fill: string, ring: string, ringWidth = 4): IconView {
118
- return new IconView(name, size, fill, ring, ringWidth);
119
- }