@orbytes/astrolab 0.3.0 → 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 (82) hide show
  1. package/README.md +261 -116
  2. package/bin/pin-gallery.mjs +53 -19
  3. package/defaults.mjs +72 -20
  4. package/dist/core/virtual-module/virtual-routes.js +12 -1
  5. package/docs/PIN-CONTRACT.md +84 -10
  6. package/docs/PIN.md +117 -37
  7. package/index.d.ts +41 -23
  8. package/index.mjs +46 -91
  9. package/package.json +7 -3
  10. package/src/Home.astro +166 -264
  11. package/src/LabHead.astro +37 -1047
  12. package/src/chrome/ActionsMenu.astro +97 -0
  13. package/src/chrome/ComponentCard.astro +76 -0
  14. package/src/chrome/Icon.astro +21 -0
  15. package/src/chrome/LICENSE-icons +43 -0
  16. package/src/chrome/Nav.astro +131 -0
  17. package/src/chrome/Panel.astro +117 -0
  18. package/src/chrome/Properties.astro +104 -0
  19. package/src/chrome/SectionsTree.astro +128 -0
  20. package/src/chrome/Shell.astro +120 -0
  21. package/src/chrome/Sprite.astro +23 -0
  22. package/src/chrome/StoryView.astro +192 -0
  23. package/src/chrome/Tree.astro +86 -0
  24. package/src/chrome/ViewportControls.astro +173 -0
  25. package/src/chrome/ViewportStage.astro +55 -0
  26. package/src/chrome/fonts/OFL.txt +93 -0
  27. package/src/chrome/fonts/inter-latin-wght-normal.woff2 +0 -0
  28. package/src/chrome/icons.ts +68 -0
  29. package/src/chrome/marks-client.ts +75 -0
  30. package/src/chrome/model.ts +156 -0
  31. package/src/chrome/navbar-client.ts +324 -0
  32. package/src/chrome/params-client.ts +434 -0
  33. package/src/chrome/pins-data.ts +63 -0
  34. package/src/chrome/shell-client.ts +468 -0
  35. package/src/chrome/site-data.ts +230 -0
  36. package/src/chrome/trees.ts +257 -0
  37. package/src/chrome/viewport-client.ts +405 -0
  38. package/src/chrome/views/Assets.astro +125 -0
  39. package/src/chrome/views/Pages.astro +214 -0
  40. package/src/chrome/views/Placeholder.astro +37 -0
  41. package/src/chrome/views/Tasks.astro +79 -0
  42. package/src/core/LICENSE-astrobook +21 -0
  43. package/src/core/lib/components/home.astro +4 -2
  44. package/src/core/lib/pages/story.astro +12 -10
  45. package/src/core/utils/kebab-case.ts +2 -2
  46. package/src/core/virtual-module/virtual-routes.ts +20 -4
  47. package/src/pin/board.mjs +414 -190
  48. package/src/pin/index.mjs +67 -22
  49. package/src/pin/tickets.mjs +6 -5
  50. package/src/pin/toolbar.js +82 -4
  51. package/src/shell/Browse.astro +131 -353
  52. package/src/shell/Viewport.astro +22 -1315
  53. package/src/shell/lab-index.ts +28 -18
  54. package/src/shell/lab-params.ts +113 -6
  55. package/src/shell/live-files.mjs +212 -10
  56. package/src/shell/marks.mjs +17 -41
  57. package/src/ui/components/app.astro +5 -7
  58. package/src/ui/components/preview-layout.astro +17 -0
  59. package/src/ui/components/theme-script.astro +17 -5
  60. package/src/ui/lab.css +3754 -371
  61. package/virtual.d.ts +13 -4
  62. package/bin/lab-cull.mjs +0 -401
  63. package/src/shell/CardGrid.astro +0 -297
  64. package/src/ui/components/build-path.ts +0 -13
  65. package/src/ui/components/build-tree.ts +0 -108
  66. package/src/ui/components/collapse-duration.ts +0 -28
  67. package/src/ui/components/compress-terms.ts +0 -10
  68. package/src/ui/components/dashboard-layout.astro +0 -39
  69. package/src/ui/components/home.astro +0 -65
  70. package/src/ui/components/layout.astro +0 -110
  71. package/src/ui/components/sidebar-button-fullscreen.astro +0 -38
  72. package/src/ui/components/sidebar-button-search.astro +0 -23
  73. package/src/ui/components/sidebar-button-theme.astro +0 -9
  74. package/src/ui/components/sidebar-button.astro +0 -24
  75. package/src/ui/components/sidebar-resize-handle.astro +0 -74
  76. package/src/ui/components/sidebar-search-panel.astro +0 -41
  77. package/src/ui/components/sidebar-search-script.ts +0 -103
  78. package/src/ui/components/sidebar-title.astro +0 -17
  79. package/src/ui/components/sidebar-tree-node.astro +0 -143
  80. package/src/ui/components/sidebar-tree.astro +0 -84
  81. package/src/ui/components/sidebar.astro +0 -29
  82. package/src/ui/components/theme-toggle.astro +0 -63
