@orbytes/astrolab 0.3.0 → 0.4.0-next.1

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 (69) hide show
  1. package/README.md +109 -64
  2. package/defaults.mjs +65 -0
  3. package/dist/core/virtual-module/virtual-routes.js +12 -1
  4. package/docs/PIN-CONTRACT.md +8 -0
  5. package/docs/PIN.md +29 -19
  6. package/index.d.ts +40 -16
  7. package/index.mjs +32 -10
  8. package/package.json +6 -2
  9. package/src/Home.astro +167 -264
  10. package/src/LabHead.astro +37 -1047
  11. package/src/chrome/ComponentCard.astro +69 -0
  12. package/src/chrome/Icon.astro +21 -0
  13. package/src/chrome/LICENSE-icons +43 -0
  14. package/src/chrome/Nav.astro +105 -0
  15. package/src/chrome/Panel.astro +104 -0
  16. package/src/chrome/Shell.astro +106 -0
  17. package/src/chrome/Sprite.astro +23 -0
  18. package/src/chrome/StoryView.astro +251 -0
  19. package/src/chrome/Tree.astro +83 -0
  20. package/src/chrome/ViewportControls.astro +98 -0
  21. package/src/chrome/ViewportStage.astro +32 -0
  22. package/src/chrome/fonts/OFL.txt +93 -0
  23. package/src/chrome/fonts/inter-latin-wght-normal.woff2 +0 -0
  24. package/src/chrome/icons.ts +59 -0
  25. package/src/chrome/marks-client.ts +102 -0
  26. package/src/chrome/model.ts +142 -0
  27. package/src/chrome/pins-data.ts +30 -0
  28. package/src/chrome/shell-client.ts +372 -0
  29. package/src/chrome/site-data.ts +230 -0
  30. package/src/chrome/trees.ts +152 -0
  31. package/src/chrome/viewport-client.ts +579 -0
  32. package/src/chrome/views/Assets.astro +110 -0
  33. package/src/chrome/views/Pages.astro +178 -0
  34. package/src/chrome/views/Placeholder.astro +37 -0
  35. package/src/chrome/views/Tasks.astro +107 -0
  36. package/src/core/LICENSE-astrobook +16 -0
  37. package/src/core/lib/components/home.astro +4 -2
  38. package/src/core/lib/pages/story.astro +12 -10
  39. package/src/core/virtual-module/virtual-routes.ts +20 -4
  40. package/src/pin/board.mjs +389 -175
  41. package/src/pin/index.mjs +33 -2
  42. package/src/pin/toolbar.js +1 -1
  43. package/src/shell/Browse.astro +106 -353
  44. package/src/shell/Viewport.astro +22 -1315
  45. package/src/shell/lab-index.ts +23 -14
  46. package/src/ui/components/app.astro +5 -7
  47. package/src/ui/components/theme-script.astro +13 -2
  48. package/src/ui/lab.css +2152 -370
  49. package/virtual.d.ts +13 -0
  50. package/src/shell/CardGrid.astro +0 -297
  51. package/src/ui/components/build-path.ts +0 -13
  52. package/src/ui/components/build-tree.ts +0 -108
  53. package/src/ui/components/collapse-duration.ts +0 -28
  54. package/src/ui/components/compress-terms.ts +0 -10
  55. package/src/ui/components/dashboard-layout.astro +0 -39
  56. package/src/ui/components/home.astro +0 -65
  57. package/src/ui/components/layout.astro +0 -110
  58. package/src/ui/components/sidebar-button-fullscreen.astro +0 -38
  59. package/src/ui/components/sidebar-button-search.astro +0 -23
  60. package/src/ui/components/sidebar-button-theme.astro +0 -9
  61. package/src/ui/components/sidebar-button.astro +0 -24
  62. package/src/ui/components/sidebar-resize-handle.astro +0 -74
  63. package/src/ui/components/sidebar-search-panel.astro +0 -41
  64. package/src/ui/components/sidebar-search-script.ts +0 -103
  65. package/src/ui/components/sidebar-title.astro +0 -17
  66. package/src/ui/components/sidebar-tree-node.astro +0 -143
  67. package/src/ui/components/sidebar-tree.astro +0 -84
  68. package/src/ui/components/sidebar.astro +0 -29
  69. package/src/ui/components/theme-toggle.astro +0 -63
