@energy8platform/shell 0.6.5 → 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 +679 -113
  2. package/dist/html.cjs.js.map +1 -1
  3. package/dist/html.d.ts +213 -31
  4. package/dist/html.esm.js +672 -114
  5. package/dist/html.esm.js.map +1 -1
  6. package/dist/index.cjs.js +376 -22
  7. package/dist/index.cjs.js.map +1 -1
  8. package/dist/index.d.ts +204 -29
  9. package/dist/index.esm.js +369 -23
  10. package/dist/index.esm.js.map +1 -1
  11. package/dist/pixi.cjs.js +957 -285
  12. package/dist/pixi.cjs.js.map +1 -1
  13. package/dist/pixi.d.ts +208 -32
  14. package/dist/pixi.esm.js +950 -286
  15. package/dist/pixi.esm.js.map +1 -1
  16. package/package.json +1 -1
  17. package/src/core/ShellController.ts +75 -21
  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 +17 -12
  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 +35 -12
  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 +41 -21
  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
@@ -4,6 +4,7 @@ import type {
4
4
  ResolvedShellConfig, ShellState, ShellEvents, BonusOption,
5
5
  ModalOptions, ReplayModalOptions, VolumeKey,
6
6
  } from './types';
7
+ import type { MenuItem } from './menu';
7
8
 
8
9
  export type ShellLayoutMode = 'wide' | 'mobile';
9
10
 
