@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/README.md CHANGED
@@ -11,12 +11,14 @@ the site right now**, derived from the pages rather than declared. Its engine be
11
11
  **Tasks — the pin board** at `/lab/tasks` — click a rendered element on `astro dev`, leave a
12
12
  comment, and a markdown ticket plus a screenshot land on local disk for an agent to pick up
13
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
14
+ table views in the lab, and a **Comment** button in its navbar that pins an element of the page it
15
+ is previewing. Dev only, and only in a site that is inside a git repository (see
16
+ [Tasks](#tasks--the-pin-board-at-labtasks)); switchable off with `pin: false`. `/pin`, its old
15
17
  address, redirects there.
16
18
 
17
19
  It ships mostly **source**. The `.astro` files, and everything under `src/chrome/`, `src/shell/`,
18
20
  `src/ui/` and `src/types/`, are compiled by the consumer's own Vite, the way Starlight ships its components —
19
- which is why the lab picks up the consumer's tokens, fonts and breakpoints in a story preview: it
21
+ which is why the lab picks up the consumer's tokens, fonts and breakpoints in a variant's preview: it
20
22
  renders inside the consumer's pipeline. The one exception is `src/core/`, transpiled to `dist/` by
21
23
  `scripts/build-core.mjs` at `prepack` and `prepare`, because `astro.config.mjs` is loaded by Node's
22
24
  own ESM loader and Node refuses to strip TypeScript under `node_modules`. Without that step an
@@ -25,9 +27,12 @@ installed copy cannot be loaded at all. The script's header comment is the full
25
27
  ## Install
26
28
 
27
29
  ```sh
28
- npm i -D @orbytes/astrolab
30
+ npm i -D -E @orbytes/astrolab
29
31
  ```
30
32
 
33
+ `-E` (`--save-exact`) pins the version: a lab upgrade is a deliberate change to a site, never a
34
+ side effect of `npm install`.
35
+
31
36
  Then add the integration:
32
37
 
33
38
  ```js
@@ -38,18 +43,26 @@ import orbytesLab from "@orbytes/astrolab";
38
43
  export default defineConfig({
39
44
  integrations: [
40
45
  orbytesLab({
41
- css: ["./src/styles/global.css"], // your token and base stylesheets
46
+ css: ["./src/styles/global.css"], // optional: your site's own stylesheet(s), for the previews
42
47
  }),
43
48
  ],
44
49
  });
45
50
  ```
46
51
 
52
+ `css` is optional, and `./src/styles/global.css` is only an example: name whatever stylesheet your
53
+ site's layout imports. A new Astro project has no such file, so either point `css` at the one you
54
+ have or leave the option out. Left out, it is `[]` and the variant previews get no site stylesheet
55
+ at all, only each component's own scoped styles; the lab's chrome never gets it either way. Each
56
+ entry becomes a plain `import` in the preview's layout (a path starting with `.` resolves against
57
+ the project root, anything else as a package), so name only files that exist.
58
+
47
59
  That is the whole configuration. In particular, **do not add an `ssr.noExternal` entry for this
48
60
  package** — the integration adds its own, because the package ships `.astro` files that Vite would
49
61
  otherwise externalise and hand to Node, which cannot read them. A consumer needs nothing. (Two
50
62
  older orbytes repos set it by hand; it is redundant, and copying it from them is copying noise.)
51
63
 
52
- Now write a story. Stories live under `src/lab/`, in one directory per tier:
64
+ Each named export in a stories file is a story; the lab lists them as **Variants**. Now write a
65
+ story. Stories live under `src/lab/`, in one directory per tier:
53
66
 
54
67
  ```
55
68
  src/lab/
@@ -57,12 +70,11 @@ src/lab/
57
70
  components/Button.astro + Button.stories.ts
58
71
  explorations/KeycapButton.astro + KeycapButton.stories.ts
59
72
  decorators/ ← yours, built against YOUR page shell (see below)
60
- cull.json ← written by the lab, commit it
61
73
  responsive.json ← written by the lab, commit it
62
74
  ```
63
75
 
64
76
  A stories file has a default export naming the component, and one named export per story. The
65
- export's name is what the sidebar shows; its `args` are the component's props.
77
+ export's name is the variant's label in the lab; its `args` are the component's props.
66
78
 
67
79
  ```ts
68
80
  // src/lab/components/Button.stories.ts
@@ -81,7 +93,13 @@ Then `npm run dev` and open **`http://localhost:4321/lab`**. `subpath` moves it;
81
93
  the stories.
82
94
 
83
95
  **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.
96
+ `npm i -D -E @orbytes/astrolab@next`. `latest` stays on the last stable release until 1.0.
97
+
98
+ **Upgrading from 0.3.** The mark-for-deletion feature (the cull) is removed, decided 2026-09-24.
99
+ The `orbytes-lab-cull` command is gone, so delete any `lab:cull` script that calls it, and the
100
+ `src/lab/cull.json` it kept. The `cullTier`, `cullDir` and `cullFile` options and a tier's
101
+ `cullable` flag are no longer read: a config that still sets them builds, but they do nothing, and
102
+ a typed config will flag them. Nothing else a 0.3 site configured changes meaning.
85
103
 
86
104
  **Peers.** Astro **7 or newer**, narrowed from `>=5.2.0` on 2026-09-22 when the pin board moved in:
87
105
  the board's dev-toolbar app imports `astro/client/dev-toolbar/apps/utils/highlight.js` and
@@ -192,7 +210,7 @@ This package is MIT too; see [LICENSE](./LICENSE).
192
210
  ### The chrome is not styled by the website
193
211
 
194
212
  **Decided 2026-09-22.** The lab's own furniture looks the same in every site it is installed in,
195
- and only the rendered story takes the site's styling. A site that wants a different-looking lab
213
+ and only the rendered variant takes the site's styling. A site that wants a different-looking lab
196
214
  edits this package's source: there is no theme API and no token override, on purpose.
197
215
 
198
216
  So the chrome — both sidebar levels, the navbar, every view — is drawn by one stylesheet that ships
@@ -200,10 +218,10 @@ with this package, [`src/ui/lab.css`](./src/ui/lab.css), in `--lab-*` tokens tha
200
218
  a site's own, in Inter, with light and dark themes of its own. It never receives the `css` list or
201
219
  the `head` component.
202
220
 
203
- The **preview** — the bare story at `/lab/stories/<id>`, and the site's own pages — gets both,
221
+ The **preview** — the bare variant at `/lab/stories/<id>`, and the site's own pages — gets both,
204
222
  because seeing a component as the real site renders it is the whole point. Since the redesign the
205
223
  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.
224
+ chrome page carries a byte of the site's CSS, and the chrome's stylesheet never reaches a variant.
207
225
  The preview also keeps its **own** light/dark, separate from the chrome's.
208
226
 
209
227
  ## Options
@@ -214,14 +232,14 @@ Every option is optional.
214
232
  | --- | --- | --- |
215
233
  | `directory` | `"src/lab"` | Where the stories live, relative to the project root. |
216
234
  | `subpath` | `"/lab"` | Where the lab is served. `""` serves it at the site root. |
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. |
235
+ | `css` | `[]` | The site's own token and base stylesheets, if you want the previews styled like the site (see [Install](#install)). They reach the variant **previews** only, never the lab's own chrome. |
236
+ | `head` | — | A path to the site's own head component, rendered inside every variant preview's head. This is how fonts arrive: variants never render through the site's layout, so whatever that layout registers has to be repeated here. |
219
237
  | `title` | `"Component lab"` | The site's name as the lab shows it — the tab title and the home page. |
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. |
238
+ | `stagingUrl` | — | The deployed lab's base URL — the real minified build, which a dev server is not. Every card and every component page link to the same variant there. Omit it and those links do not appear. |
221
239
  | `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. |
222
240
  | `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. |
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). |
241
+ | `tiers` | `["sections", "components", "explorations"]` | The tiers, in reading order. A tier is the first path segment under `directory`. A tier with no stories files is left out of the menu. |
242
+ | `viewports` | the orbytes scale | The viewport's screen sizes: `design` widths (the device switch), `breakpoints` (the band the Viewport field names, and the presets) and named `devices`. See [Viewports](#viewports). |
225
243
  | `pin` | `true` | The pin board. `false` omits it entirely; an object is the board's own options — see below. |
