@orbytes/astrolab 0.3.0 → 0.4.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +261 -116
  2. package/bin/pin-gallery.mjs +53 -19
  3. package/defaults.mjs +72 -20
  4. package/dist/core/virtual-module/virtual-routes.js +12 -1
  5. package/docs/PIN-CONTRACT.md +84 -10
  6. package/docs/PIN.md +117 -37
  7. package/index.d.ts +41 -23
  8. package/index.mjs +46 -91
  9. package/package.json +7 -3
  10. package/src/Home.astro +166 -264
  11. package/src/LabHead.astro +37 -1047
  12. package/src/chrome/ActionsMenu.astro +97 -0
  13. package/src/chrome/ComponentCard.astro +76 -0
  14. package/src/chrome/Icon.astro +21 -0
  15. package/src/chrome/LICENSE-icons +43 -0
  16. package/src/chrome/Nav.astro +131 -0
  17. package/src/chrome/Panel.astro +117 -0
  18. package/src/chrome/Properties.astro +104 -0
  19. package/src/chrome/SectionsTree.astro +128 -0
  20. package/src/chrome/Shell.astro +120 -0
  21. package/src/chrome/Sprite.astro +23 -0
  22. package/src/chrome/StoryView.astro +192 -0
  23. package/src/chrome/Tree.astro +86 -0
  24. package/src/chrome/ViewportControls.astro +173 -0
  25. package/src/chrome/ViewportStage.astro +55 -0
  26. package/src/chrome/fonts/OFL.txt +93 -0
  27. package/src/chrome/fonts/inter-latin-wght-normal.woff2 +0 -0
  28. package/src/chrome/icons.ts +68 -0
  29. package/src/chrome/marks-client.ts +75 -0
  30. package/src/chrome/model.ts +156 -0
  31. package/src/chrome/navbar-client.ts +324 -0
  32. package/src/chrome/params-client.ts +434 -0
  33. package/src/chrome/pins-data.ts +63 -0
  34. package/src/chrome/shell-client.ts +468 -0
  35. package/src/chrome/site-data.ts +230 -0
  36. package/src/chrome/trees.ts +257 -0
  37. package/src/chrome/viewport-client.ts +405 -0
  38. package/src/chrome/views/Assets.astro +125 -0
  39. package/src/chrome/views/Pages.astro +214 -0
  40. package/src/chrome/views/Placeholder.astro +37 -0
  41. package/src/chrome/views/Tasks.astro +79 -0
  42. package/src/core/LICENSE-astrobook +21 -0
  43. package/src/core/lib/components/home.astro +4 -2
  44. package/src/core/lib/pages/story.astro +12 -10
  45. package/src/core/utils/kebab-case.ts +2 -2
  46. package/src/core/virtual-module/virtual-routes.ts +20 -4
  47. package/src/pin/board.mjs +414 -190
  48. package/src/pin/index.mjs +67 -22
  49. package/src/pin/tickets.mjs +6 -5
  50. package/src/pin/toolbar.js +82 -4
  51. package/src/shell/Browse.astro +131 -353
  52. package/src/shell/Viewport.astro +22 -1315
  53. package/src/shell/lab-index.ts +28 -18
  54. package/src/shell/lab-params.ts +113 -6
  55. package/src/shell/live-files.mjs +212 -10
  56. package/src/shell/marks.mjs +17 -41
  57. package/src/ui/components/app.astro +5 -7
  58. package/src/ui/components/preview-layout.astro +17 -0
  59. package/src/ui/components/theme-script.astro +17 -5
  60. package/src/ui/lab.css +3754 -371
  61. package/virtual.d.ts +13 -4
  62. package/bin/lab-cull.mjs +0 -401
  63. package/src/shell/CardGrid.astro +0 -297
  64. package/src/ui/components/build-path.ts +0 -13
  65. package/src/ui/components/build-tree.ts +0 -108
  66. package/src/ui/components/collapse-duration.ts +0 -28
  67. package/src/ui/components/compress-terms.ts +0 -10
  68. package/src/ui/components/dashboard-layout.astro +0 -39
  69. package/src/ui/components/home.astro +0 -65
  70. package/src/ui/components/layout.astro +0 -110
  71. package/src/ui/components/sidebar-button-fullscreen.astro +0 -38
  72. package/src/ui/components/sidebar-button-search.astro +0 -23
  73. package/src/ui/components/sidebar-button-theme.astro +0 -9
  74. package/src/ui/components/sidebar-button.astro +0 -24
  75. package/src/ui/components/sidebar-resize-handle.astro +0 -74
  76. package/src/ui/components/sidebar-search-panel.astro +0 -41
  77. package/src/ui/components/sidebar-search-script.ts +0 -103
  78. package/src/ui/components/sidebar-title.astro +0 -17
  79. package/src/ui/components/sidebar-tree-node.astro +0 -143
  80. package/src/ui/components/sidebar-tree.astro +0 -84
  81. package/src/ui/components/sidebar.astro +0 -29
  82. package/src/ui/components/theme-toggle.astro +0 -63
