@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
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
// The parameters drawer — built from the Figma components Parameters drawer (20074:3489), Drawer
|
|
2
|
+
// group (20072:259700) and Param row (20072:259490) in the Astrolab file.
|
|
3
|
+
//
|
|
4
|
+
// Present only for a story that registers parameters through @orbytes/astrolab/params, which puts
|
|
5
|
+
// them on the FRAME's window and announces each with a `lab:params` event there. The frame is
|
|
6
|
+
// same-origin, so the drawer reads that array directly and calls each group's own set() across the
|
|
7
|
+
// boundary — the values object is shared, never copied.
|
|
8
|
+
//
|
|
9
|
+
// It overlays the canvas's right edge and never resizes it: resizing would change the preview's
|
|
10
|
+
// width and trip its breakpoints. Whether it is open, and which groups are collapsed, is
|
|
11
|
+
// remembered in this browser. The navbar's Parameters button, the drawer's ✕ and Esc all close it.
|
|
12
|
+
//
|
|
13
|
+
// Every row is a <label> whose input is named by the label's text (aria-labelledby), so a row's
|
|
14
|
+
// readout or unit never leaks into its accessible name. Typing into any of them is safe from the
|
|
15
|
+
// lab's single-key shortcuts: every input and select here is one of the elements
|
|
16
|
+
// window.__labIgnoreKey stands down for (../LabHead.astro).
|
|
17
|
+
import type { IconName } from "./icons";
|
|
18
|
+
import {
|
|
19
|
+
formatLabLength,
|
|
20
|
+
labUnitsOf,
|
|
21
|
+
parseLabLength,
|
|
22
|
+
type LabParamControl,
|
|
23
|
+
type LabParamEntry,
|
|
24
|
+
} from "../shell/lab-params";
|
|
25
|
+
|
|
26
|
+
const DRAWER_KEY = "lab-params-open";
|
|
27
|
+
const COLLAPSE_KEY = "lab-params-collapsed";
|
|
28
|
+
|
|
29
|
+
const readJson = <T,>(key: string, fallback: T): T => {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(localStorage.getItem(key) || "null") ?? fallback;
|
|
32
|
+
} catch {
|
|
33
|
+
return fallback;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const writeJson = (key: string, value: unknown) => {
|
|
37
|
+
try {
|
|
38
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
39
|
+
} catch {
|
|
40
|
+
/* private mode — the drawer still works, it just forgets */
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const el = <K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string) => {
|
|
45
|
+
const node = document.createElement(tag);
|
|
46
|
+
if (className) node.className = className;
|
|
47
|
+
if (text !== undefined) node.textContent = text;
|
|
48
|
+
return node;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// The chrome's icons are <symbol>s in ./Sprite.astro, on every lab page; this points at one.
|
|
52
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
53
|
+
const icon = (name: IconName, className = "") => {
|
|
54
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
55
|
+
svg.setAttribute("class", `lab-icon lab-icon--sm ${className}`.trim());
|
|
56
|
+
svg.setAttribute("aria-hidden", "true");
|
|
57
|
+
const use = document.createElementNS(SVG_NS, "use");
|
|
58
|
+
use.setAttribute("href", `#lab-i-${name}`);
|
|
59
|
+
svg.append(use);
|
|
60
|
+
return svg;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const iconButton = (name: IconName, label: string, tooltip: string) => {
|
|
64
|
+
const button = el("button", "vpp__icon-btn");
|
|
65
|
+
button.type = "button";
|
|
66
|
+
button.title = tooltip;
|
|
67
|
+
button.setAttribute("aria-label", label);
|
|
68
|
+
button.append(icon(name));
|
|
69
|
+
return button;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
let uid = 0;
|
|
73
|
+
const nextId = (stem: string) => `lab-params-${stem}-${++uid}`;
|
|
74
|
+
|
|
75
|
+
const decimalsOf = (step: number) => {
|
|
76
|
+
const text = String(step);
|
|
77
|
+
const dot = text.indexOf(".");
|
|
78
|
+
return dot === -1 ? 0 : text.length - dot - 1;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// <input type="color"> only takes #rrggbb; a declared #abc or #rrggbbaa would otherwise read as
|
|
82
|
+
// black in the picker while the hex beside it says something else.
|
|
83
|
+
const toPickerHex = (hex: string) =>
|
|
84
|
+
/^#[0-9a-f]{3,4}$/i.test(hex)
|
|
85
|
+
? `#${[...hex.slice(1, 4)].map((c) => c + c).join("")}`
|
|
86
|
+
: hex.slice(0, 7);
|
|
87
|
+
|
|
88
|
+
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
89
|
+
|
|
90
|
+
// ---- the footer ---------------------------------------------------------------------------------
|
|
91
|
+
// One line for the drawer as a whole, aria-live: what the last action did (Reset, Copy settings).
|
|
92
|
+
// Hidden until there is something to say. It is also where the drawer would report on saving;
|
|
93
|
+
// that is not built here.
|
|
94
|
+
const makeFooter = () => {
|
|
95
|
+
const footer = el("footer", "vpp__footer");
|
|
96
|
+
const line = el("p", "vpp__footer-line");
|
|
97
|
+
line.setAttribute("aria-live", "polite");
|
|
98
|
+
footer.append(line);
|
|
99
|
+
footer.hidden = true;
|
|
100
|
+
return {
|
|
101
|
+
node: footer,
|
|
102
|
+
say(text: string) {
|
|
103
|
+
line.textContent = text;
|
|
104
|
+
footer.hidden = !text;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
type Footer = ReturnType<typeof makeFooter>;
|
|
109
|
+
|
|
110
|
+
// ---- one row per control ------------------------------------------------------------------------
|
|
111
|
+
// Each returns its node and a `sync` that pulls the input back from the live values — Reset
|
|
112
|
+
// changes the values behind the inputs' backs.
|
|
113
|
+
const buildRow = (entry: LabParamEntry, control: LabParamControl) => {
|
|
114
|
+
const row = el("label", `vpp__row vpp__row--${control.kind}`);
|
|
115
|
+
const labelId = nextId("label");
|
|
116
|
+
const name = el("span", "vpp__label");
|
|
117
|
+
const text = el("span", "vpp__label-text", control.label);
|
|
118
|
+
text.id = labelId;
|
|
119
|
+
name.append(text);
|
|
120
|
+
let noteId = "";
|
|
121
|
+
if (control.note) {
|
|
122
|
+
row.title = control.note;
|
|
123
|
+
name.append(icon("help", "vpp__help"));
|
|
124
|
+
const note = el("span", "lab-visually-hidden", control.note);
|
|
125
|
+
note.id = noteId = nextId("note");
|
|
126
|
+
name.append(note);
|
|
127
|
+
}
|
|
128
|
+
const slot = el("span", "vpp__control");
|
|
129
|
+
row.append(name, slot);
|
|
130
|
+
const named = <T extends HTMLElement>(input: T) => {
|
|
131
|
+
input.setAttribute("aria-labelledby", labelId);
|
|
132
|
+
if (noteId) input.setAttribute("aria-describedby", noteId);
|
|
133
|
+
return input;
|
|
134
|
+
};
|
|
135
|
+
const set = (value: string | number | boolean) => entry.set(control.id, value);
|
|
136
|
+
|
|
137
|
+
switch (control.kind) {
|
|
138
|
+
case "toggle": {
|
|
139
|
+
const input = named(el("input", "vpp__switch"));
|
|
140
|
+
input.type = "checkbox";
|
|
141
|
+
input.setAttribute("role", "switch");
|
|
142
|
+
input.addEventListener("change", () => set(input.checked));
|
|
143
|
+
slot.append(input);
|
|
144
|
+
const sync = () => (input.checked = entry.values[control.id] === true);
|
|
145
|
+
sync();
|
|
146
|
+
return { node: row, sync };
|
|
147
|
+
}
|
|
148
|
+
case "select": {
|
|
149
|
+
const select = named(el("select", "vpp__select"));
|
|
150
|
+
for (const option of control.options) {
|
|
151
|
+
const node = el("option", undefined, option.label);
|
|
152
|
+
node.value = option.value;
|
|
153
|
+
select.append(node);
|
|
154
|
+
}
|
|
155
|
+
select.addEventListener("change", () => set(select.value));
|
|
156
|
+
slot.append(select);
|
|
157
|
+
const sync = () => (select.value = String(entry.values[control.id]));
|
|
158
|
+
sync();
|
|
159
|
+
return { node: row, sync };
|
|
160
|
+
}
|
|
161
|
+
case "color": {
|
|
162
|
+
const field = el("span", "vpp__field vpp__field--color");
|
|
163
|
+
const input = named(el("input", "vpp__swatch"));
|
|
164
|
+
input.type = "color";
|
|
165
|
+
const out = el("output", "vpp__hex");
|
|
166
|
+
out.setAttribute("aria-hidden", "true"); // the picker already announces its own value
|
|
167
|
+
input.addEventListener("input", () => {
|
|
168
|
+
out.textContent = input.value;
|
|
169
|
+
set(input.value);
|
|
170
|
+
});
|
|
171
|
+
field.append(input, out);
|
|
172
|
+
slot.append(field);
|
|
173
|
+
const sync = () => {
|
|
174
|
+
const hex = String(entry.values[control.id]);
|
|
175
|
+
input.value = toPickerHex(hex);
|
|
176
|
+
out.textContent = hex;
|
|
177
|
+
};
|
|
178
|
+
sync();
|
|
179
|
+
return { node: row, sync };
|
|
180
|
+
}
|
|
181
|
+
case "number": {
|
|
182
|
+
// Decided 2026-09-24: a size is typed, not dragged, and says what unit it is in. The value
|
|
183
|
+
// handed to the component is the number and unit joined ("72px"); changing the unit keeps
|
|
184
|
+
// the number and changes what it means, because px → rem depends on a root size the lab
|
|
185
|
+
// cannot know.
|
|
186
|
+
const units = labUnitsOf(control);
|
|
187
|
+
const field = el("span", "vpp__field vpp__field--number");
|
|
188
|
+
const input = named(el("input", "vpp__number"));
|
|
189
|
+
input.type = "number";
|
|
190
|
+
input.inputMode = "decimal";
|
|
191
|
+
input.step = String(control.step ?? 1);
|
|
192
|
+
if (control.min !== undefined) input.min = String(control.min);
|
|
193
|
+
if (control.max !== undefined) input.max = String(control.max);
|
|
194
|
+
const unit = el("select", "vpp__unit");
|
|
195
|
+
unit.setAttribute("aria-label", `${control.label} unit`);
|
|
196
|
+
for (const value of units) {
|
|
197
|
+
const option = el("option", undefined, value || "—");
|
|
198
|
+
option.value = value;
|
|
199
|
+
if (!value) option.title = "No unit";
|
|
200
|
+
unit.append(option);
|
|
201
|
+
}
|
|
202
|
+
unit.disabled = units.length < 2;
|
|
203
|
+
field.append(input, unit);
|
|
204
|
+
slot.append(field);
|
|
205
|
+
|
|
206
|
+
const current = () =>
|
|
207
|
+
parseLabLength(entry.values[control.id]) ?? { value: control.value, unit: control.unit };
|
|
208
|
+
const commit = (value: number) => {
|
|
209
|
+
const bounded = Math.min(control.max ?? Infinity, Math.max(control.min ?? -Infinity, value));
|
|
210
|
+
set(formatLabLength(bounded, unit.value));
|
|
211
|
+
};
|
|
212
|
+
// Live while typing, so the section follows the field. A half-typed value ("-", "1e") has
|
|
213
|
+
// no number yet and changes nothing; leaving the field shows what is actually applied.
|
|
214
|
+
input.addEventListener("input", () => {
|
|
215
|
+
if (input.value !== "" && Number.isFinite(input.valueAsNumber)) commit(input.valueAsNumber);
|
|
216
|
+
});
|
|
217
|
+
unit.addEventListener("change", () => commit(current().value));
|
|
218
|
+
const sync = () => {
|
|
219
|
+
const { value, unit: which } = current();
|
|
220
|
+
input.value = String(value);
|
|
221
|
+
unit.value = which;
|
|
222
|
+
};
|
|
223
|
+
input.addEventListener("change", sync);
|
|
224
|
+
sync();
|
|
225
|
+
return { node: row, sync };
|
|
226
|
+
}
|
|
227
|
+
case "range": {
|
|
228
|
+
const step = control.step ?? 1;
|
|
229
|
+
const places = decimalsOf(step);
|
|
230
|
+
const input = named(el("input", "vpp__range"));
|
|
231
|
+
input.type = "range";
|
|
232
|
+
input.min = String(control.min);
|
|
233
|
+
input.max = String(control.max);
|
|
234
|
+
input.step = String(step);
|
|
235
|
+
const out = el("output", "vpp__readout");
|
|
236
|
+
out.setAttribute("aria-hidden", "true"); // the slider announces its own value
|
|
237
|
+
const paint = () => {
|
|
238
|
+
const value = Number(input.value);
|
|
239
|
+
out.textContent = `${value.toFixed(places)}${control.unit ?? ""}`;
|
|
240
|
+
const span = control.max - control.min;
|
|
241
|
+
const fill = span > 0 ? ((value - control.min) / span) * 100 : 0;
|
|
242
|
+
input.style.setProperty("--lab-params-fill", `${fill}%`);
|
|
243
|
+
};
|
|
244
|
+
input.addEventListener("input", () => {
|
|
245
|
+
paint();
|
|
246
|
+
set(Number(input.value));
|
|
247
|
+
});
|
|
248
|
+
slot.append(input, out);
|
|
249
|
+
const sync = () => {
|
|
250
|
+
input.value = String(entry.values[control.id]);
|
|
251
|
+
paint();
|
|
252
|
+
};
|
|
253
|
+
sync();
|
|
254
|
+
return { node: row, sync };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// ---- one <details> per registered group ---------------------------------------------------------
|
|
260
|
+
// Nothing interactive sits inside <summary>: the source link, Reset and Copy live in the body and
|
|
261
|
+
// are positioned up into the header row, so they show only while the group is open.
|
|
262
|
+
const buildGroup = (entry: LabParamEntry, collapsed: Record<string, boolean>, footer: Footer) => {
|
|
263
|
+
const group = entry.group;
|
|
264
|
+
const details = el("details", "vpp__group");
|
|
265
|
+
details.open = collapsed[group.id] !== true;
|
|
266
|
+
if (group.source) details.dataset.source = "";
|
|
267
|
+
|
|
268
|
+
const summary = el("summary", "vpp__summary");
|
|
269
|
+
summary.append(
|
|
270
|
+
icon("chevron-down", "vpp__chev"),
|
|
271
|
+
el("span", "vpp__title", group.title),
|
|
272
|
+
el("span", "vpp__summary-count", plural(group.controls.length, "control")),
|
|
273
|
+
);
|
|
274
|
+
details.append(summary);
|
|
275
|
+
|
|
276
|
+
if (group.source) {
|
|
277
|
+
const link = el("a", "vpp__source", group.source.label);
|
|
278
|
+
link.href = group.source.href;
|
|
279
|
+
link.target = "_blank";
|
|
280
|
+
link.rel = "noreferrer";
|
|
281
|
+
const arrow = el("span", "vpp__source-arrow", " ↗");
|
|
282
|
+
arrow.setAttribute("aria-hidden", "true");
|
|
283
|
+
link.append(arrow);
|
|
284
|
+
details.append(link);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const reset = iconButton("reset", `Reset ${group.title} to its defaults`, "Reset to defaults");
|
|
288
|
+
const copy = iconButton("copy", `Copy ${group.title} settings`, "Copy settings");
|
|
289
|
+
const actions = el("div", "vpp__actions");
|
|
290
|
+
actions.append(reset, copy);
|
|
291
|
+
details.append(actions);
|
|
292
|
+
|
|
293
|
+
if (group.note) details.append(el("p", "vpp__note", group.note));
|
|
294
|
+
|
|
295
|
+
const list = el("div", "vpp__controls");
|
|
296
|
+
const syncers: (() => void)[] = [];
|
|
297
|
+
for (const control of group.controls) {
|
|
298
|
+
const built = buildRow(entry, control);
|
|
299
|
+
syncers.push(built.sync);
|
|
300
|
+
list.append(built.node);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The component's own status line (panel.status()) — WebGL state, frame cost, an error. Hidden
|
|
304
|
+
// until the component says something.
|
|
305
|
+
const status = el("p", "vpp__status");
|
|
306
|
+
status.setAttribute("aria-live", "polite");
|
|
307
|
+
const showStatus = (text: string) => {
|
|
308
|
+
status.textContent = text;
|
|
309
|
+
status.hidden = !text;
|
|
310
|
+
};
|
|
311
|
+
showStatus(entry.lastStatus);
|
|
312
|
+
entry.onStatus = showStatus;
|
|
313
|
+
details.append(list, status);
|
|
314
|
+
|
|
315
|
+
reset.addEventListener("click", () => {
|
|
316
|
+
entry.resetAll();
|
|
317
|
+
for (const sync of syncers) sync();
|
|
318
|
+
footer.say(`${group.title} reset to its defaults`);
|
|
319
|
+
});
|
|
320
|
+
copy.addEventListener("click", async () => {
|
|
321
|
+
const json = entry.json();
|
|
322
|
+
try {
|
|
323
|
+
await navigator.clipboard.writeText(json);
|
|
324
|
+
footer.say(`${group.title} settings copied as JSON`);
|
|
325
|
+
} catch {
|
|
326
|
+
showStatus(json);
|
|
327
|
+
footer.say("The clipboard is blocked — the settings are shown in the group");
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
details.addEventListener("toggle", () => {
|
|
332
|
+
const map = readJson<Record<string, boolean>>(COLLAPSE_KEY, {});
|
|
333
|
+
if (details.open) delete map[group.id];
|
|
334
|
+
else map[group.id] = true;
|
|
335
|
+
writeJson(COLLAPSE_KEY, map);
|
|
336
|
+
});
|
|
337
|
+
return details;
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const buildDrawer = (drawer: HTMLElement, groups: LabParamEntry[], close: () => void) => {
|
|
341
|
+
const header = el("header", "vpp__header");
|
|
342
|
+
const heading = el("div", "vpp__heading");
|
|
343
|
+
const title = el("h2", "vpp__heading-title", "Parameters");
|
|
344
|
+
title.id = nextId("title");
|
|
345
|
+
const total = groups.reduce((sum, entry) => sum + entry.group.controls.length, 0);
|
|
346
|
+
const count = el("span", "vpp__count", String(total));
|
|
347
|
+
count.append(el("span", "lab-visually-hidden", total === 1 ? " control" : " controls"));
|
|
348
|
+
heading.append(title, count);
|
|
349
|
+
const closeButton = iconButton("close", "Close parameters", "Close (Esc)");
|
|
350
|
+
closeButton.addEventListener("click", close);
|
|
351
|
+
header.append(heading, closeButton);
|
|
352
|
+
|
|
353
|
+
const footer = makeFooter();
|
|
354
|
+
const body = el("div", "vpp__scroll");
|
|
355
|
+
const collapsed = readJson<Record<string, boolean>>(COLLAPSE_KEY, {});
|
|
356
|
+
for (const entry of groups) body.append(buildGroup(entry, collapsed, footer));
|
|
357
|
+
|
|
358
|
+
drawer.setAttribute("aria-labelledby", title.id);
|
|
359
|
+
drawer.replaceChildren(header, body, footer.node);
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
export function mountParamsDrawer(drawer: HTMLElement | null, frame: HTMLIFrameElement) {
|
|
363
|
+
if (!drawer) return;
|
|
364
|
+
const button = document.querySelector<HTMLElement>("[data-lab-params-toggle]");
|
|
365
|
+
drawer.id ||= "lab-params";
|
|
366
|
+
button?.setAttribute("aria-controls", drawer.id);
|
|
367
|
+
|
|
368
|
+
let groups: LabParamEntry[] = [];
|
|
369
|
+
const wantsOpen = () => readJson(DRAWER_KEY, true) !== false;
|
|
370
|
+
const paint = () => {
|
|
371
|
+
const present = groups.length > 0;
|
|
372
|
+
const open = present && wantsOpen();
|
|
373
|
+
drawer.hidden = !open;
|
|
374
|
+
if (!button) return;
|
|
375
|
+
button.hidden = !present;
|
|
376
|
+
button.setAttribute("aria-pressed", String(open));
|
|
377
|
+
};
|
|
378
|
+
const setOpen = (open: boolean) => {
|
|
379
|
+
writeJson(DRAWER_KEY, open);
|
|
380
|
+
paint();
|
|
381
|
+
};
|
|
382
|
+
const close = () => {
|
|
383
|
+
const hadFocus = drawer.contains(document.activeElement);
|
|
384
|
+
setOpen(false);
|
|
385
|
+
if (hadFocus) button?.focus();
|
|
386
|
+
};
|
|
387
|
+
button?.addEventListener("click", () => setOpen(!wantsOpen()));
|
|
388
|
+
|
|
389
|
+
// Esc closes it — from inside the drawer always, from elsewhere only when nothing is being typed
|
|
390
|
+
// and no menu is open (an open popover takes the Esc itself).
|
|
391
|
+
document.addEventListener("keydown", (event) => {
|
|
392
|
+
if (event.key !== "Escape" || drawer.hidden || event.defaultPrevented) return;
|
|
393
|
+
const inside = drawer.contains(document.activeElement);
|
|
394
|
+
if (!inside && window.__labIgnoreKey?.(event)) return;
|
|
395
|
+
try {
|
|
396
|
+
if (document.querySelector(":popover-open")) return;
|
|
397
|
+
} catch {
|
|
398
|
+
/* no popover support — so no popover to defer to */
|
|
399
|
+
}
|
|
400
|
+
close();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const build = () => {
|
|
404
|
+
try {
|
|
405
|
+
groups = frame.contentWindow?.__labParams ?? [];
|
|
406
|
+
} catch {
|
|
407
|
+
groups = []; // a lab preview is always same-origin, but the drawer must never throw
|
|
408
|
+
}
|
|
409
|
+
if (groups.length) buildDrawer(drawer, groups, close);
|
|
410
|
+
else drawer.replaceChildren();
|
|
411
|
+
paint();
|
|
412
|
+
};
|
|
413
|
+
let queued = false;
|
|
414
|
+
const queueBuild = () => {
|
|
415
|
+
if (queued) return;
|
|
416
|
+
queued = true;
|
|
417
|
+
queueMicrotask(() => {
|
|
418
|
+
queued = false;
|
|
419
|
+
build();
|
|
420
|
+
});
|
|
421
|
+
};
|
|
422
|
+
const readParams = () => {
|
|
423
|
+
queueBuild();
|
|
424
|
+
try {
|
|
425
|
+
// Groups registered after load (an async setup) announce themselves; ones registered during
|
|
426
|
+
// module evaluation are already in the array queueBuild just read.
|
|
427
|
+
frame.contentWindow?.addEventListener("lab:params", queueBuild);
|
|
428
|
+
} catch {
|
|
429
|
+
/* cross-origin — the drawer stays as built */
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
frame.addEventListener("load", readParams);
|
|
433
|
+
if (frame.contentDocument?.readyState === "complete") readParams();
|
|
434
|
+
}
|
package/src/chrome/pins-data.ts
CHANGED
|
@@ -1,26 +1,59 @@
|
|
|
1
|
-
//
|
|
2
|
-
// is null in every build, and then this returns
|
|
1
|
+
// Pin-board tickets counted per source file and per page, for the Properties panel behind the
|
|
2
|
+
// navbar's eye. Dev only: `labConfig.tasks` is null in every build, and then this returns empty
|
|
3
|
+
// counts without touching the disk.
|
|
4
|
+
//
|
|
5
|
+
// The markers drawn on the framed preview do not come from here. They come from the dev toolbar's
|
|
6
|
+
// pin app, which re-reads the board after every write (../pin/toolbar.js › `orbytes-pin:state`),
|
|
7
|
+
// so a pin appears on the frame the moment it is created rather than on the next page load.
|
|
3
8
|
import { labConfig } from "./model";
|
|
4
9
|
|
|
10
|
+
/** Open and resolved tickets. Cancelled ones are an archive and are counted nowhere. */
|
|
11
|
+
export interface PinTally {
|
|
12
|
+
open: number;
|
|
13
|
+
resolved: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
export interface PinCounts {
|
|
6
17
|
/** open tickets whose source file ends with the given site-relative path */
|
|
7
18
|
openFor: (file: string | null | undefined) => number;
|
|
19
|
+
/** open and resolved tickets whose source file ends with the given site-relative path */
|
|
20
|
+
forFile: (file: string | null | undefined) => PinTally;
|
|
21
|
+
/** open and resolved tickets left on the given page address (`/`, `/about`) */
|
|
22
|
+
forUrl: (url: string | null | undefined) => PinTally;
|
|
8
23
|
total: number;
|
|
9
24
|
review: number;
|
|
10
25
|
}
|
|
11
26
|
|
|
12
|
-
const
|
|
27
|
+
const zero: PinTally = { open: 0, resolved: 0 };
|
|
28
|
+
const none: PinCounts = { openFor: () => 0, forFile: () => zero, forUrl: () => zero, total: 0, review: 0 };
|
|
29
|
+
|
|
30
|
+
/** `/a/` and `/a` are the same page; a query string is part of the address. */
|
|
31
|
+
const samePath = (a: string, b: string) => {
|
|
32
|
+
const norm = (value: string) => {
|
|
33
|
+
const cut = value.indexOf("?");
|
|
34
|
+
const path = cut === -1 ? value : value.slice(0, cut);
|
|
35
|
+
return (path.length > 1 ? path.replace(/\/+$/, "") : path) + (cut === -1 ? "" : value.slice(cut));
|
|
36
|
+
};
|
|
37
|
+
return norm(a) === norm(b);
|
|
38
|
+
};
|
|
13
39
|
|
|
14
40
|
export const pinCounts = async (): Promise<PinCounts> => {
|
|
15
41
|
if (!labConfig.tasks) return none;
|
|
16
42
|
try {
|
|
17
|
-
const { collectTickets, OPEN_STATUSES, READY_FOR_REVIEW } = await import("../pin/board.mjs");
|
|
18
|
-
const { tickets } = collectTickets(labConfig.tasks.repoRoot, { backlogDir: labConfig.tasks.backlogDir })
|
|
19
|
-
|
|
43
|
+
const { collectTickets, OPEN_STATUSES, READY_FOR_REVIEW, RESOLVED } = await import("../pin/board.mjs");
|
|
44
|
+
const { tickets } = collectTickets(labConfig.tasks.repoRoot, { backlogDir: labConfig.tasks.backlogDir }) as {
|
|
45
|
+
tickets: { status: string; source?: string | null; url?: string | null }[];
|
|
46
|
+
};
|
|
47
|
+
const open = tickets.filter((t) => OPEN_STATUSES.includes(t.status));
|
|
48
|
+
const resolved = tickets.filter((t) => t.status === RESOLVED);
|
|
49
|
+
// A ticket's source is repo-relative and a lab path is site-relative; in a monorepo the site
|
|
50
|
+
// sits below the repo root, so the comparison is on the tail.
|
|
51
|
+
const bySource = (file: string) => (t: { source?: string | null }) => t.source === file || Boolean(t.source?.endsWith(`/${file}`));
|
|
52
|
+
const byUrl = (url: string) => (t: { url?: string | null }) => Boolean(t.url) && samePath(t.url!, url);
|
|
20
53
|
return {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
54
|
+
openFor: (file) => (file ? open.filter(bySource(file)).length : 0),
|
|
55
|
+
forFile: (file) => (file ? { open: open.filter(bySource(file)).length, resolved: resolved.filter(bySource(file)).length } : zero),
|
|
56
|
+
forUrl: (url) => (url ? { open: open.filter(byUrl(url)).length, resolved: resolved.filter(byUrl(url)).length } : zero),
|
|
24
57
|
total: open.length,
|
|
25
58
|
review: open.filter((t) => t.status === READY_FOR_REVIEW).length,
|
|
26
59
|
};
|
|
@@ -4,14 +4,17 @@
|
|
|
4
4
|
// · the two collapses: level 1 ⇄ icon rail (⌘⇧B), level 2 open ⇄ closed (⌘B), both (⌘.), each
|
|
5
5
|
// remembered in localStorage and applied before paint by the inline script in Shell.astro
|
|
6
6
|
// · the peek: level 2 closed, pointer at the stage's left edge → the panel slides out OVER the
|
|
7
|
-
// stage for as long as the pointer stays on it. Over, not beside: pushing
|
|
8
|
-
// resize the preview frame and trip its breakpoints
|
|
7
|
+
// stage, below the navbar, for as long as the pointer stays on it. Over, not beside: pushing
|
|
8
|
+
// the stage would resize the preview frame and trip its breakpoints
|
|
9
9
|
// · level 1 groups (Site, Tasks) folding, remembered
|
|
10
|
-
// · the
|
|
10
|
+
// · the lab's own light/dark (the moon in level 1)
|
|
11
|
+
// · the tree: closed groups remembered, scroll restored, the current row kept in view, and the
|
|
12
|
+
// Sections tree's sort (slot or name), remembered
|
|
11
13
|
// · search (⌘K) and the filter menu, applied to the tree AND to any cards in the content that
|
|
12
14
|
// carry the same data-lab-search / data-lab-facets attributes
|
|
13
15
|
// · j / k: the navbar's own previous/next links when the page has them, the tree's rows when not
|
|
14
16
|
// · popover menus placed under the button that opened them
|
|
17
|
+
// · Astro's dev toolbar hidden in every frame, so the lab page's own is the only one
|
|
15
18
|
//
|
|
16
19
|
// Every shortcut asks window.__labIgnoreKey first (../LabHead.astro › THE KEYBOARD GUARD).
|
|
17
20
|
|
|
@@ -44,20 +47,34 @@ const write = (key: string, value: string | null, store: Storage = localStorage)
|
|
|
44
47
|
const ignoreKey = (event: KeyboardEvent) => window.__labIgnoreKey?.(event) ?? false;
|
|
45
48
|
|
|
46
49
|
// ---- the two collapses ---------------------------------------------------------------------------
|
|
50
|
+
// The collapse button's name follows what it will do; the tooltip's title switches in CSS.
|
|
51
|
+
const l1Button = document.querySelector<HTMLElement>(".lab-l1 [data-lab-l1-btn]");
|
|
52
|
+
const nameL1Button = () => l1Button?.setAttribute("aria-label", l1Rail() ? "Expand menu" : "Collapse menu");
|
|
47
53
|
const setL1 = (rail: boolean) => {
|
|
48
54
|
if (rail) root.dataset.labL1 = "rail";
|
|
49
55
|
else delete root.dataset.labL1;
|
|
50
56
|
write("lab-l1", rail ? "rail" : "open");
|
|
57
|
+
nameL1Button();
|
|
58
|
+
};
|
|
59
|
+
// Level 2's own header button hides the panel when it is docked and keeps it open while it only
|
|
60
|
+
// peeks, so its name says which (decided 2026-09-24: a label must match its action).
|
|
61
|
+
const l2Button = panel?.querySelector<HTMLElement>("[data-lab-l2-btn]");
|
|
62
|
+
const nameL2Button = () => {
|
|
63
|
+
const label = "labPeek" in root.dataset ? "Keep panel open" : "Hide panel";
|
|
64
|
+
l2Button?.setAttribute("aria-label", label);
|
|
65
|
+
l2Button?.setAttribute("title", `${label} · ⌘B`);
|
|
51
66
|
};
|
|
52
67
|
const setL2 = (closed: boolean) => {
|
|
53
68
|
if (!panel) return;
|
|
54
69
|
if (closed) root.dataset.labL2 = "closed";
|
|
55
70
|
else delete root.dataset.labL2;
|
|
56
71
|
delete root.dataset.labPeek;
|
|
72
|
+
nameL2Button();
|
|
57
73
|
write("lab-l2", closed ? "closed" : null);
|
|
58
74
|
};
|
|
59
75
|
const l1Rail = () => root.dataset.labL1 === "rail";
|
|
60
76
|
const l2Closed = () => root.dataset.labL2 === "closed";
|
|
77
|
+
nameL1Button();
|
|
61
78
|
const toggleL1 = () => setL1(!l1Rail());
|
|
62
79
|
const toggleL2 = () => setL2(!l2Closed());
|
|
63
80
|
// Focus mode: both away if either is showing, both back if both are away.
|
|
@@ -84,6 +101,15 @@ if (panel) {
|
|
|
84
101
|
}
|
|
85
102
|
|
|
86
103
|
// ---- the peek -----------------------------------------------------------------------------------
|
|
104
|
+
// The peek and its trigger strip start where the navbar ends, so the panel covers the stage and
|
|
105
|
+
// never the navbar (decided 2026-09-24). The navbar is one row or two depending on the width it
|
|
106
|
+
// gets, so its height is measured, not assumed.
|
|
107
|
+
const navbar = document.querySelector<HTMLElement>("[data-lab-navbar]");
|
|
108
|
+
if (panel && navbar) {
|
|
109
|
+
const measure = () => root.style.setProperty("--lab-peek-top", `${navbar.offsetHeight}px`);
|
|
110
|
+
measure();
|
|
111
|
+
new ResizeObserver(measure).observe(navbar);
|
|
112
|
+
}
|
|
87
113
|
if (panel) {
|
|
88
114
|
const zone = document.querySelector<HTMLElement>("[data-lab-peek-zone]");
|
|
89
115
|
let openTimer = 0;
|
|
@@ -91,6 +117,7 @@ if (panel) {
|
|
|
91
117
|
const peek = (on: boolean) => {
|
|
92
118
|
if (on && l2Closed()) root.dataset.labPeek = "";
|
|
93
119
|
else delete root.dataset.labPeek;
|
|
120
|
+
nameL2Button();
|
|
94
121
|
};
|
|
95
122
|
zone?.addEventListener("mouseenter", () => {
|
|
96
123
|
clearTimeout(closeTimer);
|
|
@@ -135,10 +162,15 @@ for (const group of document.querySelectorAll<HTMLElement>("[data-lab-group]"))
|
|
|
135
162
|
}
|
|
136
163
|
|
|
137
164
|
// ---- theme --------------------------------------------------------------------------------------
|
|
165
|
+
// The moon beside the logo: the LAB's light/dark, never the preview's. It is a toggle for dark, so
|
|
166
|
+
// it reports its state as pressed rather than swapping its icon.
|
|
138
167
|
for (const button of document.querySelectorAll<HTMLElement>("[data-lab-theme-toggle]")) {
|
|
168
|
+
const mark = () => button.setAttribute("aria-pressed", String(window.labTheme?.getTheme() === "dark"));
|
|
169
|
+
mark();
|
|
139
170
|
button.addEventListener("click", () => {
|
|
140
171
|
const next = window.labTheme?.getTheme() === "dark" ? "light" : "dark";
|
|
141
172
|
window.labTheme?.setTheme(next);
|
|
173
|
+
mark();
|
|
142
174
|
});
|
|
143
175
|
}
|
|
144
176
|
|
|
@@ -181,6 +213,36 @@ if (body) {
|
|
|
181
213
|
current?.scrollIntoView({ block: "nearest" });
|
|
182
214
|
}
|
|
183
215
|
|
|
216
|
+
// ---- the Sections tree's sort -------------------------------------------------------------------
|
|
217
|
+
// Slot number or Name (A–Z), remembered per viewer. The inline script in Shell.astro already put
|
|
218
|
+
// the stored choice on <html> and CSS `order` drew it; this moves the rows themselves to match, so
|
|
219
|
+
// screen readers, Tab and j / k meet them in the order they are shown. Either way the unslotted
|
|
220
|
+
// group stays under its heading, below the slotted one — the ranks from ./trees.ts guarantee it.
|
|
221
|
+
const SORT_KEY = "lab-tree-sort";
|
|
222
|
+
const stree = document.querySelector<HTMLElement>("[data-lab-stree]");
|
|
223
|
+
if (stree) {
|
|
224
|
+
const sortItems = [...document.querySelectorAll<HTMLElement>("[data-lab-sort-by]")];
|
|
225
|
+
const rank = (el: HTMLElement, by: string) =>
|
|
226
|
+
Number(el.style.getPropertyValue(by === "name" ? "--lab-o-name" : "--lab-o-slot")) || 0;
|
|
227
|
+
const applySort = (by: string) => {
|
|
228
|
+
if (by === "name") root.dataset.labSort = "name";
|
|
229
|
+
else delete root.dataset.labSort;
|
|
230
|
+
const rows = [...stree.querySelectorAll<HTMLElement>(":scope > li")];
|
|
231
|
+
rows.sort((a, b) => rank(a, by) - rank(b, by));
|
|
232
|
+
stree.append(...rows);
|
|
233
|
+
for (const item of sortItems) item.setAttribute("aria-checked", String(item.dataset.labSortBy === by));
|
|
234
|
+
};
|
|
235
|
+
applySort(read(SORT_KEY) === "name" ? "name" : "slot");
|
|
236
|
+
for (const item of sortItems) {
|
|
237
|
+
item.addEventListener("click", () => {
|
|
238
|
+
const by = item.dataset.labSortBy === "name" ? "name" : "slot";
|
|
239
|
+
write(SORT_KEY, by === "name" ? "name" : null);
|
|
240
|
+
applySort(by);
|
|
241
|
+
item.closest<HTMLElement>("[popover]")?.hidePopover?.();
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
184
246
|
// ---- search and filter --------------------------------------------------------------------------
|
|
185
247
|
const search = document.querySelector<HTMLInputElement>("[data-lab-tree-search]");
|
|
186
248
|
const filterButton = document.querySelector<HTMLElement>("[data-lab-filter-btn]");
|
|
@@ -223,6 +285,10 @@ const applyFilter = () => {
|
|
|
223
285
|
if (tree) {
|
|
224
286
|
if (!searching) applyStoredOpen();
|
|
225
287
|
for (const li of tree.querySelectorAll<HTMLElement>(":scope > .lab-tree__node")) filterNode(li, terms);
|
|
288
|
+
// The Sections tree's "Unslotted" heading goes when nothing under it is left to show.
|
|
289
|
+
for (const heading of tree.querySelectorAll<HTMLElement>(":scope > [data-lab-tree-heading]")) {
|
|
290
|
+
heading.hidden = !tree.querySelector(':scope > [data-lab-slot-group="unslotted"]:not([data-lab-hidden])');
|
|
291
|
+
}
|
|
226
292
|
let empty = tree.parentElement?.querySelector<HTMLElement>("[data-lab-tree-none]");
|
|
227
293
|
const anyShown = tree.querySelector(":scope > .lab-tree__node:not([data-lab-hidden])");
|
|
228
294
|
if (!anyShown && tree.children.length) {
|
|
@@ -369,4 +435,34 @@ if (thumbs.length) {
|
|
|
369
435
|
}
|
|
370
436
|
}
|
|
371
437
|
|
|
438
|
+
// ---- the dev toolbar inside frames --------------------------------------------------------------
|
|
439
|
+
// The dev server puts Astro's toolbar in every page it serves, framed ones included, so without
|
|
440
|
+
// this a lab page shows a second, scaled toolbar over whatever it frames. Decided 2026-09-24: the
|
|
441
|
+
// lab page's own toolbar is the only one — its pin picker already descends into same-origin
|
|
442
|
+
// frames. The bare story hides its own before first paint (../ui/components/preview-layout.astro);
|
|
443
|
+
// this covers the frames the lab does not render itself — the site's pages in the Pages view and
|
|
444
|
+
// its cards, and any page a framed link navigates to. `load` does not bubble, so it is caught on
|
|
445
|
+
// the way down, once per navigation of every frame, present or added later; a frame that finished
|
|
446
|
+
// loading before this module ran is swept once here.
|
|
447
|
+
const hideFramedToolbar = (frame: HTMLIFrameElement) => {
|
|
448
|
+
try {
|
|
449
|
+
const doc = frame.contentDocument;
|
|
450
|
+
if (!doc?.head || doc.getElementById("lab-hide-dev-toolbar")) return;
|
|
451
|
+
const style = doc.createElement("style");
|
|
452
|
+
style.id = "lab-hide-dev-toolbar";
|
|
453
|
+
style.textContent = "astro-dev-toolbar { display: none !important; }";
|
|
454
|
+
doc.head.append(style);
|
|
455
|
+
} catch {
|
|
456
|
+
/* cross-origin — not a lab frame */
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
document.addEventListener(
|
|
460
|
+
"load",
|
|
461
|
+
(event) => {
|
|
462
|
+
if (event.target instanceof HTMLIFrameElement) hideFramedToolbar(event.target);
|
|
463
|
+
},
|
|
464
|
+
true,
|
|
465
|
+
);
|
|
466
|
+
for (const frame of document.querySelectorAll("iframe")) hideFramedToolbar(frame);
|
|
467
|
+
|
|
372
468
|
export {};
|