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