devmethod-ai 0.2.0 → 0.3.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.
Files changed (76) hide show
  1. package/.agents/skills/decision-architecture/SKILL.md +10 -0
  2. package/.agents/skills/decision-architecture/assets/ADR.md +6 -3
  3. package/.agents/skills/project-foundation/SKILL.md +2 -0
  4. package/.agents/skills/project-foundation/assets/CADRAGE.md +11 -0
  5. package/.agents/skills/project-foundation/assets/EXISTANT.md +14 -0
  6. package/.agents/skills/project-foundation/assets/OPPORTUNITES.md +10 -0
  7. package/.agents/skills/project-foundation/assets/PROJECT_PROFILE.md +3 -2
  8. package/.agents/skills/project-foundation/assets/REGLES.md +6 -0
  9. package/.agents/skills/project-foundation/assets/START_HERE.md +2 -0
  10. package/.agents/skills/project-foundation/references/delivery-planning.md +11 -0
  11. package/.agents/skills/project-foundation/references/exploration.md +11 -0
  12. package/.agents/skills/project-foundation/references/mission-context.md +31 -4
  13. package/.agents/skills/project-foundation/references/operating-commands.md +12 -4
  14. package/.agents/skills/project-foundation/references/work-sizing.md +1 -1
  15. package/.agents/skills/scoped-delivery/SKILL.md +5 -1
  16. package/.agents/skills/scoped-delivery/assets/MISSION.md +1 -1
  17. package/.agents/skills/scoped-delivery/assets/PLAN.md +20 -0
  18. package/.agents/skills/scoped-delivery/assets/REPRISE.md +10 -0
  19. package/.agents/skills/scoped-delivery/assets/REVIEW.md +34 -0
  20. package/.agents/skills/scoped-delivery/assets/SLICE.md +5 -0
  21. package/.agents/skills/scoped-delivery/assets/TICKET.md +21 -0
  22. package/.agents/skills/scoped-delivery/references/review-workflow.md +29 -0
  23. package/COMPATIBILITY.md +1 -1
  24. package/README.md +35 -14
  25. package/START_HERE.md +2 -0
  26. package/dist/cli.js +122 -90
  27. package/dist/review-app.js +462 -0
  28. package/dist/review-browser.js +567 -0
  29. package/dist/review-cli.js +68 -0
  30. package/dist/review-model.js +101 -0
  31. package/dist/review-open.js +19 -0
  32. package/dist/review-ui.css +782 -0
  33. package/dist/review.js +13 -0
  34. package/docs/ADR-007-conversation-and-mission-ownership.md +13 -0
  35. package/docs/ADR-008-review-presentation.md +13 -0
  36. package/docs/MISSIONS.md +1 -1
  37. package/docs/RELEASE-0.2.0.md +14 -3
  38. package/docs/RELEASE-0.3.0.md +24 -0
  39. package/docs/RELEASE-0.3.1.md +15 -0
  40. package/docs/REVIEW-GUIDE.md +68 -0
  41. package/docs/REVIEW-SOURCES.md +13 -0
  42. package/docs/REVIEW-VALIDATION.md +34 -0
  43. package/docs/REVIEWS.md +75 -0
  44. package/docs/VISUAL-WORKFLOW.md +1 -1
  45. package/docs/WORKFLOW-0.3-VALIDATION.md +32 -0
  46. package/docs/WORKFLOW-0.3.md +34 -0
  47. package/docs/images/devmethod-delivery.svg +1 -1
  48. package/docs/images/devmethod-flow.svg +1 -1
  49. package/docs/images/review-correction.jpg +0 -0
  50. package/docs/images/review-coverage.jpg +0 -0
  51. package/docs/images/review-interface-desktop.jpg +0 -0
  52. package/docs/images/review-interface-mobile.jpg +0 -0
  53. package/docs/media/review-extension/README.md +21 -0
  54. package/docs/media/review-extension/scenes.json +70 -0
  55. package/docs/media/visual-chain/README.md +8 -2
  56. package/docs/media/visual-chain/devmethod-du-besoin-au-produit.fr.srt +65 -1
  57. package/docs/media/visual-chain/video-preview.jpg +0 -0
  58. package/docs/missions/review-media-0.3.1.md +11 -0
  59. package/docs/missions/workflow-0.3-reviews/interface/REVIEW.md +109 -0
  60. package/docs/missions/workflow-0.3-reviews/interface/review.json +255 -0
  61. package/docs/missions/workflow-0.3.md +19 -0
  62. package/examples/mission-dialogue/PROJECT_PROFILE.md +9 -0
  63. package/examples/mission-dialogue/architecture/decisions/001-storage.md +7 -0
  64. package/examples/mission-dialogue/docs/missions/first-save/PLAN.md +13 -0
  65. package/examples/mission-dialogue/docs/missions/first-save/REPRISE.md +6 -0
  66. package/examples/mission-dialogue/docs/missions/first-save/tickets/SAVE-1.md +14 -0
  67. package/examples/mission-dialogue/docs/missions/legacy-copy.md +9 -0
  68. package/examples/mission-dialogue/docs/produit/REGLES.md +5 -0
  69. package/examples/review/README.md +14 -0
  70. package/examples/review/REVIEW.md +98 -0
  71. package/examples/review/review-demo.html +1351 -0
  72. package/examples/review/review.json +199 -0
  73. package/package.json +2 -2
  74. package/scripts/build-review.mjs +8 -0
  75. package/scripts/media/review-extension/extend.py +100 -0
  76. package/scripts/package-smoke.mjs +20 -5