226
244
 
227
245
  ### Tiers
@@ -233,18 +251,17 @@ what each one *is*:
233
251
  tiers: [
234
252
  { id: "pages", label: "Page sections", responsive: true },
235
253
  "atoms",
236
- { id: "sketches", label: "Sketches", cullable: true },
254
+ { id: "sketches", label: "Sketches" },
237
255
  ]
238
256
  ```
239
257
 
240
- - **`responsive`** — this tier holds page sections. Its stories carry the responsive mark, and they
241
- render at page width (1440×900) in thumbnails and as the viewport's default frame; everything
242
- else renders on a component stage (960×540). Defaults to true for a tier named `sections`.
243
- - **`cullable`** this tier's stories may be marked for deletion in the sidebar and removed by
244
- `orbytes-lab-cull`. Nothing else can be: version history and shared chrome are never deleted from
245
- a browser. Defaults to true for a tier named `explorations`.
258
+ - **`responsive`** — this tier holds page sections, laid out `<tier>/<Section>/<Version>/`; the lab
259
+ reads the section and version from those folders whatever the tier is called. Its variants carry
260
+ the responsive mark, and they render at page width (1440×900) in thumbnails and as the viewport's
261
+ default frame; everything else renders on a component stage (960×540). Defaults to true for a
262
+ tier named `sections`.
246
263
 
247
- At most one tier holds each role.
264
+ At most one tier holds the role.
248
265
 
249
266
  ### Viewports
250
267
 
@@ -265,25 +282,57 @@ the last one `0`; the presets offer both sides of every edge (1024 and 1023).
265
282
  ## What you get
266
283
 
267
284
  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.
285
+ Settings), with the lab's own light/dark switch (a moon) beside the logo, and a panel with a
286
+ breadcrumb, a search box and a tree — and a main area with a navbar over the content. The menu
287
+ shrinks to a 64px icon rail, where the moon sits under the logomark and each group opens as a
288
+ flyout under its own name. Kanban Board and All Tasks have no panel. The panel's tree has no icons
289
+ and reads at 12px.
270
290
 
271
291
  **`<subpath>/`** — Home: counts for pages, each tier, assets and open tasks, and every page's
272
292
  mounted components in slot order — what is on the site right now.
273
293
 
274
294
  **`<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.
