@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
@@ -15,7 +15,9 @@ import Tree from "../Tree.astro";
15
15
  import Icon from "../Icon.astro";
16
16
  import ViewportControls from "../ViewportControls.astro";
17
17
  import ViewportStage from "../ViewportStage.astro";
18
- import { hrefs, type Crumb, type FilterOption } from "../model";
18
+ import Properties from "../Properties.astro";
19
+ import ActionsMenu from "../ActionsMenu.astro";
20
+ import { hrefs, tasksBase, type Crumb, type FilterOption } from "../model";
19
21
  import { componentsOf } from "../trees";
20
22
  import { pagesTree, rootDir, sitePagesModel, type SitePage } from "../site-data";
21
23
  import { pinCounts } from "../pins-data";
@@ -52,86 +54,116 @@ const filters: FilterOption[] = [
52
54
  // the build writes `/about/index.html`, so the bare route resolves either way.
53
55
  const base = import.meta.env.BASE_URL.replace(/\/+$/, "");
54
56
  const src = page ? `${base}${page.route}` : "";
57
+
58
+ // The listing's title bar (Figma `Lab / Listing`): title, counts, and chips that set the same
59
+ // filter as level 2's menu.
60
+ const facetOf = (p: SitePage) => (p.dynamic ? "dynamic" : p.mounts.length ? "mounts" : "static");
61
+ const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
62
+ const tally = (facet: string) => pages.filter((p) => facetOf(p) === facet).length;
63
+ const counts = [
64
+ plural(pages.length, "page"),
65
+ tally("mounts") ? `${tally("mounts")} with components` : null,
66
+ tally("dynamic") ? plural(tally("dynamic"), "dynamic route") : null,
67
+ ].filter(Boolean);
68
+ const chips = [
69
+ { value: "", label: "All", n: pages.length },
70
+ { value: "mounts", label: "Components", n: tally("mounts") },
71
+ { value: "static", label: "None", n: tally("static") },
72
+ ...(tally("dynamic") ? [{ value: "dynamic", label: "Dynamic", n: tally("dynamic") }] : []),
73
+ ];
55
74
  ---
56
75
 
57
76
  <Shell
58
77
  title={page ? page.label : "Pages"}
59
78
  active="pages"
79
+ navbarClass={page ? undefined : "lab-navbar--listing"}
60
80
  panel={{ label: "Pages", crumbs, filters, filterLabel: "Show pages", searchPlaceholder: "Search pages" }}
61
81
  >
62
82
  <Tree slot="panel" nodes={pagesTree(pages, page?.route)} />
63
83
 
64
84
  {
65
85
  page ? (
66
- <Fragment slot="navbar">
67
- <div class="lab-navbar__id">
68
- <h1 class="lab-navbar__title" title={page.title ?? undefined}>
69
- {page.label}
70
- <small>{page.route}</small>
71
- </h1>
72
- <button class="lab-status-btn" type="button" popovertarget="lab-mounts-menu" title="What this page mounts">
73
- <span class="lab-pill">{page.mounts.length} component{page.mounts.length === 1 ? "" : "s"}</span>
74
- {pins.openFor(page.file) > 0 && <span class="lab-pill lab-pill--brand">{pins.openFor(page.file)} open pins</span>}
75
- <Icon name="chevron-down" size="sm" />
76
- </button>
77
- <div class="lab-menu" id="lab-mounts-menu" popover role="menu">
78
- <p class="lab-menu__label">On this page, in order</p>
79
- {page.mounts.length === 0 && <p class="lab-status-menu__fact">It mounts no components of its own.</p>}
86
+ <div class="lab-nb lab-nb--page" slot="navbar" data-lab-nb>
87
+ <div class="lab-nb__id">
88
+ <div class="lab-nb__line">
89
+ <h1 class="lab-nb__name" title={page.title ?? page.label}>{page.label}</h1>
90
+ <span class="lab-nb__tag lab-nb__tag--route">{page.route}</span>
91
+ <Properties
92
+ site={[{ text: `Served at ${page.route}`, live: true }]}
93
+ pins={tasksBase ? { ...pins.forUrl(page.route), href: `${tasksBase}/all` } : null}
94
+ responsive={null}
95
+ source={page.file}
96
+ />
97
+ </div>
98
+ <span class="lab-nb__facts">
99
+ <span class="lab-nb__source" title="The page's source file">
100
+ <Icon name="page" size="sm" />
101
+ {page.file}
102
+ </span>
103
+ <span aria-hidden="true">·</span>
104
+ <button class="lab-nb__chip" type="button" popovertarget="lab-mounts-menu" title="The components this page mounts">
105
+ <Icon name="sections" size="sm" />
106
+ {page.mounts.length} component{page.mounts.length === 1 ? "" : "s"}
107
+ <Icon name="chevron-down" size="sm" />
108
+ </button>
109
+ </span>
110
+ <div class="lab-menu lab-menu--components" id="lab-mounts-menu" popover role="menu">
111
+ <p class="lab-menu__label">
112
+ {page.mounts.length} component{page.mounts.length === 1 ? "" : "s"} on {page.route}
113
+ </p>
114
+ {page.mounts.length === 0 && <p class="lab-menu__note">It mounts no components of its own.</p>}
80
115
  {page.mounts.map((file, i) => {
81
116
  const mod = byFile.get(file);
82
117
  return mod ? (
83
118
  <a class="lab-menu__item" role="menuitem" href={mod.stories[0]!.dashboardUrl}>
84
- <span class="lab-slots__n">{i + 1}</span>
85
- {mod.moduleName}
86
- {mod.version && <span class="lab-menu__hint">{mod.version}</span>}
119
+ <span class="lab-menu__mark"><Icon name="sections" size="sm" /></span>
120
+ <span class="lab-menu__text">
121
+ {mod.moduleName}
122
+ {mod.version && <span class="lab-menu__sub"> {mod.version}</span>}
123
+ </span>
124
+ <span class="lab-menu__hint">slot {i + 1}</span>
87
125
  </a>
88
126
  ) : (
89
- <span class="lab-menu__item" aria-disabled="true" title="Not in the lab — no stories file for it">
90
- <span class="lab-slots__n">{i + 1}</span>
91
- {file.split("/").pop()}
127
+ <span class="lab-menu__item" role="menuitem" aria-disabled="true" title="Not in the lab — no *.stories.ts file for it">
128
+ <span class="lab-menu__mark"><Icon name="component" size="sm" /></span>
129
+ <span class="lab-menu__text">{file.split("/").pop()}</span>
130
+ <span class="lab-menu__hint">slot {i + 1}</span>
92
131
  </span>
93
132
  );
94
133
  })}
95
- <hr />
96
- <p class="lab-status-menu__fact lab-mono">{page.file}</p>
97
134
  </div>
98
135
  </div>
99
- <div class="lab-navbar__tools">
100
- <button class="lab-btn lab-btn--sm" type="button" data-lab-reload title="Reload the page in the frame">
101
- <Icon name="reload" />
102
- Reload
136
+ <div class="lab-nb__variants">
137
+ <button class="lab-tool lab-tool--icon" type="button" data-lab-reload title="Reload the page in the frame">
138
+ <Icon name="reload-page" size="sm" />
139
+ <span class="lab-visually-hidden">Reload</span>
103
140
  </button>
104
- <a class="lab-btn lab-btn--sm" href={src} target="_blank" rel="noopener" title="Open the real page in a new tab">
105
- <Icon name="external" />
106
- Open
141
+ <a class="lab-tool lab-tool--outline" href={src} target="_blank" rel="noopener" title="Open the real page in a new tab">
142
+ <Icon name="external" size="sm" />
143
+ <span class="lab-tool__label">Open page</span>
107
144
  </a>
108
145
  </div>
109
- <ViewportControls defaultW={1440} defaultH={900} kind="page" />
110
- <div class="lab-navbar__tools">
111
- <button class="lab-btn lab-btn--sm lab-btn--icon" type="button" popovertarget="lab-actions-menu" title="More">
112
- <Icon name="more" />
113
- <span class="lab-visually-hidden">More actions</span>
114
- </button>
115
- </div>
116
- <div class="lab-menu" id="lab-actions-menu" popover role="menu" data-lab-align="end">
117
- {import.meta.env.DEV && (
118
- <a class="lab-menu__item" role="menuitem" href={`vscode://file${rootDir.startsWith("/") ? "" : "/"}${rootDir}/${page.file}`}>
119
- <Icon name="code" />
120
- Open in VS Code
121
- </a>
122
- )}
123
- <button class="lab-menu__item" type="button" role="menuitem" data-lab-copy-link>
124
- <Icon name="link" />
125
- <span data-lab-copy-label>Copy link to this size</span>
126
- </button>
146
+ <ViewportControls defaultW={1440} defaultH={900} kind="page" comment={Boolean(tasksBase)} parameters={false} />
147
+ <div class="lab-nb__actions">
148
+ <ActionsMenu
149
+ editorUrl={import.meta.env.DEV ? `vscode://file${rootDir.startsWith("/") ? "" : "/"}${rootDir}/${page.file}` : null}
150
+ pins={Boolean(tasksBase)}
151
+ />
127
152
  </div>
128
- </Fragment>
153
+ </div>
129
154
  ) : (
130
155
  <Fragment slot="navbar">
131
- <div class="lab-navbar__id">
132
- <h1 class="lab-navbar__title">Pages</h1>
133
- <div class="lab-navbar__meta">
134
- <span class="lab-pill">{pages.length} page{pages.length === 1 ? "" : "s"}</span>
156
+ <div class="lab-listing-bar">
157
+ <h1 class="lab-listing-bar__title">Pages</h1>
158
+ <p class="lab-listing-bar__counts">{counts.join(" · ")}</p>
159
+ <div class="lab-chips" role="radiogroup" aria-label="Show">
160
+ {
161
+ chips.map((chip) => (
162
+ <button class="lab-chip" type="button" role="radio" aria-checked={String(chip.value === "")} data-lab-filter={chip.value}>
163
+ {chip.label} {chip.n}
164
+ </button>
165
+ ))
166
+ }
135
167
  </div>
136
168
  </div>
137
169
  </Fragment>
@@ -140,7 +172,7 @@ const src = page ? `${base}${page.route}` : "";
140
172
 
141
173
  {
142
174
  page ? (
143
- <ViewportStage src={src} title={page.title ?? page.label} />
175
+ <ViewportStage src={src} title={page.title ?? page.label} pins={Boolean(tasksBase)} tasksHref={tasksBase ? `${tasksBase}/all` : null} />
144
176
  ) : (
145
177
  <div class="lab-page">
146
178
  <div class="lab-cards">
@@ -176,3 +208,7 @@ const src = page ? `${base}${page.route}` : "";
176
208
  )
177
209
  }
178
210
  </Shell>
211
+
212
+ <script>
213
+ import "../navbar-client";
214
+ </script>
@@ -15,10 +15,10 @@ type Props = InferGetStaticPropsType<typeof getStaticPaths>;
15
15
  const { key, title, icon } = Astro.props as Props;
16
16
  ---
17
17
 
18
- <Shell title={title} active={key}>
18
+ <Shell title={title} active={key} navbarClass="lab-navbar--listing">
19
19
  <Fragment slot="navbar">
20
- <div class="lab-navbar__id">
21
- <h1 class="lab-navbar__title">{title}</h1>
20
+ <div class="lab-listing-bar">
21
+ <h1 class="lab-listing-bar__title">{title}</h1>
22
22
  </div>
23
23
  </Fragment>
24
24
  <div class="lab-placeholder">
@@ -9,11 +9,14 @@
9
9
  // lab's chrome and maps their palette onto the lab's tokens (`.pin-board--lab`). The Astro dev
10
10
  // toolbar's ticket panel reads this page the way it read /pin: by the `[data-board]` and
11
11
  // `script.card-data` payloads it carries, so the two cannot disagree about what a ticket is.
12
+ //
13
+ // NO LEVEL 2 (decided 2026-09-24): Kanban Board and All Tasks have no secondary panel. The board's
14
+ // own toolbar already filters by status, so a status tree beside it only took width from the
15
+ // columns; main takes the whole width beside level 1.
12
16
  import type { GetStaticPaths, InferGetStaticPropsType } from "astro";
13
17
  import Shell from "../Shell.astro";
14
- import Tree from "../Tree.astro";
15
- import { labConfig, type TreeNode } from "../model";
16
- import { BOARD_CSS, BOARD_SCRIPT, boardParts, collectTickets, READY_FOR_REVIEW, STATUSES } from "../../pin/board.mjs";
18
+ import { labConfig } from "../model";
19
+ import { BOARD_CSS, BOARD_SCRIPT, boardParts, collectTickets } from "../../pin/board.mjs";
17
20
 
18
21
  export const getStaticPaths = (() => [
19
22
  { params: { view: undefined }, props: { view: "board" as const } },
@@ -39,47 +42,16 @@ const assetHref = (t: Ticket) =>
39
42
  tasks && t.shotName ? tasks.assets + t.shotName.split("/").map(encodeURIComponent).join("/") : null;
40
43
  const parts = boardParts(tickets, broken, { assetHref, apiHref: tasks?.api ?? null, links: [], view });
41
44
 
42
- const base = tasks?.base ?? "";
43
- const here = view === "table" ? `${base}/all` : base;
44
- let wanted: string | null = null;
45
- try {
46
- wanted = Astro.url.searchParams.get("status");
47
- } catch {
48
- wanted = null;
49
- }
50
- const statusNodes: TreeNode[] = [
51
- { id: "status:all", label: "All tickets", href: here, icon: "tasks", count: tickets.length, current: !wanted },
52
- ...[...STATUSES, ...new Set(tickets.map((t) => t.status).filter((s) => !STATUSES.includes(s)))].map((status) => {
53
- const n = tickets.filter((t) => t.status === status).length;
54
- return {
55
- id: `status:${status}`,
56
- label: status,
57
- href: `${here}?status=${encodeURIComponent(status)}`,
58
- icon: "check" as const,
59
- count: n,
60
- dot: status === READY_FOR_REVIEW && n > 0 ? ("open" as const) : undefined,
61
- current: wanted === status,
62
- };
63
- }),
64
- ];
65
45
  const title = view === "table" ? "All Tasks" : "Kanban Board";
66
46
  ---
67
47
 
68
- <Shell
69
- title={title}
70
- active={view === "table" ? "tasks-all" : "tasks-board"}
71
- panel={{ label: "Tasks", crumbs: [{ label: "Tasks" }, { label: title }], search: false }}
72
- >
73
- <Tree slot="panel" nodes={statusNodes} />
74
-
48
+ <Shell title={title} active={view === "table" ? "tasks-all" : "tasks-board"} navbarClass="lab-navbar--listing">
75
49
  <Fragment slot="navbar">
76
- <div class="lab-navbar__id">
77
- <h1 class="lab-navbar__title">{title}</h1>
78
- <div class="lab-navbar__meta">
79
- {parts.counts.waiting > 0 && <span class="lab-pill lab-pill--brand">{parts.counts.waiting} ready for your review</span>}
80
- <span class="lab-pill">{parts.counts.total} ticket{parts.counts.total === 1 ? "" : "s"}</span>
81
- {parts.counts.needsHuman > 0 && <span class="lab-pill lab-pill--error">{parts.counts.needsHuman} needing a human</span>}
82
- </div>
50
+ <div class="lab-listing-bar">
51
+ <h1 class="lab-listing-bar__title">{title}</h1>
52
+ {/* Amber: what waits on you (the design's own use of it). */}
53
+ {parts.counts.waiting > 0 && <span class="lab-pill lab-pill--waiting">{parts.counts.waiting} ready for your review</span>}
54
+ {parts.counts.needsHuman > 0 && <span class="lab-pill lab-pill--error">{parts.counts.needsHuman} needing a human</span>}
83
55
  </div>
84
56
  </Fragment>
85
57
 
@@ -115,6 +115,11 @@ Every deliberate divergence in src/ui/ (all of it added 2026-09-22)
115
115
  preview-layout.astro, theme-script.astro, theme-message.ts, theme.ts and index.ts. lab.css
116
116
  was never upstream's (9) and is the redesign's stylesheet. `theme-script.astro` gained a
117
117
  `storageKey` prop, so the chrome and the preview each keep their own light/dark.
118
+ 17. `preview-layout.astro` hides Astro's dev toolbar when the story is framed (2026-09-24). Every
119
+ preview is an <iframe> of the bare story, and the dev server puts its toolbar in every page,
120
+ so a component page showed two. An inline script marks <html> when `window.frameElement` is
121
+ set and one rule on that mark sets the toolbar to `display: none`; opened in its own tab the
122
+ bare story keeps its toolbar.
118
123
 
119
124
  --------------------------------------------------------------------------------
120
125
  Every deliberate divergence in src/types/ (all of it added 2026-09-22)
@@ -3,8 +3,8 @@
3
3
  // Upstream's get-story-modules.ts imports `just-kebab-case`, but @astrobook/core declares it as a
4
4
  // DEV dependency only: tsdown bundles the function into the published dist, so the package never
5
5
  // installs it. This package ships source with no bundler, so the function has to come from
6
- // somewhere — and it decides every story id, which is what lab URLs and the cull/responsive
7
- // mark files are keyed on. The three regexes below were extracted from the bundle that shipped in
6
+ // somewhere — and it decides every story id, which is what lab URLs and the responsive mark file
7
+ // are keyed on. The three regexes below were extracted from the bundle that shipped in
8
8
  // @astrobook/core 0.13.3 (dist/index.js, its `just-kebab-case@4.2.0` region) so the ids cannot
9
9
  // drift.
10
10
  //
package/src/pin/board.mjs CHANGED
@@ -1,14 +1,17 @@
1
1
  // orbytes-pin — the pin board: one reader, one writer, one renderer, two surfaces.
2
2
  //
3
- // Two things consume this module and NOTHING here knows which:
4
- // · the `/pin` route on `astro dev` (index.mjs, dev-only middleware) — generated per request,
5
- // and the only surface where the writer is reachable;
6
- // · `npx orbytes-pin-gallery` (../../bin/pin-gallery.mjs) — one standalone file on disk.
3
+ // Three things consume this module and NOTHING here knows which:
4
+ // · the lab's Tasks views at `<subpath>/tasks` on `astro dev` (../chrome/views/Tasks.astro,
5
+ // through `boardParts`) — generated per request, with the writer reachable;
6
+ // · the pin half's own board at `<route>` when it runs without the lab (index.mjs, dev-only
7
+ // middleware, through `renderBoard`) — the same, standalone;
8
+ // · `npx orbytes-pin-gallery` (../../bin/pin-gallery.mjs) — one standalone file on disk, and the
9
+ // only one of the three with no writer.
7
10
  //
8
11
  // It exists because a second copy is how this system's last three bugs happened, all of them in
9
12
  // the gallery script and all of them the same shape: a local parser that drifted from the one in
10
13
  // tickets.mjs (see `collectTickets` below). A live route rendering its own cards would have been
11
- // the fourth. So the route and the script share this file and differ in exactly two arguments —
14
+ // the fourth. So every surface shares this file and they differ in exactly two arguments —
12
15
  // where a screenshot is fetched from (`assetHref`) and whether the page is live (`apiHref`).
13
16
  //
14
17
  // ── backlog.md is retired (2026-09-22) ──────────────────────────────────────────────────────
@@ -209,8 +212,8 @@ export const TICKET_FILE = /^pin-\d+.*\.md$/;
209
212
 
210
213
  /**
211
214
  * Every ticket in `<backlogDir>/tasks`, plus the ones that could not be read. Read fresh from disk
212
- * on every call — that is what makes the `/pin` route live and `orbytes-pin-gallery` a snapshot of the
213
- * same moment.
215
+ * on every call — that is what makes the dev-server board live and `orbytes-pin-gallery` a snapshot of
216
+ * the same moment.
214
217
  *
215
218
  * ONE reader: `parseTicket`, which owns the whole file — the frontmatter as much as the ```yaml
216
219
  * pin block and the comment. It spreads the frontmatter keys at the top level of what it returns,
@@ -237,9 +240,11 @@ export const TICKET_FILE = /^pin-\d+.*\.md$/;
237
240
  *
238
241
  * `Cancelled` tickets are EXCLUDED unless `includeCancelled` is passed, for the same reason
239
242
  * `listTickets` excludes them (ruled 2026-09-22: they must not take up context space for an
240
- * agent). The default is the safe one everywhere, and the three callers that genuinely need the
241
- * archive — the `/pin` route, `orbytes-pin-gallery`, and `updateTicket`, which must be able to move a
242
- * ticket back OUT of Cancelled — each say so explicitly at the call site.
243
+ * agent). The default is the safe one everywhere, and the callers that genuinely need the archive
244
+ * each say so explicitly at the call site: the three human-facing surfaces (the lab's Tasks views,
245
+ * the pin half's standalone board, `orbytes-pin-gallery`), the write endpoint's re-read of the card
246
+ * it just moved (index.mjs), and `updateTicket`, which must be able to move a ticket back OUT of
247
+ * Cancelled.
243
248
  *
244
249
  * A ticket that cannot be READ is still collected into `broken` whatever its status, because a
245
250
  * file the parser rejects has no trustworthy status to filter on.
@@ -387,7 +392,7 @@ export function collectTickets(repoRoot, { backlogDir = "backlog", includeCancel
387
392
  * deliberate: this endpoint is the board's own write path and the board is the surface a person
388
393
  * drags on, so it cannot tell that drop from a script's POST and must not try. Refusing
389
394
  * `Resolved` here would break the one person the rule exists for. An agent finishing a ticket
390
- * writes `Ready for Review` and stops.
395
+ * writes `Ready for review` and stops.
391
396
  *
392
397
  * ── Refusals, not warnings ───────────────────────────────────────────────────────────────────
393
398
  * Every one of these throws with a sentence naming the ticket, and nothing is written:
@@ -423,7 +428,7 @@ export function updateTicket(repoRoot, change, { backlogDir = "backlog", now = n
423
428
  if (change?.status !== undefined) {
424
429
  // Normalised BEFORE the allowlist, so a caller written against the old three-column board —
425
430
  // or a hand-rolled curl — sends `Done` and gets `Resolved` written, rather than a refusal on a
426
- // word this package still understands. The allowlist itself stays the four current statuses:
431
+ // word this package still understands. The allowlist itself stays the six current statuses:
427
432
  // it is the entire input sanitiser here, and `Done` is never written to a file again.
428
433
  const wanted = normaliseStatus(change.status);
429
434
  if (!STATUSES.includes(wanted)) {
@@ -588,14 +593,19 @@ const slug = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").re
588
593
  *
589
594
  * 1 → yes. The overwhelming majority, and this returns null for it: a healthy card
590
595
  * emits no extra element at all, so a healthy board looks exactly as it did.
591
- * 0 → no. The element could not be found on reload. Everything else on the ticket is
592
- * still correct — the picture, the source file, the outer_html — so it is perfectly
593
- * workable by hand, and that is what the banner says. It is not an error.
596
+ * 0 → no. The selector finds nothing on reload, so the flag's note says an agent
597
+ * cannot locate the element. Everything else on the ticket is still correct — the picture,
598
+ * the source file, the outer_html — so a person can work it from those. It is not
599
+ * an error.
594
600
  * 2 or more → no. Several elements match, so an agent might edit the wrong one.
595
601
  * "pending" → never tested (shots off, or the screenshot failed). Not a problem; a quiet line
596
602
  * in the meta list, no banner.
597
603
  * undefined → the ticket predates the field. Nothing is shown, because nothing is known.
598
604
  *
605
+ * The flag is a label and nothing more. It changes no status, and neither `isAgentReady` nor
606
+ * `listTickets` (tickets.mjs) reads `selector_matches`, so a flagged ticket left in `Ready for agent`
607
+ * is still handed to an agent. Only its status keeps an agent off it.
608
+ *
599
609
  * The comparison is `=== 0`, on a value that is already a number. Writing this as a truthiness test
600
610
  * is the one mistake the field was shaped to prevent — see the note on `selectorMatches` above.
601
611
  */
package/src/pin/index.mjs CHANGED
@@ -6,18 +6,19 @@
6
6
  // ONE integration and gets both:
7
7
  //
8
8
  // import orbytesLab from "@orbytes/astrolab";
9
- // integrations: [ includeLab ? orbytesLab({ css: [...] }) : null ] // /lab and /pin
9
+ // integrations: [ includeLab ? orbytesLab({ css: [...] }) : null ] // /lab and /lab/tasks
10
10
  // integrations: [ includeLab ? orbytesLab({ pin: false }) : null ] // /lab alone
11
11
  //
12
12
  // `orbytesLab()` returns an array Astro flattens, and this integration is one of its entries — see
13
13
  // ../../index.mjs, which also hands it the lab's RESOLVED subpath so the board's "Lab" link points
14
- // at wherever the lab actually is. Calling this module directly still works and is what the tests
15
- // do; nothing about it assumes the lab is present.
14
+ // at wherever the lab actually is. Calling this module directly still works — it is exported as
15
+ // `orbytesPin` from the package root and at `@orbytes/astrolab/pin` — and nothing about it assumes
16
+ // the lab is present.
16
17
  //
17
18
  // No cloud, no API key, no MCP, no second process — ../../docs/PIN-CONTRACT.md. It replaces the
18
19
  // hosted-widget → issue-tracker feedback chain for a solo build pass, and only for that.
19
20
  //
20
- // Three jobs:
21
+ // Four jobs:
21
22
  // 1. stamp `data-orbytes-src="<repo-relative path>"` on the first top-level element of every
22
23
  // `.astro` file under the configured directories, so the picker can name the file an element
23
24
  // came from (./source-stamp.mjs — Astro 7 emits no source attribute of its own).
@@ -28,19 +29,28 @@
28
29
  // 4. serve the KANBAN at `<route>` — every ticket in `backlog/tasks`, read fresh on each
29
30
  // request — its screenshots at `<route>/assets/*`, and the one write endpoint at
30
31
  // `<route>/api/ticket`, all from the dev server you are already on (./board.mjs, which
31
- // `orbytes-pin-gallery` renders the standalone file with too).
32
+ // `orbytes-pin-gallery` renders the standalone file with too). Inside the lab the kanban
33
+ // itself is not this middleware's: it is ../chrome/views/Tasks.astro, injected at
34
+ // `<route>` and `<route>/all`, and the middleware keeps the screenshots and the endpoint.
32
35
  //
33
- // `route` DEFAULTS TO `/pin` AND SHADOWS ANY HOST PAGE AT THAT PATH. This middleware is installed
34
- // before Astro's own request handler, so a site with its own `src/pages/pin.astro` would serve the
35
- // board instead of its page, in dev only, with nothing on screen to say why. The default stays —
36
- // `/pin` is the chosen address and the one every doc names — and the collision is a
37
- // one-liner to avoid: `pin: { route: "/__pin" }`. Said here, in ../../docs/PIN.md and in
38
- // ../../README.md, because a silent shadow is only findable if somebody wrote it down.
36
+ // WHERE `<route>` IS, AND WHAT IT SHADOWS. On its own this integration defaults `route` to `/pin`
37
+ // and draws the board there as middleware. Through `orbytesLab()` it is `<subpath>/tasks`
38
+ // (`/lab/tasks` by default), and the middleware answers `/pin` — the board's address until
39
+ // 2026-09-24 — with a 302 to it, unless `route` is itself `/pin`. The middleware is installed
40
+ // before Astro's own request handler, so a site with its own `src/pages/pin.astro` gets the board
41
+ // (alone) or the redirect (in the lab) instead of its page, in dev only, with nothing on screen to
42
+ // say why. On its own the collision is a one-liner to avoid: `pin: { route: "/__pin" }`. Inside
43
+ // the lab that is not enough: the redirect fires for every `route` except `/pin`, and a `route` of
44
+ // `/pin` injects the Tasks views there instead, so only `pin: false` frees the path. Said here, in
45
+ // ../../docs/PIN.md and in ../../README.md, because a silent shadow is only findable if somebody
46
+ // wrote it down.
39
47
  //
40
- // The board is MIDDLEWARE, not an injected route, and the choice is deliberate. The lab injects its
48
+ // On its own the board is MIDDLEWARE, not an injected route, and the choice is deliberate. (Inside
49
+ // the lab it is an injected route after all — the Tasks views, injected in `astro:config:setup`
50
+ // past the dev-only assertion, so they are no more able to reach a build.) The lab injects its
41
51
  // pages because they are prerendered; this one is generated per request from files outside the
42
52
  // site, so it belongs with the lab's other half — the mark APIs it serves from `astro:server:setup`.
43
- // That hook does not exist in a build, so `/pin` cannot leak into `dist/` even by accident, and
53
+ // That hook does not exist in a build, so `<route>` cannot leak into `dist/` even by accident, and
44
54
  // nothing has to be marked `prerender: false` in a project with `output: "static"` and no adapter.
45
55
  // ── backlog.md is retired (2026-09-22) ──────────────────────────────────────────────────────────
46
56
  // Until today `<route>` was read-only and pointed at backlog.md's own web UI — a second server on
@@ -188,7 +198,8 @@ const DEFAULTS = {
188
198
  /** Site-relative directories whose `.astro` files get the source stamp. */
189
199
  stamp: ["src/lab/sections", "src/components"],
190
200
  /**
191
- * Repo-relative `.astro` paths exempt from the stamp's hard failure. A file whose template has
201
+ * Site-relative `.astro` paths exempt from the stamp's hard failure (a repo-relative path is
202
+ * accepted too — ./source-stamp.mjs resolves each entry both ways). A file whose template has
192
203
  * no element to stamp throws by design; this is the only way past it, and it is a deliberate,
193
204
  * named decision per file rather than a warning that lets every such file through silently.
194
205
  */
@@ -216,10 +227,12 @@ const DEFAULTS = {
216
227
  shots: true,
217
228
  /**
218
229
  * Where the board is served, dev only. `<route>/assets/*` serves its screenshots and
219
- * `<route>/api/ticket` is the one write endpoint.
230
+ * `<route>/api/ticket` is the one write endpoint. `orbytesLab()` sets it to `<subpath>/tasks`;
231
+ * `/pin` is the default only when this integration is used on its own.
220
232
  *
221
233
  * It is MIDDLEWARE and it shadows a host page at the same path (see the note at the top of this
222
- * file). A site with its own `/pin` page sets something else here.
234
+ * file). Used on its own, a site with its own `/pin` page sets something else here; inside the
235
+ * lab no value frees `/pin` (see the note at the top of this file).
223
236
  */
224
237
  route: "/pin",
225
238
  /**
@@ -425,7 +438,7 @@ export default function orbytesPin(options = {}) {
425
438
  // The wall, and its screenshots. Registered here rather than as an injected route (see the
426
439
  // note at the top of this file), which also puts it ahead of Astro's own request handler:
427
440
  // Astro installs that in a Vite post hook, after every integration's middleware is in
428
- // place, so `/pin` reaches this and never reaches Astro's 404.
441
+ // place, so every path this answers reaches it and never reaches Astro's 404.
429
442
  server.middlewares.use((req, res, next) => {
430
443
  let url;
431
444
  try {
@@ -544,9 +557,10 @@ export default function orbytesPin(options = {}) {
544
557
  // Read from disk on every request — that is the whole point of the route, and why
545
558
  // `orbytes-pin-gallery` is no longer part of looking at the board.
546
559
  try {
547
- // `includeCancelled`: `/pin` is the ONE surface where the archive is visible. Every
548
- // agent-facing read drops cancelled tickets by default — a cancelled ticket must not
549
- // take up context space for an agent — and an archive nobody can open is a delete.
560
+ // `includeCancelled`: the human-facing surfaces — this wall, the lab's Tasks views and
561
+ // the gallery file — are where the archive is visible. Every agent-facing read drops
562
+ // cancelled tickets by default — a cancelled ticket must not take up context space for
563
+ // an agent — and an archive nobody can open is a delete.
550
564
  const { tickets, broken } = collectTickets(repoRoot, { backlogDir: config.backlogDir, includeCancelled: true });
551
565
  const html = renderBoard(tickets, broken, {
552
566
  // The presence of an endpoint is what makes the page writable. The standalone
@@ -120,8 +120,9 @@ export function isOpen(status) {
120
120
  * Is this ticket archived — cancelled, and therefore invisible to every agent-facing read?
121
121
  *
122
122
  * Ruled 2026-09-22: items in here must not override anything or take up context space for an
123
- * agent. `listTickets` drops these unless explicitly asked for them; `/pin` is the one place
124
- * they are still visible, because an archive nobody can open is a delete.
123
+ * agent. `listTickets` drops these unless explicitly asked for them. The human-facing surfaces
124
+ * still show them — the lab's Tasks views, the pin half's standalone board and
125
+ * `orbytes-pin-gallery` — because an archive nobody can open is a delete.
125
126
  *
126
127
  * @param {unknown} status
127
128
  */
@@ -672,9 +673,9 @@ export function parseTicket(text, file = "<unknown file>") {
672
673
  * that never heard of the rule still cannot be handed a dead ticket, and one that wants the
673
674
  * archive has to say so in the call.
674
675
  *
675
- * `includeCancelled: true` returns them, and the only callers that pass it are the two surfaces
676
- * that draw the archive — `/pin` and `orbytes-pin-gallery` — plus `updateTicket`, which has to be able to
677
- * find a cancelled ticket in order to move it back out.
676
+ * `includeCancelled: true` returns them. Nothing in this package passes it here: the surfaces that
677
+ * draw the archive and `updateTicket`, which has to be able to find a cancelled ticket in order to
678
+ * move it back out, pass it to `collectTickets` (./board.mjs) instead.
678
679
  *
679
680
  * Statuses come back RAW (› `parseTicket`), so the filter normalises before it compares: a ticket
680
681
  * whose file still says `Done` is `Resolved`, and is live.