@energy8platform/shell 0.6.6 → 0.7.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.
Files changed (43) hide show
  1. package/dist/html.cjs.js +669 -105
  2. package/dist/html.cjs.js.map +1 -1
  3. package/dist/html.d.ts +204 -27
  4. package/dist/html.esm.js +662 -106
  5. package/dist/html.esm.js.map +1 -1
  6. package/dist/index.cjs.js +370 -18
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +196 -26
  9. package/dist/index.esm.js +363 -19
  10. package/dist/index.esm.js.map +1 -1
  11. package/dist/pixi.cjs.js +944 -278
  12. package/dist/pixi.cjs.js.map +1 -1
  13. package/dist/pixi.d.ts +199 -28
  14. package/dist/pixi.esm.js +937 -279
  15. package/dist/pixi.esm.js.map +1 -1
  16. package/package.json +1 -1
  17. package/src/core/ShellController.ts +68 -16
  18. package/src/core/icon-names.ts +30 -0
  19. package/src/core/index.ts +4 -0
  20. package/src/core/menu.ts +301 -0
  21. package/src/core/popover.ts +81 -0
  22. package/src/core/renderer.ts +13 -10
  23. package/src/core/state.ts +2 -1
  24. package/src/core/types.ts +12 -6
  25. package/src/core/version.ts +1 -1
  26. package/src/ui/html/HtmlRenderer.ts +31 -8
  27. package/src/ui/html/components/GameInfo.ts +1 -1
  28. package/src/ui/html/components/Menu.ts +153 -0
  29. package/src/ui/html/icons.ts +16 -15
  30. package/src/ui/html/primitives.ts +142 -0
  31. package/src/ui/html/shell.css.ts +21 -1
  32. package/src/ui/pixi/PixiRenderer.ts +32 -14
  33. package/src/ui/pixi/components/BottomBar.ts +80 -9
  34. package/src/ui/pixi/components/GameInfo.ts +1 -1
  35. package/src/ui/pixi/components/Menu.ts +167 -0
  36. package/src/ui/pixi/context.ts +3 -2
  37. package/src/ui/pixi/icons.ts +16 -15
  38. package/src/ui/pixi/pixi-icon.ts +15 -3
  39. package/src/ui/pixi/primitives/controls.ts +46 -0
  40. package/src/ui/pixi/primitives/popover.ts +163 -0
  41. package/src/ui/pixi/primitives/scroll.ts +9 -5
  42. package/src/ui/html/components/Settings.ts +0 -68
  43. package/src/ui/pixi/components/Settings.ts +0 -132
@@ -15,7 +15,7 @@ import type { PixiComponentContext, ShellLayer, LayerHandle } from './context';
15
15
  import { installShellFont, whenFontReady } from './text';
16
16
  import { countUpText, tween } from './motion-pixi';
17
17
  import { BottomBar } from './components/BottomBar';
18
- import { openSettings } from './components/Settings';
18
+ import { openMenu } from './components/Menu';
19
19
  import { openGameInfo } from './components/GameInfo';
20
20
  import { openBuyBonus } from './components/BuyBonus';
21
21
  import { openBetPicker, openAutoplayPicker } from './components/pickers';
@@ -96,6 +96,15 @@ export class PixiRenderer implements ShellRenderer {
96
96
  this.bar = new BottomBar(this.ctx);
97
97
  this.barLayer.addChild(this.bar);
98
98
  this.bar.applyFit();
99
+ // renderBar() runs on every resize AND on ~20 other state changes (bet/win/turbo/mode/…), any of
100
+ // which can change the bar's own fitScale()/menuPlate() (e.g. a WIN pill appearing mid-autoplay
101
+ // retriggers the wide layout's overflow-tightening branch). Only onResize used to reposition the
102
+ // open layer, so a live bar-content change while the menu was open left it at the stale
103
+ // scale/position until the next resize. Every ShellLayer's resize() only re-reads current
104
+ // geometry and re-fits/re-centres itself — none of them call back into renderBar() (verified:
105
+ // Popover, CardModal, Overlay, BuyBonusOverlay) — so this cannot recurse; `currentLayer` is
106
+ // `null` whenever nothing is open, so this cannot throw either.
107
+ this.currentLayer?.resize?.(this.screenW, this.screenH);
99
108
  }
