@orbytes/astrolab 0.3.0

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 (94) hide show
  1. package/LICENSE +37 -0
  2. package/README.md +410 -0
  3. package/bin/lab-cull.mjs +401 -0
  4. package/bin/pin-gallery.mjs +121 -0
  5. package/defaults.mjs +120 -0
  6. package/dist/core/astro-integration.js +130 -0
  7. package/dist/core/index.js +6 -0
  8. package/dist/core/lib-paths.js +29 -0
  9. package/dist/core/options.js +173 -0
  10. package/dist/core/utils/get-exports.js +52 -0
  11. package/dist/core/utils/invariant.js +12 -0
  12. package/dist/core/utils/kebab-case.js +15 -0
  13. package/dist/core/utils/path-builder.js +22 -0
  14. package/dist/core/utils/path.js +53 -0
  15. package/dist/core/virtual-module/get-story-modules.js +60 -0
  16. package/dist/core/virtual-module/story-modules.js +8 -0
  17. package/dist/core/virtual-module/virtual-module-ids.js +22 -0
  18. package/dist/core/virtual-module/virtual-routes.js +83 -0
  19. package/dist/core/virtual-module/vite-plugin.js +98 -0
  20. package/docs/PIN-CONTRACT.md +263 -0
  21. package/docs/PIN.md +429 -0
  22. package/index.d.ts +279 -0
  23. package/index.mjs +347 -0
  24. package/package.json +93 -0
  25. package/src/Empty.astro +4 -0
  26. package/src/Home.astro +298 -0
  27. package/src/LabHead.astro +1102 -0
  28. package/src/core/LICENSE-astrobook +166 -0
  29. package/src/core/astro-integration.ts +166 -0
  30. package/src/core/client.ts +89 -0
  31. package/src/core/index.ts +7 -0
  32. package/src/core/lib/components/empty.astro +1 -0
  33. package/src/core/lib/components/head.astro +1 -0
  34. package/src/core/lib/components/home.astro +8 -0
  35. package/src/core/lib/components/with-decorators.astro +22 -0
  36. package/src/core/lib/pages/app.astro +19 -0
  37. package/src/core/lib/pages/preview.astro +17 -0
  38. package/src/core/lib/pages/story.astro +16 -0
  39. package/src/core/lib-paths.ts +72 -0
  40. package/src/core/options.ts +262 -0
  41. package/src/core/utils/get-exports.ts +59 -0
  42. package/src/core/utils/invariant.ts +13 -0
  43. package/src/core/utils/kebab-case.ts +30 -0
  44. package/src/core/utils/path-builder.ts +45 -0
  45. package/src/core/utils/path.ts +80 -0
  46. package/src/core/virtual-module/get-story-modules.ts +110 -0
  47. package/src/core/virtual-module/story-modules.ts +9 -0
  48. package/src/core/virtual-module/virtual-module-ids.ts +17 -0
  49. package/src/core/virtual-module/virtual-routes.ts +130 -0
  50. package/src/core/virtual-module/vite-plugin.ts +125 -0
  51. package/src/pin/board.mjs +1521 -0
  52. package/src/pin/index.mjs +666 -0
  53. package/src/pin/shot.mjs +427 -0
  54. package/src/pin/source-stamp.mjs +159 -0
  55. package/src/pin/tickets.mjs +697 -0
  56. package/src/pin/toolbar.js +3181 -0
  57. package/src/shell/Browse.astro +371 -0
  58. package/src/shell/CardGrid.astro +297 -0
  59. package/src/shell/Viewport.astro +1330 -0
  60. package/src/shell/index.json.ts +12 -0
  61. package/src/shell/lab-index.ts +344 -0
  62. package/src/shell/lab-params.ts +245 -0
  63. package/src/shell/live-files.mjs +164 -0
  64. package/src/shell/marks.mjs +136 -0
  65. package/src/types/index.ts +6 -0
  66. package/src/types/types.ts +239 -0
  67. package/src/types/virtual.d.ts +29 -0
  68. package/src/ui/components/app.astro +13 -0
  69. package/src/ui/components/build-path.ts +13 -0
  70. package/src/ui/components/build-tree.ts +108 -0
  71. package/src/ui/components/collapse-duration.ts +28 -0
  72. package/src/ui/components/compress-terms.ts +10 -0
  73. package/src/ui/components/dashboard-layout.astro +39 -0
  74. package/src/ui/components/home.astro +65 -0
  75. package/src/ui/components/layout.astro +110 -0
  76. package/src/ui/components/preview-layout.astro +109 -0
  77. package/src/ui/components/sidebar-button-fullscreen.astro +38 -0
  78. package/src/ui/components/sidebar-button-search.astro +23 -0
  79. package/src/ui/components/sidebar-button-theme.astro +9 -0
  80. package/src/ui/components/sidebar-button.astro +24 -0
  81. package/src/ui/components/sidebar-resize-handle.astro +74 -0
  82. package/src/ui/components/sidebar-search-panel.astro +41 -0
  83. package/src/ui/components/sidebar-search-script.ts +103 -0
  84. package/src/ui/components/sidebar-title.astro +17 -0
  85. package/src/ui/components/sidebar-tree-node.astro +143 -0
  86. package/src/ui/components/sidebar-tree.astro +84 -0
  87. package/src/ui/components/sidebar.astro +29 -0
  88. package/src/ui/components/theme-message.ts +26 -0
  89. package/src/ui/components/theme-script.astro +71 -0
  90. package/src/ui/components/theme-toggle.astro +63 -0
  91. package/src/ui/components/theme.ts +32 -0
  92. package/src/ui/index.ts +4 -0
  93. package/src/ui/lab.css +549 -0
  94. package/virtual.d.ts +42 -0
