@homie-rocks/studio 0.19.2 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lab/lab.ts ADDED
@@ -0,0 +1,180 @@
1
+ /*
2
+ * @homie-rocks/studio/lab — a game opts into the Game Lab (`homie-studio lab <game>`) with a few calls. The lab plays
3
+ * the same take in two builds side by side, New (the working tree) and Today (the last commit), on one clock, with the
4
+ * same seed and the same presses, and draws what these calls report: a timeline of phases, graphs of tracked values,
5
+ * the game's own overlays, preset views and live sliders.
6
+ *
7
+ * Outside the lab every call is a no-op (one check of a constant), so the calls stay in the game for good and cost a
8
+ * player nothing. The lab's clock, dice and inputs are installed in the game's page before its first script (the
9
+ * lab's harness, lab/harness.js): requestAnimationFrame, performance.now(), Date.now(), timers and Math.random() all
10
+ * run on the lab's clock there, so netplay's offline host, a round's countdown and a knockback's timer all slow down,
11
+ * pause and step together.
12
+ *
13
+ * import { lab } from '@homie-rocks/studio/lab';
14
+ * import tuning from '../tunables.json';
15
+ *
16
+ * const T = lab.tunables(tuning); // T.knockSpeed: the file's value; in the lab, the slider's
17
+ * const view = lab.camera({ game: null, close: { zoom: 2.4 } });
18
+ * lab.overlay('trail', (ctx) => { for (const p of lab.past('me', 30)) dot(ctx, p.x, p.y); });
19
+ *
20
+ * function frame(t: number): void {
21
+ * const dt = lab.time.dt(t, 0.05); // seconds since the last frame, at most 0.05
22
+ * ...
23
+ * lab.track('speed', Math.hypot(vx, vy), 'px/s');
24
+ * lab.phase(knocked ? 'LAUNCH' : null, 'Leaves fast, eases out');
25
+ * lab.pose('me', { x, y });
26
+ * ...in the world's transform: lab.draw(ctx);
27
+ * }
28
+ *
29
+ * tunables.json (next to game.json): { "knockSpeed": { "value": 950, "min": 300, "max": 2000, "step": 10,
30
+ * "unit": "px/s", "group": "Knock", "note": "How fast a bumped body leaves" }, ... }. A slider the person keeps is
31
+ * written back into that file by the lab, so a kept change is code.
32
+ */
33
+
34
+ /** One tunable in tunables.json: its value and the slider around it. A bare number is a value with no range. */
35
+ export interface TunableSpec {
36
+ value: number;
37
+ min?: number;
38
+ max?: number;
39
+ step?: number;
40
+ unit?: string;
41
+ group?: string;
42
+ note?: string;
43
+ }
44
+ export type TunableFile = Record<string, TunableSpec | number>;
45
+ export type Tuned<S extends TunableFile> = { readonly [K in keyof S]: number };
46
+
47
+ /** What the lab's harness puts on the page (window.__homieLab) before the game's first script. */
48
+ interface LabHost {
49
+ v: 1;
50
+ pane: 'new' | 'today';
51
+ stage: string | null;
52
+ frame: number;
53
+ fps: number;
54
+ scale: number;
55
+ paused: boolean;
56
+ track(name: string, value: number, unit?: string): void;
57
+ phase(name: string | null, note?: string): void;
58
+ pose(name: string, value: Record<string, unknown>): void;
59
+ past(name: string, count: number, every: number): Record<string, unknown>[];
60
+ views(names: string[]): void;
61
+ view(): string | null;
62
+ overlays(names: string[]): void;
63
+ overlay(name: string): boolean;
64
+ tunables(spec: TunableFile): Record<string, number>;
65
+ random(): number;
66
+ hold(): void;
67
+ ready(): void;
68
+ }
69
+
70
+ const host: LabHost | null = typeof window !== 'undefined' ? ((window as unknown as { __homieLab?: LabHost }).__homieLab ?? null) : null;
71
+ const ON = Boolean(host && host.v === 1);
72
+
73
+ type Draw = (ctx: unknown, ...rest: unknown[]) => void;
74
+ const drawers = new Map<string, Draw>();
75
+ let lastT = -1;
76
+
77
+ const valueOf = (s: TunableSpec | number): number => (typeof s === 'number' ? s : Number(s?.value));
78
+
79
+ export const lab = {
80
+ /** True when this page is a pane of the Game Lab. */
81
+ on: ON,
82
+
83
+ time: {
84
+ /**
85
+ * Seconds since the previous frame, at most `max` (a tab that slept does not teleport the world). Call it once a
86
+ * frame with requestAnimationFrame's time. In the lab that time is the lab's clock: every frame is exactly one step
87
+ * of its frame rate (60, 30, 15 or 12 a second), so a take plays the same every time, at any speed.
88
+ */
89
+ dt(t: number, max = 0.05): number {
90
+ const d = lastT < 0 ? 0 : Math.max(0, (t - lastT) / 1000);
91
+ lastT = t;
92
+ return Math.min(max, d);
93
+ },
94
+ /** The time now in ms (performance.now(); the lab's clock in the lab). */
95
+ now(): number { return performance.now(); },
96
+ /** The lab's frame number (1 is the take's first frame); 0 outside the lab. */
97
+ get frame(): number { return host?.frame ?? 0; },
98
+ /** Frames a second the lab runs the game at (60 outside the lab). */
99
+ get fps(): number { return host?.fps ?? 60; },
100
+ /** How fast the lab plays (1, 0.5, 0.25, 0.1): the game never needs it, a frame is a frame at any speed. */
101
+ get scale(): number { return host?.scale ?? 1; },
102
+ get paused(): boolean { return host?.paused ?? false; },
103
+ },
104
+
105
+ /**
106
+ * The take's stage (lab.json "stage"), or null: a scene the game sets up for one mechanic, the way a fighting game's
107
+ * training mode stands a dummy in front of you. Read it where the round starts; it is null outside the lab, so a
108
+ * stage never reaches a player. Check the mechanic in a take without a stage too: real play is the test.
109
+ */
110
+ get stage(): string | null { return host?.stage ?? null; },
111
+
112
+ /** A number to graph this frame (New against Today). `unit` names its axis the first time. */
113
+ track(name: string, value: number, unit?: string): void { if (ON) host!.track(name, value, unit); },
114
+
115
+ /**
116
+ * The phase the mechanic is in from now on: a short name in capitals (COIL, RISE, HANG, LAUNCH, SETTLE) and a note
117
+ * that says what it is for ("Overlap on the way out"). null: none. It lasts until the next call, so call it when the
118
+ * phase changes, or every frame with what it is.
119
+ */
120
+ phase(name: string | null, note?: string): void { if (ON) host!.phase(name, note); },
121
+
122
+ /** A small picture of something this frame ({ x, y, angle, sx, sy }): what `past` returns to an overlay. */
123
+ pose(name: string, value: Record<string, unknown>): void { if (ON) host!.pose(name, value); },
124
+
125
+ /** The last `count` poses of `name`, newest first, one every `every` frames (onion skins, arcs, trails). */
126
+ past<P extends Record<string, unknown> = Record<string, number>>(name: string, count = 8, every = 1): P[] {
127
+ return ON ? (host!.past(name, count, every) as P[]) : [];
128
+ },
129
+
130
+ /**
131
+ * Preset views the lab offers as buttons (Side, Front, Top, Orbit in a 3D game; Close, Arena in a flat one). Returns
132
+ * the active preset's value each frame: the first preset outside the lab (give it null: the game's own camera).
133
+ */
134
+ camera<V>(presets: Record<string, V>): () => V | null {
135
+ const names = Object.keys(presets);
136
+ const first = names.length ? (presets[names[0] as string] as V) : null;
137
+ if (!ON) return () => first;
138
+ host!.views(names);
139
+ return () => { const v = host!.view(); return v !== null && Object.hasOwn(presets, v) ? (presets[v] as V) : first; };
140
+ },
141
+
142
+ /** An overlay the lab can switch on (onion skin, arcs, smears, hit boxes): drawn by the game's own renderer. */
143
+ overlay(name: string, draw: Draw): void {
144
+ if (!ON) return;
145
+ drawers.set(name, draw);
146
+ host!.overlays([...drawers.keys()]);
147
+ },
148
+
149
+ /** Draw every overlay the lab has on, in whatever transform the game is in (call it in world space). */
150
+ draw(ctx: unknown, ...rest: unknown[]): void {
151
+ if (!ON) return;
152
+ for (const [name, fn] of drawers) if (host!.overlay(name)) { try { fn(ctx, ...rest); } catch { /* an overlay never breaks the frame */ } }
153
+ },
154
+
155
+ /**
156
+ * The game's tunables (tunables.json): an object of numbers by name. Outside the lab, the file's values. In the lab,
157
+ * the values the sliders hold now (a take restarts when one moves); the lab writes kept values back into the file.
158
+ */
159
+ tunables<S extends TunableFile>(spec: S): Tuned<S> {
160
+ const base: Record<string, number> = {};
161
+ for (const [k, s] of Object.entries(spec)) base[k] = valueOf(s);
162
+ if (!ON) return Object.freeze(base) as Tuned<S>;
163
+ const live = host!.tunables(spec);
164
+ const out = {} as Record<string, number>;
165
+ for (const k of Object.keys(base)) Object.defineProperty(out, k, { enumerable: true, get: () => (Number.isFinite(live[k]) ? (live[k] as number) : (base[k] as number)) });
166
+ return out as Tuned<S>;
167
+ },
168
+
169
+ /**
170
+ * Dice for juice (particles, shake, sparks): its own stream in the lab, so a build that adds a particle never moves
171
+ * a gem or a bot in the other build (Math.random() is the world's dice, the same in both). Math.random() outside.
172
+ */
173
+ random(): number { return ON ? host!.random() : Math.random(); },
174
+
175
+ /** A game that loads assets after its page has loaded: hold() early, ready() when it can play. */
176
+ hold(): void { if (ON) host!.hold(); },
177
+ ready(): void { if (ON) host!.ready(); },
178
+ };
179
+
180
+ export default lab;
@@ -0,0 +1,83 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="color-scheme" content="dark">
7
+ <title>Game Lab</title>
8
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 1l7 7-7 7-7-7z' fill='%23ffad3b'/%3E%3C/svg%3E">
9
+ <link rel="stylesheet" href="/_lab/lab.css">
10
+ </head>
11
+ <body>
12
+ <header class="bar top">
13
+ <div class="brand"><span class="mark" aria-hidden="true"></span><span class="word">GAME LAB</span></div>
14
+ <div class="title"><b id="game-name">…</b><span class="sep">/</span><span id="take-name">…</span></div>
15
+ <div class="status" id="status" aria-live="polite"></div>
16
+ </header>
17
+
18
+ <nav class="bar tools" aria-label="View">
19
+ <div class="group" role="group" aria-label="Which build" id="layout"></div>
20
+ <div class="group" role="group" aria-label="Views" id="views"></div>
21
+ <div class="group" role="group" aria-label="Device" id="device"></div>
22
+ <div class="group" role="group" aria-label="Overlays" id="overlays"></div>
23
+ <div class="group grow"></div>
24
+ <div class="group" role="group" aria-label="Take">
25
+ <label class="select"><span class="sr">Take</span><select id="take-pick"></select></label>
26
+ <button type="button" id="rec" class="rec" title="Record a take: play it in NEW">● REC</button>
27
+ </div>
28
+ </nav>
29
+
30
+ <main class="work">
31
+ <section class="stage" id="stage" aria-label="New and Today side by side">
32
+ <div class="slot" data-pane="new">
33
+ <div class="frame"><iframe title="New: the working tree" id="pane-new" tabindex="-1"></iframe></div>
34
+ <div class="tag new"><span>NEW</span><small id="new-sub"></small></div>
35
+ <div class="caption" id="cap-new"></div>
36
+ <div class="veil" id="veil-new" hidden></div>
37
+ </div>
38
+ <div class="slot" data-pane="today">
39
+ <div class="frame"><iframe title="Today: the last commit" id="pane-today" tabindex="-1"></iframe></div>
40
+ <div class="tag today"><span>TODAY</span><small id="today-sub"></small></div>
41
+ <div class="caption" id="cap-today"></div>
42
+ <div class="veil" id="veil-today" hidden></div>
43
+ </div>
44
+ <div class="recbar" id="recbar" hidden><span class="dot"></span><span id="rec-text">Recording: play in NEW</span><button type="button" id="rec-stop">Stop</button></div>
45
+ </section>
46
+
47
+ <aside class="tune" id="tune" aria-label="Tunables">
48
+ <div class="tune-head"><b>TUNABLES</b><small id="tune-file"></small></div>
49
+ <div class="tune-list" id="tune-list"></div>
50
+ <div class="tune-foot">
51
+ <button type="button" id="keep" class="primary" disabled>Keep in code</button>
52
+ <button type="button" id="undo" disabled>Undo</button>
53
+ </div>
54
+ </aside>
55
+
56
+ <section class="deck" aria-label="Timeline">
57
+ <div class="transport">
58
+ <div class="group" role="group" aria-label="Playback">
59
+ <button type="button" id="to-start" title="Start (Home)">|◀</button>
60
+ <button type="button" id="back" title="Back a frame (←)">◀</button>
61
+ <button type="button" id="play" class="wide" title="Play or pause (Space)">Pause</button>
62
+ <button type="button" id="fwd" title="Forward a frame (→)">▶</button>
63
+ <button type="button" id="to-end" title="End (End)">▶|</button>
64
+ </div>
65
+ <div class="group" role="group" aria-label="Speed" id="speeds"></div>
66
+ <div class="group" role="group" aria-label="Frames a second" id="fpses"></div>
67
+ <div class="group" role="group" aria-label="Loop"><button type="button" id="loop" class="on" title="Play the take again from the start">Loop</button></div>
68
+ <div class="counter" id="counter">F 0 / 0 · 0.00 s</div>
69
+ </div>
70
+ <div class="legend" id="legend"></div>
71
+ <div class="lines">
72
+ <canvas id="timeline" aria-label="Phases of the take, New above Today: click or drag to move the playhead"></canvas>
73
+ </div>
74
+ <div class="tracks" id="tracks" role="tablist" aria-label="Graphs"></div>
75
+ <div class="lines">
76
+ <canvas id="graph" aria-label="The chosen value over the take, New against Today"></canvas>
77
+ </div>
78
+ <p class="hint" id="hint"></p>
79
+ </section>
80
+ </main>
81
+ <script type="module" src="/_lab/page.js"></script>
82
+ </body>
83
+ </html>
@@ -0,0 +1,174 @@
1
+ /* The Game Lab: a dark, crisp tool for one mechanic at a time. Everything is drawn by the browser: no web fonts, no
2
+ * images. Labels are small capitals in the system's monospace; the accent is amber, Today is a cool blue. */
3
+ :root {
4
+ --bg: #080b12;
5
+ --panel: #0e1320;
6
+ --panel2: #131a2a;
7
+ --raise: #1a2236;
8
+ --line: #232c42;
9
+ --line2: #2e3954;
10
+ --text: #e7eaf3;
11
+ --dim: #8d96b0;
12
+ --faint: #59627c;
13
+ --accent: #ffad3b;
14
+ --accent-ink: #1b1204;
15
+ --new: #ffad3b;
16
+ --today: #7cc4ff;
17
+ --bad: #ff5d6c;
18
+ --ok: #4fe0a0;
19
+ --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
20
+ --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
21
+ color-scheme: dark;
22
+ }
23
+ * { box-sizing: border-box; }
24
+ html, body { margin: 0; background: var(--bg); color: var(--text); font: 13px/1.35 var(--sans); }
25
+ body { min-height: 100vh; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto 1fr; overflow-x: hidden; }
26
+ button, select { font: inherit; color: inherit; }
27
+ [hidden] { display: none !important; }
28
+ .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
29
+
30
+ /* ------------------------------------------------------------------ bars and buttons */
31
+ .bar { display: flex; align-items: center; gap: 10px; padding: 8px 14px; background: var(--panel); border-bottom: 1px solid var(--line); }
32
+ .bar.top { padding-top: max(8px, env(safe-area-inset-top)); gap: 14px; }
33
+ .brand { display: flex; align-items: center; gap: 9px; font: 800 13px/1 var(--mono); letter-spacing: .32em; }
34
+ .brand .mark { width: 12px; height: 12px; background: var(--accent); transform: rotate(45deg); box-shadow: 0 0 0 3px rgba(255, 173, 59, .16); }
35
+ .title { display: flex; align-items: baseline; gap: 8px; min-width: 0; flex: 1 1 auto; font: 600 13px/1.2 var(--mono); color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
36
+ .title b { color: var(--text); font-weight: 700; }
37
+ .title .sep { color: var(--faint); }
38
+ .status { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
39
+ .chip { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 8px; border: 1px solid var(--line2); background: var(--panel2); font: 700 10.5px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; color: var(--dim); white-space: nowrap; }
40
+ .chip .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
41
+ .chip.ok { color: var(--ok); border-color: rgba(79, 224, 160, .35); }
42
+ .chip.bad { color: var(--bad); border-color: rgba(255, 93, 108, .4); cursor: help; }
43
+ .chip.warn { color: var(--accent); border-color: rgba(255, 173, 59, .4); }
44
+ .chip.busy .dot { animation: blink 1s steps(2, start) infinite; }
45
+ @keyframes blink { to { visibility: hidden; } }
46
+
47
+ .tools { gap: 12px; flex-wrap: wrap; row-gap: 8px; }
48
+ .group { display: flex; align-items: center; gap: 2px; }
49
+ .group.grow { flex: 1; }
50
+ .group:empty { display: none; }
51
+ .group .label { font: 700 10px/1 var(--mono); letter-spacing: .14em; color: var(--faint); margin-right: 6px; text-transform: uppercase; }
52
+ .bar button, .deck button, .tune button, .recbar button {
53
+ height: 26px; min-width: 26px; padding: 0 9px; border: 1px solid var(--line2); background: var(--panel2); color: var(--text);
54
+ font: 700 11px/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; cursor: pointer; border-radius: 2px;
55
+ }
56
+ .bar button:hover, .deck button:hover, .tune button:hover { border-color: var(--faint); background: var(--raise); }
57
+ button.on { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
58
+ button.on:hover { background: #ffbd61; border-color: #ffbd61; }
59
+ button:disabled { opacity: .4; cursor: default; }
60
+ button:focus-visible, select:focus-visible, input:focus-visible { outline: 2px solid var(--today); outline-offset: 1px; }
61
+ button.wide { min-width: 64px; }
62
+ button.rec { color: #ff8a95; }
63
+ button.rec.on { background: var(--bad); border-color: var(--bad); color: #fff; }
64
+ .select select { height: 26px; padding: 0 26px 0 9px; border: 1px solid var(--line2); background: var(--panel2) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%238d96b0'/%3E%3C/svg%3E") no-repeat right 9px center; appearance: none; font: 700 11px/1 var(--mono); letter-spacing: .04em; border-radius: 2px; max-width: 220px; }
65
+
66
+ /* ------------------------------------------------------------------ the work area */
67
+ .work { display: grid; grid-template-columns: minmax(0, 1fr) 300px; grid-template-rows: minmax(260px, 1fr) auto; grid-template-areas: "stage tune" "deck tune"; min-height: 0; }
68
+ /* A computer: the whole lab in the window (the tunables scroll in their own column). */
69
+ @media (min-width: 1101px) { body { height: 100vh; min-height: 0; } .tune { min-height: 0; overflow: hidden; } }
70
+ .stage { grid-area: stage; position: relative; overflow: hidden; min-height: 260px;
71
+ background: radial-gradient(rgba(141, 150, 176, .14) 1px, transparent 1.2px) 0 0 / 18px 18px, #0a0e18; }
72
+ .slot { position: absolute; }
73
+ .slot .frame { position: absolute; left: 0; right: 0; top: 26px; bottom: 34px; overflow: hidden; background: #000; box-shadow: 0 0 0 1px var(--line2), 0 12px 40px rgba(0, 0, 0, .45); }
74
+ .slot iframe { position: absolute; left: 0; top: 0; border: 0; transform-origin: 0 0; background: #000; }
75
+ .slot[data-pane="new"] .frame { box-shadow: 0 0 0 1px rgba(255, 173, 59, .55), 0 12px 40px rgba(0, 0, 0, .45); }
76
+ .slot[data-pane="today"] .frame { box-shadow: 0 0 0 1px rgba(124, 196, 255, .45), 0 12px 40px rgba(0, 0, 0, .45); }
77
+ .slot.ghost { pointer-events: none; }
78
+ .slot.ghost .frame { background: transparent; box-shadow: none; opacity: .5; }
79
+ .slot.ghost .tag { left: auto; right: 0; flex-direction: row-reverse; }
80
+ .slot.ghost .caption { left: auto; right: 0; flex-direction: row-reverse; }
81
+ .tag { position: absolute; left: 0; top: 0; height: 20px; display: flex; align-items: center; gap: 6px; pointer-events: none; max-width: 100%; }
82
+ .tag span { padding: 4px 7px 3px; font: 800 10.5px/1 var(--mono); letter-spacing: .14em; border: 1px solid; background: rgba(8, 11, 18, .86); }
83
+ .tag.new span { color: var(--new); border-color: rgba(255, 173, 59, .7); }
84
+ .tag.today span { color: var(--today); border-color: rgba(124, 196, 255, .6); }
85
+ .tag small { font: 600 10.5px/1.2 var(--mono); color: var(--dim); background: rgba(8, 11, 18, .7); padding: 3px 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
86
+ .caption { position: absolute; left: 0; bottom: 0; height: 26px; display: flex; gap: 6px; align-items: stretch; pointer-events: none; max-width: 100%; }
87
+ .caption:empty { display: none; }
88
+ .caption b { padding: 5px 8px 4px; font: 800 12.5px/1 var(--mono); letter-spacing: .2em; border: 2px solid; background: rgba(8, 11, 18, .88); }
89
+ .caption span { padding: 6px 9px; font: 700 11.5px/1 var(--mono); color: var(--text); border: 1px solid var(--line2); background: rgba(8, 11, 18, .82); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
90
+ .veil { position: absolute; left: 0; right: 0; top: 26px; bottom: 34px; display: grid; place-items: center; padding: 18px; text-align: center; background: rgba(8, 11, 18, .82); font: 600 12.5px/1.5 var(--mono); color: var(--dim); }
91
+ .veil b { display: block; color: var(--text); margin-bottom: 6px; letter-spacing: .1em; }
92
+ .recbar { position: absolute; left: 50%; top: 10px; transform: translateX(-50%); display: flex; align-items: center; gap: 10px; padding: 6px 8px 6px 12px; background: rgba(40, 8, 14, .92); border: 1px solid var(--bad); font: 700 11px/1 var(--mono); letter-spacing: .06em; text-transform: uppercase; z-index: 3; }
93
+ .recbar .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--bad); animation: blink 1s steps(2, start) infinite; }
94
+
95
+ /* ------------------------------------------------------------------ tunables */
96
+ .tune { grid-area: tune; display: flex; flex-direction: column; min-height: 0; background: var(--panel); border-left: 1px solid var(--line); }
97
+ .tune-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 12px 14px 8px; font: 800 11px/1 var(--mono); letter-spacing: .2em; }
98
+ .tune-head small { font: 600 10.5px/1 var(--mono); letter-spacing: 0; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
99
+ .tune-list { flex: 1; overflow: auto; padding: 0 14px 10px; }
100
+ .tune-group { margin: 12px 0 4px; font: 700 10px/1 var(--mono); letter-spacing: .18em; color: var(--faint); text-transform: uppercase; }
101
+ .knob { padding: 9px 0 10px; border-bottom: 1px solid var(--line); }
102
+ .knob .row { display: flex; align-items: baseline; gap: 8px; }
103
+ .knob .name { font: 700 12px/1.2 var(--mono); color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
104
+ .knob.changed .name::before { content: ""; display: inline-block; width: 6px; height: 6px; margin-right: 6px; background: var(--accent); transform: rotate(45deg) translateY(-1px); }
105
+ .knob .val { margin-left: auto; font: 800 12px/1 var(--mono); color: var(--new); font-variant-numeric: tabular-nums; }
106
+ .knob .unit { font: 600 10.5px/1 var(--mono); color: var(--faint); }
107
+ .knob .was { display: block; margin-top: 3px; font: 600 10.5px/1.3 var(--mono); color: var(--today); }
108
+ .knob .note { display: block; margin-top: 3px; font: 12px/1.35 var(--sans); color: var(--dim); }
109
+ .knob .track { position: relative; margin-top: 8px; height: 18px; }
110
+ .knob input[type=range] { position: absolute; inset: 0; width: 100%; margin: 0; appearance: none; background: transparent; cursor: pointer; }
111
+ .knob input[type=range]::-webkit-slider-runnable-track { height: 4px; background: linear-gradient(90deg, var(--accent) var(--fill, 50%), var(--line2) var(--fill, 50%)); border-radius: 2px; }
112
+ .knob input[type=range]::-moz-range-track { height: 4px; background: var(--line2); border-radius: 2px; }
113
+ .knob input[type=range]::-moz-range-progress { height: 4px; background: var(--accent); }
114
+ .knob input[type=range]::-webkit-slider-thumb { appearance: none; width: 14px; height: 14px; margin-top: -5px; background: var(--text); border: 2px solid var(--accent); transform: rotate(45deg); }
115
+ .knob input[type=range]::-moz-range-thumb { width: 12px; height: 12px; background: var(--text); border: 2px solid var(--accent); border-radius: 0; }
116
+ .knob .tick { position: absolute; top: 1px; width: 2px; height: 16px; background: var(--today); pointer-events: none; box-shadow: 0 0 0 1px rgba(8, 11, 18, .8); }
117
+ .tune-foot { display: flex; gap: 6px; padding: 10px 14px max(12px, env(safe-area-inset-bottom)); border-top: 1px solid var(--line); }
118
+ .tune-foot .primary { flex: 1; }
119
+ .tune-foot .primary:not(:disabled) { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
120
+ .tune-empty { margin: 8px 0; font: 12.5px/1.5 var(--sans); color: var(--dim); }
121
+ .tune-empty code { font: 600 11.5px/1 var(--mono); color: var(--text); }
122
+
123
+ /* ------------------------------------------------------------------ timeline and graph */
124
+ .deck { grid-area: deck; padding: 10px 14px 12px; background: var(--panel); border-top: 1px solid var(--line); min-width: 0; }
125
+ .transport { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; row-gap: 8px; }
126
+ .counter { margin-left: auto; font: 800 13px/1 var(--mono); letter-spacing: .12em; color: var(--accent); font-variant-numeric: tabular-nums; white-space: nowrap; }
127
+ .legend { display: flex; gap: 14px; margin: 10px 0 6px; font: 700 10px/1 var(--mono); letter-spacing: .16em; color: var(--dim); text-transform: uppercase; flex-wrap: wrap; }
128
+ .legend i { display: inline-block; width: 9px; height: 9px; margin-right: 6px; vertical-align: -1px; }
129
+ .legend .right { margin-left: auto; letter-spacing: .08em; }
130
+ .lines { position: relative; }
131
+ .lines canvas { display: block; width: 100%; cursor: ew-resize; touch-action: none; }
132
+ #timeline { height: 58px; }
133
+ #graph { height: 132px; margin-top: 4px; }
134
+ .tracks { display: flex; gap: 2px; margin-top: 10px; flex-wrap: wrap; }
135
+ .tracks button { height: 22px; font-size: 10.5px; }
136
+ .hint { margin: 8px 0 0; font: 12px/1.4 var(--sans); color: var(--dim); min-height: 1em; }
137
+ .hint code { font: 600 11px/1 var(--mono); color: var(--text); }
138
+
139
+ /* ------------------------------------------------------------------ a phone, or a narrow window */
140
+ @media (max-width: 1100px) {
141
+ .work { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto auto; grid-template-areas: "stage" "deck" "tune"; }
142
+ .stage { height: min(62vh, 620px); }
143
+ .tune { border-left: 0; border-top: 1px solid var(--line); }
144
+ .tune-list { max-height: none; }
145
+ }
146
+ @media (max-width: 640px) {
147
+ .bar { padding: 7px 10px; }
148
+ .bar.top { flex-wrap: wrap; row-gap: 6px; }
149
+ .title { flex-basis: 100%; }
150
+ .title #take-name { overflow: hidden; text-overflow: ellipsis; }
151
+ .status { margin-left: 0; width: 100%; justify-content: flex-start; }
152
+ .legend .right { display: none; }
153
+ .tools { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
154
+ .tools::-webkit-scrollbar { display: none; }
155
+ .group.grow { display: none; }
156
+ .stage { height: 58vh; min-height: 300px; }
157
+ .deck { padding: 10px 10px 12px; }
158
+ .transport { gap: 8px; }
159
+ .counter { width: 100%; margin-left: 0; order: -1; font-size: 14px; }
160
+ .bar button, .deck button, .tune button { height: 32px; min-width: 32px; padding: 0 9px; }
161
+ .caption b { font-size: 12px; padding: 5px 7px 4px; }
162
+ .caption span { font-size: 11px; padding: 5px 7px; }
163
+ #graph { height: 116px; }
164
+ }
165
+
166
+ /* ------------------------------------------------------------------ the index of games */
167
+ body.index { display: block; }
168
+ body.index main { max-width: 640px; margin: 10vh auto; padding: 0 20px; }
169
+ body.index .brand { margin: 0 0 18px; }
170
+ body.index h1 { font: 800 28px/1.1 var(--sans); margin: 0 0 18px; }
171
+ body.index .games { list-style: none; padding: 0; margin: 0; border-top: 1px solid var(--line); }
172
+ body.index .games li { padding: 14px 0; border-bottom: 1px solid var(--line); }
173
+ body.index .games a { color: var(--accent); font: 700 15px/1 var(--mono); text-decoration: none; letter-spacing: .04em; }
174
+ body.index .games small { margin-left: 10px; color: var(--faint); font: 600 11px/1 var(--mono); }