@orbytes/astrolab 0.4.0-next.1 → 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 (50) hide show
  1. package/README.md +184 -84
  2. package/bin/pin-gallery.mjs +53 -19
  3. package/defaults.mjs +7 -20
  4. package/docs/PIN-CONTRACT.md +76 -10
  5. package/docs/PIN.md +93 -23
  6. package/index.d.ts +1 -7
  7. package/index.mjs +14 -81
  8. package/package.json +2 -2
  9. package/src/Home.astro +7 -8
  10. package/src/LabHead.astro +1 -1
  11. package/src/chrome/ActionsMenu.astro +97 -0
  12. package/src/chrome/ComponentCard.astro +9 -2
  13. package/src/chrome/Nav.astro +36 -10
  14. package/src/chrome/Panel.astro +17 -4
  15. package/src/chrome/Properties.astro +104 -0
  16. package/src/chrome/SectionsTree.astro +128 -0
  17. package/src/chrome/Shell.astro +20 -6
  18. package/src/chrome/StoryView.astro +103 -162
  19. package/src/chrome/Tree.astro +56 -53
  20. package/src/chrome/ViewportControls.astro +136 -61
  21. package/src/chrome/ViewportStage.astro +26 -3
  22. package/src/chrome/icons.ts +9 -0
  23. package/src/chrome/marks-client.ts +26 -53
  24. package/src/chrome/model.ts +14 -0
  25. package/src/chrome/navbar-client.ts +324 -0
  26. package/src/chrome/params-client.ts +434 -0
  27. package/src/chrome/pins-data.ts +42 -9
  28. package/src/chrome/shell-client.ts +99 -3
  29. package/src/chrome/trees.ts +112 -7
  30. package/src/chrome/viewport-client.ts +68 -242
  31. package/src/chrome/views/Assets.astro +21 -6
  32. package/src/chrome/views/Pages.astro +90 -54
  33. package/src/chrome/views/Placeholder.astro +3 -3
  34. package/src/chrome/views/Tasks.astro +12 -40
  35. package/src/core/LICENSE-astrobook +5 -0
  36. package/src/core/utils/kebab-case.ts +2 -2
  37. package/src/pin/board.mjs +25 -15
  38. package/src/pin/index.mjs +34 -20
  39. package/src/pin/tickets.mjs +6 -5
  40. package/src/pin/toolbar.js +81 -3
  41. package/src/shell/Browse.astro +35 -10
  42. package/src/shell/lab-index.ts +5 -4
  43. package/src/shell/lab-params.ts +113 -6
  44. package/src/shell/live-files.mjs +212 -10
  45. package/src/shell/marks.mjs +17 -41
  46. package/src/ui/components/preview-layout.astro +17 -0
  47. package/src/ui/components/theme-script.astro +4 -3
  48. package/src/ui/lab.css +2167 -566
  49. package/virtual.d.ts +0 -4
  50. package/bin/lab-cull.mjs +0 -401
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orbytes/astrolab",
3
- "version": "0.4.0-next.1",
3
+ "version": "0.4.0-next.2",
4
4
  "type": "module",
5
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": [
@@ -28,10 +28,10 @@
28
28
  "preview": "npm --workspace testbed-orbytes-v2 run preview",
29
29
  "build:staging": "npm --workspace testbed-orbytes-v2 run build:staging",
30
30
  "check:isolation": "npm --workspace testbed-orbytes-v2 run check:isolation",
31
+ "check:lab-index": "npm --workspace testbed-orbytes-v2 run check:lab-index",
31
32
  "verify": "npm --workspace testbed-orbytes-v2 run verify"
32
33
  },
33
34
  "bin": {
34
- "orbytes-lab-cull": "bin/lab-cull.mjs",
35
35
  "orbytes-pin-gallery": "bin/pin-gallery.mjs"
36
36
  },
37
37
  "main": "./index.mjs",
