@polymorphism-tech/morph-spec 10.5.4 → 10.7.0

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.
Files changed (37) hide show
  1. package/CLAUDE.md +1 -0
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +9 -0
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/docs/CLI.md +7 -2
  8. package/framework/hooks/shared/phase-utils.js +0 -2
  9. package/framework/rules/plan-grammar.md +6 -2
  10. package/framework/schemas/config.schema.json +10 -0
  11. package/framework/schemas/feature.schema.json +12 -2
  12. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  13. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  14. package/framework/skills/core/morph-apply/SKILL.md +2 -2
  15. package/framework/skills/core/morph-apply/references/review.md +1 -1
  16. package/framework/skills/core/morph-plan/SKILL.md +5 -4
  17. package/framework/skills/core/morph-uiux/SKILL.md +2 -2
  18. package/framework/templates/docs/plan.md +3 -3
  19. package/package.json +1 -1
  20. package/src/commands/state/finish.js +99 -11
  21. package/src/commands/state/recap.js +6 -5
  22. package/src/commands/validation/plan-check.js +4 -7
  23. package/src/commands/validation/verify.js +13 -4
  24. package/src/core/paths/output-schema.js +3 -1
  25. package/src/lib/ci/ci-verify.js +16 -0
  26. package/src/lib/ci/pr-checks.js +76 -0
  27. package/src/lib/plan/checks.js +6 -6
  28. package/src/lib/plan/plan-parser.js +3 -0
  29. package/src/lib/recap/recap.js +6 -5
  30. package/src/lib/validators/verify-runner.js +6 -3
  31. package/src/lib/view/feature-html.js +307 -0
  32. package/src/lib/view/feature-page.client.js +116 -0
  33. package/src/lib/view/feature-page.css +169 -0
  34. package/src/lib/view/feature-view.js +104 -0
  35. package/src/lib/view/flow-svg.js +63 -0
  36. package/src/lib/view/tokens.css +18 -0
  37. package/src/lib/plan/plan-html.js +0 -125
