From 795cc944fcf05032ae1a513938277761efbb7a1e Mon Sep 17 00:00:00 2001 From: Mark Beacom Date: Sat, 10 Oct 2026 20:13:58 -0400 Subject: [PATCH] feat(portal,server): ship a portal icon and point the server-hosted path view at its governing route (#309, #315) - #309: the portal bundle ships `page/icon.svg`, referenced relatively from index.html, so browsers stop requesting /favicon.ico and getting a 404. The CSP is unchanged (img-src 'self'); the REST server serves it as image/svg+xml (operation getPortalIcon). - #315: portal-data.json gains `pathLookup: { route } | null`, always present and additive (version stays "1"). The pure builder accepts it only beside an `absent` path table and requires a page-relative route. The REST server sets it to its own `governing` route, and the page shows that route and an example query as text, with no link and no fetch, so the renderer stays network-free (ADR-0054, ADR-0055 clause 9). packages/ci/dist/portal-action.js rebuilt under linux/amd64 Bun 1.4.2. Every new test was observed failing first (ADR-0016). Signed-off-by: Mark Beacom --- AGENTS.md | 10 +++-- CHANGELOG.md | 20 +++++++++ packages/ci/dist/portal-action.js | 36 ++++++++++++++- packages/portal/src/data/build.ts | 33 +++++++++++++- packages/portal/src/data/contract.ts | 21 +++++++++ packages/portal/src/page/app.mjs | 35 ++++++++++++++- packages/portal/src/page/assets.ts | 3 ++ packages/portal/src/page/icon.svg | 6 +++ packages/portal/src/page/index.html | 1 + packages/portal/src/page/page.css | 1 + packages/portal/src/page/text-imports.d.ts | 11 +++-- packages/portal/test/assets.test.ts | 2 +- packages/portal/test/build.test.ts | 46 ++++++++++++++++++++ packages/portal/test/contract.test.ts | 3 +- packages/portal/test/generate-safety.test.ts | 2 +- packages/portal/test/page-fixture.ts | 4 +- packages/portal/test/page-render.test.ts | 38 +++++++++++++++- packages/portal/test/page-static.test.ts | 33 ++++++++++++-- packages/server/src/openapi.ts | 1 + packages/server/src/routes.ts | 3 +- packages/server/src/server.ts | 11 ++++- packages/server/test/openapi.test.ts | 1 + packages/server/test/portal.test.ts | 10 ++++- scripts/check-portal-bundle.ts | 2 + 24 files changed, 310 insertions(+), 23 deletions(-) create mode 100644 packages/portal/src/page/icon.svg diff --git a/AGENTS.md b/AGENTS.md index 94a06a4..2191b19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1112,9 +1112,9 @@ Load-bearing: fails on any diff. The plugin owns them; never edit the copies, and never copy `board-page.mjs`, `board.mjs`, `panel-http.mjs`, or `board-review-write.mjs`. -- **Page assets are text imports.** `src/page/assets.ts` imports the page and - two vendor modules `with { type: 'text' }`, so `bun build --target=node` - embeds them. `canvas-theme.mjs` is imported as a module (for `THEME_CSS`) +- **Page assets are text imports.** `src/page/assets.ts` imports the page files + (the icon among them) and two vendor modules `with { type: 'text' }`, so + `bun build --target=node` embeds them. `canvas-theme.mjs` is imported as a module (for `THEME_CSS`) and cannot also be a text import in the same bundle, which is why it is not written into the output. @@ -1220,7 +1220,9 @@ plus a local Podman smoke; the APIM path is unmeasured. Load-bearing: - **The portal is per corpus at `/portal/{corpus}/`.** `/` redirects relatively when one corpus is configured and lists them otherwise; links stay relative so a gateway base path survives. Portal files carry the page's CSP - plus `frame-ancestors 'none'`. + plus `frame-ancestors 'none'`. Its `portal-data.json` sets `pathLookup` to + this server's own `governing` route (#315); the page prints it as text and + never requests it, so do not add a fetch or loosen `connect-src` for it. - **Configuration is `ADRKIT_SERVER_CORPORA`** (`key[=dir],...` or JSON), confined under `ADRKIT_SERVER_ROOT` by `realpath`, at start and before every load. As PID 1 the binary handles SIGTERM itself. diff --git a/CHANGELOG.md b/CHANGELOG.md index 26aa1ac..80ac751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,16 @@ Until `1.0.0`, minor releases may include breaking changes `governing` matches `affects` only: it never reads the file at the path, so it has no `@adr` marker evidence. +- **The server-hosted decision portal points at the server's own + `governing?path=` route** ([#315](https://github.com/mbeacom/adrkit/issues/315), + ADR-0055 clause 9). The governance-by-path view still says this host has no + path table, and now names the route, relative to the page, with an example + such as `../../api/v1/corpora/demo/governing?path=src/index.ts`. The page + shows it as text and never requests it, so the renderer, its CSP, and the + GitHub Pages host are unchanged. `portal-data.json` grows one additive + top-level field, `pathLookup` (`{ route }` or `null`); `version` stays `"1"`, + and a Pages bundle carries `"pathLookup": null`. + ### Fixed - **A `repo`-qualified `affects` matcher no longer vanishes silently @@ -100,6 +110,16 @@ Until `1.0.0`, minor releases may include breaking changes `matcherAppliesToMember` applies a qualified matcher when it equals either name. No surface passes a member yet; that is ADR-0059's federation work. +- **The decision portal ships its own tab icon** + ([#309](https://github.com/mbeacom/adrkit/issues/309)). Without one, every + load also requested `/favicon.ico` at the host root and logged a 404 on a + Pages project path, at adrkit.dev's `/decisions/`, or behind the REST + server. The bundle now writes `page/icon.svg` and `index.html` names it + relatively, so it works at any path; the CSP is unchanged (`img-src 'self'` + already covers it). The REST server serves it at + `/portal/{corpus}/page/icon.svg` as `image/svg+xml` (operation + `getPortalIcon`). + ### Added - **`@adrkit/core` exports `todayUtcDate()`, the `--as-of` default.** When diff --git a/packages/ci/dist/portal-action.js b/packages/ci/dist/portal-action.js index c71c673..ce24bb6 100644 --- a/packages/ci/dist/portal-action.js +++ b/packages/ci/dist/portal-action.js @@ -43469,8 +43469,11 @@ function convert(parent, depth, inLink) { var ALL_EDGE_KINDS = ["supersedes", "relatesTo", "conflictsWith"]; var COMMIT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; var CALENDAR_DATE = /^\d{4}-\d{2}-\d{2}$/; +var LOOKUP_ROUTE = /^(?:\.\.?\/)*(?:[A-Za-z0-9_-][A-Za-z0-9._-]*\/)*[A-Za-z0-9_-][A-Za-z0-9._-]*$/; +var LOOKUP_ROUTE_MAX = 512; function buildPortalData(handle, options) { const { dir, commit, asOf, pathTable } = options; + const pathLookup = pathLookupOf(options.pathLookup ?? null); if (typeof dir !== "string" || dir.length === 0) throw new TypeError("dir must be a non-empty string"); if (commit !== null && (typeof commit !== "string" || !COMMIT.test(commit))) { @@ -43484,6 +43487,9 @@ function buildPortalData(handle, options) { if (hasCorpusError && table.state === "available") { throw new TypeError("an available path table is inconsistent with a corpus that has errors; pass state 'unavailable'"); } + if (pathLookup !== null && table.state !== "absent") { + throw new TypeError("a pathLookup hint is only for a host with no path table; pass pathTable state 'absent'"); + } const recordPaths = new Map; for (const record of handle.records) recordPaths.set(record.path, record.id); @@ -43501,9 +43507,19 @@ function buildPortalData(handle, options) { edges: graph.edges.map((edge) => ({ from: edge.from, to: edge.to, kind: edge.kind })) }, queue: queue.entries.map(queueEntryOf), - pathTable: table + pathTable: table, + pathLookup }; } +function pathLookupOf(lookup) { + if (lookup === null) + return null; + const route = lookup?.route; + if (typeof route !== "string" || route.length > LOOKUP_ROUTE_MAX || !LOOKUP_ROUTE.test(route)) { + throw new TypeError("pathLookup.route must be a page-relative path with no query, fragment, or scheme"); + } + return { route }; +} function recordOf(record, recordPaths) { return { id: record.id, @@ -44056,7 +44072,7 @@ var exports_app = {}; __export(exports_app, { default: () => app_default }); -var app_default = "/**\n * The decision portal's page module (ADR-0054 clauses 4 to 9).\n *\n * It reads `./portal-data.json` (the contract in `../data/contract.ts`) and draws five\n * read-only views behind hash routes, so the bundle works at any mount point (clause 8):\n *\n * - `#/` status counts and the record list\n * - `#/adr/` one record, its relationships, and its body tokens\n * - `#/graph` the supersession graph, laid out by the board's own `layoutBoard`\n * - `#/queue` the review queue's raw facts, never a verdict (clause 5)\n * - `#/paths` governance by path, a lookup in the precomputed table (clause 6)\n *\n * Every title, body, path, tag, message, and routing target is untrusted repository text.\n * The DOM is built with `createElement`, `createElementNS`, and `createTextNode`; text goes in\n * through `textContent` only, and no string is ever parsed as markup. Repository strings\n * never pick a class or a property: statuses, kinds, and SLA states are looked up in fixed\n * tables through `own`, and an unknown value gets the neutral class. Links are rebuilt here\n * from validated parts rather than trusted from the data file.\n *\n * SVG classes go through `setAttribute('class', …)`, because an SVG element's `className`\n * is an `SVGAnimatedString` that ignores a plain assignment. A dimmed graph record dims its\n * box, never its label. A pointer drag pans and never also opens the record it ended on.\n *\n * The page has no form, sends nothing, and has no control that changes anything (clause 4).\n * It touches the browser only through the `env` given to `startPortal`, so the tests drive\n * it with a fake document.\n */\n\nimport { layoutBoard, MAX_EXTENT, NODE_HEIGHT, NODE_LIMIT, NODE_WIDTH } from '../vendor/board-layout.mjs';\nimport { clampView, fitView, initialView, panView, resizeView, viewBoxOf, zoomView } from '../vendor/board-view.mjs';\n\n/** The data file, relative to the document (fetch resolves against the page, not this module). */\nexport const DATA_URL = './portal-data.json';\n/** The one contract version this page renders. */\nexport const SUPPORTED_VERSION = '1';\n\nconst SVG_NS = 'http://www.w3.org/2000/svg';\nconst TITLE_CHARS = 28;\n\nconst STATUSES = ['accepted', 'proposed', 'draft', 'rejected', 'superseded', 'deprecated'];\nconst STATUS_TONE = { accepted: 'green', proposed: 'blue', draft: 'neutral', rejected: 'red', superseded: 'purple', deprecated: 'yellow' };\nconst STATUS_GLYPH = { accepted: 'check', proposed: 'half', draft: 'ring', rejected: 'cross', superseded: 'arrow', deprecated: 'bang' };\nconst STANDING_TEXT = { governing: 'governing', activeProposals: 'active proposal', history: 'history' };\nconst KINDS = ['supersedes', 'relatesTo', 'conflictsWith'];\n/** The kinds the graph draws when it opens. */\nexport const DEFAULT_KINDS = Object.freeze(['supersedes']);\nconst KIND_TEXT = { supersedes: 'supersedes', relatesTo: 'relates to', conflictsWith: 'conflicts with' };\nconst KIND_LOOK = { supersedes: 'solid, arrow to the replaced record', relatesTo: 'dashed', conflictsWith: 'dotted' };\nconst SLA_TONE = { overdue: 'red', escalated: 'red', due: 'yellow', 'within-sla': 'green', 'missing-sla': 'neutral', 'not-queued': 'neutral', decided: 'neutral' };\nconst SLA_GLYPH = { overdue: 'bang', escalated: 'bang', due: 'wait', 'within-sla': 'check', 'missing-sla': 'ask', 'not-queued': 'ring', decided: 'dot' };\nconst SLA_TEXT = { overdue: 'overdue', escalated: 'escalated', due: 'due', 'within-sla': 'within SLA', 'missing-sla': 'no SLA set', 'not-queued': 'not queued', decided: 'decided' };\nconst TIER_TEXT = { auto: 'auto', async: 'async', arb: 'ARB' };\nconst ALIGN_CLASS = { left: 'align-left', center: 'align-center', right: 'align-right' };\nconst EVIDENCE_TEXT = { affects: 'affects matcher', marker: '@adr marker' };\nconst SEVERITY_TEXT = { error: 'error', warn: 'warning', info: 'info' };\n/** A record id as a route segment: letters, digits, `.`, `_`, `-`, at most 64. */\nconst ID_PATTERN = /^[0-9A-Za-z][0-9A-Za-z._-]{0,63}$/;\n\n/**\n * What the path view says in each table state (contract.ts, `PortalPathTable`). Each is a\n * contract obligation, and none of them calls a path free of decisions.\n */\nexport const PATH_TEXT = {\n missAtCommit: (commit) =>\n 'No tracked file by this path at commit ' + shortCommit(commit) + ' is reached by any decision.',\n missInIndex: 'No tracked file by this path in the index is reached by any decision. This repository had no commits when the portal was built.',\n missCapped:\n 'Not in this table. The table is capped, so a governed path can be missing from it; run adr explain on the path for the full answer.',\n unavailable:\n 'Resolution was withheld because the corpus has errors. The errors are listed above; fix them and rebuild the portal to look paths up.',\n absent:\n 'This host has no path table, so it cannot say which decisions reach a path. Build the portal from a git work tree to get one, or run adr explain on the path.',\n};\n\n/* ------------------------------------------------------------------ *\n * Small pure helpers (exported for the tests)\n * ------------------------------------------------------------------ */\n\nfunction list(value) {\n return Array.isArray(value) ? value : [];\n}\nfunction text(value) {\n return typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value);\n}\n/** A fixed table's own entry for a repository string, or undefined; never an inherited one. */\nfunction own(table, key) {\n return typeof key === 'string' && Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined;\n}\nfunction plural(n, noun, many) {\n return text(n) + ' ' + (n === 1 ? noun : many || noun + 's');\n}\nexport function shortCommit(commit) {\n return typeof commit === 'string' && /^[0-9a-f]{40,64}$/.test(commit) ? commit.slice(0, 12) : 'unknown';\n}\n\n/**\n * Clip to at most `size` characters without cutting one in half: by grapheme where the\n * runtime can segment, else by code point, so an emoji or a combining mark never splits.\n */\nexport function clipText(value, size) {\n const s = text(value);\n let parts;\n if (typeof Intl === 'object' && Intl && typeof Intl.Segmenter === 'function') {\n parts = [];\n for (const piece of new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(s)) parts.push(piece.segment);\n } else {\n parts = Array.from(s);\n }\n return parts.length > size ? parts.slice(0, Math.max(1, size - 1)).join('') + '…' : s;\n}\n\n/** The route a hash names. Unknown hashes are their own view, never a guess. */\nexport function parseRoute(hash) {\n const h = text(hash);\n if (h === '' || h === '#' || h === '#/') return { view: 'overview' };\n if (h === '#/graph') return { view: 'graph' };\n if (h === '#/queue') return { view: 'queue' };\n if (h === '#/paths') return { view: 'paths' };\n const match = /^#\\/adr\\/([^/?#]+)$/.exec(h);\n if (match) {\n let id = null;\n try {\n id = decodeURIComponent(match[1]);\n } catch {\n id = null;\n }\n if (id !== null && ID_PATTERN.test(id)) return { view: 'record', id };\n }\n return { view: 'missing' };\n}\n\n/** The hash route of a record, or null when the id is not a routable id. */\nexport function recordHref(id) {\n return typeof id === 'string' && ID_PATTERN.test(id) ? '#/adr/' + encodeURIComponent(id) : null;\n}\n\n/** An external link target this page will follow: absolute `http:` or `https:` only. */\nexport function safeExternalHref(href) {\n if (typeof href !== 'string' || !/^https?:\\/\\//i.test(href)) return null;\n try {\n const url = new URL(href);\n return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Trim a typed path for lookup: surrounding whitespace and a leading `./` only. A backslash is\n * a literal character in a repository path (core treats `src/we\\ird.ts` and `src/we/ird.ts`\n * as two paths), so it is never rewritten.\n */\nexport function normalizeLookup(value) {\n let path = text(value).trim();\n while (path.startsWith('./')) path = path.slice(2);\n return path;\n}\n\n/**\n * Look a path up in the table. Returns `{ kind, entry?, message? }` where `kind` is `hit`,\n * `miss`, `unavailable`, `absent`, or `empty` (nothing typed); `message` is the contract\n * wording for every kind but `hit` and `empty`.\n */\nexport function lookupPath(table, commit, value) {\n const state = table && table.state;\n if (state === 'unavailable') return { kind: 'unavailable', message: PATH_TEXT.unavailable };\n if (state !== 'available') return { kind: 'absent', message: PATH_TEXT.absent };\n const path = normalizeLookup(value);\n if (path === '') return { kind: 'empty' };\n const entry = list(table.entries).find((candidate) => candidate && candidate.path === path);\n if (entry) return { kind: 'hit', entry };\n if (table.capped === true) return { kind: 'miss', message: PATH_TEXT.missCapped };\n return { kind: 'miss', message: commit ? PATH_TEXT.missAtCommit(commit) : PATH_TEXT.missInIndex };\n}\n\n/** Error-severity issues, in data order. */\nexport function errorIssues(data) {\n return list(data && data.issues).filter((issue) => issue && issue.severity === 'error');\n}\n\n/** Counts by status, in the fixed status order, including zeros. */\nexport function statusCounts(records) {\n const counts = STATUSES.map((status) => ({ status, count: 0 }));\n let other = 0;\n for (const record of list(records)) {\n const row = counts.find((candidate) => candidate.status === record.status);\n if (row) row.count += 1;\n else other += 1;\n }\n return other > 0 ? counts.concat([{ status: 'unknown', count: other }]) : counts;\n}\n\n/* ------------------------------------------------------------------ *\n * The page\n * ------------------------------------------------------------------ */\n\n/**\n * Start the portal. `env` carries `document`, `window`, and `fetch`; nothing else of the\n * browser is reached. Returns a promise that settles after the first render, with a handle\n * the tests use to re-render a route.\n */\nexport function startPortal(env) {\n const doc = env.document;\n const win = env.window;\n let data = null;\n /**\n * The graph's kinds filter. It opens on supersession alone, as the SDK's graph does and as\n * clause 4 names the view; relatesTo and conflictsWith are opt-in, because on a real corpus\n * all three kinds together draw an unreadable tangle.\n */\n const shownKinds = new Set(DEFAULT_KINDS);\n /** The path last typed in the path view, kept across route changes. */\n let typedPath = '';\n /** Graph view state. */\n let view = null;\n let drawn = null;\n let viewport = { width: 0, height: 0 };\n let drag = null;\n let dragged = false;\n let nodeElements = [];\n /** Redraws the current graph's lines for the kinds filter, or null when no graph is drawn. */\n let redrawEdges = null;\n\n const $ = (id) => doc.getElementById(id);\n\n /** Every HTML node is built here; text only ever goes through textContent. */\n function el(tag, className, content) {\n const node = doc.createElement(tag);\n if (className) node.className = className;\n if (content !== undefined && content !== null) node.textContent = text(content);\n return node;\n }\n /** Every SVG node is built here. Classes go through setAttribute, never className. */\n function svg(tag, attrs, content) {\n const node = doc.createElementNS(SVG_NS, tag);\n for (const name of Object.keys(attrs || {})) node.setAttribute(name, text(attrs[name]));\n if (content !== undefined && content !== null) node.textContent = text(content);\n return node;\n }\n function textNode(value) {\n return doc.createTextNode(text(value));\n }\n function clear(node) {\n while (node.firstChild) node.removeChild(node.firstChild);\n }\n function link(label, href, className) {\n const node = el('a', className, label);\n node.setAttribute('href', href);\n return node;\n }\n /** A record id as a link to its route, or plain text when the id is not routable. */\n function recordLink(id, label) {\n const href = recordHref(id);\n return href ? link(label === undefined ? id : label, href, 'record-link mono') : el('span', 'mono', label === undefined ? id : label);\n }\n function statusClass(status) {\n return 'status-' + (own(STATUS_TONE, status) ? status : 'unknown');\n }\n function badge(label, tone, glyph, extra) {\n return el('span', 'badge tone-' + (tone || 'neutral') + ' glyph-' + (glyph || 'ask') + (extra ? ' ' + extra : ''), label);\n }\n function statusBadge(status) {\n return badge(text(status) || 'unknown', own(STATUS_TONE, status), own(STATUS_GLYPH, status));\n }\n function facts(rows) {\n const dl = el('dl', 'facts');\n for (const [label, value] of rows) {\n dl.appendChild(el('dt', null, label));\n const dd = el('dd');\n if (value && typeof value === 'object' && 'appendChild' in value) dd.appendChild(value);\n else dd.textContent = text(value);\n dl.appendChild(dd);\n }\n return dl;\n }\n function callout(tone, glyph, title, lines) {\n const box = el('div', 'callout tone-' + tone);\n box.appendChild(el('p', 'callout-title glyph-' + glyph, title));\n for (const line of lines || []) box.appendChild(typeof line === 'string' ? el('p', null, line) : line);\n return box;\n }\n function heading(level, label) {\n const node = el(level, 'view-title', label);\n node.setAttribute('tabindex', '-1');\n return node;\n }\n\n /** The newest record per id, as the SDK's `get` resolves a repeated id. */\n function recordsById() {\n const map = new Map();\n for (const record of list(data.records)) map.set(record.id, record);\n return map;\n }\n\n /* ---------------------------- chrome ---------------------------- */\n\n function renderChrome() {\n const corpus = $('corpus');\n if (corpus) corpus.textContent = text(data.dir) + ' · ' + plural(list(data.records).length, 'record');\n const banner = $('banner');\n if (banner) {\n clear(banner);\n const errors = errorIssues(data);\n if (errors.length > 0) {\n const box = callout('red', 'cross', 'The corpus has ' + plural(errors.length, 'error') + '.', [\n 'Every record that loaded is shown, and the data file carries every error. Governance by path is withheld until the errors are fixed.',\n ]);\n box.setAttribute('role', 'alert');\n box.appendChild(issueList(errors));\n banner.appendChild(box);\n }\n }\n const footer = $('footer');\n if (footer) {\n clear(footer);\n footer.appendChild(\n el(\n 'p',\n null,\n 'Read-only decision portal, built from ' + text(data.dir) + ' at commit ' + shortCommit(data.commit) +\n '; queue SLA states as of ' + text(data.asOf) + '. It has no form and changes nothing.',\n ),\n );\n }\n }\n\n function issueList(issues) {\n const ul = el('ul', 'issues');\n for (const issue of issues) {\n const li = el('li', 'issue');\n li.appendChild(el('span', 'chip-kind', own(SEVERITY_TEXT, issue.severity) || 'issue'));\n li.appendChild(el('code', null, issue.code));\n if (issue.path !== null && issue.path !== undefined) li.appendChild(el('span', 'mono issue-path', issue.path));\n li.appendChild(el('span', 'issue-message', issue.message));\n ul.appendChild(li);\n }\n return ul;\n }\n\n function markNav(route) {\n for (const [id, viewName] of [['nav-overview', 'overview'], ['nav-graph', 'graph'], ['nav-queue', 'queue'], ['nav-paths', 'paths']]) {\n const node = $(id);\n if (!node) continue;\n if (route.view === viewName) node.setAttribute('aria-current', 'page');\n else node.removeAttribute('aria-current');\n }\n }\n\n /* --------------------------- overview --------------------------- */\n\n function renderOverview(main) {\n main.appendChild(heading('h2', 'Records'));\n const counts = el('ul', 'status-counts plain');\n counts.setAttribute('aria-label', 'Records by status');\n for (const row of statusCounts(data.records)) {\n const li = el('li', 'status-count');\n const shown = statusBadge(row.status);\n shown.textContent = row.status + ': ' + row.count;\n li.appendChild(shown);\n counts.appendChild(li);\n }\n main.appendChild(counts);\n\n const others = list(data.issues).filter((issue) => issue && issue.severity !== 'error');\n if (others.length > 0) {\n const details = el('details', 'notes');\n details.appendChild(el('summary', null, 'Warnings and notes (' + others.length + ')'));\n details.appendChild(issueList(others));\n main.appendChild(details);\n }\n\n const filterRow = el('div', 'filter-row');\n const label = el('label', 'filter-label', 'Filter ');\n const input = el('input', 'filter-input');\n input.setAttribute('type', 'search');\n input.setAttribute('aria-label', 'Filter records by id, title, status, or tag');\n input.setAttribute('placeholder', 'id, title, status, or tag');\n label.appendChild(input);\n filterRow.appendChild(label);\n const shownCount = el('span', 'muted small');\n filterRow.appendChild(shownCount);\n main.appendChild(filterRow);\n\n const cards = el('ul', 'cards record-list');\n main.appendChild(cards);\n const all = list(data.records);\n const draw = () => {\n clear(cards);\n const needle = text(input.value).trim().toLowerCase();\n const matching = all.filter((record) => {\n if (needle === '') return true;\n const hay = [record.id, record.title, record.status].concat(list(record.tags)).map((part) => text(part).toLowerCase());\n return hay.some((part) => part.includes(needle));\n });\n shownCount.textContent = matching.length === all.length ? plural(all.length, 'record') : matching.length + ' of ' + plural(all.length, 'record');\n if (all.length === 0) {\n const empty = el('li', 'empty');\n empty.appendChild(el('strong', null, 'No records'));\n empty.appendChild(el('span', null, 'The corpus directory holds no record that loaded.'));\n cards.appendChild(empty);\n return;\n }\n for (const record of matching) cards.appendChild(recordCard(record));\n };\n input.addEventListener('input', draw);\n draw();\n }\n\n function recordCard(record) {\n const li = el('li', 'card record-card');\n const head = el('div', 'card-head');\n head.appendChild(recordLink(record.id));\n head.appendChild(statusBadge(record.status));\n head.appendChild(el('span', 'muted small', record.date));\n li.appendChild(head);\n const href = recordHref(record.id);\n li.appendChild(href ? link(record.title, href, 'record-title') : el('span', 'record-title', record.title));\n if (list(record.tags).length > 0) {\n const chips = el('div', 'chips');\n for (const tag of list(record.tags)) chips.appendChild(el('span', 'chip', tag));\n li.appendChild(chips);\n }\n return li;\n }\n\n /* ---------------------------- record ---------------------------- */\n\n function renderRecord(main, id) {\n const record = recordsById().get(id);\n if (!record) {\n main.appendChild(heading('h2', 'Record not found'));\n main.appendChild(el('p', null, 'This bundle has no record with id ' + id + '.'));\n main.appendChild(link('Back to the records', '#/'));\n return;\n }\n const head = el('div', 'record-head');\n head.appendChild(el('span', 'record-id', record.id));\n head.appendChild(statusBadge(record.status));\n head.appendChild(badge(own(STANDING_TEXT, record.standing) || 'unknown standing', 'neutral', 'info'));\n main.appendChild(head);\n main.appendChild(heading('h2', record.title));\n\n const rows = [\n ['Date', record.date],\n ['File', el('span', 'mono', record.path)],\n ];\n if (list(record.tags).length > 0) {\n const chips = el('span', 'chips inline');\n for (const tag of list(record.tags)) chips.appendChild(el('span', 'chip', tag));\n rows.push(['Tags', chips]);\n }\n if (list(record.supersedes).length > 0) rows.push(['Supersedes', idList(record.supersedes)]);\n if (record.supersededBy !== null && record.supersededBy !== undefined) rows.push(['Superseded by', recordLink(record.supersededBy)]);\n const edges = list(data.graph && data.graph.edges);\n for (const kind of ['relatesTo', 'conflictsWith']) {\n const outgoing = edges.filter((edge) => edge.kind === kind && edge.from === record.id).map((edge) => edge.to);\n const incoming = edges.filter((edge) => edge.kind === kind && edge.to === record.id).map((edge) => edge.from);\n if (outgoing.length > 0) rows.push([kind === 'relatesTo' ? 'Relates to' : 'Conflicts with', idList(outgoing)]);\n if (incoming.length > 0) rows.push([kind === 'relatesTo' ? 'Related from' : 'In conflict from', idList(incoming)]);\n }\n main.appendChild(facts(rows));\n\n const article = el('article', 'record-body');\n renderBody(article, list(record.body));\n main.appendChild(article);\n }\n\n function idList(ids) {\n const span = el('span', 'id-list');\n list(ids).forEach((id, index) => {\n if (index > 0) span.appendChild(textNode(', '));\n span.appendChild(recordLink(id));\n });\n return span;\n }\n\n /* ---------------------------- body ------------------------------ */\n\n function renderBody(container, blocks) {\n const slugs = new Map();\n for (const block of blocks) container.appendChild(renderBlock(block, slugs));\n }\n\n function renderBlock(block, slugs) {\n const type = block && block.type;\n if (type === 'heading') {\n const level = Number.isInteger(block.level) && block.level >= 1 && block.level <= 6 ? block.level : 6;\n // The record title is the page's heading, so body headings sit one level below it.\n const node = el('h' + Math.min(6, level + 2), 'md-h md-h' + level);\n appendInlines(node, list(block.children), slugs);\n if (typeof block.slug === 'string' && block.slug !== '' && !slugs.has(block.slug)) slugs.set(block.slug, node);\n return node;\n }\n if (type === 'paragraph') {\n const node = el('p');\n appendInlines(node, list(block.children), slugs);\n return node;\n }\n if (type === 'list') {\n const node = el(block.ordered === true ? 'ol' : 'ul', 'md-list');\n if (block.ordered === true && Number.isSafeInteger(block.start) && block.start >= 0 && block.start !== 1) {\n node.setAttribute('start', String(block.start));\n }\n for (const item of list(block.items)) {\n const li = el('li');\n for (const child of list(item)) li.appendChild(renderBlock(child, slugs));\n node.appendChild(li);\n }\n return node;\n }\n if (type === 'code') {\n const figure = el('div', 'md-code');\n if (typeof block.lang === 'string' && block.lang !== '') figure.appendChild(el('span', 'md-code-lang', block.lang));\n const pre = el('pre');\n pre.appendChild(el('code', null, block.text));\n figure.appendChild(pre);\n return figure;\n }\n if (type === 'table') {\n const wrap = el('div', 'md-table-wrap');\n const table = el('table', 'md-table');\n const align = list(block.align);\n const row = (cells, tag) => {\n const tr = el('tr');\n list(cells).forEach((cell, index) => {\n const td = el(tag, own(ALIGN_CLASS, align[index]) || null);\n appendInlines(td, list(cell), slugs);\n tr.appendChild(td);\n });\n return tr;\n };\n const thead = el('thead');\n thead.appendChild(row(block.header, 'th'));\n table.appendChild(thead);\n const tbody = el('tbody');\n for (const cells of list(block.rows)) tbody.appendChild(row(cells, 'td'));\n table.appendChild(tbody);\n wrap.appendChild(table);\n return wrap;\n }\n if (type === 'blockquote') {\n const node = el('blockquote', 'md-quote');\n for (const child of list(block.children)) node.appendChild(renderBlock(child, slugs));\n return node;\n }\n // The tree is closed (contract.ts); anything else is shown as nothing but its text.\n return el('p', 'md-unknown', block && typeof block.text === 'string' ? block.text : '');\n }\n\n function appendInlines(parent, inlines, slugs) {\n for (const inline of inlines) parent.appendChild(renderInline(inline, slugs));\n }\n\n function renderInline(inline, slugs) {\n const type = inline && inline.type;\n if (type === 'text') return textNode(inline.text);\n if (type === 'inlineCode') return el('code', null, inline.text);\n if (type === 'emphasis' || type === 'strong') {\n const node = el(type === 'strong' ? 'strong' : 'em');\n appendInlines(node, list(inline.children), slugs);\n return node;\n }\n if (type === 'link') {\n const children = list(inline.children);\n if (inline.kind === 'external') {\n const href = safeExternalHref(inline.href);\n if (href) {\n const node = el('a', 'md-link external');\n node.setAttribute('href', href);\n node.setAttribute('rel', 'noopener noreferrer nofollow');\n appendInlines(node, children, slugs);\n return node;\n }\n } else if (inline.kind === 'record') {\n const href = recordHref(inline.id);\n if (href) {\n const node = el('a', 'md-link record-link');\n node.setAttribute('href', href);\n appendInlines(node, children, slugs);\n return node;\n }\n } else if (inline.kind === 'fragment' && typeof inline.fragment === 'string') {\n // A hash route owns the URL fragment, so a same-record link scrolls instead.\n const fragment = inline.fragment;\n const node = el('a', 'md-link fragment');\n node.setAttribute('role', 'link');\n node.setAttribute('tabindex', '0');\n const go = (event) => {\n if (event && typeof event.preventDefault === 'function') event.preventDefault();\n const target = slugs.get(fragment);\n if (target && typeof target.scrollIntoView === 'function') target.scrollIntoView();\n };\n node.addEventListener('click', go);\n node.addEventListener('keydown', (event) => {\n if (event && event.key === 'Enter') go(event);\n });\n appendInlines(node, children, slugs);\n return node;\n }\n // A link that fails its check is shown as its words, never as a link.\n const span = el('span', 'md-link-text');\n appendInlines(span, children, slugs);\n return span;\n }\n return textNode(inline && typeof inline.text === 'string' ? inline.text : '');\n }\n\n /* ---------------------------- graph ----------------------------- */\n\n function renderGraph(main) {\n main.appendChild(heading('h2', 'Decision graph'));\n const graph = data.graph || { nodes: [], edges: [] };\n const nodes = list(graph.nodes);\n const edges = list(graph.edges);\n\n const filters = el('fieldset', 'filters');\n filters.appendChild(el('legend', null, 'Relationship kinds'));\n for (const kind of KINDS) {\n const label = el('label', 'toggle');\n const box = el('input');\n box.setAttribute('type', 'checkbox');\n box.checked = shownKinds.has(kind);\n box.addEventListener('change', () => {\n if (box.checked) shownKinds.add(kind);\n else shownKinds.delete(kind);\n // Only the lines change: the frame, the checkboxes (and so focus), and the view stay.\n if (redrawEdges) redrawEdges();\n });\n label.appendChild(box);\n label.appendChild(textNode(' ' + own(KIND_TEXT, kind)));\n filters.appendChild(label);\n }\n main.appendChild(filters);\n main.appendChild(legend());\n\n // Positions come from every edge, so unticking a kind hides lines without moving boxes.\n const layout = layoutBoard({ nodes, edges });\n if (layout.mode === 'summary') {\n const box = el('div', 'summary');\n const why =\n layout.reason === 'nodes'\n ? 'more than the ' + NODE_LIMIT + ' records the graph draws legibly'\n : 'a drawing wider or taller than ' + MAX_EXTENT + ' px';\n box.appendChild(\n el('p', 'summary-lede', plural(layout.totalNodes, 'record') + ' and ' + plural(layout.totalEdges, 'relationship') + ': ' + why + ', so the graph shows counts instead.'),\n );\n const counts = el('ul', 'status-counts plain');\n for (const row of list(layout.byStatus)) {\n const li = el('li', 'status-count');\n const shown = statusBadge(row.status);\n shown.textContent = text(row.status) + ': ' + text(row.count);\n li.appendChild(shown);\n counts.appendChild(li);\n }\n box.appendChild(counts);\n box.appendChild(link('Browse the records instead', '#/'));\n main.appendChild(box);\n drawn = null;\n return;\n }\n if (nodes.length === 0) {\n const empty = el('div', 'empty');\n empty.appendChild(el('strong', null, 'No records to draw'));\n empty.appendChild(el('span', null, 'The corpus holds no record that loaded.'));\n main.appendChild(empty);\n drawn = null;\n return;\n }\n\n const byId = new Map();\n for (const node of nodes) byId.set(node.id, node);\n const at = new Map();\n for (const position of layout.positions) at.set(position.id, position);\n\n main.appendChild(zoomBar());\n const wanted = layout.height * 0.75 + 32;\n const frame = el('div', 'graph-frame ' + (wanted <= 240 ? 'h-s' : wanted <= 360 ? 'h-m' : wanted <= 480 ? 'h-l' : 'h-xl'));\n frame.setAttribute('tabindex', '0');\n frame.setAttribute('role', 'region');\n frame.setAttribute('aria-label', 'Decision graph: ' + plural(nodes.length, 'record') + '. Use + and − to zoom, 0 to fit, arrows to pan.');\n frame.addEventListener('keydown', graphKeys);\n const root = svg('svg', {\n width: '100%',\n height: '100%',\n preserveAspectRatio: 'xMidYMid meet',\n viewBox: '0 0 ' + layout.width + ' ' + layout.height,\n role: 'group',\n 'aria-label': 'Decision graph: ' + plural(nodes.length, 'record'),\n class: 'graph-svg',\n });\n const defs = svg('defs');\n const marker = svg('marker', { id: 'arrow-supersedes', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: 'auto-start-reverse' });\n marker.appendChild(svg('path', { d: 'M 0 0 L 10 5 L 0 10 z', class: 'arrow' }));\n defs.appendChild(marker);\n root.appendChild(defs);\n\n // Edges are painted first, beneath the records, and every box has an opaque base, so a\n // line passing behind a record never crosses its id or title.\n const edgeGroup = svg('g', { class: 'edges' });\n const drawEdges = () => {\n clear(edgeGroup);\n for (const edge of edges) {\n if (!own(KIND_TEXT, edge.kind) || !shownKinds.has(edge.kind)) continue;\n const from = at.get(edge.from);\n const to = at.get(edge.to);\n if (!from || !to) continue;\n const start = edgePoint(from, to);\n const end = edgePoint(to, from);\n const attrs = { x1: start.x, y1: start.y, x2: end.x, y2: end.y, class: 'edge edge-' + edge.kind };\n if (edge.kind === 'supersedes') attrs['marker-end'] = 'url(#arrow-supersedes)';\n const line = svg('line', attrs);\n line.appendChild(svg('title', {}, text(edge.from) + ' ' + own(KIND_TEXT, edge.kind) + ' ' + text(edge.to)));\n edgeGroup.appendChild(line);\n }\n if (drawn) drawn.edges = edges.filter((edge) => shownKinds.has(edge.kind));\n };\n drawEdges();\n root.appendChild(edgeGroup);\n\n const nodeGroup = svg('g', { class: 'nodes' });\n nodeElements = [];\n for (const position of layout.positions) {\n const node = byId.get(position.id);\n if (!node) continue;\n const id = text(node.id);\n const status = text(node.status);\n const g = svg('g', {\n class: 'node ' + statusClass(status),\n transform: 'translate(' + position.x + ' ' + position.y + ')',\n tabindex: 0,\n role: 'link',\n 'aria-label': id + ', ' + status + ': ' + text(node.title),\n });\n g.appendChild(svg('title', {}, id + ' (' + status + '): ' + text(node.title)));\n g.appendChild(svg('rect', { width: NODE_WIDTH, height: NODE_HEIGHT, rx: 8, class: 'node-base' }));\n g.appendChild(svg('rect', { width: NODE_WIDTH, height: NODE_HEIGHT, rx: 8, class: 'node-box ' + statusClass(status) }));\n g.appendChild(svg('rect', { width: 5, height: NODE_HEIGHT - 12, x: 0, y: 6, rx: 2, class: 'node-stripe ' + statusClass(status) }));\n g.appendChild(svg('text', { x: 14, y: 22, class: 'node-id' }, id + ' · ' + status));\n g.appendChild(svg('text', { x: 14, y: 41, class: 'node-title' }, clipText(node.title, TITLE_CHARS)));\n const open = () => {\n const href = recordHref(id);\n if (href) win.location.hash = href;\n };\n g.addEventListener('click', () => {\n if (dragged) {\n dragged = false;\n return;\n }\n open();\n });\n g.addEventListener('keydown', (event) => {\n if (event && (event.key === 'Enter' || event.key === ' ')) {\n if (typeof event.preventDefault === 'function') event.preventDefault();\n open();\n }\n });\n g.addEventListener('focus', () => highlight(id));\n g.addEventListener('mouseenter', () => highlight(id));\n g.addEventListener('blur', () => highlight(null));\n g.addEventListener('mouseleave', () => highlight(null));\n nodeElements.push({ id, status, g });\n nodeGroup.appendChild(g);\n }\n root.appendChild(nodeGroup);\n frame.appendChild(root);\n main.appendChild(frame);\n wirePointer(root);\n\n drawn = { root, width: layout.width, height: layout.height, edges: edges.filter((edge) => shownKinds.has(edge.kind)) };\n redrawEdges = drawEdges;\n const size = measure(frame, layout);\n view = initialView(drawn.width, drawn.height, size.width, size.height);\n viewport = size;\n applyView();\n }\n\n /** Mark the record under the pointer or focus and its neighbors; dim the other boxes, never their labels. */\n function highlight(id) {\n const near = new Set();\n if (id !== null && drawn) {\n near.add(id);\n for (const edge of drawn.edges) {\n if (edge.from === id) near.add(edge.to);\n if (edge.to === id) near.add(edge.from);\n }\n }\n for (const item of nodeElements) {\n const extra = id === null ? '' : item.id === id ? ' selected' : near.has(item.id) ? ' neighbor' : ' dim';\n item.g.setAttribute('class', 'node ' + statusClass(item.status) + extra);\n }\n }\n\n function edgePoint(from, to) {\n const cx = from.x + NODE_WIDTH / 2;\n const cy = from.y + NODE_HEIGHT / 2;\n const dx = to.x + NODE_WIDTH / 2 - cx;\n const dy = to.y + NODE_HEIGHT / 2 - cy;\n if (dx === 0 && dy === 0) return { x: cx, y: cy };\n const sx = dx === 0 ? Infinity : NODE_WIDTH / 2 / Math.abs(dx);\n const sy = dy === 0 ? Infinity : NODE_HEIGHT / 2 / Math.abs(dy);\n const s = Math.min(sx, sy);\n return { x: cx + dx * s, y: cy + dy * s };\n }\n\n function legend() {\n const box = el('div', 'legend');\n const statusGroup = el('ul', 'legend-list plain');\n statusGroup.setAttribute('aria-label', 'Status');\n for (const status of STATUSES) {\n const li = el('li', 'legend-item');\n const swatch = svg('svg', { width: 18, height: 12, 'aria-hidden': 'true', class: 'swatch' });\n swatch.appendChild(svg('rect', { x: 1, y: 1, width: 16, height: 10, rx: 3, class: 'node-box ' + statusClass(status) }));\n li.appendChild(swatch);\n li.appendChild(el('span', null, status));\n statusGroup.appendChild(li);\n }\n box.appendChild(statusGroup);\n const kindGroup = el('ul', 'legend-list plain');\n kindGroup.setAttribute('aria-label', 'Relationship');\n for (const kind of KINDS) {\n const li = el('li', 'legend-item');\n const sample = svg('svg', { width: 30, height: 12, 'aria-hidden': 'true', class: 'swatch' });\n sample.appendChild(svg('line', { x1: 1, y1: 6, x2: kind === 'supersedes' ? 24 : 29, y2: 6, class: 'edge edge-' + kind }));\n if (kind === 'supersedes') sample.appendChild(svg('path', { d: 'M 23 2 L 29 6 L 23 10 z', class: 'arrow' }));\n li.appendChild(sample);\n li.appendChild(el('span', null, own(KIND_TEXT, kind) + ' (' + own(KIND_LOOK, kind) + ')'));\n kindGroup.appendChild(li);\n }\n box.appendChild(kindGroup);\n return box;\n }\n\n function measure(frame, layout) {\n const rect = frame && typeof frame.getBoundingClientRect === 'function' ? frame.getBoundingClientRect() : null;\n if (rect && rect.width > 0 && rect.height > 0) return { width: rect.width, height: rect.height };\n return { width: layout.width || NODE_WIDTH, height: Math.min(layout.height || NODE_HEIGHT, 560) };\n }\n\n function applyView() {\n if (drawn && view) drawn.root.setAttribute('viewBox', viewBoxOf(view));\n }\n\n function zoomBy(factor, fx, fy) {\n if (!drawn || !view) return;\n const floor = viewport.width / fitView(drawn.width, drawn.height, viewport.width, viewport.height).w;\n view = clampView(zoomView(view, factor, fx, fy, viewport.width, floor), drawn.width, drawn.height);\n applyView();\n }\n\n function panBy(dx, dy) {\n if (!drawn || !view) return;\n view = clampView(panView(view, dx, dy, viewport.width), drawn.width, drawn.height);\n applyView();\n }\n\n function resetView() {\n if (!drawn) return;\n view = fitView(drawn.width, drawn.height, viewport.width, viewport.height);\n applyView();\n }\n\n function graphKeys(event) {\n if (!event) return;\n const tag = event.target && event.target.tagName ? String(event.target.tagName).toLowerCase() : '';\n if (tag === 'input' || tag === 'button' || tag === 'textarea') return;\n const step = 60;\n let handled = true;\n if (event.key === '+' || event.key === '=') zoomBy(1.25, 0.5, 0.5);\n else if (event.key === '-' || event.key === '_') zoomBy(0.8, 0.5, 0.5);\n else if (event.key === '0') resetView();\n else if (event.key === 'ArrowLeft') panBy(step, 0);\n else if (event.key === 'ArrowRight') panBy(-step, 0);\n else if (event.key === 'ArrowUp') panBy(0, step);\n else if (event.key === 'ArrowDown') panBy(0, -step);\n else handled = false;\n if (handled && typeof event.preventDefault === 'function') event.preventDefault();\n }\n\n /** Pointer drag pans; a drag never also opens the record it ended on. */\n function wirePointer(root) {\n root.addEventListener('pointerdown', (event) => {\n if (!event || (event.button !== undefined && event.button !== 0)) return;\n drag = { x: event.clientX, y: event.clientY, id: event.pointerId };\n dragged = false;\n });\n root.addEventListener('pointermove', (event) => {\n if (!drag || !event) return;\n const dx = event.clientX - drag.x;\n const dy = event.clientY - drag.y;\n if (!dragged && Math.abs(dx) + Math.abs(dy) < 4) return;\n if (!dragged && typeof root.setPointerCapture === 'function') {\n try {\n root.setPointerCapture(drag.id);\n } catch {\n // Not capturable; the drag still pans.\n }\n }\n dragged = true;\n root.setAttribute('class', 'graph-svg dragging');\n drag.x = event.clientX;\n drag.y = event.clientY;\n panBy(dx, dy);\n });\n const end = () => {\n drag = null;\n root.setAttribute('class', 'graph-svg');\n };\n root.addEventListener('pointerup', end);\n root.addEventListener('pointercancel', end);\n // Only Ctrl or Cmd with the wheel (a trackpad pinch) zooms; a plain wheel scrolls the page.\n root.addEventListener(\n 'wheel',\n (event) => {\n if (!event || !(event.ctrlKey || event.metaKey)) return;\n if (typeof event.preventDefault === 'function') event.preventDefault();\n const rect = typeof root.getBoundingClientRect === 'function' ? root.getBoundingClientRect() : null;\n const fx = rect && rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0.5;\n const fy = rect && rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5;\n zoomBy(event.deltaY < 0 ? 1.1 : 1 / 1.1, fx, fy);\n },\n { passive: false },\n );\n }\n\n function zoomBar() {\n const bar = el('div', 'zoom-bar');\n const make = (label, aria, action) => {\n const node = el('button', 'secondary', label);\n node.setAttribute('type', 'button');\n node.setAttribute('aria-label', aria);\n node.addEventListener('click', action);\n return node;\n };\n bar.appendChild(make('−', 'Zoom out', () => zoomBy(0.8, 0.5, 0.5)));\n bar.appendChild(make('+', 'Zoom in', () => zoomBy(1.25, 0.5, 0.5)));\n bar.appendChild(make('Fit', 'Fit the whole graph', () => resetView()));\n bar.appendChild(el('span', 'hint', 'Drag to pan · Ctrl or Cmd + wheel to zoom · keys + − 0 and arrows · select a record to open it'));\n return bar;\n }\n\n function onResize() {\n if (!drawn || !view) return;\n const frame = drawn.root;\n const rect = typeof frame.getBoundingClientRect === 'function' ? frame.getBoundingClientRect() : null;\n if (!rect || !(rect.width > 0) || !(rect.height > 0)) return;\n if (rect.width === viewport.width && rect.height === viewport.height) return;\n view = resizeView(view, rect.width, rect.height, viewport.width);\n viewport = { width: rect.width, height: rect.height };\n applyView();\n }\n\n /* ---------------------------- queue ----------------------------- */\n\n function renderQueue(main) {\n main.appendChild(heading('h2', 'Review queue'));\n const entries = list(data.queue);\n main.appendChild(\n el('p', 'lede', plural(entries.length, 'proposed record') + ' in review, as of ' + text(data.asOf) + '. These are the review facts each record carries; the portal adds no verdict to them.'),\n );\n if (entries.length === 0) {\n const empty = el('div', 'empty');\n empty.appendChild(el('strong', null, 'Nothing in review'));\n empty.appendChild(el('span', null, 'No record is proposed.'));\n main.appendChild(empty);\n return;\n }\n const cards = el('ul', 'cards queue-list');\n for (const entry of entries) cards.appendChild(queueCard(entry));\n main.appendChild(cards);\n }\n\n function queueCard(entry) {\n const li = el('li', 'card queue-row');\n const head = el('div', 'card-head');\n head.appendChild(recordLink(entry.id));\n head.appendChild(badge(own(SLA_TEXT, entry.slaState) || 'unknown SLA state', own(SLA_TONE, entry.slaState), own(SLA_GLYPH, entry.slaState)));\n li.appendChild(head);\n li.appendChild(el('p', 'record-title', entry.title));\n\n const approvals = Number.isSafeInteger(entry.approvalCount) ? entry.approvalCount : 0;\n const quorum = Number.isSafeInteger(entry.quorum) ? entry.quorum : null;\n const approvalText = quorum === null ? plural(approvals, 'approval') + ', no quorum set' : approvals + ' of ' + quorum + ' approvals';\n const unresolved = Number.isSafeInteger(entry.unresolvedObjectionCount) ? entry.unresolvedObjectionCount : 0;\n const resolved = Number.isSafeInteger(entry.resolvedObjectionCount) ? entry.resolvedObjectionCount : 0;\n const routing = list(entry.routingTargets);\n const routingNode = el('span', 'chips inline');\n if (routing.length === 0) routingNode.textContent = 'none declared';\n for (const target of routing) routingNode.appendChild(el('span', 'chip mono', target));\n const findings = Number.isSafeInteger(entry.itemFindingCount) ? entry.itemFindingCount : 0;\n\n li.appendChild(\n facts([\n ['Approvals', approvalText],\n ['Objections', unresolved + ' unresolved, ' + resolved + ' resolved'],\n ['SLA state', own(SLA_TEXT, entry.slaState) || 'unknown'],\n ['Deadline', entry.deadline === null || entry.deadline === undefined ? 'none' : entry.deadline],\n ['Tier', own(TIER_TEXT, entry.tier) || 'none declared'],\n ['Routing', routingNode],\n ['Queue findings', String(findings)],\n ['File', el('span', 'mono', entry.path)],\n ]),\n );\n return li;\n }\n\n /* ---------------------------- paths ----------------------------- */\n\n function renderPaths(main) {\n main.appendChild(heading('h2', 'Governance by path'));\n const table = data.pathTable || { state: 'absent' };\n if (table.state === 'unavailable') {\n const box = callout('red', 'cross', 'Path lookup withheld', [PATH_TEXT.unavailable]);\n box.setAttribute('role', 'status');\n main.appendChild(box);\n return;\n }\n if (table.state !== 'available') {\n main.appendChild(callout('neutral', 'info', 'No path table', [PATH_TEXT.absent]));\n return;\n }\n const entries = list(table.entries);\n const scope = data.commit ? 'at commit ' + shortCommit(data.commit) : 'in the index (no commits yet)';\n main.appendChild(\n el(\n 'p',\n 'lede',\n 'A lookup in a table built from the tracked files ' + scope + '. ' + plural(table.governedPathCount, 'tracked path') +\n ' are reached by at least one decision. The table cannot answer for a branch, an uncommitted file, or a path added since the build.',\n ),\n );\n if (table.capped === true) {\n main.appendChild(\n callout('yellow', 'bang', 'This table is capped', [\n 'It keeps ' + entries.length + ' of ' + plural(table.governedPathCount, 'governed path') + ' (at most ' + text(table.caps && table.caps.count) + ' entries and ' + text(table.caps && table.caps.bytes) + ' bytes). A path missing from it may still be governed.',\n ]),\n );\n }\n\n const row = el('div', 'filter-row');\n const label = el('label', 'filter-label', 'Path ');\n const input = el('input', 'filter-input mono');\n input.setAttribute('type', 'search');\n input.setAttribute('aria-label', 'Repository-relative path to look up');\n input.setAttribute('placeholder', 'src/index.ts');\n input.setAttribute('spellcheck', 'false');\n input.setAttribute('autocomplete', 'off');\n input.value = typedPath;\n label.appendChild(input);\n row.appendChild(label);\n main.appendChild(row);\n\n const result = el('div', 'path-result');\n result.setAttribute('aria-live', 'polite');\n main.appendChild(result);\n const suggestions = el('div', 'path-suggestions');\n main.appendChild(suggestions);\n\n const draw = () => {\n typedPath = text(input.value);\n clear(result);\n clear(suggestions);\n const outcome = lookupPath(table, data.commit, typedPath);\n if (outcome.kind === 'hit') result.appendChild(pathEntry(outcome.entry));\n else if (outcome.kind === 'miss') result.appendChild(callout('neutral', 'info', 'No entry for ' + normalizeLookup(typedPath), [outcome.message]));\n const prefix = normalizeLookup(typedPath);\n const matches = entries.filter((entry) => prefix === '' || entry.path.startsWith(prefix)).slice(0, 50);\n if (matches.length > 0 && outcome.kind !== 'hit') {\n suggestions.appendChild(el('h3', null, prefix === '' ? 'Governed paths (first ' + matches.length + ')' : 'Governed paths starting with this text'));\n const ul = el('ul', 'path-list plain');\n for (const entry of matches) {\n const li = el('li');\n const pick = el('button', 'link', entry.path);\n pick.setAttribute('type', 'button');\n pick.addEventListener('click', () => {\n input.value = entry.path;\n draw();\n });\n li.appendChild(pick);\n ul.appendChild(li);\n }\n suggestions.appendChild(ul);\n }\n };\n input.addEventListener('input', draw);\n draw();\n }\n\n function pathEntry(entry) {\n const box = el('div', 'card path-entry');\n box.appendChild(el('p', 'mono path', entry.path));\n for (const [key, label] of [['governing', 'Governing'], ['activeProposals', 'Active proposals'], ['history', 'History']]) {\n const decisions = list(entry[key]);\n const section = el('div', 'path-group');\n section.appendChild(el('h3', null, label + ' (' + decisions.length + ')'));\n if (decisions.length === 0) {\n section.appendChild(el('p', 'muted small', 'none'));\n } else {\n const ul = el('ul', 'plain');\n for (const decision of decisions) {\n const li = el('li', 'path-decision');\n li.appendChild(recordLink(decision.id));\n const record = recordsById().get(decision.id);\n if (record) li.appendChild(el('span', 'path-decision-title', record.title));\n for (const kind of list(decision.evidence)) li.appendChild(el('span', 'chip', own(EVIDENCE_TEXT, kind) || 'evidence'));\n ul.appendChild(li);\n }\n section.appendChild(ul);\n }\n box.appendChild(section);\n }\n return box;\n }\n\n /* ---------------------------- router ---------------------------- */\n\n function render() {\n const main = $('app');\n if (!main || !data) return;\n const route = parseRoute(win.location && win.location.hash);\n markNav(route);\n clear(main);\n drawn = null;\n redrawEdges = null;\n nodeElements = [];\n if (route.view === 'overview') renderOverview(main);\n else if (route.view === 'record') renderRecord(main, route.id);\n else if (route.view === 'graph') renderGraph(main);\n else if (route.view === 'queue') renderQueue(main);\n else if (route.view === 'paths') renderPaths(main);\n else {\n main.appendChild(heading('h2', 'Page not found'));\n main.appendChild(el('p', null, 'This portal has no view at this address.'));\n main.appendChild(link('Back to the records', '#/'));\n }\n return route;\n }\n\n function onRoute() {\n render();\n const title = $('app') && $('app').firstChild;\n if (title && typeof title.focus === 'function') title.focus();\n if (typeof win.scrollTo === 'function') win.scrollTo(0, 0);\n }\n\n function fail(title, message) {\n const main = $('app');\n if (!main) return;\n clear(main);\n const box = callout('red', 'cross', title, [message]);\n box.setAttribute('role', 'alert');\n main.appendChild(box);\n }\n\n const loaded = Promise.resolve()\n .then(() => env.fetch(DATA_URL, { credentials: 'same-origin' }))\n .then((response) => {\n if (!response || !response.ok) throw new Error('http ' + (response ? response.status : 'none'));\n return response.json();\n })\n .then(\n (json) => {\n if (!json || typeof json !== 'object' || json.version !== SUPPORTED_VERSION) {\n fail(\n 'Unsupported portal data',\n 'This page renders portal-data.json version ' + SUPPORTED_VERSION + '; the file says version ' +\n text(json && json.version) + '. Rebuild the portal with a matching generator.',\n );\n return;\n }\n data = json;\n renderChrome();\n render();\n win.addEventListener('hashchange', onRoute);\n win.addEventListener('resize', onResize);\n },\n () => fail('Could not load the decisions', 'The page could not read ' + DATA_URL + ' next to it. Serve the bundle over HTTP with every file the generator wrote.'),\n );\n\n return loaded.then(() => ({ render, data: () => data }));\n}\n\nif (typeof document !== 'undefined' && typeof window !== 'undefined' && typeof window.fetch === 'function') {\n startPortal({ document, window, fetch: window.fetch.bind(window) });\n}\n"; +var app_default = "/**\n * The decision portal's page module (ADR-0054 clauses 4 to 9).\n *\n * It reads `./portal-data.json` (the contract in `../data/contract.ts`) and draws five\n * read-only views behind hash routes, so the bundle works at any mount point (clause 8):\n *\n * - `#/` status counts and the record list\n * - `#/adr/` one record, its relationships, and its body tokens\n * - `#/graph` the supersession graph, laid out by the board's own `layoutBoard`\n * - `#/queue` the review queue's raw facts, never a verdict (clause 5)\n * - `#/paths` governance by path, a lookup in the precomputed table (clause 6)\n *\n * Every title, body, path, tag, message, and routing target is untrusted repository text.\n * The DOM is built with `createElement`, `createElementNS`, and `createTextNode`; text goes in\n * through `textContent` only, and no string is ever parsed as markup. Repository strings\n * never pick a class or a property: statuses, kinds, and SLA states are looked up in fixed\n * tables through `own`, and an unknown value gets the neutral class. Links are rebuilt here\n * from validated parts rather than trusted from the data file.\n *\n * SVG classes go through `setAttribute('class', …)`, because an SVG element's `className`\n * is an `SVGAnimatedString` that ignores a plain assignment. A dimmed graph record dims its\n * box, never its label. A pointer drag pans and never also opens the record it ended on.\n *\n * The page has no form, sends nothing, and has no control that changes anything (clause 4).\n * It touches the browser only through the `env` given to `startPortal`, so the tests drive\n * it with a fake document.\n */\n\nimport { layoutBoard, MAX_EXTENT, NODE_HEIGHT, NODE_LIMIT, NODE_WIDTH } from '../vendor/board-layout.mjs';\nimport { clampView, fitView, initialView, panView, resizeView, viewBoxOf, zoomView } from '../vendor/board-view.mjs';\n\n/** The data file, relative to the document (fetch resolves against the page, not this module). */\nexport const DATA_URL = './portal-data.json';\n/** The one contract version this page renders. */\nexport const SUPPORTED_VERSION = '1';\n\nconst SVG_NS = 'http://www.w3.org/2000/svg';\nconst TITLE_CHARS = 28;\n\nconst STATUSES = ['accepted', 'proposed', 'draft', 'rejected', 'superseded', 'deprecated'];\nconst STATUS_TONE = { accepted: 'green', proposed: 'blue', draft: 'neutral', rejected: 'red', superseded: 'purple', deprecated: 'yellow' };\nconst STATUS_GLYPH = { accepted: 'check', proposed: 'half', draft: 'ring', rejected: 'cross', superseded: 'arrow', deprecated: 'bang' };\nconst STANDING_TEXT = { governing: 'governing', activeProposals: 'active proposal', history: 'history' };\nconst KINDS = ['supersedes', 'relatesTo', 'conflictsWith'];\n/** The kinds the graph draws when it opens. */\nexport const DEFAULT_KINDS = Object.freeze(['supersedes']);\nconst KIND_TEXT = { supersedes: 'supersedes', relatesTo: 'relates to', conflictsWith: 'conflicts with' };\nconst KIND_LOOK = { supersedes: 'solid, arrow to the replaced record', relatesTo: 'dashed', conflictsWith: 'dotted' };\nconst SLA_TONE = { overdue: 'red', escalated: 'red', due: 'yellow', 'within-sla': 'green', 'missing-sla': 'neutral', 'not-queued': 'neutral', decided: 'neutral' };\nconst SLA_GLYPH = { overdue: 'bang', escalated: 'bang', due: 'wait', 'within-sla': 'check', 'missing-sla': 'ask', 'not-queued': 'ring', decided: 'dot' };\nconst SLA_TEXT = { overdue: 'overdue', escalated: 'escalated', due: 'due', 'within-sla': 'within SLA', 'missing-sla': 'no SLA set', 'not-queued': 'not queued', decided: 'decided' };\nconst TIER_TEXT = { auto: 'auto', async: 'async', arb: 'ARB' };\nconst ALIGN_CLASS = { left: 'align-left', center: 'align-center', right: 'align-right' };\nconst EVIDENCE_TEXT = { affects: 'affects matcher', marker: '@adr marker' };\nconst SEVERITY_TEXT = { error: 'error', warn: 'warning', info: 'info' };\n/** A record id as a route segment: letters, digits, `.`, `_`, `-`, at most 64. */\nconst ID_PATTERN = /^[0-9A-Za-z][0-9A-Za-z._-]{0,63}$/;\n\n/**\n * What the path view says in each table state (contract.ts, `PortalPathTable`). Each is a\n * contract obligation, and none of them calls a path free of decisions.\n */\nexport const PATH_TEXT = {\n missAtCommit: (commit) =>\n 'No tracked file by this path at commit ' + shortCommit(commit) + ' is reached by any decision.',\n missInIndex: 'No tracked file by this path in the index is reached by any decision. This repository had no commits when the portal was built.',\n missCapped:\n 'Not in this table. The table is capped, so a governed path can be missing from it; run adr explain on the path for the full answer.',\n unavailable:\n 'Resolution was withheld because the corpus has errors. The errors are listed above; fix them and rebuild the portal to look paths up.',\n absent:\n 'This host has no path table, so it cannot say which decisions reach a path. Build the portal from a git work tree to get one, or run adr explain on the path.',\n /** The absent state on a host that sets `pathLookup` (ADR-0055's server), in place of `absent`. */\n absentServed:\n 'This host has no path table: it holds the decision records, not the source tree, so this page cannot say which decisions reach a path.',\n /** The lines naming that host's own route. Text only: the page never requests it. */\n lookupRoute: (route) => [\n 'This server answers for one path at a time, matching affects only and reading no file, at',\n route + '?path=',\n 'relative to this page. For example:',\n route + '?path=src/index.ts',\n ],\n};\n\n/** The host's `pathLookup` route, when the data file carries a usable one; else `null`. */\nexport function lookupRouteOf(data) {\n const lookup = data && data.pathLookup;\n const route = lookup && typeof lookup === 'object' ? lookup.route : null;\n return typeof route === 'string' && route.length > 0 && route.length <= 512 ? route : null;\n}\n\n/* ------------------------------------------------------------------ *\n * Small pure helpers (exported for the tests)\n * ------------------------------------------------------------------ */\n\nfunction list(value) {\n return Array.isArray(value) ? value : [];\n}\nfunction text(value) {\n return typeof value === 'string' ? value : value === null || value === undefined ? '' : String(value);\n}\n/** A fixed table's own entry for a repository string, or undefined; never an inherited one. */\nfunction own(table, key) {\n return typeof key === 'string' && Object.prototype.hasOwnProperty.call(table, key) ? table[key] : undefined;\n}\nfunction plural(n, noun, many) {\n return text(n) + ' ' + (n === 1 ? noun : many || noun + 's');\n}\nexport function shortCommit(commit) {\n return typeof commit === 'string' && /^[0-9a-f]{40,64}$/.test(commit) ? commit.slice(0, 12) : 'unknown';\n}\n\n/**\n * Clip to at most `size` characters without cutting one in half: by grapheme where the\n * runtime can segment, else by code point, so an emoji or a combining mark never splits.\n */\nexport function clipText(value, size) {\n const s = text(value);\n let parts;\n if (typeof Intl === 'object' && Intl && typeof Intl.Segmenter === 'function') {\n parts = [];\n for (const piece of new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(s)) parts.push(piece.segment);\n } else {\n parts = Array.from(s);\n }\n return parts.length > size ? parts.slice(0, Math.max(1, size - 1)).join('') + '…' : s;\n}\n\n/** The route a hash names. Unknown hashes are their own view, never a guess. */\nexport function parseRoute(hash) {\n const h = text(hash);\n if (h === '' || h === '#' || h === '#/') return { view: 'overview' };\n if (h === '#/graph') return { view: 'graph' };\n if (h === '#/queue') return { view: 'queue' };\n if (h === '#/paths') return { view: 'paths' };\n const match = /^#\\/adr\\/([^/?#]+)$/.exec(h);\n if (match) {\n let id = null;\n try {\n id = decodeURIComponent(match[1]);\n } catch {\n id = null;\n }\n if (id !== null && ID_PATTERN.test(id)) return { view: 'record', id };\n }\n return { view: 'missing' };\n}\n\n/** The hash route of a record, or null when the id is not a routable id. */\nexport function recordHref(id) {\n return typeof id === 'string' && ID_PATTERN.test(id) ? '#/adr/' + encodeURIComponent(id) : null;\n}\n\n/** An external link target this page will follow: absolute `http:` or `https:` only. */\nexport function safeExternalHref(href) {\n if (typeof href !== 'string' || !/^https?:\\/\\//i.test(href)) return null;\n try {\n const url = new URL(href);\n return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null;\n } catch {\n return null;\n }\n}\n\n/**\n * Trim a typed path for lookup: surrounding whitespace and a leading `./` only. A backslash is\n * a literal character in a repository path (core treats `src/we\\ird.ts` and `src/we/ird.ts`\n * as two paths), so it is never rewritten.\n */\nexport function normalizeLookup(value) {\n let path = text(value).trim();\n while (path.startsWith('./')) path = path.slice(2);\n return path;\n}\n\n/**\n * Look a path up in the table. Returns `{ kind, entry?, message? }` where `kind` is `hit`,\n * `miss`, `unavailable`, `absent`, or `empty` (nothing typed); `message` is the contract\n * wording for every kind but `hit` and `empty`.\n */\nexport function lookupPath(table, commit, value) {\n const state = table && table.state;\n if (state === 'unavailable') return { kind: 'unavailable', message: PATH_TEXT.unavailable };\n if (state !== 'available') return { kind: 'absent', message: PATH_TEXT.absent };\n const path = normalizeLookup(value);\n if (path === '') return { kind: 'empty' };\n const entry = list(table.entries).find((candidate) => candidate && candidate.path === path);\n if (entry) return { kind: 'hit', entry };\n if (table.capped === true) return { kind: 'miss', message: PATH_TEXT.missCapped };\n return { kind: 'miss', message: commit ? PATH_TEXT.missAtCommit(commit) : PATH_TEXT.missInIndex };\n}\n\n/** Error-severity issues, in data order. */\nexport function errorIssues(data) {\n return list(data && data.issues).filter((issue) => issue && issue.severity === 'error');\n}\n\n/** Counts by status, in the fixed status order, including zeros. */\nexport function statusCounts(records) {\n const counts = STATUSES.map((status) => ({ status, count: 0 }));\n let other = 0;\n for (const record of list(records)) {\n const row = counts.find((candidate) => candidate.status === record.status);\n if (row) row.count += 1;\n else other += 1;\n }\n return other > 0 ? counts.concat([{ status: 'unknown', count: other }]) : counts;\n}\n\n/* ------------------------------------------------------------------ *\n * The page\n * ------------------------------------------------------------------ */\n\n/**\n * Start the portal. `env` carries `document`, `window`, and `fetch`; nothing else of the\n * browser is reached. Returns a promise that settles after the first render, with a handle\n * the tests use to re-render a route.\n */\nexport function startPortal(env) {\n const doc = env.document;\n const win = env.window;\n let data = null;\n /**\n * The graph's kinds filter. It opens on supersession alone, as the SDK's graph does and as\n * clause 4 names the view; relatesTo and conflictsWith are opt-in, because on a real corpus\n * all three kinds together draw an unreadable tangle.\n */\n const shownKinds = new Set(DEFAULT_KINDS);\n /** The path last typed in the path view, kept across route changes. */\n let typedPath = '';\n /** Graph view state. */\n let view = null;\n let drawn = null;\n let viewport = { width: 0, height: 0 };\n let drag = null;\n let dragged = false;\n let nodeElements = [];\n /** Redraws the current graph's lines for the kinds filter, or null when no graph is drawn. */\n let redrawEdges = null;\n\n const $ = (id) => doc.getElementById(id);\n\n /** Every HTML node is built here; text only ever goes through textContent. */\n function el(tag, className, content) {\n const node = doc.createElement(tag);\n if (className) node.className = className;\n if (content !== undefined && content !== null) node.textContent = text(content);\n return node;\n }\n /** Every SVG node is built here. Classes go through setAttribute, never className. */\n function svg(tag, attrs, content) {\n const node = doc.createElementNS(SVG_NS, tag);\n for (const name of Object.keys(attrs || {})) node.setAttribute(name, text(attrs[name]));\n if (content !== undefined && content !== null) node.textContent = text(content);\n return node;\n }\n function textNode(value) {\n return doc.createTextNode(text(value));\n }\n function clear(node) {\n while (node.firstChild) node.removeChild(node.firstChild);\n }\n function link(label, href, className) {\n const node = el('a', className, label);\n node.setAttribute('href', href);\n return node;\n }\n /** A record id as a link to its route, or plain text when the id is not routable. */\n function recordLink(id, label) {\n const href = recordHref(id);\n return href ? link(label === undefined ? id : label, href, 'record-link mono') : el('span', 'mono', label === undefined ? id : label);\n }\n function statusClass(status) {\n return 'status-' + (own(STATUS_TONE, status) ? status : 'unknown');\n }\n function badge(label, tone, glyph, extra) {\n return el('span', 'badge tone-' + (tone || 'neutral') + ' glyph-' + (glyph || 'ask') + (extra ? ' ' + extra : ''), label);\n }\n function statusBadge(status) {\n return badge(text(status) || 'unknown', own(STATUS_TONE, status), own(STATUS_GLYPH, status));\n }\n function facts(rows) {\n const dl = el('dl', 'facts');\n for (const [label, value] of rows) {\n dl.appendChild(el('dt', null, label));\n const dd = el('dd');\n if (value && typeof value === 'object' && 'appendChild' in value) dd.appendChild(value);\n else dd.textContent = text(value);\n dl.appendChild(dd);\n }\n return dl;\n }\n function callout(tone, glyph, title, lines) {\n const box = el('div', 'callout tone-' + tone);\n box.appendChild(el('p', 'callout-title glyph-' + glyph, title));\n for (const line of lines || []) box.appendChild(typeof line === 'string' ? el('p', null, line) : line);\n return box;\n }\n function heading(level, label) {\n const node = el(level, 'view-title', label);\n node.setAttribute('tabindex', '-1');\n return node;\n }\n\n /** The newest record per id, as the SDK's `get` resolves a repeated id. */\n function recordsById() {\n const map = new Map();\n for (const record of list(data.records)) map.set(record.id, record);\n return map;\n }\n\n /* ---------------------------- chrome ---------------------------- */\n\n function renderChrome() {\n const corpus = $('corpus');\n if (corpus) corpus.textContent = text(data.dir) + ' · ' + plural(list(data.records).length, 'record');\n const banner = $('banner');\n if (banner) {\n clear(banner);\n const errors = errorIssues(data);\n if (errors.length > 0) {\n const box = callout('red', 'cross', 'The corpus has ' + plural(errors.length, 'error') + '.', [\n 'Every record that loaded is shown, and the data file carries every error. Governance by path is withheld until the errors are fixed.',\n ]);\n box.setAttribute('role', 'alert');\n box.appendChild(issueList(errors));\n banner.appendChild(box);\n }\n }\n const footer = $('footer');\n if (footer) {\n clear(footer);\n footer.appendChild(\n el(\n 'p',\n null,\n 'Read-only decision portal, built from ' + text(data.dir) + ' at commit ' + shortCommit(data.commit) +\n '; queue SLA states as of ' + text(data.asOf) + '. It has no form and changes nothing.',\n ),\n );\n }\n }\n\n function issueList(issues) {\n const ul = el('ul', 'issues');\n for (const issue of issues) {\n const li = el('li', 'issue');\n li.appendChild(el('span', 'chip-kind', own(SEVERITY_TEXT, issue.severity) || 'issue'));\n li.appendChild(el('code', null, issue.code));\n if (issue.path !== null && issue.path !== undefined) li.appendChild(el('span', 'mono issue-path', issue.path));\n li.appendChild(el('span', 'issue-message', issue.message));\n ul.appendChild(li);\n }\n return ul;\n }\n\n function markNav(route) {\n for (const [id, viewName] of [['nav-overview', 'overview'], ['nav-graph', 'graph'], ['nav-queue', 'queue'], ['nav-paths', 'paths']]) {\n const node = $(id);\n if (!node) continue;\n if (route.view === viewName) node.setAttribute('aria-current', 'page');\n else node.removeAttribute('aria-current');\n }\n }\n\n /* --------------------------- overview --------------------------- */\n\n function renderOverview(main) {\n main.appendChild(heading('h2', 'Records'));\n const counts = el('ul', 'status-counts plain');\n counts.setAttribute('aria-label', 'Records by status');\n for (const row of statusCounts(data.records)) {\n const li = el('li', 'status-count');\n const shown = statusBadge(row.status);\n shown.textContent = row.status + ': ' + row.count;\n li.appendChild(shown);\n counts.appendChild(li);\n }\n main.appendChild(counts);\n\n const others = list(data.issues).filter((issue) => issue && issue.severity !== 'error');\n if (others.length > 0) {\n const details = el('details', 'notes');\n details.appendChild(el('summary', null, 'Warnings and notes (' + others.length + ')'));\n details.appendChild(issueList(others));\n main.appendChild(details);\n }\n\n const filterRow = el('div', 'filter-row');\n const label = el('label', 'filter-label', 'Filter ');\n const input = el('input', 'filter-input');\n input.setAttribute('type', 'search');\n input.setAttribute('aria-label', 'Filter records by id, title, status, or tag');\n input.setAttribute('placeholder', 'id, title, status, or tag');\n label.appendChild(input);\n filterRow.appendChild(label);\n const shownCount = el('span', 'muted small');\n filterRow.appendChild(shownCount);\n main.appendChild(filterRow);\n\n const cards = el('ul', 'cards record-list');\n main.appendChild(cards);\n const all = list(data.records);\n const draw = () => {\n clear(cards);\n const needle = text(input.value).trim().toLowerCase();\n const matching = all.filter((record) => {\n if (needle === '') return true;\n const hay = [record.id, record.title, record.status].concat(list(record.tags)).map((part) => text(part).toLowerCase());\n return hay.some((part) => part.includes(needle));\n });\n shownCount.textContent = matching.length === all.length ? plural(all.length, 'record') : matching.length + ' of ' + plural(all.length, 'record');\n if (all.length === 0) {\n const empty = el('li', 'empty');\n empty.appendChild(el('strong', null, 'No records'));\n empty.appendChild(el('span', null, 'The corpus directory holds no record that loaded.'));\n cards.appendChild(empty);\n return;\n }\n for (const record of matching) cards.appendChild(recordCard(record));\n };\n input.addEventListener('input', draw);\n draw();\n }\n\n function recordCard(record) {\n const li = el('li', 'card record-card');\n const head = el('div', 'card-head');\n head.appendChild(recordLink(record.id));\n head.appendChild(statusBadge(record.status));\n head.appendChild(el('span', 'muted small', record.date));\n li.appendChild(head);\n const href = recordHref(record.id);\n li.appendChild(href ? link(record.title, href, 'record-title') : el('span', 'record-title', record.title));\n if (list(record.tags).length > 0) {\n const chips = el('div', 'chips');\n for (const tag of list(record.tags)) chips.appendChild(el('span', 'chip', tag));\n li.appendChild(chips);\n }\n return li;\n }\n\n /* ---------------------------- record ---------------------------- */\n\n function renderRecord(main, id) {\n const record = recordsById().get(id);\n if (!record) {\n main.appendChild(heading('h2', 'Record not found'));\n main.appendChild(el('p', null, 'This bundle has no record with id ' + id + '.'));\n main.appendChild(link('Back to the records', '#/'));\n return;\n }\n const head = el('div', 'record-head');\n head.appendChild(el('span', 'record-id', record.id));\n head.appendChild(statusBadge(record.status));\n head.appendChild(badge(own(STANDING_TEXT, record.standing) || 'unknown standing', 'neutral', 'info'));\n main.appendChild(head);\n main.appendChild(heading('h2', record.title));\n\n const rows = [\n ['Date', record.date],\n ['File', el('span', 'mono', record.path)],\n ];\n if (list(record.tags).length > 0) {\n const chips = el('span', 'chips inline');\n for (const tag of list(record.tags)) chips.appendChild(el('span', 'chip', tag));\n rows.push(['Tags', chips]);\n }\n if (list(record.supersedes).length > 0) rows.push(['Supersedes', idList(record.supersedes)]);\n if (record.supersededBy !== null && record.supersededBy !== undefined) rows.push(['Superseded by', recordLink(record.supersededBy)]);\n const edges = list(data.graph && data.graph.edges);\n for (const kind of ['relatesTo', 'conflictsWith']) {\n const outgoing = edges.filter((edge) => edge.kind === kind && edge.from === record.id).map((edge) => edge.to);\n const incoming = edges.filter((edge) => edge.kind === kind && edge.to === record.id).map((edge) => edge.from);\n if (outgoing.length > 0) rows.push([kind === 'relatesTo' ? 'Relates to' : 'Conflicts with', idList(outgoing)]);\n if (incoming.length > 0) rows.push([kind === 'relatesTo' ? 'Related from' : 'In conflict from', idList(incoming)]);\n }\n main.appendChild(facts(rows));\n\n const article = el('article', 'record-body');\n renderBody(article, list(record.body));\n main.appendChild(article);\n }\n\n function idList(ids) {\n const span = el('span', 'id-list');\n list(ids).forEach((id, index) => {\n if (index > 0) span.appendChild(textNode(', '));\n span.appendChild(recordLink(id));\n });\n return span;\n }\n\n /* ---------------------------- body ------------------------------ */\n\n function renderBody(container, blocks) {\n const slugs = new Map();\n for (const block of blocks) container.appendChild(renderBlock(block, slugs));\n }\n\n function renderBlock(block, slugs) {\n const type = block && block.type;\n if (type === 'heading') {\n const level = Number.isInteger(block.level) && block.level >= 1 && block.level <= 6 ? block.level : 6;\n // The record title is the page's heading, so body headings sit one level below it.\n const node = el('h' + Math.min(6, level + 2), 'md-h md-h' + level);\n appendInlines(node, list(block.children), slugs);\n if (typeof block.slug === 'string' && block.slug !== '' && !slugs.has(block.slug)) slugs.set(block.slug, node);\n return node;\n }\n if (type === 'paragraph') {\n const node = el('p');\n appendInlines(node, list(block.children), slugs);\n return node;\n }\n if (type === 'list') {\n const node = el(block.ordered === true ? 'ol' : 'ul', 'md-list');\n if (block.ordered === true && Number.isSafeInteger(block.start) && block.start >= 0 && block.start !== 1) {\n node.setAttribute('start', String(block.start));\n }\n for (const item of list(block.items)) {\n const li = el('li');\n for (const child of list(item)) li.appendChild(renderBlock(child, slugs));\n node.appendChild(li);\n }\n return node;\n }\n if (type === 'code') {\n const figure = el('div', 'md-code');\n if (typeof block.lang === 'string' && block.lang !== '') figure.appendChild(el('span', 'md-code-lang', block.lang));\n const pre = el('pre');\n pre.appendChild(el('code', null, block.text));\n figure.appendChild(pre);\n return figure;\n }\n if (type === 'table') {\n const wrap = el('div', 'md-table-wrap');\n const table = el('table', 'md-table');\n const align = list(block.align);\n const row = (cells, tag) => {\n const tr = el('tr');\n list(cells).forEach((cell, index) => {\n const td = el(tag, own(ALIGN_CLASS, align[index]) || null);\n appendInlines(td, list(cell), slugs);\n tr.appendChild(td);\n });\n return tr;\n };\n const thead = el('thead');\n thead.appendChild(row(block.header, 'th'));\n table.appendChild(thead);\n const tbody = el('tbody');\n for (const cells of list(block.rows)) tbody.appendChild(row(cells, 'td'));\n table.appendChild(tbody);\n wrap.appendChild(table);\n return wrap;\n }\n if (type === 'blockquote') {\n const node = el('blockquote', 'md-quote');\n for (const child of list(block.children)) node.appendChild(renderBlock(child, slugs));\n return node;\n }\n // The tree is closed (contract.ts); anything else is shown as nothing but its text.\n return el('p', 'md-unknown', block && typeof block.text === 'string' ? block.text : '');\n }\n\n function appendInlines(parent, inlines, slugs) {\n for (const inline of inlines) parent.appendChild(renderInline(inline, slugs));\n }\n\n function renderInline(inline, slugs) {\n const type = inline && inline.type;\n if (type === 'text') return textNode(inline.text);\n if (type === 'inlineCode') return el('code', null, inline.text);\n if (type === 'emphasis' || type === 'strong') {\n const node = el(type === 'strong' ? 'strong' : 'em');\n appendInlines(node, list(inline.children), slugs);\n return node;\n }\n if (type === 'link') {\n const children = list(inline.children);\n if (inline.kind === 'external') {\n const href = safeExternalHref(inline.href);\n if (href) {\n const node = el('a', 'md-link external');\n node.setAttribute('href', href);\n node.setAttribute('rel', 'noopener noreferrer nofollow');\n appendInlines(node, children, slugs);\n return node;\n }\n } else if (inline.kind === 'record') {\n const href = recordHref(inline.id);\n if (href) {\n const node = el('a', 'md-link record-link');\n node.setAttribute('href', href);\n appendInlines(node, children, slugs);\n return node;\n }\n } else if (inline.kind === 'fragment' && typeof inline.fragment === 'string') {\n // A hash route owns the URL fragment, so a same-record link scrolls instead.\n const fragment = inline.fragment;\n const node = el('a', 'md-link fragment');\n node.setAttribute('role', 'link');\n node.setAttribute('tabindex', '0');\n const go = (event) => {\n if (event && typeof event.preventDefault === 'function') event.preventDefault();\n const target = slugs.get(fragment);\n if (target && typeof target.scrollIntoView === 'function') target.scrollIntoView();\n };\n node.addEventListener('click', go);\n node.addEventListener('keydown', (event) => {\n if (event && event.key === 'Enter') go(event);\n });\n appendInlines(node, children, slugs);\n return node;\n }\n // A link that fails its check is shown as its words, never as a link.\n const span = el('span', 'md-link-text');\n appendInlines(span, children, slugs);\n return span;\n }\n return textNode(inline && typeof inline.text === 'string' ? inline.text : '');\n }\n\n /* ---------------------------- graph ----------------------------- */\n\n function renderGraph(main) {\n main.appendChild(heading('h2', 'Decision graph'));\n const graph = data.graph || { nodes: [], edges: [] };\n const nodes = list(graph.nodes);\n const edges = list(graph.edges);\n\n const filters = el('fieldset', 'filters');\n filters.appendChild(el('legend', null, 'Relationship kinds'));\n for (const kind of KINDS) {\n const label = el('label', 'toggle');\n const box = el('input');\n box.setAttribute('type', 'checkbox');\n box.checked = shownKinds.has(kind);\n box.addEventListener('change', () => {\n if (box.checked) shownKinds.add(kind);\n else shownKinds.delete(kind);\n // Only the lines change: the frame, the checkboxes (and so focus), and the view stay.\n if (redrawEdges) redrawEdges();\n });\n label.appendChild(box);\n label.appendChild(textNode(' ' + own(KIND_TEXT, kind)));\n filters.appendChild(label);\n }\n main.appendChild(filters);\n main.appendChild(legend());\n\n // Positions come from every edge, so unticking a kind hides lines without moving boxes.\n const layout = layoutBoard({ nodes, edges });\n if (layout.mode === 'summary') {\n const box = el('div', 'summary');\n const why =\n layout.reason === 'nodes'\n ? 'more than the ' + NODE_LIMIT + ' records the graph draws legibly'\n : 'a drawing wider or taller than ' + MAX_EXTENT + ' px';\n box.appendChild(\n el('p', 'summary-lede', plural(layout.totalNodes, 'record') + ' and ' + plural(layout.totalEdges, 'relationship') + ': ' + why + ', so the graph shows counts instead.'),\n );\n const counts = el('ul', 'status-counts plain');\n for (const row of list(layout.byStatus)) {\n const li = el('li', 'status-count');\n const shown = statusBadge(row.status);\n shown.textContent = text(row.status) + ': ' + text(row.count);\n li.appendChild(shown);\n counts.appendChild(li);\n }\n box.appendChild(counts);\n box.appendChild(link('Browse the records instead', '#/'));\n main.appendChild(box);\n drawn = null;\n return;\n }\n if (nodes.length === 0) {\n const empty = el('div', 'empty');\n empty.appendChild(el('strong', null, 'No records to draw'));\n empty.appendChild(el('span', null, 'The corpus holds no record that loaded.'));\n main.appendChild(empty);\n drawn = null;\n return;\n }\n\n const byId = new Map();\n for (const node of nodes) byId.set(node.id, node);\n const at = new Map();\n for (const position of layout.positions) at.set(position.id, position);\n\n main.appendChild(zoomBar());\n const wanted = layout.height * 0.75 + 32;\n const frame = el('div', 'graph-frame ' + (wanted <= 240 ? 'h-s' : wanted <= 360 ? 'h-m' : wanted <= 480 ? 'h-l' : 'h-xl'));\n frame.setAttribute('tabindex', '0');\n frame.setAttribute('role', 'region');\n frame.setAttribute('aria-label', 'Decision graph: ' + plural(nodes.length, 'record') + '. Use + and − to zoom, 0 to fit, arrows to pan.');\n frame.addEventListener('keydown', graphKeys);\n const root = svg('svg', {\n width: '100%',\n height: '100%',\n preserveAspectRatio: 'xMidYMid meet',\n viewBox: '0 0 ' + layout.width + ' ' + layout.height,\n role: 'group',\n 'aria-label': 'Decision graph: ' + plural(nodes.length, 'record'),\n class: 'graph-svg',\n });\n const defs = svg('defs');\n const marker = svg('marker', { id: 'arrow-supersedes', viewBox: '0 0 10 10', refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: 'auto-start-reverse' });\n marker.appendChild(svg('path', { d: 'M 0 0 L 10 5 L 0 10 z', class: 'arrow' }));\n defs.appendChild(marker);\n root.appendChild(defs);\n\n // Edges are painted first, beneath the records, and every box has an opaque base, so a\n // line passing behind a record never crosses its id or title.\n const edgeGroup = svg('g', { class: 'edges' });\n const drawEdges = () => {\n clear(edgeGroup);\n for (const edge of edges) {\n if (!own(KIND_TEXT, edge.kind) || !shownKinds.has(edge.kind)) continue;\n const from = at.get(edge.from);\n const to = at.get(edge.to);\n if (!from || !to) continue;\n const start = edgePoint(from, to);\n const end = edgePoint(to, from);\n const attrs = { x1: start.x, y1: start.y, x2: end.x, y2: end.y, class: 'edge edge-' + edge.kind };\n if (edge.kind === 'supersedes') attrs['marker-end'] = 'url(#arrow-supersedes)';\n const line = svg('line', attrs);\n line.appendChild(svg('title', {}, text(edge.from) + ' ' + own(KIND_TEXT, edge.kind) + ' ' + text(edge.to)));\n edgeGroup.appendChild(line);\n }\n if (drawn) drawn.edges = edges.filter((edge) => shownKinds.has(edge.kind));\n };\n drawEdges();\n root.appendChild(edgeGroup);\n\n const nodeGroup = svg('g', { class: 'nodes' });\n nodeElements = [];\n for (const position of layout.positions) {\n const node = byId.get(position.id);\n if (!node) continue;\n const id = text(node.id);\n const status = text(node.status);\n const g = svg('g', {\n class: 'node ' + statusClass(status),\n transform: 'translate(' + position.x + ' ' + position.y + ')',\n tabindex: 0,\n role: 'link',\n 'aria-label': id + ', ' + status + ': ' + text(node.title),\n });\n g.appendChild(svg('title', {}, id + ' (' + status + '): ' + text(node.title)));\n g.appendChild(svg('rect', { width: NODE_WIDTH, height: NODE_HEIGHT, rx: 8, class: 'node-base' }));\n g.appendChild(svg('rect', { width: NODE_WIDTH, height: NODE_HEIGHT, rx: 8, class: 'node-box ' + statusClass(status) }));\n g.appendChild(svg('rect', { width: 5, height: NODE_HEIGHT - 12, x: 0, y: 6, rx: 2, class: 'node-stripe ' + statusClass(status) }));\n g.appendChild(svg('text', { x: 14, y: 22, class: 'node-id' }, id + ' · ' + status));\n g.appendChild(svg('text', { x: 14, y: 41, class: 'node-title' }, clipText(node.title, TITLE_CHARS)));\n const open = () => {\n const href = recordHref(id);\n if (href) win.location.hash = href;\n };\n g.addEventListener('click', () => {\n if (dragged) {\n dragged = false;\n return;\n }\n open();\n });\n g.addEventListener('keydown', (event) => {\n if (event && (event.key === 'Enter' || event.key === ' ')) {\n if (typeof event.preventDefault === 'function') event.preventDefault();\n open();\n }\n });\n g.addEventListener('focus', () => highlight(id));\n g.addEventListener('mouseenter', () => highlight(id));\n g.addEventListener('blur', () => highlight(null));\n g.addEventListener('mouseleave', () => highlight(null));\n nodeElements.push({ id, status, g });\n nodeGroup.appendChild(g);\n }\n root.appendChild(nodeGroup);\n frame.appendChild(root);\n main.appendChild(frame);\n wirePointer(root);\n\n drawn = { root, width: layout.width, height: layout.height, edges: edges.filter((edge) => shownKinds.has(edge.kind)) };\n redrawEdges = drawEdges;\n const size = measure(frame, layout);\n view = initialView(drawn.width, drawn.height, size.width, size.height);\n viewport = size;\n applyView();\n }\n\n /** Mark the record under the pointer or focus and its neighbors; dim the other boxes, never their labels. */\n function highlight(id) {\n const near = new Set();\n if (id !== null && drawn) {\n near.add(id);\n for (const edge of drawn.edges) {\n if (edge.from === id) near.add(edge.to);\n if (edge.to === id) near.add(edge.from);\n }\n }\n for (const item of nodeElements) {\n const extra = id === null ? '' : item.id === id ? ' selected' : near.has(item.id) ? ' neighbor' : ' dim';\n item.g.setAttribute('class', 'node ' + statusClass(item.status) + extra);\n }\n }\n\n function edgePoint(from, to) {\n const cx = from.x + NODE_WIDTH / 2;\n const cy = from.y + NODE_HEIGHT / 2;\n const dx = to.x + NODE_WIDTH / 2 - cx;\n const dy = to.y + NODE_HEIGHT / 2 - cy;\n if (dx === 0 && dy === 0) return { x: cx, y: cy };\n const sx = dx === 0 ? Infinity : NODE_WIDTH / 2 / Math.abs(dx);\n const sy = dy === 0 ? Infinity : NODE_HEIGHT / 2 / Math.abs(dy);\n const s = Math.min(sx, sy);\n return { x: cx + dx * s, y: cy + dy * s };\n }\n\n function legend() {\n const box = el('div', 'legend');\n const statusGroup = el('ul', 'legend-list plain');\n statusGroup.setAttribute('aria-label', 'Status');\n for (const status of STATUSES) {\n const li = el('li', 'legend-item');\n const swatch = svg('svg', { width: 18, height: 12, 'aria-hidden': 'true', class: 'swatch' });\n swatch.appendChild(svg('rect', { x: 1, y: 1, width: 16, height: 10, rx: 3, class: 'node-box ' + statusClass(status) }));\n li.appendChild(swatch);\n li.appendChild(el('span', null, status));\n statusGroup.appendChild(li);\n }\n box.appendChild(statusGroup);\n const kindGroup = el('ul', 'legend-list plain');\n kindGroup.setAttribute('aria-label', 'Relationship');\n for (const kind of KINDS) {\n const li = el('li', 'legend-item');\n const sample = svg('svg', { width: 30, height: 12, 'aria-hidden': 'true', class: 'swatch' });\n sample.appendChild(svg('line', { x1: 1, y1: 6, x2: kind === 'supersedes' ? 24 : 29, y2: 6, class: 'edge edge-' + kind }));\n if (kind === 'supersedes') sample.appendChild(svg('path', { d: 'M 23 2 L 29 6 L 23 10 z', class: 'arrow' }));\n li.appendChild(sample);\n li.appendChild(el('span', null, own(KIND_TEXT, kind) + ' (' + own(KIND_LOOK, kind) + ')'));\n kindGroup.appendChild(li);\n }\n box.appendChild(kindGroup);\n return box;\n }\n\n function measure(frame, layout) {\n const rect = frame && typeof frame.getBoundingClientRect === 'function' ? frame.getBoundingClientRect() : null;\n if (rect && rect.width > 0 && rect.height > 0) return { width: rect.width, height: rect.height };\n return { width: layout.width || NODE_WIDTH, height: Math.min(layout.height || NODE_HEIGHT, 560) };\n }\n\n function applyView() {\n if (drawn && view) drawn.root.setAttribute('viewBox', viewBoxOf(view));\n }\n\n function zoomBy(factor, fx, fy) {\n if (!drawn || !view) return;\n const floor = viewport.width / fitView(drawn.width, drawn.height, viewport.width, viewport.height).w;\n view = clampView(zoomView(view, factor, fx, fy, viewport.width, floor), drawn.width, drawn.height);\n applyView();\n }\n\n function panBy(dx, dy) {\n if (!drawn || !view) return;\n view = clampView(panView(view, dx, dy, viewport.width), drawn.width, drawn.height);\n applyView();\n }\n\n function resetView() {\n if (!drawn) return;\n view = fitView(drawn.width, drawn.height, viewport.width, viewport.height);\n applyView();\n }\n\n function graphKeys(event) {\n if (!event) return;\n const tag = event.target && event.target.tagName ? String(event.target.tagName).toLowerCase() : '';\n if (tag === 'input' || tag === 'button' || tag === 'textarea') return;\n const step = 60;\n let handled = true;\n if (event.key === '+' || event.key === '=') zoomBy(1.25, 0.5, 0.5);\n else if (event.key === '-' || event.key === '_') zoomBy(0.8, 0.5, 0.5);\n else if (event.key === '0') resetView();\n else if (event.key === 'ArrowLeft') panBy(step, 0);\n else if (event.key === 'ArrowRight') panBy(-step, 0);\n else if (event.key === 'ArrowUp') panBy(0, step);\n else if (event.key === 'ArrowDown') panBy(0, -step);\n else handled = false;\n if (handled && typeof event.preventDefault === 'function') event.preventDefault();\n }\n\n /** Pointer drag pans; a drag never also opens the record it ended on. */\n function wirePointer(root) {\n root.addEventListener('pointerdown', (event) => {\n if (!event || (event.button !== undefined && event.button !== 0)) return;\n drag = { x: event.clientX, y: event.clientY, id: event.pointerId };\n dragged = false;\n });\n root.addEventListener('pointermove', (event) => {\n if (!drag || !event) return;\n const dx = event.clientX - drag.x;\n const dy = event.clientY - drag.y;\n if (!dragged && Math.abs(dx) + Math.abs(dy) < 4) return;\n if (!dragged && typeof root.setPointerCapture === 'function') {\n try {\n root.setPointerCapture(drag.id);\n } catch {\n // Not capturable; the drag still pans.\n }\n }\n dragged = true;\n root.setAttribute('class', 'graph-svg dragging');\n drag.x = event.clientX;\n drag.y = event.clientY;\n panBy(dx, dy);\n });\n const end = () => {\n drag = null;\n root.setAttribute('class', 'graph-svg');\n };\n root.addEventListener('pointerup', end);\n root.addEventListener('pointercancel', end);\n // Only Ctrl or Cmd with the wheel (a trackpad pinch) zooms; a plain wheel scrolls the page.\n root.addEventListener(\n 'wheel',\n (event) => {\n if (!event || !(event.ctrlKey || event.metaKey)) return;\n if (typeof event.preventDefault === 'function') event.preventDefault();\n const rect = typeof root.getBoundingClientRect === 'function' ? root.getBoundingClientRect() : null;\n const fx = rect && rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0.5;\n const fy = rect && rect.height > 0 ? (event.clientY - rect.top) / rect.height : 0.5;\n zoomBy(event.deltaY < 0 ? 1.1 : 1 / 1.1, fx, fy);\n },\n { passive: false },\n );\n }\n\n function zoomBar() {\n const bar = el('div', 'zoom-bar');\n const make = (label, aria, action) => {\n const node = el('button', 'secondary', label);\n node.setAttribute('type', 'button');\n node.setAttribute('aria-label', aria);\n node.addEventListener('click', action);\n return node;\n };\n bar.appendChild(make('−', 'Zoom out', () => zoomBy(0.8, 0.5, 0.5)));\n bar.appendChild(make('+', 'Zoom in', () => zoomBy(1.25, 0.5, 0.5)));\n bar.appendChild(make('Fit', 'Fit the whole graph', () => resetView()));\n bar.appendChild(el('span', 'hint', 'Drag to pan · Ctrl or Cmd + wheel to zoom · keys + − 0 and arrows · select a record to open it'));\n return bar;\n }\n\n function onResize() {\n if (!drawn || !view) return;\n const frame = drawn.root;\n const rect = typeof frame.getBoundingClientRect === 'function' ? frame.getBoundingClientRect() : null;\n if (!rect || !(rect.width > 0) || !(rect.height > 0)) return;\n if (rect.width === viewport.width && rect.height === viewport.height) return;\n view = resizeView(view, rect.width, rect.height, viewport.width);\n viewport = { width: rect.width, height: rect.height };\n applyView();\n }\n\n /* ---------------------------- queue ----------------------------- */\n\n function renderQueue(main) {\n main.appendChild(heading('h2', 'Review queue'));\n const entries = list(data.queue);\n main.appendChild(\n el('p', 'lede', plural(entries.length, 'proposed record') + ' in review, as of ' + text(data.asOf) + '. These are the review facts each record carries; the portal adds no verdict to them.'),\n );\n if (entries.length === 0) {\n const empty = el('div', 'empty');\n empty.appendChild(el('strong', null, 'Nothing in review'));\n empty.appendChild(el('span', null, 'No record is proposed.'));\n main.appendChild(empty);\n return;\n }\n const cards = el('ul', 'cards queue-list');\n for (const entry of entries) cards.appendChild(queueCard(entry));\n main.appendChild(cards);\n }\n\n function queueCard(entry) {\n const li = el('li', 'card queue-row');\n const head = el('div', 'card-head');\n head.appendChild(recordLink(entry.id));\n head.appendChild(badge(own(SLA_TEXT, entry.slaState) || 'unknown SLA state', own(SLA_TONE, entry.slaState), own(SLA_GLYPH, entry.slaState)));\n li.appendChild(head);\n li.appendChild(el('p', 'record-title', entry.title));\n\n const approvals = Number.isSafeInteger(entry.approvalCount) ? entry.approvalCount : 0;\n const quorum = Number.isSafeInteger(entry.quorum) ? entry.quorum : null;\n const approvalText = quorum === null ? plural(approvals, 'approval') + ', no quorum set' : approvals + ' of ' + quorum + ' approvals';\n const unresolved = Number.isSafeInteger(entry.unresolvedObjectionCount) ? entry.unresolvedObjectionCount : 0;\n const resolved = Number.isSafeInteger(entry.resolvedObjectionCount) ? entry.resolvedObjectionCount : 0;\n const routing = list(entry.routingTargets);\n const routingNode = el('span', 'chips inline');\n if (routing.length === 0) routingNode.textContent = 'none declared';\n for (const target of routing) routingNode.appendChild(el('span', 'chip mono', target));\n const findings = Number.isSafeInteger(entry.itemFindingCount) ? entry.itemFindingCount : 0;\n\n li.appendChild(\n facts([\n ['Approvals', approvalText],\n ['Objections', unresolved + ' unresolved, ' + resolved + ' resolved'],\n ['SLA state', own(SLA_TEXT, entry.slaState) || 'unknown'],\n ['Deadline', entry.deadline === null || entry.deadline === undefined ? 'none' : entry.deadline],\n ['Tier', own(TIER_TEXT, entry.tier) || 'none declared'],\n ['Routing', routingNode],\n ['Queue findings', String(findings)],\n ['File', el('span', 'mono', entry.path)],\n ]),\n );\n return li;\n }\n\n /* ---------------------------- paths ----------------------------- */\n\n function renderPaths(main) {\n main.appendChild(heading('h2', 'Governance by path'));\n const table = data.pathTable || { state: 'absent' };\n if (table.state === 'unavailable') {\n const box = callout('red', 'cross', 'Path lookup withheld', [PATH_TEXT.unavailable]);\n box.setAttribute('role', 'status');\n main.appendChild(box);\n return;\n }\n if (table.state !== 'available') {\n const route = lookupRouteOf(data);\n if (route === null) {\n main.appendChild(callout('neutral', 'info', 'No path table', [PATH_TEXT.absent]));\n return;\n }\n // ADR-0055 clause 9: point at the server's own route, as text. No link and no request,\n // so the renderer keeps one network path (its data file) and one CSP on both hosts.\n const [lead, template, between, example] = PATH_TEXT.lookupRoute(route);\n main.appendChild(\n callout('neutral', 'info', 'No path table', [\n PATH_TEXT.absentServed,\n el('p', null, lead),\n el('p', 'mono path-route', template),\n el('p', null, between),\n el('p', 'mono path-route', example),\n ]),\n );\n return;\n }\n const entries = list(table.entries);\n const scope = data.commit ? 'at commit ' + shortCommit(data.commit) : 'in the index (no commits yet)';\n main.appendChild(\n el(\n 'p',\n 'lede',\n 'A lookup in a table built from the tracked files ' + scope + '. ' + plural(table.governedPathCount, 'tracked path') +\n ' are reached by at least one decision. The table cannot answer for a branch, an uncommitted file, or a path added since the build.',\n ),\n );\n if (table.capped === true) {\n main.appendChild(\n callout('yellow', 'bang', 'This table is capped', [\n 'It keeps ' + entries.length + ' of ' + plural(table.governedPathCount, 'governed path') + ' (at most ' + text(table.caps && table.caps.count) + ' entries and ' + text(table.caps && table.caps.bytes) + ' bytes). A path missing from it may still be governed.',\n ]),\n );\n }\n\n const row = el('div', 'filter-row');\n const label = el('label', 'filter-label', 'Path ');\n const input = el('input', 'filter-input mono');\n input.setAttribute('type', 'search');\n input.setAttribute('aria-label', 'Repository-relative path to look up');\n input.setAttribute('placeholder', 'src/index.ts');\n input.setAttribute('spellcheck', 'false');\n input.setAttribute('autocomplete', 'off');\n input.value = typedPath;\n label.appendChild(input);\n row.appendChild(label);\n main.appendChild(row);\n\n const result = el('div', 'path-result');\n result.setAttribute('aria-live', 'polite');\n main.appendChild(result);\n const suggestions = el('div', 'path-suggestions');\n main.appendChild(suggestions);\n\n const draw = () => {\n typedPath = text(input.value);\n clear(result);\n clear(suggestions);\n const outcome = lookupPath(table, data.commit, typedPath);\n if (outcome.kind === 'hit') result.appendChild(pathEntry(outcome.entry));\n else if (outcome.kind === 'miss') result.appendChild(callout('neutral', 'info', 'No entry for ' + normalizeLookup(typedPath), [outcome.message]));\n const prefix = normalizeLookup(typedPath);\n const matches = entries.filter((entry) => prefix === '' || entry.path.startsWith(prefix)).slice(0, 50);\n if (matches.length > 0 && outcome.kind !== 'hit') {\n suggestions.appendChild(el('h3', null, prefix === '' ? 'Governed paths (first ' + matches.length + ')' : 'Governed paths starting with this text'));\n const ul = el('ul', 'path-list plain');\n for (const entry of matches) {\n const li = el('li');\n const pick = el('button', 'link', entry.path);\n pick.setAttribute('type', 'button');\n pick.addEventListener('click', () => {\n input.value = entry.path;\n draw();\n });\n li.appendChild(pick);\n ul.appendChild(li);\n }\n suggestions.appendChild(ul);\n }\n };\n input.addEventListener('input', draw);\n draw();\n }\n\n function pathEntry(entry) {\n const box = el('div', 'card path-entry');\n box.appendChild(el('p', 'mono path', entry.path));\n for (const [key, label] of [['governing', 'Governing'], ['activeProposals', 'Active proposals'], ['history', 'History']]) {\n const decisions = list(entry[key]);\n const section = el('div', 'path-group');\n section.appendChild(el('h3', null, label + ' (' + decisions.length + ')'));\n if (decisions.length === 0) {\n section.appendChild(el('p', 'muted small', 'none'));\n } else {\n const ul = el('ul', 'plain');\n for (const decision of decisions) {\n const li = el('li', 'path-decision');\n li.appendChild(recordLink(decision.id));\n const record = recordsById().get(decision.id);\n if (record) li.appendChild(el('span', 'path-decision-title', record.title));\n for (const kind of list(decision.evidence)) li.appendChild(el('span', 'chip', own(EVIDENCE_TEXT, kind) || 'evidence'));\n ul.appendChild(li);\n }\n section.appendChild(ul);\n }\n box.appendChild(section);\n }\n return box;\n }\n\n /* ---------------------------- router ---------------------------- */\n\n function render() {\n const main = $('app');\n if (!main || !data) return;\n const route = parseRoute(win.location && win.location.hash);\n markNav(route);\n clear(main);\n drawn = null;\n redrawEdges = null;\n nodeElements = [];\n if (route.view === 'overview') renderOverview(main);\n else if (route.view === 'record') renderRecord(main, route.id);\n else if (route.view === 'graph') renderGraph(main);\n else if (route.view === 'queue') renderQueue(main);\n else if (route.view === 'paths') renderPaths(main);\n else {\n main.appendChild(heading('h2', 'Page not found'));\n main.appendChild(el('p', null, 'This portal has no view at this address.'));\n main.appendChild(link('Back to the records', '#/'));\n }\n return route;\n }\n\n function onRoute() {\n render();\n const title = $('app') && $('app').firstChild;\n if (title && typeof title.focus === 'function') title.focus();\n if (typeof win.scrollTo === 'function') win.scrollTo(0, 0);\n }\n\n function fail(title, message) {\n const main = $('app');\n if (!main) return;\n clear(main);\n const box = callout('red', 'cross', title, [message]);\n box.setAttribute('role', 'alert');\n main.appendChild(box);\n }\n\n const loaded = Promise.resolve()\n .then(() => env.fetch(DATA_URL, { credentials: 'same-origin' }))\n .then((response) => {\n if (!response || !response.ok) throw new Error('http ' + (response ? response.status : 'none'));\n return response.json();\n })\n .then(\n (json) => {\n if (!json || typeof json !== 'object' || json.version !== SUPPORTED_VERSION) {\n fail(\n 'Unsupported portal data',\n 'This page renders portal-data.json version ' + SUPPORTED_VERSION + '; the file says version ' +\n text(json && json.version) + '. Rebuild the portal with a matching generator.',\n );\n return;\n }\n data = json;\n renderChrome();\n render();\n win.addEventListener('hashchange', onRoute);\n win.addEventListener('resize', onResize);\n },\n () => fail('Could not load the decisions', 'The page could not read ' + DATA_URL + ' next to it. Serve the bundle over HTTP with every file the generator wrote.'),\n );\n\n return loaded.then(() => ({ render, data: () => data }));\n}\n\nif (typeof document !== 'undefined' && typeof window !== 'undefined' && typeof window.fetch === 'function') {\n startPortal({ document, window, fetch: window.fetch.bind(window) });\n}\n"; // ../portal/src/page/index.html var exports_page = {}; @@ -44071,6 +44087,7 @@ var page_default = ` Decision portal + @@ -44099,6 +44116,19 @@ var page_default = ` `; +// ../portal/src/page/icon.svg +var exports_icon = {}; +__export(exports_icon, { + default: () => icon_default +}); +var icon_default = ` + + + + + +`; + // ../portal/src/page/page.css var exports_page2 = {}; __export(exports_page2, { @@ -44234,6 +44264,7 @@ input[type="search"]:focus-visible { outline: 2px solid var(--c-focus); outline- /* Queue and paths */ .queue-row .facts { margin: var(--sp-2) 0 0; } .path-result { margin: var(--sp-3) 0; } +.path-route { overflow-wrap: anywhere; } .path-entry .path { font-weight: 600; overflow-wrap: anywhere; margin: 0 0 var(--sp-2); } .path-group { margin-top: var(--sp-3); } .path-group h3 { margin-bottom: var(--sp-1); } @@ -44264,6 +44295,7 @@ var PORTAL_ASSETS = Object.freeze([ { path: "app.css", contents: `${THEME_CSS} ${PAGE_CSS}` }, { path: "page/app.mjs", contents: textOf(exports_app, "app.mjs") }, + { path: "page/icon.svg", contents: textOf(exports_icon, "icon.svg") }, { path: "vendor/board-layout.mjs", contents: textOf(exports_board_layout, "board-layout.mjs") }, { path: "vendor/board-view.mjs", contents: textOf(exports_board_view, "board-view.mjs") } ]); diff --git a/packages/portal/src/data/build.ts b/packages/portal/src/data/build.ts index 27814d7..b83e475 100644 --- a/packages/portal/src/data/build.ts +++ b/packages/portal/src/data/build.ts @@ -26,6 +26,7 @@ import { type PortalIssue, type PortalPathDecision, type PortalPathEntry, + type PortalPathLookup, type PortalPathTable, type PortalPathTableAvailable, type PortalPathTableCaps, @@ -50,18 +51,31 @@ export interface BuildPortalDataOptions { readonly asOf: string; /** The resolved path table (see `buildPathTable`), `{ state: 'unavailable', … }`, or `{ state: 'absent' }`. */ readonly pathTable: PortalPathTable; + /** + * Where this host answers one path per request, or `null` (the default). Only ADR-0055's + * server passes one, and only beside an `absent` path table. Written as `pathLookup`. + */ + readonly pathLookup?: PortalPathLookup | null; } const ALL_EDGE_KINDS = ['supersedes', 'relatesTo', 'conflictsWith'] as const; const COMMIT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; const CALENDAR_DATE = /^\d{4}-\d{2}-\d{2}$/; +/** + * A page-relative route with no query, fragment, scheme, or root: `./` or `../` steps, then + * plain segments. The page shows it as text, but a host still cannot publish something that + * reads as an absolute URL or carries markup-looking characters. + */ +const LOOKUP_ROUTE = /^(?:\.\.?\/)*(?:[A-Za-z0-9_-][A-Za-z0-9._-]*\/)*[A-Za-z0-9_-][A-Za-z0-9._-]*$/; +const LOOKUP_ROUTE_MAX = 512; /** * Build `portal-data.json` v1 from an SDK handle. Same inputs, byte-identical * {@link serializePortalData} output. * - * Throws `TypeError` for malformed options, and for one inconsistent combination: an - * `available` path table over a corpus with an error-severity issue. With corpus errors the + * Throws `TypeError` for malformed options (a `pathLookup` beside a table that is not + * `absent` among them), and for one inconsistent combination: an `available` path table + * over a corpus with an error-severity issue. With corpus errors the * SDK's `governing` answers every path with empty groups, so such a table would present a * confident "nothing governs this" for every file (clause 6). The caller must pass * `unavailable` (or `absent`) instead. Any `RangeError` from `handle.queue` (a malformed @@ -69,6 +83,7 @@ const CALENDAR_DATE = /^\d{4}-\d{2}-\d{2}$/; */ export function buildPortalData(handle: PortalDataSource, options: BuildPortalDataOptions): PortalData { const { dir, commit, asOf, pathTable } = options; + const pathLookup = pathLookupOf(options.pathLookup ?? null); if (typeof dir !== 'string' || dir.length === 0) throw new TypeError('dir must be a non-empty string'); if (commit !== null && (typeof commit !== 'string' || !COMMIT.test(commit))) { throw new TypeError('commit must be a 40- or 64-character lowercase hex string, or null'); @@ -81,6 +96,9 @@ export function buildPortalData(handle: PortalDataSource, options: BuildPortalDa if (hasCorpusError && table.state === 'available') { throw new TypeError("an available path table is inconsistent with a corpus that has errors; pass state 'unavailable'"); } + if (pathLookup !== null && table.state !== 'absent') { + throw new TypeError("a pathLookup hint is only for a host with no path table; pass pathTable state 'absent'"); + } const recordPaths = new Map(); for (const record of handle.records) recordPaths.set(record.path, record.id); @@ -100,9 +118,20 @@ export function buildPortalData(handle: PortalDataSource, options: BuildPortalDa }, queue: queue.entries.map(queueEntryOf), pathTable: table, + pathLookup, }; } +/** Rebuild the lookup hint from its known field only, after checking the route's shape. */ +function pathLookupOf(lookup: PortalPathLookup | null): PortalPathLookup | null { + if (lookup === null) return null; + const route = (lookup as { route?: unknown } | undefined)?.route; + if (typeof route !== 'string' || route.length > LOOKUP_ROUTE_MAX || !LOOKUP_ROUTE.test(route)) { + throw new TypeError('pathLookup.route must be a page-relative path with no query, fragment, or scheme'); + } + return { route }; +} + function recordOf(record: DecisionRecord, recordPaths: ReadonlyMap): PortalRecord { return { id: record.id, diff --git a/packages/portal/src/data/contract.ts b/packages/portal/src/data/contract.ts index 5275216..17307de 100644 --- a/packages/portal/src/data/contract.ts +++ b/packages/portal/src/data/contract.ts @@ -95,6 +95,27 @@ export interface PortalData { readonly queue: readonly PortalQueueEntry[]; /** Governance by path (clause 6). */ readonly pathTable: PortalPathTable; + /** + * Where this host answers governance for one path at a time, or `null` (the Pages host). + * Added within version 1. Only ADR-0055's server sets it, alongside an `absent` path table + * (clause 12; ADR-0055 clause 9); see {@link PortalPathLookup}. + */ + readonly pathLookup: PortalPathLookup | null; +} + +/** + * A hint, set by a host that has no path table but can answer one path per request + * (ADR-0055's `governing?path=` route). The page shows it as **text** and never fetches it: + * ADR-0054 keeps the renderer free of network calls beyond its own data file, under the + * same CSP on both hosts. Never set beside an `available` or `unavailable` table. + */ +export interface PortalPathLookup { + /** + * The route, relative to the page (e.g. `../../api/v1/corpora/demo/governing`), without a + * query: a caller appends `?path=`. Built by the host from its + * own configuration, not from repository text. Relative so a gateway base path survives. + */ + readonly route: string; } /* ------------------------------------------------------------------ * diff --git a/packages/portal/src/page/app.mjs b/packages/portal/src/page/app.mjs index bc88a7d..48a49bf 100644 --- a/packages/portal/src/page/app.mjs +++ b/packages/portal/src/page/app.mjs @@ -70,8 +70,25 @@ export const PATH_TEXT = { 'Resolution was withheld because the corpus has errors. The errors are listed above; fix them and rebuild the portal to look paths up.', absent: 'This host has no path table, so it cannot say which decisions reach a path. Build the portal from a git work tree to get one, or run adr explain on the path.', + /** The absent state on a host that sets `pathLookup` (ADR-0055's server), in place of `absent`. */ + absentServed: + 'This host has no path table: it holds the decision records, not the source tree, so this page cannot say which decisions reach a path.', + /** The lines naming that host's own route. Text only: the page never requests it. */ + lookupRoute: (route) => [ + 'This server answers for one path at a time, matching affects only and reading no file, at', + route + '?path=', + 'relative to this page. For example:', + route + '?path=src/index.ts', + ], }; +/** The host's `pathLookup` route, when the data file carries a usable one; else `null`. */ +export function lookupRouteOf(data) { + const lookup = data && data.pathLookup; + const route = lookup && typeof lookup === 'object' ? lookup.route : null; + return typeof route === 'string' && route.length > 0 && route.length <= 512 ? route : null; +} + /* ------------------------------------------------------------------ * * Small pure helpers (exported for the tests) * ------------------------------------------------------------------ */ @@ -1015,7 +1032,23 @@ export function startPortal(env) { return; } if (table.state !== 'available') { - main.appendChild(callout('neutral', 'info', 'No path table', [PATH_TEXT.absent])); + const route = lookupRouteOf(data); + if (route === null) { + main.appendChild(callout('neutral', 'info', 'No path table', [PATH_TEXT.absent])); + return; + } + // ADR-0055 clause 9: point at the server's own route, as text. No link and no request, + // so the renderer keeps one network path (its data file) and one CSP on both hosts. + const [lead, template, between, example] = PATH_TEXT.lookupRoute(route); + main.appendChild( + callout('neutral', 'info', 'No path table', [ + PATH_TEXT.absentServed, + el('p', null, lead), + el('p', 'mono path-route', template), + el('p', null, between), + el('p', 'mono path-route', example), + ]), + ); return; } const entries = list(table.entries); diff --git a/packages/portal/src/page/assets.ts b/packages/portal/src/page/assets.ts index 4b9ca1e..542498f 100644 --- a/packages/portal/src/page/assets.ts +++ b/packages/portal/src/page/assets.ts @@ -16,6 +16,7 @@ * index.html src/page/index.html, with the meta CSP of clause 7 * app.css THEME_CSS, then src/page/page.css * page/app.mjs src/page/app.mjs + * page/icon.svg src/page/icon.svg, the tab icon (no script, no external reference) * vendor/board-layout.mjs src/vendor/board-layout.mjs, the generated copy * vendor/board-view.mjs src/vendor/board-view.mjs, the generated copy * portal-data.json written by generate.ts @@ -32,6 +33,7 @@ import * as layoutText from '../vendor/board-layout.mjs' with { type: 'text' }; import * as viewText from '../vendor/board-view.mjs' with { type: 'text' }; import * as appText from './app.mjs' with { type: 'text' }; import * as htmlText from './index.html' with { type: 'text' }; +import * as iconText from './icon.svg' with { type: 'text' }; import * as cssText from './page.css' with { type: 'text' }; /** One file of the page bundle. `path` is relative to the output directory, POSIX separators. */ @@ -55,6 +57,7 @@ export const PORTAL_ASSETS: readonly PortalAsset[] = Object.freeze([ { path: 'index.html', contents: textOf(htmlText, 'index.html') }, { path: 'app.css', contents: `${THEME_CSS}\n${PAGE_CSS}` }, { path: 'page/app.mjs', contents: textOf(appText, 'app.mjs') }, + { path: 'page/icon.svg', contents: textOf(iconText, 'icon.svg') }, { path: 'vendor/board-layout.mjs', contents: textOf(layoutText, 'board-layout.mjs') }, { path: 'vendor/board-view.mjs', contents: textOf(viewText, 'board-view.mjs') }, ]); diff --git a/packages/portal/src/page/icon.svg b/packages/portal/src/page/icon.svg new file mode 100644 index 0000000..2ded9e3 --- /dev/null +++ b/packages/portal/src/page/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/portal/src/page/index.html b/packages/portal/src/page/index.html index d32f2bd..e8fac3c 100644 --- a/packages/portal/src/page/index.html +++ b/packages/portal/src/page/index.html @@ -6,6 +6,7 @@ Decision portal + diff --git a/packages/portal/src/page/page.css b/packages/portal/src/page/page.css index dadb7fa..506b038 100644 --- a/packages/portal/src/page/page.css +++ b/packages/portal/src/page/page.css @@ -128,6 +128,7 @@ input[type="search"]:focus-visible { outline: 2px solid var(--c-focus); outline- /* Queue and paths */ .queue-row .facts { margin: var(--sp-2) 0 0; } .path-result { margin: var(--sp-3) 0; } +.path-route { overflow-wrap: anywhere; } .path-entry .path { font-weight: 600; overflow-wrap: anywhere; margin: 0 0 var(--sp-2); } .path-group { margin-top: var(--sp-3); } .path-group h3 { margin-bottom: var(--sp-1); } diff --git a/packages/portal/src/page/text-imports.d.ts b/packages/portal/src/page/text-imports.d.ts index 97d0955..fcb7fef 100644 --- a/packages/portal/src/page/text-imports.d.ts +++ b/packages/portal/src/page/text-imports.d.ts @@ -1,9 +1,14 @@ /** - * `src/page/assets.ts` imports the page stylesheet with `with { type: 'text' }`. Bun's own - * declarations cover `*.txt` and `*.html` but not `*.css`; this one says what the text loader - * gives. No other module in the repository imports a stylesheet. + * `src/page/assets.ts` imports the page stylesheet and icon with `with { type: 'text' }`. + * Bun's own declarations cover `*.txt` and `*.html` but not `*.css` or `*.svg`; these say + * what the text loader gives. No other module in the repository imports either. */ declare module '*.css' { const text: string; export default text; } + +declare module '*.svg' { + const text: string; + export default text; +} diff --git a/packages/portal/test/assets.test.ts b/packages/portal/test/assets.test.ts index 8cab132..8737720 100644 --- a/packages/portal/test/assets.test.ts +++ b/packages/portal/test/assets.test.ts @@ -69,7 +69,7 @@ describe('the page bundle', () => { ...[...app.matchAll(/^\s*import\b[^;]*?from\s+['"]([^'"]+)['"]/gm)].map((match) => new URL(match[1]!, 'https://x.invalid/page/app.mjs').pathname.slice(1)), ...[...html.matchAll(/\b(?:href|src)="(\.\/[^"]+)"/g)].map((match) => new URL(match[1]!, 'https://x.invalid/index.html').pathname.slice(1)), ]; - expect(wanted.length).toBe(4); + expect(wanted.length).toBe(5); for (const path of wanted) expect({ path, written: written.has(path) }).toEqual({ path, written: true }); }); diff --git a/packages/portal/test/build.test.ts b/packages/portal/test/build.test.ts index 7b88b90..c2a315d 100644 --- a/packages/portal/test/build.test.ts +++ b/packages/portal/test/build.test.ts @@ -16,6 +16,10 @@ * fit" failed. * - the graph called with no `kinds`: "maps a real SDK handle" (missing `relatesTo` and * `conflictsWith` edges) and "asks the handle for all three edge kinds" failed. + * - `pathLookup` written as `options.pathLookup` (not rebuilt from `route`): "the pathLookup + * hint is rebuilt from its route" failed on the leaked key; the `absent`-only guard + * removed: "a pathLookup hint is refused beside a table" failed; the route check removed: + * "a pathLookup route must be page-relative" failed. */ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; @@ -179,6 +183,48 @@ describe('buildPortalData', () => { }); }); +describe('the pathLookup hint (ADR-0055 clause 9)', () => { + const LOOKUP = { route: '../../api/v1/corpora/demo/governing' }; + + test('is null unless a host passes one', () => { + expect(buildPortalData(fakeHandle(), OPTIONS).pathLookup).toBeNull(); + expect(buildPortalData(fakeHandle(), { ...OPTIONS, pathLookup: null }).pathLookup).toBeNull(); + expect(serializePortalData(buildPortalData(fakeHandle(), OPTIONS))).toContain('"pathLookup":null,"pathTable":'); + }); + + test('the pathLookup hint is rebuilt from its route', () => { + const data = buildPortalData(fakeHandle(), { ...OPTIONS, pathLookup: { ...LOOKUP, extra: 'x' } as never }); + expect(data.pathLookup).toEqual(LOOKUP); + expect(serializePortalData(data)).not.toContain('"extra"'); + }); + + test('a pathLookup hint is refused beside a table', () => { + const table = buildPathTable([governance('src/a.ts', { governing: ['0001'] })]); + expect(() => buildPortalData(healthy, { ...OPTIONS, pathTable: table, pathLookup: LOOKUP })).toThrow(TypeError); + expect(() => buildPortalData(broken, { ...OPTIONS, pathTable: { state: 'unavailable', reason: 'corpus-errors' }, pathLookup: LOOKUP })).toThrow(TypeError); + // The server always passes `absent`, so a corpus with errors still carries the hint. + expect(buildPortalData(broken, { ...OPTIONS, pathLookup: LOOKUP }).pathLookup).toEqual(LOOKUP); + }); + + test('a pathLookup route must be page-relative', () => { + for (const route of ['./governing', 'governing', '../../api/v1/corpora/a.b_c-d/governing']) { + expect({ route, kept: buildPortalData(fakeHandle(), { ...OPTIONS, pathLookup: { route } }).pathLookup?.route }).toEqual({ route, kept: route }); + } + for (const route of ['', '/api/v1/governing', '//evil.invalid/x', 'https://evil.invalid/x', 'javascript:alert(1)', 'governing?path=a', 'governing#x', '../a/../b', 'a b', 'a/', `a/${'x'.repeat(512)}`, 42]) { + expect({ route, threw: (() => { try { buildPortalData(fakeHandle(), { ...OPTIONS, pathLookup: { route } as never }); return false; } catch (error) { return error instanceof TypeError; } })() }).toEqual({ route, threw: true }); + } + expect(() => buildPortalData(fakeHandle(), { ...OPTIONS, pathLookup: 'governing' as never })).toThrow(TypeError); + }); + + test('is byte-identical across calls, with and without the hint', () => { + const without = serializePortalData(buildPortalData(healthy, OPTIONS)); + expect(serializePortalData(buildPortalData(healthy, { ...OPTIONS, pathLookup: null }))).toBe(without); + const withHint = serializePortalData(buildPortalData(healthy, { ...OPTIONS, pathLookup: LOOKUP })); + expect(serializePortalData(buildPortalData(healthy, { ...OPTIONS, pathLookup: { ...LOOKUP } }))).toBe(withHint); + expect(withHint).toBe(without.replace('"pathLookup":null', `"pathLookup":{"route":"${LOOKUP.route}"}`)); + }); +}); + describe('determinism', () => { test('is byte-identical across calls and across separately opened handles', async () => { const first = serializePortalData(buildPortalData(healthy, OPTIONS)); diff --git a/packages/portal/test/contract.test.ts b/packages/portal/test/contract.test.ts index 55b54cd..94728a7 100644 --- a/packages/portal/test/contract.test.ts +++ b/packages/portal/test/contract.test.ts @@ -49,7 +49,8 @@ describe('portal-data.json v1', () => { }); test('top-level keys', () => { - expect(keys(json)).toEqual(['asOf', 'commit', 'dir', 'graph', 'issues', 'pathTable', 'queue', 'records', 'version']); + expect(keys(json)).toEqual(['asOf', 'commit', 'dir', 'graph', 'issues', 'pathLookup', 'pathTable', 'queue', 'records', 'version']); + expect(json.pathLookup).toBeNull(); expect(json.commit).toBeNull(); expect('generatedAt' in json).toBe(false); }); diff --git a/packages/portal/test/generate-safety.test.ts b/packages/portal/test/generate-safety.test.ts index c53287d..495e9a6 100644 --- a/packages/portal/test/generate-safety.test.ts +++ b/packages/portal/test/generate-safety.test.ts @@ -174,7 +174,7 @@ describe('exit 2 leaves out untouched', () => { const result = await generatePortal({ cwd: inner, out: 'site-replaced', asOf: AS_OF }); expect(result.exitCode).toBe(0); expect(existsSync(join(out, 'stale.txt'))).toBe(false); - expect(Object.keys(await snapshot(out)).sort()).toEqual(['app.css', 'index.html', 'page/app.mjs', 'portal-data.json', 'vendor/board-layout.mjs', 'vendor/board-view.mjs']); + expect(Object.keys(await snapshot(out)).sort()).toEqual(['app.css', 'index.html', 'page/app.mjs', 'page/icon.svg', 'portal-data.json', 'vendor/board-layout.mjs', 'vendor/board-view.mjs']); const siblings = await readdir(inner); expect(siblings.filter((name) => name.includes('staging') || name.includes('.old-'))).toEqual([]); }); diff --git a/packages/portal/test/page-fixture.ts b/packages/portal/test/page-fixture.ts index c8b5e21..3c70324 100644 --- a/packages/portal/test/page-fixture.ts +++ b/packages/portal/test/page-fixture.ts @@ -6,7 +6,7 @@ import type { CorpusIssue, DecisionRecord } from '@adrkit/sdk/src/index.ts'; import { buildPortalData } from '../src/data/build.ts'; -import type { PortalData, PortalPathTable } from '../src/data/contract.ts'; +import type { PortalData, PortalPathLookup, PortalPathTable } from '../src/data/contract.ts'; import { fakeHandle, fakeQueueEntry, fakeRecord } from './fake-handle.ts'; export const COMMIT = 'a'.repeat(40); @@ -65,6 +65,7 @@ export interface FixtureOptions { readonly issues?: CorpusIssue[]; readonly pathTable?: PortalPathTable; readonly commit?: string | null; + readonly pathLookup?: PortalPathLookup | null; } export function portalData(options: FixtureOptions = {}): PortalData { @@ -96,6 +97,7 @@ export function portalData(options: FixtureOptions = {}): PortalData { commit: options.commit === undefined ? COMMIT : options.commit, asOf: '2026-10-10', pathTable: options.pathTable ?? pathTable(), + pathLookup: options.pathLookup ?? null, }); } diff --git a/packages/portal/test/page-render.test.ts b/packages/portal/test/page-render.test.ts index fce5897..04d559c 100644 --- a/packages/portal/test/page-render.test.ts +++ b/packages/portal/test/page-render.test.ts @@ -25,11 +25,15 @@ * - against the code before the fix (the checkbox's `change` handler called `render()`): "a * kind toggle redraws only the lines: same checkbox, same frame, same view" failed, the * view's nodes and checkbox replaced and the zoom reset. + * - the `pathLookup` branch in `renderPaths` bypassed (`if (true)`), the example rendered as + * an `` through `link(...)`, and an `env.fetch(route)` added to it: "an absent table with + * a pathLookup shows the route and an example as text, and requests nothing" failed each + * time (the fetch also failed page-static's "it sends nothing and builds no form"). */ import { beforeEach, describe, expect, test } from 'bun:test'; import { NODE_LIMIT } from '../src/vendor/board-layout.mjs'; -import { clipText, DEFAULT_KINDS, lookupPath, normalizeLookup, parseRoute, PATH_TEXT, recordHref, safeExternalHref } from '../src/page/app.mjs'; +import { clipText, DEFAULT_KINDS, lookupPath, lookupRouteOf, normalizeLookup, parseRoute, PATH_TEXT, recordHref, safeExternalHref } from '../src/page/app.mjs'; import { FakeNode, runPortal, SVG_NS } from './fake-dom.ts'; import { brokenData, COMMIT, HOSTILE_TITLE, pathTable, portalData, records, SCRIPT_BODY } from './page-fixture.ts'; import { fakeRecord } from './fake-handle.ts'; @@ -472,6 +476,36 @@ describe('path view (clause 6): every table state has its own wording', () => { expect(PATH_TEXT.absent).toContain('This host has no path table'); }); + test('an absent table with no pathLookup keeps the generic wording and names no route', async () => { + const page = await lookUp(portalData({ pathTable: { state: 'absent' } }), 'src/a.ts'); + expect(page.app.allText()).toContain(PATH_TEXT.absent); + expect(page.app.allText()).not.toContain(PATH_TEXT.absentServed); + expect(page.app.allText()).not.toContain('?path='); + }); + + // ADR-0055 clause 9: the server-hosted page points at its own governing route, as text. + test('an absent table with a pathLookup shows the route and an example as text, and requests nothing', async () => { + const route = '../../api/v1/corpora/demo/governing'; + const page = await lookUp(portalData({ pathTable: { state: 'absent' }, pathLookup: { route } }), 'src/a.ts'); + const text = page.app.allText(); + expect(text).toContain(PATH_TEXT.absentServed); + expect(text).not.toContain(PATH_TEXT.absent); + expect(text).toContain(`${route}?path=`); + expect(text).toContain(`${route}?path=src/index.ts`); + expect(text).toContain('matching affects only and reading no file'); + expect(page.app.all().filter((node) => node.tag === 'a')).toEqual([]); + expect(page.app.all().some((node) => node.tag === 'input')).toBe(false); + expect(page.fetched).toEqual(['./portal-data.json']); + }); + + test('a pathLookup is read only when it carries a usable route string', () => { + expect(lookupRouteOf({ pathLookup: { route: 'governing' } })).toBe('governing'); + for (const pathLookup of [null, undefined, 'governing', { route: '' }, { route: 42 }, { route: 'x'.repeat(513) }]) { + expect(lookupRouteOf({ pathLookup })).toBeNull(); + } + expect(lookupRouteOf(undefined)).toBeNull(); + }); + test('no state ever calls a path ungoverned or shows an empty result', async () => { for (const data of [portalData(), portalData({ commit: null }), brokenData(), portalData({ pathTable: { state: 'absent' } }), portalData({ pathTable: pathTable({ capped: true } as never) })]) { const page = await lookUp(data, 'nowhere/at/all.ts'); @@ -479,5 +513,7 @@ describe('path view (clause 6): every table state has its own wording', () => { expect(page.app.allText()).not.toMatch(/\bnot governed\b/i); } for (const message of Object.values(PATH_TEXT)) expect(String(typeof message === 'function' ? message(COMMIT) : message)).not.toMatch(/ungoverned/i); + const served = await lookUp(portalData({ pathTable: { state: 'absent' }, pathLookup: { route: 'governing' } }), 'nowhere/at/all.ts'); + expect(served.app.allText()).not.toMatch(/ungoverned|\bnot governed\b/i); }); }); diff --git a/packages/portal/test/page-static.test.ts b/packages/portal/test/page-static.test.ts index 1585492..dd315f1 100644 --- a/packages/portal/test/page-static.test.ts +++ b/packages/portal/test/page-static.test.ts @@ -22,6 +22,11 @@ * letting lines show through): "it loads nothing and dims the box, never the label" failed. * - the queue lede changed to end "no verdict to them, ready or not.": "no ratifying or review * command, and no readiness word" failed. + * - the icon link in `src/page/index.html` changed to `href="/page/icon.svg"`: "every + * reference is relative and every link a hash route" failed; `img-src 'self' data:` in the + * meta CSP: "the CSP meta is exactly the decided policy" failed; `` + * added to `src/page/icon.svg`: "it is the source file, shipped verbatim, and loads or runs + * nothing" failed. */ import { describe, expect, test } from 'bun:test'; @@ -81,7 +86,10 @@ describe('index.html (clauses 7 and 8)', () => { // for a second, inline script however it is closed. expect(HTML.match(/'); - expect([...HTML.matchAll(/]*>/gi)].map((match) => match[0])).toEqual(['']); + expect([...HTML.matchAll(/]*>/gi)].map((match) => match[0])).toEqual([ + '', + '', + ]); expect(HTML).not.toMatch(/ { }); }); +describe('the tab icon (#309; clauses 7 and 8)', () => { + const ICON = asset('page/icon.svg'); + + test('index.html names it relatively, and the CSP is unchanged: no data: source', () => { + expect(HTML).toContain(''); + expect(HTML).not.toMatch(/data:/i); + }); + + test('it is the source file, shipped verbatim, and loads or runs nothing', () => { + expect(ICON).toBe(readFileSync(join(PAGE_DIR, 'icon.svg'), 'utf8')); + expect(ICON.startsWith(' match[0])).toEqual(['http://www.w3.org/2000/svg']); + }); +}); + describe('the stylesheet', () => { test('app.css is the shared theme followed by the page rules', () => { expect(CSS).toBe(`${THEME_CSS}\n${PAGE_CSS}`); @@ -172,12 +199,12 @@ describe('the stylesheet', () => { describe('the bundle as a whole', () => { test('every path is relative, inside the output directory', () => { - expect(PORTAL_ASSETS.map((file) => file.path)).toEqual(['index.html', 'app.css', 'page/app.mjs', 'vendor/board-layout.mjs', 'vendor/board-view.mjs']); + expect(PORTAL_ASSETS.map((file) => file.path)).toEqual(['index.html', 'app.css', 'page/app.mjs', 'page/icon.svg', 'vendor/board-layout.mjs', 'vendor/board-view.mjs']); for (const { path } of PORTAL_ASSETS) expect({ path, ok: /^[a-z0-9-]+(\/[a-z0-9-]+)*\.[a-z]+$/.test(path) && !path.split('/').includes('..') }).toEqual({ path, ok: true }); }); test('every page source file is either shipped or a build-only declaration', () => { - expect(readdirSync(PAGE_DIR).sort()).toEqual(['app.mjs', 'assets.ts', 'index.html', 'page.css', 'text-imports.d.ts']); + expect(readdirSync(PAGE_DIR).sort()).toEqual(['app.mjs', 'assets.ts', 'icon.svg', 'index.html', 'page.css', 'text-imports.d.ts']); }); test('no ratifying or review command, and no readiness word, in the portal\'s own page strings (clause 5)', () => { diff --git a/packages/server/src/openapi.ts b/packages/server/src/openapi.ts index 68d764e..8677355 100644 --- a/packages/server/src/openapi.ts +++ b/packages/server/src/openapi.ts @@ -16,6 +16,7 @@ const MEDIA_TYPES: Readonly> = Object.freeze({ html: 'text/html', css: 'text/css', javascript: 'text/javascript', + svg: 'image/svg+xml', }); const ERROR_SCHEMA = { diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 10c03a5..4d65ca4 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -16,7 +16,7 @@ import { CORPUS_KEY } from './config.ts'; import { RECORD_ID } from './validate.ts'; -export type RouteKind = 'json' | 'html' | 'css' | 'javascript'; +export type RouteKind = 'json' | 'html' | 'css' | 'javascript' | 'svg'; export interface RouteParameter { readonly name: string; @@ -56,6 +56,7 @@ const CORPUS_ERRORS = { export const PORTAL_ASSET_ROUTES: Readonly> = Object.freeze({ 'app.css': { operationId: 'getPortalStylesheet', kind: 'css', summary: "The decision portal's stylesheet." }, 'page/app.mjs': { operationId: 'getPortalApp', kind: 'javascript', summary: "The decision portal's page module." }, + 'page/icon.svg': { operationId: 'getPortalIcon', kind: 'svg', summary: "The decision portal's tab icon." }, 'vendor/board-layout.mjs': { operationId: 'getPortalBoardLayout', kind: 'javascript', summary: "The decision portal's graph layout module." }, 'vendor/board-view.mjs': { operationId: 'getPortalBoardView', kind: 'javascript', summary: "The decision portal's graph view module." }, }); diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 52ec678..a498ae5 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -41,6 +41,7 @@ const CONTENT_TYPES = Object.freeze({ html: 'text/html; charset=utf-8', css: 'text/css; charset=utf-8', javascript: 'text/javascript; charset=utf-8', + svg: 'image/svg+xml; charset=utf-8', }); export interface ServerOptions { @@ -206,7 +207,15 @@ export function createRequestHandler(options: ServerOptions): (request: Incoming let body = corpus.portal.get(asOf); if (body === undefined) { body = serializePortalData( - buildPortalData(corpus.handle, { dir: corpus.adrDir, commit: corpus.commit, asOf, pathTable: { state: 'absent' } }), + buildPortalData(corpus.handle, { + dir: corpus.adrDir, + commit: corpus.commit, + asOf, + pathTable: { state: 'absent' }, + // Clause 9: the page names this server's own governing route, as text. Relative to + // /portal/{corpus}/, so a gateway base path survives; the key is CORPUS_KEY-checked. + pathLookup: { route: `../../api/v1/corpora/${corpus.key}/governing` }, + }), ); corpus.portal.clear(); corpus.portal.set(asOf, body); diff --git a/packages/server/test/openapi.test.ts b/packages/server/test/openapi.test.ts index cef16f5..73d07f9 100644 --- a/packages/server/test/openapi.test.ts +++ b/packages/server/test/openapi.test.ts @@ -34,6 +34,7 @@ const STABLE_IDS = [ 'getPortalApp', 'getPortalBoardLayout', 'getPortalBoardView', + 'getPortalIcon', ]; type Doc = ReturnType; diff --git a/packages/server/test/portal.test.ts b/packages/server/test/portal.test.ts index ef4bfae..ffa8600 100644 --- a/packages/server/test/portal.test.ts +++ b/packages/server/test/portal.test.ts @@ -39,7 +39,7 @@ describe('the portal', () => { test('serves the bundle files verbatim, with their content types', async () => { const { url } = await serve(); - const types: Record = { 'index.html': 'text/html', 'app.css': 'text/css' }; + const types: Record = { 'index.html': 'text/html', 'app.css': 'text/css', 'page/icon.svg': 'image/svg+xml' }; for (const asset of PORTAL_ASSETS) { const response = await fetch(`${url}/portal/demo/${asset.path === 'index.html' ? '' : asset.path}`); expect(await response.text()).toBe(asset.contents); @@ -47,6 +47,9 @@ describe('the portal', () => { } }); + // ADR-0016 clause 2: observed failing with the `pathLookup` option removed from the + // buildPortalData call in src/server.ts, and with its route starting `../api` (one step + // short). The retained input is the route as written there. test('portal-data.json is built at the sidecar commit with an absent path table', async () => { const { url } = await serve(); const { body, headers } = await getJson(`${url}/portal/demo/portal-data.json`); @@ -56,6 +59,11 @@ describe('the portal', () => { expect(body.dir).toBe('docs/adr'); expect(body.asOf).toBe('2026-10-10'); expect(body.pathTable).toEqual({ state: 'absent' }); + // Clause 9: the page points at this server's own governing route, relative to the page. + expect(body.pathLookup).toEqual({ route: '../../api/v1/corpora/demo/governing' }); + const resolved = new URL(`${body.pathLookup.route}?path=src/index.ts`, `${url}/portal/demo/`); + expect(resolved.pathname).toBe('/api/v1/corpora/demo/governing'); + expect((await fetch(resolved)).status).toBe(200); expect(body.records.map((record: { id: string }) => record.id)).toEqual(['0001', '0002', '0003']); const records = await getJson(`${url}/api/v1/corpora/demo/records`); expect(records.body.commit).toBe(body.commit); diff --git a/scripts/check-portal-bundle.ts b/scripts/check-portal-bundle.ts index d970ced..f74afbc 100644 --- a/scripts/check-portal-bundle.ts +++ b/scripts/check-portal-bundle.ts @@ -34,6 +34,8 @@ import { join } from 'node:path'; /** Files the page needs to load; a bundle missing any of them renders nothing. */ // page/app.mjs imports the two vendor modules statically (packages/portal/src/page/app.mjs); // if either is missing the browser aborts the page module and the portal renders nothing. +// `page/icon.svg` (#309) is deliberately not listed: without it the tab shows no icon and the +// browser logs one 404, and the page still renders. export const REQUIRED_FILES = [ 'index.html', 'app.css',