package/src/Home.astro CHANGED
@@ -79,15 +79,14 @@ const previewAlias = (branch: string) =>
79
79
  const unusedAssets = assets.filter((a) => a.usedBy.length === 0).length;
80
80
  ---
81
81
 
82
- <Shell title="Home" active="home">
82
+ <Shell title="Home" active="home" navbarClass="lab-navbar--listing">
83
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">
84
+ <div class="lab-listing-bar">
85
+ <h1 class="lab-listing-bar__title">Home</h1>
86
+ <p class="lab-listing-bar__counts">{labConfig.title}</p>
88
87
  {
89
88
  STAGING_URL && (
90
- <a class="lab-btn lab-btn--sm" href={STAGING_URL} target="_blank" rel="noopener">
89
+ <a class="lab-btn lab-btn--sm lab-listing-bar__end" href={STAGING_URL} target="_blank" rel="noopener">
91
90
  <Icon name="external" />
92
91
  Staging
93
92
  </a>
@@ -126,7 +125,7 @@ const unusedAssets = assets.filter((a) => a.usedBy.length === 0).length;
126
125
  {tier.live > 0 && <span class="lab-pill lab-pill--live">{tier.live} live</span>}
127
126
  {tier.used > 0 && <span class="lab-pill lab-pill--used">{tier.used} used</span>}
128
127
  {tier.unused > 0 && <span class="lab-pill">{tier.unused} unused</span>}
129
- <span class="lab-pill">{tier.stories} stories</span>
128
+ <span class="lab-pill">{tier.stories} variant{tier.stories === 1 ? "" : "s"}</span>
130
129
  </span>
131
130
  </a>
132
131
  ))
@@ -168,7 +167,7 @@ const unusedAssets = assets.filter((a) => a.usedBy.length === 0).length;
168
167
  {component.version && <span class="lab-card__version">{component.version}</span>}
169
168
  </a>
170
169
  ) : (
171
- <span class="lab-slots__name lab-slots__name--bare" title="Not in the lab — no stories file for it">
170
+ <span class="lab-slots__name lab-slots__name--bare" title="Not in the lab — no *.stories.ts file for it">
172
171
  {file.split("/").pop()}
173
172
  </span>
174
173
  )}
package/src/LabHead.astro CHANGED
@@ -5,7 +5,7 @@
5
5
  // consumer configured a key.
6
6
  //
7
7
  // REWRITTEN 2026-09-24 for the redesign. Until then this file also decorated Astrobook's sidebar
8
- // after the fact — pills, cull and responsive boxes, search widening, header buttons — with 800
8
+ // after the fact — pills, mark boxes, search widening, header buttons — with 800
9
9
  // lines of script patching a DOM it did not render, plus a copy of the whole lab index as JSON on
10
10
  // every page. The new chrome renders all of that on the server, so none of it is needed here.
11
11
  //
