@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.
Files changed (82) hide show
  1. package/README.md +261 -116
  2. package/bin/pin-gallery.mjs +53 -19
  3. package/defaults.mjs +72 -20
  4. package/dist/core/virtual-module/virtual-routes.js +12 -1
  5. package/docs/PIN-CONTRACT.md +84 -10
  6. package/docs/PIN.md +117 -37
  7. package/index.d.ts +41 -23
  8. package/index.mjs +46 -91
  9. package/package.json +7 -3
  10. package/src/Home.astro +166 -264
  11. package/src/LabHead.astro +37 -1047
  12. package/src/chrome/ActionsMenu.astro +97 -0
  13. package/src/chrome/ComponentCard.astro +76 -0
  14. package/src/chrome/Icon.astro +21 -0
  15. package/src/chrome/LICENSE-icons +43 -0
  16. package/src/chrome/Nav.astro +131 -0
  17. package/src/chrome/Panel.astro +117 -0
  18. package/src/chrome/Properties.astro +104 -0
  19. package/src/chrome/SectionsTree.astro +128 -0
  20. package/src/chrome/Shell.astro +120 -0
  21. package/src/chrome/Sprite.astro +23 -0
  22. package/src/chrome/StoryView.astro +192 -0
  23. package/src/chrome/Tree.astro +86 -0
  24. package/src/chrome/ViewportControls.astro +173 -0
  25. package/src/chrome/ViewportStage.astro +55 -0
  26. package/src/chrome/fonts/OFL.txt +93 -0
  27. package/src/chrome/fonts/inter-latin-wght-normal.woff2 +0 -0
  28. package/src/chrome/icons.ts +68 -0
  29. package/src/chrome/marks-client.ts +75 -0
  30. package/src/chrome/model.ts +156 -0
  31. package/src/chrome/navbar-client.ts +324 -0
  32. package/src/chrome/params-client.ts +434 -0
  33. package/src/chrome/pins-data.ts +63 -0
  34. package/src/chrome/shell-client.ts +468 -0
  35. package/src/chrome/site-data.ts +230 -0
  36. package/src/chrome/trees.ts +257 -0
  37. package/src/chrome/viewport-client.ts +405 -0
  38. package/src/chrome/views/Assets.astro +125 -0
  39. package/src/chrome/views/Pages.astro +214 -0
  40. package/src/chrome/views/Placeholder.astro +37 -0
  41. package/src/chrome/views/Tasks.astro +79 -0
  42. package/src/core/LICENSE-astrobook +21 -0
  43. package/src/core/lib/components/home.astro +4 -2
  44. package/src/core/lib/pages/story.astro +12 -10
  45. package/src/core/utils/kebab-case.ts +2 -2
  46. package/src/core/virtual-module/virtual-routes.ts +20 -4
  47. package/src/pin/board.mjs +414 -190
  48. package/src/pin/index.mjs +67 -22
  49. package/src/pin/tickets.mjs +6 -5
  50. package/src/pin/toolbar.js +82 -4
  51. package/src/shell/Browse.astro +131 -353
  52. package/src/shell/Viewport.astro +22 -1315
  53. package/src/shell/lab-index.ts +28 -18
  54. package/src/shell/lab-params.ts +113 -6
  55. package/src/shell/live-files.mjs +212 -10
  56. package/src/shell/marks.mjs +17 -41
  57. package/src/ui/components/app.astro +5 -7
  58. package/src/ui/components/preview-layout.astro +17 -0
  59. package/src/ui/components/theme-script.astro +17 -5
  60. package/src/ui/lab.css +3754 -371
  61. package/virtual.d.ts +13 -4
  62. package/bin/lab-cull.mjs +0 -401
  63. package/src/shell/CardGrid.astro +0 -297
  64. package/src/ui/components/build-path.ts +0 -13
  65. package/src/ui/components/build-tree.ts +0 -108
  66. package/src/ui/components/collapse-duration.ts +0 -28
  67. package/src/ui/components/compress-terms.ts +0 -10
  68. package/src/ui/components/dashboard-layout.astro +0 -39
  69. package/src/ui/components/home.astro +0 -65
  70. package/src/ui/components/layout.astro +0 -110
  71. package/src/ui/components/sidebar-button-fullscreen.astro +0 -38
  72. package/src/ui/components/sidebar-button-search.astro +0 -23
  73. package/src/ui/components/sidebar-button-theme.astro +0 -9
  74. package/src/ui/components/sidebar-button.astro +0 -24
  75. package/src/ui/components/sidebar-resize-handle.astro +0 -74
  76. package/src/ui/components/sidebar-search-panel.astro +0 -41
  77. package/src/ui/components/sidebar-search-script.ts +0 -103
  78. package/src/ui/components/sidebar-title.astro +0 -17
  79. package/src/ui/components/sidebar-tree-node.astro +0 -143
  80. package/src/ui/components/sidebar-tree.astro +0 -84
  81. package/src/ui/components/sidebar.astro +0 -29
  82. package/src/ui/components/theme-toggle.astro +0 -63
@@ -13,8 +13,8 @@
13
13
  // survives a multi-page site.
