@orbytes/astrolab 0.3.0 → 0.4.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +109 -64
  2. package/defaults.mjs +65 -0
  3. package/dist/core/virtual-module/virtual-routes.js +12 -1
  4. package/docs/PIN-CONTRACT.md +8 -0
  5. package/docs/PIN.md +29 -19
  6. package/index.d.ts +40 -16
  7. package/index.mjs +32 -10
  8. package/package.json +6 -2
  9. package/src/Home.astro +167 -264
  10. package/src/LabHead.astro +37 -1047
  11. package/src/chrome/ComponentCard.astro +69 -0
  12. package/src/chrome/Icon.astro +21 -0
  13. package/src/chrome/LICENSE-icons +43 -0
  14. package/src/chrome/Nav.astro +105 -0
  15. package/src/chrome/Panel.astro +104 -0
  16. package/src/chrome/Shell.astro +106 -0
  17. package/src/chrome/Sprite.astro +23 -0
  18. package/src/chrome/StoryView.astro +251 -0
  19. package/src/chrome/Tree.astro +83 -0
  20. package/src/chrome/ViewportControls.astro +98 -0
  21. package/src/chrome/ViewportStage.astro +32 -0
  22. package/src/chrome/fonts/OFL.txt +93 -0
  23. package/src/chrome/fonts/inter-latin-wght-normal.woff2 +0 -0
  24. package/src/chrome/icons.ts +59 -0
  25. package/src/chrome/marks-client.ts +102 -0
  26. package/src/chrome/model.ts +142 -0
  27. package/src/chrome/pins-data.ts +30 -0
  28. package/src/chrome/shell-client.ts +372 -0
  29. package/src/chrome/site-data.ts +230 -0
  30. package/src/chrome/trees.ts +152 -0
  31. package/src/chrome/viewport-client.ts +579 -0
  32. package/src/chrome/views/Assets.astro +110 -0
  33. package/src/chrome/views/Pages.astro +178 -0
  34. package/src/chrome/views/Placeholder.astro +37 -0
  35. package/src/chrome/views/Tasks.astro +107 -0
  36. package/src/core/LICENSE-astrobook +16 -0
  37. package/src/core/lib/components/home.astro +4 -2
  38. package/src/core/lib/pages/story.astro +12 -10
  39. package/src/core/virtual-module/virtual-routes.ts +20 -4
  40. package/src/pin/board.mjs +389 -175
  41. package/src/pin/index.mjs +33 -2
  42. package/src/pin/toolbar.js +1 -1
  43. package/src/shell/Browse.astro +106 -353
  44. package/src/shell/Viewport.astro +22 -1315
  45. package/src/shell/lab-index.ts +23 -14
  46. package/src/ui/components/app.astro +5 -7
  47. package/src/ui/components/theme-script.astro +13 -2
  48. package/src/ui/lab.css +2152 -370
  49. package/virtual.d.ts +13 -0
  50. package/src/shell/CardGrid.astro +0 -297
  51. package/src/ui/components/build-path.ts +0 -13
  52. package/src/ui/components/build-tree.ts +0 -108
  53. package/src/ui/components/collapse-duration.ts +0 -28
  54. package/src/ui/components/compress-terms.ts +0 -10
  55. package/src/ui/components/dashboard-layout.astro +0 -39
  56. package/src/ui/components/home.astro +0 -65
  57. package/src/ui/components/layout.astro +0 -110
  58. package/src/ui/components/sidebar-button-fullscreen.astro +0 -38
  59. package/src/ui/components/sidebar-button-search.astro +0 -23
  60. package/src/ui/components/sidebar-button-theme.astro +0 -9
  61. package/src/ui/components/sidebar-button.astro +0 -24
  62. package/src/ui/components/sidebar-resize-handle.astro +0 -74
  63. package/src/ui/components/sidebar-search-panel.astro +0 -41
  64. package/src/ui/components/sidebar-search-script.ts +0 -103
  65. package/src/ui/components/sidebar-title.astro +0 -17
  66. package/src/ui/components/sidebar-tree-node.astro +0 -143
  67. package/src/ui/components/sidebar-tree.astro +0 -84
  68. package/src/ui/components/sidebar.astro +0 -29
  69. package/src/ui/components/theme-toggle.astro +0 -63
package/src/pin/board.mjs CHANGED
@@ -671,16 +671,10 @@ const haystack = (t) =>
671
671
  * @param {(ticket: object) => string | null} assetHref where THIS surface fetches a screenshot
672
672
  * @param {boolean} live whether the card can be dragged and written
673
673
  */