@@ -0,0 +1,97 @@
1
+ ---
2
+ // The navbar's ⋯ button and its menu — Figma `Astrolab` › Menu › Type=Actions. Everything that
3
+ // leaves the lab (VS Code, the bare variant full screen, staging, a link at this exact size), then
4
+ // two sections that are switches rather than places:
5
+ //
6
+ // Pins Show pins (on) and Show resolved (off) — they govern the markers drawn on the framed
7
+ // preview. They moved here from the navbar (decided 2026-09-24); the navbar keeps only
8
+ // Comment. Only while the pin board runs, which is `astro dev` with the board enabled.
9
+ // Preview Dark mode — the PREVIEW's own light or dark, separate from the chrome's. Hidden until
10
+ // the framed site turns out to have a dark mode (./viewport-client.ts looks for one).
11
+ //
12
+ // Checkable rows are `menuitemcheckbox` buttons, so the menu stays one list a keyboard can walk.
13
+ // Behaviour: ./navbar-client.ts (pins), ./viewport-client.ts (copy link, dark mode).
14
+ import Icon from "./Icon.astro";
15
+
16
+ interface Props {
17
+ editorUrl?: string | null;
18
+ /** The bare variant, alone in a tab. */
19
+ fullScreenUrl?: string | null;
20
+ stagingUrl?: string | null;
21
+ /** Show the Pins section — only where the pin board runs. */
22
+ pins: boolean;
23
+ }
24
+
25
+ const { editorUrl, fullScreenUrl, stagingUrl, pins } = Astro.props;
26
+ ---
27
+
28
+ <button
29
+ class="lab-tool lab-tool--icon lab-nb__more"
30
+ type="button"
31
+ popovertarget="lab-actions-menu"
32
+ title="More actions"
33
+ >
34
+ <Icon name="more" size="sm" />
35
+ <span class="lab-visually-hidden">More actions</span>
36
+ </button>
37
+ <div class="lab-menu lab-menu--actions" id="lab-actions-menu" popover role="menu" data-lab-align="end">
38
+ {
39
+ editorUrl && (
40
+ <a class="lab-menu__item" role="menuitem" href={editorUrl}>
41
+ <span class="lab-menu__mark"><Icon name="code" size="sm" /></span>
42
+ <span class="lab-menu__text">Open in VS Code</span>
43
+ </a>
44
+ )
45
+ }
46
+ {
47
+ fullScreenUrl && (
48
+ <a class="lab-menu__item" role="menuitem" href={fullScreenUrl} target="_blank" rel="noopener">
49
+ <span class="lab-menu__mark"><Icon name="expand" size="sm" /></span>
50
+ <span class="lab-menu__text">Open full screen</span>
51
+ <span class="lab-menu__hint">the bare variant</span>
52
+ </a>
53
+ )
54
+ }
55
+ {
56
+ stagingUrl && (
57
+ <a class="lab-menu__item" role="menuitem" href={stagingUrl} target="_blank" rel="noopener">
58
+ <span class="lab-menu__mark"><Icon name="staging" size="sm" /></span>
59
+ <span class="lab-menu__text">View on staging</span>
60
+ </a>
61
+ )
62
+ }
63
+ {(editorUrl || fullScreenUrl || stagingUrl) && <hr />}
64
+ <button class="lab-menu__item" type="button" role="menuitem" data-lab-copy-link>
65
+ <span class="lab-menu__mark"><Icon name="link" size="sm" /></span>
66
+ <span class="lab-menu__text" data-lab-copy-label>Copy link to this exact size</span>
67
+ <span class="lab-menu__hint" data-lab-copy-size></span>
68
+ </button>
69
+ {
70
+ pins && (
71
+ <>
72
+ <hr />
73
+ <p class="lab-menu__label" id="lab-actions-pins">Pins</p>
74
+ <div role="group" aria-labelledby="lab-actions-pins">
75
+ <button class="lab-menu__item" type="button" role="menuitemcheckbox" aria-checked="true" data-lab-pins-toggle="show">
76
+ <span class="lab-menu__mark"><span class="lab-menu__box" aria-hidden="true" /></span>
77
+ <span class="lab-menu__text">Show pins</span>
78
+ </button>
79
+ <button class="lab-menu__item" type="button" role="menuitemcheckbox" aria-checked="false" data-lab-pins-toggle="resolved">
80
+ <span class="lab-menu__mark"><span class="lab-menu__box" aria-hidden="true" /></span>
81
+ <span class="lab-menu__text">Show resolved</span>
82
+ </button>
83
+ </div>
84
+ </>
85
+ )
86
+ }
87
+ <div data-lab-preview-group hidden>
88
+ <hr />
89
+ <p class="lab-menu__label" id="lab-actions-preview">Preview</p>
90
+ <div role="group" aria-labelledby="lab-actions-preview">
91
+ <button class="lab-menu__item" type="button" role="menuitemcheckbox" aria-checked="false" data-lab-preview-theme>
92
+ <span class="lab-menu__mark"><span class="lab-menu__box" aria-hidden="true" /></span>
93
+ <span class="lab-menu__text">Dark mode</span>
94
+ </button>
95
+ </div>
96
+ </div>
97
+ </div>
@@ -4,6 +4,9 @@
4
4
  // <iframe> sized to a virtual viewport and scaled to the card by ../chrome/shell-client.ts: page
