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