674
- function card(t, assetHref, live) {
674
+ function cardData(t, assetHref) {
675
675
  const href = assetHref(t);
676
676
  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 = {
677
+ return {
684
678
  id: t.id,
685
679
  title: t.title,
686
680
  status: t.status,
@@ -703,6 +697,18 @@ function card(t, assetHref, live) {
703
697
  shotExpected: t.shotExpected,
704
698
  commentHtml: commentHtml(t.comment, "dtext"),
705
699
  };
700
+ }
701
+
702
+ function card(t, assetHref, live) {
703
+ const href = assetHref(t);
704
+ const flag = selectorFlag(t.selectorMatches);
705
+
706
+ const dims = t.shotW && t.shotH ? ` width="${t.shotW}" height="${t.shotH}"` : "";
707
+ const thumb = href
708
+ ? `<div class="thumb"><img src="${esc(href)}"${dims} alt="" loading="lazy" decoding="async"></div>`
709
+ : `<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>`;
710
+
711
+ const data = cardData(t, assetHref);
706
712
 
707
713
  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
714
  ${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 +749,44 @@ function brokenPanel(broken) {
743
749
  }
744
750
 
745
751
  /**
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.
752
+ * One row of the All Tasks table — the same ticket, the same `card-data` payload and the same
753
+ * data attributes as its card, so the detail panel, the search, the chips and the write path work on
754
+ * it unchanged. Rows are not dragged; the detail panel's menus move them.
755
+ */
756
+ function row(t, assetHref) {
757
+ const flag = selectorFlag(t.selectorMatches);
758
+ const data = cardData(t, assetHref);
759
+ const created = t.created ? String(t.created) : "";
760
+ const updated = t.updated ? String(t.updated) : created;
761
+ 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)}">
762
+ <td class="tt-id"><span class="tid">${esc(t.id)}</span></td>
763
+ <td class="tt-title">${esc(t.title)}${flag ? ` <span class="pill pill--flag-${flag.kind}" title="${esc(flag.note)}">${esc(flag.label)}</span>` : ""}</td>
764
+ <td data-cell="status"><span class="dot dot--${esc(slug(t.status))}"></span>${esc(t.status)}</td>
765
+ <td data-cell="priority">${t.priority ? `<span class="pill pill--pri-${esc(slug(t.priority))}">${esc(t.priority)}</span>` : ""}</td>
766
+ <td>${t.dispatch ? `<span class="pill pill--${t.dispatch === "now" ? "now" : "queue"}">${esc(t.dispatch)}</span>` : ""}</td>
767
+ <td class="tt-src" title="${esc(t.source ?? "")}">${t.source && t.source !== "unresolved" ? esc(t.source) : "<em>unresolved</em>"}</td>
768
+ <td class="tt-date">${esc(updated.slice(0, 16).replace("T", " "))}</td>
769
+ <td class="tt-data"><script type="application/json" class="card-data">${jsonScript(data)}</script></td>
770
+ </tr>`;
771
+ }
772
+
773
+ /**
774
+ * The board's stylesheet — ONE copy, used by both surfaces: the standalone file and the lab's
775
+ * Tasks views (../chrome/views/Tasks.astro).
755
776
  *
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.
777
+ * SCOPED WITH `@scope (.pin-board)` since 2026-09-24, when the board moved into the lab's chrome
778
+ * (decided that day: one URL, `<subpath>/tasks`, drawn in the lab's own furniture). Until then it
779
+ * was a document of its own and styled `body`, `*` and `dialog` freely; inside the lab those would
780
+ * have restyled the chrome around it. Now every rule stops at the board's own root, and its column
781
+ * breakpoints are CONTAINER queries on that root rather than viewport queries — in the lab the
782
+ * board is the width of the main area, not of the window.
760
783
  *
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]
784
+ * Its palette is its own greys and status colours standalone (and follows the OS dark mode there);
785
+ * `.pin-board--lab` maps it onto the lab's `--lab-*` tokens instead, so it themes with the chrome.
767
786
  */
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;
787
+ export const BOARD_CSS = String.raw`@scope (.pin-board) {
788
+ :scope{
789
+ container:pin-board / inline-size;
856
790
  --bg:#f5f6f8; --col:#eceef1; --panel:#fff; --line:#d7dce2; --soft:#e8ebef;
857
791
  --ink:#141a20; --dim:#5b6874; --faint:#8a95a1;
858
792
  --accent:#2563eb; --accent-soft:#dbe6fe;
@@ -872,7 +806,7 @@ export function renderBoard(tickets, broken = [], options = {}) {
872
806
  --r:8px;
873
807
  }
874
808
  @media (prefers-color-scheme:dark){
875
- :root{
809
+ :scope:not(.pin-board--lab){
876
810
  --bg:#0f1317; --col:#171c22; --panel:#1d242b; --line:#2b333c; --soft:#232b33;
877
811
  --ink:#e6edf3; --dim:#9aa7b4; --faint:#7c8894;
878
812
  --accent:#60a5fa; --accent-soft:#1e3a5f;
@@ -887,10 +821,9 @@ export function renderBoard(tickets, broken = [], options = {}) {
887
821
  --shadow:0 1px 2px rgba(0,0,0,.5); --lift:0 8px 20px -6px rgba(0,0,0,.7);
888
822
  }
889
823
  }
890
- *{box-sizing:border-box}
891
- html{-webkit-text-size-adjust:100%}
892
- body{
893
- margin:0;background:var(--bg);color:var(--ink);
824
+ :scope,:scope *{box-sizing:border-box}
825
+ :scope{
826
+ background:var(--bg);color:var(--ink);
894
827
  font:14px/1.5 ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
895
828
  }
896
829
  code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
@@ -1031,8 +964,8 @@ code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
1031
964
  in its column as a hole until the drop lands — which is what makes a refused write a visible
1032
965
  snap-back rather than a card that was never anywhere. */
1033
966
  .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}
967
+ :scope.is-dragging-card{user-select:none;cursor:grabbing}
968
+ :scope.is-dragging-card *{cursor:grabbing!important}
1036
969
  .card.is-busy{opacity:.55;pointer-events:none}
1037
970
  .card--dead{box-shadow:var(--shadow),inset 3px 0 0 var(--dead)}
1038
971
  .card--many{box-shadow:var(--shadow),inset 3px 0 0 var(--many)}
@@ -1144,19 +1077,19 @@ dialog::backdrop{background:rgba(10,14,18,.55)}
1144
1077
  #toast.is-ok{background:var(--s-resolved)}
1145
1078
  /* Six columns need more steps down than three did. The archive keeps its narrow track only while
1146
1079
  the live columns are still side by side; once they wrap it is just another column, last. */
1147
- @media (max-width:1400px){
1080
+ @container pin-board (max-width:1400px){
1148
1081
  .board{grid-template-columns:repeat(3,minmax(0,1fr))}
1149
1082
  }
1150
- @media (max-width:1040px){
1083
+ @container pin-board (max-width:1040px){
1151
1084
  .board{grid-template-columns:repeat(2,minmax(0,1fr))}
1152
1085
  }
1153
- @media (max-width:700px){
1086
+ @container pin-board (max-width:700px){
1154
1087
  .board{grid-template-columns:1fr;gap:12px}
1155
1088
  .col-head{position:static}
1156
1089
  .col-body{min-height:0}
1157
1090
  .col--archive .card{opacity:1;filter:none}
1158
1091
  }
1159
- @media (max-width:560px){
1092
+ @container pin-board (max-width:560px){
1160
1093
  .top{padding:9px 12px;gap:8px}
1161
1094
  .board{padding:12px}
1162
1095
  .search{max-width:none;flex-basis:100%}
@@ -1169,52 +1102,88 @@ dialog::backdrop{background:rgba(10,14,18,.55)}
1169
1102
  .tools{order:2;flex-basis:100%}
1170
1103
  }
1171
1104
  @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
1105
 
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(){
1106
+ /* ── the All Tasks table ───────────────────────────────────────────────── */
1107
+ .tt-wrap{padding:16px}
1108
+ .tt-id{white-space:nowrap}
1109
+ .tt{width:100%;border-collapse:separate;border-spacing:0;background:var(--panel);border:1px solid var(--line);border-radius:10px;font-size:13px}
1110
+ .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}
1111
+ .tt th:first-child{border-top-left-radius:10px}
1112
+ .tt th:last-child{border-top-right-radius:10px}
1113
+ .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}
1114
+ .tt th button:hover{color:var(--ink)}
1115
+ .tt th[aria-sort="ascending"] button::after{content:"↑"}
1116
+ .tt th[aria-sort="descending"] button::after{content:"↓"}
1117
+ .tt td{padding:9px 12px;border-bottom:1px solid var(--soft);vertical-align:middle}
1118
+ .tt tbody tr:last-child td{border-bottom:0}
1119
+ .trow{cursor:pointer}
1120
+ .trow:hover td{background:var(--soft)}
1121
+ .trow:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
1122
+ .trow.is-busy{opacity:.55;pointer-events:none}
1123
+ .trow--dead td:first-child{box-shadow:inset 3px 0 0 var(--dead)}
1124
+ .trow--many td:first-child{box-shadow:inset 3px 0 0 var(--many)}
1125
+ .tt-title{font-weight:600;min-width:220px;overflow-wrap:anywhere}
1126
+ .tt-title .pill{margin-left:6px;vertical-align:1px}
1127
+ .pill--flag-dead{background:var(--dead);color:var(--dead-ink)}
1128
+ .pill--flag-many{background:var(--many);color:var(--many-ink)}
1129
+ .tt td[data-cell="status"]{white-space:nowrap}
1130
+ .tt td[data-cell="status"] .dot{display:inline-block;margin-right:7px;vertical-align:1px}
1131
+ .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)}
1132
+ .tt-src em{font-style:normal;color:var(--now)}
1133
+ .tt-date{white-space:nowrap;color:var(--dim);font-variant-numeric:tabular-nums}
1134
+ .tt-data{display:none}
1135
+ @container pin-board (max-width:900px){
1136
+ .tt-src,.tt th:nth-child(6){display:none}
1137
+ }
1138
+ }
1139
+
1140
+ /* The lab's palette for the board, outside the scope so it outranks the board's own defaults. */
1141
+ .pin-board.pin-board--lab{
1142
+ --bg:var(--lab-bg-secondary); --col:var(--lab-bg-hover); --panel:var(--lab-bg-primary);
1143
+ --line:var(--lab-border-secondary); --soft:var(--lab-bg-hover);
1144
+ --ink:var(--lab-text-primary); --dim:var(--lab-text-tertiary); --faint:var(--lab-text-quaternary);
1145
+ --accent:var(--lab-brand-solid); --accent-soft:var(--lab-bg-active);
1146
+ --review:#6941c6; --review-ink:#fff; --review-soft:#f9f5ff; --review-line:#d6bbfb;
1147
+ --shadow:var(--lab-shadow-xs); --lift:var(--lab-shadow-lg); --r:8px;
1148
+ font-family:var(--lab-font);
1149
+ min-height:100%;
1150
+ }
1151
+ .pin-board.pin-board--lab .top{background:var(--lab-bg-primary)}
1152
+ /* In the lab the board shares the window with two sidebars, so its six columns keep a fixed width
1153
+ and the row scrolls sideways — a board you can read across beats one wrapped into a stack. */
1154
+ .pin-board.pin-board--lab .board{
1155
+ grid-template-columns:repeat(5,minmax(248px,1fr)) minmax(160px,.55fr);
1156
+ overflow-x:auto;
1157
+ }
1158
+ :root.dark .pin-board.pin-board--lab{
1159
+ --s-backlog:#6b7683; --s-ready-for-agent:#2dd4bf; --s-in-progress:#60a5fa;
1160
+ --s-ready-for-review:#c4b5fd; --s-resolved:#4ade80; --s-cancelled:#5c656f;
1161
+ --review:#b692f6; --review-ink:#1a1030; --review-soft:#231b36; --review-line:#53389e;
1162
+ --now:#fca5a5; --now-bg:#3f1d1a; --queue:#a9b6c3; --queue-bg:#262f38;
1163
+ --pri-high:#fca5a5; --pri-high-bg:#3f1d1a;
1164
+ --pri-medium:#a9b6c3; --pri-medium-bg:#262f38;
1165
+ --pri-low:#8a95a1; --pri-low-bg:#212931;
1166
+ --dead:#dc4c42; --dead-ink:#fff; --many:#c2761a; --many-ink:#1a1204;
1167
+ }
1168
+ `;
1169
+
1170
+ /** What only the standalone file needs: it is its own document, so it owns <html> and <body>. */
1171
+ const STANDALONE_CSS = `html{-webkit-text-size-adjust:100%}
1172
+ body{margin:0;background:#f5f6f8}
1173
+ @media (prefers-color-scheme:dark){body{background:#0f1317}}
1174
+ .pin-board{min-height:100vh}`;
1175
+
1176
+ /**
1177
+ * The board's script — ONE copy for both surfaces, like the stylesheet. `String.raw` on purpose:
1178
+ * this used to sit inside the page's template literal, which silently turned every backslash
1179
+ * escape into its bare letter — the search box split queries on the letter "s" (`/\s+/` arrived
1180
+ * as `/s+/`) from 2026-09-22 until this move. A raw string keeps the script byte for byte.
1181
+ */
1182
+ export const BOARD_SCRIPT = String.raw`(function(){
1216
1183
  "use strict";
1184
+ var root = document.querySelector(".pin-board") || document.body;
1217
1185
  var board = document.querySelector("[data-board]");
1186
+ var table = board && board.getAttribute("data-view") === "table";
1218
1187
  var api = board ? board.getAttribute("data-api") : null;
1219
1188
  var dlg = document.getElementById("detail");
1220
1189
  var toastEl = document.getElementById("toast");
@@ -1237,6 +1206,8 @@ ${tickets.length ? `<main class="board" data-board data-live="${live ? "1" : "0"
1237
1206
  clearTimeout(toast.t);
1238
1207
  toast.t = setTimeout(function(){ toastEl.className = ""; }, ok ? 2200 : 6000);
1239
1208
  }
1209
+ function slugOf(s){ return String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "none"; }
1210
+ function escOf(s){ return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;"); }
1240
1211
  function cards(){ return Array.prototype.slice.call(document.querySelectorAll("[data-card]")); }
1241
1212
  function dataOf(el){
1242
1213
  var s = el.querySelector(".card-data");
@@ -1262,6 +1233,7 @@ ${tickets.length ? `<main class="board" data-board data-live="${live ? "1" : "0"
1262
1233
  if (show) { var s = el.getAttribute("data-status"); visible[s] = (visible[s] || 0) + 1; }
1263
1234
  });
1264
1235
  var total = 0;
1236
+ if (table) Object.keys(visible).forEach(function(k){ total += visible[k]; });
1265
1237
  document.querySelectorAll("[data-col]").forEach(function(col){
1266
1238
  var s = col.getAttribute("data-status");
1267
1239
  var n = visible[s] || 0;
@@ -1290,7 +1262,22 @@ ${tickets.length ? `<main class="board" data-board data-live="${live ? "1" : "0"
1290
1262
  applyFilters();
1291
1263
  });
1292
1264
  });
1265
+ // ?status=<name> opens the board filtered to that column — how the lab's level 2 links to one.
1266
+ (function(){
1267
+ var wanted = new URLSearchParams(location.search).get("status");
1268
+ if (!wanted) return;
1269
+ document.querySelectorAll(".chip").forEach(function(chip){
1270
+ if (chip.getAttribute("data-filter") === wanted) {
1271
+ statusFilter = wanted;
1272
+ document.querySelectorAll(".chip").forEach(function(c){ c.classList.toggle("is-on", c === chip); });
1273
+ }
1274
+ });
1275
+ })();
1293
1276
  document.addEventListener("keydown", function(e){
1277
+ // The lab's keyboard guard, when there is one (../LabHead.astro): a "/" typed into the pin
1278
+ // composer is retargeted to the dev toolbar's shadow host, so activeElement alone reads it
1279
+ // as a stray keystroke and steals it.
1280
+ if (window.__labIgnoreKey && window.__labIgnoreKey(e)) return;
1294
1281
  if (e.key === "/" && document.activeElement !== q && !dlg.open) { e.preventDefault(); q.focus(); q.select(); }
1295
1282
  });
1296
1283
 
@@ -1353,10 +1340,31 @@ ${tickets.length ? `<main class="board" data-board data-live="${live ? "1" : "0"
1353
1340
  });
1354
1341
  }
1355
1342
 
1343
+ /* ── sorting (the table view) ──────────────────────────────────────── */
1344
+ if (table) {
1345
+ board.querySelectorAll("[data-sort]").forEach(function(button){
1346
+ button.addEventListener("click", function(){
1347
+ var key = button.getAttribute("data-sort");
1348
+ var th = button.closest("th");
1349
+ var dir = th.getAttribute("aria-sort") === "ascending" ? -1 : 1;
1350
+ board.querySelectorAll("th[aria-sort]").forEach(function(t){ t.removeAttribute("aria-sort"); });
1351
+ th.setAttribute("aria-sort", dir === 1 ? "ascending" : "descending");
1352
+ var body = board.querySelector("tbody");
1353
+ var rows = Array.prototype.slice.call(body.querySelectorAll("[data-card]"));
1354
+ rows.sort(function(a, b){
1355
+ var x = a.getAttribute("data-sort-" + key) || "", y = b.getAttribute("data-sort-" + key) || "";
1356
+ return x < y ? -dir : x > y ? dir : 0;
1357
+ });
1358
+ rows.forEach(function(r){ body.appendChild(r); });
1359
+ });
1360
+ });
1361
+ }
1362
+
1356
1363
  /* ── the write path ────────────────────────────────────────────────── */
1357
1364
  if (!api) { applyFilters(); return; }
1358
1365
 
1359
1366
  function moveTo(el, status){
1367
+ if (table) return;
1360
1368
  var col = document.querySelector('[data-col][data-status="' + status + '"] [data-body]');
1361
1369
  if (col) col.insertBefore(el, col.querySelector("[data-empty]"));
1362
1370
  }
@@ -1382,8 +1390,25 @@ ${tickets.length ? `<main class="board" data-board data-live="${live ? "1" : "0"
1382
1390
  toast(res.body && res.body.error ? res.body.error : "the write was refused", false);
1383
1391
  return;
1384
1392
  }
1385
- var fresh = document.createRange().createContextualFragment(res.body.card).firstElementChild;
1386
- el.replaceWith(fresh);
1393
+ var fresh;
1394
+ if (table) {
1395
+ // A row stays a row: its cells and payload are brought up to date where it stands.
1396
+ fresh = el;
1397
+ var d2 = dataOf(el) || {};
1398
+ d2.status = res.body.status;
1399
+ d2.priority = res.body.priority;
1400
+ el.querySelector(".card-data").textContent = JSON.stringify(d2);
1401
+ el.setAttribute("data-status", res.body.status);
1402
+ el.setAttribute("data-priority", res.body.priority || "");
1403
+ el.setAttribute("data-sort-status", res.body.status);
1404
+ var sc = el.querySelector('[data-cell="status"]');
1405
+ if (sc) sc.innerHTML = '<span class="dot dot--' + slugOf(res.body.status) + '"></span>' + escOf(res.body.status);
1406
+ var pc = el.querySelector('[data-cell="priority"]');
1407
+ if (pc) pc.innerHTML = res.body.priority ? '<span class="pill pill--pri-' + slugOf(res.body.priority) + '">' + escOf(res.body.priority) + "</span>" : "";
1408
+ } else {
1409
+ fresh = document.createRange().createContextualFragment(res.body.card).firstElementChild;
1410
+ el.replaceWith(fresh);
1411
+ }
1387
1412
  if (open === el) { open = fresh; fill(dataOf(fresh)); }
1388
1413
  moveTo(fresh, res.body.status);
1389
1414
  applyFilters();
@@ -1421,12 +1446,13 @@ ${tickets.length ? `<main class="board" data-board data-live="${live ? "1" : "0"
1421
1446
  if (!drag) return;
1422
1447
  if (drag.ghost) drag.ghost.remove();
1423
1448
  drag.el.classList.remove("is-dragging");
1424
- document.body.classList.remove("is-dragging-card");
1449
+ root.classList.remove("is-dragging-card");
1425
1450
  clearHighlight();
1426
1451
  drag = null;
1427
1452
  }
1428
1453
 
1429
1454
  board.addEventListener("pointerdown", function(e){
1455
+ if (table) return; // rows are not dragged; the status menu in the detail panel moves them
1430
1456
  if (e.button !== 0 || e.pointerType === "touch") return;
1431
1457
  var el = e.target.closest("[data-card]");
1432
1458
  if (!el || e.target.closest("a") || el.classList.contains("is-busy")) return;
@@ -1445,10 +1471,10 @@ ${tickets.length ? `<main class="board" data-board data-live="${live ? "1" : "0"
1445
1471
  ghost.classList.add("ghost");
1446
1472
  ghost.removeAttribute("data-card");
1447
1473
  ghost.style.width = box.width + "px";
1448
- document.body.appendChild(ghost);
1474
+ root.appendChild(ghost);
1449
1475
  drag.ghost = ghost;
1450
1476
  drag.el.classList.add("is-dragging");
1451
- document.body.classList.add("is-dragging-card");
1477
+ root.classList.add("is-dragging-card");
1452
1478
  try { drag.el.setPointerCapture(e.pointerId); } catch (x) {}
1453
1479
  drag.started = true;
1454
1480
  }
@@ -1513,7 +1539,195 @@ ${tickets.length ? `<main class="board" data-board data-live="${live ? "1" : "0"
1513
1539
  });
1514
1540
 
1515
1541
  applyFilters();
1516
- })();
1542
+ })();`;
1543
+
1544
+ /**
1545
+ * The board, in parts — each surface lays them out in its own frame. `view: "table"` draws the
1546
+ * All Tasks table instead of the columns; everything else (search, chips, detail panel, the write
1547
+ * path) is shared.
1548
+ *
1549
+ * ── Why it looks like nothing ────────────────────────────────────────────────────────────────
1550
+ * Ruled 2026-09-22: the board is never styled in the styles of the site being built — it is a
1551
+ * standard kanban board, easy to use and easy to take in at a glance. This is a tool,
1552
+ * not a deliverable. A board that borrows the brand makes a screenshot of a bug look like a page of
1553
+ * the site, which is precisely the wrong thing when every card ON it is a screenshot of the site.
1554
+ *
1555
+ * `apiHref` is what makes the board writable: without it the page has no drag handlers and no
1556
+ * status menus, which is exactly the standalone file's situation, since nothing is listening on the
1557
+ * other end of a `file://` page.
1558
+ *
1559
+ * @param {object[]} tickets from `collectTickets`
1560
+ * @param {{file: string, reason: string}[]} broken
1561
+ * @param {object} options
1562
+ * @param {(ticket: object) => string | null} options.assetHref where this surface fetches a shot
1563
+ * @param {string|null} [options.apiHref] the write endpoint; null makes the page read-only
1564
+ * @param {Date} [options.generatedAt]
1565
+ * @param {{href: string, label: string}[]|null} [options.links] corner links; [] draws none
1566
+ * @param {"board"|"table"} [options.view]
1567
+ */
1568
+ export function boardParts(tickets, broken = [], options = {}) {
1569
+ const { assetHref = () => null, apiHref = null, generatedAt = new Date(), links = null, view = "board" } = options;
1570
+ const live = Boolean(apiHref);
1571
+
1572
+ // The corner links. The caller passes its own resolved addresses; `[]` draws nothing; `null`
1573
+ // keeps the historical pair for a standalone caller.
1574
+ const navLinks = (Array.isArray(links) ? links : [{ href: "/", label: "Site" }, { href: "/lab", label: "Lab" }])
1575
+ .filter((l) => l && l.href)
1576
+ .map((l) => ({ href: String(l.href), label: String(l.label ?? l.href) }));
1577
+ const nav = navLinks.length
1578
+ ? `<nav class="links">${navLinks.map((l) => `<a class="link" href="${esc(l.href)}">${esc(l.label)}</a>`).join("")}</nav>`
1579
+ : "";
1580
+
1581
+ // Every status on disk, in board order, with the known ones always present so an empty column is
1582
+ // still a place to drop a card. Anything else found on disk gets a column too, marked
1583
+ // undroppable: the writer refuses a status outside the allowlist.
1584
+ const seen = new Set(tickets.map((t) => t.status));
1585
+ const columns = [...STATUSES, ...[...seen].filter((s) => !STATUSES.includes(s)).sort()];
1586
+
1587
+ const counts = Object.fromEntries(columns.map((s) => [s, tickets.filter((t) => t.status === s).length]));
1588
+ // Both of these are calls to action, so they are counted over LIVE tickets only.
1589
+ const liveTickets = tickets.filter((t) => t.status !== CANCELLED);
1590
+ const needsHuman = liveTickets.filter((t) => selectorFlag(t.selectorMatches)).length;
1591
+ const missing = liveTickets.filter((t) => !assetHref(t)).length;
1592
+ // How many tickets an agent has finished and no human has accepted. It leads the summary.
1593
+ const waiting = counts[READY_FOR_REVIEW] ?? 0;
1594
+ const archived = counts[CANCELLED] ?? 0;
1595
+ const stamp = generatedAt.toLocaleString("en-AU", { dateStyle: "medium", timeStyle: "short" });
1596
+
1597
+ // One line per column saying what it MEANS — four of these six carry a real rule.
1598
+ const COLUMN_NOTES = {
1599
+ [BACKLOG]: "not for agents yet",
1600
+ [READY_FOR_AGENT]: "agents start here",
1601
+ [IN_PROGRESS]: "an agent is on it",
1602
+ [READY_FOR_REVIEW]: "waiting on you",
1603
+ [RESOLVED]: "you accept these",
1604
+ [CANCELLED]: "archive — agents skip it",
1605
+ };
1606
+
1607
+ const board = columns
1608
+ .map((status) => {
1609
+ const rows = tickets.filter((t) => t.status === status);
1610
+ const known = STATUSES.includes(status);
1611
+ const note = COLUMN_NOTES[status];
1612
+ const kind = status === READY_FOR_REVIEW ? " col--review" : status === CANCELLED ? " col--archive" : "";
1613
+ return `<section class="col${kind}" data-col data-status="${esc(status)}" data-droppable="${live && known ? "1" : "0"}" aria-label="${esc(status)}${note ? ` — ${esc(note)}` : ""}">
1614
+ <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>
1615
+ <div class="col-body" data-body>
1616
+ ${rows.map((t) => card(t, assetHref, live)).join("\n")}
1617
+ <p class="col-empty" data-empty>${live && known ? "Drop a card here" : "Nothing here"}</p>
1618
+ </div>
1619
+ </section>`;
1620
+ })
1621
+ .join("\n");
1622
+
1623
+ const sortable = (key, label) => `<th scope="col"><button type="button" data-sort="${key}">${label}</button></th>`;
1624
+ // Newest first by default: the table is where you go to find the thing you pinned a moment ago.
1625
+ const byUpdated = [...tickets].sort((a, b) =>
1626
+ String(b.updated ?? b.created ?? "").localeCompare(String(a.updated ?? a.created ?? "")),
1627
+ );
1628
+ const table = `<table class="tt">
1629
+ <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>
1630
+ <tbody>${byUpdated.map((t) => row(t, assetHref)).join("\n")}</tbody>
1631
+ </table>`;
1632
+
1633
+ const chips = [`<button type="button" class="chip is-on" data-filter="all">All <b data-chip-count="all">${tickets.length}</b></button>`]
1634
+ .concat(
1635
+ columns.map(
1636
+ (s) =>
1637
+ `<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>`,
1638
+ ),
1639
+ )
1640
+ .join("");
1641
+
1642
+ const statusOptions = STATUSES.map((s) => `<option value="${esc(s)}">${esc(s)}</option>`).join("");
1643
+ const priorityOptions = PRIORITIES.map((p) => `<option value="${esc(p)}">${esc(p)}</option>`).join("");
1644
+
1645
+ 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)}`}`;
1646
+
1647
+ const tools = `<div class="tools">
1648
+ <div class="search">
1649
+ <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>
1650
+ <input id="q" type="search" placeholder="Search title, comment, file, label…" autocomplete="off" aria-label="Search tickets">
1651
+ </div>
1652
+ <div class="chips" role="group" aria-label="Filter by status">${chips}</div>
1653
+ <label class="check"><input type="checkbox" id="human"> Needs a human</label>
1654
+ </div>`;
1655
+
1656
+ const liveAttrs = live ? ` data-api="${esc(apiHref)}"` : "";
1657
+ const main = !tickets.length
1658
+ ? `<p class="none">No tickets in <code>backlog/tasks/</code> yet.</p>`
1659
+ : view === "table"
1660
+ ? `<div class="tt-wrap" data-board data-view="table" data-live="${live ? "1" : "0"}"${liveAttrs}>${table}</div>`
1661
+ : `<main class="board" data-board data-live="${live ? "1" : "0"}"${liveAttrs}>${board}</main>`;
1662
+
1663
+ const dialog = `<dialog id="detail" aria-label="Ticket detail">
1664
+ <div class="d-head">
1665
+ <div>
1666
+ <h2 data-d-title></h2>
1667
+ <div class="card-row"><span class="tid" data-d-id></span><span data-d-dispatch></span></div>
1668
+ </div>
1669
+ <button type="button" class="d-close" data-d-close aria-label="Close">&times;</button>
1670
+ </div>
1671
+ <div class="d-body">
1672
+ <div data-d-flag></div>
1673
+ <div class="d-controls">
1674
+ <label class="field"><span>Status</span><select data-d-status ${live ? "" : "disabled"}>${statusOptions}</select></label>
1675
+ <label class="field"><span>Priority</span><select data-d-priority ${live ? "" : "disabled"}>${priorityOptions}</select></label>
1676
+ </div>
1677
+ <div data-d-shot></div>
1678
+ <div data-d-comment></div>
1679
+ <dl class="d-meta" data-d-meta></dl>
1680
+ <div class="labels" data-d-labels></div>
1681
+ </div>
1682
+ </dialog>
1683
+ <div id="toast" role="status" aria-live="polite"></div>`;
1684
+
1685
+ return {
1686
+ title: `Pin board — ${tickets.length} ticket${tickets.length === 1 ? "" : "s"}`,
1687
+ summary,
1688
+ tools,
1689
+ nav: live ? nav : "",
1690
+ broken: brokenPanel(broken),
1691
+ main,
1692
+ dialog,
1693
+ counts: { total: tickets.length, waiting, needsHuman, missing, archived, byStatus: counts },
1694
+ };
1695
+ }
1696
+
1697
+ /**
1698
+ * The board as one standalone HTML document — what `orbytes-pin-gallery` writes, and what the pin
1699
+ * middleware serves when the pin half runs without the lab. Inside the lab the same parts render in
1700
+ * the chrome instead (../chrome/views/Tasks.astro).
1701
+ */
1702
+ export function renderBoard(tickets, broken = [], options = {}) {
1703
+ const p = boardParts(tickets, broken, options);
1704
+ return `<!doctype html>
1705
+ <html lang="en">
1706
+ <head>
1707
+ <meta charset="utf-8">
1708
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1709
+ <title>${esc(p.title)}</title>
1710
+ <style>
1711
+ ${STANDALONE_CSS}
1712
+ ${BOARD_CSS}
1713
+ </style>
1714
+ </head>
1715
+ <body>
1716
+ <div class="pin-board">
1717
+ <header class="top">
1718
+ <div class="brand">
1719
+ <h1>Pin board</h1>
1720
+ <p class="sub">${p.summary}</p>
1721
+ </div>
1722
+ ${p.tools}
1723
+ ${p.nav}
1724
+ </header>
1725
+ ${p.broken}
1726
+ ${p.main}
1727
+ ${p.dialog}
1728
+ </div>
1729
+ <script>
1730
+ ${BOARD_SCRIPT}
1517
1731
  </script>
1518
1732
  </body>
1519
1733
  </html>