@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/board.mjs CHANGED
@@ -1,14 +1,17 @@
1
1
  // orbytes-pin — the pin board: one reader, one writer, one renderer, two surfaces.
2
2
  //
3
- // Two things consume this module and NOTHING here knows which:
4
- // · the `/pin` route on `astro dev` (index.mjs, dev-only middleware) — generated per request,
5
- // and the only surface where the writer is reachable;
6
- // · `npx orbytes-pin-gallery` (../../bin/pin-gallery.mjs) — one standalone file on disk.
3
+ // Three things consume this module and NOTHING here knows which:
4
+ // · the lab's Tasks views at `<subpath>/tasks` on `astro dev` (../chrome/views/Tasks.astro,
5
+ // through `boardParts`) — generated per request, with the writer reachable;
6
+ // · the pin half's own board at `<route>` when it runs without the lab (index.mjs, dev-only
7
+ // middleware, through `renderBoard`) — the same, standalone;
8
+ // · `npx orbytes-pin-gallery` (../../bin/pin-gallery.mjs) — one standalone file on disk, and the
9
+ // only one of the three with no writer.
7
10
  //
8
11
  // It exists because a second copy is how this system's last three bugs happened, all of them in
9
12
  // the gallery script and all of them the same shape: a local parser that drifted from the one in
10
13
  // tickets.mjs (see `collectTickets` below). A live route rendering its own cards would have been
11
- // the fourth. So the route and the script share this file and differ in exactly two arguments —
14
+ // the fourth. So every surface shares this file and they differ in exactly two arguments —
12
15
  // where a screenshot is fetched from (`assetHref`) and whether the page is live (`apiHref`).
13
16
  //
14
17
  // ── backlog.md is retired (2026-09-22) ──────────────────────────────────────────────────────
@@ -209,8 +212,8 @@ export const TICKET_FILE = /^pin-\d+.*\.md$/;
209
212
 
210
213
  /**
211
214
  * Every ticket in `<backlogDir>/tasks`, plus the ones that could not be read. Read fresh from disk
212
- * on every call — that is what makes the `/pin` route live and `orbytes-pin-gallery` a snapshot of the
213
- * same moment.
215
+ * on every call — that is what makes the dev-server board live and `orbytes-pin-gallery` a snapshot of
216
+ * the same moment.
214
217
  *
215
218
  * ONE reader: `parseTicket`, which owns the whole file — the frontmatter as much as the ```yaml
216
219
  * pin block and the comment. It spreads the frontmatter keys at the top level of what it returns,
@@ -237,9 +240,11 @@ export const TICKET_FILE = /^pin-\d+.*\.md$/;
237
240
  *
238
241
  * `Cancelled` tickets are EXCLUDED unless `includeCancelled` is passed, for the same reason
239
242
  * `listTickets` excludes them (ruled 2026-09-22: they must not take up context space for an
240
- * agent). The default is the safe one everywhere, and the three callers that genuinely need the
241
- * archive — the `/pin` route, `orbytes-pin-gallery`, and `updateTicket`, which must be able to move a
242
- * ticket back OUT of Cancelled — each say so explicitly at the call site.
243
+ * agent). The default is the safe one everywhere, and the callers that genuinely need the archive
244
+ * each say so explicitly at the call site: the three human-facing surfaces (the lab's Tasks views,
245
+ * the pin half's standalone board, `orbytes-pin-gallery`), the write endpoint's re-read of the card
246
+ * it just moved (index.mjs), and `updateTicket`, which must be able to move a ticket back OUT of
247
+ * Cancelled.
243
248
  *
244
249
  * A ticket that cannot be READ is still collected into `broken` whatever its status, because a
245
250
  * file the parser rejects has no trustworthy status to filter on.
@@ -387,7 +392,7 @@ export function collectTickets(repoRoot, { backlogDir = "backlog", includeCancel
387
392
  * deliberate: this endpoint is the board's own write path and the board is the surface a person
388
393
  * drags on, so it cannot tell that drop from a script's POST and must not try. Refusing
389
394
  * `Resolved` here would break the one person the rule exists for. An agent finishing a ticket
390
- * writes `Ready for Review` and stops.
395
+ * writes `Ready for review` and stops.
391
396
  *
392
397
  * ── Refusals, not warnings ───────────────────────────────────────────────────────────────────
393
398
  * Every one of these throws with a sentence naming the ticket, and nothing is written:
@@ -423,7 +428,7 @@ export function updateTicket(repoRoot, change, { backlogDir = "backlog", now = n
423
428
  if (change?.status !== undefined) {
424
429
  // Normalised BEFORE the allowlist, so a caller written against the old three-column board —
425
430
  // or a hand-rolled curl — sends `Done` and gets `Resolved` written, rather than a refusal on a
426
- // word this package still understands. The allowlist itself stays the four current statuses:
431
+ // word this package still understands. The allowlist itself stays the six current statuses:
427
432
  // it is the entire input sanitiser here, and `Done` is never written to a file again.
428
433
  const wanted = normaliseStatus(change.status);
429
434
  if (!STATUSES.includes(wanted)) {
@@ -588,14 +593,19 @@ const slug = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").re
588
593
  *
589
594
  * 1 → yes. The overwhelming majority, and this returns null for it: a healthy card
590
595
  * emits no extra element at all, so a healthy board looks exactly as it did.
591
- * 0 → no. The element could not be found on reload. Everything else on the ticket is
592
- * still correct — the picture, the source file, the outer_html — so it is perfectly
593
- * workable by hand, and that is what the banner says. It is not an error.
596
+ * 0 → no. The selector finds nothing on reload, so the flag's note says an agent
597
+ * cannot locate the element. Everything else on the ticket is still correct — the picture,
598
+ * the source file, the outer_html — so a person can work it from those. It is not
599
+ * an error.
594
600
  * 2 or more → no. Several elements match, so an agent might edit the wrong one.
595
601
  * "pending" → never tested (shots off, or the screenshot failed). Not a problem; a quiet line
596
602
  * in the meta list, no banner.
597
603
  * undefined → the ticket predates the field. Nothing is shown, because nothing is known.
598
604
  *
605
+ * The flag is a label and nothing more. It changes no status, and neither `isAgentReady` nor
606
+ * `listTickets` (tickets.mjs) reads `selector_matches`, so a flagged ticket left in `Ready for agent`
607
+ * is still handed to an agent. Only its status keeps an agent off it.
608
+ *
599
609
  * The comparison is `=== 0`, on a value that is already a number. Writing this as a truthiness test
600
610
  * is the one mistake the field was shaped to prevent — see the note on `selectorMatches` above.
601
611
  */
@@ -671,16 +681,10 @@ const haystack = (t) =>
671
681
  * @param {(ticket: object) => string | null} assetHref where THIS surface fetches a screenshot