100
109
 
101
110
  setLayout(): void {
@@ -132,8 +141,18 @@ export class PixiRenderer implements ShellRenderer {
132
141
  openOverlay(req: OverlayRequest): OverlayHandle | void {
133
142
  let layer: ShellLayer | null = null;
134
143
  switch (req.kind) {
135
- case 'settings':
136
- layer = openSettings(this.ctx);
144
+ case 'menu':
145
+ // Getters, not `this.bar` by value: renderBar() destroys/rebuilds the bar on every resize
146
+ // and ~20 other state changes, in the same resize handler that then repositions this popover
147
+ // — see Menu.ts's openMenu doc comment for why this must stay lazy. menuAnchor (the burger)
148
+ // is the arrow's pointer; menuPlate (the plaque) drives placement; fitScale is the same
149
+ // factor BottomBar applies to its own content via `inner.scale`.
150
+ layer = openMenu(
151
+ this.ctx,
152
+ () => this.bar?.menuAnchor() ?? null,
153
+ () => this.bar?.menuPlate() ?? null,
154
+ () => this.bar?.fitScale() ?? 1,
155
+ );
137
156
  break;
138
157
  case 'gameInfo':
139
158
  layer = openGameInfo(this.ctx);
@@ -155,7 +174,7 @@ export class PixiRenderer implements ShellRenderer {
155
174
  break;
156
175
  }
157
176
  if (!layer) return;
158
- this.pushLayer(layer);
177
+ this.pushLayer(layer, { backdrop: req.kind !== 'menu' });
159
178
  const built = layer;
160
179
  return {
161
180
  onKey: built.onKey ? built.onKey.bind(built) : undefined,
@@ -167,11 +186,6 @@ export class PixiRenderer implements ShellRenderer {
167
186
  this.closeLayer();
168
187
  }
169
188
 
170
- refreshSoundIcon(_on: boolean): void {
171
- // No-op: the open Settings overlay registers its icon updater via host.setSoundRefresh, which
172
- // the controller's setSound path drives. The renderer has nothing extra to refresh.
173
- }
174
-
175
189
  /** Fade out (≈250ms, like GameShell's REMOVE_FADE_MS) then tear down; resolves when removed. */
176
190
  destroy(): Promise<void> {
177
191
  if (this.destroyed) return Promise.resolve();
@@ -195,9 +209,11 @@ export class PixiRenderer implements ShellRenderer {
195
209
  }
196
210
 
197
211
  // ── layer stack ────────────────────────────────────────────────────────────
198
- pushLayer(node: ShellLayer): LayerHandle {
212
+ pushLayer(node: ShellLayer, opts?: { backdrop?: boolean }): LayerHandle {
199
213
  this.clearLayer();
200
- this.makeBackdrop(); // frosted snapshot of the scene behind (the DOM's backdrop-filter:blur)
214
+ // Light-dismiss layers (the menu popover) opt out with `{ backdrop: false }` no frosted
215
+ // snapshot, the game stays visible behind them. Every other caller is unaffected (defaults on).
216
+ if (opts?.backdrop !== false) this.makeBackdrop(); // frosted snapshot (the DOM's backdrop-filter:blur)
201
217
  this.currentLayer = node;
202
218
  this.modalLayer.addChild(node);
203
219
  this.fitModals();
@@ -324,6 +340,7 @@ export class PixiRenderer implements ShellRenderer {
324
340
  get tokens() { return host.tokens; },
325
341
  get layout() { return host.layout; },
326
342
  get soundOn() { return host.soundOn; },
343
+ get menu() { return host.menu; },
327
344
  get actions() { return host.actions; },
328
345
  openReplay: (opts) => host.openReplay(opts),
329
346
  t: (s) => host.t(s),
@@ -331,17 +348,18 @@ export class PixiRenderer implements ShellRenderer {
331
348
  emit: host.emit.bind(host),
332
349
  notifyResize: (w, h) => host.notifyResize(w, h),
333
350
  setSound: (on) => host.setSound(on),
334
- setSoundRefresh: (fn) => host.setSoundRefresh(fn),
335
351
  getVolume: (key) => host.getVolume(key),
336
352
  setVolume: (key, v) => host.setVolume(key, v),
337
- setVolumeRefresh: (fn) => host.setVolumeRefresh(fn),
353
+ getMenuValue: (id) => host.getMenuValue(id),
354
+ setMenuValue: (id, v) => host.setMenuValue(id, v),
355
+ setMenuRefresh: (fn) => host.setMenuRefresh(fn),
338
356
  // — Pixi-specific surface —
339
357
  get ticker() { return self.app.ticker; },
340
358
  get canvas() { return self.app.canvas as HTMLCanvasElement | undefined; },
341
359
  get screenW() { return self.app.screen.width; },
342
360
  get screenH() { return self.app.screen.height; },
343
361
  render: () => self.renderBar(),
344
- pushLayer: (node) => self.pushLayer(node),
362
+ pushLayer: (node, opts) => self.pushLayer(node, opts),
345
363
  // Route component-initiated closes through the controller (not straight to self.closeLayer) so
346
364
  // it clears its OverlayHandle. Otherwise the handle goes stale: hasOpenLayer() stays true and
347
365
  // keydowns keep routing to onKey on a destroyed overlay → write to a torn-down ScrollBox.
@@ -1,5 +1,6 @@
1
1
  import { Container, Graphics, Text } from 'pixi.js';
2
2
  import type { PixiComponentContext } from '../context';
3
+ import type { Rect } from '@/core/popover';
3
4
  import { effectiveAccent } from '@/core/colors';
4
5
  import { BUY_BONUS_ART, BUY_BONUS_SOCIAL_ART, BUY_BONUS_DISABLED_ART } from '../../buy-bonus-art';
5
6
  import { FlexBox } from '../primitives/flex';
@@ -118,6 +119,11 @@ export class BottomBar extends Container {
118
119
  private spin?: SpinDisc;
119
120
  private autoBtn?: IconButton;
120
121
  private turboBtn?: IconButton;
122
+ private menuBtn?: IconButton;
123
+ /** The menu's PLATE, in LOCAL coordinates relative to `inner` — the continuous dark panel (wide)
124
+ * or the controls row (mobile). Set at the point `applyFit()`/`applyFitMobile()` compute it;
125
+ * `menuPlate()` converts it to screen space lazily (see that method's doc comment for why). */
126
+ private plateRect: Rect | null = null;
121
127
 
122
128
  constructor(host: PixiComponentContext) {
123
129
  super();
@@ -127,6 +133,57 @@ export class BottomBar extends Container {
127
133
  else this.buildWide();
128
134
  }
129
135
 
136
+ /** Screen-space rect of the burger — the menu popover's POINTER (the arrow's target only; see
137
+ * `menuPlate` for what drives placement). `measureSize()` (the button's own nominal box, e.g.
138
+ * 36×36) rather than the built-in `getSize()` (the glyph's ink bounds only) so a burger whose icon
139
+ * doesn't fill its box still reports its true hit-box size; multiplied by `inner.scale` — `getSize`
140
+ * /`measureSize` only reflect the node's OWN scale (always 1 here), not the ancestor `inner.scale`
141
+ * the bar's whole fit-scale lives on, so the UNMULTIPLIED width/height would silently overstate the
142
+ * burger's true on-screen footprint on a scaled-down bar and skew the arrow off-centre. */
143
+ menuAnchor(): Rect | null {
144
+ if (!this.menuBtn || this.menuBtn.destroyed) return null;
145
+ const p = this.menuBtn.getGlobalPosition();
146
+ const s = this.inner.scale.x;
147
+ const size = this.menuBtn.measureSize();
148
+ return { x: p.x, y: p.y, w: size.w * s, h: size.h * s };
149
+ }
150
+
151
+ /** Screen-space rect of the menu's PLATE — the continuous dark panel (wide) or the controls row
152
+ * (mobile), NOT the burger itself and NOT (mobile) the info pill below it. Drives the popover's
153
+ * x/y/maxH/below; the burger (`menuAnchor`) drives only the arrow. Resolved lazily on every call,
154
+ * like `menuAnchor` — `PixiRenderer.renderBar()` destroys and rebuilds this BottomBar on every
155
+ * resize and ~20 other state changes, so a value captured once would go stale. `plateRect` is
156
+ * local to `inner`; `getGlobalPosition` + `inner.scale` convert it to screen space (`outer*` on the
157
+ * mobile FlexBox and the wide panel's own drawn rect are both LOCAL sizes, unaffected by `inner`'s
158
+ * ancestor scale, same reasoning as `menuAnchor` above). */
159
+ menuPlate(): Rect | null {
160
+ // Pixi v8's Container.destroy() nulls `_position`/`_scale`, so `inner.scale.x` /
161
+ // `inner.getGlobalPosition()` on a destroyed bar throws — guard for symmetry with menuAnchor()
162
+ // above, which already returns null once its button is destroyed. Not reachable today
163
+ // (PixiRenderer.renderBar() destroys and reassigns `this.bar` synchronously, in the same call),
164
+ // but the asymmetry is a trap for a future caller that holds a reference across a render.
165
+ if (this.destroyed) return null;
166
+ if (!this.plateRect) return null;
167
+ const s = this.inner.scale.x;
168
+ const origin = this.inner.getGlobalPosition();
169
+ return {
170
+ x: origin.x + this.plateRect.x * s,
171
+ y: origin.y + this.plateRect.y * s,
172
+ w: this.plateRect.w * s,
173
+ h: this.plateRect.h * s,
174
+ };
175
+ }
176
+
177
+ /** The scale factor the bar currently applies to itself (`inner.scale`) — shared with the menu
178
+ * popover so its typography/padding/row-heights scale in lockstep with the bar's own chrome,
179
+ * instead of ignoring the viewport like a fixed-local-size card would. */
180
+ fitScale(): number {
181
+ // Same destroyed guard as menuPlate()/menuAnchor() above, and the same neutral fallback a
182
+ // scale factor should have (1 = no scaling) rather than throwing.
183
+ if (this.destroyed) return 1;
184
+ return this.inner.scale.x;
185
+ }
186
+
130
187
  // ── wide / landscape ──────────────────────────────────────────────────────
131
188
  private buildWide(): void {
132
189
  const { state, tokens } = this.host;
@@ -139,15 +196,14 @@ export class BottomBar extends Container {
139
196
 
140
197
  // LEFT info group: menu · balance · (Total win) · (Win)
141
198
  const left = new FlexBox({ direction: 'row', align: 'center', gap: ZONE_GAP });
142
- left.add(
143
- new IconButton('menu', {
144
- size: 36,
145
- glyph: 30,
146
- color: '#ffffff',
147
- hover: tokens.accent,
148
- onTap: () => this.host.actions.openMenu(),
149
- }),
150
- );
199
+ this.menuBtn = new IconButton('menu', {
200
+ size: 36,
201
+ glyph: 30,
202
+ color: '#ffffff',
203
+ hover: tokens.accent,
204
+ onTap: () => this.host.actions.openMenu(),
205
+ });
206
+ left.add(this.menuBtn);
151
207
  if (!state.replay) {
152
208
  const bal = readout(this.host, 'Balance', this.host.fmt(state.balance));
153
209
  this.balanceValue = bal.valueText;
@@ -340,6 +396,7 @@ export class BottomBar extends Container {
340
396
  hover: tokens.accent,
341
397
  onTap: () => this.host.actions.openMenu(),
342
398
  });
399
+ this.menuBtn = menu;
343
400
  // autoplay is a base-mode control only — hidden in free spins / replay (matches the DOM bar)
344
401
  if (isBase && config.features.autoplay) {
345
402
  this.autoBtn = new IconButton('autoplay', {
@@ -574,6 +631,8 @@ export class BottomBar extends Container {
574
631
  this.panelBg.clear();
575
632
  roundedPath(this.panelBg, panelX, SPIN_POP, panelRight - panelX, BAR_H, [12, 12, 12, 12]);
576
633
  this.panelBg.fill(tokens.bar);
634
+ // The menu's plate — the whole continuous dark panel, local to `inner` (see menuPlate()).
635
+ this.plateRect = { x: panelX, y: SPIN_POP, w: panelRight - panelX, h: BAR_H };
577
636
 
578
637
  if (this.buy) this.buy.position.set(OUTER_PAD, panelCenterY - BUY_W / 2);
579
638
  left.position.set(panelX + PANEL_PAD, panelCenterY - left.outerHeight / 2);
@@ -613,6 +672,18 @@ export class BottomBar extends Container {
613
672
  // SPIN hero), inflating the controls row so it overlaps the info row below it.
614
673
  controls.setLayoutSize(rowW, M_CTRL_H);
615
674
  info.setLayoutSize(rowW, M_INFO_H);
675
+ // The menu's plate — the controls row (NOT the info pill below it), local to `inner`. Read from
676
+ // `controls.outerWidth/outerHeight` (its own nominal box), NOT the FlexBox's own bounds (which
677
+ // would include the SPIN/FS hero popping out both above AND below it — see menuPlate()'s doc
678
+ // comment) — but the TOP edge is deliberately extended upward by `pop`, the same hero pop-out
679
+ // amount reserved as `topPad` above, so the plate the popover math sees is the row's true visual
680
+ // extent on the side that matters: without this, the card's bottom (only `gap` above the plate's
681
+ // top) can clip the top ~(pop-gap)px of the hero's arc, since 62px M_CTRL_H is 11px shorter than
682
+ // the 84px hero. The BOTTOM edge is left alone — the hero's symmetric under-pop is a separate,
683
+ // unrelated concern (the info pill sits right below with its own small gap) and extending it too
684
+ // would perturb the `below`-placement branch (spaceBelow) for no benefit. A no-op when `pop` is 0
685
+ // (no hero shown — e.g. replay without free spins).
686
+ this.plateRect = { x: 0, y: topPad - pop, w: controls.outerWidth, h: controls.outerHeight + pop };
616
687
  const s = rowW > 0 ? Math.max(0.4, Math.min(1, avail / rowW)) : 1;
617
688
 
618
689
  this.inner.scale.set(s);
@@ -29,7 +29,7 @@ export function openGameInfo(host: PixiComponentContext): ShellLayer {
29
29
  onClose: () => host.closeLayer(),
30
30
  onBack: () => {
31
31
  host.closeLayer();
32
- host.actions.openSettings();
32
+ host.actions.openMenu();
33
33
  },
34
34
  build: (w) => buildBody(host, w),
35
35
  });
@@ -0,0 +1,167 @@
1
+ import { Container, Graphics, Text } from 'pixi.js';
2
+ import { resolveMenu, type MenuRow } from '@/core/menu';
3
+ import type { Rect } from '@/core/popover';
4
+ import type { PixiComponentContext, ShellLayer } from '../context';
5
+ import { Popover } from '../primitives/popover';
6
+ import { FlexBox } from '../primitives/flex';
7
+ import { Slider, Spacer, Toggle } from '../primitives/controls';
8
+ import { makeText } from '../text';
9
+ import { makeIcon } from '../pixi-icon';
10
+ import { attachHover } from '../primitives/widgets';
11
+
12
+ /** The bar menu as a Pixi popover. Same rows, same order as the DOM — both come from resolveMenu.
13
+ *
14
+ * `getAnchor` (the burger — the arrow's POINTER), `getPlate` (the bar's plaque — drives placement)
15
+ * and `getScale` (the bar's own fit-scale) are all FUNCTIONS, resolved lazily on every reposition —
16
+ * never a captured `BottomBar` instance. `PixiRenderer.renderBar()` destroys and rebuilds the
17
+ * BottomBar on every resize AND on ~20 other state changes, in the SAME resize handler that then
18
+ * repositions this popover. A captured instance would already be destroyed by the time `resize()`
19
+ * next runs, so `menuAnchor()`/`menuPlate()` on it would return `null` and the card would silently
20
+ * recentre with its arrow hidden — the exact bug the DOM renderer shipped and fixed the same way
21
+ * (see `html/Menu.ts`'s plate/pointer callbacks). Passing getters means every call reads whichever
22
+ * bar is CURRENT. `getPlate`/`getScale` are optional so every pre-existing caller (which only ever
23
+ * passed `getAnchor`) keeps behaving exactly as it did before the plate/pointer/scale split. */
24
+ export function openMenu(
25
+ host: PixiComponentContext,
26
+ getAnchor?: () => Rect | null,
27
+ getPlate?: () => Rect | null,
28
+ getScale?: () => number,
29
+ ): ShellLayer {
30
+ const updaters: Record<string, (v: boolean | number) => void> = {};
31
+ const layer = new Popover(host, {
32
+ tag: 'menu',
33
+ plate: () => getPlate?.() ?? null,
34
+ pointer: () => getAnchor?.() ?? null,
35
+ scale: () => getScale?.() ?? 1,
36
+ onClose: () => host.closeLayer(),
37
+ build: (width) => {
38
+ const col = new FlexBox({ direction: 'column', align: 'stretch', gap: 6 });
39
+ for (const row of resolveMenu(host)) col.add(buildRow(host, row, width, updaters));
40
+ return col;
41
+ },
42
+ });
43
+ host.setMenuRefresh((id, v) => updaters[id]?.(v));
44
+ return layer;
45
+ }
46
+
47
+ function label(text: string): Text {
48
+ return makeText(text, { size: 13, weight: '600', color: '#ffffff' }) as Text;
49
+ }
50
+
51
+ function rowBox(host: PixiComponentContext, name: string, column = false): FlexBox {
52
+ const box = new FlexBox({
53
+ direction: column ? 'column' : 'row',
54
+ align: column ? 'stretch' : 'center',
55
+ gap: column ? 8 : 10,
56
+ padding: { top: 10, bottom: 10, left: 12, right: 12 },
57
+ minHeight: column ? undefined : 44,
58
+ background: { fill: host.tokens.plaqueGlass, radius: 14 },
59
+ });
60
+ box.label = name;
61
+ return box;
62
+ }
63
+
64
+ function buildRow(
65
+ host: PixiComponentContext,
66
+ row: MenuRow,
67
+ width: number,
68
+ updaters: Record<string, (v: boolean | number) => void>,
69
+ ): Container {
70
+ if (row.kind === 'separator') {
71
+ const sep = new Container();
72
+ sep.label = 'menu-sep';
73
+ const line = new Graphics().rect(4, 6, width - 8, 1).fill(host.tokens.plaqueLine);
74
+ line.alpha = 0.5;
75
+ sep.addChild(line);
76
+ return sep;
77
+ }
78
+ if (row.kind === 'button') {
79
+ const box = rowBox(host, `menu-row-${row.id}`);
80
+ if (row.icon) box.add(makeIcon(row.icon, 20, '#ffffff'));
81
+ const text = label(row.label);
82
+ box.add(text);
83
+ box.add(new Spacer(), { grow: 1 });
84
+ if (row.chevron) box.add(makeIcon('chevronRight', 16, host.tokens.muted));
85
+ if (!row.disabled) {
86
+ box.setInteractive(true);
87
+ box.on('pointertap', () => {
88
+ host.closeLayer();
89
+ row.select();
90
+ });
91
+ attachHover(
92
+ box,
93
+ () => { box.setBgFill(host.tokens.plaqueGlassHover); text.style.fill = host.tokens.accent; },
94
+ () => { box.setBgFill(host.tokens.plaqueGlass); text.style.fill = '#ffffff'; },
95
+ );
96
+ } else {
97
+ box.alpha = 0.5;
98
+ }
99
+ return box;
100
+ }
101
+ if (row.kind === 'toggle') {
102
+ const box = rowBox(host, `menu-row-${row.id}`);
103
+ const glyph = row.icon(row.get());
104
+ const iconNode = glyph ? makeIcon(glyph, 20, '#ffffff') : null;
105
+ if (iconNode) box.add(iconNode);
106
+ box.add(label(row.label));
107
+ box.add(new Spacer(), { grow: 1 });
108
+ // Disabled: neutralise the write-through at the source (a no-op onChange) rather than relying
109
+ // solely on eventMode — Toggle wires its own pointertap listener unconditionally in its
110
+ // constructor, so a stray direct emit must still be harmless, not just unreachable by real hits.
111
+ const toggle = new Toggle(
112
+ row.get(),
113
+ row.disabled ? () => {} : (v) => row.set(v),
114
+ host.tokens.accent,
115
+ host.tokens.plaqueLine,
116
+ );
117
+ box.add(toggle);
118
+ updaters[row.id] = (v) => {
119
+ const on = v === true;
120
+ toggle.setValue(on);
121
+ const next = row.icon(on);
122
+ if (iconNode && next) iconNode.setIcon(next);
123
+ };
124
+ if (row.disabled) {
125
+ box.alpha = 0.5;
126
+ toggle.eventMode = 'none'; // dimmed + inert — mirrors the button branch's disabled treatment
127
+ }
128
+ return box;
129
+ }
130
+ const box = rowBox(host, `menu-row-${row.id}`, true);
131
+ const head = new FlexBox({ direction: 'row', align: 'center' });
132
+ const value = makeText(row.format(row.get()), { size: 12, weight: '700', color: host.tokens.plaqueLabel });
133
+ head.add(label(row.label));
134
+ head.add(new Spacer(), { grow: 1 });
135
+ head.add(value);
136
+ // Slider works in 0..1; map to the row's declared bounds, snapping to the MIN-anchored lattice
137
+ // (min + k·step). Snapping to a bare multiple of step instead (Math.round(raw/step)*step) drifts
138
+ // off `min` whenever min isn't itself a multiple of step — the common case, since the default step
139
+ // is (max-min)/20: e.g. min:1,max:10 gives step 0.45, and the bare-multiple formula would emit 0.9
140
+ // at the far left, BELOW the declared min. Clamp guards any float overshoot past either end.
141
+ const toUnit = (v: number): number => (row.max === row.min ? 0 : (v - row.min) / (row.max - row.min));
142
+ const fromUnit = (u: number): number => {
143
+ const raw = row.min + u * (row.max - row.min);
144
+ const snapped = row.min + Math.round((raw - row.min) / row.step) * row.step;
145
+ return Math.max(row.min, Math.min(row.max, snapped));
146
+ };
147
+ // Disabled: same reasoning as the toggle branch above — a no-op onInput, not just eventMode.
148
+ const slider = new Slider(host, toUnit(row.get()), row.disabled ? () => {} : (u) => {
149
+ const v = fromUnit(u);
150
+ value.text = row.format(v);
151
+ head.layout(); // the readout's text just changed width (e.g. "5%" → "100%") — reposition it
152
+ row.set(v);
153
+ });
154
+ updaters[row.id] = (v) => {
155
+ const n = Number(v);
156
+ value.text = row.format(n);
157
+ head.layout();
158
+ slider.setValue(toUnit(n));
159
+ };
160
+ box.add(head);
161
+ box.add(slider);
162
+ if (row.disabled) {
163
+ box.alpha = 0.5;
164
+ slider.eventMode = 'none';
165
+ }
166
+ return box;
167
+ }
@@ -25,7 +25,8 @@ export interface LayerHandle {
25
25
  * PixiRenderer provides (ticker, screen size, layer stack).
26
26
  *
27
27
  * Members already on core ShellHost (state, config, tokens, layout, soundOn, t, emit, setSound,
28
- * setSoundRefresh, actions, formatCurrency, notifyResize) are NOT re-declared here.
28
+ * getVolume, setVolume, menu, getMenuValue, setMenuValue, setMenuRefresh, actions, formatCurrency,
29
+ * notifyResize) are NOT re-declared here.
29
30
  *
30
31
  * fmt/fmtWin decision: pixi components in pixi-shell use `host.fmt(n)` / `host.fmtWin(n)`.
31
32
  * Rather than retargeting all components to `host.formatCurrency(n)` / `host.formatCurrency(n, true)`
@@ -38,7 +39,7 @@ export interface PixiComponentContext extends ShellHost {
38
39
  readonly screenW: number;
39
40
  readonly screenH: number;
40
41
  render(): void;
41
- pushLayer(node: ShellLayer): LayerHandle;
42
+ pushLayer(node: ShellLayer, opts?: { backdrop?: boolean }): LayerHandle;
42
43
  closeLayer(): void;
43
44
  fitModals(): void;
44
45
  /** Swap the active language at runtime (rebuilds resolver, re-renders bar). Optional. */
@@ -5,29 +5,30 @@
5
5
  // GraphicsContext.svg. To change an icon: edit icons.svg, then run
6
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="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>`,
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.41L13.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.59L10.43 15.59L10.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.62L17.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.41L19.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.36L9.3 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.59L4.03 11.59Z"/></g>`,
11
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
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
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>`,
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.37L19.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"><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>`,
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.32L13.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.09L16.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.04L1.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.12L19.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.16L22.4 12.16Z"/></g>`,
18
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>`,
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.37L21.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.58L6.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.31L17.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.42L17.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.69L6.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
+ 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.81L8.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.04L20.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.51 10.3 7.61 10.42 7.55 10.55L6.58 12.22C6.39 12.59 6.05 12.69 5.96 12.68L5.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.66L9.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.62L17.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.8L10.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.49L21.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.2L13.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.51L2.32 6.51L2.32 6.51Z"/></g>`,
27
27
  };
28
28
 
29
- export type IconName = keyof typeof SVGS;
30
- export const ICON_NAMES = Object.keys(SVGS) as IconName[];
29
+ export type { IconName } from '@/core/icon-names';
30
+ import type { IconName } from '@/core/icon-names';
31
+ export { ICON_NAMES } from '@/core/icon-names';
31
32
 
32
33
  /** Inline SVG string for an icon, sized to 1em (scale via font-size/width). */
33
34
  export function icon(name: IconName): string {
@@ -35,11 +35,11 @@ export class IconView extends Container implements Sizable {
35
35
  private gfx: Graphics;
36
36
  private _size: number;
37
37
  private _color: string;
38
- readonly iconName: IconName;
38
+ private _iconName: IconName;
39
39
 
40
40
  constructor(name: IconName, size: number, color = '#ffffff') {
41
41
  super();
42
- this.iconName = name;
42
+ this._iconName = name;
43
43
  this._size = size;
44
44
  this._color = color;
45
45
  this.gfx = new Graphics(context(name, color));
@@ -58,10 +58,22 @@ export class IconView extends Container implements Sizable {
58
58
  return this._size;
59
59
  }
60
60
 
61
+ get iconName(): IconName {
62
+ return this._iconName;
63
+ }
64
+
61
65
  setColor(color: string): void {
62
66
  if (color === this._color) return;
63
67
  this._color = color;
64
- this.gfx.context = context(this.iconName, color);
68
+ this.gfx.context = context(this._iconName, color);
69
+ }
70
+
71
+ /** Swap the glyph in place (e.g. the menu's sound row flips soundOn/soundOff on toggle) — same
72
+ * cached-context rebuild as setColor, keyed on the new name instead of a new colour. */
73
+ setIcon(name: IconName): void {
74
+ if (name === this._iconName) return;
75
+ this._iconName = name;
76
+ this.gfx.context = context(name, this._color);
65
77
  }
66
78
 
67
79
  setSize(size: number): void {
@@ -198,6 +198,52 @@ export class Slider extends Container implements Sizable {
198
198
  }
199
199
  }
200
200
 
201
+ /** Pill switch — the Pixi twin of the DOM `.ge-toggle`. 42×24, knob 20, accent when on. */
202
+ export class Toggle extends Container {
203
+ private track = new Graphics();
204
+ private knob = new Graphics();
205
+ private _value: boolean;
206
+ private onChange: (v: boolean) => void;
207
+ private accent: string;
208
+ private offFill: string;
209
+
210
+ constructor(value: boolean, onChange: (v: boolean) => void, accent = '#8b5cf6', off = 'rgba(255,255,255,.22)') {
211
+ super();
212
+ this._value = value;
213
+ this.onChange = onChange;
214
+ this.accent = accent;
215
+ this.offFill = off;
216
+ this.addChild(this.track, this.knob);
217
+ this.eventMode = 'static';
218
+ this.cursor = 'pointer';
219
+ this.hitArea = new Rectangle(0, 0, 42, 24);
220
+ this.on('pointertap', () => this.onChange(!this._value));
221
+ this.paint();
222
+ }
223
+
224
+ get value(): boolean {
225
+ return this._value;
226
+ }
227
+ setValue(v: boolean): void {
228
+ this._value = v;
229
+ this.paint();
230
+ }
231
+ private paint(): void {
232
+ this.track.clear();
233
+ this.track.roundRect(0, 0, 42, 24, 12);
234
+ this.track.fill(this._value ? this.accent : this.offFill);
235
+ this.knob.clear();
236
+ this.knob.circle(this._value ? 30 : 12, 12, 10);
237
+ this.knob.fill('#ffffff');
238
+ }
239
+ measureSize(): { w: number; h: number } {
240
+ return { w: 42, h: 24 };
241
+ }
242
+ setLayoutSize(): void {
243
+ /* fixed size */
244
+ }
245
+ }
246
+
201
247
  /** Picker chip — `.ge-chip`. Selected = accent bg + accent border. */
202
248
  export class Chip extends Container implements Sizable {
203
249
  readonly id: string;