@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,468 @@
1
+ // The chrome's behaviour — one module, run once per page (./Shell.astro has no client router, so
2
+ // there is no swapped document to re-initialise and no guard against running twice).
3
+ //
4
+ // · the two collapses: level 1 ⇄ icon rail (⌘⇧B), level 2 open ⇄ closed (⌘B), both (⌘.), each
5
+ // remembered in localStorage and applied before paint by the inline script in Shell.astro
6
+ // · the peek: level 2 closed, pointer at the stage's left edge → the panel slides out OVER the
7
+ // stage, below the navbar, for as long as the pointer stays on it. Over, not beside: pushing
8
+ // the stage would resize the preview frame and trip its breakpoints
9
+ // · level 1 groups (Site, Tasks) folding, remembered
10
+ // · the lab's own light/dark (the moon in level 1)
11
+ // · the tree: closed groups remembered, scroll restored, the current row kept in view, and the
12
+ // Sections tree's sort (slot or name), remembered
13
+ // · search (⌘K) and the filter menu, applied to the tree AND to any cards in the content that
14
+ // carry the same data-lab-search / data-lab-facets attributes
15
+ // · j / k: the navbar's own previous/next links when the page has them, the tree's rows when not
16
+ // · popover menus placed under the button that opened them
17
+ // · Astro's dev toolbar hidden in every frame, so the lab page's own is the only one
18
+ //
19
+ // Every shortcut asks window.__labIgnoreKey first (../LabHead.astro › THE KEYBOARD GUARD).
20
+
21
+ declare global {
22
+ interface Window {
23
+ __labIgnoreKey?: (event: KeyboardEvent) => boolean;
24
+ labTheme?: { setTheme: (theme: "light" | "dark") => void; getTheme: () => "light" | "dark" };
25
+ }
26
+ }
27
+
28
+ const root = document.documentElement;
29
+ const panel = document.getElementById("lab-l2");
30
+ const page = root.dataset.labPage || "lab";
31
+
32
+ const read = (key: string, store: Storage = localStorage): string | null => {
33
+ try {
34
+ return store.getItem(key);
35
+ } catch {
36
+ return null;
37
+ }
38
+ };
39
+ const write = (key: string, value: string | null, store: Storage = localStorage) => {
40
+ try {
41
+ if (value === null) store.removeItem(key);
42
+ else store.setItem(key, value);
43
+ } catch {
44
+ /* private mode — the state holds for this page and is forgotten after */
45
+ }
46
+ };
47
+ const ignoreKey = (event: KeyboardEvent) => window.__labIgnoreKey?.(event) ?? false;
48
+
49
+ // ---- the two collapses ---------------------------------------------------------------------------
50
+ // The collapse button's name follows what it will do; the tooltip's title switches in CSS.
51
+ const l1Button = document.querySelector<HTMLElement>(".lab-l1 [data-lab-l1-btn]");
52
+ const nameL1Button = () => l1Button?.setAttribute("aria-label", l1Rail() ? "Expand menu" : "Collapse menu");
53
+ const setL1 = (rail: boolean) => {
54
+ if (rail) root.dataset.labL1 = "rail";
55
+ else delete root.dataset.labL1;
56
+ write("lab-l1", rail ? "rail" : "open");
57
+ nameL1Button();
58
+ };
59
+ // Level 2's own header button hides the panel when it is docked and keeps it open while it only
60
+ // peeks, so its name says which (decided 2026-09-24: a label must match its action).
61
+ const l2Button = panel?.querySelector<HTMLElement>("[data-lab-l2-btn]");
62
+ const nameL2Button = () => {
63
+ const label = "labPeek" in root.dataset ? "Keep panel open" : "Hide panel";
64
+ l2Button?.setAttribute("aria-label", label);
65
+ l2Button?.setAttribute("title", `${label} · ⌘B`);
66
+ };
67
+ const setL2 = (closed: boolean) => {
68
+ if (!panel) return;
69
+ if (closed) root.dataset.labL2 = "closed";
70
+ else delete root.dataset.labL2;
71
+ delete root.dataset.labPeek;
72
+ nameL2Button();
73
+ write("lab-l2", closed ? "closed" : null);
74
+ };
75
+ const l1Rail = () => root.dataset.labL1 === "rail";
76
+ const l2Closed = () => root.dataset.labL2 === "closed";
77
+ nameL1Button();
78
+ const toggleL1 = () => setL1(!l1Rail());
79
+ const toggleL2 = () => setL2(!l2Closed());
80
+ // Focus mode: both away if either is showing, both back if both are away.
81
+ const toggleFocus = () => {
82
+ const away = l1Rail() && (l2Closed() || !panel);
83
+ setL1(!away);
84
+ setL2(!away);
85
+ };
86
+
87
+ for (const button of document.querySelectorAll<HTMLElement>("[data-lab-toggle]")) {
88
+ button.addEventListener("click", () => (button.dataset.labToggle === "l1" ? toggleL1() : toggleL2()));
89
+ }
90
+
91
+ // Clicking the level 1 item you are already on toggles level 2, rather than reloading the page you
92
+ // are looking at — the activity-bar behaviour.
93
+ if (panel) {
94
+ for (const link of document.querySelectorAll<HTMLAnchorElement>('.lab-l1 a[aria-current="page"]')) {
95
+ link.addEventListener("click", (event) => {
96
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return;
97
+ event.preventDefault();
98
+ toggleL2();
99
+ });
100
+ }
101
+ }
102
+
103
+ // ---- the peek -----------------------------------------------------------------------------------
104
+ // The peek and its trigger strip start where the navbar ends, so the panel covers the stage and
105
+ // never the navbar (decided 2026-09-24). The navbar is one row or two depending on the width it
106
+ // gets, so its height is measured, not assumed.
107
+ const navbar = document.querySelector<HTMLElement>("[data-lab-navbar]");
108
+ if (panel && navbar) {
109
+ const measure = () => root.style.setProperty("--lab-peek-top", `${navbar.offsetHeight}px`);
110
+ measure();
111
+ new ResizeObserver(measure).observe(navbar);
112
+ }
113
+ if (panel) {
114
+ const zone = document.querySelector<HTMLElement>("[data-lab-peek-zone]");
115
+ let openTimer = 0;
116
+ let closeTimer = 0;
117
+ const peek = (on: boolean) => {
118
+ if (on && l2Closed()) root.dataset.labPeek = "";
119
+ else delete root.dataset.labPeek;
120
+ nameL2Button();
121
+ };
122
+ zone?.addEventListener("mouseenter", () => {
123
+ clearTimeout(closeTimer);
124
+ openTimer = window.setTimeout(() => peek(true), 120);
125
+ });
126
+ zone?.addEventListener("mouseleave", () => clearTimeout(openTimer));
127
+ panel.addEventListener("mouseenter", () => clearTimeout(closeTimer));
128
+ panel.addEventListener("mouseleave", () => {
129
+ if (!("labPeek" in root.dataset)) return;
130
+ closeTimer = window.setTimeout(() => peek(false), 250);
131
+ });
132
+ document.addEventListener("keydown", (event) => {
133
+ if (event.key === "Escape" && "labPeek" in root.dataset) peek(false);
134
+ });
135
+ }
136
+
137
+ // ---- level 1 groups -----------------------------------------------------------------------------
138
+ const GROUPS_KEY = "lab-nav-collapsed";
139
+ const collapsedGroups = new Set<string>((read(GROUPS_KEY) || "").split(",").filter(Boolean));
140
+ for (const group of document.querySelectorAll<HTMLElement>("[data-lab-group]")) {
141
+ const id = group.dataset.labGroup!;
142
+ const button = group.querySelector<HTMLButtonElement>("[data-lab-group-toggle]");
143
+ const apply = (collapsed: boolean) => {
144
+ group.toggleAttribute("data-collapsed", collapsed);
145
+ button?.setAttribute("aria-expanded", String(!collapsed));
146
+ };
147
+ // The group holding the current page is always open — you are in it.
148
+ apply(collapsedGroups.has(id) && !group.hasAttribute("data-lab-holds-current"));
149
+ button?.addEventListener("click", () => {
150
+ // In the rail the header is an icon whose items fly out on hover; a click goes to the first.
151
+ if (l1Rail()) {
152
+ const first = group.querySelector<HTMLAnchorElement>(".lab-nav-group__items a");
153
+ if (first) location.href = first.href;
154
+ return;
155
+ }
156
+ const collapsed = !group.hasAttribute("data-collapsed");
157
+ apply(collapsed);
158
+ if (collapsed) collapsedGroups.add(id);
159
+ else collapsedGroups.delete(id);
160
+ write(GROUPS_KEY, [...collapsedGroups].join(",") || null);
161
+ });
162
+ }
163
+
164
+ // ---- theme --------------------------------------------------------------------------------------
165
+ // The moon beside the logo: the LAB's light/dark, never the preview's. It is a toggle for dark, so
166
+ // it reports its state as pressed rather than swapping its icon.
167
+ for (const button of document.querySelectorAll<HTMLElement>("[data-lab-theme-toggle]")) {
168
+ const mark = () => button.setAttribute("aria-pressed", String(window.labTheme?.getTheme() === "dark"));
169
+ mark();
170
+ button.addEventListener("click", () => {
171
+ const next = window.labTheme?.getTheme() === "dark" ? "light" : "dark";
172
+ window.labTheme?.setTheme(next);
173
+ mark();
174
+ });
175
+ }
176
+
177
+ // ---- the tree -----------------------------------------------------------------------------------
178
+ const tree = document.querySelector<HTMLElement>("[data-lab-tree]");
179
+ const body = document.querySelector<HTMLElement>("[data-lab-l2-body]");
180
+ const CLOSED_KEY = "lab-tree-closed";
181
+ const closedIds = new Set<string>((read(CLOSED_KEY) || "").split("\n").filter(Boolean));
182
+ let searching = false;
183
+
184
+ const applyStoredOpen = () => {
185
+ for (const details of tree?.querySelectorAll<HTMLDetailsElement>("details[data-lab-tree-id]") ?? []) {
186
+ const shut = closedIds.has(details.dataset.labTreeId!) && !details.hasAttribute("data-lab-holds-current");
187
+ details.open = !shut;
188
+ }
189
+ };
190
+
191
+ if (tree) {
192
+ applyStoredOpen();
193
+ tree.addEventListener(
194
+ "toggle",
195
+ (event) => {
196
+ const details = event.target as HTMLDetailsElement;
197
+ const id = details.dataset?.labTreeId;
198
+ if (!id || searching) return; // a search opens groups; that is not the reader's choice
199
+ if (details.open) closedIds.delete(id);
200
+ else closedIds.add(id);
201
+ write(CLOSED_KEY, [...closedIds].join("\n") || null);
202
+ },
203
+ true,
204
+ );
205
+ }
206
+
207
+ if (body) {
208
+ const SCROLL_KEY = `lab-l2-scroll:${page}`;
209
+ const saved = Number(read(SCROLL_KEY, sessionStorage));
210
+ if (saved) body.scrollTop = saved;
211
+ body.addEventListener("scrollend", () => write(SCROLL_KEY, String(body.scrollTop), sessionStorage));
212
+ const current = tree?.querySelector<HTMLElement>('[aria-current="page"]');
213
+ current?.scrollIntoView({ block: "nearest" });
214
+ }
215
+
216
+ // ---- the Sections tree's sort -------------------------------------------------------------------
217
+ // Slot number or Name (A–Z), remembered per viewer. The inline script in Shell.astro already put
218
+ // the stored choice on <html> and CSS `order` drew it; this moves the rows themselves to match, so
219
+ // screen readers, Tab and j / k meet them in the order they are shown. Either way the unslotted
220
+ // group stays under its heading, below the slotted one — the ranks from ./trees.ts guarantee it.
221
+ const SORT_KEY = "lab-tree-sort";
222
+ const stree = document.querySelector<HTMLElement>("[data-lab-stree]");
223
+ if (stree) {
224
+ const sortItems = [...document.querySelectorAll<HTMLElement>("[data-lab-sort-by]")];
225
+ const rank = (el: HTMLElement, by: string) =>
226
+ Number(el.style.getPropertyValue(by === "name" ? "--lab-o-name" : "--lab-o-slot")) || 0;
227
+ const applySort = (by: string) => {
228
+ if (by === "name") root.dataset.labSort = "name";
229
+ else delete root.dataset.labSort;
230
+ const rows = [...stree.querySelectorAll<HTMLElement>(":scope > li")];
231
+ rows.sort((a, b) => rank(a, by) - rank(b, by));
232
+ stree.append(...rows);
233
+ for (const item of sortItems) item.setAttribute("aria-checked", String(item.dataset.labSortBy === by));
234
+ };
235
+ applySort(read(SORT_KEY) === "name" ? "name" : "slot");
236
+ for (const item of sortItems) {
237
+ item.addEventListener("click", () => {
238
+ const by = item.dataset.labSortBy === "name" ? "name" : "slot";
239
+ write(SORT_KEY, by === "name" ? "name" : null);
240
+ applySort(by);
241
+ item.closest<HTMLElement>("[popover]")?.hidePopover?.();
242
+ });
243
+ }
244
+ }
245
+
246
+ // ---- search and filter --------------------------------------------------------------------------
247
+ const search = document.querySelector<HTMLInputElement>("[data-lab-tree-search]");
248
+ const filterButton = document.querySelector<HTMLElement>("[data-lab-filter-btn]");
249
+ const FILTER_KEY = `lab-filter:${page}`;
250
+ let facet = read(FILTER_KEY, sessionStorage) || "";
251
+
252
+ const matches = (el: HTMLElement, terms: string[]) => {
253
+ const hay = el.dataset.labSearch || "";
254
+ if (!terms.every((term) => hay.includes(term))) return false;
255
+ if (!facet) return true;
256
+ return (el.dataset.labFacets || "").split(/\s+/).includes(facet);
257
+ };
258
+
259
+ /** Show a tree node when it, or anything beneath it, matches. Returns whether it is shown. */
260
+ const filterNode = (li: HTMLElement, terms: string[]): boolean => {
261
+ const children = [...li.querySelectorAll<HTMLElement>(":scope > details > ul > .lab-tree__node")];
262
+ let shown: boolean;
263
+ if (children.length) {
264
+ const any = children.map((child) => filterNode(child, terms)).some(Boolean);
265
+ // A group whose own name matches shows everything under it (unless a facet is narrowing).
266
+ shown = any || (!facet && terms.length > 0 && matches(li, terms));
267
+ if (shown && !any) for (const child of children) showAll(child);
268
+ const details = li.querySelector<HTMLDetailsElement>(":scope > details");
269
+ if (details && (terms.length || facet) && any) details.open = true;
270
+ } else {
271
+ shown = matches(li, terms);
272
+ }
273
+ li.toggleAttribute("data-lab-hidden", !shown);
274
+ return shown;
275
+ };
276
+ const showAll = (li: HTMLElement) => {
277
+ li.removeAttribute("data-lab-hidden");
278
+ for (const child of li.querySelectorAll<HTMLElement>(".lab-tree__node")) child.removeAttribute("data-lab-hidden");
279
+ };
280
+
281
+ const applyFilter = () => {
282
+ const text = (search?.value || "").trim().toLowerCase();
283
+ const terms = text ? text.split(/\s+/) : [];
284
+ searching = terms.length > 0 || Boolean(facet);
285
+ if (tree) {
286
+ if (!searching) applyStoredOpen();
287
+ for (const li of tree.querySelectorAll<HTMLElement>(":scope > .lab-tree__node")) filterNode(li, terms);
288
+ // The Sections tree's "Unslotted" heading goes when nothing under it is left to show.
289
+ for (const heading of tree.querySelectorAll<HTMLElement>(":scope > [data-lab-tree-heading]")) {
290
+ heading.hidden = !tree.querySelector(':scope > [data-lab-slot-group="unslotted"]:not([data-lab-hidden])');
291
+ }
292
+ let empty = tree.parentElement?.querySelector<HTMLElement>("[data-lab-tree-none]");
293
+ const anyShown = tree.querySelector(":scope > .lab-tree__node:not([data-lab-hidden])");
294
+ if (!anyShown && tree.children.length) {
295
+ if (!empty) {
296
+ empty = document.createElement("p");
297
+ empty.className = "lab-tree__empty";
298
+ empty.dataset.labTreeNone = "";
299
+ empty.textContent = "Nothing matches.";
300
+ tree.after(empty);
301
+ }
302
+ } else empty?.remove();
303
+ }
304
+ // The content's cards follow the same search and filter, so one box narrows both.
305
+ for (const card of document.querySelectorAll<HTMLElement>(".lab-content [data-lab-search]")) {
306
+ card.toggleAttribute("data-lab-hidden", !matches(card, terms));
307
+ }
308
+ for (const section of document.querySelectorAll<HTMLElement>(".lab-content [data-lab-filter-section]")) {
309
+ const any = section.querySelector("[data-lab-search]:not([data-lab-hidden])");
310
+ section.hidden = !any;
311
+ }
312
+ filterButton?.setAttribute("aria-pressed", String(Boolean(facet)));
313
+ };
314
+
315
+ search?.addEventListener("input", applyFilter);
316
+ search?.addEventListener("keydown", (event) => {
317
+ if (event.key !== "Escape") return;
318
+ if (search.value) {
319
+ search.value = "";
320
+ applyFilter();
321
+ } else search.blur();
322
+ });
323
+
324
+ const filterItems = [...document.querySelectorAll<HTMLElement>("[data-lab-filter]")];
325
+ const markFilter = () => {
326
+ for (const item of filterItems) item.setAttribute("aria-checked", String(item.dataset.labFilter === facet));
327
+ };
328
+ if (filterItems.length && !filterItems.some((item) => item.dataset.labFilter === facet)) facet = "";
329
+ for (const item of filterItems) {
330
+ item.addEventListener("click", () => {
331
+ facet = item.dataset.labFilter || "";
332
+ write(FILTER_KEY, facet || null, sessionStorage);
333
+ markFilter();
334
+ applyFilter();
335
+ item.closest<HTMLElement>("[popover]")?.hidePopover?.();
336
+ });
337
+ }
338
+ markFilter();
339
+ if (facet) applyFilter();
340
+
341
+ // ---- keys ---------------------------------------------------------------------------------------
342
+ const stepLinks = () => {
343
+ // A page with its own previous/next (a story or a page view) steps through those.
344
+ const prev = document.querySelector<HTMLAnchorElement>('a[data-lab-step="prev"]');
345
+ const next = document.querySelector<HTMLAnchorElement>('a[data-lab-step="next"]');
346
+ if (prev || next || document.querySelector("[data-lab-step]")) return { prev, next };
347
+ // Otherwise the tree's visible leaves.
348
+ const leaves = [...(tree?.querySelectorAll<HTMLAnchorElement>("a[data-lab-leaf]") ?? [])].filter(
349
+ (a) => a.offsetParent !== null,
350
+ );
351
+ const at = leaves.findIndex((a) => a.getAttribute("aria-current") === "page");
352
+ return {
353
+ prev: at > 0 ? leaves[at - 1] : at === -1 ? leaves[leaves.length - 1] : null,
354
+ next: at === -1 ? leaves[0] : leaves[at + 1] ?? null,
355
+ };
356
+ };
357
+
358
+ document.addEventListener("keydown", (event) => {
359
+ const key = event.key.toLowerCase();
360
+ const mod = event.metaKey || event.ctrlKey;
361
+
362
+ // ⌘K stays live inside the lab's own search box, where it means "select what is there".
363
+ if (mod && key === "k") {
364
+ if (event.target !== search && ignoreKey(event)) return;
365
+ if (!search) return;
366
+ event.preventDefault();
367
+ if (l2Closed()) setL2(false);
368
+ search.focus();
369
+ search.select();
370
+ return;
371
+ }
372
+ if (ignoreKey(event)) return;
373
+ if (mod && key === "b") {
374
+ event.preventDefault();
375
+ if (event.shiftKey) toggleL1();
376
+ else toggleL2();
377
+ return;
378
+ }
379
+ if (mod && key === ".") {
380
+ event.preventDefault();
381
+ toggleFocus();
382
+ return;
383
+ }
384
+ if (mod || event.altKey || event.shiftKey || event.isComposing) return;
385
+ if (key !== "j" && key !== "k") return;
386
+ const { prev, next } = stepLinks();
387
+ const target = key === "j" ? next : prev;
388
+ if (!target) return;
389
+ event.preventDefault();
390
+ target.click();
391
+ });
392
+
393
+ // ---- popover menus ------------------------------------------------------------------------------
394
+ // Placed under the button that opened them. CSS anchor positioning would do this with no script,
395
+ // but it is not in every browser yet; this is twelve lines.
396
+ for (const menu of document.querySelectorAll<HTMLElement>(".lab-menu[popover]")) {
397
+ menu.addEventListener("toggle", (event) => {
398
+ if ((event as ToggleEvent).newState !== "open") return;
399
+ const invoker = document.querySelector<HTMLElement>(`[popovertarget="${menu.id}"]`);
400
+ if (!invoker) return;
401
+ const box = invoker.getBoundingClientRect();
402
+ const width = menu.offsetWidth;
403
+ const alignEnd = menu.dataset.labAlign === "end";
404
+ let left = alignEnd ? box.right - width : box.left;
405
+ left = Math.max(8, Math.min(left, window.innerWidth - width - 8));
406
+ let top = box.bottom + 4;
407
+ if (top + menu.offsetHeight > window.innerHeight - 8) top = Math.max(8, box.top - menu.offsetHeight - 4);
408
+ menu.style.left = `${left}px`;
409
+ menu.style.top = `${top}px`;
410
+ });
411
+ }
412
+
413
+ // ---- card thumbnails ----------------------------------------------------------------------------
414
+ // Each thumbnail is the real bare render in an <iframe> sized to a virtual viewport
415
+ // (data-frame-w × data-frame-h), scaled down to the card. One observer for all of them.
416
+ const thumbs = document.querySelectorAll<HTMLElement>("[data-frame-w]");
417
+ if (thumbs.length) {
418
+ const fit = (thumb: HTMLElement) => {
419
+ const frame = thumb.querySelector<HTMLElement>("iframe");
420
+ if (!frame) return;
421
+ const w = Number(thumb.dataset.frameW) || 1440;
422
+ const h = Number(thumb.dataset.frameH) || 900;
423
+ const scale = thumb.clientWidth / w;
424
+ frame.style.width = `${w}px`;
425
+ frame.style.height = `${h}px`;
426
+ frame.style.transform = `scale(${scale})`;
427
+ thumb.style.aspectRatio = `${w} / ${h}`;
428
+ };
429
+ const observer = new ResizeObserver((entries) => {
430
+ for (const entry of entries) fit(entry.target as HTMLElement);
431
+ });
432
+ for (const thumb of thumbs) {
433
+ fit(thumb);
434
+ observer.observe(thumb);
435
+ }
436
+ }
437
+
438
+ // ---- the dev toolbar inside frames --------------------------------------------------------------
439
+ // The dev server puts Astro's toolbar in every page it serves, framed ones included, so without
440
+ // this a lab page shows a second, scaled toolbar over whatever it frames. Decided 2026-09-24: the
441
+ // lab page's own toolbar is the only one — its pin picker already descends into same-origin
442
+ // frames. The bare story hides its own before first paint (../ui/components/preview-layout.astro);
443
+ // this covers the frames the lab does not render itself — the site's pages in the Pages view and
444
+ // its cards, and any page a framed link navigates to. `load` does not bubble, so it is caught on
445
+ // the way down, once per navigation of every frame, present or added later; a frame that finished
446
+ // loading before this module ran is swept once here.
447
+ const hideFramedToolbar = (frame: HTMLIFrameElement) => {
448
+ try {
449
+ const doc = frame.contentDocument;
450
+ if (!doc?.head || doc.getElementById("lab-hide-dev-toolbar")) return;
451
+ const style = doc.createElement("style");
452
+ style.id = "lab-hide-dev-toolbar";
453
+ style.textContent = "astro-dev-toolbar { display: none !important; }";
454
+ doc.head.append(style);
455
+ } catch {
456
+ /* cross-origin — not a lab frame */
457
+ }
458
+ };
459
+ document.addEventListener(
460
+ "load",
461
+ (event) => {
462
+ if (event.target instanceof HTMLIFrameElement) hideFramedToolbar(event.target);
463
+ },
464
+ true,
465
+ );
466
+ for (const frame of document.querySelectorAll("iframe")) hideFramedToolbar(frame);
467
+
468
+ export {};