package/index.mjs CHANGED
@@ -25,8 +25,12 @@
25
25
  // components (which render the consumer's head inside them).
26
26
  // 2. inject the shell's routes, prerendered like every Astrobook route:
27
27
  // <subpath>/browse/[...path] folder pages → ./src/shell/Browse.astro
28
- // <subpath>/viewport/[...id] viewport configurator → ./src/shell/Viewport.astro
28
+ // <subpath>/pages/[...path] the site's pages → ./src/chrome/views/Pages.astro
29
+ // <subpath>/assets the site's images → ./src/chrome/views/Assets.astro
30
+ // <subpath>/[section] support, settings → ./src/chrome/views/Placeholder.astro
31
+ // <subpath>/viewport/[...id] a redirect, kept for old links → ./src/shell/Viewport.astro
29
32
  // <subpath>/index.json the lab index → ./src/shell/index.json.ts
33
+ // (the task views, <subpath>/tasks and /tasks/all, are the pin half's, dev only)
30
34
  // and define `virtual:orbytes-lab/*` so those pages can read the resolved options, the
31
35
  // consumer's head component and the consumer's CSS without importing anything by path.
32
36
  // 3. serve the two mark APIs on the Vite dev server only (astro:server:setup never runs in a
@@ -117,14 +121,23 @@ const send = (res, status, payload) => {
117
121
  res.end(JSON.stringify(payload));
118
122
  };
119
123
 
124
+ /**
125
+ * What the lab and the pin half tell each other at setup. One object, created per `orbytesLab()`
126
+ * call and handed to both. The pin half fills `tasks` in its own `astro:config:setup` — only in dev,
127
+ * only when it found a repository — and the lab reads it when its config module LOADS, which Vite
128
+ * does on first request, after every integration's setup hook has run. So the lab's Tasks group
129
+ * exists exactly when the board does, and neither half decides for the other.
130
+ * @typedef {{ tasks: import("./defaults.mjs").LabConfig["tasks"] | { base: string; api: string; assets: string; repoRoot: string; backlogDir: string } }} Shared
131
+ */
132
+
120
133
  /** The virtual modules the package's own pages read. Everything in them is JSON-serialisable. */
