diff --git a/.agents/skills/bearings/assets/board-template.html b/.agents/skills/bearings/assets/board-template.html index 786d14e4249..2a1a95b7468 100644 --- a/.agents/skills/bearings/assets/board-template.html +++ b/.agents/skills/bearings/assets/board-template.html @@ -190,14 +190,17 @@ .bb-decision__pad { padding: 18px 20px 16px; display: flex; flex-direction: column; gap: 12px; flex: 1; } .bb-decision__top { display: flex; align-items: center; justify-content: space-between; gap: 10px; } .bb-decision__repo { font-family: var(--font-mono); font-size: var(--fs-2xs); color: var(--text-faint); white-space: nowrap; } -.bb-decision__title { margin: 0; font-size: var(--fs-h4); font-weight: 800; color: var(--text-strong); line-height: 1.25; } -.bb-decision__detail { margin: 0; font-size: var(--fs-sm); color: var(--text-muted); line-height: 1.45; } +.bb-decision__title { margin: 0; font-size: var(--fs-h4); font-weight: 800; color: var(--text-strong); line-height: 1.25; + overflow-wrap: anywhere; } +.bb-decision__detail { margin: 0; font-size: var(--fs-sm); color: var(--text-muted); line-height: 1.45; + overflow-wrap: anywhere; } /* about / decide / recommended - three scannable context rows */ .bb-ctx { display: flex; flex-direction: column; gap: 6px; } .bb-ctx__row { display: grid; grid-template-columns: 78px minmax(0,1fr); gap: 8px; align-items: baseline; } .bb-ctx__k { font-family: var(--font-mono); font-size: var(--fs-2xs); letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-faint); white-space: nowrap; } -.bb-ctx__v { font-size: var(--fs-sm); color: var(--text-muted); line-height: 1.4; min-width: 0; } +.bb-ctx__v { font-size: var(--fs-sm); color: var(--text-muted); line-height: 1.4; min-width: 0; + overflow-wrap: anywhere; } .bb-decision__link { font-family: var(--font-mono); font-size: var(--fs-xs); color: var(--ocean-600); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; } .bb-decision__link:hover { text-decoration: underline; } /* One rhythm owner: the answer form is a flex column with the same 12px gap as @@ -213,8 +216,10 @@ .bb-opt:hover { border-color: var(--ink-300); } .bb-opt input { accent-color: var(--rust-500); margin-top: 3px; flex: none; } .bb-opt__body { min-width: 0; flex: 1 1 auto; } -.bb-opt__label { display: block; font-size: var(--fs-sm); font-weight: 700; color: var(--text-strong); line-height: 1.3; } -.bb-opt__hint { display: block; font-size: var(--fs-xs); color: var(--text-muted); line-height: 1.35; margin-top: 1px; } +.bb-opt__label { display: block; font-size: var(--fs-sm); font-weight: 700; color: var(--text-strong); line-height: 1.3; + overflow-wrap: anywhere; } +.bb-opt__hint { display: block; font-size: var(--fs-xs); color: var(--text-muted); line-height: 1.35; margin-top: 1px; + overflow-wrap: anywhere; } .bb-opt__rec { flex: none; align-self: center; font-size: var(--fs-2xs); font-weight: 800; text-transform: uppercase; letter-spacing: 0.07em; color: var(--navy-700); background: var(--gold-300); border: 1px solid var(--gold-600); border-radius: var(--radius-xs); padding: 3px 7px 2px; } @@ -273,7 +278,7 @@ .bb-foot { display: flex; justify-content: flex-end; border-top: 1px solid var(--border-default); padding-top: 14px; } @media (max-width: 900px) { - .bb-cols { grid-template-columns: 1fr; } + .bb-cols { grid-template-columns: minmax(0, 1fr); } } @@ -434,11 +439,42 @@ if (text != null) n.textContent = text; return n; } + /* The styles above deliberately clamp, ellipsize, or hard-wrap captain-facing + text, so any renderer-produced text node CSS can visually cut also carries + its exact full text as a native title tooltip - the visible label stays as + rendered and the complete value stays reachable on hover. Build every such + node with titledEl when its tooltip is exactly its visible text, or with + titledValueEl when the visible label is a deliberate short form of a longer + value (the landed PR link shows "#" and titles the full URL), and + keep plain el for text that is already fully shown; a node that already + exists in the static markup instead sets .title beside its textContent at + the one place that fills it. + The null guard matters because title reflects a DOMString: assigning a + missing value would surface the literal "null" as the tooltip. + tests/fm-bearings-board.test.sh renders a built board and asserts these + exact tooltip values. */ + function titledEl(tag, cls, text) { + var n = el(tag, cls, text); + if (text != null) n.title = text; + return n; + } + function titledValueEl(tag, cls, text, fullText) { + var n = el(tag, cls, text); + if (fullText != null) n.title = fullText; + return n; + } function badge(tone, text) { return el("span", "fm-badge fm-badge--" + tone, text); } + /* Fixed badge literals are short, nowrap, and always fully drawn, so they + stay tooltip-free; payload-derived badge text has no bounded length and + carries its exact full value like every other clampable node. */ + function dynamicBadge(tone, text) { return titledEl("span", "fm-badge fm-badge--" + tone, text); } function utf8ByteLength(text) { return new TextEncoder().encode(text).length; } var CHECK_SVG = ''; - document.getElementById("bb-provenance").textContent = data.schema + " · " + data.generated; + var provenance = data.schema + " · " + data.generated; + var provenanceNode = document.getElementById("bb-provenance"); + provenanceNode.textContent = provenance; + provenanceNode.title = provenance; /* stat strip */ var callTotal = data.captains_call.length; @@ -463,7 +499,7 @@ function ctxRow(k, v, rec) { var row = el("div", "bb-ctx__row" + (rec ? " bb-ctx__row--rec" : "")); row.appendChild(el("span", "bb-ctx__k", k)); - row.appendChild(el("span", "bb-ctx__v", v)); + row.appendChild(titledEl("span", "bb-ctx__v", v)); return row; } @@ -474,19 +510,19 @@ var top = el("div", "bb-decision__top"); if (item.type === "merge") { top.appendChild(badge("online", "checks green")); - top.appendChild(badge(item.risk === "low" ? "neutral" : "warn", "risk " + item.risk)); + top.appendChild(dynamicBadge(item.risk === "low" ? "neutral" : "warn", "risk " + item.risk)); } else { top.appendChild(badge("solid", "decision")); - top.appendChild(el("span", "bb-decision__repo", item.repo)); + top.appendChild(titledEl("span", "bb-decision__repo", item.repo)); } pad.appendChild(top); - pad.appendChild(el("h3", "bb-decision__title", item.title)); + pad.appendChild(titledEl("h3", "bb-decision__title", item.title)); if (item.type === "merge") { - if (item.detail) pad.appendChild(el("p", "bb-decision__detail", item.detail)); + if (item.detail) pad.appendChild(titledEl("p", "bb-decision__detail", item.detail)); if (item.pr_url) { - var pr = el("a", "bb-decision__link", item.pr_url); + var pr = titledEl("a", "bb-decision__link", item.pr_url); pr.href = item.pr_url; pr.target = "_blank"; pr.rel = "noopener"; pad.appendChild(pr); } @@ -507,8 +543,8 @@ input.type = "radio"; input.name = "answer"; input.value = o.value; lab.appendChild(input); var body = el("span", "bb-opt__body"); - body.appendChild(el("span", "bb-opt__label", o.label)); - if (o.hint) body.appendChild(el("span", "bb-opt__hint", o.hint)); + body.appendChild(titledEl("span", "bb-opt__label", o.label)); + if (o.hint) body.appendChild(titledEl("span", "bb-opt__hint", o.hint)); lab.appendChild(body); if (item.recommend_value && o.value === item.recommend_value) { lab.appendChild(el("span", "bb-opt__rec", "rec")); @@ -521,6 +557,10 @@ var ff = document.createElement("input"); ff.type = "text"; ff.name = "note"; ff.className = "bb-freeform"; ff.placeholder = item.freeform_hint || "or answer in your own words…"; + /* a single-line field hard-clips its placeholder at the field edge, and + freeform_hint is unbounded payload text, so the supplied hint stays + reachable in full; the short built-in fallback needs no tooltip */ + if (item.freeform_hint) ff.title = item.freeform_hint; form.appendChild(ff); } @@ -605,12 +645,12 @@ if (!data.underway.length) uw.appendChild(el("div", "bb-empty", "Nothing is underway.")); data.underway.forEach(function (t) { var row = el("div", "bb-row"); - row.appendChild(badge(t.state === "working" ? "online" : "info", t.state)); + row.appendChild(dynamicBadge(t.state === "working" ? "online" : "info", t.state)); var main = el("div", "bb-row__main"); - main.appendChild(el("div", "bb-row__title", t.doing)); + main.appendChild(titledEl("div", "bb-row__title", t.doing)); /* captain-facing rows name the repo; the internal task id shows only when no repo is known */ - main.appendChild(el("div", "bb-row__sub", t.kind + " · " + (t.repo || t.id))); + main.appendChild(titledEl("div", "bb-row__sub", t.kind + " · " + (t.repo || t.id))); row.appendChild(main); uw.appendChild(row); }); @@ -624,12 +664,12 @@ chk.innerHTML = ''; row.appendChild(chk); var main = el("div", "bb-row__main"); - main.appendChild(el("div", "bb-row__title", t.what)); - main.appendChild(el("div", "bb-row__sub", (t.repo || t.id) + " · " + t.owner)); + main.appendChild(titledEl("div", "bb-row__title", t.what)); + main.appendChild(titledEl("div", "bb-row__sub", (t.repo || t.id) + " · " + t.owner)); row.appendChild(main); if (t.pr_url) { - var a = el("a", "bb-row__pr", "#" + t.pr_url.split("/").pop()); - a.href = t.pr_url; a.title = t.pr_url; a.target = "_blank"; a.rel = "noopener"; + var a = titledValueEl("a", "bb-row__pr", "#" + t.pr_url.split("/").pop(), t.pr_url); + a.href = t.pr_url; a.target = "_blank"; a.rel = "noopener"; row.appendChild(a); } ld.appendChild(row); @@ -672,10 +712,10 @@ row.appendChild(el("span", "bb-pick-spacer")); } var main = el("div", "bb-row__main"); - main.appendChild(el("div", "bb-row__title", t.title)); + main.appendChild(titledEl("div", "bb-row__title", t.title)); /* second line keeps the title uncrowded in the half-width column */ var chSub = t.repo || t.id; - main.appendChild(el("div", "bb-row__sub", t.reason ? t.reason + " · " + chSub : chSub)); + main.appendChild(titledEl("div", "bb-row__sub", t.reason ? t.reason + " · " + chSub : chSub)); row.appendChild(main); if (t.reason) row.appendChild(badge("warn", "waiting")); ch.appendChild(row); diff --git a/tests/fm-bearings-board.test.sh b/tests/fm-bearings-board.test.sh index a0807d87a7e..296121e92a7 100644 --- a/tests/fm-bearings-board.test.sh +++ b/tests/fm-bearings-board.test.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash # Behavior tests for bin/fm-bearings-board.sh: fail-closed payload validation, # slot-injection round-trip through the built page, bind-before-arm, and -# idempotent re-arm of the stable board source. +# idempotent re-arm of the stable board source, plus the built board's +# full-text tooltip invariant, asserted by running the shipped renderer against +# a minimal DOM shim so the coverage never depends on a headless browser. set -u # shellcheck source=tests/lib.sh @@ -104,6 +106,179 @@ extract_payload() { # | sed '1d;$d' } +# Execute the shipped renderer from a built board page against a minimal DOM +# shim and print the rendered node state as JSON, so tooltip behavior is +# asserted from what the renderer actually produced rather than from a headless +# browser that the runner may not have. The board page is generated public +# output and the renderer is the real consumer under test. +render_board_node_json() { # + local render + command -v node >/dev/null 2>&1 || return 4 + render=$(BOARD_PAGE="$1" node --input-type=module <<'JS' +import { readFileSync } from "node:fs"; + +const html = readFileSync(process.env.BOARD_PAGE, "utf8"); +const dataBlock = html.match( + /