@orbytes/astrolab 0.3.0 → 0.4.0-next.2

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 (82) hide show
  1. package/README.md +261 -116
  2. package/bin/pin-gallery.mjs +53 -19
  3. package/defaults.mjs +72 -20
  4. package/dist/core/virtual-module/virtual-routes.js +12 -1
  5. package/docs/PIN-CONTRACT.md +84 -10
  6. package/docs/PIN.md +117 -37
  7. package/index.d.ts +41 -23
  8. package/index.mjs +46 -91
  9. package/package.json +7 -3
  10. package/src/Home.astro +166 -264
  11. package/src/LabHead.astro +37 -1047
  12. package/src/chrome/ActionsMenu.astro +97 -0
  13. package/src/chrome/ComponentCard.astro +76 -0
  14. package/src/chrome/Icon.astro +21 -0
  15. package/src/chrome/LICENSE-icons +43 -0
  16. package/src/chrome/Nav.astro +131 -0
  17. package/src/chrome/Panel.astro +117 -0
  18. package/src/chrome/Properties.astro +104 -0
  19. package/src/chrome/SectionsTree.astro +128 -0
  20. package/src/chrome/Shell.astro +120 -0
  21. package/src/chrome/Sprite.astro +23 -0
  22. package/src/chrome/StoryView.astro +192 -0
  23. package/src/chrome/Tree.astro +86 -0
  24. package/src/chrome/ViewportControls.astro +173 -0
  25. package/src/chrome/ViewportStage.astro +55 -0
  26. package/src/chrome/fonts/OFL.txt +93 -0
  27. package/src/chrome/fonts/inter-latin-wght-normal.woff2 +0 -0
  28. package/src/chrome/icons.ts +68 -0
  29. package/src/chrome/marks-client.ts +75 -0
  30. package/src/chrome/model.ts +156 -0
  31. package/src/chrome/navbar-client.ts +324 -0
  32. package/src/chrome/params-client.ts +434 -0
  33. package/src/chrome/pins-data.ts +63 -0
  34. package/src/chrome/shell-client.ts +468 -0
  35. package/src/chrome/site-data.ts +230 -0
  36. package/src/chrome/trees.ts +257 -0
  37. package/src/chrome/viewport-client.ts +405 -0
  38. package/src/chrome/views/Assets.astro +125 -0
  39. package/src/chrome/views/Pages.astro +214 -0
  40. package/src/chrome/views/Placeholder.astro +37 -0
  41. package/src/chrome/views/Tasks.astro +79 -0
  42. package/src/core/LICENSE-astrobook +21 -0
  43. package/src/core/lib/components/home.astro +4 -2
  44. package/src/core/lib/pages/story.astro +12 -10
  45. package/src/core/utils/kebab-case.ts +2 -2
  46. package/src/core/virtual-module/virtual-routes.ts +20 -4
  47. package/src/pin/board.mjs +414 -190
  48. package/src/pin/index.mjs +67 -22
  49. package/src/pin/tickets.mjs +6 -5
  50. package/src/pin/toolbar.js +82 -4
  51. package/src/shell/Browse.astro +131 -353
  52. package/src/shell/Viewport.astro +22 -1315
  53. package/src/shell/lab-index.ts +28 -18
  54. package/src/shell/lab-params.ts +113 -6
  55. package/src/shell/live-files.mjs +212 -10
  56. package/src/shell/marks.mjs +17 -41
  57. package/src/ui/components/app.astro +5 -7
  58. package/src/ui/components/preview-layout.astro +17 -0
  59. package/src/ui/components/theme-script.astro +17 -5
  60. package/src/ui/lab.css +3754 -371
  61. package/virtual.d.ts +13 -4
  62. package/bin/lab-cull.mjs +0 -401
  63. package/src/shell/CardGrid.astro +0 -297
  64. package/src/ui/components/build-path.ts +0 -13
  65. package/src/ui/components/build-tree.ts +0 -108
  66. package/src/ui/components/collapse-duration.ts +0 -28
  67. package/src/ui/components/compress-terms.ts +0 -10
  68. package/src/ui/components/dashboard-layout.astro +0 -39
  69. package/src/ui/components/home.astro +0 -65
  70. package/src/ui/components/layout.astro +0 -110
  71. package/src/ui/components/sidebar-button-fullscreen.astro +0 -38
  72. package/src/ui/components/sidebar-button-search.astro +0 -23
  73. package/src/ui/components/sidebar-button-theme.astro +0 -9
  74. package/src/ui/components/sidebar-button.astro +0 -24
  75. package/src/ui/components/sidebar-resize-handle.astro +0 -74
  76. package/src/ui/components/sidebar-search-panel.astro +0 -41
  77. package/src/ui/components/sidebar-search-script.ts +0 -103
  78. package/src/ui/components/sidebar-title.astro +0 -17
  79. package/src/ui/components/sidebar-tree-node.astro +0 -143
  80. package/src/ui/components/sidebar-tree.astro +0 -84
  81. package/src/ui/components/sidebar.astro +0 -29
  82. package/src/ui/components/theme-toggle.astro +0 -63
