@orbytes/astrolab 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/LICENSE +37 -0
  2. package/README.md +410 -0
  3. package/bin/lab-cull.mjs +401 -0
  4. package/bin/pin-gallery.mjs +121 -0
  5. package/defaults.mjs +120 -0
  6. package/dist/core/astro-integration.js +130 -0
  7. package/dist/core/index.js +6 -0
  8. package/dist/core/lib-paths.js +29 -0
  9. package/dist/core/options.js +173 -0
  10. package/dist/core/utils/get-exports.js +52 -0
  11. package/dist/core/utils/invariant.js +12 -0
  12. package/dist/core/utils/kebab-case.js +15 -0
  13. package/dist/core/utils/path-builder.js +22 -0
  14. package/dist/core/utils/path.js +53 -0
  15. package/dist/core/virtual-module/get-story-modules.js +60 -0
  16. package/dist/core/virtual-module/story-modules.js +8 -0
  17. package/dist/core/virtual-module/virtual-module-ids.js +22 -0
  18. package/dist/core/virtual-module/virtual-routes.js +83 -0
  19. package/dist/core/virtual-module/vite-plugin.js +98 -0
  20. package/docs/PIN-CONTRACT.md +263 -0
  21. package/docs/PIN.md +429 -0
  22. package/index.d.ts +279 -0
  23. package/index.mjs +347 -0
  24. package/package.json +93 -0
  25. package/src/Empty.astro +4 -0
  26. package/src/Home.astro +298 -0
  27. package/src/LabHead.astro +1102 -0
  28. package/src/core/LICENSE-astrobook +166 -0
  29. package/src/core/astro-integration.ts +166 -0
  30. package/src/core/client.ts +89 -0
  31. package/src/core/index.ts +7 -0
  32. package/src/core/lib/components/empty.astro +1 -0
  33. package/src/core/lib/components/head.astro +1 -0
  34. package/src/core/lib/components/home.astro +8 -0
  35. package/src/core/lib/components/with-decorators.astro +22 -0
  36. package/src/core/lib/pages/app.astro +19 -0
  37. package/src/core/lib/pages/preview.astro +17 -0
  38. package/src/core/lib/pages/story.astro +16 -0
  39. package/src/core/lib-paths.ts +72 -0
  40. package/src/core/options.ts +262 -0
  41. package/src/core/utils/get-exports.ts +59 -0
  42. package/src/core/utils/invariant.ts +13 -0
  43. package/src/core/utils/kebab-case.ts +30 -0
  44. package/src/core/utils/path-builder.ts +45 -0
  45. package/src/core/utils/path.ts +80 -0
  46. package/src/core/virtual-module/get-story-modules.ts +110 -0
  47. package/src/core/virtual-module/story-modules.ts +9 -0
  48. package/src/core/virtual-module/virtual-module-ids.ts +17 -0
  49. package/src/core/virtual-module/virtual-routes.ts +130 -0
  50. package/src/core/virtual-module/vite-plugin.ts +125 -0
  51. package/src/pin/board.mjs +1521 -0
  52. package/src/pin/index.mjs +666 -0
  53. package/src/pin/shot.mjs +427 -0
  54. package/src/pin/source-stamp.mjs +159 -0
  55. package/src/pin/tickets.mjs +697 -0
  56. package/src/pin/toolbar.js +3181 -0
  57. package/src/shell/Browse.astro +371 -0
  58. package/src/shell/CardGrid.astro +297 -0
  59. package/src/shell/Viewport.astro +1330 -0
  60. package/src/shell/index.json.ts +12 -0
  61. package/src/shell/lab-index.ts +344 -0
  62. package/src/shell/lab-params.ts +245 -0
  63. package/src/shell/live-files.mjs +164 -0
  64. package/src/shell/marks.mjs +136 -0
  65. package/src/types/index.ts +6 -0
  66. package/src/types/types.ts +239 -0
  67. package/src/types/virtual.d.ts +29 -0
  68. package/src/ui/components/app.astro +13 -0
  69. package/src/ui/components/build-path.ts +13 -0
  70. package/src/ui/components/build-tree.ts +108 -0
  71. package/src/ui/components/collapse-duration.ts +28 -0
  72. package/src/ui/components/compress-terms.ts +10 -0
  73. package/src/ui/components/dashboard-layout.astro +39 -0
  74. package/src/ui/components/home.astro +65 -0
  75. package/src/ui/components/layout.astro +110 -0
  76. package/src/ui/components/preview-layout.astro +109 -0
  77. package/src/ui/components/sidebar-button-fullscreen.astro +38 -0
  78. package/src/ui/components/sidebar-button-search.astro +23 -0
  79. package/src/ui/components/sidebar-button-theme.astro +9 -0
  80. package/src/ui/components/sidebar-button.astro +24 -0
  81. package/src/ui/components/sidebar-resize-handle.astro +74 -0
  82. package/src/ui/components/sidebar-search-panel.astro +41 -0
  83. package/src/ui/components/sidebar-search-script.ts +103 -0
  84. package/src/ui/components/sidebar-title.astro +17 -0
  85. package/src/ui/components/sidebar-tree-node.astro +143 -0
  86. package/src/ui/components/sidebar-tree.astro +84 -0
  87. package/src/ui/components/sidebar.astro +29 -0
  88. package/src/ui/components/theme-message.ts +26 -0
  89. package/src/ui/components/theme-script.astro +71 -0
  90. package/src/ui/components/theme-toggle.astro +63 -0
  91. package/src/ui/components/theme.ts +32 -0
  92. package/src/ui/index.ts +4 -0
  93. package/src/ui/lab.css +549 -0
  94. package/virtual.d.ts +42 -0
