@orbytes/astrolab 0.3.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 (94) hide show
  1. package/LICENSE +37 -0
  2. package/README.md +410 -0
  3. package/bin/lab-cull.mjs +401 -0
  4. package/bin/pin-gallery.mjs +121 -0
  5. package/defaults.mjs +120 -0
  6. package/dist/core/astro-integration.js +130 -0
  7. package/dist/core/index.js +6 -0
  8. package/dist/core/lib-paths.js +29 -0
  9. package/dist/core/options.js +173 -0
  10. package/dist/core/utils/get-exports.js +52 -0
  11. package/dist/core/utils/invariant.js +12 -0
  12. package/dist/core/utils/kebab-case.js +15 -0
  13. package/dist/core/utils/path-builder.js +22 -0
  14. package/dist/core/utils/path.js +53 -0
  15. package/dist/core/virtual-module/get-story-modules.js +60 -0
  16. package/dist/core/virtual-module/story-modules.js +8 -0
  17. package/dist/core/virtual-module/virtual-module-ids.js +22 -0
  18. package/dist/core/virtual-module/virtual-routes.js +83 -0
  19. package/dist/core/virtual-module/vite-plugin.js +98 -0
  20. package/docs/PIN-CONTRACT.md +263 -0
  21. package/docs/PIN.md +429 -0
  22. package/index.d.ts +279 -0
  23. package/index.mjs +347 -0
  24. package/package.json +93 -0
  25. package/src/Empty.astro +4 -0
  26. package/src/Home.astro +298 -0
  27. package/src/LabHead.astro +1102 -0
  28. package/src/core/LICENSE-astrobook +166 -0
  29. package/src/core/astro-integration.ts +166 -0
  30. package/src/core/client.ts +89 -0
  31. package/src/core/index.ts +7 -0
  32. package/src/core/lib/components/empty.astro +1 -0
  33. package/src/core/lib/components/head.astro +1 -0
  34. package/src/core/lib/components/home.astro +8 -0
  35. package/src/core/lib/components/with-decorators.astro +22 -0
  36. package/src/core/lib/pages/app.astro +19 -0
  37. package/src/core/lib/pages/preview.astro +17 -0
  38. package/src/core/lib/pages/story.astro +16 -0
  39. package/src/core/lib-paths.ts +72 -0
  40. package/src/core/options.ts +262 -0
  41. package/src/core/utils/get-exports.ts +59 -0
  42. package/src/core/utils/invariant.ts +13 -0
  43. package/src/core/utils/kebab-case.ts +30 -0
  44. package/src/core/utils/path-builder.ts +45 -0
  45. package/src/core/utils/path.ts +80 -0
  46. package/src/core/virtual-module/get-story-modules.ts +110 -0
  47. package/src/core/virtual-module/story-modules.ts +9 -0
  48. package/src/core/virtual-module/virtual-module-ids.ts +17 -0
  49. package/src/core/virtual-module/virtual-routes.ts +130 -0
  50. package/src/core/virtual-module/vite-plugin.ts +125 -0
  51. package/src/pin/board.mjs +1521 -0
  52. package/src/pin/index.mjs +666 -0
  53. package/src/pin/shot.mjs +427 -0
  54. package/src/pin/source-stamp.mjs +159 -0
  55. package/src/pin/tickets.mjs +697 -0
  56. package/src/pin/toolbar.js +3181 -0
  57. package/src/shell/Browse.astro +371 -0
  58. package/src/shell/CardGrid.astro +297 -0
  59. package/src/shell/Viewport.astro +1330 -0
  60. package/src/shell/index.json.ts +12 -0
  61. package/src/shell/lab-index.ts +344 -0
  62. package/src/shell/lab-params.ts +245 -0
  63. package/src/shell/live-files.mjs +164 -0
  64. package/src/shell/marks.mjs +136 -0
  65. package/src/types/index.ts +6 -0
  66. package/src/types/types.ts +239 -0
  67. package/src/types/virtual.d.ts +29 -0
  68. package/src/ui/components/app.astro +13 -0
  69. package/src/ui/components/build-path.ts +13 -0
  70. package/src/ui/components/build-tree.ts +108 -0
  71. package/src/ui/components/collapse-duration.ts +28 -0
  72. package/src/ui/components/compress-terms.ts +10 -0
  73. package/src/ui/components/dashboard-layout.astro +39 -0
  74. package/src/ui/components/home.astro +65 -0
  75. package/src/ui/components/layout.astro +110 -0
  76. package/src/ui/components/preview-layout.astro +109 -0
  77. package/src/ui/components/sidebar-button-fullscreen.astro +38 -0
  78. package/src/ui/components/sidebar-button-search.astro +23 -0
  79. package/src/ui/components/sidebar-button-theme.astro +9 -0
  80. package/src/ui/components/sidebar-button.astro +24 -0
  81. package/src/ui/components/sidebar-resize-handle.astro +74 -0
  82. package/src/ui/components/sidebar-search-panel.astro +41 -0
  83. package/src/ui/components/sidebar-search-script.ts +103 -0
  84. package/src/ui/components/sidebar-title.astro +17 -0
  85. package/src/ui/components/sidebar-tree-node.astro +143 -0
  86. package/src/ui/components/sidebar-tree.astro +84 -0
  87. package/src/ui/components/sidebar.astro +29 -0
  88. package/src/ui/components/theme-message.ts +26 -0
  89. package/src/ui/components/theme-script.astro +71 -0
  90. package/src/ui/components/theme-toggle.astro +63 -0
  91. package/src/ui/components/theme.ts +32 -0
  92. package/src/ui/index.ts +4 -0
  93. package/src/ui/lab.css +549 -0
  94. package/virtual.d.ts +42 -0
