yay-layer 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/map.js ADDED
@@ -0,0 +1,1742 @@
1
+ 'use strict';
2
+ const { ratifyBundle, autoCellIds } = require('./ratify'); // render-time bundle hash for TOCTOU-safe ratify + the delegated set
3
+ // Generate the flowchart — a self-contained, theme-aware, ZOOMABLE mind-map.
4
+ //
5
+ // It's a drill-down hierarchy explorer: the mind-map shows one level at a time —
6
+ // System → Modules → sub-groups (Public API / Internal / classes) → units.
7
+ // Click a node to ZOOM IN (its children become the new mind-map); use the
8
+ // breadcrumb to zoom back out; click a unit to open its spec, code (offending
9
+ // lines in red) and checks — the place the spec must match the code.
10
+ //
11
+ // Structure is derived generally (ES export / CommonJS / UMD / IIFE / classes /
12
+ // objects) via @babel/parser; module-to-module flow comes from the call graph.
13
+ // No external libraries — one self-contained file.
14
+
15
+ function esc(s) {
16
+ return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
17
+ }
18
+ function base(f) { return String(f || 'other').split('/').pop(); }
19
+
20
+ // Tint the two fields a non-coder architect reads first: `intent` (blue) and
21
+ // `ensures` (purple). Colours the field line AND its continuation lines, until
22
+ // the next field or marker. Everything else stays default.
23
+ function colorizeSpec(block) {
24
+ let cur = null;
25
+ return String(block || '').split('\n').map((l) => {
26
+ const e = esc(l);
27
+ if (/∷YAY/.test(l)) { cur = null; return e; }
28
+ const m = l.match(/^\s*(?:\/\/|#)\s*([A-Za-z_]+)\s*:/);
29
+ if (m) cur = m[1].toLowerCase();
30
+ else if (!/^\s*(?:\/\/|#)/.test(l)) cur = null; // not a comment line → end of field
31
+ if (cur === 'intent') return `<span class="sp-intent">${e}</span>`;
32
+ if (cur === 'ensures') return `<span class="sp-ensures">${e}</span>`;
33
+ return e;
34
+ }).join('\n');
35
+ }
36
+
37
+ // Light syntax highlighting for the code panel — subtle, readable on the dark code bg.
38
+ // Runs on the already-ESCAPED line (esc() leaves quotes intact, so string matching still
39
+ // works). Single pass; strings come first so a `//` inside a string isn't read as a comment.
40
+ function hlCode(escLine) {
41
+ return escLine.replace(
42
+ /("[^"]*"|'[^']*'|`[^`]*`)|(\/\/.*$)|(\b(?:const|let|var|function|return|if|else|for|while|do|switch|case|break|continue|new|class|extends|super|this|typeof|instanceof|await|async|yield|throw|try|catch|finally|import|export|from|as|default|null|true|false|undefined|void|delete)\b)|(\b\d[\w.]*\b)/g,
43
+ (m, str, com, kw, num) => {
44
+ if (str) return `<span class="tk-s">${str}</span>`;
45
+ if (com) return `<span class="tk-c">${com}</span>`;
46
+ if (kw) return `<span class="tk-k">${kw}</span>`;
47
+ if (num) return `<span class="tk-n">${num}</span>`;
48
+ return m;
49
+ });
50
+ }
51
+
52
+ const COMMANDS = [
53
+ { cmd: 'yay init [dir]', desc: 'Guided setup, in order: files (+ .gitattributes for auto-merging ledgers) → signing key (+ this clone’s Cell-id shard) → adopt → Brief tags → Constitution → project AI (one provider powering System Plan, Ask & the spec-adversary) → foundation seal. Signing key: local, mobile over your LAN, or mobile over relay.yaylayer.com (off-LAN, end-to-end encrypted).', flags: [['--key local|mobile', 'signing-key type (mobile = pair your phone)'], ['--relay / --lan', 'mobile transport: hosted relay.yaylayer.com (off-LAN) or your local network'], ['--name <you>', 'signer name on every seal'], ['--tags <set>', 'Brief-tag starter set (technical, responsibility, component, layer, area, product)'], ['--adopt / --no-adopt', 'scaffold specs over existing code'], ['--constitution <keys|all>', 'write the Constitution into AI-harness files'], ['--plan / --no-plan', 'configure the project AI (System Plan, Ask, adversary)'], ['--provider anthropic|openai|custom', 'AI provider (+ --base-url, --model, --api-key)'], ['--durable', 'also keep an encrypted archive of signed source'], ['--foundation guarded|strict / --no-foundation', 'foundation-seal posture']] },
54
+ { cmd: 'yay keygen --name <you>', desc: 'Create your ed25519 signing key (public → roster, private → encrypted keystore).', flags: [['--passphrase <p>', 'or the YAY_PASSPHRASE env var']] },
55
+ { cmd: 'yay pair [--name you]', desc: 'Pair your phone as the signer — scan the QR; the private key stays on the phone. The FIRST pairing (no roster yet) makes the phone the trust root itself, so no local key is ever needed. Served over HTTPS by default.', flags: [['--name <you>', 'attach the phone key to this identity'], ['--relay / --lan', 'route via relay.yaylayer.com (off-LAN, E2E) or the local network'], ['--no-https', 'disable TLS (default: mkcert-trusted cert if available, else self-signed)']] },
56
+ { cmd: 'yay enroll --name X --pubkey <b64>', desc: 'Enroll another signer via an OWNER-signed roster event.', flags: [['--role owner|signer', 'role to grant (default signer)'], ['--by <owner>', 'which owner authorizes it'], ['--phone', 'authorize on an owner’s phone (no local key needed)']] },
57
+ { cmd: 'yay invite "Bob"', desc: 'Mint a 30-min, one-time link a teammate opens to request to join — they make their key, you get an approval on your phone (verify the 6-digit code, tap Approve). No pubkey to copy. Needs a running dashboard.', flags: [['--role owner|signer', 'role to grant on approval (default signer; owner can enroll/revoke others)']] },
58
+ { cmd: 'yay revoke --name X', desc: 'Revoke a compromised/rotated key (or a whole identity) via an owner-signed event. Past approvals stay attributed; refuses if it would leave no owner.', flags: [['--pubkey <b64>', 'revoke just this key (omit to remove the whole identity)'], ['--phone', 'authorize on an owner’s phone']] },
59
+ { cmd: 'yay reroot', desc: 'Retire the current trust root and establish a new one — recovery for a lost/compromised root key. A trust discontinuity: re-sign specs and repoint the CI pin afterward.', flags: [['--phone', 'root the new key on your phone (phone-as-genesis)'], ['--name <you>', 'new local owner name'], ['--force', 'skip the confirmation prompt']] },
60
+ { cmd: 'yay adopt [path]', desc: 'Scaffold draft (unsigned) spec blocks over existing code.', flags: [['--dry', 'preview what would be added']] },
61
+ { cmd: 'yay sign [--cell IDs]', desc: 'Approve the current specs — appends a signed seal. Uses THIS project’s signing method automatically (phone or local); no flag needed. If a dashboard is running, the request pops up on the phone you already scanned.', flags: [['--brief "<text>"', 'the signed Brief prose, REQUIRED by default (read-only on the phone: Accept or Send back; prompted if omitted at a terminal)'], ['--title "<headline>"', 'a short title over the Brief — the scannable headline in the ledger, clouds and phone'], ['--tags "A,B"', 'tag the Brief from the project pool (see yay tags) — required when a pool exists; --no-tags to skip'], ['--name "<signer>"', 'sign as / route to that signer — a teammate over relay gets it in their inbox (fire-and-return, returns a request id)'], ['--check [id]', 'collect a routed teammate’s signature and write the seal'], ['--phone / --local', 'force the device (default = the project’s method)'], ['--no-brief', 'skip the Brief for a trivial re-sign'], ['--relay / --lan', 'phone transport override'], ['--cell <ids>', 'only these Cells (comma-separated)']] },
62
+ { cmd: 'yay inbox', desc: 'Print YOUR on-duty relay link (+ QR) — open it on your phone and leave it up to receive approval requests teammates address to you with `yay sign --name "You"`.', flags: [] },
63
+ { cmd: 'yay requests [done <id>]', desc: 'The AI’s inbox of plain requests queued from the dashboard’s “Request a change” button. The AI turns each into a polished Brief + Cells to sign.', flags: [['done <id> / clear', 'remove a handled request (or all)']] },
64
+ { cmd: 'yay tags [--set id]', desc: 'The project’s Brief-tag vocabulary — every Brief is tagged from it, so work can be sorted by concern over time. Six starter sets or a blank custom set; edit here or live in the dashboard Tags tab.', flags: [['--set <id>', 'switch to a starter set (technical, responsibility, component, layer, area, product) or custom (blank placeholders)'], ['add "Tag" / remove "Tag"', 'edit the pool'], ['rename "A" "B"', 'relabel a tag — blocked once it is used in a signed Brief (would split history)'], ['desc "Tag" "…"', 'set a tag’s description'], ['sets', 'list the six starter sets and their tags']] },
65
+ { cmd: 'yay policy [--init|--set]', desc: 'Signing policy — who must sign what (neutral by default). A rule requires a specific person to sign Cells matched by path glob, spec tag, or module; the gate blocks any match they haven’t signed. Edit the draft in the dashboard Policy tab or the file, then --set owner-signs it into the roster (tamper-evident).', flags: [['--init', 'write a commented policy.json template'], ['--set', 'owner-sign the draft policy.json into effect (routes to your phone)']] },
66
+ { cmd: 'yay grant [--for 2h] [--count 20]', desc: 'AUTOPILOT (delegated execution): an owner-signed grant lets the AI approve in-scope, non-sensitive Cells (delegated) unattended until it expires or hits the count. Sensitive / code-pinned Cells always still need a real signature.', flags: [['--for <dur>', 'time window, e.g. 2h, 90m, 1d (default 2h)'], ['--count <n>', 'max delegated approvals (default 20)'], ['--cell <ids>', 'scope to named Cells (else all non-sensitive)'], ['list / revoke [id]', 'show active grants / stop one']] },
67
+ { cmd: 'yay ratify [--sign]', desc: 'List delegated Cells awaiting ratification (produced under a grant, not human-reviewed); --sign signs them for real.', flags: [['--sign', 'sign the delegated approvals for real (human)']] },
68
+ { cmd: 'yay verify [--strict] [-d]', desc: 'The gate: paint every Cell + run the behavioural prover & mutation grading.', flags: [['--strict', 'non-zero exit if blocked (for CI)'], ['-d, --details', 'print each spec, code & checks'], ['--problems', 'show only non-green Cells'], ['--no-mutate', 'skip mutation grading']] },
69
+ { cmd: 'yay test [--test "cmd"]', desc: 'Run the project’s OWN test suite (package.json "test" / config.test) — the runtime backstop for what per-Cell checks can’t reach. Non-zero exit on failure (for CI).', flags: [['--test "<cmd>"', 'the command to run (else package.json test)']] },
70
+ { cmd: 'yay adversary [--cell IDs]', desc: 'Spec-only adversary: an LLM sees ONLY each Cell’s spec (never the code) and writes probes to BREAK it, run against the real code. A break is a genuine spec↔code violation. Needs an LLM key.', flags: [['--cell <ids>', 'only these Cells'], ['--provider …', 'same provider config as the System Plan']] },
71
+ { cmd: 'yay plan', desc: 'AI-synthesize the high-level System Plan → .yaylayer/plan.json.', flags: [['--provider anthropic|openai|custom', 'LLM provider (key from .env)'], ['--base-url <url>', 'custom / OpenAI-compatible endpoint (Ollama, LM Studio, vLLM — key optional)'], ['--model <m>', 'model id']] },
72
+ { cmd: 'yay map [-o file.html]', desc: 'Write this HTML site (Map / Files / System Plan / Briefs / Tags / Policy / Signers / Commands).', flags: [['-o <file>', 'output path'], ['--no-plan', 'omit the System Plan entirely'], ['--replan', 'force plan regeneration']] },
73
+ { cmd: 'yay dashboard [--port N]', desc: 'Live control panel + phone relay: serves the map (auto-refreshes) with on-demand buttons — ➕ Request a change (queue a request your AI turns into a Brief to sign), ▷ Preview (run a package.json script — dev server, build — with a live link + Stop), Changes, Run tests, Adversary, Regenerate System Plan — AND routes pair/sign/authorize to the phone you scanned ONCE. Has Briefs, Tags and Policy tabs. Leave it running. HTTPS by default; the phone installs the cert from the /trust page for warning-free https.', flags: [['--port <n>', 'port (default 48757)'], ['--open', 'open it in your browser'], ['--no-https', 'disable TLS (default: mkcert-trusted cert if available, else self-signed)']] },
74
+ { cmd: 'yay gate [dir]', desc: 'Write the CI gate pipeline for your host and print its one-time branch-protection steps. Not GitHub-only.', flags: [['--for <platform>', 'github (default), azure, gitlab, bitbucket, gitea, gerrit'], ['--hook', 'also install a local pre-push gate'], ['--scope <dir>', 'gate only a subfolder'], ['--force', 'overwrite existing files']] },
75
+ { cmd: 'yay protect [--mode …]', desc: 'FOUNDATION SEAL: owner-sign a baseline of the FIXED core files (Constitution, CI workflow, .gitignore, protocol files) so any later change is REVEALED at verify. Re-run it to RE-SEAL after a legitimate change — the dashboard’s 🛡 Re-seal foundation button does exactly this (approve on your phone).', flags: [['--mode guarded|strict', 'guarded warns; strict blocks the gate on drift (default guarded)'], ['--add / --remove <glob>', 'add/remove a watched path'], ['--ignore <glob>', 'exclude an expected-churn path from the seal'], ['--off', 'disable the seal (owner-signed)']] },
76
+ { cmd: 'yay ask "<question>"', desc: 'Ask your configured system AI about THIS repo (primed with live state — Cells, briefs, grants, rejections, the gate) AND the manual. Same as the dashboard Ask tab. Needs an LLM key in .env.', flags: [['--provider …', 'override the configured provider/model']] },
77
+ { cmd: 'yay id', desc: 'Show THIS working copy’s Cell-id shard and the next id it would mint. New Cells are C-<shard>-n, with a per-clone shard, so ids never collide when branches merge.', flags: [] },
78
+ { cmd: 'yay merge', desc: 'Run after a git merge/pull: re-verify and list anything the merge left behind — id collisions (only possible for legacy flat ids) and Cells edited on both sides that need re-signing. A botched merge never goes green silently.', flags: [] },
79
+ { cmd: 'yay batch [N|on|off]', desc: 'How the AI groups small changes into one Brief before asking you to sign. Default on, barrier 5.', flags: [['<N>', 'set the barrier (ask to close a batch after N small changes)'], ['on / off', 'batching on, or a Brief per change']] },
80
+ { cmd: 'yay constitution --for <keys>', desc: 'Write the Constitution where an AI harness auto-reads it.', flags: [['--for <keys|all>', 'claude, agents, copilot, cursor, windsurf, cline, gemini, generic'], ['--list', 'list the harnesses']] },
81
+ { cmd: 'yay attest [list|verify]', desc: 'The MACHINE verifier signs its own verdict over the current tree — a chained, capability-versioned attestation. Anyone can re-check it (also at yaylayer.com/verify).', flags: [['list', 'show the attestation chain'], ['verify', 're-check the latest attestation'], ['--force', 'attest even a blocked gate (recorded as such)']] },
82
+ { cmd: 'yay archive [install|--restore]', desc: 'DURABLE mode: keep an encrypted, sha256-anchored archive of signed source (secret scan + signed tombstones) so provenance survives even if working files are lost.', flags: [['install', 'add the git hook that updates the archive'], ['--restore', 'restore signed source from the archive'], ['--verify', 're-check archive integrity'], ['--forget <id>', 'tombstone an entry (signed)']] },
83
+ { cmd: 'yay capability', desc: 'Show the verifier’s derived capability descriptor (what it can and cannot prove) and flag drift from the signed attestation.', flags: [] },
84
+ { cmd: 'yay reverify [--all] [posture …]', desc: 'Re-attest the current tree, or --all: replay every preserved (Durable) state through today’s verifier and diff vs its original verdict — a keyless "upgrade report." Never rewrites old Green.', flags: [['--all', 'the historical sweep (keyless report)'], ['--attest', 'mint signed, append-only reverification records (needs the verifier key)'], ['--since <date> · --eligible', 'filter the sweep'], ['-o <file> · --json', 'save / machine-readable report'], ['posture off|guarded|strict', 'grandfathering: off (default) · guarded (warn) · strict (block until history is re-verified)']] },
85
+ { cmd: 'yay witness · metrics', desc: 'Integrity witness (cross-check the attestation chain, spec archive, and git-vs-ledger coverage) · earned-autonomy metrics (how delegated work has held up).', flags: [] },
86
+ { cmd: 'yay status', desc: 'One-line health summary of the project.', flags: [] },
87
+ ];
88
+ function commandsHTML() {
89
+ return '<h1>Commands</h1><div class="snote" style="margin:0 0 18px">Every <code>yay</code> command and its flags. Passphrases/keys come from your <code>.env</code>; run <code>yay help</code> in the terminal for the terse version.</div>'
90
+ + COMMANDS.map((c) => `<div class="cmdcard"><div class="cmdname">${esc(c.cmd)}</div><div class="cmddesc">${esc(c.desc)}</div>`
91
+ + (c.flags.length ? `<div class="cmdflags">${c.flags.map((f) => `<div class="cmdflag"><code>${esc(f[0])}</code><span>${esc(f[1])}</span></div>`).join('')}</div>` : '')
92
+ + '</div>').join('');
93
+ }
94
+
95
+ const COLORS = {
96
+ GREEN: ['#1f9d57', 'Green'], YELLOW: ['#c9860f', 'Yellow'],
97
+ RED: ['#cf4436', 'Red'], UNSIGNED: ['#7f8796', 'Unsigned'], PINK: ['#e0559b', 'Pink'],
98
+ };
99
+ const SEV = { GREEN: 1, YELLOW: 2, UNSIGNED: 3, PINK: 4, RED: 5 };
100
+ function subOrder(s) { return s === 'Public API' ? 0 : s === 'Modules' ? 1 : s === 'Internal' ? 3 : s === 'module-level' ? 4 : 2; }
101
+
102
+ function fmtWhen(v) {
103
+ if (!v) return '';
104
+ const d = new Date(v);
105
+ return isNaN(d) ? String(v) : d.toISOString().slice(0, 16).replace('T', ' ');
106
+ }
107
+ function detailInner(cell, res, t) {
108
+ t = t || {};
109
+ const [col, label] = COLORS[res.state];
110
+ if (res.untracked) {
111
+ return `<div class="mhead"><span class="mid">${esc(cell.id)}</span><span class="mname">${esc(res.name || cell.unitName || '')}</span><span class="mpill" style="color:${col}">${label}</span></div>
112
+ <div class="dmeta"><span>untracked</span><span>${esc(res.file)}:${esc(res.line)}</span></div>
113
+ <div class="dh" style="color:${col}">No formal specification</div>
114
+ <div class="allok" style="color:${col};font-weight:600">◆ This code was never described in YayLayer or signed — it sits outside the system, where silent bugs hide.</div>
115
+ <div class="allok" style="margin-top:8px">Run <code>yay adopt</code> to scaffold a spec above it, then prune &amp; sign it.</div>`;
116
+ }
117
+ const isMod = !!(cell.contains && cell.contains.length);
118
+ const meta = [];
119
+ meta.push(res.trust && res.trust.signed ? (res.trust.auto ? `Delegated · ${esc(res.trust.grant || 'grant')}` : `signed by ${esc(res.trust.signer)}`) : 'unsigned');
120
+ meta.push(`spec ${esc((cell.specHash || '').slice(0, 12))}…`);
121
+ if (cell.feeds && cell.feeds.length) meta.push(`feeds → ${esc(cell.feeds.join(', '))}`);
122
+ meta.push(esc(`${cell.file}:${cell.line}`));
123
+ const bad = new Set((res.badLines || []).map((s) => s.trim()));
124
+ const codeHtml = (cell.unitBody || '').split('\n').map((l) => {
125
+ const t = l.trim();
126
+ const html = hlCode(esc(l));
127
+ return (t && bad.has(t)) ? `<span class="badline">${html}</span>` : html;
128
+ }).join('\n');
129
+ const body = isMod
130
+ ? `<div class="dh">Contains</div><pre class="code">${esc(cell.contains.join('\n'))}</pre>`
131
+ : (cell.unitBody ? `<div class="dh">Code</div><pre class="code">${codeHtml}</pre>`
132
+ : `<div class="dh">Code</div><div class="allok">no unit body found below the spec</div>`);
133
+ const sym = { red: '✗', yellow: '⚠', info: '•' };
134
+ const checks = (res.notes || []).length
135
+ ? `<div class="dh">Checks</div><ul class="checks">${res.notes.map((n) => `<li class="ck-${n.level}">${sym[n.level] || '•'} ${esc(n.text)}</li>`).join('')}</ul>`
136
+ : `<div class="dh">Checks</div><div class="allok" style="color:${col}">✓ all checks passed</div>`;
137
+ // History / semantic timeline — a Cell's provenance events over time (P4). Prefer the stitched
138
+ // timeline (approvals/attestations/rejections from the ledgers); fall back to the derived
139
+ // created/signed/changed markers (lock + git) when no ledger events exist.
140
+ let history = '';
141
+ const TLSYM = { signed: '✍', delegated: '⚡', ratified: '✅', attested: '◆', rejected: '✗' };
142
+ const TLCLS = { signed: 'ck-info', delegated: 'ck-yellow', ratified: 'ck-info', attested: 'ck-info', rejected: 'ck-red' };
143
+ if (t.timeline && t.timeline.length) {
144
+ const evs = [];
145
+ if (t.createdCode) evs.push(`<li class="ck-info">• created in code · ${fmtWhen(t.createdCode)}</li>`);
146
+ for (const e of t.timeline) evs.push(`<li class="${TLCLS[e.kind] || 'ck-info'}">${TLSYM[e.kind] || '•'} ${esc(e.text)}${e.at ? ` · ${fmtWhen(e.at)}` : ''}</li>`);
147
+ history = `<div class="dh">Timeline</div><ul class="checks">${evs.join('')}</ul>`;
148
+ } else {
149
+ const hist = [];
150
+ if (t.createdCode) hist.push(`created in code · ${fmtWhen(t.createdCode)}`);
151
+ if (res.trust && res.trust.firstAt) hist.push(`first signed · ${fmtWhen(res.trust.firstAt)}`);
152
+ if (res.trust && res.trust.signed && res.trust.at) hist.push(`signed by ${esc(res.trust.signer)} · ${fmtWhen(res.trust.at)}`);
153
+ if (t.changedCode && t.changedCode !== t.createdCode) hist.push(`code last changed · ${fmtWhen(t.changedCode)}`);
154
+ history = hist.length ? `<div class="dh">History</div><ul class="checks">${hist.map((x) => `<li class="ck-info">• ${x}</li>`).join('')}</ul>` : '';
155
+ }
156
+ // Impact — computed from the call graph, not narrated. "What breaks if this is wrong."
157
+ const impact = isMod ? '' : `<div class="dh">Impact</div><ul class="checks">
158
+ <li>▲ <b>${res.blast || 0}</b> Cell(s) depend on this${res.dependents ? ` — ${res.dependents} directly` : ''}${(res.blast || 0) === 0 ? ' (nothing breaks downstream)' : ''}</li>
159
+ ${res.isEntry ? '<li class="ck-info">• public entry point — external callers expected</li>' : ''}
160
+ ${res.bloat ? '<li class="ck-yellow">⚠ no callers found — possible dead code (or called dynamically)</li>' : ''}</ul>`;
161
+ // What changed in THIS Cell's spec since the last commit (old red / new green).
162
+ const diffSection = (cell.diff && cell.diff.length)
163
+ ? `<div class="dh">Changes since last commit</div><pre class="code diff">${cell.diff.map((d) => {
164
+ const cls = d.t === '+' ? 'dl-add' : d.t === '-' ? 'dl-del' : 'dl-ctx';
165
+ return `<span class="${cls}">${esc((d.t === '+' ? '+ ' : d.t === '-' ? '- ' : ' ') + d.text)}</span>`;
166
+ }).join('\n')}</pre>`
167
+ : '';
168
+ // green ≠ proven: show whether the promise is machine-proven or only signed.
169
+ const provBadge = (res.state === 'GREEN' && res.hasEnsures)
170
+ ? `<span class="mpill" style="color:${res.proven ? 'var(--accent)' : 'var(--amber)'};margin-left:6px" title="${res.proven ? 'ensures machine-proven' : 'signed, but its ensures is not machine-checked — strengthen it'}">${res.proven ? '✓ proven' : '● unproven'}</span>`
171
+ : '';
172
+ // Branch-exercise honesty: show the coverage boundary of a proof (only when some branch was
173
+ // missed — full coverage needs no badge). Never a colour change; policy decides gate impact.
174
+ const covBadge = (res.coverage && res.coverage.total && res.coverage.missed && res.coverage.missed.length)
175
+ ? `<span class="mpill" style="color:var(--amber);margin-left:6px" title="Proven, but only ${res.coverage.exercised} of ${res.coverage.total} branches were exercised by spec-derived inputs — unexercised branches (where a dormant branch hides) at ${esc(res.coverage.missed.slice(0, 4).map((m) => 'line ' + m.line).join(', '))}.">◧ ${res.coverage.exercised}/${res.coverage.total} branches</span>`
176
+ : '';
177
+ // Undeclared-input predicate provenance: a branch keys off an input the spec's in: never declares.
178
+ const predBadge = (res.predicate && res.predicate.findings && res.predicate.findings.length)
179
+ ? `<span class="mpill" style="color:var(--amber);margin-left:6px" title="A branch keys off ${esc((res.predicate.undeclared || []).join(', '))}, which this Cell's in: never declares — an undeclared control input (a hidden mode/flag hides here). Declare it in in: (or a throws:/ensures case), or prune the branch.">◈ undeclared input</span>`
180
+ : '';
181
+ // Autopilot: mark Cells approved by a delegation grant (not a human) — awaiting ratification.
182
+ const autoBadge = (res.trust && res.trust.auto)
183
+ ? `<span class="mpill" style="color:var(--amber);margin-left:6px" title="Delegated under grant ${esc(res.trust.grant || '')} (Autopilot) — awaiting ratification, NOT human-reviewed. Run \`yay ratify\` to sign it for real.">⚡ Delegated</span>`
184
+ : '';
185
+ return `<div class="mhead"><span class="mid">${esc(cell.id)}</span><span class="mname">${esc(cell.unitName || cell.spec.unit || cell.id)}</span><span class="mpill" style="color:${col}">${label}</span>${provBadge}${covBadge}${predBadge}${autoBadge}</div>
186
+ <div class="dmeta">${meta.map((m) => `<span>${m}</span>`).join('')}</div>
187
+ <div class="dh">Sealed spec</div><pre class="code">${colorizeSpec(cell.specBlock)}</pre>
188
+ ${diffSection}
189
+ ${body}
190
+ ${history}
191
+ ${impact}
192
+ ${checks}`;
193
+ }
194
+
195
+ function renderMap(manifest, verified, project, changes, times, planDoc, gov, briefs, tagCfg, policyInfo, tagSets, batchCfg, extra) {
196
+ extra = extra || {};
197
+ // Build the hierarchy: system → module → sub-group → unit.
198
+ const nodes = {}; const details = {};
199
+ const ensure = (id, label, kind, parent) => {
200
+ if (!nodes[id]) nodes[id] = { id, label, kind, parent: parent || null, children: [] };
201
+ return nodes[id];
202
+ };
203
+ ensure('system', project || 'system', 'system', null);
204
+
205
+ for (const id of Object.keys(verified.results)) {
206
+ const res = verified.results[id];
207
+ const mc = manifest.cells[id];
208
+ const cell = mc || {
209
+ id, unitName: res.name || id, file: res.file, line: res.line, lang: res.lang,
210
+ spec: { intent: res.untracked ? 'No formal specification — untracked code.' : '' },
211
+ feeds: [], contains: [], specHash: '', specBlock: '', unitBody: null,
212
+ };
213
+ const file = res.file || cell.file || 'other';
214
+ const module = (mc && mc.module) || res.module || base(file);
215
+ const group = (mc && mc.group) || res.group || 'Internal';
216
+ const modId = 'm:' + module;
217
+ const grpId = 'g:' + module + '\u0000' + group;
218
+ const unitId = 'u:' + id;
219
+ const m = ensure(modId, module, 'module', 'system');
220
+ if (!nodes.system.children.includes(modId)) nodes.system.children.push(modId);
221
+ const g = ensure(grpId, group, 'group', modId);
222
+ if (!m.children.includes(grpId)) m.children.push(grpId);
223
+ nodes[unitId] = { id: unitId, label: cell.unitName || cell.spec.unit || id, kind: 'unit', parent: grpId, children: [], state: res.state, cellId: cell.id, intent: cell.spec.intent || '', blast: res.blast || 0, bloat: !!res.bloat, auto: !!(res.trust && res.trust.auto) };
224
+ g.children.push(unitId);
225
+ details[unitId] = detailInner(cell, res, (times && times[id]) || {});
226
+ }
227
+
228
+ // roll-up state + counts (post-order)
229
+ const ru = (id) => {
230
+ const n = nodes[id];
231
+ if (n.kind === 'unit') { n.count = 1; return n.state; }
232
+ let s = 'GREEN', c = 0;
233
+ for (const k of n.children) { const cs = ru(k); if (SEV[cs] > SEV[s]) s = cs; c += nodes[k].count; }
234
+ n.state = s; n.count = c; return s;
235
+ };
236
+ ru('system');
237
+ // order children: modules by name; groups by subOrder; units by name
238
+ for (const n of Object.values(nodes)) {
239
+ if (n.kind === 'module') n.children.sort((a, b) => subOrder(nodes[a].label) - subOrder(nodes[b].label) || nodes[a].label.localeCompare(nodes[b].label));
240
+ else if (n.kind === 'unit') { /* leaf */ } else n.children.sort((a, b) => nodes[a].label.localeCompare(nodes[b].label));
241
+ }
242
+
243
+ // trim to a light payload
244
+ const YLnodes = {};
245
+ for (const id of Object.keys(nodes)) {
246
+ const n = nodes[id];
247
+ YLnodes[id] = { id, label: n.label, kind: n.kind, color: COLORS[n.state][0], state: n.state, count: n.count || 1, children: n.children, parent: n.parent, intent: n.intent || '', blast: n.blast || 0, bloat: !!n.bloat, auto: !!n.auto };
248
+ }
249
+ const modEdges = (manifest.moduleEdges || [])
250
+ .filter(([a, b]) => nodes['m:' + a] && nodes['m:' + b] && a !== b)
251
+ .map(([a, b]) => ['m:' + a, 'm:' + b]);
252
+ // "Needs attention" — the gate-blockers a human must act on, leaf Cells only
253
+ // (containers just roll up). Order: unsigned (needs signing) → red → pink.
254
+ const NEEDSEV = { UNSIGNED: 0, RED: 1, PINK: 2 };
255
+ const needs = Object.keys(verified.results)
256
+ .filter((id) => NEEDSEV[verified.results[id].state] !== undefined
257
+ && !(nodes['u:' + id] && nodes['u:' + id].kind !== 'unit')
258
+ && !(manifest.cells[id] && manifest.cells[id].contains && manifest.cells[id].contains.length))
259
+ .map((id) => ({ id: 'u:' + id, state: verified.results[id].state }))
260
+ .sort((a, b) => NEEDSEV[a.state] - NEEDSEV[b.state]);
261
+ const c = verified.counts;
262
+ // The five gate states as gradient stat blocks (replaces the flat legend). YayLayer-toned
263
+ // gradients; white text; the Green block shows proven/unproven as its sub-metric.
264
+ const STAT_META = {
265
+ GREEN: { label: 'Green', glyph: '✓', sub: 'proven &amp; signed', grad: 'linear-gradient(135deg,#18b368,#0f8a4d)' },
266
+ YELLOW: { label: 'Yellow', glyph: '⚠', sub: 'signed, not proven', grad: 'linear-gradient(135deg,#e0a53a,#c17d16)' },
267
+ RED: { label: 'Red', glyph: '✕', sub: 'contradicts its spec', grad: 'linear-gradient(135deg,#ef5b57,#cc352c)' },
268
+ UNSIGNED: { label: 'Unsigned', glyph: '✎', sub: 'awaiting a signature', grad: 'linear-gradient(135deg,#8b95a6,#586274)' },
269
+ PINK: { label: 'Pink', glyph: '◆', sub: 'no spec — blocks the gate', grad: 'linear-gradient(135deg,#ec6aa6,#cf3f86)' },
270
+ };
271
+ // Matched inline line-icons (Feather set, MIT) for the sidebar nav — stroke:currentColor so they
272
+ // tint with the tab (muted → accent when active). Uniform 24×24, no fill.
273
+ const ICONS = {
274
+ project: '<svg viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>',
275
+ map: '<svg viewBox="0 0 24 24"><polygon points="1 6 8 3 16 6 23 3 23 18 16 21 8 18 1 21"/><line x1="8" y1="3" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="21"/></svg>',
276
+ briefs: '<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>',
277
+ plan: '<svg viewBox="0 0 24 24"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>',
278
+ tags: '<svg viewBox="0 0 24 24"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>',
279
+ files: '<svg viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>',
280
+ signers: '<svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
281
+ policy: '<svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>',
282
+ commands: '<svg viewBox="0 0 24 24"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>',
283
+ capability: '<svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><polyline points="9 12 11.5 14.5 16 9.5"/></svg>',
284
+ manual: '<svg viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
285
+ ask: '<svg viewBox="0 0 24 24"><path d="M21 11.5a8.38 8.38 0 0 1-8.5 8.5 8.5 8.5 0 0 1-3.9-.9L3 21l1.9-5.6A8.5 8.5 0 1 1 21 11.5z"/></svg>',
286
+ grants: '<svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
287
+ add: '<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
288
+ };
289
+ const statblocks = '<div class="statgrid">' + ['GREEN', 'YELLOW', 'RED', 'UNSIGNED', 'PINK'].map((k) => {
290
+ const m = STAT_META[k];
291
+ const sub = (k === 'GREEN' && (c.GREEN || 0) > 0) ? `${c.proven || 0} proven · ${c.unproven || 0} unproven` : m.sub;
292
+ return `<div class="statcard" style="background:${m.grad}"><div class="sc-top"><span class="sc-label">${m.label}</span><span class="sc-glyph">${m.glyph}</span></div><div class="sc-num">${c[k] || 0}</div><div class="sc-sub">${sub}</div></div>`;
293
+ }).join('') + '</div>';
294
+ // Foundation-seal banner (reveal): a broken/expected-missing seal shows loudly on the map.
295
+ const esc = (s) => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
296
+ let foundationBanner = '';
297
+ const fnd = verified.foundation;
298
+ if (fnd && (fnd.expectedButMissing || !fnd.clean)) {
299
+ const strict = fnd.mode === 'strict';
300
+ let msg;
301
+ if (fnd.expectedButMissing) {
302
+ msg = 'Foundation protection is expected but no valid seal exists under the current trust root. Run <code>yay protect</code> to (re-)seal.';
303
+ } else {
304
+ const bits = [];
305
+ if (fnd.changed && fnd.changed.length) bits.push(fnd.changed.length + ' changed (' + fnd.changed.map(esc).join(', ') + ')');
306
+ if (fnd.missing && fnd.missing.length) bits.push(fnd.missing.length + ' missing (' + fnd.missing.map(esc).join(', ') + ')');
307
+ if (fnd.addedFiles && fnd.addedFiles.length) bits.push(fnd.addedFiles.length + ' new file(s) (' + fnd.addedFiles.map(esc).join(', ') + ')');
308
+ if (fnd.removedFiles && fnd.removedFiles.length) bits.push(fnd.removedFiles.length + ' removed (' + fnd.removedFiles.map(esc).join(', ') + ')');
309
+ msg = '<b>A sealed core file (or watched zone) changed since it was sealed:</b> ' + bits.join(' · ') + '. If you did this, re-seal (<code>yay protect</code>). If not, investigate — this reveals tampering or corruption.';
310
+ }
311
+ foundationBanner = `<div style="border:1px solid var(--red);border-left:4px solid var(--red);border-radius:12px;padding:12px 16px;margin:0 0 18px;background:color-mix(in srgb,var(--red) 8%,var(--card))"><div style="font-weight:800;color:var(--red);font-size:.92rem">⚠ FOUNDATION ${fnd.expectedButMissing ? 'UNSEALED' : 'CHANGED'}${strict ? ' — gate blocked' : ''}</div><div style="font-size:.84rem;color:var(--ink2);margin-top:4px">${msg}</div></div>`;
312
+ }
313
+ const totalUnits = Object.values(nodes).filter((n) => n.kind === 'unit').length;
314
+ const FILES = [];
315
+ for (const id of Object.keys(verified.results)) {
316
+ const res = verified.results[id];
317
+ const mc = manifest.cells[id];
318
+ if (mc && mc.contains && mc.contains.length) continue; // container Cells aren't file units
319
+ FILES.push({ file: res.file || (mc && mc.file) || 'other', name: (mc && (mc.unitName || (mc.spec && mc.spec.unit))) || res.name || id, id: 'u:' + id, state: res.state, line: res.line || (mc && mc.line) || 0, auto: !!(res.trust && res.trust.auto), grant: (res.trust && res.trust.grant) || null });
320
+ }
321
+ // Per-Cell spec-block character count — so the Briefs "Chart" view can plot the growth
322
+ // of signed Specs + Briefs over time (a Brief's weight = its own chars + its Cells' specs).
323
+ const specChars = {};
324
+ for (const id of Object.keys(manifest.cells)) { const c = manifest.cells[id]; if (c) specChars[id] = (c.specBlock || '').length; }
325
+ // Per-Cell ratify decision signals: the already-computed flags for each delegated Cell, so the human
326
+ // ratifying sees complexity + smells at a glance (surfacing only — no new check). Extensible: add a chip.
327
+ const ratFlags = (r) => {
328
+ const chips = [];
329
+ if (r.predicate && (r.predicate.undeclared || []).length) chips.push('◈ undeclared input');
330
+ if (r.coverage && r.coverage.total && (r.coverage.missed || []).length) chips.push(r.coverage.exercised + '/' + r.coverage.total + ' branches');
331
+ let inert = false, weak = false;
332
+ for (const nt of (r.notes || [])) { if (/^inert code/.test(nt.text)) inert = true; else if (/weak ensures/.test(nt.text)) weak = true; }
333
+ if (inert) chips.push('inert');
334
+ if (weak) chips.push('weak ensures');
335
+ return chips;
336
+ };
337
+ const ratifyCells = (function () { try { return autoCellIds(verified).map((id) => { const r = verified.results[id], c = manifest.cells[id] || {}; return { id, uid: 'u:' + id, unit: (c.unitName || (c.spec && c.spec.unit) || id), state: r.state, grant: (r.trust && r.trust.grant) || '', flags: ratFlags(r) }; }); } catch (_) { return []; } })();
338
+ const meta = { project: project || 'project', counts: verified.counts, passed: verified.passed, totalUnits, plan: planDoc || null, gov: gov || null, files: FILES, briefs: briefs || [], specChars, tags: (tagCfg && tagCfg.tags) || [], tagSet: (tagCfg && tagCfg.set) || null, tagDescriptions: (tagCfg && tagCfg.descriptions) || {}, tagSets: tagSets || [], batch: batchCfg || { enabled: true, barrier: 5 }, policy: policyInfo || { enforced: [], draft: [], violations: [], signers: [] }, signMethod: (policyInfo && policyInfo.signMethod) || 'phone', ratify: (function(){ try { var b = ratifyBundle(manifest, verified); return { hash: b.hash, count: b.ids.length }; } catch (_) { return { hash: null, count: 0 }; } })(), ratifyCells, grants: extra.grants || [], rejections: extra.rejections || [], attest: extra.attest || null, timelines: extra.timelines || {}, capability: extra.capability || null, foundation: verified.foundation || null, demo: !!extra.demo };
339
+ const payload = JSON.stringify({ root: 'system', nodes: YLnodes, edges: { system: modEdges }, details, changes: changes || [], needs, meta })
340
+ .replace(/</g, '\\u003c');
341
+
342
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">
343
+ <meta name="viewport" content="width=device-width, initial-scale=1">
344
+ <title>YayLayer map · ${esc(project || 'project')}</title>
345
+ <style>
346
+ :root{
347
+ --paper:#ffffff;--card:#ffffff;--card2:#f7f8f9;--code:#f1f3f5;--codebg:#1c1c1c;--codeink:#e6e6e6;
348
+ --ink:#141414;--ink2:#525c64;--mut:#8a939b;--rule:#e6e8eb;--accent:#1a8f5f;--brand:#3ecf8e;
349
+ --red:#d92d20;--amber:#b7791f;--blue:#6ea8fe;--purple:#c197fb;
350
+ --mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace;
351
+ --sans:"Inter",system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
352
+ --shadow:0 1px 2px rgba(16,24,40,.04),0 1px 3px rgba(16,24,40,.06);}
353
+ :root[data-theme="dark"]{
354
+ --paper:#171717;--card:#1e1e1e;--card2:#212121;--code:#262626;--codebg:#141414;--codeink:#e6e6e6;
355
+ --ink:#ededed;--ink2:#a6a6a6;--mut:#7a7a7a;--rule:#2b2b2b;--accent:#3ecf8e;--brand:#3ecf8e;
356
+ --red:#ff6b6b;--amber:#e0a437;--blue:#6ea8fe;--purple:#c197fb;
357
+ --shadow:0 1px 2px rgba(0,0,0,.3),0 2px 8px rgba(0,0,0,.25);}
358
+ *{box-sizing:border-box}
359
+ body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--sans);line-height:1.6;-webkit-font-smoothing:antialiased}
360
+ /* ── shell: fixed left sidebar + main column ── */
361
+ .side{position:fixed;left:0;top:0;width:242px;height:100vh;z-index:70;display:flex;flex-direction:column;background:var(--card);border-right:1px solid var(--rule);transition:transform .22s ease}
362
+ .brand{display:flex;align-items:center;gap:9px;min-width:0;padding:17px 18px;border-bottom:1px solid var(--rule)}
363
+ .brand .logo{width:26px;height:26px;flex:none;display:inline-flex}
364
+ .brandname{font-weight:700;font-size:1.02rem;letter-spacing:-.01em;color:var(--ink)}
365
+ .brandsep{color:var(--mut)}
366
+ .brandproj{color:var(--ink2);font-family:var(--mono);font-size:.8rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
367
+ .sidenav{flex:1;overflow-y:auto;padding:8px 12px 20px}
368
+ .navgroup{font-family:var(--sans);font-size:.62rem;font-weight:700;text-transform:uppercase;letter-spacing:.09em;color:var(--mut);padding:12px 10px 5px;line-height:1.2}
369
+ .navgroup:first-child{padding-top:8px}
370
+ .tab{display:flex;align-items:center;gap:11px;width:100%;text-align:left;font-family:var(--sans);font-size:.88rem;font-weight:500;line-height:1.10;letter-spacing:-.005em;background:none;border:none;color:var(--ink2);border-radius:9px;padding:8px 12px;cursor:pointer;transition:color .15s ease,background .15s ease}
371
+ .navrow{display:flex;align-items:center;gap:2px}
372
+ .navrow .tab{flex:1;min-width:0}
373
+ .navrefresh{flex:none;background:none;border:none;color:var(--mut);cursor:pointer;font-size:.95rem;line-height:1;padding:4px 7px;margin-right:4px;border-radius:7px;transition:color .14s,background .14s}
374
+ .navrefresh:hover{color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,transparent)}
375
+ .navrefresh.spin{animation:ask-spin .7s linear infinite;color:var(--accent)}
376
+ .tab .ti{width:18px;height:18px;flex:none;display:inline-flex;align-items:center;justify-content:center;opacity:.75}
377
+ .tab .ti svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
378
+ .projrow{display:flex;align-items:flex-start;gap:11px;padding:8px 12px;margin:0 2px;border-radius:9px;background:color-mix(in srgb,var(--ink) 4%,transparent);border:1px solid var(--rule)}
379
+ .projrow .ti{width:18px;height:18px;flex:none;display:inline-flex;align-items:center;justify-content:center;color:var(--accent);margin-top:1px}
380
+ .projrow .ti svg{width:17px;height:17px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
381
+ .projname{font-family:var(--sans);font-size:.82rem;font-weight:600;color:var(--ink);line-height:1.35;word-break:break-word;min-width:0}
382
+ .tab:hover:not(.active){color:var(--ink);background:color-mix(in srgb,var(--ink) 6%,transparent)}
383
+ .tab.active{background:color-mix(in srgb,var(--brand) 15%,transparent);color:var(--accent);font-weight:600}
384
+ .tab.active .ti{opacity:1}
385
+ .tab.navext{text-decoration:none}
386
+ .navext-ico{margin-left:auto;font-size:.78rem;opacity:.45}
387
+ /* smooth auto-save indicator (e.g. Batch settings): a soft pill that fades in on save and out after */
388
+ .savepill{font-size:.76rem;font-weight:600;padding:3px 11px;border-radius:100px;opacity:0;transition:opacity .4s ease;white-space:nowrap}
389
+ .savepill.show{opacity:1}
390
+ .savepill.saving{color:var(--mut);background:color-mix(in srgb,var(--ink) 7%,transparent)}
391
+ .savepill.saved{color:var(--accent);background:color-mix(in srgb,var(--brand) 16%,transparent)}
392
+ .savepill.err{color:var(--red);background:color-mix(in srgb,var(--red) 13%,transparent)}
393
+ .themebtn{font-family:var(--sans);font-size:.8rem;font-weight:500;background:var(--card);color:var(--ink);border:1px solid var(--rule);border-radius:9px;padding:7px 13px;cursor:pointer}
394
+ .themebtn:hover{border-color:var(--mut)}
395
+ .viewbtn{font-family:var(--sans);font-size:.8rem;font-weight:600;background:var(--brand);color:#04231a;border:1px solid transparent;border-radius:9px;padding:7px 15px;cursor:pointer}
396
+ .viewbtn:hover{filter:brightness(1.05)}
397
+ .main{margin-left:242px;min-height:100vh}
398
+ .topbar{position:sticky;top:0;z-index:50;height:60px;display:flex;align-items:center;gap:14px;padding:0 32px;background:color-mix(in srgb,var(--paper) 85%,transparent);backdrop-filter:blur(8px);border-bottom:1px solid var(--rule)}
399
+ .tb-title{font-family:var(--sans);font-weight:700;font-size:1.02rem;color:var(--ink)}
400
+ .tb-right{margin-left:auto;display:flex;align-items:center;gap:9px}
401
+ .gatepill{font-family:var(--sans);font-size:.74rem;font-weight:600;border-radius:100px;padding:5px 13px;border:1px solid var(--rule);background:var(--card)}
402
+ .gatepill.ok{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 40%,transparent)}
403
+ .gatepill.bad{color:var(--red);border-color:color-mix(in srgb,var(--red) 40%,transparent)}
404
+ .navburger{display:none;align-items:center;justify-content:center;width:38px;height:36px;font-size:1.05rem;line-height:1;background:var(--card);color:var(--ink);border:1px solid var(--rule);border-radius:9px;cursor:pointer}
405
+ .navburger:hover{border-color:var(--mut)}
406
+ .scrim{display:none}
407
+ #yd-slot:empty{display:none}
408
+ #yd-slot{border-top:1px solid var(--rule)}
409
+ /* Static replica of the live-dashboard dock, shown in the demo (mirrors dashboard.js .yd-dock). */
410
+ .yd-dock{display:flex;flex-direction:column;gap:0;padding:7px 12px 9px}
411
+ .yd-dock .yd-livewrap{display:flex;align-items:center;justify-content:space-between;padding:0 2px 5px}
412
+ .yd-dock .yd-live{font-family:var(--sans);font-size:.62rem;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:#1f9d57;padding:0 2px}
413
+ .yd-dock .yd-refresh{color:var(--mut);font-size:1rem;line-height:1;padding:2px 5px;border-radius:7px}
414
+ .yd-dock .yd-btn{display:flex;align-items:center;gap:11px;width:100%;text-align:left;color:var(--ink2);border-radius:8px;padding:5px 12px;line-height:1.25;font-family:var(--sans);font-size:.84rem;font-weight:500;cursor:default}
415
+ .yd-dock.yd-demo .yd-btn:hover{background:color-mix(in srgb,var(--ink) 5%,transparent)}
416
+ .yd-dock .yd-btn.yd-primary{color:var(--accent);font-weight:600}
417
+ .yd-dock .yd-icon{flex:0 0 18px;display:inline-flex;align-items:center;justify-content:center;font-size:13px;opacity:.85}
418
+ .yd-demo-note{font-family:var(--sans);font-size:.6rem;color:var(--mut);padding:6px 2px 0;line-height:1.4}
419
+ /* ── state stat blocks (the 5 gate states as gradient cards) ── */
420
+ .statgrid{display:grid;grid-template-columns:repeat(5,1fr);gap:14px;margin:0 0 22px}
421
+ .statcard{position:relative;border-radius:14px;padding:15px 17px;color:#fff;min-height:114px;display:flex;flex-direction:column;justify-content:space-between;box-shadow:0 8px 20px -12px rgba(0,0,0,.45);overflow:hidden}
422
+ .statcard::after{content:"";position:absolute;right:-26px;top:-26px;width:92px;height:92px;border-radius:50%;background:rgba(255,255,255,.12)}
423
+ .sc-top{display:flex;align-items:center;justify-content:space-between;gap:8px;position:relative;z-index:1}
424
+ .sc-label{font-family:var(--sans);font-size:.82rem;font-weight:700;letter-spacing:.01em}
425
+ .sc-glyph{font-size:1.1rem;opacity:.92}
426
+ .sc-num{font-family:var(--sans);font-size:2.15rem;font-weight:800;line-height:1;margin:8px 0 2px;font-variant-numeric:tabular-nums;position:relative;z-index:1}
427
+ .sc-sub{font-family:var(--sans);font-size:.72rem;opacity:.93;position:relative;z-index:1}
428
+ @media(max-width:1080px){.statgrid{grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}}
429
+ @media(max-width:900px){
430
+ .side{transform:translateX(-100%);box-shadow:0 24px 70px rgba(0,0,0,.45)}
431
+ .side.open{transform:translateX(0)}
432
+ .main{margin-left:0}
433
+ .topbar{padding:0 16px}
434
+ .navburger{display:inline-flex}
435
+ .scrim.open{display:block;position:fixed;inset:0;z-index:65;background:rgba(10,12,16,.5)}
436
+ .wrap{padding:20px 16px 72px}
437
+ .wrap>*{min-width:0}
438
+ h1{font-size:1.25rem}
439
+ }
440
+ .signers{margin-top:4px;max-width:900px}
441
+ .rootcard{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;background:var(--card2);border:1px solid var(--rule);border-radius:12px;padding:16px 18px;margin:0 0 20px}
442
+ .rootlbl{font-family:var(--sans);font-size:.68rem;font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:var(--ink2)}
443
+ .rootfp{font-family:var(--mono);font-size:1.05rem;font-weight:700;color:var(--ink);margin-top:2px}
444
+ .srow{display:flex;align-items:flex-start;gap:14px;border:1px solid var(--rule);border-radius:12px;padding:15px 18px;margin:0 0 10px;background:var(--card);box-shadow:var(--shadow)}
445
+ .mcell{display:inline-block;font-family:var(--mono);font-size:.78rem;padding:2px 9px;margin:5px 6px 0 0;border-radius:7px;border:1px solid var(--rule);color:var(--mut)}
446
+ .mcell.known{cursor:pointer;color:var(--accent);border-color:var(--accent)}
447
+ .mcell.known:hover{background:var(--rule)}
448
+ .savatar{width:40px;height:40px;border-radius:50%;flex:none;display:flex;align-items:center;justify-content:center;font-weight:700;color:#fff;font-family:var(--sans)}
449
+ .sname{font-family:var(--sans);font-weight:700;font-size:1rem}
450
+ .srole{font-family:var(--sans);font-size:.58rem;text-transform:uppercase;letter-spacing:.08em;font-weight:700;padding:2px 9px;border-radius:100px;margin-left:8px;vertical-align:middle}
451
+ .srole.owner{background:color-mix(in srgb,var(--brand) 22%,transparent);color:var(--accent)}
452
+ .srole.signer{background:var(--card2);color:var(--ink2);border:1px solid var(--rule)}
453
+ .smeta{font-family:var(--sans);font-size:.72rem;color:var(--mut);margin-top:4px}
454
+ .skey{font-family:var(--mono);font-size:.72rem;color:var(--ink2);display:flex;align-items:center;gap:8px;margin-top:6px;flex-wrap:wrap}
455
+ .skeykind{font-size:.58rem;text-transform:uppercase;letter-spacing:.05em;color:var(--mut);border:1px solid var(--rule);border-radius:5px;padding:1px 6px}
456
+ .swarn{color:var(--amber);font-family:var(--sans);font-size:.82rem;margin:0 0 12px}
457
+ .snote{color:var(--mut);font-family:var(--sans);font-size:.82rem}
458
+ .ask-ans{font-family:var(--sans);font-size:.84rem;line-height:1.62;color:var(--ink);max-width:760px}
459
+ .ask-ans p{margin:.5em 0}
460
+ .ask-ans .ask-h2{font-weight:700;font-size:.98rem;color:var(--accent);margin:1em 0 .3em}
461
+ .ask-ans .ask-h3{font-weight:700;font-size:.9rem;color:var(--ink);margin:.85em 0 .25em}
462
+ .ask-ans ul,.ask-ans ol{margin:.4em 0 .5em 1.15em;padding:0}
463
+ .ask-ans li{margin:.22em 0}
464
+ .ask-ans code{font-family:var(--mono);font-size:.82em;background:var(--card2);border:1px solid var(--rule);border-radius:5px;padding:.05em .35em}
465
+ .ask-ref{font-family:var(--mono);font-size:.82em;color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 40%,transparent);border-radius:5px;padding:.03em .32em;cursor:pointer;white-space:nowrap;text-decoration:none}
466
+ .ask-ref:hover{background:color-mix(in srgb,var(--accent) 12%,transparent)}
467
+ .ask-spin{width:12px;height:12px;border:2px solid color-mix(in srgb,var(--accent) 28%,transparent);border-top-color:var(--accent);border-radius:50%;display:inline-block;vertical-align:-2px;margin-right:7px;animation:ask-spin .7s linear infinite}
468
+ .ask-think{margin-left:5px}
469
+ .ask-think .ad{display:inline-block;width:4px;height:4px;margin:0 1px;border-radius:50%;background:var(--accent);animation:ask-blink 1.2s infinite both}
470
+ .ask-think .ad:nth-child(2){animation-delay:.18s}.ask-think .ad:nth-child(3){animation-delay:.36s}
471
+ @keyframes ask-spin{to{transform:rotate(360deg)}}
472
+ @keyframes ask-blink{0%,80%,100%{opacity:.2}40%{opacity:1}}
473
+ @media (prefers-reduced-motion:reduce){.ask-spin{animation:none}.ask-think .ad{animation:none;opacity:.6}}
474
+ .ask-ex-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:6px}
475
+ .ask-ex-grid .ask-ex{width:100%;text-align:left;font-weight:500}
476
+ @media(max-width:760px){.ask-ex-grid{grid-template-columns:1fr}}
477
+ .signers h1{margin:0 0 16px}
478
+ .files{margin-top:4px;max-width:900px}
479
+ .files h1{margin:0 0 6px}
480
+ .ftree{border:1px solid var(--rule);border-radius:12px;background:var(--card);overflow:hidden;box-shadow:var(--shadow)}
481
+ .frow{display:flex;align-items:center;gap:9px;padding:6px 12px;font-family:var(--mono);font-size:.8rem;border-top:1px solid transparent}
482
+ .fdir{color:var(--ink2);font-weight:600}
483
+ .ffile{color:var(--ink);font-weight:600;border-top:1px solid var(--rule)}
484
+ .fcell{cursor:pointer;color:var(--ink2)}
485
+ .fcell:hover{background:var(--card2)}
486
+ .fcell.bad{color:var(--ink)}
487
+ .fdot{width:8px;height:8px;border-radius:50%;flex:none}
488
+ .fname{flex:1;min-width:0}
489
+ .funit{flex:1;min-width:0}
490
+ .fcount{color:var(--mut);font-size:.7rem}
491
+ .fstate{font-size:.58rem;text-transform:uppercase;letter-spacing:.05em;font-weight:600}
492
+ .commands{max-width:840px;margin-top:4px}
493
+ .commands h1{margin:0 0 6px}
494
+ .cmdcard{border:1px solid var(--rule);border-radius:12px;background:var(--card);box-shadow:var(--shadow);padding:16px 18px;margin:0 0 12px}
495
+ .cmdname{font-family:var(--mono);font-size:.95rem;font-weight:700;color:var(--ink)}
496
+ .cmddesc{color:var(--ink2);font-size:.9rem;margin:6px 0 0}
497
+ .cmdflags{margin-top:12px;border-top:1px solid var(--rule);padding-top:11px;display:flex;flex-direction:column;gap:7px}
498
+ .cmdflag{display:flex;gap:12px;align-items:baseline;font-size:.82rem;flex-wrap:wrap}
499
+ .cmdflag code{font-family:var(--mono);font-size:.76rem;color:var(--accent);background:var(--card2);border:1px solid var(--rule);border-radius:6px;padding:2px 7px;white-space:nowrap;flex:none;min-width:172px}
500
+ .cmdflag span{color:var(--ink2)}
501
+ .commands code,.foot code,.snote code{font-family:var(--mono);font-size:.85em;background:var(--card2);border:1px solid var(--rule);border-radius:5px;padding:1px 5px}
502
+ .wrap{max-width:1800px;margin:0;padding:28px 32px 80px}
503
+ .pagehead{margin:0 0 16px}
504
+ h1{font-family:var(--sans);font-size:1.5rem;font-weight:700;letter-spacing:-.02em;margin:0 0 4px}
505
+ .sub{color:var(--mut);font-family:var(--sans);font-size:.78rem;margin:0}
506
+ .legend{display:flex;gap:9px;flex-wrap:wrap;font-family:var(--mono);font-size:.82rem;font-weight:600;margin:0 0 20px}
507
+ .lg{display:inline-flex;align-items:center;gap:7px;background:var(--card2);border:1px solid var(--rule);border-radius:100px;padding:7px 15px}
508
+ .lg::before{content:"●";font-size:1.05em}
509
+ .crumb{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-family:var(--sans);font-size:.8rem;margin-bottom:10px}
510
+ .crumb button{appearance:none;background:none;border:none;color:var(--accent);font:inherit;cursor:pointer;padding:2px 4px;border-radius:6px}
511
+ .crumb button:hover{background:var(--card2)}
512
+ .crumb .here{color:var(--ink);font-weight:600}
513
+ .crumb .sep{color:var(--mut)}
514
+ .crumb .upbtn{background:var(--card);border:1px solid var(--rule);color:var(--ink);border-radius:8px;padding:4px 11px;margin-right:4px}
515
+ .crumb .upbtn:hover:not(:disabled){border-color:var(--mut)}
516
+ .crumb .upbtn:disabled{opacity:.4;cursor:default}
517
+ .hint{color:var(--mut);font-size:.82rem;margin:0 0 12px}
518
+ .stage{position:relative;border:1px solid var(--rule);border-radius:12px;background:var(--card2);overflow:auto;max-height:660px;box-shadow:var(--shadow)}
519
+ #graph{width:100%;display:block;color:var(--mut)}
520
+ #graph .gbox{fill:var(--card)}
521
+ #graph .rootbox{fill:var(--card)}
522
+ #graph .gnode{cursor:pointer}
523
+ #graph .gnode:hover .gbox,#graph .gnode:hover .rootbox{filter:brightness(1.03);stroke-width:3}
524
+ #graph .glabel{font-family:var(--mono);font-size:12.5px;fill:var(--ink);font-weight:600;pointer-events:none}
525
+ #graph .rootlabel{font-size:13.5px;fill:var(--ink)}
526
+ #graph .gcount{font-family:var(--mono);font-size:11px;fill:var(--mut);pointer-events:none}
527
+ #graph .gchev{font-family:var(--mono);font-size:14px;fill:var(--accent);pointer-events:none}
528
+ #graph .gbloat{font-family:var(--mono);font-size:10px;fill:var(--amber);pointer-events:none}
529
+ #graph .link{fill:none;stroke:var(--mut);stroke-width:1.6;opacity:.5}
530
+ .empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--mut);font-family:var(--sans);font-size:.85rem}
531
+ .foot{margin-top:20px;font-family:var(--sans);font-size:.72rem;color:var(--mut);line-height:1.7}
532
+ .logwrap{margin-top:24px}
533
+ .logh{font-family:var(--sans);font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--ink2);margin:0 0 10px}
534
+ .log{list-style:none;margin:0;padding:0;border:1px solid var(--rule);border-radius:12px;overflow:hidden;background:var(--card);box-shadow:var(--shadow)}
535
+ .logrow{display:flex;align-items:center;gap:10px;padding:10px 14px;cursor:pointer;border-top:1px solid var(--rule);font-family:var(--sans);font-size:.8rem}
536
+ .logrow:first-child{border-top:none}
537
+ .logrow:hover{background:var(--card2)}
538
+ .logdot{width:9px;height:9px;border-radius:50%;flex:none}
539
+ .logtime{color:var(--mut);min-width:120px;white-space:nowrap}
540
+ .logname{font-weight:600;color:var(--ink);word-break:break-word}
541
+ .logmod{color:var(--mut)}
542
+ .logsrc{margin-left:auto;color:var(--mut);font-size:.66rem;text-transform:uppercase;letter-spacing:.04em}
543
+ .logempty{padding:14px;color:var(--mut);font-family:var(--sans);font-size:.8rem}
544
+ .needs{margin:0 0 18px}
545
+ .needsh{font-family:var(--sans);font-size:.72rem;font-weight:600;color:var(--ink2);margin:0 0 8px}
546
+ .needlist{display:flex;flex-wrap:wrap;gap:7px}
547
+ .needrow{display:flex;align-items:center;gap:7px;padding:6px 12px;border:1px solid var(--rule);border-radius:100px;background:var(--card);cursor:pointer;font-family:var(--sans);font-size:.76rem;box-shadow:var(--shadow)}
548
+ .needrow:hover{border-color:var(--mut);background:var(--card2)}
549
+ .needstate{font-size:.6rem;text-transform:uppercase;letter-spacing:.04em;font-weight:600}
550
+ .needrow .logname{font-weight:600;color:var(--ink)}
551
+ .needrow .logmod{color:var(--mut)}
552
+ .needok{font-family:var(--sans);font-size:.82rem;color:var(--accent)}
553
+ /* width is constant across views (wide, left-aligned) */
554
+ .plan{margin-top:4px}
555
+ .pnarr{margin:2px 0 26px}
556
+ .ptitle{font-family:var(--sans);font-weight:700;font-size:1.6rem;letter-spacing:-.02em;margin:0 0 12px}
557
+ .psys{font-size:1.1rem;color:var(--ink2);max-width:82ch;margin:0 0 16px;line-height:1.65}
558
+ .pmetrics{display:flex;flex-wrap:wrap;gap:9px}
559
+ .pm{font-family:var(--sans);font-size:.74rem;background:var(--card2);border:1px solid var(--rule);border-radius:100px;padding:5px 13px;color:var(--ink2)}
560
+ .pm.ok{color:var(--accent);border-color:currentColor}.pm.bad{color:var(--red);border-color:currentColor}
561
+ .pm.dot-GREEN{color:#1f9d57}.pm.dot-YELLOW{color:var(--amber)}.pm.dot-RED{color:var(--red)}.pm.dot-PINK{color:#e0559b}.pm.dot-UNSIGNED{color:var(--mut)}
562
+ .pfit{position:relative;overflow:hidden}
563
+ .pdiagram{position:relative;border:1px solid var(--rule);border-radius:16px;background:var(--card2)}
564
+ .parrows{position:absolute;left:0;top:0;color:var(--mut)}
565
+ .parrow{fill:none;stroke:var(--mut);stroke-width:2.5;opacity:.55}
566
+ .pleader{fill:none;stroke:var(--mut);stroke-width:1.5;opacity:.4;stroke-dasharray:3 3}
567
+ .pflow{position:absolute;transform:translate(-50%,-50%);max-width:168px;font-family:var(--sans);font-size:.66rem;line-height:1.35;text-align:center;color:var(--ink2);background:var(--card);border:1px solid var(--rule);border-radius:10px;padding:4px 9px;white-space:normal;word-break:break-word;z-index:3;box-shadow:var(--shadow)}
568
+ .pcard{position:absolute;z-index:2;background:var(--card);border:1px solid var(--rule);border-top:4px solid var(--mut);border-radius:14px;padding:16px 18px;box-shadow:0 12px 30px -18px rgba(16,24,40,.25);display:flex;flex-direction:column;overflow:hidden}
569
+ .prole{font-family:var(--sans);font-size:.6rem;text-transform:uppercase;letter-spacing:.1em;font-weight:600}
570
+ .pname{font-family:var(--sans);font-weight:700;font-size:1.05rem;margin:3px 0 7px;line-height:1.25}
571
+ .ppurpose{font-size:.9rem;color:var(--ink2);line-height:1.5;overflow:hidden;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical}
572
+ .pcardfoot{margin-top:auto;padding-top:10px}
573
+ .pmods{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:6px}
574
+ .pmod{font-family:var(--mono);font-size:.66rem;background:var(--card2);border:1px solid var(--rule);border-radius:6px;padding:2px 7px;color:var(--mut)}
575
+ .pcells{font-family:var(--sans);font-size:.68rem;color:var(--mut)}
576
+ .phi{margin:24px 0 0;background:var(--card2);border:1px solid var(--rule);border-radius:14px;padding:18px 22px}
577
+ .phih{font-family:var(--sans);font-size:.72rem;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--ink2);margin:0 0 10px}
578
+ .phi ul{margin:0;padding-left:18px}.phi li{margin:6px 0;color:var(--ink2)}
579
+ .pfoot{margin-top:18px;font-family:var(--sans);font-size:.72rem;color:var(--mut)}
580
+ .pnote{padding:24px;color:var(--mut);font-family:var(--sans);font-size:.85rem;border:1px dashed var(--rule);border-radius:12px}
581
+ .modal{position:fixed;inset:0;z-index:80;display:none;padding:48px 16px;overflow:auto;background:rgba(10,12,16,.55);backdrop-filter:blur(2px)}
582
+ .modal.open{display:flex;align-items:flex-start;justify-content:center}
583
+ .modal-panel{position:relative;background:var(--paper);border:1px solid var(--rule);border-radius:16px;max-width:840px;width:100%;padding:24px 26px 28px;box-shadow:0 40px 90px -30px rgba(0,0,0,.5);max-height:calc(100vh - 96px);overflow:auto}
584
+ .modal-close{position:absolute;top:14px;right:14px;background:var(--card);border:1px solid var(--rule);border-radius:8px;color:var(--ink);width:32px;height:32px;cursor:pointer;font-size:.85rem}
585
+ .modal-close:hover{border-color:var(--mut)}
586
+ .mhead{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px;padding-right:36px}
587
+ .mid{font-family:var(--mono);font-size:.78rem;color:var(--accent);font-weight:600}
588
+ .mname{font-family:var(--sans);font-size:1.1rem;font-weight:700;word-break:break-word}
589
+ .mpill{font-family:var(--sans);font-size:.64rem;text-transform:uppercase;letter-spacing:.05em;font-weight:600}
590
+ .dmeta{display:flex;gap:6px 12px;flex-wrap:wrap;font-family:var(--mono);font-size:.68rem;color:var(--mut);margin-bottom:6px}
591
+ .dh{font-family:var(--sans);font-size:.66rem;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--ink2);margin:16px 0 6px}
592
+ pre.code{margin:0;background:var(--codebg);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:.8rem;line-height:1.65;white-space:pre;color:var(--codeink)}
593
+ .badline{display:block;background:rgba(255,90,80,.18);color:#ff8f86;font-weight:700;border-radius:4px;margin:0 -6px;padding:0 6px}
594
+ pre.code.diff .dl-add{color:#54d98c}pre.code.diff .dl-del{color:#ff8f86}pre.code.diff .dl-ctx{color:var(--codeink);opacity:.65}
595
+ pre.code .sp-intent{color:var(--blue);font-weight:600}
596
+ pre.code .sp-ensures{color:var(--purple);font-weight:600}
597
+ pre.code .tk-k{color:#a9b7ff}
598
+ pre.code .tk-s{color:#8fcaa4}
599
+ pre.code .tk-n{color:#e2b07e}
600
+ pre.code .tk-c{color:#7f8c84;font-style:italic}
601
+ .badline .tk-k,.badline .tk-s,.badline .tk-n,.badline .tk-c{color:inherit}
602
+ .checks{margin:0;padding:0;list-style:none;font-size:.84rem}.checks li{margin:5px 0;line-height:1.5}
603
+ .ck-red{color:var(--red);font-weight:600}.ck-yellow{color:var(--amber)}.ck-info{color:var(--mut)}
604
+ .allok{font-size:.84rem;color:var(--mut)}
605
+ </style></head><body>
606
+ <aside class="side" id="side">
607
+ <div class="brand"><span class="logo"><svg width="26" height="26" viewBox="0 0 26 26" aria-hidden="true"><rect width="26" height="26" rx="7" fill="#3ecf8e"/><path d="M6.5 13.5l4 4L20 7.5" fill="none" stroke="#04231a" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="brandname">YayLayer</span></div>
608
+ <nav class="sidenav">
609
+ <div class="navgroup">Project</div>
610
+ <div class="projrow" title="${esc(project || 'project')}"><span class="ti">${ICONS.project}</span><span class="projname">${esc(project || 'project')}</span></div>
611
+ <div class="navgroup">Overview</div>
612
+ <button class="tab active" data-tab="map"><span class="ti">${ICONS.map}</span>Map</button>
613
+ <div class="navrow" id="planrow" style="display:none"><button class="tab" data-tab="plan" id="tab-plan"><span class="ti">${ICONS.plan}</span>System Plan</button><button class="navrefresh" id="plan-refresh" title="Regenerate the System Plan (calls your configured AI)" aria-label="Regenerate System Plan" style="display:none">↻</button></div>
614
+ <div class="navgroup">Work</div>
615
+ <button class="tab" data-tab="briefs"><span class="ti">${ICONS.briefs}</span>Briefs</button>
616
+ <button class="tab" data-tab="tags" id="tab-tags" style="display:none"><span class="ti">${ICONS.tags}</span>Tags</button>
617
+ <button class="tab" data-tab="files"><span class="ti">${ICONS.files}</span>Files</button>
618
+ <div class="navgroup">Governance</div>
619
+ <button class="tab" data-tab="signers"><span class="ti">${ICONS.signers}</span>Signers</button>
620
+ <button class="tab" data-tab="grants"><span class="ti">${ICONS.grants}</span>Grants</button>
621
+ <button class="tab" data-tab="policy" id="tab-policy" style="display:none"><span class="ti">${ICONS.policy}</span>Policy</button>
622
+ <div class="navgroup">Reference</div>
623
+ <button class="tab" data-tab="ask" id="tab-ask" style="display:none"><span class="ti">${ICONS.ask}</span>Ask</button>
624
+ <button class="tab" data-tab="commands"><span class="ti">${ICONS.commands}</span>Commands</button>
625
+ <button class="tab" data-tab="capability"><span class="ti">${ICONS.capability}</span>Capabilities</button>
626
+ <a class="tab navext" href="https://yaylayer.com/docs/manual.html" target="_blank" rel="noopener noreferrer"><span class="ti">${ICONS.manual}</span>Documentation<span class="navext-ico">↗</span></a>
627
+ </nav>
628
+ <div id="yd-slot"></div>
629
+ </aside>
630
+ <div class="scrim" id="scrim"></div>
631
+ <div class="main">
632
+ <header class="topbar"><button id="navburger" class="navburger" aria-label="Menu" aria-expanded="false">☰</button><div class="tb-title">Dashboard</div><div class="tb-right"><span class="gatepill ${verified.passed ? 'ok' : 'bad'}">${verified.passed ? '● Gate PASS' : '● Gate BLOCKED'}</span><button id="themebtn" class="themebtn" aria-label="Toggle theme">Dark</button></div></header>
633
+ <div class="wrap">
634
+ <div class="pagehead"><h1>System map</h1><p class="sub">${totalUnits} units · ${verified.passed ? 'gate PASS' : 'gate BLOCKED'}${verified.counts.GREEN ? ` · ${verified.counts.proven || 0} proven / ${verified.counts.unproven || 0} unproven` : ''}</p></div>
635
+ ${foundationBanner}
636
+ ${statblocks}
637
+ <div id="needs" class="needs"></div>
638
+ <p class="hint">A drill-down tree. The box on the <b>left is where you are</b>; its contents branch to the right. Click a <b>container ›</b> to zoom into it, click the left box or <b>↑ Up a level</b> to zoom out, and click a <b>unit</b> to open its spec, code &amp; checks.</p>
639
+ <div class="crumb" id="crumb"></div>
640
+ <div class="stage"><svg id="graph"></svg></div>
641
+ <div class="logwrap"><div class="logh">Recent changes</div><ol id="log" class="log"></ol></div>
642
+ <div id="plan" class="plan" style="display:none"></div>
643
+ <div id="briefs" class="signers" style="display:none"></div>
644
+ <div id="tags" class="signers" style="display:none"></div>
645
+ <div id="policy" class="signers" style="display:none"></div>
646
+ <div id="signers" class="signers" style="display:none"></div>
647
+ <div id="grants" class="signers" style="display:none"></div>
648
+ <div id="files" class="files" style="display:none"></div>
649
+ <div id="commands" class="commands" style="display:none">${commandsHTML()}</div>
650
+ <div id="capability" class="signers" style="display:none"></div>
651
+ <div id="ask" class="signers" style="display:none"></div>
652
+ <div class="foot">Generated by <code>yay map</code> · zoomable hierarchy · green = code proven to match a signed spec, pink = no spec · ▲N = Cells that depend on this (blast radius) · <span style="color:var(--amber)">unused?</span> = no callers found.</div>
653
+ </div>
654
+ </div>
655
+ <div id="modal" class="modal" role="dialog" aria-modal="true"><div class="modal-panel"><button class="modal-close" aria-label="Close">✕</button><div class="modal-body"></div></div></div>
656
+ <script id="yl-data" type="application/json">${payload}</script>
657
+ <script>
658
+ (function(){
659
+ var DATA=JSON.parse(document.getElementById('yl-data').textContent);
660
+ var pendingBrief=null; // a Brief id to open in the modal once the Briefs tab renders (set by an Ask ref click)
661
+ var NODES=DATA.nodes, DETAILS=DATA.details, EDGES=DATA.edges||{};
662
+ var svg=document.getElementById('graph'), crumbEl=document.getElementById('crumb');
663
+ var NS='http://www.w3.org/2000/svg';
664
+ var cur=DATA.root;
665
+
666
+ function crumb(){
667
+ var path=[], id=cur; while(id){ path.unshift(id); id=NODES[id].parent; }
668
+ crumbEl.innerHTML='';
669
+ var up=document.createElement('button');
670
+ up.className='upbtn'; up.textContent='↑ Up a level';
671
+ up.disabled=!NODES[cur].parent;
672
+ up.onclick=function(){ var pp=NODES[cur].parent; if(pp){ cur=pp; draw(); } };
673
+ crumbEl.appendChild(up);
674
+ path.forEach(function(pid,i){
675
+ var sep=document.createElement('span'); sep.className='sep'; sep.textContent='›'; crumbEl.appendChild(sep);
676
+ if(i===path.length-1){ var s=document.createElement('span'); s.className='here'; s.textContent=NODES[pid].label; crumbEl.appendChild(s); }
677
+ else{ var b=document.createElement('button'); b.textContent=NODES[pid].label; b.onclick=function(){ cur=pid; draw(); }; crumbEl.appendChild(b); }
678
+ });
679
+ }
680
+
681
+ function openDetail(uid){ var m=document.getElementById('modal'); m.querySelector('.modal-body').innerHTML=DETAILS[uid]||''; m.classList.add('open'); document.body.style.overflow='hidden'; }
682
+
683
+ function short(s){ s=String(s); return s.length>34 ? s.slice(0,32)+'…' : s; }
684
+ function widthFor(label, hasCount){ return Math.max(150, short(label).length*7.9 + (hasCount?72:34)); }
685
+ function mk(g,name,attrs){ var e=document.createElementNS(NS,name); for(var k in attrs) e.setAttribute(k,attrs[k]); g.appendChild(e); return e; }
686
+ function txt(g,x,y,cls,s,anchor){ var t=mk(g,'text',{'class':cls,x:x,y:y,'text-anchor':anchor||'start','dominant-baseline':'central'}); t.textContent=s; return t; }
687
+
688
+ function draw(){
689
+ crumb();
690
+ var root=NODES[cur];
691
+ var kids=(root.children||[]).map(function(k){return NODES[k];});
692
+ var W=svg.clientWidth||900;
693
+ var rowH=48, topPad=30, botPad=26, childH=34;
694
+ var H=Math.max(340, topPad+botPad+Math.max(1,kids.length)*rowH);
695
+ svg.setAttribute('viewBox','0 0 '+W+' '+H); svg.setAttribute('height',H);
696
+ svg.innerHTML='';
697
+ var linkG=document.createElementNS(NS,'g'); svg.appendChild(linkG);
698
+ var nodeG=document.createElementNS(NS,'g'); svg.appendChild(nodeG);
699
+
700
+ var rootW=widthFor(root.label,false), rootH=48;
701
+ var rootX=26, rootY=H/2, rootCx=rootX+rootW/2, rootRight=rootX+rootW;
702
+ var maxW=0; kids.forEach(function(n){ var w=widthFor(n.label,n.kind!=='unit'); if(w>maxW)maxW=w; });
703
+ var childX=Math.max(rootRight+80, Math.min(W*0.42, 340));
704
+ if(childX+maxW>W-18) childX=Math.max(rootRight+34, W-18-maxW);
705
+
706
+ kids.forEach(function(n,i){
707
+ var rightU = n.kind==='unit' ? [(n.auto?'⚡':''),(n.blast?'▲'+n.blast:(n.bloat?'unused?':''))].filter(Boolean).join(' ') : String(n.count);
708
+ var hasRight = n.kind!=='unit' ? true : !!rightU;
709
+ var cy=topPad+i*rowH+childH/2, w=widthFor(n.label,hasRight);
710
+ var x1=rootRight, y1=rootY, x2=childX, mx=(x1+x2)/2;
711
+ mk(linkG,'path',{'class':'link',d:'M'+x1+','+y1+' C'+mx+','+y1+' '+mx+','+cy+' '+x2+','+cy});
712
+ var g=document.createElementNS(NS,'g'); g.setAttribute('class','gnode'+(n.kind==='unit'?' leaf':'')); nodeG.appendChild(g);
713
+ mk(g,'rect',{'class':'gbox',x:childX,y:cy-childH/2,width:w,height:childH,rx:9,stroke:n.color,'stroke-width':n.kind==='unit'?2:2.5});
714
+ txt(g,childX+14,cy,'glabel',short(n.label),'start');
715
+ if(n.kind!=='unit'){ txt(g,childX+w-24,cy,'gcount',String(n.count),'end'); txt(g,childX+w-11,cy,'gchev','›','end'); }
716
+ else if(rightU){ txt(g,childX+w-12,cy,(n.auto||n.bloat)?'gbloat':'gcount',rightU,'end'); }
717
+ (function(node){ g.addEventListener('click',function(){ if(node.kind==='unit'){ openDetail(node.id); } else if(node.children&&node.children.length){ cur=node.id; draw(); } }); })(n);
718
+ });
719
+
720
+ var upable=!!NODES[cur].parent;
721
+ var rg=document.createElementNS(NS,'g'); rg.setAttribute('class','gnode'+(upable?'':' leaf')); nodeG.appendChild(rg);
722
+ mk(rg,'rect',{'class':'rootbox',x:rootX,y:rootY-rootH/2,width:rootW,height:rootH,rx:10,stroke:root.color,'stroke-width':3});
723
+ txt(rg,rootCx,rootY-7,'glabel rootlabel',short(root.label),'middle');
724
+ txt(rg,rootCx,rootY+12,'gcount',(upable?'↑ ':'')+root.count+' units','middle');
725
+ if(upable) rg.addEventListener('click',function(){ cur=NODES[cur].parent; draw(); });
726
+
727
+ if(!kids.length) txt(nodeG,childX,H/2,'gcount','(nothing deeper — open the node to read its spec)','start');
728
+ }
729
+
730
+ function esc2(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
731
+ function pad2(x){ return (x<10?'0':'')+x; }
732
+ function exact(ms){ var d=new Date(ms); return d.getFullYear()+'-'+pad2(d.getMonth()+1)+'-'+pad2(d.getDate())+' '+pad2(d.getHours())+':'+pad2(d.getMinutes()); }
733
+ // Relative only within the last 24h; older entries show the exact local date & time.
734
+ function ago(ms){ if(!ms) return ''; var s=(Date.now()-ms)/1000; if(s<60) return 'just now'; var m=s/60; if(m<60) return Math.round(m)+'m ago'; var h=m/60; if(h<24) return Math.round(h)+'h ago'; return exact(ms); }
735
+ function moduleLabelOf(node){ var g=NODES[node.parent]; var m=g&&NODES[g.parent]; return m?m.label:''; }
736
+ function renderLog(){
737
+ var el=document.getElementById('log'); if(!el) return;
738
+ var items=(DATA.changes||[]).filter(function(ch){ return NODES[ch.id]; });
739
+ if(!items.length){ el.innerHTML='<li class="logempty">No change history yet — sign specs (each approval is logged here) or generate the map inside a git repo.</li>'; return; }
740
+ el.innerHTML='';
741
+ items.slice(0,40).forEach(function(ch){
742
+ var node=NODES[ch.id];
743
+ var li=document.createElement('li'); li.className='logrow'; li.title=ch.at?new Date(ch.at).toLocaleString():'';
744
+ li.innerHTML='<span class="logdot" style="background:'+node.color+'"></span>'
745
+ +'<span class="logtime">'+ago(ch.at)+'</span>'
746
+ +'<span class="logname">'+esc2(node.label)+'</span>'
747
+ +'<span class="logmod">'+esc2(moduleLabelOf(node))+'</span>'
748
+ +'<span class="logsrc">'+esc2(ch.source)+'</span>';
749
+ li.addEventListener('click',function(){ openDetail(ch.id); });
750
+ el.appendChild(li);
751
+ });
752
+ }
753
+ function jumpTo(uid){ var n=NODES[uid]; if(n&&n.parent){ cur=n.parent; draw(); } openDetail(uid); }
754
+ function renderNeeds(){
755
+ var el=document.getElementById('needs'); if(!el) return;
756
+ var items=(DATA.needs||[]).filter(function(x){ return NODES[x.id]; });
757
+ if(!items.length){ el.innerHTML='<div class="needok">✓ Nothing needs signing or attention — the gate passes.</div>'; return; }
758
+ var counts={}; items.forEach(function(x){ counts[x.state]=(counts[x.state]||0)+1; });
759
+ var summary=Object.keys(counts).map(function(s){ return counts[s]+' '+s.toLowerCase(); }).join(' · ');
760
+ var rows=items.slice(0,80).map(function(x){ var n=NODES[x.id];
761
+ return '<div class="needrow" data-id="'+x.id+'"><span class="logdot" style="background:'+n.color+'"></span>'
762
+ +'<span class="needstate" style="color:'+n.color+'">'+esc2(x.state)+'</span>'
763
+ +'<span class="logname">'+esc2(n.label)+'</span>'
764
+ +'<span class="logmod">'+esc2(moduleLabelOf(n))+'</span></div>';
765
+ }).join('');
766
+ el.innerHTML='<div class="needsh">Needs attention · '+summary+' — click to jump &amp; open</div><div class="needlist">'+rows+'</div>';
767
+ Array.prototype.forEach.call(el.querySelectorAll('.needrow'), function(r){ r.addEventListener('click', function(){ jumpTo(r.getAttribute('data-id')); }); });
768
+ }
769
+ // ── System Plan poster (static, AI-synthesized) ──────────────────────────
770
+ function planColor(role){ return ({input:'#3d38a8',data:'#1f9d57',logic:'#c9860f',output:'#e0559b',ui:'#7c3aed',other:'#6f6f7a'})[role]||'#6f6f7a'; }
771
+ function layerBy(names, edges){
772
+ var rank={}; names.forEach(function(n){ rank[n]=0; });
773
+ for(var it=0; it<names.length+2; it++){ var ch=false;
774
+ edges.forEach(function(e){ if(rank[e[0]]!=null && rank[e[1]]!=null){ var r=rank[e[0]]+1; if(r>rank[e[1]]){ rank[e[1]]=r; ch=true; } } });
775
+ if(!ch) break; }
776
+ return rank;
777
+ }
778
+ function renderPlan(){
779
+ var el=document.getElementById('plan'); if(!el) return;
780
+ var meta=DATA.meta||{}, P=meta.plan;
781
+ if(!P){ el.innerHTML='<div class="pnote">No system plan yet. Run <b>yay plan</b> (needs an API key in your .env), then regenerate the map.</div>'; return; }
782
+ var subs=P.subsystems||[], flows=P.flows||[], c=meta.counts||{};
783
+ var chips='<span class="pm">'+meta.totalUnits+' cells</span><span class="pm">'+subs.length+' subsystems</span>';
784
+ // Per-state counts (incl. red) — the gate status is already shown by the top-bar pill, so we
785
+ // don't repeat "gate BLOCKED" here; a red count appears alongside the others when there are reds.
786
+ ['GREEN','YELLOW','RED','UNSIGNED','PINK'].forEach(function(k){ if(c[k]) chips+='<span class="pm dot-'+k+'">'+c[k]+' '+k.toLowerCase()+'</span>'; });
787
+ var rank=layerBy(subs.map(function(s){return s.name;}), flows.map(function(f){return [f.from,f.to];}));
788
+ var cols={}; subs.forEach(function(s){ var r=rank[s.name]||0; (cols[r]=cols[r]||[]).push(s); });
789
+ var colKeys=Object.keys(cols).map(Number).sort(function(a,b){return a-b;});
790
+ var CARDW=272, CARDH=212, COLGAP=210, ROWGAP=64, PADX=34, PADY=34, COLW=CARDW+COLGAP;
791
+ var pos={}, maxRows=1;
792
+ colKeys.forEach(function(r,ci){ cols[r].forEach(function(s,ri){ pos[s.name]={x:PADX+ci*COLW,y:PADY+ri*(CARDH+ROWGAP)}; }); maxRows=Math.max(maxRows,cols[r].length); });
793
+ var W=PADX*2 + colKeys.length*CARDW + Math.max(0,colKeys.length-1)*COLGAP;
794
+ var H=PADY*2 + maxRows*CARDH + Math.max(0,maxRows-1)*ROWGAP;
795
+ var arrows='', labels='', leaders='', placed=[];
796
+ var cardBottom=PADY + maxRows*CARDH + Math.max(0,maxRows-1)*ROWGAP; // y just under the lowest card
797
+ var belowY=cardBottom+34; // a lane BELOW the cards for labels that would otherwise cover one
798
+ // Is (lx,ly) on top of any subsystem card? (multi-column flows cross intermediate cards)
799
+ function cardAt(lx,ly){ for(var i=0;i<subs.length;i++){ var q=pos[subs[i].name]; if(!q) continue; if(lx>q.x-6&&lx<q.x+CARDW+6&&ly>q.y-6&&ly<q.y+CARDH+6) return q; } return null; }
800
+ function labelClash(lx,ly){ for(var i=0;i<placed.length;i++){ if(Math.abs(placed[i].x-lx)<150 && Math.abs(placed[i].y-ly)<24) return true; } return false; }
801
+ flows.forEach(function(f){ var a=pos[f.from], b=pos[f.to]; if(!a||!b||f.from===f.to) return; var x1=a.x+CARDW,y1=a.y+CARDH/2,x2=b.x,y2=b.y+CARDH/2,mx=(x1+x2)/2;
802
+ arrows+='<path d="M'+x1+','+y1+' C'+mx+','+y1+' '+mx+','+y2+' '+x2+','+y2+'" class="parrow" marker-end="url(#pah)"/>';
803
+ if(f.what){ var lx=mx, ly=(y1+y2)/2;
804
+ if(cardAt(lx,ly)){
805
+ // The midpoint sits on a card (a flow that spans/crosses one). NEVER draw over a
806
+ // card — drop the label into the lane below the cards, with a faint leader line.
807
+ lx=Math.min(Math.max(mx,100),W-100); ly=belowY; belowY+=30;
808
+ leaders+='<path d="M'+mx+','+cardBottom+' L'+lx+','+(ly-11)+'" class="pleader"/>';
809
+ } else if(labelClash(lx,ly)){
810
+ // Only clashing with another label in the gap — nudge vertically, staying off cards.
811
+ for(var d=1;d<=6;d++){ var up=ly-d*22, down=ly+d*22;
812
+ if(up>PADY && !cardAt(lx,up) && !labelClash(lx,up)){ ly=up; break; }
813
+ if(down<cardBottom-8 && !cardAt(lx,down) && !labelClash(lx,down)){ ly=down; break; } }
814
+ }
815
+ placed.push({x:lx,y:ly});
816
+ labels+='<div class="pflow" style="left:'+lx+'px;top:'+ly+'px">'+esc2(f.what)+'</div>'; } });
817
+ if(belowY>cardBottom+34) H=Math.max(H, belowY+16); // grow the canvas to fit the below-lane
818
+ var cards=subs.map(function(s){ var pp=pos[s.name], col=planColor(s.role);
819
+ var cells=(s.modules||[]).reduce(function(sum,mn){ var n=NODES['m:'+mn]; return sum+(n?n.count:0); },0);
820
+ var mods=(s.modules||[]).slice(0,6).map(function(mn){ return '<span class="pmod">'+esc2(mn)+'</span>'; }).join('');
821
+ return '<div class="pcard" style="left:'+pp.x+'px;top:'+pp.y+'px;width:'+CARDW+'px;height:'+CARDH+'px;border-top-color:'+col+'">'
822
+ +'<div class="prole" style="color:'+col+'">'+esc2(s.role||'')+'</div>'
823
+ +'<div class="pname">'+esc2(s.name)+'</div>'
824
+ +'<div class="ppurpose">'+esc2(s.purpose||'')+'</div>'
825
+ +'<div class="pcardfoot">'+(mods?'<div class="pmods">'+mods+'</div>':'')+(cells?'<div class="pcells">'+cells+' cells</div>':'')+'</div>'
826
+ +'</div>'; }).join('');
827
+ // scale the whole diagram to fit the page width — no ugly horizontal scrollbar
828
+ var avail=(el.clientWidth||900)-2; var scale=Math.min(1, avail/W); if(!isFinite(scale)||scale<=0) scale=1;
829
+ var diagram='<div class="pdiagram" style="width:'+W+'px;height:'+H+'px;transform:scale('+scale+');transform-origin:top left">'
830
+ +'<svg class="parrows" width="'+W+'" height="'+H+'"><defs><marker id="pah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="currentColor"/></marker></defs>'+leaders+arrows+'</svg>'+labels+cards+'</div>';
831
+ el.innerHTML='<div class="pnarr"><h2 class="ptitle">'+esc2(meta.project)+' — System Plan</h2>'
832
+ +'<p class="psys">'+esc2(P.system||'')+'</p><div class="pmetrics">'+chips+'</div></div>'
833
+ +'<div class="pfit" style="height:'+Math.ceil(H*scale)+'px">'+diagram+'</div>'
834
+ +((P.highlights&&P.highlights.length)?'<div class="phi"><div class="phih">Highlights</div><ul>'+P.highlights.map(function(h){return '<li>'+esc2(h)+'</li>';}).join('')+'</ul></div>':'')
835
+ +'<div class="pfoot">Synthesized by '+esc2(P.model||'an LLM')+(P.at?(' · '+esc2(String(P.at).slice(0,10))):'')+' from the signed specs — a view for humans, not a proof.</div>';
836
+ }
837
+ // ── Signers / Owners tab ─────────────────────────────────────────────────
838
+ function acolor(s){ var h=0; for(var i=0;i<String(s).length;i++) h=(h*31+String(s).charCodeAt(i))>>>0; return 'hsl('+(h%360)+' 52% 45%)'; }
839
+ function renderSigners(){
840
+ var el=document.getElementById('signers'); if(!el) return;
841
+ var g=DATA.meta&&DATA.meta.gov;
842
+ if(!g||!g.signers||!g.signers.length){ el.innerHTML='<h1>Signers &amp; Owners</h1><div class="snote">No signers yet — create a key (<b>yay keygen</b> / <b>yay init</b>) or pair a phone (<b>yay pair</b>).</div>'; return; }
843
+ var html='<h1>Signers &amp; Owners</h1>';
844
+ if(g.signedRoster) html+='<div class="rootcard"><div><div class="rootlbl">Trust root</div><div class="rootfp">'+esc2(g.rootFp||'')+'</div></div><div class="snote">Pin this in CI (<b>yay gate</b>). Any swap of the roster fails the gate.</div></div>';
845
+ else html+='<div class="swarn">⚠ Roster is unsigned — anyone with repo write could add a signer. Run <b>yay init</b>/<b>yay keygen</b> to establish a signed trust root.</div>';
846
+ (g.problems||[]).forEach(function(p){ html+='<div class="swarn">✗ '+esc2(p)+'</div>'; });
847
+ if(isLive()) html+='<div style="margin:0 0 16px;display:flex;gap:10px;align-items:center;flex-wrap:wrap"><button id="sg-invite" class="viewbtn">+ Invite a signer / owner</button><span class="snote" style="font-family:var(--sans);font-size:.82rem">creates a one-time join link — you approve the enrollment on your phone.</span></div>';
848
+ g.signers.forEach(function(s){
849
+ var init=(String(s.name).trim().charAt(0)||'?').toUpperCase();
850
+ html+='<div class="srow"><div class="savatar" style="background:'+acolor(s.name)+'">'+esc2(init)+'</div><div style="flex:1;min-width:0">'
851
+ +'<div><span class="sname">'+esc2(s.name)+'</span><span class="srole '+(s.role==='owner'?'owner':'signer')+'">'+esc2(s.role)+'</span></div>'
852
+ +'<div class="smeta">'+s.keys.length+' key'+(s.keys.length===1?'':'s')+' · '+s.approvals+' approval'+(s.approvals===1?'':'s')+' signed</div>'
853
+ +s.keys.map(function(k){ return '<div class="skey">'+(k.kind?'<span class="skeykind">'+esc2(k.kind)+'</span>':'')+'<span>'+esc2(k.fp)+'</span>'+(k.addedAt?'<span style="color:var(--mut)">· added '+esc2(String(k.addedAt).slice(0,10))+'</span>':'')+'</div>'; }).join('')
854
+ +'</div></div>';
855
+ });
856
+ el.innerHTML=html;
857
+ var ib=document.getElementById('sg-invite'); if(ib) ib.onclick=openInvite;
858
+ }
859
+ // Invite a signer/owner from the dashboard — mints a one-time join link (owner-signed enroll
860
+ // happens when the teammate opens it and the owner approves on their phone). Reuses /api/invite/create.
861
+ function openInvite(){
862
+ var m=document.getElementById('modal'); if(!m) return; var body=m.querySelector('.modal-body');
863
+ body.innerHTML='<div class="mhead"><span class="mname">Invite a signer or owner</span></div>'
864
+ +'<div class="snote" style="font-family:var(--sans);margin:0 0 14px">Create a one-time link for a teammate. They open it (on the same Wi-Fi), create their key on their phone, and you approve the enrollment on yours — their private key never leaves their device.</div>'
865
+ +'<div style="display:flex;flex-direction:column;gap:12px;max-width:440px">'
866
+ +'<label style="font-size:.85rem;color:var(--ink2)">Name <span style="color:var(--mut)">(a suggestion — they can edit)</span><br><input id="iv-name" placeholder="e.g. Sara" style="width:100%;margin-top:4px;padding:9px 11px;border-radius:9px;border:1px solid var(--rule);background:var(--card2);color:var(--ink);font-family:var(--sans);font-size:.9rem"></label>'
867
+ +'<label style="font-size:.85rem;color:var(--ink2)">Role<br><select id="iv-role" style="margin-top:4px;padding:9px 11px;border-radius:9px;border:1px solid var(--rule);background:var(--card2);color:var(--ink);font-family:var(--sans);font-size:.9rem"><option value="signer">Signer — can approve/sign</option><option value="owner">Owner — can also manage the roster</option></select></label>'
868
+ +'<div><button id="iv-create" class="viewbtn">Create invite link</button></div>'
869
+ +'<div id="iv-out"></div></div>';
870
+ m.classList.add('open'); document.body.style.overflow='hidden';
871
+ var out=document.getElementById('iv-out');
872
+ document.getElementById('iv-create').onclick=function(){
873
+ var name=(document.getElementById('iv-name').value||'').trim(), role=document.getElementById('iv-role').value;
874
+ out.innerHTML='<div class="snote" style="font-family:var(--sans)">Creating…</div>';
875
+ fetch('/api/invite/create',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:name,role:role})}).then(function(r){return r.json();}).then(function(j){
876
+ if(j&&j.ok){ var url=location.origin+j.joinPath;
877
+ out.innerHTML='<div style="border:1px solid var(--rule);border-radius:11px;padding:13px 15px;background:var(--card2)"><div class="rootlbl">Share this link · expires in '+j.expiresInMin+' min · role: '+esc2(j.role)+'</div>'
878
+ +'<div style="display:flex;gap:8px;align-items:center;margin-top:7px"><input readonly value="'+esc2(url)+'" style="flex:1;min-width:0;padding:8px 10px;border-radius:8px;border:1px solid var(--rule);background:var(--paper);color:var(--ink);font-family:var(--mono);font-size:.78rem"><button id="iv-copy" class="themebtn">Copy</button></div>'
879
+ +'<div class="snote" style="font-family:var(--sans);margin-top:9px">Open it on the same Wi-Fi (if this address says <code>localhost</code>, use this computer\\u2019s network address instead). When they submit, an approval appears on <b>your</b> phone \\u2014 confirm the 6-digit code to enroll them, then commit <code>.yaylayer/roster.json</code>.</div></div>';
880
+ var cp=document.getElementById('iv-copy'); if(cp) cp.onclick=function(){ try{ navigator.clipboard.writeText(url); cp.textContent='Copied \\u2713'; }catch(e){} };
881
+ } else { out.innerHTML='<div class="swarn" style="font-family:var(--sans)">\\u2717 '+esc2((j&&j.error)||'could not create the invite (run this on the computer hosting the dashboard)')+'</div>'; }
882
+ }).catch(function(e){ out.innerHTML='<div class="swarn" style="font-family:var(--sans)">\\u2717 '+esc2(String(e))+'</div>'; });
883
+ };
884
+ }
885
+
886
+ // ── Capabilities tab — what THIS verifier can detect & prove, its version + fingerprint ──
887
+ // ── Ask tab — repo + manual assistant (live: real LLM; demo/static: showcase note) ──
888
+ // Format an LLM answer (markdown-ish) into readable, YayLayer-styled HTML: escape first (safe),
889
+ // then headings/lists/bold/code, clickable Cell + Brief refs, and coloured verifier states.
890
+ function askStateColor(s){ return ({GREEN:'#1f9d57',YELLOW:'#c9860f',RED:'#cf4436',UNSIGNED:'#7f8796',PINK:'#d6519a'})[s]; }
891
+ function askRef(uid,label){ return (DATA.nodes&&DATA.nodes[uid]) ? '<a class="ask-ref" data-open="'+uid+'">'+label+'</a>' : '<code>'+label+'</code>'; }
892
+ function askInline(s){
893
+ // Pink/untracked Cells carry a «guillemet» id (e.g. «module-level code»); the model sometimes glues
894
+ // a stray C- prefix onto them — strip it, then link the id to its node. Run before the C- id pass.
895
+ s=s.replace(/\\bC-(?=«)/g,'');
896
+ s=s.replace(/«([^»]+)»/g,function(m,inner){ return askRef('u:«'+inner+'»','«'+inner+'»'); });
897
+ s=s.replace(/\\x60([^\\x60]+)\\x60/g,'<code>$1</code>');
898
+ s=s.replace(/\\*\\*([^*]+)\\*\\*/g,'<b>$1</b>');
899
+ // Real Cell ids — now include the hyphen so SHARDED ids (C-3f2a-7) match fully, not just C-3f2a.
900
+ s=s.replace(/\\bC-[A-Za-z0-9._-]+/g,function(id){ return (DATA.nodes&&DATA.nodes['u:'+id]) ? '<a class="ask-ref" data-open="u:'+id+'">'+id+'</a>' : id; });
901
+ s=s.replace(/\\bA-[0-9]{3,}\\b/g,function(id){ return '<a class="ask-ref" data-brief="'+id+'">'+id+'</a>'; });
902
+ s=s.replace(/\\b(GREEN|YELLOW|RED|UNSIGNED|PINK)\\b/g,function(w){ return '<span style="color:'+askStateColor(w)+';font-weight:600">'+w+'</span>'; });
903
+ return s;
904
+ }
905
+ function fmtAsk(md){
906
+ var lines=esc2(String(md||'')).split(/\\r?\\n/), out=[], inUl=false, inOl=false;
907
+ function close(){ if(inUl){out.push('</ul>');inUl=false;} if(inOl){out.push('</ol>');inOl=false;} }
908
+ for(var i=0;i<lines.length;i++){ var ln=lines[i];
909
+ var h=ln.match(/^(#{1,4})\\s+(.*)$/); if(h){ close(); out.push('<div class="ask-h'+(h[1].length<=2?'2':'3')+'">'+askInline(h[2])+'</div>'); continue; }
910
+ var ul=ln.match(/^\\s*[-*]\\s+(.*)$/); if(ul){ if(!inUl){close();out.push('<ul>');inUl=true;} out.push('<li>'+askInline(ul[1])+'</li>'); continue; }
911
+ var ol=ln.match(/^\\s*\\d+[.)]\\s+(.*)$/); if(ol){ if(!inOl){close();out.push('<ol>');inOl=true;} out.push('<li>'+askInline(ol[1])+'</li>'); continue; }
912
+ if(!ln.trim()){ close(); continue; }
913
+ close(); out.push('<p>'+askInline(ln)+'</p>');
914
+ }
915
+ close(); return out.join('');
916
+ }
917
+ function renderAsk(){
918
+ var el=document.getElementById('ask'); if(!el) return;
919
+ var live=isLive(); var demo=!!(DATA.meta&&DATA.meta.demo);
920
+ var fld='width:100%;box-sizing:border-box;margin-top:8px;padding:11px 13px;border-radius:10px;border:1px solid var(--rule);background:var(--card2);color:var(--ink);font-family:var(--sans);font-size:.9rem;resize:vertical;min-height:72px';
921
+ var examples=['Which cells need approval right now?','List every cell the verifier marked Red, and why.','What is awaiting ratification, and under which grant?','Explain exactly how grants work and what I can do with them.','How does the foundation seal protect my project?','What did the last few Briefs change?'];
922
+ var chips=examples.map(function(q){ return '<button class="viewbtn ask-ex" data-q="'+esc2(q)+'">'+esc2(q)+'</button>'; }).join('');
923
+ var note=live?'':('<div class="snote" style="font-family:var(--sans);margin:0 0 12px;color:#c9860f">This is a static '+(demo?'demo':'map')+' — answers need the live dashboard. Run <code>yay dashboard</code> (with an LLM key in <code>.env</code>) and open Ask to query your own repo.</div>');
924
+ el.innerHTML='<h1>Ask</h1><div class="snote" style="font-family:var(--sans);margin:0 0 14px">Ask about <b>this project</b> or <b>how YayLayer works</b> — answered by <b>your configured system AI</b>, primed with your live project state <i>and</i> the manual. Cell and Brief references in the answer are clickable.</div>'+note
925
+ +'<div style="max-width:760px"><textarea id="ask-q" placeholder="e.g. Which cells need approval? · Explain how grants work" style="'+fld+'"></textarea>'
926
+ +'<div style="margin-top:10px;display:flex;gap:10px;align-items:center"><button id="ask-go" class="viewbtn">Ask</button><span id="ask-status" class="snote" style="font-family:var(--sans)"></span></div>'
927
+ +'<div style="margin-top:14px"><div class="snote" style="font-size:.78rem;margin-bottom:6px">Try one (asks straight away):</div><div class="ask-ex-grid">'+chips+'</div></div>'
928
+ +'<div id="ask-ans" class="ask-ans" style="margin-top:18px"></div></div>';
929
+ var q=document.getElementById('ask-q'), go=document.getElementById('ask-go'), stt=document.getElementById('ask-status'), ansEl=document.getElementById('ask-ans');
930
+ // clicking a Cell/Brief ref in an answer jumps straight to it
931
+ ansEl.addEventListener('click',function(e){ var a=e.target.closest&&e.target.closest('.ask-ref'); if(!a) return; e.preventDefault(); if(a.getAttribute('data-open')) openDetail(a.getAttribute('data-open')); else if(a.getAttribute('data-brief')){ pendingBrief=a.getAttribute('data-brief'); setTab('briefs'); } });
932
+ function ask(){
933
+ var text=(q.value||'').trim(); if(!text){ q.focus(); return; }
934
+ if(!live){ ansEl.innerHTML='<div class="snote" style="font-family:var(--sans)">Run <code>yay dashboard</code> to get real answers here — a static view cannot call your AI.</div>'; return; }
935
+ go.disabled=true; go.style.opacity='.6'; stt.style.color='var(--mut)'; stt.innerHTML='<span class="ask-spin"></span>Thinking<span class="ask-think"><span class="ad"></span><span class="ad"></span><span class="ad"></span></span>'; ansEl.innerHTML='';
936
+ fetch('/api/ask',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question:text})}).then(function(r){return r.json();}).then(function(j){
937
+ go.disabled=false; go.style.opacity='1';
938
+ if(j&&j.ok){ ansEl.innerHTML=fmtAsk(j.answer); stt.style.color='var(--mut)'; stt.textContent='— '+(j.provider||'')+'/'+(j.model||''); }
939
+ else { stt.textContent=''; ansEl.innerHTML='<span style="color:var(--red)">✗ '+esc2((j&&j.error)||'failed')+'</span>'; }
940
+ }).catch(function(e){ go.disabled=false; go.style.opacity='1'; stt.textContent=''; ansEl.innerHTML='<span style="color:var(--red)">✗ '+esc2(String(e))+'</span>'; });
941
+ }
942
+ Array.prototype.forEach.call(el.querySelectorAll('.ask-ex'),function(b){ b.onclick=function(){ q.value=b.getAttribute('data-q'); ask(); }; });
943
+ go.onclick=ask; q.addEventListener('keydown',function(e){ if((e.metaKey||e.ctrlKey)&&e.key==='Enter') ask(); });
944
+ }
945
+
946
+ function renderCapability(){
947
+ var el=document.getElementById('capability'); if(!el) return;
948
+ var cap=DATA.meta&&DATA.meta.capability;
949
+ if(!cap){ el.innerHTML='<h1>Verifier capabilities</h1><div class="snote">Capability info unavailable in this build.</div>'; return; }
950
+ var d=cap.descriptor||{};
951
+ var chip=function(t){ return '<span class="pm">'+esc2(t)+'</span>'; };
952
+ var provLabel={ 'pure-call':'JS/TS functions', 'render':'React components (JSX/TSX)', 'python':'Python', 'ruby':'Ruby', 'php':'PHP' };
953
+ var provers=(d.provers||[]).map(function(p){ return chip(provLabel[p]||p); }).join('');
954
+ var langLabel={ js:'JavaScript / TS', python:'Python', ruby:'Ruby', php:'PHP', solidity:'Solidity', rust:'Rust', csharp:'C#' };
955
+ var nets=(Object.keys(d.effectNets||{})).map(function(l){ return chip(langLabel[l]||l); }).join('');
956
+ var checks=(d.checks||[]).map(function(c){ return chip(c.replace(/-/g,' ')); }).join('');
957
+ var kinds=(d.policyKinds||[]).map(function(k){ return chip(k.replace(/-/g,' ')); }).join('');
958
+ var sec=function(h,body,note){ return '<div style="border:1px solid var(--rule);border-radius:14px;background:var(--card);box-shadow:var(--shadow);padding:16px 18px;margin:0 0 14px"><div class="dh" style="margin:0 0 9px">'+h+'</div>'+body+(note?('<div class="snote" style="margin-top:9px;font-family:var(--sans)">'+note+'</div>'):'')+'</div>'; }
959
+ var pinned=cap.pinned;
960
+ var html='<h1>Verifier capabilities</h1>'
961
+ +'<div class="snote" style="font-family:var(--sans);margin:0 0 16px">What this machine verifier can currently detect and prove. The version is <b>derived</b> from exactly the set below — a fingerprint over it — so it can never claim more than it does. The verifier improves over versions (each is a capability bump); <b>bring-your-own adapters</b> — custom languages and checks — are on the roadmap.</div>'
962
+ +'<div class="rootcard"><div><div class="rootlbl">Capability version</div><div class="rootfp">'+esc2(cap.version||'?')+'</div></div>'
963
+ +'<div style="text-align:right"><div class="rootlbl">Fingerprint</div><div class="smeta" style="font-family:var(--mono)">'+esc2(String(cap.fingerprint||'').slice(0,24))+'…</div></div></div>'
964
+ +sec('Behaviorally proven → machine Green', '<div class="pmetrics">'+provers+'</div>', 'Pure functions with an <span class="inline">ensures</span> run and are checked against it. (Ruby/PHP: top-level/module functions today.)')
965
+ +sec('First-class effect nets', '<div class="pmetrics">'+nets+'</div>', 'A <span class="inline">pure: yes</span> Cell that actually does I/O is caught (Red) in each of these languages, in its own idioms.')
966
+ +sec('Security &amp; quality checks', '<div class="pmetrics">'+checks+'</div>', 'Run on every passing Cell — mutation grading, inertness, literal-seeding (dormant-trigger hunt), branch-coverage honesty.')
967
+ +sec('Policy rule kinds understood', '<div class="pmetrics">'+kinds+'</div>', 'Owner-signed policy rules the gate enforces.')
968
+ +sec('Verifier of record', pinned?('<div class="skey" style="font-family:var(--mono)"><span class="skeykind">verifier key</span><span>'+esc2(pinned.fp||'')+'</span><span style="color:var(--mut)">· capability '+esc2(pinned.capability||cap.version)+'</span></div>'):'<div class="snote" style="font-family:var(--sans)">No verifier key pinned yet — run <b>yay attest</b> to mint the project verifier and sign the first attestation.</div>', null)
969
+ +'<div style="display:flex;flex-wrap:wrap;gap:10px;margin-top:4px">'
970
+ +'<a class="viewbtn" href="https://yaylayer.com/verify" target="_blank" rel="noopener noreferrer" style="text-decoration:none">Verify an attestation ↗</a>'
971
+ +'<a class="themebtn" href="https://yaylayer.com/capabilities.json" target="_blank" rel="noopener noreferrer" style="text-decoration:none">Canonical registry ↗</a>'
972
+ +'<a class="themebtn" href="https://yaylayer.com/docs/manual.html" target="_blank" rel="noopener noreferrer" style="text-decoration:none">Manual ↗</a>'
973
+ +'</div>';
974
+ el.innerHTML=html;
975
+ }
976
+
977
+ // ── Grants tab — Autopilot delegation grants (capability envelopes) ──
978
+ function renderGrants(){
979
+ var el=document.getElementById('grants'); if(!el) return;
980
+ var gs=(DATA.meta&&DATA.meta.grants)||[];
981
+ var html='<h1>Delegation grants</h1><div class="snote" style="font-family:var(--sans);margin:0 0 16px">Autopilot: owner-signed <b>capability envelopes</b> that let the AI approve in-scope changes (delegated) without contacting your phone — until a grant expires or hits its count. Everything delegated is queued for ratification in the Briefs tab.</div>';
982
+ if(isLive()) html+='<div style="margin:0 0 16px;display:flex;gap:10px;align-items:center;flex-wrap:wrap"><button id="gr-new" class="viewbtn">+ New grant</button><span class="snote" style="font-family:var(--sans);font-size:.82rem">you approve the grant on your phone; only a human can issue it.</span></div>';
983
+ if(!gs.length){ html+='<div class="snote" style="font-family:var(--sans)">No grants issued yet.'+(isLive()?' Click <b>+ New grant</b> above, or use':' Start Autopilot with')+' <code>yay grant --for 2h --count 20</code> — shape the envelope with <code>--allow "src/ui/**"</code>, <code>--deny</code>, <code>--max-risk medium</code>, <code>--child-grants</code>.</div>'; el.innerHTML=html; var nb0=document.getElementById('gr-new'); if(nb0) nb0.onclick=openGrantModal; return; }
984
+ gs.slice().sort(function(a,b){ return (b.active?1:0)-(a.active?1:0); }).forEach(function(g){
985
+ var st=g.active?['active','#1f9d57']:g.revoked?['revoked','#cf4436']:g.expired?['expired','#7f8796']:['spent','#7f8796'];
986
+ var e=g.envelope||{}; var scope=[];
987
+ if(e.cells&&e.cells.length) scope.push(e.cells.length+' named Cell(s)');
988
+ if(e.allow&&e.allow.length) scope.push('allow '+e.allow.join(', '));
989
+ if(e.deny&&e.deny.length) scope.push('deny '+e.deny.join(', '));
990
+ if(e.allowTags&&e.allowTags.length) scope.push('allow-tags '+e.allowTags.join(', '));
991
+ if(e.denyTags&&e.denyTags.length) scope.push('deny-tags '+e.denyTags.join(', '));
992
+ if(e.maxRisk) scope.push('≤ '+e.maxRisk+' risk');
993
+ if(!scope.length) scope.push('all non-sensitive Cells');
994
+ var used=g.spent||0, max=g.maxCount||0, pct=max?Math.round(used/max*100):0;
995
+ var childBit=(e.childGrants&&e.childGrants.allowed)?'<span class="srole signer">child grants ✓ d'+e.childGrants.maxDepth+'</span>':'';
996
+ var guardBit=(e.guard===false)?'<span class="srole" style="background:color-mix(in srgb,var(--red) 15%,transparent);color:var(--red)">⚠ guard off</span>':'<span class="srole signer">🔒 guard on</span>';
997
+ var parentBit=g.parent?'<span class="srole signer">child of '+esc2(g.parent)+'</span>':'';
998
+ var bad=(g.parent&&g.chain&&!g.chain.attenuates)?'<div class="swarn">⚠ '+esc2(g.chain.reason||'invalid chain')+'</div>':'';
999
+ html+='<div class="srow" style="border-left:3px solid '+st[1]+'"><div style="flex:1;min-width:0">'
1000
+ +'<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap"><span class="sname" style="font-family:var(--mono)">'+esc2(g.id)+'</span><span class="srole '+(g.active?'owner':'signer')+'" style="text-transform:uppercase">'+st[0]+'</span>'+guardBit+parentBit+childBit+'</div>'
1001
+ +'<div class="smeta" style="font-family:var(--sans);color:var(--ink2)">'+esc2(scope.join(' · '))+'</div>'
1002
+ +'<div class="smeta">'+used+' / '+(max||'∞')+' delegations used'+(max?(' · '+pct+'%'):'')+' · expires '+esc2(String(g.expiresAt||'').slice(0,16).replace('T',' '))+'</div>'
1003
+ +bad+'</div></div>';
1004
+ });
1005
+ html+='<div class="snote" style="font-family:var(--sans);margin-top:14px">Issue with <b>+ New grant</b> or <code>yay grant</code> · stop one with <code>yay grant revoke [id]</code> · ratify delegated work in <b>Briefs</b>. Only a human owner can issue or revoke a grant — the AI never can.</div>';
1006
+ el.innerHTML=html;
1007
+ var nb=document.getElementById('gr-new'); if(nb) nb.onclick=openGrantModal;
1008
+ }
1009
+ // Issue an Autopilot grant from the dashboard — owner approves on the phone (POST /api/grant/create
1010
+ // spawns "yay grant … --phone"). Only a human can issue a grant; the AI never can.
1011
+ function openGrantModal(){
1012
+ var m=document.getElementById('modal'); if(!m) return; var body=m.querySelector('.modal-body');
1013
+ var fld='width:100%;box-sizing:border-box;margin-top:4px;padding:9px 11px;border-radius:9px;border:1px solid var(--rule);background:var(--card2);color:var(--ink);font-family:var(--sans);font-size:.9rem';
1014
+ var hint='font-size:.72rem;color:var(--mut);margin-top:3px;display:block';
1015
+ body.innerHTML='<div class="mhead"><span class="mname">New delegation grant</span></div>'
1016
+ +'<div class="snote" style="font-family:var(--sans);margin:0 0 14px">Hand the AI a bounded, signed envelope. It approves in-scope changes (delegated) without contacting your phone until this expires or hits the count — everything queues for ratification. You approve <i>this grant</i> on your phone.</div>'
1017
+ +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;max-width:520px">'
1018
+ +'<label style="font-size:.85rem;color:var(--ink2)">Duration<input id="gr-for" value="2h" placeholder="2h, 90m, 1d" style="'+fld+'"></label>'
1019
+ +'<label style="font-size:.85rem;color:var(--ink2)">Max approvals<input id="gr-count" type="number" min="1" value="20" style="'+fld+'"></label>'
1020
+ +'<label style="font-size:.85rem;color:var(--ink2);grid-column:1/3">Allow folders <span style="color:var(--mut)">(glob — limit the AI to these paths)</span><input id="gr-allow" placeholder="src/ui/**, src/lib/**" style="'+fld+'"><span style="'+hint+'">Empty = anywhere (minus the guard &amp; denies below).</span></label>'
1021
+ +'<label style="font-size:.85rem;color:var(--ink2);grid-column:1/3">Block folders <span style="color:var(--mut)">(glob — never auto-approve these)</span><input id="gr-deny" placeholder="src/experimental/**" style="'+fld+'"></label>'
1022
+ +'<label style="font-size:.85rem;color:var(--ink2)">Allow tags <span style="color:var(--mut)">(only these)</span><input id="gr-allowtag" placeholder="ui, docs" style="'+fld+'"></label>'
1023
+ +'<label style="font-size:.85rem;color:var(--ink2)">Block tags <span style="color:var(--mut)">(never these)</span><input id="gr-denytag" placeholder="payments" style="'+fld+'"></label>'
1024
+ +'<label style="font-size:.85rem;color:var(--ink2)">Max risk<select id="gr-risk" style="'+fld+'"><option value="">(any)</option><option value="low">low</option><option value="medium">medium</option><option value="high">high</option></select></label>'
1025
+ +'<label style="font-size:.85rem;color:var(--ink2);display:flex;align-items:center;gap:8px;align-self:end;padding-bottom:9px"><input id="gr-child" type="checkbox"> allow child grants</label>'
1026
+ +'</div>'
1027
+ +'<label id="gr-guardbox" style="display:flex;gap:9px;align-items:flex-start;margin-top:14px;max-width:520px;padding:11px 13px;border:1px solid var(--rule);border-radius:10px;background:var(--card2)"><input id="gr-guard" type="checkbox" checked style="margin-top:3px"><span style="font-size:.85rem;color:var(--ink2)"><b>🔒 Security guard</b> — always require <i>your</i> signature for <b>auth, payments, secrets, deploy &amp; CI</b> (matched by path or tag). <span style="color:var(--mut)">Recommended — leave on. Uncheck only if you truly want the AI to auto-approve those areas.</span></span></label>'
1028
+ +'<div style="margin-top:16px;display:flex;gap:10px;align-items:center"><button id="gr-issue" class="viewbtn">Issue grant → approve on phone</button><span id="gr-out" class="snote" style="font-family:var(--sans)"></span></div>';
1029
+ m.classList.add('open'); document.body.style.overflow='hidden';
1030
+ var out=document.getElementById('gr-out');
1031
+ var guard=document.getElementById('gr-guard'), gbox=document.getElementById('gr-guardbox');
1032
+ guard.onchange=function(){ if(guard.checked){ gbox.style.borderColor='var(--rule)'; gbox.style.background='var(--card2)'; } else { gbox.style.borderColor='#cf4436'; gbox.style.background='color-mix(in srgb,#cf4436 8%,transparent)'; } };
1033
+ document.getElementById('gr-issue').onclick=function(){
1034
+ var btn=this; btn.disabled=true; btn.style.opacity='.6';
1035
+ out.style.color='var(--mut)'; out.textContent='Sent to your phone — approve there…';
1036
+ var payload={ dur:(document.getElementById('gr-for').value||'2h').trim(), count:parseInt(document.getElementById('gr-count').value,10)||20, allow:(document.getElementById('gr-allow').value||'').trim(), deny:(document.getElementById('gr-deny').value||'').trim(), allowTags:(document.getElementById('gr-allowtag').value||'').trim(), denyTags:(document.getElementById('gr-denytag').value||'').trim(), maxRisk:document.getElementById('gr-risk').value, childGrants:document.getElementById('gr-child').checked, noGuard:!guard.checked };
1037
+ fetch('/api/grant/create',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload)}).then(function(r){return r.json();}).then(function(j){
1038
+ if(j&&j.ok){ out.style.color='#1f9d57'; out.textContent='✓ Grant issued — reloading…'; setTimeout(function(){ location.reload(); },1100); }
1039
+ else { out.style.color='#cf4436'; out.textContent='✗ '+((j&&j.error)||'failed'); btn.disabled=false; btn.style.opacity='1'; }
1040
+ }).catch(function(e){ out.style.color='#cf4436'; out.textContent='✗ '+String(e); btn.disabled=false; btn.style.opacity='1'; });
1041
+ };
1042
+ }
1043
+
1044
+ // ── Briefs tab — the plain-English ledger of what was ordered (Standard §5) ──
1045
+ var briefTagFilter=null, briefGroupMode='none', briefView='list'; // Briefs-tab view state (group: none|tag|signer)
1046
+ var briefChartTag=null, briefChartSigner=null, briefChartMetric='count', briefChartRatify=false; // Chart-view filters (tag / signer / both / awaiting-ratification) + Y-axis metric
1047
+ function renderBriefs(){
1048
+ var el=document.getElementById('briefs'); if(!el) return;
1049
+ var ms=(DATA.meta&&DATA.meta.briefs)||[];
1050
+ if(!ms.length){ el.innerHTML='<h1>Briefs</h1><div class="snote">No briefs yet. A Brief is the plain-English record of what you ordered, signed together with each change-set. Sign with <b>yay sign --brief "…"</b> (your AI supplies it automatically).</div>'; return; }
1051
+ var lc=function(s){return String(s).toLowerCase();};
1052
+ // Current verification state of every Cell (from the Files/Map data), so a Brief can show
1053
+ // whether the code it covers STILL proves what was signed — a valid seal over drifted code
1054
+ // must not read as green. RED = broke, PINK = unauthorised, UNSIGNED = lost its signature.
1055
+ var CST={GREEN:'#1f9d57',YELLOW:'#c9860f',RED:'#cf4436',UNSIGNED:'#7f8796',PINK:'#e0559b'};
1056
+ var CRANK={GREEN:0,YELLOW:1,UNSIGNED:2,PINK:3,RED:4};
1057
+ var cellState={}, cellAuto={}; ((DATA.meta&&DATA.meta.files)||[]).forEach(function(f){ cellState[f.id]=f.state; if(f.auto) cellAuto[f.id]=f.grant||true; });
1058
+ // Autopilot: a Cell delegated under an owner-signed grant is GREEN-on-verify but was
1059
+ // NOT human-reviewed. It awaits ratification — a human should look back and sign it for real
1060
+ // with "yay ratify --sign". Surfaced distinctly (⚡) so delegated work never hides as done.
1061
+ function briefAutoCells(cells){ var r=[]; (cells||[]).forEach(function(c){ if(cellAuto['u:'+c]) r.push(c); }); return r; }
1062
+ function ratifyBadge(cells){ var a=briefAutoCells(cells); if(!a.length) return ''; return '<span style="font-size:.72rem;font-weight:800;color:#c9860f" title="Delegated under a grant (Autopilot) — awaiting ratification, not human-reviewed. A human should look back and sign for real: yay ratify --sign">⚡ '+a.length+' awaiting ratification</span>'; }
1063
+ function briefWorst(cells){ var w='GREEN'; (cells||[]).forEach(function(c){ var st=cellState['u:'+c]; if(st && (CRANK[st]||0)>(CRANK[w]||0)) w=st; }); return w; }
1064
+ function briefHealthBadge(cells){ var by={}; (cells||[]).forEach(function(c){ var st=cellState['u:'+c]; if(st && st!=='GREEN') by[st]=(by[st]||0)+1; }); var parts=[]; ['RED','PINK','UNSIGNED','YELLOW'].forEach(function(s){ if(by[s]) parts.push(by[s]+' '+s); }); if(!parts.length) return ''; var w=briefWorst(cells); return '<span style="font-size:.72rem;font-weight:800;color:'+CST[w]+'" title="A Cell this Brief covers no longer verifies GREEN — yay re-derived it as '+w+'. The seal is intact; the code drifted from what was signed.">⚠ '+parts.join(' · ')+'</span>'; }
1065
+ function cellChips(cells,briefId){ return (cells||[]).map(function(c){ var uid='u:'+c; var known=!!DETAILS[uid]; var st=cellState[uid]; var auto=!!cellAuto[uid]; var col=known?(auto?'#c9860f':(CST[st]||'var(--accent)')):null;
1066
+ return '<span class="mcell'+(known?' known':'')+'"'+(known?(' data-uid="'+esc2(uid)+'"'):'')+(briefId?(' data-brief="'+esc2(briefId)+'"'):'')+(col?(' style="color:'+col+';border-color:'+col+'"'):'')+' title="'+(known?((briefId?'See this Cell as this Brief signed it':'Open this Cell')+(st?(' — '+st):'')+(auto?' · ⚡ delegated under grant, awaiting ratification':'')):'This Cell is no longer in the codebase')+'">'+(auto?'⚡ ':'')+esc2(c)+((st&&st!=='GREEN')?(' · '+esc2(st)):'')+'</span>'; }).join(''); }
1067
+ // Briefs are a history lens: open a Cell THROUGH a Brief and you see it as that Brief signed it
1068
+ // (reconstructed from git via the stored specHash), with Current and What-changed tabs. Live only;
1069
+ // on a static snapshot it falls back to the current Cell detail.
1070
+ function htStyle(on){ return 'border:none;padding:6px 14px;font-weight:600;cursor:pointer;font-family:inherit;font-size:.8rem;'+(on?'background:var(--accent);color:#fff':'background:transparent;color:var(--ink)'); }
1071
+ function historyHTML(j){
1072
+ var then=j.then||{}, cur=j.current||{};
1073
+ var head='<div class="mhead"><span class="mid">'+esc2(j.cell)+'</span><span class="mname">as signed in Brief '+esc2(j.brief)+'</span><span class="mpill" style="color:'+(j.drifted?'#c9860f':'#1f9d57')+'">'+(j.drifted?'changed since':'unchanged')+'</span></div>';
1074
+ var meta='<div class="dmeta"><span>signed '+esc2(String(j.at||'').replace('T',' ').slice(0,16))+(j.signer?(' by '+esc2(j.signer)):'')+'</span><span>spec '+esc2(String(j.signedHash||'').slice(0,12))+'…</span>'+(then.commit?('<span>commit '+esc2(then.commit)+'</span>'):'')+'</div>';
1075
+ var hasDiff=j.diff&&j.diff.length;
1076
+ var tabs='<div style="display:inline-flex;border:1px solid var(--rule);border-radius:9px;overflow:hidden;margin:12px 0 12px">'
1077
+ +'<button class="hist-t" data-p="signed" style="'+htStyle(true)+'">As signed</button>'
1078
+ +'<button class="hist-t" data-p="current" style="'+htStyle(false)+'">Current</button>'
1079
+ +'<button class="hist-t" data-p="diff" style="'+htStyle(false)+'">Diff'+(hasDiff?' •':'')+'</button>'
1080
+ +(tlEvents(j.cell).length?('<button class="hist-t" data-p="timeline" style="'+htStyle(false)+'">Timeline</button>'):'')
1081
+ +'</div>';
1082
+ var signedPanel=then.found
1083
+ ?('<div class="dh">Sealed spec'+(then.current?' (still current)':'')+'</div><pre class="code">'+esc2(then.block||'')+'</pre>'+(then.code?('<div class="dh">Code'+((cellAuto['u:'+j.cell])?' it built':'')+'</div><pre class="code">'+esc2(then.code)+'</pre>'):''))
1084
+ :('<div class="snote">'+esc2(then.reason||'The signed version could not be reconstructed')+'. Signed spec-hash <code>'+esc2(String(j.signedHash||'').slice(0,16))+'…</code> — the exact text lives in your git history.</div>');
1085
+ var curPanel=(cur&&cur.block!=null)
1086
+ ?('<div class="dh">Sealed spec'+(cur.state?(' · '+esc2(cur.state)):'')+'</div><pre class="code">'+esc2(cur.block)+'</pre>'+(cur.code?('<div class="dh">Code</div><pre class="code">'+esc2(cur.code)+'</pre>'):''))
1087
+ :'<div class="snote">This Cell is no longer in the codebase.</div>';
1088
+ var diffPanel=hasDiff
1089
+ ?('<div class="dh">Spec — signed → current</div><pre class="code diff">'+j.diff.map(function(d){ var cl=d.t==='+'?'dl-add':(d.t==='-'?'dl-del':'dl-ctx'); var pre=d.t==='+'?'+ ':(d.t==='-'?'- ':' '); return '<span class="'+cl+'">'+esc2(pre+d.text)+'</span>'; }).join('\\n')+'</pre>')
1090
+ :'<div class="snote">No spec changes since it was signed.</div>';
1091
+ return head+meta+tabs
1092
+ +'<div class="hist-panel" data-p="signed">'+signedPanel+'</div>'
1093
+ +'<div class="hist-panel" data-p="current" style="display:none">'+curPanel+'</div>'
1094
+ +'<div class="hist-panel" data-p="diff" style="display:none">'+diffPanel+'</div>'
1095
+ +'<div class="hist-panel" data-p="timeline" style="display:none">'+timelinePanel(j.cell)+'</div>';
1096
+ }
1097
+ // A Cell's semantic timeline (P4): provenance events stitched from the ledgers (signed / delegated
1098
+ // / ratified / attested / rejected), oldest→newest. Structured data comes from DATA.meta.timelines.
1099
+ function tlEvents(cellId){ return ((DATA.meta&&DATA.meta.timelines)||{})[cellId]||[]; }
1100
+ function timelinePanel(cellId){
1101
+ var evs=tlEvents(cellId); if(!evs.length) return '<div class="snote">No recorded provenance events for this Cell yet.</div>';
1102
+ var sym={signed:'✍',delegated:'⚡',ratified:'✅',attested:'◆',rejected:'✗'};
1103
+ var col={signed:'var(--ink-2)',delegated:'#c9860f',ratified:'#1f9d57',attested:'var(--accent)',rejected:'#cf4436'};
1104
+ return '<div class="dh">Provenance timeline</div><ul class="checks">'+evs.map(function(e){ var when=e.at?(' · '+esc2(String(e.at).replace('T',' ').slice(0,16))):''; return '<li style="color:'+(col[e.kind]||'var(--ink)')+'">'+(sym[e.kind]||'•')+' '+esc2(e.text)+when+'</li>'; }).join('')+'</ul>';
1105
+ }
1106
+ function wireHistory(mo){
1107
+ var tabs=mo.querySelectorAll('.hist-t'), panels=mo.querySelectorAll('.hist-panel');
1108
+ Array.prototype.forEach.call(tabs,function(t){ t.onclick=function(){ var p=t.getAttribute('data-p');
1109
+ Array.prototype.forEach.call(tabs,function(x){ var on=x===t; x.style.background=on?'var(--accent)':'transparent'; x.style.color=on?'#fff':'var(--ink)'; });
1110
+ Array.prototype.forEach.call(panels,function(pl){ pl.style.display=(pl.getAttribute('data-p')===p)?'block':'none'; });
1111
+ }; });
1112
+ }
1113
+ function openCellHistory(uid,briefId){
1114
+ var cellId=String(uid).replace(/^u:/,'');
1115
+ if(!isLive()){ if(typeof openDetail==='function') openDetail(uid); return; } // static snapshot → current only
1116
+ var mo=document.getElementById('modal'); if(!mo) return; var body=mo.querySelector('.modal-body');
1117
+ body.innerHTML='<div class="snote">Reconstructing '+esc2(cellId)+' as Brief '+esc2(briefId)+' signed it…</div>';
1118
+ mo.classList.add('open'); document.body.style.overflow='hidden';
1119
+ fetch('/api/cell-history',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({brief:briefId,cell:cellId})}).then(function(r){return r.json();}).then(function(j){
1120
+ if(!j||!j.ok){ body.innerHTML='<div class="snote">Could not load history: '+esc2((j&&j.error)||'failed')+'</div>'; return; }
1121
+ body.innerHTML=historyHTML(j); wireHistory(mo);
1122
+ }).catch(function(){ body.innerHTML='<div class="snote">History request failed.</div>'; });
1123
+ }
1124
+ function briefCard(m){
1125
+ var when=m.at?String(m.at).slice(0,10):'';
1126
+ var cells=m.cells||[]; var valid=m.valid!==false; var worst=valid?briefWorst(cells):'RED';
1127
+ var bar = !valid ? '#cf4436' : (CST[worst]||'var(--accent)');
1128
+ var hb = valid?briefHealthBadge(cells):'';
1129
+ var badge=valid?'<span style="font-size:.72rem;font-weight:700;color:#1f9d57" title="Signature verifies against a trusted signer">✓ signed</span>':'<span style="font-size:.72rem;font-weight:700;color:#cf4436" title="This seal does NOT verify — the brief or approval was tampered with, or it was not signed by a trusted key">⚠ seal invalid</span>';
1130
+ var tagline=(m.tags&&m.tags.length)?('<div style="margin:0 0 8px">'+m.tags.map(function(t){var on=briefTagFilter&&lc(t)===lc(briefTagFilter);return '<span class="btag" data-tag="'+esc2(t)+'" title="Filter Briefs by this tag" style="display:inline-block;font-size:.68rem;font-weight:700;padding:2px 9px;border-radius:100px;border:1px solid '+(on?'var(--accent)':'var(--rule)')+';margin:0 5px 4px 0;cursor:pointer;'+(on?'background:var(--accent);color:#fff':'color:var(--accent)')+'">'+esc2(t)+'</span>';}).join('')+'</div>'):'';
1131
+ return '<div style="border:1px solid var(--rule);border-left:3px solid '+bar+';border-radius:12px;padding:14px 16px;margin:0 0 12px;background:var(--card2)">'
1132
+ +'<div style="display:flex;justify-content:space-between;gap:12px;align-items:baseline;margin-bottom:6px"><span style="font-weight:800;letter-spacing:.06em;font-size:.72rem;color:var(--accent)">BRIEF '+esc2(m.id||'')+'</span><span style="font-size:.78rem;color:var(--mut)">'+badge+' · '+esc2(when)+(m.signer?(' · '+esc2(m.signer)):'')+'</span></div>'
1133
+ +(m.title?('<div style="font-size:1.06rem;font-weight:800;color:var(--ink);margin-bottom:3px">'+esc2(m.title)+'</div>'):'')
1134
+ +'<div style="font-size:'+(m.title?'.92rem':'1.02rem')+';line-height:1.45;color:'+(m.title?'var(--mut)':'var(--ink)')+';margin-bottom:8px">'+esc2(m.text||'')+'</div>'+tagline
1135
+ +'<div style="font-size:.8rem;color:var(--mut);display:flex;flex-wrap:wrap;gap:10px;align-items:baseline"><span>covers '+cells.length+' part'+(cells.length===1?'':'s')+(cells.length?' — click to open:':'')+'</span>'+hb+ratifyBadge(cells)+'</div>'
1136
+ +(cells.length?('<div style="margin-top:2px">'+cellChips(cells,m.id)+'</div>'):'')
1137
+ +(function(){ var a=briefAutoCells(cells); if(!a.length) return ''; var g=m.grant||cellAuto['u:'+a[0]]||''; return '<div style="margin-top:10px;border:1px solid #c9860f;border-radius:9px;padding:9px 11px;background:var(--paper);font-size:.8rem;color:var(--ink-2)"><b style="color:#c9860f">⚡ Delegated'+((g&&g!==true)?(' · grant '+esc2(g)):'')+'</b> — delegated under Autopilot, <b>awaiting ratification</b>. Ratify (sign for real) '+(isLive()?'with the <b>⚡ Ratify now</b> button at the top of the Briefs tab':'from a live dashboard’s ⚡ Ratify button, or run <code>yay ratify --sign</code>')+'.</div>'; })()
1138
+ +'</div>';
1139
+ }
1140
+ // ── Chart view: cumulative characters of signed Specs + Briefs over time, filterable
1141
+ // by tag and/or signer. A Brief's "weight" = its own chars (title + prose) plus the
1142
+ // spec-block chars of every Cell it covers (DATA.meta.specChars).
1143
+ var specChars=(DATA.meta&&DATA.meta.specChars)||{};
1144
+ function briefChars(b){ var s=(b.title||'').length+(b.text||'').length; (b.cells||[]).forEach(function(c){ s+=(specChars[c]||0); }); return s; }
1145
+ function chartControls(){
1146
+ var pool=(DATA.meta&&DATA.meta.tags)||[];
1147
+ var signers=[]; ms.forEach(function(b){ if(b.signer&&signers.indexOf(b.signer)<0) signers.push(b.signer); });
1148
+ var selCss='padding:7px 10px;border-radius:8px;border:1px solid var(--rule);background:var(--paper);color:var(--ink);font-family:inherit;font-size:.82rem';
1149
+ var tagOpts='<option value="">All tags</option>'+pool.map(function(t){return '<option value="'+esc2(t)+'"'+(briefChartTag&&lc(t)===lc(briefChartTag)?' selected':'')+'>#'+esc2(t)+'</option>';}).join('');
1150
+ var sigOpts='<option value="">All signers</option>'+signers.map(function(s){return '<option value="'+esc2(s)+'"'+(briefChartSigner&&lc(s)===lc(briefChartSigner)?' selected':'')+'>'+esc2(s)+'</option>';}).join('');
1151
+ var reset=(briefChartTag||briefChartSigner||briefChartRatify)?'<span id="bf-creset" style="font-size:.78rem;color:var(--accent);cursor:pointer;text-decoration:underline">reset</span>':'';
1152
+ function mb(v,l,tip){ var on=briefChartMetric===v; return '<button class="bf-metric" data-m="'+v+'" title="'+esc2(tip)+'" style="border:none;padding:6px 13px;font-weight:600;cursor:pointer;font-family:inherit;font-size:.78rem;'+(on?'background:var(--accent);color:#fff':'background:transparent;color:var(--ink)')+'">'+l+'</button>'; }
1153
+ var metricSeg='<span style="font-size:.8rem;color:var(--mut)">Y-axis:</span><div style="display:inline-flex;border:1px solid var(--rule);border-radius:8px;overflow:hidden">'+mb('count','Count','How many things you have signed — each Brief plus every Cell it covers (counted once)')+mb('chars','Chars','Depth — characters of Brief prose plus the specs it covers')+'</div>';
1154
+ var anyAuto=ms.some(function(b){ return briefAutoCells(b.cells).length; });
1155
+ var ratTog=anyAuto?('<button id="bf-crat" title="Show only Briefs with Cells delegated under a grant (Autopilot), awaiting a real human signature — yay ratify --sign" style="border:1px solid '+(briefChartRatify?'#c9860f':'var(--rule)')+';background:'+(briefChartRatify?'#c9860f':'transparent')+';color:'+(briefChartRatify?'#fff':'var(--ink)')+';border-radius:100px;padding:6px 12px;cursor:pointer;font-family:inherit;font-size:.78rem;font-weight:700">⚡ Awaiting ratification</button>'):'';
1156
+ return '<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 0 12px">'+metricSeg+'<span style="width:8px"></span><span style="font-size:.8rem;color:var(--mut)">Filter:</span><select id="bf-ct" style="'+selCss+'">'+tagOpts+'</select><select id="bf-cs" style="'+selCss+'">'+sigOpts+'</select>'+ratTog+reset+'</div>';
1157
+ }
1158
+ function chartSVG(list){
1159
+ if(!list.length) return '<div class="snote">No Briefs match this filter.</div>';
1160
+ // Y-axis metric. Default "count" = approved units the human has signed over time:
1161
+ // each Brief adds itself (+1) plus every Cell it covers that no earlier Brief already
1162
+ // counted (distinct). "chars" is the secondary depth lens (title+prose+covered specs).
1163
+ var isCount=(briefChartMetric==='count'), unit=isCount?'units':'chars';
1164
+ function inc(b,seen){ if(!isCount) return briefChars(b); var k=1; (b.cells||[]).forEach(function(c){ if(!seen[c]){ seen[c]=1; k++; } }); return k; }
1165
+ var pts=[], cum=0, tmin=Infinity, tmax=-Infinity, seen={};
1166
+ list.forEach(function(b){ var d=inc(b,seen); cum+=d; var t=Date.parse(b.at||'')||0; if(t<tmin)tmin=t; if(t>tmax)tmax=t; pts.push({t:t,y:cum,inc:d,b:b}); });
1167
+ var ymax=cum||1, n=list.length;
1168
+ function fmtY(v){ return isCount?String(Math.round(v)):(v>=1000?(Math.round(v/100)/10)+'k':Math.round(v)); }
1169
+ var W=760,H=340,L=58,R=20,Tp=18,Bp=46, pw=W-L-R, ph=H-Tp-Bp, same=(tmax<=tmin);
1170
+ function X(i,t){ return same?(n<=1?L+pw/2:L+(i/(n-1))*pw):(L+(t-tmin)/(tmax-tmin)*pw); }
1171
+ function Y(v){ return Tp+ph-(v/ymax)*ph; }
1172
+ // One colour per signer so several signers are visually distinct. First signer keeps the
1173
+ // accent (ties to the line); the rest get a fixed palette; unattributed Briefs are grey.
1174
+ var sigNamed=[]; list.forEach(function(b){ var s=b.signer||''; if(s&&sigNamed.indexOf(s)<0) sigNamed.push(s); }); sigNamed.sort();
1175
+ var PAL=['#3b82f6','#f59e0b','#a855f7','#14b8a6','#ec4899','#6366f1','#84cc16','#f97316'];
1176
+ function sigColor(s){ if(!s) return '#9aa0a6'; var i=sigNamed.indexOf(s); return i<=0?'var(--accent)':PAL[(i-1)%PAL.length]; }
1177
+ var line='', dots='', pdata=[], dr=Math.max(1.4, 4-Math.floor(n/25)); // dots shrink as points crowd (a year of dailies stays legible; the line always reads)
1178
+ pts.forEach(function(p,i){ var x=X(i,p.t), y=Y(p.y), col=sigColor(p.b.signer||''), au=briefAutoCells(p.b.cells).length; line+=(i?' L':'M')+x.toFixed(1)+','+y.toFixed(1);
1179
+ pdata.push({x:+x.toFixed(1),y:+y.toFixed(1),d:String(p.b.at||'').slice(0,10),t:(p.b.title||''),bt:(p.b.text||''),c:p.inc,v:p.y,s:p.b.signer||'',id:(p.b.id||''),col:col,au:au});
1180
+ if(au) dots+='<circle cx="'+x.toFixed(1)+'" cy="'+y.toFixed(1)+'" r="'+(dr+3)+'" fill="none" stroke="#c9860f" stroke-width="1.6"/>'; // ⚡ awaiting-ratification ring
1181
+ dots+='<circle cx="'+x.toFixed(1)+'" cy="'+y.toFixed(1)+'" r="'+dr+'" fill="'+col+'"'+(dr>=3?' stroke="var(--card2)" stroke-width="1.5"':'')+'><title>'+esc2((au?'⚡ awaiting ratification · ':'')+(p.b.title||p.b.text||'')+' — +'+p.inc+' '+unit+' → '+p.y+' total · '+String(p.b.at||'').replace('T',' ').slice(0,16)+(p.b.signer?' · '+p.b.signer:''))+'</title></circle>'; });
1182
+ var pattr=esc2(JSON.stringify(pdata)).replace(/"/g,'&quot;');
1183
+ var hasUnsigned=list.some(function(b){ return !(b.signer); });
1184
+ var legItems=sigNamed.map(function(s){ return {s:s,c:sigColor(s)}; }); if(hasUnsigned) legItems.push({s:'(unattributed)',c:'#9aa0a6'});
1185
+ var legend=(legItems.length>1)?('<div style="display:flex;flex-wrap:wrap;gap:12px;padding:9px 4px 2px;border-top:1px solid var(--rule);margin-top:2px">'+legItems.map(function(it){ return '<span style="display:inline-flex;align-items:center;gap:6px;font-size:.72rem;color:var(--ink-2)"><span style="width:9px;height:9px;border-radius:50%;background:'+it.c+';display:inline-block;flex:0 0 auto"></span>'+esc2(it.s)+'</span>'; }).join('')+'</div>'):'';
1186
+ var x0=X(0,pts[0].t), xl=X(n-1,pts[n-1].t), y0=Y(0);
1187
+ var area='M'+x0.toFixed(1)+','+y0.toFixed(1)+' '+line.replace(/^M/,'L')+' L'+xl.toFixed(1)+','+y0.toFixed(1)+' Z';
1188
+ // integer metric → at most (ymax+1) distinct gridlines, so a 3-unit chart doesn't show "0.75, 1.5…"
1189
+ var steps=isCount?Math.max(1,Math.min(4,Math.round(ymax))):4;
1190
+ var yt=''; for(var k=0;k<=steps;k++){ var v=ymax*k/steps, yy=Y(v); yt+='<line x1="'+L+'" y1="'+yy.toFixed(1)+'" x2="'+(W-R)+'" y2="'+yy.toFixed(1)+'" stroke="var(--rule)" stroke-width="1" opacity="0.55"/><text x="'+(L-8)+'" y="'+(yy+3.5).toFixed(1)+'" text-anchor="end" font-size="10" fill="var(--mut)">'+fmtY(v)+'</text>'; }
1191
+ var xt='', idxs=(n<=1)?[0]:(n<=3?pts.map(function(_,i){return i;}):[0,Math.floor((n-1)/2),n-1]);
1192
+ idxs.forEach(function(i){ var x=X(i,pts[i].t); xt+='<text x="'+x.toFixed(1)+'" y="'+(H-24)+'" text-anchor="middle" font-size="10" fill="var(--mut)">'+esc2(String(pts[i].b.at||'').slice(0,10))+'</text>'; });
1193
+ return '<div id="bf-chartwrap" style="position:relative;border:1px solid var(--rule);border-radius:14px;background:var(--card2);padding:14px 12px 8px;overflow-x:auto">'
1194
+ +'<svg id="bf-chartsvg" data-pts="'+pattr+'" data-unit="'+unit+'" viewBox="0 0 '+W+' '+H+'" style="width:100%;min-width:520px;height:auto;display:block;cursor:pointer">'
1195
+ +yt+'<path d="'+area+'" fill="var(--accent)" opacity="0.10"/><path d="'+line+'" fill="none" stroke="var(--accent)" stroke-width="2"/>'+dots
1196
+ +'<text x="'+L+'" y="'+(Tp+1)+'" font-size="10" fill="var(--mut)">'+(isCount?'cumulative approved units — Cells + Briefs':'cumulative chars — Specs + Briefs')+'</text>'+xt+'</svg>'
1197
+ +legend
1198
+ +'<div style="font-size:.75rem;color:var(--mut);padding:6px 4px 2px">'+n+' Brief'+(n===1?'':'s')+' · '+(isCount?(ymax+' approved unit'+(ymax===1?'':'s')+' (Cells + Briefs)'):(ymax+' total characters signed'))+(briefChartTag?(' · #'+esc2(briefChartTag)):'')+(briefChartSigner?(' · '+esc2(briefChartSigner)):'')+'. Hover to preview a Brief; click a point to open it.</div></div>';
1199
+ }
1200
+ // Hover the line: a small white bubble shows the nearest Brief — its title in a
1201
+ // readable size, the full Brief text in smaller letters below.
1202
+ function setupChartLens(){
1203
+ var svg=document.getElementById('bf-chartsvg'), wrap=document.getElementById('bf-chartwrap');
1204
+ if(!svg||!wrap) return; var pd; try{ pd=JSON.parse(svg.getAttribute('data-pts')||'[]'); }catch(e){ pd=[]; }
1205
+ if(!pd.length) return; var VW=760, VH=340, unitW=svg.getAttribute('data-unit')||'chars';
1206
+ var oldTip=document.getElementById('bf-chart-tip'); if(oldTip) oldTip.remove(); // a body-level tip from a previous render would otherwise leak
1207
+ var tip=document.createElement('div'); tip.id='bf-chart-tip'; tip.style.cssText='position:fixed;pointer-events:none;cursor:pointer;opacity:0;transition:opacity .1s ease;z-index:9999;max-width:230px;background:#fff;border:1px solid rgba(0,0,0,0.10);border-radius:12px;box-shadow:0 10px 28px rgba(0,0,0,0.22);padding:9px 12px;text-align:left;font-weight:400'; document.body.appendChild(tip);
1208
+ var mark=document.createElement('div'); mark.style.cssText='position:absolute;pointer-events:none;opacity:0;transition:opacity .1s ease;z-index:29;width:14px;height:14px;border-radius:50%;border:2px solid var(--accent);background:#fff;box-shadow:0 0 0 3px rgba(0,0,0,0.05)'; wrap.appendChild(mark);
1209
+ var overSvg=false, overTip=false, hideT=null, curId='';
1210
+ function reallyHide(){ if(overSvg||overTip) return; tip.style.opacity='0'; tip.style.pointerEvents='none'; mark.style.opacity='0'; tip._key=''; }
1211
+ function scheduleHide(){ if(hideT) clearTimeout(hideT); hideT=setTimeout(reallyHide,140); }
1212
+ // Open the Brief in the shared modal — full text, tags, and its exact Cells (each chip opens the Cell).
1213
+ function openBrief(id){ var b=((DATA.meta&&DATA.meta.briefs)||[]).filter(function(x){ return String(x.id)===String(id); })[0]; if(!b) return;
1214
+ var mo=document.getElementById('modal'); if(!mo) return; mo.querySelector('.modal-body').innerHTML=briefCard(b); mo.classList.add('open'); document.body.style.overflow='hidden'; overSvg=false; overTip=false; reallyHide();
1215
+ Array.prototype.forEach.call(mo.querySelectorAll('.mcell.known'),function(ch){ ch.addEventListener('click',function(){ var br=ch.getAttribute('data-brief'); if(br) openCellHistory(ch.getAttribute('data-uid'),br); else openDetail(ch.getAttribute('data-uid')); }); });
1216
+ Array.prototype.forEach.call(mo.querySelectorAll('.btag'),function(ch){ ch.addEventListener('click',function(){ briefChartTag=ch.getAttribute('data-tag'); var cl=mo.querySelector('.modal-close'); if(cl) cl.click(); renderBriefs(); }); }); }
1217
+ // The bubble is a live element: hovering it keeps it up, and clicking it opens the Brief — same as clicking the point.
1218
+ tip.addEventListener('mouseenter',function(){ overTip=true; if(hideT){ clearTimeout(hideT); hideT=null; } });
1219
+ tip.addEventListener('mouseleave',function(){ overTip=false; scheduleHide(); });
1220
+ tip.addEventListener('click',function(){ if(curId) openBrief(curId); });
1221
+ svg.addEventListener('mouseenter',function(){ overSvg=true; });
1222
+ svg.addEventListener('mouseleave',function(){ overSvg=false; scheduleHide(); });
1223
+ svg.addEventListener('mousemove',function(ev){
1224
+ var r=svg.getBoundingClientRect(); if(!r.width) return; var wr=wrap.getBoundingClientRect();
1225
+ var sx=r.width/VW, sy=r.height/VH, vx=(ev.clientX-r.left)/sx;
1226
+ var near=pd[0], bd=1e9; pd.forEach(function(p){ var d=Math.abs(p.x-vx); if(d<bd){bd=d;near=p;} });
1227
+ curId=near.id; overSvg=true; if(hideT){ clearTimeout(hideT); hideT=null; }
1228
+ var offX=r.left-wr.left, offY=r.top-wr.top, cx=offX+near.x*sx, cy=offY+near.y*sy;
1229
+ mark.style.left=(cx-7)+'px'; mark.style.top=(cy-7)+'px'; mark.style.borderColor=near.col||'var(--accent)'; mark.style.opacity='1';
1230
+ var key=near.x;
1231
+ if(tip._key!==key){
1232
+ tip._key=key; var head=near.t||near.bt, body=near.t?near.bt:'';
1233
+ tip.innerHTML=(near.au?'<div style="font-size:10px;font-weight:800;color:#c9860f;margin-bottom:3px">⚡ '+near.au+' cell'+(near.au===1?'':'s')+' awaiting ratification</div>':'')
1234
+ +'<div style="font-size:11px;font-weight:700;color:'+(near.col||'var(--accent)')+';margin-bottom:2px">+'+near.c+' '+unitW+' → '+near.v+' total</div>'
1235
+ +'<div style="font-size:12px;font-weight:700;color:#1a1a1a;line-height:1.3;white-space:normal;overflow-wrap:anywhere;word-break:break-word">'+esc2(head)+'</div>'
1236
+ +(body?('<div style="font-size:10px;font-weight:400;color:#666;line-height:1.4;margin-top:3px;white-space:normal;overflow-wrap:anywhere">'+esc2(body)+'</div>'):'')
1237
+ +'<div style="font-size:9px;font-weight:400;color:#9a9a9a;margin-top:5px;letter-spacing:.02em;display:flex;align-items:center;gap:5px"><span style="width:8px;height:8px;border-radius:50%;display:inline-block;flex:0 0 auto;background:'+(near.col||'#9aa0a6')+'"></span>'+esc2(near.d)+(near.s?(' · '+esc2(near.s)):'')+'</div>';
1238
+ }
1239
+ var px=r.left+near.x*sx, py=r.top+near.y*sy; // the point in viewport (fixed) coords
1240
+ var tw=tip.offsetWidth||200, th=tip.offsetHeight||60;
1241
+ var lx=px-tw/2, ly=py-th-14; if(ly<4) ly=py+16;
1242
+ lx=Math.max(4, Math.min(lx, window.innerWidth-tw-4));
1243
+ if(ly+th>window.innerHeight-4) ly=Math.max(4, window.innerHeight-th-4);
1244
+ tip.style.left=lx+'px'; tip.style.top=ly+'px'; tip.style.pointerEvents='auto'; tip.style.opacity='1';
1245
+ });
1246
+ svg.addEventListener('click',function(ev){
1247
+ var r=svg.getBoundingClientRect(); if(!r.width) return; var vx=(ev.clientX-r.left)/(r.width/VW);
1248
+ var near=pd[0], bd=1e9; pd.forEach(function(p){ var d=Math.abs(p.x-vx); if(d<bd){bd=d;near=p;} });
1249
+ openBrief(near.id);
1250
+ });
1251
+ }
1252
+ // View toggle: List (flat / grouped) vs Clouds (a card per tag) vs Chart (growth over time)
1253
+ function vbtn(v,label){ var on=briefView===v; return '<button class="bf-view" data-v="'+v+'" style="border:none;padding:6px 15px;font-weight:600;cursor:pointer;font-family:inherit;font-size:.8rem;'+(on?'background:var(--accent);color:#fff':'background:transparent;color:var(--ink)')+'">'+label+'</button>'; }
1254
+ var seg='<div style="display:inline-flex;border:1px solid var(--rule);border-radius:9px;overflow:hidden;margin-right:4px">'+vbtn('list','List')+vbtn('clouds','Clouds')+vbtn('chart','Chart')+'</div>';
1255
+ // Compact group toggles (tag / signer). Own class — NOT .tab (that's the full-width sidebar style now).
1256
+ function gbtn(mode,label){ var on=briefGroupMode===mode; return '<button class="bf-gbtn" data-g="'+mode+'" style="border:1px solid '+(on?'var(--accent)':'var(--rule)')+';background:'+(on?'color-mix(in srgb,var(--brand) 15%,transparent)':'transparent')+';color:'+(on?'var(--accent)':'var(--ink2)')+';border-radius:100px;padding:6px 13px;cursor:pointer;font-family:var(--sans);font-size:.8rem;font-weight:600;white-space:nowrap">'+(on?'✓ ':'')+label+'</button>'; }
1257
+ var toolbar='<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 0 14px">'+seg
1258
+ +(briefView==='list'?(gbtn('tag','Group by tag')+gbtn('signer','Group by signer')
1259
+ +(briefTagFilter?('<span style="display:inline-flex;align-items:center;gap:6px;font-size:.78rem;font-weight:700;color:var(--accent);border:1px solid var(--accent);border-radius:100px;padding:3px 11px">#'+esc2(briefTagFilter)+' <span id="bf-clear" style="cursor:pointer;opacity:.7" title="Clear filter">✕</span></span>'):'')):'')
1260
+ +'</div>';
1261
+ var bcfg=(DATA.meta&&DATA.meta.batch)||{enabled:true,barrier:5};
1262
+ // A real PROJECT SETTING (changes the AI's behaviour) — styled distinctly from the
1263
+ // List/Clouds/Group view controls above so it can't be mistaken for a display toggle.
1264
+ var batchbar=isLive()?('<div style="border:1px solid var(--rule);border-left:3px solid var(--accent);border-radius:10px;padding:11px 14px;margin:0 0 18px;background:var(--card2)">'
1265
+ +'<div style="display:flex;flex-wrap:wrap;gap:9px;align-items:center">'
1266
+ +'<span style="font-family:var(--sans);font-size:.6rem;font-weight:700;letter-spacing:.13em;text-transform:uppercase;color:var(--accent)">⚙ Project setting</span>'
1267
+ +'<b style="color:var(--ink);font-size:.9rem">Batch</b>'
1268
+ +'<label style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;font-size:.85rem;color:var(--ink2)"><input type="checkbox" id="bf-batch-en"'+(bcfg.enabled?' checked':'')+'>on</label>'
1269
+ +'<span style="color:var(--mut)">·</span>'
1270
+ +'<span style="font-size:.85rem;color:var(--ink2)">sign after <input id="bf-batch-n" type="number" min="1" max="100" value="'+bcfg.barrier+'" style="width:52px;padding:4px 6px;border-radius:7px;border:1px solid var(--rule);background:var(--paper);color:var(--ink)"> small changes</span>'
1271
+ +'<span id="bf-batch-msg" class="savepill" style="margin-left:auto"></span>'
1272
+ +'</div>'
1273
+ +'<div style="font-size:.77rem;color:var(--mut);margin-top:7px">Controls how the <b style="color:var(--ink2)">AI groups changes into Briefs</b> before you sign — and is shared with your team. It does <b style="color:var(--ink2)">not</b> affect how Briefs are displayed here.</div>'
1274
+ +'</div>'):'';
1275
+ var hint=briefView==='clouds'?'Each tag is a cloud; inside, its Briefs newest-first. A Brief with several tags appears in every matching cloud — tap one to see its parts.':briefView==='chart'?'How much you’ve signed over time. <b>Count</b> = approved units (each Brief + the Cells it covers); <b>Chars</b> = the same growth by depth (Brief prose + covered specs). Filter by tag and/or signer.':'Click a tag to filter; group by tag or signer to organize the list.';
1276
+ // Order: description → project setting (batch) → view controls + their hint → the Briefs.
1277
+ // The List/Clouds/Group controls sit right above the Briefs they display.
1278
+ // Autopilot call-out: how many Cells across how many Briefs are delegated (produced under a grant
1279
+ // under a grant) and still await a real human signature.
1280
+ var ratCells=0, ratBriefs=0; ms.forEach(function(b){ var a=briefAutoCells(b.cells); if(a.length){ ratBriefs++; ratCells+=a.length; } });
1281
+ var onPhone=((DATA.meta&&DATA.meta.signMethod)!=='local');
1282
+ var ratBtn=isLive()?('<div style="margin-top:11px;display:flex;gap:10px;align-items:center;flex-wrap:wrap"><button id="bf-ratify" style="padding:8px 15px;border-radius:8px;border:none;background:#c9860f;color:#fff;font-weight:700;cursor:pointer">⚡ Ratify now — '+(onPhone?'sign on your phone':'sign')+'</button><span style="font-size:.82rem;color:var(--mut)">reviews & signs all delegated Cells for real</span><span id="bf-ratmsg" style="font-size:.82rem;color:var(--mut)"></span></div>'):'';
1283
+ // Meaningful ratify screen (P3): the GRANT SCOPE + a BOUNDARY / DEVIATION report + the verifier
1284
+ // attestation, so ratification is real review — humans review exceptions far better than they
1285
+ // re-read everything. Only the grants that actually authorized the pending delegations are shown.
1286
+ // Per-Cell review surface: each delegated Cell with its state + already-computed flags, clickable
1287
+ // through to the full detail — so you triage which delegated Cell needs a close look before signing.
1288
+ function ratifyCellList(){
1289
+ var rc=(DATA.meta&&DATA.meta.ratifyCells)||[]; if(!rc.length) return '';
1290
+ var rows=rc.map(function(c){
1291
+ var col=({GREEN:'#1f9d57',YELLOW:'#c9860f',RED:'#cf4436',UNSIGNED:'#7f8796',PINK:'#d6519a'})[c.state]||'var(--mut)';
1292
+ var chips=(c.flags||[]).map(function(f){ return '<span style="font-size:.7rem;color:#c9860f;border:1px solid color-mix(in srgb,#c9860f 42%,transparent);border-radius:5px;padding:.03em .34em;margin-left:5px;white-space:nowrap">'+esc2(f)+'</span>'; }).join('');
1293
+ var known=!!(DATA.nodes&&DATA.nodes[c.uid]);
1294
+ return '<div class="ratrow'+(known?' known':'')+'"'+(known?(' data-uid="'+esc2(c.uid)+'"'):'')+' style="display:flex;align-items:center;gap:7px;flex-wrap:wrap;padding:6px 0;border-top:1px solid var(--rule)'+(known?';cursor:pointer':'')+'"><span style="color:'+col+';font-weight:700;font-size:.8rem" title="'+esc2(c.state)+'">●</span><b style="font-size:.82rem">'+esc2(c.id)+'</b><span style="font-size:.78rem;color:var(--mut)">'+esc2(c.unit)+'</span>'+chips+(known?'<span style="font-size:.72rem;color:var(--accent);margin-left:auto">review →</span>':'')+'</div>';
1295
+ }).join('');
1296
+ return '<div style="margin-top:8px">'+rows+'</div>';
1297
+ }
1298
+ function ratifyDetail(){
1299
+ var g=(DATA.meta&&DATA.meta.grants)||[]; if(!g.length) return '';
1300
+ // which grants are referenced by the delegated Cells still awaiting ratification?
1301
+ var used={}; ms.forEach(function(b){ briefAutoCells(b.cells).forEach(function(c){ var gr=cellAuto['u:'+c]; if(gr&&gr!==true) used[gr]=true; }); });
1302
+ var rows=g.filter(function(x){ return used[x.id]||(x.active&&x.spent>0); }).map(function(x){
1303
+ var e=x.envelope||{}; var sc=[];
1304
+ if(e.cells&&e.cells.length) sc.push(e.cells.length+' named Cell(s)');
1305
+ if(e.allow&&e.allow.length) sc.push('allow '+e.allow.join(', '));
1306
+ if(e.deny&&e.deny.length) sc.push('deny '+e.deny.join(', '));
1307
+ if(e.allowTags&&e.allowTags.length) sc.push('allow-tags '+e.allowTags.join(', '));
1308
+ if(e.denyTags&&e.denyTags.length) sc.push('deny-tags '+e.denyTags.join(', '));
1309
+ if(e.maxRisk) sc.push('≤'+e.maxRisk+' risk');
1310
+ if(!sc.length) sc.push('all non-sensitive Cells');
1311
+ var used=x.spent||0, max=x.maxCount||0; var pct=max?Math.round(used/max*100):0;
1312
+ var dev=max?(used+' of '+max+' delegations consumed ('+pct+'%)'):(used+' delegations');
1313
+ var guardBit=(e.guard===false)?' · <b style="color:#cf4436">⚠ security guard off</b>':' · 🔒 security guard on';
1314
+ var childBit=(e.childGrants&&e.childGrants.allowed)?(' · child grants allowed (depth '+e.childGrants.maxDepth+')'):'';
1315
+ var parentBit=x.parent?(' · child of '+esc2(x.parent)):'';
1316
+ return '<div style="margin-top:7px;padding-top:7px;border-top:1px solid var(--rule)"><b>Grant '+esc2(x.id)+'</b>'+guardBit+parentBit+childBit+'<div style="font-size:.8rem;color:var(--ink-2);margin-top:2px"><b>scope:</b> '+esc2(sc.join(' · '))+'</div><div style="font-size:.8rem;color:var(--mut);margin-top:2px"><b>boundary:</b> '+esc2(dev)+' · expires '+esc2(String(x.expiresAt||'').replace('T',' ').slice(0,16))+(x.revoked?' · <b style="color:#cf4436">revoked</b>':'')+'</div></div>';
1317
+ }).join('');
1318
+ var at=DATA.meta&&DATA.meta.attest;
1319
+ var atLine=at?('<div style="font-size:.8rem;margin-top:7px;padding-top:7px;border-top:1px solid var(--rule)"><b>verifier attestation:</b> '+(at.covered?('<span style="color:#1f9d57">✓ '+esc2(at.hash.slice(0,12))+'</span> covers the current code — '+(at.passed?'PASS':'BLOCKED')+' · capability '+esc2(at.capability)):'<span style="color:#c9860f">⚠ stale</span> — code changed since the last attestation ('+esc2(at.hash.slice(0,12))+'); re-mint with <code>yay attest</code>')+'</div>'):('<div style="font-size:.8rem;color:var(--mut);margin-top:7px;padding-top:7px;border-top:1px solid var(--rule)">No verifier attestation yet — mint one with <code>yay attest</code> so ratification references a signed machine verdict.</div>');
1320
+ if(!rows&&!at) return '';
1321
+ return '<details style="margin-top:9px"><summary style="cursor:pointer;font-weight:700;font-size:.82rem;color:var(--ink-2)">What was delegated — grant scope, boundary & verification</summary><div style="margin-top:4px">'+rows+atLine+'</div></details>';
1322
+ }
1323
+ var ratNote=ratCells?('<div style="border:1px solid #c9860f;border-left:3px solid #c9860f;border-radius:12px;padding:11px 14px;margin:0 0 16px;background:var(--card2)"><div style="font-weight:800;color:#c9860f;font-size:.9rem">⚡ '+ratCells+' Cell'+(ratCells===1?'':'s')+' across '+ratBriefs+' Brief'+(ratBriefs===1?'':'s')+' await ratification</div><div style="font-size:.82rem;color:var(--mut);margin-top:4px">Delegated under a grant (<b>Autopilot</b>) — <b>awaiting ratification</b>, not human-reviewed. Look back, then sign them for real'+(isLive()?' with the button below':' with <code>yay ratify --sign</code> (list them with <code>yay ratify</code>)')+'. Filter the Chart to just these with the <b>⚡ Awaiting ratification</b> toggle.</div>'+ratifyCellList()+ratifyDetail()+ratBtn+'</div>'):'';
1324
+ // Rejections (P3): first-class provenance — where agent autonomy failed human judgment. Kept, never erased.
1325
+ var rj=(DATA.meta&&DATA.meta.rejections)||[];
1326
+ var rejNote=rj.length?('<div style="border:1px solid var(--rule);border-left:3px solid #cf4436;border-radius:12px;padding:11px 14px;margin:0 0 16px;background:var(--card2)"><div style="font-weight:800;color:#cf4436;font-size:.9rem">✗ '+rj.length+' rejected delegation'+(rj.length===1?'':'s')+' on record</div><div style="font-size:.8rem;color:var(--mut);margin-top:3px">A human reviewed delegated work and did not accept it. Kept as provenance (feeds earned-autonomy).</div>'+rj.slice().reverse().map(function(x){ return '<div style="margin-top:6px;font-size:.8rem;color:var(--ink-2)"><b>'+esc2(x.id)+'</b> · '+esc2((x.category||'other'))+' · '+esc2((x.cells||[]).join(', '))+' — “'+esc2(x.reason||'')+'”'+(x.signer?(' <span style="color:var(--mut)">by '+esc2(x.signer)+'</span>'):'')+'</div>'; }).join('')+'</div>'):'';
1327
+ var html='<h1>Briefs</h1><div class="snote" style="margin:0 0 14px">What was ordered, in plain language.</div>'+ratNote+rejNote+batchbar+toolbar+'<div class="snote" style="margin:2px 0 14px;font-size:.82rem">'+hint+'</div>';
1328
+
1329
+ if(briefView==='chart'){
1330
+ html+=chartControls();
1331
+ var cf=ms.filter(function(b){
1332
+ if(briefChartTag && !(b.tags||[]).some(function(t){return lc(t)===lc(briefChartTag);})) return false;
1333
+ if(briefChartSigner && lc(b.signer||'')!==lc(briefChartSigner)) return false;
1334
+ if(briefChartRatify && !briefAutoCells(b.cells).length) return false;
1335
+ return true;
1336
+ }).slice().sort(function(a,z){ return String(a.at||'').localeCompare(String(z.at||'')); });
1337
+ html+=chartSVG(cf);
1338
+ } else if(briefView==='clouds'){
1339
+ var tagMap={};
1340
+ ms.forEach(function(b){ ((b.tags&&b.tags.length)?b.tags:['(untagged)']).forEach(function(t){ var k=lc(t); if(!tagMap[k])tagMap[k]={label:(String(t)==='(untagged)'?'Untagged':t),briefs:[]}; tagMap[k].briefs.push(b); }); });
1341
+ var keys=Object.keys(tagMap).sort(function(a,z){ if(a==='(untagged)')return 1; if(z==='(untagged)')return -1; return String((tagMap[z].briefs[0]||{}).at||'').localeCompare(String((tagMap[a].briefs[0]||{}).at||'')); });
1342
+ html+='<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,290px),1fr));gap:14px;align-items:start">';
1343
+ keys.forEach(function(k){ var c=tagMap[k]; var untag=(k==='(untagged)');
1344
+ var rows=c.briefs.map(function(b){ var when=String(b.at||'').slice(0,10); var vc=(b.valid===false)?'#cf4436':(CST[briefWorst(b.cells)]||'var(--accent)'); var hb2=(b.valid!==false)?briefHealthBadge(b.cells):'';
1345
+ return '<div class="cbrief" style="padding:7px 9px;border-radius:9px;cursor:pointer;border-left:2px solid '+vc+';margin:0 0 5px;background:var(--paper)">'
1346
+ +'<div style="display:flex;gap:8px;justify-content:space-between;align-items:baseline"><span style="font-size:.9rem;font-weight:'+(b.title?'700':'400')+';color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc2(b.title||b.text||'')+'</span><span style="font-size:.68rem;color:var(--mut);font-variant-numeric:tabular-nums;white-space:nowrap">'+esc2(when)+'</span></div>'
1347
+ +'<div class="cbdetail" style="display:none;margin-top:7px;padding-top:7px;border-top:1px solid var(--rule)">'
1348
+ +'<div style="font-size:.88rem;color:var(--ink);margin-bottom:6px">'+esc2(b.text||'')+'</div>'
1349
+ +'<div style="font-size:.72rem;color:var(--mut);margin-bottom:5px">'+esc2(b.id||'')+' · '+((b.valid!==false)?'✓ signed':'⚠ seal invalid')+(b.signer?(' · '+esc2(b.signer)):'')+(hb2?(' · '+hb2):'')+(function(){var rb=ratifyBadge(b.cells);return rb?(' · '+rb):'';})()+'</div>'
1350
+ +((b.cells&&b.cells.length)?('<div style="font-size:.72rem;color:var(--mut);margin-bottom:3px">covers '+b.cells.length+' part'+(b.cells.length===1?'':'s')+':</div><div>'+cellChips(b.cells,b.id)+'</div>'):'<div style="font-size:.72rem;color:var(--mut)">no parts</div>')
1351
+ +((b.tags&&b.tags.length>1)?('<div style="font-size:.7rem;color:var(--mut);margin-top:5px">also in: '+b.tags.filter(function(t){return lc(t)!==k;}).map(esc2).join(', ')+'</div>'):'')
1352
+ +'</div></div>';
1353
+ }).join('');
1354
+ html+='<div style="border:1px solid var(--rule);border-radius:16px;background:var(--card2);box-shadow:var(--shadow);overflow:hidden">'
1355
+ +'<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;padding:11px 14px;border-bottom:1px solid var(--rule)"><span style="font-weight:800;font-size:.9rem;color:'+(untag?'var(--mut)':'var(--accent)')+'">'+(untag?'Untagged':'#'+esc2(c.label))+'</span><span style="font-size:.72rem;font-weight:700;color:var(--mut);background:var(--paper);border:1px solid var(--rule);border-radius:100px;padding:1px 9px">'+c.briefs.length+'</span></div>'
1356
+ +'<div style="padding:10px 12px;max-height:340px;overflow:auto">'+rows+'</div></div>';
1357
+ });
1358
+ html+='</div>';
1359
+ } else {
1360
+ var list=briefTagFilter?ms.filter(function(b){return (b.tags||[]).some(function(t){return lc(t)===lc(briefTagFilter);});}):ms;
1361
+ if(!list.length){ html+='<div class="snote">No Briefs with that tag.</div>'; }
1362
+ else if(briefGroupMode==='tag'){
1363
+ var byTag={}; list.forEach(function(b){ ((b.tags&&b.tags.length)?b.tags:['(untagged)']).forEach(function(t){ (byTag[t]=byTag[t]||[]).push(b); }); });
1364
+ var names=Object.keys(byTag).sort(function(a,z){return a==='(untagged)'?1:z==='(untagged)'?-1:a.localeCompare(z);});
1365
+ names.forEach(function(t){ html+='<div style="font-weight:800;font-size:.82rem;color:var(--accent);margin:14px 0 8px">'+(t==='(untagged)'?'Untagged':'#'+esc2(t))+' <span style="color:var(--mut);font-weight:600">· '+byTag[t].length+'</span></div>'; byTag[t].forEach(function(b){ html+=briefCard(b); }); });
1366
+ } else if(briefGroupMode==='signer'){
1367
+ var bySig={}; list.forEach(function(b){ var s=b.signer||'(unsigned)'; (bySig[s]=bySig[s]||[]).push(b); });
1368
+ var snames=Object.keys(bySig).sort(function(a,z){return a==='(unsigned)'?1:z==='(unsigned)'?-1:a.localeCompare(z);});
1369
+ snames.forEach(function(s){ html+='<div style="font-weight:800;font-size:.82rem;color:var(--ink);margin:14px 0 8px">'+esc2(s)+' <span style="color:var(--mut);font-weight:600">· '+bySig[s].length+'</span></div>'; bySig[s].forEach(function(b){ html+=briefCard(b); }); });
1370
+ } else { list.forEach(function(b){ html+=briefCard(b); }); }
1371
+ }
1372
+ el.innerHTML=html;
1373
+ // An Ask answer linked a specific Brief (A-…): open it in the shared modal now that briefCard is in scope.
1374
+ if(pendingBrief){
1375
+ var _pb=pendingBrief; pendingBrief=null;
1376
+ var _b=((DATA.meta&&DATA.meta.briefs)||[]).filter(function(x){ return String(x.id)===String(_pb); })[0];
1377
+ if(_b){ var _mo=document.getElementById('modal'); if(_mo){ _mo.querySelector('.modal-body').innerHTML=briefCard(_b); _mo.classList.add('open'); document.body.style.overflow='hidden';
1378
+ Array.prototype.forEach.call(_mo.querySelectorAll('.mcell.known'),function(ch){ ch.addEventListener('click',function(){ var br=ch.getAttribute('data-brief'); if(br) openCellHistory(ch.getAttribute('data-uid'),br); else openDetail(ch.getAttribute('data-uid')); }); });
1379
+ Array.prototype.forEach.call(_mo.querySelectorAll('.btag'),function(ch){ ch.addEventListener('click',function(){ briefChartTag=ch.getAttribute('data-tag'); var cl=_mo.querySelector('.modal-close'); if(cl) cl.click(); renderBriefs(); }); });
1380
+ } }
1381
+ }
1382
+ Array.prototype.forEach.call(el.querySelectorAll('.ratrow.known'),function(rr){ rr.addEventListener('click',function(){ openDetail(rr.getAttribute('data-uid')); }); });
1383
+ Array.prototype.forEach.call(el.querySelectorAll('.bf-view'),function(bt){ bt.onclick=function(){ briefView=bt.getAttribute('data-v'); renderBriefs(); }; });
1384
+ Array.prototype.forEach.call(el.querySelectorAll('.bf-metric'),function(bt){ bt.onclick=function(){ briefChartMetric=bt.getAttribute('data-m'); renderBriefs(); }; });
1385
+ var cct=document.getElementById('bf-ct'); if(cct) cct.onchange=function(){ briefChartTag=cct.value||null; renderBriefs(); };
1386
+ var ccs=document.getElementById('bf-cs'); if(ccs) ccs.onchange=function(){ briefChartSigner=ccs.value||null; renderBriefs(); };
1387
+ var crat=document.getElementById('bf-crat'); if(crat) crat.onclick=function(){ briefChartRatify=!briefChartRatify; renderBriefs(); };
1388
+ var rbtn=document.getElementById('bf-ratify'); if(rbtn) rbtn.onclick=function(){ var rm=document.getElementById('bf-ratmsg'); if(rm){ rm.textContent=onPhone?'Sending to your phone to sign…':'Signing locally…'; rm.style.color=''; } rbtn.disabled=true; rbtn.style.opacity='.6';
1389
+ var reviewed=(DATA.meta&&DATA.meta.ratify&&DATA.meta.ratify.hash)||null; // TOCTOU: sign exactly what this page rendered
1390
+ fetch('/api/ratify',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({reviewed:reviewed})}).then(function(r){return r.json();}).then(function(j){ if(j&&j.ok){ if(rm){ rm.textContent='✓ Ratified — reloading…'; rm.style.color='#1f9d57'; } setTimeout(function(){ location.reload(); },1200); } else { rbtn.disabled=false; rbtn.style.opacity='1'; if(rm){ rm.textContent='✗ '+((j&&j.error)||'failed'); rm.style.color='#cf4436'; } } }).catch(function(){ rbtn.disabled=false; rbtn.style.opacity='1'; if(rm){ rm.textContent='✗ request failed'; rm.style.color='#cf4436'; } }); };
1391
+ var crs=document.getElementById('bf-creset'); if(crs) crs.onclick=function(){ briefChartTag=null; briefChartSigner=null; briefChartRatify=false; renderBriefs(); };
1392
+ if(briefView==='chart') setupChartLens();
1393
+ var ben=document.getElementById('bf-batch-en'), bn=document.getElementById('bf-batch-n'), bmsg=document.getElementById('bf-batch-msg');
1394
+ function batchPill(state,text){ if(!bmsg) return; clearTimeout(bmsg._t); bmsg.className='savepill show '+state; bmsg.textContent=text; if(state==='saved'){ bmsg._t=setTimeout(function(){ bmsg.classList.remove('show'); },1500); } }
1395
+ function saveBatch(){ batchPill('saving','Saving…'); fetch('/api/batch',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({enabled:ben.checked,barrier:parseInt(bn.value,10)||5})}).then(function(r){return r.json();}).then(function(j){ batchPill((j&&j.ok)?'saved':'err',(j&&j.ok)?'✓ Saved':'✗ '+((j&&j.error)||'failed')); }).catch(function(){ batchPill('err','✗ Save failed'); }); }
1396
+ if(ben) ben.onchange=saveBatch; if(bn) bn.onchange=saveBatch;
1397
+ Array.prototype.forEach.call(el.querySelectorAll('.bf-gbtn'),function(bt){ bt.onclick=function(){ var m=bt.getAttribute('data-g'); briefGroupMode=(briefGroupMode===m?'none':m); renderBriefs(); }; });
1398
+ var clr=document.getElementById('bf-clear'); if(clr) clr.onclick=function(){ briefTagFilter=null; renderBriefs(); };
1399
+ Array.prototype.forEach.call(el.querySelectorAll('.btag'),function(ch){ ch.onclick=function(){ briefTagFilter=ch.getAttribute('data-tag'); renderBriefs(); }; });
1400
+ Array.prototype.forEach.call(el.querySelectorAll('.cbrief'),function(row){ row.onclick=function(e){ if(e.target.classList&&e.target.classList.contains('mcell')) return; var d=row.querySelector('.cbdetail'); if(d) d.style.display=(d.style.display==='none'?'block':'none'); }; });
1401
+ Array.prototype.forEach.call(el.querySelectorAll('.mcell.known'),function(ch){ ch.addEventListener('click',function(){ var br=ch.getAttribute('data-brief'); if(br) openCellHistory(ch.getAttribute('data-uid'),br); else openDetail(ch.getAttribute('data-uid')); }); });
1402
+ }
1403
+
1404
+ // ── Tags tab — the project vocabulary + what was built, sorted by tag over time. Under
1405
+ // the live dashboard it's editable (relabel/describe/add/remove); a tag already in a
1406
+ // signed Brief can't be renamed (that would split the history) but can be removed.
1407
+ function renderTags(){
1408
+ var el=document.getElementById('tags'); if(!el) return;
1409
+ var LIVE=isLive();
1410
+ var pool=(DATA.meta&&DATA.meta.tags)||[];
1411
+ var setName=(DATA.meta&&DATA.meta.tagSet)||'';
1412
+ var descs=(DATA.meta&&DATA.meta.tagDescriptions)||{};
1413
+ var briefs=(DATA.meta&&DATA.meta.briefs)||[];
1414
+ var lc=function(s){return String(s).toLowerCase();};
1415
+ var counts={}; briefs.forEach(function(b){ (b.tags||[]).forEach(function(t){ counts[lc(t)]=(counts[lc(t)]||0)+1; }); });
1416
+ function descOf(t){ for(var k in descs){ if(lc(k)===lc(t)) return descs[k]; } return ''; }
1417
+ function post(u,b){return fetch(u,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(b||{})}).then(function(r){return r.json();});}
1418
+ if(!pool.length){
1419
+ // No pool yet → offer the starter sets to pick from (these become what signers pick from).
1420
+ var sets=(DATA.meta&&DATA.meta.tagSets)||[];
1421
+ var ph='<h1>Tags</h1><div class="snote" style="margin:0 0 14px">No tag pool yet. Pick a starter set — every Brief is then tagged from it, and it becomes the list a signer can choose from (including when correcting the AI’s tags on the phone). You can switch, relabel, add or remove later.</div>';
1422
+ ph+='<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr));gap:10px;margin:0 0 6px">';
1423
+ function setTagChips(tags){ return (tags&&tags.length)?('<div style="display:flex;flex-wrap:wrap;gap:5px;margin-top:8px">'+tags.map(function(t){ return '<span style="font-size:.7rem;font-weight:600;color:var(--ink-2);background:var(--paper);border:1px solid var(--rule);border-radius:100px;padding:1px 8px">'+esc2(t)+'</span>'; }).join('')+'</div>'):''; }
1424
+ sets.forEach(function(s){ ph+='<div class="setpick" data-set="'+esc2(s.id)+'" style="border:1px solid var(--rule);border-radius:12px;padding:12px 14px;background:var(--card2)'+(LIVE?';cursor:pointer':'')+'"><div style="font-weight:800;color:var(--accent)">'+esc2(s.name)+' <span style="font-weight:600;color:var(--mut);font-size:.76rem">· '+((s.tags&&s.tags.length)||0)+' tags</span></div><div style="font-size:.78rem;color:var(--mut);margin-top:3px">'+esc2(s.desc)+'</div>'+setTagChips(s.tags)+'</div>'; });
1425
+ ph+='<div class="setpick" data-set="custom" style="border:1px dashed var(--rule);border-radius:12px;padding:12px 14px;background:var(--card2)'+(LIVE?';cursor:pointer':'')+'"><div style="font-weight:800;color:var(--mut)">Custom</div><div style="font-size:.78rem;color:var(--mut);margin-top:3px">blank placeholders (Custom 1–4) you relabel yourself</div></div>';
1426
+ ph+='</div>';
1427
+ ph+='<div class="snote" style="margin:12px 0 0">'+(LIVE?'Tap a set to use it.':'Set one with <b>yay tags --set &lt;id&gt;</b> (e.g. <b>responsibility</b>, or <b>custom</b>).')+'</div>';
1428
+ if(LIVE){ ph+='<div style="margin:16px 0 0"><div style="font-weight:800;font-size:.8rem;color:var(--mut);margin-bottom:6px">…or start your own</div>'
1429
+ +'<div style="display:flex;gap:8px;align-items:center"><input id="tag-new" placeholder="type a tag label" style="flex:1;min-width:140px;padding:8px;border-radius:8px;border:1px solid var(--rule);background:var(--paper);color:var(--ink)"><button id="tag-add" style="padding:8px 14px;border-radius:8px;border:none;background:var(--brand);color:#04231a;font-weight:700;cursor:pointer">Add tag</button></div><div id="tag-msg" style="margin-top:8px;font-size:.82rem;color:var(--mut)"></div></div>'; }
1430
+ el.innerHTML=ph;
1431
+ if(LIVE){
1432
+ Array.prototype.forEach.call(el.querySelectorAll('.setpick'),function(c){ c.onclick=function(){ post('/api/tags/edit',{action:'set',set:c.getAttribute('data-set')}).then(function(j){ if(j&&j.ok) location.reload(); }); }; });
1433
+ var an=document.getElementById('tag-add'), ai=document.getElementById('tag-new'), am=document.getElementById('tag-msg');
1434
+ function addOwn(){ var v=(ai.value||'').trim(); if(!v){ if(am){am.textContent='✗ enter a tag label';am.style.color='#cf4436';} return; } post('/api/tags/edit',{action:'add',label:v}).then(function(j){ if(j&&j.ok) location.reload(); else if(am){am.textContent='✗ '+((j&&j.error)||'failed');am.style.color='#cf4436';} }); }
1435
+ if(an) an.onclick=addOwn; if(ai) ai.addEventListener('keydown',function(e){ if(e.key==='Enter') addOwn(); });
1436
+ }
1437
+ return;
1438
+ }
1439
+ var html='<h1>Tags</h1><div class="snote" style="margin:0 0 14px">The project vocabulary'+(setName?(' ('+esc2(setName)+' set)'):'')+' — every Brief is tagged from this pool.'+(LIVE?' Relabel, describe, add or remove below. A tag already used in a signed Brief can’t be renamed (it would split the history), but can be removed.':' You choose this pool — switch starter sets, rename, add or remove tags with <b>yay tags</b> or live in the dashboard’s Tags tab.')+'</div>';
1440
+ var anyUsed=Object.keys(counts).length>0;
1441
+ if(LIVE && !anyUsed){
1442
+ // No tagged Briefs yet → a wholesale switch to a different set is still safe.
1443
+ var sw=(DATA.meta&&DATA.meta.tagSets)||[];
1444
+ html+='<div style="border:1px solid var(--rule);border-radius:12px;padding:12px 14px;margin:0 0 16px;background:var(--card2)"><div style="font-weight:700;margin-bottom:10px">Switch to a different set <span style="font-weight:400;color:var(--mut);font-size:.8rem">— allowed until the first tagged Brief is signed</span></div><div style="display:flex;flex-direction:column;gap:8px">'
1445
+ +sw.map(function(s){ var on=cur.set===s.id; return '<div class="setpick" data-set="'+esc2(s.id)+'" title="'+esc2(s.desc)+'" style="border:1px solid '+(on?'var(--accent)':'var(--rule)')+';border-radius:10px;padding:9px 12px;cursor:pointer;background:var(--paper)">'
1446
+ +'<div style="font-weight:700;color:'+(on?'var(--accent)':'var(--ink)')+';font-size:.85rem">'+esc2(s.name)+' <span style="font-weight:600;color:var(--mut);font-size:.74rem">· '+((s.tags&&s.tags.length)||0)+' tags'+(on?' · current':'')+'</span></div>'
1447
+ +'<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:6px">'+((s.tags||[]).map(function(t){ return '<span style="font-size:.68rem;font-weight:600;color:var(--ink-2);background:var(--card2);border:1px solid var(--rule);border-radius:100px;padding:1px 7px">'+esc2(t)+'</span>'; }).join(''))+'</div></div>'; }).join('')
1448
+ +'<div class="setpick" data-set="custom" style="border:1px dashed var(--rule);border-radius:10px;padding:9px 12px;cursor:pointer;background:var(--paper)"><div style="font-weight:700;color:var(--mut);font-size:.85rem">Custom <span style="font-weight:600;font-size:.74rem">· blank placeholders (Custom 1–4) you relabel</span></div></div></div></div>';
1449
+ }
1450
+ // Read-only reference to the built-in starter sets and their tags — shown when the interactive
1451
+ // switcher isn't (i.e. once a set is in use, or when viewing a non-live snapshot / the site demo),
1452
+ // so anyone can still glimpse what each set contains.
1453
+ if(!(LIVE && !anyUsed)){
1454
+ var ref=(DATA.meta&&DATA.meta.tagSets)||[];
1455
+ if(ref.length && ref.some(function(s){return s.tags&&s.tags.length;})){
1456
+ html+='<details style="margin:0 0 16px;border:1px solid var(--rule);border-radius:12px;background:var(--card2);padding:2px 4px"><summary style="cursor:pointer;padding:11px 12px;font-weight:700;font-size:.9rem;color:var(--ink)">Starter sets <span style="font-weight:600;color:var(--mut);font-size:.8rem">— '+ref.length+' built-in vocabularies you can switch to (what’s in each)</span></summary><div style="padding:2px 12px 12px;display:flex;flex-direction:column;gap:10px">'
1457
+ +ref.map(function(s){ var on=cur.set===s.id; return '<div style="border:1px solid '+(on?'var(--accent)':'var(--rule)')+';border-radius:10px;padding:9px 12px;background:var(--paper)">'
1458
+ +'<div style="font-weight:700;color:'+(on?'var(--accent)':'var(--ink)')+';font-size:.85rem">'+esc2(s.name)+' <span style="font-weight:600;color:var(--mut);font-size:.74rem">· '+((s.tags&&s.tags.length)||0)+' tags'+(on?' · in use':'')+'</span></div>'
1459
+ +'<div style="font-size:.74rem;color:var(--mut);margin-top:2px">'+esc2(s.desc||'')+'</div>'
1460
+ +'<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:6px">'+((s.tags||[]).map(function(t){ return '<span style="font-size:.68rem;font-weight:600;color:var(--ink-2);background:var(--card2);border:1px solid var(--rule);border-radius:100px;padding:1px 7px">'+esc2(t)+'</span>'; }).join(''))+'</div></div>'; }).join('')
1461
+ +'</div></details>';
1462
+ }
1463
+ }
1464
+ if(LIVE){
1465
+ html+='<div id="tag-editor" style="margin:0 0 22px">';
1466
+ pool.forEach(function(t){
1467
+ var n=counts[lc(t)]||0, d=descOf(t);
1468
+ html+='<div class="tag-row" data-tag="'+esc2(t)+'" style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;border:1px solid var(--rule);border-radius:10px;padding:8px 10px;margin:0 0 8px">';
1469
+ if(n) html+='<span style="font-weight:700;color:var(--accent);min-width:120px">'+esc2(t)+'</span><span style="font-size:.72rem;color:var(--mut)" title="used in signed Briefs — locked from renaming">🔒 used ×'+n+'</span>';
1470
+ else html+='<input class="tag-label" value="'+esc2(t)+'" style="font-weight:700;min-width:120px;padding:6px 8px;border-radius:7px;border:1px solid var(--rule);background:var(--paper);color:var(--ink)">';
1471
+ html+='<input class="tag-desc" value="'+esc2(d)+'" placeholder="description (optional)" style="flex:1;min-width:150px;padding:6px 8px;border-radius:7px;border:1px solid var(--rule);background:var(--paper);color:var(--ink)">';
1472
+ html+='<button class="tag-rm" style="border:1px solid var(--rule);background:none;color:#cf4436;border-radius:7px;padding:5px 9px;cursor:pointer;font-size:.8rem">remove</button></div>';
1473
+ });
1474
+ html+='<div style="display:flex;gap:8px;align-items:center;margin-top:10px"><input id="tag-new" placeholder="new tag label" style="flex:1;min-width:140px;padding:8px;border-radius:8px;border:1px solid var(--rule);background:var(--paper);color:var(--ink)"><button id="tag-add" style="padding:8px 14px;border-radius:8px;border:none;background:var(--brand);color:#04231a;font-weight:700;cursor:pointer">Add tag</button></div>';
1475
+ html+='<div id="tag-msg" style="margin-top:8px;font-size:.82rem;color:var(--mut)"></div></div>';
1476
+ } else {
1477
+ html+='<div style="border:1px solid var(--rule);border-left:3px solid var(--accent);border-radius:10px;padding:10px 13px;margin:0 0 14px;background:var(--card2);font-size:.85rem;color:var(--mut);line-height:1.5">You’re <b style="color:var(--ink)">not</b> handed a fixed list — you <b style="color:var(--ink)">choose the pool</b>. Switch to another starter set, <b style="color:var(--ink)">rename</b> any tag, <b style="color:var(--ink)">add</b> your own, or <b style="color:var(--ink)">remove</b> one — from the CLI (<b style="color:var(--ink)">yay tags</b>) or live in a running dashboard’s Tags tab. This static preview shows the current pool read-only.</div>';
1478
+ html+='<div style="font-weight:800;font-size:.8rem;color:var(--mut);margin:0 0 8px">'+pool.length+' tag'+(pool.length===1?'':'s')+' in the pool</div>';
1479
+ html+='<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,230px),1fr));gap:9px;margin:0 0 6px">'+pool.map(function(t){var n=counts[lc(t)]||0,d=descOf(t);
1480
+ return '<div style="border:1px solid var(--rule);border-radius:11px;padding:10px 13px;background:var(--card2)"><div style="display:flex;justify-content:space-between;align-items:center;gap:8px"><span style="font-weight:800;color:var(--accent);font-size:.92rem">'+esc2(t)+'</span><span style="font-size:.68rem;font-weight:700;color:var(--mut);background:var(--paper);border:1px solid var(--rule);border-radius:100px;padding:1px 8px" title="used in '+n+' Brief(s)">'+n+'</span></div>'+(d?('<div style="font-size:.78rem;color:var(--mut);margin-top:4px;line-height:1.35">'+esc2(d)+'</div>'):'')+'</div>';
1481
+ }).join('')+'</div>';
1482
+ var sw2=(DATA.meta&&DATA.meta.tagSets)||[];
1483
+ if(sw2.length){
1484
+ html+='<div style="margin:16px 0 0"><div style="font-weight:800;font-size:.8rem;color:var(--mut);margin-bottom:6px">Starter sets you can switch to</div><div style="display:flex;flex-wrap:wrap;gap:6px">'
1485
+ +sw2.map(function(s){var act=(s.id===setName||s.name===setName);return '<span title="'+esc2(s.desc)+'" style="border:1px solid var(--rule);background:var(--paper);color:var(--ink);border-radius:100px;padding:5px 12px;font-size:.8rem;font-weight:600'+(act?';border-color:var(--accent);color:var(--accent)':'')+'">'+esc2(s.name)+(act?' ✓':'')+'</span>';}).join('')
1486
+ +'<span style="border:1px dashed var(--rule);background:var(--paper);color:var(--mut);border-radius:100px;padding:5px 12px;font-size:.8rem;font-weight:600">Custom (relabel your own)</span></div>'
1487
+ +'<div class="snote" style="margin:8px 0 0">Switch with <b>yay tags --set &lt;id&gt;</b>, then rename / add / remove freely.</div></div>';
1488
+ }
1489
+ }
1490
+ // Retired = tags that appear in past Briefs but are no longer in the pool. Vocabulary
1491
+ // context only (names + counts) — browsing Briefs by tag lives in the Briefs tab.
1492
+ var poolLc={}; pool.forEach(function(t){poolLc[lc(t)]=1;});
1493
+ var retired={}; briefs.forEach(function(b){ (b.tags||[]).forEach(function(t){ if(!poolLc[lc(t)]) retired[lc(t)]=t; }); });
1494
+ var rkeys=Object.keys(retired);
1495
+ if(rkeys.length){
1496
+ html+='<div style="margin:18px 0 0"><div style="font-weight:800;font-size:.8rem;color:var(--mut);margin-bottom:6px" title="Used in past Briefs but no longer offered for new ones">Retired · in history, not in the pool</div>'
1497
+ +'<div>'+rkeys.map(function(k){return '<span style="display:inline-block;font-size:.76rem;font-weight:600;color:var(--mut);border:1px dashed var(--rule);border-radius:100px;padding:3px 10px;margin:0 6px 6px 0">'+esc2(retired[k])+' <span style="opacity:.6">'+(counts[k]||0)+'</span></span>';}).join('')+'</div></div>';
1498
+ }
1499
+ html+='<div class="snote" style="margin:18px 0 0">This tab manages the vocabulary. To <b>browse Briefs by tag</b>, open the <b>Briefs</b> tab — its <b>Clouds</b> view shows a card per tag, or use <b>Group by tag</b> in the list.</div>';
1500
+ el.innerHTML=html;
1501
+ if(LIVE){
1502
+ var msg=document.getElementById('tag-msg');
1503
+ function fail(m){ if(msg){msg.textContent='✗ '+m;msg.style.color='#cf4436';} }
1504
+ Array.prototype.forEach.call(el.querySelectorAll('.tag-row'),function(row){
1505
+ var orig=row.getAttribute('data-tag');
1506
+ var lab=row.querySelector('.tag-label'), de=row.querySelector('.tag-desc'), rm=row.querySelector('.tag-rm');
1507
+ if(lab) lab.addEventListener('change',function(){ var v=(lab.value||'').trim(); if(!v||v===orig){lab.value=orig;return;} post('/api/tags/edit',{action:'rename',from:orig,to:v}).then(function(j){ if(j&&j.ok) location.reload(); else { lab.value=orig; fail((j&&j.error)||'rename failed'); } }); });
1508
+ if(de) de.addEventListener('change',function(){ post('/api/tags/edit',{action:'desc',label:orig,text:(de.value||'').trim()}).then(function(j){ if(j&&j.ok){ if(msg){msg.textContent='✓ description saved';msg.style.color='#1f9d57';} } else fail((j&&j.error)||'save failed'); }); });
1509
+ if(rm) rm.addEventListener('click',function(){ var used=counts[lc(orig)]||0; if(used && !confirm('Remove “'+orig+'”? '+used+' signed Brief(s) keep it in their history (shown under Retired). It just won’t be offered for new Briefs.')) return; post('/api/tags/edit',{action:'remove',label:orig}).then(function(j){ if(j&&j.ok) location.reload(); else fail((j&&j.error)||'remove failed'); }); });
1510
+ });
1511
+ var addBtn=document.getElementById('tag-add'), addInp=document.getElementById('tag-new');
1512
+ if(addBtn) addBtn.onclick=function(){ var v=(addInp.value||'').trim(); if(!v){fail('enter a tag label');return;} post('/api/tags/edit',{action:'add',label:v}).then(function(j){ if(j&&j.ok) location.reload(); else fail((j&&j.error)||'add failed'); }); };
1513
+ if(addInp) addInp.addEventListener('keydown',function(e){ if(e.key==='Enter'&&addBtn) addBtn.onclick(); });
1514
+ Array.prototype.forEach.call(el.querySelectorAll('.setpick'),function(c){ c.onclick=function(){ post('/api/tags/edit',{action:'set',set:c.getAttribute('data-set')}).then(function(j){ if(j&&j.ok) location.reload(); else fail((j&&j.error)||'switch failed'); }); }; });
1515
+ }
1516
+ }
1517
+
1518
+ // ── Policy tab — who must sign what. Viewer (enforced vs draft + violations) plus,
1519
+ // when live under the dashboard, an editor that owner-signs the draft via the phone.
1520
+ function renderPolicy(){
1521
+ var el=document.getElementById('policy'); if(!el) return;
1522
+ var LIVE=isLive(); // re-checked here (DOM ready by the time a tab is opened)
1523
+ var pol=(DATA.meta&&DATA.meta.policy)||{enforced:[],draft:[],violations:[],signers:[]};
1524
+ var enforced=pol.enforced||[], draft=pol.draft||[], viol=pol.violations||[], signers=pol.signers||[];
1525
+ var onPhone=(pol.signMethod!=='local'); // local keystore signs here; mobile routes to the phone
1526
+ var applyWord=onPhone?'Apply — owner-sign on your phone':'Apply — owner-sign';
1527
+ function ruleLine(r){
1528
+ var m=r.match||{}, parts=[];
1529
+ if(m.path) parts.push('path <code>'+esc2(m.path)+'</code>');
1530
+ if(m.tag) parts.push('tag <code>'+esc2(m.tag)+'</code>');
1531
+ if(m.module) parts.push('module <code>'+esc2(m.module)+'</code>');
1532
+ var left=(parts.join(' &amp; ')||'(no matcher)');
1533
+ if(r.inert){
1534
+ var lv=String(r.inert).toLowerCase();
1535
+ var desc=lv==='block'?'<b style="color:#cf4436">inert: block</b> — inert code gate-blocks these Cells':lv==='note'?'<b>inert: note</b> — inert findings shown as info only':'<b style="color:#c9860f">inert: yellow</b> — inert code caps these Cells at Yellow (the default, scoped explicitly)';
1536
+ return left+' → '+desc;
1537
+ }
1538
+ if(r.ignore){ return left+' → <b>ignore: source</b> — source here may be excluded from the gate (kept out on purpose)'; }
1539
+ var who=r.signer?esc2(r.signer):((r.signers||[]).map(esc2).join(' or '));
1540
+ return left+' → must be signed by <b>'+who+'</b>';
1541
+ }
1542
+ var same=JSON.stringify(enforced)===JSON.stringify(draft);
1543
+ var html='<h1>Policy</h1><div class="snote" style="margin:0 0 14px">Who must sign what. Neutral by default — a rule requires a specific person to sign matching Cells, and the gate blocks any match they haven’t signed. Enforced rules are <b>owner-signed</b> into the roster (tamper-evident).</div>';
1544
+ if(!enforced.length){ html+='<div class="snote" style="margin:0 0 14px">Enforced: <b>none</b> — every enrolled signer is treated the same.</div>'; }
1545
+ else { html+='<div style="margin:0 0 16px"><div style="font-weight:800;font-size:.8rem;color:var(--accent);margin-bottom:6px">ENFORCED · owner-signed</div>'+enforced.map(function(r){return '<div style="border:1px solid var(--rule);border-left:3px solid var(--accent);border-radius:10px;padding:9px 12px;margin:0 0 8px;font-size:.92rem">'+ruleLine(r)+'</div>';}).join('')+'</div>'; }
1546
+ if(viol.length){ html+='<div style="margin:0 0 16px"><div style="font-weight:800;font-size:.8rem;color:#cf4436;margin-bottom:6px">VIOLATIONS · '+viol.length+'</div>'+viol.map(function(v){return '<div style="font-size:.88rem;color:#cf4436;padding:2px 0 2px 12px;border-left:2px solid #cf4436;margin:0 0 6px">'+esc2(v.id)+' — '+esc2(v.note)+'</div>';}).join('')+'</div>'; }
1547
+ // ── Built-in security: the INERTNESS feature — always visible so users discover it.
1548
+ // Templates are OFF by default (examples, not active rules) until added to the
1549
+ // draft and owner-signed. Uses the project's own security-ish spec tag if the
1550
+ // pool suggests one; the built-in synthetic tag "sensitive" always works.
1551
+ (function(){
1552
+ var pool=(DATA.meta&&DATA.meta.tags)||[];
1553
+ var secTag='sensitive';
1554
+ for(var i=0;i<pool.length;i++){ if(/secur|auth/i.test(pool[i])){ secTag=pool[i].toLowerCase(); break; } }
1555
+ var tpls=[
1556
+ { match:{path:'src/payments/**'}, inert:'yellow', why:'scope the default explicitly to a payments area' },
1557
+ { match:{tag:secTag}, inert:'block', why:'crown jewels — inert code BLOCKS the gate here' },
1558
+ { match:{path:'legacy/**'}, inert:'note', why:'relax for an adopted/legacy area so retrofit noise stays informational' },
1559
+ ];
1560
+ var hasInert=enforced.concat(draft).some(function(r){return r&&r.inert;});
1561
+ html+='<div style="border:1px solid var(--rule);border-left:3px solid var(--accent);border-radius:12px;padding:13px 15px;margin:4px 0 16px;background:var(--card2)">'
1562
+ +'<div style="font-weight:800;margin-bottom:4px">🛡 Built-in security: inert-code strictness</div>'
1563
+ +'<div style="font-size:.85rem;color:var(--mut);line-height:1.5;margin-bottom:10px">The prover flags <b>inert code</b> — a branch removable with every spec-derived test still passing (dead weight, ahead-of-spec scaffolding, or a <b>dormant payload</b> riding under a signature). Default verdict: <b style="color:#c9860f">Yellow</b>, with the route “prune it, spec it, or declare it” (<code>throws:</code> for guards, <code>perf:</code> for optimizations). Policy rules adjust it per path/tag/module — <b>owner-signed either way, so it can’t be quietly weakened</b>:</div>'
1564
+ +tpls.map(function(t){
1565
+ var m=t.match.path?('path <code>'+esc2(t.match.path)+'</code>'):('tag <code>'+esc2(t.match.tag)+'</code>');
1566
+ var lv=t.inert==='block'?'<b style="color:#cf4436">inert: block</b>':t.inert==='note'?'<b>inert: note</b>':'<b style="color:#c9860f">inert: yellow</b>';
1567
+ return '<div style="display:flex;justify-content:space-between;gap:10px;align-items:center;border:1px dashed var(--rule);border-radius:10px;padding:8px 11px;margin:0 0 7px;font-size:.88rem;opacity:.92">'
1568
+ +'<span>'+m+' → '+lv+' <span style="color:var(--mut)">— '+esc2(t.why)+'</span></span>'
1569
+ +'<span style="display:flex;gap:8px;align-items:center;flex-shrink:0">'
1570
+ +'<span style="font-size:.68rem;font-weight:700;color:var(--mut);border:1px solid var(--rule);border-radius:100px;padding:1px 9px" title="An example — not an active rule until you add it to the draft and owner-sign it">off</span>'
1571
+ +(LIVE?('<button class="pol-tpl" data-rule="'+esc2(JSON.stringify({match:t.match,inert:t.inert})).replace(/"/g,'&quot;')+'" style="border:1px solid var(--accent);background:none;color:var(--accent);border-radius:8px;padding:3px 10px;cursor:pointer;font-size:.78rem;font-weight:700">Add to draft</button>'):'')
1572
+ +'</span></div>';
1573
+ }).join('')
1574
+ +'<div style="font-size:.78rem;color:var(--mut)">'+(hasInert?'This project has inert rules '+(LIVE?'below':'listed above/below')+'.':(LIVE?'Templates are examples — tap “Add to draft”, edit the matcher below if needed, then Apply (owner-signs '+(onPhone?'on your phone':'locally')+').':'Enable via the live dashboard’s Policy tab, or add a rule with <b>yay policy</b> (e.g. <code>{ "match": { "tag": "'+esc2(secTag)+'" }, "inert": "block" }</code>) and <b>yay policy --set</b>.'))+'</div>'
1575
+ +'</div>';
1576
+ })();
1577
+ html+='<div style="margin:18px 0 6px;font-weight:800;font-size:.8rem;color:var(--mut)">DRAFT · .yaylayer/policy.json'+(same?' (matches enforced)':' (differs — not yet signed)')+'</div>';
1578
+ if(!draft.length){ html+='<div class="snote" style="margin:0 0 10px">No draft rules.</div>'; }
1579
+ else { html+=draft.map(function(r,i){return '<div style="display:flex;justify-content:space-between;gap:10px;align-items:center;border:1px dashed var(--rule);border-radius:10px;padding:9px 12px;margin:0 0 8px;font-size:.92rem"><span>'+ruleLine(r)+'</span>'+(LIVE?('<button class="pol-rm" data-i="'+i+'" style="border:1px solid var(--rule);background:none;color:#cf4436;border-radius:8px;padding:3px 9px;cursor:pointer;font-size:.8rem">remove</button>'):'')+'</div>';}).join(''); }
1580
+ if(LIVE){
1581
+ var sigOpts=signers.map(function(s){return '<option value="s:'+esc2(s)+'">must be signed by '+esc2(s)+'</option>';}).join('');
1582
+ html+='<div style="border:1px solid var(--rule);border-radius:12px;padding:14px;margin:12px 0 0;background:var(--card2)">'
1583
+ +'<div style="font-weight:700;margin-bottom:10px">Add a rule</div>'
1584
+ +'<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">'
1585
+ +'<select id="pol-mtype" style="padding:8px;border-radius:8px;border:1px solid var(--rule);background:var(--paper);color:var(--ink)"><option value="path">path glob</option><option value="tag">spec tag</option><option value="module">module</option></select>'
1586
+ +'<input id="pol-mval" placeholder="e.g. **/auth/**" style="flex:1;min-width:150px;padding:8px;border-radius:8px;border:1px solid var(--rule);background:var(--paper);color:var(--ink)">'
1587
+ +'<span style="color:var(--mut)">→</span>'
1588
+ +'<select id="pol-req" style="padding:8px;border-radius:8px;border:1px solid var(--rule);background:var(--paper);color:var(--ink)">'
1589
+ +(sigOpts||'')
1590
+ +'<option value="i:yellow">inert code → Yellow (default, scoped)</option><option value="i:block">inert code → BLOCK the gate</option><option value="i:note">inert code → note only (relax)</option>'
1591
+ +'<option value="g:source">authorise ignoring source (.yaylayerignore)</option>'
1592
+ +'</select>'
1593
+ +'<button id="pol-add" style="padding:8px 14px;border-radius:8px;border:none;background:var(--brand);color:#04231a;font-weight:700;cursor:pointer">Add to draft</button>'
1594
+ +'</div>'
1595
+ +'</div>';
1596
+ // Apply governs the WHOLE draft — used after either the Built-in-security templates or the
1597
+ // form above — so it sits at section level, not inside the "Add a rule" card.
1598
+ html+='<div style="margin:16px 0 0;padding:13px 15px;border:1px solid var(--rule);border-left:3px solid var(--accent);border-radius:12px;background:var(--card2)">'
1599
+ +(!same
1600
+ ?('<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap"><button id="pol-apply" style="padding:9px 16px;border-radius:8px;border:none;background:var(--accent);color:#fff;font-weight:700;cursor:pointer">'+applyWord+'</button><span style="color:var(--mut);font-size:.85rem">Signs the current draft — every rule above — into the roster. This is what makes them enforced.</span></div>')
1601
+ :'<div style="color:var(--mut);font-size:.85rem">✓ The draft matches what’s enforced — nothing to apply.</div>')
1602
+ +'<div id="pol-msg" style="margin-top:10px;font-size:.85rem;color:var(--mut)"></div></div>';
1603
+ } else if(!same){
1604
+ html+='<div class="snote" style="margin:10px 0 0">The draft differs from what’s enforced. Apply it with <b>yay policy --set</b> (owner-signs '+(onPhone?'on your phone':'locally')+'), or edit it live in <b>yay dashboard</b>.</div>';
1605
+ }
1606
+ el.innerHTML=html;
1607
+ if(LIVE){
1608
+ var msg=document.getElementById('pol-msg');
1609
+ function post(u,b){return fetch(u,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(b||{})}).then(function(r){return r.json();});}
1610
+ Array.prototype.forEach.call(el.querySelectorAll('.pol-rm'),function(btn){ btn.onclick=function(){ post('/api/policy/remove',{index:parseInt(btn.getAttribute('data-i'),10)}).then(function(){ location.reload(); }); }; });
1611
+ var add=document.getElementById('pol-add'); if(add) add.onclick=function(){
1612
+ var t=document.getElementById('pol-mtype').value, v=(document.getElementById('pol-mval').value||'').trim(), req=document.getElementById('pol-req').value;
1613
+ if(!v){ if(msg){msg.textContent='Enter a value to match.';msg.style.color='#cf4436';} return; }
1614
+ if(!req){ if(msg){msg.textContent='Pick a requirement — a signer, or an inert level.';msg.style.color='#cf4436';} return; }
1615
+ var match={}; match[t]=v;
1616
+ var body={match:match};
1617
+ if(req.slice(0,2)==='i:') body.inert=req.slice(2); else if(req.slice(0,2)==='g:') body.ignore=req.slice(2); else body.signer=req.slice(2);
1618
+ post('/api/policy/rule',body).then(function(j){ if(j&&j.ok){location.reload();} else if(msg){msg.textContent='✗ '+((j&&j.error)||'failed');msg.style.color='#cf4436';} });
1619
+ };
1620
+ Array.prototype.forEach.call(el.querySelectorAll('.pol-tpl'),function(btn){ btn.onclick=function(){
1621
+ var r; try{ r=JSON.parse(btn.getAttribute('data-rule')); }catch(_){ return; }
1622
+ post('/api/policy/rule',r).then(function(j){ if(j&&j.ok){location.reload();} else if(msg){msg.textContent='✗ '+((j&&j.error)||'failed');msg.style.color='#cf4436';} });
1623
+ };});
1624
+ var ap=document.getElementById('pol-apply'); if(ap) ap.onclick=function(){
1625
+ if(msg){msg.textContent=onPhone?'Sending to your phone to owner-sign…':'Owner-signing locally…';msg.style.color='';}
1626
+ post('/api/policy/apply',{}).then(function(j){ if(j&&j.ok){ if(msg){msg.textContent='✓ Applied — reloading…';msg.style.color='#1f9d57';} setTimeout(function(){location.reload();},1200);} else if(msg){msg.textContent='✗ '+((j&&j.error)||'failed');msg.style.color='#cf4436';} });
1627
+ };
1628
+ }
1629
+ }
1630
+
1631
+ // ── Files tab — classic file tree, problem states marked in colour ────────
1632
+ var SEVN={GREEN:0,YELLOW:2,UNSIGNED:3,PINK:4,RED:5};
1633
+ function worse(a,b){ return (SEVN[b]||0)>(SEVN[a]||0)?b:a; }
1634
+ function renderFiles(){
1635
+ var el=document.getElementById('files'); if(!el) return;
1636
+ var items=(DATA.meta&&DATA.meta.files)||[];
1637
+ if(!items.length){ el.innerHTML='<h1>Files</h1><div class="snote">No files to show.</div>'; return; }
1638
+ var tree={_d:{},_f:[]};
1639
+ items.forEach(function(it){ var parts=String(it.file).split('/'); var node=tree; for(var i=0;i<parts.length-1;i++){ var d=parts[i]; node._d[d]=node._d[d]||{_d:{},_f:[],_name:d}; node=node._d[d]; } var fname=parts[parts.length-1]; var f=node._f.find(function(x){return x.name===fname;}); if(!f){ f={name:fname,cells:[],worst:'GREEN'}; node._f.push(f);} f.cells.push(it); f.worst=worse(f.worst,it.state); });
1640
+ function dot(state){ var col={GREEN:'#1f9d57',YELLOW:'#c9860f',RED:'#cf4436',UNSIGNED:'#7f8796',PINK:'#e0559b'}[state]||'#7f8796'; return '<span class="fdot" style="background:'+col+'"></span>'; }
1641
+ function renderDir(node,depth){
1642
+ var out='';
1643
+ Object.keys(node._d).sort().forEach(function(d){ out+='<div class="frow fdir" style="padding-left:'+(depth*18+10)+'px">▸ '+esc2(d)+'</div>'+renderDir(node._d[d],depth+1); });
1644
+ node._f.sort(function(a,b){return a.name.localeCompare(b.name);}).forEach(function(f){
1645
+ out+='<div class="frow ffile" style="padding-left:'+(depth*18+10)+'px">'+(f.worst!=='GREEN'?dot(f.worst):'<span class="fdot" style="background:#1f9d57"></span>')+'<span class="fname">'+esc2(f.name)+'</span><span class="fcount">'+f.cells.length+'</span></div>';
1646
+ f.cells.sort(function(a,b){return (a.line||0)-(b.line||0);}).forEach(function(cl){ out+='<div class="frow fcell'+(cl.state!=='GREEN'?' bad':'')+'" data-id="'+esc2(cl.id)+'" style="padding-left:'+((depth+1)*18+16)+'px">'+dot(cl.state)+'<span class="funit">'+esc2(cl.name)+'</span><span class="fstate" style="color:'+({GREEN:'#1f9d57',YELLOW:'#c9860f',RED:'#cf4436',UNSIGNED:'#7f8796',PINK:'#e0559b'}[cl.state]||'#7f8796')+'">'+esc2(cl.state)+'</span></div>'; });
1647
+ });
1648
+ return out;
1649
+ }
1650
+ el.innerHTML='<h1>Files</h1><div class="snote" style="margin:0 0 12px">Every file and its Cells. Green ones are proven; anything <b>yellow / red / unsigned / pink</b> is coloured so it stands out. Click a Cell to open it.</div><div class="ftree">'+renderDir(tree,0)+'</div>';
1651
+ Array.prototype.forEach.call(el.querySelectorAll('.fcell'),function(r){ r.addEventListener('click',function(){ openDetail(r.getAttribute('data-id')); }); });
1652
+ }
1653
+
1654
+ // ── tabs ──────────────────────────────────────────────────────────────────
1655
+ var MAP_ELS=['.pagehead','.statgrid','#needs','.hint','#crumb','.stage','.logwrap','.foot'];
1656
+ function showSel(sel,on){ var e=document.querySelector(sel); if(e) e.style.display=on?'':'none'; }
1657
+ var curTab='map';
1658
+ function setTab(name){
1659
+ curTab=name;
1660
+ try{ sessionStorage.setItem('yay.tab', name); }catch(e){} // remember across reloads (e.g. after a tag/policy save)
1661
+ MAP_ELS.forEach(function(s){ showSel(s, name==='map'); });
1662
+ showSel('#plan', name==='plan'); showSel('#signers', name==='signers'); showSel('#files', name==='files'); showSel('#commands', name==='commands'); showSel('#briefs', name==='briefs'); showSel('#tags', name==='tags'); showSel('#policy', name==='policy'); showSel('#capability', name==='capability'); showSel('#grants', name==='grants'); showSel('#ask', name==='ask');
1663
+ Array.prototype.forEach.call(document.querySelectorAll('.tab'),function(b){ b.classList.toggle('active', b.getAttribute('data-tab')===name); });
1664
+ var nm=document.getElementById('side'); if(nm) nm.classList.remove('open');
1665
+ var sc=document.getElementById('scrim'); if(sc) sc.classList.remove('open');
1666
+ var nb=document.getElementById('navburger'); if(nb){ nb.textContent='☰'; nb.setAttribute('aria-expanded','false'); }
1667
+ if(name==='plan') renderPlan();
1668
+ if(name==='signers') renderSigners();
1669
+ if(name==='briefs') renderBriefs();
1670
+ if(name==='tags') renderTags();
1671
+ if(name==='policy') renderPolicy();
1672
+ if(name==='capability') renderCapability();
1673
+ if(name==='grants') renderGrants();
1674
+ if(name==='files') renderFiles();
1675
+ if(name==='ask') renderAsk();
1676
+ }
1677
+ // Live = the dashboard's control bar is on the page. Checked LAZILY (not at parse time),
1678
+ // because the bar is injected AFTER this script, so it isn't in the DOM yet when we load.
1679
+ function isLive(){ return !!document.getElementById('yd-bar'); }
1680
+ (function(){
1681
+ // Show the System Plan row when a plan exists, or when live (so the ↻ can generate the first one).
1682
+ if((DATA.meta && DATA.meta.plan) || isLive()){ var _pr=document.getElementById('planrow'); if(_pr) _pr.style.display=''; }
1683
+ var _demo=!!(DATA.meta && DATA.meta.demo);
1684
+ if(isLive() || _demo){ var _prf=document.getElementById('plan-refresh'); if(_prf) _prf.style.display=''; }
1685
+ // Demo only: render a STATIC replica of the live dock (lower-left) so the demo shows the Live menu —
1686
+ // shown, never runnable (the real controls need a running yay dashboard; a static map has no server).
1687
+ if(_demo && !isLive()){
1688
+ var slot=document.getElementById('yd-slot');
1689
+ if(slot){
1690
+ var b=function(icon,label,pri){ return '<div class="yd-btn'+(pri?' yd-primary':'')+'" title="Live dashboard only — shown for the demo; run it with yay dashboard"><span class="yd-icon">'+icon+'</span>'+label+'</div>'; };
1691
+ slot.innerHTML='<div class="yd-dock yd-demo">'
1692
+ +'<div class="yd-livewrap"><span class="yd-live">● live</span><span class="yd-refresh" title="Refresh">↻</span></div>'
1693
+ +b('➕','Request a change',true)+b('≷','Changes')+b('▷','Preview')+b('▶','Run tests')+b('⚔','Adversary')+b('🛡','Re-seal foundation')
1694
+ +'<div class="yd-demo-note">Live controls — shown for the demo. Run them with <b>yay dashboard</b>.</div>'
1695
+ +'</div>';
1696
+ }
1697
+ }
1698
+ function revealTags(){ if(isLive() || (DATA.meta && DATA.meta.tags && DATA.meta.tags.length)) Array.prototype.forEach.call(document.querySelectorAll('[data-tab="tags"]'),function(t){ t.style.display=''; }); }
1699
+ revealTags(); window.addEventListener('load', revealTags);
1700
+ var pol=(DATA.meta&&DATA.meta.policy)||{};
1701
+ function revealPolicy(){ if(isLive() || (pol.enforced&&pol.enforced.length) || (pol.draft&&pol.draft.length)) Array.prototype.forEach.call(document.querySelectorAll('[data-tab="policy"]'),function(t){ t.style.display=''; }); }
1702
+ revealPolicy(); window.addEventListener('load', revealPolicy); // re-check once yd-bar is in the DOM
1703
+ Array.prototype.forEach.call(document.querySelectorAll('.tab[data-tab]'),function(b){ b.addEventListener('click',function(){ setTab(b.getAttribute('data-tab')); }); });
1704
+ var nb=document.getElementById('navburger'), nm=document.getElementById('side'), sc=document.getElementById('scrim');
1705
+ if(nb && nm) nb.addEventListener('click',function(){ var open=nm.classList.toggle('open'); if(sc) sc.classList.toggle('open',open); nb.textContent=open?'✕':'☰'; nb.setAttribute('aria-expanded',open?'true':'false'); });
1706
+ if(sc) sc.addEventListener('click',function(){ nm.classList.remove('open'); sc.classList.remove('open'); if(nb){ nb.textContent='☰'; nb.setAttribute('aria-expanded','false'); } });
1707
+ // Restore the tab the user was on before a reload (registered after the reveal listeners
1708
+ // so hidden tabs like Tags/Policy are visible by the time we restore).
1709
+ window.addEventListener('load',function(){
1710
+ // The Ask tab is live-only (needs the server + an LLM key) — reveal it when the control bar
1711
+ // is present, or in the published demo (showcase). A plain static map keeps it hidden.
1712
+ try{ if(isLive() || (DATA.meta&&DATA.meta.demo)){ var at=document.getElementById('tab-ask'); if(at) at.style.display=''; } }catch(e){}
1713
+ try{ var t=sessionStorage.getItem('yay.tab'); if(t && t!=='map'){ var b=document.querySelector('[data-tab="'+t+'"]'); if(b && b.style.display!=='none') setTab(t); } }catch(e){}
1714
+ });
1715
+ })();
1716
+
1717
+ window.addEventListener('resize',function(){ if(curTab==='plan') renderPlan(); else if(curTab==='map') draw(); });
1718
+ draw();
1719
+ renderNeeds();
1720
+ renderLog();
1721
+ })();
1722
+ (function(){
1723
+ var modal=document.getElementById('modal');
1724
+ function close(){ modal.classList.remove('open'); document.body.style.overflow=''; }
1725
+ modal.addEventListener('click',function(e){ if(e.target===modal) close(); });
1726
+ modal.querySelector('.modal-close').addEventListener('click',close);
1727
+ document.addEventListener('keydown',function(e){ if(e.key==='Escape') close(); });
1728
+ })();
1729
+ (function(){
1730
+ var root=document.documentElement, btn=document.getElementById('themebtn');
1731
+ function current(){ return root.getAttribute('data-theme') || 'light'; } // white by default; dark is opt-in
1732
+ function label(){ btn.textContent = current()==='dark' ? '☀ Light' : '☾ Dark'; }
1733
+ var saved; try{ saved=localStorage.getItem('yay-theme'); }catch(e){}
1734
+ if(saved){ root.setAttribute('data-theme', saved); }
1735
+ label();
1736
+ btn.addEventListener('click',function(){ var next=current()==='dark'?'light':'dark'; root.setAttribute('data-theme',next); try{localStorage.setItem('yay-theme',next);}catch(e){} label(); });
1737
+ })();
1738
+ </script>
1739
+ </body></html>`;
1740
+ }
1741
+
1742
+ module.exports = { renderMap };