@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.
- package/README.md +184 -84
- package/bin/pin-gallery.mjs +53 -19
- package/defaults.mjs +7 -20
- package/docs/PIN-CONTRACT.md +76 -10
- package/docs/PIN.md +93 -23
- package/index.d.ts +1 -7
- package/index.mjs +14 -81
- package/package.json +2 -2
- package/src/Home.astro +7 -8
- package/src/LabHead.astro +1 -1
- package/src/chrome/ActionsMenu.astro +97 -0
- package/src/chrome/ComponentCard.astro +9 -2
- package/src/chrome/Nav.astro +36 -10
- package/src/chrome/Panel.astro +17 -4
- package/src/chrome/Properties.astro +104 -0
- package/src/chrome/SectionsTree.astro +128 -0
- package/src/chrome/Shell.astro +20 -6
- package/src/chrome/StoryView.astro +103 -162
- package/src/chrome/Tree.astro +56 -53
- package/src/chrome/ViewportControls.astro +136 -61
- package/src/chrome/ViewportStage.astro +26 -3
- package/src/chrome/icons.ts +9 -0
- package/src/chrome/marks-client.ts +26 -53
- package/src/chrome/model.ts +14 -0
- package/src/chrome/navbar-client.ts +324 -0
- package/src/chrome/params-client.ts +434 -0
- package/src/chrome/pins-data.ts +42 -9
- package/src/chrome/shell-client.ts +99 -3
- package/src/chrome/trees.ts +112 -7
- package/src/chrome/viewport-client.ts +68 -242
- package/src/chrome/views/Assets.astro +21 -6
- package/src/chrome/views/Pages.astro +90 -54
- package/src/chrome/views/Placeholder.astro +3 -3
- package/src/chrome/views/Tasks.astro +12 -40
- package/src/core/LICENSE-astrobook +5 -0
- package/src/core/utils/kebab-case.ts +2 -2
- package/src/pin/board.mjs +25 -15
- package/src/pin/index.mjs +34 -20
- package/src/pin/tickets.mjs +6 -5
- package/src/pin/toolbar.js +81 -3
- package/src/shell/Browse.astro +35 -10
- package/src/shell/lab-index.ts +5 -4
- package/src/shell/lab-params.ts +113 -6
- package/src/shell/live-files.mjs +212 -10
- package/src/shell/marks.mjs +17 -41
- package/src/ui/components/preview-layout.astro +17 -0
- package/src/ui/components/theme-script.astro +4 -3
- package/src/ui/lab.css +2167 -566
- package/virtual.d.ts +0 -4
- 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
|
|
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
|
-
<
|
|
67
|
-
<div class="lab-
|
|
68
|
-
<
|
|
69
|
-
{page.label}
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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-
|
|
85
|
-
|
|
86
|
-
|
|
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-
|
|
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-
|
|
100
|
-
<button class="lab-
|
|
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-
|
|
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-
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
</
|
|
153
|
+
</div>
|
|
129
154
|
) : (
|
|
130
155
|
<Fragment slot="navbar">
|
|
131
|
-
<div class="lab-
|
|
132
|
-
<h1 class="lab-
|
|
133
|
-
<
|
|
134
|
-
|
|
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-
|
|
21
|
-
<h1 class="lab-
|
|
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
|
|
15
|
-
import {
|
|
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-
|
|
77
|
-
<h1 class="lab-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
7
|
-
//
|
|
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
|
-
//
|
|
4
|
-
// · the
|
|
5
|
-
//
|
|
6
|
-
// ·
|
|
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
|
|
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
|
|
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
|
|
241
|
-
*
|
|
242
|
-
*
|
|
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
|
|
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
|
|
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
|
|
592
|
-
*
|
|
593
|
-
*
|
|
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 /
|
|
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
|
|
15
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
*
|
|
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).
|
|
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
|
|
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`:
|
|
548
|
-
//
|
|
549
|
-
//
|
|
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
|
package/src/pin/tickets.mjs
CHANGED
|
@@ -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
|
|
124
|
-
*
|
|
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
|
|
676
|
-
*
|
|
677
|
-
*
|
|
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.
|