@orbytes/astrolab 0.4.0-next.1 → 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 (50) hide show
  1. package/README.md +184 -84
  2. package/bin/pin-gallery.mjs +53 -19
  3. package/defaults.mjs +7 -20
  4. package/docs/PIN-CONTRACT.md +76 -10
  5. package/docs/PIN.md +93 -23
  6. package/index.d.ts +1 -7
  7. package/index.mjs +14 -81
  8. package/package.json +2 -2
  9. package/src/Home.astro +7 -8
  10. package/src/LabHead.astro +1 -1
  11. package/src/chrome/ActionsMenu.astro +97 -0
  12. package/src/chrome/ComponentCard.astro +9 -2
  13. package/src/chrome/Nav.astro +36 -10
  14. package/src/chrome/Panel.astro +17 -4
  15. package/src/chrome/Properties.astro +104 -0
  16. package/src/chrome/SectionsTree.astro +128 -0
  17. package/src/chrome/Shell.astro +20 -6
  18. package/src/chrome/StoryView.astro +103 -162
  19. package/src/chrome/Tree.astro +56 -53
  20. package/src/chrome/ViewportControls.astro +136 -61
  21. package/src/chrome/ViewportStage.astro +26 -3
  22. package/src/chrome/icons.ts +9 -0
  23. package/src/chrome/marks-client.ts +26 -53
  24. package/src/chrome/model.ts +14 -0
  25. package/src/chrome/navbar-client.ts +324 -0
  26. package/src/chrome/params-client.ts +434 -0
  27. package/src/chrome/pins-data.ts +42 -9
  28. package/src/chrome/shell-client.ts +99 -3
  29. package/src/chrome/trees.ts +112 -7
  30. package/src/chrome/viewport-client.ts +68 -242
  31. package/src/chrome/views/Assets.astro +21 -6
  32. package/src/chrome/views/Pages.astro +90 -54
  33. package/src/chrome/views/Placeholder.astro +3 -3
  34. package/src/chrome/views/Tasks.astro +12 -40
  35. package/src/core/LICENSE-astrobook +5 -0
  36. package/src/core/utils/kebab-case.ts +2 -2
  37. package/src/pin/board.mjs +25 -15
  38. package/src/pin/index.mjs +34 -20
  39. package/src/pin/tickets.mjs +6 -5
  40. package/src/pin/toolbar.js +81 -3
  41. package/src/shell/Browse.astro +35 -10
  42. package/src/shell/lab-index.ts +5 -4
  43. package/src/shell/lab-params.ts +113 -6
  44. package/src/shell/live-files.mjs +212 -10
  45. package/src/shell/marks.mjs +17 -41
  46. package/src/ui/components/preview-layout.astro +17 -0
  47. package/src/ui/components/theme-script.astro +4 -3
  48. package/src/ui/lab.css +2167 -566
  49. package/virtual.d.ts +0 -4
  50. package/bin/lab-cull.mjs +0 -401
@@ -1,15 +1,22 @@
1
1
  // Level 2's trees, built from the lab index and the site's files. Server-side only.
2
2
  //
3
- // THE TREE STOPS AT THE COMPONENT (decided 2026-09-24). A component's stories are tabs in the
4
- // navbar, not rows here, so a leaf is one stories module and links to its first story. Folders
5
- // above it are the directories under the tier, with one fold: a directory holding exactly one
6
- // module and nothing else IS that module, so `Section01Hero › V1 › Section01Hero` reads
7
- // `Section01Hero › V1`.
3
+ // THE TREE STOPS AT THE COMPONENT (decided 2026-09-24). A component's stories are its variants,
4
+ // chosen in the navbar, not rows here, so a leaf is one stories module and links to its first
5
+ // story. Folders above it are the directories under the tier, with one fold: a directory holding
6
+ // exactly one module and nothing else IS that module, so `Section01Hero › V1 › Section01Hero`
7
+ // reads `Section01Hero › V1`.
8
+ //
9
+ // THE SECTIONS TIER IS DIFFERENT (decided 2026-09-24): its level 2 is the Sections tree
10
+ // (./SectionsTree.astro) — one row per section with its slot on the page, the sections the site
11
+ // mounts first, the rest grouped under them. See sectionsTree below.
8
12
  import type { LabItem, LabIndex } from "../shell/lab-index";
