@polymorphism-tech/morph-spec 10.6.0 → 10.7.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.
@@ -0,0 +1,307 @@
1
+ import { readFileSync } from 'fs';
2
+ import { PROOF_KINDS, RISK_KINDS, REUSE_VERDICTS } from '../plan/plan-parser.js';
3
+ import { renderRecapHtml } from '../recap/recap.js';
4
+ import { stateMachineSvg } from './flow-svg.js';
5
+
6
+ export const TOKENS_CSS = readFileSync(new URL('./tokens.css', import.meta.url), 'utf8');
7
+ const PAGE_CSS = readFileSync(new URL('./feature-page.css', import.meta.url), 'utf8');
8
+ const CLIENT_JS = readFileSync(new URL('./feature-page.client.js', import.meta.url), 'utf8');
9
+
10
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
11
+ const WORK_LABEL = { feature: 'Feature', bug: 'Correção de bug', chore: 'Manutenção', hotfix: 'Hotfix' };
12
+ const STATUS_LABEL = { pendente: 'Pendente', feito: 'Feito', bloqueado: 'Bloqueado' };
13
+ const RUNG_TONE = { migrar: 'warn', 'irreversível': 'bad', irreversivel: 'bad' };
14
+ const NONE_RE = /^(nenhum|nenhuma)\.?$/i;
15
+
16
+ function linkifier(plan) {
17
+ const known = new Set([...plan.scenarios.map((c) => c.id), ...plan.adrs.map((a) => a.id)]);
18
+ const defaults = new Set(plan.defaults.items.map((d) => d.n));
19
+ const ref = (id, label) => `<a href="#${id}" data-ref="${id}">${label}</a>`;
20
+ return (escaped) => escaped
21
+ .replace(/\b(C\d+|ADR-\d+)\b/g, (m, id) => (known.has(id) ? ref(id, m) : m))
22
+ .replace(/\b([Pp]adrão) (\d+)\b/g, (m, word, n) => (defaults.has(Number(n)) ? ref(`P${n}`, `${word} ${n}`) : m));
23
+ }
24
+
25
+ function inline(text, link) {
26
+ return String(text).split(/(`[^`]+`)/).map((part) => {
27
+ if (/^`[^`]+`$/.test(part)) return `<code>${esc(part.slice(1, -1))}</code>`;
28
+ return link(esc(part)).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
29
+ }).join('');
30
+ }
31
+
32
+ function blocks(markdown, link) {
33
+ const text = String(markdown ?? '').replace(/<!--[\s\S]*?-->/g, '');
34
+ const out = [];
35
+ let para = [];
36
+ let list = null;
37
+ let table = null;
38
+ let fence = null;
39
+ const closePara = () => { if (para.length) out.push(`<p>${inline(para.join(' '), link)}</p>`); para = []; };
40
+ const closeList = () => { if (list) out.push(`<ul>${list.map((i) => `<li>${inline(i, link)}</li>`).join('')}</ul>`); list = null; };
41
+ const closeTable = () => {
42
+ if (!table) return;
43
+ const [head, ...rows] = table;
44
+ const cells = (row, tag) => row.map((c) => `<${tag}>${inline(c, link)}</${tag}>`).join('');
45
+ out.push(`<div class="scroll-x"><table><thead><tr>${cells(head, 'th')}</tr></thead><tbody>${rows.map((r) => `<tr>${cells(r, 'td')}</tr>`).join('')}</tbody></table></div>`);
46
+ table = null;
47
+ };
48
+ const closeAll = () => { closePara(); closeList(); closeTable(); };
49
+ for (const line of text.split(/\r?\n/)) {
50
+ if (fence) {
51
+ if (/^\s*```/.test(line)) { out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(line);
52
+ continue;
53
+ }
54
+ if (/^\s*```/.test(line)) { closeAll(); fence = []; continue; }
55
+ if (!line.trim()) { closeAll(); continue; }
56
+ if (/^\s*\|/.test(line)) {
57
+ closePara(); closeList();
58
+ if (/^\s*\|[\s:|-]+\|?\s*$/.test(line)) continue;
59
+ (table ??= []).push(line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim()));
60
+ continue;
61
+ }
62
+ closeTable();
63
+ const bullet = /^\s*[-*]\s+(.*)$/.exec(line);
64
+ if (bullet) { closePara(); (list ??= []).push(bullet[1]); continue; }
65
+ if (list && /^\s+\S/.test(line)) { list[list.length - 1] += ` ${line.trim()}`; continue; }
66
+ closeList();
67
+ const heading = /^#{1,6}\s+(.*)$/.exec(line);
68
+ if (heading) { closePara(); out.push(`<h3>${inline(heading[1], link)}</h3>`); continue; }
69
+ para.push(line.trim());
70
+ }
71
+ if (fence) out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`);
72
+ closeAll();
73
+ return out.join('');
74
+ }
75
+
76
+ export function splitProblem(text) {
77
+ const m = /^\s*\**\s*Resultado buscado\s*:\s*\**\s*(.+)$/im.exec(text ?? '');
78
+ const outcome = m ? m[1].replace(/\*\*/g, '').trim() : null;
79
+ return { outcome, problem: m ? text.replace(m[0], '').trim() : text ?? '' };
80
+ }
81
+
82
+ const bulletsOf = (text) => String(text ?? '').replace(/<!--[\s\S]*?-->/g, '').split(/\r?\n/)
83
+ .map((l) => /^\s*[-*]\s+(.+)$/.exec(l)?.[1]).filter((l) => l && !NONE_RE.test(l.trim()));
84
+
85
+ const conflictsOf = (plan) => {
86
+ const text = plan.conflicts?.text?.replace(/<!--[\s\S]*?-->/g, '').trim();
87
+ return text && !NONE_RE.test(text) ? text : null;
88
+ };
89
+
90
+ export function pendingAsks(view) {
91
+ const { plan } = view;
92
+ const asks = [];
93
+ for (const c of plan.scenarios) {
94
+ if (c.rung && c.rung.toLowerCase() !== 'refazer') asks.push({ id: c.id, verb: 'Revisar', action: 'Pedir ajuste', anchor: `ask-${c.id}`, text: `Não se desfaz com revert (degrau: ${c.rung}). ${c.text}`, tag: 'Degrau' });
95
+ if (c.status === 'bloqueado') asks.push({ id: c.id, verb: 'Destravar', action: 'Decidir', anchor: `ask-${c.id}-b`, text: `Bloqueado na implementação. ${c.evidence || c.text}`, tag: 'Bloqueio' });
96
+ }
97
+ bulletsOf(plan.text.assumptions).forEach((text, i) => asks.push({ id: `suposição ${i + 1}`, verb: 'Corrigir', action: 'Corrigir', anchor: `S${i + 1}`, text, tag: 'Suposição sem prova' }));
98
+ for (const d of plan.defaults.items) asks.push({ id: `padrão ${d.n}`, verb: 'Vetar', action: 'Vetar', anchor: `P${d.n}`, text: d.text, tag: `Padrão ${d.n}` });
99
+ return asks;
100
+ }
101
+
102
+ function askRow(a, link) {
103
+ return `<li id="${esc(a.anchor)}" data-ask="${esc(a.id)}" data-verb="${esc(a.verb)}"><div><p>${inline(a.text, link)}</p><span class="tag">${esc(a.tag)}</span></div><button type="button" class="toggle" aria-pressed="false">${esc(a.action)}</button></li>`;
104
+ }
105
+
106
+ function segmented(title, entries, filterable = true) {
107
+ const used = entries.filter((e) => e.n > 0);
108
+ if (!used.length) return '';
109
+ const total = used.reduce((sum, e) => sum + e.n, 0);
110
+ const attrs = (e) => (filterable ? `data-filter="${esc(e.key)}" data-jump` : 'disabled');
111
+ return `<div class="chart"><h3>${esc(title)}</h3><div class="seg" role="img" aria-label="${esc(used.map((e) => `${e.label}: ${e.n}`).join(', '))}">${used.map((e) => `<button type="button" ${attrs(e)} style="--n:${e.n};--c:var(--s${e.slot})" title="${esc(e.label)}: ${e.n} de ${total}" aria-label="${esc(e.label)}: ${e.n}"></button>`).join('')}</div><ul class="legend">${used.map((e) => `<li><i style="--c:var(--s${e.slot})"></i>${esc(e.label)} ${e.n}</li>`).join('')}</ul></div>`;
112
+ }
113
+
114
+ function riskBars(scenarios) {
115
+ const rows = RISK_KINDS.map((r) => ({ r, n: scenarios.filter((c) => c.risk?.toLowerCase() === r).length })).filter((x) => x.n);
116
+ if (!rows.length) return '';
117
+ const max = Math.max(...rows.map((x) => x.n));
118
+ return `<div class="chart"><h3>Risco que os cenários protegem</h3><ul class="bars">${rows.map((x) => `<li><button type="button" data-filter="${esc(x.r)}" data-jump title="Ver cenários de ${esc(x.r)}"><span>${esc(x.r)}</span><span class="track"><span class="fill" style="--share:${x.n / max}"></span></span><span>${x.n}</span></button></li>`).join('')}</ul></div>`;
119
+ }
120
+
121
+ function panorama(view, asks, link) {
122
+ const { plan } = view;
123
+ const { outcome, problem } = splitProblem(plan.text.problem);
124
+ const rungs = plan.scenarios.filter((c) => c.rung && c.rung.toLowerCase() !== 'refazer').length;
125
+ const proofs = PROOF_KINDS.map((k, i) => ({ key: k, label: k, slot: i + 1, n: plan.scenarios.filter((c) => c.proof?.toLowerCase() === k).length }));
126
+ const reuse = REUSE_VERDICTS.map((v, i) => ({ key: v, label: v.toLowerCase(), slot: i + 1, n: plan.reuse.filter((r) => r.verdict === v).length }));
127
+ const num = (href, n, label, alert = false) => `<li${alert ? ' class="alert"' : ''}><a href="${href}"><b>${n}</b>${esc(label)}</a></li>`;
128
+ return `<section id="panorama">
129
+ ${outcome ? `<p class="outcome">${inline(outcome, link)}</p>` : ''}
130
+ <ul class="numbers">
131
+ ${num('#cenarios', plan.scenarios.length, plan.scenarios.length === 1 ? 'cenário define o pronto' : 'cenários definem o pronto')}
132
+ ${num('#decisoes', plan.adrs.length, plan.adrs.length === 1 ? 'decisão registrada' : 'decisões registradas')}
133
+ ${num('#decidir', asks.length, asks.length === 1 ? 'ponto para você olhar' : 'pontos para você olhar')}
134
+ ${rungs ? num('#decidir', rungs, 'sem volta por revert', true) : ''}
135
+ </ul>
136
+ ${problem ? `<details class="fold" open><summary>O problema hoje</summary><div class="prose">${blocks(problem, link)}</div></details>` : ''}
137
+ ${segmented('Como cada cenário será provado', proofs)}
138
+ ${riskBars(plan.scenarios)}
139
+ ${segmented('Reaproveitar ou criar', reuse, false)}
140
+ </section>`;
141
+ }
142
+
143
+ function gateSign(view) {
144
+ const g = view.gate;
145
+ if (g?.pause && g.resolver !== 'session') {
146
+ return `<div class="sign"><h3>O ${esc(g.label.toLowerCase())} espera sua decisão</h3><p>${inline(g.reason ?? 'Leia os pontos abaixo e responda no terminal.', (s) => s)}</p></div>`;
147
+ }
148
+ const approved = Object.values(view.gates).filter((x) => x === true || x?.approved === true).length;
149
+ return `<div class="sign clear"><h3>${approved ? 'Nenhuma decisão parada' : 'Gate ainda não chamado'}</h3><p>Você pode vetar qualquer padrão abaixo a qualquer momento.</p></div>`;
150
+ }
151
+
152
+ function decide(view, asks, link) {
153
+ const conflicts = conflictsOf(view.plan);
154
+ return `<section id="decidir"><h2>O que precisa de você</h2><p class="sub">Marque o que quer mudar. O que ficar sem marca está aceito. A resposta pronta aparece no rodapé para colar no terminal.</p>
155
+ ${gateSign(view)}
156
+ ${asks.length ? `<ul class="asks">${asks.map((a) => askRow(a, link)).join('')}</ul>` : '<p class="empty">Nenhum ponto em aberto neste plano.</p>'}
157
+ ${conflicts ? `<h3>Conflitos com decisões anteriores</h3><div class="prose">${blocks(conflicts, link)}</div>` : ''}
158
+ ${view.critique ? `<details class="fold"><summary>Ler a crítica independente do plano</summary><div class="prose">${blocks(view.critique, link)}</div></details>` : ''}
159
+ </section>`;
160
+ }
161
+
162
+ function scenarioItem(c, cites, link) {
163
+ const state = c.proved === 'PROVADO' ? 'provado' : c.proved === 'FALHA' ? 'falha' : c.status;
164
+ const label = c.proved ?? STATUS_LABEL[c.status];
165
+ const tone = c.proved === 'PROVADO' || c.status === 'feito' ? 'ok' : c.proved === 'FALHA' || c.status === 'bloqueado' ? 'bad' : '';
166
+ const tags = [c.proof, c.risk, c.status, c.rung].filter(Boolean).map((t) => String(t).toLowerCase());
167
+ const rung = c.rung && c.rung.toLowerCase() !== 'refazer' ? `<span class="${RUNG_TONE[c.rung.toLowerCase()] ?? ''}">Degrau: ${esc(c.rung)}</span>` : '';
168
+ const adrs = cites.length ? `<span>${cites.map((id) => `<a href="#${id}" data-ref="${id}">${id}</a>`).join(', ')}</span>` : '';
169
+ const evidence = c.proof_note || c.evidence;
170
+ return `<li id="${esc(c.id)}" data-state="${state}" data-tags="${esc(tags.join(' '))}"><p><span class="id">${esc(c.id)}</span>${inline(c.text, link)}</p><div class="tags"><span class="${tone}">${esc(label)}</span>${c.proof ? `<span>Prova: ${esc(c.proof)}</span>` : ''}${c.risk ? `<span>Risco: ${esc(c.risk)}</span>` : ''}${rung}${adrs}</div>${evidence ? `<p class="evidence">${inline(evidence, link)}</p>` : ''}</li>`;
171
+ }
172
+
173
+ function scenariosSection(view, citesOf, link) {
174
+ const { scenarios } = view.plan;
175
+ const keys = (list) => [...new Set(list.filter(Boolean).map((k) => k.toLowerCase()))];
176
+ const filters = ['todos', ...keys(scenarios.map((c) => c.proof)), ...keys(scenarios.map((c) => c.risk)), ...keys(scenarios.map((c) => c.status))];
177
+ return `<section id="cenarios"><h2>O que vai estar pronto</h2><p class="sub">Cada ponto da linha é um comportamento que o verificador executa antes da entrega.</p>
178
+ <div class="filters" role="group" aria-label="Filtrar cenários">${filters.map((f, i) => `<button type="button" data-filter="${esc(f)}" aria-pressed="${i === 0}">${esc(f)}</button>`).join('')}</div>
179
+ <ol class="line">${scenarios.map((c) => scenarioItem(c, citesOf(c.id), link)).join('')}</ol></section>`;
180
+ }
181
+
182
+ const adrBody = (a) => a.body.replace(/^\s*\*\*Alcance:\*\*.*$/im, '').trim();
183
+
184
+ function matrix(view, cited) {
185
+ const adrs = view.plan.adrs.filter((a) => cited.get(a.id).length);
186
+ if (!adrs.length) return '';
187
+ const ids = view.plan.scenarios.map((c) => c.id);
188
+ return `<h3>Qual decisão sustenta qual cenário</h3><div class="scroll-x"><table class="matrix"><thead><tr><th></th>${ids.map((id) => `<th><a href="#${id}" data-ref="${id}">${id}</a></th>`).join('')}</tr></thead><tbody>${adrs.map((a) => `<tr><th><a href="#${a.id}" data-ref="${a.id}">${a.id}</a></th>${ids.map((id) => `<td>${cited.get(a.id).includes(id) ? `<a href="#${id}" aria-label="${a.id} sustenta ${id}"></a>` : ''}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
189
+ }
190
+
191
+ function decisions(view, cited, link) {
192
+ const { adrs } = view.plan;
193
+ return `<section id="decisoes"><h2>Decisões</h2><p class="sub">Abra uma decisão para ver o motivo. Os links levam aos cenários que dependem dela.</p>
194
+ ${adrs.length ? adrs.map((a) => `<details class="adr" id="${esc(a.id)}"><summary>${esc(a.id)}: ${esc(a.title)}${a.scope ? `<small>vale para ${a.scope === 'projeto' ? 'o projeto' : 'esta feature'}</small>` : ''}</summary><div class="prose">${blocks(adrBody(a), link)}</div></details>`).join('') : '<p class="empty">Este plano não registrou decisões de arquitetura.</p>'}
195
+ ${matrix(view, cited)}</section>`;
196
+ }
197
+
198
+ function howItWorks(view, link) {
199
+ const { plan } = view;
200
+ const flow = plan.flow?.transitions ?? [];
201
+ const states = plan.states?.transitions ?? [];
202
+ const reuse = plan.reuse.length ? `<h3>O que aproveita e o que nasce</h3><div class="scroll-x"><table><thead><tr><th>Conceito</th><th>Veredito</th><th>Onde</th></tr></thead><tbody>${plan.reuse.map((r) => `<tr><td>${inline(r.concept, link)}</td><td>${esc(r.verdict.toLowerCase())}</td><td>${inline(r.target, link)}${r.why ? `<br><small>${inline(r.why, link)}</small>` : ''}</td></tr>`).join('')}</tbody></table></div>` : '';
203
+ const out = plan.text.outOfScope;
204
+ return `<section id="como"><h2>Como funciona</h2>
205
+ ${flow.length ? `<h3>Fluxo</h3><div class="diagram">${stateMachineSvg(flow, 'Fluxo', 'flow')}</div>` : ''}
206
+ ${states.length ? `<h3>Estados</h3><div class="diagram">${stateMachineSvg(states, 'Máquina de estados', 'states')}</div>` : ''}
207
+ ${plan.text.architecture ? `<h3>Arquitetura</h3><div class="prose">${blocks(plan.text.architecture, link)}</div>` : ''}
208
+ ${reuse}
209
+ ${out ? `<h3>Fora de escopo</h3><div class="prose">${blocks(out, link)}</div>` : ''}
210
+ ${plan.text.contract ? `<details class="fold"><summary>Contrato de implementação</summary><div class="prose">${blocks(plan.text.contract, link)}</div></details>` : ''}
211
+ ${plan.errata ? `<h3>Errata</h3><div class="prose">${blocks(plan.errata, link)}</div>` : ''}
212
+ </section>`;
213
+ }
214
+
215
+ const REVIEW_LABEL = { verifier: 'Verificador', reviewer: 'Revisor', security: 'Segurança' };
216
+
217
+ function execution(view, link) {
218
+ const { progress, verification, reviews, design, changes } = view;
219
+ const started = progress?.done > 0 || progress?.blocked > 0 || verification || Object.values(reviews).some((r) => r.status !== 'missing');
220
+ if (!started && !design.length) return '';
221
+ const share = progress?.total ? progress.done / progress.total : 0;
222
+ const ring = progress?.total ? `<div class="gauge"><svg class="ring" viewBox="0 0 112 112" role="img" aria-label="${progress.done} de ${progress.total} cenários feitos"><circle class="base" cx="56" cy="56" r="45"/><circle class="part" cx="56" cy="56" r="45" style="--share:${share}"/></svg><div><b>${progress.done} de ${progress.total}</b>cenários feitos${progress.blocked ? `, ${progress.blocked} bloqueado${progress.blocked > 1 ? 's' : ''}` : ''}</div></div>` : '';
223
+ const stat = changes?.stat ? `<div class="gauge"><div><b>${changes.stat.files}</b>arquivos alterados, +${changes.stat.added} e −${changes.stat.removed} linhas</div></div>` : '';
224
+ const rows = [];
225
+ if (verification) {
226
+ const ok = verification.status === 'pass';
227
+ rows.push(`<li>Verify: <span class="${ok ? 'ok' : 'bad'}">${ok ? 'passou' : 'falhou'}</span>${verification.sha ? ` em <code>${esc(String(verification.sha).slice(0, 8))}</code>` : ''}</li>`);
228
+ }
229
+ for (const [role, r] of Object.entries(reviews)) {
230
+ if (r.status === 'missing') continue;
231
+ const tone = r.verdict === 'SHIP' ? 'ok' : r.verdict === 'FIX' ? 'bad' : 'wait';
232
+ rows.push(`<li>${REVIEW_LABEL[role]}: <span class="${tone}">${esc(r.verdict ?? 'em andamento')}</span>${r.blockers?.length ? `, ${r.blockers.length} bloqueador${r.blockers.length > 1 ? 'es' : ''}` : ''}</li>`);
233
+ }
234
+ const blockers = Object.values(reviews).flatMap((r) => r.blockers ?? []);
235
+ return `<section id="execucao"><h2>Execução e provas</h2>
236
+ <div class="gauges">${ring}${stat}${rows.length ? `<ul class="status-list">${rows.join('')}</ul>` : ''}</div>
237
+ ${blockers.length ? `<h3>Bloqueadores da revisão</h3><div class="prose"><ul>${blockers.map((b) => `<li>${inline(`${b.id}: ${b.evidence || b.where}`, link)}</li>`).join('')}</ul></div>` : ''}
238
+ ${design.length ? `<h3>Telas</h3><ul class="shots">${design.map((d) => `<li><a href="${esc(d.href)}">${d.shot ? `<img src="${esc(d.shot)}" alt="" loading="lazy">` : ''}${esc(d.name)}${d.kind === 'opcoes' ? ' (opções)' : ''}</a></li>`).join('')}</ul>` : ''}
239
+ </section>`;
240
+ }
241
+
242
+ function recapSection(view) {
243
+ if (!view.recap) return '';
244
+ return `<section id="recap"><h2>A história da feature</h2><p class="sub">Toque para ouvir o que foi feito, cena por cena.</p><iframe class="recap" title="Recap narrado" loading="lazy" srcdoc="${esc(renderRecapHtml(view.recap))}"></iframe></section>`;
245
+ }
246
+
247
+ function refsOf(plan) {
248
+ const refs = {};
249
+ for (const c of plan.scenarios) refs[c.id] = c.text.replace(/`/g, '');
250
+ for (const a of plan.adrs) refs[a.id] = a.title;
251
+ for (const d of plan.defaults.items) refs[`P${d.n}`] = d.text.replace(/`/g, '');
252
+ return JSON.stringify(refs).replace(/</g, '\\u003c');
253
+ }
254
+
255
+ export function renderFeatureHtml(view) {
256
+ const { plan } = view;
257
+ const link = linkifier(plan);
258
+ const asks = pendingAsks(view);
259
+ const ids = plan.scenarios.map((c) => c.id);
260
+ const cited = new Map(plan.adrs.map((a) => [a.id, ids.filter((id) => new RegExp(`\\b${id}\\b`).test(a.body))]));
261
+ const citesOf = (id) => plan.adrs.filter((a) => cited.get(a.id).includes(id)).map((a) => a.id);
262
+ const done = view.stations.filter((s) => s.done).length;
263
+ const exec = execution(view, link);
264
+ const recap = recapSection(view);
265
+ const nav = [
266
+ ['panorama', 'Panorama'], ['decidir', 'Decidir', asks.length], ['cenarios', 'Cenários'], ['decisoes', 'Decisões'], ['como', 'Como funciona'],
267
+ ...(exec ? [['execucao', 'Execução']] : []), ...(recap ? [['recap', 'Recap']] : []),
268
+ ];
269
+ const key = `${view.feature}:${(view.planHash ?? '').slice(0, 12)}`;
270
+ return `<!doctype html>
271
+ <html lang="pt-BR">
272
+ <head>
273
+ <meta charset="utf-8">
274
+ <meta name="viewport" content="width=device-width, initial-scale=1">
275
+ <title>${esc(view.feature)}</title>
276
+ <style>
277
+ ${TOKENS_CSS}${PAGE_CSS}</style>
278
+ </head>
279
+ <body data-key="${esc(key)}" data-feature="${esc(view.feature)}">
280
+ <header class="top">
281
+ <p class="kind">${esc(WORK_LABEL[view.workType] ?? 'Feature')}</p>
282
+ <h1>${esc(view.feature)}</h1>
283
+ ${view.description ? `<p class="ask">${esc(view.description)}</p>` : ''}
284
+ <ol class="route" style="--done:${Math.min(1, done / Math.max(1, view.stations.length - 1)).toFixed(3)}">${view.stations.map((s) => `<li class="stop ${s.state}"${s.state === 'now' ? ' aria-current="step"' : ''}>${esc(s.label)}${s.note ? `<small>${esc(s.note)}</small>` : ''}</li>`).join('')}</ol>
285
+ </header>
286
+ <div class="shell">
287
+ <nav class="side" aria-label="Seções">${nav.map(([id, label, badge]) => `<a href="#${id}">${esc(label)}${badge ? `<b>${badge}</b>` : ''}</a>`).join('')}</nav>
288
+ <main>
289
+ ${panorama(view, asks, link)}
290
+ ${decide(view, asks, link)}
291
+ ${scenariosSection(view, citesOf, link)}
292
+ ${decisions(view, cited, link)}
293
+ ${howItWorks(view, link)}
294
+ ${exec}
295
+ ${recap}
296
+ <p class="source">Gerada pelo morph-spec a partir de plan.md${view.planHash ? ` (${esc(view.planHash.slice(0, 12))})` : ''} e dos relatórios da feature. Atualize a aba para ver o estado mais recente. Para mudar o plano, edite o plan.md.</p>
297
+ </main>
298
+ </div>
299
+ <div id="peek" role="tooltip"></div>
300
+ <div id="tray" role="region" aria-label="Sua resposta"><span class="count"></span><button type="button" class="copy">Copiar resposta</button><button type="button" class="clear">Limpar</button></div>
301
+ <script type="application/json" id="refs">${refsOf(plan)}</script>
302
+ <script>
303
+ ${CLIENT_JS}</script>
304
+ </body>
305
+ </html>
306
+ `;
307
+ }
@@ -0,0 +1,116 @@
1
+ const REFS = JSON.parse(document.getElementById('refs').textContent);
2
+ const STORE = `morph-view:${document.body.dataset.key}`;
3
+ const load = () => { try { return JSON.parse(localStorage.getItem(STORE)) ?? {}; } catch { return {}; } };
4
+ const save = (v) => { try { localStorage.setItem(STORE, JSON.stringify(v)); } catch { /* the page works without it */ } };
5
+
6
+ function jump() {
7
+ const id = decodeURIComponent(location.hash.slice(1));
8
+ const el = id && document.getElementById(id);
9
+ if (!el) return;
10
+ if (el.tagName === 'DETAILS') el.open = true;
11
+ el.closest('details')?.setAttribute('open', '');
12
+ clearFilter(el);
13
+ el.classList.remove('flash');
14
+ void el.offsetWidth;
15
+ el.classList.add('flash');
16
+ }
17
+ addEventListener('hashchange', jump);
18
+
19
+ const links = [...document.querySelectorAll('.side a')];
20
+ const sections = links.map((a) => document.querySelector(a.getAttribute('href'))).filter(Boolean);
21
+ const spy = new IntersectionObserver((entries) => {
22
+ for (const e of entries) {
23
+ if (e.isIntersecting) links.forEach((a) => a.classList.toggle('on', a.getAttribute('href') === `#${e.target.id}`));
24
+ }
25
+ }, { rootMargin: '-15% 0px -75% 0px' });
26
+ sections.forEach((s) => spy.observe(s));
27
+
28
+ const run = new IntersectionObserver((entries, o) => {
29
+ for (const e of entries) if (e.isIntersecting) { e.target.classList.add('run'); o.unobserve(e.target); }
30
+ }, { threshold: 0.4 });
31
+ document.querySelectorAll('.machine').forEach((m) => run.observe(m));
32
+
33
+ const items = [...document.querySelectorAll('#cenarios .line > li')];
34
+ const buttons = [...document.querySelectorAll('[data-filter]')];
35
+ function applyFilter(key) {
36
+ items.forEach((li) => { li.hidden = key !== 'todos' && !li.dataset.tags.split(' ').includes(key); });
37
+ buttons.forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.filter === key)));
38
+ }
39
+ function clearFilter(el) { if (el.hidden) applyFilter('todos'); }
40
+ buttons.forEach((b) => b.addEventListener('click', () => {
41
+ applyFilter(b.dataset.filter);
42
+ if (b.dataset.jump !== undefined) document.getElementById('cenarios').scrollIntoView();
43
+ }));
44
+
45
+ const peek = document.getElementById('peek');
46
+ function showPeek(a) {
47
+ const text = REFS[a.dataset.ref];
48
+ if (!text) return;
49
+ peek.textContent = text;
50
+ const r = a.getBoundingClientRect();
51
+ peek.style.left = `${Math.max(8, Math.min(r.left, innerWidth - 350))}px`;
52
+ peek.style.top = `${r.bottom + 6 + 90 > innerHeight ? r.top - 96 : r.bottom + 6}px`;
53
+ peek.classList.add('on');
54
+ }
55
+ const hidePeek = () => peek.classList.remove('on');
56
+ document.querySelectorAll('a[data-ref]').forEach((a) => {
57
+ a.addEventListener('mouseenter', () => showPeek(a));
58
+ a.addEventListener('focus', () => showPeek(a));
59
+ a.addEventListener('mouseleave', hidePeek);
60
+ a.addEventListener('blur', hidePeek);
61
+ });
62
+
63
+ const state = load();
64
+ const tray = document.getElementById('tray');
65
+ const asks = [...document.querySelectorAll('[data-ask]')];
66
+ function answer() {
67
+ const lines = [];
68
+ for (const li of asks) {
69
+ const id = li.dataset.ask;
70
+ const pressed = li.querySelector('.toggle')?.getAttribute('aria-pressed') === 'true';
71
+ const note = li.querySelector('.note')?.value.trim();
72
+ if (pressed || note) lines.push(`- ${li.dataset.verb} ${id}${note ? `: ${note}` : ''}`);
73
+ }
74
+ return lines;
75
+ }
76
+ function sync() {
77
+ const n = answer().length;
78
+ tray.classList.toggle('on', n > 0);
79
+ tray.querySelector('.count').textContent = `${n} ${n === 1 ? 'ajuste marcado' : 'ajustes marcados'}`;
80
+ save(state);
81
+ }
82
+ for (const li of asks) {
83
+ const id = li.dataset.ask;
84
+ const toggle = li.querySelector('.toggle');
85
+ const open = (on) => {
86
+ toggle.setAttribute('aria-pressed', String(on));
87
+ let note = li.querySelector('.note');
88
+ if (on && !note) {
89
+ note = document.createElement('textarea');
90
+ note.className = 'note';
91
+ note.setAttribute('aria-label', `O que mudar em ${id}`);
92
+ note.placeholder = 'O que você quer no lugar? Opcional.';
93
+ note.value = state[id]?.note ?? '';
94
+ note.addEventListener('input', () => { state[id] = { note: note.value }; sync(); });
95
+ li.append(note);
96
+ } else if (!on && note) note.remove();
97
+ };
98
+ toggle.addEventListener('click', () => {
99
+ const on = toggle.getAttribute('aria-pressed') !== 'true';
100
+ open(on);
101
+ if (on) { state[id] = { note: state[id]?.note ?? '' }; li.querySelector('.note').focus(); } else delete state[id];
102
+ sync();
103
+ });
104
+ if (state[id]) open(true);
105
+ }
106
+ tray.querySelector('.copy').addEventListener('click', async (e) => {
107
+ const text = `Ajustes em ${document.body.dataset.feature}:\n${answer().join('\n')}`;
108
+ try { await navigator.clipboard.writeText(text); e.target.textContent = 'Copiado'; } catch { prompt('Copie o texto', text); }
109
+ setTimeout(() => { e.target.textContent = 'Copiar resposta'; }, 1800);
110
+ });
111
+ tray.querySelector('.clear').addEventListener('click', () => {
112
+ for (const li of asks) if (li.querySelector('.toggle').getAttribute('aria-pressed') === 'true') li.querySelector('.toggle').click();
113
+ });
114
+
115
+ sync();
116
+ jump();
@@ -0,0 +1,169 @@
1
+ * { box-sizing:border-box; }
2
+ html { scroll-behavior:smooth; scroll-padding-top:24px; }
3
+ body { margin:0; background:var(--paper); color:var(--ink); font:16px/1.6 var(--text); }
4
+ a { color:var(--route); text-underline-offset:2px; }
5
+ :focus-visible { outline:2px solid var(--route); outline-offset:2px; border-radius:3px; }
6
+ code { font:.88em var(--code); background:color-mix(in srgb, var(--route) 10%, transparent); padding:.08em .32em; border-radius:4px; overflow-wrap:anywhere; }
7
+ pre { background:var(--panel); border:1px solid var(--rule); border-radius:8px; padding:12px; overflow-x:auto; }
8
+ pre code { background:none; padding:0; }
9
+ h1, h2, h3 { font-family:var(--display); font-weight:600; line-height:1.15; margin:0; }
10
+ button { font:inherit; color:inherit; cursor:pointer; }
11
+
12
+ .top { padding:40px 24px 28px; max-width:1180px; margin:0 auto; }
13
+ .kind { color:var(--faint); font-size:14px; margin:0 0 6px; }
14
+ h1 { font-size:clamp(30px, 5.4vw, 54px); letter-spacing:-.01em; overflow-wrap:anywhere; }
15
+ .ask { color:var(--dim); max-width:72ch; margin:10px 0 0; }
16
+
17
+ .route { list-style:none; margin:30px 0 0; padding:0; display:flex; position:relative; }
18
+ .route::before, .route::after { content:""; position:absolute; left:9px; right:9px; top:8px; height:4px; border-radius:2px; background:var(--rule); }
19
+ .route::after { background:var(--route); right:auto; width:calc((100% - 18px) * var(--done)); transform-origin:left; animation:draw 1s cubic-bezier(.3,.7,.2,1) both; }
20
+ @keyframes draw { from { transform:scaleX(0); } }
21
+ .stop { flex:1; position:relative; z-index:1; font-size:14px; color:var(--faint); padding-top:30px; min-width:0; }
22
+ .stop:last-child { flex:0 0 auto; text-align:right; }
23
+ .stop::before { content:""; position:absolute; top:0; left:0; width:20px; height:20px; border-radius:50%; background:var(--paper); border:4px solid var(--rule); }
24
+ .stop:last-child::before { left:auto; right:0; }
25
+ .stop.done { color:var(--dim); } .stop.done::before { border-color:var(--route); background:var(--route); }
26
+ .stop.now { color:var(--ink); font-weight:600; } .stop.now::before { border-color:var(--route); box-shadow:0 0 0 0 var(--route); animation:pulse 2.4s ease-out 1s 3; }
27
+ @keyframes pulse { to { box-shadow:0 0 0 12px transparent; } }
28
+ .stop small { display:block; font-weight:400; color:var(--faint); font-size:13px; }
29
+
30
+ .shell { display:grid; grid-template-columns:176px minmax(0, 1fr); gap:40px; max-width:1180px; margin:0 auto; padding:0 24px 120px; align-items:start; }
31
+ .side { position:sticky; top:16px; }
32
+ .side a { display:block; padding:6px 0 6px 12px; color:var(--dim); text-decoration:none; font-size:15px; border-left:3px solid var(--rule); }
33
+ .side a.on { color:var(--ink); font-weight:600; border-left-color:var(--route); }
34
+ .side a b { float:right; font-family:var(--display); font-weight:600; background:var(--sign); color:var(--on-sign); border-radius:9px; padding:0 7px; font-size:13px; line-height:18px; margin-top:3px; }
35
+
36
+ section { padding-top:44px; max-width:860px; }
37
+ section:first-child { padding-top:0; }
38
+ h2 { font-size:26px; margin-bottom:6px; }
39
+ .sub { color:var(--dim); margin:0 0 18px; max-width:68ch; }
40
+ h3 { font-size:17px; margin:26px 0 8px; }
41
+ .prose p { margin:0 0 10px; max-width:72ch; }
42
+ .prose ul { margin:0 0 10px; padding-left:20px; max-width:72ch; }
43
+ .prose li { margin:4px 0; }
44
+ .empty { color:var(--faint); }
45
+ .scroll-x { overflow-x:auto; }
46
+ table { border-collapse:collapse; width:100%; font-size:15px; }
47
+ th { text-align:left; font-weight:600; color:var(--dim); border-bottom:2px solid var(--rule); padding:6px 12px 6px 0; }
48
+ td { border-bottom:1px solid var(--rule); padding:8px 12px 8px 0; vertical-align:top; }
49
+
50
+ .outcome { font-family:var(--display); font-size:clamp(22px, 3.2vw, 31px); line-height:1.25; margin:0 0 20px; max-width:30em; }
51
+ .numbers { display:flex; flex-wrap:wrap; gap:12px 40px; margin:22px 0 28px; padding:0; list-style:none; }
52
+ .numbers li { font-size:14px; color:var(--dim); max-width:9.5em; line-height:1.3; }
53
+ .numbers a { color:inherit; text-decoration:none; display:block; }
54
+ .numbers b { display:block; font:600 42px/1 var(--display); color:var(--ink); margin-bottom:4px; font-variant-numeric:tabular-nums; }
55
+ .numbers .alert b { color:var(--bad); }
56
+
57
+ .chart { margin:18px 0 0; }
58
+ .chart h3 { margin:0 0 8px; font-size:15px; font-family:var(--text); color:var(--dim); font-weight:600; }
59
+ .seg { display:flex; gap:2px; height:22px; }
60
+ .seg button { flex:var(--n) 1 0; min-width:6px; border:0; padding:0; background:var(--c); transform-origin:left; animation:grow .7s .25s cubic-bezier(.3,.7,.2,1) both; }
61
+ .seg button:first-child { border-radius:4px 0 0 4px; } .seg button:last-child { border-radius:0 4px 4px 0; }
62
+ @keyframes grow { from { transform:scaleX(0); } }
63
+ .legend { list-style:none; display:flex; flex-wrap:wrap; gap:4px 16px; margin:8px 0 0; padding:0; font-size:14px; color:var(--dim); }
64
+ .legend i { display:inline-block; width:10px; height:10px; border-radius:2px; background:var(--c); margin-right:6px; }
65
+ .bars { list-style:none; margin:0; padding:0; display:grid; gap:6px; }
66
+ .bars li { display:grid; grid-template-columns:7.5em 1fr 2em; gap:8px; align-items:center; font-size:14px; color:var(--dim); }
67
+ .bars button { all:unset; cursor:pointer; display:contents; }
68
+ .bars span.track { height:10px; }
69
+ .bars span.fill { display:block; height:100%; width:calc(var(--share) * 100%); min-width:4px; border-radius:0 4px 4px 0; background:var(--route); transform-origin:left; animation:grow .7s .35s cubic-bezier(.3,.7,.2,1) both; }
70
+
71
+ .sign { background:var(--sign); color:var(--on-sign); border-radius:6px; padding:20px 22px; margin-bottom:18px; }
72
+ .sign h3 { margin:0 0 4px; font-size:21px; }
73
+ .sign p { margin:0; max-width:66ch; }
74
+ .sign.clear { background:transparent; color:var(--dim); border:2px solid var(--rule); padding:12px 16px; }
75
+ .sign.clear h3 { font-size:17px; color:var(--ink); }
76
+ .sign code { background:rgba(0,0,0,.09); }
77
+
78
+ .asks { list-style:none; margin:0; padding:0; }
79
+ .asks > li { display:grid; grid-template-columns:minmax(0, 1fr) auto; gap:4px 16px; padding:12px 0; border-bottom:1px solid var(--rule); scroll-margin-top:24px; border-radius:4px; }
80
+ .asks p { margin:0; }
81
+ .asks .tag { color:var(--faint); font-size:14px; }
82
+ .toggle { background:transparent; border:1px solid var(--rule); border-radius:6px; padding:5px 12px; min-height:36px; font-size:14px; color:var(--dim); align-self:start; white-space:nowrap; }
83
+ .toggle:hover { border-color:var(--route); color:var(--ink); }
84
+ .toggle[aria-pressed="true"] { background:var(--bad); border-color:var(--bad); color:var(--paper); }
85
+ .note { grid-column:1 / -1; width:100%; min-height:62px; font:inherit; font-size:15px; color:var(--ink); background:var(--panel); border:1px solid var(--rule); border-radius:6px; padding:8px; }
86
+
87
+ .filters { display:flex; flex-wrap:wrap; gap:6px; margin:0 0 12px; }
88
+ .filters button { background:transparent; border:1px solid var(--rule); border-radius:6px; padding:3px 10px; min-height:32px; font-size:14px; color:var(--dim); }
89
+ .filters button[aria-pressed="true"] { border-color:var(--route); color:var(--ink); font-weight:600; }
90
+
91
+ .line { list-style:none; margin:0; padding:0 0 0 7px; }
92
+ .line > li { position:relative; padding:0 0 22px 28px; border-left:3px solid var(--route); scroll-margin-top:24px; }
93
+ .line > li:last-child { border-left-color:transparent; padding-bottom:0; }
94
+ .line > li::before { content:""; position:absolute; left:-10.5px; top:3px; width:18px; height:18px; border-radius:50%; background:var(--paper); border:3px solid var(--route); }
95
+ .line > li[data-state="feito"]::before, .line > li[data-state="provado"]::before { background:var(--ok); border-color:var(--ok); }
96
+ .line > li[data-state="bloqueado"]::before, .line > li[data-state="falha"]::before { background:var(--bad); border-color:var(--bad); }
97
+ .line .id { font:600 15px var(--display); color:var(--route); margin-right:6px; }
98
+ .line p { margin:0; max-width:70ch; }
99
+ .tags { display:flex; flex-wrap:wrap; gap:4px 12px; font-size:14px; color:var(--faint); margin-top:4px; }
100
+ .tags .warn { color:var(--warn); font-weight:600; } .tags .bad { color:var(--bad); font-weight:600; } .tags .ok { color:var(--ok); font-weight:600; }
101
+ .evidence { font-size:14px; color:var(--dim); margin-top:4px; }
102
+
103
+ details.adr { border-left:3px solid var(--rule); padding:0 0 0 14px; margin-bottom:12px; scroll-margin-top:24px; }
104
+ details.adr[open] { border-left-color:var(--route); }
105
+ details.adr summary { cursor:pointer; padding:6px 0; font:600 17px/1.3 var(--display); }
106
+ details.adr summary small { font:400 14px var(--text); color:var(--faint); margin-left:6px; }
107
+ .fold { margin-top:12px; } .fold summary { cursor:pointer; color:var(--dim); padding:6px 0; }
108
+
109
+ .matrix { font-size:14px; }
110
+ .matrix th, .matrix td { padding:5px 8px; text-align:center; }
111
+ .matrix th:first-child { text-align:left; white-space:nowrap; }
112
+ .matrix td a { display:block; width:14px; height:14px; margin:auto; border-radius:50%; background:var(--route); }
113
+
114
+ .diagram { background:var(--panel); border:1px solid var(--rule); border-radius:6px; padding:12px; overflow-x:auto; margin-bottom:14px; }
115
+ .machine .state rect { fill:var(--paper); stroke:var(--route); stroke-width:2; }
116
+ .machine .state text { fill:var(--ink); font:600 13px var(--text); }
117
+ .machine .edge { fill:none; stroke:var(--faint); stroke-width:1.5; stroke-dasharray:6 5; }
118
+ .machine.run .edge { animation:travel 1.1s linear 4; }
119
+ @keyframes travel { to { stroke-dashoffset:-22; } }
120
+ .machine .arrow-head { fill:var(--faint); }
121
+ .machine .edge-label { fill:var(--dim); font:12px var(--text); paint-order:stroke; stroke:var(--panel); stroke-width:4px; }
122
+
123
+ .gauges { display:flex; flex-wrap:wrap; gap:24px 44px; align-items:center; margin-bottom:16px; }
124
+ .ring { width:112px; height:112px; transform:rotate(-90deg); }
125
+ .ring circle { fill:none; stroke-width:10; }
126
+ .ring .base { stroke:var(--rule); }
127
+ .ring .part { stroke:var(--ok); stroke-dasharray:calc(var(--share) * 283) 283; animation:sweep .9s .2s ease-out both; }
128
+ @keyframes sweep { from { stroke-dasharray:0 283; } }
129
+ .gauge { display:flex; align-items:center; gap:14px; }
130
+ .gauge b { display:block; font:600 28px/1 var(--display); }
131
+ .status-list { list-style:none; margin:0; padding:0; font-size:15px; }
132
+ .status-list li { padding:3px 0; }
133
+ .status-list .ok { color:var(--ok); font-weight:600; } .status-list .bad { color:var(--bad); font-weight:600; } .status-list .wait { color:var(--faint); }
134
+
135
+ .shots { display:grid; grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); gap:14px; list-style:none; margin:0; padding:0; }
136
+ .shots a { display:block; color:var(--ink); text-decoration:none; font-weight:600; font-size:15px; }
137
+ .shots img { display:block; width:100%; aspect-ratio:16/10; object-fit:cover; object-position:top; border:1px solid var(--rule); border-radius:6px; margin-bottom:6px; background:var(--panel); }
138
+ iframe.recap { width:100%; height:620px; border:1px solid var(--rule); border-radius:6px; background:var(--panel); }
139
+
140
+ .flash { animation:flash 1.6s ease-out; }
141
+ @keyframes flash { from { background:color-mix(in srgb, var(--sign) 55%, transparent); } }
142
+
143
+ #peek { position:fixed; z-index:20; max-width:340px; background:var(--ink); color:var(--paper); font-size:14px; line-height:1.4; padding:8px 10px; border-radius:6px; pointer-events:none; opacity:0; transition:opacity .12s; }
144
+ #peek.on { opacity:1; }
145
+
146
+ #tray { position:fixed; left:0; right:0; bottom:0; z-index:15; background:var(--ink); color:var(--paper); padding:12px 24px; display:flex; gap:16px; align-items:center; justify-content:center; flex-wrap:wrap; transform:translateY(110%); visibility:hidden; transition:transform .25s ease-out, visibility .25s; }
147
+ #tray.on { transform:none; visibility:visible; }
148
+ #tray button { background:var(--sign); color:var(--on-sign); border:0; border-radius:6px; padding:8px 16px; font-weight:600; min-height:40px; }
149
+ #tray .clear { background:transparent; color:var(--paper); border:1px solid currentColor; font-weight:400; }
150
+
151
+ .source { color:var(--faint); font-size:14px; margin-top:48px; }
152
+
153
+ @media (max-width:820px) {
154
+ .top { padding:24px 16px 18px; }
155
+ .shell { display:block; padding:0 16px 120px; }
156
+ .side { top:0; z-index:10; display:flex; gap:14px; overflow-x:auto; scrollbar-width:none; background:var(--paper); padding:8px 0; border-bottom:1px solid var(--rule); }
157
+ .side a { border-left:0; border-bottom:3px solid transparent; padding:4px 0; white-space:nowrap; min-height:32px; }
158
+ .side a.on { border-bottom-color:var(--route); }
159
+ .side a b { float:none; margin-left:6px; }
160
+ html { scroll-padding-top:64px; }
161
+ .stop { font-size:12px; } .stop small { display:none; }
162
+ .numbers { gap:12px 28px; } .numbers b { font-size:34px; }
163
+ iframe.recap { height:560px; }
164
+ }
165
+ @media (prefers-reduced-motion: reduce) {
166
+ html { scroll-behavior:auto; }
167
+ *, *::before, *::after { animation:none !important; transition:none !important; }
168
+ }
169
+ @media print { .side, #tray, .toggle, .filters { display:none; } .shell { display:block; } }