@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
package/src/pin/index.mjs CHANGED
@@ -6,18 +6,19 @@
6
6
  // ONE integration and gets both:
7
7
  //
8
8
  // import orbytesLab from "@orbytes/astrolab";
9
- // integrations: [ includeLab ? orbytesLab({ css: [...] }) : null ] // /lab and /pin
9
+ // integrations: [ includeLab ? orbytesLab({ css: [...] }) : null ] // /lab and /lab/tasks
10
10
  // integrations: [ includeLab ? orbytesLab({ pin: false }) : null ] // /lab alone
11
11
  //
12
12
  // `orbytesLab()` returns an array Astro flattens, and this integration is one of its entries — see
13
13
  // ../../index.mjs, which also hands it the lab's RESOLVED subpath so the board's "Lab" link points
14
- // at wherever the lab actually is. Calling this module directly still works and is what the tests
15
- // do; nothing about it assumes the lab is present.
14
+ // at wherever the lab actually is. Calling this module directly still works — it is exported as
15
+ // `orbytesPin` from the package root and at `@orbytes/astrolab/pin` — and nothing about it assumes
16
+ // the lab is present.
16
17
  //
17
18
  // No cloud, no API key, no MCP, no second process — ../../docs/PIN-CONTRACT.md. It replaces the
18
19
  // hosted-widget → issue-tracker feedback chain for a solo build pass, and only for that.
19
20
  //
20
- // Three jobs:
21
+ // Four jobs:
21
22
  // 1. stamp `data-orbytes-src="<repo-relative path>"` on the first top-level element of every
22
23
  // `.astro` file under the configured directories, so the picker can name the file an element
23
24
  // came from (./source-stamp.mjs — Astro 7 emits no source attribute of its own).
@@ -28,19 +29,28 @@
28
29
  // 4. serve the KANBAN at `<route>` — every ticket in `backlog/tasks`, read fresh on each
29
30
  // request — its screenshots at `<route>/assets/*`, and the one write endpoint at
30
31
  // `<route>/api/ticket`, all from the dev server you are already on (./board.mjs, which
31
- // `orbytes-pin-gallery` renders the standalone file with too).
32
+ // `orbytes-pin-gallery` renders the standalone file with too). Inside the lab the kanban
33
+ // itself is not this middleware's: it is ../chrome/views/Tasks.astro, injected at
34
+ // `<route>` and `<route>/all`, and the middleware keeps the screenshots and the endpoint.
32
35
  //
33
- // `route` DEFAULTS TO `/pin` AND SHADOWS ANY HOST PAGE AT THAT PATH. This middleware is installed
34
- // before Astro's own request handler, so a site with its own `src/pages/pin.astro` would serve the
35
- // board instead of its page, in dev only, with nothing on screen to say why. The default stays —
36
- // `/pin` is the chosen address and the one every doc names — and the collision is a
37
- // one-liner to avoid: `pin: { route: "/__pin" }`. Said here, in ../../docs/PIN.md and in
38
- // ../../README.md, because a silent shadow is only findable if somebody wrote it down.
36
+ // WHERE `<route>` IS, AND WHAT IT SHADOWS. On its own this integration defaults `route` to `/pin`
37
+ // and draws the board there as middleware. Through `orbytesLab()` it is `<subpath>/tasks`
38
+ // (`/lab/tasks` by default), and the middleware answers `/pin` — the board's address until
39
+ // 2026-09-24 — with a 302 to it, unless `route` is itself `/pin`. The middleware is installed
40
+ // before Astro's own request handler, so a site with its own `src/pages/pin.astro` gets the board
41
+ // (alone) or the redirect (in the lab) instead of its page, in dev only, with nothing on screen to
42
+ // say why. On its own the collision is a one-liner to avoid: `pin: { route: "/__pin" }`. Inside
43
+ // the lab that is not enough: the redirect fires for every `route` except `/pin`, and a `route` of
44
+ // `/pin` injects the Tasks views there instead, so only `pin: false` frees the path. Said here, in
45
+ // ../../docs/PIN.md and in ../../README.md, because a silent shadow is only findable if somebody
46
+ // wrote it down.
39
47
  //