@@ -0,0 +1,462 @@
1
+ import { sanitizedReview, summarizeReview, severityLabels, confidenceLabels, resolutionLabels, checkLabels, conclusionLabels, filterFindings, reviewFreshness, reviewMarkdown, reviewUrl, redactReviewText } from './review-model.js';
2
+ const app = document.getElementById('app');
3
+ const data = document.getElementById('review-data');
4
+ let review = null;
5
+ let legacy = null;
6
+ let currentRevision = null;
7
+ let changedTargets = [];
8
+ let state = { view: 'findings', selected: '', section: 'evidence', filters: { query: '', domain: '', severity: '', confidence: '', resolution: '' }, mobileDetail: false };
9
+ const el = (tag, cls = '', text) => {
10
+ const n = document.createElement(tag);
11
+ if (cls)
12
+ n.className = cls;
13
+ if (text !== undefined)
14
+ n.textContent = text;
15
+ return n;
16
+ };
17
+ function button(label, cls, action) { const b = el('button', cls, label); b.type = 'button'; b.addEventListener('click', action); return b; }
18
+ function textBlock(title, value, parent) { const box = el('section', 'text-block'); box.append(el('h3', '', title), el('p', '', value)); parent.append(box); }
19
+ function badge(label, kind, icon) { return el('span', `badge ${kind}`, `${icon} ${label}`); }
20
+ function link(label, url, parent, cls = 'link') {
21
+ if (!reviewUrl(url)) {
22
+ parent.append(el('p', 'muted unavailable', 'Destination indisponible : aucun lien HTTPS valide renseigné.'));
23
+ return;
24
+ }
25
+ const a = el('a', cls, `${label} ↗`);
26
+ a.href = url;
27
+ a.target = '_blank';
28
+ a.rel = 'noopener noreferrer';
29
+ parent.append(a);
30
+ }
31
+ function download(name, body, type) {
32
+ const url = URL.createObjectURL(new Blob([body], { type }));
33
+ const a = el('a');
34
+ a.href = url;
35
+ a.download = name;
36
+ document.body.append(a);
37
+ a.click();
38
+ a.remove();
39
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
40
+ }
41
+ const safeJSON = (value) => JSON.stringify(value).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
42
+ function exportHtml() {
43
+ const copy = document.documentElement.cloneNode(true);
44
+ copy.querySelector('#review-data').textContent = safeJSON({ review, legacy, currentRevision, changedTargets, uiState: state });
45
+ // Rebuild the presentation on reopening from the same validated result source.
46
+ copy.querySelector('#app').replaceChildren();
47
+ download(`${review?.id || 'review'}.html`, '<!doctype html>\n' + copy.outerHTML, 'text/html;charset=utf-8');
48
+ }
49
+ function exports(parent) {
50
+ const box = el('details', 'export');
51
+ const summary = el('summary', 'button secondary', '↓ Exporter le rapport');
52
+ box.append(summary);
53
+ const menu = el('div', 'export-menu');
54
+ menu.append(button('HTML interactif · rouvrir hors ligne', '', () => { box.open = false; exportHtml(); }));
55
+ if (review) {
56
+ menu.append(button('Rapport Markdown', '', () => { box.open = false; download(`${review.id}.md`, reviewMarkdown(review), 'text/markdown;charset=utf-8'); }), button('Source JSON', '', () => { box.open = false; download(`${review.id}.json`, JSON.stringify(review, null, 2) + '\n', 'application/json'); }));
57
+ }
58
+ else if (legacy !== null)
59
+ menu.append(button('Rapport Markdown original', '', () => download('review-legacy.md', legacy, 'text/markdown;charset=utf-8')));
60
+ box.append(menu);
61
+ parent.append(box);
62
+ }
63
+ function importer(parent) {
64
+ const label = el('label', 'button import', '+ Ouvrir une review');
65
+ const input = el('input');
66
+ input.type = 'file';
67
+ input.accept = '.json,.md';
68
+ input.setAttribute('aria-label', 'Ouvrir une review JSON ou Markdown');
69
+ label.append(input);
70
+ parent.append(label);
71
+ input.addEventListener('change', async () => {
72
+ const file = input.files?.[0];
73
+ if (!file)
74
+ return;
75
+ if (file.size > 4 * 1024 * 1024) {
76
+ showError('Le fichier dépasse la limite de 4 Mio.');
77
+ return;
78
+ }
79
+ app.replaceChildren(el('p', 'loading', 'Chargement de la review…'));
80
+ app.setAttribute('aria-busy', 'true');
81
+ try {
82
+ const content = await file.text();
83
+ if (file.name.toLowerCase().endsWith('.md')) {
84
+ legacy = redactReviewText(content);
85
+ review = null;
86
+ }
87
+ else {
88
+ review = sanitizedReview(JSON.parse(content));
89
+ legacy = null;
90
+ }
91
+ currentRevision = null;
92
+ changedTargets = [];
93
+ state = { view: 'findings', selected: '', section: 'evidence', filters: { query: '', domain: '', severity: '', confidence: '', resolution: '' }, mobileDetail: false };
94
+ location.hash = '';
95
+ render();
96
+ }
97
+ catch {
98
+ review = null;
99
+ legacy = null;
100
+ showError('Résultats invalides ou version non supportée. Utilisez un document review format 1 ; aucune donnée du fichier n’a été exécutée.');
101
+ }
102
+ finally {
103
+ app.removeAttribute('aria-busy');
104
+ }
105
+ });
106
+ }
107
+ function shell() {
108
+ app.replaceChildren();
109
+ const layout = el('div', 'layout');
110
+ const aside = el('aside', 'sidebar');
111
+ aside.append(el('div', 'brand', '⬡ DevMethod'), el('p', 'eyebrow', 'PROJET'), el('strong', 'project', review?.project || 'Consultation locale'));
112
+ const nav = el('nav', 'side-nav');
113
+ nav.setAttribute('aria-label', 'Navigation de review');
114
+ nav.append(el('div', 'side-active', '▤ Review'));
115
+ aside.append(nav);
116
+ importer(aside);
117
+ const note = el('div', 'side-note');
118
+ note.append(el('strong', '', 'Vos données restent locales'), el('p', '', 'Aucune commande exécutée. Aucune ressource distante chargée à l’ouverture.'));
119
+ aside.append(note);
120
+ const main = el('main', 'main');
121
+ const header = el('header', 'page-header');
122
+ const title = el('div');
123
+ title.append(el('h1', '', review?.title || (legacy !== null ? 'Rapport historique' : 'Espace de review')));
124
+ if (review) {
125
+ title.append(el('p', 'subtitle', `${review.project} / ${review.mission} · ${review.tickets.map(t => t.id).join(', ') || 'Sans ticket'}`), el('p', 'revision', `Révision ${review.revision.commit} · ${review.date}${review.revision.dirty.length ? ` · Modifications non commitées : ${review.revision.dirty.join(', ')}` : ''}`));
126
+ }
127
+ header.append(title);
128
+ if (review || legacy !== null)
129
+ exports(header);
130
+ main.append(header);
131
+ layout.append(aside, main);
132
+ app.append(layout);
133
+ return main;
134
+ }
135
+ function showError(message) { const main = shell(); const box = el('section', 'empty error'); box.setAttribute('role', 'alert'); box.append(el('h2', '', 'Review indisponible'), el('p', '', message)); main.append(box); }
136
+ function tabs(items, active, change, label, panelId) {
137
+ const group = el('div', 'tabs');
138
+ group.setAttribute('role', 'tablist');
139
+ group.setAttribute('aria-label', label);
140
+ for (const [id, title] of items) {
141
+ const b = button(title, active === id ? 'tab active' : 'tab', () => { change(id); document.getElementById(`${panelId}-${id}`)?.focus(); });
142
+ b.id = `${panelId}-${id}`;
143
+ b.setAttribute('role', 'tab');
144
+ b.setAttribute('aria-selected', String(active === id));
145
+ b.setAttribute('aria-controls', panelId);
146
+ b.tabIndex = active === id ? 0 : -1;
147
+ b.addEventListener('keydown', e => { const index = items.findIndex(i => i[0] === id); let next = null; if (e.key === 'ArrowRight')
148
+ next = (index + 1) % items.length; if (e.key === 'ArrowLeft')
149
+ next = (index + items.length - 1) % items.length; if (e.key === 'Home')
150
+ next = 0; if (e.key === 'End')
151
+ next = items.length - 1; if (next !== null) {
152
+ e.preventDefault();
153
+ change(items[next][0]);
154
+ document.getElementById(`${panelId}-${items[next][0]}`)?.focus();
155
+ } });
156
+ group.append(b);
157
+ }
158
+ return group;
159
+ }
160
+ function render() {
161
+ const main = shell();
162
+ if (!review) {
163
+ if (legacy !== null) {
164
+ main.append(el('div', 'banner incomplete', 'ⓘ Rapport Markdown historique — champs structurés et couverture inconnus. Aucune conversion implicite.'), el('pre', 'legacy', legacy));
165
+ }
166
+ else {
167
+ const box = el('section', 'empty');
168
+ box.append(el('span', 'empty-icon', '▤'), el('h2', '', 'Aucune review disponible'), el('p', '', 'Ouvrez un fichier review.json validé ou un ancien rapport Markdown pour commencer.'));
169
+ importer(box);
170
+ main.append(box);
171
+ }
172
+ return;
173
+ }
174
+ const r = review, sum = summarizeReview(r), fresh = reviewFreshness(r, currentRevision, changedTargets);
175
+ const banner = el('section', `banner ${sum.conclusion}`);
176
+ banner.append(el('span', 'banner-icon', sum.conclusion === 'ready' ? '✓' : sum.conclusion === 'corrections' ? '⚠' : 'ⓘ'));
177
+ const message = el('div');
178
+ message.append(el('h2', '', conclusionLabels[sum.conclusion]), el('p', '', r.summary), el('p', 'policy', r.policy.rationale));
179
+ banner.append(message);
180
+ main.append(banner);
181
+ if (fresh.state === 'different')
182
+ main.append(el('p', 'notice', `↻ Révision différente ou cibles modifiées : réévaluer les éléments concernés. Contrôles ciblés : ${fresh.affectedChecks.join(', ') || 'à déterminer'}. Constats ciblés : ${fresh.affectedFindings.join(', ') || 'à déterminer'}. Les preuves historiques et résultats indépendants sont conservés.`));
183
+ else if (fresh.state === 'unknown')
184
+ main.append(el('p', 'revision-hint', 'Historique : la révision courante n’est pas fournie. Cette review atteste uniquement de sa révision inspectée.'));
185
+ const stats = el('section', 'stats');
186
+ stats.setAttribute('aria-label', 'Compteurs de toute la review, indépendants des filtres');
187
+ for (const severity of ['critical', 'major', 'moderate', 'minor']) {
188
+ const card = el('div', `stat ${severity}`);
189
+ card.append(el('span', 'stat-icon', severity === 'critical' || severity === 'major' ? '!' : '△'), el('strong', '', String(sum.severities[severity])), el('span', '', severityLabels[severity]));
190
+ stats.append(card);
191
+ }
192
+ const uncertain = el('div', 'stat suspected');
193
+ uncertain.append(el('span', 'stat-icon', '?'), el('strong', '', String(sum.suspected)), el('span', '', 'À vérifier'));
194
+ stats.append(uncertain);
195
+ const coverage = el('div', 'stat coverage-stat');
196
+ coverage.append(el('strong', '', `${sum.checks.passed} réussis · ${sum.checks.failed} en échec`), el('span', '', `${sum.checks['not-run']} non exécutés · ${sum.checks.blocked} bloqués · ${sum.checks['out-of-scope']} hors périmètre`));
197
+ const bars = el('div', 'coverage-bars');
198
+ bars.setAttribute('aria-hidden', 'true');
199
+ const total = r.checks.length || 1;
200
+ for (const [status, count] of Object.entries(sum.checks)) {
201
+ const bar = el('span', status);
202
+ bar.style.flexGrow = String(count / total);
203
+ if (count)
204
+ bars.append(bar);
205
+ }
206
+ coverage.append(bars);
207
+ stats.append(coverage);
208
+ main.append(stats, el('p', 'count-caption', 'Compteurs : toute la review, y compris les constats résolus. Les risques à vérifier sont indiqués séparément.'));
209
+ if (r.limits.length || r.exclusions.length) {
210
+ const limits = el('details', 'limits');
211
+ limits.append(el('summary', '', `Limites & périmètre · ${r.limits.length} limite(s) signalée(s)`));
212
+ const ul = el('ul');
213
+ for (const t of [...r.scope.map(t => `Inclus : ${t}`), ...r.exclusions.map(t => `Exclu : ${t}`), ...r.limits])
214
+ ul.append(el('li', '', t));
215
+ limits.append(ul);
216
+ main.append(limits);
217
+ if (r.limits.length)
218
+ main.append(el('p', 'important-limit', r.limits[0]));
219
+ }
220
+ const workspace = el('section', `workspace ${state.mobileDetail ? 'show-detail' : ''}`);
221
+ const left = el('div', 'list-panel');
222
+ left.append(tabs([['findings', `Constats ${r.findings.length}`], ['coverage', 'Couverture'], ['sources', 'Sources']], state.view, id => { state.view = id; state.mobileDetail = false; render(); }, 'Vues de review', 'view-panel'));
223
+ const panel = el('div', 'view-panel');
224
+ panel.id = 'view-panel';
225
+ panel.setAttribute('role', 'tabpanel');
226
+ panel.setAttribute('aria-labelledby', `view-panel-${state.view}`);
227
+ if (state.view === 'findings')
228
+ renderFindingList(panel);
229
+ if (state.view === 'coverage')
230
+ renderCoverage(panel);
231
+ if (state.view === 'sources')
232
+ renderSources(panel);
233
+ left.append(panel);
234
+ const detail = el('article', 'detail-panel');
235
+ detail.id = 'finding-detail';
236
+ renderDetail(detail);
237
+ workspace.append(left, detail);
238
+ main.append(workspace);
239
+ }
240
+ function renderFindingList(parent) {
241
+ const filters = el('div', 'filters');
242
+ const searchLabel = el('label', 'search-label', 'Rechercher un constat');
243
+ const search = el('input', 'search');
244
+ search.type = 'search';
245
+ search.placeholder = 'Titre, identifiant, fichier…';
246
+ search.value = state.filters.query;
247
+ search.id = 'finding-search';
248
+ searchLabel.append(search);
249
+ filters.append(searchLabel);
250
+ const select = (key, label, values) => { const l = el('label', '', label); const node = el('select'); node.setAttribute('aria-label', label); for (const [value, name] of [['', 'Tous'], ...values]) {
251
+ const option = el('option', '', name);
252
+ option.value = value;
253
+ node.append(option);
254
+ } node.value = state.filters[key]; node.addEventListener('change', () => { state.filters[key] = node.value; renderListOnly(); }); l.append(node); filters.append(l); };
255
+ select('domain', 'Domaine', [...new Set(review.findings.map(f => f.domain))].sort().map(v => [v, v]));
256
+ select('severity', 'Gravité', Object.entries(severityLabels));
257
+ select('confidence', 'Confiance', Object.entries(confidenceLabels));
258
+ select('resolution', 'Résolution', Object.entries(resolutionLabels));
259
+ search.addEventListener('input', () => { state.filters.query = search.value; renderListOnly(); });
260
+ parent.append(filters);
261
+ const results = el('div', 'finding-results');
262
+ results.id = 'finding-results';
263
+ parent.append(results);
264
+ renderListOnly();
265
+ // The parent is not yet attached during a complete render.
266
+ fillFindingResults(results);
267
+ }
268
+ function renderListOnly() { const list = document.getElementById('finding-results'); if (list)
269
+ fillFindingResults(list); const note = document.getElementById('detail-filter-note'); if (note && review)
270
+ note.hidden = filterFindings(review, state.filters).some(f => f.id === state.selected); }
271
+ function fillFindingResults(parent) {
272
+ parent.replaceChildren();
273
+ const findings = filterFindings(review, state.filters);
274
+ const count = el('p', 'result-count', `${findings.length} constat(s) affiché(s) sur ${review.findings.length}`);
275
+ count.setAttribute('role', 'status');
276
+ parent.append(count);
277
+ if (!findings.length) {
278
+ parent.append(el('div', 'empty small', review.findings.length ? 'Aucun résultat ne correspond aux filtres.' : 'Aucun problème détecté. Consultez la couverture et les limites avant de conclure.'));
279
+ return;
280
+ }
281
+ for (const f of findings) {
282
+ const b = button('', `finding ${state.selected === f.id ? 'selected' : ''}`, () => { state.selected = f.id; state.mobileDetail = true; state.section = 'evidence'; history.pushState(null, '', `#finding=${encodeURIComponent(f.id)}`); render(); document.getElementById('detail-title')?.focus(); });
283
+ b.setAttribute('aria-label', `${f.id} ${f.title}, ${severityLabels[f.severity]}, ${confidenceLabels[f.confidence]}, ${resolutionLabels[f.resolution]}`);
284
+ b.setAttribute('aria-current', String(state.selected === f.id));
285
+ const top = el('div', 'finding-top');
286
+ top.append(el('strong', 'finding-id', f.id), badge(confidenceLabels[f.confidence], f.confidence, f.confidence === 'confirmed' ? '✓' : '?'));
287
+ b.append(top, el('h3', '', f.title));
288
+ const meta = el('div', 'finding-meta');
289
+ meta.append(badge(severityLabels[f.severity], f.severity, '!'), el('span', '', f.domain), el('span', 'resolution', resolutionLabels[f.resolution]));
290
+ b.append(meta);
291
+ parent.append(b);
292
+ }
293
+ }
294
+ function renderCoverage(parent) {
295
+ parent.append(el('h2', 'panel-title', 'Couverture par domaine'), el('p', 'muted', 'Contrôles exécutés, non exécutés et hors périmètre. Aucun score de risque.'));
296
+ if (!review.checks.length)
297
+ parent.append(el('p', 'empty small', 'Aucun contrôle enregistré : couverture inconnue.'));
298
+ for (const domain of [...new Set(review.checks.map(c => c.domain))]) {
299
+ parent.append(el('h3', 'domain-title', domain));
300
+ for (const c of review.checks.filter(c => c.domain === domain)) {
301
+ const card = el('section', 'check-card');
302
+ card.append(badge(checkLabels[c.status], c.status, c.status === 'passed' ? '✓' : c.status === 'failed' ? '✕' : '—'), el('h4', '', `${c.id} · ${c.title}`), el('p', '', c.result), el('p', 'muted', `${c.kind === 'manual' ? 'Inspection manuelle' : 'Contrôle automatisé'} · Révision ${c.revision}`));
303
+ if (c.reason)
304
+ card.append(el('p', '', `Raison : ${c.reason}`));
305
+ for (const id of c.evidenceIds) {
306
+ const e = review.evidence.find(e => e.id === id);
307
+ const d = el('details');
308
+ d.append(el('summary', '', `Preuve ${id} · ${e.title}`), el('pre', '', e.content));
309
+ card.append(d);
310
+ }
311
+ if (!c.evidenceIds.length)
312
+ card.append(el('p', 'muted', 'Aucune preuve jointe.'));
313
+ parent.append(card);
314
+ }
315
+ }
316
+ }
317
+ function sourceCard(source) {
318
+ const card = el('section', 'source-card');
319
+ card.append(el('h3', '', source.title), el('p', 'source-meta', `${source.publisher} · ${source.technology} ${source.version}`), badge(source.access === 'consulted' ? 'Consultée' : source.access === 'unavailable' ? 'Inaccessible' : 'Non vérifiée', source.access === 'consulted' ? 'confirmed' : 'suspected', source.access === 'consulted' ? '✓' : '?'), el('p', 'muted', `Consultation : ${source.consultedAt || 'non enregistrée'} · ${source.kind}`));
320
+ textBlock('Usage', source.usage, card);
321
+ textBlock('Compatibilité & limites', source.compatibility, card);
322
+ textBlock('Provenance', source.provenance, card);
323
+ link('Ouvrir la référence', source.url, card);
324
+ return card;
325
+ }
326
+ function renderSources(parent) {
327
+ parent.append(el('h2', 'panel-title', 'Sources & technologies'));
328
+ for (const t of review.technologies)
329
+ parent.append(el('p', 'technology', `${t.name} ${t.version} — ${t.detectedFrom}`));
330
+ if (!review.sources.length)
331
+ parent.append(el('p', 'empty small', 'Aucune source consultée enregistrée.'));
332
+ for (const source of review.sources)
333
+ parent.append(sourceCard(source));
334
+ }
335
+ function renderDetail(parent) {
336
+ const back = button('← Retour aux résultats', 'back-button', () => { state.mobileDetail = false; render(); (document.querySelector('.finding.selected') || document.getElementById('view-panel-findings'))?.focus(); });
337
+ parent.append(back);
338
+ const f = review.findings.find(f => f.id === state.selected);
339
+ if (!f) {
340
+ parent.append(el('div', 'empty detail-empty', state.selected ? 'Ce constat est introuvable dans cette review. Revenez à la liste pour en sélectionner un autre.' : 'Sélectionnez un constat pour consulter ses preuves, sa correction et ses références.'));
341
+ return;
342
+ }
343
+ const filterNote = el('p', 'notice', 'Ce constat est hors de la sélection filtrée. Son détail reste disponible.');
344
+ filterNote.id = 'detail-filter-note';
345
+ filterNote.hidden = filterFindings(review, state.filters).some(item => item.id === f.id);
346
+ parent.append(filterNote);
347
+ const title = el('h2', 'detail-title', `${f.id} · ${f.title}`);
348
+ title.id = 'detail-title';
349
+ title.tabIndex = -1;
350
+ parent.append(title);
351
+ const badges = el('div', 'detail-badges');
352
+ badges.append(badge(severityLabels[f.severity], f.severity, '!'), badge(confidenceLabels[f.confidence], f.confidence, f.confidence === 'confirmed' ? '✓' : '?'), badge(resolutionLabels[f.resolution], 'neutral', '◌'));
353
+ parent.append(badges, el('p', 'impact', f.impact), el('p', 'location', `${f.location.path}${f.location.line ? `:${f.location.line}` : ''}${f.location.component ? ` · ${f.location.component}` : ''}`));
354
+ parent.append(tabs([['evidence', 'Preuve'], ['correction', 'Correction'], ['references', 'Références']], state.section, id => { state.section = id; render(); }, 'Détail du constat', 'detail-content'));
355
+ const content = el('div', 'detail-content');
356
+ content.id = 'detail-content';
357
+ content.setAttribute('role', 'tabpanel');
358
+ content.setAttribute('aria-labelledby', `detail-content-${state.section}`);
359
+ if (state.section === 'evidence')
360
+ evidenceDetail(f, content);
361
+ if (state.section === 'correction') {
362
+ textBlock('Correction recommandée', f.correction, content);
363
+ textBlock('Conséquences & compromis', f.tradeoffs, content);
364
+ textBlock('Vérification après correction', f.verification, content);
365
+ textBlock('État de résolution', `${resolutionLabels[f.resolution]} · Preuves de résolution : ${f.resolutionEvidenceIds.join(', ') || 'aucune'}`, content);
366
+ ticketLinks(f, content);
367
+ }
368
+ if (state.section === 'references') {
369
+ textBlock('Justification de la gravité', f.severityReason, content);
370
+ if (!f.sourceIds.length)
371
+ content.append(el('p', 'empty small', 'Aucune référence pertinente renseignée.'));
372
+ for (const id of f.sourceIds)
373
+ content.append(sourceCard(review.sources.find(s => s.id === id)));
374
+ }
375
+ parent.append(content);
376
+ const actions = el('footer', 'detail-actions');
377
+ actions.append(button('↗ Lien vers ce constat', 'button secondary', () => { const url = new URL(location.href); url.hash = `finding=${encodeURIComponent(f.id)}`; location.hash = url.hash; const destination = el('input'); destination.readOnly = true; destination.value = url.href; destination.setAttribute('aria-label', 'Lien local vers le constat'); actions.querySelector('input')?.remove(); actions.append(destination); destination.focus(); destination.select(); }));
378
+ if (f.evidenceIds.length)
379
+ actions.append(button('▤ Voir la preuve', 'button primary', () => { state.section = 'evidence'; render(); document.getElementById('evidence-full')?.scrollIntoView({ block: 'nearest' }); document.getElementById('evidence-full')?.focus(); }));
380
+ parent.append(actions);
381
+ }
382
+ function ticketLinks(f, parent) {
383
+ if (!f.ticketIds.length)
384
+ parent.append(el('p', 'muted', 'Aucun ticket associé.'));
385
+ for (const id of f.ticketIds) {
386
+ const t = review.tickets.find(t => t.id === id);
387
+ parent.append(el('p', '', `${t.id} · ${t.title}`));
388
+ link('Ouvrir le ticket', t.url, parent, 'button primary');
389
+ }
390
+ }
391
+ function evidenceDetail(f, parent) {
392
+ textBlock('Scénario déclencheur', f.trigger, parent);
393
+ const compare = el('div', 'compare');
394
+ textBlock('Attendu', f.expected, compare);
395
+ textBlock('Observé', f.observed, compare);
396
+ parent.append(compare);
397
+ const steps = el('ol', 'reproduction');
398
+ for (const step of f.reproduction)
399
+ steps.append(el('li', '', step));
400
+ parent.append(steps);
401
+ if (!f.evidenceIds.length)
402
+ parent.append(el('p', 'notice', 'Preuve non disponible : ce constat repose sur les étapes et limites décrites ci-dessus.'));
403
+ for (const [index, id] of f.evidenceIds.entries()) {
404
+ const e = review.evidence.find(e => e.id === id);
405
+ const card = el('section', 'evidence-card');
406
+ if (index === 0) {
407
+ card.id = 'evidence-full';
408
+ card.tabIndex = -1;
409
+ }
410
+ card.append(el('h3', '', e.title));
411
+ if (e.kind === 'diagram')
412
+ card.append(el('span', 'eyebrow', 'SCHÉMA EXPLICATIF · PAS UNE CAPTURE D’EXÉCUTION'));
413
+ if (e.image) {
414
+ const img = el('img', 'evidence-image');
415
+ img.src = `data:${e.image.mime};base64,${e.image.base64}`;
416
+ img.alt = e.image.alt;
417
+ card.append(el('p', 'muted', e.image.origin === 'captured' ? 'Capture déclarée réelle par l’auteur · confidentialité relue' : 'Illustration explicative · pas une preuve d’exécution'), img);
418
+ img.addEventListener('error', () => { img.replaceWith(el('p', 'notice', `Image indisponible. Alternative : ${e.image.alt}`)); });
419
+ }
420
+ card.append(el('pre', e.kind === 'diagram' ? 'diagram' : '', e.content));
421
+ if (e.url)
422
+ link('Ouvrir la preuve complète', e.url, card);
423
+ else
424
+ card.append(el('p', 'muted', 'Preuve incluse dans ce rapport ; aucun lien externe renseigné.'));
425
+ parent.append(card);
426
+ }
427
+ const correction = el('div', 'correction-preview');
428
+ correction.append(el('strong', '', '⌁ Correction proposée'), el('p', '', f.correction));
429
+ parent.append(correction);
430
+ }
431
+ function hashSelection() {
432
+ const id = new URLSearchParams(location.hash.slice(1)).get('finding');
433
+ if (id) {
434
+ state.selected = id;
435
+ state.view = 'findings';
436
+ state.mobileDetail = true;
437
+ }
438
+ }
439
+ window.addEventListener('hashchange', () => { hashSelection(); render(); });
440
+ window.addEventListener('popstate', () => { hashSelection(); render(); });
441
+ try {
442
+ const payload = JSON.parse(data.textContent || '{}');
443
+ if (payload.review != null)
444
+ review = sanitizedReview(payload.review);
445
+ if (typeof payload.legacy === 'string')
446
+ legacy = redactReviewText(payload.legacy);
447
+ currentRevision = typeof payload.currentRevision === 'string' ? payload.currentRevision : null;
448
+ changedTargets = Array.isArray(payload.changedTargets) && payload.changedTargets.every(v => typeof v === 'string') ? payload.changedTargets : [];
449
+ // Saved UI state is only a convenience; validate before using it in selectors/rendering.
450
+ const saved = payload.uiState;
451
+ if (saved && ['findings', 'coverage', 'sources'].includes(saved.view) && ['evidence', 'correction', 'references'].includes(saved.section) && typeof saved.selected === 'string' && saved.filters && ['query', 'domain', 'severity', 'confidence', 'resolution'].every(k => typeof saved.filters[k] === 'string'))
452
+ state = saved;
453
+ if (!state.selected && review?.findings[0])
454
+ state.selected = review.findings[0].id;
455
+ hashSelection();
456
+ render();
457
+ }
458
+ catch {
459
+ review = null;
460
+ legacy = null;
461
+ showError('Résultats invalides ou version non supportée. Ouvrez une review format 1 ou un rapport Markdown historique.');
462
+ }