121
- const virtualModulesPlugin = (config, headEntrypoint) => ({
134
+ const virtualModulesPlugin = (config, headEntrypoint, shared) => ({
122
135
  name: "orbytes-lab/virtual",
123
136
  resolveId: (id) => RESOLVED[id],
124
137
  load(id) {
125
138
  switch (id) {
126
139
  case RESOLVED[VIRTUAL.config]:
127
- return `export default ${JSON.stringify(config)};`;
140
+ return `export default ${JSON.stringify({ ...config, tasks: shared.tasks ?? null })};`;
128
141
  case RESOLVED[VIRTUAL.head]:
129
142
  return `export { default } from ${JSON.stringify(headEntrypoint)};`;
130
143
  case RESOLVED[VIRTUAL.css]:
@@ -133,8 +146,8 @@ const virtualModulesPlugin = (config, headEntrypoint) => ({
133
146
  },
134
147
  });
135
148
 
136
- /** @param {Record<string, unknown>} [options] */
137
- function labShell(options) {
149
+ /** @param {Record<string, unknown>} [options] @param {Shared} [shared] */
150
+ function labShell(options, shared = { tasks: null }) {
138
151
  const config = resolveLabOptions(options);
139
152
  let rootDir = process.cwd();
140
153
  return {
@@ -153,6 +166,9 @@ function labShell(options) {
153
166
 
154
167
  for (const [pattern, entrypoint] of [
155
168
  [`${config.subpath}/browse/[...path]`, "./src/shell/Browse.astro"],
169
+ [`${config.subpath}/pages/[...path]`, "./src/chrome/views/Pages.astro"],
170
+ [`${config.subpath}/assets`, "./src/chrome/views/Assets.astro"],
171
+ [`${config.subpath}/[section]`, "./src/chrome/views/Placeholder.astro"],
156
172
  [`${config.subpath}/viewport/[...id]`, "./src/shell/Viewport.astro"],
157
173
  [`${config.subpath}/index.json`, "./src/shell/index.json.ts"],
158
174
  ]) {
@@ -161,7 +177,7 @@ function labShell(options) {
161
177
 
162
178
  updateConfig({
163
179
  vite: {
164
- plugins: [virtualModulesPlugin(config, head)],
180
+ plugins: [virtualModulesPlugin(config, head, shared)],
165
181
  // This package ships SOURCE — .astro and .ts compiled by the consumer's Vite, the way
166
182
  // Starlight ships its components. Vite externalises node_modules for SSR by default,
167
183
  // and an externalised .astro file is handed to Node, which cannot read it. The
@@ -176,7 +192,7 @@ function labShell(options) {
176
192
  },
177
193
  });
178
194
  logger.info(
179
- `folder pages at ${config.subpath}/browse, viewport at ${config.subpath}/viewport, index at ${config.subpath}/index.json`,
195
+ `pages at ${config.subpath}/pages, folders at ${config.subpath}/browse, assets at ${config.subpath}/assets, index at ${config.subpath}/index.json`,
180
196
  );
181
197
  },
182
198
  "astro:server:setup": ({ server, logger }) => {
@@ -277,7 +293,7 @@ function labShell(options) {
277
293
  * @param {Record<string, unknown>} [options] the lab's options
278
294
  * @param {import("./defaults.mjs").LabConfig} config the RESOLVED lab config
279
295
  */
280
- const pinHalf = (options, config) => {
296
+ const pinHalf = (options, config, shared) => {
281
297
  const pin = options?.pin;
282
298
  if (pin === false || pin === null) return [];
283
299
  const given = typeof pin === "object" ? pin : {};
@@ -289,7 +305,11 @@ const pinHalf = (options, config) => {
289
305
  { href: "/", label: "Site" },
290
306
  { href: config.subpath || "/", label: "Lab" },
291
307
  ],
308
+ // ONE URL (decided 2026-09-24): the board lives under the lab, at <subpath>/tasks, drawn in
309
+ // the lab's own chrome. `/pin` still answers, with a redirect. A consumer's `route` wins.
310
+ route: `${config.subpath}/tasks`,
292
311
  ...given,
312
+ lab: { shared },
293
313
  }),
294
314
  ];
295
315
  };
@@ -302,6 +322,8 @@ const pinHalf = (options, config) => {
302
322
  */
303
323
  export default function orbytesLab(options = {}) {
304
324
  const config = resolveLabOptions(options);
325
+ /** @type {Shared} */
326
+ const shared = { tasks: null };
305
327
  return [
306
328
  createAstrobookIntegration({
307
329
  directory: config.directory,
@@ -313,8 +335,8 @@ export default function orbytesLab(options = {}) {
313
335
  head: "@orbytes/astrolab/head",
314
336
  home: "@orbytes/astrolab/home",
315
337
  }),
316
- labShell(options),
317
- ...pinHalf(options, config),
338
+ labShell(options, shared),
339
+ ...pinHalf(options, config, shared),
318
340
  ];
319
341
  }
320
342
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@orbytes/astrolab",
3
- "version": "0.3.0",
3
+ "version": "0.4.0-next.1",
4
4
  "type": "module",
5
- "description": "The orbytes component lab and pin board — an Astrobook shell for Astro sites (live/used-by pills, a viewport configurator, responsive and cull marks, a parameters panel) plus a dev-only click-to-ticket feedback board at /pin.",
5
+ "description": "The orbytes lab for Astro sites — every page, section and component rendered live, which of them the site actually mounts, a viewport canvas to size them on, and a dev-only click-to-ticket pin board — in one integration, all local.",
6
6
  "keywords": [
7
7
  "astro",
8
8
  "astro-integration",
@@ -70,6 +70,10 @@
70
70
  "src",
71
71
  "virtual.d.ts"
72
72
  ],
73
+ "publishConfig": {
74
+ "access": "public",
75
+ "tag": "next"
76
+ },
73
77
  "dependencies": {
74
78
  "@sveltejs/acorn-typescript": "^1.0.12",
75
79
  "acorn": "^8.18.0",
package/src/Home.astro CHANGED
@@ -1,47 +1,69 @@
1
1
  ---
2
- // Lab home — Astrobook's `home` component, given to it by the integration (../index.mjs).
3
- // Replaces the stock splash with the lab index: a row of tier cards (each opening its folder page
4
- // at <subpath>/browse/<tier>), then every story as a live thumbnail card grouped by tier, each
5
- // card carrying its derived pill — live slot, used-by, or nothing.
2
+ // Lab home — Astrobook's `home` component, given to it by the integration (../index.mjs) and
3
+ // rendered as the whole `/lab` document (./ui/components/app.astro).
6
4
  //
7
- // Everything drawn here comes from ./shell/lab-index.ts, which reads Astrobook's own story list
8
- // (so ids and URLs never drift from what it routes) and derives "live" from what the pages under
9
- // src/pages/ actually mount. The cards themselves are ./shell/CardGrid.astro, shared with the
10
- // folder pages.
5
+ // A summary, not a gallery (redesigned 2026-09-24). The previous home drew every story in the lab
6
+ // as a live <iframe> thumbnail — forty-odd full renders of the site, each loading its fonts and
7
+ // stylesheets, before you could click anything. Those cards now live one tier at a time on the
8
+ // tier pages, lazily; the home page is the counts and the one thing only this lab can tell you —
9
+ // what is mounted on which page, in which slot, right now.
11
10
  import { execSync } from "node:child_process";
12
- import labConfig from "virtual:orbytes-lab/config.mjs";
13
- import CardGrid from "./shell/CardGrid.astro";
14
- import {
15
- buildLabIndex,
16
- labBase,
17
- liveCountOf,
18
- sortTiers,
19
- STAGING_URL,
20
- tierLabel,
21
- } from "./shell/lab-index";
11
+ import Shell from "./chrome/Shell.astro";
12
+ import Icon from "./chrome/Icon.astro";
13
+ import { hrefs, labConfig, tasksBase } from "./chrome/model";
14
+ import { componentsOf } from "./chrome/trees";
15
+ import { rootDir, siteAssetsModel, sitePagesModel } from "./chrome/site-data";
16
+ import { buildLabIndex, STAGING_URL, sortTiers, tierLabel } from "./shell/lab-index";
22
17
 
23
18
  const index = buildLabIndex();
24
- const tiers = sortTiers([...new Set(index.items.map((item) => item.tier))]).map((tier) => {
25
- const items = index.items.filter((item) => item.tier === tier);
19
+ const components = componentsOf(index);
20
+ const pages = sitePagesModel();
21
+ const assets = siteAssetsModel();
22
+
23
+ const tiers = sortTiers([...new Set(components.map((c) => c.tier))]).map((tier) => {
24
+ const mods = components.filter((c) => c.tier === tier);
26
25
  return {
27
26
  tier,
28
27
  label: tierLabel(tier),
29
- href: tier ? `${labBase}/browse/${tier}` : `${labBase}/browse`,
30
- count: items.length,
31
- live: liveCountOf(items),
32
- items,
28
+ href: hrefs.tier(tier),
29
+ components: mods.length,
30
+ stories: mods.reduce((n, m) => n + m.stories.length, 0),
31
+ live: mods.filter((m) => m.live).length,
32
+ used: mods.filter((m) => !m.live && m.usedBy.length).length,
33
+ unused: mods.filter((m) => !m.live && !m.usedBy.length).length,
33
34
  };
34
35
  });
35
36
 
37
+ // The pin board's counts, in dev only (tasksBase is null in every build).
38
+ let tasks: { open: number; review: number } | null = null;
39
+ if (tasksBase && labConfig.tasks) {
40
+ const { collectTickets, OPEN_STATUSES, READY_FOR_REVIEW } = await import("./pin/board.mjs");
41
+ try {
42
+ const { tickets } = collectTickets(labConfig.tasks.repoRoot, { backlogDir: labConfig.tasks.backlogDir });
43
+ tasks = {
44
+ open: tickets.filter((t: { status: string }) => OPEN_STATUSES.includes(t.status)).length,
45
+ review: tickets.filter((t: { status: string }) => t.status === READY_FOR_REVIEW).length,
46
+ };
47
+ } catch {
48
+ tasks = null;
49
+ }
50
+ }
51
+
52
+ // What is on the site: every page, and in slot order what it mounts, with the lab component it is.
53
+ const byComponentFile = new Map(components.filter((c) => c.componentFile).map((c) => [c.componentFile!, c]));
54
+ const onSite = pages
55
+ .filter((page) => page.mounts.length > 0)
56
+ .map((page) => ({
57
+ page,
58
+ slots: page.mounts.map((file, i) => ({ slot: i + 1, file, component: byComponentFile.get(file) ?? null })),
59
+ }));
60
+
36
61
  // Local branches → per-branch preview builds, when the consumer configured a URL template.
37
- // `previewUrlTemplate` is a string with a {branch} placeholder rather than a function because the
38
- // config crosses a virtual module and has to stay JSON-serialisable. No template, no branch list:
39
- // a host that does not build branches has nothing to link to.
40
62
  const previewTemplate = labConfig.previewUrlTemplate;
41
63
  let branches: string[] = [];
42
64
  if (previewTemplate) {
43
65
  try {
44
- branches = execSync('git branch --format="%(refname:short)"', { encoding: "utf8" })
66
+ branches = execSync('git branch --format="%(refname:short)"', { encoding: "utf8", cwd: rootDir })
45
67
  .split("\n")
46
68
  .map((line) => line.trim())
47
69
  .filter(Boolean);
@@ -52,247 +74,128 @@ if (previewTemplate) {
52
74
  const previewAlias = (branch: string) =>
53
75
  (previewTemplate ?? "").replace(
54
76
  /\{branch\}/g,
55
- branch
56
- .toLowerCase()
57
- .replace(/[^a-z0-9]+/g, "-")
58
- .replace(/^-+|-+$/g, ""),
77
+ branch.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""),
59
78
  );
79
+ const unusedAssets = assets.filter((a) => a.usedBy.length === 0).length;
60
80
  ---
61
81
 
62
- <div class="lab-home lab-chrome">
63
- <header class="lab-home__header">
64
- <p class="lab-home__eyebrow">// component lab</p>
65
- <h1 class="lab-home__title">{labConfig.title}</h1>
66
- <p class="lab-home__intro">
67
- Every section, component and exploration in the build, rendered live. Cards open the story
68
- in the dashboard; thumbnails are the bare renders themselves, scaled down. A white pill means
69
- a page mounts that component right now, and names the page when it is not the home page. On
70
- section versions a second pill says whether that version has been made responsive; tick it in
71
- the sidebar. Where a deployed lab is configured, each card also links to the same story
72
- there — the real minified build, which this dev server is not — and, locally, to the
73
- component in VS Code.
74
- </p>
75
- {
76
- STAGING_URL && (
77
- <a class="lab-home__staging" href={STAGING_URL} target="_blank" rel="noopener">
78
- Staging site
79
- <span aria-hidden="true">&nbsp;&#8599;</span>
80
- </a>
81
- )
82
- }
83
- </header>
82
+ <Shell title="Home" active="home">
83
+ <Fragment slot="navbar">
84
+ <div class="lab-navbar__id">
85
+ <h1 class="lab-navbar__title">Home <small>{labConfig.title}</small></h1>
86
+ </div>
87
+ <div class="lab-navbar__tools">
88
+ {
89
+ STAGING_URL && (
90
+ <a class="lab-btn lab-btn--sm" href={STAGING_URL} target="_blank" rel="noopener">
91
+ <Icon name="external" />
92
+ Staging
93
+ </a>
94
+ )
95
+ }
96
+ </div>
97
+ </Fragment>
98
+
99
+ <div class="lab-page">
100
+ <header class="lab-page__head">
101
+ <h2 class="lab-page__title">What is on this site</h2>
102
+ <p class="lab-page__lede">
103
+ Every page, section and component in the build, rendered live — and which of them the site
104
+ actually mounts right now, read from <code>src/pages/</code> rather than declared.
105
+ </p>
106
+ </header>
107
+
108
+ <div class="lab-stats">
109
+ <a class="lab-stat" href={hrefs.pages}>
110
+ <span class="lab-stat__head"><Icon name="pages" />Pages</span>
111
+ <span class="lab-stat__value">{pages.length}</span>
112
+ <span class="lab-stat__foot">
113
+ <span class="lab-pill">{onSite.length} with components</span>
114
+ {pages.some((p) => p.dynamic) && <span class="lab-pill">{pages.filter((p) => p.dynamic).length} dynamic</span>}
115
+ </span>
116
+ </a>
117
+ {
118
+ tiers.map((tier) => (
119
+ <a class="lab-stat" href={tier.href}>
120
+ <span class="lab-stat__head">
121
+ <Icon name={tier.tier === labConfig.sectionsTier ? "sections" : tier.tier === "components" ? "components" : "component"} />
122
+ {tier.label}
123
+ </span>
124
+ <span class="lab-stat__value">{tier.components}</span>
125
+ <span class="lab-stat__foot">
126
+ {tier.live > 0 && <span class="lab-pill lab-pill--live">{tier.live} live</span>}
127
+ {tier.used > 0 && <span class="lab-pill lab-pill--used">{tier.used} used</span>}
128
+ {tier.unused > 0 && <span class="lab-pill">{tier.unused} unused</span>}
129
+ <span class="lab-pill">{tier.stories} stories</span>
130
+ </span>
131
+ </a>
132
+ ))
133
+ }
134
+ <a class="lab-stat" href={hrefs.assets}>
135
+ <span class="lab-stat__head"><Icon name="assets" />Assets</span>
136
+ <span class="lab-stat__value">{assets.length}</span>
137
+ <span class="lab-stat__foot">
138
+ {unusedAssets > 0 ? <span class="lab-pill lab-pill--unresponsive">{unusedAssets} unused</span> : <span class="lab-pill">all referenced</span>}
139
+ </span>
140
+ </a>
141
+ {
142
+ tasks && tasksBase && (
143
+ <a class="lab-stat" href={tasksBase}>
144
+ <span class="lab-stat__head"><Icon name="tasks" />Tasks</span>
145
+ <span class="lab-stat__value">{tasks.open}</span>
146
+ <span class="lab-stat__foot">
147
+ {tasks.review > 0 ? <span class="lab-pill lab-pill--brand">{tasks.review} ready for review</span> : <span class="lab-pill">open</span>}
148
+ </span>
149
+ </a>
150
+ )
151
+ }
152
+ </div>
84
153
 
85
- <section class="lab-home__tiers">
86
154
  {
87
- tiers.map((tier) => (
88
- <a class="tier-card" href={tier.href}>
89
- <span class="tier-card__eyebrow">// {tier.tier || "root"}</span>
90
- <span class="tier-card__name">{tier.label}</span>
91
- <span class="tier-card__meta">
92
- <span>
93
- {tier.count} stor{tier.count === 1 ? "y" : "ies"}
94
- </span>
95
- {tier.live > 0 && <span class="tier-card__live">{tier.live} live</span>}
96
- </span>
97
- </a>
155
+ onSite.map(({ page, slots }) => (
156
+ <section>
157
+ <h3 class="lab-section-title">
158
+ <a href={page.href}>{page.label}</a>
159
+ <small>{page.route} · {slots.length} mounted</small>
160
+ </h3>
161
+ <ol class="lab-slots">
162
+ {slots.map(({ slot, file, component }) => (
163
+ <li class="lab-slots__row">
164
+ <span class="lab-slots__n">{slot}</span>
165
+ {component ? (
166
+ <a class="lab-slots__name" href={component.stories[0]!.dashboardUrl}>
167
+ {component.moduleName}
168
+ {component.version && <span class="lab-card__version">{component.version}</span>}
169
+ </a>
170
+ ) : (
171
+ <span class="lab-slots__name lab-slots__name--bare" title="Not in the lab — no stories file for it">
172
+ {file.split("/").pop()}
173
+ </span>
174
+ )}
175
+ <span class="lab-slots__file lab-mono">{file}</span>
176
+ </li>
177
+ ))}
178
+ </ol>
179
+ </section>
98
180
  ))
99
181
  }
100
- </section>
101
-
102
- {
103
- branches.length > 0 && (
104
- <section class="lab-home__branches">
105
- <h2 class="lab-home__group-title">Branches</h2>
106
- <ul>
107
- {branches.map((branch) => (
108
- <li>
109
- <code>{branch}</code>
110
- <a href={previewAlias(branch)} target="_blank" rel="noopener">
111
- branch preview
112
- </a>
113
- </li>
114
- ))}
115
- </ul>
116
- </section>
117
- )
118
- }
119
-
120
- {
121
- tiers.map((tier) => (
122
- <section class="lab-home__group">
123
- <h2 class="lab-home__group-title">
124
- <a href={tier.href}>{tier.label}</a>
125
- </h2>
126
- <CardGrid items={tier.items} />
127
- </section>
128
- ))
129
- }
130
- </div>
131
-
132
- <style>
133
- .lab-home {
134
- background-color: var(--lab-color-bg);
135
- color: var(--lab-color-text);
136
- font-family: var(--lab-font-body);
137
- min-height: 100%;
138
- padding: 3em 2.5em 5em;
139
- }
140
-
141
- .lab-home__eyebrow {
142
- font-family: var(--lab-font-mono);
143
- font-weight: 500;
144
- color: var(--lab-color-text-muted);
145
- margin-bottom: 0.75em;
146
- }
147
-
148
- .lab-home__title {
149
- font-family: var(--lab-font-heading);
150
- color: var(--lab-color-text-strong);
151
- font-size: 2.25em;
152
- font-weight: 500;
153
- letter-spacing: -0.5px;
154
- margin-bottom: 0.4em;
155
- }
156
-
157
- .lab-home__intro {
158
- max-width: 42em;
159
- color: var(--lab-color-text-faint);
160
- line-height: 1.55;
161
- margin-bottom: 1.5em;
162
- }
163
-
164
- .lab-home__staging {
165
- display: inline-block;
166
- font-family: var(--lab-font-mono);
167
- font-weight: 500;
168
- color: var(--lab-color-text-strong);
169
- text-decoration: none;
170
- border: 1px solid var(--lab-color-border);
171
- border-left: 1px solid var(--lab-color-text-strong);
172
- padding: 0.75em 1.25em;
173
- transition: background-color var(--lab-transition), color var(--lab-transition);
174
- }
175
-
176
- .lab-home__staging:hover,
177
- .lab-home__staging:focus-visible {
178
- background-color: var(--lab-color-text-strong);
179
- color: var(--lab-color-accent-text);
180
- }
181
-
182
- .lab-home__tiers {
183
- display: grid;
184
- grid-template-columns: repeat(auto-fit, minmax(14em, 1fr));
185
- gap: 1em;
186
- margin-top: 2.5em;
187
- }
188
-
189
- .tier-card {
190
- display: flex;
191
- flex-direction: column;
192
- gap: 0.5em;
193
- text-decoration: none;
194
- border: 1px solid var(--lab-color-border);
195
- background-color: var(--lab-color-surface);
196
- padding: 1.25em 1.5em;
197
- transition: border-color var(--lab-transition);
198
- }
199
-
200
- .tier-card:hover,
201
- .tier-card:focus-visible {
202
- border-color: var(--lab-color-border-strong);
203
- }
204
-
205
- .tier-card__eyebrow {
206
- font-family: var(--lab-font-mono);
207
- font-weight: 500;
208
- font-size: 0.75em;
209
- color: var(--lab-color-text-faint);
210
- }
211
-
212
- .tier-card__name {
213
- font-family: var(--lab-font-heading);
214
- font-size: 1.4em;
215
- font-weight: 500;
216
- color: var(--lab-color-text-strong);
217
- }
218
-
219
- .tier-card__meta {
220
- display: flex;
221
- align-items: baseline;
222
- justify-content: space-between;
223
- gap: 1em;
224
- font-family: var(--lab-font-mono);
225
- font-size: 0.8em;
226
- color: var(--lab-color-text-faint);
227
- }
228
-
229
- .tier-card__live {
230
- font-weight: 500;
231
- line-height: 1;
232
- padding: 0.45em 0.85em;
233
- border-radius: var(--lab-radius-pill);
234
- background-color: var(--lab-color-accent);
235
- color: var(--lab-color-accent-text);
236
- }
237
-
238
- .lab-home__branches {
239
- margin-top: 2.5em;
240
- }
241
-
242
- .lab-home__branches ul {
243
- list-style: none;
244
- padding: 0;
245
- margin: 0;
246
- }
247
-
248
- .lab-home__branches li {
249
- display: flex;
250
- align-items: baseline;
251
- gap: 1em;
252
- padding: 0.5em 0;
253
- border-bottom: 1px solid var(--lab-color-border);
254
- }
255
182
 
256
- .lab-home__branches code {
257
- font-family: var(--lab-font-mono);
258
- color: var(--lab-color-text-strong);
259
- }
260
-
261
- .lab-home__branches a {
262
- font-family: var(--lab-font-mono);
263
- font-size: 0.85em;
264
- color: var(--lab-color-text-faint);
265
- text-decoration: none;
266
- }
267
-
268
- .lab-home__branches a:hover,
269
- .lab-home__branches a:focus-visible {
270
- color: var(--lab-color-text-strong);
271
- text-decoration: underline;
272
- }
273
-
274
- .lab-home__group {
275
- margin-top: 3em;
276
- }
277
-
278
- .lab-home__group-title {
279
- font-family: var(--lab-font-mono);
280
- font-weight: 500;
281
- font-size: 1em;
282
- color: var(--lab-color-text-muted);
283
- margin-bottom: 1em;
284
- padding-bottom: 0.5em;
285
- border-bottom: 1px solid var(--lab-color-border);
286
- }
287
-
288
- .lab-home__group-title a {
289
- color: inherit;
290
- text-decoration: none;
291
- }
292
-
293
- .lab-home__group-title a:hover,
294
- .lab-home__group-title a:focus-visible {
295
- color: var(--lab-color-text-strong);
296
- text-decoration: underline;
297
- }
298
- </style>
183
+ {
184
+ branches.length > 0 && (
185
+ <section>
186
+ <h3 class="lab-section-title">Branches</h3>
187
+ <ol class="lab-slots">
188
+ {branches.map((branch) => (
189
+ <li class="lab-slots__row">
190
+ <span class="lab-slots__name lab-mono">{branch}</span>
191
+ <a class="lab-slots__file" href={previewAlias(branch)} target="_blank" rel="noopener">
192
+ branch preview ↗
193
+ </a>
194
+ </li>
195
+ ))}
196
+ </ol>
197
+ </section>
198
+ )
199
+ }
200
+ </div>
201
+ </Shell>