@orbytes/astrolab 0.3.0 → 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 +261 -116
- package/bin/pin-gallery.mjs +53 -19
- package/defaults.mjs +72 -20
- package/dist/core/virtual-module/virtual-routes.js +12 -1
- package/docs/PIN-CONTRACT.md +84 -10
- package/docs/PIN.md +117 -37
- package/index.d.ts +41 -23
- package/index.mjs +46 -91
- package/package.json +7 -3
- package/src/Home.astro +166 -264
- package/src/LabHead.astro +37 -1047
- package/src/chrome/ActionsMenu.astro +97 -0
- package/src/chrome/ComponentCard.astro +76 -0
- package/src/chrome/Icon.astro +21 -0
- package/src/chrome/LICENSE-icons +43 -0
- package/src/chrome/Nav.astro +131 -0
- package/src/chrome/Panel.astro +117 -0
- package/src/chrome/Properties.astro +104 -0
- package/src/chrome/SectionsTree.astro +128 -0
- package/src/chrome/Shell.astro +120 -0
- package/src/chrome/Sprite.astro +23 -0
- package/src/chrome/StoryView.astro +192 -0
- package/src/chrome/Tree.astro +86 -0
- package/src/chrome/ViewportControls.astro +173 -0
- package/src/chrome/ViewportStage.astro +55 -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 +68 -0
- package/src/chrome/marks-client.ts +75 -0
- package/src/chrome/model.ts +156 -0
- package/src/chrome/navbar-client.ts +324 -0
- package/src/chrome/params-client.ts +434 -0
- package/src/chrome/pins-data.ts +63 -0
- package/src/chrome/shell-client.ts +468 -0
- package/src/chrome/site-data.ts +230 -0
- package/src/chrome/trees.ts +257 -0
- package/src/chrome/viewport-client.ts +405 -0
- package/src/chrome/views/Assets.astro +125 -0
- package/src/chrome/views/Pages.astro +214 -0
- package/src/chrome/views/Placeholder.astro +37 -0
- package/src/chrome/views/Tasks.astro +79 -0
- package/src/core/LICENSE-astrobook +21 -0
- package/src/core/lib/components/home.astro +4 -2
- package/src/core/lib/pages/story.astro +12 -10
- package/src/core/utils/kebab-case.ts +2 -2
- package/src/core/virtual-module/virtual-routes.ts +20 -4
- package/src/pin/board.mjs +414 -190
- package/src/pin/index.mjs +67 -22
- package/src/pin/tickets.mjs +6 -5
- package/src/pin/toolbar.js +82 -4
- package/src/shell/Browse.astro +131 -353
- package/src/shell/Viewport.astro +22 -1315
- package/src/shell/lab-index.ts +28 -18
- 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/app.astro +5 -7
- package/src/ui/components/preview-layout.astro +17 -0
- package/src/ui/components/theme-script.astro +17 -5
- package/src/ui/lab.css +3754 -371
- package/virtual.d.ts +13 -4
- package/bin/lab-cull.mjs +0 -401
- 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,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
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
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.
|
|
8
|
+
import { labConfig } from "./model";
|
|
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
|
+
|
|
16
|
+
export interface PinCounts {
|
|
17
|
+
/** open tickets whose source file ends with the given site-relative path */
|
|
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;
|
|
23
|
+
total: number;
|
|
24
|
+
review: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
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
|
+
};
|
|
39
|
+
|
|
40
|
+
export const pinCounts = async (): Promise<PinCounts> => {
|
|
41
|
+
if (!labConfig.tasks) return none;
|
|
42
|
+
try {
|
|
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);
|
|
53
|
+
return {
|
|
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),
|
|
57
|
+
total: open.length,
|
|
58
|
+
review: open.filter((t) => t.status === READY_FOR_REVIEW).length,
|
|
59
|
+
};
|
|
60
|
+
} catch {
|
|
61
|
+
return none;
|
|
62
|
+
}
|
|
63
|
+
};
|