@orbytes/astrolab 0.3.0

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 (94) hide show
  1. package/LICENSE +37 -0
  2. package/README.md +410 -0
  3. package/bin/lab-cull.mjs +401 -0
  4. package/bin/pin-gallery.mjs +121 -0
  5. package/defaults.mjs +120 -0
  6. package/dist/core/astro-integration.js +130 -0
  7. package/dist/core/index.js +6 -0
  8. package/dist/core/lib-paths.js +29 -0
  9. package/dist/core/options.js +173 -0
  10. package/dist/core/utils/get-exports.js +52 -0
  11. package/dist/core/utils/invariant.js +12 -0
  12. package/dist/core/utils/kebab-case.js +15 -0
  13. package/dist/core/utils/path-builder.js +22 -0
  14. package/dist/core/utils/path.js +53 -0
  15. package/dist/core/virtual-module/get-story-modules.js +60 -0
  16. package/dist/core/virtual-module/story-modules.js +8 -0
  17. package/dist/core/virtual-module/virtual-module-ids.js +22 -0
  18. package/dist/core/virtual-module/virtual-routes.js +83 -0
  19. package/dist/core/virtual-module/vite-plugin.js +98 -0
  20. package/docs/PIN-CONTRACT.md +263 -0
  21. package/docs/PIN.md +429 -0
  22. package/index.d.ts +279 -0
  23. package/index.mjs +347 -0
  24. package/package.json +93 -0
  25. package/src/Empty.astro +4 -0
  26. package/src/Home.astro +298 -0
  27. package/src/LabHead.astro +1102 -0
  28. package/src/core/LICENSE-astrobook +166 -0
  29. package/src/core/astro-integration.ts +166 -0
  30. package/src/core/client.ts +89 -0
  31. package/src/core/index.ts +7 -0
  32. package/src/core/lib/components/empty.astro +1 -0
  33. package/src/core/lib/components/head.astro +1 -0
  34. package/src/core/lib/components/home.astro +8 -0
  35. package/src/core/lib/components/with-decorators.astro +22 -0
  36. package/src/core/lib/pages/app.astro +19 -0
  37. package/src/core/lib/pages/preview.astro +17 -0
  38. package/src/core/lib/pages/story.astro +16 -0
  39. package/src/core/lib-paths.ts +72 -0
  40. package/src/core/options.ts +262 -0
  41. package/src/core/utils/get-exports.ts +59 -0
  42. package/src/core/utils/invariant.ts +13 -0
  43. package/src/core/utils/kebab-case.ts +30 -0
  44. package/src/core/utils/path-builder.ts +45 -0
  45. package/src/core/utils/path.ts +80 -0
  46. package/src/core/virtual-module/get-story-modules.ts +110 -0
  47. package/src/core/virtual-module/story-modules.ts +9 -0
  48. package/src/core/virtual-module/virtual-module-ids.ts +17 -0
  49. package/src/core/virtual-module/virtual-routes.ts +130 -0
  50. package/src/core/virtual-module/vite-plugin.ts +125 -0
  51. package/src/pin/board.mjs +1521 -0
  52. package/src/pin/index.mjs +666 -0
  53. package/src/pin/shot.mjs +427 -0
  54. package/src/pin/source-stamp.mjs +159 -0
  55. package/src/pin/tickets.mjs +697 -0
  56. package/src/pin/toolbar.js +3181 -0
  57. package/src/shell/Browse.astro +371 -0
  58. package/src/shell/CardGrid.astro +297 -0
  59. package/src/shell/Viewport.astro +1330 -0
  60. package/src/shell/index.json.ts +12 -0
  61. package/src/shell/lab-index.ts +344 -0
  62. package/src/shell/lab-params.ts +245 -0
  63. package/src/shell/live-files.mjs +164 -0
  64. package/src/shell/marks.mjs +136 -0
  65. package/src/types/index.ts +6 -0
  66. package/src/types/types.ts +239 -0
  67. package/src/types/virtual.d.ts +29 -0
  68. package/src/ui/components/app.astro +13 -0
  69. package/src/ui/components/build-path.ts +13 -0
  70. package/src/ui/components/build-tree.ts +108 -0
  71. package/src/ui/components/collapse-duration.ts +28 -0
  72. package/src/ui/components/compress-terms.ts +10 -0
  73. package/src/ui/components/dashboard-layout.astro +39 -0
  74. package/src/ui/components/home.astro +65 -0
  75. package/src/ui/components/layout.astro +110 -0
  76. package/src/ui/components/preview-layout.astro +109 -0
  77. package/src/ui/components/sidebar-button-fullscreen.astro +38 -0
  78. package/src/ui/components/sidebar-button-search.astro +23 -0
  79. package/src/ui/components/sidebar-button-theme.astro +9 -0
  80. package/src/ui/components/sidebar-button.astro +24 -0
  81. package/src/ui/components/sidebar-resize-handle.astro +74 -0
  82. package/src/ui/components/sidebar-search-panel.astro +41 -0
  83. package/src/ui/components/sidebar-search-script.ts +103 -0
  84. package/src/ui/components/sidebar-title.astro +17 -0
  85. package/src/ui/components/sidebar-tree-node.astro +143 -0
  86. package/src/ui/components/sidebar-tree.astro +84 -0
  87. package/src/ui/components/sidebar.astro +29 -0
  88. package/src/ui/components/theme-message.ts +26 -0
  89. package/src/ui/components/theme-script.astro +71 -0
  90. package/src/ui/components/theme-toggle.astro +63 -0
  91. package/src/ui/components/theme.ts +32 -0
  92. package/src/ui/index.ts +4 -0
  93. package/src/ui/lab.css +549 -0
  94. package/virtual.d.ts +42 -0