@@ -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; } }
@@ -0,0 +1,104 @@
1
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { FEATURE_VIEW_FILE } from '../../core/paths/output-schema.js';
4
+ import { parsePlan } from '../plan/plan-parser.js';
5
+ import { planHash } from '../plan/checks.js';
6
+ import { reviewReportsFromDir } from '../review-reports.js';
7
+ import { implementProgressFrom } from '../../../framework/hooks/shared/report-progress.js';
8
+ import { changeSummary } from '../recap/recap.js';
9
+ import { renderFeatureHtml } from './feature-html.js';
10
+
11
+ const readText = (path) => { try { return readFileSync(path, 'utf8'); } catch { return null; } };
12
+ const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
13
+ const listDir = (path) => { try { return readdirSync(path); } catch { return []; } };
14
+
15
+ const GATE_ASK = { plan: 'Plano', uiux: 'Design', review: 'Revisão' };
16
+
17
+ function collectDesign(featureDir) {
18
+ const screens = (sub) => listDir(join(featureDir, '1-design', sub))
19
+ .filter((f) => f.endsWith('.html'))
20
+ .map((f) => {
21
+ const name = f.replace(/\.html$/, '');
22
+ const shot = `${name}-1280.png`;
23
+ return { name, kind: sub, href: `1-design/${sub}/${f}`, shot: existsSync(join(featureDir, '1-design', sub, shot)) ? `1-design/${sub}/${shot}` : null };
24
+ });
25
+ return [...screens('prototipos'), ...screens('opcoes')];
26
+ }
27
+
28
+ function stations({ gates, design, progress, reviews, archived }) {
29
+ const approved = (g) => gates[g]?.approved === true || gates[g] === true;
30
+ const hasReview = Object.values(reviews).some((r) => r.status !== 'missing');
31
+ const built = hasReview || (progress?.total > 0 && progress.done === progress.total);
32
+ const list = [
33
+ { id: 'plan', label: 'Plano', done: approved('plan') },
34
+ ...(design ? [{ id: 'uiux', label: 'Design', done: approved('uiux') }] : []),
35
+ { id: 'implement', label: 'Implementação', done: built, note: progress?.total ? `${progress.done} de ${progress.total}` : null },
36
+ { id: 'review', label: 'Revisão', done: approved('review') },
37
+ { id: 'ship', label: 'Entrega', done: archived },
38
+ ];
39
+ const now = list.findIndex((s) => !s.done);
40
+ return list.map((s, i) => ({ ...s, state: s.done ? 'done' : i === now ? 'now' : 'next' }));
41
+ }
42
+
43
+ function currentGate(fj) {
44
+ const pending = ['plan', 'uiux', 'review'].find((g) => fj.gateDecisions?.[g] && !(fj.approvalGates?.[g]?.approved === true));
45
+ if (!pending) return null;
46
+ const d = fj.gateDecisions[pending];
47
+ return { gate: pending, label: GATE_ASK[pending], pause: d.pause === true, resolver: d.resolver ?? null, reason: d.reason ?? null };
48
+ }
49
+
50
+ /** Everything the page shows, read from the feature folder; a missing file only drops its part. */
51
+ export function collectFeatureView(featureDir, feature, { changes = null, archived = false } = {}) {
52
+ const fj = readJson(join(featureDir, 'feature.json')) ?? {};
53
+ const planText = readText(join(featureDir, '2-plan', 'plan.md'));
54
+ const plan = parsePlan(planText ?? '');
55
+ const recap = readJson(join(featureDir, 'recap.json'));
56
+ const reviews = reviewReportsFromDir(featureDir);
57
+ const progress = implementProgressFrom(readText(join(featureDir, '2-plan', 'checks.json')), readText(join(featureDir, '3-implement', 'report.md')));
58
+ const design = collectDesign(featureDir);
59
+ const hasDesign = design.length > 0 || fj.approvalGates?.uiux !== undefined || existsSync(join(featureDir, '1-design'));
60
+ const proved = new Map([
61
+ ...(recap?.scenarios ?? []).filter((c) => c.status).map((c) => [c.id, c]),
62
+ ...(reviews.verifier?.checks ?? []).map((c) => [c.id, c]),
63
+ ]);
64
+ const scenarios = plan.scenarios.map((c) => {
65
+ const row = progress?.checks.find((p) => p.id === c.id);
66
+ return { ...c, status: row?.status ?? 'pendente', evidence: row?.evidence ?? '', proved: proved.get(c.id)?.status ?? null, proof_note: proved.get(c.id)?.evidence ?? '' };
67
+ });
68
+ return {
69
+ feature,
70
+ description: fj.description ?? null,
71
+ workType: fj.workType ?? null,
72
+ plan: { ...plan, scenarios },
73
+ planHash: planText ? planHash(planText) : null,
74
+ gate: currentGate(fj),
75
+ gates: fj.approvalGates ?? {},
76
+ stations: stations({ gates: fj.approvalGates ?? {}, design: hasDesign, progress, reviews, archived: archived || fj.status === 'completed' }),
77
+ progress,
78
+ verification: fj.verification ?? null,
79
+ reviews,
80
+ critique: readText(join(featureDir, '2-plan', 'critica.md')),
81
+ design: archived ? [] : design,
82
+ changes,
83
+ recap,
84
+ };
85
+ }
86
+
87
+ /** Rewrites `feature.html` in place. Never throws: the page must not fail the command that refreshed it. */
88
+ export function writeFeatureView(featureDir, feature, { cwd = null, archived = false, changes } = {}) {
89
+ try {
90
+ const stat = changes !== undefined ? changes : (cwd && changeSummary(cwd, { statOnly: true }));
91
+ const path = join(featureDir, FEATURE_VIEW_FILE);
92
+ writeFileSync(path, renderFeatureHtml(collectFeatureView(featureDir, feature, { changes: stat ?? null, archived })));
93
+ return { ok: true, path };
94
+ } catch (err) {
95
+ return { ok: false, reason: err.message };
96
+ }
97
+ }
98
+
99
+ /** The page exists once the plan passed `plan-check`; before that there is nothing to decide on. */
100
+ export function refreshFeatureView(cwd, feature) {
101
+ const dir = join(cwd, '.morph', 'features', feature);
102
+ if (!existsSync(join(dir, '2-plan', 'checks.json'))) return null;
103
+ return writeFeatureView(dir, feature, { cwd });
104
+ }
@@ -0,0 +1,63 @@
1
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
2
+
3
+ const MIN_NODE_W = 132;
4
+ const CHAR_W = 7.4;
5
+ const NODE_H = 40;
6
+ const GAP = 84;
7
+ const LANE = 38;
8
+
9
+ /** States in the order a reader meets them: breadth-first from the first transition's origin. */
10
+ export function stateOrder(transitions) {
11
+ const order = [];
12
+ const seen = new Set();
13
+ const queue = transitions.length ? [transitions[0].from] : [];
14
+ const visit = (s) => { if (!seen.has(s)) { seen.add(s); queue.push(s); } };
15
+ if (queue.length) seen.add(queue[0]);
16
+ while (queue.length) {
17
+ const s = queue.shift();
18
+ order.push(s);
19
+ for (const t of transitions) if (t.from === s) visit(t.to);
20
+ }
21
+ for (const t of transitions) for (const s of [t.from, t.to]) if (!seen.has(s)) { seen.add(s); order.push(s); }
22
+ return order;
23
+ }
24
+
25
+ // One row of states; a forward transition arcs above, a backward one below, each in its own lane so
26
+ // labels never stack; a self-transition loops over its node.
27
+ export function stateMachineSvg(transitions, label = 'Máquina de estados', id = 'arrow') {
28
+ if (!transitions.length) return '';
29
+ const order = stateOrder(transitions);
30
+ const NODE_W = Math.max(MIN_NODE_W, Math.ceil(Math.max(...order.map((s) => s.length)) * CHAR_W) + 24);
31
+ const x = (s) => 24 + order.indexOf(s) * (NODE_W + GAP);
32
+ const span = (t) => Math.abs(order.indexOf(t.to) - order.indexOf(t.from));
33
+ const forward = transitions.filter((t) => order.indexOf(t.to) > order.indexOf(t.from)).sort((a, b) => span(a) - span(b));
34
+ const backward = transitions.filter((t) => order.indexOf(t.to) < order.indexOf(t.from)).sort((a, b) => span(a) - span(b));
35
+ const loops = transitions.filter((t) => t.to === t.from);
36
+ const top = 24 + (forward.length + (loops.length ? 1 : 0)) * LANE;
37
+ const height = top + NODE_H + 24 + backward.length * LANE;
38
+ const width = 48 + order.length * NODE_W + (order.length - 1) * GAP;
39
+ const mid = (s) => x(s) + NODE_W / 2;
40
+
41
+ const edge = (t, i, above) => {
42
+ const x1 = mid(t.from) + (above ? 18 : -18);
43
+ const x2 = mid(t.to) + (above ? -18 : 18);
44
+ const y0 = above ? top : top + NODE_H;
45
+ const y = above ? top - (i + 1) * LANE : top + NODE_H + (i + 1) * LANE;
46
+ const lx = (x1 + x2) / 2;
47
+ return `<path class="edge" d="M${x1} ${y0} C${x1} ${y} ${x2} ${y} ${x2} ${y0}" marker-end="url(#${id})"/>`
48
+ + `<text class="edge-label" x="${lx}" y="${y + (above ? 0 : 14)}" text-anchor="middle"><title>${esc(t.trigger)}</title>${esc(t.trigger)}</text>`;
49
+ };
50
+ const loop = (t) => {
51
+ const cx = mid(t.from);
52
+ return `<path class="edge" d="M${cx - 14} ${top} C${cx - 30} ${top - LANE} ${cx + 30} ${top - LANE} ${cx + 14} ${top}" marker-end="url(#${id})"/>`
53
+ + `<text class="edge-label" x="${cx}" y="${top - LANE + 4}" text-anchor="middle">${esc(t.trigger)}</text>`;
54
+ };
55
+
56
+ return `<svg class="machine" viewBox="0 0 ${width} ${height}" style="width:100%;min-width:${Math.round(width * 0.85)}px" role="img" aria-label="${esc(label)}: ${esc(order.join(', '))}">
57
+ <defs><marker id="${id}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" class="arrow-head"/></marker></defs>
58
+ ${forward.map((t, i) => edge(t, i, true)).join('\n')}
59
+ ${backward.map((t, i) => edge(t, i, false)).join('\n')}
60
+ ${loops.map(loop).join('\n')}
61
+ ${order.map((s) => `<g class="state"><rect x="${x(s)}" y="${top}" width="${NODE_W}" height="${NODE_H}" rx="10"/><text x="${mid(s)}" y="${top + NODE_H / 2 + 5}" text-anchor="middle">${esc(s)}</text></g>`).join('\n')}
62
+ </svg>`;
63
+ }
@@ -0,0 +1,18 @@
1
+ :root {
2
+ color-scheme: light dark;
3
+ --paper:#eef2f0; --panel:#fbfcfb; --ink:#12282b; --dim:#3f5558; --faint:#5a6e70; --rule:#cfdad7;
4
+ --route:#0b6e75; --sign:#ffc629; --on-sign:#1c1a0a;
5
+ --ok:#1f7a4d; --warn:#935500; --bad:#b4232f;
6
+ --s1:#2a78d6; --s2:#eb6834; --s3:#1baf7a; --s4:#eda100; --s5:#e87ba4;
7
+ --display:"Bahnschrift","DIN Alternate","DIN Next","Avenir Next",system-ui,sans-serif;
8
+ --text:"Segoe UI Variable Text","Segoe UI",-apple-system,"Helvetica Neue",system-ui,sans-serif;
9
+ --code:"Cascadia Code",ui-monospace,Menlo,Consolas,monospace;
10
+ }
11
+ @media (prefers-color-scheme: dark) {
12
+ :root {
13
+ --paper:#0e1c1e; --panel:#152729; --ink:#e6efee; --dim:#adc1c0; --faint:#8ba1a2; --rule:#2a4144;
14
+ --route:#45c4c9;
15
+ --ok:#5cc28a; --warn:#e2a54b; --bad:#f07178;
16
+ --s1:#3987e5; --s2:#d95926; --s3:#199e70; --s4:#c98500; --s5:#d55181;
17
+ }
18
+ }
@@ -1,125 +0,0 @@
1
- const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
2
-
3
- const NODE_W = 132;
4
- const NODE_H = 40;
5
- const GAP = 84;
6
- const LANE = 38;
7
-
8
- /** States in the order a reader meets them: breadth-first from the first transition's origin. */
9
- export function stateOrder(transitions) {
10
- const order = [];
11
- const seen = new Set();
12
- const queue = transitions.length ? [transitions[0].from] : [];
13
- const visit = (s) => { if (!seen.has(s)) { seen.add(s); queue.push(s); } };
14
- if (queue.length) seen.add(queue[0]);
15
- while (queue.length) {
16
- const s = queue.shift();
17
- order.push(s);
18
- for (const t of transitions) if (t.from === s) visit(t.to);
19
- }
20
- for (const t of transitions) for (const s of [t.from, t.to]) if (!seen.has(s)) { seen.add(s); order.push(s); }
21
- return order;
22
- }
23
-
24
- // One row of states; a forward transition arcs above, a backward one below, each in its own lane so
25
- // labels never stack; a self-transition loops over its node.
26
- export function stateMachineSvg(transitions) {
27
- if (!transitions.length) return '';
28
- const order = stateOrder(transitions);
29
- const x = (s) => 24 + order.indexOf(s) * (NODE_W + GAP);
30
- const span = (t) => Math.abs(order.indexOf(t.to) - order.indexOf(t.from));
31
- const forward = transitions.filter((t) => order.indexOf(t.to) > order.indexOf(t.from)).sort((a, b) => span(a) - span(b));
32
- const backward = transitions.filter((t) => order.indexOf(t.to) < order.indexOf(t.from)).sort((a, b) => span(a) - span(b));
33
- const loops = transitions.filter((t) => t.to === t.from);
34
- const top = 24 + (forward.length + (loops.length ? 1 : 0)) * LANE;
35
- const height = top + NODE_H + 24 + backward.length * LANE;
36
- const width = 48 + order.length * NODE_W + (order.length - 1) * GAP;
37
- const mid = (s) => x(s) + NODE_W / 2;
38
-
39
- const edge = (t, i, above) => {
40
- const x1 = mid(t.from) + (above ? 18 : -18);
41
- const x2 = mid(t.to) + (above ? -18 : 18);
42
- const y0 = above ? top : top + NODE_H;
43
- const y = above ? top - (i + 1) * LANE : top + NODE_H + (i + 1) * LANE;
44
- const lx = (x1 + x2) / 2;
45
- return `<path class="edge" d="M${x1} ${y0} C${x1} ${y} ${x2} ${y} ${x2} ${y0}" marker-end="url(#arrow)"/>`
46
- + `<text class="edge-label" x="${lx}" y="${y + (above ? 0 : 14)}" text-anchor="middle"><title>${esc(t.trigger)}</title>${esc(t.trigger)}</text>`;
47
- };
48
- const loop = (t) => {
49
- const cx = mid(t.from);
50
- return `<path class="edge" d="M${cx - 14} ${top} C${cx - 30} ${top - LANE} ${cx + 30} ${top - LANE} ${cx + 14} ${top}" marker-end="url(#arrow)"/>`
51
- + `<text class="edge-label" x="${cx}" y="${top - LANE + 4}" text-anchor="middle">${esc(t.trigger)}</text>`;
52
- };
53
-
54
- return `<svg class="machine" viewBox="0 0 ${width} ${height}" style="width:100%;min-width:${Math.round(width * 0.6)}px" role="img" aria-label="Máquina de estados: ${esc(order.join(', '))}">
55
- <defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" class="arrow-head"/></marker></defs>
56
- ${forward.map((t, i) => edge(t, i, true)).join('\n')}
57
- ${backward.map((t, i) => edge(t, i, false)).join('\n')}
58
- ${loops.map(loop).join('\n')}
59
- ${order.map((s) => `<g class="state"><rect x="${x(s)}" y="${top}" width="${NODE_W}" height="${NODE_H}" rx="10"/><text x="${mid(s)}" y="${top + NODE_H / 2 + 5}" text-anchor="middle">${esc(s)}</text></g>`).join('\n')}
60
- </svg>`;
61
- }
62
-
63
- const RUNG_TONE = { 'migrar': 'warn', 'irreversível': 'bad', 'irreversivel': 'bad' };
64
-
65
- function scenarioCard(c) {
66
- const chips = [
67
- c.proof && `<span class="chip">prova: ${esc(c.proof)}</span>`,
68
- c.risk && `<span class="chip">risco: ${esc(c.risk)}</span>`,
69
- c.rung && `<span class="chip ${RUNG_TONE[c.rung.toLowerCase()] ?? ''}">degrau: ${esc(c.rung)}</span>`,
70
- ].filter(Boolean).join('');
71
- return `<li class="card"><div class="cid">${esc(c.id)}</div><div><p>${esc(c.text)}</p>${chips ? `<div class="chips">${chips}</div>` : ''}</div></li>`;
72
- }
73
-
74
- export function renderPlanHtml(feature, plan, { planHash = null } = {}) {
75
- const defaults = plan.defaults?.items ?? [];
76
- const transitions = plan.states?.transitions ?? [];
77
- const section = (title, body) => `<section><h2>${esc(title)}</h2>${body}</section>`;
78
- return `<!doctype html>
79
- <html lang="pt-BR">
80
- <head>
81
- <meta charset="utf-8">
82
- <meta name="viewport" content="width=device-width, initial-scale=1">
83
- <title>Plano — ${esc(feature)}</title>
84
- <style>
85
- :root { --bg:#f6f7f9; --card:#fff; --fg:#14171c; --dim:#4b5260; --faint:#7a8291; --hair:#dfe3ea; --ok:#2f7d4f; --warn:#a35f00; --bad:#b4232f; --accent:#2d5bd7; }
86
- @media (prefers-color-scheme: dark) { :root { --bg:#0f1115; --card:#171a21; --fg:#e8ebf0; --dim:#b3bac6; --faint:#7f8796; --hair:#2a2f3a; --ok:#5cc28a; --warn:#e2a54b; --bad:#f07178; --accent:#8fb0ff; } }
87
- * { box-sizing:border-box; }
88
- body { margin:0; background:var(--bg); color:var(--fg); font:15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; }
89
- main { max-width:920px; margin:0 auto; padding:40px 16px 64px; }
90
- h1 { font-size:28px; margin:0 0 4px; letter-spacing:-.01em; }
91
- .meta { color:var(--faint); font-size:13px; margin:0 0 32px; }
92
- h2 { font-size:15px; margin:32px 0 12px; }
93
- ul.cards { list-style:none; padding:0; margin:0; display:grid; gap:10px; }
94
- .card { display:grid; grid-template-columns:3rem minmax(0,1fr); gap:12px; background:var(--card); border:1px solid var(--hair); border-radius:12px; padding:14px 16px; }
95
- .card p { margin:0; }
96
- .cid { font:600 13px/1.6 ui-monospace, monospace; color:var(--accent); }
97
- .chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
98
- .chip { font-size:12px; color:var(--dim); border:1px solid var(--hair); border-radius:999px; padding:1px 9px; }
99
- .chip.warn { color:var(--warn); border-color:currentColor; }
100
- .chip.bad { color:var(--bad); border-color:currentColor; }
101
- ol.defaults { margin:0; padding-left:22px; color:var(--dim); }
102
- ol.defaults li { margin:4px 0; }
103
- .scroll { overflow-x:auto; background:var(--card); border:1px solid var(--hair); border-radius:12px; padding:12px; }
104
- .machine .state rect { fill:var(--bg); stroke:var(--accent); stroke-width:1.5; }
105
- .machine .state text { fill:var(--fg); font:600 13px system-ui, sans-serif; }
106
- .machine .edge { fill:none; stroke:var(--faint); stroke-width:1.5; }
107
- .machine .arrow-head { fill:var(--faint); }
108
- .machine .edge-label { fill:var(--dim); font:12px system-ui, sans-serif; paint-order:stroke; stroke:var(--card); stroke-width:4px; }
109
- .none { color:var(--faint); }
110
- </style>
111
- </head>
112
- <body>
113
- <main>
114
- <h1>${esc(feature)}</h1>
115
- <p class="meta">Visão do plano, só leitura — gerada pelo <code>morph-spec plan-check</code> a partir de 2-plan/plan.md${planHash ? ` (${esc(planHash.slice(0, 12))})` : ''}. Para mudar, edite o plan.md.</p>
116
- ${section(`Cenários (${plan.scenarios.length})`, `<ul class="cards">${plan.scenarios.map(scenarioCard).join('')}</ul>`)}
117
- ${transitions.length ? section('Estados', `<div class="scroll">${stateMachineSvg(transitions)}</div>`) : ''}
118
- ${section('Padrões assumidos', defaults.length
119
- ? `<ol class="defaults">${defaults.map((d) => `<li value="${d.n}">${esc(d.text)}</li>`).join('')}</ol><p class="meta">Para vetar um, responda "veta N" no gate.</p>`
120
- : '<p class="none">Nenhum.</p>')}
121
- </main>
122
- </body>
123
- </html>
124
- `;
125
- }