@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
package/docs/PIN.md ADDED
@@ -0,0 +1,429 @@
1
+ # The pin board
2
+
3
+ Click a rendered element on `astro dev`, leave a comment, and get a markdown ticket plus a PNG on
4
+ local disk for a Claude Code agent to pick up. Then work the tickets on a kanban at `/pin`, on the
5
+ same dev server. No cloud, no API key, no MCP, no second process.
6
+
7
+ It is for a **solo pass** — your own review of a site in dev, before anyone else sees it. It does
8
+ not sync with whatever tool you use for review rounds with other people, and is not meant to
9
+ replace one. **Dev only** — nothing here can run in `astro build`.
10
+
11
+ > **It ships as half of `@orbytes/astrolab`.** Built as its own package, `orbytes-pin`, over
12
+ > 2026-09-21/22 and merged into the lab on 2026-09-22 so the two install, configure and version as
13
+ > one thing. The source is [`src/pin/`](../src/pin/), the interface it agrees on is
14
+ > [PIN-CONTRACT.md](./PIN-CONTRACT.md), and there is no `orbytes-pin` package any more — import
15
+ > from `@orbytes/astrolab` or `@orbytes/astrolab/pin`.
16
+
17
+ ## backlog.md is retired (2026-09-22)
18
+
19
+ The board used to be [backlog.md](https://github.com/MrLesk/Backlog.md): a separate server on its
20
+ own port (`backlog browser`, 6420). That is the whole reason it is gone — the site was on
21
+ `localhost:4321`, the lab on `localhost:4321/lab`, and the tickets somewhere else entirely.
22
+
23
+ **Stop doing two things.** Neither is merely redundant:
24
+
25
+ | Stop | Do instead |
26
+ |---|---|
27
+ | `backlog browser` | `npm run dev`, then `/pin` |
28
+ | `backlog task edit <n> …` on a pin ticket | drag the card, or use the status menu in its detail panel |
29
+
30
+ `backlog task edit` is the original defect, not an alternative. Any write through backlog.md
31
+ re-serialises frontmatter to its own schema and silently drops every key it does not know; on a
32
+ pin ticket that erases `dispatch`, `source`, `selector`, `rect`, `shot` and `outer_html`. It is
33
+ why the pin fields live in a fenced block in the body, and why `/pin` was read-only until
34
+ 2026-09-22:
35
+ a second writer of these files would have rebuilt the bug. With backlog.md out of the loop there
36
+ is no second writer, so `/pin` writes them correctly by construction (› *The write path*).
37
+
38
+ If backlog.md is still installed globally, this does not uninstall it.
39
+ A `backlog/config.yml` left behind in a repo that used backlog.md is a record and nothing reads it
40
+ — those values live in `BOARD_CONVENTIONS` and `PRIORITIES`
41
+ ([`src/pin/board.mjs`](../src/pin/board.mjs)) and `STATUSES`
42
+ ([`src/pin/tickets.mjs`](../src/pin/tickets.mjs)). `backlog/tasks/` and `backlog/assets/` are
43
+ unchanged.
44
+
45
+ ## The status model
46
+
47
+ Six statuses, in board order. Three of them carry a rule, and each rule is enforced by code rather
48
+ than left as a convention to remember.
49
+
50
+ | Status | What it means | The rule |
51
+ |---|---|---|
52
+ | **Backlog** | raised, but not ready for an agent to pick up | not work yet |
53
+ | **Ready for agent** | queued work — it replaces the usual *To Do* | **the only status an agent may start work from** — `isAgentReady()` |
54
+ | **In Progress** | an agent is on it | — |
55
+ | **Ready for review** | an agent has finished; a human decides whether it is done | **the human review queue.** An agent that finishes a ticket writes this and stops |
56
+ | **Resolved** | accepted by a human | **only a human sets this.** No agent moves a ticket here, ever |
57
+ | **Cancelled** | an archive for abandoned work, which must not take up an agent's context | **excluded from every agent-facing read** — `listTickets()` drops it unless asked |
58
+
59
+ Adopted 2026-09-22. Until then the last column was **Done**, which conflated *an agent finished it*
60
+ with *a human accepted it*. Work waiting on a person had nowhere to sit, and nothing on the board
61
+ could say **this one needs you** — which is the entire point of the change.
62
+
63
+ **Never write `To Do` or `Done` again.** Both still *read*, as `Ready for agent` and `Resolved`,
64
+ so nothing written before 2026-09-22 fails to parse; `updateTicket` rewrites the line on the next write
65
+ of that ticket, so the aliases drain themselves rather than living on disk forever. The detail
66
+ panel says so on any ticket where the card and the file still disagree.
67
+
68
+ Ask the status a question rather than comparing it to a string — every one of these handles the
69
+ aliases, and a bare `=== "Ready for agent"` does not:
70
+
71
+ ```js
72
+ import { isAgentReady, isOpen, isCancelled, normaliseStatus } from "@orbytes/astrolab";
73
+
74
+ isAgentReady("To Do"); // true — legacy alias for Ready for agent
75
+ isOpen("Done"); // false — legacy alias for Resolved
76
+ isCancelled(t.status); // the archive test
77
+ ```
78
+
79
+ `isOpen()` is *not `Resolved` and not `Cancelled`* — one definition, shared by this board's chips
80
+ and the in-page panel's Open tab, so the two cannot disagree by one ticket. `Cancelled` appears in
81
+ neither tab: it is reachable on `/pin` only.
82
+
83
+ ## The board at `/pin`
84
+
85
+ Six columns in the order above, read fresh from `backlog/tasks/` on every request. No build step,
86
+ no regeneration; a refresh is the reload.
87
+
88
+ - **Ready for review is drawn to be seen**, because it is the human review queue and the whole
89
+ reason the model changed — violet rule, violet dot, *waiting on you* under the name, a tinted panel
90
+ with a filled count **only when it actually holds something**. An empty queue stays quiet; one
91
+ that shouts at nothing teaches you to stop looking at it. Its count also leads the top bar.
92
+ - **Cancelled is drawn to be ignored**: a narrow dashed rail rather than a sixth of the wall, its
93
+ cards desaturated until hovered. Still a real drop target, and still the only place a cancelled
94
+ ticket can be seen at all — an archive nobody can open is a delete.
95
+ - Every column carries a one-line gloss under its name (*agents start here*, *waiting on you*),
96
+ because six nouns with no explanation is a vocabulary you have to be told once and remember.
97
+ - **A card** carries its screenshot, id, title, dispatch mode, priority and source file, plus the
98
+ selector-health banner when the ticket needs a human. Click it for the full comment, the shot at
99
+ full width, and every field.
100
+ - **Drag a card to another column** to change its status. Priority changes in the detail panel;
101
+ so does status, which is the keyboard and touch path to the same write.
102
+ - **Search** matches the title, the comment, the source file, the page, the selector and the
103
+ labels at once. The status chips and *Needs a human* filter alongside it, and `/` focuses the box.
104
+ - Neutral tool chrome, deliberately sharing nothing with the site being built (decided
105
+ 2026-09-22).
106
+
107
+ `orbytes-pin-gallery` still writes `backlog/gallery.html`, the standalone snapshot for opening with
108
+ no dev server running (`--backlog-dir` when the site moved its board — it took the default and
109
+ printed `0 ticket(s)` until 2026-09-22). It renders from the same module and is **read-only** — the
110
+ board is writable exactly when `renderBoard` is given an endpoint, and a `file://` page has nothing
111
+ listening behind it.
112
+
113
+ **The board's corner links** are an option (`links`), not the hardcoded `/` and `/lab` they were
114
+ until 2026-09-22. Through `orbytesLab()` they are filled in from the lab's own resolved `subpath`,
115
+ so the "Lab" link is derived from the value the routes are injected at rather than agreeing with it
116
+ by luck.
117
+
118
+ ## The write path
119
+
120
+ One endpoint, `POST <route>/api/ticket`, registered in `astro:server:setup` — which does not exist
121
+ in a build, so it cannot reach `dist/`.
122
+
123
+ **`route` shadows a host page at the same path.** The board and this endpoint are middleware,
124
+ installed ahead of Astro's own request handler, so a site with its own `src/pages/pin.astro` gets
125
+ the board instead of its page — in dev only, and with nothing on screen to explain it. The default
126
+ stays `/pin`: it is the address every document here names. A site that owns
127
+ that path sets `pin: { route: "/__pin" }`.
128
+
129
+ ```json
130
+ { "id": "PIN-004", "status": "Ready for review", "expect": { "status": "In Progress" } }
131
+ ```
132
+
133
+ `updateTicket` (`src/board.mjs`) rewrites **only** the frontmatter lines it is changing, plus
134
+ `updated_date`. It never serialises: the file is split by index, and the slice from the closing
135
+ `---` onward — the comment, the image and the fenced ```yaml pin block — is the same substring on
136
+ the way out as on the way in. That identity is asserted on every write, and the write is a temp
137
+ file plus a rename, so a reader never sees half a ticket.
138
+
139
+ **Measured on the real ticket, 2026-09-22, and the numbers name the command that produced them.**
140
+ `pin-001` was dragged with a real mouse from `Ready for agent` into `Ready for review` and back on
141
+ a live `/pin`. Its body — everything from the closing `---` on — stayed at **685 bytes, sha256
142
+ `92270bdc2a4c…`**, before the first drag, between the two, and after the second; `parseTicket`
143
+ returned all nine pin fields unchanged each time; the whole-file diff of a single drag was **one
144
+ line**, and after the round trip `diff` had nothing to say about the file at all.
145
+
146
+ ```sh
147
+ # the probe: byte length and sha256 of the slice from the closing `---` onward
148
+ node -e 'const {createHash}=require("node:crypto"),t=require("node:fs").readFileSync(f,"utf8"), \
149
+ b=t.slice(t.indexOf("\n---",4)); console.log(Buffer.byteLength(b), createHash("sha256").update(b).digest("hex"))'
150
+ ```
151
+
152
+ **The control.** A green check that cannot go red measures nothing, so the same probe was run over
153
+ the same ticket against a naive round-trip writer — `js-yaml` load, set the field, `dump` — which
154
+ is exactly what backlog.md did and exactly what `updateTicket` refuses to do. It turned the check
155
+ red: the body went **685 → 686 bytes** with a different hash, and the frontmatter's
156
+ `labels: [pin, sample, hero]` was rewritten as a three-line block sequence on the way past.
157
+
158
+ Every refusal is a refusal, never a warning, and answers with a sentence the board puts on screen:
159
+
160
+ | Refused | Why |
161
+ |---|---|
162
+ | a status or priority off the allowlist | a closed set is what lets the value be written bare, with no YAML emitter. A legacy `To Do` / `Done` is normalised first, not refused |
163
+ | an id that names no readable ticket | the ticket is found by id through `collectTickets`, so no caller path ever reaches disk |
164
+ | a missing key, or one held as a YAML block scalar | a single-line replace would orphan its continuation lines |
165
+ | an `expect` that disagrees with disk | the board was stale; refusing beats one drop overwriting another |
166
+ | the file changing between the read and the rename | compare-and-swap |
167
+ | a non-POST, a cross-origin POST, a non-JSON body, a body over 16 KB | before a byte of the body is parsed |
168
+
169
+ ## Wiring
170
+
171
+ Nothing is wired separately: the board comes with the lab, through the one integration the site
172
+ already configures.
173
+
174
+ ```js
175
+ import orbytesLab from "./packages/astro-lab/index.mjs";
176
+
177
+ const includeLab =
178
+ process.argv.includes("dev") || process.env.PUBLIC_DEPLOY_ENV === "staging";
179
+
180
+ export default defineConfig({
181
+ integrations: [
182
+ includeLab ? orbytesLab({ css: ["./src/styles/global.css"] }) : null,
183
+ ],
184
+ });
185
+ ```
186
+
187
+ `pin: false` omits the board and leaves the lab; `pin: { … }` is the option table below. The board
188
+ gates itself on `command === "dev"`, so including the lab in a staging build registers it and it
189
+ does nothing there.
190
+
191
+ `playwright` is an **optional** peer and is loaded by dynamic import inside the screenshotter, not
192
+ at module scope — a site without it loads its config, runs the lab and writes tickets without
193
+ pictures. Install it (`npm i -D playwright && npx playwright install chromium`) for the screenshots,
194
+ or pass `pin: { shots: false }` and never load it.
195
+
196
+ ## Options
197
+
198
+ Pass these as `orbytesLab({ pin: { … } })`.
199
+
200
+ | Option | Default | What it does |
201
+ |---|---|---|
202
+ | `stamp` | `["src/lab/sections", "src/components"]` | **Site-relative** directories whose `.astro` files get `data-orbytes-src`. |
203
+ | `stampSkip` | `[]` | **Site-relative** `.astro` paths exempt from the stamp's hard failure. See below. A repo-relative path is accepted too — until 2026-09-22 that was the only spelling that worked, while `stamp` beside it was site-relative, so the exemption silently lapsed wherever the Astro project was not the repo root. |
204
+ | `backlogDir` | `"backlog"` | Repo-relative board directory: tickets in `tasks/`, PNGs in `assets/`. |
205
+ | `archiveDir` | `~/.orbytes/feedback-archive` | Canonical screenshot home; the repo copy is a hardlink into it. |
206
+ | `project` | the checkout's folder name | The folder under `archiveDir` this project's screenshots live in. Two clones of **different** repos under one folder name share an archive silently, one overwriting the other's `pin-007.png` — set it where that is possible. |
207
+ | `shots` | `true` | `false` writes tickets and takes no screenshots. |
208
+ | `route` | `"/pin"` | Where the board is served. `<route>/assets/*` serves its screenshots, `<route>/api/ticket` is the write endpoint. **It shadows a host page at that path** — see *The write path*. Trailing slashes are stripped. The toolbar app reads the resolved value too (it fetches the ticket list from it and its **Board ↗** button opens it), through the virtual module below — until 2026-09-22 it carried a literal `/pin`, so setting this left the panel fetching a path nothing serves. |
209
+ | `links` | the lab's resolved subpath | The board's corner links, `[{ href, label }]`. `[]` draws none. |
210
+ | `appId` / `appName` / `icon` | `"orbytes-pin"` / `"Pin"` / `"bug"` | The dev-toolbar app's identity. |
211
+
212
+ ## What a ticket looks like
213
+
214
+ `<repo>/backlog/tasks/pin-<NNN>-<slug>.md`. `<NNN>` is max+1 of the numbers already on disk,
215
+ zero-padded to 3; `<slug>` is the title lowercased, non-alphanumerics collapsed to `-`, cut to 40.
216
+
217
+ ```markdown
218
+ ---
219
+ id: PIN-001
220
+ title: Hero headline sits too low against the image
221
+ status: Ready for agent
222
+ assignee: []
223
+ created_date: '2026-09-21'
224
+ labels: [pin, hero]
225
+ dependencies: []
226
+ priority: medium
227
+ ---
228
+
229
+ ## Description
230
+
231
+ The comment, verbatim, unedited.
232
+
233
+ ![](/assets/pin-001.png)
234
+
235
+ ```yaml pin
236
+ dispatch: queue
237
+ source: site/src/lab/sections/Section01Hero/V1/HeroSection.astro
238
+ selector: 'section.hero > div > h1'
239
+ selector_matches: 1
240
+ url: /
241
+ viewport: { width: 1440, height: 960, dpr: 2 }
242
+ scroll: { x: 0, y: 2840 }
243
+ rect: { x: 120, y: 340, width: 800, height: 96 }
244
+ shot: backlog/assets/pin-001.png
245
+ outer_html: '<h1 class="hero__title">Clarity, not more noise</h1>'
246
+ ```
247
+ ```
248
+
249
+ **Why the pin data is in the body and not in frontmatter.** Any write through backlog.md — the
250
+ CLI, or dragging a card in its web UI — re-serialises frontmatter to its own schema and silently
251
+ drops every key it does not know. Measured 2026-09-21: `backlog task edit 1 -s Done` on a
252
+ frontmatter-shaped ticket erased `dispatch`, `source`, `selector`, `rect`, `shot` and
253
+ `outer_html`, so the first time a card was moved the ticket stopped being actionable. The body
254
+ and its image survive that write untouched.
255
+
256
+ backlog.md is retired now and `updateTicket` is careful, so the format is no longer defending
257
+ against a live threat — **but do not flatten it back into frontmatter.** It costs nothing to keep,
258
+ the tickets on disk are already in this shape, and it is what makes a careless future writer (a
259
+ hand edit, a script, backlog.md itself if anyone runs it again) survivable rather than fatal.
260
+ The heading stays `## Description` for the same reason it was chosen: it is what backlog.md
261
+ rendered, every ticket on disk uses it, and `parseTicket` refuses any other.
262
+
263
+ Read a ticket with the reader, never by hand-parsing frontmatter:
264
+
265
+ ```js
266
+ import { isAgentReady, listTickets } from "@orbytes/astrolab/pin/tickets";
267
+
268
+ // Live tickets only — `Cancelled` is not in this list, by default and on purpose.
269
+ for (const t of listTickets(repoRoot)) console.log(t.id, t.status, t.pin.source, t.pin.rect);
270
+
271
+ // What an agent picking up work actually asks. `status` is raw, so never compare it to a literal.
272
+ const mine = listTickets(repoRoot).filter((t) => isAgentReady(t.status));
273
+
274
+ // The archive, when you explicitly want it.
275
+ listTickets(repoRoot, { includeCancelled: true });
276
+ ```
277
+
278
+ `status`, `title`, `labels` and `priority` come from frontmatter — in either the inline or the
279
+ block-sequence shape a previous backlog.md write may have left them in — and every `pin.*` field
280
+ comes from the fenced block. A ticket whose fence is missing or unparseable is a hard error naming
281
+ the file, never a half-filled object.
282
+
283
+ **`status` comes back raw**, exactly as the file says it, which on a ticket written before
284
+ 2026-09-22 is `To Do` or `Done`. The reader stays honest so that something can still tell you what
285
+ the file actually holds; every *question* about a status normalises first. The board does this
286
+ once, in `collectTickets`, and shows the raw value in the detail panel when the two differ.
287
+
288
+ ## `selector_matches` — whether the selector survives a reload
289
+
290
+ The picking page cannot tell you whether a selector still works, because it *is* the load the
291
+ selector was written against. `cssPathOf` verifies uniqueness before it sends, so every selector
292
+ looks healthy at pick time — including the ones that are already dead.
293
+
294
+ Measured 2026-09-21 on `Home05FounderIntro`: Swiper mints `id="swiper-wrapper-<random hex>"` when
295
+ it initialises and a **different** one on every load, so a pin inside the carousel wrote
296
+ `#swiper-wrapper-2a2469caeb1c8a14 > … > blockquote` — **1 node when written, 0 on each of the two
297
+ next loads**, with the wrapper coming back as `…54d9ec0d44549cd5` then `…78419b56f1752d81`. The
298
+ ticket's source file, screenshot and `outer_html` were all correct, so nothing looked wrong.
299
+
300
+ Two rules now keep a selector off that ground, both in
301
+ [`src/pin/toolbar.js`](../src/pin/toolbar.js):
302
+
303
+ - **An id anchors a selector only if a human plausibly typed it** (`isTrustworthyId`) — an
304
+ allowlist of authored shapes (words, separators, small counters), not a blocklist of one
305
+ library's garbage. An untrustworthy id is skipped and the walk keeps climbing.
306
+ - **A class naming a runtime condition never enters a selector** (`isTransientClass`,
307
+ `extendsSiblingClass`) — `swiper-slide-active` is on slide 1 now and slide 2 after a scroll.
308
+
309
+ Neither is complete, and the field is what makes the remainder visible. `src/shot.mjs` already
310
+ loads the page **fresh in a real browser** to take the screenshot; before the shutter it asks that
311
+ page how many nodes the ticket's selector finds, and writes the answer back into the pin block:
312
+
313
+ | Value | Means |
314
+ |---|---|
315
+ | `1` | healthy — the selector names exactly this element on a fresh load |
316
+ | `0` | dead — work the ticket from `source`, the screenshot and `outer_html` |
317
+ | `2`+ | ambiguous — the selector names several elements |
318
+ | `pending` | never tested — shots are off, or the screenshot failed |
319
+
320
+ Anything but `1` is said in the dev log and in the toolbar panel. A ticket whose selector died is
321
+ **still a good ticket** — it is labelled, never discarded. The defect was the silence.
322
+
323
+ ## Screenshots
324
+
325
+ One chromium for the whole dev session, launched lazily on the first pin and kept warm, closed
326
+ when the dev server closes. Measured on an Apple-silicon laptop, 2026-09-21: the launch itself is
327
+ ~98 ms, and a full capture — navigate, freeze motion, settle every image, hold the scroll position,
328
+ clip — is ~1.34 s warm against ~1.42 s cold.
329
+
330
+ You never wait for any of it. The ticket is written and `orbytes-pin:created` replied on the
331
+ same synchronous tick; the screenshot follows and sends `orbytes-pin:shot` when it lands. A ticket
332
+ with no screenshot yet is valid.
333
+
334
+ The PNG is written to `~/.orbytes/feedback-archive/<project>/pin-<NNN>.png` and **hardlinked** to
335
+ `<repo>/backlog/assets/pin-<NNN>.png` — the same bytes under two names, zero extra storage. If the
336
+ link fails because the two live on different filesystems it falls back to a copy and says so in
337
+ the dev log. A failed shot leaves no file behind under either name, so a stale PNG can never read
338
+ as a success.
339
+
340
+ The capture settings are not defaults — each was worked out against a real site, and each is there
341
+ because its absence produced a wrong picture: freeze motion both ways, settle every lazy image
342
+ before the shutter, hide the dev toolbar, delete the destination first.
343
+
344
+ ## The source attribute
345
+
346
+ Astro 7 emits no `data-astro-source-file`, so this package injects `data-orbytes-src` at section
347
+ granularity: a dev-only Vite `load` hook adds it to the first top-level element of every `.astro`
348
+ file under `stamp`, and the picker walks up from the clicked node to the nearest ancestor carrying
349
+ it. No ancestor found → `source` is `unresolved`, and the CSS selector plus `outer_html` still
350
+ make the ticket actionable.
351
+
352
+ The attribute is inserted immediately after the tag name (`<section` → `<section
353
+ data-orbytes-src="…"`), so no attribute value, expression, spread or self-closing slash can be
354
+ mangled and no line number moves.
355
+
356
+ **A matched file with no element to stamp is a hard failure**, not a warning — it throws, naming
357
+ the repo-relative path. Two templates reach that throw: one with no tag at all, and one whose
358
+ first top-level tag is a *component* (`<HeroSection />`), because Astro passes an attribute on a
359
+ component as a prop and a component that does not spread its props drops it silently. Because the
360
+ hook is lazy, the throw lands only when that one file is actually requested, not at server start.
361
+ Clear it by wrapping the template in an element, or name the file in `stampSkip`.
362
+
363
+ The case that reaches it in practice is a superseded shim: a file whose whole template is a single
364
+ component tag (`<HeroSection />`), whose rendered DOM already carries that component's own stamp.
365
+ Exempt it by naming it, **site-relative** — the same base as `stamp` beside it:
366
+
367
+ ```js
368
+ orbytesLab({
369
+ pin: {
370
+ stampSkip: ["src/lab/sections/HeroShim/V1/HeroShim.astro"],
371
+ },
372
+ })
373
+ ```
374
+
375
+ ## The browser ↔ server channel
376
+
377
+ Astro's dev toolbar, over the existing Vite HMR socket. No HTTP endpoint, no port, no CORS.
378
+
379
+ | Direction | Event | Payload |
380
+ |---|---|---|
381
+ | browser → server | `orbytes-pin:create` | `{ comment, dispatch, source, selector, url, viewport, scroll, rect, outerHTML }` |
382
+ | server → browser | `orbytes-pin:created` | `{ id, file, shot, error }` — all four keys, always |
383
+ | server → browser | `orbytes-pin:shot` | `{ id, shot, selectorMatches, error }` |
384
+ | board → server | `POST <route>/api/ticket` | `{ id, status?, priority?, expect? }` → `{ ok, id, status, priority, file, changed, card }` |
385
+
386
+ On failure the reply is `{ id: null, file: null, shot: null, error: "why, in one plain sentence" }`.
387
+
388
+ **Options reach the toolbar app a different way**, because Astro instantiates a dev-toolbar app
389
+ entrypoint with `(canvas, app, server)` and no options at all. The integration registers a
390
+ `apply: "serve"` Vite plugin that generates `virtual:orbytes-pin/config.mjs`, and
391
+ [`src/pin/toolbar.js`](../src/pin/toolbar.js) imports it — the same pattern the lab uses for
392
+ `virtual:orbytes-lab/config.mjs`. It carries `route` today. Nothing else about the app is
393
+ configurable from a site, and anything that becomes so goes through this module rather than a
394
+ second mechanism.
395
+
396
+ ## What the panel tells you
397
+
398
+ One status line, `#pin-status`, not a log — decided 2026-09-22. A failure (a refused ticket,
399
+ a screenshot that did not happen, a selector that dies on reload) shows and **stays** until it is
400
+ clicked away or replaced; an acknowledgement, including *"PIN-00n written to …"*, clears itself.
401
+ The running commentary the panel used to keep was deleted, not hidden: the mode badge, the hint
402
+ line and the target box already said all of it. Reasons, and the rule for adding a line back, are in
403
+ [the contract](./PIN-CONTRACT.md) › *The status line*.
404
+
405
+ The panel's head carries two controls that are easy to confuse. **Tickets** toggles the in-page rail
406
+ over the page you are on. **Board ↗** opens the full kanban at `route` in its own tab — a named
407
+ window, so clicking it again focuses that tab instead of opening another.
408
+
409
+ ## Dev only, asserted four times
410
+
411
+ 1. `astro:config:setup` returns early unless the Astro command is `dev` — no plugin, no toolbar
412
+ app, no stamp.
413
+ 2. Both Vite plugins carry `apply: "serve"`, so they cannot load in a build even if they were
414
+ registered.
415
+ 3. `astro:server:setup`, where every write lives — the ticket writer, the board and the
416
+ `/pin/api/ticket` endpoint — does not run in a build at all.
417
+ 4. That hook returns early anyway when `astro:config:setup` stood down, so a host that runs the
418
+ hooks in an order this package did not choose still gets nothing.
419
+
420
+ The first one matters more since the merge than it did before: the lab is deliberately included in
421
+ **staging** builds, and `orbytesLab()` returns this integration beside it — so it is now registered
422
+ in a build for the first time, and `command === "dev"` is what makes that a no-op.
423
+
424
+ Verified by running a real `npm run build` and grepping `dist/` for `orbytes-pin`, `data-orbytes-src`
425
+ and `/pin/api`: no hit.
426
+
427
+ No path this package emits into served HTML or into a ticket is absolute. The repo root is found
428
+ by walking up from the Astro config root to the first directory containing `.git`, and it throws
429
+ rather than guess.