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