@@ -0,0 +1,666 @@
1
+ // orbytes-pin — click a rendered element on `astro dev`, leave a comment, get a markdown ticket
2
+ // and a PNG on local disk for an agent to pick up.
3
+ //
4
+ // SHIPPED AS HALF OF THE LAB since 2026-09-22 — decided that day: the lab and the pin board are ONE
5
+ // app, bundled and working as one, not two packages a site installs separately. A consumer configures
6
+ // ONE integration and gets both:
7
+ //
8
+ // import orbytesLab from "@orbytes/astrolab";
9
+ // integrations: [ includeLab ? orbytesLab({ css: [...] }) : null ] // /lab and /pin
10
+ // integrations: [ includeLab ? orbytesLab({ pin: false }) : null ] // /lab alone
11
+ //
12
+ // `orbytesLab()` returns an array Astro flattens, and this integration is one of its entries — see
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.
16
+ //
17
+ // No cloud, no API key, no MCP, no second process — ../../docs/PIN-CONTRACT.md. It replaces the
18
+ // hosted-widget → issue-tracker feedback chain for a solo build pass, and only for that.
19
+ //
20
+ // Three jobs:
21
+ // 1. stamp `data-orbytes-src="<repo-relative path>"` on the first top-level element of every
22
+ // `.astro` file under the configured directories, so the picker can name the file an element
23
+ // came from (./source-stamp.mjs — Astro 7 emits no source attribute of its own).
24
+ // 2. register the dev toolbar app the picking happens in (./toolbar.js — the client half: the
25
+ // element picker, the comment panel and the ticket list, loaded by Astro's dev toolbar).
26
+ // 3. on the toolbar's `orbytes-pin:create`, write the ticket and REPLY, then take the
27
+ // screenshot and send a second event when it lands (./tickets.mjs, ./shot.mjs).
28
+ // 4. serve the KANBAN at `<route>` — every ticket in `backlog/tasks`, read fresh on each
29
+ // request — its screenshots at `<route>/assets/*`, and the one write endpoint at
30
+ // `<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
+ //
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.
39
+ //
40
+ // The board is MIDDLEWARE, not an injected route, and the choice is deliberate. The lab injects its
41
+ // pages because they are prerendered; this one is generated per request from files outside the
42
+ // 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
44
+ // nothing has to be marked `prerender: false` in a project with `output: "static"` and no adapter.
45
+ // ── backlog.md is retired (2026-09-22) ──────────────────────────────────────────────────────────
46
+ // Until today `<route>` was read-only and pointed at backlog.md's own web UI — a second server on
47
+ // a third port — for the one thing it could not do: change a ticket's status. That made three
48
+ // places to remember. backlog.md re-serialises frontmatter on any write and silently drops every
49
+ // key it does not know, which is why the pin fields live in a fenced block in the BODY, and a
50
+ // SECOND writer of that frontmatter would have rebuilt the bug.
51
+ //
52
+ // With backlog.md out of the loop there is no second writer, so `<route>/api/ticket` can write
53
+ // those files correctly by construction: `updateTicket` (./board.mjs) rewrites only the
54
+ // frontmatter lines it is changing and returns the body — comment, image and fenced pin block —
55
+ // byte for byte, asserting that on every write rather than documenting it. That endpoint is the
56
+ // ONLY write path, it is POST-only, same-origin, and it lives in `astro:server:setup`, which does
57
+ // not exist in a build. Do not run `backlog browser`; do not run `backlog task edit` on a pin
58
+ // ticket.
59
+ //
60
+ // DEV ONLY, asserted FOUR times independently, because the whole write path's safety rests on it
61
+ // (contract non-negotiable 1). All four are kept, and none of them is the spare:
62
+ // · `astro:config:setup` returns early unless `command === "dev"` — nothing is registered;
63
+ // · the Vite plugins carry `apply: "serve"`, so they cannot load in a build even if they were;
64
+ // · `astro:server:setup` never runs in a build at all, which is where the writes live;
65
+ // · that hook still returns early when `repoRoot` is null, i.e. when config:setup stood down —
66
+ // the one that covers a host that calls the hooks in an order this file did not choose.
67
+ //
68
+ // This matters more now than it did in its own package. The lab is deliberately included in
69
+ // STAGING builds (`PUBLIC_DEPLOY_ENV=staging`), and `orbytesLab()` returns this integration
70
+ // alongside it — so from 2026-09-22 this file is registered in a build for the first time. The
71
+ // first assertion is what makes that a no-op: `command` is `build` there, not `dev`.
72
+ //
73
+ // Ordering is the one thing not to tune. The reply goes out on the same synchronous tick as the
74
+ // ticket write; the browser launch happens after it. A ticket with no screenshot yet is valid, a
75
+ // ticket somebody waited two seconds for is not.
76
+ import { fileURLToPath } from "node:url";
77
+ import { homedir } from "node:os";
78
+ import { createReadStream, realpathSync, statSync } from "node:fs";
79
+ import { extname, join, resolve, sep } from "node:path";
80
+ import { findRepoRoot, projectName, writeTicket } from "./tickets.mjs";
81
+ import { sourceStampPlugin } from "./source-stamp.mjs";
82
+ import { close as closeBrowser, shoot } from "./shot.mjs";
83
+ import { collectTickets, renderBoard, renderCard, updateTicket } from "./board.mjs";
84
+
85
+ const file = (relative) => fileURLToPath(new URL(relative, import.meta.url));
86
+
87
+ /** One plain sentence, never a stack — this string is shown to the user in the toolbar. */
88
+ const oneLine = (e) => String(e?.message ?? e).split("\n")[0].trim();
89
+
90
+ /**
91
+ * What `<route>/assets/*` will serve. An allowlist, not a lookup with a fallback: an extension
92
+ * that is not on this list is a 404, so no file type can be handed out because nobody thought
93
+ * about it. Every PNG the picker writes is the first entry; the rest are for a shot dropped in
94
+ * by hand.
95
+ */
96
+ const ASSET_MIME = {
97
+ ".png": "image/png",
98
+ ".jpg": "image/jpeg",
99
+ ".jpeg": "image/jpeg",
100
+ ".webp": "image/webp",
101
+ ".gif": "image/gif",
102
+ ".avif": "image/avif",
103
+ };
104
+
105
+ /**
106
+ * `child` is inside `parent`, or is `parent`. Both must already be absolute and resolved.
107
+ * The `+ sep` is the whole point: a plain `startsWith` would let `/x/backlog-secrets` pass as
108
+ * inside `/x/backlog`.
109
+ */
110
+ const contains = (parent, child) => child === parent || child.startsWith(parent + sep);
111
+
112
+ /**
113
+ * The one file `<route>/assets/<rel>` names, or null when it names anything else.
114
+ *
115
+ * The screenshots sit in `backlog/assets/`, outside `site/public/`, so the dev server has no route
116
+ * to them and this opens exactly one — a single directory, image types only, read-only. Four
117
+ * refusals, in order, because each catches something the one before it cannot:
118
+ *
119
+ * 1. a null byte, which truncates a path inside some syscalls;
120
+ * 2. containment AFTER `resolve`, which is what stops `../` and an absolute `/etc/passwd`
121
+ * (`new URL()` normalises literal dot segments in a pathname, so the form that actually
122
+ * arrives here is the percent-encoded one — `%2e%2e%2f` — and it is decoded above this
123
+ * line precisely so this check is the thing that sees it);
124
+ * 3. containment AGAIN after `realpathSync`, because step 2 is defeated by a symlink inside
125
+ * the directory pointing out of it, and the PNGs here are hardlinks into ~/.orbytes, so
126
+ * links in this folder are normal rather than suspicious;
127
+ * 4. a regular file with a known image extension — a directory, a socket or a `.md` is a 404.
128
+ */
129
+ function resolveAsset(assetsRoot, rel) {
130
+ if (!rel || rel.includes("\0")) return null;
131
+ const abs = resolve(assetsRoot, rel);
132
+ if (!contains(assetsRoot, abs)) return null;
133
+ let real;
134
+ let realRoot;
135
+ try {
136
+ real = realpathSync(abs);
137
+ realRoot = realpathSync(assetsRoot);
138
+ } catch {
139
+ return null; // missing, or a broken link
140
+ }
141
+ if (!contains(realRoot, real)) return null;
142
+ const type = ASSET_MIME[extname(real).toLowerCase()];
143
+ if (!type) return null;
144
+ let stat;
145
+ try {
146
+ stat = statSync(real);
147
+ } catch {
148
+ return null;
149
+ }
150
+ if (!stat.isFile()) return null;
151
+ return { file: real, type, size: stat.size };
152
+ }
153
+
154
+ /**
155
+ * The resolved options the TOOLBAR APP needs, handed to it as a virtual module.
156
+ *
157
+ * A dev-toolbar app entrypoint is instantiated by Astro with `(canvas, app, server)` and nothing
158
+ * else — there is no channel for integration options, which is why `./toolbar.js` carried a
159
+ * hardcoded `/pin` and a site setting `pin: { route: "/__pin" }` got a panel that fetched a route
160
+ * it does not serve. The lab solves the same problem the same way, with
161
+ * `virtual:orbytes-lab/config.mjs` (../../index.mjs), so this mirrors it rather than inventing a
162
+ * second mechanism.
163
+ *
164
+ * Client-side and dev-only: the plugin carries `apply: "serve"`, and in a build this whole
165
+ * integration stands down before `updateConfig` is ever reached.
166
+ *
167
+ * The resolved id carries no leading NUL, the same choice the lab and Astrobook make — Astro's
168
+ * pipeline handles a plain id and a NUL-prefixed one has to be special-cased.
169
+ */
170
+ const PIN_VIRTUAL = "virtual:orbytes-pin/config.mjs";
171
+ const PIN_VIRTUAL_RESOLVED = "__virtual_orbytes_pin_config__.mjs";
172
+
173
+ /**
174
+ * The board's path, with any trailing slashes off. ONE definition: the middleware matches on it
175
+ * and the toolbar app fetches and opens it, and the two disagreeing is the defect this replaces.
176
+ */
177
+ const boardRoute = (raw) => String(raw ?? "").replace(/\/+$/, "") || "/pin";
178
+
179
+ const pinConfigPlugin = (exposed) => ({
180
+ name: "orbytes-pin/virtual",
181
+ apply: "serve",
182
+ resolveId: (id) => (id === PIN_VIRTUAL ? PIN_VIRTUAL_RESOLVED : undefined),
183
+ load: (id) =>
184
+ id === PIN_VIRTUAL_RESOLVED ? `export default ${JSON.stringify(exposed)};` : undefined,
185
+ });
186
+
187
+ const DEFAULTS = {
188
+ /** Site-relative directories whose `.astro` files get the source stamp. */
189
+ stamp: ["src/lab/sections", "src/components"],
190
+ /**
191
+ * Repo-relative `.astro` paths exempt from the stamp's hard failure. A file whose template has
192
+ * no element to stamp throws by design; this is the only way past it, and it is a deliberate,
193
+ * named decision per file rather than a warning that lets every such file through silently.
194
+ */
195
+ stampSkip: [],
196
+ /** Repo-relative board directory. Tickets land in `<backlogDir>/tasks`, PNGs in `/assets`. */
197
+ backlogDir: "backlog",
198
+ /** Canonical screenshot home. The repo copy is a hardlink to a file in here. */
199
+ archiveDir: join(homedir(), ".orbytes", "feedback-archive"),
200
+ /**
201
+ * The archive folder this project's screenshots live in: `<archiveDir>/<project>/`.
202
+ *
203
+ * `null` derives it from the checkout's own directory name, which is what it always did and is
204
+ * right almost always — and wrong in the one case nobody would notice. TWO CHECKOUTS OF
205
+ * DIFFERENT REPOS UNDER THE SAME FOLDER NAME SHARE AN ARCHIVE, silently: `pin-007.png` from one
206
+ * overwrites `pin-007.png` from the other, both boards go on rendering (the repo copy is a
207
+ * hardlink, so the survivor keeps a name in each), and the only evidence is a screenshot of
208
+ * somebody else's site. Two clients called `site`, a `~/work/acme` beside a `~/archive/acme`,
209
+ * or a worktree named after its branch all reach it. Set it explicitly to be sure.
210
+ *
211
+ * Added on the move into astrolab, 2026-09-22 — a package that travels between repos is exactly
212
+ * where a path derived from the checkout stops being safe.
213
+ */
214
+ project: null,
215
+ /** Set false to write tickets and take no screenshots at all. */
216
+ shots: true,
217
+ /**
218
+ * Where the board is served, dev only. `<route>/assets/*` serves its screenshots and
219
+ * `<route>/api/ticket` is the one write endpoint.
220
+ *
221
+ * 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.
223
+ */
224
+ route: "/pin",
225
+ /**
226
+ * The links in the board's top-right corner, `[{ href, label }]`. `null` means the pair the
227
+ * board has always drawn — the site root and `/lab` — which was hardcoded until 2026-09-22 and
228
+ * is a guess anywhere the lab is not at `/lab`. `orbytesLab()` passes the lab's RESOLVED subpath
229
+ * rather than the guess; `[]` draws no links at all.
230
+ */
231
+ links: null,
232
+ appId: "orbytes-pin",
233
+ appName: "Pin",
234
+ icon: "bug",
235
+ };
236
+
237
+ /** The dev server's own origin, read from Vite. Never hardcode 4321 — Astro picks a free port. */
238
+ function devOrigin(server) {
239
+ const resolved = server.resolvedUrls?.local?.[0];
240
+ if (resolved) return resolved.replace(/\/+$/, "");
241
+ const address = server.httpServer?.address?.();
242
+ if (address && typeof address === "object") return `http://localhost:${address.port}`;
243
+ const port = server.config?.server?.port;
244
+ if (port) return `http://localhost:${port}`;
245
+ throw new Error("the dev server has not reported a port yet, so there is nowhere to screenshot from");
246
+ }
247
+
248
+ /**
249
+ * @param {Partial<typeof DEFAULTS>} [options]
250
+ * @returns {import("astro").AstroIntegration}
251
+ */
252
+ export default function orbytesPin(options = {}) {
253
+ const config = { ...DEFAULTS, ...options };
254
+ let repoRoot = null;
255
+ let project = null;
256
+
257
+ return {
258
+ name: "orbytes-pin",
259
+ hooks: {
260
+ "astro:config:setup": ({ config: astroConfig, command, updateConfig, addDevToolbarApp, logger }) => {
261
+ // Assertion one. In `build`, `preview` or `sync` this integration registers nothing at
262
+ // all — no plugin, no toolbar app, no stamp.
263
+ if (command !== "dev") {
264
+ logger.debug(`command is ${command}, not dev — orbytes-pin stands down`);
265
+ return;
266
+ }
267
+ const siteRoot = fileURLToPath(astroConfig.root);
268
+
269
+ // Assertion one and a half. `findRepoRoot` throws rather than guess, which is right for a
270
+ // library — every ticket path hangs off it and a guessed root writes files somewhere
271
+ // nobody asked for. But an integration that lets that reach Astro takes the whole dev
272
+ // server down with it, and the board is ON BY DEFAULT: measured 2026-09-22, a freshly
273
+ // scaffolded Astro project (which has no git yet) could not run `astro dev` at all after
274
+ // installing this package. The lab half had already logged that it found its stories, so
275
+ // the failure looked like the package rather than the one check it was.
276
+ //
277
+ // So: stand down the way the non-dev path does, and say both escapes. The lab is
278
+ // unaffected, and `astro build` never reaches here at all.
279
+ try {
280
+ repoRoot = findRepoRoot(siteRoot);
281
+ } catch {
282
+ logger.warn(
283
+ `no git repository found above ${siteRoot} — the pin board needs one, because every ` +
284
+ `ticket path is stored relative to it. The board is disabled for this run; the lab ` +
285
+ `is unaffected. Run \`git init\` to enable it, or pass \`pin: false\` to silence this.`,
286
+ );
287
+ return;
288
+ }
289
+ project = String(config.project ?? projectName(repoRoot));
290
+
291
+ addDevToolbarApp({
292
+ id: config.appId,
293
+ name: config.appName,
294
+ icon: config.icon,
295
+ entrypoint: file("./toolbar.js"),
296
+ });
297
+
298
+ updateConfig({
299
+ vite: {
300
+ plugins: [
301
+ sourceStampPlugin({
302
+ repoRoot,
303
+ siteRoot,
304
+ dirs: config.stamp,
305
+ skip: config.stampSkip,
306
+ }),
307
+ // The resolved options ./toolbar.js reads — today just the board's route, which it
308
+ // fetches for the ticket list and opens with the "Board ↗" button. Registered here
309
+ // rather than hardcoded there, because a toolbar app entrypoint is handed no
310
+ // options (› pinConfigPlugin, above).
311
+ pinConfigPlugin({ route: boardRoute(config.route) }),
312
+ // The browser outlives every request, so something has to end it. Vite fires
313
+ // `closeBundle` when the dev environment's plugin container closes; the httpServer
314
+ // handler in astro:server:setup covers the path where it does not, and Playwright
315
+ // kills its own child on process exit. Three, because a stray headless chromium is
316
+ // invisible until you notice the fans.
317
+ { name: "orbytes-pin/lifecycle", apply: "serve", closeBundle: () => closeBrowser() },
318
+ ],
319
+ // The same exemption the lab integration declares for itself. It is SSR-only by
320
+ // definition and cannot reach the CLIENT graph, which is where ./src/toolbar.js is
321
+ // loaded — checked 2026-09-21, because the toolbar's bare `astro/...` imports look
322
+ // like they might need one. They do not: resolving its three specifiers from this
323
+ // package's own directory, `astro/toolbar`,
324
+ // `astro/client/dev-toolbar/apps/utils/highlight.js` and `.../window.js` all resolve
325
+ // through astro's declared "./toolbar" and "./client/*" exports. NO alias for
326
+ // `astro` is declared anywhere here, and none may be added.
327
+ //
328
+ // The NAME is the lab's since the merge — one package now ships both halves, and this
329
+ // file is `@orbytes/astrolab/src/pin/index.mjs`. `orbytes-pin` no longer resolves to
330
+ // anything, so exempting it would exempt nothing. (The lab's own integration declares
331
+ // the same line; declaring it twice is harmless — Vite dedupes the array — and it is
332
+ // kept here so calling this module on its own still works.)
333
+ ssr: { noExternal: ["@orbytes/astrolab"] },
334
+ // The CLIENT-side counterpart, and the one that actually keeps the dev toolbar
335
+ // alive. Measured on astro 7.3.1 / vite 8.2.2, 2026-09-21.
336
+ //
337
+ // `node_modules/orbytes-pin` is a symlink to `packages/orbytes-pin`, and Vite
338
+ // resolves symlinks (`resolve.preserveSymlinks` is false by default). So the
339
+ // importer of ./src/toolbar.js is its REAL path, which is not inside
340
+ // node_modules — and Vite's `finalizeBareSpecifier` only skips the dep optimizer
341
+ // when `importer && isInNodeModules(importer)` (vite/dist/node/chunks/node.js,
342
+ // the `skipOptimization` expression). A package that genuinely sat in
343
+ // node_modules would be skipped; this one is workspace-linked, so it is not, and
344
+ // its three bare `astro/...` specifiers are treated as app source imports.
345
+ //
346
+ // They are reachable only through the toolbar app entrypoint, which Astro loads
347
+ // by dynamic import from the `astro:toolbar:internal` virtual module — after the
348
+ // scan, after the server is up. Two of them are not in Astro's own include list
349
+ // (`astro/toolbar` is — vite-plugin-dev-toolbar.js `config()`), so they arrive as
350
+ // missing imports and force a re-optimize of a set that already contains
351
+ // `astro/runtime/client/dev-toolbar/entrypoint.js`. The HTML asks for that
352
+ // entrypoint at a URL with no version query, so once its optimized copy is
353
+ // replaced the request can no longer be matched and answers 504 Outdated Optimize
354
+ // Dep — permanently, warm cache included. `customElements.get("astro-dev-toolbar")`
355
+ // stays undefined and ALL FIVE of Astro's own apps die with ours.
356
+ //
357
+ // The lever is `exclude` rather than `include`: `skipOptimization` reads
358
+ // `exclude?.includes(rawId)` — an exact match on the specifier as written — so
359
+ // these two names, and nothing else, stop being optimizer candidates. They then
360
+ // resolve to Astro's own dist files, the same ones the built-in apps use. Naming
361
+ // the package (`exclude: ["astro"]`) would match via `pkgId` too, but it would
362
+ // also swallow `astro/toolbar` and the toolbar entrypoint, both of which Astro
363
+ // deliberately pre-bundles. `include` would be the wrong shape as well: it would
364
+ // pre-bundle a second copy of Astro's highlight/window helpers to cure a problem
365
+ // whose cause is that they were bundled at all.
366
+ optimizeDeps: {
367
+ exclude: [
368
+ "astro/client/dev-toolbar/apps/utils/highlight.js",
369
+ "astro/client/dev-toolbar/apps/utils/window.js",
370
+ ],
371
+ },
372
+ // Tickets land OUTSIDE site/, so Vite's watcher should never see them — but a write
373
+ // that reloads the page mid-comment would be the one defect a user notices every
374
+ // single time, so it is ruled out here as well (contract non-negotiable 4).
375
+ server: {
376
+ watch: {
377
+ ignored: [
378
+ `**/${config.backlogDir}/**`,
379
+ join(repoRoot, config.backlogDir, "**"),
380
+ join(config.archiveDir, "**"),
381
+ ],
382
+ },
383
+ },
384
+ },
385
+ });
386
+
387
+ logger.info(`pin app on the dev toolbar · tickets → ${config.backlogDir}/tasks · stamping ${config.stamp.join(", ")}`);
388
+ },
389
+
390
+ "astro:server:setup": ({ server, toolbar, logger }) => {
391
+ // Assertion three: this hook does not exist in a build. The write path lives only here,
392
+ // and so does the wall — neither can exist in `dist/`.
393
+ if (!repoRoot) return; // config:setup stood down; nothing to wire
394
+
395
+ const route = boardRoute(config.route);
396
+ const assetPrefix = `${route}/assets/`;
397
+ const apiPath = `${route}/api/ticket`;
398
+ const assetsRoot = join(repoRoot, config.backlogDir, "assets");
399
+ const assetHref = (t) =>
400
+ t.shotName ? assetPrefix + t.shotName.split("/").map(encodeURIComponent).join("/") : null;
401
+
402
+ // The wall, and its screenshots. Registered here rather than as an injected route (see the
403
+ // note at the top of this file), which also puts it ahead of Astro's own request handler:
404
+ // Astro installs that in a Vite post hook, after every integration's middleware is in
405
+ // place, so `/pin` reaches this and never reaches Astro's 404.
406
+ server.middlewares.use((req, res, next) => {
407
+ let url;
408
+ try {
409
+ url = new URL(req.url ?? "/", "http://localhost");
410
+ } catch {
411
+ return next();
412
+ }
413
+ const path = url.pathname;
414
+ const wall = path === route || path === `${route}/`;
415
+ const api = path === apiPath;
416
+ if (!wall && !api && !path.startsWith(assetPrefix)) return next();
417
+
418
+ const json = (code, body) => {
419
+ res.statusCode = code;
420
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
421
+ res.setHeader("Cache-Control", "no-store");
422
+ return res.end(JSON.stringify(body));
423
+ };
424
+
425
+ // ── the one write path ───────────────────────────────────────────────────────────
426
+ // Four refusals before a byte of the request body is even parsed, then `updateTicket`,
427
+ // which refuses again on everything it cannot verify. None of them warn; all of them
428
+ // answer with a sentence the board puts on screen.
429
+ if (api) {
430
+ if (req.method !== "POST") {
431
+ res.statusCode = 405;
432
+ res.setHeader("Allow", "POST");
433
+ return res.end("POST");
434
+ }
435
+ // Same-origin. A page on another origin can POST JSON cross-site without a preflight
436
+ // only if it lies about its content type, which the next check refuses — but the
437
+ // browser always sends `Origin` on a cross-site POST, so this is the cheap first no.
438
+ const origin = req.headers.origin;
439
+ if (origin) {
440
+ let sameOrigin = false;
441
+ try {
442
+ sameOrigin = new URL(origin).host === req.headers.host;
443
+ } catch {
444
+ sameOrigin = false;
445
+ }
446
+ if (!sameOrigin) return json(403, { ok: false, error: "cross-origin writes are refused" });
447
+ }
448
+ if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
449
+ return json(415, { ok: false, error: "send application/json" });
450
+ }
451
+
452
+ const chunks = [];
453
+ let size = 0;
454
+ let overflowed = false;
455
+ req.on("data", (c) => {
456
+ size += c.length;
457
+ if (size > 16384) {
458
+ // A ticket change is three short strings. Anything larger is not one.
459
+ overflowed = true;
460
+ req.destroy();
461
+ return;
462
+ }
463
+ chunks.push(c);
464
+ });
465
+ req.on("end", () => {
466
+ if (overflowed) return json(413, { ok: false, error: "that request body is far too large for a status change" });
467
+ let change;
468
+ try {
469
+ change = JSON.parse(Buffer.concat(chunks).toString("utf8"));
470
+ } catch {
471
+ return json(400, { ok: false, error: "the request body was not JSON" });
472
+ }
473
+ let result;
474
+ try {
475
+ result = updateTicket(repoRoot, change, { backlogDir: config.backlogDir });
476
+ } catch (e) {
477
+ // 409: the board asked for something the file does not agree with — a stale
478
+ // `expect`, a ticket that moved. Every other refusal is a bad request.
479
+ const msg = oneLine(e);
480
+ logger.warn(`pin write refused: ${msg}`);
481
+ return json(/changed on disk|on disk, not|was out of date/.test(msg) ? 409 : 400, { ok: false, error: msg });
482
+ }
483
+ // Re-read rather than echo: the card that goes back is the card the FILE now draws.
484
+ // `includeCancelled`, because the write that just landed may have BEEN a cancel —
485
+ // without it the ticket would be written correctly and then reported unreadable.
486
+ const { tickets } = collectTickets(repoRoot, { backlogDir: config.backlogDir, includeCancelled: true });
487
+ const fresh = tickets.find((t) => t.id === result.id);
488
+ if (!fresh) return json(500, { ok: false, error: `${result.id} was written but cannot be read back` });
489
+ if (result.changed.length) {
490
+ logger.info(`${result.id} → ${result.changed.map((k) => `${k}=${k === "status" ? result.status : result.priority}`).join(" ")} (${result.file})`);
491
+ }
492
+ return json(200, {
493
+ ok: true,
494
+ id: fresh.id,
495
+ status: fresh.status,
496
+ priority: fresh.priority,
497
+ file: fresh.file,
498
+ changed: result.changed,
499
+ card: renderCard(fresh, { assetHref, live: true }),
500
+ });
501
+ });
502
+ req.on("error", () => json(400, { ok: false, error: "the request stream failed" }));
503
+ return;
504
+ }
505
+
506
+ if (req.method !== "GET" && req.method !== "HEAD") {
507
+ res.statusCode = 405;
508
+ res.setHeader("Allow", "GET, HEAD");
509
+ return res.end("GET or HEAD");
510
+ }
511
+
512
+ if (wall) {
513
+ // Read from disk on every request — that is the whole point of the route, and why
514
+ // `orbytes-pin-gallery` is no longer part of looking at the board.
515
+ try {
516
+ // `includeCancelled`: `/pin` is the ONE surface where the archive is visible. Every
517
+ // agent-facing read drops cancelled tickets by default — a cancelled ticket must not
518
+ // take up context space for an agent — and an archive nobody can open is a delete.
519
+ const { tickets, broken } = collectTickets(repoRoot, { backlogDir: config.backlogDir, includeCancelled: true });
520
+ const html = renderBoard(tickets, broken, {
521
+ // The presence of an endpoint is what makes the page writable. The standalone
522
+ // file passes none, so it renders the same board with no drag and no menus —
523
+ // there is nothing listening behind a `file://` page.
524
+ apiHref: apiPath,
525
+ // A shot outside `backlog/assets/` has no URL here, because this route serves that
526
+ // one directory and nothing else; the card then shows its placeholder, naming the
527
+ // path it expected. Nothing the picker writes can land outside it.
528
+ assetHref,
529
+ // Where "Site" and "Lab" point. `null` keeps the board's own default pair; the
530
+ // lab passes its resolved subpath so the link is the truth rather than a guess.
531
+ links: config.links,
532
+ });
533
+ res.statusCode = 200;
534
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
535
+ res.setHeader("Cache-Control", "no-store");
536
+ return res.end(req.method === "HEAD" ? undefined : html);
537
+ } catch (e) {
538
+ // A broken TICKET never gets here — collectTickets collects those and the page says
539
+ // so. This is the board directory being unreadable, which is worth saying plainly.
540
+ logger.warn(`${route} failed: ${oneLine(e)}`);
541
+ res.statusCode = 500;
542
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
543
+ return res.end(`orbytes-pin: could not read ${config.backlogDir}/tasks — ${oneLine(e)}\n`);
544
+ }
545
+ }
546
+
547
+ let rel;
548
+ try {
549
+ rel = decodeURIComponent(path.slice(assetPrefix.length));
550
+ } catch {
551
+ res.statusCode = 400;
552
+ return res.end("bad path");
553
+ }
554
+ const asset = resolveAsset(assetsRoot, rel);
555
+ if (!asset) {
556
+ res.statusCode = 404;
557
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
558
+ return res.end("not found\n");
559
+ }
560
+ res.statusCode = 200;
561
+ res.setHeader("Content-Type", asset.type);
562
+ res.setHeader("Content-Length", String(asset.size));
563
+ // A ticket's PNG is a hardlink that shot.mjs can replace under the same name, so a
564
+ // cached copy would show the previous shot after a re-pin.
565
+ res.setHeader("Cache-Control", "no-store");
566
+ if (req.method === "HEAD") return res.end();
567
+ return createReadStream(asset.file).pipe(res);
568
+ });
569
+
570
+ toolbar.on("orbytes-pin:create", (payload) => {
571
+ // ── Synchronous half: the ticket, and the reply. Nothing awaited before send(). ──
572
+ let ticket;
573
+ try {
574
+ ticket = writeTicket(repoRoot, payload, { backlogDir: config.backlogDir });
575
+ } catch (e) {
576
+ logger.warn(`pin refused: ${oneLine(e)}`);
577
+ toolbar.send("orbytes-pin:created", { id: null, file: null, shot: null, error: oneLine(e) });
578
+ return;
579
+ }
580
+ toolbar.send("orbytes-pin:created", {
581
+ id: ticket.id,
582
+ file: ticket.file,
583
+ shot: config.shots ? ticket.shot : null,
584
+ error: null,
585
+ });
586
+ logger.info(`${ticket.id} → ${ticket.file} (dispatch: ${payload.dispatch})`);
587
+
588
+ // ── Asynchronous half: the screenshot. The user is already back at work. ──
589
+ if (!config.shots) return;
590
+ const dest = join(config.archiveDir, project, ticket.archiveName);
591
+ const link = join(repoRoot, config.backlogDir, "assets", ticket.archiveName);
592
+ shoot({
593
+ origin: devOrigin(server),
594
+ url: payload.url,
595
+ viewport: payload.viewport,
596
+ scroll: payload.scroll,
597
+ rect: payload.rect,
598
+ dest,
599
+ link,
600
+ // The fresh page this shot loads is the only honest test of whether the selector
601
+ // written a moment ago survives a reload — the picking page cannot answer, because
602
+ // it IS the load the selector was built against. shoot() counts the matches there
603
+ // and writes the number into the ticket; these two arguments are what let it.
604
+ selector: payload.selector,
605
+ ticketFile: ticket.absolute,
606
+ log: (m) => logger.warn(`${ticket.id}: ${m}`),
607
+ })
608
+ .then(({ ms, cold, linked, selectorMatches }) => {
609
+ logger.info(`${ticket.id} shot in ${ms} ms${cold ? " (cold launch)" : ""} → ${ticket.shot}${linked === "copy" ? " (copied, not hardlinked)" : ""}`);
610
+ if (selectorMatches !== 1 && selectorMatches !== null) {
611
+ logger.warn(
612
+ selectorMatches === 0
613
+ ? `${ticket.id}: its selector matched NOTHING on a fresh load — the ticket is still workable from its source file, screenshot and outer_html, but not by selector`
614
+ : `${ticket.id}: its selector matched ${selectorMatches} elements on a fresh load — it does not name one element`,
615
+ );
616
+ }
617
+ toolbar.send("orbytes-pin:shot", { id: ticket.id, shot: ticket.shot, selectorMatches, error: null });
618
+ })
619
+ .catch((e) => {
620
+ // The ticket stands. Only the picture is missing, and the ticket says where it
621
+ // would have been — no stale PNG is left behind to read as a success. The ticket's
622
+ // `selector_matches` stays `pending`, which is the truth: nothing was tested.
623
+ logger.warn(`${ticket.id} screenshot failed: ${oneLine(e)}`);
624
+ toolbar.send("orbytes-pin:shot", { id: ticket.id, shot: null, selectorMatches: null, error: oneLine(e) });
625
+ });
626
+ });
627
+
628
+ server.httpServer?.once("close", () => { closeBrowser(); });
629
+
630
+ logger.info(`the pin board is at ${route} · every ticket in ${config.backlogDir}/tasks, live on refresh · drag a card to change its status · agents work "Ready for agent" and stop at "Ready for review"; only you set "Resolved"`);
631
+ // The path comes from `config.archiveDir`, the same value the shot actually writes to —
632
+ // it was a hardcoded `~/.orbytes/feedback-archive/...` until 2026-09-22, so a site that
633
+ // set `archiveDir` was told at every startup that its screenshots were somewhere they had
634
+ // never been. `~` is restored only when the directory really is under $HOME.
635
+ const home = homedir();
636
+ const archiveHere = join(config.archiveDir, project);
637
+ const shown = archiveHere.startsWith(home + sep) ? `~${archiveHere.slice(home.length)}` : archiveHere;
638
+ logger.info(`listening for orbytes-pin:create (dev only) · screenshots ${config.shots ? `→ ${shown}` : "disabled"}`);
639
+ },
640
+ },
641
+ };
642
+ }
643
+
644
+ export { findRepoRoot, listTickets, parseTicket, writeTicket } from "./tickets.mjs";
645
+ // The status vocabulary, and the three questions everything asks of a status. An agent picking
646
+ // work off the board asks `isAgentReady` — not "is it open", and certainly not `=== "To Do"`,
647
+ // which no longer exists. `listTickets` already drops `Cancelled` before an agent sees it.
648
+ export {
649
+ BACKLOG,
650
+ CANCELLED,
651
+ CLOSED_STATUSES,
652
+ DEFAULT_STATUS,
653
+ IN_PROGRESS,
654
+ OPEN_STATUSES,
655
+ READY_FOR_AGENT,
656
+ READY_FOR_REVIEW,
657
+ RESOLVED,
658
+ STATUSES,
659
+ isAgentReady,
660
+ isCancelled,
661
+ isOpen,
662
+ normaliseStatus,
663
+ } from "./tickets.mjs";
664
+ export { sourceStampPlugin, stampSource } from "./source-stamp.mjs";
665
+ export { close as closeBrowser, shoot } from "./shot.mjs";
666
+ export { BOARD_CONVENTIONS, PRIORITIES, collectTickets, renderBoard, renderCard, updateTicket } from "./board.mjs";