@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/src/pin/index.mjs CHANGED
@@ -232,6 +232,14 @@ const DEFAULTS = {
232
232
  appId: "orbytes-pin",
233
233
  appName: "Pin",
234
234
  icon: "bug",
235
+ /**
236
+ * Set by `orbytesLab()` (../../index.mjs), never by a consumer: `{ shared }`, the object the two
237
+ * halves talk through. When present, the board is drawn IN THE LAB'S CHROME at `<route>` and
238
+ * `<route>/all` (../chrome/views/Tasks.astro, injected here, dev only) instead of as this
239
+ * middleware's standalone page, and `/pin` — the board's address until 2026-09-24 — redirects
240
+ * there. Absent, the pin half behaves exactly as it did on its own.
241
+ */
242
+ lab: null,
235
243
  };
236
244
 
237
245
  /** The dev server's own origin, read from Vite. Never hardcode 4321 — Astro picks a free port. */
@@ -257,7 +265,7 @@ export default function orbytesPin(options = {}) {
257
265
  return {
258
266
  name: "orbytes-pin",
259
267
  hooks: {
260
- "astro:config:setup": ({ config: astroConfig, command, updateConfig, addDevToolbarApp, logger }) => {
268
+ "astro:config:setup": ({ config: astroConfig, command, updateConfig, addDevToolbarApp, injectRoute, logger }) => {
261
269
  // Assertion one. In `build`, `preview` or `sync` this integration registers nothing at
262
270
  // all — no plugin, no toolbar app, no stamp.
263
271
  if (command !== "dev") {
@@ -288,6 +296,21 @@ export default function orbytesPin(options = {}) {
288
296
  }
289
297
  project = String(config.project ?? projectName(repoRoot));
290
298
 
299
+ // Inside the lab: the Tasks views are Astro pages in the lab's chrome, injected HERE — past
300
+ // assertion one, so they exist in `astro dev` and in no build — and the lab is told where
301
+ // they are through the shared object, which is how its Tasks group knows to appear.
302
+ if (config.lab && config.lab.shared) {
303
+ const route = boardRoute(config.route);
304
+ injectRoute({ pattern: `${route}/[...view]`, entrypoint: file("../chrome/views/Tasks.astro"), prerender: true });
305
+ config.lab.shared.tasks = {
306
+ base: route,
307
+ api: `${route}/api/ticket`,
308
+ assets: `${route}/assets/`,
309
+ repoRoot,
310
+ backlogDir: config.backlogDir,
311
+ };
312
+ }
313
+
291
314
  addDevToolbarApp({
292
315
  id: config.appId,
293
316
  name: config.appName,
@@ -411,7 +434,15 @@ export default function orbytesPin(options = {}) {
411
434
  return next();
412
435
  }
413
436
  const path = url.pathname;
414
- const wall = path === route || path === `${route}/`;
437
+ // The board's old address, kept as a redirect for bookmarks and muscle memory.
438
+ if (config.lab && route !== "/pin" && (path === "/pin" || path === "/pin/")) {
439
+ res.statusCode = 302;
440
+ res.setHeader("Location", route + url.search);
441
+ return res.end();
442
+ }
443
+ // Inside the lab the board itself is an Astro page (injected above), so the wall is not
444
+ // this middleware's to draw — only its screenshots and its one write endpoint are.
445
+ const wall = !config.lab && (path === route || path === `${route}/`);
415
446
  const api = path === apiPath;
416
447
  if (!wall && !api && !path.startsWith(assetPrefix)) return next();
417
448
 
@@ -698,7 +698,7 @@ const BOARD_ROUTE = (pinConfig && pinConfig.route) || '/pin';
698
698
  * The panel has exactly TWO tabs, which is fixed and not a preference, so the six map onto them:
699
699
  * **Open is everything that is not `Resolved` and not `Cancelled`**, `Resolved` is its own tab, and
700
700
  * `Cancelled` appears in neither and draws no marker — drawing an archived ticket on the page is
701
- * precisely the "taking up space" the status exists to stop. It stays reachable on `/pin`.
701
+ * precisely the "taking up space" the status exists to stop. It stays reachable on the board.
702
702
  *
703
703
  * These names are for CLASSIFYING and LABELLING only. Every write sends the status string the
704
704
  * board itself reported (› `setStatus`), never a string from this list, because `expect` is
@@ -1,371 +1,124 @@
1
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.
2
+ // Folder pages — <subpath>/browse/[...path], injected by ../../index.mjs. One page per tier and per
3
+ // folder under it (sections, sections/Section02Manifesto, …), plus the root, which lists the tiers.
4
+ // Level 2 is the tier's tree; the content is the folder's components as cards, grouped by the
5
+ // sub-folder they sit in, and the panel's search and filter narrow both at once.
6
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.
7
+ // REDESIGNED 2026-09-24 onto the chrome shell (../chrome/Shell.astro). Cards are per component
8
+ // now, not per story — a component's stories are tabs on its own page.
11
9
  //
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.
10
+ // Pure chrome: this page renders no story. Its thumbnails are iframes of /lab/stories/<id>, which
11
+ // are their own documents and get the consumer's CSS there (decided 2026-09-22 — ../ui/lab.css).
19
12
  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";
13
+ import Shell from "../chrome/Shell.astro";
14
+ import Tree from "../chrome/Tree.astro";
15
+ import ComponentCard from "../chrome/ComponentCard.astro";
16
+ import { hrefs, labConfig, type Crumb, type FilterOption, type TreeNode } from "../chrome/model";
17
+ import { componentsOf, tierTree, type LabComponent } from "../chrome/trees";
18
+ import { buildLabIndex, sortTiers, tierLabel } from "./lab-index";
23
19
 
24
20
  export const getStaticPaths = (() => {
25
21
  const index = buildLabIndex();
26
22
  return [
27
- { params: { path: undefined }, props: { index, folder: null } },
28
- ...index.folders.map((folder) => ({ params: { path: folder.path }, props: { index, folder } })),
23
+ { params: { path: undefined }, props: { folder: "" } },
24
+ ...index.folders.map((folder) => ({ params: { path: folder.path }, props: { folder: folder.path } })),
29
25
  ];
30
26
  }) satisfies GetStaticPaths;
31
27
 
32
28
  type Props = InferGetStaticPropsType<typeof getStaticPaths>;
33
29
 
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
- })),
30
+ const { folder } = Astro.props as Props;
31
+ const index = buildLabIndex();
32
+ const all = componentsOf(index);
33
+ const segments = folder ? folder.split("/") : [];
34
+ const tier = segments[0] ?? "";
35
+
36
+ const under = (mod: LabComponent) => !folder || mod.directory === folder || mod.directory.startsWith(`${folder}/`);
37
+
38
+ // One grid, in the tree's own order, so the cards read in the same sequence as level 2. The root
39
+ // page (no folder) lists every tier in turn, each under its name.
40
+ const leafOrder = (tierId: string) => {
41
+ const ids: string[] = [];
42
+ const walk = (nodes: TreeNode[]) => {
43
+ for (const node of nodes) node.children ? walk(node.children) : ids.push(node.id);
44
+ };
45
+ walk(tierTree(index, tierId));
46
+ return ids;
47
+ };
48
+ const inOrder = (tierId: string) => {
49
+ const byId = new Map(all.filter((m) => m.tier === tierId).map((m) => [`mod:${m.moduleId}`, m]));
50
+ return leafOrder(tierId).map((id) => byId.get(id)!).filter(Boolean);
51
+ };
52
+ const ordered: (readonly [string, LabComponent[]])[] = folder
53
+ ? [["", inOrder(tier).filter(under)] as const]
54
+ : sortTiers([...new Set(all.map((m) => m.tier))]).map((t) => [t, inOrder(t)] as const);
55
+ const mods = ordered.flatMap(([, list]) => list);
56
+
57
+ const tierDef = labConfig.tiers.find((t) => t.id === tier);
58
+ const title = folder ? (segments.length === 1 ? tierLabel(tier) : segments[segments.length - 1]!) : "All components";
59
+ // Level 2's breadcrumb names the panel's place (Site › Sections); a deeper folder is the navbar's.
60
+ const crumbs: Crumb[] = folder
61
+ ? [{ label: "Site" }, { label: tierLabel(tier), href: hrefs.tier(tier) }]
62
+ : [{ label: "Site" }, { label: "All components" }];
63
+
64
+ const filters: FilterOption[] = [
65
+ { value: "live", label: "Live on the site" },
66
+ { value: "used", label: "Used by something live" },
67
+ { value: "unused", label: "Unused" },
68
+ ...(tierDef?.responsive
69
+ ? [
70
+ { value: "responsive", label: "Made responsive" },
71
+ { value: "unresponsive", label: "Not responsive yet" },
72
+ ]
73
+ : []),
58
74
  ];
59
- const title = crumbs.map((c) => c.label).join(" › ");
75
+ const live = mods.filter((m) => m.live).length;
76
+ const stories = mods.reduce((n, m) => n + m.stories.length, 0);
60
77
  ---
61
78
 
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
- })}
79
+ <Shell
80
+ title={title}
81
+ active={tier ? `tier:${tier}` : "home"}
82
+ panel={folder ? { label: tierLabel(tier), crumbs, filters, filterLabel: "Show components", searchPlaceholder: `Search ${tierLabel(tier).toLowerCase()}` } : undefined}
83
+ >
84
+ {folder && <Tree slot="panel" nodes={tierTree(index, tier)} />}
85
+
86
+ <Fragment slot="navbar">
87
+ <div class="lab-navbar__id">
88
+ <h1 class="lab-navbar__title">
89
+ {title}
90
+ {segments.length > 1 && <small>{segments.slice(0, -1).map((s, i) => (i === 0 ? tierLabel(s) : s)).join(" / ")}</small>}
91
+ </h1>
92
+ <div class="lab-navbar__meta">
93
+ <span class="lab-pill">{mods.length} component{mods.length === 1 ? "" : "s"}</span>
94
+ <span class="lab-pill">{stories} stor{stories === 1 ? "y" : "ies"}</span>
95
+ {live > 0 && <span class="lab-pill lab-pill--live">{live} live</span>}
96
+ </div>
97
+ </div>
98
+ </Fragment>
99
+
100
+ <div class="lab-page">
101
+ {
102
+ mods.length === 0 ? (
103
+ <div class="lab-empty">
104
+ <strong>Nothing here yet</strong>
105
+ Add a <code>*.stories.ts</code> file under <code>{labConfig.directory}/{folder}</code> and it appears here.
106
+ </div>
107
+ ) : (
108
+ ordered.map(([key, list]) => (
109
+ <section data-lab-filter-section>
110
+ {key && (
111
+ <h2 class="lab-section-title">
112
+ <a href={hrefs.tier(key)}>{tierLabel(key)}</a>
113
+ <small>{list.length}</small>
114
+ </h2>
115
+ )}
116
+ <div class="lab-cards">
117
+ {list.map((mod) => <ComponentCard mod={mod} />)}
135
118
  </div>
136
119
  </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>
120
+ ))
121
+ )
122
+ }
123
+ </div>
124
+ </Shell>