@@ -0,0 +1,1330 @@
1
+ ---
2
+ // The viewport configurator — <subpath>/viewport/[...id], injected by ../../index.mjs, one page
3
+ // per story. The story's bare render (/lab/stories/<id>) sits in an <iframe> whose width and height
4
+ // you drag, type or pick from presets; media queries and vh units inside the frame follow the
5
+ // iframe's own size, which is why this is an iframe and never the inline dashboard render. Zoom is a
6
+ // transform on a wrapper, so the frame's logical size — and therefore its breakpoints — never
7
+ // changes when you zoom out to go wider than the window. The viewport is in the URL
8
+ // (?w=390&h=844&z=0.5) on every change, so the address Feedbucket captures names the exact frame.
9
+ //
10
+ // This page is NOT rendered through Astrobook's layout, so it carries its own <head> — and with it
11
+ // <LabHead />, which brings the chrome stylesheet (../ui/lab.css), Feedbucket and the
12
+ // UNCONDITIONAL noindex. A consumer's lab gate walks every built lab page and fails on any
13
+ // without it. Do not drop that import.
14
+ //
15
+ // CHANGED 2026-09-22 — the lab is not styled in the styles of the website being built. This
16
+ // file used to open with `import "virtual:orbytes-lab/user-css.mjs"` — it asked for the
17
+ // consumer's stylesheets by hand, because Astrobook injects the `css` option into its own pages
18
+ // only. The configurator is pure chrome: the thing being measured is inside the <iframe>, which
19
+ // is /lab/stories/<id>, its own document, and that is where the consumer's CSS belongs. So it
20
+ // asks for nothing now, and the chrome below is drawn entirely in `--lab-*` tokens. Reasoning:
21
+ // ../ui/lab.css.
22
+ import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
23
+ import LabHead from "../LabHead.astro";
24
+ import { buildLabIndex, isSectionLike, labBase } from "./lab-index";
25
+
26
+ export const getStaticPaths = (() => {
27
+ const index = buildLabIndex();
28
+ const byId = new Map(index.items.map((item) => [item.storyId, item]));
29
+ return index.items.map((item) => {
30
+ // prev/next step through the stories of the same folder, in Astrobook's own order.
31
+ const folder = index.folders.find((f) => f.path === item.directory);
32
+ const siblings = folder ? folder.items : index.items.filter((i) => i.directory === "").map((i) => i.storyId);
33
+ const at = siblings.indexOf(item.storyId);
34
+ const prev = at > 0 ? byId.get(siblings[at - 1]!) : undefined;
35
+ const next = at >= 0 && at < siblings.length - 1 ? byId.get(siblings[at + 1]!) : undefined;
36
+ return {
37
+ params: { id: item.storyId },
38
+ props: {
39
+ item,
40
+ prev: prev ? { id: prev.storyId, label: `${prev.moduleName} — ${prev.storyName}` } : null,
41
+ next: next ? { id: next.storyId, label: `${next.moduleName} — ${next.storyName}` } : null,
42
+ },
43
+ };
44
+ });
45
+ }) satisfies GetStaticPaths;
46
+
47
+ type Props = InferGetStaticPropsType<typeof getStaticPaths>;
48
+
49
+ const { item, prev, next } = Astro.props as Props;
50
+ const viewportBase = `${labBase}/viewport`;
51
+ const browseBase = `${labBase}/browse`;
52
+
53
+ // Default frame when neither the URL nor localStorage names one — the same virtual viewports the
54
+ // thumbnail cards use (CardGrid.astro): page width for sections, a component stage otherwise.
55
+ const defaults = isSectionLike(item) ? { w: 1440, h: 900 } : { w: 960, h: 540 };
56
+
57
+ const segments = item.directory ? item.directory.split("/") : [];
58
+ const crumbs = [
59
+ { label: "lab", href: labBase },
60
+ ...segments.map((segment, i) => ({
61
+ label: segment,
62
+ href: `${browseBase}/${segments.slice(0, i + 1).join("/")}`,
63
+ })),
64
+ ];
65
+ const title = `${item.moduleName} — ${item.storyName} · viewport`;
66
+
67
+ // Presets. Width-only chips keep the current height; device chips set both. The design widths and
68
+ // the breakpoint edges are the four frames and three breakpoints of qa/RESPONSIVE-RECIPE.md §1–2:
69
+ // 1440 / 834 / 550 / 390 drawn, 991 / 767 / 479 as the max-width queries — so both sides of every
70
+ // edge are one click away.
71
+ const widthPresets = {
72
+ design: [1440, 834, 550, 390],
73
+ edges: [992, 991, 768, 767, 480, 479],
74
+ };
75
+ const devices = [
76
+ { label: "iPhone 15", w: 390, h: 844 },
77
+ { label: "iPhone 15 Pro Max", w: 430, h: 932 },
78
+ { label: "iPad", w: 834, h: 1194 },
79
+ { label: "iPad landscape", w: 1194, h: 834 },
80
+ { label: "MacBook", w: 1440, h: 900 },
81
+ { label: "Wide", w: 1920, h: 1080 },
82
+ { label: "Ultra-wide", w: 2560, h: 1080 },
83
+ ];
84
+ // The `[` / `]` keys step through these, sorted, from wherever the width currently is.
85
+ const stepWidths = [...new Set([...widthPresets.design, ...widthPresets.edges])].sort((a, b) => a - b);
86
+ ---
87
+
88
+ <html lang="en">
89
+ <head>
90
+ <meta charset="utf-8" />
91
+ <meta name="viewport" content="width=device-width" />
92
+ <meta name="generator" content={Astro.generator} />
93
+ <title>{title}</title>
94
+ <LabHead />
95
+ </head>
96
+ <body>
97
+ <div
98
+ class="vp lab-chrome"
99
+ id="vp"
100
+ data-default-w={defaults.w}
101
+ data-default-h={defaults.h}
102
+ data-step-widths={stepWidths.join(",")}
103
+ data-story-id={item.storyId}
104
+ >
105
+ <header class="vp__bar">
106
+ <div class="vp__row vp__row--top">
107
+ <nav class="vp__crumbs" aria-label="Breadcrumb">
108
+ <ol>
109
+ {crumbs.map((crumb) => <li><a href={crumb.href}>{crumb.label}</a></li>)}
110
+ <li>
111
+ <span aria-current="page">
112
+ {item.moduleName}
113
+ <span class="vp__story">/ {item.storyName}</span>
114
+ </span>
115
+ </li>
116
+ </ol>
117
+ <a class="vp__dashboard" href={item.dashboardUrl}>
118
+ dashboard <span aria-hidden="true">&#8599;</span>
119
+ </a>
120
+ </nav>
121
+
122
+ <div class="vp__siblings">
123
+ {
124
+ prev ? (
125
+ <a class="vp__sibling" id="vp-prev" href={`${viewportBase}/${prev.id}`} title={prev.label}>
126
+ ‹ prev
127
+ </a>
128
+ ) : (
129
+ <span class="vp__sibling vp__sibling--off" aria-disabled="true">‹ prev</span>
130
+ )
131
+ }
132
+ {
133
+ next ? (
134
+ <a class="vp__sibling" id="vp-next" href={`${viewportBase}/${next.id}`} title={next.label}>
135
+ next ›
136
+ </a>
137
+ ) : (
138
+ <span class="vp__sibling vp__sibling--off" aria-disabled="true">next ›</span>
139
+ )
140
+ }
141
+ </div>
142
+ </div>
143
+
144
+ <div class="vp__row vp__row--controls">
145
+ <div class="vp__group vp__size" role="group" aria-label="Frame size">
146
+ <label class="vp__field">
147
+ <span>W</span>
148
+ <input id="vp-w" type="number" inputmode="numeric" min="240" step="1" aria-label="Width" />
149
+ </label>
150
+ <span class="vp__times" aria-hidden="true">×</span>
151
+ <label class="vp__field">
152
+ <span>H</span>
153
+ <input id="vp-h" type="number" inputmode="numeric" min="240" step="1" aria-label="Height" />
154
+ </label>
155
+ <button class="vp__button" id="vp-rotate" type="button" title="Rotate — swap width and height (r)">
156
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-9-9"/><path d="M21 3v6h-6"/></svg>
157
+ rotate
158
+ </button>
159
+ </div>
160
+
161
+ <div class="vp__group vp__readout" aria-live="polite">
162
+ <span class="vp__dims" id="vp-dims">— × —</span>
163
+ <span class="vp__band" id="vp-band" data-band="">—</span>
164
+ </div>
165
+
166
+ <div class="vp__group vp__zoom" role="group" aria-label="Zoom">
167
+ <button class="vp__button vp__button--icon" id="vp-zoom-out" type="button" title="Zoom out" aria-label="Zoom out">−</button>
168
+ <input id="vp-zoom" type="range" min="25" max="200" step="5" aria-label="Zoom" />
169
+ <button class="vp__button vp__button--icon" id="vp-zoom-in" type="button" title="Zoom in" aria-label="Zoom in">+</button>
170
+ <output class="vp__zoom-value" id="vp-zoom-value" for="vp-zoom">100%</output>
171
+ <button class="vp__button" id="vp-fit" type="button" title="Fit the whole frame in the stage (f)">fit</button>
172
+ <button class="vp__button" id="vp-zoom-reset" type="button" title="Zoom 100% (0)">1:1</button>
173
+ </div>
174
+
175
+ <button class="vp__button vp__copy" id="vp-copy" type="button" title="Copy a link to this story at this exact viewport">
176
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
177
+ <span id="vp-copy-label">copy link</span>
178
+ </button>
179
+ </div>
180
+
181
+ <div class="vp__row vp__row--presets">
182
+ <div class="vp__presets" role="group" aria-label="Design widths">
183
+ <span class="vp__presets-label">// design</span>
184
+ {widthPresets.design.map((w) => <button class="vp__chip" type="button" data-w={w}>{w}</button>)}
185
+ </div>
186
+ <div class="vp__presets" role="group" aria-label="Breakpoint edges">
187
+ <span class="vp__presets-label">// edges</span>
188
+ {widthPresets.edges.map((w) => <button class="vp__chip" type="button" data-w={w}>{w}</button>)}
189
+ </div>
190
+ <div class="vp__presets" role="group" aria-label="Devices">
191
+ <span class="vp__presets-label">// devices</span>
192
+ {
193
+ devices.map((d) => (
194
+ <button class="vp__chip" type="button" data-w={d.w} data-h={d.h} title={`${d.w} × ${d.h}`}>
195
+ {d.label}
196
+ </button>
197
+ ))
198
+ }
199
+ </div>
200
+ <p class="vp__keys">
201
+ <kbd>[</kbd><kbd>]</kbd> width presets · <kbd>r</kbd> rotate · <kbd>f</kbd> fit · <kbd>0</kbd> 1:1
202
+ </p>
203
+ </div>
204
+ </header>
205
+
206
+ {/* The parameters panel. Empty and `hidden` until the story inside the frame registers a
207
+ group through @orbytes/astrolab/params — a story with nothing to tune leaves the
208
+ layout exactly as it was. Everything inside is built by the script below. */}
209
+ <section class="vp__params" id="vp-params" aria-label="Parameters" hidden></section>
210
+
211
+ <main class="vp__stage" id="vp-stage">
212
+ <div class="vp__box" id="vp-box">
213
+ <div class="vp__wrapper" id="vp-wrapper">
214
+ <iframe id="vp-frame" src={item.storyUrl} title={`${item.moduleName} — ${item.storyName}`}></iframe>
215
+ </div>
216
+ <div class="vp__handle vp__handle--e" id="vp-handle-e" data-axis="x" title="Drag to resize width" aria-hidden="true"></div>
217
+ <div class="vp__handle vp__handle--s" id="vp-handle-s" data-axis="y" title="Drag to resize height" aria-hidden="true"></div>
218
+ <div class="vp__handle vp__handle--se" id="vp-handle-se" data-axis="xy" title="Drag to resize" aria-hidden="true"></div>
219
+ </div>
220
+ </main>
221
+ </div>
222
+
223
+ <script>
224
+ import type { LabParamControl, LabParamEntry } from "./lab-params";
225
+
226
+ declare global {
227
+ interface Window {
228
+ /** Set by ../LabHead.astro. True when a keystroke belongs to someone else. */
229
+ __labIgnoreKey?: (event: KeyboardEvent) => boolean;
230
+ }
231
+ }
232
+
233
+ // The configurator. State is { w, h, z }: logical frame size in CSS px and the zoom factor.
234
+ // Precedence on load: the URL (?w=&h=&z=) > localStorage (last used) > the page's defaults.
235
+ // Every committed change (drag end, input change, chip, key) writes the URL with
236
+ // history.replaceState and localStorage; live drags only redraw — Safari throttles
237
+ // replaceState to 100 calls per 30 s, and a drag would exhaust that in a second.
238
+ const MIN = 240;
239
+ const ZOOM_MIN = 0.25;
240
+ const ZOOM_MAX = 2;
241
+ const STORE_KEY = "lab-viewport";
242
+ const STAGE_PAD = 32; // px of stage kept around a fitted frame
243
+
244
+ const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
245
+ const root = $<HTMLDivElement>("vp");
246
+ const stage = $<HTMLElement>("vp-stage");
247
+ const box = $<HTMLDivElement>("vp-box");
248
+ const wrapper = $<HTMLDivElement>("vp-wrapper");
249
+ const frame = $<HTMLIFrameElement>("vp-frame");
250
+ const inputW = $<HTMLInputElement>("vp-w");
251
+ const inputH = $<HTMLInputElement>("vp-h");
252
+ const dims = $<HTMLSpanElement>("vp-dims");
253
+ const band = $<HTMLSpanElement>("vp-band");
254
+ const zoomRange = $<HTMLInputElement>("vp-zoom");
255
+ const zoomValue = $<HTMLOutputElement>("vp-zoom-value");
256
+ const copyLabel = $<HTMLSpanElement>("vp-copy-label");
257
+ const params = $<HTMLElement>("vp-params");
258
+
259
+ const defaults = { w: Number(root.dataset.defaultW) || 1440, h: Number(root.dataset.defaultH) || 900 };
260
+ const stepWidths = (root.dataset.stepWidths || "")
261
+ .split(",")
262
+ .map(Number)
263
+ .filter((n) => n > 0);
264
+
265
+ const clampSize = (n: number, fallback: number) =>
266
+ Number.isFinite(n) && n >= MIN ? Math.round(n) : fallback;
267
+ const clampZoom = (z: number, fallback: number) =>
268
+ Number.isFinite(z) && z > 0 ? Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z)) : fallback;
269
+
270
+ const readStore = (): { w?: number; h?: number; z?: number } => {
271
+ try {
272
+ return JSON.parse(localStorage.getItem(STORE_KEY) || "{}") || {};
273
+ } catch {
274
+ return {};
275
+ }
276
+ };
277
+
278
+ const initial = () => {
279
+ const q = new URLSearchParams(location.search);
280
+ const hasQuery = q.has("w") || q.has("h") || q.has("z");
281
+ const base = hasQuery ? {} : readStore();
282
+ const w = clampSize(Number(q.get("w") ?? base.w), defaults.w);
283
+ const h = clampSize(Number(q.get("h") ?? base.h), defaults.h);
284
+ const z = clampZoom(Number(q.get("z") ?? base.z), 1);
285
+ return { w, h, z };
286
+ };
287
+
288
+ const state = initial();
289
+
290
+ // ---- band ---------------------------------------------------------------------------------
291
+ // The four CSS bands of the orbytes scale: desktop (no query), ≤991, ≤767, ≤479.
292
+ const bandOf = (w: number) => {
293
+ if (w >= 992) return { name: "desktop", range: "≥ 992" };
294
+ if (w >= 768) return { name: "tablet", range: "768–991" };
295
+ if (w >= 480) return { name: "phone-landscape", range: "480–767" };
296
+ return { name: "phone", range: "≤ 479" };
297
+ };
298
+
299
+ // ---- render -------------------------------------------------------------------------------
300
+ const zoomLabel = () => `${Math.round(state.z * 100)}%`;
301
+ const render = () => {
302
+ wrapper.style.width = `${state.w}px`;
303
+ wrapper.style.height = `${state.h}px`;
304
+ wrapper.style.transform = `scale(${state.z})`;
305
+ box.style.width = `${state.w * state.z}px`;
306
+ box.style.height = `${state.h * state.z}px`;
307
+ if (document.activeElement !== inputW) inputW.value = String(state.w);
308
+ if (document.activeElement !== inputH) inputH.value = String(state.h);
309
+ dims.textContent = `${state.w} × ${state.h}`;
310
+ const b = bandOf(state.w);
311
+ band.textContent = `${b.name} · ${b.range}`;
312
+ band.dataset.band = b.name;
313
+ zoomRange.value = String(Math.round(state.z * 100));
314
+ zoomValue.textContent = zoomLabel();
315
+ for (const chip of root.querySelectorAll<HTMLButtonElement>(".vp__chip")) {
316
+ const w = Number(chip.dataset.w);
317
+ const h = chip.dataset.h ? Number(chip.dataset.h) : null;
318
+ chip.toggleAttribute("data-active", w === state.w && (h === null || h === state.h));
319
+ }
320
+ };
321
+
322
+ // ---- commit: URL + localStorage + sibling links --------------------------------------------
323
+ const query = () => {
324
+ const params = new URLSearchParams();
325
+ params.set("w", String(state.w));
326
+ params.set("h", String(state.h));
327
+ params.set("z", String(Math.round(state.z * 100) / 100));
328
+ return params;
329
+ };
330
+ const commit = () => {
331
+ render();
332
+ const url = new URL(location.href);
333
+ url.search = query().toString();
334
+ history.replaceState(history.state, "", url);
335
+ try {
336
+ localStorage.setItem(STORE_KEY, JSON.stringify({ w: state.w, h: state.h, z: state.z }));
337
+ } catch {
338
+ /* private mode — the URL still carries the state */
339
+ }
340
+ // prev/next keep the viewport, so stepping through a folder compares like with like.
341
+ for (const id of ["vp-prev", "vp-next"]) {
342
+ const link = document.getElementById(id) as HTMLAnchorElement | null;
343
+ if (link) link.search = url.search;
344
+ }
345
+ };
346
+
347
+ const setSize = (w: number, h: number) => {
348
+ state.w = clampSize(w, state.w);
349
+ state.h = clampSize(h, state.h);
350
+ commit();
351
+ };
352
+ const setZoom = (z: number) => {
353
+ state.z = clampZoom(z, state.z);
354
+ commit();
355
+ };
356
+ const fit = () => {
357
+ const availW = Math.max(1, stage.clientWidth - STAGE_PAD);
358
+ const availH = Math.max(1, stage.clientHeight - STAGE_PAD);
359
+ const z = Math.min(availW / state.w, availH / state.h);
360
+ // Floor to 2 dp so the fitted frame really fits and the URL round-trips to the same zoom.
361
+ setZoom(Math.floor(z * 100) / 100);
362
+ };
363
+ const rotate = () => setSize(state.h, state.w);
364
+
365
+ // ---- inputs -------------------------------------------------------------------------------
366
+ inputW.addEventListener("change", () => setSize(Number(inputW.value), state.h));
367
+ inputH.addEventListener("change", () => setSize(state.w, Number(inputH.value)));
368
+ // Shift+arrow steps by 10 (the native step is 1).
369
+ for (const input of [inputW, inputH]) {
370
+ input.addEventListener("keydown", (event) => {
371
+ if (!event.shiftKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
372
+ event.preventDefault();
373
+ const delta = event.key === "ArrowUp" ? 10 : -10;
374
+ const value = clampSize(Number(input.value) + delta, MIN);
375
+ if (input === inputW) setSize(value, state.h);
376
+ else setSize(state.w, value);
377
+ });
378
+ }
379
+ $("vp-rotate").addEventListener("click", rotate);
380
+
381
+ zoomRange.addEventListener("input", () => {
382
+ state.z = clampZoom(Number(zoomRange.value) / 100, state.z);
383
+ render();
384
+ });
385
+ zoomRange.addEventListener("change", () => setZoom(Number(zoomRange.value) / 100));
386
+ $("vp-zoom-out").addEventListener("click", () => setZoom(Math.round((state.z - 0.1) * 100) / 100));
387
+ $("vp-zoom-in").addEventListener("click", () => setZoom(Math.round((state.z + 0.1) * 100) / 100));
388
+ $("vp-fit").addEventListener("click", fit);
389
+ $("vp-zoom-reset").addEventListener("click", () => setZoom(1));
390
+
391
+ for (const chip of root.querySelectorAll<HTMLButtonElement>(".vp__chip")) {
392
+ chip.addEventListener("click", () => {
393
+ const w = Number(chip.dataset.w);
394
+ const h = chip.dataset.h ? Number(chip.dataset.h) : state.h;
395
+ setSize(w, h);
396
+ });
397
+ }
398
+
399
+ $("vp-copy").addEventListener("click", async () => {
400
+ commit(); // make sure the address is current before it is copied
401
+ try {
402
+ await navigator.clipboard.writeText(location.href);
403
+ copyLabel.textContent = "copied";
404
+ } catch {
405
+ copyLabel.textContent = "copy failed";
406
+ }
407
+ setTimeout(() => (copyLabel.textContent = "copy link"), 1500);
408
+ });
409
+
410
+ // ---- drag handles -------------------------------------------------------------------------
411
+ // Pointer capture keeps the move events on the handle even when the pointer crosses the
412
+ // iframe; the iframe is also made inert for the drag so its own document never sees them.
413
+ // Screen deltas are divided by the zoom so the frame grows by the logical pixels dragged.
414
+ for (const handle of root.querySelectorAll<HTMLDivElement>(".vp__handle")) {
415
+ const axis = handle.dataset.axis || "xy";
416
+ let startX = 0;
417
+ let startY = 0;
418
+ let startW = 0;
419
+ let startH = 0;
420
+ handle.addEventListener("pointerdown", (event) => {
421
+ if (event.button !== 0) return;
422
+ event.preventDefault();
423
+ handle.setPointerCapture(event.pointerId);
424
+ startX = event.clientX;
425
+ startY = event.clientY;
426
+ startW = state.w;
427
+ startH = state.h;
428
+ root.dataset.dragging = axis;
429
+ frame.style.pointerEvents = "none";
430
+ });
431
+ handle.addEventListener("pointermove", (event) => {
432
+ if (!handle.hasPointerCapture(event.pointerId)) return;
433
+ if (axis.includes("x")) state.w = clampSize(startW + (event.clientX - startX) / state.z, MIN);
434
+ if (axis.includes("y")) state.h = clampSize(startH + (event.clientY - startY) / state.z, MIN);
435
+ render();
436
+ });
437
+ const end = (event: PointerEvent) => {
438
+ if (!handle.hasPointerCapture(event.pointerId)) return;
439
+ handle.releasePointerCapture(event.pointerId);
440
+ delete root.dataset.dragging;
441
+ frame.style.pointerEvents = "";
442
+ commit();
443
+ };
444
+ handle.addEventListener("pointerup", end);
445
+ handle.addEventListener("pointercancel", end);
446
+ }
447
+
448
+ // ---- keys ---------------------------------------------------------------------------------
449
+ // The shared keyboard guard, declared by ../LabHead.astro (› THE KEYBOARD GUARD) — which
450
+ // this page renders, in <head>, so the inline script that defines it has always run by the
451
+ // time this deferred module does. The local `event.target` test that used to stand here was
452
+ // blind to the Astro dev toolbar: a keystroke typed into the pin composer is retargeted to
453
+ // the shadow host, so `[`, `]`, `r`, `f` and `0` all fired mid-sentence.
454
+ const ignoreKey = (event: KeyboardEvent) => window.__labIgnoreKey?.(event) ?? false;
455
+ const stepWidth = (direction: 1 | -1) => {
456
+ const candidates = direction > 0 ? stepWidths.filter((w) => w > state.w) : stepWidths.filter((w) => w < state.w);
457
+ if (!candidates.length) return;
458
+ setSize(direction > 0 ? candidates[0]! : candidates[candidates.length - 1]!, state.h);
459
+ };
460
+ const onKey = (event: KeyboardEvent) => {
461
+ if (event.metaKey || event.ctrlKey || event.altKey || ignoreKey(event)) return;
462
+ switch (event.key) {
463
+ case "[":
464
+ stepWidth(-1);
465
+ break;
466
+ case "]":
467
+ stepWidth(1);
468
+ break;
469
+ case "r":
470
+ rotate();
471
+ break;
472
+ case "f":
473
+ fit();
474
+ break;
475
+ case "0":
476
+ setZoom(1);
477
+ break;
478
+ default:
479
+ return;
480
+ }
481
+ event.preventDefault();
482
+ };
483
+ document.addEventListener("keydown", onKey);
484
+ // The story is same-origin, so the keys work while focus is inside the frame too.
485
+ frame.addEventListener("load", () => {
486
+ try {
487
+ frame.contentDocument?.addEventListener("keydown", onKey);
488
+ } catch {
489
+ /* cross-origin — keys work on the page only */
490
+ }
491
+ });
492
+
493
+ // ---- parameters panel ---------------------------------------------------------------------
494
+ // Ruled 2026-09-06: a configurator right below the top navigation in the viewport lab, and
495
+ // available only to the specific sections or experiments that have parameters to change.
496
+ // The story declares its parameters through @orbytes/astrolab/params, which
497
+ // puts them on the FRAME's window and announces each one with a `lab:params` event there. The
498
+ // frame is same-origin, so this reads that array directly and calls each group's own apply()
499
+ // across the boundary — the values object is shared, never copied. Register nothing and this
500
+ // element stays `hidden`, so a story with no parameters renders exactly as it did before.
501
+ const COLLAPSE_KEY = "lab-params-collapsed";
502
+
503
+ const readCollapsed = (): Record<string, boolean> => {
504
+ try {
505
+ const raw = JSON.parse(localStorage.getItem(COLLAPSE_KEY) || "{}");
506
+ return raw && typeof raw === "object" ? (raw as Record<string, boolean>) : {};
507
+ } catch {
508
+ return {};
509
+ }
510
+ };
511
+ const writeCollapsed = (map: Record<string, boolean>) => {
512
+ try {
513
+ localStorage.setItem(COLLAPSE_KEY, JSON.stringify(map));
514
+ } catch {
515
+ /* private mode — the panel just forgets between reloads */
516
+ }
517
+ };
518
+
519
+ // A slider reads back at its step's precision: step 0.05 → "0.50×", step 1 → "66px".
520
+ const decimalsOf = (step: number) => {
521
+ const text = String(step);
522
+ const dot = text.indexOf(".");
523
+ return dot === -1 ? 0 : text.length - dot - 1;
524
+ };
525
+
526
+ const el = <K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string) => {
527
+ const node = document.createElement(tag);
528
+ if (className) node.className = className;
529
+ if (text !== undefined) node.textContent = text;
530
+ return node;
531
+ };
532
+
533
+ // Each control returns its node plus a `sync` that pulls the input back from the live values
534
+ // — Reset changes the values behind the inputs' backs.
535
+ const buildControl = (entry: LabParamEntry, control: LabParamControl) => {
536
+ const field = el("label", `vpp__control vpp__control--${control.kind}`);
537
+ if (control.note) field.title = control.note;
538
+ const name = el("span", "vpp__label", control.label);
539
+
540
+ if (control.kind === "toggle") {
541
+ const input = el("input");
542
+ input.type = "checkbox";
543
+ input.addEventListener("change", () => entry.set(control.id, input.checked));
544
+ field.append(input, name);
545
+ const sync = () => {
546
+ input.checked = entry.values[control.id] === true;
547
+ };
548
+ sync();
549
+ return { node: field, sync };
550
+ }
551
+
552
+ if (control.kind === "select") {
553
+ const select = el("select", "vpp__select");
554
+ for (const option of control.options) {
555
+ const node = el("option", undefined, option.label);
556
+ node.value = option.value;
557
+ select.append(node);
558
+ }
559
+ select.addEventListener("change", () => entry.set(control.id, select.value));
560
+ field.append(name, select);
561
+ const sync = () => {
562
+ select.value = String(entry.values[control.id]);
563
+ };
564
+ sync();
565
+ return { node: field, sync };
566
+ }
567
+
568
+ if (control.kind === "color") {
569
+ const input = el("input", "vpp__swatch");
570
+ input.type = "color";
571
+ const out = el("output", "vpp__value");
572
+ input.addEventListener("input", () => {
573
+ out.textContent = input.value;
574
+ entry.set(control.id, input.value);
575
+ });
576
+ field.append(name, out, input);
577
+ const sync = () => {
578
+ input.value = String(entry.values[control.id]);
579
+ out.textContent = input.value;
580
+ };
581
+ sync();
582
+ return { node: field, sync };
583
+ }
584
+
585
+ const step = control.step ?? 1;
586
+ const places = decimalsOf(step);
587
+ const unit = control.unit ?? "";
588
+ const input = el("input");
589
+ input.type = "range";
590
+ input.min = String(control.min);
591
+ input.max = String(control.max);
592
+ input.step = String(step);
593
+ const out = el("output", "vpp__value");
594
+ const paint = () => {
595
+ out.textContent = `${Number(input.value).toFixed(places)}${unit}`;
596
+ };
597
+ input.addEventListener("input", () => {
598
+ paint();
599
+ entry.set(control.id, Number(input.value));
600
+ });
601
+ field.append(name, out, input);
602
+ const sync = () => {
603
+ input.value = String(entry.values[control.id]);
604
+ paint();
605
+ };
606
+ sync();
607
+ return { node: field, sync };
608
+ };
609
+
610
+ const buildGroup = (entry: LabParamEntry, collapsed: Record<string, boolean>) => {
611
+ const group = entry.group;
612
+ const wrap = el("div", "vpp__group");
613
+
614
+ const head = el("div", "vpp__head");
615
+ const toggle = el("button", "vpp__toggle");
616
+ toggle.type = "button";
617
+ toggle.append(el("span", "vpp__chev", "▾"), el("span", "vpp__title", `// ${group.title}`));
618
+ head.append(toggle);
619
+ if (group.source) {
620
+ const link = el("a", "vpp__source", group.source.label);
621
+ link.href = group.source.href;
622
+ link.target = "_blank";
623
+ link.rel = "noreferrer";
624
+ head.append(link);
625
+ }
626
+ if (group.note) head.append(el("p", "vpp__note", group.note));
627
+
628
+ const controls = el("div", "vpp__controls");
629
+ const syncers: (() => void)[] = [];
630
+ for (const control of group.controls) {
631
+ const built = buildControl(entry, control);
632
+ syncers.push(built.sync);
633
+ controls.append(built.node);
634
+ }
635
+
636
+ const reset = el("button", "vpp__button", "Reset");
637
+ reset.type = "button";
638
+ const copy = el("button", "vpp__button", "Copy settings");
639
+ copy.type = "button";
640
+ const actions = el("div", "vpp__actions");
641
+ actions.append(reset, copy);
642
+
643
+ const status = el("p", "vpp__status", entry.lastStatus || "Starting…");
644
+ status.setAttribute("aria-live", "polite");
645
+ entry.onStatus = (text) => {
646
+ status.textContent = text;
647
+ };
648
+
649
+ reset.addEventListener("click", () => {
650
+ entry.resetAll();
651
+ for (const sync of syncers) sync();
652
+ entry.status("Reset to the declared defaults.");
653
+ });
654
+ copy.addEventListener("click", async () => {
655
+ const json = entry.json();
656
+ try {
657
+ await navigator.clipboard.writeText(json);
658
+ entry.status("Settings copied as JSON — paste them onto the ticket.");
659
+ } catch {
660
+ entry.status(json);
661
+ }
662
+ });
663
+
664
+ const body = el("div", "vpp__body");
665
+ body.append(controls, actions, status);
666
+ wrap.append(head, body);
667
+
668
+ const setCollapsed = (value: boolean) => {
669
+ wrap.toggleAttribute("data-collapsed", value);
670
+ toggle.setAttribute("aria-expanded", String(!value));
671
+ };
672
+ setCollapsed(collapsed[group.id] === true);
673
+ toggle.addEventListener("click", () => {
674
+ const next = !wrap.hasAttribute("data-collapsed");
675
+ setCollapsed(next);
676
+ const map = readCollapsed();
677
+ if (next) map[group.id] = true;
678
+ else delete map[group.id];
679
+ writeCollapsed(map);
680
+ });
681
+
682
+ return wrap;
683
+ };
684
+
685
+ const buildPanel = () => {
686
+ params.textContent = "";
687
+ let groups: LabParamEntry[] = [];
688
+ try {
689
+ groups = frame.contentWindow?.__labParams ?? [];
690
+ } catch {
691
+ groups = []; // a lab story is always same-origin, but the panel must never throw
692
+ }
693
+ params.hidden = groups.length === 0;
694
+ if (!groups.length) return;
695
+ const collapsed = readCollapsed();
696
+ for (const entry of groups) params.append(buildGroup(entry, collapsed));
697
+ };
698
+
699
+ // Several groups registering in one tick rebuild the panel once.
700
+ let queued = false;
701
+ const queueBuild = () => {
702
+ if (queued) return;
703
+ queued = true;
704
+ queueMicrotask(() => {
705
+ queued = false;
706
+ buildPanel();
707
+ });
708
+ };
709
+
710
+ const readParams = () => {
711
+ queueBuild();
712
+ try {
713
+ // Groups registered after load (an async setup) announce themselves; ones registered
714
+ // during module evaluation are already in the array queueBuild just read.
715
+ frame.contentWindow?.addEventListener("lab:params", queueBuild);
716
+ } catch {
717
+ /* cross-origin — the panel stays as built */
718
+ }
719
+ };
720
+ frame.addEventListener("load", readParams);
721
+ if (frame.contentDocument?.readyState === "complete") readParams();
722
+
723
+ // ---- lifecycle ----------------------------------------------------------------------------
724
+ render();
725
+ commit(); // the URL always names the viewport, even when it came from storage or defaults
726
+ </script>
727
+ </body>
728
+ </html>
729
+
730
+ <style>
731
+ html,
732
+ body {
733
+ height: 100%;
734
+ }
735
+
736
+ .vp {
737
+ display: flex;
738
+ flex-direction: column;
739
+ height: 100vh;
740
+ background-color: var(--lab-color-bg);
741
+ color: var(--lab-color-text);
742
+ font-family: var(--lab-font-body);
743
+ font-size: 0.875em;
744
+ }
745
+
746
+ /* ---- top bar ------------------------------------------------------------------------------- */
747
+ .vp__bar {
748
+ flex: none;
749
+ display: flex;
750
+ flex-direction: column;
751
+ gap: 0.75em;
752
+ padding: 0.85em 1.25em;
753
+ border-bottom: 1px solid var(--lab-color-border);
754
+ }
755
+
756
+ .vp__row {
757
+ display: flex;
758
+ flex-wrap: wrap;
759
+ align-items: center;
760
+ gap: 1em 1.75em;
761
+ }
762
+
763
+ .vp__row--top {
764
+ justify-content: space-between;
765
+ font-family: var(--lab-font-mono);
766
+ font-weight: 500;
767
+ font-size: 0.85em;
768
+ color: var(--lab-color-text-faint);
769
+ }
770
+
771
+ .vp__crumbs {
772
+ display: flex;
773
+ flex-wrap: wrap;
774
+ align-items: baseline;
775
+ gap: 0.5em 1.25em;
776
+ }
777
+
778
+ .vp__crumbs ol {
779
+ display: flex;
780
+ flex-wrap: wrap;
781
+ list-style: none;
782
+ gap: 0.5em;
783
+ }
784
+
785
+ .vp__crumbs li + li::before {
786
+ content: "›";
787
+ color: var(--lab-color-text-faint);
788
+ margin-right: 0.5em;
789
+ }
790
+
791
+ .vp__crumbs a,
792
+ .vp__dashboard,
793
+ .vp__sibling {
794
+ color: var(--lab-color-text-faint);
795
+ text-decoration: none;
796
+ }
797
+
798
+ .vp__crumbs a:hover,
799
+ .vp__crumbs a:focus-visible,
800
+ .vp__dashboard:hover,
801
+ .vp__dashboard:focus-visible,
802
+ .vp__sibling:hover,
803
+ .vp__sibling:focus-visible {
804
+ color: var(--lab-color-text-strong);
805
+ text-decoration: underline;
806
+ }
807
+
808
+ .vp__crumbs [aria-current] {
809
+ color: var(--lab-color-text-strong);
810
+ }
811
+
812
+ .vp__story {
813
+ color: var(--lab-color-text-muted);
814
+ margin-left: 0.25em;
815
+ }
816
+
817
+ .vp__dashboard {
818
+ border-bottom: 1px solid var(--lab-color-border);
819
+ }
820
+
821
+ .vp__siblings {
822
+ display: flex;
823
+ gap: 1.25em;
824
+ }
825
+
826
+ .vp__sibling--off {
827
+ opacity: 0.35;
828
+ }
829
+
830
+ /* ---- controls ------------------------------------------------------------------------------ */
831
+ .vp__group {
832
+ display: flex;
833
+ align-items: center;
834
+ gap: 0.5em;
835
+ font-family: var(--lab-font-mono);
836
+ font-weight: 500;
837
+ font-size: 0.85em;
838
+ }
839
+
840
+ .vp__field {
841
+ display: inline-flex;
842
+ align-items: center;
843
+ gap: 0.5em;
844
+ color: var(--lab-color-text-faint);
845
+ border: 1px solid var(--lab-color-border);
846
+ padding: 0.35em 0.6em;
847
+ }
848
+
849
+ .vp__field:focus-within {
850
+ border-color: var(--lab-color-border-strong);
851
+ }
852
+
853
+ .vp__field input {
854
+ width: 4.5em;
855
+ font: inherit;
856
+ color: var(--lab-color-text-strong);
857
+ background: transparent;
858
+ border: 0;
859
+ outline: none;
860
+ text-align: right;
861
+ -moz-appearance: textfield;
862
+ appearance: textfield;
863
+ }
864
+
865
+ .vp__field input::-webkit-outer-spin-button,
866
+ .vp__field input::-webkit-inner-spin-button {
867
+ -webkit-appearance: none;
868
+ margin: 0;
869
+ }
870
+
871
+ .vp__times {
872
+ color: var(--lab-color-text-faint);
873
+ }
874
+
875
+ .vp__button {
876
+ display: inline-flex;
877
+ align-items: center;
878
+ gap: 0.45em;
879
+ font: inherit;
880
+ font-family: var(--lab-font-mono);
881
+ font-weight: 500;
882
+ font-size: 0.85em;
883
+ line-height: 1;
884
+ color: var(--lab-color-text-muted);
885
+ background: transparent;
886
+ border: 1px solid var(--lab-color-border);
887
+ padding: 0.55em 0.75em;
888
+ cursor: pointer;
889
+ transition: border-color var(--lab-transition), color var(--lab-transition);
890
+ }
891
+
892
+ .vp__group .vp__button {
893
+ font-size: 1em;
894
+ }
895
+
896
+ .vp__button:hover,
897
+ .vp__button:focus-visible {
898
+ color: var(--lab-color-text-strong);
899
+ border-color: var(--lab-color-border-strong);
900
+ outline: none;
901
+ }
902
+
903
+ .vp__button svg {
904
+ display: block;
905
+ }
906
+
907
+ .vp__button--icon {
908
+ width: 2.2em;
909
+ justify-content: center;
910
+ padding-left: 0;
911
+ padding-right: 0;
912
+ }
913
+
914
+ .vp__readout {
915
+ gap: 0.85em;
916
+ }
917
+
918
+ .vp__dims {
919
+ color: var(--lab-color-text-strong);
920
+ font-size: 1.1em;
921
+ letter-spacing: 0.02em;
922
+ min-width: 7em;
923
+ }
924
+
925
+ .vp__band {
926
+ font-size: 0.85em;
927
+ line-height: 1;
928
+ padding: 0.45em 0.85em;
929
+ border-radius: var(--lab-radius-pill);
930
+ border: 1px solid var(--lab-color-border);
931
+ color: var(--lab-color-text-muted);
932
+ }
933
+
934
+ .vp__band[data-band="desktop"] {
935
+ color: var(--lab-color-text-strong);
936
+ border-color: var(--lab-color-border-strong);
937
+ }
938
+
939
+ .vp__band[data-band="tablet"],
940
+ .vp__band[data-band="phone-landscape"],
941
+ .vp__band[data-band="phone"] {
942
+ color: var(--lab-color-focus);
943
+ border-color: color-mix(in srgb, var(--lab-color-focus) 50%, transparent);
944
+ }
945
+
946
+ .vp__zoom input[type="range"] {
947
+ width: 9em;
948
+ accent-color: var(--lab-color-text-strong);
949
+ }
950
+
951
+ .vp__zoom-value {
952
+ min-width: 3.2em;
953
+ text-align: right;
954
+ color: var(--lab-color-text-strong);
955
+ }
956
+
957
+ .vp__copy {
958
+ margin-left: auto;
959
+ }
960
+
961
+ /* ---- presets ------------------------------------------------------------------------------- */
962
+ .vp__row--presets {
963
+ gap: 0.75em 1.5em;
964
+ }
965
+
966
+ .vp__presets {
967
+ display: flex;
968
+ flex-wrap: wrap;
969
+ align-items: center;
970
+ gap: 0.35em;
971
+ }
972
+
973
+ .vp__presets-label {
974
+ font-family: var(--lab-font-mono);
975
+ font-weight: 500;
976
+ font-size: 0.75em;
977
+ color: var(--lab-color-text-faint);
978
+ margin-right: 0.35em;
979
+ }
980
+
981
+ .vp__chip {
982
+ font: inherit;
983
+ font-family: var(--lab-font-mono);
984
+ font-weight: 500;
985
+ font-size: 0.75em;
986
+ line-height: 1;
987
+ color: var(--lab-color-text-muted);
988
+ background: transparent;
989
+ border: 1px solid var(--lab-color-border);
990
+ border-radius: var(--lab-radius-pill);
991
+ padding: 0.45em 0.85em;
992
+ cursor: pointer;
993
+ transition: border-color var(--lab-transition), color var(--lab-transition), background-color var(--lab-transition);
994
+ }
995
+
996
+ .vp__chip:hover,
997
+ .vp__chip:focus-visible {
998
+ color: var(--lab-color-text-strong);
999
+ border-color: var(--lab-color-border-strong);
1000
+ outline: none;
1001
+ }
1002
+
1003
+ .vp__chip[data-active] {
1004
+ color: var(--lab-color-accent-text);
1005
+ background-color: var(--lab-color-accent);
1006
+ border-color: var(--lab-color-accent);
1007
+ }
1008
+
1009
+ .vp__keys {
1010
+ margin-left: auto;
1011
+ font-family: var(--lab-font-mono);
1012
+ font-size: 0.7em;
1013
+ color: var(--lab-color-text-faint);
1014
+ }
1015
+
1016
+ .vp__keys kbd {
1017
+ font: inherit;
1018
+ border: 1px solid var(--lab-color-border);
1019
+ padding: 0.1em 0.4em;
1020
+ margin-right: 0.2em;
1021
+ }
1022
+
1023
+ /* ---- stage --------------------------------------------------------------------------------- */
1024
+ .vp__stage {
1025
+ flex: 1;
1026
+ min-height: 0;
1027
+ display: flex;
1028
+ overflow: auto;
1029
+ margin: 1em 1.25em 1.25em;
1030
+ border: 1px solid var(--lab-color-border);
1031
+ background-color: var(--lab-color-bg-sunken);
1032
+ /* A faint grid so the frame's edges read against the stage at any zoom — half the hairline. */
1033
+ background-image:
1034
+ linear-gradient(color-mix(in srgb, var(--lab-color-border) 50%, transparent) 1px, transparent 1px),
1035
+ linear-gradient(90deg, color-mix(in srgb, var(--lab-color-border) 50%, transparent) 1px, transparent 1px);
1036
+ background-size: 2em 2em;
1037
+ }
1038
+
1039
+ /* margin:auto centres the box while it fits and pins it to the top-left once it overflows —
1040
+ the stage then scrolls instead of clipping the far edge, which justify-content:center would. */
1041
+ .vp__box {
1042
+ position: relative;
1043
+ flex: none;
1044
+ margin: auto;
1045
+ padding: 1em; /* room for the handles around the frame */
1046
+ /* The script sets width/height to the SCALED frame size; content-box so the padding is extra
1047
+ and the zoomed wrapper fills the content area exactly. overflow:hidden keeps the wrapper's
1048
+ unscaled layout box (transforms do not change layout) from ever widening the stage's scroll
1049
+ range — the box's own border box is what the stage scrolls to. */
1050
+ box-sizing: content-box;
1051
+ overflow: hidden;
1052
+ }
1053
+
1054
+ .vp__wrapper {
1055
+ position: relative;
1056
+ transform-origin: 0 0;
1057
+ outline: 1px solid var(--lab-color-border);
1058
+ background-color: var(--lab-color-bg);
1059
+ }
1060
+
1061
+ .vp__wrapper iframe {
1062
+ display: block;
1063
+ width: 100%;
1064
+ height: 100%;
1065
+ border: 0;
1066
+ background-color: var(--lab-color-bg);
1067
+ }
1068
+
1069
+ .vp[data-dragging] .vp__stage {
1070
+ user-select: none;
1071
+ }
1072
+
1073
+ /* Handles: a generous transparent hit area with a small visible grip drawn in ::after. They sit
1074
+ outside the zoomed wrapper, so they stay the same size at every zoom. */
1075
+ .vp__handle {
1076
+ position: absolute;
1077
+ touch-action: none;
1078
+ }
1079
+
1080
+ .vp__handle::after {
1081
+ content: "";
1082
+ position: absolute;
1083
+ background-color: var(--lab-color-border-strong);
1084
+ transition: background-color var(--lab-transition);
1085
+ }
1086
+
1087
+ .vp__handle:hover::after,
1088
+ .vp[data-dragging="x"] .vp__handle--e::after,
1089
+ .vp[data-dragging="y"] .vp__handle--s::after,
1090
+ .vp[data-dragging="xy"] .vp__handle--se::after {
1091
+ background-color: var(--lab-color-text-strong);
1092
+ }
1093
+
1094
+ .vp__handle--e {
1095
+ top: 1em;
1096
+ right: 0;
1097
+ bottom: 1em;
1098
+ width: 1em;
1099
+ cursor: ew-resize;
1100
+ }
1101
+
1102
+ .vp__handle--e::after {
1103
+ top: 50%;
1104
+ left: 0.25em;
1105
+ width: 0.25em;
1106
+ height: 2.5em;
1107
+ transform: translateY(-50%);
1108
+ }
1109
+
1110
+ .vp__handle--s {
1111
+ left: 1em;
1112
+ right: 1em;
1113
+ bottom: 0;
1114
+ height: 1em;
1115
+ cursor: ns-resize;
1116
+ }
1117
+
1118
+ .vp__handle--s::after {
1119
+ left: 50%;
1120
+ top: 0.25em;
1121
+ height: 0.25em;
1122
+ width: 2.5em;
1123
+ transform: translateX(-50%);
1124
+ }
1125
+
1126
+ .vp__handle--se {
1127
+ right: 0;
1128
+ bottom: 0;
1129
+ width: 1.25em;
1130
+ height: 1.25em;
1131
+ cursor: nwse-resize;
1132
+ }
1133
+
1134
+ .vp__handle--se::after {
1135
+ top: 0.2em;
1136
+ left: 0.2em;
1137
+ width: 0.6em;
1138
+ height: 0.6em;
1139
+ }
1140
+ </style>
1141
+
1142
+ {/* The parameters panel, styled globally because its contents are built by the script above and
1143
+ so never carry Astro's scoping attribute. Every selector is under `.vp__params`, which exists
1144
+ on this page only. Same language as the top bar: mono labels, hairlines, dark ground. */}
1145
+ <style is:global>
1146
+ .vp__params {
1147
+ display: none;
1148
+ }
1149
+
1150
+ .vp__params:not([hidden]) {
1151
+ flex: none;
1152
+ display: flex;
1153
+ flex-direction: column;
1154
+ gap: 0.75em;
1155
+ /* Deep panels (one library declares eighteen controls) scroll rather than eat the stage. */
1156
+ max-height: 40vh;
1157
+ overflow: auto;
1158
+ padding: 0.85em 1.25em;
1159
+ border-bottom: 1px solid var(--lab-color-border);
1160
+ background-color: var(--lab-color-surface-raised);
1161
+ font-family: var(--lab-font-mono);
1162
+ font-weight: 500;
1163
+ font-size: 0.8em;
1164
+ line-height: 1.4;
1165
+ color: var(--lab-color-text-muted);
1166
+ }
1167
+
1168
+ .vp__params .vpp__group + .vpp__group {
1169
+ padding-top: 0.75em;
1170
+ border-top: 1px solid var(--lab-color-border);
1171
+ }
1172
+
1173
+ .vp__params .vpp__head {
1174
+ display: flex;
1175
+ flex-wrap: wrap;
1176
+ align-items: baseline;
1177
+ gap: 0.4em 1.25em;
1178
+ }
1179
+
1180
+ .vp__params .vpp__toggle {
1181
+ display: inline-flex;
1182
+ align-items: baseline;
1183
+ gap: 0.5em;
1184
+ font: inherit;
1185
+ color: var(--lab-color-text-strong);
1186
+ background: transparent;
1187
+ border: 0;
1188
+ padding: 0;
1189
+ cursor: pointer;
1190
+ }
1191
+
1192
+ .vp__params .vpp__toggle:focus-visible {
1193
+ outline: 1px solid var(--lab-color-focus);
1194
+ outline-offset: 0.25em;
1195
+ }
1196
+
1197
+ .vp__params .vpp__chev {
1198
+ display: inline-block;
1199
+ color: var(--lab-color-text-faint);
1200
+ transition: transform var(--lab-transition);
1201
+ }
1202
+
1203
+ .vp__params .vpp__group[data-collapsed] .vpp__chev {
1204
+ transform: rotate(-90deg);
1205
+ }
1206
+
1207
+ .vp__params .vpp__group[data-collapsed] .vpp__body {
1208
+ display: none;
1209
+ }
1210
+
1211
+ .vp__params .vpp__source {
1212
+ color: var(--lab-color-text-faint);
1213
+ text-decoration: none;
1214
+ border-bottom: 1px solid var(--lab-color-border);
1215
+ }
1216
+
1217
+ .vp__params .vpp__source:hover,
1218
+ .vp__params .vpp__source:focus-visible {
1219
+ color: var(--lab-color-text-strong);
1220
+ }
1221
+
1222
+ .vp__params .vpp__note {
1223
+ flex-basis: 100%;
1224
+ margin: 0;
1225
+ font-size: 0.9em;
1226
+ color: var(--lab-color-text-faint);
1227
+ }
1228
+
1229
+ .vp__params .vpp__body {
1230
+ display: flex;
1231
+ flex-direction: column;
1232
+ gap: 0.75em;
1233
+ margin-top: 0.15em;
1234
+ padding-top: 0.75em;
1235
+ border-top: 1px solid var(--lab-color-border);
1236
+ }
1237
+
1238
+ .vp__params .vpp__controls {
1239
+ display: flex;
1240
+ flex-wrap: wrap;
1241
+ align-items: end;
1242
+ gap: 0.75em 1.5em;
1243
+ }
1244
+
1245
+ .vp__params .vpp__control {
1246
+ display: grid;
1247
+ grid-template-columns: 1fr auto;
1248
+ align-content: start;
1249
+ gap: 0.25em 0.5em;
1250
+ width: 14em;
1251
+ cursor: pointer;
1252
+ }
1253
+
1254
+ .vp__params .vpp__control input[type="range"] {
1255
+ grid-column: 1 / -1;
1256
+ width: 100%;
1257
+ margin: 0;
1258
+ accent-color: var(--lab-color-accent);
1259
+ }
1260
+
1261
+ .vp__params .vpp__control--toggle {
1262
+ display: flex;
1263
+ align-items: center;
1264
+ gap: 0.5em;
1265
+ width: auto;
1266
+ padding-bottom: 0.35em;
1267
+ color: var(--lab-color-text-strong);
1268
+ }
1269
+
1270
+ .vp__params .vpp__control--toggle input {
1271
+ accent-color: var(--lab-color-accent);
1272
+ }
1273
+
1274
+ .vp__params .vpp__select {
1275
+ grid-column: 1 / -1;
1276
+ font: inherit;
1277
+ color: var(--lab-color-text-strong);
1278
+ background-color: var(--lab-color-bg);
1279
+ border: 1px solid var(--lab-color-border);
1280
+ border-radius: var(--lab-radius-square);
1281
+ padding: 0.3em 0.4em;
1282
+ }
1283
+
1284
+ .vp__params .vpp__swatch {
1285
+ grid-column: 1 / -1;
1286
+ width: 100%;
1287
+ height: 1.75em;
1288
+ padding: 0;
1289
+ background: transparent;
1290
+ border: 1px solid var(--lab-color-border);
1291
+ cursor: pointer;
1292
+ }
1293
+
1294
+ .vp__params .vpp__label {
1295
+ color: var(--lab-color-text-muted);
1296
+ }
1297
+
1298
+ .vp__params .vpp__value {
1299
+ color: var(--lab-color-text-strong);
1300
+ font-variant-numeric: tabular-nums;
1301
+ }
1302
+
1303
+ .vp__params .vpp__actions {
1304
+ display: flex;
1305
+ gap: 0.5em;
1306
+ }
1307
+
1308
+ .vp__params .vpp__button {
1309
+ font: inherit;
1310
+ color: var(--lab-color-text-strong);
1311
+ background: transparent;
1312
+ border: 1px solid var(--lab-color-border);
1313
+ border-radius: var(--lab-radius-pill);
1314
+ padding: 0.35em 0.9em;
1315
+ cursor: pointer;
1316
+ transition: border-color var(--lab-transition);
1317
+ }
1318
+
1319
+ .vp__params .vpp__button:hover,
1320
+ .vp__params .vpp__button:focus-visible {
1321
+ border-color: var(--lab-color-border-strong);
1322
+ outline: none;
1323
+ }
1324
+
1325
+ .vp__params .vpp__status {
1326
+ margin: 0;
1327
+ color: var(--lab-color-text-faint);
1328
+ white-space: pre-wrap;
1329
+ }
1330
+ </style>