@@ -0,0 +1,405 @@
1
+ // The viewport — the controls in the navbar (./ViewportControls.astro) driving the canvas
2
+ // (./ViewportStage.astro). Ported from the standalone viewport configurator, which it replaces.
3
+ //
4
+ // State is { w, h, z }: the frame's LOGICAL size in CSS px, and the zoom — a number, or "fit",
5
+ // which is the default and re-fits as the window changes. Precedence on load: the URL
6
+ // (?w=&h=&z=) > the last size used for this kind of thing (sections, components and pages each
7
+ // remember their own) > the page's defaults. Every committed change (drag end, field change,
8
+ // preset, key) rewrites the URL with replaceState and the memory; a live drag only redraws —
9
+ // Safari throttles replaceState to 100 calls per 30 s, and a drag would spend that in a second.
10
+ //
11
+ // The controls are the device switch and ONE Viewport field (decided 2026-09-24, ASTROL-19 option
12
+ // B): width × height typed in place, and a button naming the band and the zoom that opens the
13
+ // Presets menu — rotate, the breakpoint edges, the devices and the zoom row. Every render
14
+ // announces itself as a `lab:viewport` event on the stage's root, which the pins overlay
15
+ // (./navbar-client.ts) follows.
16
+ //
17
+ // The viewport rides along on every link marked data-lab-keep-viewport (the variant menu, previous
18
+ // and next), so stepping through variants compares like with like.
19
+
20
+ declare global {
21
+ interface Window {
22
+ __labIgnoreKey?: (event: KeyboardEvent) => boolean;
23
+ }
24
+ }
25
+
26
+ const controls = document.querySelector<HTMLElement>("[data-lab-vp-controls]");
27
+ const vp = document.querySelector<HTMLElement>("[data-lab-vp]");
28
+
29
+ if (controls && vp) {
30
+ const $ = <T extends Element>(selector: string, scope: ParentNode = document) => scope.querySelector<T>(selector)!;
31
+ const stage = $<HTMLElement>("[data-lab-vp-stage]", vp);
32
+ const box = $<HTMLElement>("[data-lab-vp-box]", vp);
33
+ const wrapper = $<HTMLElement>("[data-lab-vp-wrapper]", vp);
34
+ const frame = $<HTMLIFrameElement>("[data-lab-vp-frame]", vp);
35
+ const live = $<HTMLElement>("[data-lab-vp-live]", vp);
36
+ const field = $<HTMLElement>("[data-lab-vpf]", controls);
37
+ const inputW = $<HTMLInputElement>("[data-lab-vp-w]", controls);
38
+ const inputH = $<HTMLInputElement>("[data-lab-vp-h]", controls);
39
+ const band = $<HTMLElement>("[data-lab-vp-band]", controls);
40
+ const zoomLabel = $<HTMLElement>("[data-lab-vp-zoom-label]", controls);
41
+ const menuButton = band.closest<HTMLElement>("button")!;
42
+ const zoomButtons = [...controls.querySelectorAll<HTMLElement>("[data-lab-vp-zoom]")];
43
+ const sizeButtons = [...controls.querySelectorAll<HTMLElement>("[data-lab-vp-width]")];
44
+ const copySize = document.querySelector<HTMLElement>("[data-lab-copy-size]");
45
+
46
+ const MIN = 240;
47
+ const ZOOM_MIN = 0.1;
48
+ const ZOOM_MAX = 2;
49
+ const STAGE_PAD = 32;
50
+ const kind = controls.dataset.kind || "component";
51
+ const STORE_KEY = `lab-viewport:${kind}`;
52
+ const defaults = { w: Number(controls.dataset.defaultW) || 1440, h: Number(controls.dataset.defaultH) || 900 };
53
+ const stepWidths = (controls.dataset.stepWidths || "").split(",").map(Number).filter((n) => n > 0);
54
+ const breakpoints: { name: string; min: number }[] = (() => {
55
+ try {
56
+ return JSON.parse(controls.dataset.breakpoints || "[]");
57
+ } catch {
58
+ return [];
59
+ }
60
+ })();
61
+
62
+ type Zoom = number | "fit";
63
+ const clampSize = (n: number, fallback: number) => (Number.isFinite(n) && n >= MIN ? Math.round(n) : fallback);
64
+ const clampZoom = (z: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
65
+ const parseZoom = (raw: unknown, fallback: Zoom): Zoom => {
66
+ if (raw === "fit") return "fit";
67
+ const n = Number(raw);
68
+ return Number.isFinite(n) && n > 0 ? clampZoom(n) : fallback;
69
+ };
70
+
71
+ const readStore = (): { w?: number; h?: number; z?: Zoom } => {
72
+ try {
73
+ return JSON.parse(localStorage.getItem(STORE_KEY) || "{}") || {};
74
+ } catch {
75
+ return {};
76
+ }
77
+ };
78
+
79
+ const state = (() => {
80
+ const q = new URLSearchParams(location.search);
81
+ const fromUrl = q.has("w") || q.has("h") || q.has("z");
82
+ const base = fromUrl ? {} : readStore();
83
+ return {
84
+ w: clampSize(Number(q.get("w") ?? base.w), defaults.w),
85
+ h: clampSize(Number(q.get("h") ?? base.h), defaults.h),
86
+ z: parseZoom(q.get("z") ?? base.z, "fit") as Zoom,
87
+ };
88
+ })();
89
+
90
+ const fitZoom = () => {
91
+ const availW = Math.max(1, stage.clientWidth - STAGE_PAD * 2);
92
+ const availH = Math.max(1, stage.clientHeight - STAGE_PAD * 2);
93
+ // Never enlarge past 100% to "fit": a small component at fit reads at its real size.
94
+ return Math.min(1, Math.floor(Math.min(availW / state.w, availH / state.h) * 100) / 100);
95
+ };
96
+ const scale = () => (state.z === "fit" ? fitZoom() : state.z);
97
+
98
+ /** The band a width falls in, and its range — the field names the band, its tooltip the range. */
99
+ const bandOf = (w: number) => {
100
+ const i = breakpoints.findIndex((b) => w >= b.min);
101
+ if (i === -1) return null;
102
+ const b = breakpoints[i]!;
103
+ const upper = i > 0 ? breakpoints[i - 1]!.min - 1 : null;
104
+ const range = upper === null ? `≥ ${b.min}` : b.min === 0 ? `≤ ${upper}` : `${b.min}–${upper}`;
105
+ return { name: b.name, range };
106
+ };
107
+
108
+ // ---- render ---------------------------------------------------------------------------------
109
+ const render = () => {
110
+ const z = scale();
111
+ wrapper.style.width = `${state.w}px`;
112
+ wrapper.style.height = `${state.h}px`;
113
+ wrapper.style.transform = `scale(${z})`;
114
+ box.style.width = `${Math.round(state.w * z)}px`;
115
+ box.style.height = `${Math.round(state.h * z)}px`;
116
+ if (document.activeElement !== inputW) inputW.value = String(state.w);
117
+ if (document.activeElement !== inputH) inputH.value = String(state.h);
118
+ live.textContent = `${state.w} × ${state.h}`;
119
+ const b = bandOf(state.w);
120
+ band.textContent = b ? b.name : `${state.w}px`;
121
+ zoomLabel.textContent = state.z === "fit" ? "Fit" : `${Math.round(state.z * 100)}%`;
122
+ menuButton.title = `${b ? `${b.name} · ${b.range}` : `${state.w}px`} — ${
123
+ state.z === "fit" ? `Fit · ${Math.round(z * 100)}%` : `${Math.round(z * 100)}%`
124
+ }. Rotate, breakpoints, devices and zoom.`;
125
+ if (copySize) copySize.textContent = `${state.w} × ${state.h}`;
126
+ // Where the frame is now: the device switch presses the width it is at, the menu ticks the edge
127
+ // or device it matches, and the zoom row the zoom — a width that is none of them presses nothing.
128
+ for (const button of sizeButtons) {
129
+ const w = Number(button.dataset.labVpWidth);
130
+ const h = button.dataset.labVpHeight ? Number(button.dataset.labVpHeight) : null;
131
+ const on = w === state.w && (h === null || h === state.h);
132
+ if (button.hasAttribute("aria-pressed")) button.setAttribute("aria-pressed", String(on));
133
+ else button.setAttribute("aria-checked", String(on));
134
+ }
135
+ const zoomValue = state.z === "fit" ? "fit" : String(Math.round(state.z * 100) / 100);
136
+ for (const button of zoomButtons) button.setAttribute("aria-checked", String(button.dataset.labVpZoom === zoomValue));
137
+ vp.dispatchEvent(new CustomEvent("lab:viewport", { detail: { w: state.w, h: state.h, z } }));
138
+ };
139
+
140
+ // ---- commit ---------------------------------------------------------------------------------
141
+ const query = () => {
142
+ const params = new URLSearchParams(location.search);
143
+ params.set("w", String(state.w));
144
+ params.set("h", String(state.h));
145
+ params.set("z", state.z === "fit" ? "fit" : String(Math.round(state.z * 100) / 100));
146
+ return params;
147
+ };
148
+ const commit = () => {
149
+ render();
150
+ const url = new URL(location.href);
151
+ url.search = query().toString();
152
+ history.replaceState(history.state, "", url);
153
+ try {
154
+ localStorage.setItem(STORE_KEY, JSON.stringify(state));
155
+ } catch {
156
+ /* private mode — the URL still carries the state */
157
+ }
158
+ for (const link of document.querySelectorAll<HTMLAnchorElement>("a[data-lab-keep-viewport]")) {
159
+ const target = new URL(link.href, location.href);
160
+ for (const key of ["w", "h", "z"]) target.searchParams.set(key, url.searchParams.get(key)!);
161
+ link.href = target.pathname + target.search;
162
+ }
163
+ };
164
+ const setSize = (w: number, h: number) => {
165
+ state.w = clampSize(w, state.w);
166
+ state.h = clampSize(h, state.h);
167
+ commit();
168
+ };
169
+ const setZoom = (z: Zoom) => {
170
+ state.z = z === "fit" ? "fit" : clampZoom(z);
171
+ commit();
172
+ };
173
+ const rotate = () => setSize(state.h, state.w);
174
+
175
+ // ---- controls -------------------------------------------------------------------------------
176
+ inputW.addEventListener("change", () => setSize(Number(inputW.value), state.h));
177
+ inputH.addEventListener("change", () => setSize(state.w, Number(inputH.value)));
178
+ for (const input of [inputW, inputH]) {
179
+ // Shift+arrow steps by 10 (the native step is 1).
180
+ input.addEventListener("keydown", (event) => {
181
+ if (!event.shiftKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
182
+ event.preventDefault();
183
+ const value = clampSize(Number(input.value) + (event.key === "ArrowUp" ? 10 : -10), MIN);
184
+ if (input === inputW) setSize(value, state.h);
185
+ else setSize(state.w, value);
186
+ });
187
+ }
188
+ // Choosing from the presets menu closes it, as choosing from any menu does.
189
+ const closeMenu = (el: HTMLElement) => el.closest<HTMLElement>("[popover]")?.hidePopover?.();
190
+ const rotateItem = $<HTMLElement>("[data-lab-vp-rotate]", controls);
191
+ rotateItem.addEventListener("click", () => {
192
+ rotate();
193
+ closeMenu(rotateItem);
194
+ });
195
+ for (const button of zoomButtons) {
196
+ button.addEventListener("click", () => {
197
+ setZoom(parseZoom(button.dataset.labVpZoom, "fit"));
198
+ closeMenu(button);
199
+ });
200
+ }
201
+ for (const button of sizeButtons) {
202
+ button.addEventListener("click", () => {
203
+ const h = button.dataset.labVpHeight ? Number(button.dataset.labVpHeight) : state.h;
204
+ setSize(Number(button.dataset.labVpWidth), h);
205
+ closeMenu(button);
206
+ });
207
+ }
208
+ new ResizeObserver(() => {
209
+ if (state.z === "fit") render();
210
+ }).observe(stage);
211
+
212
+ // ---- drag handles ---------------------------------------------------------------------------
213
+ // Pointer capture keeps the moves on the handle even across the iframe, which is also made inert
214
+ // for the drag. Screen deltas are divided by the zoom, so the frame grows by the pixels dragged.
215
+ // A drag at "fit" freezes the zoom at what fit was, or the frame would shrink under the pointer,
216
+ // and fits again on release. The field's numbers follow the drag live, in violet.
217
+ for (const handle of vp.querySelectorAll<HTMLElement>(".lab-vp-handle")) {
218
+ const axis = handle.dataset.axis || "xy";
219
+ let startX = 0;
220
+ let startY = 0;
221
+ let startW = 0;
222
+ let startH = 0;
223
+ let z = 1;
224
+ let wasFit = false;
225
+ handle.addEventListener("pointerdown", (event) => {
226
+ if (event.button !== 0) return;
227
+ event.preventDefault();
228
+ handle.setPointerCapture(event.pointerId);
229
+ startX = event.clientX;
230
+ startY = event.clientY;
231
+ startW = state.w;
232
+ startH = state.h;
233
+ z = scale();
234
+ wasFit = state.z === "fit";
235
+ if (wasFit) state.z = z;
236
+ vp.dataset.dragging = axis;
237
+ field.dataset.dragging = axis;
238
+ frame.style.pointerEvents = "none";
239
+ });
240
+ handle.addEventListener("pointermove", (event) => {
241
+ if (!handle.hasPointerCapture(event.pointerId)) return;
242
+ if (axis.includes("x")) state.w = clampSize(startW + (event.clientX - startX) / z, MIN);
243
+ if (axis.includes("y")) state.h = clampSize(startH + (event.clientY - startY) / z, MIN);
244
+ render();
245
+ });
246
+ const end = (event: PointerEvent) => {
247
+ if (!handle.hasPointerCapture(event.pointerId)) return;
248
+ handle.releasePointerCapture(event.pointerId);
249
+ delete vp.dataset.dragging;
250
+ delete field.dataset.dragging;
251
+ frame.style.pointerEvents = "";
252
+ if (wasFit) state.z = "fit"; // back to fitting the new size, as the field still says
253
+ commit();
254
+ };
255
+ handle.addEventListener("pointerup", end);
256
+ handle.addEventListener("pointercancel", end);
257
+ }
258
+
259
+ // ---- keys -----------------------------------------------------------------------------------
260
+ // [ ] step through the widths, r rotates, f fits, 0 is 100%. The shared keyboard guard
261
+ // (../LabHead.astro) keeps them quiet while anything is being typed, the pin composer included.
262
+ const stepWidth = (direction: 1 | -1) => {
263
+ const candidates = direction > 0 ? stepWidths.filter((w) => w > state.w) : stepWidths.filter((w) => w < state.w);
264
+ if (!candidates.length) return;
265
+ setSize(direction > 0 ? candidates[0]! : candidates[candidates.length - 1]!, state.h);
266
+ };
267
+ const onKey = (event: KeyboardEvent) => {
268
+ if (event.metaKey || event.ctrlKey || event.altKey || window.__labIgnoreKey?.(event)) return;
269
+ switch (event.key) {
270
+ case "[":
271
+ stepWidth(-1);
272
+ break;
273
+ case "]":
274
+ stepWidth(1);
275
+ break;
276
+ case "r":
277
+ rotate();
278
+ break;
279
+ case "f":
280
+ setZoom("fit");
281
+ break;
282
+ case "0":
283
+ setZoom(1);
284
+ break;
285
+ default:
286
+ return;
287
+ }
288
+ event.preventDefault();
289
+ };
290
+ document.addEventListener("keydown", onKey);
291
+ // Same-origin, so the keys also work while focus is inside the frame — j/k too, handed to the
292
+ // page's own handler by re-dispatching on this document.
293
+ frame.addEventListener("load", () => {
294
+ try {
295
+ frame.contentDocument?.addEventListener("keydown", (event) => {
296
+ onKey(event);
297
+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
298
+ if ((event.key === "j" || event.key === "k") && !window.__labIgnoreKey?.(event)) {
299
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: event.key }));
300
+ }
301
+ });
302
+ } catch {
303
+ /* cross-origin — keys work on the page only */
304
+ }
305
+ });
306
+
307
+ // ---- actions --------------------------------------------------------------------------------
308
+ for (const button of document.querySelectorAll<HTMLElement>("[data-lab-copy-link]")) {
309
+ button.addEventListener("click", async () => {
310
+ commit();
311
+ const label = button.querySelector<HTMLElement>("[data-lab-copy-label]");
312
+ try {
313
+ await navigator.clipboard.writeText(location.href);
314
+ if (label) label.textContent = "Link copied";
315
+ } catch {
316
+ if (label) label.textContent = "Copy failed";
317
+ }
318
+ setTimeout(() => {
319
+ if (label) label.textContent = "Copy link to this exact size";
320
+ }, 1500);
321
+ });
322
+ }
323
+ for (const button of document.querySelectorAll<HTMLElement>("[data-lab-reload]")) {
324
+ button.addEventListener("click", () => {
325
+ try {
326
+ frame.contentWindow?.location.reload();
327
+ } catch {
328
+ frame.src = frame.src;
329
+ }
330
+ });
331
+ }
332
+
333
+ // ---- the preview's own theme ----------------------------------------------------------------
334
+ // Separate from the chrome's (decided 2026-09-24): it is ⋯ › Preview › Dark mode, a checkbox row
335
+ // that appears only when the site in the frame has something to switch — a `.dark` or
336
+ // `[data-theme]` rule, or a dark colour-scheme query. Ticking it does both things a site might
337
+ // listen to — the frame's theme script (the same `astrobook:set-theme` message the thumbnails
338
+ // have always used) and the iframe's color-scheme, which is what `prefers-color-scheme` reads
339
+ // inside it.
340
+ const themeItem = document.querySelector<HTMLElement>("[data-lab-preview-theme]");
341
+ const themeGroup = document.querySelector<HTMLElement>("[data-lab-preview-group]");
342
+ const PREVIEW_THEME_KEY = "theme-toggle";
343
+ const previewTheme = () => {
344
+ try {
345
+ const stored = localStorage.getItem(PREVIEW_THEME_KEY);
346
+ return stored === "dark" ? "dark" : "light";
347
+ } catch {
348
+ return "light";
349
+ }
350
+ };
351
+ const paintPreviewTheme = (theme: "light" | "dark") => {
352
+ frame.dataset.theme = theme;
353
+ themeItem?.setAttribute("aria-checked", String(theme === "dark"));
354
+ };
355
+ const supportsDark = (doc: Document) => {
356
+ const scan = (rules: CSSRuleList | undefined): boolean => {
357
+ for (const rule of rules ?? []) {
358
+ if (rule instanceof CSSStyleRule && /\.dark\b|\[data-theme/.test(rule.selectorText)) return true;
359
+ if (rule instanceof CSSMediaRule && /prefers-color-scheme:\s*dark/.test(rule.conditionText)) return true;
360
+ if ("cssRules" in rule && scan((rule as CSSGroupingRule).cssRules)) return true;
361
+ }
362
+ return false;
363
+ };
364
+ for (const sheet of doc.styleSheets) {
365
+ try {
366
+ if (scan(sheet.cssRules)) return true;
367
+ } catch {
368
+ /* a cross-origin sheet — unreadable, and not the site's own theme anyway */
369
+ }
370
+ }
371
+ return false;
372
+ };
373
+ paintPreviewTheme(previewTheme());
374
+ frame.addEventListener("load", () => {
375
+ try {
376
+ if (themeGroup && frame.contentDocument) themeGroup.hidden = !supportsDark(frame.contentDocument);
377
+ } catch {
378
+ /* cross-origin */
379
+ }
380
+ });
381
+ themeItem?.addEventListener("click", () => {
382
+ const next = previewTheme() === "dark" ? "light" : "dark";
383
+ try {
384
+ localStorage.setItem(PREVIEW_THEME_KEY, next);
385
+ } catch {
386
+ /* private mode — the message still flips the frame */
387
+ }
388
+ paintPreviewTheme(next);
389
+ frame.contentWindow?.postMessage({ type: "astrobook:set-theme", theme: next }, location.origin);
390
+ });
391
+
392
+ // ---- the parameters drawer --------------------------------------------------------------------
393
+ // Present only for a story that registers parameters through @orbytes/astrolab/params. The
394
+ // drawer — its rows, groups, footer, storage and Esc — is ./params-client.ts.
395
+ mountParamsDrawer(vp.querySelector<HTMLElement>("[data-lab-params]"), frame);
396
+
397
+ render();
398
+ commit(); // the URL always names the viewport, even when it came from memory or defaults
399
+ }
400
+
401
+ // Hoisted like every import. It stands here, beside the one call that uses it, so the drawer's
402
+ // wiring is in one place in this file.
403
+ import { mountParamsDrawer } from "./params-client";
404
+
405
+ export {};
@@ -0,0 +1,125 @@
1
+ ---
2
+ // Assets — <subpath>/assets, injected by ../../../index.mjs. Every image under src/ and public/,
3
+ // with its size, its pixel dimensions where Astro knows them, and which source files reference it
4
+ // — so the one question worth asking of a pile of images ("what does nothing use?") is a filter.
5
+ //
6
+ // src/ images are shown through Astro's own import (a glob of image metadata: the URL and the
7
+ // dimensions, never the pixels); public/ images at their public URL. Neither loads until scrolled
8
+ // into view.
9
+ import Shell from "../Shell.astro";
10
+ import Tree from "../Tree.astro";
11
+ import { hrefs, type FilterOption } from "../model";
12
+ import { assetsTree, formatBytes, siteAssetsModel } from "../site-data";
13
+
14
+ type Meta = { src?: string; width?: number; height?: number };
15
+ const imported = import.meta.glob<Meta>("/src/**/*.{png,jpg,jpeg,webp,avif,gif,svg}", {
16
+ eager: true,
17
+ import: "default",
18
+ });
19
+ const base = import.meta.env.BASE_URL.replace(/\/+$/, "");
20
+
21
+ const assets = siteAssetsModel().map((asset) => {
22
+ const meta = asset.where === "src" ? imported[`/${asset.file}`] : undefined;
23
+ const url = asset.where === "public" ? `${base}/${asset.file.slice("public/".length)}` : (meta?.src ?? null);
24
+ return { ...asset, url, width: meta?.width ?? null, height: meta?.height ?? null };
25
+ });
26
+ const unused = assets.filter((a) => a.usedBy.length === 0).length;
27
+ const total = assets.reduce((n, a) => n + a.bytes, 0);
28
+ const filters: FilterOption[] = [
29
+ { value: "used", label: "Referenced" },
30
+ { value: "unused", label: "Referenced by nothing" },
31
+ ];
32
+ ---
33
+
34
+ <Shell
35
+ title="Assets"
36
+ active="assets"
37
+ navbarClass="lab-navbar--listing"
38
+ panel={{
39
+ label: "Assets",
40
+ crumbs: [{ label: "Site" }, { label: "Assets", href: hrefs.assets }],
41
+ filters,
42
+ filterLabel: "Show images",
43
+ searchPlaceholder: "Search images",
44
+ }}
45
+ >
46
+ <Tree slot="panel" nodes={assetsTree(assets)} />
47
+
48
+ <Fragment slot="navbar">
49
+ <div class="lab-listing-bar">
50
+ <h1 class="lab-listing-bar__title">Assets</h1>
51
+ <p class="lab-listing-bar__counts">
52
+ {[`${assets.length} image${assets.length === 1 ? "" : "s"}`, formatBytes(total), unused > 0 ? `${unused} unused` : null]
53
+ .filter(Boolean)
54
+ .join(" · ")}
55
+ </p>
56
+ {/* The same filter as level 2's menu (../shell-client.ts). */}
57
+ <div class="lab-chips" role="radiogroup" aria-label="Show images">
58
+ {
59
+ [
60
+ { value: "", label: "All", n: assets.length },
61
+ { value: "used", label: "Referenced", n: assets.length - unused },
62
+ { value: "unused", label: "Unused", n: unused },
63
+ ].map((chip) => (
64
+ <button class="lab-chip" type="button" role="radio" aria-checked={String(chip.value === "")} data-lab-filter={chip.value}>
65
+ {chip.label} {chip.n}
66
+ </button>
67
+ ))
68
+ }
69
+ </div>
70
+ </div>
71
+ </Fragment>
72
+
73
+ <div class="lab-page">
74
+ {
75
+ assets.length === 0 ? (
76
+ <div class="lab-empty">
77
+ <strong>No images</strong>
78
+ Nothing under <code>src/</code> or <code>public/</code> ends in .png, .jpg, .webp, .avif, .gif or .svg.
79
+ </div>
80
+ ) : (
81
+ <div class="lab-cards">
82
+ {assets.map((asset) => (
83
+ <article
84
+ class="lab-card lab-asset"
85
+ id={asset.id}
86
+ data-lab-search={`${asset.file} ${asset.ext}`.toLowerCase()}
87
+ data-lab-facets={asset.usedBy.length ? "used" : "unused"}
88
+ >
89
+ <span class="lab-card__thumb">
90
+ {asset.url ? <img src={asset.url} alt="" loading="lazy" decoding="async" /> : null}
91
+ </span>
92
+ <span class="lab-card__body">
93
+ <span class="lab-card__name" title={asset.file}>
94
+ <span>{asset.name}</span>
95
+ </span>
96
+ <dl class="lab-kv">
97
+ <dt>Folder</dt>
98
+ <dd title={asset.folder}>{asset.folder}</dd>
99
+ <dt>Size</dt>
100
+ <dd>
101
+ {formatBytes(asset.bytes)}
102
+ {asset.width && asset.height ? ` · ${asset.width} × ${asset.height}` : ""}
103
+ </dd>
104
+ <dt>Used by</dt>
105
+ <dd title={asset.usedBy.join("\n")}>
106
+ {asset.usedBy.length === 0
107
+ ? "nothing"
108
+ : asset.usedBy.length === 1
109
+ ? asset.usedBy[0]!.split("/").pop()
110
+ : `${asset.usedBy[0]!.split("/").pop()} and ${asset.usedBy.length - 1} more`}
111
+ </dd>
112
+ </dl>
113
+ {asset.usedBy.length === 0 && (
114
+ <span class="lab-card__pills">
115
+ <span class="lab-pill lab-pill--unresponsive">unused</span>
116
+ </span>
117
+ )}
118
+ </span>
119
+ </article>
120
+ ))}
121
+ </div>
122
+ )
123
+ }
124
+ </div>
125
+ </Shell>