295
+ shown on the viewport canvas at its real URL. The navbar is the component page's, less the
296
+ variants: the page's name and route, the eye (below), a menu of the components the page mounts, in
297
+ slot order, and **Reload** and **Open page** beside the viewport controls. `<subpath>/pages` lists
298
+ them all.
276
299
 
277
300
  **`<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 ⋯.
301
+ thumbnail, under a title bar with filter chips (All · Live · Used · Unused). A live component's card
302
+ carries a green badge with its slot number; the others carry a *used by N* pill when something live
303
+ uses them, and a section carries its responsive state. On the sections tier the panel's filter
304
+ menu adds responsive state.
305
+
306
+ On the sections tier the panel is the **Sections tree**: one row per section with a slot badge —
307
+ green with its slot number, a grey dash when no page mounts it, violet on the row you are on. The
308
+ mounted sections come first; the rest sit under *Unslotted · on no page*. **Sort by** switches
309
+ between slot number and name (A–Z) and is remembered in this browser.
310
+
311
+ **`<subpath>/dashboard/<story>`** — a component's page. The tree stops at the component, and the
312
+ navbar carries the rest:
313
+
314
+ - **The name, the version and an eye.** Hovering the eye opens the **Properties** panel: where the
315
+ component is live and in which slot (or what uses it, or *Not on any page*), its open and resolved
316
+ pins, the two responsive marks as checkboxes (sections only, see [Marks](#marks)) and its source
317
+ file. Clicking the eye keeps the panel open; Escape or a click elsewhere closes it.
318
+ - **Variants** — always a dropdown ("Default 1 of 3"), whatever the count — and previous / next
319
+ buttons, with `j`/`k` stepping through every variant of the tier.
320
+ - **The device switch and the Viewport field.** The switch picks a design width. The field is the
321
+ frame's width × height, typed in place, and a button naming the band and the zoom ("tablet Fit")
322
+ that opens the presets: Rotate, both sides of every breakpoint edge, the named devices and a zoom
323
+ row (Fit, 25–200%). Drag the frame's right edge, bottom edge or corner to resize it. Fit is the
324
+ default: it scales the frame to fit the stage in both directions, and never past 100%.
325
+ - **Comment**, where the pin board runs — see [Pinning from the lab](#pinning-from-the-lab).
326
+ - **Parameters**, only for a variant that registers some — see [Parameters panel](#parameters-panel).
327
+ - **⋯** — everything that leaves the lab (Open in VS Code, open the bare variant full screen, view
328
+ on staging, copy a link to this exact size), then two sets of switches: **Pins** (Show pins, on;
329
+ Show resolved, off) and **Preview › Dark mode**, which appears only when the framed site has a
330
+ dark mode of its own and switches the preview, not the lab.
331
+
332
+ The navbar is one row when it is 1040px wide or more, and two rows below that, the viewport row
333
+ last. The viewport is in the URL (`?w=390&h=844&z=fit`), so a link names the exact frame, and it
334
+ rides along when you step between variants. `[` `]` step the widths, `r` rotates, `f` fits, `0` is
335
+ 100%.
287
336
 
288
337
  **`<subpath>/assets`** — every image under `src/` and `public/`, its size and dimensions, and which
289
338
  files reference it, so "what does nothing use?" is a filter.
@@ -298,8 +347,24 @@ read this instead of globbing.
298
347
  component page, kept so old links still land.
299
348
 
300
349
  **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.
350
+ `⌘K` searches. With the panel closed, point at the canvas's left edge and the panel slides out
351
+ under the navbar, over the canvas, without resizing the preview.
352
+
353
+ **Astro's dev toolbar is hidden inside the lab's frames**, so a lab page shows one toolbar: its
354
+ own. The site's pages, and a bare variant opened in its own tab, keep theirs.
355
+
356
+ ### Pinning from the lab
357
+
358
+ Where the pin board runs, **Comment** pins an element of the framed preview: click it, click the
359
+ element, and the pin app on Astro's dev toolbar opens its composer. The pick is confined to the
360
+ canvas, so it can never land on the lab's own chrome; Escape stops it. The button stays disabled
361
+ until that toolbar app has started, because it is the one picker and composer the lab uses.
362
+
363
+ Pins already left on the framed page are drawn over the frame at their elements, blue while open and
364
+ grey once resolved; a pin whose selector finds nothing on the page, or more than one thing, is not
365
+ drawn. Clicking a pin, or the pin row in the Properties panel, opens All Tasks. ⋯ › Pins decides
366
+ what is drawn — Show pins (on) and Show resolved (off) — and the choice is remembered in this
367
+ browser. On the site's own pages, outside the lab, pinning goes through the dev toolbar as before.
303
368
 
304
369
  **An unconditional `noindex`** on every lab page. Lab pages do not render through the site's
305
370
  layout, so they never inherit its staging `noindex` — this one is not gated on the environment, on
@@ -309,47 +374,26 @@ purpose. Keep your own build gate as well: two mechanisms, because the rule is a
309
374
 
310
375
  A component is live when a page under `src/pages/` imports it **and** mounts it as a tag. Every
311
376
  page is walked, the layout is excluded, and the slot is the component's position in that page's
312
- document order. Nothing is configured, so the pill cannot go stale.
377
+ document order. Nothing is configured, so the slot badge cannot go stale.
313
378
 
314
379
  ## Marks
315
380
 
316
- Two JSON files under `directory`, both written by a **dev-only** API (`PUT /__lab/cull`,
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):
319
-
320
- - **`responsive.json`** — `{ done, approved }` per section version. Responsive work is
321
- approval-gated and neither fact is readable from the code, so both are ticked by hand.
322
- - **`cull.json`** — `{ marked }`: stories in the cullable tier, marked for deletion.
323
-
324
- The API refuses anything outside the right tier, anything that is not a stories file on disk, and
325
- anything live or used by a live section — with the offender and the reason. In a build there is no
326
- dev server, so there is no write path at all and the boxes stay disabled.
327
-
328
- ### `orbytes-lab-cull`
329
-
330
- The marks delete nothing. This binary does, and only when told to:
331
-
332
- ```sh
333
- orbytes-lab-cull # dry run — print the plan, remove nothing
334
- orbytes-lab-cull --json # the same plan as JSON
335
- orbytes-lab-cull --yes # perform it: `git rm` (staged, not committed)
336
- orbytes-lab-cull --dir=… # when the lab is not at src/lab
337
- ```
338
-
339
- It runs in the current working directory, so wire it as a script in the consumer:
381
+ One JSON file under `directory`, **`responsive.json`**, written by a **dev-only** API
382
+ (`PUT /__lab/responsive`) and meant to be committed. It holds `{ done, approved }` per section
383
+ version, ticked in the component's **Properties** panel (the eye beside its name). Responsive work
384
+ is approval-gated and neither fact is readable from the code, so both are ticked by hand.
340
385
 
341
- ```json
342
- "scripts": { "lab:cull": "orbytes-lab-cull" }
343
- ```
344
-
345
- It removes each marked stories file, the component it renders, and anything under the lab
346
- directory that only the removal set imports — then refuses the whole run if any entry is blocked.
386
+ The API refuses anything outside the responsive tier and anything that is not a stories file on
387
+ disk with the offender and the reason. In a build there is no dev server, so there is no write
388
+ path at all: the boxes stay disabled and show the state the page was built with.
347
389
 
348
390
  ## Parameters panel
349
391
 
350
392
  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.
393
+ over the right edge of the component page's canvas, opened from the navbar's **Parameters** button,
394
+ which exists only for a variant that registered something — the bare variant page shows nothing.
395
+ The drawer never resizes the canvas, because that would change the preview's width and trip its
396
+ breakpoints; whether it is open, and which groups are collapsed, is remembered in this browser.
353
397
 
354
398
  ```ts
