@orbytes/astrolab 0.4.0-next.1 → 0.4.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +184 -84
  2. package/bin/pin-gallery.mjs +53 -19
  3. package/defaults.mjs +7 -20
  4. package/docs/PIN-CONTRACT.md +76 -10
  5. package/docs/PIN.md +93 -23
  6. package/index.d.ts +1 -7
  7. package/index.mjs +14 -81
  8. package/package.json +2 -2
  9. package/src/Home.astro +7 -8
  10. package/src/LabHead.astro +1 -1
  11. package/src/chrome/ActionsMenu.astro +97 -0
  12. package/src/chrome/ComponentCard.astro +9 -2
  13. package/src/chrome/Nav.astro +36 -10
  14. package/src/chrome/Panel.astro +17 -4
  15. package/src/chrome/Properties.astro +104 -0
  16. package/src/chrome/SectionsTree.astro +128 -0
  17. package/src/chrome/Shell.astro +20 -6
  18. package/src/chrome/StoryView.astro +103 -162
  19. package/src/chrome/Tree.astro +56 -53
  20. package/src/chrome/ViewportControls.astro +136 -61
  21. package/src/chrome/ViewportStage.astro +26 -3
  22. package/src/chrome/icons.ts +9 -0
  23. package/src/chrome/marks-client.ts +26 -53
  24. package/src/chrome/model.ts +14 -0
  25. package/src/chrome/navbar-client.ts +324 -0
  26. package/src/chrome/params-client.ts +434 -0
  27. package/src/chrome/pins-data.ts +42 -9
  28. package/src/chrome/shell-client.ts +99 -3
  29. package/src/chrome/trees.ts +112 -7
  30. package/src/chrome/viewport-client.ts +68 -242
  31. package/src/chrome/views/Assets.astro +21 -6
  32. package/src/chrome/views/Pages.astro +90 -54
  33. package/src/chrome/views/Placeholder.astro +3 -3
  34. package/src/chrome/views/Tasks.astro +12 -40
  35. package/src/core/LICENSE-astrobook +5 -0
  36. package/src/core/utils/kebab-case.ts +2 -2
  37. package/src/pin/board.mjs +25 -15
  38. package/src/pin/index.mjs +34 -20
  39. package/src/pin/tickets.mjs +6 -5
  40. package/src/pin/toolbar.js +81 -3
  41. package/src/shell/Browse.astro +35 -10
  42. package/src/shell/lab-index.ts +5 -4
  43. package/src/shell/lab-params.ts +113 -6
  44. package/src/shell/live-files.mjs +212 -10
  45. package/src/shell/marks.mjs +17 -41
  46. package/src/ui/components/preview-layout.astro +17 -0
  47. package/src/ui/components/theme-script.astro +4 -3
  48. package/src/ui/lab.css +2167 -566
  49. package/virtual.d.ts +0 -4
  50. package/bin/lab-cull.mjs +0 -401
package/defaults.mjs CHANGED
@@ -1,6 +1,6 @@
1
- // The lab's defaults and the one option resolver — imported by the integration (./index.mjs) and
2
- // by the cull binary (./bin/lab-cull.mjs), so a path the lab writes and a path the cull script
3
- // reads can never drift. Dependency-free: the binary runs in plain Node, outside Vite.
1
+ // The lab's defaults and the one option resolver — imported by the integration (./index.mjs), so
2
+ // every path the lab reads and writes is derived in one place. Dependency-free: it loads in plain
3
+ // Node, from the consumer's astro.config.mjs, outside Vite.
4
4
  //
5
5
  // Everything `resolveLabOptions` returns is JSON-serialisable on purpose. It is handed to the
6
6
  // package's own .astro pages through the `virtual:orbytes-lab/config.mjs` module the integration
@@ -25,17 +25,13 @@ export const DEFAULT_TIERS = ["sections", "components", "explorations"];
25
25
  * What a tier NAMED like one of these does, unless the consumer says otherwise on the tier itself:
26
26
  *
27
27
  * - `responsive` — its stories are section versions: they carry the responsive mark (ticked by
28
- * hand in the sidebar, stored in `<directory>/responsive.json`) and they render at page width
29
- * in thumbnails and in the viewport configurator.
30
- * - `cullable` — its stories may be marked for deletion (`<directory>/cull.json`, drained by
31
- * `orbytes-lab-cull`). Nothing else in the lab can be marked: version history and shared chrome
32
- * are never culled from a browser.
28
+ * hand on the component's page in the lab, stored in `<directory>/responsive.json`) and they
29
+ * render at page width in thumbnails and in the viewport configurator.
33
30
  *
34
- * At most one tier holds each role; the first one that claims it wins.
31
+ * At most one tier holds the role; the first one that claims it wins.
35
32
  */
36
33
  export const TIER_ROLES = {
37
34
  sections: { responsive: true },
38
- explorations: { cullable: true },
39
35
  };
40
36
 
41
37
  /**
@@ -118,19 +114,17 @@ export const normaliseSubpath = (value) => {
118
114
  * @property {string} id the first path segment under `directory`
119
115
  * @property {string} label shown on the tier card and the folder pages
120
116
  * @property {boolean} responsive section versions: responsive marks, page-width thumbnails
121
- * @property {boolean} cullable may be marked for deletion
122
117
  */
123
118
 
124
119
  /** @param {string | Partial<LabTier>} tier @returns {LabTier} */