40
- // The board is MIDDLEWARE, not an injected route, and the choice is deliberate. The lab injects its
48
+ // On its own the board is MIDDLEWARE, not an injected route, and the choice is deliberate. (Inside
49
+ // the lab it is an injected route after all — the Tasks views, injected in `astro:config:setup`
50
+ // past the dev-only assertion, so they are no more able to reach a build.) The lab injects its
41
51
  // pages because they are prerendered; this one is generated per request from files outside the
42
52
  // site, so it belongs with the lab's other half — the mark APIs it serves from `astro:server:setup`.
43
- // That hook does not exist in a build, so `/pin` cannot leak into `dist/` even by accident, and
53
+ // That hook does not exist in a build, so `<route>` cannot leak into `dist/` even by accident, and
44
54
  // nothing has to be marked `prerender: false` in a project with `output: "static"` and no adapter.
45
55
  // ── backlog.md is retired (2026-09-22) ──────────────────────────────────────────────────────────
46
56
  // Until today `<route>` was read-only and pointed at backlog.md's own web UI — a second server on
@@ -188,7 +198,8 @@ const DEFAULTS = {
188
198
  /** Site-relative directories whose `.astro` files get the source stamp. */
189
199
  stamp: ["src/lab/sections", "src/components"],
190
200
  /**
191
- * Repo-relative `.astro` paths exempt from the stamp's hard failure. A file whose template has
201
+ * Site-relative `.astro` paths exempt from the stamp's hard failure (a repo-relative path is
202
+ * accepted too — ./source-stamp.mjs resolves each entry both ways). A file whose template has
192
203
  * no element to stamp throws by design; this is the only way past it, and it is a deliberate,
193
204
  * named decision per file rather than a warning that lets every such file through silently.
194
205
  */
@@ -216,10 +227,12 @@ const DEFAULTS = {
216
227
  shots: true,
217
228
  /**
218
229
  * Where the board is served, dev only. `<route>/assets/*` serves its screenshots and
219
- * `<route>/api/ticket` is the one write endpoint.
230
+ * `<route>/api/ticket` is the one write endpoint. `orbytesLab()` sets it to `<subpath>/tasks`;
231
+ * `/pin` is the default only when this integration is used on its own.
220
232
  *
221
233
  * It is MIDDLEWARE and it shadows a host page at the same path (see the note at the top of this
222
- * file). A site with its own `/pin` page sets something else here.
234
+ * file). Used on its own, a site with its own `/pin` page sets something else here; inside the
235
+ * lab no value frees `/pin` (see the note at the top of this file).
223
236
  */
224
237
  route: "/pin",
225
238
  /**
@@ -232,6 +245,14 @@ const DEFAULTS = {
232
245
  appId: "orbytes-pin",
233
246
  appName: "Pin",
234
247
  icon: "bug",
248
+ /**
249
+ * Set by `orbytesLab()` (../../index.mjs), never by a consumer: `{ shared }`, the object the two
250
+ * halves talk through. When present, the board is drawn IN THE LAB'S CHROME at `<route>` and
251
+ * `<route>/all` (../chrome/views/Tasks.astro, injected here, dev only) instead of as this
252
+ * middleware's standalone page, and `/pin` — the board's address until 2026-09-24 — redirects
253
+ * there. Absent, the pin half behaves exactly as it did on its own.
254
+ */
255
+ lab: null,
235
256
  };
236
257
 
237
258
  /** The dev server's own origin, read from Vite. Never hardcode 4321 — Astro picks a free port. */
@@ -257,7 +278,7 @@ export default function orbytesPin(options = {}) {
257
278
  return {
258
279
  name: "orbytes-pin",
259
280
  hooks: {
260
- "astro:config:setup": ({ config: astroConfig, command, updateConfig, addDevToolbarApp, logger }) => {
281
+ "astro:config:setup": ({ config: astroConfig, command, updateConfig, addDevToolbarApp, injectRoute, logger }) => {
261
282
  // Assertion one. In `build`, `preview` or `sync` this integration registers nothing at
262
283
  // all — no plugin, no toolbar app, no stamp.
263
284
  if (command !== "dev") {
@@ -288,6 +309,21 @@ export default function orbytesPin(options = {}) {
288
309
  }
289
310
  project = String(config.project ?? projectName(repoRoot));
290
311
 
312
+ // Inside the lab: the Tasks views are Astro pages in the lab's chrome, injected HERE — past
313
+ // assertion one, so they exist in `astro dev` and in no build — and the lab is told where
314
+ // they are through the shared object, which is how its Tasks group knows to appear.
315
+ if (config.lab && config.lab.shared) {
316
+ const route = boardRoute(config.route);
317
+ injectRoute({ pattern: `${route}/[...view]`, entrypoint: file("../chrome/views/Tasks.astro"), prerender: true });
318
+ config.lab.shared.tasks = {
319
+ base: route,
320
+ api: `${route}/api/ticket`,
321
+ assets: `${route}/assets/`,
322
+ repoRoot,
323
+ backlogDir: config.backlogDir,
324
+ };
325
+ }
326
+
291
327
  addDevToolbarApp({
292
328
  id: config.appId,
293
329
  name: config.appName,
@@ -402,7 +438,7 @@ export default function orbytesPin(options = {}) {
402
438
  // The wall, and its screenshots. Registered here rather than as an injected route (see the
403
439
  // note at the top of this file), which also puts it ahead of Astro's own request handler:
404
440
  // Astro installs that in a Vite post hook, after every integration's middleware is in
405
- // place, so `/pin` reaches this and never reaches Astro's 404.
441
+ // place, so every path this answers reaches it and never reaches Astro's 404.
406
442
  server.middlewares.use((req, res, next) => {
407
443
  let url;
408
444
  try {
@@ -411,7 +447,15 @@ export default function orbytesPin(options = {}) {
411
447
  return next();
412
448
  }
413
449
  const path = url.pathname;
414
- const wall = path === route || path === `${route}/`;
450
+ // The board's old address, kept as a redirect for bookmarks and muscle memory.
451
+ if (config.lab && route !== "/pin" && (path === "/pin" || path === "/pin/")) {
452
+ res.statusCode = 302;
453
+ res.setHeader("Location", route + url.search);
454
+ return res.end();
455
+ }
456
+ // Inside the lab the board itself is an Astro page (injected above), so the wall is not
457
+ // this middleware's to draw — only its screenshots and its one write endpoint are.
458
+ const wall = !config.lab && (path === route || path === `${route}/`);
415
459
  const api = path === apiPath;
416
460
  if (!wall && !api && !path.startsWith(assetPrefix)) return next();
417
461
 
@@ -513,9 +557,10 @@ export default function orbytesPin(options = {}) {
513
557
  // Read from disk on every request — that is the whole point of the route, and why
514
558
  // `orbytes-pin-gallery` is no longer part of looking at the board.
515
559
  try {
516
- // `includeCancelled`: `/pin` is the ONE surface where the archive is visible. Every
517
- // agent-facing read drops cancelled tickets by default — a cancelled ticket must not
518
- // take up context space for an agent — and an archive nobody can open is a delete.
560
+ // `includeCancelled`: the human-facing surfaces — this wall, the lab's Tasks views and
561
+ // the gallery file — are where the archive is visible. Every agent-facing read drops
562
+ // cancelled tickets by default — a cancelled ticket must not take up context space for
563
+ // an agent — and an archive nobody can open is a delete.
519
564
  const { tickets, broken } = collectTickets(repoRoot, { backlogDir: config.backlogDir, includeCancelled: true });
520
565
  const html = renderBoard(tickets, broken, {
521
566
  // The presence of an endpoint is what makes the page writable. The standalone
@@ -120,8 +120,9 @@ export function isOpen(status) {
120
120
  * Is this ticket archived — cancelled, and therefore invisible to every agent-facing read?
121
121
  *
122
122
  * Ruled 2026-09-22: items in here must not override anything or take up context space for an
123
- * agent. `listTickets` drops these unless explicitly asked for them; `/pin` is the one place
124
- * they are still visible, because an archive nobody can open is a delete.
123
+ * agent. `listTickets` drops these unless explicitly asked for them. The human-facing surfaces
124
+ * still show them — the lab's Tasks views, the pin half's standalone board and
125
+ * `orbytes-pin-gallery` — because an archive nobody can open is a delete.
125
126
  *
126
127
  * @param {unknown} status
127
128
  */
@@ -672,9 +673,9 @@ export function parseTicket(text, file = "<unknown file>") {
672
673
  * that never heard of the rule still cannot be handed a dead ticket, and one that wants the
673
674
  * archive has to say so in the call.
674
675
  *
675
- * `includeCancelled: true` returns them, and the only callers that pass it are the two surfaces
676
- * that draw the archive — `/pin` and `orbytes-pin-gallery` — plus `updateTicket`, which has to be able to
677
- * find a cancelled ticket in order to move it back out.
676
+ * `includeCancelled: true` returns them. Nothing in this package passes it here: the surfaces that
677
+ * draw the archive and `updateTicket`, which has to be able to find a cancelled ticket in order to
678
+ * move it back out, pass it to `collectTickets` (./board.mjs) instead.
678
679
  *
679
680
  * Statuses come back RAW (› `parseTicket`), so the filter normalises before it compares: a ticket
680
681
  * whose file still says `Done` is `Resolved`, and is live.
@@ -698,7 +698,7 @@ const BOARD_ROUTE = (pinConfig && pinConfig.route) || '/pin';
698
698
  * The panel has exactly TWO tabs, which is fixed and not a preference, so the six map onto them:
699
699
  * **Open is everything that is not `Resolved` and not `Cancelled`**, `Resolved` is its own tab, and
700
700
  * `Cancelled` appears in neither and draws no marker — drawing an archived ticket on the page is
701
- * precisely the "taking up space" the status exists to stop. It stays reachable on `/pin`.
701
+ * precisely the "taking up space" the status exists to stop. It stays reachable on the board.
702
702
  *
703
703
  * These names are for CLASSIFYING and LABELLING only. Every write sends the status string the
704
704
  * board itself reported (› `setStatus`), never a string from this list, because `expect` is
@@ -860,6 +860,10 @@ export default defineToolbarApp({
860
860
  }
861
861
 
862
862
  let mode = 'idle'; // idle | picking | selected
863
+ /** Whether Astro has this app toggled on — the lab's Comment button shows it as pressed. */
864
+ let appActive = false;
865
+ /** Set by the lab's Comment: the element whose box confines the pick (› the lab channel). */
866
+ let scope = null;
863
867
  let hoverEl = null;
864
868
  let selectedEl = null;
865
869
  /**
@@ -1633,6 +1637,7 @@ export default defineToolbarApp({
1633
1637
  syncMarkers();
1634
1638
  // The count on the toolbar button is read from the same list, so it moves with it.
1635
1639
  updateTicketsButton();
1640
+ publish();
1636
1641
  }
1637
1642
  }
1638
1643
 
@@ -2280,6 +2285,7 @@ export default defineToolbarApp({
2280
2285
 
2281
2286
  if (next === 'picking') {
2282
2287
  if (!surface.isConnected) canvas.append(surface);
2288
+ fitSurface();
2283
2289
  hideChrome(hoverHighlight);
2284
2290
  // The picker owns clicks while it is armed — a marker must not swallow one that
2285
2291
  // was meant to pin the element underneath it.
@@ -2300,6 +2306,18 @@ export default defineToolbarApp({
2300
2306
  clearCrumbs();
2301
2307
  hideChrome(selectedHighlight);
2302
2308
  }
2309
+ publish();
2310
+ }
2311
+
2312
+ /** The surface covers the page — or only the scope's box, when the lab's Comment set one. */
2313
+ function fitSurface() {
2314
+ const r = scope && scope.isConnected ? scope.getBoundingClientRect() : null;
2315
+ Object.assign(
2316
+ surface.style,
2317
+ r
2318
+ ? { left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px`, right: 'auto', bottom: 'auto' }
2319
+ : { left: '0', top: '0', right: '0', bottom: '0', width: '', height: '' },
2320
+ );
2303
2321
  }
2304
2322
 
2305
2323
  function hideChrome(el) {
@@ -2362,6 +2380,7 @@ export default defineToolbarApp({
2362
2380
  }
2363
2381
 
2364
2382
  function reposition() {
2383
+ if (mode === 'picking') fitSurface();
2365
2384
  if (mode === 'picking' && hoverEl) {
2366
2385
  place(hoverHighlight, hoverEl, hoverFrames);
2367
2386
  const { source } = resolveSource(hoverEl);
@@ -2424,6 +2443,10 @@ export default defineToolbarApp({
2424
2443
  * @returns {{ el: Element|null, frames: Element[], note: string|null }}
2425
2444
  */
2426
2445
  function hitTest(x, y) {
2446
+ if (scope) {
2447
+ const r = scope.getBoundingClientRect();
2448
+ if (x < r.left || x > r.right || y < r.top || y > r.bottom) return { el: null, frames: [], note: null };
2449
+ }
2427
2450
  let doc = document;
2428
2451
  let px = x;
2429
2452
  let py = y;
@@ -2439,7 +2462,8 @@ export default defineToolbarApp({
2439
2462
  }
2440
2463
 
2441
2464
  const frame = frameUnder(el, px, py);
2442
- if (!frame) return { el, frames: frames.slice(), note: null };
2465
+ // A scoped pick (the lab's Comment) pins what is IN the frame, never the lab around it.
2466
+ if (!frame) return scope && !frames.length ? { el: null, frames: [], note: null } : { el, frames: frames.slice(), note: null };
2443
2467
 
2444
2468
  const inner = frameDocument(frame);
2445
2469
  if (!inner) {
@@ -2881,6 +2905,19 @@ export default defineToolbarApp({
2881
2905
  // box constantly — and a dictated paragraph cannot be retyped from memory. So Escape
2882
2906
  // never discards text: with anything in the box it holds the selection, the words and
2883
2907
  // the caret, and points at Cancel, which is the one control that discards on purpose.
2908
+ //
2909
+ // Inside the lab, Escape ends Comment mode rather than stopping at idle. Decided
2910
+ // 2026-09-24: the lab's Comment is one mode — pick, write, send — so wherever Escape would
2911
+ // leave this app on and idle ("Nothing selected", with Comment still pressed), it turns the
2912
+ // app off instead, exactly as pressing Comment again does. Only a pick the lab started has a
2913
+ // `scope`; the toolbar's own button keeps the steps above. The branch that holds a comment
2914
+ // still runs first, and turning the app off never empties the box, so a word typed here
2915
+ // survives every Escape.
2916
+ function leaveOrIdle() {
2917
+ if (scope) app.toggleState({ state: false });
2918
+ else setMode('idle');
2919
+ }
2920
+
2884
2921
  function onKeydown(event) {
2885
2922
  // The climb. Capture phase and preventDefault, because ⌥↑ in a textarea is
2886
2923
  // "move the caret to the start of the paragraph" on macOS and would otherwise
@@ -2904,7 +2941,8 @@ export default defineToolbarApp({
2904
2941
  swallowEscapeKeyup = true;
2905
2942
  // The badge and the hint line both change on setMode — saying it again in words
2906
2943
  // was the log talking to itself.
2907
- setMode(selectedEl ? 'selected' : 'idle');
2944
+ if (selectedEl) setMode('selected');
2945
+ else leaveOrIdle();
2908
2946
  } else if (mode === 'selected') {
2909
2947
  event.preventDefault();
2910
2948
  event.stopPropagation();
@@ -2915,7 +2953,7 @@ export default defineToolbarApp({
2915
2953
  return;
2916
2954
  }
2917
2955
  clearSelection();
2918
- setMode('idle');
2956
+ leaveOrIdle();
2919
2957
  } else if (panelOpen) {
2920
2958
  // Idle, with the panel open. Escape peels one layer at a time — the expanded entry,
2921
2959
  // then the panel — and only once both are shut does it reach Astro's own
@@ -2936,6 +2974,13 @@ export default defineToolbarApp({
2936
2974
  } else {
2937
2975
  closePanel();
2938
2976
  }
2977
+ } else if (scope && appActive) {
2978
+ // Idle in the lab, with nothing left to peel: close the app here rather than hope
2979
+ // Astro's own Escape handler is armed, which it is only after a real hover.
2980
+ event.preventDefault();
2981
+ event.stopPropagation();
2982
+ swallowEscapeKeyup = true;
2983
+ leaveOrIdle();
2939
2984
  }
2940
2985
  }
2941
2986
 
@@ -2951,6 +2996,8 @@ export default defineToolbarApp({
2951
2996
  /* ---------- lifecycle ---------- */
2952
2997
 
2953
2998
  app.onToggled(({ state }) => {
2999
+ appActive = Boolean(state);
3000
+ if (!state) scope = null;
2954
3001
  if (state) {
2955
3002
  setMode(selectedEl ? 'selected' : 'picking');
2956
3003
  // Astro hides an app's whole canvas when the app is toggled off, so the markers go
@@ -3129,6 +3176,37 @@ export default defineToolbarApp({
3129
3176
  }
3130
3177
  });
3131
3178
 
3179
+ /* ---------- the lab's channel ---------- */
3180
+
3181
+ /**
3182
+ * The component lab drives this app from its own navbar — the Comment button, and the pins
3183
+ * it draws over its framed preview — and imports nothing from this file, so the door in and
3184
+ * the window out are plain window events:
3185
+ *
3186
+ * in `orbytes-pin:command` { action: 'pick', within?: Element } | { action: 'stop' }
3187
+ * out `orbytes-pin:state` { active, mode, tickets } after every mode change, toggle and
3188
+ * board read; the same snapshot sits on `window.__orbytesPin` for a late listener.
3189
+ *
3190
+ * `within` confines the pick to that element's box, and to what lies inside a frame there,
3191
+ * so Comment pins the previewed page and never the lab's chrome around it. The toolbar's own
3192
+ * button still opens the app unscoped, exactly as before.
3193
+ */
3194
+ function publish() {
3195
+ const detail = { active: appActive, mode, tickets: panelTickets };
3196
+ window.__orbytesPin = detail;
3197
+ window.dispatchEvent(new CustomEvent('orbytes-pin:state', { detail }));
3198
+ }
3199
+ window.addEventListener('orbytes-pin:command', (event) => {
3200
+ const { action, within } = (event && event.detail) || {};
3201
+ if (action === 'pick') {
3202
+ scope = within instanceof Element ? within : null;
3203
+ if (appActive) setMode(selectedEl ? 'selected' : 'picking');
3204
+ else app.toggleState({ state: true });
3205
+ } else if (action === 'stop') {
3206
+ app.toggleState({ state: false });
3207
+ }
3208
+ });
3209
+
3132
3210
  setMode('idle');
3133
3211
  restoreAfterReload();
3134
3212
  // Read the board once at start-up so the markers are on the page the moment the app is