@@ -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`);
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,74 @@ 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 },
35
+ };
36
+
37
+ /**
38
+ * The screen sizes the viewport offers — a per-site setting (decided 2026-09-24), defaulting to the
39
+ * orbytes scale.
40
+ *
41
+ * - `design` — the widths the design is drawn at: the navbar's device switch, in this order. The
42
+ * `icon` names one of the chrome's icons (monitor, tablet, phone-landscape, phone).
43
+ * - `breakpoints` — the lower edge of each CSS band, widest first; the last is 0. The readout
44
+ * names the band the frame is in, and the presets offer both sides of every edge (992 and 991).
45
+ * - `devices` — named sizes that set width AND height.
46
+ *
47
+ * @typedef {{ label: string; width: number; icon: string }} DesignWidth
48
+ * @typedef {{ name: string; min: number }} Breakpoint
49
+ * @typedef {{ label: string; width: number; height: number }} Device
50
+ * @typedef {{ design: DesignWidth[]; breakpoints: Breakpoint[]; devices: Device[] }} Viewports
51
+ */
52
+ /** @type {Viewports} */
53
+ export const DEFAULT_VIEWPORTS = {
54
+ design: [
55
+ { label: "Desktop", width: 1440, icon: "monitor" },
56
+ { label: "Tablet", width: 834, icon: "tablet" },
57
+ { label: "Phone landscape", width: 550, icon: "phone-landscape" },
58
+ { label: "Phone", width: 390, icon: "phone" },
59
+ ],
60
+ breakpoints: [
61
+ { name: "desktop", min: 992 },
62
+ { name: "tablet", min: 768 },
63
+ { name: "phone landscape", min: 480 },
64
+ { name: "phone", min: 0 },
65
+ ],
66
+ devices: [
67
+ { label: "iPhone 15", width: 390, height: 844 },
68
+ { label: "iPhone 15 Pro Max", width: 430, height: 932 },
69
+ { label: "iPad", width: 834, height: 1194 },
70
+ { label: "iPad landscape", width: 1194, height: 834 },
71
+ { label: "MacBook", width: 1440, height: 900 },
72
+ { label: "Wide", width: 1920, height: 1080 },
73
+ { label: "Ultra-wide", width: 2560, height: 1080 },
74
+ ],
75
+ };
76
+
77
+ /** @param {unknown} value @returns {Viewports} */
78
+ const resolveViewports = (value) => {
79
+ const given = value && typeof value === "object" ? /** @type {Partial<Viewports>} */ (value) : {};
80
+ const list = (v, fallback) => (Array.isArray(v) && v.length ? v : fallback);
81
+ return {
82
+ design: list(given.design, DEFAULT_VIEWPORTS.design).map((d) => ({
83
+ label: String(d.label ?? `${d.width}`),
84
+ width: Number(d.width),
85
+ icon: String(d.icon ?? "monitor"),
86
+ })),
87
+ breakpoints: list(given.breakpoints, DEFAULT_VIEWPORTS.breakpoints)
88
+ .map((b) => ({ name: String(b.name), min: Number(b.min) || 0 }))
89
+ .sort((a, b) => b.min - a.min),
90
+ devices: list(given.devices, DEFAULT_VIEWPORTS.devices).map((d) => ({
91
+ label: String(d.label),
92
+ width: Number(d.width),
93
+ height: Number(d.height),
94
+ })),
95
+ };
39
96
  };
40
97
 
41
98
  /** `./src/lab/` and `src/lab` both mean `src/lab`. @param {string} value */
@@ -57,19 +114,17 @@ export const normaliseSubpath = (value) => {
57
114
  * @property {string} id the first path segment under `directory`
58
115
  * @property {string} label shown on the tier card and the folder pages
59
116
  * @property {boolean} responsive section versions: responsive marks, page-width thumbnails
60
- * @property {boolean} cullable may be marked for deletion
61
117
  */
62
118
 
63
119
  /** @param {string | Partial<LabTier>} tier @returns {LabTier} */
64
120
  const resolveTier = (tier) => {
65
121
  const raw = typeof tier === "string" ? { id: tier } : { ...tier };
66
122
  const id = String(raw.id ?? "");
67
- const role = /** @type {Record<string, {responsive?: boolean, cullable?: boolean}>} */ (TIER_ROLES)[id] ?? {};
123
+ const role = /** @type {Record<string, {responsive?: boolean}>} */ (TIER_ROLES)[id] ?? {};
68
124
  return {
69
125
  id,
70
126
  label: raw.label ?? (id ? id.charAt(0).toUpperCase() + id.slice(1) : "Root"),
71
127
  responsive: raw.responsive ?? role.responsive ?? false,
72
- cullable: raw.cullable ?? role.cullable ?? false,
73
128
  };
74
129
  };
75
130
 
@@ -84,11 +139,10 @@ const resolveTier = (tier) => {
84
139
  * @property {string | null} feedbucketKey
85
140
  * @property {LabTier[]} tiers
86
141
  * @property {string | null} sectionsTier the id of the tier holding the responsive role
87
- * @property {string | null} cullTier the id of the tier holding the cull role
88
142
  * @property {string | null} responsiveDir `<directory>/<sectionsTier>/`, or null
89
- * @property {string | null} cullDir `<directory>/<cullTier>/`, or null
90
143
  * @property {string} responsiveFile `<directory>/responsive.json`
91
- * @property {string} cullFile `<directory>/cull.json`
144
+ * @property {Viewports} viewports the viewport's screen sizes (DEFAULT_VIEWPORTS)
145
+ * @property {null} tasks filled in by the integration when the pin board runs
92
146
  */
93
147
 
94
148
  /**
@@ -100,7 +154,6 @@ export function resolveLabOptions(options = {}) {
100
154
  const directory = normaliseDirectory(options.directory ?? DEFAULT_DIRECTORY);
101
155
  const tiers = (Array.isArray(options.tiers) ? options.tiers : DEFAULT_TIERS).map(resolveTier);
102
156
  const sections = tiers.find((tier) => tier.responsive) ?? null;
103
- const cull = tiers.find((tier) => tier.cullable) ?? null;
104
157
  return {
105
158
  directory,
106
159
  subpath: normaliseSubpath(options.subpath ?? DEFAULT_SUBPATH),
@@ -111,10 +164,9 @@ export function resolveLabOptions(options = {}) {
111
164
  feedbucketKey: options.feedbucketKey ? String(options.feedbucketKey) : null,
112
165
  tiers,
113
166
  sectionsTier: sections ? sections.id : null,
114
- cullTier: cull ? cull.id : null,
115
167
  responsiveDir: sections ? `${directory}/${sections.id}/` : null,
116
- cullDir: cull ? `${directory}/${cull.id}/` : null,
117
168
  responsiveFile: `${directory}/responsive.json`,
118
- cullFile: `${directory}/cull.json`,
169
+ viewports: resolveViewports(options.viewports),
170
+ tasks: null,
119
171
  };
120
172
  }
@@ -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
@@ -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", …)`
@@ -182,6 +204,48 @@ site setting `pin: { route: "/__pin" }` got a panel that fetched a path the midd
182
204
  serve and showed an empty list — fixed 2026-09-22. One function normalises the trailing
