From dddfe9550633bbd47dacd47a3faa1ab891aec264 Mon Sep 17 00:00:00 2001 From: Mark Beacom Date: Fri, 9 Oct 2026 22:25:09 -0400 Subject: [PATCH 1/6] feat(agent-plugin): visual pass on the decision-review and decision-board canvases I gave both Copilot app canvases one shared stylesheet (canvas-theme.mjs) so their tokens, badges, buttons, and focus ring cannot drift. Colours still read the app's injected tokens first; the fallbacks now follow prefers-color-scheme, where before every render outside the app was light. A test checks every fallback text colour at 4.5:1 in both themes. decision-review: governing records as cards with status, verdict, and provenance in view; findings grouped by severity; notes collapsed; the review cost stated under the button; distinct loading, empty, and error states, the last showing the fixed messages. The paid button starts disabled until the first snapshot. decision-board: pan and zoom through pure viewBox helpers (board-view.mjs, embedded into the page from their own source), neighbour highlight, a clearer legend, approvals as dots against quorum, labelled objection counts, and review controls that label their three steps. With recording off, the fixed note is shown once above the queue. Routes, actions, payload, and the CSP are unchanged. No inline style, no HTML sink, no external asset. A new test bans inline style on both pages. Signed-off-by: Mark Beacom --- AGENTS.md | 39 +- CHANGELOG.md | 24 + docs/reference-verification-agent-plugin.md | 52 ++ packages/adapters/agent-plugin/README.md | 67 +- .../extensions/adrkit/board-page.mjs | 603 ++++++++++++++---- .../extensions/adrkit/board-view.mjs | 159 +++++ .../extensions/adrkit/canvas-page.mjs | 405 +++++++----- .../extensions/adrkit/canvas-theme.mjs | 246 +++++++ .../agent-plugin/test/packaging.test.ts | 4 + .../agent-plugin/test/visual-pass.test.ts | 519 +++++++++++++++ 10 files changed, 1829 insertions(+), 289 deletions(-) create mode 100644 packages/adapters/agent-plugin/extensions/adrkit/board-view.mjs create mode 100644 packages/adapters/agent-plugin/extensions/adrkit/canvas-theme.mjs create mode 100644 packages/adapters/agent-plugin/test/visual-pass.test.ts diff --git a/AGENTS.md b/AGENTS.md index 3a1b3c9..0aa95f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -771,7 +771,7 @@ will usually be a regression: including a variable whose name ends in `_SECRET_TOKEN`. Its arrival in the app, and hook firing in the app, an interactive CLI session, and subagent child sessions, are unmeasured. -- `copilot plugin install` prints only a skill count. Version 0.10.0 should report +- `copilot plugin install` prints only a skill count. Version 0.11.0 should report two skills; that does not inventory the agent or commands — verify them in a fresh session. @@ -1016,6 +1016,43 @@ modes are unmeasured there. Load-bearing: through `session.log` with the id, kind, identity, and outcome (`not-confirmed` included), never the summary. +### Canvas look and feel (0.11.0) + +Both canvases share one stylesheet and the board pans and zooms. **Rung 1**: +unit tests, mutation plants, and headless SDK-host renders in Chrome; +unmeasured in the Copilot app. Load-bearing: + +- **One shared stylesheet, generated from its tokens.** `canvas-theme.mjs` + exports `THEME_CSS`, and each page's `/app.css` is that block followed by + the page's own rules (no new route). Every colour is an alias that reads the + app's injected token first, `var(--background-color-default, var(--ak-bg))`, + so theming stays the app's. The `--ak-*` fallbacks come from `THEME_TOKENS` + and follow `prefers-color-scheme`; before 0.11.0 every dark and light render + outside the app was byte-identical. The contrast test reads `THEME_TOKENS`, + so a hue changed there is checked at 4.5:1 on the background, the surface, + and its own tint, in both themes. +- **No inline style on either page, tested.** `test/visual-pass.test.ts` fails + on a `style` attribute or element in the HTML and on `.style`, `'style'`, + `cssText`, `setProperty(`, `insertRule(`, or `CSSStyleSheet` in either + script. Sizes are class steps (the graph frame's `h-s` to `h-xl`), never + computed styles. +- **Pan and zoom are pure helpers shipped twice.** `board-view.mjs` exports + them for the tests, and `board-page.mjs` embeds the same functions with + `Function.prototype.toString`. Each must stay self-contained ES2017 (no + imports, no module constants, no closures); a test runs the embedded copies. + A view is a `viewBox`, so zooming sets one attribute and rebuilds nothing. +- **A drag must not select.** A pointer drag past 4 px sets a flag the record's + click handler consumes, and the graph's `+ - 0` and arrow keys ignore events + from an input or button. +- **The confirm step is an attribute, not a class.** `data-step="confirm"` + styles step 2 of the review controls, because the review tests find the box + by `className === 'review-controls'`. With recording off, the fixed note is + shown once above the queue and each disabled control carries it as its + description. +- **Objector names are not shown, because `adr queue` does not report them.** + QueueReport v1 carries only the two objection counts; showing who raised each + needs a CLI field, not a page change. + ## The OCI container (`ghcr.io/mbeacom/adrkit`) The OCI image is the fifth distribution surface, authorized by diff --git a/CHANGELOG.md b/CHANGELOG.md index 8246487..89ed9cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,30 @@ Until `1.0.0`, minor releases may include breaking changes nothing, an accept wrote it, and a quick second click was rate-limited. Object, Resolve, and the app's autonomous modes are not yet measured there. +### Changed + +- **The agent plugin's two Copilot app canvases get a visual pass (`adrkit` + 0.11.0).** Function, routes, actions, the payload, and the + Content-Security-Policy are unchanged. + - Both pages share one stylesheet. Colours still come from the app's tokens + first; the built-in fallbacks now follow the system's light or dark + setting, where before every render outside the app was light. Text + colours meet 4.5:1 in both themes, checked by a test. + - Status is never colour alone: every badge has a symbol and its word. + - `decision-review` shows each governing decision as a card with its + status, verdict, and provenance (the `affects` pattern, or the marker's + file and line) in view; findings grouped by severity; notes collapsed; the + review cost in plain words under the button; and distinct loading, empty, + and error states. + - `decision-board` pans and zooms (drag, Ctrl or Cmd with the wheel, the + buttons, or `+`, `-`, `0`, and the arrows), dims everything but the + selected record's neighbors, and shows approvals as dots against the + quorum and objections as two labelled counts. The review controls label + their three steps, and with recording off one note above the list says + why. Objector names are not shown, because `adr queue` reports counts only. + - Rendered from a headless Copilot CLI 1.0.93 SDK host in Chrome at 900 and + 420 px, light and dark, with no model calls; unmeasured in the Copilot app. + ### Fixed - **Docs: the Spec Kit install command, the command names, and the checkout pin diff --git a/docs/reference-verification-agent-plugin.md b/docs/reference-verification-agent-plugin.md index bae893b..e3938a0 100644 --- a/docs/reference-verification-agent-plugin.md +++ b/docs/reference-verification-agent-plugin.md @@ -1345,6 +1345,58 @@ recorded because they affect anyone testing a local build in the app. Each board allows one write at a time, but separate processes are not serialized, as ADR-0051 accepts for the CLI. +## v0.11.0 canvas visual pass (2026-10-10) + +A presentation pass on `decision-review` and `decision-board`: one shared +stylesheet (`canvas-theme.mjs`), light and dark fallbacks, badges with a +symbol and a word, and pan and zoom on the board graph (`board-view.mjs`). +Routes, actions, the payload, and the Content-Security-Policy are unchanged. + +### Headless renders (Copilot CLI 1.0.93 SDK host, Chrome headless, no model calls) + +Each canvas was opened through `createSession({ pluginDirectories, +requestCanvasRenderer: true, requestExtensions: true })` with no prompt sent and +no `run_review`, and its page URL captured in Chrome over the DevTools protocol +at 900 and 420 px wide, with `prefers-color-scheme` light and dark. Corpora: +this repository's `docs/adr` (52 records, 255 relationships), a five-record +fixture with a proposed record carrying two approvals of a quorum of three, one +open and one resolved objection, and an SLA deadline, a 320-record synthetic +corpus (past the 300-record budget), and a copy of the `adrkit-demo` corpus. + +| # | Surface | State | Result | +|---|---|---|---| +| V1 | review | governing records, before a review | Cards with status badge and provenance chips (`affects path: src/cache/**`, `marker src/cache/ttl.ts:1 names 0003`); cost line under the button | +| V2 | review | stale-marker finding | Findings grouped, `Warnings (1)` | +| V3 | review | no changed files | Distinct empty state; Run review disabled with its reason | +| V4 | review | session directory outside a git work tree | `usage-error`; the fixed not-a-work-tree note shown open in a red callout, not repeated under Notes | +| V5 | review | loading (state request held) | Spinner and "Reading the change and running adr check…" | +| V6 | board | 52-record graph | Opens at the top at 0.6 scale (the whole graph would be smaller); edges at reduced weight; zoom bar and keyboard hint | +| V7 | board | 320 records | Summary: status counts as badges, focus form | +| V8 | board | a record focused | Neighbors bright, the rest dimmed; detail pane with status badge | +| V9 | board | queue row selected | Approvals as dots (2 of 3), `1 unresolved` and `1 resolved` badges, SLA badge and deadline | +| V10 | board | recording off (headless host has no elicitation) | All three controls disabled; one note above the list; each control carries it as its description | +| V11 | both | dark vs light | Every pair differs (before 0.11.0 every pair was byte-identical) | + +The finished review (verdicts `consistent` and `conflicts`), the enabled review +controls, and the confirm step cannot be reached in the headless host, which +has no completed run and no elicitation. Those three were rendered by a stub +server that serves the shipped page strings with the real security headers and +a snapshot captured from the host, with only `review` replaced: the confirm +step shows "Step 2 of 3" in its own highlighted box and names step 3, the +host's dialog. The chrome-devtools MCP was used for a spot check of keyboard +order and accessible names on the fixture board. + +Screenshots, before and after, are kept with the track's working notes, not in +the repository. + +### Not verified + +- **Any of this in the Copilot app.** The app injects its own theme tokens, + which replace the fallback colours, so the app's contrast is the app's. +- Pan and zoom with a real trackpad pinch, and the wheel's pan direction, in + the app's webview. +- The enabled review controls and the confirm step outside the stub server. + ## Verdict The plugin's six components load on Copilot CLI and function correctly against a diff --git a/packages/adapters/agent-plugin/README.md b/packages/adapters/agent-plugin/README.md index 2ae4e88..1996406 100644 --- a/packages/adapters/agent-plugin/README.md +++ b/packages/adapters/agent-plugin/README.md @@ -73,9 +73,11 @@ confirmation before executing a CLI resolved inside that worktree. ### Updating -Version 0.10.0 adds approve, object, and resolve controls to the -`decision-board` canvas (they need `@adrkit/cli` 0.18.0 or later, -`ADRKIT_REVIEWER`, and a host that can ask you to confirm each write); 0.9.1 reviews uncommitted and untracked edits (the default change was the committed range alone) and names a session directory git does not treat as a work tree; 0.9.0 added the `decision-board` canvas; 0.8.1 hardens the extension (fixed error messages, `/cd` for the +Version 0.11.0 is a visual pass on both canvases (a shared stylesheet, light +and dark fallbacks, and pan and zoom on the board graph); 0.10.0 added approve, +object, and resolve controls to the `decision-board` canvas (they need +`@adrkit/cli` 0.18.0 or later, `ADRKIT_REVIEWER`, and a host that can ask you +to confirm each write); 0.9.1 reviews uncommitted and untracked edits (the default change was the committed range alone) and names a session directory git does not treat as a work tree; 0.9.0 added the `decision-board` canvas; 0.8.1 hardens the extension (fixed error messages, `/cd` for the workflow, batched checks for wide changes, and process-tree cleanup); 0.8.0 added the advisory session hooks; 0.7.0 added the read-only `adr_check`, `adr_explain`, and `adr_lint` extension tools; 0.6.0 added @@ -352,10 +354,14 @@ architecture decisions governing the current change. It ships in the same **What it shows.** The status, the working directory, and the changed files; the governing decisions; active proposals; history (listed, not judged); the `adr check` and `adr lint` findings; notes; and, once a review exists, the -`adr-review` verdicts and anything left `unverified`. Each decision has a -collapsed evidence section, and clicking a decision asks the agent to explain -it (see Explain, below). Before a review runs, the panel shows what `adr check` -found. A wide change lists its first 200 paths and counts the rest; the +`adr-review` verdicts and anything left `unverified`. Each decision is a card +with its status, its verdict once a review exists, and what tied it to the +change; the reviewer's evidence is a collapsed section, and a button asks the +agent to explain the decision (see Explain, below). Findings are grouped by +severity, errors first, and notes are collapsed. Loading, no changed files, +and a check that could not run (for example, a session directory outside a git +work tree) each have their own state, the last showing the panel's fixed +message. Before a review runs, the panel shows what `adr check` found. A wide change lists its first 200 paths and counts the rest; the panel's notes are fixed messages and never show the CLI's stderr, an exception's text, or a run's own error. @@ -415,15 +421,16 @@ Proposed in [ADR-0047](../../../docs/adr/0047-show-provenance-review-cost-and-a-read-only-proposal-queue-in-the-decision-revie.md) (**proposed**, amends ADR-0046). Three additions, with no new action or route: -- **Why each decision governs.** A decision's evidence section names what tied - it to the change. An inbound marker shows the changed file and line that +- **Why each decision governs.** A decision's card names what tied it to the + change. An inbound marker shows the changed file and line that named the record. An `affects` match shows the pattern, and says that `adr check` does not report which changed file matched it; the panel does not guess. `get_state` carries the same data as `declaredBy` and `firedMatchers`. - **What a review costs before you start it.** `adr-review` makes at most one `decision-checker` call per governing decision, so the button reads "Run review: N decision-checker call(s) (uses AI credits)", and `get_state` - carries the count as `judgeCalls`. With no governing decision the button is + carries the count as `judgeCalls`; a line under the button says the same in + words, and that Refresh is free. With no governing decision the button is disabled and says there is nothing to judge. When `adr check` or `adr lint` exits 2 or more, the workflow skips its Judge, so `judgeCalls` is 0 and the button says no checker calls will be made. Runtime retries are not counted. @@ -442,6 +449,24 @@ Proposed in Measured in a headless Copilot CLI 1.0.93 SDK host; the new UI is unmeasured in the Copilot app. +### Look and feel (0.11.0) + +Both canvases share one stylesheet, `extensions/adrkit/canvas-theme.mjs`, so +their spacing, type, badges, buttons, and focus ring cannot drift apart. The +theme is still the app's: every colour reads the app's own token first, and +the built-in fallbacks follow the system's light or dark setting when the app +supplies none. Status is never shown by colour alone; each badge also has a +symbol and its word. Motion is limited to a spinner and short transitions, and +both stop under reduced motion. Below about 520 px the panes stack. A test +checks that every text colour in the fallbacks meets 4.5:1 in both themes. + +The pages still set no inline style, take no external font or library, and +build their DOM with `textContent`; the Content-Security-Policy is unchanged. + +Rendered from a headless Copilot CLI 1.0.93 SDK host in Chrome at 900 and 420 +px, light and dark; the new look is **unmeasured in the Copilot app**, where +the app's own tokens replace the fallback colours. + ## Tools: `adr_check`, `adr_explain`, `adr_lint` (GitHub Copilot) The same `extensions/adrkit/` extension registers three read-only tools that @@ -564,8 +589,15 @@ ADR-0047). It renders what the CLI computed and derives nothing of its own: title, and status, and each `supersedes`, `relatesTo`, or `conflictsWith` relationship. A supersession chain reads left to right, oldest first. Status is shown by color and by a text label, and a relationship kind by line style - and a legend. Records are focusable; Enter selects one, and the detail pane - shows its fields, its neighbors, and its queue row if it has one. + and a legend. Records are focusable; Enter selects one, its neighbors stay + bright and the rest dim, and the detail pane shows its fields, its neighbors, + and its queue row if it has one. Long titles are clipped in the box, with the + full title as the record's tooltip. +- **Pan and zoom.** Drag to pan, Ctrl or Cmd with the wheel (or a trackpad + pinch) to zoom at the pointer, the −, +, and Fit buttons, or, with the graph + focused, `+`, `-`, `0` (fit), and the arrow keys. The graph opens whole when + it fits at 60% or more, and otherwise at the top, fitted to the width. Panning + and zooming rewrite the drawing's `viewBox` and nothing else. - **Focus and kind filters re-run the CLI.** Focusing on a record runs `adr graph --focus `, and the kind checkboxes add `--kind`, so the board and `adr graph` cannot disagree. The id must be a record id (four or more @@ -576,7 +608,10 @@ ADR-0047). It renders what the CLI computed and derives nothing of its own: - **The queue** comes from `adr queue --format json` and shows each open proposal's raw review facts: approvals against quorum, unresolved and resolved objections, SLA state, deadline, routing, and how many findings it - carries. **The board never says a proposal is ready.** Review state alone + carries. Approvals show as dots against the quorum, with the numbers; the + objections as two labelled counts. `adr queue` reports counts only, so the + board cannot say who raised each objection. **The board never says a + proposal is ready.** Review state alone misses refusals such as an empty `deciders`, so a verdict would be a claim the board cannot back. There is no ratify control. @@ -615,12 +650,16 @@ Each open proposal on the board has three controls, proposed in the environment GitHub Copilot runs in, then restart it. - **A host that can show a confirmation dialog** to you (elicitation). -Without any one of these the controls are disabled, with a note saying why. +Without any one of these the controls are disabled, with one note above the +list saying why; each disabled control carries the same note as its +description. **How a write happens.** 1. On the board, the first click prepares a confirmation. 2. The second, "Confirm approval as ``" (or objection, or resolution), sends it. + The board labels these as steps 1 and 2 of 3, and step 2 is a highlighted + box of its own, so a confirmation waiting for its click is hard to miss. 3. **GitHub Copilot then asks you to confirm it in its own dialog**, naming the record, the kind, and your identity, and the board writes only if you say yes there. The dialog's first line is the record and the action, such as diff --git a/packages/adapters/agent-plugin/extensions/adrkit/board-page.mjs b/packages/adapters/agent-plugin/extensions/adrkit/board-page.mjs index e79f7fa..f266562 100644 --- a/packages/adapters/agent-plugin/extensions/adrkit/board-page.mjs +++ b/packages/adapters/agent-plugin/extensions/adrkit/board-page.mjs @@ -24,9 +24,15 @@ * posts it. No browser dialog: one can block the app. The identity shown is * the server's `ADRKIT_REVIEWER`; the page sends none. The page names kinds * (approval, objection, resolution), never the CLI's subcommands. + * + * The graph pans and zooms by rewriting the SVG `viewBox` through the pure + * helpers in board-view.mjs, embedded here from their own source: pointer + * drag, Ctrl or Cmd with the wheel (a trackpad pinch), the zoom buttons, and + * the keys +, -, 0, and the arrows on the focused graph. */ -import { PAGE_CSS } from './canvas-page.mjs'; +import { THEME_CSS } from './canvas-theme.mjs'; +import { VIEW_HELPERS_SRC } from './board-view.mjs'; const TOKEN_SLOT = '__ADRKIT_TOKEN__'; @@ -44,31 +50,33 @@ export const BOARD_HTML = `

