@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.
- package/README.md +184 -84
- package/bin/pin-gallery.mjs +53 -19
- package/defaults.mjs +7 -20
- package/docs/PIN-CONTRACT.md +76 -10
- package/docs/PIN.md +93 -23
- package/index.d.ts +1 -7
- package/index.mjs +14 -81
- package/package.json +2 -2
- package/src/Home.astro +7 -8
- package/src/LabHead.astro +1 -1
- package/src/chrome/ActionsMenu.astro +97 -0
- package/src/chrome/ComponentCard.astro +9 -2
- package/src/chrome/Nav.astro +36 -10
- package/src/chrome/Panel.astro +17 -4
- package/src/chrome/Properties.astro +104 -0
- package/src/chrome/SectionsTree.astro +128 -0
- package/src/chrome/Shell.astro +20 -6
- package/src/chrome/StoryView.astro +103 -162
- package/src/chrome/Tree.astro +56 -53
- package/src/chrome/ViewportControls.astro +136 -61
- package/src/chrome/ViewportStage.astro +26 -3
- package/src/chrome/icons.ts +9 -0
- package/src/chrome/marks-client.ts +26 -53
- package/src/chrome/model.ts +14 -0
- package/src/chrome/navbar-client.ts +324 -0
- package/src/chrome/params-client.ts +434 -0
- package/src/chrome/pins-data.ts +42 -9
- package/src/chrome/shell-client.ts +99 -3
- package/src/chrome/trees.ts +112 -7
- package/src/chrome/viewport-client.ts +68 -242
- package/src/chrome/views/Assets.astro +21 -6
- package/src/chrome/views/Pages.astro +90 -54
- package/src/chrome/views/Placeholder.astro +3 -3
- package/src/chrome/views/Tasks.astro +12 -40
- package/src/core/LICENSE-astrobook +5 -0
- package/src/core/utils/kebab-case.ts +2 -2
- package/src/pin/board.mjs +25 -15
- package/src/pin/index.mjs +34 -20
- package/src/pin/tickets.mjs +6 -5
- package/src/pin/toolbar.js +81 -3
- package/src/shell/Browse.astro +35 -10
- package/src/shell/lab-index.ts +5 -4
- package/src/shell/lab-params.ts +113 -6
- package/src/shell/live-files.mjs +212 -10
- package/src/shell/marks.mjs +17 -41
- package/src/ui/components/preview-layout.astro +17 -0
- package/src/ui/components/theme-script.astro +4 -3
- package/src/ui/lab.css +2167 -566
- package/virtual.d.ts +0 -4
- package/bin/lab-cull.mjs +0 -401
package/src/chrome/trees.ts
CHANGED
|
@@ -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
|
|
4
|
-
// navbar, not rows here, so a leaf is one stories module and links to its first
|
|
5
|
-
// above it are the directories under the tier, with one fold: a directory holding
|
|
6
|
-
// module and nothing else IS that module, so `Section01Hero › V1 › Section01Hero`
|
|
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
|
|
14
|
+
import { sectionsTier, type TreeNode } from "./model";
|
|
11
15
|
|
|
12
|
-
/**
|
|
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
|
|
12
|
-
//
|
|
13
|
-
|
|
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
|
|
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 ?
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
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
|
|
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)
|
|
309
|
-
// frame has something to switch
|
|
310
|
-
//
|
|
311
|
-
// `astrobook:set-theme` message the thumbnails
|
|
312
|
-
// which is what `prefers-color-scheme` reads
|
|
313
|
-
|
|
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
|
-
|
|
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 (
|
|
376
|
+
if (themeGroup && frame.contentDocument) themeGroup.hidden = !supportsDark(frame.contentDocument);
|
|
349
377
|
} catch {
|
|
350
378
|
/* cross-origin */
|
|
351
379
|
}
|
|
352
380
|
});
|
|
353
|
-
|
|
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
|
|
366
|
-
//
|
|
367
|
-
|
|
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-
|
|
49
|
-
<h1 class="lab-
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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>
|