@orbytes/astrolab 0.4.0-next.1 → 0.4.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +184 -84
  2. package/bin/pin-gallery.mjs +53 -19
  3. package/defaults.mjs +7 -20
  4. package/docs/PIN-CONTRACT.md +76 -10
  5. package/docs/PIN.md +93 -23
  6. package/index.d.ts +1 -7
  7. package/index.mjs +14 -81
  8. package/package.json +2 -2
  9. package/src/Home.astro +7 -8
  10. package/src/LabHead.astro +1 -1
  11. package/src/chrome/ActionsMenu.astro +97 -0
  12. package/src/chrome/ComponentCard.astro +9 -2
  13. package/src/chrome/Nav.astro +36 -10
  14. package/src/chrome/Panel.astro +17 -4
  15. package/src/chrome/Properties.astro +104 -0
  16. package/src/chrome/SectionsTree.astro +128 -0
  17. package/src/chrome/Shell.astro +20 -6
  18. package/src/chrome/StoryView.astro +103 -162
  19. package/src/chrome/Tree.astro +56 -53
  20. package/src/chrome/ViewportControls.astro +136 -61
  21. package/src/chrome/ViewportStage.astro +26 -3
  22. package/src/chrome/icons.ts +9 -0
  23. package/src/chrome/marks-client.ts +26 -53
  24. package/src/chrome/model.ts +14 -0
  25. package/src/chrome/navbar-client.ts +324 -0
  26. package/src/chrome/params-client.ts +434 -0
  27. package/src/chrome/pins-data.ts +42 -9
  28. package/src/chrome/shell-client.ts +99 -3
  29. package/src/chrome/trees.ts +112 -7
  30. package/src/chrome/viewport-client.ts +68 -242
  31. package/src/chrome/views/Assets.astro +21 -6
  32. package/src/chrome/views/Pages.astro +90 -54
  33. package/src/chrome/views/Placeholder.astro +3 -3
  34. package/src/chrome/views/Tasks.astro +12 -40
  35. package/src/core/LICENSE-astrobook +5 -0
  36. package/src/core/utils/kebab-case.ts +2 -2
  37. package/src/pin/board.mjs +25 -15
  38. package/src/pin/index.mjs +34 -20
  39. package/src/pin/tickets.mjs +6 -5
  40. package/src/pin/toolbar.js +81 -3
  41. package/src/shell/Browse.astro +35 -10
  42. package/src/shell/lab-index.ts +5 -4
  43. package/src/shell/lab-params.ts +113 -6
  44. package/src/shell/live-files.mjs +212 -10
  45. package/src/shell/marks.mjs +17 -41
  46. package/src/ui/components/preview-layout.astro +17 -0
  47. package/src/ui/components/theme-script.astro +4 -3
  48. package/src/ui/lab.css +2167 -566
  49. package/virtual.d.ts +0 -4
  50. package/bin/lab-cull.mjs +0 -401
@@ -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
- }
@@ -24,6 +24,16 @@
24
24
  // this meta has to be here as well as in LabHead.astro. Do not make it conditional.
25
25
  // · Feedbucket, when the consumer configured a key — a bare story page is a lab page you can
26
26
  // open full screen, and it carried the widget before this split.
27
+ // · the FRAMED flag, which hides Astro's dev toolbar inside a lab frame (decided 2026-09-24).
28
+ // Every preview is an <iframe> of this document, and the dev server injects its toolbar into
29
+ // every page it serves, so a component page carried two: the lab page's own and a second,
30
+ // scaled with the canvas, sitting over the story. The outer one is enough — its pin picker
31
+ // already descends into same-origin frames. So an inline script marks <html> before first
32
+ // paint when `window.frameElement` is set (a same-origin parent, which every lab frame is),
33
+ // and one rule on that mark hides the toolbar. It stays on this route opened in its own tab,
34
+ // and on the site's own pages, which never render through this layout. The cost: a ticket
35
+ // left on a bare story draws its marker only there, because the outer toolbar places markers
36
+ // for its own page's url and a framed toolbar can no longer be opened to draw them.
27
37
  import 'virtual:astrobook/user-css.mjs'
28
38
 
29
39
  import { ClientRouter } from 'astro:transitions'
@@ -55,6 +65,9 @@ const feedbucketKey = labConfig.feedbucketKey
55
65
  <meta name="generator" content={Astro.generator} />
56
66
  <title>{config.title}</title>
57
67
  <meta name="robots" content="noindex, nofollow" />
68
+ <script is:inline>
69
+ if (window.frameElement) document.documentElement.setAttribute('data-lab-framed', '')
70
+ </script>
58
71
  <ThemeScript />
59
72
  <script>
60
73
  import { initThemeMessage } from './theme-message.ts'
@@ -95,6 +108,10 @@ const feedbucketKey = labConfig.feedbucketKey
95
108
  min-width: 0;
96
109
  overflow: auto;
97
110
  }
111
+
112
+ html.lab-preview-shell[data-lab-framed] astro-dev-toolbar {
113
+ display: none !important;
114
+ }
98
115
  </style>
99
116
  </head>
100
117
  <body class="lab-preview-shell">
@@ -6,9 +6,10 @@
6
6
  //
7
7
  // 1. The whole point of the pass is that the lab depends on nothing but Astro. One npm package
8
8
  // left behind for a sun/moon button is the one that would surprise the next reader.
9
- // 2. `.dark` on <html> is a DOM contract this package leans on hard — LabHead.astro styles the
10
- // live pill, the cull switch and the row error against it. A contract that load-bearing
11
- // should be owned here, not read off a third party's minified inline script.
9
+ // 2. `.dark` on <html> is a DOM contract this package leans on hard — the stylesheet LabHead.astro
10
+ // loads (../lab.css) swaps every chrome token against it, and the sidebars, the device switch
11
+ // and the parameters drawer with them. A contract that load-bearing should be owned here, not
12
+ // read off a third party's minified inline script.
12
13
  // 3. What was left after dropping the ripple `startViewTransition` animation — presentation,
13
14
  // and phase 2's business if it wants it back — was about forty lines.
14
15
  //