@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.
- package/LICENSE +37 -0
- package/README.md +410 -0
- package/bin/lab-cull.mjs +401 -0
- package/bin/pin-gallery.mjs +121 -0
- package/defaults.mjs +120 -0
- package/dist/core/astro-integration.js +130 -0
- package/dist/core/index.js +6 -0
- package/dist/core/lib-paths.js +29 -0
- package/dist/core/options.js +173 -0
- package/dist/core/utils/get-exports.js +52 -0
- package/dist/core/utils/invariant.js +12 -0
- package/dist/core/utils/kebab-case.js +15 -0
- package/dist/core/utils/path-builder.js +22 -0
- package/dist/core/utils/path.js +53 -0
- package/dist/core/virtual-module/get-story-modules.js +60 -0
- package/dist/core/virtual-module/story-modules.js +8 -0
- package/dist/core/virtual-module/virtual-module-ids.js +22 -0
- package/dist/core/virtual-module/virtual-routes.js +83 -0
- package/dist/core/virtual-module/vite-plugin.js +98 -0
- package/docs/PIN-CONTRACT.md +263 -0
- package/docs/PIN.md +429 -0
- package/index.d.ts +279 -0
- package/index.mjs +347 -0
- package/package.json +93 -0
- package/src/Empty.astro +4 -0
- package/src/Home.astro +298 -0
- package/src/LabHead.astro +1102 -0
- package/src/core/LICENSE-astrobook +166 -0
- package/src/core/astro-integration.ts +166 -0
- package/src/core/client.ts +89 -0
- package/src/core/index.ts +7 -0
- package/src/core/lib/components/empty.astro +1 -0
- package/src/core/lib/components/head.astro +1 -0
- package/src/core/lib/components/home.astro +8 -0
- package/src/core/lib/components/with-decorators.astro +22 -0
- package/src/core/lib/pages/app.astro +19 -0
- package/src/core/lib/pages/preview.astro +17 -0
- package/src/core/lib/pages/story.astro +16 -0
- package/src/core/lib-paths.ts +72 -0
- package/src/core/options.ts +262 -0
- package/src/core/utils/get-exports.ts +59 -0
- package/src/core/utils/invariant.ts +13 -0
- package/src/core/utils/kebab-case.ts +30 -0
- package/src/core/utils/path-builder.ts +45 -0
- package/src/core/utils/path.ts +80 -0
- package/src/core/virtual-module/get-story-modules.ts +110 -0
- package/src/core/virtual-module/story-modules.ts +9 -0
- package/src/core/virtual-module/virtual-module-ids.ts +17 -0
- package/src/core/virtual-module/virtual-routes.ts +130 -0
- package/src/core/virtual-module/vite-plugin.ts +125 -0
- package/src/pin/board.mjs +1521 -0
- package/src/pin/index.mjs +666 -0
- package/src/pin/shot.mjs +427 -0
- package/src/pin/source-stamp.mjs +159 -0
- package/src/pin/tickets.mjs +697 -0
- package/src/pin/toolbar.js +3181 -0
- package/src/shell/Browse.astro +371 -0
- package/src/shell/CardGrid.astro +297 -0
- package/src/shell/Viewport.astro +1330 -0
- package/src/shell/index.json.ts +12 -0
- package/src/shell/lab-index.ts +344 -0
- package/src/shell/lab-params.ts +245 -0
- package/src/shell/live-files.mjs +164 -0
- package/src/shell/marks.mjs +136 -0
- package/src/types/index.ts +6 -0
- package/src/types/types.ts +239 -0
- package/src/types/virtual.d.ts +29 -0
- package/src/ui/components/app.astro +13 -0
- package/src/ui/components/build-path.ts +13 -0
- package/src/ui/components/build-tree.ts +108 -0
- package/src/ui/components/collapse-duration.ts +28 -0
- package/src/ui/components/compress-terms.ts +10 -0
- package/src/ui/components/dashboard-layout.astro +39 -0
- package/src/ui/components/home.astro +65 -0
- package/src/ui/components/layout.astro +110 -0
- package/src/ui/components/preview-layout.astro +109 -0
- package/src/ui/components/sidebar-button-fullscreen.astro +38 -0
- package/src/ui/components/sidebar-button-search.astro +23 -0
- package/src/ui/components/sidebar-button-theme.astro +9 -0
- package/src/ui/components/sidebar-button.astro +24 -0
- package/src/ui/components/sidebar-resize-handle.astro +74 -0
- package/src/ui/components/sidebar-search-panel.astro +41 -0
- package/src/ui/components/sidebar-search-script.ts +103 -0
- package/src/ui/components/sidebar-title.astro +17 -0
- package/src/ui/components/sidebar-tree-node.astro +143 -0
- package/src/ui/components/sidebar-tree.astro +84 -0
- package/src/ui/components/sidebar.astro +29 -0
- package/src/ui/components/theme-message.ts +26 -0
- package/src/ui/components/theme-script.astro +71 -0
- package/src/ui/components/theme-toggle.astro +63 -0
- package/src/ui/components/theme.ts +32 -0
- package/src/ui/index.ts +4 -0
- package/src/ui/lab.css +549 -0
- 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">↗</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">⇲</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">↗</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">↗</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>
|