9
13
  import { livePillText } from "../shell/lab-index";
10
- import type { TreeNode } from "./model";
14
+ import { sectionsTier, type TreeNode } from "./model";
11
15
 
12
- /** One stories module — a component — with its stories in file order. */
16
+ /**
17
+ * One stories module — a component — with its stories sorted by export name, not in file order:
18
+ * the core's getExports (src/core/utils/get-exports.ts) sorts them, and nothing after it re-orders.
19
+ */
13
20
  export interface LabComponent {
14
21
  moduleId: string;
15
22
  moduleName: string;
@@ -91,9 +98,107 @@ interface Dir {
91
98
  mods: LabComponent[];
92
99
  }
93
100
 
101
+ /** Where a component is mounted — its primary mount (the home page's, when it is on it) — or null. */
102
+ const mountOf = (mod: LabComponent): { n: number; of: number; page: string } | null => {
103
+ const story = mod.stories.find((s) => s.live && s.livePage);
104
+ return story ? { n: story.live!.slot, of: story.live!.of, page: story.livePage! } : null;
105
+ };
106
+
107
+ const byName = (a: string, b: string) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
108
+
109
+ /** A section module's version folder (`V1`), read from its directory when the index has none. */
110
+ const versionOf = (mod: LabComponent): string | null => mod.version ?? mod.directory.split("/")[2] ?? null;
111
+
112
+ /** Home page first, then the other pages by route, then top to bottom. */
113
+ const bySlot = (a: { n: number; page: string }, b: { n: number; page: string }) =>
114
+ a.page === b.page ? a.n - b.n : a.page === "/" ? -1 : b.page === "/" ? 1 : byName(a.page, b.page);
115
+
116
+ /**
117
+ * The Sections tree (decided 2026-09-24). One row per section, with a slot badge: the sections the
118
+ * site mounts come first, each badged with its slot; the rest follow under their own heading,
119
+ * badged with a dash. A section's versions sit under its row, unbadged, and are drawn only while
120
+ * you are on one of them — the tree still stops at the component. A version no page mounts is
121
+ * listed in the unslotted group as "Section · Version" even when a sibling version is mounted,
122
+ * because that is where you look for what is not on the site.
123
+ *
124
+ * Rows come out in slot order, which is also the order previous / next and the cards follow. Each
125
+ * root row carries its rank in both sort orders; the viewer's choice between them is theirs, kept
126
+ * in their browser and applied on the page (./SectionsTree.astro, ./shell-client.ts).
127
+ *
128
+ * Leaves keep the `mod:<moduleId>` id every other tree uses, so a caller walking the leaves for
129
+ * previous / next gets exactly the section versions, in order.
130
+ */
131
+ const sectionsTree = (mods: LabComponent[], currentModuleId?: string): TreeNode[] => {
132
+ const groups = new Map<string, LabComponent[]>();
133
+ for (const mod of mods) {
134
+ const name = mod.section ?? mod.directory.split("/")[1] ?? mod.moduleName;
135
+ groups.set(name, [...(groups.get(name) ?? []), mod]);
136
+ }
137
+
138
+ const leaf = (mod: LabComponent, label: string, extra: Partial<TreeNode> = {}): TreeNode => {
139
+ const state = componentState(mod);
140
+ return {
141
+ id: `mod:${mod.moduleId}`,
142
+ label,
143
+ href: mod.stories[0]!.dashboardUrl,
144
+ current: mod.moduleId === currentModuleId,
145
+ facets: state.facets,
146
+ search: state.search,
147
+ title: label === mod.moduleName ? undefined : mod.moduleName,
148
+ ...extra,
149
+ };
150
+ };
151
+
152
+ const slotted: (TreeNode & { slot: NonNullable<TreeNode["slot"]> })[] = [];
153
+ const unslotted: TreeNode[] = [];
154
+ for (const [name, list] of groups) {
155
+ const versions = [...list].sort((a, b) => byName(versionOf(a) ?? a.moduleName, versionOf(b) ?? b.moduleName));
156
+ const mounted = versions
157
+ .map((mod) => ({ mod, mount: mountOf(mod) }))
158
+ .filter((v): v is { mod: LabComponent; mount: NonNullable<ReturnType<typeof mountOf>> } => v.mount !== null)
159
+ .sort((a, b) => bySlot(a.mount, b.mount));
160
+ const lead = mounted[0];
161
+ if (lead) {
162
+ if (versions.length === 1 && !versionOf(lead.mod)) {
163
+ // A section with no version folders is its own module: one row, badged, no children.
164
+ slotted.push({ ...leaf(lead.mod, name), slot: lead.mount, group: "slotted" });
165
+ } else {
166
+ const state = componentState(lead.mod);
167
+ slotted.push({
168
+ id: `sec:${lead.mod.tier}/${name}`,
169
+ label: name,
170
+ href: lead.mod.stories[0]!.dashboardUrl,
171
+ slot: lead.mount,
172
+ group: "slotted",
173
+ childrenWhenCurrent: true,
174
+ facets: state.facets,
175
+ search: [name, ...mounted.map((v) => componentState(v.mod).search)].join(" ").toLowerCase(),
176
+ children: mounted.map((v) => leaf(v.mod, versionOf(v.mod) ?? v.mod.moduleName)),
177
+ });
178
+ }
179
+ }
180
+ for (const mod of versions) {
181
+ if (mountOf(mod)) continue;
182
+ const version = versionOf(mod);
183
+ const label = version ? `${name} · ${version}` : mod.moduleName;
184
+ unslotted.push({ ...leaf(mod, label), slot: null, group: "unslotted" });
185
+ }
186
+ }
187
+
188
+ slotted.sort((a, b) => bySlot(a.slot, b.slot) || byName(a.label, b.label));
189
+ unslotted.sort((a, b) => byName(a.label, b.label));
190
+ // Ranks for the two sort orders. Unslotted rows always follow the heading between the groups,
191
+ // which sits at rank `slotted.length` in both.
192
+ const nameRank = new Map([...slotted].sort((a, b) => byName(a.label, b.label)).map((n, i) => [n.id, i]));
193
+ slotted.forEach((node, i) => (node.order = { slot: i, name: nameRank.get(node.id)! }));
194
+ unslotted.forEach((node, i) => (node.order = { slot: slotted.length + 1 + i, name: slotted.length + 1 + i }));
195
+ return [...slotted, ...unslotted];
196
+ };
197
+
94
198
  /** The tree for one tier: its directories, folded, down to its components. */
95
199
  export const tierTree = (index: LabIndex, tier: string, currentModuleId?: string): TreeNode[] => {
96
200
  const mods = componentsOf(index).filter((mod) => mod.tier === tier);
201
+ if (tier === sectionsTier) return sectionsTree(mods, currentModuleId);
97
202
  const top: Dir = { name: tier, path: tier, dirs: new Map(), mods: [] };
98
203
  for (const mod of mods) {
99
204
  const segments = mod.directory.split("/").slice(1);
@@ -8,9 +8,14 @@
8
8
  // preset, key) rewrites the URL with replaceState and the memory; a live drag only redraws —
9
9
  // Safari throttles replaceState to 100 calls per 30 s, and a drag would spend that in a second.
10
10
  //
11
- // The viewport rides along on every link marked data-lab-keep-viewport (the story tabs, previous
12
- // and next), so stepping through stories compares like with like.
13
- import type { LabParamControl, LabParamEntry } from "../shell/lab-params";
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.
14
19
 
15
20
  declare global {
16
21
  interface Window {
@@ -28,10 +33,15 @@ if (controls && vp) {
28
33
  const wrapper = $<HTMLElement>("[data-lab-vp-wrapper]", vp);
29
34
  const frame = $<HTMLIFrameElement>("[data-lab-vp-frame]", vp);
30
35
  const live = $<HTMLElement>("[data-lab-vp-live]", vp);
36
+ const field = $<HTMLElement>("[data-lab-vpf]", controls);
31
37
  const inputW = $<HTMLInputElement>("[data-lab-vp-w]", controls);
32
38
  const inputH = $<HTMLInputElement>("[data-lab-vp-h]", controls);
33
39
  const band = $<HTMLElement>("[data-lab-vp-band]", controls);
34
- const zoom = $<HTMLSelectElement>("[data-lab-vp-zoom]", 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]");
35
45
 
36
46
  const MIN = 240;
37
47
  const ZOOM_MIN = 0.1;
@@ -85,6 +95,7 @@ if (controls && vp) {
85
95
  };
86
96
  const scale = () => (state.z === "fit" ? fitZoom() : state.z);
87
97
 
98
+ /** The band a width falls in, and its range — the field names the band, its tooltip the range. */
88
99
  const bandOf = (w: number) => {
89
100
  const i = breakpoints.findIndex((b) => w >= b.min);
90
101
  if (i === -1) return null;
@@ -106,22 +117,24 @@ if (controls && vp) {
106
117
  if (document.activeElement !== inputH) inputH.value = String(state.h);
107
118
  live.textContent = `${state.w} × ${state.h}`;
108
119
  const b = bandOf(state.w);
109
- band.textContent = b ? `${b.name} · ${b.range}` : `${state.w}px`;
110
- // The zoom menu shows Fit while fitting; otherwise the nearest stop, or the exact value added.
111
- const value = state.z === "fit" ? "fit" : String(Math.round(state.z * 100) / 100);
112
- if (![...zoom.options].some((o) => o.value === value)) {
113
- zoom.querySelector("[data-lab-custom]")?.remove();
114
- const option = new Option(`${Math.round(Number(value) * 100)}%`, value);
115
- option.dataset.labCustom = "";
116
- zoom.add(option);
117
- }
118
- zoom.value = value;
119
- zoom.options[0]!.textContent = state.z === "fit" ? `Fit · ${Math.round(z * 100)}%` : "Fit";
120
- for (const button of controls.querySelectorAll<HTMLElement>("[data-lab-vp-width]")) {
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) {
121
129
  const w = Number(button.dataset.labVpWidth);
122
130
  const h = button.dataset.labVpHeight ? Number(button.dataset.labVpHeight) : null;
123
- button.toggleAttribute("data-active", w === state.w && (h === null || h === state.h));
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));
124
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 } }));
125
138
  };
126
139
 
127
140
  // ---- commit ---------------------------------------------------------------------------------
@@ -172,13 +185,24 @@ if (controls && vp) {
172
185
  else setSize(state.w, value);
173
186
  });
174
187
  }