355
399
  import { registerLabParams } from "@orbytes/astrolab/params";
@@ -359,6 +403,7 @@ const panel = registerLabParams({
359
403
  title: "hero shader",
360
404
  controls: [
361
405
  { kind: "range", id: "speed", label: "Speed", min: 0, max: 4, step: 0.05, value: 1, unit: "×" },
406
+ { kind: "number", id: "headlineSize", label: "Headline size", value: 72, unit: "px" },
362
407
  { kind: "toggle", id: "grain", label: "Grain", value: true },
363
408
  ],
364
409
  apply(values) { /* push them into whatever they configure */ },
@@ -368,34 +413,88 @@ const values = panel.values; // LIVE — mutated in place, safe to hold
368
413
  panel.status("WebGL2 · 0.8 ms/frame");
369
414
  ```
370
415
 
416
+ The control kinds are `range` (a slider), `number` (below), `toggle`, `select` and `color`.
417
+
371
418
  `apply` is called once at registration with the restored values, so it must tolerate running before
372
419
  the thing it configures exists. Values persist per group in `localStorage`. Reset, Copy settings,
373
420
  the readouts and the collapse are the panel's job, not the component's.
374
421
 
375
- Exported types: `LabParamControl`, `LabParamGroup`, `LabParamHandle`, `LabParamValue`,
376
- `LabParamValues`, `LabParamEntry`.
422
+ ### Sizes are never sliders
423
+
424
+ **Decided 2026-09-24.** A parameter that is a size — a font size, a tracking, a line height, a width,
425
+ an offset, a radius — is `kind: "number"`: a field to type the value into, joined to a select of the
426
+ units it can be expressed in. Sliders stay for unitless tuning values (speed, grain, scale,
427
+ opacity).
428
+
429
+ ```ts
430
+ { kind: "number", id, label, value, unit, units?, step?, min?, max?, note? }
431
+ ```
432
+
433
+ - `value` is the bare default number (`72`) and `unit` its default unit (`"px"`, or `""` for none).
434
+ - `units` is what the select offers. It defaults to `LAB_UNITS.size` — px · rem · em · vw · vh · %.
435
+ `LAB_UNITS.tracking` (em · px) and `LAB_UNITS.lineHeight` (none · px) are there too, or pass a
436
+ list of your own; the default `unit` is added if the list lacks it.
437
+ - `step` is what ↑ / ↓ add (1 by default); `min` and `max` bound typed and restored values alike.
438
+
439
+ `apply` receives **one CSS-ready string** per number control, the number and the unit joined —
440
+ `"72px"`, `"12.5rem"`, or `"1.05"` when the unit is `""` — so it drops straight into a style:
441
+
442
+ ```ts
443
+ el.style.setProperty("--headline-size", String(values.headlineSize)); // "72px"
444
+ ```
445
+
446
+ `parseLabLength("72px")` gives `{ value: 72, unit: "px" }` back when the number itself is wanted,
447
+ and `null` for anything that is not one number and an optional unit. Changing the unit keeps the
448
+ number and changes what it means: the panel never converts between units, because px → rem
449
+ depends on a root size it cannot know. A size that used to be a `range` can become a `number`
450
+ without losing its saved tuning — a stored bare number is read in the default unit.
451
+
452
+ Exported from `@orbytes/astrolab/params`: `registerLabParams`, `LAB_UNITS`, `parseLabLength`,
453
+ `formatLabLength` (its inverse), `labUnitsOf`, and the types `LabParamControl`, `LabParamGroup`,
454
+ `LabParamHandle`, `LabParamValue`, `LabParamValues`, `LabParamEntry`.
377
455
 
378
456
  ## Tasks — the pin board at `/lab/tasks`
379
457
 
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.
458
+ Dev only and on by default. Inside the lab it is two views in the lab's own chrome — **Kanban
459
+ Board** at `<subpath>/tasks` and **All Tasks**, a sortable table, at `<subpath>/tasks/all` — with no
460
+ panel beside them: the board's own chips filter by status. `/pin`, its old address, redirects there.
461
+ Pinning from inside the lab is the navbar's Comment button ([Pinning from the
462
+ lab](#pinning-from-the-lab)). Everything the board does is in [docs/PIN.md](./docs/PIN.md); what
463
+ matters at the integration boundary is here.
464
+
465
+ **It needs the site to be inside a git repository.** Every ticket path is stored relative to the
466
+ repository root: the Astro project's folder, or the nearest one above it, that holds `.git`.
467
+ With none, the board switches itself off for that run and says so only as a warning in the dev
468
+ server's log; the lab is unaffected. Nothing on screen says why: `<subpath>/tasks`,
469
+ `<subpath>/tasks/all` and `/pin` answer 404, the Tasks group and the Comment button are absent,
470
+ there is no pin app on the dev toolbar and no source stamp. Run `git init` (then restart the dev
471
+ server) to turn it on, or pass `pin: false` to silence the warning.
385
472
 
386
473
  ```js
387
474
  orbytesLab({
388
475
  css: ["./src/styles/global.css"],
389
476
  pin: {
390
- stamp: ["src/lab/sections", "src/components"], // site-relative
477
+ // site-relative; this adds the lab's components tier to the default
478
+ stamp: ["src/lab/sections", "src/lab/components", "src/components"],
391
479
  project: "acme-website", // the screenshot archive folder
392
480
  },
393
481
  })
394
482
  ```
395
483
 
484
+ **Which files a pin can name.** A pin names the source file of the nearest element that carries a
485
+ `data-orbytes-src` attribute, and `stamp` decides which `.astro` files get one. The default is
486
+ `src/lab/sections` and `src/components` — **not** `src/lab/components` or `src/lab/explorations`,
487
+ where the layout above puts components — so a pin on one of those, in its own variant, records
488
+ its source as `unresolved` (its selector, screenshot and HTML are still in the ticket). Rendered
489
+ inside a stamped section, the same component's pin names the section's file. Add the directories you
490
+ want, as in the example: `stamp` replaces the default rather than adding to it, and it does not
491
+ follow `directory` if you moved the stories. Before widening it, read the last bullet below: a
492
+ template in a stamped folder with no element to stamp (its first top-level tag a component, say)
493
+ throws, and needs a `stampSkip` entry.
494
+
396
495
  | Option | Default | What it does |
397
496
  | --- | --- | --- |
398
- | `stamp` | `["src/lab/sections", "src/components"]` | **Site-relative** directories whose `.astro` files get `data-orbytes-src`, so a pin can name the file it came from. |
497
+ | `stamp` | `["src/lab/sections", "src/components"]` | **Site-relative** directories whose `.astro` files get `data-orbytes-src`, so a pin can name the file it came from. Replaces the default whole; see above for `src/lab/components`. |
399
498
  | `stampSkip` | `[]` | **Site-relative** `.astro` paths exempt from the stamp's hard failure. A repo-relative path is accepted too, for configs written before 2026-09-22. |
400
499
  | `backlogDir` | `"backlog"` | Repo-relative board directory: tickets in `tasks/`, PNGs in `assets/`. |
401
500
  | `archiveDir` | `~/.orbytes/feedback-archive` | Canonical screenshot home; the repo copy is a hardlink into it. |
@@ -430,8 +529,8 @@ orbytes-pin-gallery --backlog-dir feedback # when the site moved its board
430
529
  ```
431
530
 
432
531
  It renders from the same module the live board does and is read-only — a `file://` page has nothing
433
- listening behind it. It runs in the current working directory, like `orbytes-lab-cull`, so wire it
434
- as a script in the consumer:
532
+ listening behind it. It runs in the current working directory, so wire it as a script in the
533
+ consumer:
435
534
 
436
535
  ```json
437
536
  "scripts": { "pin:gallery": "orbytes-pin-gallery" }
@@ -442,7 +541,7 @@ as a script in the consumer:
442
541
  - **`decorators/`** — a decorator wraps a story in the page shell it will really live in. That
443
542
  shell is different in every build, so the package ships none: write your own against your own
444
543
  layout.
445
- - **`cull.json` / `responsive.json`** — data about your components, committed with them.
544
+ - **`responsive.json`** — data about your components, committed with them.
446
545
  - **The build gate** — whether the lab is in this build is the consumer's decision, in the
447
546
  consumer's `astro.config.mjs`, and worth asserting with a check of your own.
448
547
  - **The head component** — fonts and anything else the site's layout registers.
@@ -450,6 +549,7 @@ as a script in the consumer:
450
549
  ## Notes
451
550
 
452
551
  - The `tags` a story declares (`export default { component, meta: { tags: [...] } }`) are read
453
- through `import.meta.glob("/src/**/*.stories.ts")`, so a `directory` outside `src/` gets no tags.
552
+ from the stories file's text, not imported, so write them as string literals: a tag built at
553
+ runtime is not seen.
454
554
  - Editing the integration or its `.mjs` helpers is not picked up by the dev server's config-change
455
555
  restart — they stay in Node's ESM cache. Stop and start the server.
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  // pin-gallery — render every pin ticket as ONE standalone kanban on disk.
3
3
  //
4
- // The board itself lives at `/pin` on `astro dev` now, generated from `backlog/tasks/` on every
4
+ // The board itself lives at `<subpath>/tasks` on `astro dev` now (`/lab/tasks` by default, or the
5
+ // pin half's `route` when it runs without the lab), generated from `backlog/tasks/` on every
5
6
  // request, so looking at it is a refresh and this script is no longer part of that loop. What it
6
7
  // is still for is the file: a page that opens with no dev server running — on a plane, on another
7
8
  // machine, attached to a message — and a snapshot of the board at a moment in time.
@@ -15,15 +16,16 @@
15
16
  // npx orbytes-pin-gallery # → backlog/gallery.html
16
17
  // npx orbytes-pin-gallery --out x.html
17
18
  // npx orbytes-pin-gallery --backlog-dir feedback # a site that moved its board
19
+ // npx orbytes-pin-gallery --help # the usage; writes nothing
18
20
  //
19
- // IT RUNS IN THE CONSUMER'S REPO, NOT ITS OWN the same rule as ./lab-cull.mjs, and for the same
20
- // reason. The root is walked up from `process.cwd()`, never from this file's location, which since
21
- // the merge into @orbytes/astrolab (2026-09-22) is somewhere under the consumer's node_modules and,
22
- // when the package is workspace-linked, resolves to THIS repo instead of theirs. A gallery of the
21
+ // IT RUNS IN THE CONSUMER'S REPO, NOT ITS OWN. The root is walked up from `process.cwd()`, never
22
+ // from this file's location, which since the merge into @orbytes/astrolab (2026-09-22) is somewhere
23
+ // under the consumer's node_modules and, when the package is workspace-linked, resolves to THIS
24
+ // repo instead of theirs. A gallery of the
23
25
  // lab's own (empty) backlog, written into the lab's own checkout, would exit 0 and look fine.
24
26
  //
25
27
  // EVERY line of the board — the parser, the cards, the columns, the CSS — comes from
26
- // `../src/pin/board.mjs`, which the `/pin` board renders with as well. Nothing about a ticket is read
28
+ // `../src/pin/board.mjs`, which the live board renders with as well. Nothing about a ticket is read
27
29
  // or drawn here. That is the whole shape of this file, and it is not a preference: this script
28
30
  // carried its own copy of the parsing and rendering until 2026-09-21 and that copy produced three
29
31
  // separate bugs, each of them a drift from the package's own reader — a frontmatter parser that
@@ -32,19 +34,17 @@
32
34
  // A second implementation of this page would be the fourth. Add nothing here; add it to board.mjs.
33
35
  //
34
36
  // The page opens straight from file:// — image sources stay relative to the output file, so the
35
- // HTML stays a few KB and the (gitignored) PNGs are never inlined.
37
+ // HTML stays a few KB and the PNGs are never inlined.
36
38
 
37
39
  import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
38
40
  import { dirname, join, relative, resolve, sep } from "node:path";
39
41
 
40
- // `../src/pin/board.mjs` and not the package root: the root pulls in the lab, which imports
41
- // TypeScript the consumer's Vite compiles and plain Node cannot. The board module is dependency-
42
- // free by design, and it does not reach shot.mjs so a static HTML renderer needs no browser and
43
- // no bundler to run.
42
+ // `../src/pin/board.mjs` and not the package root. The root does load in plain Node now — it
43
+ // imports the compiled core in ../dist/core but it brings the lab integration and that core
44
+ // with it for a job that needs neither. The board module imports only node:fs and node:path, and it
45
+ // does not reach shot.mjs — so a static HTML renderer needs no browser and no bundler to run.
44
46
  import { collectTickets, renderBoard, findRepoRoot } from "../src/pin/board.mjs";
45
47
 
46
- const REPO = findRepoRoot(process.cwd());
47
-
48
48
  /* -------------------------------------------------------------------- main */
49
49
 
50
50
  const args = process.argv.slice(2);
@@ -53,6 +53,41 @@ const flag = (name) => {
53
53
  return at !== -1 && args[at + 1] ? args[at + 1] : null;
54
54
  };
55
55
 
56
+ const USAGE = `Usage: orbytes-pin-gallery [--out <file>] [--backlog-dir <dir>]
57
+
58
+ Writes every pin ticket in <repo>/<backlog-dir>/tasks/, cancelled ones included, as one read-only
59
+ kanban page that opens with no dev server running. <repo> is the current directory or the nearest
60
+ one above it that holds .git, so run it from inside the site's repo.
61
+
62
+ --out <file> where to write the page, relative to the current directory
63
+ (default: <repo>/<backlog-dir>/gallery.html)
64
+ --backlog-dir <dir> the board directory, relative to <repo> (default: backlog)
65
+ -h, --help print this and exit, writing nothing
66
+
67
+ If any ticket cannot be read it writes nothing and exits 1.
68
+ The live board is <subpath>/tasks on astro dev (/lab/tasks by default).`;
69
+
70
+ // Every argument is accounted for, or the run is refused. Until 2026-09-24 nothing checked them:
71
+ // a typo — `--output`, `--out=x.html`, `--help` itself — was ignored and the default gallery was
72
+ // written anyway, exit 0, which reads exactly like the command having done what was asked.
73
+ const VALUE_FLAGS = ["--out", "--backlog-dir"];
74
+ for (let i = 0; i < args.length; i++) {
75
+ const arg = args[i];
76
+ if (arg === "--help" || arg === "-h") {
77
+ console.log(USAGE);
78
+ process.exit(0);
79
+ }
80
+ if (VALUE_FLAGS.includes(arg)) {
81
+ i++; // its value, read below by `flag()`
82
+ continue;
83
+ }
84
+ console.error(`pin-gallery: unknown argument ${JSON.stringify(arg)} — nothing was written.\n`);
85
+ console.error(USAGE);
86
+ process.exit(2);
87
+ }
88
+
89
+ const REPO = findRepoRoot(process.cwd());
90
+
56
91
  // The board directory, and the reason this flag exists at all.
57
92
  //
58
93
  // A binary cannot read the integration's options — it runs in a different process, from a shell,
@@ -67,7 +102,7 @@ const outPath = flag("--out")
67
102
  ? resolve(process.cwd(), flag("--out"))
68
103
  : join(REPO, backlogDir, "gallery.html");
69
104
 
70
- // `includeCancelled`: this file is the offline twin of `/pin`, so it shows what `/pin` shows —
105
+ // `includeCancelled`: this file is the offline twin of the live board, so it shows what that shows —
71
106
  // archive rail included. The default is the other way round precisely because every OTHER reader
72
107
  // is agent-facing, and a cancelled ticket must not reach an agent's context (ruled 2026-09-22).
73
108
  const { tickets, broken, paths } = collectTickets(REPO, { backlogDir, includeCancelled: true });
@@ -84,7 +119,7 @@ const { tickets, broken, paths } = collectTickets(REPO, { backlogDir, includeCan
84
119
  // on disk is left exactly as it was — stale, but the message below says so in as many words, which
85
120
  // is the difference between a stale file and a lie.
86
121
  //
87
- // The `/pin` board does the opposite and shows the panel instead, because a live page that refuses is a
122
+ // The live board does the opposite and shows the panel instead, because a live page that refuses is a
88
123
  // blank screen with nothing to act on. A file that refuses is simply the file you already had.
89
124
  if (broken.length) {
90
125
  console.error(`pin-gallery: refusing to write — ${broken.length} ticket(s) could not be read:`);
@@ -92,7 +127,7 @@ if (broken.length) {
92
127
  console.error(` ${tickets.length} readable ticket(s) were NOT written anywhere.`);
93
128
  console.error(` An agent reading the board hits the same wall: listTickets() throws on the first`);
94
129
  console.error(` of these and returns no tickets at all. Fix the ticket(s) and run again.`);
95
- console.error(` The /pin board lists them by name on the dev server, if it is running.`);
130
+ console.error(` The live board (<subpath>/tasks, /lab/tasks by default) lists them by name on the dev server, if it is running.`);
96
131
  if (existsSync(outPath)) {
97
132
  const stamp = statSync(outPath).mtime.toLocaleString("en-AU", { dateStyle: "medium", timeStyle: "short" });
98
133
  console.error(` ${relative(REPO, outPath) || outPath} was left untouched (written ${stamp}) and is now out of date.`);
@@ -108,8 +143,7 @@ const assetHref = (t) => (t.shotAbs ? relative(outDir, t.shotAbs).split(sep).joi
108
143
  // The directory may not exist yet: on a fresh install `backlog/` is only created when the first
109
144
  // ticket is written, and this command can run before that — or be pointed somewhere else entirely
110
145
  // with `--out`. Measured 2026-09-22 on a clean project: without this the command died with a raw
111
- // `ENOENT ... open 'backlog/gallery.html'` stack trace. Its sibling `orbytes-lab-cull` already
112
- // handles the same case.
146
+ // `ENOENT ... open 'backlog/gallery.html'` stack trace.
113
147
  mkdirSync(outDir, { recursive: true });
114
148
  writeFileSync(outPath, renderBoard(tickets, broken, { assetHref }), "utf8");
115
149
 
@@ -117,5 +151,5 @@ const missing = tickets.filter((t) => !assetHref(t)).length;
117
151
  console.log(`pin-gallery: ${tickets.length} ticket(s), ${missing} without a screenshot`);
118
152
  console.log(` → ${relative(REPO, outPath) || outPath}`);
119
153
  console.log(` read from ${relative(REPO, paths.tasks)}`);
120
- console.log(` the live board is /pin on the dev server; this file is the read-only standalone copy`);
154
+ console.log(` the live board is <subpath>/tasks on the dev server (/lab/tasks by default); this file is the read-only standalone copy`);
121
155
  if (!existsSync(paths.assets)) console.log(` note: ${relative(REPO, paths.assets)} does not exist yet`);