672
682
  * @param {boolean} live whether the card can be dragged and written
673
683
  */
674
- function card(t, assetHref, live) {
684
+ function cardData(t, assetHref) {
675
685
  const href = assetHref(t);
676
686
  const flag = selectorFlag(t.selectorMatches);
677
-
678
- const dims = t.shotW && t.shotH ? ` width="${t.shotW}" height="${t.shotH}"` : "";
679
- const thumb = href
680
- ? `<div class="thumb"><img src="${esc(href)}"${dims} alt="" loading="lazy" decoding="async"></div>`
681
- : `<div class="thumb thumb--empty"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 5h18v14H3z" fill="none" stroke="currentColor" stroke-width="1.4"/><circle cx="8.5" cy="10" r="1.6" fill="currentColor"/><path d="m4 17 5-4.5 3.5 3L16 12l4 4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg><span>No screenshot yet</span></div>`;
682
-
683
- const data = {
687
+ return {
684
688
  id: t.id,
685
689
  title: t.title,
686
690
  status: t.status,
@@ -703,6 +707,18 @@ function card(t, assetHref, live) {
703
707
  shotExpected: t.shotExpected,
704
708
  commentHtml: commentHtml(t.comment, "dtext"),
705
709
  };
710
+ }
711
+
712
+ function card(t, assetHref, live) {
713
+ const href = assetHref(t);
714
+ const flag = selectorFlag(t.selectorMatches);
715
+
716
+ const dims = t.shotW && t.shotH ? ` width="${t.shotW}" height="${t.shotH}"` : "";
717
+ const thumb = href
718
+ ? `<div class="thumb"><img src="${esc(href)}"${dims} alt="" loading="lazy" decoding="async"></div>`
719
+ : `<div class="thumb thumb--empty"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 5h18v14H3z" fill="none" stroke="currentColor" stroke-width="1.4"/><circle cx="8.5" cy="10" r="1.6" fill="currentColor"/><path d="m4 17 5-4.5 3.5 3L16 12l4 4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg><span>No screenshot yet</span></div>`;
720
+
721
+ const data = cardData(t, assetHref);
706
722
 
707
723
  return `<article class="card${flag ? ` card--${flag.kind}` : ""}" data-card data-id="${esc(t.id)}" data-status="${esc(t.status)}" data-priority="${esc(t.priority ?? "")}" data-flag="${flag ? "1" : "0"}" data-search="${esc(haystack(t))}" tabindex="0" role="button" aria-label="${esc(t.id)}: ${esc(t.title)}">
708
724
  ${flag ? `<div class="flag flag--${flag.kind}" title="${esc(flag.note)}"><svg viewBox="0 0 24 24" aria-hidden="true">${flag.icon}</svg><span>${esc(flag.label)}</span></div>` : ""}
@@ -743,116 +759,44 @@ function brokenPanel(broken) {
743
759
  }
744
760
 
745
761
  /**
746
- * The board, as one HTML document: a plain kanban, three columns, neutral tool chrome.
747
- *
748
- * ── Why it looks like nothing ────────────────────────────────────────────────────────────────
749
- * Ruled 2026-09-22: the board is never styled in the styles of the site being built — it is a
750
- * standard kanban board, easy to use and easy to take in at a glance. This is a tool,
751
- * not a deliverable. The palette here is greys with coloured status and priority pills — the
752
- * register of the backlog.md UI it replaces — and deliberately shares nothing with the client's
753
- * site. A board that borrows the brand makes a screenshot of a bug look like a page of the site,
754
- * which is precisely the wrong thing when every card ON it is a screenshot of the site.
762
+ * One row of the All Tasks table — the same ticket, the same `card-data` payload and the same
763
+ * data attributes as its card, so the detail panel, the search, the chips and the write path work on
764
+ * it unchanged. Rows are not dragged; the detail panel's menus move them.
765
+ */
766
+ function row(t, assetHref) {
767
+ const flag = selectorFlag(t.selectorMatches);
768
+ const data = cardData(t, assetHref);
769
+ const created = t.created ? String(t.created) : "";
770
+ const updated = t.updated ? String(t.updated) : created;
771
+ return `<tr class="trow${flag ? ` trow--${flag.kind}` : ""}" data-card data-id="${esc(t.id)}" data-status="${esc(t.status)}" data-priority="${esc(t.priority ?? "")}" data-flag="${flag ? "1" : "0"}" data-search="${esc(haystack(t))}" data-sort-id="${esc(t.id)}" data-sort-title="${esc(String(t.title ?? "").toLowerCase())}" data-sort-status="${esc(STATUSES.indexOf(t.status) === -1 ? 99 : STATUSES.indexOf(t.status))}" data-sort-priority="${esc(PRIORITIES.indexOf(t.priority) === -1 ? 9 : PRIORITIES.indexOf(t.priority))}" data-sort-source="${esc(t.source ?? "")}" data-sort-updated="${esc(updated)}" tabindex="0" role="button" aria-label="${esc(t.id)}: ${esc(t.title)}">
772
+ <td class="tt-id"><span class="tid">${esc(t.id)}</span></td>
773
+ <td class="tt-title">${esc(t.title)}${flag ? ` <span class="pill pill--flag-${flag.kind}" title="${esc(flag.note)}">${esc(flag.label)}</span>` : ""}</td>
774
+ <td data-cell="status"><span class="dot dot--${esc(slug(t.status))}"></span>${esc(t.status)}</td>
775
+ <td data-cell="priority">${t.priority ? `<span class="pill pill--pri-${esc(slug(t.priority))}">${esc(t.priority)}</span>` : ""}</td>
776
+ <td>${t.dispatch ? `<span class="pill pill--${t.dispatch === "now" ? "now" : "queue"}">${esc(t.dispatch)}</span>` : ""}</td>
777
+ <td class="tt-src" title="${esc(t.source ?? "")}">${t.source && t.source !== "unresolved" ? esc(t.source) : "<em>unresolved</em>"}</td>
778
+ <td class="tt-date">${esc(updated.slice(0, 16).replace("T", " "))}</td>
779
+ <td class="tt-data"><script type="application/json" class="card-data">${jsonScript(data)}</script></td>
780
+ </tr>`;
781
+ }
782
+
783
+ /**
784
+ * The board's stylesheet — ONE copy, used by both surfaces: the standalone file and the lab's
785
+ * Tasks views (../chrome/views/Tasks.astro).
755
786
  *
756
- * Both surfaces render from here. The only differences are `assetHref` (where a screenshot is
757
- * fetched from) and `apiHref` (the write endpoint) — and `apiHref` is what makes the board
758
- * writable: without it the page has no drag handlers and no status menus, which is exactly the
759
- * standalone file's situation, since nothing is listening on the other end of a `file://` page.
787
+ * SCOPED WITH `@scope (.pin-board)` since 2026-09-24, when the board moved into the lab's chrome
788
+ * (decided that day: one URL, `<subpath>/tasks`, drawn in the lab's own furniture). Until then it
789
+ * was a document of its own and styled `body`, `*` and `dialog` freely; inside the lab those would
790
+ * have restyled the chrome around it. Now every rule stops at the board's own root, and its column
791
+ * breakpoints are CONTAINER queries on that root rather than viewport queries — in the lab the
792
+ * board is the width of the main area, not of the window.
760
793
  *
761
- * @param {object[]} tickets from `collectTickets`
762
- * @param {{file: string, reason: string}[]} broken
763
- * @param {object} options
764
- * @param {(ticket: object) => string | null} options.assetHref where this surface fetches a shot
765
- * @param {string|null} [options.apiHref] the write endpoint; null makes the page read-only
766
- * @param {Date} [options.generatedAt]
794
+ * Its palette is its own greys and status colours standalone (and follows the OS dark mode there);
795
+ * `.pin-board--lab` maps it onto the lab's `--lab-*` tokens instead, so it themes with the chrome.
767
796
  */
768
- export function renderBoard(tickets, broken = [], options = {}) {
769
- const { assetHref = () => null, apiHref = null, generatedAt = new Date(), links = null } = options;
770
- const live = Boolean(apiHref);
771
-
772
- // The corner links. `<a href="/lab">Lab</a>` was written into the markup until 2026-09-22 — a
773
- // guess that is right only while the lab sits at its default subpath, and one a consumer had no
774
- // way to correct. It is an option now, and in the merged package the caller can do better than
775
- // an option's default: `orbytesLab()` reads its OWN resolved `subpath` and passes that, so the
776
- // link is derived from the same value the routes are injected at rather than agreeing with it
777
- // by luck. `[]` draws nothing; `null` keeps the historical pair for a standalone caller.
778
- const navLinks = (Array.isArray(links) ? links : [{ href: "/", label: "Site" }, { href: "/lab", label: "Lab" }])
779
- .filter((l) => l && l.href)
780
- .map((l) => ({ href: String(l.href), label: String(l.label ?? l.href) }));
781
- const nav = navLinks.length
782
- ? `<nav class="links">${navLinks.map((l) => `<a class="link" href="${esc(l.href)}">${esc(l.label)}</a>`).join("")}</nav>`
783
- : "";
784
-
785
- // Every status on disk, in board order, with the four known ones always present so an empty
786
- // column is still a place to drop a card. Anything else found on disk gets a column too, marked
787
- // undroppable: the writer refuses a status outside the allowlist, so offering it as a target
788
- // would be a promise this board cannot keep. A legacy `Done` never reaches here as a column of
789
- // its own — `collectTickets` has already normalised it to `Resolved`.
790
- const seen = new Set(tickets.map((t) => t.status));
791
- const columns = [...STATUSES, ...[...seen].filter((s) => !STATUSES.includes(s)).sort()];
792
-
793
- const counts = Object.fromEntries(columns.map((s) => [s, tickets.filter((t) => t.status === s).length]));
794
- // Both of these are calls to action, so they are counted over LIVE tickets only — a cancelled
795
- // ticket with a dead selector is not a job, and putting it in the tally would send you looking
796
- // for work in the archive.
797
- const liveTickets = tickets.filter((t) => t.status !== CANCELLED);
798
- const needsHuman = liveTickets.filter((t) => selectorFlag(t.selectorMatches)).length;
799
- const missing = liveTickets.filter((t) => !assetHref(t)).length;
800
- // The number the whole 2026-09-22 change exists to put on screen: how many tickets an agent has
801
- // finished and no human has accepted. It leads the summary line, ahead of every other count.
802
- const waiting = counts[READY_FOR_REVIEW] ?? 0;
803
- const archived = counts[CANCELLED] ?? 0;
804
- const stamp = generatedAt.toLocaleString("en-AU", { dateStyle: "medium", timeStyle: "short" });
805
-
806
- // One line per column saying what it MEANS. Six nouns with no gloss is a vocabulary you have to
807
- // be told once and then remember, and four of these six carry a real rule — who may start work,
808
- // who may close it, what an agent never sees. The line is the cheapest place to keep the rule
809
- // where the decision is made.
810
- const COLUMN_NOTES = {
811
- [BACKLOG]: "not for agents yet",
812
- [READY_FOR_AGENT]: "agents start here",
813
- [IN_PROGRESS]: "an agent is on it",
814
- [READY_FOR_REVIEW]: "waiting on you",
815
- [RESOLVED]: "you accept these",
816
- [CANCELLED]: "archive — agents skip it",
817
- };
818
-
819
- const board = columns
820
- .map((status) => {
821
- const rows = tickets.filter((t) => t.status === status);
822
- const known = STATUSES.includes(status);
823
- const note = COLUMN_NOTES[status];
824
- const kind = status === READY_FOR_REVIEW ? " col--review" : status === CANCELLED ? " col--archive" : "";
825
- return `<section class="col${kind}" data-col data-status="${esc(status)}" data-droppable="${live && known ? "1" : "0"}" aria-label="${esc(status)}${note ? ` — ${esc(note)}` : ""}">
826
- <header class="col-head"><span class="dot dot--${esc(slug(status))}"></span><span class="col-name">${esc(status)}</span><span class="count" data-count>${rows.length}</span>${note ? `<span class="col-note">${esc(note)}</span>` : ""}</header>
827
- <div class="col-body" data-body>
828
- ${rows.map((t) => card(t, assetHref, live)).join("\n")}
829
- <p class="col-empty" data-empty>${live && known ? "Drop a card here" : "Nothing here"}</p>
830
- </div>
831
- </section>`;
832
- })
833
- .join("\n");
834
-
835
- const chips = [`<button type="button" class="chip is-on" data-filter="all">All <b data-chip-count="all">${tickets.length}</b></button>`]
836
- .concat(
837
- columns.map(
838
- (s) =>
839
- `<button type="button" class="chip${s === READY_FOR_REVIEW ? " chip--review" : ""}" data-filter="${esc(s)}">${esc(s)} <b data-chip-count="${esc(s)}">${counts[s]}</b></button>`,
840
- ),
841
- )
842
- .join("");
843
-
844
- const statusOptions = STATUSES.map((s) => `<option value="${esc(s)}">${esc(s)}</option>`).join("");
845
- const priorityOptions = PRIORITIES.map((p) => `<option value="${esc(p)}">${esc(p)}</option>`).join("");
846
-
847
- return `<!doctype html>
848
- <html lang="en">
849
- <head>
850
- <meta charset="utf-8">
851
- <meta name="viewport" content="width=device-width, initial-scale=1">
852
- <title>Pin board — ${esc(tickets.length)} ticket${tickets.length === 1 ? "" : "s"}</title>
853
- <style>
854
- :root{
855
- color-scheme:light dark;
797
+ export const BOARD_CSS = String.raw`@scope (.pin-board) {
798
+ :scope{
799
+ container:pin-board / inline-size;
856
800
  --bg:#f5f6f8; --col:#eceef1; --panel:#fff; --line:#d7dce2; --soft:#e8ebef;
857
801
  --ink:#141a20; --dim:#5b6874; --faint:#8a95a1;
858
802
  --accent:#2563eb; --accent-soft:#dbe6fe;
@@ -872,7 +816,7 @@ export function renderBoard(tickets, broken = [], options = {}) {
872
816
  --r:8px;
873
817
  }
874
818
  @media (prefers-color-scheme:dark){
875
- :root{
819
+ :scope:not(.pin-board--lab){
876
820
  --bg:#0f1317; --col:#171c22; --panel:#1d242b; --line:#2b333c; --soft:#232b33;
877
821
  --ink:#e6edf3; --dim:#9aa7b4; --faint:#7c8894;
878
822
  --accent:#60a5fa; --accent-soft:#1e3a5f;
@@ -887,10 +831,9 @@ export function renderBoard(tickets, broken = [], options = {}) {
887
831
  --shadow:0 1px 2px rgba(0,0,0,.5); --lift:0 8px 20px -6px rgba(0,0,0,.7);
888
832
  }
889
833
  }
890
- *{box-sizing:border-box}
891
- html{-webkit-text-size-adjust:100%}
892
- body{
893
- margin:0;background:var(--bg);color:var(--ink);
834
+ :scope,:scope *{box-sizing:border-box}
835
+ :scope{
836
+ background:var(--bg);color:var(--ink);
894
837
  font:14px/1.5 ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
895
838
  }
896
839
  code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
@@ -1031,8 +974,8 @@ code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
1031
974
  in its column as a hole until the drop lands — which is what makes a refused write a visible
1032
975
  snap-back rather than a card that was never anywhere. */
1033
976
  .card.ghost{position:fixed;z-index:80;margin:0;pointer-events:none;opacity:.95;box-shadow:var(--lift);transform:rotate(1.2deg)}
1034
- body.is-dragging-card{user-select:none;cursor:grabbing}
1035
- body.is-dragging-card *{cursor:grabbing!important}
977
+ :scope.is-dragging-card{user-select:none;cursor:grabbing}
978
+ :scope.is-dragging-card *{cursor:grabbing!important}
1036
979
  .card.is-busy{opacity:.55;pointer-events:none}
1037
980
  .card--dead{box-shadow:var(--shadow),inset 3px 0 0 var(--dead)}
1038
981
  .card--many{box-shadow:var(--shadow),inset 3px 0 0 var(--many)}
@@ -1144,19 +1087,19 @@ dialog::backdrop{background:rgba(10,14,18,.55)}
1144
1087
  #toast.is-ok{background:var(--s-resolved)}
1145
1088
  /* Six columns need more steps down than three did. The archive keeps its narrow track only while
1146
1089
  the live columns are still side by side; once they wrap it is just another column, last. */
1147
- @media (max-width:1400px){
1090
+ @container pin-board (max-width:1400px){
1148
1091
  .board{grid-template-columns:repeat(3,minmax(0,1fr))}
1149
1092
  }
1150
- @media (max-width:1040px){
1093
+ @container pin-board (max-width:1040px){
1151
1094
  .board{grid-template-columns:repeat(2,minmax(0,1fr))}
1152
1095
  }
1153
- @media (max-width:700px){
1096
+ @container pin-board (max-width:700px){
1154
1097
  .board{grid-template-columns:1fr;gap:12px}
1155
1098
  .col-head{position:static}
1156
1099
  .col-body{min-height:0}
1157
1100
  .col--archive .card{opacity:1;filter:none}
1158
1101
  }
1159
- @media (max-width:560px){
1102
+ @container pin-board (max-width:560px){
1160
1103
  .top{padding:9px 12px;gap:8px}
1161
1104
  .board{padding:12px}
1162
1105
  .search{max-width:none;flex-basis:100%}
@@ -1169,52 +1112,88 @@ dialog::backdrop{background:rgba(10,14,18,.55)}
1169
1112
  .tools{order:2;flex-basis:100%}
1170
1113
  }
1171
1114
  @media (prefers-reduced-motion:reduce){*{transition:none!important}}
1172
- </style>
1173
- </head>
1174
- <body>
1175
- <header class="top">
1176
- <div class="brand">
1177
- <h1>Pin board</h1>
1178
- <p class="sub">${waiting ? `<b class="ready">${esc(waiting)}</b> ready for your review · ` : ""}<b>${esc(tickets.length)}</b> ticket${tickets.length === 1 ? "" : "s"}${needsHuman ? ` · <b class="warn">${esc(needsHuman)}</b> needing a human` : ""}${missing ? ` · <b>${esc(missing)}</b> without a screenshot` : ""}${broken.length ? ` · <b class="warn">${esc(broken.length)}</b> unreadable` : ""}${archived ? ` · ${esc(archived)} archived` : ""}${live ? "" : ` · snapshot, ${esc(stamp)}`}</p>
1179
- </div>
1180
- <div class="tools">
1181
- <div class="search">
1182
- <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6.4" fill="none" stroke="currentColor" stroke-width="2"/><path d="m16 16 4.6 4.6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
1183
- <input id="q" type="search" placeholder="Search title, comment, file, label…" autocomplete="off" aria-label="Search tickets">
1184
- </div>
1185
- <div class="chips" role="group" aria-label="Filter by status">${chips}</div>
1186
- <label class="check"><input type="checkbox" id="human"> Needs a human</label>
1187
- </div>
1188
- ${live ? nav : ""}
1189
- </header>
1190
- ${brokenPanel(broken)}
1191
- ${tickets.length ? `<main class="board" data-board data-live="${live ? "1" : "0"}"${live ? ` data-api="${esc(apiHref)}"` : ""}>${board}</main>` : `<p class="none">No tickets in <code>backlog/tasks/</code> yet.</p>`}
1192
1115
 
1193
- <dialog id="detail" aria-label="Ticket detail">
1194
- <div class="d-head">
1195
- <div>
1196
- <h2 data-d-title></h2>
1197
- <div class="card-row"><span class="tid" data-d-id></span><span data-d-dispatch></span></div>
1198
- </div>
1199
- <button type="button" class="d-close" data-d-close aria-label="Close">&times;</button>
1200
- </div>
1201
- <div class="d-body">
1202
- <div data-d-flag></div>
1203
- <div class="d-controls">
1204
- <label class="field"><span>Status</span><select data-d-status ${live ? "" : "disabled"}>${statusOptions}</select></label>
1205
- <label class="field"><span>Priority</span><select data-d-priority ${live ? "" : "disabled"}>${priorityOptions}</select></label>
1206
- </div>
1207
- <div data-d-shot></div>
1208
- <div data-d-comment></div>
1209
- <dl class="d-meta" data-d-meta></dl>
1210
- <div class="labels" data-d-labels></div>
1211
- </div>
1212
- </dialog>
1213
- <div id="toast" role="status" aria-live="polite"></div>
1214
- <script>
1215
- (function(){
1116
+ /* ── the All Tasks table ───────────────────────────────────────────────── */
1117
+ .tt-wrap{padding:16px}
1118
+ .tt-id{white-space:nowrap}
1119
+ .tt{width:100%;border-collapse:separate;border-spacing:0;background:var(--panel);border:1px solid var(--line);border-radius:10px;font-size:13px}
1120
+ .tt th{position:sticky;top:var(--top-h,57px);z-index:3;background:var(--panel);text-align:left;padding:0;border-bottom:1px solid var(--line);white-space:nowrap}
1121
+ .tt th:first-child{border-top-left-radius:10px}
1122
+ .tt th:last-child{border-top-right-radius:10px}
1123
+ .tt th button{appearance:none;display:flex;align-items:center;gap:4px;width:100%;padding:10px 12px;border:0;background:none;cursor:pointer;font:inherit;font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--dim);text-align:left}
1124
+ .tt th button:hover{color:var(--ink)}
1125
+ .tt th[aria-sort="ascending"] button::after{content:"↑"}
1126
+ .tt th[aria-sort="descending"] button::after{content:"↓"}
1127
+ .tt td{padding:9px 12px;border-bottom:1px solid var(--soft);vertical-align:middle}
1128
+ .tt tbody tr:last-child td{border-bottom:0}
1129
+ .trow{cursor:pointer}
1130
+ .trow:hover td{background:var(--soft)}
1131
+ .trow:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
1132
+ .trow.is-busy{opacity:.55;pointer-events:none}
1133
+ .trow--dead td:first-child{box-shadow:inset 3px 0 0 var(--dead)}
1134
+ .trow--many td:first-child{box-shadow:inset 3px 0 0 var(--many)}
1135
+ .tt-title{font-weight:600;min-width:220px;overflow-wrap:anywhere}
1136
+ .tt-title .pill{margin-left:6px;vertical-align:1px}
1137
+ .pill--flag-dead{background:var(--dead);color:var(--dead-ink)}
1138
+ .pill--flag-many{background:var(--many);color:var(--many-ink)}
1139
+ .tt td[data-cell="status"]{white-space:nowrap}
1140
+ .tt td[data-cell="status"] .dot{display:inline-block;margin-right:7px;vertical-align:1px}
1141
+ .tt-src{max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;color:var(--faint)}
1142
+ .tt-src em{font-style:normal;color:var(--now)}
1143
+ .tt-date{white-space:nowrap;color:var(--dim);font-variant-numeric:tabular-nums}
1144
+ .tt-data{display:none}
1145
+ @container pin-board (max-width:900px){
1146
+ .tt-src,.tt th:nth-child(6){display:none}
1147
+ }
1148
+ }
1149
+
1150
+ /* The lab's palette for the board, outside the scope so it outranks the board's own defaults. */
1151
+ .pin-board.pin-board--lab{
1152
+ --bg:var(--lab-bg-secondary); --col:var(--lab-bg-hover); --panel:var(--lab-bg-primary);
1153
+ --line:var(--lab-border-secondary); --soft:var(--lab-bg-hover);
1154
+ --ink:var(--lab-text-primary); --dim:var(--lab-text-tertiary); --faint:var(--lab-text-quaternary);
1155
+ --accent:var(--lab-brand-solid); --accent-soft:var(--lab-bg-active);
1156
+ --review:#6941c6; --review-ink:#fff; --review-soft:#f9f5ff; --review-line:#d6bbfb;
1157
+ --shadow:var(--lab-shadow-xs); --lift:var(--lab-shadow-lg); --r:8px;
1158
+ font-family:var(--lab-font);
1159
+ min-height:100%;
1160
+ }
1161
+ .pin-board.pin-board--lab .top{background:var(--lab-bg-primary)}
1162
+ /* In the lab the board shares the window with two sidebars, so its six columns keep a fixed width
1163
+ and the row scrolls sideways — a board you can read across beats one wrapped into a stack. */
1164
+ .pin-board.pin-board--lab .board{
1165
+ grid-template-columns:repeat(5,minmax(248px,1fr)) minmax(160px,.55fr);
1166
+ overflow-x:auto;
1167
+ }
1168
+ :root.dark .pin-board.pin-board--lab{
1169
+ --s-backlog:#6b7683; --s-ready-for-agent:#2dd4bf; --s-in-progress:#60a5fa;
1170
+ --s-ready-for-review:#c4b5fd; --s-resolved:#4ade80; --s-cancelled:#5c656f;
1171
+ --review:#b692f6; --review-ink:#1a1030; --review-soft:#231b36; --review-line:#53389e;
1172
+ --now:#fca5a5; --now-bg:#3f1d1a; --queue:#a9b6c3; --queue-bg:#262f38;
1173
+ --pri-high:#fca5a5; --pri-high-bg:#3f1d1a;
1174
+ --pri-medium:#a9b6c3; --pri-medium-bg:#262f38;
1175
+ --pri-low:#8a95a1; --pri-low-bg:#212931;
1176
+ --dead:#dc4c42; --dead-ink:#fff; --many:#c2761a; --many-ink:#1a1204;
1177
+ }
1178
+ `;
1179
+
1180
+ /** What only the standalone file needs: it is its own document, so it owns <html> and <body>. */
1181
+ const STANDALONE_CSS = `html{-webkit-text-size-adjust:100%}
1182
+ body{margin:0;background:#f5f6f8}
1183
+ @media (prefers-color-scheme:dark){body{background:#0f1317}}
1184
+ .pin-board{min-height:100vh}`;
1185
+
1186
+ /**
1187
+ * The board's script — ONE copy for both surfaces, like the stylesheet. `String.raw` on purpose:
1188
+ * this used to sit inside the page's template literal, which silently turned every backslash
1189
+ * escape into its bare letter — the search box split queries on the letter "s" (`/\s+/` arrived
1190
+ * as `/s+/`) from 2026-09-22 until this move. A raw string keeps the script byte for byte.
1191
+ */
1192
+ export const BOARD_SCRIPT = String.raw`(function(){
1216
1193
  "use strict";
1194
+ var root = document.querySelector(".pin-board") || document.body;
1217
1195
  var board = document.querySelector("[data-board]");
1196
+ var table = board && board.getAttribute("data-view") === "table";
1218
1197
  var api = board ? board.getAttribute("data-api") : null;
1219
1198
  var dlg = document.getElementById("detail");
1220
1199
  var toastEl = document.getElementById("toast");
@@ -1237,6 +1216,8 @@ ${tickets.length ? `<main class="board" data-board data-live="${live ? "1" : "0"
1237
1216
  clearTimeout(toast.t);
1238
1217
  toast.t = setTimeout(function(){ toastEl.className = ""; }, ok ? 2200 : 6000);
1239
1218
  }
1219
+ function slugOf(s){ return String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "none"; }
1220
+ function escOf(s){ return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;"); }
1240
1221
  function cards(){ return Array.prototype.slice.call(document.querySelectorAll("[data-card]")); }
1241
1222
  function dataOf(el){
1242
1223
  var s = el.querySelector(".card-data");
@@ -1262,6 +1243,7 @@ ${tickets.length ? `<main class="board" data-board data-live="${live ? "1" : "0"
1262
1243
  if (show) { var s = el.getAttribute("data-status"); visible[s] = (visible[s] || 0) + 1; }
1263
1244
  });
1264
1245
  var total = 0;
1246
+ if (table) Object.keys(visible).forEach(function(k){ total += visible[k]; });
1265
1247
  document.querySelectorAll("[data-col]").forEach(function(col){
1266
1248
  var s = col.getAttribute("data-status");
1267
1249
  var n = visible[s] || 0;
@@ -1290,7 +1272,22 @@ ${tickets.length ? `<main class="board" data-board data-live="${live ? "1" : "0"
1290
1272
  applyFilters();
1291
1273
  });
1292
1274
  });
1275
+ // ?status=<name> opens the board filtered to that column — how the lab's level 2 links to one.
1276
+ (function(){
1277
+ var wanted = new URLSearchParams(location.search).get("status");
1278
+ if (!wanted) return;
1279
+ document.querySelectorAll(".chip").forEach(function(chip){
1280
+ if (chip.getAttribute("data-filter") === wanted) {
1281
+ statusFilter = wanted;
1282
+ document.querySelectorAll(".chip").forEach(function(c){ c.classList.toggle("is-on", c === chip); });
1283
+ }
1284
+ });
1285
+ })();
1293
1286
  document.addEventListener("keydown", function(e){
1287
+ // The lab's keyboard guard, when there is one (../LabHead.astro): a "/" typed into the pin
1288
+ // composer is retargeted to the dev toolbar's shadow host, so activeElement alone reads it
1289
+ // as a stray keystroke and steals it.
1290
+ if (window.__labIgnoreKey && window.__labIgnoreKey(e)) return;
1294
1291
  if (e.key === "/" && document.activeElement !== q && !dlg.open) { e.preventDefault(); q.focus(); q.select(); }
1295
1292
  });
1296
1293
 
@@ -1353,10 +1350,31 @@ ${tickets.length ? `<main class="board" data-board data-live="${live ? "1" : "0"
1353
1350
  });
1354
1351
  }
1355
1352
 
1353
+ /* ── sorting (the table view) ──────────────────────────────────────── */
1354
+ if (table) {
1355
+ board.querySelectorAll("[data-sort]").forEach(function(button){
1356
+ button.addEventListener("click", function(){
1357
+ var key = button.getAttribute("data-sort");
1358
+ var th = button.closest("th");
1359
+ var dir = th.getAttribute("aria-sort") === "ascending" ? -1 : 1;
1360
+ board.querySelectorAll("th[aria-sort]").forEach(function(t){ t.removeAttribute("aria-sort"); });
1361
+ th.setAttribute("aria-sort", dir === 1 ? "ascending" : "descending");
1362
+ var body = board.querySelector("tbody");
1363
+ var rows = Array.prototype.slice.call(body.querySelectorAll("[data-card]"));
1364
+ rows.sort(function(a, b){
1365
+ var x = a.getAttribute("data-sort-" + key) || "", y = b.getAttribute("data-sort-" + key) || "";
1366
+ return x < y ? -dir : x > y ? dir : 0;
1367
+ });
1368
+ rows.forEach(function(r){ body.appendChild(r); });
1369
+ });
1370
+ });
1371
+ }
1372
+
1356
1373
  /* ── the write path ────────────────────────────────────────────────── */
1357
1374
  if (!api) { applyFilters(); return; }
1358
1375
 
1359
1376
  function moveTo(el, status){
1377
+ if (table) return;
1360
1378
  var col = document.querySelector('[data-col][data-status="' + status + '"] [data-body]');
1361
1379
  if (col) col.insertBefore(el, col.querySelector("[data-empty]"));
1362
1380
  }
@@ -1382,8 +1400,25 @@ ${tickets.length ? `<main class="board" data-board data-live="${live ? "1" : "0"
1382
1400
  toast(res.body && res.body.error ? res.body.error : "the write was refused", false);
1383
1401
  return;
1384
1402
  }
1385
- var fresh = document.createRange().createContextualFragment(res.body.card).firstElementChild;
1386
- el.replaceWith(fresh);
1403
+ var fresh;
1404
+ if (table) {
1405
+ // A row stays a row: its cells and payload are brought up to date where it stands.
1406
+ fresh = el;
1407
+ var d2 = dataOf(el) || {};
1408
+ d2.status = res.body.status;
1409
+ d2.priority = res.body.priority;
1410
+ el.querySelector(".card-data").textContent = JSON.stringify(d2);
1411
+ el.setAttribute("data-status", res.body.status);
1412
+ el.setAttribute("data-priority", res.body.priority || "");
1413
+ el.setAttribute("data-sort-status", res.body.status);
1414
+ var sc = el.querySelector('[data-cell="status"]');
1415
+ if (sc) sc.innerHTML = '<span class="dot dot--' + slugOf(res.body.status) + '"></span>' + escOf(res.body.status);
1416
+ var pc = el.querySelector('[data-cell="priority"]');
1417
+ if (pc) pc.innerHTML = res.body.priority ? '<span class="pill pill--pri-' + slugOf(res.body.priority) + '">' + escOf(res.body.priority) + "</span>" : "";
1418
+ } else {
1419
+ fresh = document.createRange().createContextualFragment(res.body.card).firstElementChild;
1420
+ el.replaceWith(fresh);
1421
+ }
1387
1422
  if (open === el) { open = fresh; fill(dataOf(fresh)); }
1388
1423
  moveTo(fresh, res.body.status);
1389
1424
  applyFilters();
@@ -1421,12 +1456,13 @@ ${tickets.length ? `<main class="board" data-board data-live="${live ? "1" : "0"
1421
1456
  if (!drag) return;
1422
1457
  if (drag.ghost) drag.ghost.remove();
1423
1458
  drag.el.classList.remove("is-dragging");
1424
- document.body.classList.remove("is-dragging-card");
1459
+ root.classList.remove("is-dragging-card");
1425
1460
  clearHighlight();
1426
1461
  drag = null;
1427
1462
  }
1428
1463
 
1429
1464
  board.addEventListener("pointerdown", function(e){
1465
+ if (table) return; // rows are not dragged; the status menu in the detail panel moves them
1430
1466
  if (e.button !== 0 || e.pointerType === "touch") return;
1431
1467
  var el = e.target.closest("[data-card]");
1432
1468
  if (!el || e.target.closest("a") || el.classList.contains("is-busy")) return;
@@ -1445,10 +1481,10 @@ ${tickets.length ? `<main class="board" data-board data-live="${live ? "1" : "0"
1445
1481
  ghost.classList.add("ghost");
1446
1482
  ghost.removeAttribute("data-card");
1447
1483
  ghost.style.width = box.width + "px";
1448
- document.body.appendChild(ghost);
1484
+ root.appendChild(ghost);
1449
1485
  drag.ghost = ghost;
1450
1486
  drag.el.classList.add("is-dragging");
1451
- document.body.classList.add("is-dragging-card");
1487
+ root.classList.add("is-dragging-card");
1452
1488
  try { drag.el.setPointerCapture(e.pointerId); } catch (x) {}
1453
1489
  drag.started = true;
1454
1490
  }
@@ -1513,7 +1549,195 @@ ${tickets.length ? `<main class="board" data-board data-live="${live ? "1" : "0"
1513
1549
  });
1514
1550
 
1515
1551
  applyFilters();
1516
- })();
1552
+ })();`;
1553
+
1554
+ /**
1555
+ * The board, in parts — each surface lays them out in its own frame. `view: "table"` draws the
1556
+ * All Tasks table instead of the columns; everything else (search, chips, detail panel, the write
1557
+ * path) is shared.
1558
+ *
1559
+ * ── Why it looks like nothing ────────────────────────────────────────────────────────────────
1560
+ * Ruled 2026-09-22: the board is never styled in the styles of the site being built — it is a
1561
+ * standard kanban board, easy to use and easy to take in at a glance. This is a tool,
1562
+ * not a deliverable. A board that borrows the brand makes a screenshot of a bug look like a page of
1563
+ * the site, which is precisely the wrong thing when every card ON it is a screenshot of the site.
1564
+ *
1565
+ * `apiHref` is what makes the board writable: without it the page has no drag handlers and no
1566
+ * status menus, which is exactly the standalone file's situation, since nothing is listening on the
1567
+ * other end of a `file://` page.
1568
+ *
1569
+ * @param {object[]} tickets from `collectTickets`
1570
+ * @param {{file: string, reason: string}[]} broken
1571
+ * @param {object} options
1572
+ * @param {(ticket: object) => string | null} options.assetHref where this surface fetches a shot
1573
+ * @param {string|null} [options.apiHref] the write endpoint; null makes the page read-only
1574
+ * @param {Date} [options.generatedAt]
1575
+ * @param {{href: string, label: string}[]|null} [options.links] corner links; [] draws none
1576
+ * @param {"board"|"table"} [options.view]
1577
+ */
1578
+ export function boardParts(tickets, broken = [], options = {}) {
1579
+ const { assetHref = () => null, apiHref = null, generatedAt = new Date(), links = null, view = "board" } = options;
1580
+ const live = Boolean(apiHref);
1581
+
1582
+ // The corner links. The caller passes its own resolved addresses; `[]` draws nothing; `null`
1583
+ // keeps the historical pair for a standalone caller.
1584
+ const navLinks = (Array.isArray(links) ? links : [{ href: "/", label: "Site" }, { href: "/lab", label: "Lab" }])
1585
+ .filter((l) => l && l.href)
1586
+ .map((l) => ({ href: String(l.href), label: String(l.label ?? l.href) }));
1587
+ const nav = navLinks.length
1588
+ ? `<nav class="links">${navLinks.map((l) => `<a class="link" href="${esc(l.href)}">${esc(l.label)}</a>`).join("")}</nav>`
1589
+ : "";
1590
+
1591
+ // Every status on disk, in board order, with the known ones always present so an empty column is
1592
+ // still a place to drop a card. Anything else found on disk gets a column too, marked
1593
+ // undroppable: the writer refuses a status outside the allowlist.
1594
+ const seen = new Set(tickets.map((t) => t.status));
1595
+ const columns = [...STATUSES, ...[...seen].filter((s) => !STATUSES.includes(s)).sort()];
1596
+
1597
+ const counts = Object.fromEntries(columns.map((s) => [s, tickets.filter((t) => t.status === s).length]));
1598
+ // Both of these are calls to action, so they are counted over LIVE tickets only.
1599
+ const liveTickets = tickets.filter((t) => t.status !== CANCELLED);
1600
+ const needsHuman = liveTickets.filter((t) => selectorFlag(t.selectorMatches)).length;
1601
+ const missing = liveTickets.filter((t) => !assetHref(t)).length;
1602
+ // How many tickets an agent has finished and no human has accepted. It leads the summary.
1603
+ const waiting = counts[READY_FOR_REVIEW] ?? 0;
1604
+ const archived = counts[CANCELLED] ?? 0;
1605
+ const stamp = generatedAt.toLocaleString("en-AU", { dateStyle: "medium", timeStyle: "short" });
1606
+
1607
+ // One line per column saying what it MEANS — four of these six carry a real rule.
1608
+ const COLUMN_NOTES = {
1609
+ [BACKLOG]: "not for agents yet",
1610
+ [READY_FOR_AGENT]: "agents start here",
1611
+ [IN_PROGRESS]: "an agent is on it",
1612
+ [READY_FOR_REVIEW]: "waiting on you",
1613
+ [RESOLVED]: "you accept these",
1614
+ [CANCELLED]: "archive — agents skip it",
1615
+ };
1616
+
1617
+ const board = columns
1618
+ .map((status) => {
1619
+ const rows = tickets.filter((t) => t.status === status);
1620
+ const known = STATUSES.includes(status);
1621
+ const note = COLUMN_NOTES[status];
1622
+ const kind = status === READY_FOR_REVIEW ? " col--review" : status === CANCELLED ? " col--archive" : "";
1623
+ return `<section class="col${kind}" data-col data-status="${esc(status)}" data-droppable="${live && known ? "1" : "0"}" aria-label="${esc(status)}${note ? ` — ${esc(note)}` : ""}">
1624
+ <header class="col-head"><span class="dot dot--${esc(slug(status))}"></span><span class="col-name">${esc(status)}</span><span class="count" data-count>${rows.length}</span>${note ? `<span class="col-note">${esc(note)}</span>` : ""}</header>
1625
+ <div class="col-body" data-body>
1626
+ ${rows.map((t) => card(t, assetHref, live)).join("\n")}
1627
+ <p class="col-empty" data-empty>${live && known ? "Drop a card here" : "Nothing here"}</p>
1628
+ </div>
1629
+ </section>`;
1630
+ })
1631
+ .join("\n");
1632
+
1633
+ const sortable = (key, label) => `<th scope="col"><button type="button" data-sort="${key}">${label}</button></th>`;
1634
+ // Newest first by default: the table is where you go to find the thing you pinned a moment ago.
1635
+ const byUpdated = [...tickets].sort((a, b) =>
1636
+ String(b.updated ?? b.created ?? "").localeCompare(String(a.updated ?? a.created ?? "")),
1637
+ );
1638
+ const table = `<table class="tt">
1639
+ <thead><tr>${sortable("id", "Ticket")}${sortable("title", "Title")}${sortable("status", "Status")}${sortable("priority", "Priority")}<th scope="col"><button type="button" disabled>Dispatch</button></th>${sortable("source", "Source")}${sortable("updated", "Updated")}<th class="tt-data"></th></tr></thead>
1640
+ <tbody>${byUpdated.map((t) => row(t, assetHref)).join("\n")}</tbody>
1641
+ </table>`;
1642
+
1643
+ const chips = [`<button type="button" class="chip is-on" data-filter="all">All <b data-chip-count="all">${tickets.length}</b></button>`]
1644
+ .concat(
1645
+ columns.map(
1646
+ (s) =>
1647
+ `<button type="button" class="chip${s === READY_FOR_REVIEW ? " chip--review" : ""}" data-filter="${esc(s)}">${esc(s)} <b data-chip-count="${esc(s)}">${counts[s]}</b></button>`,
1648
+ ),
1649
+ )
1650
+ .join("");
1651
+
1652
+ const statusOptions = STATUSES.map((s) => `<option value="${esc(s)}">${esc(s)}</option>`).join("");
1653
+ const priorityOptions = PRIORITIES.map((p) => `<option value="${esc(p)}">${esc(p)}</option>`).join("");
1654
+
1655
+ const summary = `${waiting ? `<b class="ready">${esc(waiting)}</b> ready for your review · ` : ""}<b>${esc(tickets.length)}</b> ticket${tickets.length === 1 ? "" : "s"}${needsHuman ? ` · <b class="warn">${esc(needsHuman)}</b> needing a human` : ""}${missing ? ` · <b>${esc(missing)}</b> without a screenshot` : ""}${broken.length ? ` · <b class="warn">${esc(broken.length)}</b> unreadable` : ""}${archived ? ` · ${esc(archived)} archived` : ""}${live ? "" : ` · snapshot, ${esc(stamp)}`}`;
1656
+
1657
+ const tools = `<div class="tools">
1658
+ <div class="search">
1659
+ <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6.4" fill="none" stroke="currentColor" stroke-width="2"/><path d="m16 16 4.6 4.6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
1660
+ <input id="q" type="search" placeholder="Search title, comment, file, label…" autocomplete="off" aria-label="Search tickets">
1661
+ </div>
1662
+ <div class="chips" role="group" aria-label="Filter by status">${chips}</div>
1663
+ <label class="check"><input type="checkbox" id="human"> Needs a human</label>
1664
+ </div>`;
1665
+
1666
+ const liveAttrs = live ? ` data-api="${esc(apiHref)}"` : "";
1667
+ const main = !tickets.length
1668
+ ? `<p class="none">No tickets in <code>backlog/tasks/</code> yet.</p>`
1669
+ : view === "table"
1670
+ ? `<div class="tt-wrap" data-board data-view="table" data-live="${live ? "1" : "0"}"${liveAttrs}>${table}</div>`
1671
+ : `<main class="board" data-board data-live="${live ? "1" : "0"}"${liveAttrs}>${board}</main>`;
1672
+
1673
+ const dialog = `<dialog id="detail" aria-label="Ticket detail">
1674
+ <div class="d-head">
1675
+ <div>
1676
+ <h2 data-d-title></h2>
1677
+ <div class="card-row"><span class="tid" data-d-id></span><span data-d-dispatch></span></div>
1678
+ </div>
1679
+ <button type="button" class="d-close" data-d-close aria-label="Close">&times;</button>
1680
+ </div>
1681
+ <div class="d-body">
1682
+ <div data-d-flag></div>
1683
+ <div class="d-controls">
1684
+ <label class="field"><span>Status</span><select data-d-status ${live ? "" : "disabled"}>${statusOptions}</select></label>
1685
+ <label class="field"><span>Priority</span><select data-d-priority ${live ? "" : "disabled"}>${priorityOptions}</select></label>
1686
+ </div>
1687
+ <div data-d-shot></div>
1688
+ <div data-d-comment></div>
1689
+ <dl class="d-meta" data-d-meta></dl>
1690
+ <div class="labels" data-d-labels></div>
1691
+ </div>
1692
+ </dialog>
1693
+ <div id="toast" role="status" aria-live="polite"></div>`;
1694
+
1695
+ return {
1696
+ title: `Pin board — ${tickets.length} ticket${tickets.length === 1 ? "" : "s"}`,
1697
+ summary,
1698
+ tools,
1699
+ nav: live ? nav : "",
1700
+ broken: brokenPanel(broken),
1701
+ main,
1702
+ dialog,
1703
+ counts: { total: tickets.length, waiting, needsHuman, missing, archived, byStatus: counts },
1704
+ };
1705
+ }
1706
+
1707
+ /**
1708
+ * The board as one standalone HTML document — what `orbytes-pin-gallery` writes, and what the pin
1709
+ * middleware serves when the pin half runs without the lab. Inside the lab the same parts render in
1710
+ * the chrome instead (../chrome/views/Tasks.astro).
1711
+ */
1712
+ export function renderBoard(tickets, broken = [], options = {}) {
1713
+ const p = boardParts(tickets, broken, options);
1714
+ return `<!doctype html>
1715
+ <html lang="en">
1716
+ <head>
1717
+ <meta charset="utf-8">
1718
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1719
+ <title>${esc(p.title)}</title>
1720
+ <style>
1721
+ ${STANDALONE_CSS}
1722
+ ${BOARD_CSS}
1723
+ </style>
1724
+ </head>
1725
+ <body>
1726
+ <div class="pin-board">
1727
+ <header class="top">
1728
+ <div class="brand">
1729
+ <h1>Pin board</h1>
1730
+ <p class="sub">${p.summary}</p>
1731
+ </div>
1732
+ ${p.tools}
1733
+ ${p.nav}
1734
+ </header>
1735
+ ${p.broken}
1736
+ ${p.main}
1737
+ ${p.dialog}
1738
+ </div>
1739
+ <script>
1740
+ ${BOARD_SCRIPT}
1517
1741
  </script>
1518
1742
  </body>
1519
1743
  </html>