14
14
  // 5. <directory>/responsive.json, through marks.mjs — the hand-set responsive marks. The one
15
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`.
16
+ // 6. The stories files themselves, read as text — the component they render, the leading
17
+ // comment (summary) and the default export's optional `meta.tags`.
18
18
  //
19
19
  // Classification is by Astrobook `directory` (relative to its `directory` option):
20
20
  // <tier>/<Section>/<Version>/… tier = first segment; for the tier holding the RESPONSIVE role
@@ -75,13 +75,14 @@ export interface LabItem {
75
75
  livePage: string | null;
76
76
  /** every page that mounts it — one entry on a normal site, more for shared chrome */
77
77
  liveOn: LiveMount[];
78
- /** section versions only (tier "sections"); null everywhere the mark does not apply */
78
+ /** section versions only (the tier holding the responsive role); null everywhere else */
79
79
  responsive: { done: boolean; approved: boolean } | null;
80
80
  /** the same story on the deployed lab; null when no `stagingUrl` is configured */
81
81
  stagingUrl: string | null;
82
82
  /** vscode://file/<absolute path> to the component; null in a build — the link is dev-only */
83
83
  editorUrl: string | null;
84
- /** repo-relative files of LIVE section components that import this item's componentFile */
84
+ /** repo-relative files that import this item's componentFile and are live themselves (a page
85
+ * mounts them, whatever their tier or folder) */
85
86
  usedBy: string[];
86
87
  tags: string[];
87
88
  summary: string | null;
@@ -108,17 +109,26 @@ export interface LabIndex {
108
109
  live: { file: string; page: string; slot: number; of: number }[];
109
110
  }
110
111
 
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") : [];
112
+ // The optional `meta.tags` on a story module's default export — Astrobook ignores unknown keys, so
113
+ // `export default { component, meta: { tags: ["cta", "v2"] } }` is legal and this is where it is
114
+ // read.
115
+ //
116
+ // READ AS TEXT, NOT IMPORTED (2026-09-24, ASTROL-12). This used to be an EAGER
117
+ // `import.meta.glob("/src/**/*.stories.ts")`, and importing a stories file imports the component it
118
+ // renders — so every chrome page's module graph held every section, and Astro put every section's
119
+ // scoped stylesheet on every chrome page (18 of them on the lab home). The tags are string
120
+ // literals in the file, so a parse of its text gives the same answer and imports nothing. A tag
121
+ // built at runtime is not seen, which a tag never needs to be.
122
+ const tagsOf = (rootDir: string, moduleFile: string): string[] => {
123
+ let source = "";
124
+ try {
125
+ source = readFileSync(path.join(rootDir, moduleFile), "utf8");
126
+ } catch {
127
+ return [];
128
+ }
129
+ const list = source.match(/\bmeta\s*:\s*\{[^}]*?\btags\s*:\s*\[([^\]]*)\]/);
130
+ if (!list) return [];
131
+ return [...list[1]!.matchAll(/["'`]([^"'`\n]+)["'`]/g)].map((m) => m[1]!);
122
132
  };
123
133
 
124
134
  // First sentence of the leading `//` comment block — the stories files already open with the
@@ -140,7 +150,7 @@ const summaryOf = (rootDir: string, moduleFile: string): string | null => {
140
150
  const classify = (directory: string) => {
141
151
  const parts = directory ? directory.split("/") : [];
142
152
  const tier = parts[0] ?? "";
143
- const sectional = tier === "sections";
153
+ const sectional = labConfig.sectionsTier !== null && tier === labConfig.sectionsTier;
144
154
  return {
145
155
  tier,
146
156
  section: sectional && parts[1] ? parts[1] : null,
@@ -200,7 +210,7 @@ export function buildLabIndex(): LabIndex {
200
210
  const liveOn = (componentFile ? mountsByFile.get(componentFile) : undefined) ?? [];
201
211
  const primary = liveOn[0];
202
212
  const { tier, section, version } = classify(mod.directory);
203
- const tags = tagsOf(moduleFile);
213
+ const tags = tagsOf(rootDir, moduleFile);
204
214
  const summary = summaryOf(rootDir, moduleFile);
205
215
  const responsive = RESPONSIVE_DIR && moduleFile.startsWith(RESPONSIVE_DIR)
206
216
  ? { done: doneMarks.has(moduleFile), approved: approvedMarks.has(moduleFile) }
@@ -320,7 +330,7 @@ export const itemsUnder = (index: LabIndex, folderPath: string): LabItem[] =>
320
330
  /**
321
331
  * The live pill's text. A single-page site reads exactly as it always did — `live · slot 3 of 12`
322
332
  * — 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.
333
+ * Every live pill in the chrome is built here; nothing else formats one.
324
334
  */
325
335
  export const livePillText = (item: LabItem): string | null => {
326
336
  if (!item.live) return null;
@@ -10,9 +10,9 @@
10
10
  at all.
11
11
 
12
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.
13
+ drawn by the lab's chrome (../chrome/params-client.ts) as a drawer on the right edge of the
14
+ component page's canvas — and only there. The same story at <subpath>/stories/<id> (the bare
15
+ render) shows no panel at all, because nothing in this module touches the DOM.
16
16
 
17
17
  HOW IT FITS TOGETHER. This module runs INSIDE the story document (the iframe). It keeps the
18
18
  registered groups on `window.__labParams` and announces each one with a `lab:params` event on
@@ -34,7 +34,7 @@
34
34
  with the restored values, so `apply` must tolerate being called before the thing it configures
35
35
  exists. Then:
36
36
 
37
- import { registerLabParams } from "@orbytes/astrolab/params";
37
+ import { LAB_UNITS, parseLabLength, registerLabParams } from "@orbytes/astrolab/params";
38
38
 
39
39
  const shader = document.querySelector<HTMLCanvasElement>(".hero__shader");
40
40
  let uniforms: Uniforms | null = null; // filled in below; apply() tolerates null
@@ -46,6 +46,7 @@
46
46
  note: "Uniforms are read every frame; nothing here is on the live page.",
47
47
  controls: [
48
48
  { kind: "range", id: "speed", label: "Speed", min: 0, max: 4, step: 0.05, value: 1, unit: "×" },
49
+ { kind: "number", id: "warp", label: "Warp", value: 24, unit: "px", units: LAB_UNITS.size },
49
50
  { kind: "toggle", id: "grain", label: "Grain", value: true },
50
51
  { kind: "select", id: "blend", label: "Blend", value: "screen",
51
52
  options: [{ value: "screen", label: "screen" }, { value: "add", label: "add" }] },
@@ -54,6 +55,7 @@
54
55
  apply(values) {
55
56
  if (!uniforms) return;
56
57
  uniforms.speed = Number(values.speed);
58
+ uniforms.warp = parseLabLength(values.warp)?.value ?? 24; // "24px" → 24
57
59
  uniforms.grain = Boolean(values.grain);
58
60
  uniforms.blend = String(values.blend);
59
61
  uniforms.tint = String(values.tint);
@@ -66,14 +68,55 @@
66
68
 
67
69
  That is all. Reset, Copy settings, the collapse chevron, the readouts, the units and the
68
70
  storage are the panel's job, not the component's.
71
+
72
+ ---------------------------------------------------------------------------------------------
73
+ SLIDER OR NUMBER? Decided 2026-09-24: a parameter that is a SIZE — a font size, a tracking, a
74
+ line height, a width, an offset, a blur radius — is never a slider. It is `kind: "number"`: a
75
+ field to type the value into, joined to a select of the units it can be expressed in (72 px,
76
+ 4.5 rem, 12.5 vw). Unless it names its own `units`, it offers px · rem · em · vw · vh · %.
77
+ Sliders stay for unitless tuning values (speed, grain, scale, opacity).
78
+
79
+ A number control hands `apply` ONE CSS-READY STRING — the number and the unit joined, "72px",
80
+ "12.5rem", or "1.05" when the unit is "" — so it drops straight into a style:
81
+
82
+ el.style.setProperty("--headline-size", String(values.headlineSize)); // "72px"
83
+
84
+ and `parseLabLength` takes it apart again when the number itself is wanted (a shader uniform,
85
+ a calculation). Changing the unit keeps the number and changes what it means; the panel never
86
+ converts between units, because px → rem depends on a root size it cannot know.
87
+
88
+ { kind: "number", id: "headlineSize", label: "Headline size", value: 72, unit: "px",
89
+ units: LAB_UNITS.size },
90
+ { kind: "number", id: "tracking", label: "Tracking", value: -0.02, unit: "em",
91
+ units: LAB_UNITS.tracking, step: 0.01 },
92
+ { kind: "number", id: "lineHeight", label: "Line height", value: 1.05, unit: "",
93
+ units: LAB_UNITS.lineHeight, step: 0.01 },
69
94
  ============================================================================================= */
70
95
 
71
96
  export type LabParamValue = number | boolean | string;
72
97
  export type LabParamValues = Record<string, LabParamValue>;
73
98
 
99
+ /**
100
+ * The unit sets a number control usually offers — pass one as `units`, or any list of your own.
101
+ * "" is "no unit" (the drawer shows it as "—"), which is what a line height is at its most
102
+ * useful. Figma writes tracking as a percentage; that is em × 100, so Figma's −2% is −0.02em.
103
+ */
104
+ export const LAB_UNITS = {
105
+ /** Font sizes, widths, offsets, radii — and the set a number control gets when it names none. */
106
+ size: ["px", "rem", "em", "vw", "vh", "%"],
107
+ /** Letter spacing. */
108
+ tracking: ["em", "px"],
109
+ /** Line height: unitless first, because a unitless line height scales with the font. */
110
+ lineHeight: ["", "px"],
111
+ } as const satisfies Record<string, readonly string[]>;
112
+
74
113
  /** One row of the panel. `id` is the key in `values`; `note` is shown on hover. */
75
114
  export type LabParamControl =
76
115
  | {
116
+ /**
117
+ * A slider, for a UNITLESS tuning value — speed, grain, scale. The value is a number and
118
+ * `unit` is only the readout's suffix ("×"). A size is a `number` control instead.
119
+ */
77
120
  kind: "range";
78
121
  id: string;
79
122
  label: string;
@@ -93,7 +136,59 @@ export type LabParamControl =
93
136
  options: { value: string; label: string }[];
94
137
  note?: string;
95
138
  }
96
- | { kind: "color"; id: string; label: string; value: string; note?: string };
139
+ | { kind: "color"; id: string; label: string; value: string; note?: string }
140
+ | {
141
+ /**
142
+ * A SIZE — a number field joined to a unit select (72 px · 4.5 rem · 12.5 vw).
143
+ *
144
+ * The value in `values` is ONE CSS-ready STRING, the number and unit joined: "72px",
145
+ * "12.5rem", "-0.02em", or "1.05" when the unit is "". Put it straight into a style, or
146
+ * take it apart with `parseLabLength`. Changing the unit keeps the number; nothing is
147
+ * converted.
148
+ */
149
+ kind: "number";
150
+ id: string;
151
+ label: string;
152
+ /** The default number, bare: 72. */
153
+ value: number;
154
+ /** The default unit — "px", "em", or "" for none. Added to `units` if it is missing. */
155
+ unit: string;
156
+ /**
157
+ * What the unit select offers, in order. Defaults to `LAB_UNITS.size` (px · rem · em · vw ·
158
+ * vh · %); a tracking wants `LAB_UNITS.tracking`, a line height `LAB_UNITS.lineHeight`.
159
+ * A list of one shows the unit without a choice.
160
+ */
161
+ units?: readonly string[];
162
+ /** What ↑ / ↓ add. Defaults to 1. */
163
+ step?: number;
164
+ /** Optional bounds, applied to typed and stored values alike. */
165
+ min?: number;
166
+ max?: number;
167
+ note?: string;
168
+ };
169
+
170
+ /** The units a number control offers: its declared `units`, with its default `unit` guaranteed. */
171
+ export const labUnitsOf = (control: Extract<LabParamControl, { kind: "number" }>): string[] => {
172
+ const units = [...(control.units ?? LAB_UNITS.size)];
173
+ return units.includes(control.unit) ? units : [control.unit, ...units];
174
+ };
175
+
176
+ /**
177
+ * "72px" → { value: 72, unit: "px" }; "1.05" → { value: 1.05, unit: "" }. Null for anything that
178
+ * is not one number followed by an optional unit — so a value from a number control always
179
+ * parses, and a stray string never quietly becomes 0.
180
+ */
181
+ export const parseLabLength = (raw: unknown): { value: number; unit: string } | null => {
182
+ if (typeof raw === "number") return Number.isFinite(raw) ? { value: raw, unit: "" } : null;
183
+ if (typeof raw !== "string") return null;
184
+ const match = /^\s*([-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?)\s*([a-z%]*)\s*$/i.exec(raw);
185
+ if (!match) return null;
186
+ const value = Number(match[1]);
187
+ return Number.isFinite(value) ? { value, unit: match[2]!.toLowerCase() } : null;
188
+ };
189
+
190
+ /** The inverse: 72 and "px" → "72px". `String()` already turns −0 into "0". */
191
+ export const formatLabLength = (value: number, unit: string): string => `${String(value)}${unit}`;
97
192
 
98
193
  export interface LabParamGroup {
99
194
  /** Stable, and the localStorage key suffix. One group per thing being tuned. */
@@ -180,14 +275,26 @@ const coerce = (control: LabParamControl, raw: unknown): LabParamValue | undefin
180
275
  return typeof raw === "string" && control.options.some((o) => o.value === raw) ? raw : undefined;
181
276
  case "color":
182
277
  return typeof raw === "string" && /^#[0-9a-f]{3,8}$/i.test(raw) ? raw : undefined;
278
+ case "number": {
279
+ // A bare number is what a slider saved. Reading it in the default unit is what lets a size
280
+ // that used to be a `range` become a `number` without losing the tuning saved for it.
281
+ const parsed = typeof raw === "number" ? parseLabLength(`${raw}${control.unit}`) : parseLabLength(raw);
282
+ if (!parsed || !labUnitsOf(control).includes(parsed.unit)) return undefined;
283
+ const value = Math.min(control.max ?? Infinity, Math.max(control.min ?? -Infinity, parsed.value));
284
+ return formatLabLength(value, parsed.unit);
285
+ }
183
286
  }
184
287
  };
185
288
 
289
+ /** A control's declared default, as `values` holds it. */
290
+ const defaultOf = (control: LabParamControl): LabParamValue =>
291
+ control.kind === "number" ? formatLabLength(control.value, control.unit) : control.value;
292
+
186
293
  export function registerLabParams(group: LabParamGroup): LabParamHandle {
187
294
  const storageKey = group.storageKey ?? `${STORE_PREFIX}${group.id}`;
188
295
 
189
296
  const defaults: LabParamValues = {};
190
- for (const control of group.controls) defaults[control.id] = control.value;
297
+ for (const control of group.controls) defaults[control.id] = defaultOf(control);
191
298
 
192
299
  const values: LabParamValues = { ...defaults };
193
300
  const stored = readStore(storageKey);
@@ -1,9 +1,8 @@
1
1
  // Which lab components are LIVE on the site — derived from the pages under src/pages/, never declared.
2
2
  //
3
- // Dependency-free Node (fs + path only) on purpose: it is imported by three consumers that cannot
4
- // share a runtime — the build-time index (./lab-index.ts, inside Vite), the dev-only mark APIs
5
- // (../../index.mjs, a Vite middleware) and the cull binary (../../bin/lab-cull.mjs, plain Node).
6
- // One parser, so the three can never disagree about what "live" means.
3
+ // Dependency-free Node (fs + path only) on purpose: it is imported by the build-time index
4
+ // (./lab-index.ts) and by the chrome's page views (../chrome/site-data.ts,
5
+ // ../chrome/views/Pages.astro). One parser, so they can never disagree about what "live" means.
7
6
  //
8
7
  // The rule: a component file is live when a page imports it AND mounts it as a tag. Slot is its
9
8
  // 1-based position among the mounted tags in that page's document order (the Footer sits outside
@@ -15,7 +14,7 @@
15
14
  // home-page-only reader reports nothing, or the wrong slot, for everything that is not on the
16
15
  // home page. Pages come home first, then alphabetically, so a component mounted on
17
16
  // several pages reports the home page's slot as its primary one.
18
- import { existsSync, readdirSync, readFileSync } from "node:fs";
17
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
19
18
  import path from "node:path";
20
19
 
21
20
  export const PAGES_DIR = "src/pages";
@@ -37,9 +36,212 @@ export const templateOf = (source) => {
37
36
  return m ? m[1] : source;
38
37
  };
39
38
 
39
+ // A page may mount a section through a tsconfig path alias (`@/lab/sections/…`) or a root-relative
40
+ // path (`/src/lab/sections/…`) as well as a relative one, and Vite resolves all three. Following only
41
+ // the relative kind reported such a section as not live (ASTROL-23). The alias reading below was
42
+ // written for the cull (ASTROL-21), which was removed before it merged.
43
+
44
+ /** tsconfig.json's dialect: JSON with comments and trailing commas. @param {string} file */
45
+ const readJsonc = (file) => {
46
+ const src = readFileSync(file, "utf8").replace(/^\uFEFF/, "");
47
+ let out = "";
48
+ for (let i = 0; i < src.length; i++) {
49
+ const ch = src[i];
50
+ if (ch === '"') {
51
+ let j = i + 1;
52
+ while (j < src.length && src[j] !== '"' && src[j] !== "\n") j += src[j] === "\\" ? 2 : 1;
53
+ out += src.slice(i, j + 1);
54
+ i = j;
55
+ } else if (ch === "/" && src[i + 1] === "/") {
56
+ while (i + 1 < src.length && src[i + 1] !== "\n") i++;
57
+ } else if (ch === "/" && src[i + 1] === "*") {
58
+ const close = src.indexOf("*/", i + 2);
59
+ i = close < 0 ? src.length : close + 1;
60
+ } else out += ch;
61
+ }
62
+ return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
63
+ };
64
+
65
+ /** @param {string} specifier */
66
+ const packageNameOf = (specifier) =>
67
+ specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0];
68
+
69
+ /** The installed package's directory, looking up from fromDir. @param {string} fromDir @param {string} name */
70
+ function packageDir(fromDir, name) {
71
+ for (let dir = fromDir; ; dir = path.dirname(dir)) {
72
+ if (existsSync(path.join(dir, "node_modules", name))) return path.join(dir, "node_modules", name);
73
+ if (path.dirname(dir) === dir) return null;
74
+ }
75
+ }
76
+
77
+ /** An `exports` value → the path it names under the conditions TypeScript reads a config with. @param {unknown} value @returns {string | null} */
78
+ const exportTarget = (value) => {
79
+ if (typeof value === "string") return value;
80
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
81
+ for (const [condition, inner] of Object.entries(value)) {
82
+ if (!["node", "require", "types", "default"].includes(condition)) continue;
83
+ const hit = exportTarget(inner);
84
+ if (hit) return hit;
85
+ }
86
+ return null;
87
+ };
88
+
89
+ /**
90
+ * The file an `extends` entry names: a path from the extending config's folder, or a package
91
+ * found from that folder — through the package's `exports` map (or its `tsconfig` field, for a
92
+ * bare name) when one matches, otherwise the path as written, with `.json` or `/tsconfig.json`.
93
+ * @param {string} dir folder of the config doing the extending @param {string} name
94
+ * @returns {string | null}
95
+ */
96
+ function extendsTarget(dir, name) {
97
+ /** @param {string} base */
98
+ const asFile = (base) =>
99
+ [base, `${base}.json`, path.join(base, "tsconfig.json")].find((c) => existsSync(c) && statSync(c).isFile()) ?? null;
100
+ if (name.startsWith(".") || path.isAbsolute(name)) return asFile(path.resolve(dir, name));
101
+ const pkgName = packageNameOf(name);
102
+ const pkg = packageDir(dir, pkgName);
103
+ if (!pkg) return null;
104
+ const subpath = name.slice(pkgName.length);
105
+ const manifestFile = path.join(pkg, "package.json");
106
+ const manifest = existsSync(manifestFile) ? readJsonc(manifestFile) : {};
107
+ const { exports } = manifest;
108
+ if (exports !== undefined && exports !== null) {
109
+ const bySubpath =
110
+ typeof exports === "object" && !Array.isArray(exports) && Object.keys(exports).some((k) => k.startsWith("."))
111
+ ? exports
112
+ : { ".": exports };
113
+ /** @type {[string, unknown][]} */
114
+ const rules = Object.entries(bySubpath).map(([key, value]) => [key, [exportTarget(value)].filter(Boolean)]);
115
+ const mapped = aliasTargets({ rules }, `.${subpath}`)?.[0];
116
+ const file = mapped && asFile(path.join(pkg, mapped));
117
+ if (file) return file;
118
+ }
119
+ if (!subpath && typeof manifest.tsconfig === "string") return asFile(path.join(pkg, manifest.tsconfig));
120
+ return asFile(path.join(pkg, subpath));
121
+ }
122
+
123
+ /**
124
+ * tsconfig.json's `compilerOptions.paths`, following `extends` (a relative path or a package, one
125
+ * or several), the child overriding the parent as TypeScript does. `base` is what the targets are
126
+ * relative to: `baseUrl` when set, otherwise the directory of the config that declared `paths`.
127
+ * `${configDir}` in either means the site's own tsconfig folder, wherever it was written.
128
+ * Throws when the config cannot be read: every alias would silently stop resolving, and a section
129
+ * mounted through one would lose its live pill with nothing to say why.
130
+ * @param {string} rootDir absolute repo root
131
+ * @returns {{ base: string; rules: [string, unknown][] }}
132
+ */
133
+ export function readAliases(rootDir) {
134
+ const file = path.join(rootDir, "tsconfig.json");
135
+ if (!existsSync(file)) return { base: rootDir, rules: [] };
136
+ /** @param {string} value */
137
+ const withConfigDir = (value) => value.replaceAll("${configDir}", path.dirname(file));
138
+ /** @type {string | null} */
139
+ let baseUrl = null;
140
+ /** @type {Record<string, unknown> | null} */
141
+ let paths = null;
142
+ /** @type {string | null} */
143
+ let pathsDir = null;
144
+ const seen = new Set();
145
+ /** @param {string} configFile */
146
+ const load = (configFile) => {
147
+ if (seen.has(configFile)) return;
148
+ seen.add(configFile);
149
+ const config = readJsonc(configFile);
150
+ const dir = path.dirname(configFile);
151
+ for (const parent of config.extends === undefined ? [] : [].concat(config.extends)) {
152
+ const name = String(parent);
153
+ const target = extendsTarget(dir, name);
154
+ if (!target) throw new Error(`its "extends": "${name}" resolves to no file`);
155
+ load(target);
156
+ }
157
+ const options = config.compilerOptions ?? {};
158
+ if (typeof options.baseUrl === "string") baseUrl = path.resolve(dir, withConfigDir(options.baseUrl));
159
+ if (options.paths && typeof options.paths === "object") {
160
+ paths = options.paths;
161
+ pathsDir = dir;
162
+ }
163
+ };
164
+ try {
165
+ load(file);
166
+ } catch (error) {
167
+ throw new Error(
168
+ `astrolab: ${toRepoRelative(rootDir, file)} could not be read (${error instanceof Error ? error.message : error}), ` +
169
+ "so no path alias can be followed and a section a page mounts through one would not show as live",
170
+ );
171
+ }
172
+ const rules = Object.entries(paths ?? {}).map(([key, targets]) =>
173
+ /** @type {[string, unknown]} */ ([key, Array.isArray(targets) ? targets.map((t) => withConfigDir(String(t))) : targets]),
174
+ );
175
+ return { base: baseUrl ?? pathsDir ?? rootDir, rules };
176
+ }
177
+
178
+ /**
179
+ * The targets `specifier` maps to under the tsconfig aliases, TypeScript's way: an exact key wins,
180
+ * then the wildcard key with the longest prefix. Null when no key matches.
181
+ * @param {{ rules: [string, unknown][] }} aliases @param {string} specifier
182
+ * @returns {string[] | null}
183
+ */
184
+ export function aliasTargets(aliases, specifier) {
185
+ /** @type {{ prefix: string; targets: string[] } | null} */
186
+ let best = null;
187
+ for (const [key, targets] of aliases.rules) {
188
+ if (!Array.isArray(targets)) continue;
189
+ const star = key.indexOf("*");
190
+ if (star < 0) {
191
+ if (key === specifier) return targets.map(String);
192
+ continue;
193
+ }
194
+ const prefix = key.slice(0, star);
195
+ const suffix = key.slice(star + 1);
196
+ if (specifier.length < prefix.length + suffix.length) continue;
197
+ if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue;
198
+ if (best && prefix.length <= best.prefix.length) continue;
199
+ const middle = specifier.slice(prefix.length, specifier.length - suffix.length);
200
+ best = { prefix, targets: targets.map((t) => String(t).replace("*", middle)) };
201
+ }
202
+ return best ? best.targets : null;
203
+ }
204
+
205
+ /** An absolute path → the file it names, the way the bundler would try it, or null. @param {string} base */
206
+ export function fileAt(base) {
207
+ const candidates = [base];
208
+ for (const ext of [".ts", ".mts", ".tsx", ".mjs", ".js", ".jsx", ".astro", ".mdx"]) candidates.push(base + ext);
209
+ if (base.endsWith(".js")) candidates.push(base.slice(0, -3) + ".ts", base.slice(0, -3) + ".tsx");
210
+ if (base.endsWith(".mjs")) candidates.push(base.slice(0, -4) + ".mts");
211
+ for (const index of ["index.ts", "index.mts", "index.mjs", "index.js"]) candidates.push(path.join(base, index));
212
+ for (const c of candidates) if (existsSync(c) && statSync(c).isFile()) return c;
213
+ return null;
214
+ }
215
+
216
+ /**
217
+ * The absolute file an import specifier names, or null when it names none in this repo (a
218
+ * package, a virtual module, a `#` subpath import, an alias with no file behind it).
219
+ * A relative specifier that matches no file keeps its plain resolved path, as it always has.
220
+ * @param {string} rootDir absolute repo root
221
+ * @param {string} fromAbs absolute path of the importing file
222
+ * @param {string} specifier
223
+ * @param {{ base: string; rules: [string, unknown][] }} [aliases] from readAliases, when reading many
224
+ * @returns {string | null}
225
+ */
226
+ export function resolveImport(rootDir, fromAbs, specifier, aliases = readAliases(rootDir)) {
227
+ if (specifier.startsWith("#")) return null;
228
+ const clean = specifier.replace(/[?#].*$/, "");
229
+ if (clean === "." || clean === ".." || clean.startsWith("./") || clean.startsWith("../")) {
230
+ const base = path.resolve(path.dirname(fromAbs), clean);
231
+ return fileAt(base) ?? base;
232
+ }
233
+ if (clean.startsWith("/")) return fileAt(path.join(rootDir, clean)) ?? fileAt(clean);
234
+ for (const target of aliasTargets(aliases, clean) ?? []) {
235
+ const file = fileAt(path.resolve(aliases.base, target));
236
+ if (file) return file;
237
+ }
238
+ return null;
239
+ }
240
+
40
241
  /**
41
- * Every `import Name from "<relative>"` in a source file, resolved to repo-relative paths.
42
- * For .astro files only the frontmatter is read; for .ts/.mjs the whole file. Package and
242
+ * Every `import Name from "<specifier>"` in a source file that names a file here — relative,
243
+ * root-relative (`/src/…`) or through a tsconfig path alias (`@/…`) — resolved to repo-relative
244
+ * paths. For .astro files only the frontmatter is read; for .ts/.mjs the whole file. Package and
43
245
  * virtual-module imports are skipped — they are not files in this repo.
44
246
  * @param {string} rootDir absolute repo root
45
247
  * @param {string} fileRel repo-relative path of the file to read
@@ -50,14 +252,14 @@ export function defaultImports(rootDir, fileRel) {
50
252
  if (!existsSync(abs)) return [];
51
253
  const source = readFileSync(abs, "utf8");
52
254
  const code = fileRel.endsWith(".astro") ? frontmatterOf(source) : source;
255
+ const aliases = readAliases(rootDir);
53
256
  /** @type {{ name: string; file: string }[]} */
54
257
  const out = [];
55
258
  const re = /^\s*import\s+([A-Za-z_$][\w$]*)\s+from\s+["']([^"']+)["']/gm;
56
259
  let m;
57
260
  while ((m = re.exec(code))) {
58
- const spec = m[2];
59
- if (!spec.startsWith(".")) continue;
60
- out.push({ name: m[1], file: toRepoRelative(rootDir, path.resolve(path.dirname(abs), spec)) });
261
+ const file = resolveImport(rootDir, abs, m[2], aliases);
262
+ if (file) out.push({ name: m[1], file: toRepoRelative(rootDir, file) });
61
263
  }
62
264
  return out;
63
265
  }
@@ -1,12 +1,11 @@
1
- // The lab's hand-set marks — everything a person ticks in the sidebar rather than something the
2
- // lab derives, plus the one path rule both mark files obey.
1
+ // The lab's hand-set marks — everything a person ticks rather than something the lab derives,
2
+ // plus the path rule every mark obeys.
3
3
  //
4
4
  // <directory>/responsive.json { "done": string[], "approved": string[], "updated": string|null }
5
- // <directory>/cull.json { "marked": string[], "updated": string|null }
6
5
  //
7
- // Both are keyed by the story module's repo-relative path
8
- // (`<directory>/<tier>/…/<Name>.stories.ts`) — one row means one key everywhere — and both are
9
- // validated through `pathOffence` below, so they can never disagree about what a markable row is.
6
+ // Keyed by the story module's repo-relative path (`<directory>/<tier>/…/<Name>.stories.ts`, or any
7
+ // extension in STORY_EXTENSIONS below) — one row means one key everywhere — and validated through
8
+ // `pathOffence` below.
10
9
  //
11
10
  // Why the responsive state is marked and not measured: responsive work is approval-gated and
12
11
  // final-versions-only (ruled 2026-09-06) — a version is worked on only once it has been approved
@@ -14,16 +13,24 @@
14
13
  // be withdrawn later. Neither fact is readable from the code, so both are ticked here.
15
14
  //
16
15
  // Dependency-free Node (fs + path only) on purpose: this module is imported inside Vite
17
- // (./lab-index.ts, at build), by a plain Vite middleware (../../index.mjs) and by the cull binary
18
- // (../../bin/lab-cull.mjs, plain Node). One parser, three runtimes.
16
+ // (./lab-index.ts, at build) and by a plain Vite middleware (../../index.mjs). One parser, two
17
+ // runtimes.
19
18
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
20
19
  import path from "node:path";
21
20
 
22
21
  /** @typedef {import("../../defaults.mjs").LabConfig} LabConfig */
23
22
 
23
+ /**
24
+ * Every extension the core finds a stories file by — the `*.stories.{…}` glob in
25
+ * src/core/virtual-module/get-story-modules.ts. A file the lab lists must be one it can mark.
26
+ */
27
+ export const STORY_EXTENSIONS = ["ts", "tsx", "js", "jsx", "mts", "mtsx", "mjs", "mjsx"];
28
+
24
29
  /** A story module inside the lab directory: the only thing any mark may name. */
25
30
  export const storiesPattern = (directory) =>
26
- new RegExp(`^${directory.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/(?:[\\w.-]+/)*[\\w.-]+\\.stories\\.ts$`);
31
+ new RegExp(
32
+ `^${directory.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/(?:[\\w.-]+/)*[\\w.-]+\\.stories\\.(?:${STORY_EXTENSIONS.join("|")})$`,
33
+ );
27
34
 
28
35
  /**
29
36
  * Why `entry` is not a markable story module under `tier`, or null when it is.
@@ -37,7 +44,7 @@ export const storiesPattern = (directory) =>
37
44
  export const pathOffence = (rootDir, config, entry, tier, tierReason) => {
38
45
  if (typeof entry !== "string") return "not a string";
39
46
  if (!storiesPattern(config.directory).test(entry) || entry.includes(".."))
40
- return `not a ${config.directory}/**/*.stories.ts path`;
47
+ return `not a ${config.directory}/**/*.stories.{${STORY_EXTENSIONS.join(",")}} path`;
41
48
  if (!tier) return tierReason;
42
49
  if (!entry.startsWith(tier)) return tierReason;
43
50
  if (!existsSync(path.join(rootDir, entry))) return "no such file";
@@ -54,10 +61,6 @@ export const responsiveOffence = (rootDir, config, entry) =>
54
61
  "only section versions can be marked responsive",
55
62
  );
56
63
 
57
- /** @param {string} rootDir @param {LabConfig} config @param {unknown} entry */
58
- export const cullOffence = (rootDir, config, entry) =>
59
- pathOffence(rootDir, config, entry, config.cullDir, "only the explorations tier can be marked");
60
-
61
64
  /** @param {unknown} value */
62
65
  const stringList = (value) =>
63
66
  Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
@@ -107,30 +110,3 @@ export function writeResponsive(rootDir, config, done, approved) {
107
110
  writeFileSync(path.join(rootDir, config.responsiveFile), JSON.stringify(data, null, 2) + "\n");
108
111
  return data;
109
112
  }
110
-
111
- /**
112
- * The cull marks as written.
113
- * @param {string} rootDir absolute repo root
114
- * @param {LabConfig} config
115
- * @returns {{ marked: string[]; updated: string | null }}
116
- */
117
- export function readCull(rootDir, config) {
118
- const data = readJson(rootDir, config.cullFile);
119
- if (!data) return { marked: [], updated: null };
120
- return {
121
- marked: stringList(data.marked),
122
- updated: typeof data.updated === "string" ? data.updated : null,
123
- };
124
- }
125
-
126
- /**
127
- * Replace the cull marks.
128
- * @param {string} rootDir absolute repo root
129
- * @param {LabConfig} config
130
- * @param {string[]} marked
131
- */
132
- export function writeCull(rootDir, config, marked) {
133
- const data = { marked: [...new Set(marked)].sort(), updated: new Date().toISOString() };
134
- writeFileSync(path.join(rootDir, config.cullFile), JSON.stringify(data, null, 2) + "\n");
135
- return data;
136
- }
@@ -1,13 +1,11 @@
1
1
  ---
2
2
  // Vendored from astrobook 0.13.3 (packages/ui/src/components/app.astro) — see
3
- // ../../core/LICENSE-astrobook. Unchanged apart from the .astro extension on the relative import.
3
+ // ../../core/LICENSE-astrobook.
4
4
  //
5
- // The `/lab` home page: pure chrome, so it renders through ./layout.astro and gets no consumer
6
- // CSS at all (ruled 2026-09-22 — ../lab.css).
7
- import Layout from './layout.astro'
5
+ // The `/lab` home page. Divergence: upstream wrapped the `home` component in its layout; since the
6
+ // redesign (2026-09-24) the home component IS the whole document — it renders through the
7
+ // package's chrome shell (../../chrome/Shell.astro) itself, so this file only mounts it.
8
8
  import Home from 'virtual:astrobook/components/home.mjs'
9
9
  ---
10
10
 
11
- <Layout story={undefined} hasSidebar={true}>
12
- <Home />
13
- </Layout>
11
+ <Home />