From ca2ac86fca4797521ea703433823cadd103dd6e5 Mon Sep 17 00:00:00 2001 From: Daan Aerts Date: Fri, 2 Oct 2026 13:50:12 +0200 Subject: [PATCH 1/3] bearings board: color-code statuses and add keep/now/remove triage choices Status badges now read at a glance: request, project and person statuses map from their wording to danger/warn/online/info/done/neutral tones, unknown wording stays neutral, and the words always stay visible so color is a second signal, never the only one. Every requests row, optional knowledge entry, project and person whose routing id can carry a keyed answer gets one three-radio group (leave on backlog / do now / remove completely). Each section's send bar queues exactly one fm-bearings-answer.v1 choice annotation per marked row over the board's existing single answer path, keyed triage..; rows whose ids cannot route render without choices. The keyed-answer intake keeps skipping non-task keys, so firstmate applies the choices from the captured result read. The payload contract gains only optional fields: knowledge[] entries and optional project ids, both slug-gated so full triage keys stay within the 128-character keyed-answer limit. The script header documents how firstmate reads a triage answer; SKILL.md points composers at the new optional fields. Verified: 19 board build tests, 24 render tests, full procevent and captain-hold suites, shellcheck/actionlint, and an in-browser pass at 390px and desktop width (distinct tone colors, no horizontal overflow, queue path exercised in the real DOM). --- .agents/skills/bearings/SKILL.md | 1 + .../bearings/assets/board-template.html | 242 +++++++++++++++++- bin/fm-bearings-board.sh | 45 +++- tests/assets/board-render-harness.mjs | 105 +++++++- tests/fm-bearings-board-render.test.sh | 178 ++++++++++++- tests/fm-bearings-board.test.sh | 57 +++++ 6 files changed, 597 insertions(+), 31 deletions(-) diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index 3cf043e8e4b..519de7d1eab 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -119,6 +119,7 @@ Compose the payload from the same snapshot with the same ranking judgment as the Include every snapshot `crew` row, including paused people, present people with unconfirmed progress, project leads, and sampled delegated children. Group by captured project associations, resolving repository paths to known project names; do not infer associations from internal identifiers. A lead responsible for several projects appears under each; a person whose project is unknown stays visible in the unassigned group. + Optionally give each `projects` entry an `id` and include the captain's remembered knowledge as a `knowledge` array, both using the board command's contract; the page then offers a keep / do now / remove choice for each routable row, answered through the board's normal answer path. Include work on Firstmate itself or other known non-registry work separately, not as invented registry entries. Give every project and person a human-readable name, a one- or two-sentence status, and explained open questions: the issue, the actual choice, and a recommendation or an explicit statement that none is recorded. Translate structured questions into plain copy; distinguish a composing recommendation from a recorded recommendation and never invent approval options. diff --git a/.agents/skills/bearings/assets/board-template.html b/.agents/skills/bearings/assets/board-template.html index d3c61f074e5..bd93820650a 100644 --- a/.agents/skills/bearings/assets/board-template.html +++ b/.agents/skills/bearings/assets/board-template.html @@ -73,6 +73,7 @@ .fm-badge--info { background: var(--ocean-050); color: var(--ocean-600); } .fm-badge--neutral { background: var(--paper-000); color: var(--text-muted); } .fm-badge--solid { background: var(--rust-050); color: var(--rust-600); } +.fm-badge--done { background: var(--sea-700); color: var(--navy-700); } /* ---- fm-btn ---- */ .fm-btn { @@ -267,6 +268,23 @@ .bb-dispatch__count { flex: 1 1 auto; font-size: var(--fs-xs); font-weight: 800; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.07em; } +/* ---- keep / do-now / remove choices ---- */ +/* One three-choice group per listed item. The three inputs are one radio + group, so exactly one choice can carry per item and an untouched group + sends nothing; the sent chip appears once that row's choice was queued. */ +.bb-triage { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 14px; min-width: 0; } +.bb-triage__opt { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; + font-size: var(--fs-xs); font-weight: 700; color: var(--text-body); line-height: 1.3; } +.bb-triage__opt input { accent-color: var(--rust-500); width: 15px; height: 15px; flex: none; margin: 0; cursor: pointer; } +.bb-triage__opt:focus-within { outline: 2px solid var(--gold-300); outline-offset: 2px; border-radius: var(--radius-xs); } +.bb-triage.is-queued .bb-triage__opt { color: var(--text-muted); } +.bb-triage__sent { display: none; align-items: center; gap: 4px; font-size: var(--fs-2xs); font-weight: 800; + text-transform: uppercase; letter-spacing: 0.07em; color: var(--sea-700); } +.bb-triage__sent svg { width: 12px; height: 12px; } +.bb-triage.is-queued .bb-triage__sent { display: inline-flex; } +.bb-project__triage, .bb-person__triage { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-soft); } +.bb-knowledge .bb-row__title, .bb-knowledge .bb-row__sub { white-space: normal; overflow-wrap: anywhere; } + .bb-foot { display: flex; justify-content: flex-end; border-top: 1px solid var(--border-default); padding-top: 14px; } .bb-main--status .bb-cols { grid-template-columns: 1fr; } @@ -275,7 +293,8 @@ .bb-main--status .bb-call::before, .bb-main--status .bb-call::after { display: none; } .bb-requests { width: 100%; border-collapse: collapse; table-layout: fixed; } .bb-requests th, .bb-requests td { text-align: left; padding: 12px; vertical-align: top; overflow-wrap: anywhere; } -.bb-requests th:first-child { width: 34%; } +.bb-requests th:first-child { width: 30%; } +.bb-requests th:last-child { width: 32%; } .bb-requests td { border-top: 1px solid var(--border-soft); } .bb-requests p { margin: 6px 0; } .bb-requests a { color: #8cc4ff; } @@ -421,16 +440,52 @@ + + @@ -496,6 +551,19 @@

Your requests

renderBoardError("The saved page information is incomplete"); return; } + /* knowledge is optional, but a present array must be well formed: its + entries ride the keep / do-now / remove triage, so each needs a routing + id and readable text. */ + var knowledgeItemsValid = !Array.isArray(data.knowledge) || data.knowledge.every(function (item) { + return item && typeof item === "object" && + typeof item.id === "string" && item.id.length > 0 && + typeof item.title === "string" && item.title.length > 0 && + typeof item.detail === "string" && item.detail.length > 0; + }); + if (!knowledgeItemsValid) { + renderBoardError("The saved page information is incomplete"); + return; + } try { function el(tag, cls, text) { @@ -525,6 +593,113 @@

Your requests

function utf8ByteLength(text) { return new TextEncoder().encode(text).length; } var CHECK_SVG = ''; + /* Keep / do now / remove: one three-choice group per listed item, answered + through the board's single Lavish answer path. The three inputs are one + radio group, so exactly one choice can carry per item and an untouched + group sends nothing. The answer key is namespaced - triage.request., + triage.knowledge., triage.project., triage.crew. - mirroring + merge., so a triage mark can never resolve a captain-held task + through the keyed-answer intake by accident. */ + var TRIAGE_CHOICES = [ + { value: "backlog", label: "Leave on backlog" }, + { value: "now", label: "Do now" }, + { value: "remove", label: "Remove completely" } + ]; + var triageSeq = 0; + var triagedKeys = {}; + function triageKey(kind, id) { + var prefix = "triage." + kind + "."; + if (typeof id !== "string" || !/^[A-Za-z0-9._-]+$/.test(id)) return null; + return (prefix + id).length <= 128 ? prefix + id : null; + } + function triageGroup(key, title) { + if (triagedKeys[key]) return null; /* a row listed twice gets one group */ + triagedKeys[key] = true; + var group = el("div", "bb-triage"); + group.setAttribute("data-triage-key", key); + group.setAttribute("data-triage-title", title); + group.setAttribute("role", "group"); + group.setAttribute("aria-label", "Keep on backlog, do now, or remove completely: " + title); + var name = "triage-" + (++triageSeq); + TRIAGE_CHOICES.forEach(function (c) { + var lab = el("label", "bb-triage__opt"); + var input = document.createElement("input"); + input.type = "radio"; + input.className = "bb-triage__pick"; + input.name = name; + input.value = c.value; + input.setAttribute("data-label", c.label); + lab.appendChild(input); + lab.appendChild(el("span", "", c.label)); + group.appendChild(lab); + }); + var sent = el("span", "bb-triage__sent"); + sent.innerHTML = CHECK_SVG + " sent"; + group.appendChild(sent); + return group; + } + function triageInto(parent, kind, id, title) { + var key = triageKey(kind, id); + if (!key) return false; + var group = triageGroup(key, title); + if (!group) return false; + parent.appendChild(group); + return true; + } + /* One send bar per section: it queues every marked, not-yet-sent group as + its own versioned choice annotation, exactly like an option card. A + section with nothing to route never touches its bar, so the bar (and the + requests choice column) simply stays hidden. */ + function armTriageBar(barId, scopeId, hintId) { + var scope = document.getElementById(scopeId); + var groups = scope.querySelectorAll(".bb-triage"); + if (!groups.length) return false; + var bar = document.getElementById(barId); + var hint = hintId ? document.getElementById(hintId) : null; + if (hint) hint.hidden = false; + var card = document.getElementById(barId + "-card"); + if (card) card.hidden = false; + bar.hidden = false; + var count = document.getElementById(barId + "-count"); + var btn = document.getElementById(barId + "-btn"); + function pendingGroups() { + return Array.prototype.filter.call(groups, function (g) { + return !g.classList.contains("is-queued") && g.querySelectorAll(".bb-triage__pick:checked").length > 0; + }); + } + function refresh() { + var n = pendingGroups().length; + count.textContent = n ? n + " choice" + (n === 1 ? "" : "s") + " marked to send" : "no choice marked"; + btn.disabled = !n; + } + Array.prototype.forEach.call(groups, function (g) { g.addEventListener("change", refresh); }); + btn.addEventListener("click", function () { + var items = pendingGroups(); + if (!items.length) return; + Array.prototype.forEach.call(items, function (g) { + var choice = g.querySelectorAll(".bb-triage__pick:checked")[0]; + var label = choice.getAttribute("data-label"); + var title = g.getAttribute("data-triage-title"); + if (window.lavish && window.lavish.queuePrompt) { + window.lavish.queuePrompt( + "Keep, do or remove - " + title + ": " + label, + { tag: "choice", text: title + " -> " + label, element: g, + queueKey: g.getAttribute("data-triage-key"), + data: { schema: "fm-bearings-answer.v1", + question: g.getAttribute("data-triage-key"), + selection: choice.value, + note: "" } } + ); + } + g.classList.add("is-queued"); + }); + bar.classList.add("is-queued"); + refresh(); + }); + refresh(); + return true; + } + document.getElementById("bb-provenance").textContent = "Updated " + data.generated; var statusPage = Array.isArray(data.requests); if (statusPage) { @@ -537,7 +712,7 @@

Your requests

title.setAttribute("scope", "row"); row.appendChild(title); var detail = el("td"); - detail.appendChild(badge("info", t.status)); + detail.appendChild(badge(statusTone(t.status), t.status)); detail.appendChild(el("p", "", t.detail)); if (t.pr_url) { var link = el("a", "", "Review the proposed changes"); @@ -545,17 +720,38 @@

Your requests

detail.appendChild(link); } row.appendChild(detail); + var choices = el("td"); + if (triageInto(choices, "request", t.id, t.title)) row.appendChild(choices); requests.appendChild(row); }); if (!data.requests.length) { var emptyRow = el("tr"), emptyCell = el("td", "bb-empty", "No requests are recorded."); - emptyCell.setAttribute("colspan", "2"); emptyRow.appendChild(emptyCell); requests.appendChild(emptyRow); + emptyCell.setAttribute("colspan", "3"); emptyRow.appendChild(emptyCell); requests.appendChild(emptyRow); } } (data.coverage || []).forEach(function (notice) { document.getElementById("bb-coverage").appendChild(el("p", "bb-coverage", notice)); }); + /* Knowledge entries: remembered facts offered for keep / do-now / remove + triage; the section appears only when the payload carries entries. */ + if (Array.isArray(data.knowledge)) { + var knowledgeRows = document.getElementById("bb-knowledge"); + document.getElementById("bb-knowledge-section").hidden = false; + data.knowledge.forEach(function (k) { + var row = el("div", "bb-row"); + var main = el("div", "bb-row__main"); + main.appendChild(el("div", "bb-row__title", k.title)); + main.appendChild(el("div", "bb-row__sub", k.detail)); + row.appendChild(main); + triageInto(row, "knowledge", k.id, k.title); + knowledgeRows.appendChild(row); + }); + if (!data.knowledge.length) { + knowledgeRows.appendChild(el("div", "bb-empty", "No remembered entry is recorded.")); + } + } + function renderQuestions(parent, questions) { if (!questions.length) { parent.appendChild(el("p", "bb-coverage", "No unanswered question is recorded in the checked information.")); @@ -575,16 +771,20 @@

Your requests

var card = el("article", "fm-card bb-project"); card.appendChild(el("h3", "bb-project__name", project.name)); card.appendChild(el("p", "bb-project__delivery", project.delivery)); - card.appendChild(badge("info", project.status)); + card.appendChild(badge(statusTone(project.status), project.status)); card.appendChild(el("p", "bb-project__detail", project.detail)); renderQuestions(card, project.questions); + var projectChoices = el("div", "bb-project__triage"); + if (triageInto(projectChoices, "project", project.id, project.name)) card.appendChild(projectChoices); people.forEach(function (person) { var row = el("div", "bb-person"); row.appendChild(el("h4", "bb-person__name", person.name)); row.appendChild(el("p", "bb-person__role", person.role)); - row.appendChild(badge("info", person.status)); + row.appendChild(badge(statusTone(person.status), person.status)); row.appendChild(el("p", "bb-person__detail", person.detail)); renderQuestions(row, person.questions); + var personChoices = el("div", "bb-person__triage"); + if (triageInto(personChoices, "crew", person.id, person.name)) row.appendChild(personChoices); card.appendChild(row); }); projects.appendChild(card); @@ -608,6 +808,7 @@

Your requests

]; if (statusPage) stats = [stats[0], { n: data.requests.length, l: "requests" }]; if (Array.isArray(data.projects)) stats.push({ n: data.projects.length, l: "projects" }, { n: data.crew.length, l: "people" }); + if (Array.isArray(data.knowledge)) stats.push({ n: data.knowledge.length, l: "knowledge" }); var strip = document.getElementById("bb-stats"); stats.forEach(function (s) { var t = el("div", "bb-stat" + (s.call ? " bb-stat--call" : "")); @@ -775,11 +976,19 @@

Your requests

document.getElementById("bb-landed-section").hidden = !data.landed.length; document.getElementById("bb-landed-label").textContent = "Completed ยท " + data.landed.length; - function stateTone(state) { - if (/^(blocked|failed|needs-decision|needs_decision)$/.test(state)) return "danger"; - if (/^(paused|waiting|held|captain_hold|stalled)$/.test(state)) return "warn"; - if (/^(working|running|fixing)$/.test(state)) return "online"; - if (/^(review|reviewing|validating|testing)$/.test(state)) return "info"; + /* One wording-to-tone map for every status the page shows, request + wording and worker state alike, with a neutral fallback so unknown + wording still renders. Color is a second signal: the badge always keeps + the full status words. */ + function statusTone(text) { + var s = String(text || "").trim().toLowerCase(); + if (/^(blocked|failed|needs-decision|needs_decision)/.test(s)) return "danger"; + if (/^waiting on you/.test(s)) return "danger"; + if (/^(paused|held|stalled|captain_hold)/.test(s)) return "warn"; + if (/^waiting/.test(s)) return "warn"; + if (/^(working|running|fixing)/.test(s)) return "online"; + if (/^(in review|review|reviewing|validating|testing)/.test(s)) return "info"; + if (/^completed/.test(s)) return "done"; return "neutral"; } function workRow(tone, title, repo, status, detail) { @@ -800,7 +1009,7 @@

Your requests

Keep the worker kind and routing identity out of the status line. */ var uw = document.getElementById("bb-underway"); data.underway.forEach(function (t) { - uw.appendChild(workRow(stateTone(t.state), t.name, t.repo || "", t.state, t.doing)); + uw.appendChild(workRow(statusTone(t.state), t.name, t.repo || "", t.state, t.doing)); }); /* Landed */ @@ -896,6 +1105,15 @@

Your requests

bar.classList.add("is-queued"); }); } + + /* Keep / do-now / remove send bars: one per section that actually listed + triage-routable rows. A section with nothing to route keeps its bar and + its choice column hidden. */ + if (!armTriageBar("bb-requests-triage", "bb-requests", "bb-requests-triage-hint")) { + document.getElementById("bb-requests-choice-head").hidden = true; + } + armTriageBar("bb-knowledge-triage", "bb-knowledge", "bb-knowledge-hint"); + armTriageBar("bb-projects-triage", "bb-projects", "bb-projects-triage-hint"); } catch (e) { renderBoardError("The saved page information could not be displayed"); } diff --git a/bin/fm-bearings-board.sh b/bin/fm-bearings-board.sh index 7b130e77e13..57564922787 100755 --- a/bin/fm-bearings-board.sh +++ b/bin/fm-bearings-board.sh @@ -74,20 +74,48 @@ # # All-tasks status pages additionally carry requests[] with one row per task: # {id,title,status,detail,repo,pr_url?}. id is a routing identity, title describes -# the request, status is plain wording, and detail explains the outcome or wait. +# the request, status is plain wording the template colors by explicit map +# (unknown wording stays neutral and the words always stay visible), and +# detail explains the outcome or wait. # Ids must be unique; pr_url is HTTPS only. coverage[] is an optional array of # non-empty plain-language notices about unavailable, cached or omitted state. # When requests is present, every call requires non-empty about/decide and every # authored option requires a non-empty hint, including merge/credential cards. # Optional projects[] lists the entire registered portfolio, with additional -# managed work allowed: {name,delivery,status,detail,questions:[]}. -# Optional crew[] lists workers/leads: {id,name,role,status,detail,projects:[],questions:[]}. +# managed work allowed: {name,id?,delivery,status,detail,questions:[]}; its +# optional id is the triage routing id for that project. Optional crew[] lists +# workers/leads: {id,name,role,status,detail,projects:[],questions:[]}. # Project names and crew ids are unique. Each crew project must name a projects # entry; an empty projects list is shown as unassigned, never silently dropped. # Every question is {issue,choice,recommendation}, all non-empty plain strings. # projects and crew must appear together and require a status-page requests array. +# Optional knowledge[] lists remembered knowledge entries offered for triage: +# {id,title,detail}, plain strings with unique ids, rendered as its own section +# only when the array is present (digest-only payloads may carry it too). # Older digest-only payloads may omit requests; no second board or listener is used. # +# KEEP / DO-NOW / REMOVE CHOICES. The template renders one three-choice group +# with values backlog, now and remove beside every requests row, knowledge +# entry, project and person whose routing id can route: requests rows and +# knowledge entries route on their id, crew rows on their id, projects on the +# optional project id. The three inputs are one radio group, so a row carries +# exactly one choice or none, and an untouched row sends nothing. A triage id +# must match [A-Za-z0-9._-] and keep its full triage key within 128 characters, +# the keyed-answer key limit bin/fm-procevent-lavish.sh accepts; a row whose id +# cannot route simply renders without choices. Each section's send bar queues +# every marked row as its own fm-bearings-answer.v1 choice annotation through +# the board's single Lavish answer path - the same window.lavish.queuePrompt +# the option cards use; no second listener exists - with question +# triage.. and the selection +# backlog, now or remove. Firstmate reads such an answer from the board's +# captured result like any other choice: bin/fm-procevent-lavish.sh read prints +# each annotation (" -> <label>"), while the keyed-answer intake +# (bin/fm-captain-hold.sh answers) skips the triage.* key because it names a +# triage row, not a captain-held task, so firstmate applies the choice itself +# from the result read. backlog leaves the row as planned (a request stays on +# the backlog untouched), now brings the work forward immediately, and remove +# permanently deletes the backlog row or remembered knowledge entry. +# # Every Underway row likewise carries a non-empty `name`: the durable task name # when known, otherwise its durable identifier. # A Charted Next row MAY carry `filed`, the durable filed date (YYYY-MM-DD, or @@ -193,9 +221,14 @@ validate_payload() { # <data.json> type == "object" and (.issue | nonempty_string) and (.choice | nonempty_string) and (.recommendation | nonempty_string); def questions: (.questions | type == "array") and ([.questions[] | question_item] | all); + def triage_id($prefix): . | slug(128 - ($prefix | length)); + def knowledge_item: + type == "object" and (.id | triage_id("triage.knowledge.")) + and (.title | nonempty_string) and (.detail | nonempty_string); def project_item: type == "object" and (.name | nonempty_string) and (.delivery | nonempty_string) - and (.status | nonempty_string) and (.detail | nonempty_string) and questions; + and (.status | nonempty_string) and (.detail | nonempty_string) and questions + and ((has("id") | not) or (.id | triage_id("triage.project."))); def crew_item: type == "object" and (.id | nonempty_string) and (.name | nonempty_string) and (.role | nonempty_string) and (.status | nonempty_string) @@ -246,6 +279,10 @@ validate_payload() { # <data.json> and (([.crew[].id] | unique | length) == (.crew | length)) and (([.crew[].projects[]] - [.projects[].name]) | length == 0) else true end) + and ((has("knowledge") | not) + or ((.knowledge | type == "array") + and ([.knowledge[] | knowledge_item] | all) + and (([.knowledge[].id] | unique | length) == (.knowledge | length)))) and ([.captains_call[] | call_item] | all) and ([.underway[] | underway_item] | all) and ([.landed[] | landed_item] | all) diff --git a/tests/assets/board-render-harness.mjs b/tests/assets/board-render-harness.mjs index 6339b385efc..c385646d00b 100644 --- a/tests/assets/board-render-harness.mjs +++ b/tests/assets/board-render-harness.mjs @@ -2,10 +2,13 @@ // shim and print what the renderer actually produced, so board behavior is // asserted through the real template rather than by reading its source. // -// Usage: node board-render-harness.mjs <built-board.html> [answer] +// Usage: node board-render-harness.mjs <built-board.html> [answer|triage [choice [key-substring [mark]]]] // Prints rendered stats, task rows (including disclosures and status tones), -// section visibility, queued answer context, empty/more labels and errors. -// The optional answer mode submits the first card with its first radio and note. +// section visibility, keep/do-now/remove triage groups, triage send bars, +// queued answer context, empty/more labels and errors. +// The answer mode submits the first card with its first radio and note. The +// triage mode marks the given choice (default remove) on every matching group +// and clicks every section's send bar, exercising the choice-answer path. import { readFileSync } from "node:fs"; const html = readFileSync(process.argv[2], "utf8"); @@ -38,6 +41,7 @@ class Node { set textContent(v) { this._text = String(v); this.children = []; } appendChild(n) { n.parentNode = this; this.children.push(n); return n; } setAttribute(k, v) { this.attributes[k] = v; } + getAttribute(k) { return Object.prototype.hasOwnProperty.call(this.attributes, k) ? this.attributes[k] : null; } addEventListener(event, handler) { this.listeners[event] = handler; } querySelectorAll(sel) { const want = sel.replace(/^\./, "").replace(/:checked$/, ""); @@ -99,6 +103,58 @@ const badgesOf = (row) => .filter((c) => c.className.includes("fm-badge")) .map((c) => ({ tone: c.className.replace(/.*fm-badge--/, "").trim(), text: c.textContent })); +const walkAll = (n) => n.children.flatMap((c) => [c, ...walkAll(c)]); +const hasClass = (n, cls) => n.className.split(/\s+/).includes(cls); +// Keep / do-now / remove: one three-radio group per listed row. +const triageNodesOf = (node) => walkAll(node).filter((c) => hasClass(c, "bb-triage")); +const triageOf = (node) => { + const group = triageNodesOf(node)[0]; + if (!group) return null; + return { + key: group.attributes["data-triage-key"] ?? "", + title: group.attributes["data-triage-title"] ?? "", + choices: walkAll(group).filter((c) => hasClass(c, "bb-triage__pick")).map((i) => ({ + value: i.value, + label: i.attributes["data-label"] ?? "", + name: i.name, + })), + sent: group.classList.contains("is-queued"), + }; +}; + +// Answer modes run before extraction so printed state reflects the submitted +// answer, not the pre-click page. +if (process.argv[3] === "answer") { + const walk = (n) => n.children.flatMap((c) => [c, ...walk(c)]); + const nodes = walk(byId.get("bb-call")); + const form = nodes.find((n) => n.tagName === "form"); + const radio = nodes.find((n) => n.type === "radio"); + const note = nodes.find((n) => n.name === "note"); + radio.checked = true; + if (note) note.value = "a note"; + form.listeners.submit({ preventDefault() {} }); +} +if (process.argv[3] === "triage") { + // Mark the given choice (default remove) on every group whose key contains + // the optional substring, fire the change listeners, then click every + // section's send bar. "mark" as a fourth argument stops before the click, so + // a test can assert the pending-count state the bar shows. + const want = process.argv[4] ?? "remove"; + const only = process.argv[5] ?? ""; + for (const g of ["bb-requests", "bb-knowledge", "bb-projects"].flatMap((id) => triageNodesOf(byId.get(id) ?? new Node("div")))) { + if (only && !(g.attributes["data-triage-key"] ?? "").includes(only)) continue; + const pick = walkAll(g).find((c) => hasClass(c, "bb-triage__pick") && c.value === want); + if (!pick) continue; + pick.checked = true; + g.listeners.change?.(); + } + if (process.argv[6] !== "mark") { + for (const id of ["bb-requests-triage", "bb-knowledge-triage", "bb-projects-triage"]) { + byId.get(id + "-btn")?.listeners.click?.(); + } + } +} + const strip = byId.get("bb-stats") || new Node("div"); const stats = strip.children.map((t) => ({ n: Number(t.children.find((c) => c.className.includes("bb-stat__num"))?.textContent), @@ -141,8 +197,10 @@ const more = ch.children.filter((c) => c.className.includes("bb-morechip")).map( const requests = (byId.get("bb-requests")?.children || []).map((row) => ({ title: row.children[0]?.textContent, status: row.children[1]?.children[0]?.textContent, + tone: badgesOf(row.children[1] ?? new Node("div"))[0]?.tone ?? "", detail: row.children[1]?.children[1]?.textContent, href: row.children[1]?.children.find((c) => c.tagName === "a")?.href ?? "", + triage: triageOf(row), })); const cards = (byId.get("bb-call")?.children || []) .filter((c) => c.className.split(/\s+/).includes("bb-decision")) @@ -156,29 +214,48 @@ const coverage = (byId.get("bb-coverage")?.children || []).map((n) => n.textCont const projects = (byId.get("bb-projects")?.children || []).map((card) => ({ name: card.querySelectorAll(".bb-project__name")[0]?.textContent, delivery: card.querySelectorAll(".bb-project__delivery")[0]?.textContent, + tone: badgesOf(card)[0]?.tone ?? "", text: card.textContent, + triageKeys: triageNodesOf(card).map((g) => g.attributes["data-triage-key"] ?? ""), people: card.querySelectorAll(".bb-person").map((person) => ({ name: person.querySelectorAll(".bb-person__name")[0]?.textContent, role: person.querySelectorAll(".bb-person__role")[0]?.textContent, + tone: badgesOf(person)[0]?.tone ?? "", questions: person.querySelectorAll(".bb-question").map((q) => q.textContent), + triage: triageOf(person), text: person.textContent, })), })); +const knowledge = (byId.get("bb-knowledge")?.children || []).map((row) => ({ + title: walkAll(row).find((c) => hasClass(c, "bb-row__title"))?.textContent ?? "", + detail: walkAll(row).find((c) => hasClass(c, "bb-row__sub"))?.textContent ?? "", + triage: triageOf(row), +})); +const knowledgeHidden = byId.get("bb-knowledge-section")?.hidden ?? true; +const triageGroups = ["bb-requests", "bb-knowledge", "bb-projects"] + .flatMap((id) => triageNodesOf(byId.get(id) ?? new Node("div"))) + .map((g) => ({ + key: g.attributes["data-triage-key"] ?? "", + title: g.attributes["data-triage-title"] ?? "", + choices: walkAll(g).filter((c) => hasClass(c, "bb-triage__pick")).map((i) => ({ value: i.value, name: i.name })), + sent: g.classList.contains("is-queued"), + })); +const triageBars = ["bb-requests-triage", "bb-knowledge-triage", "bb-projects-triage"].map((id) => { + const bar = byId.get(id); + if (!bar) return { id, missing: true }; + return { + id, + hidden: bar.hidden, + count: byId.get(id + "-count")?.textContent ?? "", + disabled: byId.get(id + "-btn")?.disabled ?? null, + queued: bar.classList.contains("is-queued"), + }; +}); const sections = Object.fromEntries(["current", "call", "charted", "landed"].map((name) => [name, { hidden: byId.get("bb-" + name + "-section")?.hidden ?? false }])); const idle = !(byId.get("bb-idle")?.hidden ?? true); const projectsHidden = byId.get("bb-projects-section")?.hidden ?? true; // Section order is static markup in the built page, so read it from that output. const order = [...html.matchAll(/id="bb-(\w+)-section"/g)].map((m) => m[1]); -if (process.argv[3] === "answer") { - const walk = (n) => n.children.flatMap((c) => [c, ...walk(c)]); - const nodes = walk(byId.get("bb-call")); - const form = nodes.find((n) => n.tagName === "form"); - const radio = nodes.find((n) => n.type === "radio"); - const note = nodes.find((n) => n.name === "note"); - radio.checked = true; - if (note) note.value = "a note"; - form.listeners.submit({ preventDefault() {} }); -} process.stdout.write( - JSON.stringify({ stats, underway, charted, sections, order, idle, projectsHidden, queued, empty, more, requests, cards, projects, coverage, error: errorText }) + "\n"); + JSON.stringify({ stats, underway, charted, sections, order, idle, projectsHidden, knowledgeHidden, queued, empty, more, requests, cards, projects, knowledge, triageGroups, triageBars, coverage, error: errorText }) + "\n"); diff --git a/tests/fm-bearings-board-render.test.sh b/tests/fm-bearings-board-render.test.sh index 3950ca8cfbc..c64cf6a7851 100755 --- a/tests/fm-bearings-board-render.test.sh +++ b/tests/fm-bearings-board-render.test.sh @@ -293,7 +293,7 @@ test_projects_drop_idle_work_and_keep_shared_leads_and_unassigned_people() { and .projects[1].people[0].role == "Project lead" and .projects[2].people[0].name == "Assignment to confirm" and (.projects[0].people[0].questions[0] | contains("What it isReport data is old.") and contains("Your choiceApprove") and contains("RecommendationUse the test-only refresh")) - and (.projects | tostring | contains("routing-only") | not) + and ([.projects[].text, .projects[].people[].text] | tostring | contains("routing-only") | not) and (.stats | any(.label == "projects" and .n == 3)) and (.stats | any(.label == "people" and .n == 3)) ' >/dev/null || fail "project grouping showed an idle project or dropped people or questions: $out" @@ -353,6 +353,176 @@ test_needs_you_comes_before_open_work_and_the_queue() { pass "Needs you renders above open work and the waiting queue" } +test_request_statuses_read_differently_by_color_while_keeping_their_words() { + local home out extra + home=$(make_home status-colors) + extra=$(jq -n '{requests:[ + {id:"r-working",repo:"sample",title:"Working one",status:"Working",detail:"Under way today."}, + {id:"r-you",repo:"sample",title:"Waiting on you",status:"Waiting on you",detail:"You choose the format."}, + {id:"r-wait",repo:"sample",title:"Waiting task",status:"Waiting for another task",detail:"After the portal check."}, + {id:"r-until",repo:"sample",title:"Waiting date",status:"Waiting until Friday",detail:"Deferred to Friday."}, + {id:"r-review",repo:"sample",title:"Review one",status:"In review",detail:"Checks are running."}, + {id:"r-done",repo:"sample",title:"Done one",status:"Completed",detail:"The change shipped."}, + {id:"r-unconfirmed",repo:"sample",title:"Unconfirmed",status:"Status not confirmed",detail:"Could not be checked."}, + {id:"r-queued",repo:"sample",title:"Queued one",status:"Queued",detail:"Not started yet."}, + {id:"r-paused",repo:"sample",title:"Paused one",status:"Paused",detail:"Held for now."}, + {id:"r-free",repo:"sample",title:"Free wording",status:"Delivering next week",detail:"Plain words, no mapped tone."} + ],coverage:[]}') + out=$(render_board "$home" '[]' '[]' 0 0 '[]' '[]' "$extra") + printf '%s' "$out" | jq -e ' + .error == "" + and [.requests[].tone] == ["online","danger","warn","warn","info","done","neutral","neutral","warn","neutral"] + and [.requests[].status] == ["Working","Waiting on you","Waiting for another task","Waiting until Friday","In review","Completed","Status not confirmed","Queued","Paused","Delivering next week"] + ' >/dev/null || fail "request statuses did not read differently at a glance: $out" + pass "request statuses color from their wording, unknown wording stays neutral, the words stay visible" +} + +test_project_and_person_statuses_use_the_same_color_map() { + local home out extra + home=$(make_home project-colors) + extra=$(jq -n '{requests:[],coverage:[],projects:[ + {name:"Urgent portal",delivery:"You decide whether to publish.",status:"Waiting on you",detail:"One choice from you.",questions:[]}, + {name:"Finished sweep",delivery:"Changes stay local.",status:"Completed",detail:"Nothing left to do.",questions:[]} + ],crew:[ + {id:"w-active",name:"Active worker",role:"Worker",status:"Working",detail:"Fixing the login flow.",projects:["Urgent portal"],questions:[]}, + {id:"w-unconfirmed",name:"Unconfirmed worker",role:"Worker",status:"Status not confirmed",detail:"Could not be checked.",projects:["Urgent portal"],questions:[]}, + {id:"w-done",name:"Finished worker",role:"Worker",status:"Completed",detail:"Sweep is done.",projects:["Finished sweep"],questions:[]} + ]}') + out=$(render_board "$home" '[]' '[]' 0 0 '[]' '[]' "$extra") + printf '%s' "$out" | jq -e ' + .error == "" + and [.projects[].tone] == ["danger","done"] + and [.projects[0].people[].tone] == ["online","neutral"] + and .projects[1].people[0].tone == "done" + ' >/dev/null || fail "project and person statuses did not use the shared color map: $out" + pass "project and person statuses use the same wording-to-tone map as requests" +} + +test_every_listed_item_carries_one_three_way_triage_choice() { + local home out extra + home=$(make_home triage-groups) + extra=$(jq -n '{requests:[ + {id:"r-one",repo:"sample",title:"Routable request",status:"Working",detail:"Under way."}, + {id:"has space",repo:"sample",title:"Legacy request",status:"Status not confirmed",detail:"Id cannot route."} + ],coverage:[],knowledge:[ + {id:"k-one",title:"Deploys go through the canary pipeline",detail:"Never push straight to production."} + ],projects:[ + {name:"Charted",id:"p-one",delivery:"Steady delivery.",status:"Working",detail:"Two people.",questions:[]}, + {name:"Urgent",delivery:"You decide whether to publish.",status:"Waiting on you",detail:"One lead.",questions:[]} + ],crew:[ + {id:"lead",name:"Shared lead",role:"Project lead",status:"Working",detail:"Leads both projects.",projects:["Charted","Urgent"],questions:[]}, + {id:"wrk",name:"Charted worker",role:"Worker",status:"Paused",detail:"Waiting for a test refresh.",projects:["Charted"],questions:[]} + ]}') + out=$(render_board "$home" '[]' '[]' 0 0 '[]' '[]' "$extra") + printf '%s' "$out" | jq -e ' + .error == "" + and .requests[0].triage.key == "triage.request.r-one" + and .requests[1].triage == null + and .knowledgeHidden == false + and .knowledge[0].triage.key == "triage.knowledge.k-one" + and .projects[0].triageKeys == ["triage.project.p-one","triage.crew.lead","triage.crew.wrk"] + and .projects[1].triageKeys == [] + and .projects[1].people[0].triage == null + and (.triageGroups | length) == 5 + and ([.triageGroups[].key] | sort) == (["triage.crew.lead","triage.crew.wrk","triage.knowledge.k-one","triage.project.p-one","triage.request.r-one"] | sort) + and ([.triageGroups[].choices | map(.value)] | all(. == ["backlog","now","remove"])) + and ([.triageGroups[].choices | map(.name) | unique | length] == [1,1,1,1,1]) + and ([.triageGroups[].choices[].name] | unique | length) == 5 + and (.requests[0].triage.choices | map(.label)) == ["Leave on backlog","Do now","Remove completely"] + and ([.triageBars[] | select(.missing)] | length) == 0 + and ([.triageBars[] | .hidden] | all(. == false)) + and ([.triageBars[] | .count] | all(. == "no choice marked")) + and ([.triageBars[] | .disabled] | all(. == true)) + ' >/dev/null || fail "listed items did not each carry exactly one three-way choice: $out" + pass "every request, knowledge entry, project and person carries one three-choice keep/now/remove group" +} + +test_a_section_with_no_routable_rows_hides_its_choice_column_and_bar() { + local home out extra + home=$(make_home triage-unroutable) + extra=$(jq -n '{requests:[ + {id:"has space",repo:"sample",title:"Legacy request",status:"Status not confirmed",detail:"Id cannot route."} + ],coverage:[],projects:[ + {name:"Charted",delivery:"Steady delivery.",status:"Working",detail:"No ids anywhere.",questions:[]} + ],crew:[ + {id:"bad/id",name:"Worker",role:"Worker",status:"Working",detail:"Id cannot route.",projects:["Charted"],questions:[]} + ]}') + out=$(render_board "$home" '[]' '[]' 0 0 '[]' '[]' "$extra") + printf '%s' "$out" | jq -e ' + .error == "" + and (.triageGroups | length) == 0 + and ([.triageBars[] | select(.missing)] | length) == 3 + and .knowledgeHidden == true + and .requests[0].triage == null + and .projects[0].triageKeys == [] + and .projects[0].people[0].triage == null + ' >/dev/null || fail "unroutable rows still exposed triage affordances: $out" + pass "a row whose id cannot route renders without choices and without a send bar" +} + +test_the_knowledge_section_renders_only_when_the_payload_carries_entries() { + local home out extra + home=$(make_home knowledge-present) + extra=$(jq -n '{requests:[],coverage:[],knowledge:[ + {id:"k-deploy",title:"Deploys run through the canary pipeline",detail:"Never push straight to production."}, + {id:"k-review",title:"Reviews land within a day",detail:"Keeps the queue short."} + ]}') + out=$(render_board "$home" '[]' '[]' 0 0 '[]' '[]' "$extra") + printf '%s' "$out" | jq -e ' + .error == "" + and .knowledgeHidden == false + and [.knowledge[].title] == ["Deploys run through the canary pipeline","Reviews land within a day"] + and [.knowledge[].detail] == ["Never push straight to production.","Keeps the queue short."] + and [.knowledge[].triage.key] == ["triage.knowledge.k-deploy","triage.knowledge.k-review"] + and (.stats | any(.label == "knowledge" and .n == 2)) + ' >/dev/null || fail "the knowledge section did not render from the payload: $out" + out=$(render "$home" '[]') + printf '%s' "$out" | jq -e ' + .error == "" and .knowledgeHidden == true + and (.stats | any(.label == "knowledge") | not) + ' >/dev/null || fail "a payload without knowledge still showed a knowledge section: $out" + pass "the knowledge section and its stat appear only when the payload carries entries" +} + +test_triage_answers_queue_one_versioned_choice_per_marked_row() { + local home out extra + home=$(make_home triage-answers) + extra=$(jq -n '{requests:[ + {id:"r-one",repo:"sample",title:"Routable request",status:"Working",detail:"Under way."}, + {id:"r-two",repo:"sample",title:"Second request",status:"Waiting on you",detail:"You choose."} + ],coverage:[],knowledge:[ + {id:"k-one",title:"Deploys run through the canary pipeline",detail:"Never push straight to production."} + ],projects:[ + {name:"Charted",id:"p-one",delivery:"Steady delivery.",status:"Working",detail:"One lead.",questions:[]} + ],crew:[ + {id:"lead",name:"Shared lead",role:"Project lead",status:"Working",detail:"Leads the project.",projects:["Charted"],questions:[]} + ]}') + render_board "$home" '[]' '[]' 0 0 '[]' '[]' "$extra" >/dev/null + out=$(node "$HARNESS" "$home/.lavish/bearings-board.html" triage now triage.request mark) + printf '%s' "$out" | jq -e ' + .error == "" and (.queued | length) == 0 + and ([.triageBars[] | select(.id == "bb-requests-triage") | .count] == ["2 choices marked to send"]) + and ([.triageBars[] | select(.id == "bb-requests-triage") | .disabled] == [false]) + and ([.triageBars[] | select(.id != "bb-requests-triage") | .count] | all(. == "no choice marked")) + ' >/dev/null || fail "marking requests did not update only the requests send bar: $out" + out=$(node "$HARNESS" "$home/.lavish/bearings-board.html" triage now triage.request) + printf '%s' "$out" | jq -e ' + .error == "" and (.queued | length) == 2 + and .queued[0].data == {schema:"fm-bearings-answer.v1",question:"triage.request.r-one",selection:"now",note:""} + and .queued[1].data.question == "triage.request.r-two" + and (.queued[0].prompt | contains("Routable request") and contains("Do now")) + and ([.triageGroups[] | select(.sent) | .key] == ["triage.request.r-one","triage.request.r-two"]) + and ([.triageBars[] | select(.id == "bb-requests-triage") | .queued] == [true]) + and ([.triageBars[] | select(.id != "bb-requests-triage") | .queued] | all(. == false)) + ' >/dev/null || fail "a triage mark did not queue exactly one versioned choice for its row: $out" + out=$(node "$HARNESS" "$home/.lavish/bearings-board.html" triage remove triage.knowledge) + printf '%s' "$out" | jq -e ' + .error == "" and (.queued | length) == 1 + and .queued[0].data == {schema:"fm-bearings-answer.v1",question:"triage.knowledge.k-one",selection:"remove",note:""} + ' >/dev/null || fail "a knowledge triage mark did not queue its own choice: $out" + pass "a marked row queues exactly one fm-bearings-answer.v1 choice per section bar" +} + test_empty_sections_and_idle_projects_do_not_take_up_space() { local home out home=$(make_home empty-sections) @@ -423,3 +593,9 @@ test_warnings_are_excluded_from_the_charted_next_count test_a_board_of_only_warnings_still_reports_nothing_queued test_omitted_warnings_never_count_as_more_queued test_an_omitted_kind_keeps_queued_status_even_without_a_reason +test_request_statuses_read_differently_by_color_while_keeping_their_words +test_project_and_person_statuses_use_the_same_color_map +test_every_listed_item_carries_one_three_way_triage_choice +test_a_section_with_no_routable_rows_hides_its_choice_column_and_bar +test_the_knowledge_section_renders_only_when_the_payload_carries_entries +test_triage_answers_queue_one_versioned_choice_per_marked_row diff --git a/tests/fm-bearings-board.test.sh b/tests/fm-bearings-board.test.sh index 8c618b1c009..3fdd7e3cd2e 100644 --- a/tests/fm-bearings-board.test.sh +++ b/tests/fm-bearings-board.test.sh @@ -343,10 +343,66 @@ test_build_refuses_malformed_payloads_before_touching_the_board() { [ "$rc" -ne 0 ] || fail "invalid project/crew payload was accepted: $invalid_projects" done + # knowledge[] is optional, but a present array must carry routable ids and + # readable text; project ids are optional but must route when present. + for invalid_knowledge in \ + '"not-an-array"' \ + '[{"id":"k1","title":"Title","detail":""}]' \ + '[{"id":"k1","title":"Title","detail":"Detail"},{"id":"k1","title":"Again","detail":"Detail"}]' \ + '[{"id":"bad id","title":"Title","detail":"Detail"}]' \ + '[{"id":42,"title":"Title","detail":"Detail"}]' \ + '[{"id":"k1","title":"","detail":"Detail"}]'; do + write_valid_payload "$data" + jq --argjson knowledge "$invalid_knowledge" '.knowledge = $knowledge' "$data" > "$data.tmp" \ + && mv "$data.tmp" "$data" + set +e; out=$(run_board "$home" build "$data" 2>&1); rc=$?; set -e + [ "$rc" -ne 0 ] || fail "invalid knowledge payload was accepted: $invalid_knowledge" + done + + # A triage id must keep its full triage key within the 128-character keyed-answer + # key limit: triage.knowledge.<id> leaves 111 characters for the id. + write_valid_payload "$data" + jq --arg id "$(printf 'k%.0s' $(seq 1 112))" '.knowledge = [{id:$id,title:"Title",detail:"Detail"}]' "$data" > "$data.tmp" \ + && mv "$data.tmp" "$data" + set +e; out=$(run_board "$home" build "$data" 2>&1); rc=$?; set -e + [ "$rc" -ne 0 ] || fail "a knowledge id that breaks the triage key limit was accepted" + + for invalid_project_id in 'bad id' "$(printf 'p%.0s' $(seq 1 114))"; do + write_valid_payload "$data" + jq --arg id "$invalid_project_id" '.requests=[] | .captains_call=[] | + .projects=[{name:"sample",id:$id,delivery:"Changes stay local.",status:"Working",detail:"Still managed.",questions:[]}] | + .crew=[{id:"one",name:"A person",role:"Worker",status:"Working",detail:"Checking the report.",projects:["sample"],questions:[]}]' \ + "$data" > "$data.tmp" && mv "$data.tmp" "$data" + set +e; out=$(run_board "$home" build "$data" 2>&1); rc=$?; set -e + [ "$rc" -ne 0 ] || fail "an invalid project triage id was accepted: $invalid_project_id" + done + assert_absent "$board" "a refused payload still produced a board" pass "build refuses malformed payloads before touching the board" } +test_build_accepts_optional_knowledge_and_triage_ids_at_their_limits() { + local home data board out + home=$(make_home triage-limits) + data="$home/payload.json" + board="$home/.lavish/bearings-board.html" + write_valid_payload "$data" + # 111 id characters keep triage.knowledge.<id> exactly at the 128-character + # keyed-answer key limit; a project id rides along on a minimal portfolio. + jq --arg kid "$(printf 'k%.0s' $(seq 1 111))" --arg pid "p-one" ' + .requests=[] | .captains_call=[] | .coverage=[] + | .knowledge=[{id:$kid,title:"Deploys run through the canary pipeline",detail:"Never push straight to production."}] + | .projects=[{name:"sample",id:$pid,delivery:"Changes stay local.",status:"Working",detail:"Still managed.",questions:[]}] + | .crew=[{id:"one",name:"A person",role:"Worker",status:"Working",detail:"Checking the report.",projects:["sample"],questions:[]}]' \ + "$data" > "$data.tmp" && mv "$data.tmp" "$data" + out=$(run_board "$home" build "$data") || fail "a payload with optional knowledge and triage ids did not build: $out" + assert_present "$board" "an accepted optional-fields payload produced no board" + extract_payload "$board" | jq -e --arg kid "$(printf 'k%.0s' $(seq 1 111))" ' + .knowledge[0].id == $kid and .projects[0].id == "p-one" and .crew[0].id == "one" + ' >/dev/null || fail "the built board did not carry the optional knowledge and triage ids" + pass "build accepts optional knowledge and triage ids up to the key limit" +} + test_build_injects_binds_then_arms() { local home data board out sid home=$(make_home build) @@ -821,6 +877,7 @@ test_build_refuses_a_nondecision_reconcile_value() { test_path_is_stable_and_home_scoped test_build_refuses_malformed_payloads_before_touching_the_board +test_build_accepts_optional_knowledge_and_triage_ids_at_their_limits test_charted_kind_is_optional_and_accepts_both_values test_build_injects_binds_then_arms test_registration_cannot_consume_before_any_origin_binding From b803d318fe1f65bd347306c0b9d210121148f387 Mon Sep 17 00:00:00 2001 From: Daan Aerts <daan@daeverhuur.nl> Date: Fri, 2 Oct 2026 13:53:19 +0200 Subject: [PATCH 2/3] no-mistakes(review): Let changed triage choices be re-sent after queueing --- .agents/skills/bearings/assets/board-template.html | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.agents/skills/bearings/assets/board-template.html b/.agents/skills/bearings/assets/board-template.html index bd93820650a..17eb8d101a4 100644 --- a/.agents/skills/bearings/assets/board-template.html +++ b/.agents/skills/bearings/assets/board-template.html @@ -672,7 +672,13 @@ <h2>Your requests</h2> count.textContent = n ? n + " choice" + (n === 1 ? "" : "s") + " marked to send" : "no choice marked"; btn.disabled = !n; } - Array.prototype.forEach.call(groups, function (g) { g.addEventListener("change", refresh); }); + Array.prototype.forEach.call(groups, function (g) { + g.addEventListener("change", function () { + g.classList.remove("is-queued"); + bar.classList.remove("is-queued"); + refresh(); + }); + }); btn.addEventListener("click", function () { var items = pendingGroups(); if (!items.length) return; From ec61141860775776ce2fc42e594bdd2e72a46558 Mon Sep 17 00:00:00 2001 From: Daan Aerts <daan@daeverhuur.nl> Date: Fri, 2 Oct 2026 13:59:47 +0200 Subject: [PATCH 3/3] no-mistakes(test): Stack request table cells at phone width to stop mid-word wraps --- .agents/skills/bearings/assets/board-template.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.agents/skills/bearings/assets/board-template.html b/.agents/skills/bearings/assets/board-template.html index 17eb8d101a4..35614672a9d 100644 --- a/.agents/skills/bearings/assets/board-template.html +++ b/.agents/skills/bearings/assets/board-template.html @@ -355,7 +355,12 @@ .bb-row__title, .bb-row__sub { white-space: normal; overflow-wrap: anywhere; } .bb-decision__title, .bb-ctx__v, .bb-opt__body, .bb-meta-mono { overflow-wrap: anywhere; } .bb-meta-mono, .bb-decision__repo { white-space: normal; } - .bb-requests th, .bb-requests td { padding: 9px; } + /* Three fixed columns leave each cell too narrow for a badge or a choice + label at phone width, so each request stacks its cells full width. */ + .bb-requests thead { display: none; } + .bb-requests tbody, .bb-requests tr, .bb-requests th, .bb-requests td, .bb-requests th:first-child { display: block; width: auto; } + .bb-requests tr { border-top: 1px solid var(--border-soft); padding: 4px 0; } + .bb-requests th, .bb-requests td { padding: 5px 9px; border-top: 0; } } </style> </head>