@@ -0,0 +1,128 @@
1
+ ---
2
+ // Level 2 for the sections tier (Figma `Astrolab` › `Sections tree`, decided 2026-09-24): a
3
+ // "Sort by" dropdown, then one row per section with its slot badge — the sections a page mounts
4
+ // first, the rest under "Unslotted · on no page". The rows and their order come from
5
+ // ./trees.ts › sectionsTree; ./Tree.astro hands a tree to this component when its root rows carry a
6
+ // slot.
7
+ //
8
+ // It is the same tree to the shell script as any other: one [data-lab-tree] list of
9
+ // .lab-tree__node rows, so search, the filter menu, j / k and the scroll memory all work unchanged.
10
+ // What it adds is the sort. The rows are rendered in slot order; each carries its rank in both
11
+ // orders as CSS custom properties, and the viewer's choice (localStorage `lab-tree-sort`) is put on
12
+ // <html> before paint by the inline script in ./Shell.astro, so a remembered "Name" sort is drawn
13
+ // by CSS `order` from the first frame. ./shell-client.ts then moves the rows themselves into that
14
+ // order, so reading order, keyboard order and j / k agree with what is on screen.
15
+ import Icon from "./Icon.astro";
16
+ import { labConfig, sectionsTier, type TreeNode } from "./model";
17
+
18
+ interface Props {
19
+ nodes: TreeNode[];
20
+ }
21
+
22
+ const { nodes } = Astro.props;
23
+
24
+ const slotted = nodes.filter((n) => n.group !== "unslotted");
25
+ const unslotted = nodes.filter((n) => n.group === "unslotted");
26
+
27
+ const holdsCurrent = (node: TreeNode): boolean => Boolean(node.current) || (node.children ?? []).some(holdsCurrent);
28
+ const leaves = (list: TreeNode[]): number => list.reduce((n, node) => n + (node.children ? leaves(node.children) : 1), 0);
29
+ const count = leaves(nodes);
30
+ const tierName = (labConfig.tiers.find((t) => t.id === sectionsTier)?.label ?? "Sections").toLowerCase();
31
+ const noun = count === 1 && tierName.endsWith("s") ? tierName.slice(0, -1) : tierName;
32
+
33
+ const searchText = (node: TreeNode) => `${node.label} ${node.search ?? ""}`.toLowerCase();
34
+ const orderStyle = (node: TreeNode) =>
35
+ node.order ? `--lab-o-slot:${node.order.slot};--lab-o-name:${node.order.name}` : undefined;
36
+ const badge = (node: TreeNode) =>
37
+ node.slot
38
+ ? {
39
+ text: String(node.slot.n),
40
+ state: node.current ? "current" : "slotted",
41
+ title: `Slot ${node.slot.n} of ${node.slot.of} on ${node.slot.page}`,
42
+ aria: `${node.label}, slot ${node.slot.n}`,
43
+ }
44
+ : {
45
+ text: "–",
46
+ state: node.current ? "current" : "unslotted",
47
+ title: "On no page",
48
+ aria: `${node.label}, on no page`,
49
+ };
50
+ const heading = slotted.length;
51
+ ---
52
+
53
+ <div class="lab-stree-head">
54
+ <span class="lab-stree-head__count">{count} {noun}</span>
55
+ <button class="lab-stree-head__sort" type="button" popovertarget="lab-tree-sort" aria-haspopup="menu" data-lab-sort-btn>
56
+ Sort by
57
+ <b><span class="lab-stree-head__by-slot">Slot</span><span class="lab-stree-head__by-name">Name</span></b>
58
+ <Icon name="chevron-down" size="sm" />
59
+ </button>
60
+ </div>
61
+ <div class="lab-menu lab-menu--sort" id="lab-tree-sort" popover role="menu" data-lab-align="end">
62
+ <button class="lab-menu__item" type="button" role="menuitemradio" aria-checked="true" data-lab-sort-by="slot">
63
+ <Icon name="check" size="sm" class="lab-menu__tick" />
64
+ Slot number
65
+ </button>
66
+ <button class="lab-menu__item" type="button" role="menuitemradio" aria-checked="false" data-lab-sort-by="name">
67
+ <Icon name="check" size="sm" class="lab-menu__tick" />
68
+ Name (A–Z)
69
+ </button>
70
+ </div>
71
+
72
+ <ul class="lab-tree lab-stree" role="tree" aria-label={`${count} ${noun}`} data-lab-tree data-lab-stree>
73
+ {
74
+ [...slotted, ...unslotted].map((node, i) => {
75
+ const b = badge(node);
76
+ const open = Boolean(node.children?.length) && (!node.childrenWhenCurrent || holdsCurrent(node));
77
+ return (
78
+ <>
79
+ {i === heading && unslotted.length > 0 && (
80
+ <li class="lab-stree__heading" role="none" style={`--lab-o-slot:${heading};--lab-o-name:${heading}`} data-lab-tree-heading>
81
+ Unslotted · on no page
82
+ </li>
83
+ )}
84
+ <li
85
+ class="lab-tree__node"
86
+ role="none"
87
+ style={orderStyle(node)}
88
+ data-lab-slot-group={node.group}
89
+ data-lab-search={searchText(node)}
90
+ data-lab-facets={node.facets}
91
+ >
92
+ <a
93
+ class="lab-tree__row"
94
+ role="treeitem"
95
+ href={node.href}
96
+ aria-current={node.current ? "page" : undefined}
97
+ aria-label={b.aria}
98
+ aria-expanded={node.children?.length ? String(open) : undefined}
99
+ title={node.title}
100
+ data-lab-leaf
101
+ >
102
+ <span class="lab-slot" data-state={b.state} title={b.title}>{b.text}</span>
103
+ <span class="lab-tree__label">{node.label}</span>
104
+ </a>
105
+ {open && (
106
+ <ul class="lab-tree__group" role="group">
107
+ {node.children!.map((child) => (
108
+ <li class="lab-tree__node" role="none" style="--lab-d:1" data-lab-search={searchText(child)} data-lab-facets={child.facets}>
109
+ <a
110
+ class="lab-tree__row"
111
+ role="treeitem"
112
+ href={child.href}
113
+ aria-current={child.current ? "page" : undefined}
114
+ title={child.title}
115
+ data-lab-leaf
116
+ >
117
+ <span class="lab-tree__label">{child.label}</span>
118
+ </a>
119
+ </li>
120
+ ))}
121
+ </ul>
122
+ )}
123
+ </li>
124
+ </>
125
+ );
126
+ })
127
+ }
128
+ </ul>
@@ -0,0 +1,120 @@
1
+ ---
2
+ // The chrome document — every lab page renders through this, and nothing of the consumer's does.
3
+ // Level 1 (./Nav.astro), level 2 (./Panel.astro, when the page has a list to show), and main: the
4
+ // navbar (slot "navbar") above the content (default slot). Figma `Astrolab` › Design › Desktop.
5
+ //
6
+ // PURE CHROME, NO CONSUMER CSS. Previews are <iframe>s of their own documents (the bare story
7
+ // route, or the site's own page), so this document never imports `virtual:astrobook/user-css.mjs`
8
+ // or the consumer's head. That split is what testbeds/orbytes-v2/scripts/check-isolation.mjs
9
+ // asserts, both ways, on every built page.
10
+ //
11
+ // NO CLIENT ROUTER. A click is a real page load; `@view-transition { navigation: auto }` in the
12
+ // stylesheet keeps the old page painted until the new one is ready, and everything worth keeping
13
+ // across a load — which levels are collapsed, which tree groups are closed, the tree's scroll — is
14
+ // in storage and re-applied before paint. That keeps every script here a plain module that runs
15
+ // once per page, with none of the re-run guards a swapped document needs.
16
+ import LabHead from "../LabHead.astro";
17
+ import Nav from "./Nav.astro";
18
+ import Panel from "./Panel.astro";
19
+ import Sprite from "./Sprite.astro";
20
+ import Icon from "./Icon.astro";
21
+ import { labConfig, type Crumb, type FilterOption, type NavKey } from "./model";
22
+
23
+ interface Props {
24
+ /** The document title's first half; the lab's own title follows it. */
25
+ title: string;
26
+ active: NavKey;
27
+ /** Level 2. Omit `panel` for a page with no list to show (Home, Settings): level 2 is not drawn. */
28
+ panel?: {
29
+ label: string;
30
+ crumbs: Crumb[];
31
+ search?: boolean;
32
+ searchPlaceholder?: string;
33
+ filters?: FilterOption[];
34
+ filterLabel?: string;
35
+ };
36
+ /** Extra class on main's content box, e.g. to make it a positioning context for the stage. */
37
+ contentClass?: string;
38
+ /** Extra class on the navbar, e.g. `lab-navbar--listing` for a listing's title bar. */
39
+ navbarClass?: string;
40
+ }
41
+
42
+ const { title, active, panel, contentClass, navbarClass } = Astro.props;
43
+ ---
44
+
45
+ <html lang="en" class="lab-shell" data-lab-page={active}>
46
+ <head>
47
+ <meta charset="utf-8" />
48
+ <meta name="viewport" content="width=device-width" />
49
+ <meta name="generator" content={Astro.generator} />
50
+ <title>{`${title} · ${labConfig.title}`}</title>
51
+ <LabHead />
52
+ {/* The collapse state and the Sections tree's sort, before paint — so a closed panel never
53
+ flashes open and a tree sorted by name never flashes in slot order. Level 1 starts as the
54
+ rail on a narrow window unless it was explicitly expanded there. */}
55
+ <script is:inline>
56
+ (() => {
57
+ try {
58
+ const root = document.documentElement;
59
+ const l1 = localStorage.getItem("lab-l1");
60
+ const l2 = localStorage.getItem("lab-l2");
61
+ if (l1 === "rail" || (!l1 && window.innerWidth < 1024)) root.dataset.labL1 = "rail";
62
+ if (l2 === "closed") root.dataset.labL2 = "closed";
63
+ if (localStorage.getItem("lab-tree-sort") === "name") root.dataset.labSort = "name";
64
+ } catch {
65
+ /* storage blocked — the defaults are fine */
66
+ }
67
+ })();
68
+ </script>
69
+ </head>
70
+ <body class="lab-shell">
71
+ <Sprite />
72
+ <div class="lab-app">
73
+ <Nav active={active} />
74
+ {
75
+ panel && (
76
+ <Panel
77
+ label={panel.label}
78
+ crumbs={panel.crumbs}
79
+ search={panel.search}
80
+ searchPlaceholder={panel.searchPlaceholder}
81
+ filters={panel.filters}
82
+ filterLabel={panel.filterLabel}
83
+ >
84
+ <slot name="panel" />
85
+ </Panel>
86
+ )
87
+ }
88
+ <main class="lab-main" id="lab-main">
89
+ {panel && <div class="lab-peek-zone" data-lab-peek-zone aria-hidden="true" />}
90
+ <header class:list={["lab-navbar", navbarClass]} data-lab-navbar>
91
+ {
92
+ panel && (
93
+ <button
94
+ class="lab-icon-btn lab-navbar__panel-btn lab-tipped"
95
+ type="button"
96
+ data-lab-toggle="l2"
97
+ aria-label="Show panel"
98
+ aria-describedby="lab-tip-panel"
99
+ >
100
+ <Icon name="panel-open" />
101
+ <span class="lab-tip lab-tip--panel" id="lab-tip-panel" role="tooltip">
102
+ <span class="lab-tip__title">Show panel · ⌘B</span>
103
+ <span class="lab-tip__text">⌘⇧B collapses the menu</span>
104
+ <span class="lab-tip__text">⌘. hides both (focus mode)</span>
105
+ </span>
106
+ </button>
107
+ )
108
+ }
109
+ <slot name="navbar" />
110
+ </header>
111
+ <div class:list={["lab-content", contentClass]} id="lab-content">
112
+ <slot />
113
+ </div>
114
+ </main>
115
+ </div>
116
+ <script>
117
+ import "./shell-client";
118
+ </script>
119
+ </body>
120
+ </html>
@@ -0,0 +1,23 @@
1
+ ---
2
+ // The icon sprite — every icon the chrome uses, once per page, as <symbol>s that ./Icon.astro
3
+ // points at with <use>. A tree of fifty rows costs fifty `<use href>` tags rather than fifty copies
4
+ // of the same path data. Shapes and licence: ./icons.ts.
5
+ import { ICONS } from "./icons";
6
+ ---
7
+
8
+ <svg aria-hidden="true" focusable="false" style="position:absolute;width:0;height:0;overflow:hidden">
9
+ {
10
+ Object.entries(ICONS).map(([name, inner]) => (
11
+ <symbol
12
+ id={`lab-i-${name}`}
13
+ viewBox="0 0 24 24"
14
+ fill="none"
15
+ stroke="currentColor"
16
+ stroke-width="2"
17
+ stroke-linecap="round"
18
+ stroke-linejoin="round"
19
+ set:html={inner}
20
+ />
21
+ ))
22
+ }
23
+ </svg>
@@ -0,0 +1,192 @@
1
+ ---
2
+ // A component's page — <subpath>/dashboard/<storyId>, the route Astrobook's core generates for every
3
+ // story (../core/lib/pages/story.astro renders this).
4
+ //
5
+ // · the story is an <iframe> of its bare route on a canvas you size (./ViewportStage.astro), never
6
+ // rendered inline beside the chrome — so this page carries none of the site's CSS;
7
+ // · the tree stops at the component; the component's variants are ALWAYS a dropdown ("Default
8
+ // 1 of 3"), at any count, and previous / next (and j / k) step through every variant of the
9
+ // tier in tree order (decided 2026-09-24: no tabs in the navbar);
10
+ // · the identity is the name, the version and an eye: no pills. The eye opens the Properties
11
+ // panel — live and slot, open pins, the responsive marks, the source file (./Properties.astro);
12
+ // · everything that leaves the lab, and the pin and preview switches, are in ⋯ (./ActionsMenu.astro).
13
+ //
14
+ // THE NAVBAR (Figma `Astrolab` › Lab / Components › Navbar, View=Story) is one grid laid out by a
15
+ // container query on its own width, because the sidebars change the main area and never the
16
+ // viewport: ONE row when the navbar is 1040px wide or more — identity · variants · previous / next
17
+ // · device switch + Viewport field · Comment · Parameters · ⋯ — otherwise TWO rows, the viewport
18
+ // row last, and under 552px three or four, the identity on a row of its own with ⋯. The rules are
19
+ // lab.css › "The navbar" and "The narrow navbar".
20
+ import Shell from "./Shell.astro";
21
+ import Tree from "./Tree.astro";
22
+ import Icon from "./Icon.astro";
23
+ import Properties from "./Properties.astro";
24
+ import ActionsMenu from "./ActionsMenu.astro";
25
+ import ViewportControls from "./ViewportControls.astro";
26
+ import ViewportStage from "./ViewportStage.astro";
27
+ import { hrefs, tasksBase, type Crumb, type TreeNode } from "./model";
28
+ import { componentsOf, tierTree } from "./trees";
29
+ import { pinCounts } from "./pins-data";
30
+ import { buildLabIndex, isSectionLike, tierLabel } from "../shell/lab-index";
31
+
32
+ interface Props {
33
+ story: string;
34
+ }
35
+
36
+ const index = buildLabIndex();
37
+ const item = index.items.find((i) => i.storyId === Astro.props.story)!;
38
+ const mod = componentsOf(index).find((c) => c.moduleId === item.moduleId)!;
39
+ const tree = tierTree(index, item.tier, mod.moduleId);
40
+
41
+ // Every story of the tier, in the tree's own order, for previous / next.
42
+ const leaves = (nodes: TreeNode[]): TreeNode[] => nodes.flatMap((n) => (n.children ? leaves(n.children) : [n]));
43
+ const modules = new Map(componentsOf(index).map((c) => [`mod:${c.moduleId}`, c]));
44
+ const sequence = leaves(tree).flatMap((leaf) => modules.get(leaf.id)?.stories ?? []);
45
+ const at = sequence.findIndex((s) => s.storyId === item.storyId);
46
+ const prev = at > 0 ? sequence[at - 1] : null;
47
+ const next = at >= 0 && at < sequence.length - 1 ? sequence[at + 1] : null;
48
+
49
+ const pins = await pinCounts();
50
+ const tally = pins.forFile(mod.componentFile);
51
+ const liveStory = mod.stories.find((s) => s.live);
52
+ const section = isSectionLike(item);
53
+ const variantAt = mod.stories.indexOf(item) + 1;
54
+
55
+ // The Properties panel's "On the site" lines: every page the component is mounted on, or what
56
+ // uses it, or the honest grey "Not on any page".
57
+ const site = liveStory
58
+ ? liveStory.liveOn.map((m) => ({ text: `Live on ${m.page} — slot ${m.slot} of ${m.of}`, live: true }))
59
+ : mod.usedBy.length
60
+ ? [{ text: `Used by ${mod.usedBy.length}`, live: true }]
61
+ : [{ text: "Not on any page", live: false }];
62
+
63
+ // Level 2's breadcrumb names the PANEL's place — Site › Sections — as the design does; where the
64
+ // component sits inside the tier is the tree's job, and which component it is, the navbar's.
65
+ const crumbs: Crumb[] = [{ label: "Site" }, { label: tierLabel(item.tier), href: hrefs.tier(item.tier) }];
66
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
67
+ ---
68
+
69
+ <Shell
70
+ title={`${mod.moduleName} — ${item.storyName}`}
71
+ active={`tier:${item.tier}`}
72
+ panel={{
73
+ label: tierLabel(item.tier),
74
+ crumbs,
75
+ searchPlaceholder: `Search ${tierLabel(item.tier).toLowerCase()}`,
76
+ filterLabel: "Show components",
77
+ filters: [
78
+ { value: "live", label: "Live on the site" },
79
+ { value: "used", label: "Used by something live" },
80
+ { value: "unused", label: "Unused" },
81
+ ],
82
+ }}
83
+ >
84
+ <Tree slot="panel" nodes={tree} />
85
+
86
+ <div class="lab-nb" slot="navbar" data-lab-nb>
87
+ <div class="lab-nb__id">
88
+ <div class="lab-nb__line">
89
+ <h1 class="lab-nb__name" title={mod.moduleName}>{mod.moduleName}</h1>
90
+ {mod.version && <span class="lab-nb__tag">{mod.version}</span>}
91
+ <Properties
92
+ site={site}
93
+ pins={tasksBase ? { ...tally, href: `${tasksBase}/all` } : null}
94
+ responsive={mod.responsive ? { file: mod.moduleFile, ...mod.responsive } : null}
95
+ source={mod.componentFile}
96
+ />
97
+ </div>
98
+ </div>
99
+
100
+ <div class="lab-nb__variants">
101
+ <button
102
+ class="lab-variant-btn"
103
+ type="button"
104
+ popovertarget="lab-variant-menu"
105
+ title={`${plural(mod.stories.length, "variant")} · j / k step`}
106
+ >
107
+ <span class="lab-variant-btn__name">{item.storyName}</span>
108
+ <span class="lab-variant-btn__count">{variantAt} of {mod.stories.length}</span>
109
+ <Icon name="chevron-down" size="sm" />
110
+ </button>
111
+ <div class="lab-menu lab-menu--variants" id="lab-variant-menu" popover role="menu" data-lab-align="end">
112
+ <p class="lab-menu__label">{plural(mod.stories.length, "variant")}</p>
113
+ {
114
+ mod.stories.map((s) => (
115
+ <a
116
+ class="lab-menu__item"
117
+ role="menuitemradio"
118
+ aria-checked={String(s.storyId === item.storyId)}
119
+ href={s.dashboardUrl}
120
+ data-lab-keep-viewport
121
+ >
122
+ <span class="lab-menu__mark"><Icon name="check" size="sm" class="lab-menu__tick" /></span>
123
+ <span class="lab-menu__text">{s.storyName}</span>
124
+ </a>
125
+ ))
126
+ }
127
+ <hr />
128
+ <p class="lab-menu__note">j / k — next and previous</p>
129
+ </div>
130
+
131
+ <div class="lab-nb__step" role="group" aria-label="Previous and next variant">
132
+ {
133
+ prev ? (
134
+ <a
135
+ class="lab-tool lab-tool--icon"
136
+ href={prev.dashboardUrl}
137
+ title={`Previous variant · k — ${prev.moduleName} — ${prev.storyName}`}
138
+ data-lab-step="prev"
139
+ data-lab-keep-viewport
140
+ >
141
+ <Icon name="chevron-left" size="sm" />
142
+ <span class="lab-visually-hidden">Previous variant</span>
143
+ </a>
144
+ ) : (
145
+ <button class="lab-tool lab-tool--icon" type="button" disabled data-lab-step>
146
+ <Icon name="chevron-left" size="sm" />
147
+ <span class="lab-visually-hidden">Previous variant</span>
148
+ </button>
149
+ )
150
+ }
151
+ {
152
+ next ? (
153
+ <a
154
+ class="lab-tool lab-tool--icon"
155
+ href={next.dashboardUrl}
156
+ title={`Next variant · j — ${next.moduleName} — ${next.storyName}`}
157
+ data-lab-step="next"
158
+ data-lab-keep-viewport
159
+ >
160
+ <Icon name="chevron-right" size="sm" />
161
+ <span class="lab-visually-hidden">Next variant</span>
162
+ </a>
163
+ ) : (
164
+ <button class="lab-tool lab-tool--icon" type="button" disabled data-lab-step>
165
+ <Icon name="chevron-right" size="sm" />
166
+ <span class="lab-visually-hidden">Next variant</span>
167
+ </button>
168
+ )
169
+ }
170
+ </div>
171
+ </div>
172
+
173
+ <ViewportControls
174
+ defaultW={section ? 1440 : 960}
175
+ defaultH={section ? 900 : 540}
176
+ kind={section ? "section" : "component"}
177
+ comment={Boolean(tasksBase)}
178
+ parameters
179
+ />
180
+
181
+ <div class="lab-nb__actions">
182
+ <ActionsMenu editorUrl={item.editorUrl} fullScreenUrl={item.storyUrl} stagingUrl={item.stagingUrl} pins={Boolean(tasksBase)} />
183
+ </div>
184
+ </div>
185
+
186
+ <ViewportStage src={item.storyUrl} title={`${mod.moduleName} — ${item.storyName}`} pins={Boolean(tasksBase)} tasksHref={tasksBase ? `${tasksBase}/all` : null} parameters />
187
+ </Shell>
188
+
189
+ <script>
190
+ import "./marks-client";
191
+ import "./navbar-client";
192
+ </script>
@@ -0,0 +1,86 @@
1
+ ---
2
+ // The level 2 tree (Figma `Tree view`, sm) — recursive, server-rendered, and usable with no script:
3
+ // groups are <details>, rows are links. The shell script adds memory (which groups you closed),
4
+ // search, the filter menu and j/k on top. Connector lines are CSS on the <li>, so the tree carries
5
+ // no connector markup at all.
6
+ //
7
+ // Rows carry no icons and read at 12px (decided 2026-09-24, to condense level 2). A node's `icon`
8
+ // is still accepted — the data layer keeps it — and simply not drawn.
9
+ //
10
+ // A tree whose root rows carry a slot is the Sections tree, which draws its own rows and sort
11
+ // control (./SectionsTree.astro).
12
+ import Icon from "./Icon.astro";
13
+ import SectionsTree from "./SectionsTree.astro";
14
+ import type { TreeNode } from "./model";
15
+
16
+ interface Props {
17
+ nodes: TreeNode[];
18
+ depth?: number;
19
+ /** Set on the outermost call only. */
20
+ root?: boolean;
21
+ }
22
+
23
+ const { nodes, depth = 0, root = true } = Astro.props;
24
+ const sections = root && nodes.some((node) => node.slot !== undefined);
25
+
26
+ /** Whether a node is, or holds, the current page — those groups render open whatever was stored. */
27
+ const holdsCurrent = (node: TreeNode): boolean =>
28
+ Boolean(node.current) || (node.children ?? []).some(holdsCurrent);
29
+
30
+ const searchText = (node: TreeNode) => `${node.label} ${node.search ?? ""}`.toLowerCase();
31
+ ---
32
+
33
+ {
34
+ sections ? (
35
+ <SectionsTree nodes={nodes} />
36
+ ) : (
37
+ <ul class={root ? "lab-tree" : "lab-tree__group"} role={root ? "tree" : "group"} data-lab-tree={root ? "" : undefined}>
38
+ {nodes.map((node) => {
39
+ const hasChildren = (node.children?.length ?? 0) > 0;
40
+ const trailing = (
41
+ <>
42
+ {node.count !== undefined && <span class="lab-tree__count">{node.count}</span>}
43
+ {node.dot && <span class={`lab-dot lab-dot--${node.dot}`} title={node.dotTitle} />}
44
+ </>
45
+ );
46
+ return (
47
+ <li
48
+ class="lab-tree__node"
49
+ role="none"
50
+ style={`--lab-d:${depth}`}
51
+ data-lab-search={searchText(node)}
52
+ data-lab-facets={node.facets}
53
+ >
54
+ {hasChildren ? (
55
+ <details open data-lab-tree-id={node.id} data-lab-holds-current={holdsCurrent(node) ? "" : undefined}>
56
+ <summary class="lab-tree__row" role="treeitem" aria-current={node.current ? "page" : undefined} title={node.title}>
57
+ <span class="lab-tree__label">{node.href ? <a href={node.href}>{node.label}</a> : node.label}</span>
58
+ {trailing}
59
+ <Icon name="chevron-down" size="sm" class="lab-tree__chevron" />
60
+ </summary>
61
+ <Astro.self nodes={node.children!} depth={depth + 1} root={false} />
62
+ </details>
63
+ ) : node.href && !node.disabled ? (
64
+ <a
65
+ class="lab-tree__row"
66
+ role="treeitem"
67
+ href={node.href}
68
+ aria-current={node.current ? "page" : undefined}
69
+ title={node.title}
70
+ data-lab-leaf
71
+ >
72
+ <span class="lab-tree__label">{node.label}</span>
73
+ {trailing}
74
+ </a>
75
+ ) : (
76
+ <span class="lab-tree__row" role="treeitem" aria-disabled="true" title={node.title}>
77
+ <span class="lab-tree__label">{node.label}</span>
78
+ {trailing}
79
+ </span>
80
+ )}
81
+ </li>
82
+ );
83
+ })}
84
+ </ul>
85
+ )
86
+ }