183
205
  slash, `boardRoute`, and both the middleware and the toolbar read it, so they cannot disagree.
184
206
 
207
+ **The board page moved into the lab on 2026-09-24.** Through `orbytesLab()` the route is
208
+ `<subpath>/tasks` and the page there is an Astro page in the lab's chrome
209
+ (`../src/chrome/views/Tasks.astro`), not this middleware's standalone document. The panel's contract
210
+ with it is unchanged, because the page renders the SAME markup from the same function
211
+ (`boardParts`): it still reads `[data-board]`, its `data-api`, every `[data-col][data-status]`, the
212
+ `script.card-data` payloads and `.broken li code` off the page at `route`. A change to those
213
+ selectors in `board.mjs` is a change to this contract.
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
+
185
249
  ## The status line — one line, not a log
186
250
 
187
251
  Decided 2026-09-22, replacing a running four-entry list that narrated every step. The log was
@@ -215,7 +279,7 @@ binding Astro 7 uses contains zero. So this package stamps its own `data-orbytes
215
279
  via a dev-only Vite `load` hook. **Expect it to break again** — Astro dropped these once before,
216
280
  silently, with no deprecation.
217
281
 
218
- 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
219
283
  is the guard, not an inconvenience: a warned-past file silently produces tickets with no source.
220
284
 
221
285
  Selectors are built up to the **shortest form resolving to exactly one node**, verified against the
@@ -247,15 +311,25 @@ four times independently, and verified on real production builds — normal and
247
311
  the inner `<button>` in their shadow root.
248
312
  - **Markers belong in the toolbar canvas, never `document.body`.** The screenshotter hides only
249
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.
250
321
 
251
322
  ## Chrome
252
323
 
253
324
  Neutral tool chrome, never the host site's styling — decided for this board and, separately, for
254
325
  the component lab. A standard board that reads the same in every project it is installed in.
255
326
 
256
- Every value is a custom property in one namespaced block (`--pin-*`, on `.pin-chrome`). No literal
257
- colours outside it, and **no bare element selectors** anywhere — those inherit down into the
258
- 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.
259
333
 
260
334
  **The `--pin-*` namespace survived the merge and is still the board's own** (2026-09-22). Repointing
261
335
  it at the lab's `--lab-*` tokens is the shared-chrome design — lab phase 2 — and was deliberately