@orbytes/astrolab 0.3.0 → 0.4.0-next.1

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 (69) hide show
  1. package/README.md +109 -64
  2. package/defaults.mjs +65 -0
  3. package/dist/core/virtual-module/virtual-routes.js +12 -1
  4. package/docs/PIN-CONTRACT.md +8 -0
  5. package/docs/PIN.md +29 -19
  6. package/index.d.ts +40 -16
  7. package/index.mjs +32 -10
  8. package/package.json +6 -2
  9. package/src/Home.astro +167 -264
  10. package/src/LabHead.astro +37 -1047
  11. package/src/chrome/ComponentCard.astro +69 -0
  12. package/src/chrome/Icon.astro +21 -0
  13. package/src/chrome/LICENSE-icons +43 -0
  14. package/src/chrome/Nav.astro +105 -0
  15. package/src/chrome/Panel.astro +104 -0
  16. package/src/chrome/Shell.astro +106 -0
  17. package/src/chrome/Sprite.astro +23 -0
  18. package/src/chrome/StoryView.astro +251 -0
  19. package/src/chrome/Tree.astro +83 -0
  20. package/src/chrome/ViewportControls.astro +98 -0
  21. package/src/chrome/ViewportStage.astro +32 -0
  22. package/src/chrome/fonts/OFL.txt +93 -0
  23. package/src/chrome/fonts/inter-latin-wght-normal.woff2 +0 -0
  24. package/src/chrome/icons.ts +59 -0
  25. package/src/chrome/marks-client.ts +102 -0
  26. package/src/chrome/model.ts +142 -0
  27. package/src/chrome/pins-data.ts +30 -0
  28. package/src/chrome/shell-client.ts +372 -0
  29. package/src/chrome/site-data.ts +230 -0
  30. package/src/chrome/trees.ts +152 -0
  31. package/src/chrome/viewport-client.ts +579 -0
  32. package/src/chrome/views/Assets.astro +110 -0
  33. package/src/chrome/views/Pages.astro +178 -0
  34. package/src/chrome/views/Placeholder.astro +37 -0
  35. package/src/chrome/views/Tasks.astro +107 -0
  36. package/src/core/LICENSE-astrobook +16 -0
  37. package/src/core/lib/components/home.astro +4 -2
  38. package/src/core/lib/pages/story.astro +12 -10
  39. package/src/core/virtual-module/virtual-routes.ts +20 -4
  40. package/src/pin/board.mjs +389 -175
  41. package/src/pin/index.mjs +33 -2
  42. package/src/pin/toolbar.js +1 -1
  43. package/src/shell/Browse.astro +106 -353
  44. package/src/shell/Viewport.astro +22 -1315
  45. package/src/shell/lab-index.ts +23 -14
  46. package/src/ui/components/app.astro +5 -7
  47. package/src/ui/components/theme-script.astro +13 -2
  48. package/src/ui/lab.css +2152 -370
  49. package/virtual.d.ts +13 -0
  50. package/src/shell/CardGrid.astro +0 -297
  51. package/src/ui/components/build-path.ts +0 -13
  52. package/src/ui/components/build-tree.ts +0 -108
  53. package/src/ui/components/collapse-duration.ts +0 -28
  54. package/src/ui/components/compress-terms.ts +0 -10
  55. package/src/ui/components/dashboard-layout.astro +0 -39
  56. package/src/ui/components/home.astro +0 -65
  57. package/src/ui/components/layout.astro +0 -110
  58. package/src/ui/components/sidebar-button-fullscreen.astro +0 -38
  59. package/src/ui/components/sidebar-button-search.astro +0 -23
  60. package/src/ui/components/sidebar-button-theme.astro +0 -9
  61. package/src/ui/components/sidebar-button.astro +0 -24
  62. package/src/ui/components/sidebar-resize-handle.astro +0 -74
  63. package/src/ui/components/sidebar-search-panel.astro +0 -41
  64. package/src/ui/components/sidebar-search-script.ts +0 -103
  65. package/src/ui/components/sidebar-title.astro +0 -17
  66. package/src/ui/components/sidebar-tree-node.astro +0 -143
  67. package/src/ui/components/sidebar-tree.astro +0 -84
  68. package/src/ui/components/sidebar.astro +0 -29
  69. package/src/ui/components/theme-toggle.astro +0 -63
package/README.md CHANGED
@@ -1,20 +1,21 @@
1
1
  # @orbytes/astrolab
2
2
 