175
- $("[data-lab-vp-rotate]", controls).addEventListener("click", rotate);
176
- zoom.addEventListener("change", () => setZoom(parseZoom(zoom.value, "fit")));
177
- for (const button of controls.querySelectorAll<HTMLElement>("[data-lab-vp-width]")) {
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) {
178
202
  button.addEventListener("click", () => {
179
203
  const h = button.dataset.labVpHeight ? Number(button.dataset.labVpHeight) : state.h;
180
204
  setSize(Number(button.dataset.labVpWidth), h);
181
- button.closest<HTMLElement>("[popover]")?.hidePopover?.();
205
+ closeMenu(button);
182
206
  });
183
207
  }
184
208
  new ResizeObserver(() => {
@@ -189,7 +213,7 @@ if (controls && vp) {
189
213
  // Pointer capture keeps the moves on the handle even across the iframe, which is also made inert
190
214
  // for the drag. Screen deltas are divided by the zoom, so the frame grows by the pixels dragged.
191
215
  // A drag at "fit" freezes the zoom at what fit was, or the frame would shrink under the pointer,
192
- // and fits again on release.
216
+ // and fits again on release. The field's numbers follow the drag live, in violet.
193
217
  for (const handle of vp.querySelectorAll<HTMLElement>(".lab-vp-handle")) {
194
218
  const axis = handle.dataset.axis || "xy";
195
219
  let startX = 0;
@@ -210,6 +234,7 @@ if (controls && vp) {
210
234
  wasFit = state.z === "fit";
211
235
  if (wasFit) state.z = z;
212
236
  vp.dataset.dragging = axis;
237
+ field.dataset.dragging = axis;
213
238
  frame.style.pointerEvents = "none";
214
239
  });
215
240
  handle.addEventListener("pointermove", (event) => {
@@ -222,8 +247,9 @@ if (controls && vp) {
222
247
  if (!handle.hasPointerCapture(event.pointerId)) return;
223
248
  handle.releasePointerCapture(event.pointerId);
224
249
  delete vp.dataset.dragging;
250
+ delete field.dataset.dragging;
225
251
  frame.style.pointerEvents = "";
226
- if (wasFit) state.z = "fit"; // back to fitting the new size, as the menu still says
252
+ if (wasFit) state.z = "fit"; // back to fitting the new size, as the field still says
227
253
  commit();
228
254
  };
229
255
  handle.addEventListener("pointerup", end);
@@ -290,7 +316,7 @@ if (controls && vp) {
290
316
  if (label) label.textContent = "Copy failed";
291
317
  }
292
318
  setTimeout(() => {
293
- if (label) label.textContent = "Copy link to this size";
319
+ if (label) label.textContent = "Copy link to this exact size";
294
320
  }, 1500);
295
321
  });
296
322
  }
@@ -305,12 +331,14 @@ if (controls && vp) {
305
331
  }
306
332
 
307
333
  // ---- the preview's own theme ----------------------------------------------------------------
308
- // Separate from the chrome's (decided 2026-09-24). The switch appears only when the site in the
309
- // frame has something to switch: a `.dark` or `[data-theme]` rule, or a dark colour-scheme query.
310
- // Flipping it does both things a site might listen to — the frame's theme script (the same
311
- // `astrobook:set-theme` message the thumbnails have always used) and the iframe's color-scheme,
312
- // which is what `prefers-color-scheme` reads inside it.
313
- const themeButton = controls.querySelector<HTMLElement>("[data-lab-preview-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]");
314
342
  const PREVIEW_THEME_KEY = "theme-toggle";
315
343
  const previewTheme = () => {
316
344
  try {
@@ -322,7 +350,7 @@ if (controls && vp) {
322
350
  };
323
351
  const paintPreviewTheme = (theme: "light" | "dark") => {
324
352
  frame.dataset.theme = theme;
325
- themeButton?.setAttribute("data-theme", theme);
353
+ themeItem?.setAttribute("aria-checked", String(theme === "dark"));
326
354
  };
327
355
  const supportsDark = (doc: Document) => {
328
356
  const scan = (rules: CSSRuleList | undefined): boolean => {
@@ -345,12 +373,12 @@ if (controls && vp) {
345
373
  paintPreviewTheme(previewTheme());
346
374
  frame.addEventListener("load", () => {
347
375
  try {
348
- if (themeButton && frame.contentDocument) themeButton.hidden = !supportsDark(frame.contentDocument);
376
+ if (themeGroup && frame.contentDocument) themeGroup.hidden = !supportsDark(frame.contentDocument);
349
377
  } catch {
350
378
  /* cross-origin */
351
379
  }
352
380
  });
353
- themeButton?.addEventListener("click", () => {
381
+ themeItem?.addEventListener("click", () => {
354
382
  const next = previewTheme() === "dark" ? "light" : "dark";
355
383
  try {
356
384
  localStorage.setItem(PREVIEW_THEME_KEY, next);
@@ -362,218 +390,16 @@ if (controls && vp) {
362
390
  });
363
391
 
364
392
  // ---- the parameters drawer --------------------------------------------------------------------
365
- // Present only for a story that registers parameters through @orbytes/astrolab/params, which
366
- // puts them on the FRAME's window and announces each with a `lab:params` event there. The frame
367
- // is same-origin, so the drawer reads that array directly and calls each group's own apply()
368
- // across the boundary — the values object is shared, never copied.
369
- const drawer = vp.querySelector<HTMLElement>("[data-lab-params]");
370
- const drawerButton = controls.querySelector<HTMLElement>("[data-lab-params-toggle]");
371
- const DRAWER_KEY = "lab-params-open";
372
- const COLLAPSE_KEY = "lab-params-collapsed";
373
- const storedJson = <T,>(key: string, fallback: T): T => {
374
- try {
375
- const raw = JSON.parse(localStorage.getItem(key) || "null");
376
- return raw ?? fallback;
377
- } catch {
378
- return fallback;
379
- }
380
- };
381
- let groups: LabParamEntry[] = [];
382
- const drawerOpen = () => storedJson(DRAWER_KEY, true) !== false;
383
- const applyDrawer = () => {
384
- if (!drawer || !drawerButton) return;
385
- drawerButton.hidden = groups.length === 0;
386
- const open = groups.length > 0 && drawerOpen();
387
- drawer.hidden = !open;
388
- drawerButton.setAttribute("aria-pressed", String(open));
389
- };
390
- drawerButton?.addEventListener("click", () => {
391
- localStorage.setItem(DRAWER_KEY, JSON.stringify(!drawerOpen()));
392
- applyDrawer();
393
- });
394
-
395
- const decimalsOf = (step: number) => {
396
- const text = String(step);
397
- const dot = text.indexOf(".");
398
- return dot === -1 ? 0 : text.length - dot - 1;
399
- };
400
- const el = <K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string) => {
401
- const node = document.createElement(tag);
402
- if (className) node.className = className;
403
- if (text !== undefined) node.textContent = text;
404
- return node;
405
- };
406
-
407
- // Each control returns its node and a `sync` that pulls the input back from the live values —
408
- // Reset changes the values behind the inputs' backs.
409
- const buildControl = (entry: LabParamEntry, control: LabParamControl) => {
410
- const field = el("label", `vpp__control vpp__control--${control.kind}`);
411
- if (control.note) field.title = control.note;
412
- const name = el("span", "vpp__label", control.label);
413
- if (control.kind === "toggle") {
414
- const input = el("input");
415
- input.type = "checkbox";
416
- input.addEventListener("change", () => entry.set(control.id, input.checked));
417
- field.append(input, name);
418
- const sync = () => (input.checked = entry.values[control.id] === true);
419
- sync();
420
- return { node: field, sync };
421
- }
422
- if (control.kind === "select") {
423
- const select = el("select", "vpp__select");
424
- for (const option of control.options) {
425
- const node = el("option", undefined, option.label);
426
- node.value = option.value;
427
- select.append(node);
428
- }
429
- select.addEventListener("change", () => entry.set(control.id, select.value));
430
- field.append(name, select);
431
- const sync = () => (select.value = String(entry.values[control.id]));
432
- sync();
433
- return { node: field, sync };
434
- }
435
- if (control.kind === "color") {
436
- const input = el("input", "vpp__swatch");
437
- input.type = "color";
438
- const out = el("output", "vpp__value");
439
- input.addEventListener("input", () => {
440
- out.textContent = input.value;
441
- entry.set(control.id, input.value);
442
- });
443
- field.append(name, out, input);
444
- const sync = () => {
445
- input.value = String(entry.values[control.id]);
446
- out.textContent = input.value;
447
- };
448
- sync();
449
- return { node: field, sync };
450
- }
451
- const step = control.step ?? 1;
452
- const places = decimalsOf(step);
453
- const unit = control.unit ?? "";
454
- const input = el("input");
455
- input.type = "range";
456
- input.min = String(control.min);
457
- input.max = String(control.max);
458
- input.step = String(step);
459
- const out = el("output", "vpp__value");
460
- const paint = () => (out.textContent = `${Number(input.value).toFixed(places)}${unit}`);
461
- input.addEventListener("input", () => {
462
- paint();
463
- entry.set(control.id, Number(input.value));
464
- });
465
- field.append(name, out, input);
466
- const sync = () => {
467
- input.value = String(entry.values[control.id]);
468
- paint();
469
- };
470
- sync();
471
- return { node: field, sync };
472
- };
473
-
474
- const buildGroup = (entry: LabParamEntry, collapsed: Record<string, boolean>) => {
475
- const group = entry.group;
476
- const wrap = el("div", "vpp__group");
477
- const head = el("div", "vpp__head");
478
- const toggle = el("button", "vpp__toggle");
479
- toggle.type = "button";
480
- toggle.append(el("span", "vpp__chev", "▾"), el("span", "vpp__title", group.title));
481
- head.append(toggle);
482
- if (group.source) {
483
- const link = el("a", "vpp__source", group.source.label);
484
- link.href = group.source.href;
485
- link.target = "_blank";
486
- link.rel = "noreferrer";
487
- head.append(link);
488
- }
489
- if (group.note) head.append(el("p", "vpp__note", group.note));
490
-
491
- const list = el("div", "vpp__controls");
492
- const syncers: (() => void)[] = [];
493
- for (const control of group.controls) {
494
- const built = buildControl(entry, control);
495
- syncers.push(built.sync);
496
- list.append(built.node);
497
- }
498
- const reset = el("button", "vpp__button", "Reset");
499
- reset.type = "button";
500
- const copy = el("button", "vpp__button", "Copy settings");
501
- copy.type = "button";
502
- const actions = el("div", "vpp__actions");
503
- actions.append(reset, copy);
504
- const status = el("p", "vpp__status", entry.lastStatus || "Starting…");
505
- status.setAttribute("aria-live", "polite");
506
- entry.onStatus = (text) => (status.textContent = text);
507
- reset.addEventListener("click", () => {
508
- entry.resetAll();
509
- for (const sync of syncers) sync();
510
- entry.status("Reset to the declared defaults.");
511
- });
512
- copy.addEventListener("click", async () => {
513
- const json = entry.json();
514
- try {
515
- await navigator.clipboard.writeText(json);
516
- entry.status("Settings copied as JSON — paste them onto the ticket.");
517
- } catch {
518
- entry.status(json);
519
- }
520
- });
521
- const body = el("div", "vpp__body");
522
- body.append(list, actions, status);
523
- wrap.append(head, body);
524
-
525
- const setCollapsed = (value: boolean) => {
526
- wrap.toggleAttribute("data-collapsed", value);
527
- toggle.setAttribute("aria-expanded", String(!value));
528
- };
529
- setCollapsed(collapsed[group.id] === true);
530
- toggle.addEventListener("click", () => {
531
- const next = !wrap.hasAttribute("data-collapsed");
532
- setCollapsed(next);
533
- const map = storedJson<Record<string, boolean>>(COLLAPSE_KEY, {});
534
- if (next) map[group.id] = true;
535
- else delete map[group.id];
536
- localStorage.setItem(COLLAPSE_KEY, JSON.stringify(map));
537
- });
538
- return wrap;
539
- };
540
-
541
- const buildDrawer = () => {
542
- if (!drawer) return;
543
- drawer.textContent = "";
544
- try {
545
- groups = frame.contentWindow?.__labParams ?? [];
546
- } catch {
547
- groups = []; // a lab preview is always same-origin, but the drawer must never throw
548
- }
549
- const collapsed = storedJson<Record<string, boolean>>(COLLAPSE_KEY, {});
550
- for (const entry of groups) drawer.append(buildGroup(entry, collapsed));
551
- applyDrawer();
552
- };
553
- let queued = false;
554
- const queueBuild = () => {
555
- if (queued) return;
556
- queued = true;
557
- queueMicrotask(() => {
558
- queued = false;
559
- buildDrawer();
560
- });
561
- };
562
- const readParams = () => {
563
- queueBuild();
564
- try {
565
- // Groups registered after load (an async setup) announce themselves; ones registered during
566
- // module evaluation are already in the array queueBuild just read.
567
- frame.contentWindow?.addEventListener("lab:params", queueBuild);
568
- } catch {
569
- /* cross-origin — the drawer stays as built */
570
- }
571
- };
572
- frame.addEventListener("load", readParams);
573
- if (frame.contentDocument?.readyState === "complete") readParams();
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);
574
396
 
575
397
  render();
576
398
  commit(); // the URL always names the viewport, even when it came from memory or defaults
577
399
  }
578
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
+
579
405
  export {};
@@ -34,6 +34,7 @@ const filters: FilterOption[] = [
34
34
  <Shell
35
35
  title="Assets"
36
36
  active="assets"
37
+ navbarClass="lab-navbar--listing"
37
38
  panel={{
38
39
  label: "Assets",
39
40
  crumbs: [{ label: "Site" }, { label: "Assets", href: hrefs.assets }],
@@ -45,12 +46,26 @@ const filters: FilterOption[] = [
45
46
  <Tree slot="panel" nodes={assetsTree(assets)} />
46
47
 
47
48
  <Fragment slot="navbar">
48
- <div class="lab-navbar__id">
49
- <h1 class="lab-navbar__title">Assets</h1>
50
- <div class="lab-navbar__meta">
51
- <span class="lab-pill">{assets.length} image{assets.length === 1 ? "" : "s"}</span>
52
- <span class="lab-pill">{formatBytes(total)}</span>
53
- {unused > 0 && <span class="lab-pill lab-pill--unresponsive">{unused} unused</span>}
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
+ }
54
69
  </div>
55
70
  </div>
56
71
  </Fragment>