@@ -0,0 +1,1521 @@
1
+ // orbytes-pin — the pin board: one reader, one writer, one renderer, two surfaces.
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.
7
+ //
8
+ // It exists because a second copy is how this system's last three bugs happened, all of them in
9
+ // the gallery script and all of them the same shape: a local parser that drifted from the one in
10
+ // 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 —
12
+ // where a screenshot is fetched from (`assetHref`) and whether the page is live (`apiHref`).
13
+ //
14
+ // ── backlog.md is retired (2026-09-22) ──────────────────────────────────────────────────────
15
+ // Until today the wall was READ-ONLY and linked out to backlog.md's web UI — a second server on
16
+ // a third port (`backlog browser`, 6420) — because moving a ticket's status meant letting
17
+ // backlog.md re-serialise its frontmatter, and a second writer of that frontmatter was exactly
18
+ // what this package refused to be. That left the site on 4321, the lab on 4321/lab, and the
19
+ // board somewhere else entirely.
20
+ //
21
+ // The objection died with the second writer. Nothing but this file writes a ticket's frontmatter
22
+ // now, so it can be written CORRECTLY by construction: `updateTicket` rewrites only the
23
+ // frontmatter lines it is changing and returns the body — the comment, the screenshot, and the
24
+ // fenced ```yaml pin block that carries every field this package owns — byte for byte. That is
25
+ // asserted in code on every single write, not documented and hoped for.
26
+ //
27
+ // backlog.md is still installed globally and nothing here uninstalls it. It is simply no longer
28
+ // part of the loop: do not run `backlog browser`, and do not run `backlog task edit` on a pin
29
+ // ticket — that command is the original defect, and it still drops every pin field it meets.
30
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
31
+ import { join, relative, resolve } from "node:path";
32
+ import {
33
+ BACKLOG,
34
+ CANCELLED,
35
+ DEFAULT_STATUS,
36
+ IN_PROGRESS,
37
+ READY_FOR_AGENT,
38
+ READY_FOR_REVIEW,
39
+ RESOLVED,
40
+ STATUSES,
41
+ isCancelled,
42
+ normaliseStatus,
43
+ parseTicket,
44
+ } from "./tickets.mjs";
45
+
46
+ // The repo root is resolved the one way the package resolves it — a second walk-up loop is a
47
+ // second implementation of the thing every repo-relative path in every ticket hangs off.
48
+ export { findRepoRoot } from "./tickets.mjs";
49
+ // The status vocabulary and the definitions of "open", "cancelled" and "an agent may take this"
50
+ // all live in tickets.mjs and are re-exported here, so a surface that imports only the board still
51
+ // gets them from their one home rather than writing its own copy of the same six strings.
52
+ export {
53
+ BACKLOG,
54
+ CANCELLED,
55
+ CLOSED_STATUSES,
56
+ DEFAULT_STATUS,
57
+ IN_PROGRESS,
58
+ OPEN_STATUSES,
59
+ READY_FOR_AGENT,
60
+ READY_FOR_REVIEW,
61
+ RESOLVED,
62
+ STATUSES,
63
+ isAgentReady,
64
+ isCancelled,
65
+ isOpen,
66
+ normaliseStatus,
67
+ } from "./tickets.mjs";
68
+
69
+ /**
70
+ * What `backlog/config.yml` used to tell backlog.md, now owned here.
71
+ *
72
+ * Every one of these was already encoded in this package's behaviour — `STATUSES` in tickets.mjs,
73
+ * the `pin-` prefix in `nextNumber`/`TICKET_FILE`, the 3-digit pad in `pad()`, `DEFAULT_STATUS` in
74
+ * `renderTicket`. Naming them together is what makes the retirement lossless: a reader who goes
75
+ * looking for the board's conventions finds them in the package that enforces them, not in a
76
+ * config file for a program nobody runs any more.
77
+ *
78
+ * `backlog/config.yml` is kept on disk as a record and is NOT read by anything here. The two
79
+ * directories it named — `backlog/tasks/` and `backlog/assets/` — are unchanged and stay
80
+ * unchanged: renaming them would break every ticket already written for no gain at all.
81
+ */
82
+ export const BOARD_CONVENTIONS = Object.freeze({
83
+ // A `projectName` key sat here until 2026-09-22 with one client's name hardcoded into it, and
84
+ // nothing ever read it — it was backlog.md's `project_name`, carried across out of
85
+ // completeness. Deleted on the move into astrolab: a client's name hardcoded in a package that
86
+ // now ships to every client is a string waiting to be believed. The project a run belongs to is
87
+ // the integration's `project` option (src/pin/index.mjs), which defaults to the checkout's own
88
+ // directory name.
89
+ /** was `task_prefix` */ taskPrefix: "pin",
90
+ /** was `zero_padded_ids` */ zeroPaddedIds: 3,
91
+ /** was `default_status` — the single source is tickets.mjs, re-exported above */ defaultStatus: DEFAULT_STATUS,
92
+ /** was `statuses` — the single source is tickets.mjs, re-exported above */ statuses: STATUSES,
93
+ /** was `date_format` */ dateFormat: "yyyy-mm-dd",
94
+ });
95
+
96
+ /**
97
+ * The priorities a card may carry. backlog.md's own three, kept because every ticket on disk
98
+ * already uses one of them and `renderTicket` writes `medium`.
99
+ *
100
+ * Ordered most-urgent first, which is the order the board's filter offers them in.
101
+ */
102
+ export const PRIORITIES = Object.freeze(["high", "medium", "low"]);
103
+
104
+ /**
105
+ * A PNG's pixel dimensions, read from its 24-byte IHDR header. `null` for anything that is not a
106
+ * PNG, which is every hand-dropped file and nothing the picker writes.
107
+ *
108
+ * It is here so a card can emit real `width`/`height` attributes, which is what lets the
109
+ * thumbnail box follow the image's own shape instead of being a fixed rectangle. Without them a
110
+ * `height:auto` image has no size until it loads, a zero-height `loading="lazy"` image never
111
+ * comes into view to load, and every thumbnail on the board disappears — measured, 2026-09-22.
112
+ * Twenty-four bytes per ticket, on a page that already reads every ticket from disk.
113
+ */
114
+ function pngSize(file) {
115
+ let fd;
116
+ try {
117
+ fd = openSync(file, "r");
118
+ const buf = Buffer.alloc(24);
119
+ if (readSync(fd, buf, 0, 24, 0) < 24) return null;
120
+ if (buf.readUInt32BE(0) !== 0x89504e47 || buf.readUInt32BE(4) !== 0x0d0a1a0a) return null;
121
+ if (buf.toString("latin1", 12, 16) !== "IHDR") return null;
122
+ const w = buf.readUInt32BE(16);
123
+ const h = buf.readUInt32BE(20);
124
+ return w > 0 && h > 0 ? { w, h } : null;
125
+ } catch {
126
+ return null;
127
+ } finally {
128
+ if (fd !== undefined) try { closeSync(fd); } catch {}
129
+ }
130
+ }
131
+
132
+ /** Tickets in `<backlogDir>/tasks`, screenshots in `<backlogDir>/assets`. Set by tickets.mjs. */
133
+ function boardPaths(repoRoot, backlogDir) {
134
+ const backlog = join(repoRoot, backlogDir);
135
+ return { backlog, tasks: join(backlog, "tasks"), assets: join(backlog, "assets") };
136
+ }
137
+
138
+ /* ----------------------------------------------------------------- tickets */
139
+
140
+ /**
141
+ * The ticket's comment, split into prose runs and fenced code blocks.
142
+ *
143
+ * `parseTicket` has already cut the comment at the screenshot or the ```yaml pin fence, whichever
144
+ * comes first, so neither can reach a card — measured, not assumed: tickets.mjs ends the comment at
145
+ * `Math.min(rest.indexOf(PIN_FENCE), rest.search(/^!\[\]\(/m))`, and a parse of both a real sample
146
+ * and a fixture whose body quotes CSS returns a comment containing no fence marker of that kind and
147
+ * no pin field.
148
+ *
149
+ * So the `.replace(/^```[\s\S]*$/m, "")` that opened this function until 2026-09-21 — a leftover
150
+ * from when the comment and the pin data were parsed out of one string — could no longer reach the
151
+ * thing it was written to strip. The only fence left for it to match was one typed by hand,
152
+ * and it cut from there to the end: a comment quoting a CSS rule lost the rule and every word after
153
+ * it. On the board only; the ticket on disk was always intact, which is exactly why nobody saw it.
154
+ *
155
+ * Prose is reflowed to the card width — ticket bodies are hard-wrapped on disk, so single newlines
156
+ * are unwrapped and blank lines stay as real paragraph breaks. Code is kept exactly as typed. The
157
+ * image strip is belt and braces, for a hand-edited ticket with an inline image in its prose; it is
158
+ * deliberately not applied inside a fence, where `![...](...)` is something the comment quotes.
159
+ */
160
+ const FENCE_OPEN = /^[ \t]{0,3}(`{3,}|~{3,})[ \t]*(\S*)/;
161
+
162
+ export function commentParts(comment) {
163
+ const lines = String(comment ?? "").split(/\r?\n/);
164
+ const parts = [];
165
+ let prose = [];
166
+
167
+ const flushProse = () => {
168
+ const text = prose
169
+ .join("\n")
170
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, "")
171
+ .trim()
172
+ .split(/\n{2,}/)
173
+ .map((para) => para.replace(/\s*\n\s*/g, " ").trim())
174
+ .filter(Boolean)
175
+ .join("\n\n");
176
+ if (text) parts.push({ type: "text", value: text });
177
+ prose = [];
178
+ };
179
+
180
+ for (let i = 0; i < lines.length; i++) {
181
+ const open = FENCE_OPEN.exec(lines[i]);
182
+ if (!open) {
183
+ prose.push(lines[i]);
184
+ continue;
185
+ }
186
+ flushProse();
187
+
188
+ const [, marker, lang] = open;
189
+ const close = new RegExp(`^[ \\t]{0,3}${marker[0]}{${marker.length},}[ \\t]*$`);
190
+ const code = [];
191
+ while (++i < lines.length && !close.test(lines[i])) code.push(lines[i]);
192
+ // An unterminated fence keeps its tail as code rather than losing it. Dropping text is the
193
+ // failure this whole function was fixed for; showing a little too much is not.
194
+ parts.push({ type: "code", lang, value: code.join("\n").replace(/^\n+|\n+$/g, "") });
195
+ }
196
+ flushProse();
197
+ return parts;
198
+ }
199
+
200
+ /**
201
+ * What counts as a ticket is the PACKAGE's answer, not a second one invented here. `listTickets()`
202
+ * in tickets.mjs takes `/^pin-\d+.*\.md$/` and silently skips everything else, so a README or a
203
+ * scratch note in that folder is simply not a ticket — it must not be reported as an unreadable
204
+ * one, which is what globbing every `*.md` did: the gallery failed hard on a file the package does
205
+ * not even look at. The pattern is copied because tickets.mjs does not export it; if that filter
206
+ * ever moves, this one moves with it.
207
+ */
208
+ export const TICKET_FILE = /^pin-\d+.*\.md$/;
209
+
210
+ /**
211
+ * 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.
214
+ *
215
+ * ONE reader: `parseTicket`, which owns the whole file — the frontmatter as much as the ```yaml
216
+ * pin block and the comment. It spreads the frontmatter keys at the top level of what it returns,
217
+ * so `title`, `status`, `labels` and `priority` are read straight off it.
218
+ *
219
+ * A second, local `parseFrontmatter` lived in the gallery script until 2026-09-21, kept only
220
+ * because tickets.mjs could not decode the block scalars a long title gets folded into — it would
221
+ * hand back a title of ">-". That stopped being true when tickets.mjs was rewritten to route the
222
+ * frontmatter and the fence through one `parseMapping`, and its block-scalar reader is strictly
223
+ * better than the copy that script carried. Measured against fixtures the day it was deleted, the
224
+ * local copy lost on every count:
225
+ * · its header regex `^([|>])[-+]?\d*$` matched `|-2` but NOT `|2-`, which is the form js-yaml
226
+ * emits — so the header parsed as the value (`title` came back as the literal "|2-") and every
227
+ * line of the real title was dropped.
228
+ * · it read the explicit indent digit and then ignored it, stripping minimum indentation instead
229
+ * of exactly that many columns — destroying the leading spaces the indicator exists to preserve.
230
+ * · its folded join flattened interior runs of spaces (`\s+` → " ") and folded a more-indented
231
+ * line into its neighbours, which is the one shape where folding destroys structure.
232
+ *
233
+ * A ticket that cannot be read is NOT skipped with a warning: it is collected and returned in
234
+ * `broken`, and what each surface does with that differs by design — the script refuses to write
235
+ * anything at all, the route shows the panel, because a live page that refuses is a blank screen
236
+ * and a file that refuses is simply the old file, which is honest as long as it says so.
237
+ *
238
+ * `Cancelled` tickets are EXCLUDED unless `includeCancelled` is passed, for the same reason
239
+ * `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
+ *
244
+ * A ticket that cannot be READ is still collected into `broken` whatever its status, because a
245
+ * file the parser rejects has no trustworthy status to filter on.
246
+ *
247
+ * @param {string} repoRoot absolute, from `findRepoRoot`
248
+ * @param {{ backlogDir?: string, includeCancelled?: boolean }} [options]
249
+ */
250
+ export function collectTickets(repoRoot, { backlogDir = "backlog", includeCancelled = false } = {}) {
251
+ const paths = boardPaths(repoRoot, backlogDir);
252
+ if (!existsSync(paths.tasks)) return { tickets: [], broken: [], paths };
253
+ const tickets = [];
254
+ const broken = [];
255
+
256
+ for (const name of readdirSync(paths.tasks)) {
257
+ if (!TICKET_FILE.test(name)) continue;
258
+ const full = join(paths.tasks, name);
259
+ const file = relative(repoRoot, full).split(/[\\/]/).join("/");
260
+
261
+ let text;
262
+ try {
263
+ text = readFileSync(full, "utf8");
264
+ } catch (err) {
265
+ broken.push({ file, reason: err.message });
266
+ continue;
267
+ }
268
+
269
+ let parsed;
270
+ try {
271
+ parsed = parseTicket(text, file);
272
+ } catch (err) {
273
+ broken.push({
274
+ file,
275
+ reason: err.message
276
+ .replace(/^orbytes-pin:\s*/, "")
277
+ .replace(new RegExp("^" + file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\s*"), ""),
278
+ });
279
+ continue;
280
+ }
281
+ const pin = parsed.pin;
282
+
283
+ // Resolve the screenshot. `shot` is repo-relative on disk; fall back to the conventional path
284
+ // for this id so a ticket whose shot has not landed yet still finds a late arrival.
285
+ const idNum = String(parsed.id ?? name).match(/(\d+)/)?.[1] ?? null;
286
+ const candidates = [];
287
+ if (pin.shot) candidates.push(String(pin.shot));
288
+ if (idNum) candidates.push(`${backlogDir}/assets/pin-${idNum}.png`);
289
+
290
+ let shotAbs = null;
291
+ let shotRel = null;
292
+ for (const c of candidates) {
293
+ const abs = resolve(repoRoot, c);
294
+ if (existsSync(abs) && statSync(abs).isFile()) {
295
+ shotAbs = abs;
296
+ shotRel = c;
297
+ break;
298
+ }
299
+ }
300
+ // `shotName` is the path INSIDE the assets directory, which is the only directory the dev
301
+ // route will serve from. A shot resolved anywhere else keeps `shotAbs` (the standalone file
302
+ // can still reach it with a relative path) and gets no `shotName`, so the route shows the
303
+ // placeholder rather than opening a second door onto the disk. No ticket the picker writes
304
+ // can land outside that directory — tickets.mjs hardlinks every PNG into it.
305
+ const inAssets = shotAbs ? relative(paths.assets, shotAbs) : null;
306
+ const shotName =
307
+ inAssets && inAssets !== "" && !inAssets.startsWith("..") && !inAssets.startsWith("/")
308
+ ? inAssets.split(/[\\/]/).join("/")
309
+ : null;
310
+
311
+ // The ONE place a status read off disk becomes a status the board means. `parseTicket` hands
312
+ // back the raw string (it is the file reader and must stay honest); everything downstream —
313
+ // columns, chips, the `expect` compare in `updateTicket`, the card's data block — reads this
314
+ // normalised value, so a ticket still saying `Done` sits in Resolved rather than growing a
315
+ // fourth-and-a-half column of its own. `statusOnDisk` carries the raw string, but only when it
316
+ // differs, so the detail panel can say why the card and the file disagree.
317
+ const rawStatus = String(parsed.status ?? "").trim();
318
+ const status = normaliseStatus(rawStatus) || "No status";
319
+ if (!includeCancelled && isCancelled(rawStatus)) continue;
320
+
321
+ tickets.push({
322
+ id: parsed.id ?? "(no id)",
323
+ num: idNum ? Number(idNum) : 0,
324
+ title: parsed.title ?? "(untitled)",
325
+ status,
326
+ statusOnDisk: rawStatus && rawStatus !== status ? rawStatus : null,
327
+ priority: parsed.priority ?? null,
328
+ created: parsed.created_date ?? "",
329
+ updated: parsed.updated_date ?? "",
330
+ labels: Array.isArray(parsed.labels) ? parsed.labels : parsed.labels ? [parsed.labels] : [],
331
+ comment: commentParts(parsed.comment),
332
+ file,
333
+ dispatch: pin.dispatch ?? null,
334
+ source: pin.source ?? null,
335
+ selector: pin.selector ?? null,
336
+ // Carried through EXACTLY as the parser hands it over — never defaulted, never coerced.
337
+ // A number (0, 1, 2…) means the selector was tested on a fresh load and that is what it
338
+ // found; the string "pending" means it was never tested; `undefined` means the ticket
339
+ // predates the field. tickets.mjs turns the numeric cases into real numbers precisely so
340
+ // a reader cannot write `?? 1` or `|| "pending"` here and quietly turn a dead selector
341
+ // into a healthy one — so this line does neither.
342
+ selectorMatches: pin.selector_matches,
343
+ url: pin.url ?? null,
344
+ viewport: pin.viewport ?? null,
345
+ shotAbs,
346
+ shotRel,
347
+ shotName,
348
+ ...(() => {
349
+ const size = shotAbs ? pngSize(shotAbs) : null;
350
+ return { shotW: size ? size.w : null, shotH: size ? size.h : null };
351
+ })(),
352
+ shotExpected: pin.shot ?? (idNum ? `${backlogDir}/assets/pin-${idNum}.png` : null),
353
+ });
354
+ }
355
+
356
+ // Newest first: by created date, then by ticket number.
357
+ tickets.sort((a, b) => String(b.created).localeCompare(String(a.created)) || b.num - a.num);
358
+ broken.sort((a, b) => String(a.file).localeCompare(String(b.file)));
359
+ return { tickets, broken, paths };
360
+ }
361
+
362
+ /* ------------------------------------------------------------------- write */
363
+
364
+ /**
365
+ * The one writer of a ticket's frontmatter, and the reason backlog.md could be retired.
366
+ *
367
+ * ── The whole safety argument, in one place ──────────────────────────────────────────────────
368
+ * The pin fields live in a fenced ```yaml pin block in the BODY and not in frontmatter, because
369
+ * backlog.md re-serialised frontmatter to its own schema on every write and silently dropped
370
+ * every key it did not know (measured 2026-09-21: `backlog task edit 1 -s Done` erased dispatch,
371
+ * source, selector, rect, shot and outer_html). That format exists to survive a careless writer.
372
+ * This function is the careful one, and it earns that by construction rather than by care:
373
+ *
374
+ * · It never serialises. There is no YAML emitter here and no round trip through an object —
375
+ * a document that goes in as text comes out as text, and the only bytes that can differ are
376
+ * the ones on the frontmatter lines named in `fields`.
377
+ * · The split is by INDEX, not by capture-and-rejoin. `tail` is the slice of the original
378
+ * string from the closing `\n---` onward: the body, the image and the fenced block are
379
+ * literally the same substring object, so byte-identity is not a property to be tested, it
380
+ * is the only thing the code is able to produce.
381
+ * · It is asserted anyway, on every write (› `bodyUnchanged`), because the next person to edit
382
+ * this function will not have read this comment.
383
+ *
384
+ * ── Who may set `Resolved` ───────────────────────────────────────────────────────────────────
385
+ * A human, and nobody else: no agent ever moves a ticket there (ruled 2026-09-22 — › STATUSES in
386
+ * tickets.mjs). That is a rule for agents, NOT a refusal in this function, and the difference is
387
+ * deliberate: this endpoint is the board's own write path and the board is the surface a person
388
+ * drags on, so it cannot tell that drop from a script's POST and must not try. Refusing
389
+ * `Resolved` here would break the one person the rule exists for. An agent finishing a ticket
390
+ * writes `Ready for Review` and stops.
391
+ *
392
+ * ── Refusals, not warnings ───────────────────────────────────────────────────────────────────
393
+ * Every one of these throws with a sentence naming the ticket, and nothing is written:
394
+ * · a status or priority not on the allowlist — a closed set, so no value this function writes
395
+ * can ever need quoting, which is what lets it write the value bare with no YAML emitter;
396
+ * · a ticket id that names no ticket, or one `parseTicket` cannot read;
397
+ * · a file with no frontmatter, or missing the key being changed;
398
+ * · a key whose current value is a YAML block scalar (`>-`, `|`), whose continuation lines a
399
+ * single-line replace would orphan. No status or priority is ever that shape, so this is a
400
+ * refusal, not a case to handle;
401
+ * · `expect` not matching what is on disk — the card the browser dragged was showing something
402
+ * stale, so the drop is refused and the board re-read rather than one write silently losing
403
+ * to another;
404
+ * · the file changing between the read and the rename.
405
+ *
406
+ * The write itself is a temp file beside the destination plus a rename, the same as
407
+ * `writeTicket` — `wx` so it cannot land on a stray temp, `rmSync` on any failure, so a reader
408
+ * never sees half a ticket and a failed write leaves the original exactly as it was.
409
+ *
410
+ * @param {string} repoRoot absolute, from `findRepoRoot`
411
+ * @param {{ id: string, status?: string, priority?: string, expect?: { status?: string, priority?: string } }} change
412
+ * @param {{ backlogDir?: string, now?: Date }} [options]
413
+ * @returns {{ id: string, file: string, status: string, priority: string|null, changed: string[], bodyUnchanged: true }}
414
+ */
415
+ export function updateTicket(repoRoot, change, { backlogDir = "backlog", now = new Date() } = {}) {
416
+ const id = String(change?.id ?? "").trim();
417
+ if (!id) throw new Error("no ticket id given");
418
+
419
+ // Wanted fields first, so a request that asks for nothing legal is refused before any file is
420
+ // opened. The allowlists are the entire input sanitiser for this function: `fields` can only
421
+ // ever hold a value from `STATUSES` or `PRIORITIES`, both of which are bare-safe YAML scalars.
422
+ const fields = new Map();
423
+ if (change?.status !== undefined) {
424
+ // Normalised BEFORE the allowlist, so a caller written against the old three-column board —
425
+ // 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:
427
+ // it is the entire input sanitiser here, and `Done` is never written to a file again.
428
+ const wanted = normaliseStatus(change.status);
429
+ if (!STATUSES.includes(wanted)) {
430
+ throw new Error(`"${change.status}" is not a status — the board has only ${STATUSES.join(", ")}`);
431
+ }
432
+ fields.set("status", wanted);
433
+ }
434
+ if (change?.priority !== undefined) {
435
+ if (!PRIORITIES.includes(change.priority)) {
436
+ throw new Error(`"${change.priority}" is not a priority — the board has only ${PRIORITIES.join(", ")}`);
437
+ }
438
+ fields.set("priority", change.priority);
439
+ }
440
+ if (fields.size === 0) throw new Error("nothing to change — send a status or a priority");
441
+
442
+ // The ticket is found by ID through the package's own reader, and the path comes back from
443
+ // that. No caller-supplied path ever reaches the filesystem, so there is no traversal to
444
+ // defend against here — the only files this function can open are the ones `collectTickets`
445
+ // already decided were tickets.
446
+ // `includeCancelled`: the archive is still writable. A ticket cancelled by mistake has to
447
+ // be moveable back out, and a reader that cannot see it would refuse with "no readable ticket
448
+ // with id …" — a sentence that would send someone looking for a corrupt file.
449
+ const { tickets, paths } = collectTickets(repoRoot, { backlogDir, includeCancelled: true });
450
+ const ticket = tickets.find((t) => t.id === id);
451
+ if (!ticket) throw new Error(`no readable ticket with id ${id} — it was deleted, renamed, or its pin block is broken`);
452
+
453
+ const absolute = resolve(repoRoot, ticket.file);
454
+ // Belt and braces on a path that is already package-derived: it must still be inside tasks/
455
+ // and still be named like a ticket.
456
+ if (!absolute.startsWith(paths.tasks + "/") || !TICKET_FILE.test(absolute.slice(paths.tasks.length + 1))) {
457
+ throw new Error(`${ticket.file} is not inside ${backlogDir}/tasks — refusing to write`);
458
+ }
459
+
460
+ // What the browser believed when the drag started. A mismatch means the board it was
461
+ // looking at is stale — refuse and let it re-read, rather than let one drop overwrite a change
462
+ // that happened in between.
463
+ for (const [key, want] of Object.entries(change?.expect ?? {})) {
464
+ const have = key === "status" ? ticket.status : key === "priority" ? ticket.priority : undefined;
465
+ // `ticket.status` is already normalised, so the expectation is too — otherwise a card showing
466
+ // a legacy `Done` ticket as Resolved would send `expect: "Resolved"` and be refused against
467
+ // its own file, or send `"Done"` and be refused against the board it was drawn from.
468
+ const wanted = key === "status" ? normaliseStatus(want) : want;
469
+ if (want !== undefined && want !== null && String(have ?? "") !== String(wanted)) {
470
+ throw new Error(`${id} is "${have}" on disk, not "${want}" — the board was out of date, so nothing was written. Refresh.`);
471
+ }
472
+ }
473
+
474
+ const original = readFileSync(absolute, "utf8");
475
+
476
+ // The split, by index. `tail` starts at the closing `\r?\n---` and runs to the end of the
477
+ // file: every byte of the body is in it, untouched and untouchable by what follows.
478
+ const open = /^---\r?\n/.exec(original);
479
+ if (!open) throw new Error(`${ticket.file} does not open with frontmatter — refusing to write`);
480
+ const close = /\r?\n---(\r?\n|$)/.exec(original.slice(open[0].length));
481
+ if (!close) throw new Error(`${ticket.file} has unterminated frontmatter — refusing to write`);
482
+ const frontStart = open[0].length;
483
+ const frontEnd = frontStart + close.index;
484
+ const head = original.slice(0, frontStart);
485
+ const tail = original.slice(frontEnd);
486
+
487
+ const eol = original.slice(0, frontEnd).includes("\r\n") ? "\r\n" : "\n";
488
+ let front = original.slice(frontStart, frontEnd);
489
+ const changed = [];
490
+
491
+ // The legacy alias drains itself. A ticket still carrying `Done` has its status line rewritten
492
+ // to `Resolved` on the NEXT WRITE OF THAT TICKET — whatever that write was for, priority
493
+ // included — so the alias leaves disk by being touched rather than by a migration pass that
494
+ // someone has to remember to run. Skipped when the caller is already setting a status: what it
495
+ // asked for wins, and moving a `Done` ticket to `To Do` must not silently resolve it instead.
496
+ if (!fields.has("status")) {
497
+ const line = /^status:[ \t]*(.*)$/m.exec(front);
498
+ const raw = line ? line[1].trim().replace(/^['"]|['"]$/g, "") : "";
499
+ const norm = normaliseStatus(raw);
500
+ if (raw && norm !== raw && STATUSES.includes(norm)) fields.set("status", norm);
501
+ }
502
+
503
+ for (const [key, value] of fields) {
504
+ // Column-0 anchored: an indented `status:` inside some nested map can never be hit.
505
+ const line = new RegExp(`^${key}:[ \\t]*(.*)$`, "m").exec(front);
506
+ if (!line) throw new Error(`${ticket.file} has no \`${key}:\` line in its frontmatter — refusing to invent one`);
507
+ if (/^[|>][-+]?\d*\s*$/.test(line[1].trim())) {
508
+ throw new Error(`${ticket.file} carries \`${key}\` as a YAML block scalar — refusing to rewrite it line by line`);
509
+ }
510
+ if (line[1].trim().replace(/^['"]|['"]$/g, "") === value) continue; // already there; not a change
511
+ front = front.replace(new RegExp(`^${key}:[ \\t]*.*$`, "m"), `${key}: ${value}`);
512
+ changed.push(key);
513
+ }
514
+
515
+ if (changed.length === 0) {
516
+ return { id, file: ticket.file, status: ticket.status, priority: ticket.priority, changed, bodyUnchanged: true };
517
+ }
518
+
519
+ // `updated_date` is the one key this function adds rather than replaces, and it is here because
520
+ // backlog.md set it on every write — retiring that program should not quietly lose the only
521
+ // record of when a ticket last moved. Placed directly after `created_date` so the two dates sit
522
+ // together; appended at the end when there is no `created_date` to sit under.
523
+ const today = now.toLocaleDateString("en-CA"); // local YYYY-MM-DD, as tickets.mjs writes dates
524
+ const stampLine = `updated_date: '${today}'`;
525
+ if (/^updated_date:[ \t]*.*$/m.test(front)) {
526
+ front = front.replace(/^updated_date:[ \t]*.*$/m, stampLine);
527
+ } else if (/^created_date:[ \t]*.*$/m.test(front)) {
528
+ front = front.replace(/^(created_date:[ \t]*.*)$/m, `$1${eol}${stampLine}`);
529
+ } else {
530
+ front = front + eol + stampLine;
531
+ }
532
+
533
+ const updated = head + front + tail;
534
+
535
+ // The assertion. By construction `tail` cannot have changed — this is here for the edit to this
536
+ // function that stops that being true, and it refuses rather than warns.
537
+ if (!updated.endsWith(tail) || updated.slice(updated.length - tail.length) !== tail) {
538
+ throw new Error(`${ticket.file}: the write would have altered the ticket body — refusing`);
539
+ }
540
+
541
+ // Compare-and-swap. The window between this read and the rename is microseconds, but a stale
542
+ // overwrite of someone's own hand-edit is exactly the failure the pin format exists to prevent.
543
+ if (readFileSync(absolute, "utf8") !== original) {
544
+ throw new Error(`${ticket.file} changed on disk while it was being edited — nothing was written`);
545
+ }
546
+
547
+ const tmp = `${absolute}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
548
+ try {
549
+ writeFileSync(tmp, updated, { encoding: "utf8", flag: "wx" });
550
+ renameSync(tmp, absolute);
551
+ } catch (e) {
552
+ rmSync(tmp, { force: true });
553
+ throw e;
554
+ }
555
+
556
+ return {
557
+ id,
558
+ file: ticket.file,
559
+ status: fields.get("status") ?? ticket.status,
560
+ priority: fields.get("priority") ?? ticket.priority,
561
+ changed,
562
+ bodyUnchanged: true,
563
+ };
564
+ }
565
+
566
+ /* -------------------------------------------------------------------- html */
567
+
568
+ const esc = (s) =>
569
+ String(s ?? "")
570
+ .replace(/&/g, "&amp;")
571
+ .replace(/</g, "&lt;")
572
+ .replace(/>/g, "&gt;")
573
+ .replace(/"/g, "&quot;");
574
+
575
+ /** JSON safe to sit inside a `<script type="application/json">` block. */
576
+ const jsonScript = (value) => JSON.stringify(value).replace(/</g, "\\u003c");
577
+
578
+ /** `To Do` → `to-do`, for a class name. */
579
+ const slug = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "none";
580
+
581
+ /**
582
+ * Selector health, as the board shows it.
583
+ *
584
+ * `selector_matches` is how many DOM nodes the ticket's selector resolved to on a genuinely fresh
585
+ * load of its page — counted by shot.mjs on the same DOM it screenshots, immediately before the
586
+ * shutter. It answers one question: can an agent be pointed at this ticket, or does it need
587
+ * human hands?
588
+ *
589
+ * 1 → yes. The overwhelming majority, and this returns null for it: a healthy card
590
+ * 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.
594
+ * 2 or more → no. Several elements match, so an agent might edit the wrong one.
595
+ * "pending" → never tested (shots off, or the screenshot failed). Not a problem; a quiet line
596
+ * in the meta list, no banner.
597
+ * undefined → the ticket predates the field. Nothing is shown, because nothing is known.
598
+ *
599
+ * The comparison is `=== 0`, on a value that is already a number. Writing this as a truthiness test
600
+ * is the one mistake the field was shaped to prevent — see the note on `selectorMatches` above.
601
+ */
602
+ export function selectorFlag(matches) {
603
+ if (matches === 0) {
604
+ return {
605
+ kind: "dead",
606
+ label: "Selector finds nothing",
607
+ note: "Needs a human — an agent cannot locate this element",
608
+ icon: `<path d="M12 3.6 1.9 20.4h20.2L12 3.6Z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M12 9.6v4.6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="12" cy="17.3" r="1.05" fill="currentColor"/>`,
609
+ };
610
+ }
611
+ if (typeof matches === "number" && Number.isInteger(matches) && matches >= 2) {
612
+ return {
613
+ kind: "many",
614
+ label: `Selector finds ${matches} elements`,
615
+ note: "Needs a human — an agent could edit the wrong one",
616
+ icon: `<rect x="3.1" y="3.1" width="11.2" height="11.2" rx="2.1" fill="none" stroke="currentColor" stroke-width="1.7"/><rect x="9.7" y="9.7" width="11.2" height="11.2" rx="2.1" fill="none" stroke="currentColor" stroke-width="1.7"/>`,
617
+ };
618
+ }
619
+ return null;
620
+ }
621
+
622
+ /**
623
+ * The neutral half: what the detail panel says about a selector that carries no banner.
624
+ *
625
+ * `1` and a missing field both say nothing — one because it is fine, the other because nothing is
626
+ * known and inventing a reading would be worse than silence. Anything else non-numeric is shown
627
+ * verbatim rather than rounded to a state this file made up.
628
+ */
629
+ function selectorNote(matches) {
630
+ if (matches === undefined || typeof matches === "number") return null;
631
+ return String(matches) === "pending" ? "not tested yet" : String(matches);
632
+ }
633
+
634
+ /** The comment, as prose paragraphs and code blocks. Never folded into one string. */
635
+ function commentHtml(parts, cls = "comment") {
636
+ if (!parts.length) return `<p class="${cls} ${cls}--none">No comment recorded.</p>`;
637
+ return parts
638
+ .map((p) =>
639
+ p.type === "code"
640
+ ? `<pre class="snippet"${p.lang ? ` data-lang="${esc(p.lang)}"` : ""}><code>${esc(p.value)}</code></pre>`
641
+ : `<p class="${cls}">${esc(p.value)}</p>`,
642
+ )
643
+ .join("\n");
644
+ }
645
+
646
+ /** Everything a search box should match on, flattened once here instead of in the browser. */
647
+ const haystack = (t) =>
648
+ [
649
+ t.id,
650
+ t.title,
651
+ t.status,
652
+ t.priority,
653
+ t.dispatch,
654
+ t.source,
655
+ t.url,
656
+ t.file,
657
+ t.selector,
658
+ ...(t.labels ?? []),
659
+ ...t.comment.map((p) => p.value),
660
+ ]
661
+ .filter(Boolean)
662
+ .join(" ")
663
+ .toLowerCase();
664
+
665
+ /**
666
+ * One card. Kept deliberately shallow — a thumbnail, a title, and the three facts that decide
667
+ * whether you open it: which ticket it is, whether an agent fires now or waits, and which file
668
+ * it lands in. Everything else is one click away in the detail panel, because thirty cards that
669
+ * each show everything is a wall you cannot scan, which is the whole complaint this board fixes.
670
+ *
671
+ * @param {(ticket: object) => string | null} assetHref where THIS surface fetches a screenshot
672
+ * @param {boolean} live whether the card can be dragged and written
673
+ */
674
+ function card(t, assetHref, live) {
675
+ const href = assetHref(t);
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 = {
684
+ id: t.id,
685
+ title: t.title,
686
+ status: t.status,
687
+ // Only set when the file says something older than the card does — see the detail panel's
688
+ // meta rows, which explain the difference rather than leaving it to be found in the file.
689
+ statusOnDisk: t.statusOnDisk ?? null,
690
+ priority: t.priority,
691
+ dispatch: t.dispatch,
692
+ source: t.source,
693
+ selector: t.selector,
694
+ selectorNote: selectorNote(t.selectorMatches),
695
+ flag: flag ? { kind: flag.kind, label: flag.label, note: flag.note } : null,
696
+ url: t.url,
697
+ viewport: t.viewport,
698
+ file: t.file,
699
+ created: t.created,
700
+ updated: t.updated,
701
+ labels: t.labels,
702
+ shot: href,
703
+ shotExpected: t.shotExpected,
704
+ commentHtml: commentHtml(t.comment, "dtext"),
705
+ };
706
+
707
+ 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
+ ${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>` : ""}
709
+ ${thumb}
710
+ <div class="card-body">
711
+ <h3 class="card-title">${esc(t.title)}</h3>
712
+ <div class="card-row">
713
+ <span class="tid">${esc(t.id)}</span>
714
+ ${t.dispatch ? `<span class="pill pill--${t.dispatch === "now" ? "now" : "queue"}" title="Dispatch mode">${esc(t.dispatch)}</span>` : `<span class="pill pill--unset" title="No dispatch field in this ticket">no dispatch</span>`}
715
+ ${t.priority ? `<span class="pill pill--pri pill--pri-${esc(slug(t.priority))}" title="Priority">${esc(t.priority)}</span>` : ""}
716
+ </div>
717
+ <p class="card-src" title="${esc(t.source ?? "")}">${t.source && t.source !== "unresolved" ? esc(t.source) : `<em>unresolved</em>`}</p>
718
+ </div>
719
+ <script type="application/json" class="card-data">${jsonScript(data)}</script>
720
+ </article>`;
721
+ }
722
+
723
+ /**
724
+ * One card, on its own — what the write endpoint hands back after a successful status change so
725
+ * the browser can replace the card it moved with the one the file now says exists.
726
+ *
727
+ * It re-renders from a FRESH read of the ticket rather than from what the browser believed, which
728
+ * is why the endpoint re-collects before calling this: the card on screen after a drag is the
729
+ * card the disk would draw, not an optimistic guess that happens to match.
730
+ */
731
+ export function renderCard(ticket, { assetHref = () => null, live = true } = {}) {
732
+ return card(ticket, assetHref, live);
733
+ }
734
+
735
+ /** The panel listing tickets that could not be read at all. */
736
+ function brokenPanel(broken) {
737
+ if (!broken.length) return "";
738
+ return `<section class="broken" role="alert">
739
+ <h2>${esc(broken.length)} ticket${broken.length === 1 ? "" : "s"} could not be read</h2>
740
+ <p>These are not shown below. Each is a <code>pin-NNN</code> ticket the parser rejected, so an agent reading the board gets nothing at all: <code>listTickets()</code> throws on the first one it reaches and returns no tickets, not a shorter list.</p>
741
+ <ul>${broken.map((b) => `<li><code>${esc(b.file)}</code><span>${esc(b.reason)}</span></li>`).join("")}</ul>
742
+ </section>`;
743
+ }
744
+
745
+ /**
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.
755
+ *
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.
760
+ *
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]
767
+ */
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;
856
+ --bg:#f5f6f8; --col:#eceef1; --panel:#fff; --line:#d7dce2; --soft:#e8ebef;
857
+ --ink:#141a20; --dim:#5b6874; --faint:#8a95a1;
858
+ --accent:#2563eb; --accent-soft:#dbe6fe;
859
+ --s-backlog:#94a3b8; --s-ready-for-agent:#0f766e; --s-in-progress:#1d4ed8;
860
+ --s-ready-for-review:#6d28d9; --s-resolved:#15803d; --s-cancelled:#a1a8b0;
861
+ /* The review column's own colour, and it is violet on purpose: red and amber are already spoken
862
+ for by the two selector-health flags (--dead, --many), which sit ON cards inside every column.
863
+ A queue that borrowed either would read as a wall of broken tickets. */
864
+ --review:#6d28d9; --review-ink:#fff; --review-soft:#f1ebfe; --review-line:#c4b5fd;
865
+ --now:#b42318; --now-bg:#fde8e6; --queue:#475569; --queue-bg:#e6eaef;
866
+ --pri-high:#b42318; --pri-high-bg:#fde8e6;
867
+ --pri-medium:#5b6874; --pri-medium-bg:#eceef1;
868
+ --pri-low:#5b6874; --pri-low-bg:#f2f4f6;
869
+ --dead:#b42318; --dead-ink:#fff; --many:#b45309; --many-ink:#fff;
870
+ --shadow:0 1px 2px rgba(16,24,40,.08),0 1px 3px rgba(16,24,40,.06);
871
+ --lift:0 6px 16px -4px rgba(16,24,40,.18);
872
+ --r:8px;
873
+ }
874
+ @media (prefers-color-scheme:dark){
875
+ :root{
876
+ --bg:#0f1317; --col:#171c22; --panel:#1d242b; --line:#2b333c; --soft:#232b33;
877
+ --ink:#e6edf3; --dim:#9aa7b4; --faint:#7c8894;
878
+ --accent:#60a5fa; --accent-soft:#1e3a5f;
879
+ --s-backlog:#6b7683; --s-ready-for-agent:#2dd4bf; --s-in-progress:#60a5fa;
880
+ --s-ready-for-review:#c4b5fd; --s-resolved:#4ade80; --s-cancelled:#5c656f;
881
+ --review:#a78bfa; --review-ink:#1a1030; --review-soft:#241b3d; --review-line:#5b47a0;
882
+ --now:#fca5a5; --now-bg:#3f1d1a; --queue:#a9b6c3; --queue-bg:#262f38;
883
+ --pri-high:#fca5a5; --pri-high-bg:#3f1d1a;
884
+ --pri-medium:#a9b6c3; --pri-medium-bg:#262f38;
885
+ --pri-low:#8a95a1; --pri-low-bg:#212931;
886
+ --dead:#dc4c42; --dead-ink:#fff; --many:#c2761a; --many-ink:#1a1204;
887
+ --shadow:0 1px 2px rgba(0,0,0,.5); --lift:0 8px 20px -6px rgba(0,0,0,.7);
888
+ }
889
+ }
890
+ *{box-sizing:border-box}
891
+ html{-webkit-text-size-adjust:100%}
892
+ body{
893
+ margin:0;background:var(--bg);color:var(--ink);
894
+ font:14px/1.5 ui-sans-serif,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
895
+ }
896
+ code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
897
+
898
+ /* ── top bar ───────────────────────────────────────────────────────────── */
899
+ .top{
900
+ position:sticky;top:0;z-index:20;background:var(--panel);border-bottom:1px solid var(--line);
901
+ padding:10px 16px;display:flex;align-items:center;gap:14px;flex-wrap:wrap;
902
+ }
903
+ .brand{display:flex;align-items:baseline;gap:10px;min-width:0}
904
+ .brand h1{margin:0;font-size:16px;font-weight:650;letter-spacing:-.01em;white-space:nowrap}
905
+ .sub{margin:0;color:var(--dim);font-size:12.5px;white-space:nowrap}
906
+ .sub b{color:var(--ink);font-weight:650}
907
+ .sub .warn{color:var(--dead);font-weight:650}
908
+ .sub .ready{color:var(--review);font-weight:750}
909
+ .tools{display:flex;align-items:center;gap:10px;flex-wrap:wrap;flex:1 1 320px;min-width:0}
910
+ .search{position:relative;flex:1 1 200px;min-width:160px;max-width:340px}
911
+ .search input{
912
+ width:100%;appearance:none;background:var(--bg);color:var(--ink);
913
+ border:1px solid var(--line);border-radius:var(--r);padding:7px 10px 7px 30px;font:inherit;font-size:13px;
914
+ }
915
+ .search input:focus{outline:2px solid var(--accent);outline-offset:-1px;border-color:var(--accent)}
916
+ .search svg{position:absolute;left:9px;top:50%;transform:translateY(-50%);width:14px;height:14px;color:var(--faint)}
917
+ .chips{display:flex;gap:5px;flex-wrap:wrap}
918
+ .chip{
919
+ appearance:none;cursor:pointer;font:inherit;font-size:12.5px;font-weight:550;
920
+ background:var(--bg);color:var(--dim);border:1px solid var(--line);border-radius:999px;padding:5px 10px;
921
+ display:inline-flex;align-items:center;gap:6px;white-space:nowrap;
922
+ }
923
+ .chip b{font-weight:650;font-size:11px;background:var(--soft);border-radius:999px;padding:1px 6px;color:var(--dim)}
924
+ .chip:hover{border-color:var(--accent);color:var(--ink)}
925
+ .chip.is-on{background:var(--accent-soft);border-color:var(--accent);color:var(--ink)}
926
+ .chip.is-on b{background:var(--panel);color:var(--ink)}
927
+ /* Same rule as the column: violet identifies it, but the badge only FILLS when there is something
928
+ in it. A filled violet zero points at nothing, and a signal that points at nothing twice is a
929
+ signal you stop reading. has-count is toggled in applyFilters beside the number itself. */
930
+ .chip--review{border-color:var(--review-line);color:var(--review)}
931
+ .chip--review.has-count b{background:var(--review);color:var(--review-ink)}
932
+ .chip--review:hover{border-color:var(--review);color:var(--review)}
933
+ .chip--review.is-on{background:var(--review-soft);border-color:var(--review);color:var(--review)}
934
+ .chip--review.is-on b{background:var(--review);color:var(--review-ink)}
935
+ .check{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;color:var(--dim);cursor:pointer;white-space:nowrap}
936
+ .check input{accent-color:var(--accent);width:14px;height:14px;cursor:pointer}
937
+ .links{display:flex;gap:6px;margin-left:auto}
938
+ .link{
939
+ text-decoration:none;background:var(--bg);border:1px solid var(--line);border-radius:var(--r);
940
+ padding:6px 11px;color:var(--dim);font-size:12.5px;font-weight:600;white-space:nowrap;
941
+ }
942
+ .link:hover{border-color:var(--accent);color:var(--accent)}
943
+
944
+ /* ── columns ───────────────────────────────────────────────────────────── */
945
+ /* Five live columns at equal width, then a NARROW sixth for the archive.
946
+ Cancelled is just an archive for cancelled work, so it gets a rail rather
947
+ than a column: a wall whose last sixth is dead tickets is a wall that reads as one-sixth dead
948
+ work. Its track is minmax(118px,.42fr) — wide enough to drop into and to read the count,
949
+ narrow enough that it never competes. On the stacked layouts it becomes an ordinary column,
950
+ last, which is the only sensible thing a single-column list can do with it. */
951
+ .board{display:grid;grid-template-columns:repeat(5,minmax(0,1fr)) minmax(118px,.42fr);gap:14px;padding:16px;align-items:start}
952
+ .col{background:var(--col);border:1px solid var(--line);border-radius:10px;display:flex;flex-direction:column;min-width:0}
953
+ .col.is-over{border-color:var(--accent);background:var(--accent-soft)}
954
+ .col-head{
955
+ display:flex;align-items:center;gap:8px;padding:11px 13px 9px;flex-wrap:wrap;
956
+ font-size:12px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--dim);
957
+ position:sticky;top:var(--top-h,57px);background:var(--col);border-radius:10px 10px 0 0;z-index:5;
958
+ }
959
+ .col-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
960
+ /* The gloss, on its own row under the name. Six columns share 1440px, so a note beside the name
961
+ would be three words of ellipsis; a second row costs 13px once and reads at every width. */
962
+ .col-note{
963
+ flex:0 0 100%;margin-top:-3px;
964
+ font-size:10px;font-weight:600;letter-spacing:.01em;text-transform:none;color:var(--faint);
965
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;
966
+ }
967
+ .count{margin-left:auto;font-size:11px;font-weight:700;letter-spacing:0;background:var(--panel);border:1px solid var(--line);color:var(--dim);border-radius:999px;padding:1px 7px}
968
+ .dot{width:8px;height:8px;border-radius:50%;background:var(--faint);flex:none}
969
+ .dot--backlog{background:var(--s-backlog)}
970
+ .dot--ready-for-agent{background:var(--s-ready-for-agent)}
971
+ .dot--in-progress{background:var(--s-in-progress)}
972
+ .dot--ready-for-review{background:var(--s-ready-for-review)}
973
+ .dot--resolved{background:var(--s-resolved)}
974
+ .dot--cancelled{background:var(--s-cancelled)}
975
+
976
+ /* ── the review column ─────────────────────────────────────────────────────
977
+ The human review queue, and the reason the status model changed at all (2026-09-22): tickets
978
+ go into Ready for review and a human closes them. A column that merely existed would have moved
979
+ nothing — the board's job is to make the pile waiting on review the first thing you see, from
980
+ across the room, without reading a word.
981
+
982
+ Two levels, and the split is what keeps it honest. The QUIET level is always on — violet dot,
983
+ violet rule down the left, "waiting on you" under the name — so the column says what it is even
984
+ when it is empty. The LOUD level is gated on .has-cards, which applyFilters() toggles from
985
+ the count it has already computed, so a tinted panel and a filled count appear only when there
986
+ is genuinely something in there, and go quiet the moment a filter empties the column. A queue
987
+ that shouts at an empty column teaches you to stop looking at it, which is the one failure that
988
+ would undo this whole change.
989
+
990
+ The class comes from JS rather than :has(.card:not([hidden])), which is the obvious way to
991
+ write it and is a trap: :has() takes the specificity of its argument, so that selector would
992
+ outweigh .col.is-over and the drag-hover highlight would stop appearing on this column alone.
993
+ The is-over override below is belt and braces for the same reason. */
994
+ .col--review{border-color:var(--review-line);box-shadow:inset 3px 0 0 var(--review)}
995
+ .col--review .col-note{color:var(--review)}
996
+ .col--review.has-cards{background:var(--review-soft);border-color:var(--review)}
997
+ .col--review.has-cards .col-head{background:var(--review-soft);color:var(--review)}
998
+ .col--review.has-cards .count{background:var(--review);border-color:var(--review);color:var(--review-ink)}
999
+ .col--review.is-over{background:var(--accent-soft);border-color:var(--accent)}
1000
+ .col--review.is-over .col-head{background:var(--accent-soft)}
1001
+
1002
+ /* ── the archive column ────────────────────────────────────────────────────
1003
+ The opposite job to the one above: Cancelled has to be reachable and has to stop pulling the
1004
+ eye. Narrow track (› .board), no dot colour worth noticing, cards desaturated and dimmed until
1005
+ you actually hover one. It is still a real drop target — a ticket is cancelled by dragging it
1006
+ here, or from the status menu — and it is the ONLY place a cancelled ticket is visible at all,
1007
+ since every agent-facing read drops them. An archive nobody can open is a delete. */
1008
+ .col--archive{background:transparent;border-style:dashed;box-shadow:none}
1009
+ .col--archive .col-head{background:transparent}
1010
+ .col--archive .col-name,.col--archive .col-note{color:var(--faint)}
1011
+ .col--archive .card{opacity:.55;filter:saturate(.25)}
1012
+ .col--archive .card:hover,.col--archive .card:focus-visible{opacity:1;filter:none}
1013
+ .col--archive .card-title{-webkit-line-clamp:2}
1014
+ .col--archive .thumb img{max-height:56px}
1015
+ .col-body{padding:0 9px 10px;display:flex;flex-direction:column;gap:9px;min-height:90px}
1016
+ .col-empty{
1017
+ margin:0;padding:16px 8px;text-align:center;color:var(--faint);font-size:12.5px;
1018
+ border:1px dashed var(--line);border-radius:var(--r);
1019
+ }
1020
+ .col-body:has(.card:not([hidden])) .col-empty{display:none}
1021
+
1022
+ /* ── card ──────────────────────────────────────────────────────────────── */
1023
+ .card{
1024
+ background:var(--panel);border:1px solid var(--line);border-radius:var(--r);
1025
+ box-shadow:var(--shadow);overflow:hidden;cursor:pointer;min-width:0;
1026
+ }
1027
+ .card:hover{border-color:var(--accent)}
1028
+ .card:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
1029
+ .card.is-dragging{opacity:.35}
1030
+ /* The card that follows the pointer. A clone, positioned fixed and inert, so the real card stays
1031
+ in its column as a hole until the drop lands — which is what makes a refused write a visible
1032
+ snap-back rather than a card that was never anywhere. */
1033
+ .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}
1036
+ .card.is-busy{opacity:.55;pointer-events:none}
1037
+ .card--dead{box-shadow:var(--shadow),inset 3px 0 0 var(--dead)}
1038
+ .card--many{box-shadow:var(--shadow),inset 3px 0 0 var(--many)}
1039
+ .flag{
1040
+ display:flex;align-items:center;gap:6px;padding:5px 10px;
1041
+ font:700 10px/1.3 inherit;letter-spacing:.05em;text-transform:uppercase;
1042
+ }
1043
+ .flag svg{width:13px;height:13px;flex:none}
1044
+ .flag--dead{background:var(--dead);color:var(--dead-ink)}
1045
+ .flag--many{background:var(--many);color:var(--many-ink)}
1046
+ .thumb{background:var(--soft);border-bottom:1px solid var(--line);line-height:0}
1047
+ /* "contain" in a FIXED box, and both halves of that are load-bearing.
1048
+ · contain, not cover: a pin screenshot is a crop of ONE element, so the thumbnail's whole job is
1049
+ recognising which one. "cover" on a 250px-wide card cut 64px off each side of an 800x220 hero
1050
+ shot, so the headline arrived with its first letter clipped — measured on this board at
1051
+ 1024px, 2026-09-22.
1052
+ · fixed height, not auto: "height:auto" was the first fix and it was worse. Without intrinsic
1053
+ dimensions the box is 0px tall until the image loads, and a 0px-tall loading="lazy" image never
1054
+ comes into view to load — every thumbnail on the board vanished. A fixed box needs no intrinsic
1055
+ size, shifts no layout, and keeps lazy loading working.
1056
+ The box then follows the image rather than boxing it: real width/height attributes (› pngSize)
1057
+ give the browser the aspect ratio before the bytes arrive, so a 3024x154 navbar strip renders as
1058
+ a 19px band and the card is 19px taller, instead of a 19px band adrift in a 96px rectangle that
1059
+ reads as a broken thumbnail — which is exactly how it read at 390px. max-height clamps a tall
1060
+ element; min-height keeps a box with no known dimensions off zero, because a zero-height lazy
1061
+ image never loads. The detail panel shows every shot whole at full width regardless. */
1062
+ .thumb img{width:100%;height:auto;max-height:96px;min-height:28px;object-fit:contain;display:block}
1063
+ .thumb--empty{
1064
+ height:56px;display:flex;align-items:center;justify-content:center;gap:7px;
1065
+ color:var(--faint);line-height:1.3;font-size:11.5px;
1066
+ }
1067
+ .thumb--empty svg{width:16px;height:16px;opacity:.8}
1068
+ .card-body{padding:10px 11px 11px;display:flex;flex-direction:column;gap:7px;min-width:0}
1069
+ .card-title{
1070
+ margin:0;font-size:13.5px;line-height:1.4;font-weight:600;letter-spacing:-.005em;
1071
+ display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;overflow-wrap:anywhere;
1072
+ }
1073
+ .card-row{display:flex;align-items:center;gap:5px;flex-wrap:wrap}
1074
+ .tid{font:650 11px/1 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:.03em;color:var(--dim)}
1075
+ .pill{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;border-radius:4px;padding:2px 6px}
1076
+ .pill--now{background:var(--now-bg);color:var(--now)}
1077
+ .pill--queue{background:var(--queue-bg);color:var(--queue)}
1078
+ .pill--unset{background:transparent;border:1px dashed var(--line);color:var(--faint);text-transform:none;letter-spacing:0}
1079
+ .pill--pri-high{background:var(--pri-high-bg);color:var(--pri-high)}
1080
+ .pill--pri-medium{background:var(--pri-medium-bg);color:var(--pri-medium)}
1081
+ .pill--pri-low{background:var(--pri-low-bg);color:var(--pri-low)}
1082
+ .card-src{
1083
+ margin:0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;
1084
+ color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left;
1085
+ }
1086
+ .card-src em{font-style:normal;color:var(--now)}
1087
+ .card-data{display:none}
1088
+
1089
+ /* ── detail ────────────────────────────────────────────────────────────── */
1090
+ dialog{
1091
+ border:1px solid var(--line);border-radius:12px;background:var(--panel);color:var(--ink);
1092
+ padding:0;width:min(860px,calc(100vw - 32px));max-height:calc(100vh - 48px);box-shadow:var(--lift);
1093
+ }
1094
+ dialog::backdrop{background:rgba(10,14,18,.55)}
1095
+ .d-head{display:flex;align-items:flex-start;gap:12px;padding:15px 18px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--panel);border-radius:12px 12px 0 0}
1096
+ .d-head h2{margin:0 0 4px;font-size:16.5px;line-height:1.35;font-weight:650;overflow-wrap:anywhere}
1097
+ .d-close{margin-left:auto;appearance:none;cursor:pointer;background:var(--bg);border:1px solid var(--line);border-radius:var(--r);color:var(--dim);font:inherit;font-size:18px;line-height:1;padding:4px 10px}
1098
+ .d-close:hover{border-color:var(--accent);color:var(--accent)}
1099
+ .d-body{padding:16px 18px 20px;overflow:auto;display:grid;gap:16px}
1100
+ .d-controls{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end}
1101
+ .field{display:grid;gap:4px}
1102
+ .field span{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--dim)}
1103
+ .field select{
1104
+ appearance:none;background:var(--bg) no-repeat right 8px center;color:var(--ink);font:inherit;font-size:13px;
1105
+ border:1px solid var(--line);border-radius:var(--r);padding:6px 26px 6px 9px;min-width:132px;cursor:pointer;
1106
+ background-image:linear-gradient(45deg,transparent 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,transparent 50%);
1107
+ background-size:5px 5px,5px 5px;background-position:right 13px center,right 8px center;
1108
+ }
1109
+ .field select:disabled{opacity:.5;cursor:not-allowed}
1110
+ .field select:focus{outline:2px solid var(--accent);outline-offset:-1px}
1111
+ /* min-height reserves room before the shot loads. Without it the panel opens with the comment
1112
+ directly under the menus and then shoves it 200px down a moment later — measured here on first
1113
+ open, 2026-09-22, where the card thumbnail had not finished loading either. */
1114
+ .d-shot{display:block;border:1px solid var(--line);border-radius:var(--r);overflow:hidden;background:var(--soft);line-height:0;min-height:120px}
1115
+ .d-shot img{width:100%;height:auto;display:block;max-height:46vh;object-fit:contain}
1116
+ .dtext{margin:0;font-size:14px;line-height:1.6;overflow-wrap:anywhere}
1117
+ .dtext+.dtext{margin-top:9px}
1118
+ .dtext--none{color:var(--faint);font-style:italic}
1119
+ .snippet{margin:9px 0 0;background:var(--bg);border:1px solid var(--line);border-radius:var(--r);padding:9px 11px;overflow-x:auto}
1120
+ .snippet code{display:block;white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;line-height:1.55}
1121
+ .snippet[data-lang]::before{content:attr(data-lang);display:block;margin:0 0 6px;font:700 9.5px/1 inherit;text-transform:uppercase;letter-spacing:.1em;color:var(--faint)}
1122
+ .d-meta{margin:0;display:grid;grid-template-columns:minmax(88px,auto) minmax(0,1fr);gap:5px 14px;font-size:12.5px}
1123
+ .d-meta dt{color:var(--dim);font-weight:650}
1124
+ .d-meta dd{margin:0;min-width:0;overflow-wrap:anywhere;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px}
1125
+ .d-flag{display:flex;align-items:center;gap:8px;padding:8px 11px;border-radius:var(--r);font-size:12.5px;font-weight:600}
1126
+ .d-flag--dead{background:var(--dead);color:var(--dead-ink)}
1127
+ .d-flag--many{background:var(--many);color:var(--many-ink)}
1128
+ .labels{display:flex;flex-wrap:wrap;gap:5px}
1129
+ .tag{font-size:10.5px;color:var(--dim);background:var(--bg);border:1px solid var(--line);border-radius:4px;padding:2px 6px}
1130
+
1131
+ /* ── the rest ──────────────────────────────────────────────────────────── */
1132
+ .broken{margin:16px 16px 0;padding:13px 15px;border-radius:var(--r);background:var(--now-bg);border:1px solid var(--now);color:var(--now)}
1133
+ .broken h2{margin:0 0 4px;font-size:13.5px}
1134
+ .broken p{margin:0 0 8px;font-size:12.5px;opacity:.92}
1135
+ .broken ul{margin:0;padding:0;list-style:none;display:grid;gap:6px}
1136
+ .broken li{display:grid;gap:2px;font-size:12px;overflow-wrap:anywhere}
1137
+ .none{margin:60px auto;text-align:center;color:var(--faint)}
1138
+ #toast{
1139
+ position:fixed;left:50%;bottom:22px;transform:translateX(-50%) translateY(14px);z-index:60;
1140
+ background:var(--dead);color:#fff;border-radius:var(--r);padding:10px 15px;font-size:13px;font-weight:600;
1141
+ max-width:min(560px,calc(100vw - 32px));box-shadow:var(--lift);opacity:0;pointer-events:none;transition:opacity .16s,transform .16s;
1142
+ }
1143
+ #toast.is-on{opacity:1;transform:translateX(-50%) translateY(0)}
1144
+ #toast.is-ok{background:var(--s-resolved)}
1145
+ /* Six columns need more steps down than three did. The archive keeps its narrow track only while
1146
+ the live columns are still side by side; once they wrap it is just another column, last. */
1147
+ @media (max-width:1400px){
1148
+ .board{grid-template-columns:repeat(3,minmax(0,1fr))}
1149
+ }
1150
+ @media (max-width:1040px){
1151
+ .board{grid-template-columns:repeat(2,minmax(0,1fr))}
1152
+ }
1153
+ @media (max-width:700px){
1154
+ .board{grid-template-columns:1fr;gap:12px}
1155
+ .col-head{position:static}
1156
+ .col-body{min-height:0}
1157
+ .col--archive .card{opacity:1;filter:none}
1158
+ }
1159
+ @media (max-width:560px){
1160
+ .top{padding:9px 12px;gap:8px}
1161
+ .board{padding:12px}
1162
+ .search{max-width:none;flex-basis:100%}
1163
+ /* Four stacked rows of chrome pushed the first card off a 390px screen. The counts line is
1164
+ dropped because the chips already carry every one of its numbers, and Site/Lab are reordered
1165
+ up beside the title, which the shorter title row now has room for. Two rows, not four. */
1166
+ .sub{display:none}
1167
+ .brand{flex:1 1 auto}
1168
+ .links{order:1;margin-left:auto}
1169
+ .tools{order:2;flex-basis:100%}
1170
+ }
1171
+ @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
+
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(){
1216
+ "use strict";
1217
+ var board = document.querySelector("[data-board]");
1218
+ var api = board ? board.getAttribute("data-api") : null;
1219
+ var dlg = document.getElementById("detail");
1220
+ var toastEl = document.getElementById("toast");
1221
+ var open = null;
1222
+
1223
+ // The column heads stick below the toolbar, and the toolbar is two rows tall on a narrow
1224
+ // window. Measured rather than assumed: a hardcoded offset put the heads 19px under the search
1225
+ // box the moment the chips wrapped.
1226
+ var topBar = document.querySelector(".top");
1227
+ function measureTop(){
1228
+ document.documentElement.style.setProperty("--top-h", topBar.offsetHeight + "px");
1229
+ }
1230
+ measureTop();
1231
+ if (window.ResizeObserver) new ResizeObserver(measureTop).observe(topBar);
1232
+ else window.addEventListener("resize", measureTop);
1233
+
1234
+ function toast(msg, ok){
1235
+ toastEl.textContent = msg;
1236
+ toastEl.className = "is-on" + (ok ? " is-ok" : "");
1237
+ clearTimeout(toast.t);
1238
+ toast.t = setTimeout(function(){ toastEl.className = ""; }, ok ? 2200 : 6000);
1239
+ }
1240
+ function cards(){ return Array.prototype.slice.call(document.querySelectorAll("[data-card]")); }
1241
+ function dataOf(el){
1242
+ var s = el.querySelector(".card-data");
1243
+ try { return JSON.parse(s.textContent); } catch (e) { return null; }
1244
+ }
1245
+
1246
+ /* ── filtering ─────────────────────────────────────────────────────── */
1247
+ var q = document.getElementById("q");
1248
+ var humanOnly = document.getElementById("human");
1249
+ var statusFilter = "all";
1250
+
1251
+ function applyFilters(){
1252
+ var text = (q.value || "").trim().toLowerCase();
1253
+ var terms = text ? text.split(/\s+/) : [];
1254
+ var visible = {};
1255
+ cards().forEach(function(el){
1256
+ var hay = el.getAttribute("data-search") || "";
1257
+ var okText = terms.every(function(t){ return hay.indexOf(t) !== -1; });
1258
+ var okStatus = statusFilter === "all" || el.getAttribute("data-status") === statusFilter;
1259
+ var okHuman = !humanOnly.checked || el.getAttribute("data-flag") === "1";
1260
+ var show = okText && okStatus && okHuman;
1261
+ el.hidden = !show;
1262
+ if (show) { var s = el.getAttribute("data-status"); visible[s] = (visible[s] || 0) + 1; }
1263
+ });
1264
+ var total = 0;
1265
+ document.querySelectorAll("[data-col]").forEach(function(col){
1266
+ var s = col.getAttribute("data-status");
1267
+ var n = visible[s] || 0;
1268
+ total += n;
1269
+ col.querySelector("[data-count]").textContent = String(n);
1270
+ // What lights the review column up. Toggled from the count that was just computed, so it
1271
+ // follows the filters: search down to nothing and the queue goes quiet with everything else.
1272
+ // See the .col--review block for why this is a class and not :has().
1273
+ col.classList.toggle("has-cards", n > 0);
1274
+ col.hidden = statusFilter !== "all" && statusFilter !== s;
1275
+ });
1276
+ document.querySelectorAll("[data-chip-count]").forEach(function(b){
1277
+ var k = b.getAttribute("data-chip-count");
1278
+ var n = k === "all" ? total : (visible[k] || 0);
1279
+ b.textContent = String(n);
1280
+ // Only a chip with something behind it gets the filled badge — see .chip--review.
1281
+ b.parentNode.classList.toggle("has-count", n > 0);
1282
+ });
1283
+ }
1284
+ q.addEventListener("input", applyFilters);
1285
+ humanOnly.addEventListener("change", applyFilters);
1286
+ document.querySelectorAll(".chip").forEach(function(chip){
1287
+ chip.addEventListener("click", function(){
1288
+ statusFilter = chip.getAttribute("data-filter");
1289
+ document.querySelectorAll(".chip").forEach(function(c){ c.classList.toggle("is-on", c === chip); });
1290
+ applyFilters();
1291
+ });
1292
+ });
1293
+ document.addEventListener("keydown", function(e){
1294
+ if (e.key === "/" && document.activeElement !== q && !dlg.open) { e.preventDefault(); q.focus(); q.select(); }
1295
+ });
1296
+
1297
+ /* ── detail panel ──────────────────────────────────────────────────── */
1298
+ function fill(d){
1299
+ dlg.querySelector("[data-d-title]").textContent = d.title;
1300
+ dlg.querySelector("[data-d-id]").textContent = d.id;
1301
+ dlg.querySelector("[data-d-dispatch]").innerHTML = d.dispatch
1302
+ ? '<span class="pill pill--' + (d.dispatch === "now" ? "now" : "queue") + '">' + d.dispatch + "</span>"
1303
+ : "";
1304
+ var flagBox = dlg.querySelector("[data-d-flag]");
1305
+ flagBox.innerHTML = d.flag
1306
+ ? '<div class="d-flag d-flag--' + d.flag.kind + '">' + d.flag.label + " — " + d.flag.note + "</div>"
1307
+ : "";
1308
+ dlg.querySelector("[data-d-status]").value = d.status;
1309
+ var pri = dlg.querySelector("[data-d-priority]");
1310
+ pri.value = d.priority || "medium";
1311
+ dlg.querySelector("[data-d-shot]").innerHTML = d.shot
1312
+ ? '<a class="d-shot" href="' + d.shot + '" target="_blank" rel="noreferrer"><img src="' + d.shot + '" alt=""></a>'
1313
+ : "";
1314
+ dlg.querySelector("[data-d-comment]").innerHTML = d.commentHtml;
1315
+ var rows = [
1316
+ ["Source", d.source || "unresolved"],
1317
+ ["Selector", d.selector || "—"],
1318
+ ["Page", d.url ? d.url + (d.viewport && d.viewport.width ? " · " + d.viewport.width + "x" + d.viewport.height : "") : "—"],
1319
+ ["Ticket", d.file],
1320
+ ["Created", d.created || "—"]
1321
+ ];
1322
+ if (d.updated) rows.push(["Updated", d.updated]);
1323
+ if (d.statusOnDisk) rows.push(["Status on disk", d.statusOnDisk + " — retired wording, rewritten to " + d.status + " on the next change to this ticket"]);
1324
+ if (d.selectorNote) rows.push(["Selector check", d.selectorNote]);
1325
+ if (!d.shot && d.shotExpected) rows.push(["Screenshot", "missing — expected " + d.shotExpected]);
1326
+ dlg.querySelector("[data-d-meta]").innerHTML = rows.map(function(r){
1327
+ var v = String(r[1]).replace(/&/g, "&amp;").replace(/</g, "&lt;");
1328
+ return "<dt>" + r[0] + "</dt><dd>" + v + "</dd>";
1329
+ }).join("");
1330
+ dlg.querySelector("[data-d-labels]").innerHTML = (d.labels || []).map(function(l){
1331
+ return '<span class="tag">' + l + "</span>";
1332
+ }).join("");
1333
+ }
1334
+ function openCard(el){
1335
+ var d = dataOf(el);
1336
+ if (!d) return;
1337
+ open = el;
1338
+ fill(d);
1339
+ dlg.showModal();
1340
+ }
1341
+ dlg.querySelector("[data-d-close]").addEventListener("click", function(){ dlg.close(); });
1342
+ dlg.addEventListener("click", function(e){ if (e.target === dlg) dlg.close(); });
1343
+
1344
+ if (board) {
1345
+ board.addEventListener("click", function(e){
1346
+ var el = e.target.closest("[data-card]");
1347
+ if (el && !e.target.closest("a")) openCard(el);
1348
+ });
1349
+ board.addEventListener("keydown", function(e){
1350
+ if (e.key !== "Enter" && e.key !== " ") return;
1351
+ var el = e.target.closest("[data-card]");
1352
+ if (el) { e.preventDefault(); openCard(el); }
1353
+ });
1354
+ }
1355
+
1356
+ /* ── the write path ────────────────────────────────────────────────── */
1357
+ if (!api) { applyFilters(); return; }
1358
+
1359
+ function moveTo(el, status){
1360
+ var col = document.querySelector('[data-col][data-status="' + status + '"] [data-body]');
1361
+ if (col) col.insertBefore(el, col.querySelector("[data-empty]"));
1362
+ }
1363
+
1364
+ function commit(el, change, revert){
1365
+ var d = dataOf(el);
1366
+ el.classList.add("is-busy");
1367
+ return fetch(api, {
1368
+ method: "POST",
1369
+ headers: { "Content-Type": "application/json" },
1370
+ body: JSON.stringify({
1371
+ id: d.id,
1372
+ status: change.status,
1373
+ priority: change.priority,
1374
+ expect: { status: d.status, priority: d.priority }
1375
+ })
1376
+ }).then(function(r){
1377
+ return r.json().then(function(body){ return { ok: r.ok, body: body }; });
1378
+ }).then(function(res){
1379
+ el.classList.remove("is-busy");
1380
+ if (!res.ok || !res.body.ok) {
1381
+ if (revert) revert();
1382
+ toast(res.body && res.body.error ? res.body.error : "the write was refused", false);
1383
+ return;
1384
+ }
1385
+ var fresh = document.createRange().createContextualFragment(res.body.card).firstElementChild;
1386
+ el.replaceWith(fresh);
1387
+ if (open === el) { open = fresh; fill(dataOf(fresh)); }
1388
+ moveTo(fresh, res.body.status);
1389
+ applyFilters();
1390
+ toast(res.body.id + " is now " + res.body.status + (change.priority ? " / " + res.body.priority : ""), true);
1391
+ }).catch(function(err){
1392
+ el.classList.remove("is-busy");
1393
+ if (revert) revert();
1394
+ toast("could not reach the dev server: " + err.message, false);
1395
+ });
1396
+ }
1397
+
1398
+ /* ── dragging ───────────────────────────────────────────────────────
1399
+ Pointer events, not HTML5 drag-and-drop, and the reason is that the second one cannot be
1400
+ verified here. A synthetic mouse press-move-release does not start a native drag in Chrome,
1401
+ so a "dragstart" handler can only ever be tested by firing "dragstart" at it by hand — which
1402
+ tests the handler and not the drag. Pointer events are driven identically by a real mouse and
1403
+ by the harness, so the drag proved below is the drag a user performs. They also give a drag
1404
+ image we control and a drop target resolved with elementFromPoint rather than by hit-testing
1405
+ rules that differ per browser.
1406
+
1407
+ Touch is deliberately excluded: a drag that begins on touch would have to fight the page's own
1408
+ scrolling, and on a phone the columns are stacked anyway. The status menu in the detail panel
1409
+ is the touch and keyboard path to exactly the same write. */
1410
+ var drag = null;
1411
+ var swallowClick = false;
1412
+
1413
+ function columnUnder(x, y){
1414
+ var el = document.elementFromPoint(x, y);
1415
+ return el ? el.closest('[data-col][data-droppable="1"]') : null;
1416
+ }
1417
+ function clearHighlight(){
1418
+ document.querySelectorAll(".col.is-over").forEach(function(c){ c.classList.remove("is-over"); });
1419
+ }
1420
+ function endDrag(){
1421
+ if (!drag) return;
1422
+ if (drag.ghost) drag.ghost.remove();
1423
+ drag.el.classList.remove("is-dragging");
1424
+ document.body.classList.remove("is-dragging-card");
1425
+ clearHighlight();
1426
+ drag = null;
1427
+ }
1428
+
1429
+ board.addEventListener("pointerdown", function(e){
1430
+ if (e.button !== 0 || e.pointerType === "touch") return;
1431
+ var el = e.target.closest("[data-card]");
1432
+ if (!el || e.target.closest("a") || el.classList.contains("is-busy")) return;
1433
+ drag = { el: el, x0: e.clientX, y0: e.clientY, pid: e.pointerId, started: false };
1434
+ });
1435
+
1436
+ window.addEventListener("pointermove", function(e){
1437
+ if (!drag || e.pointerId !== drag.pid) return;
1438
+ if (!drag.started) {
1439
+ // A few pixels of slop, so a click on a card is a click and not a one-pixel drag.
1440
+ if (Math.abs(e.clientX - drag.x0) + Math.abs(e.clientY - drag.y0) < 6) return;
1441
+ var box = drag.el.getBoundingClientRect();
1442
+ drag.dx = drag.x0 - box.left;
1443
+ drag.dy = drag.y0 - box.top;
1444
+ var ghost = drag.el.cloneNode(true);
1445
+ ghost.classList.add("ghost");
1446
+ ghost.removeAttribute("data-card");
1447
+ ghost.style.width = box.width + "px";
1448
+ document.body.appendChild(ghost);
1449
+ drag.ghost = ghost;
1450
+ drag.el.classList.add("is-dragging");
1451
+ document.body.classList.add("is-dragging-card");
1452
+ try { drag.el.setPointerCapture(e.pointerId); } catch (x) {}
1453
+ drag.started = true;
1454
+ }
1455
+ drag.ghost.style.left = (e.clientX - drag.dx) + "px";
1456
+ drag.ghost.style.top = (e.clientY - drag.dy) + "px";
1457
+ clearHighlight();
1458
+ var col = columnUnder(e.clientX, e.clientY);
1459
+ if (col && col.getAttribute("data-status") !== drag.el.getAttribute("data-status")) col.classList.add("is-over");
1460
+ e.preventDefault();
1461
+ });
1462
+
1463
+ window.addEventListener("pointerup", function(e){
1464
+ if (!drag || e.pointerId !== drag.pid) return;
1465
+ if (!drag.started) { drag = null; return; } // it was a click after all
1466
+ // The click that ends a drag must not open the detail panel — but a drag that ends over a
1467
+ // DIFFERENT element fires no click at all, so a flag that waits to be consumed by one leaks
1468
+ // and eats the next genuine click instead. Measured on this board, 2026-09-22: after a drag
1469
+ // from To Do to Done, the first click on any card did nothing. Cleared on a timeout, which
1470
+ // runs after the click if there is one and regardless if there is not.
1471
+ swallowClick = true;
1472
+ setTimeout(function(){ swallowClick = false; }, 0);
1473
+ var el = drag.el;
1474
+ var col = columnUnder(e.clientX, e.clientY);
1475
+ endDrag();
1476
+ if (!col) return;
1477
+ var to = col.getAttribute("data-status");
1478
+ var from = el.getAttribute("data-status");
1479
+ if (to === from) return;
1480
+ var home = el.parentNode, next = el.nextSibling;
1481
+ moveTo(el, to);
1482
+ applyFilters();
1483
+ commit(el, { status: to }, function(){
1484
+ if (home) home.insertBefore(el, next);
1485
+ applyFilters();
1486
+ });
1487
+ });
1488
+ window.addEventListener("pointercancel", function(){ endDrag(); });
1489
+ // Capture phase: the click that ends a drag must not also open the detail panel.
1490
+ document.addEventListener("click", function(e){
1491
+ if (!swallowClick) return;
1492
+ e.stopPropagation();
1493
+ e.preventDefault();
1494
+ }, true);
1495
+
1496
+ /* the menus in the detail panel — the keyboard and touch path to the same write */
1497
+ dlg.querySelector("[data-d-status]").addEventListener("change", function(e){
1498
+ if (!open) return;
1499
+ var el = open, was = el.getAttribute("data-status");
1500
+ var home = el.parentNode, next = el.nextSibling;
1501
+ moveTo(el, e.target.value);
1502
+ applyFilters();
1503
+ commit(el, { status: e.target.value }, function(){
1504
+ if (home) home.insertBefore(el, next);
1505
+ e.target.value = was;
1506
+ applyFilters();
1507
+ });
1508
+ });
1509
+ dlg.querySelector("[data-d-priority]").addEventListener("change", function(e){
1510
+ if (!open) return;
1511
+ var el = open, was = el.getAttribute("data-priority");
1512
+ commit(el, { priority: e.target.value }, function(){ e.target.value = was || "medium"; });
1513
+ });
1514
+
1515
+ applyFilters();
1516
+ })();
1517
+ </script>
1518
+ </body>
1519
+ </html>
1520
+ `;
1521
+ }