3
- Two dev surfaces for an Astro site, in one package and one integration call.
4
-
5
- **The component lab** at `/lab` — an [Astrobook](https://github.com/ocavue/astrobook) shell. Every
6
- section, component and experiment in a build, rendered live, with the one thing a component
7
- playground normally cannot tell you — **which of these is actually on the site right now**, derived
8
- from the pages rather than declared.
9
-
10
- **The pin board** at `/pin` — click a rendered element on `astro dev`, leave a comment, and a
11
- markdown ticket plus a screenshot land on local disk for an agent to pick up
12
- ([docs/PIN.md](./docs/PIN.md), contract at [docs/PIN-CONTRACT.md](./docs/PIN-CONTRACT.md)). It moved
13
- in from its own package on 2026-09-22, so the two install, configure and version as one thing. Dev
14
- only, switchable off with `pin: false`.
15
-
16
- It ships mostly **source**. The `.astro` files, and everything under `src/shell/`, `src/ui/` and
17
- `src/types/`, are compiled by the consumer's own Vite, the way Starlight ships its components —
3
+ A local dev tool for an Astro site, in one package and one integration call. Everything runs on
4
+ the site's own dev server at `/lab`; nothing leaves the machine.
5
+
6
+ **The lab** — every page, section and component in the build, rendered live on a canvas you size,
7
+ with the one thing a component playground normally cannot tell you: **which of these is actually on
8
+ the site right now**, derived from the pages rather than declared. Its engine began as
9
+ [Astrobook](https://github.com/ocavue/astrobook), vendored; its chrome is this package's own.
10
+
11
+ **Tasks — the pin board** at `/lab/tasks` — click a rendered element on `astro dev`, leave a
12
+ comment, and a markdown ticket plus a screenshot land on local disk for an agent to pick up
13
+ ([docs/PIN.md](./docs/PIN.md), contract at [docs/PIN-CONTRACT.md](./docs/PIN-CONTRACT.md)). Kanban and
14
+ table views, in the lab's own sidebar. Dev only, switchable off with `pin: false`; `/pin`, its old
15
+ address, redirects there.
16
+
17
+ It ships mostly **source**. The `.astro` files, and everything under `src/chrome/`, `src/shell/`,
18
+ `src/ui/` and `src/types/`, are compiled by the consumer's own Vite, the way Starlight ships its components —
18
19
  which is why the lab picks up the consumer's tokens, fonts and breakpoints in a story preview: it
19
20
  renders inside the consumer's pipeline. The one exception is `src/core/`, transpiled to `dist/` by
20
21
  `scripts/build-core.mjs` at `prepack` and `prepare`, because `astro.config.mjs` is loaded by Node's
@@ -76,8 +77,11 @@ export const Subtle = { args: { label: "See pricing", variant: "subtle" } };
76
77
  export const Strong = { args: { label: "Book a call", variant: "strong" } };
77
78
  ```
78
79
 
79
- Then `npm run dev` and open **`http://localhost:4321/lab`** — the home page lists every tier and
80
- renders every story live. `subpath` moves it; `directory` moves the stories.
80
+ Then `npm run dev` and open **`http://localhost:4321/lab`**. `subpath` moves it; `directory` moves
81
+ the stories.
82
+
83
+ **Test versions** are published under the `next` tag while the redesign settles:
84
+ `npm i -D @orbytes/astrolab@next`. `latest` stays on the last stable release until 1.0.
81
85
 
82
86
  **Peers.** Astro **7 or newer**, narrowed from `>=5.2.0` on 2026-09-22 when the pin board moved in:
83
87
  the board's dev-toolbar app imports `astro/client/dev-toolbar/apps/utils/highlight.js` and
@@ -167,10 +171,15 @@ than silent.
167
171
 
168
172
  ### What is vendored
169
173
 
170
- **Astrobook is not a dependency**, and neither is anything it used to pull in. All three of its
171
- packages are vendored at version `0.13.3` — the core into [`src/core/`](./src/core/), the UI into
172
- [`src/ui/`](./src/ui/), the types into [`src/types/`](./src/types/) — and `astro-theme-toggle` with
173
- them. Nothing outside Astro is installed, and there is nothing to keep in step but this folder.
174
+ **Astrobook is not a dependency**, and neither is anything it used to pull in. Its core is vendored
175
+ at version `0.13.3` into [`src/core/`](./src/core/), its types into [`src/types/`](./src/types/), and
176
+ what the preview still needs of its UI into [`src/ui/`](./src/ui/) — the rest of that UI was retired
177
+ by the redesign (2026-09-24), whose chrome is this package's own, in [`src/chrome/`](./src/chrome/).
178
+ Nothing outside Astro is installed, and there is nothing to keep in step but this folder.
179
+
180
+ The chrome bundles two third-party assets, each with its licence beside it: **Inter** (SIL OFL,
181
+ [`src/chrome/fonts/`](./src/chrome/fonts/)) and **Lucide** icons (ISC,
182
+ [`src/chrome/LICENSE-icons`](./src/chrome/LICENSE-icons)).
174
183
 
175
184
  Attribution, the upstream commit, and every deliberate divergence from it are recorded in
176
185
  [`src/core/LICENSE-astrobook`](./src/core/LICENSE-astrobook) (MIT, Copyright (c) 2024 ocavue; the
@@ -186,16 +195,16 @@ This package is MIT too; see [LICENSE](./LICENSE).
186
195
  and only the rendered story takes the site's styling. A site that wants a different-looking lab
187
196
  edits this package's source: there is no theme API and no token override, on purpose.
188
197
 
189
- So the chrome — layout, sidebar, search, the home and folder pages, the viewport configurator — is
190
- drawn by one stylesheet that ships with this package, [`src/ui/lab.css`](./src/ui/lab.css), in
191
- `--lab-*` tokens that cannot collide with a site's own, on a system font stack, with light and dark
192
- themes of its own. It never receives the `css` list or the `head` component.
198
+ So the chrome — both sidebar levels, the navbar, every view — is drawn by one stylesheet that ships
199
+ with this package, [`src/ui/lab.css`](./src/ui/lab.css), in `--lab-*` tokens that cannot collide with
200
+ a site's own, in Inter, with light and dark themes of its own. It never receives the `css` list or
201
+ the `head` component.
193
202
 
194
- The **preview** — the story at `/lab/stories/<id>`, and the story rendered inside
195
- `/lab/dashboard/<id>` — still gets both, because seeing a component as the real site renders it is
196
- the whole point. Isolation runs both ways: the chrome's stylesheet is not loaded on a bare story
197
- page, and a component styled by lab CSS the real site has never heard of would be a lab that lies.
198
- The mechanism, and what it costs, is documented at the top of `lab.css`.
203
+ The **preview** — the bare story at `/lab/stories/<id>`, and the site's own pages — gets both,
204
+ because seeing a component as the real site renders it is the whole point. Since the redesign the
205
+ chrome only ever shows a preview **inside an `<iframe>`**, so the two never share a document: no
206
+ chrome page carries a byte of the site's CSS, and the chrome's stylesheet never reaches a story.
207
+ The preview also keeps its **own** light/dark, separate from the chrome's.
199
208
 
200
209
  ## Options
201
210
 
@@ -205,13 +214,14 @@ Every option is optional.
205
214
  | --- | --- | --- |
206
215
  | `directory` | `"src/lab"` | Where the stories live, relative to the project root. |
207
216
  | `subpath` | `"/lab"` | Where the lab is served. `""` serves it at the site root. |
208
- | `css` | `[]` | The site's token and base stylesheets. Astrobook injects them into its own pages; the lab's own pages (folder pages, viewport) pull the same list. |
209
- | `head` | — | A path to the site's own head component, rendered **inside** the lab's head. This is how fonts arrive: lab stories never render through the site's layout, so whatever that layout registers has to be repeated here. |
210
- | `title` | `"Component lab"` | The lab's name, on the home page and in the dashboard. |
217
+ | `css` | `[]` | The site's token and base stylesheets. They reach the story **previews** only, never the lab's own chrome. |
218
+ | `head` | — | A path to the site's own head component, rendered inside every story preview's head. This is how fonts arrive: stories never render through the site's layout, so whatever that layout registers has to be repeated here. |
219
+ | `title` | `"Component lab"` | The site's name as the lab shows it — the tab title and the home page. |
211
220
  | `stagingUrl` | — | The deployed lab's base URL — the real minified build, which a dev server is not. Every card and the story header link to the same story there. Omit it and those links do not appear. |
212
221
  | `previewUrlTemplate` | — | A branch-preview URL with a `{branch}` placeholder, e.g. `"https://{branch}-my-site.workers.dev"`. The home page then lists the local git branches with a link each. Omit it and there is no branch list. It is a string, not a function, because the config crosses a virtual module and has to stay JSON-serialisable. |
213
222
  | `feedbucketKey` | — | Set it and every lab page carries the [Feedbucket](https://feedbucket.app) tag. The package takes a key or nothing: the consumer keeps its own staging gate, so pass the key only where the widget belongs. |
214
- | `tiers` | `["sections", "components", "explorations"]` | The tiers, in reading order. A tier is the first path segment under `directory`. |
223
+ | `tiers` | `["sections", "components", "explorations"]` | The tiers, in reading order. A tier is the first path segment under `directory`. A tier with no stories is left out of the menu. |
224
+ | `viewports` | the orbytes scale | The viewport's screen sizes: `design` widths (the device switch), `breakpoints` (the band readout and the presets) and named `devices`. See [Viewports](#viewports). |
215
225
  | `pin` | `true` | The pin board. `false` omits it entirely; an object is the board's own options — see below. |
216
226
 
217
227
  ### Tiers
@@ -236,26 +246,60 @@ tiers: [
236
246
 
237
247
  At most one tier holds each role.
238
248
 
249
+ ### Viewports
250
+
251
+ ```js
252
+ viewports: {
253
+ design: [
254
+ { label: "Desktop", width: 1440, icon: "monitor" },
255
+ { label: "Phone", width: 390, icon: "phone" }, // icons: monitor, tablet, phone-landscape, phone
256
+ ],
257
+ breakpoints: [{ name: "desktop", min: 1024 }, { name: "mobile", min: 0 }],
258
+ devices: [{ label: "Pixel 8", width: 412, height: 915 }],
259
+ }
260
+ ```
261
+
262
+ Each key is optional and replaces its default whole. `breakpoints` are lower edges, widest first,
263
+ the last one `0`; the presets offer both sides of every edge (1024 and 1023).
264
+
239
265
  ## What you get
240
266
 
241
- **`<subpath>/`** — the home page: a card per tier, then every story as a scaled-down live render.
267
+ Two sidebar levels — the menu (Home; Site: Pages, one entry per tier, Assets; Tasks; Support and
268
+ Settings) and a panel with a breadcrumb, a search box and a tree — and a main area with a navbar
269
+ over the content.
270
+
271
+ **`<subpath>/`** — Home: counts for pages, each tier, assets and open tasks, and every page's
272
+ mounted components in slot order — what is on the site right now.
242
273
 
243
- **`<subpath>/browse/<path>`** — a folder page per folder in the tree, with a filter box.
274
+ **`<subpath>/pages/<route>`** — the site's own pages, in a tree nested the way the routes nest, each
275
+ shown on the viewport canvas at its real URL. `<subpath>/pages` lists them all.
244
276
 
245
- **`<subpath>/viewport/<story>`** — the viewport configurator. Drag, type or pick the frame's width
246
- and height, zoom independently of it, and read the breakpoint band. The viewport is in the URL
247
- (`?w=390&h=844&z=0.6`), so a link — or a screenshot tool — names the exact frame. `[` `]` step the
248
- width presets, `r` rotates, `f` fits, `0` is 1:1.
277
+ **`<subpath>/browse/<tier>`** — a tier (Sections, Components, …): its components as cards with a live
278
+ thumbnail and their pills, filterable by live / used / unused / responsive.
279
+
280
+ **`<subpath>/dashboard/<story>`** — a component's page. The tree stops at the component; its stories
281
+ are tabs (a dropdown past four), with previous/next buttons and `j`/`k` stepping through every story
282
+ of the tier. The story renders on a **canvas**: pick a design width, type or drag the frame's size,
283
+ rotate, pick a breakpoint edge or a device, zoom (Fit by default), and read the band. The viewport
284
+ is in the URL (`?w=390&h=844&z=fit`), so a link names the exact frame. `[` `]` step the widths, `r`
285
+ rotates, `f` fits, `0` is 100%. The pills open a **status menu** (where it is live, and the marks
286
+ below); everything that leaves the lab (VS Code, full screen, staging, a link) is under ⋯.
287
+
288
+ **`<subpath>/assets`** — every image under `src/` and `public/`, its size and dimensions, and which
289
+ files reference it, so "what does nothing use?" is a filter.
290
+
291
+ **`<subpath>/tasks`**, **`<subpath>/tasks/all`** — the pin board, dev only. See below.
249
292
 
250
293
  **`<subpath>/index.json`** — the whole index as a static file: every story with its id, URLs,
251
294
  files, tier/section/version, live slot, used-by list, tags and summary. Scripts and agent briefs
252
295
  read this instead of globbing.
253
296
 
254
- **The sidebar**, decorated: a **live** pill on every story a page actually mounts (`live · slot 3
255
- of 12`, or `live · /about, slot 3` when the page is not the home page), a **used by** pill on
256
- shared chrome, a **responsive** pill and two checkboxes on section versions, a cull switch in the
257
- header, and the search widened so `live`, `v1`, `responsive` or a tag all match. `⌘B` collapses the
258
- sidebar, `⌘K` focuses search, `j`/`k` walk the stories.
297
+ **`<subpath>/viewport/<story>`** — the old viewport configurator's address, now a redirect to the
298
+ component page, kept so old links still land.
299
+
300
+ **Keys.** `⌘B` closes and opens the panel, `⌘⇧B` shrinks the menu to icons, `⌘.` does both,
301
+ `⌘K` searches. With the panel closed, point at the canvas's left edge and the panel slides out over
302
+ it without resizing the preview.
259
303
 
260
304
  **An unconditional `noindex`** on every lab page. Lab pages do not render through the site's
261
305
  layout, so they never inherit its staging `noindex` — this one is not gated on the environment, on
@@ -270,16 +314,16 @@ document order. Nothing is configured, so the pill cannot go stale.
270
314
  ## Marks
271
315
 
272
316
  Two JSON files under `directory`, both written by a **dev-only** API (`PUT /__lab/cull`,
273
- `PUT /__lab/responsive`) and both meant to be committed:
317
+ `PUT /__lab/responsive`) and both meant to be committed. Both are ticked in a component's **status
318
+ menu** (its pills, in the navbar):
274
319
 
275
320
  - **`responsive.json`** — `{ done, approved }` per section version. Responsive work is
276
- approval-gated and neither fact is readable from the code, so both are ticked by hand in the
277
- sidebar.
321
+ approval-gated and neither fact is readable from the code, so both are ticked by hand.
278
322
  - **`cull.json`** — `{ marked }`: stories in the cullable tier, marked for deletion.
279
323
 
280
324
  The API refuses anything outside the right tier, anything that is not a stories file on disk, and
281
325
  anything live or used by a live section — with the offender and the reason. In a build there is no
282
- dev server, so there is no write path at all and the switches hide themselves.
326
+ dev server, so there is no write path at all and the boxes stay disabled.
283
327
 
284
328
  ### `orbytes-lab-cull`
285
329
 
@@ -303,9 +347,9 @@ directory that only the removal set imports — then refuses the whole run if an
303
347
 
304
348
  ## Parameters panel
305
349
 
306
- A component with something to tune declares it and renders no UI of its own. The panel is drawn by
307
- the viewport configurator, under the top bar, and only there — the dashboard and the bare story
308
- page show nothing.
350
+ A component with something to tune declares it and renders no UI of its own. The panel is a drawer
351
+ on the right of the component page's canvas, opened from the navbar's **Parameters** button, which
352
+ exists only for a story that registered something — the bare story page shows nothing.
309
353
 
310
354
  ```ts
311
355
  import { registerLabParams } from "@orbytes/astrolab/params";
@@ -331,11 +375,13 @@ the readouts and the collapse are the panel's job, not the component's.
331
375
  Exported types: `LabParamControl`, `LabParamGroup`, `LabParamHandle`, `LabParamValue`,
332
376
  `LabParamValues`, `LabParamEntry`.
333
377
 
334
- ## The pin board at `/pin`
378
+ ## Tasks — the pin board at `/lab/tasks`
335
379
 
336
- Dev only, on by default, and it needs no configuration: `orbytesLab()` already gives it the lab's
337
- resolved `subpath` so the board's own "Lab" link points wherever the lab actually is. Everything it
338
- does is in [docs/PIN.md](./docs/PIN.md); what matters at the integration boundary is here.
380
+ Dev only, on by default, and it needs no configuration. Inside the lab it is two views in the lab's
381
+ own chrome — **Kanban Board** at `<subpath>/tasks` and **All Tasks**, a sortable table, at
382
+ `<subpath>/tasks/all` — with the statuses and their counts in the panel. `/pin`, its old address,
383
+ redirects there. Everything it does is in [docs/PIN.md](./docs/PIN.md); what matters at the
384
+ integration boundary is here.
339
385
 
340
386
  ```js
341
387
  orbytesLab({
@@ -355,16 +401,15 @@ orbytesLab({
355
401
  | `archiveDir` | `~/.orbytes/feedback-archive` | Canonical screenshot home; the repo copy is a hardlink into it. |
356
402
  | `project` | the checkout's folder name | The folder under `archiveDir` this project's screenshots live in. |
357
403
  | `shots` | `true` | `false` writes tickets and never loads playwright. |
358
- | `route` | `"/pin"` | Where the board is served. |
359
- | `links` | the lab's resolved subpath | The board's corner links. `[]` draws none. |
404
+ | `route` | `"<subpath>/tasks"` | Where the board is served. On its own, without the lab, `"/pin"`. |
405
+ | `links` | the lab's resolved subpath | The standalone board's corner links. `[]` draws none. |
360
406
  | `appId` / `appName` / `icon` | `"orbytes-pin"` / `"Pin"` / `"bug"` | The dev-toolbar app's identity. |
361
407
 
362
408
  Three things about it are worth knowing before they surprise you:
363
409
 
364
- - **`route` shadows a host page at the same path.** The board is middleware installed ahead of
365
- Astro's own request handler, so a site with its own `src/pages/pin.astro` serves the board
366
- instead — in dev, with nothing on screen to say why. The default stays `/pin` because it is the
367
- address every document here names; a site that owns that path sets `pin: { route: "/__pin" }`.
410
+ - **`/pin` still answers, in dev, with a redirect.** It is middleware installed ahead of Astro's own
411
+ request handler, so a site with its own `src/pages/pin.astro` gets the redirect instead of its
412
+ page — in dev only. Build output is unaffected.
368
413
  - **`project` defaults to the checkout's directory name**, which two clones of *different* repos
369
414
  under the same folder name silently share. One's `pin-007.png` overwrites the other's and both
370
415
  boards keep rendering. Set it explicitly where that is possible.
@@ -375,8 +420,8 @@ Three things about it are worth knowing before they surprise you:
375
420
 
376
421
  ### `orbytes-pin-gallery`
377
422
 
378
- The board is live at `/pin` and read fresh on every request, so this binary is only for the file: a
379
- snapshot that opens with no dev server running.
423
+ The board is live at `/lab/tasks` and read fresh on every request, so this binary is only for the
424
+ file: a snapshot that opens with no dev server running.
380
425
 
381
426
  ```sh
382
427
  orbytes-pin-gallery # → backlog/gallery.html
package/defaults.mjs CHANGED
@@ -38,6 +38,67 @@ export const TIER_ROLES = {
38
38
  explorations: { cullable: true },
39
39
  };
40
40
 
41
+ /**
42
+ * The screen sizes the viewport offers — a per-site setting (decided 2026-09-24), defaulting to the
43
+ * orbytes scale.
44
+ *
45
+ * - `design` — the widths the design is drawn at: the navbar's device switch, in this order. The
46
+ * `icon` names one of the chrome's icons (monitor, tablet, phone-landscape, phone).
47
+ * - `breakpoints` — the lower edge of each CSS band, widest first; the last is 0. The readout
48
+ * names the band the frame is in, and the presets offer both sides of every edge (992 and 991).
49
+ * - `devices` — named sizes that set width AND height.
50
+ *
51
+ * @typedef {{ label: string; width: number; icon: string }} DesignWidth
52
+ * @typedef {{ name: string; min: number }} Breakpoint
53
+ * @typedef {{ label: string; width: number; height: number }} Device
54
+ * @typedef {{ design: DesignWidth[]; breakpoints: Breakpoint[]; devices: Device[] }} Viewports
55
+ */
56
+ /** @type {Viewports} */
57
+ export const DEFAULT_VIEWPORTS = {
58
+ design: [
59
+ { label: "Desktop", width: 1440, icon: "monitor" },
60
+ { label: "Tablet", width: 834, icon: "tablet" },
61
+ { label: "Phone landscape", width: 550, icon: "phone-landscape" },
62
+ { label: "Phone", width: 390, icon: "phone" },
63
+ ],
64
+ breakpoints: [
65
+ { name: "desktop", min: 992 },
66
+ { name: "tablet", min: 768 },
67
+ { name: "phone landscape", min: 480 },
68
+ { name: "phone", min: 0 },
69
+ ],
70
+ devices: [
71
+ { label: "iPhone 15", width: 390, height: 844 },
72
+ { label: "iPhone 15 Pro Max", width: 430, height: 932 },
73
+ { label: "iPad", width: 834, height: 1194 },
74
+ { label: "iPad landscape", width: 1194, height: 834 },
75
+ { label: "MacBook", width: 1440, height: 900 },
76
+ { label: "Wide", width: 1920, height: 1080 },
77
+ { label: "Ultra-wide", width: 2560, height: 1080 },
78
+ ],
79
+ };
80
+
81
+ /** @param {unknown} value @returns {Viewports} */
82
+ const resolveViewports = (value) => {
83
+ const given = value && typeof value === "object" ? /** @type {Partial<Viewports>} */ (value) : {};
84
+ const list = (v, fallback) => (Array.isArray(v) && v.length ? v : fallback);
85
+ return {
86
+ design: list(given.design, DEFAULT_VIEWPORTS.design).map((d) => ({
87
+ label: String(d.label ?? `${d.width}`),
88
+ width: Number(d.width),
89
+ icon: String(d.icon ?? "monitor"),
90
+ })),
91
+ breakpoints: list(given.breakpoints, DEFAULT_VIEWPORTS.breakpoints)
92
+ .map((b) => ({ name: String(b.name), min: Number(b.min) || 0 }))
93
+ .sort((a, b) => b.min - a.min),
94
+ devices: list(given.devices, DEFAULT_VIEWPORTS.devices).map((d) => ({
95
+ label: String(d.label),
96
+ width: Number(d.width),
97
+ height: Number(d.height),
98
+ })),
99
+ };
100
+ };
101
+
41
102
  /** `./src/lab/` and `src/lab` both mean `src/lab`. @param {string} value */
42
103
  export const normaliseDirectory = (value) =>
43
104
  String(value)
@@ -89,6 +150,8 @@ const resolveTier = (tier) => {
89
150
  * @property {string | null} cullDir `<directory>/<cullTier>/`, or null
90
151
  * @property {string} responsiveFile `<directory>/responsive.json`
91
152
  * @property {string} cullFile `<directory>/cull.json`
153
+ * @property {Viewports} viewports the viewport's screen sizes (DEFAULT_VIEWPORTS)
154
+ * @property {null} tasks filled in by the integration when the pin board runs
92
155
  */
93
156
 
94
157
  /**
@@ -116,5 +179,7 @@ export function resolveLabOptions(options = {}) {
116
179
  cullDir: cull ? `${directory}/${cull.id}/` : null,
117
180
  responsiveFile: `${directory}/responsive.json`,
118
181
  cullFile: `${directory}/cull.json`,
182
+ viewports: resolveViewports(options.viewports),
183
+ tasks: null,
119
184
  };
120
185
  }
@@ -51,7 +51,18 @@ async function getVirtualRoutes(rootDir, codegenDir, logger, dashboardSubpath, p
51
51
  }
52
52
  function createVirtualRouteComponent(route) {
53
53
  const modName = `astrobook_module_${route.storyModule.id}`.replaceAll(/\W+/g, "_").replaceAll(/_+/g, "_").replace(/_+$/, "");
54
- const page = route.props.hasSidebar ? STORY_PAGE : PREVIEW_PAGE;
54
+ if (route.props.hasSidebar) {
55
+ return `
56
+ ---
57
+ // Automatically generated by Astrobook
58
+
59
+ import StoryPage from '${STORY_PAGE}';
60
+ ---
61
+
62
+ <StoryPage story={'${route.props.story}'} hasSidebar={true} />
63
+ `.trim();
64
+ }
65
+ const page = PREVIEW_PAGE;
55
66
  return `
56
67
  ---
57
68
  // Automatically generated by Astrobook
@@ -182,6 +182,14 @@ site setting `pin: { route: "/__pin" }` got a panel that fetched a path the midd
182
182
  serve and showed an empty list — fixed 2026-09-22. One function normalises the trailing
183
183
  slash, `boardRoute`, and both the middleware and the toolbar read it, so they cannot disagree.
184
184
 
185
+ **The board page moved into the lab on 2026-09-24.** Through `orbytesLab()` the route is
186
+ `<subpath>/tasks` and the page there is an Astro page in the lab's chrome
187
+ (`../src/chrome/views/Tasks.astro`), not this middleware's standalone document. The panel's contract
188
+ with it is unchanged, because the page renders the SAME markup from the same function
189
+ (`boardParts`): it still reads `[data-board]`, its `data-api`, every `[data-col][data-status]`, the
190
+ `script.card-data` payloads and `.broken li code` off the page at `route`. A change to those
191
+ selectors in `board.mjs` is a change to this contract.
192
+
185
193
  ## The status line — one line, not a log
186
194
 
187
195
  Decided 2026-09-22, replacing a running four-entry list that narrated every step. The log was
package/docs/PIN.md CHANGED
@@ -1,8 +1,8 @@
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 `/pin`, on the
5
- same dev server. No cloud, no API key, no MCP, no second process.
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.
6
6
 
7
7
  It is for a **solo pass** — your own review of a site in dev, before anyone else sees it. It does
8
8
  not sync with whatever tool you use for review rounds with other people, and is not meant to
@@ -24,7 +24,7 @@ own port (`backlog browser`, 6420). That is the whole reason it is gone — the
24
24
 
25
25
  | Stop | Do instead |
26
26
  |---|---|
27
- | `backlog browser` | `npm run dev`, then `/pin` |
27
+ | `backlog browser` | `npm run dev`, then `/lab/tasks` |
28
28
  | `backlog task edit <n> …` on a pin ticket | drag the card, or use the status menu in its detail panel |
29
29
 
30
30
  `backlog task edit` is the original defect, not an alternative. Any write through backlog.md
@@ -78,12 +78,23 @@ isCancelled(t.status); // the archive test
78
78
 
79
79
  `isOpen()` is *not `Resolved` and not `Cancelled`* — one definition, shared by this board's chips
80
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.
81
+ neither tab: it is reachable on the board only.
82
82
 
83
- ## The board at `/pin`
83
+ ## The board at `/lab/tasks`
84
+
85
+ **In the lab since 2026-09-24** (decided that day: one URL, everything under `/lab`). The board is
86
+ 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
+ `/pin`, the board's address until then, redirects there. Both views are Astro pages the pin half
90
+ injects in `astro dev` only, rendered from the same markup, stylesheet and script as the standalone
91
+ file (`boardParts` in [`src/pin/board.mjs`](../src/pin/board.mjs)); the stylesheet is scoped with
92
+ `@scope (.pin-board)` so it cannot restyle the chrome around it, and it takes the lab's palette and
93
+ dark mode inside the lab.
84
94
 
85
95
  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.
96
+ no regeneration; a refresh is the reload. In the lab the columns keep a fixed width and the row
97
+ scrolls sideways, rather than wrapping into a stack beside two sidebars.
87
98
 
88
99
  - **Ready for review is drawn to be seen**, because it is the human review queue and the whole
89
100
  reason the model changed — violet rule, violet dot, *waiting on you* under the name, a tinted panel
@@ -110,21 +121,19 @@ printed `0 ticket(s)` until 2026-09-22). It renders from the same module and is
110
121
  board is writable exactly when `renderBoard` is given an endpoint, and a `file://` page has nothing
111
122
  listening behind it.
112
123
 
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.
124
+ **The standalone board's corner links** are an option (`links`), not the hardcoded `/` and `/lab`
125
+ they were until 2026-09-22. Inside the lab there are none — the lab's own menu is right there.
117
126
 
118
127
  ## The write path
119
128
 
120
129
  One endpoint, `POST <route>/api/ticket`, registered in `astro:server:setup` — which does not exist
121
130
  in a build, so it cannot reach `dist/`.
122
131
 
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" }`.
132
+ **The endpoint, the screenshots and the `/pin` redirect are middleware**, installed ahead of
133
+ Astro's own request handler, so they shadow a host page at the same path — in dev only. Through
134
+ `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`.
128
137
 
129
138
  ```json
130
139
  { "id": "PIN-004", "status": "Ready for review", "expect": { "status": "In Progress" } }
@@ -205,8 +214,8 @@ Pass these as `orbytesLab({ pin: { … } })`.
205
214
  | `archiveDir` | `~/.orbytes/feedback-archive` | Canonical screenshot home; the repo copy is a hardlink into it. |
206
215
  | `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
216
  | `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. |
217
+ | `route` | `"<subpath>/tasks"` (`"/pin"` without the lab) | Where the board is served. `<route>/assets/*` serves its screenshots, `<route>/api/ticket` is the write endpoint; in the lab, `<route>` and `<route>/all` are the two views. 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. |
218
+ | `links` | the lab's resolved subpath | The standalone board's corner links, `[{ href, label }]`. `[]` draws none. |
210
219
  | `appId` / `appName` / `icon` | `"orbytes-pin"` / `"Pin"` / `"bug"` | The dev-toolbar app's identity. |
211
220
 
212
221
  ## What a ticket looks like
@@ -412,8 +421,9 @@ window, so clicking it again focuses that tab instead of opening another.
412
421
  app, no stamp.
413
422
  2. Both Vite plugins carry `apply: "serve"`, so they cannot load in a build even if they were
414
423
  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.
424
+ 3. `astro:server:setup`, where every write lives — the ticket writer, the screenshots and the
425
+ `<route>/api/ticket` endpoint — does not run in a build at all. The lab's two Tasks views are
426
+ injected past assertion 1, so they exist in `astro dev` and in no build.
417
427
  4. That hook returns early anyway when `astro:config:setup` stood down, so a host that runs the
418
428
  hooks in an order this package did not choose still gets nothing.
419
429
 
package/index.d.ts CHANGED
@@ -23,14 +23,17 @@ export interface OrbytesLabOptions {
23
23
  directory?: string;
24
24
  /** Where the lab is served. Default `"/lab"`. */
25
25
  subpath?: string;
26
- /** The consumer's token/stylesheet files, as Astrobook takes them. Default `[]`. */
26
+ /**
27
+ * The consumer's token/stylesheet files. They reach the PREVIEW only — the bare story each
28
+ * component page shows in its canvas — never the lab's own chrome. Default `[]`.
29
+ */
27
30
  css?: string[];
28
31
  /**
29
- * A path to the consumer's own head component, rendered inside the lab's head — how fonts and
30
- * anything else site-specific reach lab pages. Relative to the project root.
32
+ * A path to the consumer's own head component, rendered inside every story preview's head — how
33
+ * fonts and anything else site-specific reach the previews. Relative to the project root.
31
34
  */
32
35
  head?: string;
33
- /** The lab's name, shown in the dashboard. Default `"Component lab"`. */
36
+ /** The site's name as the lab shows it (the tab title, the home page). Default `"Component lab"`. */
34
37
  title?: string;
35
38
  /**
36
39
  * The deployed lab's base URL — the real minified build, which a dev server is not. Every card
@@ -53,13 +56,32 @@ export interface OrbytesLabOptions {
53
56
  * the entry says otherwise.
54
57
  */
55
58
  tiers?: (string | LabTierOption)[];
59
+ /**
60
+ * The screen sizes the viewport offers. Every key is optional; an omitted one keeps the default
61
+ * (the orbytes scale).
62
+ */
63
+ viewports?: {
64
+ /**
65
+ * The widths the design is drawn at — the navbar's device switch, in order. `icon` is one of
66
+ * `monitor`, `tablet`, `phone-landscape`, `phone`.
67
+ * @default 1440 desktop · 834 tablet · 550 phone landscape · 390 phone
68
+ */
69
+ design?: { label: string; width: number; icon?: string }[];
70
+ /**
71
+ * The lower edge of each CSS band; the last is 0. The readout names the band the frame is in,
72
+ * and the presets offer both sides of every edge.
73
+ * @default desktop 992 · tablet 768 · phone landscape 480 · phone 0
74
+ */
75
+ breakpoints?: { name: string; min: number }[];
76
+ /** Named sizes that set width AND height. @default iPhone 15, iPad, MacBook, … */
77
+ devices?: { label: string; width: number; height: number }[];
78
+ };
56
79
  /**
57
80
  * The pin board — the dev-only click-to-ticket feedback half of this package (`docs/PIN.md`).
58
81
  *
59
- * `true` or omitted ships it at `/pin` with its defaults; `false` omits it entirely, for a site
60
- * that wants the component lab alone. An object is the pin integration's own options, and the
61
- * lab fills in `links` from its RESOLVED `subpath` so the board's "Lab" link is derived rather
62
- * than guessed — pass your own `links` to override that.
82
+ * `true` or omitted ships it at `<subpath>/tasks` (the lab's Kanban Board and All Tasks views),
83
+ * with `/pin` redirecting there; `false` omits it entirely, for a site that wants the lab alone.
84
+ * An object is the pin integration's own options.
63
85
  *
64
86
  * @default true
65
87
  */
@@ -95,11 +117,12 @@ export interface OrbytesPinOptions {
95
117
  shots?: boolean;
96
118
  /**
97
119
  * Where the board is served, dev only. `<route>/assets/*` serves its screenshots and
98
- * `<route>/api/ticket` is the one write endpoint.
120
+ * `<route>/api/ticket` is the one write endpoint; through `orbytesLab()`, `<route>` and
121
+ * `<route>/all` are the lab's Kanban Board and All Tasks views.
99
122
  *
100
- * It is MIDDLEWARE, installed ahead of Astro's own request handler, so it SHADOWS a host page at
101
- * the same path — in dev, silently. A site with its own `/pin` page sets something else here.
102
- * @default "/pin"
123
+ * Its asset and API paths are MIDDLEWARE, installed ahead of Astro's own request handler, so they
124
+ * shadow a host page at the same path — in dev, silently.
125
+ * @default "<subpath>/tasks" through orbytesLab(), "/pin" on its own
103
126
  */
104
127
  route?: string;
105
128
  /**
@@ -117,9 +140,10 @@ export interface OrbytesPinOptions {
117
140
  }
118
141
 
119
142
  /**
120
- * The orbytes component lab AND the pin board: the Astrobook core vendored into this package
121
- * (`src/core/`, 0.13.3), the lab shell around it, and the dev-only feedback board at `/pin`. Astro
122
- * flattens the returned array, so one entry in `integrations` configures all three.
143
+ * The orbytes lab AND the pin board: the Astrobook core vendored into this package
144
+ * (`src/core/`, 0.13.3), the lab's chrome around it, and the dev-only feedback board at
145
+ * `<subpath>/tasks`. Astro flattens the returned array, so one entry in `integrations` configures
146
+ * all three.
123
147
  *
124
148
  * `pin: false` returns the lab alone.
125
149
  */
@@ -206,7 +230,7 @@ export const OPEN_STATUSES: readonly PinStatus[];
206
230
  export function normaliseStatus(status: unknown): string;
207
231
  /** Open — neither accepted nor cancelled. `isOpen("Done")` is `false`. */
208
232
  export function isOpen(status: unknown): boolean;
209
- /** Archived. Every agent-facing read drops these; `/pin` is the one place they are visible. */
233
+ /** Archived. Every agent-facing read drops these; the board is the one place they are visible. */
210
234
  export function isCancelled(status: unknown): boolean;
211
235
  /** May an agent START work on this ticket? Exactly one status says yes. */
212
236
  export function isAgentReady(status: unknown): boolean;