125
120
  const resolveTier = (tier) => {
126
121
  const raw = typeof tier === "string" ? { id: tier } : { ...tier };
127
122
  const id = String(raw.id ?? "");
128
- const role = /** @type {Record<string, {responsive?: boolean, cullable?: boolean}>} */ (TIER_ROLES)[id] ?? {};
123
+ const role = /** @type {Record<string, {responsive?: boolean}>} */ (TIER_ROLES)[id] ?? {};
129
124
  return {
130
125
  id,
131
126
  label: raw.label ?? (id ? id.charAt(0).toUpperCase() + id.slice(1) : "Root"),
132
127
  responsive: raw.responsive ?? role.responsive ?? false,
133
- cullable: raw.cullable ?? role.cullable ?? false,
134
128
  };
135
129
  };
136
130
 
@@ -145,11 +139,8 @@ const resolveTier = (tier) => {
145
139
  * @property {string | null} feedbucketKey
146
140
  * @property {LabTier[]} tiers
147
141
  * @property {string | null} sectionsTier the id of the tier holding the responsive role
148
- * @property {string | null} cullTier the id of the tier holding the cull role
149
142
  * @property {string | null} responsiveDir `<directory>/<sectionsTier>/`, or null
150
- * @property {string | null} cullDir `<directory>/<cullTier>/`, or null
151
143
  * @property {string} responsiveFile `<directory>/responsive.json`
152
- * @property {string} cullFile `<directory>/cull.json`
153
144
  * @property {Viewports} viewports the viewport's screen sizes (DEFAULT_VIEWPORTS)
154
145
  * @property {null} tasks filled in by the integration when the pin board runs
155
146
  */
@@ -163,7 +154,6 @@ export function resolveLabOptions(options = {}) {
163
154
  const directory = normaliseDirectory(options.directory ?? DEFAULT_DIRECTORY);
164
155
  const tiers = (Array.isArray(options.tiers) ? options.tiers : DEFAULT_TIERS).map(resolveTier);
165
156
  const sections = tiers.find((tier) => tier.responsive) ?? null;
166
- const cull = tiers.find((tier) => tier.cullable) ?? null;
167
157
  return {
168
158
  directory,
169
159
  subpath: normaliseSubpath(options.subpath ?? DEFAULT_SUBPATH),
@@ -174,11 +164,8 @@ export function resolveLabOptions(options = {}) {
174
164
  feedbucketKey: options.feedbucketKey ? String(options.feedbucketKey) : null,
175
165
  tiers,
176
166
  sectionsTier: sections ? sections.id : null,
177
- cullTier: cull ? cull.id : null,
178
167
  responsiveDir: sections ? `${directory}/${sections.id}/` : null,
179
- cullDir: cull ? `${directory}/${cull.id}/` : null,
180
168
  responsiveFile: `${directory}/responsive.json`,
181
- cullFile: `${directory}/cull.json`,
182
169
  viewports: resolveViewports(options.viewports),
183
170
  tasks: null,
184
171
  };
@@ -1,6 +1,6 @@
1
1
  # orbytes-pin — the contract
2
2
 
3
- The interfaces the parts of the pin board agree on. Four source files under
3
+ The interfaces the parts of the pin board agree on. Five source files under
4
4
  [`../src/pin/`](../src/pin/) cite this document by name, and it lives inside the package so those
5
5
  citations resolve wherever the package is copied. The board moved into the lab package — today
6
6
  `@orbytes/astrolab`, and named `orbytes-astro-lab` until the 2026-09-22 rename — on 2026-09-22,
@@ -120,14 +120,27 @@ naming no readable ticket, or an `expect` that disagrees with disk.
120
120
  Canonical: `~/.orbytes/feedback-archive/<project>/pin-<NNN>.png`, **hardlinked** into
121
121
  `<repo>/backlog/assets/` so the board can serve it — the same bytes under two names, zero extra
122
122
  storage. Copy is the fallback across filesystems, and the log says which happened.
123
- `backlog/assets/` is gitignored; the ticket markdown is committed.
123
+
124
+ **The package ignores nothing in git.** It writes no `.gitignore` and adds no rule anywhere, so in
125
+ a site's repo `<backlogDir>/assets/` is committed like any other folder unless that repo ignores
126
+ it. This repo's own `.gitignore` carries `backlog/assets/` and `backlog/gallery.html`, so here the
127
+ ticket markdown is committed and the pictures and the gallery snapshot are not; a site that wants
128
+ the same adds those two lines (with its own `backlogDir`) to its `.gitignore`.
124
129
 
125
130
  Taken by a warm Playwright singleton against a freshly loaded page, motion frozen two ways, toolbar
126
131
  hidden, clipped to the ticket's rect with `fullPage: true` — **`clip` is viewport-relative without
127
132
  it**, so a pin below the fold would throw *"Clipped area is either empty or outside the resulting
128
- image"*. Measured cost: ~2.0–2.4s per shot, warm or cold, dominated by `waitForLoadState`, not by
129
- browser launch. You never wait on it: the ticket is written and the panel confirms in under 30ms,
130
- on the same tick, and the shot lands afterwards on its own event.
133
+ image"*. You never wait on a shot: the ticket is written and the panel confirms in under 30ms, on
134
+ the same tick, and the shot lands afterwards on its own event.
135
+
136
+ **What a shot costs was measured three times, and each figure times a different span**, so they
137
+ are not competing readings of one number:
138
+
139
+ | Measured | Where | Span timed | Figure |
140
+ |---|---|---|---|
141
+ | 2026-09-21 | the client site the board was first built in, an Apple-silicon laptop | the capture: navigate, freeze motion, settle every image, hold the scroll position, clip | ~1.34 s warm, ~1.42 s cold; launching chromium ~98 ms |
142
+ | 2026-09-21 | the same site, cold cache, real input | the toolbar's submit to the screenshot | 2.0–2.4 s, warm or cold, dominated by the `networkidle` wait in `page.goto`, not by browser launch |
143
+ | 2026-09-22 | this repo's testbed home page, a real mouse | the *Create ticket* click to the *screenshot landed* status line | 1934 ms (below) |
131
144
 
132
145
  **`playwright` is an optional peer and it has to be installed** — `npm i -D playwright && npx
133
146
  playwright install chromium`. Nothing else gates screenshots: the dynamic import inside
@@ -154,9 +167,18 @@ A dead-selector ticket is **labelled, never discarded** — the screenshot, sour
154
167
  `outer_html` still make it workable by hand. The defect this guards against is silence, not
155
168
  instability.
156
169
 
170
+ The board's label is `selectorFlag` in [`../src/pin/board.mjs`](../src/pin/board.mjs): `0` and
171
+ `2+` put a *needs a human* banner on the card, and for `0` its note says an agent cannot locate the
172
+ element — by selector, which is the one thing that died. **It is a label, not a gate.** It changes
173
+ no status, and `isAgentReady()` and `listTickets()` do not read `selector_matches`, so a flagged
174
+ ticket left in `Ready for agent` still reaches an agent. Only its status keeps an agent off it.
175
+
157
176
  ## The browser ↔ server channel
158
177
 
159
- Over the Vite HMR socket. No HTTP endpoint for writes from the toolbar, no port, no CORS.
178
+ Creating a ticket goes over the Vite HMR socket: no HTTP endpoint, no port, no CORS. A status
179
+ change is the one HTTP write, from the board and from the panel's own menus alike —
180
+ `POST <route>/api/ticket`, same-origin, on the dev server already running, with the address read
181
+ from the board's `data-api` (› *Writing a ticket*).
160
182
 
161
183
  Client, inside `init(canvas, app, server)` ([`../src/pin/toolbar.js`](../src/pin/toolbar.js)):
162
184
  `server.send("orbytes-pin:create", payload)` · `server.on("orbytes-pin:created" | "orbytes-pin:shot", …)`
@@ -190,6 +212,40 @@ with it is unchanged, because the page renders the SAME markup from the same fun
190
212
  `script.card-data` payloads and `.broken li code` off the page at `route`. A change to those
191
213
  selectors in `board.mjs` is a change to this contract.
192
214
 
215
+ ## The lab's channel — `orbytes-pin:command` and `orbytes-pin:state`
216
+
217
+ Added 2026-09-24, when the lab's navbar took over pinning inside `/lab`. The lab imports nothing
218
+ from [`../src/pin/toolbar.js`](../src/pin/toolbar.js); it drives the one pin app on the lab page's
219
+ dev toolbar through two window events, so there is one picker and one composer, never a second
220
+ copy.
221
+
222
+ | Direction | Event | Detail |
223
+ |---|---|---|
224
+ | lab → app | `orbytes-pin:command` | `{ action: "pick", within?: Element }` or `{ action: "stop" }` |
225
+ | app → lab | `orbytes-pin:state` | `{ active, mode, tickets }` |
226
+
227
+ - **`pick`** turns the app on in pick mode; if it is already on, it goes back to picking unless an
228
+ element is already selected. **`within`** confines the pick to that element's box, and within
229
+ it to what lies inside a frame: the lab passes its canvas, so Comment pins the previewed page and
230
+ never the lab's chrome. The scope clears when the app turns off, and the toolbar's own button
231
+ still opens the app unscoped.
232
+ - **`stop`** turns the app off.
233
+ - **`orbytes-pin:state`** is published after every mode change and every board read. `active` is
234
+ whether the app is on; `mode` is `idle`, `picking` or `selected`; `tickets` is the list the app
235
+ read off the board — the same `script.card-data` payloads described above, so the lab parses no
236
+ ticket either.
237
+ - **`window.__orbytesPin`** holds the same snapshot, for a listener that arrives after the last
238
+ event. The lab's Comment button is disabled until it exists.
239
+
240
+ **Anything the lab draws over a framed page carries `data-orbytes-pin-ui`**, the attribute the
241
+ picker skips in whichever document it finds it (`isChrome`). The picker takes the topmost element
242
+ under the pointer, so an unmarked overlay stands in front of the frame: a scoped pick finds nothing
243
+ there, and an unscoped one pins the overlay. **And it is drawn in the chrome,
244
+ outside the frame** — the lab's markers and hint live in the lab page's own document, over the
245
+ `<iframe>`, never in the framed one. The framed document is what the picker measures, builds a selector against and
246
+ copies into `outer_html`, and what `shot.mjs` re-loads fresh for the screenshot; an element the lab
247
+ added to it would be in the first and absent from the second.
248
+
193
249
  ## The status line — one line, not a log
194
250
 
195
251
  Decided 2026-09-22, replacing a running four-entry list that narrated every step. The log was
@@ -223,7 +279,7 @@ binding Astro 7 uses contains zero. So this package stamps its own `data-orbytes
223
279
  via a dev-only Vite `load` hook. **Expect it to break again** — Astro dropped these once before,
224
280
  silently, with no deprecation.
225
281
 
226
- A file matched by the `stamp` glob with no stampable element is a hard throw naming the file. That
282
+ A file under a `stamp` directory with no stampable element is a hard throw naming the file. That
227
283
  is the guard, not an inconvenience: a warned-past file silently produces tickets with no source.
228
284
 
229
285
  Selectors are built up to the **shortest form resolving to exactly one node**, verified against the
@@ -255,15 +311,25 @@ four times independently, and verified on real production builds — normal and
255
311
  the inner `<button>` in their shadow root.
256
312
  - **Markers belong in the toolbar canvas, never `document.body`.** The screenshotter hides only
257
313
  `astro-dev-toolbar`, so a page-parented marker is photographed into every later ticket.
314
+ - **An overlay the lab draws over a frame needs `data-orbytes-pin-ui`, and belongs in the chrome.**
315
+ The picker takes the topmost element under the pointer and skips only what carries the
316
+ attribute, so an unmarked overlay blocks or becomes the pick; and an overlay put inside the
317
+ framed document changes the DOM a selector is built against (› *The lab's channel*).
318
+ - **Inside the lab, Astro's dev toolbar is hidden in every frame** (2026-09-24), so the lab page's
319
+ own toolbar is the only one. A test that looks for the pin app inside a framed story will not
320
+ find it; drive the lab page's app instead.
258
321
 
259
322
  ## Chrome
260
323
 
261
324
  Neutral tool chrome, never the host site's styling — decided for this board and, separately, for
262
325
  the component lab. A standard board that reads the same in every project it is installed in.
263
326
 
264
- Every value is a custom property in one namespaced block (`--pin-*`, on `.pin-chrome`). No literal
265
- colours outside it, and **no bare element selectors** anywhere those inherit down into the
266
- previewed page.
327
+ In the in-page panel and its markers ([`../src/pin/toolbar.js`](../src/pin/toolbar.js)) every value
328
+ is a custom property in one namespaced block (`--pin-*`, on `.pin-chrome`). No literal colours
329
+ outside it, and **no bare element selectors** anywhere — those inherit down into the previewed
330
+ page. The kanban's stylesheet (`BOARD_CSS`, [`../src/pin/board.mjs`](../src/pin/board.mjs)) is
331
+ fenced differently: its tokens are unprefixed, on its own root inside `@scope (.pin-board)`, and
332
+ inside the lab `.pin-board--lab` maps them onto the lab's `--lab-*` tokens.
267
333
 
268
334
  **The `--pin-*` namespace survived the merge and is still the board's own** (2026-09-22). Repointing
269
335
  it at the lab's `--lab-*` tokens is the shared-chrome design — lab phase 2 — and was deliberately
package/docs/PIN.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # The pin board
2
2
 
3
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 `/lab/tasks`,
5
- in the lab, on the same dev server. No cloud, no API key, no MCP, no second process.
4
+ local disk for a Claude Code agent to pick up from the lab's navbar (**Comment**, on the page it is
5
+ previewing) or from Astro's dev toolbar on the site's own pages. Then work the tickets on a kanban
6
+ at `/lab/tasks`, in the lab, on the same dev server. No cloud, no API key, no MCP, no second
7
+ process. It needs the site to be inside a git repository (› *Wiring*).
6
8
 
7
9
  It is for a **solo pass** — your own review of a site in dev, before anyone else sees it. It does
8
10
  not sync with whatever tool you use for review rounds with other people, and is not meant to
@@ -33,7 +35,7 @@ pin ticket that erases `dispatch`, `source`, `selector`, `rect`, `shot` and `out
33
35
  why the pin fields live in a fenced block in the body, and why `/pin` was read-only until
34
36
  2026-09-22:
35
37
  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*).
38
+ is no second writer, so the board writes them correctly by construction (› *The write path*).
37
39
 
38
40
  If backlog.md is still installed globally, this does not uninstall it.
39
41
  A `backlog/config.yml` left behind in a repo that used backlog.md is a record and nothing reads it
@@ -84,8 +86,10 @@ neither tab: it is reachable on the board only.
84
86
 
85
87
  **In the lab since 2026-09-24** (decided that day: one URL, everything under `/lab`). The board is
86
88
  two views in the lab's own chrome — **Kanban Board** at `/lab/tasks` and **All Tasks**, a sortable
87
- table of every ticket, newest first, at `/lab/tasks/all` — with the statuses and their counts in
88
- the lab's panel; `/lab/tasks?status=Ready%20for%20review` opens the board filtered to one column.
89
+ table of every ticket, newest first, at `/lab/tasks/all` — with no second-level panel beside them
90
+ (decided 2026-09-24: the board's own status chips already filter, so a status list beside it only
91
+ took width from the columns); `/lab/tasks?status=Ready%20for%20review` opens the board filtered to
92
+ one column.
89
93
  `/pin`, the board's address until then, redirects there. Both views are Astro pages the pin half
90
94
  injects in `astro dev` only, rendered from the same markup, stylesheet and script as the standalone
91
95
  file (`boardParts` in [`src/pin/board.mjs`](../src/pin/board.mjs)); the stylesheet is scoped with
@@ -101,8 +105,9 @@ scrolls sideways, rather than wrapping into a stack beside two sidebars.
101
105
  with a filled count **only when it actually holds something**. An empty queue stays quiet; one
102
106
  that shouts at nothing teaches you to stop looking at it. Its count also leads the top bar.
103
107
  - **Cancelled is drawn to be ignored**: a narrow dashed rail rather than a sixth of the wall, its
104
- cards desaturated until hovered. Still a real drop target, and still the only place a cancelled
105
- ticket can be seen at all an archive nobody can open is a delete.
108
+ cards desaturated until hovered. Still a real drop target. This rail, the All Tasks table (which
109
+ lists every ticket) and the gallery file are the only places a cancelled ticket can be seen at
110
+ all — an archive nobody can open is a delete.
106
111
  - Every column carries a one-line gloss under its name (*agents start here*, *waiting on you*),
107
112
  because six nouns with no explanation is a vocabulary you have to be told once and remember.
108
113
  - **A card** carries its screenshot, id, title, dispatch mode, priority and source file, plus the
@@ -124,6 +129,29 @@ listening behind it.
124
129
  **The standalone board's corner links** are an option (`links`), not the hardcoded `/` and `/lab`
125
130
  they were until 2026-09-22. Inside the lab there are none — the lab's own menu is right there.
126
131
 
132
+ ## Pinning from inside the lab
133
+
134
+ Every preview in the lab is an `<iframe>`, and Astro's dev toolbar is hidden inside the lab's
135
+ frames, so a lab page has one toolbar — its own — and one pin app. The lab drives that app rather
136
+ than carrying a second picker:
137
+
138
+ - **Comment**, in the navbar of a component page and of a page view, starts a pick confined to the
139
+ canvas: only the framed page can be picked, never the lab's chrome around it. Escape stops the
140
+ pick and turns Comment off; a comment already typed into the composer is kept, never discarded.
141
+ The composer that follows is the pin app's own window on the dev toolbar. The button is disabled
142
+ until the pin app has started, which it does when the dev toolbar is idle.
143
+ - **The pins** already left on the framed page are drawn over the frame at their elements: blue
144
+ while open, grey once resolved, numbered in the order they were made, cancelled ones never. A
145
+ ticket whose selector finds nothing on that page, or more than one thing, draws no pin rather
146
+ than a guessed one. A pin, and the pin row in the component's Properties panel, open All Tasks.
147
+ - **⋯ › Pins** holds two switches, Show pins (on) and Show resolved (off), remembered per browser
148
+ in `localStorage` (`lab-pins-show`, `lab-pins-resolved`).
149
+
150
+ The list of pins is the pin app's own, re-read after every write, so a new pin appears as soon as
151
+ its ticket lands. How the lab and the app talk is in [the contract](./PIN-CONTRACT.md) › *The lab's
152
+ channel*. On the site's own pages, outside `/lab`, the dev toolbar's pin app works exactly as it
153
+ always did.
154
+
127
155
  ## The write path
128
156
 
129
157
  One endpoint, `POST <route>/api/ticket`, registered in `astro:server:setup` — which does not exist
@@ -132,14 +160,17 @@ in a build, so it cannot reach `dist/`.
132
160
  **The endpoint, the screenshots and the `/pin` redirect are middleware**, installed ahead of
133
161
  Astro's own request handler, so they shadow a host page at the same path — in dev only. Through
134
162
  `orbytesLab()` the route is `<subpath>/tasks`, which no site page is likely to be; the one exposed
135
- address is `/pin`, which redirects. On its own, without the lab, the pin half still serves its
136
- standalone board at `route`, default `/pin`.
163
+ address is `/pin`, which redirects. Setting `route` does not free it: inside the lab `/pin`
164
+ redirects to `route` whatever that is, and a `route` of `/pin` puts the Tasks views there instead,
165
+ so no `route` leaves a site's own `/pin` page alone in dev — `pin: false` does. On its own,
166
+ without the lab, the pin half still serves its standalone board at `route`, default `/pin`, and
167
+ there `pin: { route: "/__pin" }` moves it off a site page.
137
168
 
138
169
  ```json
139
170
  { "id": "PIN-004", "status": "Ready for review", "expect": { "status": "In Progress" } }
140
171
  ```
141
172
 
142
- `updateTicket` (`src/board.mjs`) rewrites **only** the frontmatter lines it is changing, plus
173
+ `updateTicket` (`src/pin/board.mjs`) rewrites **only** the frontmatter lines it is changing, plus
143
174
  `updated_date`. It never serialises: the file is split by index, and the slice from the closing
144
175
  `---` onward — the comment, the image and the fenced ```yaml pin block — is the same substring on
145
176
  the way out as on the way in. That identity is asserted on every write, and the write is a temp
@@ -181,7 +212,7 @@ Nothing is wired separately: the board comes with the lab, through the one integ
181
212
  already configures.
182
213
 
183
214
  ```js
184
- import orbytesLab from "./packages/astro-lab/index.mjs";
215
+ import orbytesLab from "@orbytes/astrolab";
185
216
 
186
217
  const includeLab =
187
218
  process.argv.includes("dev") || process.env.PUBLIC_DEPLOY_ENV === "staging";
@@ -197,6 +228,15 @@ export default defineConfig({
197
228
  gates itself on `command === "dev"`, so including the lab in a staging build registers it and it
198
229
  does nothing there.
199
230
 
231
+ **The site must be inside a git repository.** Every path in a ticket is repo-relative, and the root
232
+ is the Astro project's folder or the nearest one above it that holds `.git`. With none, the board
233
+ stands down for that run instead of taking the dev server with it (measured 2026-09-22: a freshly
234
+ scaffolded Astro project, which has no git yet, could not run `astro dev` at all before the board
235
+ learned to stand down). The only sign is a warning in the dev server's log. `<route>`,
236
+ `<route>/all` and `/pin` answer 404, the lab shows no Tasks group and no Comment button, and there
237
+ is no pin app on the dev toolbar and no source stamp. `git init` and a restart turn it on;
238
+ `pin: false` silences the warning.
239
+
200
240
  `playwright` is an **optional** peer and is loaded by dynamic import inside the screenshotter, not
201
241
  at module scope — a site without it loads its config, runs the lab and writes tickets without
202
242
  pictures. Install it (`npm i -D playwright && npx playwright install chromium`) for the screenshots,
@@ -208,7 +248,7 @@ Pass these as `orbytesLab({ pin: { … } })`.
208
248
 
209
249
  | Option | Default | What it does |
210
250
  |---|---|---|
211
- | `stamp` | `["src/lab/sections", "src/components"]` | **Site-relative** directories whose `.astro` files get `data-orbytes-src`. |
251
+ | `stamp` | `["src/lab/sections", "src/components"]` | **Site-relative** directories whose `.astro` files get `data-orbytes-src`. Replaces the default whole, and does not follow the lab's `directory`. `src/lab/components` and `src/lab/explorations` are not in it — see *The source attribute*. |
212
252
  | `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. |
213
253
  | `backlogDir` | `"backlog"` | Repo-relative board directory: tickets in `tasks/`, PNGs in `assets/`. |
214
254
  | `archiveDir` | `~/.orbytes/feedback-archive` | Canonical screenshot home; the repo copy is a hardlink into it. |
@@ -315,26 +355,33 @@ Two rules now keep a selector off that ground, both in
315
355
  - **A class naming a runtime condition never enters a selector** (`isTransientClass`,
316
356
  `extendsSiblingClass`) — `swiper-slide-active` is on slide 1 now and slide 2 after a scroll.
317
357
 
318
- Neither is complete, and the field is what makes the remainder visible. `src/shot.mjs` already
358
+ Neither is complete, and the field is what makes the remainder visible. `src/pin/shot.mjs` already
319
359
  loads the page **fresh in a real browser** to take the screenshot; before the shutter it asks that
320
360
  page how many nodes the ticket's selector finds, and writes the answer back into the pin block:
321
361
 
322
362
  | Value | Means |
323
363
  |---|---|
324
364
  | `1` | healthy — the selector names exactly this element on a fresh load |
325
- | `0` | dead — work the ticket from `source`, the screenshot and `outer_html` |
365
+ | `0` | dead — flagged *needs a human* on the board; a person works it from `source`, the screenshot and `outer_html` |
326
366
  | `2`+ | ambiguous — the selector names several elements |
327
367
  | `pending` | never tested — shots are off, or the screenshot failed |
328
368
 
329
369
  Anything but `1` is said in the dev log and in the toolbar panel. A ticket whose selector died is
330
370
  **still a good ticket** — it is labelled, never discarded. The defect was the silence.
331
371
 
372
+ On the board, `0` and `2`+ put a banner on the card, count towards *needing a human* in the
373
+ summary, and match the *Needs a human* filter. The banner's note for `0` says an agent cannot
374
+ locate the element: its selector cannot, and the rest of the ticket is intact for a person to work
375
+ from. **The flag is a label, not a gate.** It changes no status, and neither `isAgentReady()` nor
376
+ `listTickets()` reads `selector_matches` — a flagged ticket left in `Ready for agent` is still
377
+ handed to an agent. Only its status keeps an agent off it.
378
+
332
379
  ## Screenshots
333
380
 
334
381
  One chromium for the whole dev session, launched lazily on the first pin and kept warm, closed
335
- when the dev server closes. Measured on an Apple-silicon laptop, 2026-09-21: the launch itself is
336
- ~98 ms, and a full capture — navigate, freeze motion, settle every image, hold the scroll position,
337
- clip — is ~1.34 s warm against ~1.42 s cold.
382
+ when the dev server closes. What a shot costs has been measured three times, each timing a
383
+ different span; the figures, their dates and what each one timed are in
384
+ [the contract](./PIN-CONTRACT.md) *Screenshots*.
338
385
 
339
386
  You never wait for any of it. The ticket is written and `orbytes-pin:created` replied on the
340
387
  same synchronous tick; the screenshot follows and sends `orbytes-pin:shot` when it lands. A ticket
@@ -358,6 +405,22 @@ file under `stamp`, and the picker walks up from the clicked node to the nearest
358
405
  it. No ancestor found → `source` is `unresolved`, and the CSS selector plus `outer_html` still
359
406
  make the ticket actionable.
360
407
 
408
+ **The default does not cover every tier.** It stamps `src/lab/sections` and `src/components`, so a
409
+ component kept in the lab's own `src/lab/components/` (or `src/lab/explorations/`) and pinned on its
410
+ own variant has no stamped ancestor, and its tickets say `unresolved`. Name the folder to include it
411
+ — `stamp` replaces the default, so restate what you keep:
412
+
413
+ ```js
414
+ orbytesLab({
415
+ pin: {
416
+ stamp: ["src/lab/sections", "src/lab/components", "src/components"],
417
+ },
418
+ })
419
+ ```
420
+
421
+ Widening `stamp` widens the hard failure below with it, so check the folder for templates with no
422
+ element to stamp first.
423
+
361
424
  The attribute is inserted immediately after the tag name (`<section` → `<section
362
425
  data-orbytes-src="…"`), so no attribute value, expression, spread or self-closing slash can be
363
426
  mangled and no line number moves.
@@ -383,14 +446,17 @@ orbytesLab({
383
446
 
384
447
  ## The browser ↔ server channel
385
448
 
386
- Astro's dev toolbar, over the existing Vite HMR socket. No HTTP endpoint, no port, no CORS.
449
+ Creating a ticket goes over Astro's dev toolbar, on the existing Vite HMR socket no HTTP endpoint,
450
+ no port, no CORS. Changing a ticket's status is the one HTTP write, from the board and from the
451
+ panel's own menus alike: `POST <route>/api/ticket`, same-origin, on the dev server already
452
+ running.
387
453
 
388
454
  | Direction | Event | Payload |
389
455
  |---|---|---|
390
456
  | browser → server | `orbytes-pin:create` | `{ comment, dispatch, source, selector, url, viewport, scroll, rect, outerHTML }` |
391
457
  | server → browser | `orbytes-pin:created` | `{ id, file, shot, error }` — all four keys, always |
392
458
  | server → browser | `orbytes-pin:shot` | `{ id, shot, selectorMatches, error }` |
393
- | board → server | `POST <route>/api/ticket` | `{ id, status?, priority?, expect? }` → `{ ok, id, status, priority, file, changed, card }` |
459
+ | board or panel → server | `POST <route>/api/ticket` | `{ id, status?, priority?, expect? }` → `{ ok, id, status, priority, file, changed, card }` |
394
460
 
395
461
  On failure the reply is `{ id: null, file: null, shot: null, error: "why, in one plain sentence" }`.
396
462
 
@@ -419,7 +485,8 @@ window, so clicking it again focuses that tab instead of opening another.
419
485
 
420
486
  1. `astro:config:setup` returns early unless the Astro command is `dev` — no plugin, no toolbar
421
487
  app, no stamp.
422
- 2. Both Vite plugins carry `apply: "serve"`, so they cannot load in a build even if they were
488
+ 2. All three Vite plugins the source stamp, the toolbar's config module and the one that closes
489
+ the browser — carry `apply: "serve"`, so they cannot load in a build even if they were
423
490
  registered.
424
491
  3. `astro:server:setup`, where every write lives — the ticket writer, the screenshots and the
425
492
  `<route>/api/ticket` endpoint — does not run in a build at all. The lab's two Tasks views are
@@ -432,8 +499,11 @@ The first one matters more since the merge than it did before: the lab is delibe
432
499
  in a build for the first time, and `command === "dev"` is what makes that a no-op.
433
500
 
434
501
  Verified by running a real `npm run build` and grepping `dist/` for `orbytes-pin`, `data-orbytes-src`
435
- and `/pin/api`: no hit.
502
+ and `/pin/api`: no hit. Re-checked 2026-09-24 on a staging build of the testbed, the case where the
503
+ pin half is registered: no hit for those three or for `/tasks/api`, and no `lab/tasks/` page.
436
504
 
437
505
  No path this package emits into served HTML or into a ticket is absolute. The repo root is found
438
- by walking up from the Astro config root to the first directory containing `.git`, and it throws
439
- rather than guess.
506
+ by walking up from the Astro config root to the first directory containing `.git`, and
507
+ `findRepoRoot` throws rather than guess. The integration catches that throw, warns, and stands down
508
+ for the run (› *Wiring*) — a library that guesses writes files where nobody asked, and an
509
+ integration that rethrows takes the dev server down.
package/index.d.ts CHANGED
@@ -11,11 +11,6 @@ export interface LabTierOption {
11
11
  * thumbnails and in the viewport configurator. Defaults to true for a tier named "sections".
12
12
  */
13
13
  responsive?: boolean;
14
- /**
15
- * This tier's stories may be marked for deletion in the sidebar and removed by
16
- * `orbytes-lab-cull`. Defaults to true for a tier named "explorations".
17
- */
18
- cullable?: boolean;
19
14
  }
20
15
 
21
16
  export interface OrbytesLabOptions {
@@ -52,8 +47,7 @@ export interface OrbytesLabOptions {
52
47
  feedbucketKey?: string;
53
48
  /**
54
49
  * The tiers, in reading order. Default `["sections", "components", "explorations"]`; a tier
55
- * named "sections" takes the responsive role and one named "explorations" the cull role unless
56
- * the entry says otherwise.
50
+ * named "sections" takes the responsive role unless the entry says otherwise.
57
51
  */
58
52
  tiers?: (string | LabTierOption)[];
59
53
  /**
package/index.mjs CHANGED
@@ -11,9 +11,10 @@
11
11
  //
12
12
  // ── the pin board (./src/pin/, moved in 2026-09-22) ─────────────────────────────────────────────
13
13
  // Decided 2026-09-22: the lab and the pin board are ONE app, bundled and working as one — not two
14
- // packages a site installs separately. So `/lab` and `/pin` come from one call, one install, one
15
- // dev server. `pin: false` omits it entirely a site may want the lab alone — and `pin: {…}` passes
16
- // options straight through (ops/standalone-plan.md The pin board merges in; docs/PIN.md).
14
+ // packages a site installs separately. So the lab (`<subpath>`) and its Tasks board
15
+ // (`<subpath>/tasks`) come from one call, one install, one dev server. `pin: false` omits it
16
+ // entirely a site may want the lab alone — and `pin: {…}` passes options straight through
17
+ // (ops/standalone-plan.md › The pin board merges in; docs/PIN.md).
17
18
  //
18
19
  // The pin half is DEV ONLY and gates itself four times over; it is registered in staging builds
19
20
  // along with the lab and does nothing there, because its first assertion is `command === "dev"`.
@@ -33,18 +34,13 @@
33
34
  // (the task views, <subpath>/tasks and /tasks/all, are the pin half's, dev only)
34
35
  // and define `virtual:orbytes-lab/*` so those pages can read the resolved options, the
35
36
  // consumer's head component and the consumer's CSS without importing anything by path.
36
- // 3. serve the two mark APIs on the Vite dev server only (astro:server:setup never runs in a
37
- // build, so a deployed lab has no write path and the switches hide themselves):
38
- // GET /__lab/cull { marked: string[], updated: string | null }
39
- // PUT /__lab/cull body { marked: string[] } — every entry must be an existing
40
- // stories file in the CULLABLE tier AND not live on any page;
41
- // otherwise 400 with the offenders. Writes <directory>/cull.json.
37
+ // 3. serve the responsive marks API on the Vite dev server only (astro:server:setup never runs
38
+ // in a build, so a deployed lab has no write path and the switches hide themselves):
42
39
  // GET /__lab/responsive { done: string[], approved: string[], updated: string | null }
43
40
  // PUT /__lab/responsive body { done: string[], approved?: string[] } — every entry must
44
41
  // be an existing stories file in the RESPONSIVE tier; otherwise 400
45
42
  // with the offenders. Writes <directory>/responsive.json.
46
- // Marks only. NOTHING HERE DELETES A FILE — that is `orbytes-lab-cull`, a separate command,
47
- // run by hand and with a dry run first.
43
+ // Marks only. NOTHING HERE DELETES A FILE.
48
44
  //
49
45
  // Gotcha (2026-09-06): Astro re-imports astro.config.mjs on a config change, but this module and
50
46
  // its ./src/shell/*.mjs imports stay in Node's ESM cache for the life of the process — so an edit
@@ -59,15 +55,7 @@ import { fileURLToPath } from "node:url";
59
55
  import { createAstrobookIntegration } from "./dist/core/index.js";
60
56
  import { resolveLabOptions } from "./defaults.mjs";
61
57
  import orbytesPin from "./src/pin/index.mjs";
62
- import { defaultImports, liveComponentFiles, storyComponentFile } from "./src/shell/live-files.mjs";
63
- import {
64
- cullOffence,
65
- readCull,
66
- readResponsive,
67
- responsiveOffence,
68
- writeCull,
69
- writeResponsive,
70
- } from "./src/shell/marks.mjs";
58
+ import { readResponsive, responsiveOffence, writeResponsive } from "./src/shell/marks.mjs";
71
59
 
72
60
  const file = (relative) => fileURLToPath(new URL(relative, import.meta.url));
73
61
 
@@ -84,22 +72,6 @@ const RESOLVED = {
84
72
  [VIRTUAL.css]: "__virtual_orbytes_lab_user_css__.mjs",
85
73
  };
86
74
 
87
- /**
88
- * Why an entry may not be marked for deletion, or null when it may. `importedByLive` maps a
89
- * component file to the live section that imports it, so an exploration a live section uses is
90
- * refused here with the same reason the cull script would give later.
91
- */
92
- const cullEntryOffence = (rootDir, config, entry, liveByFile, importedByLive) => {
93
- const bad = cullOffence(rootDir, config, entry);
94
- if (bad) return bad;
95
- const component = storyComponentFile(rootDir, entry);
96
- const mount = component ? liveByFile.get(component) : undefined;
97
- if (mount) return `live on ${mount.page} (slot ${mount.slot}: ${component})`;
98
- const user = component ? importedByLive.get(component) : undefined;
99
- if (user) return `used by live section ${user}`;
100
- return null;
101
- };
102
-
103
75
  const readBody = (req) =>
104
76
  new Promise((resolve, reject) => {
105
77
  let body = "";
@@ -184,10 +156,10 @@ function labShell(options, shared = { tasks: null }) {
184
156
  // consumer should need one line in its config and nothing more, so the exemption is
185
157
  // declared here rather than there.
186
158
  ssr: { noExternal: ["@orbytes/astrolab"] },
187
- // Marking writes JSON files under src/; keep Vite from treating those as source
188
- // changes and reloading the lab mid-triage.
159
+ // Marking writes a JSON file under src/; keep Vite from treating it as a source
160
+ // change and reloading the lab mid-triage.
189
161
  server: {
190
- watch: { ignored: [`**/${config.cullFile}`, `**/${config.responsiveFile}`] },
162
+ watch: { ignored: [`**/${config.responsiveFile}`] },
191
163
  },
192
164
  },
193
165
  });
@@ -196,47 +168,8 @@ function labShell(options, shared = { tasks: null }) {
196
168
  );
197
169
  },
198
170
  "astro:server:setup": ({ server, logger }) => {
199
- server.middlewares.use(async (req, res, next) => {
200
- const url = new URL(req.url ?? "/", "http://x");
201
- if (url.pathname !== "/__lab/cull") return next();
202
- try {
203
- if (req.method === "GET") return send(res, 200, readCull(rootDir, config));
204
- if (req.method === "PUT") {
205
- const body = await readBody(req);
206
- if (!Array.isArray(body.marked))
207
- return send(res, 400, { error: "body must be { marked: string[] }" });
208
- const live = liveComponentFiles(rootDir);
209
- // First mount wins, so the refusal names the page a reader would look at first.
210
- const liveByFile = new Map();
211
- for (const l of live) if (!liveByFile.has(l.file)) liveByFile.set(l.file, l);
212
- const importedByLive = new Map();
213
- for (const l of live) {
214
- for (const dep of defaultImports(rootDir, l.file)) {
215
- if (!importedByLive.has(dep.file)) importedByLive.set(dep.file, l.file);
216
- }
217
- }
218
- const offenders = body.marked
219
- .map((entry) => ({
220
- path: entry,
221
- reason: cullEntryOffence(rootDir, config, entry, liveByFile, importedByLive),
222
- }))
223
- .filter((o) => o.reason !== null);
224
- if (offenders.length > 0) {
225
- return send(res, 400, { error: "some entries cannot be marked", offenders });
226
- }
227
- const data = writeCull(rootDir, config, body.marked);
228
- logger.info(`cull marks saved: ${data.marked.length} → ${config.cullFile}`);
229
- return send(res, 200, { ok: true, file: config.cullFile, ...data });
230
- }
231
- return send(res, 405, { error: "GET or PUT" });
232
- } catch (e) {
233
- return send(res, 500, { error: String(e?.message ?? e) });
234
- }
235
- });
236
-
237
- // The responsive marks — same shape of API as the cull one above, and deliberately its
238
- // sibling rather than a second mechanism: a section version is marked responsive (and,
239
- // separately, approved for that work) by hand, because neither fact is in the code.
171
+ // The responsive marks: a section version is marked responsive (and, separately, approved
172
+ // for that work) by hand, because neither fact is in the code.
240
173
  server.middlewares.use(async (req, res, next) => {
241
174
  const url = new URL(req.url ?? "/", "http://x");
242
175
  if (url.pathname !== "/__lab/responsive") return next();
@@ -269,7 +202,7 @@ function labShell(options, shared = { tasks: null }) {
269
202
  }
270
203
  });
271
204
 
272
- logger.info("mark APIs at /__lab/cull and /__lab/responsive (dev only)");
205
+ logger.info("mark API at /__lab/responsive (dev only)");
273
206
  },
274
207
  },
275
208
  };