@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,371 @@
1
+ ---
2
+ // Folder pages — <subpath>/browse/[...path], injected by ../../index.mjs. One page per folder in
3
+ // the lab index (sections, sections/Section02Manifesto, …) plus the root, which lists the tiers.
4
+ // Each page shows the folder's sub-folders as cards and its stories as live-render cards with
5
+ // their pills, behind a client-side filter box.
6
+ //
7
+ // This page is NOT rendered through Astrobook's layout, so it carries its own <head> — and with
8
+ // it <LabHead />, which brings the chrome stylesheet (../ui/lab.css) and the UNCONDITIONAL
9
+ // noindex. A consumer's lab gate walks every built lab page and fails on any without it. Do not
10
+ // drop that import.
11
+ //
12
+ // CHANGED 2026-09-22 — the lab is not styled in the styles of the website being built. This
13
+ // file used to open with `import "virtual:orbytes-lab/user-css.mjs"` — it asked for the
14
+ // consumer's stylesheets by hand, because Astrobook injects the `css` option into its own pages
15
+ // only. A folder page is pure chrome: it renders no story, and its thumbnails are iframes of
16
+ // /lab/stories/<id>, which are their own documents and get the consumer's CSS there. So it asks
17
+ // for nothing now, and the chrome below is drawn entirely in `--lab-*` tokens. Reasoning:
18
+ // ../ui/lab.css.
19
+ import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
20
+ import LabHead from "../LabHead.astro";
21
+ import CardGrid from "./CardGrid.astro";
22
+ import { buildLabIndex, itemsUnder, labBase, type LabFolder } from "./lab-index";
23
+
24
+ export const getStaticPaths = (() => {
25
+ const index = buildLabIndex();
26
+ return [
27
+ { params: { path: undefined }, props: { index, folder: null } },
28
+ ...index.folders.map((folder) => ({ params: { path: folder.path }, props: { index, folder } })),
29
+ ];
30
+ }) satisfies GetStaticPaths;
31
+
32
+ type Props = InferGetStaticPropsType<typeof getStaticPaths>;
33
+
34
+ const { index, folder } = Astro.props as Props;
35
+ const browseBase = `${labBase}/browse`;
36
+
37
+ const folderByPath = new Map(index.folders.map((f) => [f.path, f]));
38
+ const itemById = new Map(index.items.map((i) => [i.storyId, i]));
39
+
40
+ const children: LabFolder[] = folder
41
+ ? folder.children.map((p) => folderByPath.get(p)).filter((f): f is LabFolder => Boolean(f))
42
+ : index.folders.filter((f) => !f.path.includes("/"));
43
+
44
+ const items = folder
45
+ ? folder.items.map((id) => itemById.get(id)).filter((i) => i !== undefined)
46
+ : index.items.filter((i) => i.directory === "");
47
+
48
+ const storyCount = (f: LabFolder) => itemsUnder(index, f.path).length;
49
+ const firstStory = folder ? itemsUnder(index, folder.path)[0] : index.items[0];
50
+
51
+ const segments = folder ? folder.path.split("/") : [];
52
+ const crumbs = [
53
+ { label: "lab", href: labBase },
54
+ ...segments.map((segment, i) => ({
55
+ label: segment,
56
+ href: `${browseBase}/${segments.slice(0, i + 1).join("/")}`,
57
+ })),
58
+ ];
59
+ const title = crumbs.map((c) => c.label).join(" › ");
60
+ ---
61
+
62
+ <html lang="en">
63
+ <head>
64
+ <meta charset="utf-8" />
65
+ <meta name="viewport" content="width=device-width" />
66
+ <meta name="generator" content={Astro.generator} />
67
+ <title>{title}</title>
68
+ <LabHead />
69
+ </head>
70
+ <body>
71
+ <div class="browse lab-chrome">
72
+ <header class="browse__header">
73
+ <nav class="browse__crumbs" aria-label="Breadcrumb">
74
+ <a class="browse__back" href={labBase}>← lab</a>
75
+ <ol>
76
+ {
77
+ crumbs.map((crumb, i) => (
78
+ <li>
79
+ {i < crumbs.length - 1 ? <a href={crumb.href}>{crumb.label}</a> : <span aria-current="page">{crumb.label}</span>}
80
+ </li>
81
+ ))
82
+ }
83
+ </ol>
84
+ </nav>
85
+ <h1 class="browse__title">{folder ? folder.name : "Orbytes lab"}</h1>
86
+ <p class="browse__meta">
87
+ {children.length > 0 && <span>{children.length} folder{children.length === 1 ? "" : "s"}</span>}
88
+ {items.length > 0 && <span>{items.length} stor{items.length === 1 ? "y" : "ies"} here</span>}
89
+ {folder && folder.liveCount > 0 && <span class="browse__live">{folder.liveCount} live</span>}
90
+ {
91
+ folder && folder.responsive.of > 0 && (
92
+ <span class="browse__responsive" title="section versions made responsive">
93
+ {folder.responsive.done}/{folder.responsive.of} responsive
94
+ </span>
95
+ )
96
+ }
97
+ {
98
+ firstStory && (
99
+ <a href={firstStory.dashboardUrl}>
100
+ open in the dashboard <span aria-hidden="true">&#8599;</span>
101
+ </a>
102
+ )
103
+ }
104
+ </p>
105
+ <label class="browse__filter">
106
+ <span class="browse__filter-label">// filter</span>
107
+ <input id="lab-filter" type="search" placeholder="name, section, version, tag…" autocomplete="off" />
108
+ </label>
109
+ </header>
110
+
111
+ {
112
+ children.length > 0 && (
113
+ <section class="browse__group">
114
+ <h2 class="browse__group-title">Folders</h2>
115
+ <div class="browse__folders">
116
+ {children.map((child) => {
117
+ const count = storyCount(child);
118
+ return (
119
+ <a class="folder-card" href={`${browseBase}/${child.path}`} data-search={child.path.toLowerCase()}>
120
+ <span class="folder-card__name">{child.name}</span>
121
+ <span class="folder-card__meta">
122
+ <span>
123
+ {count} stor{count === 1 ? "y" : "ies"}
124
+ </span>
125
+ {child.responsive.of > 0 && (
126
+ <span class="folder-card__responsive" title="section versions made responsive">
127
+ {child.responsive.done}/{child.responsive.of} responsive
128
+ </span>
129
+ )}
130
+ {child.liveCount > 0 && <span class="folder-card__live">{child.liveCount} live</span>}
131
+ </span>
132
+ </a>
133
+ );
134
+ })}
135
+ </div>
136
+ </section>
137
+ )
138
+ }
139
+
140
+ {
141
+ items.length > 0 && (
142
+ <section class="browse__group">
143
+ <h2 class="browse__group-title">Stories</h2>
144
+ <CardGrid items={items} />
145
+ </section>
146
+ )
147
+ }
148
+
149
+ {children.length === 0 && items.length === 0 && <p class="browse__empty">Nothing in this folder.</p>}
150
+ </div>
151
+
152
+ <script>
153
+ // Client-side filter: every term typed must appear in a card's data-search text.
154
+ const input = document.getElementById("lab-filter") as HTMLInputElement | null;
155
+ const cards = document.querySelectorAll<HTMLElement>("[data-search]");
156
+ input?.addEventListener("input", () => {
157
+ const terms = input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
158
+ for (const card of cards) {
159
+ const text = card.dataset.search ?? "";
160
+ card.hidden = !terms.every((term) => text.includes(term));
161
+ }
162
+ });
163
+ </script>
164
+ </body>
165
+ </html>
166
+
167
+ <style is:global>
168
+ /* The filter hides cards with the `hidden` attribute; the cards' own `display` rules (CardGrid's
169
+ scoped styles) would otherwise beat the UA stylesheet, so this is global and !important. */
170
+ [hidden] {
171
+ display: none !important;
172
+ }
173
+ </style>
174
+
175
+ <style>
176
+ .browse {
177
+ background-color: var(--lab-color-bg);
178
+ color: var(--lab-color-text);
179
+ font-family: var(--lab-font-body);
180
+ min-height: 100vh;
181
+ padding: 3em 2.5em 5em;
182
+ }
183
+
184
+ .browse__crumbs {
185
+ display: flex;
186
+ align-items: baseline;
187
+ gap: 1.5em;
188
+ font-family: var(--lab-font-mono);
189
+ font-weight: 500;
190
+ font-size: 0.85em;
191
+ color: var(--lab-color-text-muted);
192
+ margin-bottom: 1.25em;
193
+ }
194
+
195
+ .browse__crumbs ol {
196
+ display: flex;
197
+ flex-wrap: wrap;
198
+ list-style: none;
199
+ gap: 0.5em;
200
+ }
201
+
202
+ .browse__crumbs li + li::before {
203
+ content: "›";
204
+ color: var(--lab-color-text-faint);
205
+ margin-right: 0.5em;
206
+ }
207
+
208
+ .browse__crumbs a,
209
+ .browse__back {
210
+ color: var(--lab-color-text-faint);
211
+ text-decoration: none;
212
+ }
213
+
214
+ .browse__crumbs a:hover,
215
+ .browse__crumbs a:focus-visible,
216
+ .browse__back:hover,
217
+ .browse__back:focus-visible {
218
+ color: var(--lab-color-text-strong);
219
+ text-decoration: underline;
220
+ }
221
+
222
+ .browse__crumbs [aria-current] {
223
+ color: var(--lab-color-text-strong);
224
+ }
225
+
226
+ .browse__title {
227
+ font-family: var(--lab-font-heading);
228
+ color: var(--lab-color-text-strong);
229
+ font-size: 2.25em;
230
+ font-weight: 500;
231
+ letter-spacing: -0.5px;
232
+ margin-bottom: 0.4em;
233
+ }
234
+
235
+ .browse__meta {
236
+ display: flex;
237
+ flex-wrap: wrap;
238
+ align-items: baseline;
239
+ gap: 1.25em;
240
+ font-family: var(--lab-font-mono);
241
+ font-size: 0.85em;
242
+ color: var(--lab-color-text-faint);
243
+ margin-bottom: 1.5em;
244
+ }
245
+
246
+ .browse__meta a {
247
+ color: var(--lab-color-text-strong);
248
+ text-decoration: none;
249
+ border-bottom: 1px solid var(--lab-color-border);
250
+ }
251
+
252
+ .browse__meta a:hover,
253
+ .browse__meta a:focus-visible {
254
+ border-bottom-color: var(--lab-color-text-strong);
255
+ }
256
+
257
+ .browse__responsive,
258
+ .folder-card__responsive {
259
+ line-height: 1;
260
+ padding: 0.45em 0.85em;
261
+ border-radius: var(--lab-radius-pill);
262
+ border: 1px dashed var(--lab-color-border);
263
+ color: var(--lab-color-text-faint);
264
+ }
265
+
266
+ .browse__live,
267
+ .folder-card__live {
268
+ font-weight: 500;
269
+ line-height: 1;
270
+ padding: 0.45em 0.85em;
271
+ border-radius: var(--lab-radius-pill);
272
+ background-color: var(--lab-color-accent);
273
+ color: var(--lab-color-accent-text);
274
+ }
275
+
276
+ .browse__filter {
277
+ display: flex;
278
+ align-items: baseline;
279
+ gap: 1em;
280
+ max-width: 36em;
281
+ border: 1px solid var(--lab-color-border);
282
+ border-left: 1px solid var(--lab-color-text-strong);
283
+ padding: 0.65em 1em;
284
+ }
285
+
286
+ .browse__filter-label {
287
+ font-family: var(--lab-font-mono);
288
+ font-weight: 500;
289
+ font-size: 0.85em;
290
+ color: var(--lab-color-text-muted);
291
+ white-space: nowrap;
292
+ }
293
+
294
+ .browse__filter input {
295
+ flex: 1;
296
+ font: inherit;
297
+ font-family: var(--lab-font-mono);
298
+ font-size: 0.9em;
299
+ color: var(--lab-color-text-strong);
300
+ background: transparent;
301
+ border: 0;
302
+ outline: none;
303
+ }
304
+
305
+ .browse__filter input::placeholder {
306
+ color: var(--lab-color-text-faint);
307
+ }
308
+
309
+ .browse__filter:focus-within {
310
+ border-color: var(--lab-color-border-strong);
311
+ border-left-color: var(--lab-color-text-strong);
312
+ }
313
+
314
+ .browse__group {
315
+ margin-top: 3em;
316
+ }
317
+
318
+ .browse__group-title {
319
+ font-family: var(--lab-font-mono);
320
+ font-weight: 500;
321
+ font-size: 1em;
322
+ color: var(--lab-color-text-muted);
323
+ margin-bottom: 1em;
324
+ padding-bottom: 0.5em;
325
+ border-bottom: 1px solid var(--lab-color-border);
326
+ }
327
+
328
+ .browse__folders {
329
+ display: grid;
330
+ grid-template-columns: repeat(auto-fill, minmax(16em, 1fr));
331
+ gap: 1em;
332
+ }
333
+
334
+ .folder-card {
335
+ display: flex;
336
+ flex-direction: column;
337
+ gap: 0.75em;
338
+ text-decoration: none;
339
+ border: 1px solid var(--lab-color-border);
340
+ background-color: var(--lab-color-surface);
341
+ padding: 1.1em 1.25em;
342
+ transition: border-color var(--lab-transition);
343
+ }
344
+
345
+ .folder-card:hover,
346
+ .folder-card:focus-visible {
347
+ border-color: var(--lab-color-border-strong);
348
+ }
349
+
350
+ .folder-card__name {
351
+ color: var(--lab-color-text-strong);
352
+ font-weight: 500;
353
+ }
354
+
355
+ .folder-card__meta {
356
+ display: flex;
357
+ align-items: baseline;
358
+ justify-content: space-between;
359
+ gap: 1em;
360
+ font-family: var(--lab-font-mono);
361
+ font-size: 0.75em;
362
+ color: var(--lab-color-text-faint);
363
+ }
364
+
365
+ .browse__empty {
366
+ margin-top: 3em;
367
+ font-family: var(--lab-font-mono);
368
+ font-size: 0.85em;
369
+ color: var(--lab-color-text-faint);
370
+ }
371
+ </style>
@@ -0,0 +1,297 @@
1
+ ---
2
+ // One card per story: a scaled-down live <iframe> of the bare render, the module and story
3
+ // names, the pills derived from the index — `live · slot N of M` when a page mounts the component
4
+ // (`live · /about, slot 3` when that page is not the home page), `used by N` when a live component
5
+ // imports it, plus the responsive mark on section versions — and a row of links: the viewport
6
+ // configurator, the same story on staging, and (dev only) the component in VS Code. Factored out
7
+ // of Home.astro so the folder pages (Browse.astro) draw the identical card.
8
+ //
9
+ // Thumbnails are the bare story routes themselves (/lab/stories/<id>) rendered in a virtual
10
+ // viewport: sections at 1440×900 (page width), everything else at 960×540.
11
+ //
12
+ // The card is a <div> holding several links, not one <a>: the main link (thumb + label) opens the
13
+ // dashboard and the link row sits under it. An <a> cannot nest in an <a>.
14
+ import { isSectionLike, labBase, livePillText, liveTitle, type LabItem } from "./lab-index";
15
+
16
+ interface Props {
17
+ items: LabItem[];
18
+ /** Shown when there is nothing to draw. */
19
+ empty?: string;
20
+ }
21
+
22
+ const { items, empty = "No stories here." } = Astro.props;
23
+
24
+ type Pill = { text: string; kind: string; title?: string };
25
+
26
+ const pillsOf = (item: LabItem): Pill[] => {
27
+ const pills: Pill[] = [];
28
+ const live = livePillText(item);
29
+ if (live) pills.push({ text: live, kind: "live", title: liveTitle(item) });
30
+ else if (item.usedBy.length > 0)
31
+ pills.push({ text: `used by ${item.usedBy.length}`, kind: "used", title: item.usedBy.join("\n") });
32
+ if (item.responsive) {
33
+ pills.push({
34
+ text: item.responsive.done ? "responsive" : "not responsive",
35
+ kind: item.responsive.done ? "responsive" : "unresponsive",
36
+ title: item.responsive.approved
37
+ ? "approved for responsive work"
38
+ : "not approved for responsive work yet",
39
+ });
40
+ }
41
+ return pills;
42
+ };
43
+
44
+ // What the page-level filter box matches against.
45
+ const searchText = (item: LabItem) =>
46
+ [
47
+ item.moduleName,
48
+ item.storyName,
49
+ item.section,
50
+ item.version,
51
+ item.tier,
52
+ item.livePage,
53
+ item.live ? "live" : "",
54
+ // "unresponsive", not "not responsive": the filter is a substring match per term, so a card
55
+ // that said "not responsive" would still be a hit for "responsive". Same on the sidebar rows.
56
+ item.responsive ? (item.responsive.done ? "responsive" : "unresponsive") : "",
57
+ ...item.tags,
58
+ ]
59
+ .filter(Boolean)
60
+ .join(" ")
61
+ .toLowerCase();
62
+ ---
63
+
64
+ {
65
+ items.length === 0 ? (
66
+ <p class="lab-grid__empty">{empty}</p>
67
+ ) : (
68
+ <div class="lab-grid">
69
+ {items.map((item) => {
70
+ const section = isSectionLike(item);
71
+ const pills = pillsOf(item);
72
+ return (
73
+ <div class="lab-card" data-search={searchText(item)}>
74
+ <a class="lab-card__main" href={item.dashboardUrl}>
75
+ <span
76
+ class="lab-card__thumb"
77
+ data-frame-w={section ? 1440 : 960}
78
+ data-frame-h={section ? 900 : 540}
79
+ >
80
+ <iframe
81
+ src={item.storyUrl}
82
+ title={`${item.moduleName} — ${item.storyName}`}
83
+ loading="lazy"
84
+ tabindex="-1"
85
+ />
86
+ {pills.length > 0 && (
87
+ <span class="lab-card__pills">
88
+ {pills.map((pill) => (
89
+ <span class:list={["lab-card__pill", `lab-card__pill--${pill.kind}`]} title={pill.title}>
90
+ {pill.text}
91
+ </span>
92
+ ))}
93
+ </span>
94
+ )}
95
+ </span>
96
+ <span class="lab-card__label">
97
+ <span class="lab-card__name">
98
+ {item.moduleName}
99
+ {item.version && <span class="lab-card__version">{item.version}</span>}
100
+ </span>
101
+ <span class="lab-card__variant">{item.storyName}</span>
102
+ </span>
103
+ </a>
104
+ <span class="lab-card__links">
105
+ <a
106
+ href={`${labBase}/viewport/${item.storyId}`}
107
+ title="Open in the viewport configurator"
108
+ data-astro-reload
109
+ >
110
+ viewport <span aria-hidden="true">&#8690;</span>
111
+ </a>
112
+ {item.stagingUrl && (
113
+ <a href={item.stagingUrl} title="The same story on the deployed lab" target="_blank" rel="noopener">
114
+ staging <span aria-hidden="true">&#8599;</span>
115
+ </a>
116
+ )}
117
+ {item.editorUrl && (
118
+ <a href={item.editorUrl} title="Open the component in VS Code">
119
+ code <span aria-hidden="true">&#8599;</span>
120
+ </a>
121
+ )}
122
+ </span>
123
+ </div>
124
+ );
125
+ })}
126
+ </div>
127
+ )
128
+ }
129
+
130
+ <script>
131
+ // Scale each thumbnail iframe so its virtual viewport (data-frame-w × data-frame-h) fits the
132
+ // card width exactly. Re-run on resize and after every Astro view-transition swap (the lab home
133
+ // sits inside Astrobook's ClientRouter, where module scripts do not re-execute on navigation).
134
+ const fit = () => {
135
+ for (const thumb of document.querySelectorAll<HTMLElement>(".lab-card__thumb")) {
136
+ const iframe = thumb.querySelector("iframe");
137
+ if (!iframe) continue;
138
+ const frameW = Number(thumb.dataset.frameW) || 1440;
139
+ const frameH = Number(thumb.dataset.frameH) || 900;
140
+ const scale = thumb.clientWidth / frameW;
141
+ iframe.style.width = `${frameW}px`;
142
+ iframe.style.height = `${frameH}px`;
143
+ iframe.style.transform = `scale(${scale})`;
144
+ thumb.style.height = `${Math.round(frameH * scale)}px`;
145
+ }
146
+ };
147
+ fit();
148
+ window.addEventListener("resize", fit);
149
+ document.addEventListener("astro:page-load", fit);
150
+ </script>
151
+
152
+ <style>
153
+ .lab-grid {
154
+ display: grid;
155
+ grid-template-columns: repeat(auto-fill, minmax(20em, 1fr));
156
+ gap: 1.5em;
157
+ }
158
+
159
+ .lab-grid__empty {
160
+ font-family: var(--lab-font-mono);
161
+ font-size: 0.85em;
162
+ color: var(--lab-color-text-faint);
163
+ }
164
+
165
+ .lab-card {
166
+ position: relative;
167
+ display: block;
168
+ border: 1px solid var(--lab-color-border);
169
+ background-color: var(--lab-color-surface);
170
+ transition: border-color var(--lab-transition);
171
+ }
172
+
173
+ .lab-card:hover,
174
+ .lab-card:focus-within {
175
+ border-color: var(--lab-color-border-strong);
176
+ }
177
+
178
+ .lab-card__main {
179
+ display: block;
180
+ text-decoration: none;
181
+ }
182
+
183
+ .lab-card__main:focus-visible {
184
+ outline: none;
185
+ }
186
+
187
+ /* The link row under the label. Three links no longer fit over the label's right end, so they
188
+ get their own row rather than a cramped overlay. */
189
+ .lab-card__links {
190
+ display: flex;
191
+ flex-wrap: wrap;
192
+ gap: 1.25em;
193
+ padding: 0 1em 0.85em;
194
+ }
195
+
196
+ .lab-card__links a {
197
+ font-family: var(--lab-font-mono);
198
+ font-weight: 500;
199
+ font-size: 0.75em;
200
+ line-height: 1.5;
201
+ color: var(--lab-color-text-faint);
202
+ text-decoration: none;
203
+ border-bottom: 1px solid transparent;
204
+ transition: color var(--lab-transition), border-color var(--lab-transition);
205
+ }
206
+
207
+ .lab-card__links a:hover,
208
+ .lab-card__links a:focus-visible {
209
+ color: var(--lab-color-text-strong);
210
+ border-bottom-color: var(--lab-color-text-strong);
211
+ outline: none;
212
+ }
213
+
214
+ .lab-card__thumb {
215
+ display: block;
216
+ position: relative;
217
+ overflow: hidden;
218
+ background-color: var(--lab-color-bg);
219
+ border-bottom: 1px solid var(--lab-color-border);
220
+ }
221
+
222
+ .lab-card__thumb iframe {
223
+ border: 0;
224
+ transform-origin: 0 0;
225
+ pointer-events: none; /* the card is the link; the live render is display-only */
226
+ }
227
+
228
+ .lab-card__pills {
229
+ position: absolute;
230
+ top: 0.6em;
231
+ left: 0.6em;
232
+ display: flex;
233
+ flex-wrap: wrap;
234
+ gap: 0.4em;
235
+ }
236
+
237
+ .lab-card__pill {
238
+ font-family: var(--lab-font-mono);
239
+ font-weight: 500;
240
+ font-size: 0.7em;
241
+ line-height: 1;
242
+ padding: 0.5em 0.85em;
243
+ border-radius: var(--lab-radius-pill);
244
+ border: 1px solid var(--lab-color-border);
245
+ background-color: var(--lab-color-surface-raised);
246
+ color: var(--lab-color-text-muted);
247
+ }
248
+
249
+ .lab-card__pill--live {
250
+ background-color: var(--lab-color-accent);
251
+ border-color: var(--lab-color-accent);
252
+ color: var(--lab-color-accent-text);
253
+ }
254
+
255
+ /* The responsive mark: solid border when the version has been made responsive, dashed when it
256
+ has not — the same two-state reading as the sidebar's pill. */
257
+ .lab-card__pill--responsive {
258
+ border-color: var(--lab-color-border-strong);
259
+ color: var(--lab-color-text-strong);
260
+ }
261
+
262
+ .lab-card__pill--unresponsive {
263
+ border-style: dashed;
264
+ color: var(--lab-color-text-faint);
265
+ }
266
+
267
+ .lab-card__label {
268
+ display: flex;
269
+ align-items: baseline;
270
+ justify-content: space-between;
271
+ gap: 1em;
272
+ padding: 0.75em 1em 0.5em;
273
+ }
274
+
275
+ .lab-card__name {
276
+ display: inline-flex;
277
+ align-items: baseline;
278
+ gap: 0.6em;
279
+ color: var(--lab-color-text-strong);
280
+ font-weight: 500;
281
+ font-size: 0.95em;
282
+ }
283
+
284
+ .lab-card__version {
285
+ font-family: var(--lab-font-mono);
286
+ font-size: 0.75em;
287
+ color: var(--lab-color-text-faint);
288
+ border: 1px solid var(--lab-color-border);
289
+ padding: 0.1em 0.5em;
290
+ }
291
+
292
+ .lab-card__variant {
293
+ font-family: var(--lab-font-mono);
294
+ font-size: 0.8em;
295
+ color: var(--lab-color-text-faint);
296
+ }
297
+ </style>