@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,1102 @@
1
+ ---
2
+ // LabHead — the lab's CHROME head component, given to Astrobook as its `head` option by the
3
+ // integration (../index.mjs) and imported directly by the shell's own pages (./shell/Browse.astro,
4
+ // ./shell/Viewport.astro). Everything in it is generic: the unconditional noindex, the sidebar
5
+ // collapse, the live / used-by / responsive pills, the search widening, j/k, the cull switch and
6
+ // the Feedbucket tag.
7
+ //
8
+ // CHANGED 2026-09-22 — the lab is not styled in the styles of the website being built; it has a
9
+ // standard style across every site. This file used to mount the consumer's own
10
+ // head component here — <UserHead />, the site's fonts — on EVERY lab page, and it used to declare
11
+ // the lab's tokens in a cascade layer the consumer was expected to override. Both are gone. The
12
+ // consumer's head and stylesheets now reach the PREVIEW only: ./ui/components/dashboard-layout.astro
13
+ // for the story rendered beside the chrome, and ./ui/components/preview-layout.astro for the bare
14
+ // story route. The chrome's own look ships with the package, in the one stylesheet imported below.
15
+ import "./ui/lab.css";
16
+
17
+ import ThemeScript from "./ui/components/theme-script.astro";
18
+ import labConfig from "virtual:orbytes-lab/config.mjs";
19
+
20
+ // Feedbucket on lab pages too (decided 2026-09-05 — until then the lab was the one staging
21
+ // surface nothing seen on could enter the ticket loop from). The package takes a key or
22
+ // nothing: the consumer keeps its own staging gate and passes the key only where the widget
23
+ // belongs.
24
+ const feedbucketKey = labConfig.feedbucketKey;
25
+
26
+ // The lab index — every story with its module file, tier, section/version, LIVE slot (which page
27
+ // under src/pages/ mounts it, and where in that page), its responsive mark and the live sections
28
+ // that import it. Built once per process: this head renders on every lab page (130+ in a build),
29
+ // and the index globs every story module.
30
+ import { buildLabIndex, labBase, type LabIndex } from "./shell/lab-index";
31
+
32
+ const memo = globalThis as typeof globalThis & { __labIndex?: LabIndex };
33
+ const labIndex = (memo.__labIndex ??= buildLabIndex());
34
+
35
+ // Only what the sidebar script needs; `</` is escaped so the JSON can never close its own tag.
36
+ // `viewportBase` is where the viewport configurator lives (./shell/Viewport.astro, one page
37
+ // per story), so the sidebar's Viewport button never hard-codes the lab's base path.
38
+ // `editorUrl` is null in any build — the VS Code link is a local absolute path, dev only.
39
+ const labIndexJson = JSON.stringify({
40
+ viewportBase: `${labBase}/viewport`,
41
+ // Which tier the cull switch acts on and which carries the responsive marks — the consumer's
42
+ // `tiers` option, never a tier name written into the script below.
43
+ tiers: { cull: labConfig.cullTier, responsive: labConfig.sectionsTier },
44
+ items: labIndex.items.map(
45
+ ({
46
+ storyId,
47
+ moduleId,
48
+ moduleFile,
49
+ moduleName,
50
+ tier,
51
+ section,
52
+ version,
53
+ live,
54
+ livePage,
55
+ liveOn,
56
+ responsive,
57
+ stagingUrl,
58
+ editorUrl,
59
+ usedBy,
60
+ tags,
61
+ }) => ({
62
+ storyId,
63
+ moduleId,
64
+ moduleFile,
65
+ moduleName,
66
+ tier,
67
+ section,
68
+ version,
69
+ live,
70
+ livePage,
71
+ liveOn,
72
+ responsive,
73
+ stagingUrl,
74
+ editorUrl,
75
+ usedBy,
76
+ tags,
77
+ }),
78
+ ),
79
+ }).replace(/</g, "\\u003c");
80
+ ---
81
+
82
+ {/* The theme, applied before paint. It lives here rather than in the layout because every chrome
83
+ page mounts this head — the dashboard and the `/lab` home through Astrobook's layout, the
84
+ folder pages and the viewport configurator directly — and `.dark` on <html> is what the whole
85
+ chrome themes on (./ui/lab.css). Exactly one per document: the bare story page mounts no
86
+ LabHead and carries its own copy. */}
87
+ <ThemeScript />
88
+
89
+ {/* THE LAB IS STAGING-ONLY AND NEVER INDEXABLE (ruled 2026-09-02: the lab is live on staging and
90
+ staging only, and must never go live on the main domain).
91
+
92
+ Two independent mechanisms enforce that, deliberately — one gate is not enough for a rule this
93
+ absolute:
94
+
95
+ 1. BUILD GATE — the consumer's astro.config.mjs registers this integration for `astro dev`
96
+ or a staging build only, so a production build emits no lab routes at all. On orbytes.io
97
+ that is asserted by `npm run check:lab-gate`, which fails if /lab ever appears in a
98
+ production build. The gate is the consumer's to keep; the meta below is this package's.
99
+
100
+ 2. THIS META — unconditional, not gated on the environment. Lab pages are rendered by
101
+ Astrobook, NOT by the site's own layout, so they never receive the sitewide staging
102
+ noindex that layout emits. Without this line the lab was the one crawlable corner of
103
+ an otherwise noindex staging deploy (verified live on 2026-09-02: home page carried
104
+ `noindex`, /lab/ carried no robots meta, and robots.txt has no Disallow).
105
+
106
+ It is unconditional on purpose: if the build gate ever regresses, the meta is the thing that
107
+ still keeps the lab out of search. Do not make it depend on PUBLIC_DEPLOY_ENV. */}
108
+ <meta name="robots" content="noindex, nofollow" />
109
+
110
+ {
111
+ feedbucketKey && (
112
+ <script
113
+ is:inline
114
+ defer
115
+ src="https://cdn.feedbucket.app/assets/feedbucket.js"
116
+ data-feedbucket={feedbucketKey}
117
+ />
118
+ )
119
+ }
120
+
121
+ {/* THE KEYBOARD GUARD — one predicate, shared by every lab shortcut.
122
+
123
+ `window.__labIgnoreKey(event)` answers: is this keystroke someone else's? The lab owns ⌘B,
124
+ ⌘K and j/k on `document`, and the viewport route owns [ ] r f 0 (./shell/Viewport.astro).
125
+ None of them may fire while a character is being typed — into the lab's own search box, into
126
+ a form on the previewed story, or into a panel belonging to another tool.
127
+
128
+ WHY IT READS composedPath() AND NOT document.activeElement (ruled 2026-09-22: keyboard
129
+ shortcuts in the lab must be disabled while a pin note is being written). The pin composer's
130
+ textarea lives inside the Astro dev toolbar's shadow tree. A keystroke crossing a shadow
131
+ boundary is RETARGETED, so a `document` listener sees `event.target` — and `document.activeElement` —
132
+ as the shadow HOST, <astro-dev-toolbar>, which is neither an input nor contentEditable.
133
+ Every "am I typing?" test written against either of those reads false and the shortcut
134
+ fires. Measured: typing `j` into a pin comment ran stepStory(), which preventDefault'ed the
135
+ character, clicked a sidebar story link, navigated the lab and took focus off the composer —
136
+ after which every remaining character landed on <body> and was lost.
137
+
138
+ composedPath() is the one view that still holds the real target, because Astro's toolbar
139
+ shadow roots are open. A closed root would trim the path back to the host — which is why the
140
+ <astro-dev-toolbar> test below is a second, independent line of defence and not redundant:
141
+ it holds whatever the toolbar does with its shadow roots, and it covers the toolbar's
142
+ BUTTONS too, where nothing is being typed but the keystroke is still not the lab's.
143
+
144
+ Defined before every handler that uses it, and idempotent — LabHead renders once per page,
145
+ but a view transition can re-run an inline script. */}
146
+ <script is:inline>
147
+ window.__labIgnoreKey ||= (event) => {
148
+ const path =
149
+ typeof event.composedPath === "function" ? event.composedPath() : [event.target];
150
+ for (const node of path) {
151
+ if (!node || node.nodeType !== 1) continue;
152
+ const tag = node.tagName;
153
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
154
+ if (node.isContentEditable) return true;
155
+ if (tag === "ASTRO-DEV-TOOLBAR") return true;
156
+ }
157
+ return false;
158
+ };
159
+ </script>
160
+
161
+ {/* Sidebar collapse for the Astrobook dashboard. Astrobook 0.13.3 has no built-in
162
+ collapse (its "fullscreen" button navigates away to the bare story), so this
163
+ injects one the same way Astrobook persists its sidebar width: a data attribute
164
+ on <html>, localStorage, and a re-apply on every view-transition swap. Bare
165
+ story pages render no #astrobook-sidebar, so everything below no-ops there. */}
166
+ <script is:inline>
167
+ (() => {
168
+ if (window.__labSidebarCollapseInit) return;
169
+ window.__labSidebarCollapseInit = true;
170
+
171
+ const KEY = "lab-sidebar-collapsed";
172
+
173
+ const applyState = () => {
174
+ if (localStorage.getItem(KEY) === "1") {
175
+ document.documentElement.dataset.labSidebarCollapsed = "";
176
+ } else {
177
+ delete document.documentElement.dataset.labSidebarCollapsed;
178
+ }
179
+ };
180
+
181
+ const toggle = () => {
182
+ if (localStorage.getItem(KEY) === "1") localStorage.removeItem(KEY);
183
+ else localStorage.setItem(KEY, "1");
184
+ applyState();
185
+ };
186
+
187
+ const CHEVRONS_LEFT =
188
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m11 17-5-5 5-5"/><path d="m18 17-5-5 5-5"/></svg>';
189
+ const CHEVRONS_RIGHT =
190
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 17 5-5-5-5"/><path d="m13 17 5-5-5-5"/></svg>';
191
+
192
+ const makeButton = (id, title, svg) => {
193
+ const button = document.createElement("button");
194
+ button.id = id;
195
+ button.className = "astrobook-sidebar-button";
196
+ button.title = title;
197
+ button.setAttribute("aria-label", title);
198
+ button.innerHTML = svg;
199
+ button.addEventListener("click", toggle);
200
+ return button;
201
+ };
202
+
203
+ const setup = () => {
204
+ applyState();
205
+ const sidebar = document.getElementById("astrobook-sidebar");
206
+ if (!sidebar) return; // bare story page — no dashboard chrome
207
+
208
+ const header = sidebar.querySelector("nav > div");
209
+ if (header && !document.getElementById("lab-sidebar-collapse")) {
210
+ header.appendChild(
211
+ makeButton(
212
+ "lab-sidebar-collapse",
213
+ "Collapse sidebar (⌘B)",
214
+ CHEVRONS_LEFT,
215
+ ),
216
+ );
217
+ }
218
+ if (!document.getElementById("lab-sidebar-expand")) {
219
+ document.body.appendChild(
220
+ makeButton(
221
+ "lab-sidebar-expand",
222
+ "Show sidebar (⌘B)",
223
+ CHEVRONS_RIGHT,
224
+ ),
225
+ );
226
+ }
227
+ };
228
+
229
+ applyState(); // pre-paint, so a collapsed sidebar never flashes visible
230
+ document.addEventListener("astro:after-swap", setup);
231
+ document.addEventListener("keydown", (event) => {
232
+ if (window.__labIgnoreKey(event)) return; // › THE KEYBOARD GUARD, above
233
+ if (
234
+ event.key.toLowerCase() === "b" &&
235
+ (event.metaKey || event.ctrlKey) &&
236
+ document.getElementById("astrobook-sidebar")
237
+ ) {
238
+ event.preventDefault();
239
+ toggle();
240
+ }
241
+ });
242
+ if (document.readyState === "loading") {
243
+ document.addEventListener("DOMContentLoaded", setup, { once: true });
244
+ } else {
245
+ setup();
246
+ }
247
+ })();
248
+ </script>
249
+
250
+ <style is:global>
251
+ :root[data-lab-sidebar-collapsed] #astrobook-sidebar,
252
+ :root[data-lab-sidebar-collapsed] astrobook-sidebar-resize-handle {
253
+ display: none;
254
+ }
255
+
256
+ #lab-sidebar-collapse svg,
257
+ #lab-sidebar-expand svg {
258
+ display: block;
259
+ }
260
+
261
+ /* Floating re-open control; only exists while the sidebar is collapsed. Sits
262
+ over whatever the story canvas renders, so it carries its own ground. */
263
+ #lab-sidebar-expand {
264
+ display: none;
265
+ position: fixed;
266
+ top: 0.85rem;
267
+ left: 0.5rem;
268
+ z-index: 50;
269
+ background-color: var(--lab-color-bg-sunken);
270
+ border: 1px solid var(--lab-color-border);
271
+ }
272
+ :root[data-lab-sidebar-collapsed] #lab-sidebar-expand {
273
+ display: block;
274
+ }
275
+ </style>
276
+
277
+ {/* The lab index, for the sidebar script below. Emitted on every lab page (the bare story pages
278
+ included — they have no sidebar, so nothing reads it there). */}
279
+ <script is:inline type="application/json" id="lab-index" set:html={labIndexJson} />
280
+
281
+ {/* Lab shell — live pills, responsive marks, ⌘K search, j/k navigation, the cull switch, and the
282
+ Viewport / staging / VS Code buttons. Same shape as the collapse script above:
283
+ inline, guarded on #astrobook-sidebar (bare story pages and any other page that imports this
284
+ head have no sidebar, so every step below no-ops there), re-run on astro:after-swap and
285
+ astro:page-load, idempotent — a row is decorated once and marked, so a pill is never added
286
+ twice.
287
+
288
+ Astrobook 0.13.3 DOM this couples to (verified in @astrobook/ui/dist/components — the hashed
289
+ UnoCSS class names are NOT stable, these are):
290
+ #astrobook-sidebar > nav > div the header row (the collapse button lands there too)
291
+ <html data-active-story="<storyId>"> the story the dashboard shows, "" on the home page;
292
+ replaced on every swap. The Viewport button links to
293
+ /lab/viewport/<storyId> from it.
294
+ astrobook-sidebar-tree#astrobook-sidebar-tree the tree; transition:persist'ed, so it — and
295
+ everything this script hangs on it — survives a
296
+ view transition. The header does not, so the
297
+ cull button is re-added on every swap.
298
+ details[data-astrobook-collapsible][data-id="dir:<Name>"|"module:<moduleId>"][data-search-text]
299
+ > summary.astrobook-sidebar-link folder and module rows. dir ids are BARE folder
300
+ names (dir:V2 occurs in every section), so a
301
+ folder's path is the chain of ancestor dir ids.
302
+ a[data-astrobook-story-link][data-story-id][data-search-text].astrobook-sidebar-link
303
+ story leaves. A module with a single story named
304
+ like itself is hoisted: its link sits directly
305
+ under the folder with no module: ancestor.
306
+ #astrobook-search-toggle / #astrobook-search-panel[data-open] / #astrobook-search-input
307
+ the search. Filtering is CSS Astrobook injects:
308
+ `[data-search-text]:not([data-search-text*=term])
309
+ { display:none }` per lower-cased query term — so
310
+ widening a match means appending lower-cased terms
311
+ to data-search-text on the row AND on every
312
+ details ancestor (a hidden ancestor hides the
313
+ row). Elements without data-search-text (the
314
+ pills) are never touched by that CSS.
315
+
316
+ Mark APIs (dev-only middleware, absent on staging — the switch and the boxes hide themselves
317
+ when the probe fails):
318
+ GET /__lab/cull → { marked: string[] (module files), updated }; PUT { marked } → 200
319
+ { ok, marked } | 400 { error, offenders }.
320
+ GET /__lab/responsive → { done: string[], approved: string[], updated }; PUT
321
+ { done, approved } → 200 { ok, done, approved } | 400 { error, offenders }. Section
322
+ versions only — components and explorations never show the boxes. */}
323
+ <script is:inline>
324
+ (() => {
325
+ if (window.__labShellInit) return;
326
+ window.__labShellInit = true;
327
+
328
+ // The tier roles, read straight off the index blob above (it is emitted before this script,
329
+ // so it is already in the DOM). Either may be null on a site whose `tiers` option declares no
330
+ // such tier, and then the matching controls simply never find a row to hang on.
331
+ const LAB_TIERS = (() => {
332
+ try {
333
+ const blob = document.getElementById("lab-index");
334
+ return (JSON.parse((blob && blob.textContent) || "{}").tiers) || {};
335
+ } catch {
336
+ return {};
337
+ }
338
+ })();
339
+
340
+ const CULL_KEY = "lab-cull-on"; // sessionStorage — the switch's on/off state
341
+ const CULL_ENDPOINT = "/__lab/cull";
342
+ const RESPONSIVE_ENDPOINT = "/__lab/responsive";
343
+ const TREE_ID = "astrobook-sidebar-tree";
344
+
345
+ // ---- index --------------------------------------------------------------------------------
346
+ let index = null;
347
+ const readIndex = () => {
348
+ if (index) return index;
349
+ const blob = document.getElementById("lab-index");
350
+ if (!blob) return null;
351
+ let items;
352
+ let viewportBase;
353
+ try {
354
+ const parsed = JSON.parse(blob.textContent || "{}");
355
+ items = parsed.items || [];
356
+ viewportBase = typeof parsed.viewportBase === "string" ? parsed.viewportBase : "/lab/viewport";
357
+ } catch {
358
+ return null;
359
+ }
360
+ const byStory = new Map();
361
+ const byModule = new Map();
362
+ for (const item of items) {
363
+ byStory.set(item.storyId, item);
364
+ if (!byModule.has(item.moduleId)) byModule.set(item.moduleId, []);
365
+ byModule.get(item.moduleId).push(item);
366
+ }
367
+ index = { items, byStory, byModule, viewportBase };
368
+ return index;
369
+ };
370
+
371
+ const directoryOf = (item) => {
372
+ const cut = item.moduleId.lastIndexOf("/");
373
+ return cut < 0 ? "" : item.moduleId.slice(0, cut);
374
+ };
375
+ const liveUnder = (path) =>
376
+ index.items.filter((item) => {
377
+ if (!item.live) return false;
378
+ const directory = directoryOf(item);
379
+ return directory === path || directory.startsWith(path + "/");
380
+ }).length;
381
+
382
+ // ---- DOM ----------------------------------------------------------------------------------
383
+ const tree = () => document.getElementById(TREE_ID);
384
+ const folderPath = (details) => {
385
+ const names = [];
386
+ for (let el = details; el && el.id !== TREE_ID; el = el.parentElement) {
387
+ if (el.tagName === "DETAILS" && (el.dataset.id || "").startsWith("dir:")) {
388
+ names.unshift(el.dataset.id.slice(4));
389
+ }
390
+ }
391
+ return names.join("/");
392
+ };
393
+ const moduleIdOf = (details) => details.dataset.id.slice("module:".length);
394
+ const isHoisted = (link) => !link.closest('details[data-id^="module:"]');
395
+ const summaryOf = (details) => details.querySelector(":scope > summary");
396
+ // The sidebar row a control sits in — `a.astrobook-sidebar-link` for a story leaf,
397
+ // `summary.astrobook-sidebar-link` for a module row. Both carry the class; the mark boxes live
398
+ // inside the row's pill box, so they cannot use parentElement.
399
+ const rowOf = (el) => el.closest(".astrobook-sidebar-link") || el.parentElement;
400
+
401
+ const pill = (text, extraClass, title) => {
402
+ const span = document.createElement("span");
403
+ span.className = extraClass ? `lab-pill ${extraClass}` : "lab-pill";
404
+ span.textContent = text;
405
+ if (title) span.title = title;
406
+ return span;
407
+ };
408
+ const pillsOf = (row) => {
409
+ let box = row.querySelector(":scope > .lab-pills");
410
+ if (!box) {
411
+ box = document.createElement("span");
412
+ box.className = "lab-pills";
413
+ row.appendChild(box);
414
+ }
415
+ return box;
416
+ };
417
+ const usedByPill = (usedBy) =>
418
+ pill(`used by ${usedBy.length}`, "", `used by\n${usedBy.join("\n")}`);
419
+ const liveCountPill = (count) => pill(String(count), "lab-pill--live lab-pill--count", `${count} live`);
420
+
421
+ // The live pill's text. A single-page site reads exactly as it always did — `live · slot 3 of
422
+ // 12` — and only a page that is not the home page names itself: `live · /about, slot 3`. The
423
+ // same three lines are livePillText() in shell/lab-index.ts, for the cards; keep them in step.
424
+ const livePillText = (item) =>
425
+ item.livePage && item.livePage !== "/"
426
+ ? `live · ${item.livePage}, slot ${item.live.slot}`
427
+ : `live · slot ${item.live.slot} of ${item.live.of}`;
428
+ const liveTitle = (item) =>
429
+ (item.liveOn || []).length > 1
430
+ ? item.liveOn.map((mount) => `${mount.page} · slot ${mount.slot} of ${mount.of}`).join("\n")
431
+ : null;
432
+
433
+ // ---- search widening -----------------------------------------------------------------------
434
+ const extraTerms = (item) => {
435
+ const terms = [item.tier, item.section, item.version, item.moduleName, ...(item.tags || [])];
436
+ if (item.live) terms.push("live", item.livePage);
437
+ // "unresponsive", not "not responsive": Astrobook's filter is a substring match per term, so
438
+ // a row that said "not responsive" would still be a hit for "responsive".
439
+ if (item.responsive) terms.push(item.responsive.done ? "responsive" : "unresponsive");
440
+ return terms.filter(Boolean).map((term) => String(term).toLowerCase());
441
+ };
442
+ const appendTerms = (el, terms) => {
443
+ let text = el.dataset.searchText || "";
444
+ for (const term of terms) if (!text.includes(term)) text += ` ${term}`;
445
+ el.dataset.searchText = text;
446
+ };
447
+ const widenSearch = (link, terms) => {
448
+ appendTerms(link, terms);
449
+ for (let el = link.parentElement; el && el.id !== TREE_ID; el = el.parentElement) {
450
+ if (el.tagName === "DETAILS" && el.hasAttribute("data-search-text")) appendTerms(el, terms);
451
+ }
452
+ };
453
+
454
+ // ---- pills ----------------------------------------------------------------------------------
455
+ const decorate = () => {
456
+ const root = tree();
457
+ if (!root || !readIndex()) return;
458
+
459
+ for (const link of root.querySelectorAll(
460
+ "a[data-astrobook-story-link][data-story-id]:not([data-lab-decorated])",
461
+ )) {
462
+ link.dataset.labDecorated = "";
463
+ const item = index.byStory.get(link.dataset.storyId);
464
+ if (!item) continue;
465
+ const box = pillsOf(link);
466
+ if (item.live) {
467
+ box.appendChild(pill(livePillText(item), "lab-pill--live", liveTitle(item)));
468
+ }
469
+ if (isHoisted(link) && item.usedBy.length) box.appendChild(usedByPill(item.usedBy));
470
+ widenSearch(link, extraTerms(item));
471
+ }
472
+
473
+ for (const details of root.querySelectorAll('details[data-id^="module:"]:not([data-lab-decorated])')) {
474
+ details.dataset.labDecorated = "";
475
+ const summary = summaryOf(details);
476
+ const items = index.byModule.get(moduleIdOf(details)) || [];
477
+ if (!summary || !items.length) continue;
478
+ const box = pillsOf(summary);
479
+ const liveCount = items.filter((item) => item.live).length;
480
+ if (liveCount) box.appendChild(liveCountPill(liveCount));
481
+ if (items[0].usedBy.length) box.appendChild(usedByPill(items[0].usedBy));
482
+ }
483
+
484
+ for (const details of root.querySelectorAll('details[data-id^="dir:"]:not([data-lab-decorated])')) {
485
+ details.dataset.labDecorated = "";
486
+ const summary = summaryOf(details);
487
+ if (!summary) continue;
488
+ const count = liveUnder(folderPath(details));
489
+ if (count) pillsOf(summary).appendChild(liveCountPill(count));
490
+ }
491
+ };
492
+
493
+ // ---- ⌘K search -------------------------------------------------------------------------------
494
+ document.addEventListener("keydown", (event) => {
495
+ if (!document.getElementById("astrobook-sidebar")) return;
496
+ const input = document.getElementById("astrobook-search-input");
497
+ // ⌘K stays live inside the lab's OWN search box, where it means "select what is there and
498
+ // retype it". Everywhere else that a character is being typed, the keystroke is not ours.
499
+ // › THE KEYBOARD GUARD at the top of this file.
500
+ const elsewhere = event.target !== input && window.__labIgnoreKey(event);
501
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k" && !elsewhere) {
502
+ event.preventDefault();
503
+ const panel = document.getElementById("astrobook-search-panel");
504
+ const toggle = document.getElementById("astrobook-search-toggle");
505
+ if (panel && toggle && !panel.hasAttribute("data-open")) {
506
+ toggle.click(); // Astrobook opens the panel and focuses the input itself
507
+ } else if (input) {
508
+ input.focus();
509
+ input.select();
510
+ }
511
+ } else if (event.key === "Escape" && input && document.activeElement === input) {
512
+ input.blur();
513
+ }
514
+ });
515
+
516
+ // ---- cull switch -----------------------------------------------------------------------------
517
+ let cull = { available: false, marked: new Set() };
518
+ let probe = null;
519
+ const probeCull = () => {
520
+ probe ??= fetch(CULL_ENDPOINT, { headers: { accept: "application/json" } })
521
+ .then(async (response) => {
522
+ if (!response.ok) throw new Error(String(response.status));
523
+ const body = await response.json();
524
+ cull = { available: true, marked: new Set(Array.isArray(body.marked) ? body.marked : []) };
525
+ })
526
+ .catch(() => {
527
+ cull = { available: false, marked: new Set() };
528
+ })
529
+ .then(() => {
530
+ setupCullButton();
531
+ renderCull();
532
+ });
533
+ return probe;
534
+ };
535
+
536
+ const cullOn = () => cull.available && sessionStorage.getItem(CULL_KEY) === "1";
537
+ const applyCullState = () => {
538
+ if (cullOn()) document.documentElement.dataset.labCull = "";
539
+ else delete document.documentElement.dataset.labCull;
540
+ const button = document.getElementById("lab-cull-toggle");
541
+ if (button) {
542
+ button.setAttribute("aria-pressed", String(cullOn()));
543
+ button.toggleAttribute("data-active", cullOn());
544
+ }
545
+ };
546
+ const toggleCull = () => {
547
+ if (sessionStorage.getItem(CULL_KEY) === "1") sessionStorage.removeItem(CULL_KEY);
548
+ else sessionStorage.setItem(CULL_KEY, "1");
549
+ applyCullState();
550
+ };
551
+
552
+ const TRASH =
553
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="m19 6-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>';
554
+ const setupCullButton = () => {
555
+ const sidebar = document.getElementById("astrobook-sidebar");
556
+ if (!sidebar || !cull.available) return;
557
+ const header = sidebar.querySelector("nav > div");
558
+ if (!header || document.getElementById("lab-cull-toggle")) return;
559
+ const button = document.createElement("button");
560
+ button.id = "lab-cull-toggle";
561
+ button.type = "button";
562
+ button.className = "astrobook-sidebar-button";
563
+ button.title = "Mark for deletion";
564
+ button.setAttribute("aria-label", "Mark for deletion");
565
+ button.setAttribute("aria-pressed", "false");
566
+ button.innerHTML = TRASH;
567
+ button.addEventListener("click", toggleCull);
568
+ const collapse = document.getElementById("lab-sidebar-collapse");
569
+ if (collapse && collapse.parentElement === header) header.insertBefore(button, collapse);
570
+ else header.appendChild(button);
571
+ applyCullState();
572
+ };
573
+
574
+ // Rows that can be marked: module rows and hoisted single-story leaves (module = the story's
575
+ // own moduleFile, from the index) — in the EXPLORATIONS tier only (ruled 2026-09-05: deletion
576
+ // is restricted to the explorations tier; section versions are the version history, components
577
+ // are the shared chrome, neither is ever culled). An exploration a live section imports keeps a
578
+ // disabled box: the cull script would refuse it anyway, so the UI says so up front.
579
+ const CULL_TIER = LAB_TIERS.cull;
580
+ const blockReason = (items) => {
581
+ if (items.some((item) => item.live)) return "live on the site — cannot be marked";
582
+ const usedBy = items[0].usedBy || [];
583
+ if (usedBy.length) return `used by ${usedBy.length} live section${usedBy.length === 1 ? "" : "s"} — cannot be marked`;
584
+ return null;
585
+ };
586
+ // Every row a mark can hang on, in one tier: module rows and hoisted single-story leaves.
587
+ // Shared by the cull boxes and the responsive boxes, so the two never disagree about what a
588
+ // markable row is — the same reason their path rules live together in shell/marks.mjs.
589
+ const tierRows = (tier) => {
590
+ const root = tree();
591
+ const rows = [];
592
+ if (!root) return rows;
593
+ for (const details of root.querySelectorAll('details[data-id^="module:"]')) {
594
+ const summary = summaryOf(details);
595
+ const items = index.byModule.get(moduleIdOf(details)) || [];
596
+ if (summary && items[0] && items[0].tier === tier) rows.push({ row: summary, items });
597
+ }
598
+ for (const link of root.querySelectorAll("a[data-astrobook-story-link][data-story-id]")) {
599
+ if (!isHoisted(link)) continue;
600
+ const item = index.byStory.get(link.dataset.storyId);
601
+ if (item && item.tier === tier) rows.push({ row: link, items: [item] });
602
+ }
603
+ return rows;
604
+ };
605
+
606
+ const cullRows = () =>
607
+ tierRows(CULL_TIER).map(({ row, items }) => ({
608
+ row,
609
+ item: items[0],
610
+ blocked: blockReason(items),
611
+ }));
612
+
613
+ const renderCull = () => {
614
+ if (!tree() || !readIndex()) return;
615
+ applyCullState();
616
+ for (const { row, item, blocked } of cullRows()) {
617
+ const marked = cull.marked.has(item.moduleFile);
618
+ row.classList.toggle("lab-row--marked", marked);
619
+ const box = pillsOf(row);
620
+ const markedPill = box.querySelector(".lab-pill--marked");
621
+ if (marked && !markedPill) box.appendChild(pill("marked", "lab-pill--marked", "marked for deletion"));
622
+ if (!marked && markedPill) markedPill.remove();
623
+
624
+ // The box lives in the pill box, not the row: the row wraps as one group, so a narrow
625
+ // sidebar puts pills AND boxes on a second line together instead of on three lines.
626
+ let check = box.querySelector(":scope > .lab-cull-box");
627
+ if (!cull.available) {
628
+ if (check) check.remove();
629
+ continue;
630
+ }
631
+ if (!check) {
632
+ check = document.createElement("input");
633
+ check.type = "checkbox";
634
+ check.className = "lab-cull-box";
635
+ check.addEventListener("click", onCullClick);
636
+ box.appendChild(check);
637
+ }
638
+ check.checked = marked;
639
+ check.disabled = Boolean(blocked);
640
+ check.dataset.moduleFile = item.moduleFile;
641
+ check.title = blocked ? blocked : marked ? "Unmark for deletion" : "Mark for deletion";
642
+ check.setAttribute("aria-label", `Mark ${item.moduleName} for deletion`);
643
+ }
644
+ };
645
+
646
+ const showRowError = (row, message, offenders) => {
647
+ const previous = row.nextElementSibling;
648
+ if (previous && previous.classList.contains("lab-row-error")) previous.remove();
649
+ const note = document.createElement("div");
650
+ note.className = "lab-row-error";
651
+ note.setAttribute("role", "alert");
652
+ const names = Array.isArray(offenders) ? offenders.map((path) => String(path).split("/").pop()) : [];
653
+ note.textContent = names.length ? `${message}: ${names.join(", ")}` : message;
654
+ row.insertAdjacentElement("afterend", note);
655
+ setTimeout(() => note.remove(), 4000);
656
+ };
657
+
658
+ const onCullClick = async (event) => {
659
+ // The box shows the server's state, never the click's — and preventDefault keeps the host
660
+ // <a> from navigating and the <summary> from toggling.
661
+ event.preventDefault();
662
+ event.stopPropagation();
663
+ const check = event.currentTarget;
664
+ if (check.disabled) return;
665
+ const row = rowOf(check);
666
+ const next = new Set(cull.marked);
667
+ if (next.has(check.dataset.moduleFile)) next.delete(check.dataset.moduleFile);
668
+ else next.add(check.dataset.moduleFile);
669
+ try {
670
+ const response = await fetch(CULL_ENDPOINT, {
671
+ method: "PUT",
672
+ headers: { "content-type": "application/json", accept: "application/json" },
673
+ body: JSON.stringify({ marked: [...next] }),
674
+ });
675
+ const body = await response.json().catch(() => ({}));
676
+ if (response.ok) {
677
+ cull.marked = new Set(Array.isArray(body.marked) ? body.marked : [...next]);
678
+ } else {
679
+ showRowError(row, body.error || `HTTP ${response.status}`, body.offenders);
680
+ }
681
+ } catch (error) {
682
+ showRowError(row, (error && error.message) || "request failed");
683
+ }
684
+ renderCull();
685
+ };
686
+
687
+ // ---- responsive marks ------------------------------------------------------------------------
688
+ // SECTION VERSIONS ONLY (decided 2026-09-06: a checkbox, not a pill, for whatever section has
689
+ // been made responsive). Components and explorations never get one.
690
+ //
691
+ // Two boxes, because responsive work is approval-gated (same day): `approved` says the version
692
+ // has been approved for the work — which is NOT the same as the version being final, and the
693
+ // approval can be withdrawn later — and `done` says the work is finished. Both are hand-set,
694
+ // because neither is readable from the code; the pill shows `done`.
695
+ //
696
+ // No switch guards them, unlike the cull boxes: ticking one is routine, marking a file for
697
+ // deletion is not. The pill comes from the build-time index until the API answers, so on
698
+ // staging (no dev server, no API) the marks still show, just without the boxes.
699
+ const RESPONSIVE_TIER = LAB_TIERS.responsive;
700
+ let responsive = { available: false, done: new Set(), approved: new Set() };
701
+ let responsiveProbe = null;
702
+ const probeResponsive = () => {
703
+ responsiveProbe ??= fetch(RESPONSIVE_ENDPOINT, { headers: { accept: "application/json" } })
704
+ .then(async (response) => {
705
+ if (!response.ok) throw new Error(String(response.status));
706
+ const body = await response.json();
707
+ responsive = {
708
+ available: true,
709
+ done: new Set(Array.isArray(body.done) ? body.done : []),
710
+ approved: new Set(Array.isArray(body.approved) ? body.approved : []),
711
+ };
712
+ })
713
+ .catch(() => {
714
+ responsive = { available: false, done: new Set(), approved: new Set() };
715
+ })
716
+ .then(() => renderResponsive());
717
+ return responsiveProbe;
718
+ };
719
+
720
+ // In the row's pill box, beside the pills, for the wrapping reason given on the cull box.
721
+ const responsiveBox = (pills, kind, file, checked, title) => {
722
+ let box = pills.querySelector(`:scope > .lab-resp-box[data-kind="${kind}"]`);
723
+ if (!box) {
724
+ box = document.createElement("input");
725
+ box.type = "checkbox";
726
+ box.className = `lab-resp-box lab-resp-box--${kind}`;
727
+ box.dataset.kind = kind;
728
+ box.addEventListener("click", onResponsiveClick);
729
+ pills.appendChild(box);
730
+ }
731
+ box.checked = checked;
732
+ box.dataset.moduleFile = file;
733
+ box.title = title;
734
+ box.setAttribute("aria-label", title);
735
+ };
736
+
737
+ const renderResponsive = () => {
738
+ if (!tree() || !readIndex()) return;
739
+ for (const { row, items } of tierRows(RESPONSIVE_TIER)) {
740
+ const item = items[0];
741
+ const file = item.moduleFile;
742
+ const mark = item.responsive || { done: false, approved: false };
743
+ const done = responsive.available ? responsive.done.has(file) : mark.done;
744
+ const approved = responsive.available ? responsive.approved.has(file) : mark.approved;
745
+
746
+ const box = pillsOf(row);
747
+ // "not responsive" is only news on a version that is actually meant to become responsive
748
+ // — the one a page mounts. V1s are never made responsive (ruled 2026-09-06), and an
749
+ // unmounted V2 is not owed the work yet, so neither carries the nag; both still show
750
+ // "responsive" once ticked, and both keep their boxes so a mark is always possible.
751
+ const owed = items.some((i) => i.live);
752
+ let badge = box.querySelector(".lab-pill--resp");
753
+ if (!done && !owed) {
754
+ if (badge) badge.remove();
755
+ } else {
756
+ if (!badge) {
757
+ badge = pill("", "lab-pill--resp");
758
+ box.appendChild(badge);
759
+ }
760
+ // The row's search text is written once, at decorate() — a mark ticked in this session
761
+ // shows up in the search box after a reload, not before.
762
+ badge.textContent = done ? "responsive" : "not responsive";
763
+ badge.classList.toggle("lab-pill--resp-done", done);
764
+ badge.title = approved
765
+ ? "approved for responsive work"
766
+ : "not approved for responsive work yet";
767
+ }
768
+
769
+ if (!responsive.available) {
770
+ for (const stale of box.querySelectorAll(":scope > .lab-resp-box")) stale.remove();
771
+ continue;
772
+ }
773
+ responsiveBox(
774
+ box,
775
+ "approved",
776
+ file,
777
+ approved,
778
+ approved ? "Approved for responsive work" : "Approve for responsive work",
779
+ );
780
+ responsiveBox(
781
+ box,
782
+ "done",
783
+ file,
784
+ done,
785
+ done ? "Made responsive — untick to clear" : "Mark as made responsive",
786
+ );
787
+ }
788
+ };
789
+
790
+ const onResponsiveClick = async (event) => {
791
+ // Same contract as the cull box: the box shows the server's state, never the click's, and
792
+ // preventDefault keeps the host <a> from navigating and the <summary> from toggling.
793
+ event.preventDefault();
794
+ event.stopPropagation();
795
+ const box = event.currentTarget;
796
+ const row = rowOf(box);
797
+ const file = box.dataset.moduleFile;
798
+ const done = new Set(responsive.done);
799
+ const approved = new Set(responsive.approved);
800
+ const set = box.dataset.kind === "done" ? done : approved;
801
+ if (set.has(file)) set.delete(file);
802
+ else set.add(file);
803
+ try {
804
+ const response = await fetch(RESPONSIVE_ENDPOINT, {
805
+ method: "PUT",
806
+ headers: { "content-type": "application/json", accept: "application/json" },
807
+ body: JSON.stringify({ done: [...done], approved: [...approved] }),
808
+ });
809
+ const body = await response.json().catch(() => ({}));
810
+ if (response.ok) {
811
+ responsive = {
812
+ available: true,
813
+ done: new Set(Array.isArray(body.done) ? body.done : [...done]),
814
+ approved: new Set(Array.isArray(body.approved) ? body.approved : [...approved]),
815
+ };
816
+ } else {
817
+ showRowError(row, body.error || `HTTP ${response.status}`, body.offenders);
818
+ }
819
+ } catch (error) {
820
+ showRowError(row, (error && error.message) || "request failed");
821
+ }
822
+ renderResponsive();
823
+ };
824
+
825
+ // ---- header links for the active story ---------------------------------------------------------
826
+ // Three, rebuilt on every swap because the header and <html data-active-story> are both
827
+ // replaced: the viewport configurator (src/lab/shell/Viewport.astro — drag the frame's width
828
+ // and height, zoom, presets, the viewport in the URL), the same story on the STAGING build (the
829
+ // real minified output, which the dev server is not — the blur incident), and the component in
830
+ // VS Code. No active story (the lab home) → no buttons. The viewport link carries
831
+ // `data-astro-reload` because the configurator is outside Astrobook's ClientRouter: a full
832
+ // load, never a view transition. The VS Code link exists in dev only — `editorUrl` is null in
833
+ // any build, so on staging it simply never appears.
834
+ const VIEWPORT_ICON =
835
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="12" height="20" x="6" y="2" rx="1"/><rect width="20" height="12" x="2" y="6" rx="1"/></svg>';
836
+ const STAGING_ICON =
837
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/></svg>';
838
+ const CODE_ICON =
839
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg>';
840
+
841
+ const headerOf = () => {
842
+ const sidebar = document.getElementById("astrobook-sidebar");
843
+ return sidebar ? sidebar.querySelector("nav > div") : null;
844
+ };
845
+ const headerLink = (id, title, icon, href, options) => {
846
+ const header = headerOf();
847
+ if (!header) return;
848
+ let link = document.getElementById(id);
849
+ if (!href) {
850
+ if (link) link.remove();
851
+ return;
852
+ }
853
+ if (!link) {
854
+ link = document.createElement("a");
855
+ link.id = id;
856
+ link.className = "astrobook-sidebar-button";
857
+ link.innerHTML = icon;
858
+ if (options && options.reload) link.setAttribute("data-astro-reload", "");
859
+ if (options && options.blank) {
860
+ link.target = "_blank";
861
+ link.rel = "noopener";
862
+ }
863
+ // Left of whichever of the existing header controls is furthest left, so the row's order
864
+ // is stable however many of them exist on this page.
865
+ const before = ["lab-viewport-link", "lab-cull-toggle", "lab-sidebar-collapse"]
866
+ .map((existing) => document.getElementById(existing))
867
+ .find((el) => el && el.parentElement === header);
868
+ if (before) header.insertBefore(link, before);
869
+ else header.appendChild(link);
870
+ }
871
+ link.title = title;
872
+ link.setAttribute("aria-label", title);
873
+ link.href = href;
874
+ };
875
+
876
+ const setupStoryButtons = () => {
877
+ if (!readIndex() || !headerOf()) return;
878
+ const item = index.byStory.get(document.documentElement.dataset.activeStory || "");
879
+ headerLink(
880
+ "lab-viewport-link",
881
+ "Open in the viewport configurator",
882
+ VIEWPORT_ICON,
883
+ item ? `${index.viewportBase}/${item.storyId}` : null,
884
+ { reload: true },
885
+ );
886
+ headerLink("lab-staging-link", "View this story on staging", STAGING_ICON, item ? item.stagingUrl : null, {
887
+ blank: true,
888
+ });
889
+ headerLink("lab-code-link", "Open the component in VS Code", CODE_ICON, item ? item.editorUrl : null);
890
+ };
891
+
892
+ // ---- j / k -------------------------------------------------------------------------------------
893
+ // Next / previous story in sidebar order, and navigate there. Only rows the sidebar is actually
894
+ // showing count, so j/k walk the search results while a filter is on and never jump into a
895
+ // collapsed folder. Guarded three ways so nothing collides: a modifier held is somebody else's
896
+ // shortcut (⌘B, ⌘K), a focused input or contenteditable means the key is being typed (the
897
+ // search box), and no #astrobook-sidebar means this is a bare story or the viewport
898
+ // configurator — which owns [ ] r f 0 on its own page.
899
+ const storyLinks = () => {
900
+ const root = tree();
901
+ if (!root) return [];
902
+ const links = [...root.querySelectorAll("a[data-astrobook-story-link][data-story-id]")];
903
+ const visible = links.filter((link) => link.offsetParent !== null);
904
+ return visible.length ? visible : links;
905
+ };
906
+ const stepStory = (delta) => {
907
+ const links = storyLinks();
908
+ if (!links.length) return;
909
+ const active = document.documentElement.dataset.activeStory || "";
910
+ const at = links.findIndex((link) => link.dataset.storyId === active);
911
+ const next = at === -1 ? (delta > 0 ? 0 : links.length - 1) : at + delta;
912
+ if (next < 0 || next >= links.length || next === at) return;
913
+ links[next].scrollIntoView({ block: "nearest" });
914
+ links[next].click(); // Astrobook's ClientRouter takes it from here
915
+ };
916
+ document.addEventListener("keydown", (event) => {
917
+ if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.isComposing) return;
918
+ if (!document.getElementById("astrobook-sidebar")) return;
919
+ // Bare letters, so this is the handler a typed character reaches first. The local
920
+ // activeElement test that used to stand here could not see through a shadow boundary.
921
+ // › THE KEYBOARD GUARD at the top of this file.
922
+ if (window.__labIgnoreKey(event)) return;
923
+ const key = event.key.toLowerCase();
924
+ if (key !== "j" && key !== "k") return;
925
+ event.preventDefault();
926
+ stepStory(key === "j" ? 1 : -1);
927
+ });
928
+
929
+ // ---- lifecycle ------------------------------------------------------------------------------
930
+ const setup = () => {
931
+ applyCullState(); // <html> attributes are replaced on every swap
932
+ if (!document.getElementById("astrobook-sidebar")) return; // bare story page, or not the lab
933
+ decorate();
934
+ renderCull();
935
+ renderResponsive();
936
+ setupStoryButtons(); // header and data-active-story are both replaced on every swap
937
+ setupCullButton(); // the header is re-rendered on every swap; the probe result is not
938
+ probeCull(); // once per hard load
939
+ probeResponsive();
940
+ };
941
+
942
+ document.addEventListener("astro:after-swap", setup);
943
+ document.addEventListener("astro:page-load", setup);
944
+ if (document.readyState === "loading") {
945
+ document.addEventListener("DOMContentLoaded", setup, { once: true });
946
+ } else {
947
+ setup();
948
+ }
949
+ })();
950
+ </script>
951
+ <style is:global>
952
+ /* Lab shell — pills, responsive and cull marks, drawn on top of the chrome the package ships
953
+ (./ui/lab.css). Every token here is a `--lab-*` one, defined in that file and themed there
954
+ for light and dark; nothing reads a name the consumer could also define.
955
+
956
+ REWRITTEN 2026-09-22 — the lab is not styled in the styles of the website being built.
957
+ Until then these rules read the consumer's own token names — `--color-accent`, `--font-mono`,
958
+ `--radius-pill` — with a `@layer orbytes-lab-fallback` block below supplying neutral greys
959
+ "so a host that defines --color-hairline on a plain :root wins here every time". That layer
960
+ WAS the leak, written as a policy; it is gone, and the lab wins now.
961
+
962
+ Anything that has to read on both the plain row and the inverted active row still rides on
963
+ currentColor. The one exception is the live pill's accent, which needs the other theme's
964
+ accent when it lands on the inverted row — hence --lab-color-accent-inverse. */
965
+
966
+ /* A live section version carries a live pill, a responsive pill and two boxes — wider than a
967
+ narrow sidebar. Letting the ROW wrap drops that group onto a second line, so the story's name
968
+ is never truncated and nothing is cut off at the sidebar's edge. Everything the marks add
969
+ lives inside .lab-pills, so it wraps as one group: two lines at the default 300px width for
970
+ rows that carry both pills (three for the twelve live ones), and two for everything from about
971
+ 380px, which is a sidebar drag away. */
972
+ #astrobook-sidebar-tree .astrobook-sidebar-link {
973
+ flex-wrap: wrap;
974
+ row-gap: 0.25em;
975
+ }
976
+
977
+ #astrobook-sidebar .lab-pills {
978
+ display: inline-flex;
979
+ flex: 0 1 auto;
980
+ flex-wrap: wrap;
981
+ align-items: center;
982
+ justify-content: flex-end;
983
+ gap: 0.22em;
984
+ margin-left: auto;
985
+ }
986
+
987
+ #astrobook-sidebar .lab-pill {
988
+ font-family: var(--lab-font-mono);
989
+ font-size: 0.65em;
990
+ font-weight: 500;
991
+ line-height: 1;
992
+ letter-spacing: 0.02em;
993
+ white-space: nowrap;
994
+ padding: 0.3em 0.45em;
995
+ border-radius: var(--lab-radius-pill);
996
+ border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
997
+ background-color: color-mix(in srgb, currentColor 8%, transparent);
998
+ color: inherit;
999
+ }
1000
+
1001
+ #astrobook-sidebar .lab-pill--live {
1002
+ color: var(--lab-color-accent);
1003
+ border-color: color-mix(in srgb, currentColor 50%, transparent);
1004
+ background-color: color-mix(in srgb, currentColor 12%, transparent);
1005
+ }
1006
+ /* The active row inverts its ground, so the accent has to invert with it. */
1007
+ #astrobook-sidebar .astrobook-sidebar-story-link-active .lab-pill--live {
1008
+ color: var(--lab-color-accent-inverse);
1009
+ }
1010
+
1011
+ #astrobook-sidebar .lab-pill--count {
1012
+ display: inline-flex;
1013
+ align-items: center;
1014
+ gap: 0.45em;
1015
+ }
1016
+ #astrobook-sidebar .lab-pill--count::before {
1017
+ content: "";
1018
+ width: 0.55em;
1019
+ height: 0.55em;
1020
+ border-radius: 50%;
1021
+ background-color: currentColor;
1022
+ }
1023
+
1024
+ #astrobook-sidebar .lab-pill--marked {
1025
+ border-style: dashed;
1026
+ border-color: color-mix(in srgb, currentColor 60%, transparent);
1027
+ text-transform: uppercase;
1028
+ letter-spacing: 0.08em;
1029
+ }
1030
+ #astrobook-sidebar .lab-row--marked > span:not([class]) {
1031
+ text-decoration: line-through;
1032
+ opacity: 0.7;
1033
+ }
1034
+
1035
+ /* The responsive mark: dashed and faint until the version has been made responsive, solid after
1036
+ — the same two-state reading as the cards' pill. */
1037
+ #astrobook-sidebar .lab-pill--resp {
1038
+ border-style: dashed;
1039
+ opacity: 0.6;
1040
+ }
1041
+ #astrobook-sidebar .lab-pill--resp-done {
1042
+ border-style: solid;
1043
+ border-color: color-mix(in srgb, currentColor 55%, transparent);
1044
+ opacity: 1;
1045
+ }
1046
+
1047
+ /* Responsive boxes need no switch — unlike the cull boxes they are always on. Two per row:
1048
+ approved for the work (hollow), and the work done (accented). */
1049
+ #astrobook-sidebar .lab-resp-box {
1050
+ flex: none;
1051
+ width: 0.8em;
1052
+ height: 0.8em;
1053
+ margin: 0 0 0 0.1em;
1054
+ cursor: pointer;
1055
+ }
1056
+ #astrobook-sidebar .lab-resp-box--approved {
1057
+ accent-color: var(--lab-color-border-strong);
1058
+ opacity: 0.6;
1059
+ }
1060
+ #astrobook-sidebar .lab-resp-box--done {
1061
+ accent-color: var(--lab-color-accent);
1062
+ }
1063
+
1064
+ /* Cull boxes exist only while the switch is on; live modules keep a disabled one. */
1065
+ #astrobook-sidebar .lab-cull-box {
1066
+ display: none;
1067
+ flex: none;
1068
+ width: 0.85em;
1069
+ height: 0.85em;
1070
+ margin: 0 0 0 0.25em;
1071
+ accent-color: var(--lab-color-accent);
1072
+ cursor: pointer;
1073
+ }
1074
+ :root[data-lab-cull] #astrobook-sidebar .lab-cull-box {
1075
+ display: inline-block;
1076
+ }
1077
+ #astrobook-sidebar .lab-cull-box:disabled {
1078
+ cursor: not-allowed;
1079
+ opacity: 0.35;
1080
+ }
1081
+
1082
+ #lab-cull-toggle svg,
1083
+ #lab-viewport-link svg,
1084
+ #lab-staging-link svg,
1085
+ #lab-code-link svg {
1086
+ display: block;
1087
+ }
1088
+ #lab-cull-toggle[aria-pressed="true"] {
1089
+ color: var(--lab-color-accent);
1090
+ }
1091
+
1092
+ #astrobook-sidebar .lab-row-error {
1093
+ font-family: var(--lab-font-mono);
1094
+ font-size: 0.7em;
1095
+ line-height: 1.35;
1096
+ margin: 0.25em 0.5em 0.25em 1.75em;
1097
+ padding: 0.5em 0.75em;
1098
+ border: 1px dashed color-mix(in srgb, currentColor 50%, transparent);
1099
+ border-radius: 0.25rem;
1100
+ color: var(--lab-color-text-strong);
1101
+ }
1102
+ </style>