@@ -18,15 +19,15 @@ export interface ShellRenderer {
18
19
  setLayout(layout: ShellLayoutMode): void;
19
20
  /** Apply colour tokens (CSS vars in DOM / repaint in Pixi). */
20
21
  applyTheme(tokens: ShellTokens): void;
21
- /** Count a money readout from→to on the freshly-rendered bar (DOM rAF / Pixi ticker). */
22
- animateMoney(field: 'balance' | 'win', from: number, to: number): void;
22
+ /** Count a money readout from→to on the freshly-rendered bar (DOM rAF / Pixi ticker).
23
+ * `durationMs` overrides the renderer's default count-up length a cascade/tumble scene
24
+ * reporting a win per step needs each count-up to fit inside its step. */
25
+ animateMoney(field: 'balance' | 'win', from: number, to: number, durationMs?: number): void;
23
26
  /** Build + show an overlay from a controller-supplied model; return a handle for key routing
24
27
  * and programmatic close. Returns void when nothing was shown. */
25
28
  openOverlay(req: OverlayRequest): OverlayHandle | void;
26
29
  /** Tear down any open overlay. */
27
30
  closeOverlay(): void;
28
- /** If the open overlay registered a sound-icon refresher, the controller calls this to refresh it. */
29
- refreshSoundIcon?(on: boolean): void;
30
31
  /** Fade out + remove all nodes; resolve when gone. */
31
32
  destroy(): Promise<void> | void;
32
33
 
@@ -73,17 +74,21 @@ export interface ShellHost {
73
74
  emit: EventEmitter<ShellEvents>['emit'];
74
75
  /** Renderer reports its surface size; the controller recomputes layout + re-renders. */
75
76
  notifyResize(w: number, h: number): void;
76
- /** Flip shared sound state (emits settingChange + refreshes an open Settings icon). */
77
+ /** Flip shared sound state (emits settingChange + refreshes an open menu's sound icon). */
77
78
  setSound(on: boolean): void;
78
- /** An open Settings overlay registers an icon updater here (null clears it on close). */
79
- setSoundRefresh(fn: ((on: boolean) => void) | null): void;
80
- /** Current volume slider position (0..1) for master/music/sfx. */
79
+ /** Current volume slider position (0..1) for music/sfx. */
81
80
  getVolume(key: VolumeKey): number;
82
81
  /** Set a volume slider (0..1): clamps, stores, emits `settingChange`, and live-updates an open
83
- * Settings overlay. Called by the slider control on drag AND by game code as the public API. */
82
+ * menu. Called by the slider control on drag AND by game code as the public API. */
84
83
  setVolume(key: VolumeKey, value: number): void;
85
- /** An open Settings overlay registers a slider updater here (null clears it on close). */
86
- setVolumeRefresh(fn: ((key: VolumeKey, value: number) => void) | null): void;
84
+ /** The configured menu items (see core/menu.ts). */
85
+ readonly menu: MenuItem[];
86
+ /** Current value of a menu item — presets included (sound → soundOn, music/sfx → volumes). */
87
+ getMenuValue(id: string): boolean | number | undefined;
88
+ /** Set a menu value: clamps ranges, stores, emits `settingChange`, refreshes an open menu. */
89
+ setMenuValue(id: string, value: boolean | number): void;
90
+ /** An open menu registers a row updater here (null clears it on close). */
91
+ setMenuRefresh(fn: ((id: string, value: boolean | number) => void) | null): void;
87
92
  /** Logic-bearing actions invoked by renderer controls. */
88
93
  readonly actions: ShellActions;
89
94
  /** Re-show the replay summary modal through the controller (keeps its OverlayHandle in sync).
@@ -124,7 +129,7 @@ export interface OverlayHandle {
124
129
  }
125
130
 
126
131
  export type OverlayRequest =
127
- | { kind: 'settings' }
132
+ | { kind: 'menu' }
128
133
  | { kind: 'gameInfo' }
129
134
  | { kind: 'buyBonus' }
130
135
  | { kind: 'betPicker' }
package/src/core/state.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ShellConfig, ShellState } from './types';
2
+ import { DEFAULT_MENU, seedMenuValues } from './menu';
2
3
 
3
4
  export function createInitialState(config: ShellConfig): ShellState {
4
5
  return {
@@ -16,10 +17,10 @@ export function createInitialState(config: ShellConfig): ShellState {
16
17
  bonus: null,
17
18
  activeFeature: null,
18
19
  volumes: {
19
- master: clampVolume(config.volumes?.master),
20
20
  music: clampVolume(config.volumes?.music),
21
21
  sfx: clampVolume(config.volumes?.sfx),
22
22
  },
23
+ menu: seedMenuValues(config.menu ?? DEFAULT_MENU),
23
24
  };
24
25
  }
25
26
 
package/src/core/types.ts CHANGED
@@ -1,11 +1,13 @@
1
+ import type { MenuItem } from './menu';
2
+
1
3
  /** `freeSpins` and `bonus` are the SAME bar layout (host-driven hero + Total Win); `freeSpins` is
2
4
  * kept as a back-compat alias for the common case (its readout is derived current/total), while
3
5
  * `bonus` pairs with `setBonus()` to show a game-supplied label + value (adventure, hold-and-spin,
4
6
  * respins — anything that isn't a plain free-spins counter). */
5
7
  export type ShellMode = 'base' | 'bonus' | 'freeSpins' | 'replay';
6
8
 
7
- /** The three independent volume sliders shown in the Settings overlay. */
8
- export type VolumeKey = 'master' | 'music' | 'sfx';
9
+ /** The two independent volume sliders shown in the bar menu. */
10
+ export type VolumeKey = 'music' | 'sfx';
9
11
  export type VolumeLevels = Record<VolumeKey, number>;
10
12
 
11
13
  export interface CurrencyConfig {
@@ -250,8 +252,10 @@ export interface ShellConfig {
250
252
  onBonusBuy?: () => void;
251
253
  /** Initial Settings-overlay volume slider positions (each 0..1, defaults to 1 = 100%). The shell
252
254
  * keeps them stateful across opens; read/update at runtime via `shell.getVolume()` /
253
- * `shell.setVolume()`, and listen to `settingChange` ({ key: 'master'|'music'|'sfx' }) to apply. */
255
+ * `shell.setVolume()`, and listen to `settingChange` ({ key: 'music'|'sfx' }) to apply. */
254
256
  volumes?: Partial<VolumeLevels>;
257
+ /** Bar-menu items, in order. Omit for the default list (sound, music, sfx, ─, game info). */
258
+ menu?: MenuItem[];
255
259
  }
256
260
 
257
261
  /** ShellConfig after the controller applies defaults (version, isSocial, replay, theme). No mount. */
@@ -272,7 +276,7 @@ export type ResolvedShellConfig = Required<
272
276
  | 'replay'
273
277
  >
274
278
  > &
275
- Pick<ShellConfig, 'currentBet' | 'theme' | 'onBonusBuy' | 'volumes'>;
279
+ Pick<ShellConfig, 'currentBet' | 'theme' | 'onBonusBuy' | 'volumes' | 'menu'>;
276
280
 
277
281
  export interface ShellState {
278
282
  mode: ShellMode;
@@ -295,9 +299,11 @@ export interface ShellState {
295
299
  /** The currently activated `feature` option (e.g. Ante), or null. Drives the
296
300
  * effective-bet readout tint and the BUY BONUS → DISABLE toggle on the bar. */
297
301
  activeFeature: BonusOption | null;
298
- /** Volume slider positions (0..1) surfaced in the Settings overlay. Stateful across opens so a
299
- * reopened overlay reflects the last-set positions instead of resetting to 100%. */
302
+ /** Volume slider positions (0..1) for the two sliders in the menu. */
300
303
  volumes: VolumeLevels;
304
+ /** Values of CUSTOM menu items, keyed by id. Seeded from the item list; preset values live in
305
+ * their own homes (`soundOn`, `volumes`) and are reached through `getMenuValue`. */
306
+ menu: Record<string, boolean | number>;
301
307
  }
302
308
 
303
309
  export interface ShellEvents {
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED by scripts/gen-version.mjs — do not edit. Mirrors package.json "version".
2
2
  /** The @energy8platform/shell package version, stamped into the game-info footer. */
3
- export const PACKAGE_VERSION = '0.6.5';
3
+ export const PACKAGE_VERSION = '0.7.0';
@@ -4,7 +4,7 @@ import { SHELL_CSS, SHELL_ROOT_ID } from './shell.css';
4
4
  import { buildThemeVars } from './theme-css';
5
5
  import { countUp } from './motion-dom';
6
6
  import { renderBottomBar } from './components/BottomBar';
7
- import { openSettingsModal } from './components/Settings';
7
+ import { openMenuPopover } from './components/Menu';
8
8
  import { openGameInfoModal } from './components/GameInfo';
9
9
  import { openBuyBonusOverlay } from './components/BuyBonus';
10
10
  import { openBetModal, openAutoplayModal } from './components/pickers';
@@ -19,10 +19,10 @@ const REMOVE_FADE_MS = 300;
19
19
 
20
20
  /** Count-up the value span (.ge-rd-val) of a readout, leaving its label untouched.
21
21
  * Returns the count-up canceler so the renderer can stop it before the node is replaced. */
22
- function animateReadout(el: HTMLElement, from: number, to: number, fmt: (n: number) => string): () => void {
22
+ function animateReadout(el: HTMLElement, from: number, to: number, fmt: (n: number) => string, durationMs?: number): () => void {
23
23
  const val = el.querySelector('.ge-rd-val') as HTMLElement | null;
24
24
  if (!val) { el.textContent = fmt(to); return () => {}; }
25
- return countUp(val, from, to, fmt);
25
+ return durationMs == null ? countUp(val, from, to, fmt) : countUp(val, from, to, fmt, durationMs);
26
26
  }
27
27
 
28
28
  export class HtmlRenderer implements ShellRenderer {
@@ -35,7 +35,11 @@ export class HtmlRenderer implements ShellRenderer {
35
35
  private ro: ResizeObserver | null = null;
36
36
  private moneyAnims: Array<() => void> = [];
37
37
  private modalOnKey: ((e: KeyboardEvent) => boolean) | undefined;
38
+ private popoverPosition: (() => void) | null = null;
38
39
  private destroyed = false;
40
+ /** The scale factor last applied to the bar by applyFitScale() — exposed so the menu popover can
41
+ * match it exactly (see getBarScale) rather than re-derive its own, possibly-disagreeing, value. */
42
+ private barScale = 1;
39
43
 
40
44
  /** MutationObserver for buy-bonus confirm fit — fires fitModals() when nodes are added
41
45
  * inside the modalHost (e.g. the confirm dialog appended after the grid is open). */
@@ -81,16 +85,24 @@ export class HtmlRenderer implements ShellRenderer {
81
85
  this.barHost.innerHTML = '';
82
86
  this.barHost.appendChild(renderBottomBar(this.host));
83
87
  this.applyFitScale();
88
+ // renderBar() runs on every resize AND on ~20 other state changes (bet/win/turbo/mode/…), any of
89
+ // which can change barScale and/or the plate rect (e.g. a WIN pill appearing mid-autoplay
90
+ // retriggers the overflow-tightening branch). Only the resize hook used to reposition an open
91
+ // popover, so a live bar-content change while the menu was open left the card at the stale
92
+ // scale/position until the next resize. `popoverPosition` only reads plate/pointer/scale getters
93
+ // and writes card styles — no path back into renderBar() — so this cannot recurse; it's `null`
94
+ // whenever no popover is open (or a different overlay kind is), so this cannot throw either.
95
+ this.popoverPosition?.();
84
96
  }
85
97
 
86
98
  setLayout(): void {
87
99
  this.renderBar();
88
100
  }
89
101
 
90
- animateMoney(field: 'balance' | 'win', from: number, to: number): void {
102
+ animateMoney(field: 'balance' | 'win', from: number, to: number, durationMs?: number): void {
91
103
  const fmt = (n: number) => this.host.formatCurrency(n, field === 'win');
92
104
  const el = this.barHost.querySelector(`[data-ge="${field}"]`) as HTMLElement | null;
93
- if (el) this.moneyAnims.push(animateReadout(el, from, to, fmt));
105
+ if (el) this.moneyAnims.push(animateReadout(el, from, to, fmt, durationMs));
94
106
  }
95
107
 
96
108
  openOverlay(req: OverlayRequest): OverlayHandle | void {
@@ -102,13 +114,10 @@ export class HtmlRenderer implements ShellRenderer {
102
114
 
103
115
  closeOverlay(): void {
104
116
  this.modalOnKey = undefined;
117
+ this.popoverPosition = null;
105
118
  this.modalHost.innerHTML = '';
106
119
  }
107
120
 
108
- refreshSoundIcon?(_on: boolean): void {
109
- // Settings registers via host.setSoundRefresh; nothing extra here
110
- }
111
-
112
121
  destroy(): Promise<void> {
113
122
  if (this.destroyed) return Promise.resolve();
114
123
  this.destroyed = true;
@@ -130,6 +139,11 @@ export class HtmlRenderer implements ShellRenderer {
130
139
  /** Trigger a bar fit-scale pass (used by tests that stub geometry after the initial render). */
131
140
  fitBar(): void { this.applyFitScale(); }
132
141
 
142
+ /** The scale factor applyFitScale() last applied to the bar — the menu popover multiplies its own
143
+ * card by this SAME number so its typography/padding/row-heights carry the same visual weight
144
+ * relationship the bar has, instead of ignoring the viewport like a fixed-px card would. */
145
+ getBarScale(): number { return this.barScale; }
146
+
133
147
  // ── private ────────────────────────────────────────────────────────────────
134
148
 
135
149
  private cancelMoneyAnims(): void {
@@ -139,8 +153,9 @@ export class HtmlRenderer implements ShellRenderer {
139
153
 
140
154
  private buildOverlay(req: OverlayRequest): { root: HTMLElement; onKey?: (e: KeyboardEvent) => boolean } | null {
141
155
  switch (req.kind) {
142
- case 'settings': {
143
- const root = openSettingsModal(this.host);
156
+ case 'menu': {
157
+ const { root, position } = openMenuPopover(this.host, this.root, () => this.getBarScale());
158
+ this.popoverPosition = position;
144
159
  return { root };
145
160
  }
146
161
  case 'gameInfo': {
@@ -183,6 +198,7 @@ export class HtmlRenderer implements ShellRenderer {
183
198
  this.modalHost.appendChild(el);
184
199
  this.modalOnKey = onKey;
185
200
  this.fitModals();
201
+ this.popoverPosition?.(); // measure + place now that the card is in the DOM
186
202
  }
187
203
 
188
204
  /** Uniformly scale every open centred card modal (.ge-sheet) down so it fits a short/narrow
@@ -229,13 +245,18 @@ export class HtmlRenderer implements ShellRenderer {
229
245
  let need = 0;
230
246
  for (const row of Array.from(bar.children) as HTMLElement[]) need = Math.max(need, row.scrollWidth);
231
247
  const avail = bar.clientWidth;
248
+ let s = 1;
232
249
  if (need > avail + 1 && avail > 0) {
250
+ s = Math.max(0.4, avail / need);
233
251
  host.style.transformOrigin = 'bottom left';
234
- host.style.transform = `scale(${Math.max(0.4, avail / need).toFixed(4)})`;
252
+ host.style.transform = `scale(${s.toFixed(4)})`;
235
253
  }
254
+ this.barScale = s;
236
255
  return;
237
256
  }
257
+ let sApplied = 1;
238
258
  const zoomBar = (z: number): void => {
259
+ sApplied = z;
239
260
  const v = z < 0.999 ? z.toFixed(4) : '';
240
261
  const set = (el: Element | null): void => {
241
262
  if (!el) return;
@@ -252,6 +273,7 @@ export class HtmlRenderer implements ShellRenderer {
252
273
  if (bar.scrollWidth > bar.clientWidth + 1 && bar.scrollWidth > 0) {
253
274
  zoomBar(s * (bar.clientWidth / bar.scrollWidth));
254
275
  }
276
+ this.barScale = sApplied;
255
277
  this.fitReadouts();
256
278
  }
257
279
 
@@ -280,6 +302,7 @@ export class HtmlRenderer implements ShellRenderer {
280
302
  this.host.notifyResize(w, h);
281
303
  this.applyFitScale();
282
304
  this.fitModals();
305
+ this.popoverPosition?.(); // re-place the card if the surface itself resized
283
306
  });
284
307
  this.ro.observe(this.root);
285
308
  }
@@ -20,7 +20,7 @@ export function openGameInfoModal(host: ShellHost): GameInfoModal {
20
20
  const { root, body, scroll } = createOverlay({
21
21
  title: host.t('Game info'),
22
22
  onClose: () => host.actions.closeOverlay(),
23
- onBack: () => { root.remove(); host.actions.openSettings(); },
23
+ onBack: () => { root.remove(); host.actions.openMenu(); },
24
24
  });
25
25
  root.dataset.ge = 'info-modal';
26
26
 
@@ -0,0 +1,153 @@
1
+ import type { ShellHost } from '@/core/renderer';
2
+ import { resolveMenu, type MenuRow } from '@/core/menu';
3
+ import { createPopover } from '../primitives';
4
+ import { icon, type IconName } from '../icons';
5
+
6
+ /** The bar menu, as a light-dismiss popover anchored to the burger. Rows come from the core model,
7
+ * so DOM and Pixi always show the same list in the same order. */
8
+ export function openMenuPopover(
9
+ host: ShellHost,
10
+ surface: HTMLElement,
11
+ getScale: () => number,
12
+ ): { root: HTMLElement; position(): void } {
13
+ const pop = createPopover({
14
+ ge: 'menu-popover',
15
+ surface,
16
+ // Resolved lazily on every position() call, not captured once: HtmlRenderer's renderBar() (run
17
+ // on every resize, and on ~20 other state changes) rebuilds the bottom bar from scratch, so the
18
+ // plate/burger are brand-new elements after it — a captured reference would already be detached
19
+ // by the next position() call and the card would silently recentre with its arrow hidden.
20
+ // The plate is the bar's own plaque — the continuous dark panel wide, the controls row mobile
21
+ // (NOT the info pill below it) — so the card sits flush with the WHOLE bar, not just the burger.
22
+ plate: () => surface.querySelector(host.layout === 'mobile' ? '.ge-m-controls' : '.ge-bar-panel') as HTMLElement | null,
23
+ pointer: () => surface.querySelector('[data-ge="menu"]') as HTMLElement | null,
24
+ // Mobile only: the SPIN disc / FS counter is taller than `.ge-m-controls` and centred inside it
25
+ // (`align-items:center` over a shorter fixed-height row), so it pops above the row's own top edge
26
+ // — see createPopover's `plateOverflowTop` doc comment. Exactly one of the two (or neither, e.g. a
27
+ // replay with no free spins) is ever rendered, so a combined selector resolves to whichever is
28
+ // current. Wide's plate already contains its content, so this deliberately resolves to null there.
29
+ plateOverflowTop: () =>
30
+ host.layout === 'mobile'
31
+ ? (surface.querySelector('[data-ge="spin"], [data-ge="fs-counter"]') as HTMLElement | null)
32
+ : null,
33
+ scale: getScale,
34
+ onClose: () => host.actions.closeOverlay(),
35
+ });
36
+ const updaters: Record<string, (v: boolean | number) => void> = {};
37
+ for (const row of resolveMenu(host)) {
38
+ pop.body.appendChild(buildRow(host, row, updaters, () => pop.position()));
39
+ }
40
+ // Live updates while open (host.setMenuValue / setSound / setVolume); cleared by the controller.
41
+ host.setMenuRefresh((id, v) => updaters[id]?.(v));
42
+ return { root: pop.root, position: pop.position };
43
+ }
44
+
45
+ function glyph(name: IconName | undefined, cls: string): HTMLElement | null {
46
+ if (!name) return null;
47
+ const el = document.createElement('span');
48
+ el.className = cls;
49
+ el.innerHTML = icon(name);
50
+ return el;
51
+ }
52
+
53
+ function buildRow(
54
+ host: ShellHost,
55
+ row: MenuRow,
56
+ updaters: Record<string, (v: boolean | number) => void>,
57
+ reposition: () => void,
58
+ ): HTMLElement {
59
+ if (row.kind === 'separator') {
60
+ const sep = document.createElement('div');
61
+ sep.className = 'ge-pop-sep';
62
+ sep.dataset.ge = 'menu-sep';
63
+ return sep;
64
+ }
65
+ if (row.kind === 'button') {
66
+ // The row hook goes on a wrapper so both `menu-row-<id>` (order assertions) and
67
+ // `menu-item-<id>` (the clickable control) exist, as they do for toggle/range rows.
68
+ const wrap = document.createElement('div');
69
+ wrap.dataset.ge = `menu-row-${row.id}`;
70
+ const btn = document.createElement('button');
71
+ btn.className = 'ge-ov-row';
72
+ btn.dataset.ge = `menu-item-${row.id}`;
73
+ btn.disabled = row.disabled;
74
+ const label = document.createElement('span');
75
+ label.className = 'ge-grow';
76
+ label.textContent = row.label;
77
+ const ico = glyph(row.icon, 'ge-mi-icon');
78
+ const chev = row.chevron ? glyph('chevronRight', 'ge-mi-chev') : null;
79
+ for (const el of [ico, label, chev]) if (el) btn.appendChild(el);
80
+ btn.addEventListener('click', () => {
81
+ if (btn.disabled) return;
82
+ host.actions.closeOverlay();
83
+ row.select();
84
+ });
85
+ wrap.appendChild(btn);
86
+ return wrap;
87
+ }
88
+ if (row.kind === 'toggle') {
89
+ const el = document.createElement('div');
90
+ el.className = 'ge-ov-row';
91
+ el.classList.toggle('ge-disabled', row.disabled); // a <div> can't carry [disabled] itself
92
+ el.dataset.ge = `menu-row-${row.id}`;
93
+ const ico = glyph(row.icon(row.get()), 'ge-mi-icon');
94
+ const label = document.createElement('span');
95
+ label.className = 'ge-grow';
96
+ label.textContent = row.label;
97
+ const btn = document.createElement('button');
98
+ btn.className = 'ge-toggle';
99
+ btn.dataset.ge = `menu-item-${row.id}`;
100
+ btn.disabled = row.disabled;
101
+ btn.innerHTML = '<i></i>';
102
+ const paint = (v: boolean): void => {
103
+ btn.classList.toggle('ge-on', v);
104
+ btn.setAttribute('aria-pressed', String(v));
105
+ const next = row.icon(v);
106
+ if (ico && next) ico.innerHTML = icon(next);
107
+ };
108
+ paint(row.get());
109
+ btn.addEventListener('click', () => {
110
+ if (!btn.disabled) row.set(!row.get());
111
+ });
112
+ updaters[row.id] = (v) => paint(v === true);
113
+ if (ico) el.appendChild(ico);
114
+ el.append(label, btn);
115
+ return el;
116
+ }
117
+ // range
118
+ const el = document.createElement('div');
119
+ el.className = 'ge-ov-row ge-col';
120
+ el.classList.toggle('ge-disabled', row.disabled); // a <div> can't carry [disabled] itself
121
+ el.dataset.ge = `menu-row-${row.id}`;
122
+ const head = document.createElement('div');
123
+ head.className = 'ge-row-head';
124
+ const name = document.createElement('span');
125
+ name.textContent = row.label;
126
+ const val = document.createElement('span');
127
+ val.className = 'ge-val';
128
+ head.append(name, val);
129
+ const input = document.createElement('input');
130
+ input.type = 'range';
131
+ input.className = 'ge-slider';
132
+ input.dataset.ge = `menu-item-${row.id}`;
133
+ input.min = String(row.min);
134
+ input.max = String(row.max);
135
+ input.step = String(row.step);
136
+ input.disabled = row.disabled;
137
+ const paint = (v: number): void => {
138
+ input.value = String(v);
139
+ val.textContent = row.format(v);
140
+ };
141
+ paint(row.get());
142
+ input.addEventListener('input', () => {
143
+ const v = Number(input.value);
144
+ val.textContent = row.format(v);
145
+ row.set(v);
146
+ });
147
+ updaters[row.id] = (v) => {
148
+ paint(Number(v));
149
+ reposition();
150
+ };
151
+ el.append(head, input);
152
+ return el;
153
+ }
@@ -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: `<g transform="translate(12 12) scale(0.0354) translate(-518.2 -535.2)"><g transform="rotate(-15 512 512)"><path d="M250 392H774L774 462C739 474 720 504 720 536C720 568 739 598 774 610V680H250V610C285 598 304 568 304 536C304 504 285 474 250 462V392Z" fill="none" stroke="currentColor" stroke-width="52" stroke-linejoin="miter" stroke-linecap="square"/><path d="M562.9 408.3L441.5 553.9L506.9 546.3L472.9 664.1L604 504L528.9 514.3Z" fill="currentColor"/></g></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.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 {