Decision board

- loading + loading
-

-
- - +

+
+
+ + +
+
+ Relationship kinds + + + + +
-
- Relationship kinds - - - - -
-
+
-
+
- +
-
This board writes only when you confirm a review control, as ADRKIT_REVIEWER, through the adr CLI; nothing is committed. It starts no workflow, spends no AI credits, and shows the CLI's facts, not a verdict on them.
+
This board writes only when you confirm a review control, as ADRKIT_REVIEWER, through the adr CLI; nothing is committed. It starts no workflow, spends no AI credits, and shows the CLI's facts, not a verdict on them.
`; @@ -86,12 +94,20 @@ export function renderBoardPage(token) { export const BOARD_JS = `(function () { 'use strict'; + // Pan and zoom: pure helpers over a viewBox, embedded from board-view.mjs. +${VIEW_HELPERS_SRC} + var SVG = 'http://www.w3.org/2000/svg'; var NODE_WIDTH = 200; var NODE_HEIGHT = 56; + var TITLE_CHARS = 28; var KINDS = ['supersedes', 'relatesTo', 'conflictsWith']; var STATUSES = ['accepted', 'proposed', 'draft', 'rejected', 'superseded', 'deprecated']; var KIND_TEXT = { supersedes: 'supersedes', relatesTo: 'relates to', conflictsWith: 'conflicts with' }; + var KIND_STYLE = { supersedes: 'solid, arrow to the replaced record', relatesTo: 'dashed', conflictsWith: 'dotted' }; + var STATUS_TONE = { accepted: 'green', proposed: 'blue', draft: 'neutral', rejected: 'red', superseded: 'purple', deprecated: 'yellow' }; + var STATUS_GLYPH = { accepted: 'check', proposed: 'half', draft: 'ring', rejected: 'cross', superseded: 'arrow', deprecated: 'bang' }; + var SLA_TONE = { 'within-sla': 'green', 'due-soon': 'yellow', overdue: 'red', escalated: 'red' }; var token = new URLSearchParams(window.location.search).get('token') || ''; var query = '?token=' + encodeURIComponent(token); var state = null; @@ -104,12 +120,20 @@ export const BOARD_JS = `(function () { /** What a person has typed into a row's inputs, kept across re-renders. */ var drafts = new Map(); var REVIEW_TEXT = { approval: 'approval', objection: 'objection', resolution: 'resolution' }; + /** The graph's current viewBox, kept across re-renders of the same graph. */ + var view = null; + var viewKey = null; + var viewport = { width: 0, height: 0 }; + var drawn = null; + /** Whether the person has panned or zoomed this graph; until then a resize re-opens it. */ + var touched = false; function $(id) { return document.getElementById(id); } function list(value) { return Array.isArray(value) ? value : []; } function text(value) { return typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value); } /** A value from the repository used as a lookup key: own entries of a fixed list only. */ function known(values, value) { return typeof value === 'string' && values.indexOf(value) >= 0; } + function own(table, key) { return typeof key === 'string' && Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined; } function shorten(value, size) { var s = text(value); return s.length > size ? s.slice(0, size - 1) + '…' : s; } /** Every HTML node is built here; text only ever goes through textContent. */ @@ -136,6 +160,11 @@ export const BOARD_JS = `(function () { node.className = warn ? 'message warn' : 'message'; } function statusClass(status) { return 'status-' + (known(STATUSES, status) ? status : 'unknown'); } + /** A status badge: hue, glyph, and the status word itself. */ + function statusBadge(status) { + var key = known(STATUSES, status) ? status : null; + return el('span', 'badge tone-' + (key ? STATUS_TONE[key] : 'neutral') + ' glyph-' + (key ? STATUS_GLYPH[key] : 'ask'), text(status) || 'unknown'); + } function button(label, onClick, className) { var node = el('button', className || 'secondary', label); @@ -158,23 +187,51 @@ export const BOARD_JS = `(function () { return found; } - /** A row's raw review facts, as counts and dates. No verdict is derived from them. */ - function queueFacts(item) { - var facts = []; - facts.push('approvals ' + text(item.approvalCount) + ' of quorum ' + (item.quorum === null || item.quorum === undefined ? 'not set' : text(item.quorum))); - facts.push('objections: ' + text(item.unresolvedObjectionCount) + ' unresolved, ' + text(item.resolvedObjectionCount) + ' resolved'); - facts.push('SLA ' + (text(item.slaState) || 'unknown')); - facts.push('deadline ' + (item.deadlineDate ? text(item.deadlineDate) : 'none')); - var targets = list(item.routingTargets).map(text); - facts.push('routed to ' + (targets.length > 0 ? targets.join(', ') : 'nobody')); - facts.push(text(item.itemFindingCount) + ' finding(s)'); - return facts; + function whole(value) { return typeof value === 'number' && isFinite(value) ? Math.max(0, Math.floor(value)) : 0; } + + /** Approvals against quorum as dots and the counts. No verdict is derived from them. */ + function approvalDots(item) { + var wrap = el('span', 'approvals'); + var have = whole(item.approvalCount); + var need = whole(item.quorum); + var dots = el('span', 'dots'); + dots.setAttribute('aria-hidden', 'true'); + for (var i = 0; i < Math.min(Math.max(have, need), 12); i++) dots.appendChild(el('span', i < have ? 'dot on' : 'dot')); + if (dots.firstChild) wrap.appendChild(dots); + wrap.appendChild(el('span', null, 'approvals ' + text(item.approvalCount) + ' of quorum ' + (item.quorum === null || item.quorum === undefined ? 'not set' : text(item.quorum)))); + return wrap; } - function factList(facts) { - var items = el('ul', 'facts'); - facts.forEach(function (fact) { items.appendChild(el('li', null, fact)); }); - return items; + /** A row's raw review facts, as counts and dates. No verdict is derived from them. */ + function factRows(item) { + var rows = el('dl', 'facts'); + function row(label, value) { + rows.appendChild(el('dt', null, label)); + var cell = el('dd'); + if (typeof value === 'string') cell.textContent = value; else cell.appendChild(value); + rows.appendChild(cell); + } + row('Approvals', approvalDots(item)); + var objections = el('span', 'objections'); + var open = whole(item.unresolvedObjectionCount), settled = whole(item.resolvedObjectionCount); + // The badges are for the eye; a screen reader gets the one sentence after them. + var openBadge = el('span', 'badge ' + (open > 0 ? 'tone-yellow glyph-bang' : 'tone-neutral glyph-ring'), text(item.unresolvedObjectionCount) + ' unresolved'); + var settledBadge = el('span', 'badge ' + (settled > 0 ? 'tone-green glyph-check' : 'tone-neutral glyph-ring'), text(item.resolvedObjectionCount) + ' resolved'); + openBadge.setAttribute('aria-hidden', 'true'); + settledBadge.setAttribute('aria-hidden', 'true'); + objections.appendChild(openBadge); + objections.appendChild(settledBadge); + objections.appendChild(el('span', 'sr-only', 'objections: ' + text(item.unresolvedObjectionCount) + ' unresolved, ' + text(item.resolvedObjectionCount) + ' resolved')); + row('Objections', objections); + var sla = el('span', 'sla'); + var slaState = text(item.slaState) || 'unknown'; + sla.appendChild(el('span', 'badge tone-' + (own(SLA_TONE, slaState) || 'neutral'), 'SLA ' + slaState)); + sla.appendChild(el('span', null, 'deadline ' + (item.deadlineDate ? text(item.deadlineDate) : 'none'))); + row('SLA', sla); + var targets = list(item.routingTargets).map(text); + row('Routing', 'routed to ' + (targets.length > 0 ? targets.join(', ') : 'nobody')); + row('Findings', text(item.itemFindingCount) + ' finding(s)'); + return rows; } /** Where a line from one box toward another leaves the box's edge. */ @@ -191,28 +248,36 @@ export const BOARD_JS = `(function () { function legend() { var node = $('legend'); clear(node); + var statusGroup = el('div', 'legend-group'); + statusGroup.appendChild(el('span', 'legend-title', 'Status')); var statuses = el('ul', 'legend-list'); statuses.setAttribute('aria-label', 'Status'); STATUSES.forEach(function (status) { var item = el('li', 'legend-item'); var swatch = svg('svg', { width: 18, height: 12, 'aria-hidden': 'true', class: 'swatch' }); - swatch.appendChild(svg('rect', { x: 1, y: 1, width: 16, height: 10, rx: 2, class: 'node-box ' + statusClass(status) })); + swatch.appendChild(svg('rect', { x: 1, y: 1, width: 16, height: 10, rx: 3, class: 'node-box ' + statusClass(status) })); item.appendChild(swatch); item.appendChild(el('span', null, status)); statuses.appendChild(item); }); - node.appendChild(statuses); + statusGroup.appendChild(statuses); + node.appendChild(statusGroup); + var kindGroup = el('div', 'legend-group'); + kindGroup.appendChild(el('span', 'legend-title', 'Relationships')); var kinds = el('ul', 'legend-list'); kinds.setAttribute('aria-label', 'Relationship'); KINDS.forEach(function (kind) { var item = el('li', 'legend-item'); var sample = svg('svg', { width: 30, height: 12, 'aria-hidden': 'true', class: 'swatch' }); - sample.appendChild(svg('line', { x1: 1, y1: 6, x2: 29, y2: 6, class: 'edge edge-' + kind })); + var attrs = { x1: 1, y1: 6, x2: kind === 'supersedes' ? 24 : 29, y2: 6, class: 'edge edge-' + kind }; + sample.appendChild(svg('line', attrs)); + if (kind === 'supersedes') sample.appendChild(svg('path', { d: 'M 23 2 L 29 6 L 23 10 z', class: 'arrow' })); item.appendChild(sample); - item.appendChild(el('span', null, KIND_TEXT[kind] + (kind === 'supersedes' ? ' (solid, arrow to the replaced record)' : kind === 'relatesTo' ? ' (dashed)' : ' (dotted)'))); + item.appendChild(el('span', null, KIND_TEXT[kind] + ' (' + KIND_STYLE[kind] + ')')); kinds.appendChild(item); }); - node.appendChild(kinds); + kindGroup.appendChild(kinds); + node.appendChild(kindGroup); } /** Select a record. The graph is rebuilt, so keyboard focus is put back on it. */ @@ -222,32 +287,195 @@ export const BOARD_JS = `(function () { if (state) render(state); } + /** The viewport's size in pixels, or the graph's when the page cannot measure (a test DOM). */ + function measure(root, graph) { + var rect = root && typeof root.getBoundingClientRect === 'function' ? root.getBoundingClientRect() : null; + if (rect && rect.width > 0 && rect.height > 0) return { width: rect.width, height: rect.height }; + return { width: Number(graph.width) || NODE_WIDTH, height: Math.min(Number(graph.height) || NODE_HEIGHT, 560) }; + } + + function applyView() { + if (drawn && view) drawn.root.setAttribute('viewBox', viewBoxOf(view)); + } + + function zoomBy(factor, fx, fy) { + if (!drawn || !view) return; + touched = true; + view = clampView(zoomView(view, factor, fx, fy, viewport.width), drawn.width, drawn.height); + applyView(); + } + + function panBy(dx, dy) { + if (!drawn || !view) return; + touched = true; + view = clampView(panView(view, dx, dy, viewport.width), drawn.width, drawn.height); + applyView(); + } + + /** + * Fit the view to a (possibly new) viewport size: the opening view until the + * person pans or zooms, and after that the same scale and corner. + */ + function fitTo(size) { + if (!drawn) return size; + if (!touched || !view) view = initialView(drawn.width, drawn.height, size.width, size.height); + else if (size.width !== viewport.width || size.height !== viewport.height) view = resizeView(view, size.width, size.height, viewport.width); + applyView(); + return size; + } + + function resetView() { + if (!drawn) return; + view = fitView(drawn.width, drawn.height, viewport.width, viewport.height); + applyView(); + } + + /** Keys on the focused graph: + and - zoom, 0 fits, arrows pan. Typing elsewhere is untouched. */ + function graphKeys(event) { + if (!event) return; + var tag = event.target && event.target.tagName ? String(event.target.tagName).toLowerCase() : ''; + if (tag === 'input' || tag === 'button' || tag === 'textarea') return; + var key = event.key; + var step = 60; + var handled = true; + if (key === '+' || key === '=') zoomBy(1.25, 0.5, 0.5); + else if (key === '-' || key === '_') zoomBy(0.8, 0.5, 0.5); + else if (key === '0') resetView(); + else if (key === 'ArrowLeft') panBy(step, 0); + else if (key === 'ArrowRight') panBy(-step, 0); + else if (key === 'ArrowUp') panBy(0, step); + else if (key === 'ArrowDown') panBy(0, -step); + else handled = false; + if (handled && event.preventDefault) event.preventDefault(); + } + + /** Pointer drag pans; a drag never also selects the record it started on. */ + var drag = null; + var dragged = false; + function wirePointer(root) { + root.addEventListener('pointerdown', function (event) { + if (!event || (event.button !== undefined && event.button !== 0)) return; + drag = { x: event.clientX, y: event.clientY, id: event.pointerId }; + dragged = false; + }); + root.addEventListener('pointermove', function (event) { + if (!drag || !event) return; + var dx = event.clientX - drag.x, dy = event.clientY - drag.y; + if (!dragged && Math.abs(dx) + Math.abs(dy) < 4) return; + if (!dragged && typeof root.setPointerCapture === 'function') { try { root.setPointerCapture(drag.id); } catch (error) { /* not capturable */ } } + dragged = true; + root.setAttribute('class', 'board-svg dragging'); + drag.x = event.clientX; + drag.y = event.clientY; + panBy(dx, dy); + }); + function end() { + drag = null; + root.setAttribute('class', 'board-svg'); + } + root.addEventListener('pointerup', end); + root.addEventListener('pointercancel', end); + root.addEventListener('wheel', function (event) { + if (!event) return; + if (event.preventDefault) event.preventDefault(); + if (event.ctrlKey || event.metaKey) { + var rect = typeof root.getBoundingClientRect === 'function' ? root.getBoundingClientRect() : null; + var fx = rect && rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0.5; + var fy = rect && rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5; + zoomBy(event.deltaY < 0 ? 1.1 : 1 / 1.1, fx, fy); + } else { + panBy(-(event.deltaX || 0), -(event.deltaY || 0)); + } + }, { passive: false }); + } + + function zoomBar() { + var bar = el('div', 'zoom-bar'); + var out = button('−', function () { zoomBy(0.8, 0.5, 0.5); }); + out.setAttribute('aria-label', 'Zoom out'); + out.title = 'Zoom out (-)'; + var into = button('+', function () { zoomBy(1.25, 0.5, 0.5); }); + into.setAttribute('aria-label', 'Zoom in'); + into.title = 'Zoom in (+)'; + var fit = button('Fit', function () { resetView(); }); + fit.setAttribute('aria-label', 'Fit the whole graph'); + fit.title = 'Fit the whole graph (0)'; + bar.appendChild(out); + bar.appendChild(into); + bar.appendChild(fit); + bar.appendChild(el('span', 'hint', 'Drag to pan · Ctrl or Cmd + wheel to zoom · keys + − 0 and arrows')); + return bar; + } + function drawGraph(graph) { var host = $('board'); clear(host); - if (!graph) { host.appendChild(el('p', 'muted', 'Loading the decision graph…')); return; } + drawn = null; + if (!graph) { + var loading = el('div', 'loading'); + loading.appendChild(el('span', 'spinner')); + loading.appendChild(el('span', null, 'Loading the decision graph…')); + host.appendChild(loading); + return; + } if (!graph.available) { - list(graph.notes).forEach(function (note) { host.appendChild(el('p', 'muted note', note)); }); + var failed = el('div', 'callout tone-red'); + failed.setAttribute('role', 'alert'); + failed.appendChild(el('p', 'callout-title glyph-cross', 'The decision graph is unavailable')); + list(graph.notes).forEach(function (note) { failed.appendChild(el('p', 'note', note)); }); + host.appendChild(failed); return; } if (graph.mode === 'summary') { - host.appendChild(el('p', null, text(graph.totalNodes) + ' records and ' + text(graph.totalEdges) + ' relationships. Counts by status:')); - var counts = el('ul', 'facts'); - list(graph.byStatus).forEach(function (row) { counts.appendChild(el('li', null, text(row.status) + ': ' + text(row.count))); }); - host.appendChild(counts); - host.appendChild(focusForm()); - list(graph.notes).forEach(function (note) { host.appendChild(el('p', 'muted note', note)); }); + var summary = el('div', 'summary'); + summary.appendChild(el('p', 'summary-lede', text(graph.totalNodes) + ' records and ' + text(graph.totalEdges) + ' relationships. Too many to draw legibly; focus on one record to see its neighborhood.')); + var counts = el('ul', 'status-counts'); + list(graph.byStatus).forEach(function (row) { + var item = el('li', 'status-count'); + var shown = statusBadge(text(row.status)); + shown.textContent = text(row.status) + ': ' + text(row.count); + item.appendChild(shown); + counts.appendChild(item); + }); + summary.appendChild(counts); + summary.appendChild(focusForm()); + list(graph.notes).forEach(function (note) { summary.appendChild(el('p', 'small muted note', note)); }); + host.appendChild(summary); return; } var nodes = list(graph.nodes); if (nodes.length === 0) { - host.appendChild(el('p', 'muted', 'No records to show.')); - list(graph.notes).forEach(function (note) { host.appendChild(el('p', 'muted note', note)); }); + var none = el('div', 'empty'); + none.appendChild(el('strong', null, 'No records to show')); + none.appendChild(el('span', null, 'adr graph returned an empty corpus for this filter.')); + host.appendChild(none); + list(graph.notes).forEach(function (note) { host.appendChild(el('p', 'small muted note', note)); }); return; } var byId = nodesById(graph); + // The neighborhood of the selected record: it and everything one edge away. + var near = new Set(); + if (selected !== null) { + near.add(selected); + list(graph.edges).forEach(function (edge) { + if (text(edge.from) === selected) near.add(text(edge.to)); + if (text(edge.to) === selected) near.add(text(edge.from)); + }); + } + var dimming = selected !== null && byId.has(selected); + host.appendChild(zoomBar()); + // The frame's height follows the drawing at its opening scale, in fixed + // steps (a class, never an inline size), so a small focus is not a tall + // empty box and a large corpus still gets room. + var wanted = (Number(graph.height) || 0) * 0.75 + 32; + var frame = el('div', 'graph-frame ' + (wanted <= 240 ? 'h-s' : wanted <= 360 ? 'h-m' : wanted <= 480 ? 'h-l' : 'h-xl')); + frame.setAttribute('tabindex', '0'); + frame.setAttribute('role', 'region'); + frame.setAttribute('aria-label', 'Decision graph: ' + nodes.length + ' records. Use + and − to zoom, 0 to fit, arrows to pan.'); + frame.addEventListener('keydown', graphKeys); var root = svg('svg', { - width: graph.width, height: graph.height, viewBox: '0 0 ' + text(graph.width) + ' ' + text(graph.height), + width: '100%', height: '100%', preserveAspectRatio: 'xMidYMid meet', + viewBox: '0 0 ' + text(graph.width) + ' ' + text(graph.height), role: 'group', 'aria-label': 'Decision graph: ' + nodes.length + ' records', class: 'board-svg', }); // Only supersession has a direction worth an arrowhead; relatesTo and @@ -258,13 +486,13 @@ export const BOARD_JS = `(function () { defs.appendChild(marker); root.appendChild(defs); - var edges = svg('g', { class: 'edges' }); + var edges = svg('g', { class: 'edges' + (dimming ? ' has-selection' : '') }); list(graph.edges).forEach(function (edge) { var from = byId.get(text(edge.from)), to = byId.get(text(edge.to)); if (!from || !to || !known(KINDS, edge.kind)) return; var start = edgePoint(from, to), end = edgePoint(to, from); - var near = selected !== null && (text(edge.from) === selected || text(edge.to) === selected); - var attrs = { x1: start.x, y1: start.y, x2: end.x, y2: end.y, class: 'edge edge-' + edge.kind + (near ? ' edge-near' : '') }; + var touches = selected !== null && (text(edge.from) === selected || text(edge.to) === selected); + var attrs = { x1: start.x, y1: start.y, x2: end.x, y2: end.y, class: 'edge edge-' + edge.kind + (touches ? ' edge-near' : dimming ? ' edge-dim' : '') }; if (edge.kind === 'supersedes') attrs['marker-end'] = 'url(#arrow-supersedes)'; var line = svg('line', attrs); line.appendChild(svg('title', {}, text(edge.from) + ' ' + KIND_TEXT[edge.kind] + ' ' + text(edge.to))); @@ -278,17 +506,22 @@ export const BOARD_JS = `(function () { var id = text(node.id); var status = text(node.status); var isSelected = id === selected; + var cls = 'node ' + statusClass(status) + (isSelected ? ' selected' : '') + + (state && state.filter && state.filter.id === id ? ' focused' : '') + + (dimming && !near.has(id) ? ' dim' : dimming && !isSelected ? ' neighbor' : ''); var g = svg('g', { - class: 'node ' + statusClass(status) + (isSelected ? ' selected' : '') + (state && state.filter && state.filter.id === id ? ' focused' : ''), + class: cls, transform: 'translate(' + text(node.x) + ' ' + text(node.y) + ')', tabindex: 0, role: 'button', 'aria-pressed': isSelected ? 'true' : 'false', 'aria-label': id + ', ' + status + ': ' + text(node.title), }); + // The full title, for the pointer; the box shows a clipped one. g.appendChild(svg('title', {}, id + ' (' + status + '): ' + text(node.title))); - g.appendChild(svg('rect', { width: NODE_WIDTH, height: NODE_HEIGHT, rx: 6, class: 'node-box ' + statusClass(status) })); - g.appendChild(svg('text', { x: 10, y: 20, class: 'node-id' }, id + ' · ' + status)); - g.appendChild(svg('text', { x: 10, y: 40, class: 'node-title' }, shorten(node.title, 30))); - g.addEventListener('click', function () { select(id); }); + g.appendChild(svg('rect', { width: NODE_WIDTH, height: NODE_HEIGHT, rx: 8, class: 'node-box ' + statusClass(status) })); + g.appendChild(svg('rect', { width: 5, height: NODE_HEIGHT - 12, x: 0, y: 6, rx: 2, class: 'node-stripe ' + statusClass(status) })); + g.appendChild(svg('text', { x: 14, y: 22, class: 'node-id' }, id + ' · ' + status)); + g.appendChild(svg('text', { x: 14, y: 41, class: 'node-title' }, shorten(node.title, TITLE_CHARS))); + g.addEventListener('click', function () { if (dragged) { dragged = false; return; } select(id); }); g.addEventListener('keydown', function (event) { if (event && (event.key === 'Enter' || event.key === ' ')) { if (event.preventDefault) event.preventDefault(); @@ -299,10 +532,23 @@ export const BOARD_JS = `(function () { group.appendChild(g); }); root.appendChild(group); - host.appendChild(root); + frame.appendChild(root); + host.appendChild(frame); + wirePointer(root); + + // Keep the view while the same graph is redrawn (a selection, a queue + // update); start fresh when the graph itself changed. + var size = measure(root, graph); + var key = JSON.stringify([graph.width, graph.height, graph.totalNodes, graph.totalEdges, state && state.filter]); + drawn = { root: root, width: Number(graph.width) || 1, height: Number(graph.height) || 1 }; + if (key !== viewKey || !view) { + viewKey = key; + touched = false; + } + viewport = fitTo(size); if (refocus && toFocus && typeof toFocus.focus === 'function') toFocus.focus(); refocus = false; - list(graph.notes).forEach(function (note) { host.appendChild(el('p', 'muted note', note)); }); + list(graph.notes).forEach(function (note) { host.appendChild(el('p', 'small muted note', note)); }); } function focusForm() { @@ -312,9 +558,10 @@ export const BOARD_JS = `(function () { input.type = 'text'; input.id = 'focus-id'; input.setAttribute('aria-label', 'Record id to focus on'); + input.setAttribute('placeholder', '0042'); label.appendChild(input); form.appendChild(label); - form.appendChild(button('Focus', function () { focus({ id: text(input.value).trim(), kinds: currentKinds() }); })); + form.appendChild(button('Focus', function () { focus({ id: text(input.value).trim(), kinds: currentKinds() }); }, 'primary')); return form; } @@ -329,22 +576,22 @@ export const BOARD_JS = `(function () { return; } var id = text(node.id); - pane.appendChild(el('h2', null, id)); - pane.appendChild(el('p', 'record-title', node.title)); - var head = el('p'); - head.appendChild(el('span', 'badge status-badge ' + statusClass(node.status), node.status)); + var head = el('div', 'detail-head'); + head.appendChild(el('h2', 'mono', id)); + head.appendChild(statusBadge(node.status)); pane.appendChild(head); + pane.appendChild(el('p', 'record-title detail-title', node.title)); pane.appendChild(el('h3', null, 'Neighbors')); - var neighbors = el('ul', 'facts'); + var neighbors = el('ul', 'neighbors'); list(graph.edges).forEach(function (edge) { if (!known(KINDS, edge.kind)) return; var other = null, phrase = ''; if (text(edge.from) === id) { other = text(edge.to); phrase = KIND_TEXT[edge.kind] + ' '; } else if (text(edge.to) === id) { other = text(edge.from); phrase = (edge.kind === 'supersedes' ? 'superseded by' : KIND_TEXT[edge.kind] + ' (from)') + ' '; } if (other === null) return; - var item = el('li'); - item.appendChild(el('span', null, phrase)); + var item = el('li', 'neighbor-' + edge.kind); + item.appendChild(el('span', 'muted', phrase)); var target = other; item.appendChild(button(target, function () { select(target); }, 'link')); neighbors.appendChild(item); @@ -355,13 +602,15 @@ export const BOARD_JS = `(function () { pane.appendChild(el('h3', null, 'Review facts')); var row = queueRowFor(id); if (row) { - pane.appendChild(factList(queueFacts(row))); - if (row.sourcePath) pane.appendChild(el('p', 'mono muted', row.sourcePath)); + pane.appendChild(factRows(row)); + if (row.sourcePath) pane.appendChild(el('p', 'mono small muted path', row.sourcePath)); } else { pane.appendChild(el('p', 'muted', 'Not in the open-proposal queue.')); } if (!(state.filter && state.filter.id === id)) { - pane.appendChild(button('Focus on ' + id, function () { focus({ id: id, kinds: currentKinds() }); })); + var actions = el('div', 'detail-actions'); + actions.appendChild(button('Focus on ' + id, function () { focus({ id: id, kinds: currentKinds() }); })); + pane.appendChild(actions); } } @@ -376,8 +625,6 @@ export const BOARD_JS = `(function () { return drafts.get(id); } - function whole(value) { return typeof value === 'number' && isFinite(value) ? Math.max(0, Math.floor(value)) : 0; } - /** What the confirming click will record, in words. */ function describe(request, who) { if (request.kind === 'approval') return 'Record an approval of ADR-' + request.id + ' by ' + who + '?'; @@ -392,16 +639,23 @@ export const BOARD_JS = `(function () { var box = el('div', 'review-controls'); box.appendChild(el('h3', null, 'Record review')); if (!info.enabled) { + box.setAttribute('aria-disabled', 'true'); ['Approve', 'Raise objection', 'Resolve objection'].forEach(function (label) { var off = button(label, function () {}); off.disabled = true; + off.title = info.note; + off.setAttribute('aria-description', info.note); box.appendChild(off); }); - box.appendChild(el('p', 'muted note', info.note)); + // The fixed explanation is shown once, above the list; each row points to it. + box.appendChild(el('p', 'note off-note glyph-info', 'Recording review is off on this board; the note above the list says why.')); return box; } var who = text(info.reviewer); if (armed && armed.id === id) { + // Step 2 of 3 looks like a different place, not the idle row relabelled. + box.setAttribute('data-step', 'confirm'); + box.appendChild(el('p', 'step', 'Step 2 of 3 · confirm here')); box.appendChild(el('p', 'confirm-text', describe(armed, who))); // Cancel takes the arming button's place and Confirm comes after it, so // a double click on the arming button cannot land on Confirm (review L4). @@ -409,9 +663,10 @@ export const BOARD_JS = `(function () { var yes = button('Confirm ' + REVIEW_TEXT[armed.kind] + ' as ' + who, submit, 'primary'); yes.disabled = busy; box.appendChild(yes); - box.appendChild(el('p', 'muted note', 'GitHub Copilot will then ask you to confirm it again; the board writes only if you say yes there.')); + box.appendChild(el('p', 'note host-step', 'Step 3 of 3 · GitHub Copilot will then ask you to confirm it again; the board writes only if you say yes there.')); return box; } + box.appendChild(el('p', 'step', 'Step 1 of 3 · choose · recording as ' + who)); var draft = draftFor(id); var approve = button('Approve as ' + who, function () { arm({ kind: 'approval', id: id }); }); approve.disabled = busy; @@ -422,6 +677,7 @@ export const BOARD_JS = `(function () { summary.type = 'text'; summary.value = draft.summary; summary.maxLength = 500; + summary.setAttribute('placeholder', 'One-line objection'); summary.setAttribute('aria-label', 'One-line objection summary for ' + id); summary.addEventListener('input', function () { draft.summary = text(summary.value); }); objectRow.appendChild(summary); @@ -442,6 +698,7 @@ export const BOARD_JS = `(function () { index.value = draft.objection; index.setAttribute('min', '1'); index.setAttribute('max', String(total)); + index.setAttribute('placeholder', '#'); index.setAttribute('aria-label', 'Your objection number (1 to ' + total + ') on ' + id); index.addEventListener('input', function () { draft.objection = text(index.value); }); resolveRow.appendChild(index); @@ -512,28 +769,42 @@ export const BOARD_JS = `(function () { var queue = state && state.queue; // The total open, not the rows kept: past the row or byte cap the note says how many are shown. var total = queue && queue.available ? (typeof queue.totalItems === 'number' ? queue.totalItems : list(queue.items).length) : null; - node.appendChild(el('h2', null, 'Open proposals, corpus-wide' + (total === null ? '' : ' (' + text(total) + ')'))); + node.appendChild(el('h2', 'section-title', 'Open proposals, corpus-wide' + (total === null ? '' : ' (' + text(total) + ')'))); if (!queue) { node.appendChild(el('p', 'muted', 'Loading the open-proposal list…')); return; } if (!queue.available) { node.appendChild(el('p', 'muted note', queue.note || 'The open-proposal list is unavailable.')); return; } - node.appendChild(el('p', 'muted', 'Raw review facts from adr queue' + (queue.asOf ? ', as of ' + text(queue.asOf) : '') + '. Listed, not judged.')); + node.appendChild(el('p', 'lede', 'Raw review facts from adr queue' + (queue.asOf ? ', as of ' + text(queue.asOf) : '') + '. Listed, not judged.')); var items = list(queue.items); if (items.length === 0) node.appendChild(el('p', 'muted', 'No proposed record is open.')); - var rows = el('ul', 'decisions'); + var info = reviewInfo(); + if (items.length > 0 && !info.enabled) { + var off = el('div', 'callout tone-neutral review-off'); + off.setAttribute('role', 'note'); + off.appendChild(el('p', 'callout-title glyph-info', 'Recording review is off')); + off.appendChild(el('p', null, info.note)); + node.appendChild(off); + } + var rows = el('ul', 'cards'); items.forEach(function (item) { - var row = el('li', 'decision'); - var head = el('div', 'decision-head'); - head.appendChild(el('span', 'record-id mono', item.id)); - head.appendChild(el('span', 'record-title', item.title)); - row.appendChild(head); - row.appendChild(el('p', 'muted', queueFacts(item).join(' · '))); var id = text(item.id); - if (state.graph && nodesById(state.graph).has(id)) row.appendChild(button('Show ' + id + ' on the board', function () { select(id); })); - row.appendChild(reviewControls(item)); + var row = el('li', 'card queue-row' + (id === selected ? ' is-selected' : '')); + var top = el('div', 'card-head'); + top.appendChild(el('span', 'record-id', item.id)); + top.appendChild(el('span', 'record-title', item.title)); + row.appendChild(top); + var body = el('div', 'queue-body'); + body.appendChild(factRows(item)); + body.appendChild(reviewControls(item)); + row.appendChild(body); + if (state.graph && nodesById(state.graph).has(id)) { + var actions = el('div', 'card-actions'); + actions.appendChild(button('Show ' + id + ' on the board', function () { select(id); })); + row.appendChild(actions); + } rows.appendChild(row); }); if (items.length > 0) node.appendChild(rows); - if (queue.corpusFindings > 0) node.appendChild(el('p', 'muted', 'adr queue reported ' + text(queue.corpusFindings) + ' corpus finding(s); adr lint shows them.')); - if (queue.note) node.appendChild(el('p', 'muted note', queue.note)); + if (queue.corpusFindings > 0) node.appendChild(el('p', 'small muted', 'adr queue reported ' + text(queue.corpusFindings) + ' corpus finding(s); adr lint shows them.')); + if (queue.note) node.appendChild(el('p', 'small muted note', queue.note)); } function currentKinds() { @@ -550,8 +821,12 @@ export const BOARD_JS = `(function () { var filter = snapshot.filter || { id: null, kinds: [] }; if (filter.id) parts.push('focus ' + text(filter.id)); if (list(filter.kinds).length > 0) parts.push(list(filter.kinds).map(text).join(', ')); - $('status').textContent = parts.join(' · '); - $('cwd').textContent = text(snapshot.workingDirectory); + var status = $('status'); + status.textContent = parts.join(' · '); + status.className = 'badge lg ' + (!graph ? 'tone-blue glyph-wait' : !graph.available ? 'tone-red glyph-cross' : filter.id ? 'tone-blue glyph-dot' : 'tone-neutral glyph-dot'); + var cwd = $('cwd'); + cwd.textContent = text(snapshot.workingDirectory); + cwd.title = text(snapshot.workingDirectory); $('refresh').disabled = busy; $('show-all').disabled = busy || (!filter.id && list(filter.kinds).length === 0); $('apply-kinds').disabled = busy; @@ -605,6 +880,14 @@ export const BOARD_JS = `(function () { function focus(filter) { return act('/api/focus', filter, 'Re-running adr graph…'); } legend(); + if (typeof window.addEventListener === 'function') { + window.addEventListener('resize', function () { + if (drawn) viewport = fitTo(measure(drawn.root, { width: drawn.width, height: drawn.height })); + }); + } + drawGraph(null); + detail(); + queueSection(); $('refresh').addEventListener('click', function () { act('/api/refresh', {}, 'Refreshing…'); }); $('show-all').addEventListener('click', function () { focus({}); }); $('apply-kinds').addEventListener('click', function () { @@ -625,53 +908,111 @@ export const BOARD_JS = `(function () { })(); `; -/** The board's own rules, after the shared decision-review stylesheet. */ +/** The board's own rules, after the shared canvas theme. */ const BOARD_ONLY_CSS = ` -.filters { border: 1px solid var(--border-color-default, #d1d9e0); border-radius: 6px; margin: 8px 0 0; padding: 4px 10px 8px; display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } -.filters legend { padding: 0 4px; } -.board-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(220px, 320px); gap: 16px; margin-top: 12px; } +.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-4); } +.filters { border: 1px solid var(--c-border); border-radius: var(--r-md); margin: 0; padding: 2px var(--sp-2) var(--sp-1); display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1) var(--sp-3); font-size: var(--fs-sm); } +.filters legend { padding: 0 var(--sp-1); font-size: var(--fs-xs); color: var(--c-muted); } +.toggle { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; } +.board-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(240px, 320px); gap: var(--sp-4); margin-top: var(--sp-4); align-items: start; } @media (max-width: 720px) { .board-layout { grid-template-columns: minmax(0, 1fr); } } -.board-scroll { overflow: auto; max-height: 70vh; border: 1px solid var(--border-color-default, #d1d9e0); border-radius: 6px; } -.board-detail { border: 1px solid var(--border-color-default, #d1d9e0); border-radius: 6px; padding: 8px 12px; align-self: start; } -.board-detail h2 { margin-top: 4px; } -.board-detail h3 { font-size: 0.95em; margin: 12px 0 4px; } -.legend { display: flex; flex-wrap: wrap; gap: 4px 24px; margin-bottom: 8px; } -.legend-list { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 4px 12px; } -.legend-item { display: inline-flex; align-items: center; gap: 4px; } -.swatch { display: inline-block; vertical-align: middle; } -ul.facts { padding-left: 18px; } -button.link { border: none; padding: 0 2px; text-decoration: underline; font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace); } -.focus-form { display: flex; gap: 8px; align-items: center; margin: 8px 0; } -.focus-form input { font: inherit; color: inherit; background: transparent; border: 1px solid var(--border-color-default, #d1d9e0); border-radius: 6px; padding: 2px 6px; } -.board-svg { display: block; } -.node { cursor: pointer; outline: none; } -.node-box { stroke-width: 1.5; } +.board-graph { padding: var(--sp-3); min-width: 0; } +.board-detail { position: sticky; top: var(--sp-2); } +.section-title { margin-bottom: var(--sp-2); } +.board-detail h3, .review-controls h3 { margin: var(--sp-4) 0 var(--sp-2); } +.detail-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; } +.detail-title { margin: var(--sp-1) 0 0; } +.detail-actions, .card-actions { margin-top: var(--sp-3); } +.neighbors { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 2px; font-size: var(--fs-sm); } +.legend { display: flex; flex-wrap: wrap; gap: var(--sp-2) var(--sp-6); margin-bottom: var(--sp-3); font-size: var(--fs-xs); } +.legend-group { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1) var(--sp-2); } +.legend-title { font-weight: 600; color: var(--c-muted); text-transform: uppercase; letter-spacing: 0.04em; } +.legend-list { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-3); } +.legend-item { display: inline-flex; align-items: center; gap: 5px; } +.swatch { display: inline-block; vertical-align: middle; overflow: visible; } +.zoom-bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1); margin-bottom: var(--sp-2); } +.zoom-bar button { min-width: 32px; padding: 3px var(--sp-2); } +.hint { font-size: var(--fs-xs); color: var(--c-muted); margin-left: var(--sp-2); } +.graph-frame.h-s { height: 240px; } +.graph-frame.h-m { height: 360px; } +.graph-frame.h-l { height: 480px; } +.graph-frame.h-xl { height: 640px; } +.graph-frame { border: 1px solid var(--c-border); border-radius: var(--r-md); background: var(--c-surface); overflow: hidden; } +.graph-frame:focus-visible { outline: 2px solid var(--c-focus); outline-offset: 2px; } +.board-svg { display: block; width: 100%; height: 100%; touch-action: none; cursor: grab; user-select: none; } +.board-svg.dragging { cursor: grabbing; } +.node { cursor: pointer; outline: none; transition: opacity 120ms ease; } +.node-box { fill: var(--c-bg); stroke: var(--c-border); stroke-width: 1.25; } +.node-stripe { stroke: none; } .node.selected .node-box, .node.focused .node-box { stroke-width: 3; } -.node:focus-visible .node-box { stroke: var(--color-focus-outline, #0969da); stroke-width: 3; } -.node-id { font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace); font-size: 12px; fill: var(--text-color-default, #1f2328); } -.node-title { font-size: 12px; fill: var(--text-color-muted, #59636e); } -.status-accepted { fill: var(--true-color-green-muted, rgba(26, 127, 55, 0.12)); stroke: var(--true-color-green, #1a7f37); } -.status-proposed { fill: var(--true-color-blue-muted, rgba(9, 105, 218, 0.12)); stroke: var(--true-color-blue, #0969da); } -.status-draft { fill: var(--background-color-default, #ffffff); stroke: var(--text-color-muted, #59636e); stroke-dasharray: 4 3; } -.status-rejected { fill: var(--true-color-red-muted, rgba(209, 36, 47, 0.12)); stroke: var(--true-color-red, #d1242f); } -.status-superseded { fill: var(--background-color-default, #ffffff); stroke: var(--text-color-muted, #59636e); } -.status-deprecated { fill: var(--true-color-yellow-muted, rgba(154, 103, 0, 0.12)); stroke: var(--true-color-yellow, #9a6700); } -.status-unknown { fill: var(--background-color-default, #ffffff); stroke: var(--border-color-default, #d1d9e0); } -.status-badge { color: var(--text-color-default, #1f2328); } -.edge { stroke: var(--text-color-muted, #59636e); stroke-width: 1.5; fill: none; } -.edge-supersedes { stroke-width: 2; } +.node.neighbor .node-box { stroke-width: 2.25; } +.node.dim { opacity: 0.28; } +.node:focus-visible .node-box { stroke: var(--c-focus); stroke-width: 3.5; } +.node-id { font-family: var(--font-code); font-size: 12px; font-weight: 600; fill: var(--c-text); } +.node-title { font-family: var(--font-ui); font-size: 12px; fill: var(--c-muted); } +.node-box.status-accepted { fill: var(--c-green-tint); stroke: var(--c-green); } +.node-box.status-proposed { fill: var(--c-blue-tint); stroke: var(--c-blue); } +.node-box.status-draft { fill: var(--c-bg); stroke: var(--c-muted); stroke-dasharray: 4 3; } +.node-box.status-rejected { fill: var(--c-red-tint); stroke: var(--c-red); } +.node-box.status-superseded { fill: var(--c-purple-tint); stroke: var(--c-purple); } +.node-box.status-deprecated { fill: var(--c-yellow-tint); stroke: var(--c-yellow); } +.node-box.status-unknown { fill: var(--c-bg); stroke: var(--c-border); } +.node-stripe.status-accepted { fill: var(--c-green); } +.node-stripe.status-proposed { fill: var(--c-blue); } +.node-stripe.status-draft { fill: var(--c-muted); } +.node-stripe.status-rejected { fill: var(--c-red); } +.node-stripe.status-superseded { fill: var(--c-purple); } +.node-stripe.status-deprecated { fill: var(--c-yellow); } +.node-stripe.status-unknown { fill: var(--c-border); } +.edge { stroke: var(--c-muted); stroke-width: 1.25; fill: none; opacity: 0.55; transition: opacity 120ms ease; } +.edge-supersedes { stroke-width: 2; opacity: 0.8; } .edge-relatesTo { stroke-dasharray: 6 4; } -.edge-conflictsWith { stroke: var(--true-color-red, #d1242f); stroke-dasharray: 2 3; } -.edge-near { stroke-width: 3; } -.arrow { fill: var(--text-color-muted, #59636e); } -.message.warn { font-weight: 600; border: 2px solid var(--true-color-yellow, #9a6700); border-radius: 6px; padding: 6px 8px; } -.review-controls { margin-top: 6px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; } -.review-controls h3 { font-size: 0.95em; margin: 0; flex-basis: 100%; } -.review-controls .note, .review-controls .confirm-text { flex-basis: 100%; margin: 0; } -.review-row { display: inline-flex; gap: 6px; align-items: center; } -.review-row input { font: inherit; color: inherit; background: transparent; border: 1px solid var(--border-color-default, #d1d9e0); border-radius: 6px; padding: 2px 6px; } +.edge-conflictsWith { stroke: var(--c-red); stroke-dasharray: 2 3; } +.edge-near { stroke-width: 2.5; opacity: 1; } +.edge-dim { opacity: 0.08; } +.arrow { fill: var(--c-muted); } +.summary-lede { font-size: var(--fs-md); } +.status-counts { list-style: none; padding: 0; margin: var(--sp-3) 0; display: flex; flex-wrap: wrap; gap: var(--sp-2); } +.status-count .badge { font-size: var(--fs-sm); padding: 3px 10px; } +.focus-form { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; margin: var(--sp-3) 0; } +.focus-form label { display: inline-flex; align-items: center; gap: var(--sp-2); } +.focus-form input { width: 8em; } +.facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px var(--sp-3); margin: 0; font-size: var(--fs-sm); align-items: center; } +.facts dt { color: var(--c-muted); font-weight: 600; font-size: var(--fs-xs); } +.facts dd { margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1) var(--sp-2); overflow-wrap: anywhere; } +.approvals, .objections, .sla { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.dots { display: inline-flex; gap: 3px; } +.dot { width: 10px; height: 10px; border-radius: 50%; border: 1.5px solid var(--c-green); background: transparent; } +.dot.on { background: var(--c-green); } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } +.path { overflow-wrap: anywhere; margin-top: var(--sp-2); } +.queue-row.is-selected { border-color: var(--c-blue); box-shadow: 0 0 0 1px var(--c-blue); } +.queue-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--sp-4); margin-top: var(--sp-3); align-items: start; } +@media (max-width: 720px) { .queue-body { grid-template-columns: minmax(0, 1fr); } } +.review-controls { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); padding: var(--sp-3); border: 1px solid var(--c-border); border-radius: var(--r-md); background: var(--c-surface); } +.review-controls h3 { margin: 0; flex-basis: 100%; } +.review-controls .note, .review-controls .confirm-text, .review-controls .step { flex-basis: 100%; margin: 0; } +.review-controls .step { font-size: var(--fs-xs); font-weight: 600; color: var(--c-muted); } +.review-controls[data-step="confirm"] { border: 2px solid var(--c-blue); background: var(--c-blue-tint); } +.review-controls[data-step="confirm"] .step { color: var(--c-blue); } +.confirm-text { font-weight: 600; overflow-wrap: anywhere; } +.host-step { font-size: var(--fs-sm); color: var(--c-text); } +.off-note { font-size: var(--fs-sm); color: var(--c-muted); display: flex; gap: 6px; } +.off-note::before { font-weight: 700; color: var(--c-blue); } +.review-row { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; flex-basis: 100%; } +.review-row input[type="text"] { flex: 1 1 12em; min-width: 0; } .review-row input[type="number"] { width: 5em; } +.loading { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-6) var(--sp-4); color: var(--c-muted); } +.review-off { margin: 0 0 var(--sp-3); } +.spinner { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--c-border); border-top-color: var(--c-blue); animation: ak-spin 0.9s linear infinite; flex: none; } +@keyframes ak-spin { to { transform: rotate(360deg); } } +@media (max-width: 520px) { + body { padding: var(--sp-3) var(--sp-3) var(--sp-4); } + .hint { flex-basis: 100%; margin: var(--sp-1) 0 0; } + .graph-frame.h-xl { height: 520px; } + .board-detail { position: static; } +} `; -/** Stylesheet: decision-review's (the same app tokens) plus the board's rules. */ -export const BOARD_CSS = PAGE_CSS + BOARD_ONLY_CSS; +/** Stylesheet: the shared canvas theme, then the board's own rules. */ +export const BOARD_CSS = THEME_CSS + BOARD_ONLY_CSS; diff --git a/packages/adapters/agent-plugin/extensions/adrkit/board-view.mjs b/packages/adapters/agent-plugin/extensions/adrkit/board-view.mjs new file mode 100644 index 0000000..db21435 --- /dev/null +++ b/packages/adapters/agent-plugin/extensions/adrkit/board-view.mjs @@ -0,0 +1,159 @@ +// @ts-check +/** + * Pan and zoom for the decision board's graph, as pure functions over an SVG + * `viewBox` in graph units: `{ x, y, w, h }`. The page applies a view by + * setting the `viewBox` attribute, so zooming and panning set no inline style + * and rebuild nothing. + * + * These functions are shipped twice from one source: imported here by the + * tests, and embedded into the page script with `Function.prototype.toString` + * (see board-page.mjs). So each one is self-contained ES2017: no imports, no + * module-level constants, no closures over anything outside its own body. + * A test runs the embedded copies to prove they are the same functions. + * + * A view always has the aspect ratio of the viewport it is drawn in, so the + * SVG's `preserveAspectRatio` never letterboxes it, and `scale` (pixels per + * graph unit) is `viewportWidth / view.w`. + */ + +/** + * Clamp a scale to the board's zoom range. + * + * @param {number} scale + * @returns {number} + */ +export function clampScale(scale) { + var MIN = 0.2; + var MAX = 3; + if (!(scale > 0) || !isFinite(scale)) return 1; + return Math.min(MAX, Math.max(MIN, scale)); +} + +/** + * Keep at least half the viewport over the graph, so a pan or zoom cannot + * lose the drawing off one edge. + * + * @param {{ x: number, y: number, w: number, h: number }} view + * @param {number} graphW + * @param {number} graphH + */ +export function clampView(view, graphW, graphH) { + var minX = -view.w / 2; + var maxX = Math.max(minX, graphW - view.w / 2); + var minY = -view.h / 2; + var maxY = Math.max(minY, graphH - view.h / 2); + return { + x: Math.min(maxX, Math.max(minX, view.x)), + y: Math.min(maxY, Math.max(minY, view.y)), + w: view.w, + h: view.h, + }; +} + +/** + * The whole graph, centred, at the largest scale that fits (never above 1, so + * a small graph is not blown up), within the zoom range. + * + * @param {number} graphW + * @param {number} graphH + * @param {number} viewW viewport width in pixels + * @param {number} viewH viewport height in pixels + */ +export function fitView(graphW, graphH, viewW, viewH) { + var gw = graphW > 0 ? graphW : 1; + var gh = graphH > 0 ? graphH : 1; + var vw = viewW > 0 ? viewW : gw; + var vh = viewH > 0 ? viewH : gh; + var scale = clampScale(Math.min(1, vw / gw, vh / gh)); + var w = vw / scale; + var h = vh / scale; + return { x: (gw - w) / 2, y: (gh - h) / 2, w: w, h: h }; +} + +/** + * Where the board opens: the whole graph when it fits at a readable scale + * (0.6 or more: 12 px labels stay above 7 px); otherwise the top of it, fitted + * to the width at no less than + * that scale, so labels stay legible and the rest is a pan or a zoom away. + * + * @param {number} graphW + * @param {number} graphH + * @param {number} viewW + * @param {number} viewH + */ +export function initialView(graphW, graphH, viewW, viewH) { + var fitted = fitView(graphW, graphH, viewW, viewH); + var vw = viewW > 0 ? viewW : graphW > 0 ? graphW : 1; + if (vw / fitted.w >= 0.6) return fitted; + var vh = viewH > 0 ? viewH : graphH > 0 ? graphH : 1; + var gw = graphW > 0 ? graphW : 1; + var scale = clampScale(Math.max(0.6, Math.min(1, vw / gw))); + var w = vw / scale; + var h = vh / scale; + var gh = graphH > 0 ? graphH : 1; + return { x: gw > w ? 0 : (gw - w) / 2, y: gh > h ? 0 : (gh - h) / 2, w: w, h: h }; +} + +/** + * Zoom by `factor` (above 1 zooms in) about an anchor given as a fraction of + * the viewport (0..1 on each axis; 0.5, 0.5 is the centre), so the graph point + * under the pointer stays under it. The result is clamped to the zoom range. + * + * @param {{ x: number, y: number, w: number, h: number }} view + * @param {number} factor + * @param {number} fx + * @param {number} fy + * @param {number} viewW viewport width in pixels + */ +export function zoomView(view, factor, fx, fy, viewW) { + var vw = viewW > 0 ? viewW : view.w; + var scale = vw / view.w; + var next = clampScale(scale * (factor > 0 && isFinite(factor) ? factor : 1)); + var ratio = scale / next; + var w = view.w * ratio; + var h = view.h * ratio; + var ax = fx >= 0 && fx <= 1 ? fx : 0.5; + var ay = fy >= 0 && fy <= 1 ? fy : 0.5; + return { x: view.x + (view.w - w) * ax, y: view.y + (view.h - h) * ay, w: w, h: h }; +} + +/** + * Pan by a distance in viewport pixels: dragging right moves the drawing + * right, so the view's origin moves left. + * + * @param {{ x: number, y: number, w: number, h: number }} view + * @param {number} dx + * @param {number} dy + * @param {number} viewW viewport width in pixels + */ +export function panView(view, dx, dy, viewW) { + var vw = viewW > 0 ? viewW : view.w; + var unit = view.w / vw; + return { x: view.x - (isFinite(dx) ? dx : 0) * unit, y: view.y - (isFinite(dy) ? dy : 0) * unit, w: view.w, h: view.h }; +} + +/** + * Re-shape a view for a viewport whose aspect changed (a resize), keeping its + * scale and top-left corner. + * + * @param {{ x: number, y: number, w: number, h: number }} view + * @param {number} viewW + * @param {number} viewH + * @param {number} previousW the viewport width the view was made for + */ +export function resizeView(view, viewW, viewH, previousW) { + var scale = (previousW > 0 ? previousW : view.w) / view.w; + return { x: view.x, y: view.y, w: (viewW > 0 ? viewW : view.w * scale) / scale, h: (viewH > 0 ? viewH : view.h * scale) / scale }; +} + +/** @param {{ x: number, y: number, w: number, h: number }} view */ +export function viewBoxOf(view) { + var r = /** @param {number} n */ function (n) { return Math.round(n * 100) / 100; }; + return r(view.x) + ' ' + r(view.y) + ' ' + r(view.w) + ' ' + r(view.h); +} + +/** Every helper, in the order the page script embeds them. */ +export const VIEW_HELPERS = [clampScale, clampView, fitView, initialView, zoomView, panView, resizeView, viewBoxOf]; + +/** The helpers' source, for the page script. */ +export const VIEW_HELPERS_SRC = VIEW_HELPERS.map((fn) => fn.toString()).join('\n'); diff --git a/packages/adapters/agent-plugin/extensions/adrkit/canvas-page.mjs b/packages/adapters/agent-plugin/extensions/adrkit/canvas-page.mjs index bb61562..f05a70a 100644 --- a/packages/adapters/agent-plugin/extensions/adrkit/canvas-page.mjs +++ b/packages/adapters/agent-plugin/extensions/adrkit/canvas-page.mjs @@ -10,10 +10,13 @@ * appears here. The Content-Security-Policy the server sends allows only these * same-origin files, so there is no inline script or style either. * - * Theme values come from the app's documented canvas tokens, each with a - * fallback so the page still reads when opened outside the app. + * The stylesheet is the shared canvas theme (canvas-theme.mjs) plus this + * page's own rules: app tokens first, with light and dark fallbacks, and no + * inline style anywhere, so every state is a class. */ +import { THEME_CSS } from './canvas-theme.mjs'; + const TOKEN_SLOT = '__ADRKIT_TOKEN__'; /** @@ -34,17 +37,19 @@ export const PAGE_HTML = `

Decision review

- loading + loading
-

+
+

- - + +
+

-
Read-only and advisory: this view has no exit-code authority.
+
Read-only and advisory: this view has no exit-code authority.
`; @@ -58,21 +63,34 @@ export function renderPage(token) { return PAGE_HTML.split(TOKEN_SLOT).join(token); } + /** Browser script. Plain ES2017, no modules, no dependencies. */ export const PAGE_JS = `(function () { 'use strict'; var token = new URLSearchParams(window.location.search).get('token') || ''; var query = '?token=' + encodeURIComponent(token); + // Each status, verdict, and severity has a hue and a glyph, and is always + // shown with its text label too: colour is never the only signal. var STATUS_TONE = { ok: 'green', findings: 'red', incomplete: 'yellow', 'usage-error': 'red', pending: 'blue' }; + var STATUS_GLYPH = { ok: 'check', findings: 'cross', incomplete: 'bang', 'usage-error': 'cross', pending: 'wait' }; var VERDICT_TONE = { consistent: 'green', conflicts: 'red', unclear: 'yellow' }; + var VERDICT_GLYPH = { consistent: 'check', conflicts: 'cross', unclear: 'ask' }; var SEVERITY_TONE = { error: 'red', warn: 'yellow', info: 'blue' }; + var SEVERITY_GLYPH = { error: 'cross', warn: 'bang', info: 'info' }; + var SEVERITY_TITLE = { error: 'Errors', warn: 'Warnings', info: 'Information' }; + var SEVERITIES = ['error', 'warn', 'info']; + var RECORD_TONE = { accepted: 'green', proposed: 'blue', draft: 'neutral', rejected: 'red', superseded: 'purple', deprecated: 'yellow' }; + var RECORD_GLYPH = { accepted: 'check', proposed: 'half', draft: 'ring', rejected: 'cross', superseded: 'arrow', deprecated: 'bang' }; var state = null; var busy = false; + var loaded = false; function $(id) { return document.getElementById(id); } function list(value) { return Array.isArray(value) ? value : []; } function text(value) { return typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value); } + function plural(n, noun) { return n + ' ' + noun + (n === 1 ? '' : 's'); } + function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); } /** Every node is built here; text only ever goes through textContent. */ function el(tag, className, content) { @@ -82,8 +100,6 @@ export const PAGE_JS = `(function () { return node; } - function badge(label, tone) { return el('span', 'badge tone-' + (tone || 'neutral'), label); } - /** * Look up a tone by a value that came from the repository or an agent. Own * properties only, so a key such as "constructor" finds nothing. @@ -92,6 +108,15 @@ export const PAGE_JS = `(function () { return typeof key === 'string' && Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined; } + /** A badge: hue, glyph, and the text label. */ + function badge(label, tone, glyph, extra) { + return el('span', 'badge tone-' + (tone || 'neutral') + (glyph ? ' glyph-' + glyph : '') + (extra ? ' ' + extra : ''), label); + } + function recordBadge(status) { + var key = text(status); + return badge(key || 'unknown', toneOf(RECORD_TONE, key), toneOf(RECORD_GLYPH, key) || 'ask'); + } + function button(label, onClick) { var node = el('button', 'secondary', label); node.type = 'button'; @@ -99,14 +124,25 @@ export const PAGE_JS = `(function () { return node; } - function section(title, count) { + /** A section whose heading carries its count in the same text, "Title (n)". */ + function section(title, count, lede) { var node = el('section', 'section'); - node.appendChild(el('h2', null, count === undefined ? title : title + ' (' + count + ')')); + var head = el('div', 'section-head'); + head.appendChild(el('h2', null, count === undefined ? title : title + ' (' + count + ')')); + node.appendChild(head); + if (lede) node.appendChild(el('p', 'lede', lede)); return node; } function setMessage(message) { $('message').textContent = text(message); } + function chip(kind, value) { + var node = el('span', 'chip'); + node.appendChild(el('span', 'chip-kind', kind)); + node.appendChild(el('span', 'mono', value)); + return node; + } + function describeMatcher(matcher) { if (!matcher || typeof matcher !== 'object') return text(matcher); var detail = matcher.pattern || matcher.glob || matcher.path || matcher.symbol || matcher.value; @@ -135,55 +171,67 @@ export const PAGE_JS = `(function () { return out; } + /** + * One record as a card: id, title, status, verdict, then what tied it to + * the change. A marker names its file and line; an affects match names only + * the pattern, because adr check reports no file for one, so the page does + * not invent one (ADR-0047). + */ function decisionItem(decision, verdict, explainable) { - var item = el('li', 'decision'); - var head = el('div', 'decision-head'); - head.appendChild(el('span', 'record-id mono', decision.recordId)); + var item = el('li', 'card decision'); + var head = el('div', 'card-head'); + head.appendChild(el('span', 'record-id', decision.recordId)); head.appendChild(el('span', 'record-title', decision.title)); - if (verdict) head.appendChild(badge(verdict.verdict, toneOf(VERDICT_TONE, verdict.verdict))); - if (decision.status) head.appendChild(el('span', 'muted', decision.status)); item.appendChild(head); + var badges = el('div', 'chips'); + if (decision.status) badges.appendChild(recordBadge(decision.status)); + if (verdict) badges.appendChild(badge(text(verdict.verdict) || 'no verdict', toneOf(VERDICT_TONE, verdict.verdict), toneOf(VERDICT_GLYPH, verdict.verdict) || 'ask')); + if (decision.supersededBy) badges.appendChild(chip('superseded by', decision.supersededBy)); + if (badges.firstChild) item.appendChild(badges); - var details = el('details', 'evidence'); - details.appendChild(el('summary', null, 'Evidence')); - if (verdict) details.appendChild(el('p', 'verdict-evidence', verdict.evidence)); - // Provenance: what tied this record to the change. A marker names its file - // and line; an affects match names only the pattern, because adr check - // reports no file for one, so the page does not invent one. + var provenance = el('div', 'provenance'); var matchers = list(decision.firedMatchers); + var declared = list(decision.declaredBy); if (matchers.length > 0) { - var matched = el('ul', 'matchers'); - matchers.forEach(function (matcher) { matched.appendChild(el('li', 'mono', describeMatcher(matcher))); }); - details.appendChild(el('p', 'muted', 'Matched by affects pattern:')); - details.appendChild(matched); - details.appendChild(el('p', 'muted', 'adr check does not report which changed file matched a pattern.')); + provenance.appendChild(el('p', 'small muted', 'Matched by affects pattern:')); + var matched = el('div', 'chips'); + matchers.forEach(function (matcher) { matched.appendChild(chip('affects', describeMatcher(matcher))); }); + provenance.appendChild(matched); + provenance.appendChild(el('p', 'small muted', 'adr check does not report which changed file matched a pattern.')); } - var declared = list(decision.declaredBy); if (declared.length > 0) { - var declaring = el('ul', 'matchers'); + provenance.appendChild(el('p', 'small muted', 'Declared by an inbound marker in a changed file:')); + var declaring = el('div', 'chips'); declared.forEach(function (entry) { - declaring.appendChild(el('li', 'mono', text(entry.path) + ':' + text(entry.line) + ' names ' + text(entry.ref))); + declaring.appendChild(chip('marker', text(entry.path) + ':' + text(entry.line) + ' names ' + text(entry.ref))); }); - details.appendChild(el('p', 'muted', 'Declared by an inbound marker in a changed file:')); - details.appendChild(declaring); + provenance.appendChild(declaring); + } + if (!verdict && matchers.length === 0 && declared.length === 0) provenance.appendChild(el('p', 'small muted', 'No evidence recorded.')); + item.appendChild(provenance); + + if (verdict && text(verdict.evidence)) { + var details = el('details', 'evidence'); + details.appendChild(el('summary', null, 'Reviewer evidence')); + details.appendChild(el('p', 'verdict-evidence', verdict.evidence)); + item.appendChild(details); } - if (decision.supersededBy) details.appendChild(el('p', 'muted', 'Superseded by ' + text(decision.supersededBy))); - if (!verdict && matchers.length === 0 && declared.length === 0) details.appendChild(el('p', 'muted', 'No evidence recorded.')); - item.appendChild(details); if (explainable && /^[0-9]{4}$/.test(text(decision.recordId))) { - item.appendChild(button('Ask the agent to explain ' + decision.recordId, function () { explain(decision.recordId); })); + var actions = el('div', 'card-actions'); + actions.appendChild(button('Ask the agent to explain ' + decision.recordId, function () { explain(decision.recordId); })); + item.appendChild(actions); } return item; } - function decisionList(title, decisions, verdicts, explainable, empty) { - var node = section(title, decisions.length); + function decisionList(title, decisions, verdicts, explainable, empty, lede) { + var node = section(title, decisions.length, lede); if (decisions.length === 0) { - node.appendChild(el('p', 'muted', empty)); + node.appendChild(el('p', 'muted none', empty)); return node; } - var items = el('ul', 'decisions'); + var items = el('ul', 'cards'); decisions.forEach(function (decision) { items.appendChild(decisionItem(decision, verdicts.get(text(decision.recordId)), explainable)); }); node.appendChild(items); return node; @@ -191,32 +239,47 @@ export const PAGE_JS = `(function () { function reviewSection(review) { var node = section('Review'); - var line = el('p', 'review-line'); + var card = el('div', 'card review-card'); + var line = el('div', 'card-head'); if (review.runId) { - line.appendChild(el('span', null, 'adr-review run ' + text(review.runId) + ': ')); - line.appendChild(badge(review.runStatus, review.runStatus === 'completed' ? 'green' : review.runStatus === 'running' || review.runStatus === 'pending' ? 'blue' : 'yellow')); + line.appendChild(el('span', 'mono small', 'adr-review run ' + text(review.runId))); + var run = text(review.runStatus); + line.appendChild(badge(run || 'unknown', run === 'completed' ? 'green' : run === 'running' || run === 'pending' ? 'blue' : 'yellow', run === 'completed' ? 'check' : run === 'running' || run === 'pending' ? 'wait' : 'bang')); } else { // The agent handed this over; it may have read hostile repository text, // so it is never dressed as a run the panel watched. - line.appendChild(badge('supplied by the agent', 'neutral')); - line.appendChild(el('span', 'muted', ' Result supplied by the agent, not a run this panel followed.')); + line.appendChild(badge('supplied by the agent', 'neutral', 'ask')); + line.appendChild(el('span', 'small muted', 'Result supplied by the agent, not a run this panel followed.')); } if (review.result) { - line.appendChild(el('span', 'muted', ' result ')); - line.appendChild(badge(review.result.status, toneOf(STATUS_TONE, review.result.status))); + line.appendChild(el('span', 'small muted', 'result')); + line.appendChild(badge(text(review.result.status) || 'unknown', toneOf(STATUS_TONE, review.result.status), toneOf(STATUS_GLYPH, review.result.status))); } - node.appendChild(line); + card.appendChild(line); if (review.result) { var notes = list(review.result.notes); if (notes.length > 0) { var items = el('ul', 'notes'); notes.forEach(function (note) { items.appendChild(el('li', null, note)); }); - node.appendChild(items); + card.appendChild(items); } } + node.appendChild(card); return node; } + /** Approvals against quorum as a row of dots plus the number; no verdict on them. */ + function approvalFacts(item) { + var wrap = el('span', 'approvals'); + var have = typeof item.approvalCount === 'number' && item.approvalCount > 0 ? Math.floor(item.approvalCount) : 0; + var need = typeof item.quorum === 'number' && item.quorum > 0 ? Math.floor(item.quorum) : 0; + var dots = el('span', 'dots'); + for (var i = 0; i < Math.min(Math.max(have, need), 12); i++) dots.appendChild(el('span', i < have ? 'dot on' : 'dot')); + if (dots.firstChild) wrap.appendChild(dots); + wrap.appendChild(el('span', null, 'approvals ' + text(item.approvalCount) + '/' + (item.quorum === null || item.quorum === undefined ? '-' : text(item.quorum)))); + return wrap; + } + /** * Open proposed records, corpus-wide, from adr queue. Listed only: no button, * no action, and no explain, so nothing here reaches the agent. Titles and @@ -229,67 +292,122 @@ export const PAGE_JS = `(function () { node.appendChild(el('p', 'muted note', queue.note || 'The open-proposal list is unavailable.')); return node; } - node.appendChild(el('p', 'muted', 'Listed, not judged' + (queue.asOf ? ', as of ' + text(queue.asOf) : '') + '.')); + node.appendChild(el('p', 'lede', 'Listed, not judged' + (queue.asOf ? ', as of ' + text(queue.asOf) : '') + '.')); if (items.length === 0) { - node.appendChild(el('p', 'muted', 'No proposed record is open.')); + node.appendChild(el('p', 'muted none', 'No proposed record is open.')); } else { - var rows = el('ul', 'decisions'); + var rows = el('ul', 'cards'); items.forEach(function (item) { - var row = el('li', 'decision'); - var head = el('div', 'decision-head'); - head.appendChild(el('span', 'record-id mono', item.id)); + var row = el('li', 'card queue-row'); + var head = el('div', 'card-head'); + head.appendChild(el('span', 'record-id', item.id)); head.appendChild(el('span', 'record-title', item.title)); row.appendChild(head); - var facts = ['SLA ' + (text(item.slaState) || 'unknown')]; - if (item.deadlineDate) facts.push('due ' + text(item.deadlineDate)); - facts.push('approvals ' + text(item.approvalCount) + '/' + (item.quorum === null || item.quorum === undefined ? '-' : text(item.quorum))); - if (item.unresolvedObjectionCount > 0) facts.push(text(item.unresolvedObjectionCount) + ' unresolved objection(s)'); + var facts = el('div', 'facts-row'); + facts.appendChild(approvalFacts(item)); + facts.appendChild(el('span', null, 'SLA ' + (text(item.slaState) || 'unknown'))); + if (item.deadlineDate) facts.appendChild(el('span', null, 'due ' + text(item.deadlineDate))); + if (item.unresolvedObjectionCount > 0) facts.appendChild(el('span', 'objections-open', text(item.unresolvedObjectionCount) + ' unresolved objection(s)')); var targets = list(item.routingTargets).map(text); - if (targets.length > 0) facts.push('routed to ' + targets.join(', ')); - row.appendChild(el('p', 'muted', facts.join(' · '))); - if (item.sourcePath) row.appendChild(el('p', 'mono muted', item.sourcePath)); + if (targets.length > 0) facts.appendChild(el('span', null, 'routed to ' + targets.join(', '))); + row.appendChild(facts); + if (item.sourcePath) row.appendChild(el('p', 'mono small muted path', item.sourcePath)); rows.appendChild(row); }); node.appendChild(rows); } if (queue.corpusFindings > 0) { - node.appendChild(el('p', 'muted', 'adr queue reported ' + text(queue.corpusFindings) + ' corpus finding(s); adr lint shows them.')); + node.appendChild(el('p', 'small muted', 'adr queue reported ' + text(queue.corpusFindings) + ' corpus finding(s); adr lint shows them.')); } - if (queue.note) node.appendChild(el('p', 'muted note', queue.note)); + if (queue.note) node.appendChild(el('p', 'small muted note', queue.note)); return node; } + /** Findings grouped by severity, errors first; an unknown severity is listed last. */ function findingsSection(findings) { var node = section('Findings', findings.length); if (findings.length === 0) { - node.appendChild(el('p', 'muted', 'None.')); + node.appendChild(el('p', 'muted none', 'None.')); return node; } - var items = el('ul', 'findings'); + var groups = new Map(); findings.forEach(function (finding) { - var item = el('li'); - item.appendChild(badge(finding.severity || 'finding', toneOf(SEVERITY_TONE, finding.severity))); - if (finding.rule) item.appendChild(el('span', 'mono', ' ' + text(finding.rule) + ' ')); - item.appendChild(el('span', null, finding.message)); - if (finding.path) item.appendChild(el('span', 'mono muted', ' ' + text(finding.path))); - items.appendChild(item); + var key = SEVERITIES.indexOf(text(finding.severity)) >= 0 ? text(finding.severity) : 'other'; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(finding); }); - node.appendChild(items); + SEVERITIES.concat(['other']).forEach(function (key) { + var group = groups.get(key); + if (!group) return; + var block = el('div', 'finding-group'); + block.appendChild(el('h3', null, (toneOf(SEVERITY_TITLE, key) || 'Other') + ' (' + group.length + ')')); + var items = el('ul', 'cards'); + group.forEach(function (finding) { + var item = el('li', 'card finding'); + var head = el('div', 'card-head'); + head.appendChild(badge(text(finding.severity) || 'finding', toneOf(SEVERITY_TONE, finding.severity), toneOf(SEVERITY_GLYPH, finding.severity) || 'ask')); + if (finding.rule) head.appendChild(el('span', 'mono small', text(finding.rule))); + item.appendChild(head); + item.appendChild(el('p', null, finding.message)); + if (finding.path) item.appendChild(el('p', 'mono small muted path', finding.path)); + items.appendChild(item); + }); + block.appendChild(items); + node.appendChild(block); + }); + return node; + } + + function emptyState(title, body) { + var node = el('div', 'empty'); + node.appendChild(el('strong', null, title)); + node.appendChild(el('span', null, body)); return node; } + /** + * The error state: the server's own fixed messages (the notes), shown first + * and open. Exception text never reaches a snapshot, so nothing here is one. + */ + function errorState(notes) { + var node = el('div', 'callout tone-red error-state'); + node.setAttribute('role', 'alert'); + node.appendChild(el('p', 'callout-title glyph-cross', 'The check could not run')); + if (notes.length === 0) { + node.appendChild(el('p', null, 'adr check or adr lint did not succeed. Refresh to try again.')); + } else { + var items = el('ul', 'notes'); + notes.forEach(function (note) { items.appendChild(el('li', null, note)); }); + node.appendChild(items); + } + return node; + } + + function renderMeta(snapshot, fileCount) { + var meta = $('meta'); + if (!meta) return; + clear(meta); + meta.appendChild(el('span', null, plural(fileCount, 'changed file'))); + meta.appendChild(el('span', null, 'from ' + (text(snapshot.filesSource) || 'nowhere'))); + if (snapshot.updatedAt) meta.appendChild(el('span', null, 'updated ' + text(snapshot.updatedAt))); + } + function render(snapshot) { state = snapshot; + loaded = true; var status = text(snapshot.status) || 'unknown'; var statusNode = $('status'); statusNode.textContent = status; - statusNode.className = 'badge tone-' + (toneOf(STATUS_TONE, status) || 'neutral'); - $('cwd').textContent = text(snapshot.workingDirectory); + statusNode.className = 'badge lg tone-' + (toneOf(STATUS_TONE, status) || 'neutral') + ' glyph-' + (toneOf(STATUS_GLYPH, status) || 'ask'); + var cwd = $('cwd'); + cwd.textContent = text(snapshot.workingDirectory); + cwd.title = text(snapshot.workingDirectory); $('refresh').disabled = busy; var files = list(snapshot.files); // A wide change lists at most a fixed number of paths; the rest are counted. var omitted = typeof snapshot.filesOmitted === 'number' && snapshot.filesOmitted > 0 ? snapshot.filesOmitted : 0; var fileCount = files.length + omitted; + renderMeta(snapshot, fileCount); // Cost before spend: the workflow makes one decision-checker call per // governing decision. With no changed files, or nothing governing them, a // run would judge nothing, so the button says why and stays disabled. @@ -321,11 +439,19 @@ export const PAGE_JS = `(function () { runButton.title = ''; runButton.removeAttribute('aria-description'); } + var cost = $('cost'); + if (cost) { + cost.className = 'cost' + (reason ? ' off' : ''); + cost.textContent = reason + ? reason + '. Refresh is free.' + : 'A review costs ' + plural(calls, 'decision-checker call') + ' (AI credits), one per governing decision. Refresh is free.'; + } var parts = []; - var source = el('p', 'muted', fileCount + ' changed file(s) from ' + (text(snapshot.filesSource) || 'nowhere') + - (snapshot.updatedAt ? ' · updated ' + text(snapshot.updatedAt) : '')); - parts.push(source); + var notes = list(snapshot.notes); + var failed = status === 'usage-error'; + if (failed) parts.push(errorState(notes)); + else if (noFiles) parts.push(emptyState('No changed files', 'Nothing in this change to review. Edit a file, or open the panel where the change is, then refresh.')); if (snapshot.review) parts.push(reviewSection(snapshot.review)); @@ -343,39 +469,53 @@ export const PAGE_JS = `(function () { if (unverified.length > 0) { var callout = el('div', 'callout tone-yellow'); callout.setAttribute('role', 'note'); - callout.appendChild(el('strong', null, 'Unverified: ')); - callout.appendChild(el('span', null, unverified.join(', ') + '. No usable verdict, so the review is incomplete.')); + callout.appendChild(el('p', 'callout-title glyph-bang', 'Unverified: ' + unverified.join(', '))); + callout.appendChild(el('p', null, 'No usable verdict, so the review is incomplete.')); parts.push(callout); } parts.push(decisionList('Governing', governingOf(snapshot), verdicts, true, 'No accepted decision governs these files.')); + parts.push(findingsSection(list(snapshot.findings))); parts.push(decisionList('Active proposals', list(snapshot.activeProposals), new Map(), true, 'None.')); parts.push(decisionList('History (listed, not judged)', list(snapshot.history), new Map(), true, 'None.')); if (snapshot.queue && typeof snapshot.queue === 'object') parts.push(queueSection(snapshot.queue)); - parts.push(findingsSection(list(snapshot.findings))); - var notes = list(snapshot.notes); - if (notes.length > 0) { - var notesNode = section('Notes', notes.length); + // Notes are collapsed by default; in the error state they are the error, + // shown open above, so they are not repeated here. + if (notes.length > 0 && !failed) { + var notesNode = el('details', 'section notes-block'); + notesNode.appendChild(el('summary', null, 'Notes (' + notes.length + ')')); var noteItems = el('ul', 'notes'); notes.forEach(function (note) { noteItems.appendChild(el('li', null, note)); }); notesNode.appendChild(noteItems); parts.push(notesNode); } - var filesNode = el('details', 'files'); + var filesNode = el('details', 'section files'); filesNode.appendChild(el('summary', null, 'Changed files (' + fileCount + ')')); - var fileItems = el('ul', 'mono'); + var fileItems = el('ul', 'mono small'); files.forEach(function (file) { fileItems.appendChild(el('li', null, file)); }); if (omitted > 0) fileItems.appendChild(el('li', 'muted', '+' + omitted + ' more')); filesNode.appendChild(fileItems); parts.push(filesNode); var app = $('app'); - while (app.firstChild) app.removeChild(app.firstChild); + clear(app); parts.forEach(function (part) { app.appendChild(part); }); } + /** Before the first snapshot: a distinct loading state, not a blank page. */ + function renderLoading() { + if (loaded) return; + var app = $('app'); + clear(app); + var node = el('div', 'loading'); + node.setAttribute('aria-busy', 'true'); + node.appendChild(el('span', 'spinner')); + node.appendChild(el('span', null, 'Reading the change and running adr check…')); + app.appendChild(node); + } + function post(path, body) { return fetch(path + query, { method: 'POST', @@ -418,6 +558,7 @@ export const PAGE_JS = `(function () { $('refresh').addEventListener('click', function () { act('/api/refresh', {}, 'Refreshing…'); }); $('run-review').addEventListener('click', function () { act('/api/run-review', {}, 'Starting adr-review…'); }); + renderLoading(); fetch('/api/state' + query) .then(function (response) { return response.json(); }) .then(render) @@ -432,61 +573,39 @@ export const PAGE_JS = `(function () { })(); `; -/** Stylesheet: documented app tokens only, each with a fallback. */ -export const PAGE_CSS = `:root { color-scheme: light dark; } -body { - margin: 0; - padding: 12px 16px 24px; - background: var(--background-color-default, #ffffff); - color: var(--text-color-default, #1f2328); - font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); - font-size: var(--text-body-medium, 14px); - line-height: var(--leading-body-medium, 20px); -} -.mono, code { font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace); } -.muted { color: var(--text-color-muted, #59636e); } -h1 { font-size: 1.15em; margin: 0; } -h2 { font-size: 1em; margin: 16px 0 8px; } -.bar { border-bottom: 1px solid var(--border-color-default, #d1d9e0); padding-bottom: 8px; } -.headline { display: flex; align-items: center; gap: 8px; } -.cwd { margin: 4px 0 8px; overflow-wrap: anywhere; } -.buttons { display: flex; flex-wrap: wrap; gap: 8px; } -.message:empty { display: none; } -button { - font: inherit; - color: inherit; - background: transparent; - border: 1px solid var(--border-color-default, #d1d9e0); - border-radius: 6px; - padding: 4px 10px; - cursor: pointer; -} -button.secondary { margin-top: 6px; padding: 2px 8px; } -button:disabled { opacity: 0.5; cursor: default; } -button:focus-visible, summary:focus-visible { - outline: 2px solid var(--color-focus-outline, #0969da); - outline-offset: 2px; +/** decision-review's own rules, after the shared stylesheet. */ +const REVIEW_ONLY_CSS = ` +.cost { margin: 0; font-size: var(--fs-sm); color: var(--c-text); } +.cost.off { color: var(--c-muted); } +.cost:empty { display: none; } +.provenance { margin-top: var(--sp-2); } +.provenance .chips { margin-top: var(--sp-1); } +.card-actions { margin-top: var(--sp-2); } +.evidence { margin-top: var(--sp-2); } +.verdict-evidence { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: var(--sp-2) var(--sp-3); background: var(--c-surface); border-radius: var(--r-md); } +.none { margin: 0; } +.finding-group + .finding-group { margin-top: var(--sp-3); } +.finding-group h3 { margin-bottom: var(--sp-2); } +.finding p { margin: var(--sp-1) 0 0; overflow-wrap: anywhere; } +.path { overflow-wrap: anywhere; margin: var(--sp-1) 0 0; } +.facts-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-1) var(--sp-3); margin-top: var(--sp-2); font-size: var(--fs-sm); color: var(--c-muted); } +.objections-open { color: var(--c-yellow); font-weight: 600; } +.approvals { display: inline-flex; align-items: center; gap: 6px; } +.dots { display: inline-flex; gap: 3px; } +.dot { width: 9px; height: 9px; border-radius: 50%; border: 1.5px solid var(--c-green); background: transparent; } +.dot.on { background: var(--c-green); } +.notes { margin: var(--sp-1) 0 0; padding-left: 20px; } +.notes li + li { margin-top: var(--sp-1); } +.error-state { margin-top: var(--sp-4); } +.files ul { margin-top: var(--sp-1); } +.loading { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-6) var(--sp-4); color: var(--c-muted); border: 1px dashed var(--c-border); border-radius: var(--r-lg); margin-top: var(--sp-4); } +.spinner { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--c-border); border-top-color: var(--c-blue); animation: ak-spin 0.9s linear infinite; flex: none; } +@keyframes ak-spin { to { transform: rotate(360deg); } } +@media (max-width: 520px) { + body { padding: var(--sp-3) var(--sp-3) var(--sp-4); } + .buttons button { flex: 1 1 auto; } } -ul { margin: 0; padding-left: 20px; } -ul.decisions { list-style: none; padding-left: 0; } -.decision { border: 1px solid var(--border-color-default, #d1d9e0); border-radius: 6px; padding: 8px; margin-bottom: 8px; } -.decision-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; } -.record-title { font-weight: 600; overflow-wrap: anywhere; } -.evidence { margin-top: 4px; } -.evidence summary, .files summary { cursor: pointer; } -.verdict-evidence { white-space: pre-wrap; overflow-wrap: anywhere; } -.badge { - display: inline-block; - border-radius: 999px; - padding: 0 8px; - font-size: 0.85em; - border: 1px solid currentColor; -} -.callout { border-radius: 6px; padding: 8px; margin: 12px 0; border: 1px solid currentColor; } -.tone-neutral { color: var(--text-color-muted, #59636e); } -.tone-green { color: var(--true-color-green, #1a7f37); background: var(--true-color-green-muted, rgba(26, 127, 55, 0.12)); } -.tone-red { color: var(--true-color-red, #d1242f); background: var(--true-color-red-muted, rgba(209, 36, 47, 0.12)); } -.tone-yellow { color: var(--true-color-yellow, #9a6700); background: var(--true-color-yellow-muted, rgba(154, 103, 0, 0.12)); } -.tone-blue { color: var(--true-color-blue, #0969da); background: var(--true-color-blue-muted, rgba(9, 105, 218, 0.12)); } -footer { margin-top: 16px; font-size: 0.85em; } `; + +/** Stylesheet: the shared canvas theme, then decision-review's own rules. */ +export const PAGE_CSS = THEME_CSS + REVIEW_ONLY_CSS; diff --git a/packages/adapters/agent-plugin/extensions/adrkit/canvas-theme.mjs b/packages/adapters/agent-plugin/extensions/adrkit/canvas-theme.mjs new file mode 100644 index 0000000..f234d00 --- /dev/null +++ b/packages/adapters/agent-plugin/extensions/adrkit/canvas-theme.mjs @@ -0,0 +1,246 @@ +// @ts-check +/** + * The stylesheet both canvases share: `decision-review` (ADR-0046) and + * `decision-board` (ADR-0050). Each page's `/app.css` is this block followed by + * the page's own rules, so the two cannot drift apart on colour, spacing, type, + * badges, buttons, or the focus ring. + * + * Theming is the app's. The app injects `