@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.
- package/LICENSE +37 -0
- package/README.md +410 -0
- package/bin/lab-cull.mjs +401 -0
- package/bin/pin-gallery.mjs +121 -0
- package/defaults.mjs +120 -0
- package/dist/core/astro-integration.js +130 -0
- package/dist/core/index.js +6 -0
- package/dist/core/lib-paths.js +29 -0
- package/dist/core/options.js +173 -0
- package/dist/core/utils/get-exports.js +52 -0
- package/dist/core/utils/invariant.js +12 -0
- package/dist/core/utils/kebab-case.js +15 -0
- package/dist/core/utils/path-builder.js +22 -0
- package/dist/core/utils/path.js +53 -0
- package/dist/core/virtual-module/get-story-modules.js +60 -0
- package/dist/core/virtual-module/story-modules.js +8 -0
- package/dist/core/virtual-module/virtual-module-ids.js +22 -0
- package/dist/core/virtual-module/virtual-routes.js +83 -0
- package/dist/core/virtual-module/vite-plugin.js +98 -0
- package/docs/PIN-CONTRACT.md +263 -0
- package/docs/PIN.md +429 -0
- package/index.d.ts +279 -0
- package/index.mjs +347 -0
- package/package.json +93 -0
- package/src/Empty.astro +4 -0
- package/src/Home.astro +298 -0
- package/src/LabHead.astro +1102 -0
- package/src/core/LICENSE-astrobook +166 -0
- package/src/core/astro-integration.ts +166 -0
- package/src/core/client.ts +89 -0
- package/src/core/index.ts +7 -0
- package/src/core/lib/components/empty.astro +1 -0
- package/src/core/lib/components/head.astro +1 -0
- package/src/core/lib/components/home.astro +8 -0
- package/src/core/lib/components/with-decorators.astro +22 -0
- package/src/core/lib/pages/app.astro +19 -0
- package/src/core/lib/pages/preview.astro +17 -0
- package/src/core/lib/pages/story.astro +16 -0
- package/src/core/lib-paths.ts +72 -0
- package/src/core/options.ts +262 -0
- package/src/core/utils/get-exports.ts +59 -0
- package/src/core/utils/invariant.ts +13 -0
- package/src/core/utils/kebab-case.ts +30 -0
- package/src/core/utils/path-builder.ts +45 -0
- package/src/core/utils/path.ts +80 -0
- package/src/core/virtual-module/get-story-modules.ts +110 -0
- package/src/core/virtual-module/story-modules.ts +9 -0
- package/src/core/virtual-module/virtual-module-ids.ts +17 -0
- package/src/core/virtual-module/virtual-routes.ts +130 -0
- package/src/core/virtual-module/vite-plugin.ts +125 -0
- package/src/pin/board.mjs +1521 -0
- package/src/pin/index.mjs +666 -0
- package/src/pin/shot.mjs +427 -0
- package/src/pin/source-stamp.mjs +159 -0
- package/src/pin/tickets.mjs +697 -0
- package/src/pin/toolbar.js +3181 -0
- package/src/shell/Browse.astro +371 -0
- package/src/shell/CardGrid.astro +297 -0
- package/src/shell/Viewport.astro +1330 -0
- package/src/shell/index.json.ts +12 -0
- package/src/shell/lab-index.ts +344 -0
- package/src/shell/lab-params.ts +245 -0
- package/src/shell/live-files.mjs +164 -0
- package/src/shell/marks.mjs +136 -0
- package/src/types/index.ts +6 -0
- package/src/types/types.ts +239 -0
- package/src/types/virtual.d.ts +29 -0
- package/src/ui/components/app.astro +13 -0
- package/src/ui/components/build-path.ts +13 -0
- package/src/ui/components/build-tree.ts +108 -0
- package/src/ui/components/collapse-duration.ts +28 -0
- package/src/ui/components/compress-terms.ts +10 -0
- package/src/ui/components/dashboard-layout.astro +39 -0
- package/src/ui/components/home.astro +65 -0
- package/src/ui/components/layout.astro +110 -0
- package/src/ui/components/preview-layout.astro +109 -0
- package/src/ui/components/sidebar-button-fullscreen.astro +38 -0
- package/src/ui/components/sidebar-button-search.astro +23 -0
- package/src/ui/components/sidebar-button-theme.astro +9 -0
- package/src/ui/components/sidebar-button.astro +24 -0
- package/src/ui/components/sidebar-resize-handle.astro +74 -0
- package/src/ui/components/sidebar-search-panel.astro +41 -0
- package/src/ui/components/sidebar-search-script.ts +103 -0
- package/src/ui/components/sidebar-title.astro +17 -0
- package/src/ui/components/sidebar-tree-node.astro +143 -0
- package/src/ui/components/sidebar-tree.astro +84 -0
- package/src/ui/components/sidebar.astro +29 -0
- package/src/ui/components/theme-message.ts +26 -0
- package/src/ui/components/theme-script.astro +71 -0
- package/src/ui/components/theme-toggle.astro +63 -0
- package/src/ui/components/theme.ts +32 -0
- package/src/ui/index.ts +4 -0
- package/src/ui/lab.css +549 -0
- 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
|
+

|
|
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.
|