5
5
  // width for section-like tiers, a component stage otherwise. The whole card opens the component;
6
6
  // the icon links under it sit above that link.
7
+ //
8
+ // A live component carries the Sections tree's slot badge, not a "live · slot N of M" pill: one sign
9
+ // for "live in slot N" across the lab (decided 2026-09-24, ASTROL-26). The title keeps the long form.
7
10
  import Icon from "./Icon.astro";
8
11
  import { componentState, type LabComponent } from "./trees";
9
12
  import { isSectionLike, livePillText, liveTitle } from "../shell/lab-index";
@@ -30,10 +33,14 @@ const state = componentState(mod);
30
33
  {mod.version && <span class="lab-card__version">{mod.version}</span>}
31
34
  </span>
32
35
  <span class="lab-card__sub">
33
- {mod.stories.length} stor{mod.stories.length === 1 ? "y" : "ies"}{mod.summary ? ` · ${mod.summary}` : ""}
36
+ {mod.stories.length} variant{mod.stories.length === 1 ? "" : "s"}{mod.summary ? ` · ${mod.summary}` : ""}
34
37
  </span>
35
38
  <span class="lab-card__pills">
36
- {liveStory && <span class="lab-pill lab-pill--live" title={liveTitle(liveStory)}>{livePillText(liveStory)}</span>}
39
+ {liveStory?.live && (
40
+ <span class="lab-slot" data-state="slotted" title={liveTitle(liveStory) ?? livePillText(liveStory) ?? undefined}>
41
+ <span class="lab-visually-hidden">Live in slot </span>{liveStory.live.slot}
42
+ </span>
43
+ )}
37
44
  {!mod.live && mod.usedBy.length > 0 && (
38
45
  <span class="lab-pill lab-pill--used" title={mod.usedBy.join("\n")}>used by {mod.usedBy.length}</span>
39
46
  )}
@@ -1,8 +1,13 @@
1
1
  ---
2
2
  // Level 1 — the main nav (Figma `Sidebar navigation`, the white card). Home, the Site group (Pages,
3
3
  // one entry per tier, Assets), the Tasks group when the pin board is running, and Support and
4
- // Settings at the foot. Collapses to a 56px icon strip (⌘⇧B); in the strip a group's items open
5
- // as a flyout on hover or focus, drawn by CSS alone (../ui/lab.css › level 1 as a rail).
4
+ // Settings at the foot. Collapses to a 64px icon rail (⌘⇧B); in the rail a group's items open as
5
+ // a flyout on hover or focus, under the group's name, drawn by CSS alone (../ui/lab.css › level 1
6
+ // as a rail).
7
+ //
8
+ // The header carries the collapse button and the LAB's own light/dark toggle — a moon, beside the
9
+ // logo, under the logomark in the rail (decided 2026-09-24). It switches the chrome only; the
10
+ // preview's light/dark is a separate setting of its own.
6
11
  //
7
12
  // The logo and the Support / Settings pages are placeholders until they are designed.
8
13
  import Icon from "./Icon.astro";