@@ -0,0 +1,12 @@
1
+ // <subpath>/index.json — the lab index as a static file, injected by ../../index.mjs and
2
+ // prerendered like every other lab route. Every story: id, URLs, files, tier/section/version, live
3
+ // slot, used-by, tags, summary — so worker briefs and scripts can read this instead of globbing.
4
+ import type { APIRoute } from "astro";
5
+ import { buildLabIndex } from "./lab-index";
6
+
7
+ export const prerender = true;
8
+
9
+ export const GET: APIRoute = () =>
10
+ new Response(`${JSON.stringify(buildLabIndex(), null, 2)}\n`, {
11
+ headers: { "Content-Type": "application/json; charset=utf-8" },
12
+ });
@@ -0,0 +1,344 @@
1
+ // The lab index — every story in the lab, classified, with its live status derived from the site's
2
+ // pages. Built server-side at build time (or per request in dev); consumed by Home.astro, the
3
+ // folder pages (Browse.astro) and the static <subpath>/index.json endpoint.
4
+ //
5
+ // Data sources, in order of authority:
6
+ // 1. `virtual:astrobook/story-modules.mjs` — Astrobook's own module list (id, name, directory,
7
+ // importPath, stories). Using it rather than re-deriving ids means our URLs can never drift
8
+ // from the ones Astrobook actually routes.
9
+ // 2. `virtual:astrobook/global-config.mjs` — the dashboard and story base paths.
10
+ // 3. `virtual:orbytes-lab/config.mjs` — the lab's own resolved options (../../index.mjs).
11
+ // 4. src/pages/**.astro, through live-files.mjs — what is live, and on which page. Derived,
12
+ // never declared. Every page is walked, not just the home page (2026-09-06), so the pill
13
+ // survives a multi-page site.
14
+ // 5. <directory>/responsive.json, through marks.mjs — the hand-set responsive marks. The one
15
+ // thing here that is ticked rather than derived, because approval cannot be read from code.
16
+ // 6. The stories files themselves — the component they render, the leading comment (summary)
17
+ // and, through import.meta.glob, the default export's optional `meta.tags`.
18
+ //
19
+ // Classification is by Astrobook `directory` (relative to its `directory` option):
20
+ // <tier>/<Section>/<Version>/… tier = first segment; for the tier holding the RESPONSIVE role
21
+ // (by default "sections") the second segment is the section and
22
+ // the third the version (V1, V2 …).
23
+ // Anything else classifies as tier = first segment with no section or version.
24
+ /// <reference path="../types/virtual.d.ts" />
25
+ /// <reference path="../../virtual.d.ts" />
26
+ import { fileURLToPath } from "node:url";
27
+ import { existsSync, readFileSync } from "node:fs";
28
+ import path from "node:path";
29
+ import { root } from "astro:config/server";
30
+ import storyModules from "virtual:astrobook/story-modules.mjs";
31
+ import astrobookConfig from "virtual:astrobook/global-config.mjs";
32
+ import {
33
+ defaultImports,
34
+ liveComponentFiles,
35
+ storyComponentFile,
36
+ toRepoRelative,
37
+ } from "./live-files.mjs";
38
+ import { readResponsive } from "./marks.mjs";
39
+ import labConfig from "virtual:orbytes-lab/config.mjs";
40
+
41
+ /**
42
+ * The deployed lab — the real minified build, which a dev server is not. Null when the consumer
43
+ * set no `stagingUrl`, and then nothing links to it.
44
+ */
45
+ export const STAGING_URL = labConfig.stagingUrl;
46
+
47
+ /** One page a component is mounted on. */
48
+ export interface LiveMount {
49
+ /** the page's route: "/" for src/pages/index.astro, "/about" for about.astro */
50
+ page: string;
51
+ /** 1-based position among the components that page mounts */
52
+ slot: number;
53
+ /** how many components that page mounts */
54
+ of: number;
55
+ }
56
+
57
+ export interface LabItem {
58
+ storyId: string;
59
+ storyName: string;
60
+ moduleId: string;
61
+ moduleName: string;
62
+ /** repo-relative, e.g. src/lab/explorations/KeycapButton.stories.ts */
63
+ moduleFile: string;
64
+ /** the .astro the stories file imports as `component`, repo-relative */
65
+ componentFile: string | null;
66
+ directory: string;
67
+ tier: string;
68
+ section: string | null;
69
+ version: string | null;
70
+ dashboardUrl: string;
71
+ storyUrl: string;
72
+ /** the primary mount — the home page's when there is one, else the first page in route order */
73
+ live: { slot: number; of: number } | null;
74
+ /** the page `live` is on ("/" for the home page); null when the component is not mounted */
75
+ livePage: string | null;
76
+ /** every page that mounts it — one entry on a normal site, more for shared chrome */
77
+ liveOn: LiveMount[];
78
+ /** section versions only (tier "sections"); null everywhere the mark does not apply */
79
+ responsive: { done: boolean; approved: boolean } | null;
80
+ /** the same story on the deployed lab; null when no `stagingUrl` is configured */
81
+ stagingUrl: string | null;
82
+ /** vscode://file/<absolute path> to the component; null in a build — the link is dev-only */
83
+ editorUrl: string | null;
84
+ /** repo-relative files of LIVE section components that import this item's componentFile */
85
+ usedBy: string[];
86
+ tags: string[];
87
+ summary: string | null;
88
+ }
89
+
90
+ export interface LabFolder {
91
+ /** e.g. sections/Section02Manifesto */
92
+ path: string;
93
+ name: string;
94
+ /** child folder paths */
95
+ children: string[];
96
+ /** storyIds directly inside */
97
+ items: string[];
98
+ /** distinct live component files anywhere under this folder */
99
+ liveCount: number;
100
+ /** section versions under this folder: how many are marked responsive, out of how many */
101
+ responsive: { done: number; of: number };
102
+ }
103
+
104
+ export interface LabIndex {
105
+ generatedAt: string;
106
+ items: LabItem[];
107
+ folders: LabFolder[];
108
+ live: { file: string; page: string; slot: number; of: number }[];
109
+ }
110
+
111
+ // Only for the optional `meta` on a story module's default export — Astrobook ignores unknown
112
+ // keys, so `export default { component, meta: { tags: [...] } }` is legal and this is where it is
113
+ // read. Keys are root-relative (`/src/...`); the pattern covers both the current and target trees.
114
+ const storyExports = import.meta.glob("/src/**/*.stories.ts", { eager: true }) as Record<
115
+ string,
116
+ { default?: { meta?: { tags?: unknown } } } | undefined
117
+ >;
118
+
119
+ const tagsOf = (moduleFile: string): string[] => {
120
+ const tags = storyExports[`/${moduleFile}`]?.default?.meta?.tags;
121
+ return Array.isArray(tags) ? tags.filter((t): t is string => typeof t === "string") : [];
122
+ };
123
+
124
+ // First sentence of the leading `//` comment block — the stories files already open with the
125
+ // section's provenance in one line, so that is the summary.
126
+ const summaryOf = (rootDir: string, moduleFile: string): string | null => {
127
+ const abs = path.join(rootDir, moduleFile);
128
+ if (!existsSync(abs)) return null;
129
+ const lines: string[] = [];
130
+ for (const line of readFileSync(abs, "utf8").split(/\r?\n/)) {
131
+ if (!line.startsWith("//")) break;
132
+ lines.push(line.replace(/^\/\/\s?/, ""));
133
+ }
134
+ const text = lines.join(" ").replace(/\s+/g, " ").trim();
135
+ if (!text) return null;
136
+ const sentence = text.match(/^(.+?[.!?])(?:\s|$)/);
137
+ return (sentence ? sentence[1] : text).trim();
138
+ };
139
+
140
+ const classify = (directory: string) => {
141
+ const parts = directory ? directory.split("/") : [];
142
+ const tier = parts[0] ?? "";
143
+ const sectional = tier === "sections";
144
+ return {
145
+ tier,
146
+ section: sectional && parts[1] ? parts[1] : null,
147
+ version: sectional && parts[2] ? parts[2] : null,
148
+ };
149
+ };
150
+
151
+ const trimSlash = (p: string) => p.replace(/\/+$/, "");
152
+
153
+ /** The lab's URL base (`/lab`), read from Astrobook's resolved config rather than repeated. */
154
+ export const labBase = trimSlash(astrobookConfig.astrobookBase);
155
+
156
+ export function buildLabIndex(): LabIndex {
157
+ const rootDir = fileURLToPath(root);
158
+ const live = liveComponentFiles(rootDir);
159
+
160
+ // Mounts grouped by component file, in page order — [0] is the primary one (the home page's,
161
+ // when the component is on the home page, because sitePages() puts it first).
162
+ const mountsByFile = new Map<string, LiveMount[]>();
163
+ for (const mount of live) {
164
+ const list = mountsByFile.get(mount.file) ?? [];
165
+ list.push({ page: mount.page, slot: mount.slot, of: mount.of });
166
+ mountsByFile.set(mount.file, list);
167
+ }
168
+
169
+ // usedBy: one level down from every live component — whatever it imports is "used by" it.
170
+ const usedBy = new Map<string, string[]>();
171
+ for (const file of mountsByFile.keys()) {
172
+ for (const imp of defaultImports(rootDir, file)) {
173
+ if (imp.file === file) continue;
174
+ const list = usedBy.get(imp.file) ?? [];
175
+ if (!list.includes(file)) list.push(file);
176
+ usedBy.set(imp.file, list);
177
+ }
178
+ }
179
+
180
+ const marks = readResponsive(rootDir, labConfig);
181
+ const doneMarks = new Set(marks.done);
182
+ const approvedMarks = new Set(marks.approved);
183
+
184
+ // The editor link is a local absolute path, so it is built in dev only — both because a staging
185
+ // visitor has no such file and because the path has no business in a deployed page. VS Code's
186
+ // form is `vscode://file/<absolute path>`, and on POSIX the absolute path supplies that slash.
187
+ const editorUrlOf = (file: string | null): string | null => {
188
+ if (!import.meta.env.DEV || !file) return null;
189
+ const abs = path.join(rootDir, file).split(path.sep).join("/");
190
+ return `vscode://file${abs.startsWith("/") ? "" : "/"}${abs}`;
191
+ };
192
+
193
+ const dashboardBase = trimSlash(astrobookConfig.dashboardBase);
194
+ const storyBase = trimSlash(astrobookConfig.storyBase);
195
+
196
+ const items: LabItem[] = [];
197
+ for (const mod of storyModules) {
198
+ const moduleFile = toRepoRelative(rootDir, mod.importPath);
199
+ const componentFile = storyComponentFile(rootDir, moduleFile);
200
+ const liveOn = (componentFile ? mountsByFile.get(componentFile) : undefined) ?? [];
201
+ const primary = liveOn[0];
202
+ const { tier, section, version } = classify(mod.directory);
203
+ const tags = tagsOf(moduleFile);
204
+ const summary = summaryOf(rootDir, moduleFile);
205
+ const responsive = RESPONSIVE_DIR && moduleFile.startsWith(RESPONSIVE_DIR)
206
+ ? { done: doneMarks.has(moduleFile), approved: approvedMarks.has(moduleFile) }
207
+ : null;
208
+ for (const story of mod.stories) {
209
+ items.push({
210
+ storyId: story.id,
211
+ storyName: story.name,
212
+ moduleId: mod.id,
213
+ moduleName: mod.name,
214
+ moduleFile,
215
+ componentFile,
216
+ directory: mod.directory,
217
+ tier,
218
+ section,
219
+ version,
220
+ dashboardUrl: `${dashboardBase}/${story.id}`,
221
+ storyUrl: `${storyBase}/${story.id}`,
222
+ live: primary ? { slot: primary.slot, of: primary.of } : null,
223
+ livePage: primary ? primary.page : null,
224
+ liveOn,
225
+ responsive,
226
+ stagingUrl: STAGING_URL ? `${STAGING_URL}${storyBase}/${story.id}` : null,
227
+ editorUrl: editorUrlOf(componentFile ?? moduleFile),
228
+ usedBy: componentFile ? (usedBy.get(componentFile) ?? []) : [],
229
+ tags,
230
+ summary,
231
+ });
232
+ }
233
+ }
234
+
235
+ // Folders: every prefix of every module directory. Items sit in their own directory; liveCount
236
+ // counts distinct live component files anywhere beneath, so a section folder reads "1 live"
237
+ // however many stories its live version has.
238
+ const folders = new Map<string, LabFolder>();
239
+ const ensure = (folderPath: string): LabFolder => {
240
+ let folder = folders.get(folderPath);
241
+ if (!folder) {
242
+ folder = {
243
+ path: folderPath,
244
+ name: folderPath.split("/").pop() ?? folderPath,
245
+ children: [],
246
+ items: [],
247
+ liveCount: 0,
248
+ responsive: { done: 0, of: 0 },
249
+ };
250
+ folders.set(folderPath, folder);
251
+ const parent = folderPath.includes("/") ? folderPath.slice(0, folderPath.lastIndexOf("/")) : "";
252
+ if (parent) {
253
+ const parentFolder = ensure(parent);
254
+ if (!parentFolder.children.includes(folderPath)) parentFolder.children.push(folderPath);
255
+ }
256
+ }
257
+ return folder;
258
+ };
259
+ for (const item of items) {
260
+ if (!item.directory) continue;
261
+ ensure(item.directory).items.push(item.storyId);
262
+ }
263
+ for (const folder of folders.values()) {
264
+ const under = items.filter(
265
+ (item) => item.directory === folder.path || item.directory.startsWith(`${folder.path}/`),
266
+ );
267
+ folder.liveCount = new Set(
268
+ under.filter((item) => item.live && item.componentFile).map((item) => item.componentFile),
269
+ ).size;
270
+ // Counted per version, not per story: two stories of one section version are one version.
271
+ const versions = new Map(
272
+ under.filter((item) => item.responsive).map((item) => [item.moduleFile, item.responsive!]),
273
+ );
274
+ folder.responsive = {
275
+ done: [...versions.values()].filter((mark) => mark.done).length,
276
+ of: versions.size,
277
+ };
278
+ folder.children.sort();
279
+ }
280
+
281
+ return {
282
+ generatedAt: new Date().toISOString(),
283
+ items,
284
+ folders: [...folders.values()].sort((a, b) => a.path.localeCompare(b.path)),
285
+ live,
286
+ };
287
+ }
288
+
289
+ // Shared helpers for the pages, so the ordering and grouping logic lives once.
290
+
291
+ /** The configured tiers first, in the order they were declared; anything else after, alphabetically. */
292
+ export const TIER_ORDER = labConfig.tiers.map((tier) => tier.id);
293
+
294
+ const TIER_LABELS = new Map(labConfig.tiers.map((tier) => [tier.id, tier.label]));
295
+
296
+ /** The directory the responsive marks are restricted to, e.g. `src/lab/sections/`. */
297
+ const RESPONSIVE_DIR = labConfig.responsiveDir;
298
+
299
+ export const sortTiers = (tiers: string[]): string[] =>
300
+ [...tiers].sort((a, b) => {
301
+ const ia = TIER_ORDER.indexOf(a);
302
+ const ib = TIER_ORDER.indexOf(b);
303
+ if (ia !== -1 || ib !== -1) return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
304
+ return a.localeCompare(b);
305
+ });
306
+
307
+ export const tierLabel = (tier: string): string =>
308
+ TIER_LABELS.get(tier) ?? (tier ? tier.charAt(0).toUpperCase() + tier.slice(1) : "Root");
309
+
310
+ /** Distinct live component files among a set of items. */
311
+ export const liveCountOf = (items: LabItem[]): number =>
312
+ new Set(items.filter((i) => i.live && i.componentFile).map((i) => i.componentFile)).size;
313
+
314
+ /** Items directly in a folder or anywhere beneath it. */
315
+ export const itemsUnder = (index: LabIndex, folderPath: string): LabItem[] =>
316
+ index.items.filter(
317
+ (item) => item.directory === folderPath || item.directory.startsWith(`${folderPath}/`),
318
+ );
319
+
320
+ /**
321
+ * The live pill's text. A single-page site reads exactly as it always did — `live · slot 3 of 12`
322
+ * — and only a page that is not the home page names itself: `live · /about, slot 3`.
323
+ * The sidebar script in LabHead.astro carries the same three lines; keep the two in step.
324
+ */
325
+ export const livePillText = (item: LabItem): string | null => {
326
+ if (!item.live) return null;
327
+ return item.livePage && item.livePage !== "/"
328
+ ? `live · ${item.livePage}, slot ${item.live.slot}`
329
+ : `live · slot ${item.live.slot} of ${item.live.of}`;
330
+ };
331
+
332
+ /** Every page a component is live on, one per line — the live pill's tooltip. */
333
+ export const liveTitle = (item: LabItem): string | undefined =>
334
+ item.liveOn.length > 1
335
+ ? item.liveOn.map((mount) => `${mount.page} · slot ${mount.slot} of ${mount.of}`).join("\n")
336
+ : undefined;
337
+
338
+ /**
339
+ * Whether a story renders at page width — in a thumbnail and as the viewport's default frame.
340
+ * True for the tier holding the RESPONSIVE role: a section version is the one thing in the lab
341
+ * that IS a page, which is also why it is the only thing a responsive mark applies to.
342
+ */
343
+ export const isSectionLike = (item: LabItem): boolean =>
344
+ labConfig.sectionsTier !== null && item.tier === labConfig.sectionsTier;
@@ -0,0 +1,245 @@
1
+ /* =============================================================================================
2
+ lab-params.ts — the parameters-panel contract for the viewport lab.
3
+
4
+ Consumers import it as `@orbytes/astrolab/params`; inside this package the path is relative.
5
+
6
+ WHAT THIS IS. Ruled 2026-09-06: on request, a component bakes in a parameters panel to
7
+ configure it, and the configurator for those parameters is installed right below the top
8
+ navigation in the viewport lab for each section. The panel is available only to the specific
9
+ sections or experiments that have parameters to change. For most sections, it is not needed
10
+ at all.
11
+
12
+ So: a component that has something to tune DECLARES it here and renders nothing. The panel is
13
+ drawn by the viewport lab's chrome (./Viewport.astro), between the top bar and the
14
+ stage — and only there. The same story at <subpath>/stories/<id> (the bare render) and on the
15
+ dashboard shows no panel at all, because nothing in this module touches the DOM.
16
+
17
+ HOW IT FITS TOGETHER. This module runs INSIDE the story document (the iframe). It keeps the
18
+ registered groups on `window.__labParams` and announces each one with a `lab:params` event on
19
+ that same window. The viewport page is the story's parent and same-origin, so it reads that
20
+ array directly, builds the UI, and calls the group's own `apply()` across the frame boundary.
21
+ Nothing is serialised, nothing is posted: `values` is one live object that both sides hold.
22
+
23
+ PERSISTENCE. Values are stored per group in `localStorage` under `lab-params:<group id>`, so a
24
+ tuning survives a reload and a prev/next walk. Reset clears the key and restores the declared
25
+ defaults. `storageKey` overrides the key and exists for ONE reason — a panel inheriting values
26
+ from an older hand-rolled panel (the four glass libraries keep their `glass96:<lib>` keys so
27
+ saved tunings survive the port). New panels leave it out.
28
+
29
+ ---------------------------------------------------------------------------------------------
30
+ ADDING A PANEL TO A COMPONENT — the whole recipe.
31
+
32
+ In the component's client `<script>`, set up whatever the panel drives FIRST, or declare that
33
+ state with `let` before you register: `registerLabParams` calls `apply()` once, synchronously,
34
+ with the restored values, so `apply` must tolerate being called before the thing it configures
35
+ exists. Then:
36
+
37
+ import { registerLabParams } from "@orbytes/astrolab/params";
38
+
39
+ const shader = document.querySelector<HTMLCanvasElement>(".hero__shader");
40
+ let uniforms: Uniforms | null = null; // filled in below; apply() tolerates null
41
+
42
+ const panel = registerLabParams({
43
+ id: "hero-shader", // stable — it is the storage key
44
+ title: "hero shader",
45
+ source: { href: "https://github.com/x/y", label: "github.com/x/y · MIT" },
46
+ note: "Uniforms are read every frame; nothing here is on the live page.",
47
+ controls: [
48
+ { kind: "range", id: "speed", label: "Speed", min: 0, max: 4, step: 0.05, value: 1, unit: "×" },
49
+ { kind: "toggle", id: "grain", label: "Grain", value: true },
50
+ { kind: "select", id: "blend", label: "Blend", value: "screen",
51
+ options: [{ value: "screen", label: "screen" }, { value: "add", label: "add" }] },
52
+ { kind: "color", id: "tint", label: "Tint", value: "#2e5bff" },
53
+ ],
54
+ apply(values) {
55
+ if (!uniforms) return;
56
+ uniforms.speed = Number(values.speed);
57
+ uniforms.grain = Boolean(values.grain);
58
+ uniforms.blend = String(values.blend);
59
+ uniforms.tint = String(values.tint);
60
+ },
61
+ });
62
+
63
+ const v = panel.values; // LIVE — mutated in place, so a render loop can hold it
64
+ uniforms = start(shader, v);
65
+ panel.status("WebGL2 · 0.8 ms/frame"); // one line under the controls, push it whenever
66
+
67
+ That is all. Reset, Copy settings, the collapse chevron, the readouts, the units and the
68
+ storage are the panel's job, not the component's.
69
+ ============================================================================================= */
70
+
71
+ export type LabParamValue = number | boolean | string;
72
+ export type LabParamValues = Record<string, LabParamValue>;
73
+
74
+ /** One row of the panel. `id` is the key in `values`; `note` is shown on hover. */
75
+ export type LabParamControl =
76
+ | {
77
+ kind: "range";
78
+ id: string;
79
+ label: string;
80
+ min: number;
81
+ max: number;
82
+ step?: number;
83
+ value: number;
84
+ unit?: string;
85
+ note?: string;
86
+ }
87
+ | { kind: "toggle"; id: string; label: string; value: boolean; note?: string }
88
+ | {
89
+ kind: "select";
90
+ id: string;
91
+ label: string;
92
+ value: string;
93
+ options: { value: string; label: string }[];
94
+ note?: string;
95
+ }
96
+ | { kind: "color"; id: string; label: string; value: string; note?: string };
97
+
98
+ export interface LabParamGroup {
99
+ /** Stable, and the localStorage key suffix. One group per thing being tuned. */
100
+ id: string;
101
+ /** Shown as the group's heading — "liquid-glass-webgl". */
102
+ title: string;
103
+ /** Where the code came from and under what terms — rendered as a link. */
104
+ source?: { href: string; label: string };
105
+ /** One line of explanation, shown small beside the title. */
106
+ note?: string;
107
+ controls: LabParamControl[];
108
+ /**
109
+ * Push the whole value set into whatever it configures. Called once at registration with the
110
+ * restored values, and again on every change — so it must be idempotent and must survive being
111
+ * called before the component has finished setting itself up.
112
+ */
113
+ apply(values: LabParamValues): void;
114
+ /** Extra work when Reset is pressed — the values are already back at their defaults. */
115
+ reset?(): void;
116
+ /**
117
+ * LEGACY ONLY. Overrides the storage key so a panel can inherit an older panel's saved values.
118
+ * New panels omit it and get `lab-params:<id>`.
119
+ */
120
+ storageKey?: string;
121
+ }
122
+
123
+ /** What the component gets back. */
124
+ export interface LabParamHandle {
125
+ /** Live values — mutated in place, so a render loop can just hold the reference. */
126
+ values: LabParamValues;
127
+ /** One line of status under the controls (WebGL state, frame cost, an error). */
128
+ status(text: string): void;
129
+ }
130
+
131
+ /** What the viewport panel consumes off `window.__labParams`. The component never needs this. */
132
+ export interface LabParamEntry extends LabParamHandle {
133
+ group: LabParamGroup;
134
+ storageKey: string;
135
+ /** The declared defaults — what Reset restores. */
136
+ defaults: LabParamValues;
137
+ /** The last status pushed, so a panel built after the component started still shows it. */
138
+ lastStatus: string;
139
+ /** Set by the panel; called on every `status()`. */
140
+ onStatus: ((text: string) => void) | null;
141
+ /** Write one control, persist, and apply. */
142
+ set(id: string, value: LabParamValue): void;
143
+ /** Restore the declared defaults, clear storage, apply, and run the group's own `reset`. */
144
+ resetAll(): void;
145
+ /** The current values as pretty JSON — what "Copy settings" puts on the clipboard. */
146
+ json(): string;
147
+ }
148
+
149
+ declare global {
150
+ interface Window {
151
+ /** Every group registered by this document, in registration order. */
152
+ __labParams?: LabParamEntry[];
153
+ }
154
+ interface WindowEventMap {
155
+ "lab:params": CustomEvent<LabParamEntry>;
156
+ }
157
+ }
158
+
159
+ const STORE_PREFIX = "lab-params:";
160
+
161
+ const readStore = (key: string): Record<string, unknown> => {
162
+ try {
163
+ const raw = JSON.parse(localStorage.getItem(key) || "{}");
164
+ return raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
165
+ } catch {
166
+ return {};
167
+ }
168
+ };
169
+
170
+ /** A stored value is only accepted if it still fits the control that declared it. */
171
+ const coerce = (control: LabParamControl, raw: unknown): LabParamValue | undefined => {
172
+ switch (control.kind) {
173
+ case "range": {
174
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return undefined;
175
+ return Math.min(control.max, Math.max(control.min, raw));
176
+ }
177
+ case "toggle":
178
+ return typeof raw === "boolean" ? raw : undefined;
179
+ case "select":
180
+ return typeof raw === "string" && control.options.some((o) => o.value === raw) ? raw : undefined;
181
+ case "color":
182
+ return typeof raw === "string" && /^#[0-9a-f]{3,8}$/i.test(raw) ? raw : undefined;
183
+ }
184
+ };
185
+
186
+ export function registerLabParams(group: LabParamGroup): LabParamHandle {
187
+ const storageKey = group.storageKey ?? `${STORE_PREFIX}${group.id}`;
188
+
189
+ const defaults: LabParamValues = {};
190
+ for (const control of group.controls) defaults[control.id] = control.value;
191
+
192
+ const values: LabParamValues = { ...defaults };
193
+ const stored = readStore(storageKey);
194
+ for (const control of group.controls) {
195
+ const restored = coerce(control, stored[control.id]);
196
+ if (restored !== undefined) values[control.id] = restored;
197
+ }
198
+
199
+ const save = () => {
200
+ try {
201
+ localStorage.setItem(storageKey, JSON.stringify(values));
202
+ } catch {
203
+ /* private mode, quota — the panel still works for the session */
204
+ }
205
+ };
206
+
207
+ const entry: LabParamEntry = {
208
+ group,
209
+ storageKey,
210
+ defaults,
211
+ values,
212
+ lastStatus: "",
213
+ onStatus: null,
214
+ status(text) {
215
+ entry.lastStatus = text;
216
+ entry.onStatus?.(text);
217
+ },
218
+ set(id, value) {
219
+ values[id] = value;
220
+ save();
221
+ group.apply(values);
222
+ },
223
+ resetAll() {
224
+ Object.assign(values, defaults);
225
+ try {
226
+ localStorage.removeItem(storageKey);
227
+ } catch {
228
+ /* ignore */
229
+ }
230
+ group.apply(values);
231
+ group.reset?.();
232
+ },
233
+ json() {
234
+ return JSON.stringify(values, null, 2);
235
+ },
236
+ };
237
+
238
+ (window.__labParams ??= []).push(entry);
239
+ // Apply the restored values before anyone is told the group exists, so the first frame a
240
+ // viewer sees is already the tuning that was saved.
241
+ group.apply(values);
242
+ window.dispatchEvent(new CustomEvent("lab:params", { detail: entry }));
243
+
244
+ return entry;
245
+ }