@@ -25,15 +30,35 @@ const isOpen = (group: { items: { key: NavKey }[] }) => group.items.some((item)
25
30
  <span class="lab-brand__mark" aria-hidden="true"></span>
26
31
  <span class="lab-brand__name">Astrolab</span>
27
32
  </a>
28
- <button class="lab-icon-btn lab-theme-btn" type="button" data-lab-theme-toggle title="Light or dark lab">
29
- <Icon name="sun" class="lab-icon--sun" />
30
- <Icon name="moon" class="lab-icon--moon" />
31
- <span class="lab-visually-hidden">Toggle the lab's light or dark theme</span>
33
+ {/* The collapse button's name and tooltip follow the state: "Collapse menu" when level 1
34
+ is open, "Expand menu" in the rail (./shell-client.ts keeps aria-label in step). */}
35
+ <button
36
+ class="lab-icon-btn lab-l1__btn lab-tipped"
37
+ type="button"
38
+ data-lab-toggle="l1"
39
+ data-lab-l1-btn
40
+ aria-label="Collapse menu"
41
+ aria-describedby="lab-tip-l1"
42
+ >
43
+ <Icon name="chevrons-left" size="sm" class="lab-l1-btn__collapse" />
44
+ <Icon name="chevrons-right" size="sm" class="lab-l1-btn__expand" />
45
+ <span class="lab-tip lab-tip--l1" id="lab-tip-l1" role="tooltip">
46
+ <span class="lab-tip__title">
47
+ <span class="lab-l1-btn__collapse">Collapse menu</span><span class="lab-l1-btn__expand">Expand menu</span> · ⌘⇧B
48
+ </span>
49
+ <span class="lab-tip__text">⌘B shows or hides the panel</span>
50
+ <span class="lab-tip__text">⌘. hides both (focus mode)</span>
51
+ </span>
32
52
  </button>
33
- <button class="lab-icon-btn" type="button" data-lab-toggle="l1" title="Collapse the menu (⌘⇧B)" data-lab-l1-btn>
34
- <Icon name="chevrons-left" class="lab-l1-btn__collapse" />
35
- <Icon name="chevrons-right" class="lab-l1-btn__expand" />
36
- <span class="lab-visually-hidden">Collapse or expand the menu</span>
53
+ <button
54
+ class="lab-icon-btn lab-l1__btn lab-theme-btn"
55
+ type="button"
56
+ data-lab-theme-toggle
57
+ aria-pressed="false"
58
+ aria-label="Dark mode for the lab"
59
+ title="Dark mode for the lab"
60
+ >
61
+ <Icon name="moon" size="sm" />
37
62
  </button>
38
63
  </div>
39
64
 
@@ -66,6 +91,7 @@ const isOpen = (group: { items: { key: NavKey }[] }) => group.items.some((item)
66
91
  <Icon name="chevron-down" size="sm" class="lab-nav-group__chevron" />
67
92
  </button>
68
93
  <div class="lab-nav-group__items">
94
+ <p class="lab-nav-group__flyout-label" aria-hidden="true">{group.label}</p>
69
95
  {group.items.map((item) => (
70
96
  <a
71
97
  class="lab-nav-item"
@@ -1,7 +1,10 @@
1
1
  ---
2
2
  // Level 2 — the panel (Figma `Sidebar navigation`, the grey column): breadcrumb, search with its
3
3
  // filter menu, the tree in the default slot, and the coffee footer. Closes with ⌘B or its own
4
- // button; while closed it peeks out over the stage when the pointer reaches the stage's left edge.
4
+ // button; while closed it peeks out over the stage — below the navbar, never over it — when the
5
+ // pointer reaches the stage's left edge.
6
+ //
7
+ // Only the Site views have one. Kanban Board and All Tasks have no level 2 (decided 2026-09-24).
5
8
  //
6
9
  // The coffee link is a placeholder until it has a destination.
7
10
  import Icon from "./Icon.astro";
@@ -37,9 +40,19 @@ const { crumbs, label, search = true, searchPlaceholder = "Search", filters, fil
37
40
  }
38
41
  </ol>
39
42
  </nav>
40
- <button class="lab-icon-btn lab-l2__close" type="button" data-lab-toggle="l2" title="Close the panel (⌘B)">
41
- <Icon name="panel-close" />
42
- <span class="lab-visually-hidden">Close the panel</span>
43
+ {/* One button, two actions: docked, it hides the panel; while the panel only peeks, it keeps
44
+ it open. Its name follows the action — ./shell-client.ts swaps the label when a peek
45
+ starts and ends (decided 2026-09-24). */}
46
+ <button
47
+ class="lab-icon-btn lab-l2__close"
48
+ type="button"
49
+ data-lab-toggle="l2"
50
+ data-lab-l2-btn
51
+ aria-label="Hide panel"
52
+ title="Hide panel · ⌘B"
53
+ >
54
+ <Icon name="panel-close" class="lab-l2__close-hide" />
55
+ <Icon name="panel-open" class="lab-l2__close-dock" />
43
56
  </button>
44
57
  </div>
45
58
 
@@ -0,0 +1,104 @@
1
+ ---
2
+ // The eye beside a component's (or page's) name, and the Properties panel it opens. It replaces the
3
+ // navbar's status pills and their status menu (decided 2026-09-24: no pills in the identity — a
4
+ // panel that opens when you hover a small eye beside the name). Figma `Astrolab` › Menu ›
5
+ // Type=Properties, and the four cases on the `Lab / Properties` sheet.
6
+ //
7
+ // In order: On the site (live and its slot, used by, or not on any page), Open pins (with a row to
8
+ // the task list), Responsive (the two marks, sections only), Source file. Mark for deletion is not
9
+ // here: it was removed from the lab as a feature (decided 2026-09-24).
10
+ //
11
+ // Not a menu. The panel is a `popover="manual"` dialog: hovering the eye opens it after a short
12
+ // delay and it stays open while the pointer is on the eye or the panel; clicking the eye (or
13
+ // Enter / Space) pins it open until Escape or a click outside. Behaviour: ./navbar-client.ts. The
14
+ // marks are live checkboxes whenever the panel is open (./marks-client.ts).
15
+ import Icon from "./Icon.astro";
16
+
17
+ interface Props {
18
+ /** One line per fact under "On the site"; `live` draws the green dot, otherwise it is grey. */
19
+ site: { text: string; live: boolean }[];
20
+ /** Open and resolved pins, and where the row leads. Null when the pin board is not running. */
21
+ pins: { open: number; resolved: number; href: string } | null;
22
+ /** The responsive marks for this file, or null when the lab keeps none for it. */
23
+ responsive: { file: string; approved: boolean; done: boolean } | null;
24
+ source: string | null;
25
+ }
26
+
27
+ const { site, pins, responsive, source } = Astro.props;
28
+
29
+ const pinLine = (open: number, resolved: number) =>
30
+ open === 0 && resolved === 0 ? "No pins" : `${open === 0 ? "No open pins" : `${open} open`} · ${resolved} resolved`;
31
+ ---
32
+
33
+ <button
34
+ class="lab-tool lab-tool--icon"
35
+ type="button"
36
+ aria-expanded="false"
37
+ aria-controls="lab-props"
38
+ title="Properties — hover to look, click to pin open"
39
+ data-lab-props-trigger
40
+ >
41
+ <Icon name="properties" size="sm" />
42
+ <span class="lab-visually-hidden">Properties</span>
43
+ </button>
44
+ <div class="lab-menu lab-props" id="lab-props" popover="manual" role="dialog" aria-label="Properties" data-lab-props>
45
+ <p class="lab-menu__label">On the site</p>
46
+ <div class="lab-props__facts">
47
+ {
48
+ site.map((line) => (
49
+ <p class="lab-props__fact">
50
+ <span class:list={["lab-props__dot", line.live && "lab-props__dot--live"]} aria-hidden="true" />
51
+ {line.text}
52
+ </p>
53
+ ))
54
+ }
55
+ </div>
56
+ {
57
+ pins && (
58
+ <>
59
+ <hr />
60
+ <p class="lab-menu__label">Open pins</p>
61
+ <a class="lab-menu__item" href={pins.href}>
62
+ <span class="lab-menu__mark lab-props__pin">
63
+ <Icon name="marker" size="sm" />
64
+ </span>
65
+ <span class="lab-menu__text">{pinLine(pins.open, pins.resolved)}</span>
66
+ <Icon name="chevron-right" size="sm" class="lab-menu__trail" />
67
+ </a>
68
+ </>
69
+ )
70
+ }
71
+ {
72
+ responsive && (
73
+ <>
74
+ <hr />
75
+ <p class="lab-menu__label">Responsive</p>
76
+ <label class="lab-menu__item">
77
+ <span class="lab-menu__mark">
78
+ <input class="lab-checkbox" type="checkbox" data-lab-mark="approved" data-file={responsive.file} checked={responsive.approved} disabled />
79
+ </span>
80
+ <span class="lab-menu__text">Approved for responsive work</span>
81
+ </label>
82
+ <label class="lab-menu__item">
83
+ <span class="lab-menu__mark">
84
+ <input class="lab-checkbox" type="checkbox" data-lab-mark="done" data-file={responsive.file} checked={responsive.done} disabled />
85
+ </span>
86
+ <span class="lab-menu__text">Made responsive</span>
87
+ </label>
88
+ </>
89
+ )
90
+ }
91
+ {
92
+ source && (
93
+ <>
94
+ <hr />
95
+ <p class="lab-menu__label">Source file</p>
96
+ <p class="lab-props__source">{source}</p>
97
+ </>
98
+ )
99
+ }
100
+ <hr />
101
+ <p class="lab-props__note" data-lab-marks-note>
102
+ {responsive ? "Click the eye to pin · marks save to the repo" : "Click the eye to pin"}
103
+ </p>
104
+ </div>
@@ -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>
@@ -35,9 +35,11 @@ interface Props {
35
35
  };
36
36
  /** Extra class on main's content box, e.g. to make it a positioning context for the stage. */
37
37
  contentClass?: string;
38
+ /** Extra class on the navbar, e.g. `lab-navbar--listing` for a listing's title bar. */
39
+ navbarClass?: string;
38
40
  }
39
41
 
40
- const { title, active, panel, contentClass } = Astro.props;
42
+ const { title, active, panel, contentClass, navbarClass } = Astro.props;
41
43
  ---
42
44
 
43
45
  <html lang="en" class="lab-shell" data-lab-page={active}>
@@ -47,8 +49,9 @@ const { title, active, panel, contentClass } = Astro.props;
47
49
  <meta name="generator" content={Astro.generator} />
48
50
  <title>{`${title} · ${labConfig.title}`}</title>
49
51
  <LabHead />
50
- {/* The collapse state, before paint — so a closed panel never flashes open. Level 1 starts as
51
- the rail on a narrow window unless it was explicitly expanded there. */}
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. */}
52
55
  <script is:inline>
53
56
  (() => {
54
57
  try {
@@ -57,6 +60,7 @@ const { title, active, panel, contentClass } = Astro.props;
57
60
  const l2 = localStorage.getItem("lab-l2");
58
61
  if (l1 === "rail" || (!l1 && window.innerWidth < 1024)) root.dataset.labL1 = "rail";
59
62
  if (l2 === "closed") root.dataset.labL2 = "closed";
63
+ if (localStorage.getItem("lab-tree-sort") === "name") root.dataset.labSort = "name";
60
64
  } catch {
61
65
  /* storage blocked — the defaults are fine */
62
66
  }
@@ -83,12 +87,22 @@ const { title, active, panel, contentClass } = Astro.props;
83
87
  }
84
88
  <main class="lab-main" id="lab-main">
85
89
  {panel && <div class="lab-peek-zone" data-lab-peek-zone aria-hidden="true" />}
86
- <header class="lab-navbar">
90
+ <header class:list={["lab-navbar", navbarClass]} data-lab-navbar>
87
91
  {
88
92
  panel && (
89
- <button class="lab-icon-btn lab-navbar__panel-btn" type="button" data-lab-toggle="l2" title="Open the panel (⌘B)">
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
+ >
90
100
  <Icon name="panel-open" />
91
- <span class="lab-visually-hidden">Open the panel</span>
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>
92
106
  </button>
93
107
  )
94
108
  }