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,1351 @@
1
+ <!doctype html>
2
+ <html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="referrer" content="no-referrer"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'sha256-DHuyXLWPFO7pQc0JlbDTpUTGbvVfbvAn6N63HnYxRpI='; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; font-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'"><title>DevMethod · Review</title><style>:root {
3
+ color-scheme:light;
4
+ --ink:#151643;
5
+ --muted:#5d6587;
6
+ --indigo:#4940ed;
7
+ --line:#e1e6f2;
8
+ --paper:#fff;
9
+ --canvas:#fafbfe;
10
+ --red:#c42e35;
11
+ --amber:#986000;
12
+ --green:#06764f}
13
+ * {
14
+ box-sizing:border-box}
15
+ body {
16
+ margin:0;
17
+ background:var(--canvas);
18
+ color:var(--ink);
19
+ font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
20
+ button,input,select,summary {
21
+ font:inherit}
22
+ button,a,summary,label.import {
23
+ touch-action:manipulation}
24
+ button,summary {
25
+ cursor:pointer}
26
+ button {
27
+ color:inherit}
28
+ a {
29
+ color:#3535cf;
30
+ text-underline-offset:3px}
31
+ p {
32
+ margin:8px 0}
33
+ h1,h2,h3,h4 {
34
+ line-height:1.25;
35
+ margin:0}
36
+ h1 {
37
+ font-size:32px;
38
+ letter-spacing:-.8px}
39
+ h2 {
40
+ font-size:23px}
41
+ h3 {
42
+ font-size:16px}
43
+ h4 {
44
+ font-size:15px}
45
+ .layout {
46
+ display:grid;
47
+ grid-template-columns:208px minmax(0,1fr);
48
+ min-height:100vh}
49
+ .sidebar {
50
+ position:sticky;
51
+ top:0;
52
+ height:100vh;
53
+ padding:25px 16px;
54
+ background:linear-gradient(150deg,#f3f6fd,#fafbff);
55
+ border-right:1px solid var(--line);
56
+ display:flex;
57
+ flex-direction:column;
58
+ gap:13px}
59
+ .brand {
60
+ font-size:23px;
61
+ font-weight:750;
62
+ letter-spacing:-.5px;
63
+ white-space:nowrap}
64
+ .eyebrow {
65
+ font-size:10px;
66
+ font-weight:700;
67
+ letter-spacing:1.8px;
68
+ color:#626c95;
69
+ margin:0 0 6px}
70
+ .sidebar>.eyebrow {
71
+ margin:24px 8px 0}
72
+ .project {
73
+ padding:0 8px;
74
+ overflow-wrap:anywhere}
75
+ .side-nav {
76
+ margin:21px -6px 6px;
77
+ padding-top:20px;
78
+ border-top:1px solid var(--line)}
79
+ .side-active {
80
+ border-radius:8px;
81
+ background:#e6e7ff;
82
+ color:#3023d8;
83
+ font-weight:650;
84
+ padding:12px}
85
+ .side-note {
86
+ margin-top:auto;
87
+ padding:16px 7px 0;
88
+ font-size:11px;
89
+ color:#66708d}
90
+ .side-note strong {
91
+ font-weight:600}
92
+ .main {
93
+ padding:27px 30px 42px;
94
+ min-width:0;
95
+ max-width:1720px;
96
+ width:100%;
97
+ margin:0 auto}
98
+ .page-header {
99
+ display:flex;
100
+ justify-content:space-between;
101
+ align-items:flex-start;
102
+ gap:20px;
103
+ margin-bottom:23px}
104
+ .subtitle {
105
+ font-size:15px;
106
+ color:#535d86}
107
+ .revision {
108
+ font-size:12px;
109
+ color:var(--muted);
110
+ overflow-wrap:anywhere}
111
+ .button {
112
+ display:inline-flex;
113
+ align-items:center;
114
+ justify-content:center;
115
+ gap:6px;
116
+ border:1px solid #9ca2bd;
117
+ border-radius:6px;
118
+ background:white;
119
+ padding:10px 15px;
120
+ text-decoration:none;
121
+ font-size:13px;
122
+ font-weight:650;
123
+ color:var(--ink);
124
+ white-space:nowrap;
125
+ box-shadow:0 1px 2px #15164308}
126
+ .button.primary {
127
+ background:var(--indigo);
128
+ border-color:var(--indigo);
129
+ color:white}
130
+ .button:hover {
131
+ filter:brightness(.97)}
132
+ .import {
133
+ position:relative;
134
+ overflow:hidden;
135
+ background:#fff;
136
+ border-color:var(--line);
137
+ font-size:12px}
138
+ .import input {
139
+ position:absolute;
140
+ inset:0;
141
+ opacity:0;
142
+ width:100%;
143
+ cursor:pointer}
144
+ .import:focus-within {
145
+ outline:3px solid #3023d8;
146
+ outline-offset:3px}
147
+ .export {
148
+ position:relative;
149
+ flex-shrink:0}
150
+ .export summary {
151
+ list-style:none}
152
+ .export summary::-webkit-details-marker {
153
+ display:none}
154
+ .export-menu {
155
+ position:absolute;
156
+ right:0;
157
+ top:49px;
158
+ width:278px;
159
+ z-index:3;
160
+ background:white;
161
+ border:1px solid var(--line);
162
+ border-radius:8px;
163
+ padding:7px;
164
+ box-shadow:0 9px 30px #17194322}
165
+ .export-menu button {
166
+ display:block;
167
+ text-align:left;
168
+ width:100%;
169
+ border:0;
170
+ background:white;
171
+ padding:12px;
172
+ border-radius:5px}
173
+ .export-menu button:hover {
174
+ background:#f2f1ff}
175
+ .banner {
176
+ display:flex;
177
+ gap:17px;
178
+ padding:16px 20px;
179
+ border:1px solid #edc06c;
180
+ border-radius:7px;
181
+ background:#fff6e0;
182
+ color:#8b5200;
183
+ margin-bottom:17px}
184
+ .banner h2 {
185
+ font-size:21px}
186
+ .banner p {
187
+ margin:3px 0}
188
+ .banner .policy {
189
+ font-size:11px;
190
+ color:#745f3d}
191
+ .banner-icon {
192
+ font-size:31px;
193
+ line-height:1.4}
194
+ .banner.ready {
195
+ background:#edfaf4;
196
+ border-color:#a5d9c0;
197
+ color:#076543}
198
+ .banner.incomplete,.banner.blocked {
199
+ background:#f0f3ff;
200
+ color:#324b87;
201
+ border-color:#bbcbed}
202
+ .stats {
203
+ display:grid;
204
+ grid-template-columns:repeat(5,minmax(82px,1fr)) minmax(220px,2.2fr);
205
+ gap:11px}
206
+ .stat {
207
+ background:white;
208
+ border:1px solid var(--line);
209
+ border-radius:7px;
210
+ padding:13px 12px;
211
+ display:grid;
212
+ grid-template-columns:28px 1fr;
213
+ align-items:center;
214
+ column-gap:8px;
215
+ min-width:0}
216
+ .stat strong {
217
+ font-size:27px;
218
+ line-height:1}
219
+ .stat>span:last-child {
220
+ grid-column:2;
221
+ font-size:12px;
222
+ color:#465078}
223
+ .stat-icon {
224
+ grid-row:span 2;
225
+ border-radius:50%;
226
+ width:26px;
227
+ height:26px;
228
+ text-align:center;
229
+ background:#eef0ff;
230
+ color:#4e4bdf;
231
+ font-size:18px;
232
+ font-weight:700}
233
+ .critical .stat-icon,.major .stat-icon {
234
+ background:#ffdfdf;
235
+ color:var(--red)}
236
+ .moderate .stat-icon {
237
+ background:#fff0cd;
238
+ color:var(--amber)}
239
+ .coverage-stat {
240
+ display:flex;
241
+ align-items:stretch;
242
+ flex-direction:column;
243
+ justify-content:center;
244
+ gap:7px}
245
+ .coverage-stat strong {
246
+ font-size:12px;
247
+ line-height:1.3}
248
+ .coverage-stat>span:last-child {
249
+ font-size:10px}
250
+ .coverage-bars {
251
+ display:flex;
252
+ height:7px;
253
+ background:#e1e6f0;
254
+ border-radius:10px;
255
+ overflow:hidden;
256
+ width:100%}
257
+ .coverage-bars span {
258
+ min-width:0}
259
+ .coverage-bars .passed {
260
+ background:#22a675}
261
+ .coverage-bars .failed {
262
+ background:#ec575c}
263
+ .coverage-bars .not-run,.coverage-bars .blocked {
264
+ background:#aeb7cc}
265
+ .coverage-bars .out-of-scope {
266
+ background:#e1e6f0}
267
+ .count-caption {
268
+ font-size:10px;
269
+ color:var(--muted);
270
+ margin:7px 0 11px}
271
+ .revision-hint {
272
+ font-size:11px;
273
+ color:var(--muted);
274
+ margin:0 0 12px}
275
+ .notice {
276
+ padding:12px 15px;
277
+ border:1px solid #ccd5ed;
278
+ background:#f2f5ff;
279
+ color:#35466e;
280
+ border-radius:7px;
281
+ font-size:13px}
282
+ .limits {
283
+ font-size:12px;
284
+ color:var(--muted)}
285
+ .limits summary {
286
+ font-weight:650;
287
+ width:fit-content}
288
+ .limits li {
289
+ margin:5px 0}
290
+ .important-limit {
291
+ font-size:12px;
292
+ color:#665229;
293
+ margin:7px 0 17px}
294
+ .workspace {
295
+ display:grid;
296
+ grid-template-columns:minmax(350px,.94fr) minmax(380px,1.12fr);
297
+ background:white;
298
+ border:1px solid var(--line);
299
+ border-radius:8px;
300
+ min-height:560px;
301
+ box-shadow:0 2px 8px #22224b03;
302
+ overflow:hidden}
303
+ .list-panel {
304
+ border-right:1px solid var(--line);
305
+ min-width:0}
306
+ .tabs {
307
+ display:flex;
308
+ border-bottom:1px solid var(--line);
309
+ padding:0 12px;
310
+ gap:6px}
311
+ .tab {
312
+ position:relative;
313
+ flex:1;
314
+ padding:17px 8px;
315
+ border:0;
316
+ background:none;
317
+ color:#545d85;
318
+ font-size:14px;
319
+ font-weight:500;
320
+ white-space:nowrap}
321
+ .tab.active {
322
+ color:#2f22c8;
323
+ font-weight:700}
324
+ .tab.active:after {
325
+ content:"";
326
+ position:absolute;
327
+ bottom:0;
328
+ left:2px;
329
+ right:2px;
330
+ height:3px;
331
+ background:var(--indigo);
332
+ border-radius:4px 4px 0 0}
333
+ .view-panel {
334
+ padding:16px}
335
+ .filters {
336
+ display:grid;
337
+ grid-template-columns:repeat(2,minmax(0,1fr));
338
+ gap:9px}
339
+ .filters label {
340
+ display:flex;
341
+ flex-direction:column;
342
+ gap:4px;
343
+ font-size:10px;
344
+ color:#5e688b;
345
+ font-weight:650}
346
+ .search-label {
347
+ grid-column:1/-1}
348
+ input,select {
349
+ min-width:0;
350
+ width:100%;
351
+ color:var(--ink);
352
+ background:#fafbfe;
353
+ border:1px solid #dce1ef;
354
+ border-radius:6px;
355
+ padding:8px 9px;
356
+ font-size:12px}
357
+ input[type=file] {
358
+ padding:0}
359
+ .result-count {
360
+ font-size:11px;
361
+ color:var(--muted);
362
+ margin:14px 0 9px}
363
+ .finding {
364
+ display:block;
365
+ text-align:left;
366
+ width:100%;
367
+ border:1px solid var(--line);
368
+ background:white;
369
+ border-radius:7px;
370
+ padding:13px 14px;
371
+ margin:0 0 10px;
372
+ transition:background .15s}
373
+ .finding:hover {
374
+ background:#fafaff}
375
+ .finding.selected {
376
+ border-color:#9c92ff;
377
+ background:#f7f5ff;
378
+ box-shadow:inset 3px 0 var(--indigo)}
379
+ .finding-top {
380
+ display:flex;
381
+ justify-content:space-between;
382
+ align-items:center;
383
+ gap:8px;
384
+ margin-bottom:9px}
385
+ .finding-id {
386
+ font-size:12px}
387
+ .finding h3 {
388
+ font-size:15px;
389
+ margin-bottom:10px}
390
+ .finding-meta {
391
+ display:flex;
392
+ gap:9px;
393
+ align-items:center;
394
+ flex-wrap:wrap;
395
+ font-size:11px;
396
+ color:#4c557b}
397
+ .resolution {
398
+ margin-left:auto}
399
+ .badge {
400
+ display:inline-flex;
401
+ align-items:center;
402
+ gap:4px;
403
+ font-size:11px;
404
+ font-weight:650;
405
+ padding:4px 8px;
406
+ border-radius:6px;
407
+ line-height:1.25;
408
+ white-space:nowrap}
409
+ .badge.critical,.badge.major,.badge.failed {
410
+ background:#ffe5e5;
411
+ color:#ad222b}
412
+ .badge.moderate {
413
+ background:#fff0cf;
414
+ color:#895000}
415
+ .badge.minor,.badge.suspected,.badge.unverified {
416
+ background:#edf1ff;
417
+ color:#3c50a1}
418
+ .badge.confirmed,.badge.passed {
419
+ background:#e4f6ed;
420
+ color:#04714d}
421
+ .badge.neutral,.badge.not-run,.badge.blocked,.badge.out-of-scope {
422
+ background:#edf0f6;
423
+ color:#515d78}
424
+ .detail-panel {
425
+ padding:21px 23px;
426
+ min-width:0}
427
+ .detail-title {
428
+ font-size:23px;
429
+ letter-spacing:-.35px}
430
+ .detail-badges {
431
+ display:flex;
432
+ gap:9px;
433
+ flex-wrap:wrap;
434
+ margin:12px 0}
435
+ .impact {
436
+ font-size:16px}
437
+ .location {
438
+ font:11px/1.5 ui-monospace,SFMono-Regular,monospace;
439
+ color:#5e6688;
440
+ overflow-wrap:anywhere;
441
+ margin:8px 0 16px}
442
+ .detail-panel>.tabs {
443
+ margin:0 -23px;
444
+ padding:0 20px}
445
+ .detail-panel .tab {
446
+ flex:0 1 130px}
447
+ .detail-content {
448
+ padding-top:18px}
449
+ .text-block {
450
+ margin:0 0 17px}
451
+ .text-block h3 {
452
+ font-size:12px;
453
+ color:#5f688a;
454
+ margin-bottom:6px}
455
+ .text-block p {
456
+ font-size:14px;
457
+ white-space:pre-wrap;
458
+ overflow-wrap:anywhere}
459
+ .compare {
460
+ display:grid;
461
+ grid-template-columns:1fr 1fr;
462
+ gap:12px;
463
+ padding:14px;
464
+ background:#f8f9fe;
465
+ border:1px solid var(--line);
466
+ border-radius:6px;
467
+ margin:14px 0}
468
+ .compare .text-block {
469
+ margin:0}
470
+ .reproduction {
471
+ font-size:13px;
472
+ padding-left:20px;
473
+ color:#4c5678}
474
+ .reproduction li {
475
+ margin:6px 0}
476
+ .evidence-card {
477
+ padding:15px;
478
+ border:1px solid var(--line);
479
+ border-radius:7px;
480
+ margin:16px 0}
481
+ .evidence-card h3 {
482
+ font-size:14px;
483
+ margin-bottom:10px}
484
+ .evidence-card .eyebrow {
485
+ font-size:9px;
486
+ letter-spacing:.5px}
487
+ .evidence-card pre,pre {
488
+ white-space:pre-wrap;
489
+ overflow-wrap:anywhere;
490
+ word-break:break-word;
491
+ font:12px/1.7 ui-monospace,SFMono-Regular,Consolas,monospace;
492
+ background:#f6f8fd;
493
+ padding:13px;
494
+ border-radius:6px}
495
+ .diagram {
496
+ border-left:3px solid #8b85ed;
497
+ color:#424485}
498
+ .evidence-image {
499
+ display:block;
500
+ max-width:100%;
501
+ height:auto;
502
+ border:1px solid var(--line);
503
+ border-radius:6px;
504
+ margin:10px 0}
505
+ .correction-preview {
506
+ padding:13px 15px;
507
+ background:#f6f7fc;
508
+ border:1px solid var(--line);
509
+ border-radius:6px;
510
+ font-size:13px}
511
+ .correction-preview strong {
512
+ font-size:12px}
513
+ .detail-actions {
514
+ display:flex;
515
+ justify-content:flex-end;
516
+ gap:9px;
517
+ flex-wrap:wrap;
518
+ border-top:1px solid var(--line);
519
+ padding-top:17px;
520
+ margin-top:22px}
521
+ .detail-actions input {
522
+ width:100%;
523
+ font-size:11px}
524
+ .back-button {
525
+ display:none}
526
+ .empty {
527
+ padding:60px 22px;
528
+ text-align:center;
529
+ color:var(--muted);
530
+ background:white;
531
+ border:1px solid var(--line);
532
+ border-radius:8px}
533
+ .empty h2 {
534
+ color:var(--ink);
535
+ margin:12px 0}
536
+ .empty .import {
537
+ margin-top:18px}
538
+ .empty.small {
539
+ padding:28px 16px;
540
+ font-size:13px}
541
+ .detail-empty {
542
+ border:0;
543
+ margin-top:110px}
544
+ .empty-icon {
545
+ font-size:36px;
546
+ color:var(--indigo)}
547
+ .muted {
548
+ font-size:12px;
549
+ color:var(--muted)}
550
+ .panel-title {
551
+ font-size:18px;
552
+ margin-bottom:12px}
553
+ .domain-title {
554
+ font-size:14px;
555
+ margin:23px 0 10px}
556
+ .check-card,.source-card {
557
+ border:1px solid var(--line);
558
+ border-radius:7px;
559
+ padding:15px;
560
+ margin-bottom:12px}
561
+ .check-card h4 {
562
+ margin:10px 0}
563
+ .check-card p {
564
+ font-size:12px}
565
+ .check-card summary {
566
+ font-size:12px;
567
+ color:#3d36be}
568
+ .source-card h3 {
569
+ font-size:15px}
570
+ .source-meta {
571
+ font-size:12px;
572
+ color:var(--muted)}
573
+ .source-card .text-block {
574
+ margin-top:12px}
575
+ .source-card .text-block p {
576
+ font-size:12px}
577
+ .technology {
578
+ padding:10px;
579
+ background:#f5f6fc;
580
+ border-radius:5px;
581
+ font-size:12px}
582
+ .link {
583
+ font-size:12px}
584
+ .legacy {
585
+ font-size:14px;
586
+ background:white;
587
+ border:1px solid var(--line);
588
+ padding:24px}
589
+ .loading {
590
+ padding:60px;
591
+ font-size:18px}
592
+ :focus-visible {
593
+ outline:3px solid #3523cf;
594
+ outline-offset:3px}
595
+ button:focus:not(:focus-visible) {
596
+ outline:none}
597
+ @media(min-width:1700px) {
598
+ .main {
599
+ padding:32px 42px}
600
+ .workspace {
601
+ grid-template-columns:1fr 1.2fr}
602
+ .stat {
603
+ padding:16px}
604
+ }
605
+ @media(max-width:1200px) {
606
+ .layout {
607
+ grid-template-columns:176px minmax(0,1fr)}
608
+ .brand {
609
+ font-size:19px}
610
+ .main {
611
+ padding:22px 20px}
612
+ .stats {
613
+ grid-template-columns:repeat(5,1fr)}
614
+ .coverage-stat {
615
+ grid-column:1/-1;
616
+ display:flex;
617
+ flex-direction:row;
618
+ align-items:center;
619
+ gap:18px}
620
+ .coverage-bars {
621
+ max-width:170px}
622
+ .workspace {
623
+ grid-template-columns:minmax(290px,.9fr) minmax(310px,1fr)}
624
+ .detail-panel {
625
+ padding:18px}
626
+ .detail-panel>.tabs {
627
+ margin:0 -18px}
628
+ .detail-title {
629
+ font-size:21px}
630
+ }
631
+ @media(max-width:850px) {
632
+ .layout {
633
+ display:block}
634
+ .sidebar {
635
+ position:static;
636
+ height:auto;
637
+ display:flex;
638
+ flex-direction:row;
639
+ align-items:center;
640
+ padding:13px 18px;
641
+ border-right:0;
642
+ border-bottom:1px solid var(--line);
643
+ gap:15px}
644
+ .sidebar>.eyebrow,.project,.side-nav,.side-note {
645
+ display:none}
646
+ .sidebar .import {
647
+ margin-left:auto}
648
+ .brand {
649
+ font-size:20px}
650
+ .main {
651
+ padding:20px 16px}
652
+ .page-header {
653
+ gap:10px}
654
+ .page-header h1 {
655
+ font-size:26px}
656
+ .page-header .button {
657
+ font-size:11px;
658
+ padding:9px}
659
+ .stats {
660
+ grid-template-columns:repeat(5,1fr);
661
+ gap:6px}
662
+ .stat {
663
+ display:flex;
664
+ flex-direction:column;
665
+ gap:5px;
666
+ padding:10px 4px}
667
+ .stat-icon {
668
+ display:none}
669
+ .stat strong {
670
+ font-size:23px}
671
+ .stat>span:last-child {
672
+ font-size:10px}
673
+ .coverage-stat {
674
+ flex-direction:row;
675
+ gap:12px;
676
+ flex-wrap:wrap;
677
+ padding:12px}
678
+ .coverage-stat strong {
679
+ font-size:12px}
680
+ .coverage-bars {
681
+ max-width:120px}
682
+ .workspace {
683
+ display:block;
684
+ min-height:450px}
685
+ .list-panel {
686
+ border:0}
687
+ .detail-panel {
688
+ display:none}
689
+ .workspace.show-detail .list-panel {
690
+ display:none}
691
+ .workspace.show-detail .detail-panel {
692
+ display:block}
693
+ .back-button {
694
+ display:block;
695
+ border:0;
696
+ background:transparent;
697
+ color:#4537d0;
698
+ padding:0;
699
+ margin:0 0 18px;
700
+ font-size:13px;
701
+ font-weight:650}
702
+ .banner {
703
+ padding:13px}
704
+ .banner h2 {
705
+ font-size:18px}
706
+ .banner p {
707
+ font-size:13px}
708
+ .banner .policy {
709
+ font-size:10px}
710
+ .count-caption {
711
+ font-size:9px}
712
+ .export-menu {
713
+ width:252px}
714
+ .detail-title {
715
+ font-size:22px}
716
+ .detail-empty {
717
+ margin:0}
718
+ .important-limit {
719
+ font-size:11px}
720
+ .view-panel {
721
+ padding:14px}
722
+ }
723
+ @media(max-width:440px) {
724
+ .page-header {
725
+ flex-direction:column}
726
+ .page-header .export {
727
+ align-self:flex-end;
728
+ margin-top:-2px}
729
+ .compare {
730
+ grid-template-columns:1fr}
731
+ .brand {
732
+ font-size:18px}
733
+ .sidebar {
734
+ padding:12px}
735
+ .sidebar .import {
736
+ font-size:10px;
737
+ padding:8px}
738
+ .main {
739
+ padding:18px 12px}
740
+ .coverage-stat {
741
+ gap:8px}
742
+ .coverage-bars {
743
+ max-width:100%;
744
+ width:100%}
745
+ .detail-actions {
746
+ justify-content:stretch}
747
+ .detail-actions .button {
748
+ flex:1;
749
+ white-space:normal}
750
+ .banner-icon {
751
+ font-size:25px}
752
+ }
753
+ @media(prefers-reduced-motion:reduce) {
754
+ * {
755
+ transition:none!important;
756
+ scroll-behavior:auto!important}
757
+ }
758
+
759
+
760
+ /* Keep inspection content near the first fold at the approved desktop size. */
761
+ @media(min-width:851px) {
762
+ .page-header {
763
+ margin-bottom:16px}
764
+ .subtitle {
765
+ display:inline;
766
+ margin-right:12px}
767
+ .revision {
768
+ display:inline}
769
+ .page-header h1 {
770
+ margin-bottom:8px}
771
+ .banner {
772
+ padding:13px 18px;
773
+ margin-bottom:12px}
774
+ .revision-hint {
775
+ margin-bottom:9px}
776
+ .stat {
777
+ padding-top:11px;
778
+ padding-bottom:11px}
779
+ .important-limit {
780
+ margin-bottom:12px}
781
+ .main {
782
+ padding-top:21px}
783
+ }
784
+ </style></head><body><div id="app"><p class="loading">Chargement de la review…</p></div><noscript>Activez JavaScript pour les filtres et la consultation. Le rapport Markdown reste disponible via le CLI.</noscript><script id="review-data" type="application/json">{"review":{"format":1,"id":"DEMO-REVIEW","title":"Review de la bibliothèque","project":"Atelier de lecture","mission":"Première sauvegarde","tickets":[{"id":"DEMO-12","title":"PR de l’interface (lien de démonstration)","url":"https://github.com/montassarkhalloufi/DevMethod/pull/19"}],"date":"2026-09-13","scope":["Démonstration fictive de consultation, sans audit du projet illustré."],"exclusions":["Aucun résultat réel de sécurité ou de performance."],"revision":{"commit":"demo-revision-1","dirty":[]},"technologies":[{"name":"HTML / DOM","version":"Living Standard","detectedFrom":"Fixture authored for the viewer"}],"sources":[{"id":"WAI","title":"Messages de statut accessibles","kind":"documentation","publisher":"W3C WAI","technology":"Web accessibility","version":"WCAG 2.2","url":"https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html","consultedAt":null,"access":"unverified","usage":"Référence proposée pour cette démonstration, non consultée dans une review réelle.","compatibility":"À vérifier pour le parcours cible.","provenance":"Domaine w3.org ; cette fixture ne constitue pas une vérification de provenance."}],"checks":[{"id":"C-01","title":"Annonce après sauvegarde","domain":"Accessibilité","kind":"manual","status":"failed","result":"Exemple fictif : le changement de statut ne serait pas annoncé.","reason":null,"evidenceIds":["E-01"],"revision":"demo-revision-1","targets":["save-status"]},{"id":"C-02","title":"Conservation du titre","domain":"Données","kind":"automated","status":"passed","result":"Exemple fictif : la valeur enregistrée correspond à la saisie.","reason":null,"evidenceIds":["E-02"],"revision":"demo-revision-1","targets":["save-title"]},{"id":"C-03","title":"Temps de réponse sur liste longue","domain":"Performance","kind":"automated","status":"not-run","result":"Aucune mesure disponible dans cette fixture.","reason":"Jeu de données représentatif indisponible.","evidenceIds":[],"revision":"demo-revision-1","targets":["list-view"]}],"findings":[{"id":"R-01","title":"Le retour de sauvegarde reste silencieux","domain":"Accessibilité","severity":"moderate","severityReason":"Exemple fictif : le lecteur pourrait ignorer que son action a réussi.","confidence":"confirmed","resolution":"open","location":{"path":"save-status (composant fictif)","line":null,"component":"Confirmation de sauvegarde"},"trigger":"Sauvegarder un titre en utilisant un lecteur d’écran.","expected":"Le succès est annoncé sans déplacer le focus.","observed":"Dans ce scénario fictif, seul le texte visuel change.","impact":"Le lecteur ne sait pas si son titre a été conservé.","reproduction":["Scénario illustratif, non exécuté.","Saisir un titre, activer Sauvegarder et écouter le retour."],"evidenceIds":["E-01"],"correction":"Exposer le message de succès comme statut accessible, puis vérifier son annonce.","tradeoffs":"Éviter les annonces répétées ; conserver le focus dans le parcours.","sourceIds":["WAI"],"ticketIds":["DEMO-12"],"verification":"Inspecter avec un lecteur d’écran réel et vérifier le maintien du focus.","resolutionEvidenceIds":[],"targets":["save-status"]},{"id":"R-02","title":"Une grande liste pourrait ralentir la recherche","domain":"Performance","severity":"minor","severityReason":"Impact supposé limité aux collections volumineuses.","confidence":"suspected","resolution":"open","location":{"path":"list-view (composant fictif)","line":null,"component":"Liste de titres"},"trigger":"Rechercher dans une collection volumineuse.","expected":"La saisie reste réactive.","observed":"Aucune mesure ; risque hypothétique seulement.","impact":"Un délai pourrait gêner la saisie ; cela reste à vérifier.","reproduction":["Préparer un jeu représentatif puis mesurer ; non exécuté."],"evidenceIds":[],"correction":"Mesurer avant de décider d’une optimisation.","tradeoffs":"Éviter une complexité non justifiée par des mesures.","sourceIds":[],"ticketIds":[],"verification":"Mesurer latence et réactivité sur le matériel cible.","resolutionEvidenceIds":[],"targets":["list-view"]}],"evidence":[{"id":"E-01","title":"Flux du retour utilisateur — schéma explicatif","kind":"diagram","content":"Saisie du titre → Sauvegarder → Titre conservé → Message visuel\n ↳ Annonce vocale manquante (hypothèse de fixture)\nSchéma explicatif fictif, pas une capture ni un journal d’exécution.","url":null,"image":null},{"id":"E-02","title":"Résultat illustratif de conservation","kind":"log","content":"DÉMONSTRATION — journal rédigé, test non exécuté.\nEntrée « Un titre » → valeur conservée « Un titre ».","url":null,"image":null}],"limits":["DÉMONSTRATION : données fictives, sans validation d’un produit réel.","Le contrôle de performance n’a pas été exécuté."],"policy":{"blockingSeverities":["critical","major","moderate"],"requireAllChecks":true,"rationale":"Politique illustrative : corriger les problèmes confirmés modérés ou plus graves avant intégration."},"summary":"Démonstration de l’interface. Les constats et contrôles ci-dessous sont fictifs."},"legacy":null,"currentRevision":null,"changedTargets":[]}</script><script>(function () {
785
+ 'use strict';
786
+ const severityLabels = { critical: 'Critique', major: 'Majeur', moderate: 'Modéré', minor: 'Mineur' };
787
+ const confidenceLabels = { confirmed: 'Confirmé', suspected: 'À vérifier' };
788
+ const resolutionLabels = { open: 'Ouvert', 'in-progress': 'En correction', resolved: 'Résolu et vérifié', 'accepted-risk': 'Risque accepté' };
789
+ const checkLabels = { passed: 'Réussi', failed: 'En échec', 'not-run': 'Non exécuté', blocked: 'Bloqué', 'out-of-scope': 'Hors périmètre' };
790
+ const rec = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
791
+ const str = (v) => typeof v === 'string' && v.trim().length > 0 && v.length <= 16384;
792
+ const identifier = (v) => typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(v);
793
+ const arr = (v, test, max = 256) => Array.isArray(v) && v.length <= max && v.every(test);
794
+ const nullable = (v, test) => v === null || test(v);
795
+ const has = (v, choices) => typeof v === 'string' && choices.includes(v);
796
+ const shape = (v, keys) => rec(v) && Object.keys(v).length === keys.length && keys.every(k => Object.hasOwn(v, k));
797
+ const date = (v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z)?$/.test(v) && Number.isFinite(Date.parse(v));
798
+ function reviewUrl(value) {
799
+ if (typeof value !== 'string' || value.length > 2048 || /[\s\x00-\x1f\x7f\\<>`"\[\]]/.test(value))
800
+ return false;
801
+ try {
802
+ const u = new URL(value);
803
+ return u.protocol === 'https:' && Boolean(u.hostname) && !u.username && !u.password;
804
+ }
805
+ catch {
806
+ return false;
807
+ }
808
+ }
809
+ function assertReview(ok, message) { if (!ok)
810
+ throw new Error(message); }
811
+ /** Errors identify schema sections, never echo untrusted input. */
812
+ function validateReview(input) {
813
+ assertReview(rec(input) && input.format === 1, 'Unsupported review format; expected format 1.');
814
+ assertReview(shape(input, ['format', 'id', 'title', 'project', 'mission', 'tickets', 'date', 'scope', 'exclusions', 'revision', 'technologies', 'sources', 'checks', 'findings', 'evidence', 'limits', 'policy', 'summary']), 'Invalid review fields.');
815
+ assertReview(identifier(input.id) && ['title', 'project', 'mission', 'summary'].every(k => str(input[k])) && date(input.date), 'Invalid review identity or date.');
816
+ assertReview(arr(input.scope, str) && input.scope.length > 0 && arr(input.exclusions, str) && arr(input.limits, str), 'Invalid review scope or limits.');
817
+ assertReview(shape(input.revision, ['commit', 'dirty']) && str(input.revision.commit) && arr(input.revision.dirty, str), 'Invalid inspected revision.');
818
+ assertReview(arr(input.tickets, v => shape(v, ['id', 'title', 'url']) && identifier(v.id) && str(v.title) && nullable(v.url, reviewUrl)), 'Invalid review ticket.');
819
+ assertReview(arr(input.technologies, v => shape(v, ['name', 'version', 'detectedFrom']) && Object.values(v).every(str)), 'Invalid detected technologies.');
820
+ assertReview(arr(input.sources, v => shape(v, ['id', 'title', 'kind', 'publisher', 'technology', 'version', 'url', 'consultedAt', 'access', 'usage', 'compatibility', 'provenance']) && identifier(v.id) && ['title', 'publisher', 'technology', 'version', 'usage', 'compatibility', 'provenance'].every(k => str(v[k])) && has(v.kind, ['documentation', 'skill', 'project']) && nullable(v.url, reviewUrl) && nullable(v.consultedAt, date) && has(v.access, ['consulted', 'unavailable', 'unverified']) && (v.access !== 'consulted' || v.consultedAt !== null)), 'Invalid review source or consultation provenance.');
821
+ assertReview(arr(input.evidence, v => shape(v, ['id', 'title', 'kind', 'content', 'url', 'image']) && identifier(v.id) && str(v.title) && has(v.kind, ['text', 'log', 'screenshot', 'diagram']) && str(v.content) && nullable(v.url, reviewUrl) && nullable(v.image, i => shape(i, ['mime', 'base64', 'alt', 'origin', 'privacyReviewed']) && has(i.mime, ['image/png', 'image/jpeg']) && typeof i.base64 === 'string' && i.base64.length <= 1400000 && /^[A-Za-z0-9+/]+={0,2}$/.test(i.base64) && (i.mime === 'image/png' ? i.base64.startsWith('iVBORw0KGgo') : i.base64.startsWith('/9j/')) && str(i.alt) && has(i.origin, ['captured', 'explanatory']) && i.privacyReviewed === true)), 'Invalid evidence; images require bounded PNG/JPEG data, text alternative and privacy review.');
822
+ assertReview(arr(input.checks, v => shape(v, ['id', 'title', 'domain', 'kind', 'status', 'result', 'reason', 'evidenceIds', 'revision', 'targets']) && identifier(v.id) && ['title', 'domain', 'result', 'revision'].every(k => str(v[k])) && has(v.kind, ['automated', 'manual']) && has(v.status, Object.keys(checkLabels)) && nullable(v.reason, str) && (!['not-run', 'blocked', 'out-of-scope'].includes(v.status) || str(v.reason)) && arr(v.evidenceIds, identifier) && arr(v.targets, str)), 'Invalid review check; unexecuted checks need a reason.');
823
+ assertReview(arr(input.findings, v => shape(v, ['id', 'title', 'domain', 'severity', 'severityReason', 'confidence', 'resolution', 'location', 'trigger', 'expected', 'observed', 'impact', 'reproduction', 'evidenceIds', 'correction', 'tradeoffs', 'sourceIds', 'ticketIds', 'verification', 'resolutionEvidenceIds', 'targets']) && identifier(v.id) && ['title', 'domain', 'severityReason', 'trigger', 'expected', 'observed', 'impact', 'correction', 'tradeoffs', 'verification'].every(k => str(v[k])) && has(v.severity, Object.keys(severityLabels)) && has(v.confidence, Object.keys(confidenceLabels)) && has(v.resolution, Object.keys(resolutionLabels)) && shape(v.location, ['path', 'line', 'component']) && str(v.location.path) && nullable(v.location.line, n => Number.isSafeInteger(n) && n > 0) && nullable(v.location.component, str) && ['reproduction', 'targets'].every(k => arr(v[k], str)) && ['evidenceIds', 'sourceIds', 'ticketIds', 'resolutionEvidenceIds'].every(k => arr(v[k], identifier)) && (v.reproduction.length > 0 || v.evidenceIds.length > 0) && (v.resolution !== 'resolved' || v.resolutionEvidenceIds.length > 0)), 'Invalid finding; reproduction/evidence and verified resolution references are required.');
824
+ assertReview(shape(input.policy, ['blockingSeverities', 'requireAllChecks', 'rationale']) && arr(input.policy.blockingSeverities, v => has(v, Object.keys(severityLabels)), 4) && typeof input.policy.requireAllChecks === 'boolean' && str(input.policy.rationale), 'Invalid review conclusion policy.');
825
+ const r = input;
826
+ for (const items of [r.tickets, r.sources, r.evidence, r.checks, r.findings])
827
+ assertReview(new Set(items.map(i => i.id)).size === items.length, 'Duplicate review identifiers.');
828
+ const ids = (items) => new Set(items.map(i => i.id));
829
+ const evidence = ids(r.evidence), sources = ids(r.sources), tickets = ids(r.tickets);
830
+ assertReview(r.checks.every(c => c.evidenceIds.every(id => evidence.has(id))) && r.findings.every(f => [...f.evidenceIds, ...f.resolutionEvidenceIds].every(id => evidence.has(id)) && f.sourceIds.every(id => sources.has(id)) && f.ticketIds.every(id => tickets.has(id))), 'Unresolved review references.');
831
+ assertReview(JSON.stringify(input).length <= 4 * 1024 * 1024, 'Review exceeds 4 MiB.');
832
+ return JSON.parse(JSON.stringify(r));
833
+ }
834
+ /** Conservative convenience redaction, not a privacy classifier or authorization. */
835
+ function redactReviewText(s) {
836
+ return s.replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '[REDACTED PRIVATE KEY]')
837
+ .replace(/\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{16,})\b/g, '[REDACTED TOKEN]')
838
+ .replace(/\b(?:[a-z][a-z0-9_-]*[_-])?(?:token|api[_-]?key|password|secret|authorization)\s*[:=]\s*(?:"[^"\r\n]*"|'[^'\r\n]*'|(?:Bearer|Basic)\s+[^\s,;]+|[^\s,;]+)/gi, '[REDACTED CREDENTIAL]')
839
+ .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, '[REDACTED EMAIL]');
840
+ }
841
+ function sanitizedReview(input) {
842
+ const r = validateReview(input);
843
+ const walk = (v, key = '') => typeof v === 'string' ? key === 'base64' ? v : redactReviewText(v) : Array.isArray(v) ? v.map(x => walk(x)) : rec(v) ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x, k)])) : v;
844
+ // Redaction of a link can make it invalid. Remove it rather than navigating a changed URL.
845
+ const clean = walk(r);
846
+ for (const item of [...clean.sources, ...clean.tickets, ...clean.evidence])
847
+ if (item.url !== null && !reviewUrl(item.url))
848
+ item.url = null;
849
+ return validateReview(clean);
850
+ }
851
+ function summarizeReview(r) {
852
+ const severities = { critical: 0, major: 0, moderate: 0, minor: 0 }, checks = { passed: 0, failed: 0, 'not-run': 0, blocked: 0, 'out-of-scope': 0 };
853
+ for (const f of r.findings)
854
+ severities[f.severity]++;
855
+ for (const c of r.checks)
856
+ checks[c.status]++;
857
+ const suspected = r.findings.filter(f => f.confidence === 'suspected' && f.resolution !== 'resolved').length;
858
+ const blocking = r.findings.some(f => f.confidence === 'confirmed' && ['open', 'in-progress'].includes(f.resolution) && r.policy.blockingSeverities.includes(f.severity));
859
+ const conclusion = blocking || checks.failed > 0 ? 'corrections' : checks.blocked > 0 ? 'blocked' : checks.passed === 0 || suspected > 0 || (r.policy.requireAllChecks && checks['not-run'] > 0) ? 'incomplete' : 'ready';
860
+ return { severities, suspected, checks, conclusion };
861
+ }
862
+ const conclusionLabels = { corrections: 'Corrections nécessaires', ready: 'Prêt sur le périmètre vérifié', incomplete: 'Review incomplète', blocked: 'Vérification bloquée' };
863
+ function filterFindings(r, f) {
864
+ const q = f.query.trim().toLocaleLowerCase();
865
+ return r.findings.filter(i => (!q || [i.id, i.title, i.impact, i.location.path].join(' ').toLocaleLowerCase().includes(q)) && (f.domain === '' || i.domain === f.domain) && (f.severity === '' || i.severity === f.severity) && (f.confidence === '' || i.confidence === f.confidence) && (f.resolution === '' || i.resolution === f.resolution));
866
+ }
867
+ function reviewFreshness(r, currentRevision, changedTargets) {
868
+ const different = currentRevision !== null && currentRevision !== r.revision.commit;
869
+ const affected = (targets) => targets.some(t => changedTargets.includes(t));
870
+ return { state: different || changedTargets.length ? 'different' : currentRevision === null ? 'unknown' : 'same', affectedChecks: r.checks.filter(c => affected(c.targets)).map(c => c.id), affectedFindings: r.findings.filter(f => affected(f.targets)).map(f => f.id) };
871
+ }
872
+ const md = (s) => s.replace(/[\\`*_{}\[\]<>|#]/g, c => `\\${c}`).replace(/\n/g, '\\' + '\n');
873
+ function reviewMarkdown(r) {
874
+ const s = summarizeReview(r);
875
+ const lines = [`# ${md(r.title)}`, '', `${md(r.project)} · ${md(r.mission)} · ${md(r.date)}`, `Revision: ${md(r.revision.commit)}; uncommitted changes: ${r.revision.dirty.map(md).join(', ') || 'none recorded'}`, '', `Conclusion: **${conclusionLabels[s.conclusion]}**`, md(r.summary), `Policy: ${md(r.policy.rationale)}`, '', '## Scope', ...r.scope.map(v => `- ${md(v)}`), '', '## Exclusions and limits', ...[...r.exclusions, ...r.limits].map(v => `- ${md(v)}`), '', '## Counts (whole review)', ...Object.entries(s.severities).map(([severity, count]) => `- ${severityLabels[severity]}: ${count}`), `- À vérifier: ${s.suspected}`, ...Object.entries(s.checks).map(([status, count]) => `- ${checkLabels[status]}: ${count}`), '', '## Coverage', ...r.checks.map(c => `- **${md(c.id)} — ${md(c.title)}** (${md(c.domain)}, ${c.kind}): ${checkLabels[c.status]}. ${md(c.result)}${c.reason ? ` Reason: ${md(c.reason)}` : ''} Revision: ${md(c.revision)}. Evidence: ${c.evidenceIds.map(md).join(', ') || 'none'}`), '', '## Findings'];
876
+ for (const f of r.findings)
877
+ lines.push('', `### ${md(f.id)} — ${md(f.title)}`, `${severityLabels[f.severity]} / ${confidenceLabels[f.confidence]} / ${resolutionLabels[f.resolution]}`, `Severity rationale: ${md(f.severityReason)}`, `Location: ${md(f.location.path)}${f.location.line ? `:${f.location.line}` : ''}`, `Impact: ${md(f.impact)}`, `Trigger: ${md(f.trigger)}`, `Expected: ${md(f.expected)}`, `Observed: ${md(f.observed)}`, 'Reproduction:', ...f.reproduction.map(v => `- ${md(v)}`), `Evidence: ${f.evidenceIds.map(md).join(', ') || 'none'}`, `Correction: ${md(f.correction)}`, `Trade-offs: ${md(f.tradeoffs)}`, `Resolution verification: ${md(f.verification)}`, `Resolution evidence: ${f.resolutionEvidenceIds.map(md).join(', ') || 'none'}`, `Sources: ${f.sourceIds.map(md).join(', ') || 'none'}`, `Tickets: ${f.ticketIds.map(md).join(', ') || 'none'}`);
878
+ lines.push('', '## Evidence');
879
+ for (const e of r.evidence)
880
+ lines.push('', `### ${md(e.id)} — ${md(e.title)}`, `${e.kind}${e.image ? ` (${e.image.origin}; image is embedded in HTML/JSON, text alternative: ${md(e.image.alt)})` : ''}`, md(e.content), e.url ? `Full evidence: ${e.url}` : 'No external evidence destination.');
881
+ lines.push('', '## Sources');
882
+ for (const source of r.sources)
883
+ lines.push(`- ${md(source.id)}: ${md(source.title)} — ${md(source.publisher)}; ${md(source.technology)} ${md(source.version)}; ${source.access}; ${source.consultedAt || 'not consulted'}; ${source.url || 'no destination'}. ${md(source.usage)} Compatibility: ${md(source.compatibility)} Provenance: ${md(source.provenance)}`);
884
+ lines.push('', '## Technologies', ...r.technologies.map(t => `- ${md(t.name)} ${md(t.version)} (${md(t.detectedFrom)})`), '', '## Tickets', ...r.tickets.map(t => `- ${md(t.id)} — ${md(t.title)}: ${t.url || 'no published destination'}`), '', 'Generated from review format 1. Counts describe the whole review. Historical evidence does not certify a later revision.', '');
885
+ return lines.join('\n');
886
+ }
887
+
888
+ const app = document.getElementById('app');
889
+ const data = document.getElementById('review-data');
890
+ let review = null;
891
+ let legacy = null;
892
+ let currentRevision = null;
893
+ let changedTargets = [];
894
+ let state = { view: 'findings', selected: '', section: 'evidence', filters: { query: '', domain: '', severity: '', confidence: '', resolution: '' }, mobileDetail: false };
895
+ const el = (tag, cls = '', text) => {
896
+ const n = document.createElement(tag);
897
+ if (cls)
898
+ n.className = cls;
899
+ if (text !== undefined)
900
+ n.textContent = text;
901
+ return n;
902
+ };
903
+ function button(label, cls, action) { const b = el('button', cls, label); b.type = 'button'; b.addEventListener('click', action); return b; }
904
+ function textBlock(title, value, parent) { const box = el('section', 'text-block'); box.append(el('h3', '', title), el('p', '', value)); parent.append(box); }
905
+ function badge(label, kind, icon) { return el('span', `badge ${kind}`, `${icon} ${label}`); }
906
+ function link(label, url, parent, cls = 'link') {
907
+ if (!reviewUrl(url)) {
908
+ parent.append(el('p', 'muted unavailable', 'Destination indisponible : aucun lien HTTPS valide renseigné.'));
909
+ return;
910
+ }
911
+ const a = el('a', cls, `${label} ↗`);
912
+ a.href = url;
913
+ a.target = '_blank';
914
+ a.rel = 'noopener noreferrer';
915
+ parent.append(a);
916
+ }
917
+ function download(name, body, type) {
918
+ const url = URL.createObjectURL(new Blob([body], { type }));
919
+ const a = el('a');
920
+ a.href = url;
921
+ a.download = name;
922
+ document.body.append(a);
923
+ a.click();
924
+ a.remove();
925
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
926
+ }
927
+ const safeJSON = (value) => JSON.stringify(value).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
928
+ function exportHtml() {
929
+ const copy = document.documentElement.cloneNode(true);
930
+ copy.querySelector('#review-data').textContent = safeJSON({ review, legacy, currentRevision, changedTargets, uiState: state });
931
+ // Rebuild the presentation on reopening from the same validated result source.
932
+ copy.querySelector('#app').replaceChildren();
933
+ download(`${review?.id || 'review'}.html`, '<!doctype html>\n' + copy.outerHTML, 'text/html;charset=utf-8');
934
+ }
935
+ function exports(parent) {
936
+ const box = el('details', 'export');
937
+ const summary = el('summary', 'button secondary', '↓ Exporter le rapport');
938
+ box.append(summary);
939
+ const menu = el('div', 'export-menu');
940
+ menu.append(button('HTML interactif · rouvrir hors ligne', '', () => { box.open = false; exportHtml(); }));
941
+ if (review) {
942
+ 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'); }));
943
+ }
944
+ else if (legacy !== null)
945
+ menu.append(button('Rapport Markdown original', '', () => download('review-legacy.md', legacy, 'text/markdown;charset=utf-8')));
946
+ box.append(menu);
947
+ parent.append(box);
948
+ }
949
+ function importer(parent) {
950
+ const label = el('label', 'button import', '+ Ouvrir une review');
951
+ const input = el('input');
952
+ input.type = 'file';
953
+ input.accept = '.json,.md';
954
+ input.setAttribute('aria-label', 'Ouvrir une review JSON ou Markdown');
955
+ label.append(input);
956
+ parent.append(label);
957
+ input.addEventListener('change', async () => {
958
+ const file = input.files?.[0];
959
+ if (!file)
960
+ return;
961
+ if (file.size > 4 * 1024 * 1024) {
962
+ showError('Le fichier dépasse la limite de 4 Mio.');
963
+ return;
964
+ }
965
+ app.replaceChildren(el('p', 'loading', 'Chargement de la review…'));
966
+ app.setAttribute('aria-busy', 'true');
967
+ try {
968
+ const content = await file.text();
969
+ if (file.name.toLowerCase().endsWith('.md')) {
970
+ legacy = redactReviewText(content);
971
+ review = null;
972
+ }
973
+ else {
974
+ review = sanitizedReview(JSON.parse(content));
975
+ legacy = null;
976
+ }
977
+ currentRevision = null;
978
+ changedTargets = [];
979
+ state = { view: 'findings', selected: '', section: 'evidence', filters: { query: '', domain: '', severity: '', confidence: '', resolution: '' }, mobileDetail: false };
980
+ location.hash = '';
981
+ render();
982
+ }
983
+ catch {
984
+ review = null;
985
+ legacy = null;
986
+ 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.');
987
+ }
988
+ finally {
989
+ app.removeAttribute('aria-busy');
990
+ }
991
+ });
992
+ }
993
+ function shell() {
994
+ app.replaceChildren();
995
+ const layout = el('div', 'layout');
996
+ const aside = el('aside', 'sidebar');
997
+ aside.append(el('div', 'brand', '⬡ DevMethod'), el('p', 'eyebrow', 'PROJET'), el('strong', 'project', review?.project || 'Consultation locale'));
998
+ const nav = el('nav', 'side-nav');
999
+ nav.setAttribute('aria-label', 'Navigation de review');
1000
+ nav.append(el('div', 'side-active', '▤ Review'));
1001
+ aside.append(nav);
1002
+ importer(aside);
1003
+ const note = el('div', 'side-note');
1004
+ note.append(el('strong', '', 'Vos données restent locales'), el('p', '', 'Aucune commande exécutée. Aucune ressource distante chargée à l’ouverture.'));
1005
+ aside.append(note);
1006
+ const main = el('main', 'main');
1007
+ const header = el('header', 'page-header');
1008
+ const title = el('div');
1009
+ title.append(el('h1', '', review?.title || (legacy !== null ? 'Rapport historique' : 'Espace de review')));
1010
+ if (review) {
1011
+ 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(', ')}` : ''}`));
1012
+ }
1013
+ header.append(title);
1014
+ if (review || legacy !== null)
1015
+ exports(header);
1016
+ main.append(header);
1017
+ layout.append(aside, main);
1018
+ app.append(layout);
1019
+ return main;
1020
+ }
1021
+ 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); }
1022
+ function tabs(items, active, change, label, panelId) {
1023
+ const group = el('div', 'tabs');
1024
+ group.setAttribute('role', 'tablist');
1025
+ group.setAttribute('aria-label', label);
1026
+ for (const [id, title] of items) {
1027
+ const b = button(title, active === id ? 'tab active' : 'tab', () => { change(id); document.getElementById(`${panelId}-${id}`)?.focus(); });
1028
+ b.id = `${panelId}-${id}`;
1029
+ b.setAttribute('role', 'tab');
1030
+ b.setAttribute('aria-selected', String(active === id));
1031
+ b.setAttribute('aria-controls', panelId);
1032
+ b.tabIndex = active === id ? 0 : -1;
1033
+ b.addEventListener('keydown', e => { const index = items.findIndex(i => i[0] === id); let next = null; if (e.key === 'ArrowRight')
1034
+ next = (index + 1) % items.length; if (e.key === 'ArrowLeft')
1035
+ next = (index + items.length - 1) % items.length; if (e.key === 'Home')
1036
+ next = 0; if (e.key === 'End')
1037
+ next = items.length - 1; if (next !== null) {
1038
+ e.preventDefault();
1039
+ change(items[next][0]);
1040
+ document.getElementById(`${panelId}-${items[next][0]}`)?.focus();
1041
+ } });
1042
+ group.append(b);
1043
+ }
1044
+ return group;
1045
+ }
1046
+ function render() {
1047
+ const main = shell();
1048
+ if (!review) {
1049
+ if (legacy !== null) {
1050
+ main.append(el('div', 'banner incomplete', 'ⓘ Rapport Markdown historique — champs structurés et couverture inconnus. Aucune conversion implicite.'), el('pre', 'legacy', legacy));
1051
+ }
1052
+ else {
1053
+ const box = el('section', 'empty');
1054
+ 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.'));
1055
+ importer(box);
1056
+ main.append(box);
1057
+ }
1058
+ return;
1059
+ }
1060
+ const r = review, sum = summarizeReview(r), fresh = reviewFreshness(r, currentRevision, changedTargets);
1061
+ const banner = el('section', `banner ${sum.conclusion}`);
1062
+ banner.append(el('span', 'banner-icon', sum.conclusion === 'ready' ? '✓' : sum.conclusion === 'corrections' ? '⚠' : 'ⓘ'));
1063
+ const message = el('div');
1064
+ message.append(el('h2', '', conclusionLabels[sum.conclusion]), el('p', '', r.summary), el('p', 'policy', r.policy.rationale));
1065
+ banner.append(message);
1066
+ main.append(banner);
1067
+ if (fresh.state === 'different')
1068
+ 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.`));
1069
+ else if (fresh.state === 'unknown')
1070
+ 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.'));
1071
+ const stats = el('section', 'stats');
1072
+ stats.setAttribute('aria-label', 'Compteurs de toute la review, indépendants des filtres');
1073
+ for (const severity of ['critical', 'major', 'moderate', 'minor']) {
1074
+ const card = el('div', `stat ${severity}`);
1075
+ card.append(el('span', 'stat-icon', severity === 'critical' || severity === 'major' ? '!' : '△'), el('strong', '', String(sum.severities[severity])), el('span', '', severityLabels[severity]));
1076
+ stats.append(card);
1077
+ }
1078
+ const uncertain = el('div', 'stat suspected');
1079
+ uncertain.append(el('span', 'stat-icon', '?'), el('strong', '', String(sum.suspected)), el('span', '', 'À vérifier'));
1080
+ stats.append(uncertain);
1081
+ const coverage = el('div', 'stat coverage-stat');
1082
+ 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`));
1083
+ const bars = el('div', 'coverage-bars');
1084
+ bars.setAttribute('aria-hidden', 'true');
1085
+ const total = r.checks.length || 1;
1086
+ for (const [status, count] of Object.entries(sum.checks)) {
1087
+ const bar = el('span', status);
1088
+ bar.style.flexGrow = String(count / total);
1089
+ if (count)
1090
+ bars.append(bar);
1091
+ }
1092
+ coverage.append(bars);
1093
+ stats.append(coverage);
1094
+ 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.'));
1095
+ if (r.limits.length || r.exclusions.length) {
1096
+ const limits = el('details', 'limits');
1097
+ limits.append(el('summary', '', `Limites & périmètre · ${r.limits.length} limite(s) signalée(s)`));
1098
+ const ul = el('ul');
1099
+ for (const t of [...r.scope.map(t => `Inclus : ${t}`), ...r.exclusions.map(t => `Exclu : ${t}`), ...r.limits])
1100
+ ul.append(el('li', '', t));
1101
+ limits.append(ul);
1102
+ main.append(limits);
1103
+ if (r.limits.length)
1104
+ main.append(el('p', 'important-limit', r.limits[0]));
1105
+ }
1106
+ const workspace = el('section', `workspace ${state.mobileDetail ? 'show-detail' : ''}`);
1107
+ const left = el('div', 'list-panel');
1108
+ 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'));
1109
+ const panel = el('div', 'view-panel');
1110
+ panel.id = 'view-panel';
1111
+ panel.setAttribute('role', 'tabpanel');
1112
+ panel.setAttribute('aria-labelledby', `view-panel-${state.view}`);
1113
+ if (state.view === 'findings')
1114
+ renderFindingList(panel);
1115
+ if (state.view === 'coverage')
1116
+ renderCoverage(panel);
1117
+ if (state.view === 'sources')
1118
+ renderSources(panel);
1119
+ left.append(panel);
1120
+ const detail = el('article', 'detail-panel');
1121
+ detail.id = 'finding-detail';
1122
+ renderDetail(detail);
1123
+ workspace.append(left, detail);
1124
+ main.append(workspace);
1125
+ }
1126
+ function renderFindingList(parent) {
1127
+ const filters = el('div', 'filters');
1128
+ const searchLabel = el('label', 'search-label', 'Rechercher un constat');
1129
+ const search = el('input', 'search');
1130
+ search.type = 'search';
1131
+ search.placeholder = 'Titre, identifiant, fichier…';
1132
+ search.value = state.filters.query;
1133
+ search.id = 'finding-search';
1134
+ searchLabel.append(search);
1135
+ filters.append(searchLabel);
1136
+ 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]) {
1137
+ const option = el('option', '', name);
1138
+ option.value = value;
1139
+ node.append(option);
1140
+ } node.value = state.filters[key]; node.addEventListener('change', () => { state.filters[key] = node.value; renderListOnly(); }); l.append(node); filters.append(l); };
1141
+ select('domain', 'Domaine', [...new Set(review.findings.map(f => f.domain))].sort().map(v => [v, v]));
1142
+ select('severity', 'Gravité', Object.entries(severityLabels));
1143
+ select('confidence', 'Confiance', Object.entries(confidenceLabels));
1144
+ select('resolution', 'Résolution', Object.entries(resolutionLabels));
1145
+ search.addEventListener('input', () => { state.filters.query = search.value; renderListOnly(); });
1146
+ parent.append(filters);
1147
+ const results = el('div', 'finding-results');
1148
+ results.id = 'finding-results';
1149
+ parent.append(results);
1150
+ renderListOnly();
1151
+ // The parent is not yet attached during a complete render.
1152
+ fillFindingResults(results);
1153
+ }
1154
+ function renderListOnly() { const list = document.getElementById('finding-results'); if (list)
1155
+ fillFindingResults(list); const note = document.getElementById('detail-filter-note'); if (note && review)
1156
+ note.hidden = filterFindings(review, state.filters).some(f => f.id === state.selected); }
1157
+ function fillFindingResults(parent) {
1158
+ parent.replaceChildren();
1159
+ const findings = filterFindings(review, state.filters);
1160
+ const count = el('p', 'result-count', `${findings.length} constat(s) affiché(s) sur ${review.findings.length}`);
1161
+ count.setAttribute('role', 'status');
1162
+ parent.append(count);
1163
+ if (!findings.length) {
1164
+ 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.'));
1165
+ return;
1166
+ }
1167
+ for (const f of findings) {
1168
+ 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(); });
1169
+ b.setAttribute('aria-label', `${f.id} ${f.title}, ${severityLabels[f.severity]}, ${confidenceLabels[f.confidence]}, ${resolutionLabels[f.resolution]}`);
1170
+ b.setAttribute('aria-current', String(state.selected === f.id));
1171
+ const top = el('div', 'finding-top');
1172
+ top.append(el('strong', 'finding-id', f.id), badge(confidenceLabels[f.confidence], f.confidence, f.confidence === 'confirmed' ? '✓' : '?'));
1173
+ b.append(top, el('h3', '', f.title));
1174
+ const meta = el('div', 'finding-meta');
1175
+ meta.append(badge(severityLabels[f.severity], f.severity, '!'), el('span', '', f.domain), el('span', 'resolution', resolutionLabels[f.resolution]));
1176
+ b.append(meta);
1177
+ parent.append(b);
1178
+ }
1179
+ }
1180
+ function renderCoverage(parent) {
1181
+ 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.'));
1182
+ if (!review.checks.length)
1183
+ parent.append(el('p', 'empty small', 'Aucun contrôle enregistré : couverture inconnue.'));
1184
+ for (const domain of [...new Set(review.checks.map(c => c.domain))]) {
1185
+ parent.append(el('h3', 'domain-title', domain));
1186
+ for (const c of review.checks.filter(c => c.domain === domain)) {
1187
+ const card = el('section', 'check-card');
1188
+ 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}`));
1189
+ if (c.reason)
1190
+ card.append(el('p', '', `Raison : ${c.reason}`));
1191
+ for (const id of c.evidenceIds) {
1192
+ const e = review.evidence.find(e => e.id === id);
1193
+ const d = el('details');
1194
+ d.append(el('summary', '', `Preuve ${id} · ${e.title}`), el('pre', '', e.content));
1195
+ card.append(d);
1196
+ }
1197
+ if (!c.evidenceIds.length)
1198
+ card.append(el('p', 'muted', 'Aucune preuve jointe.'));
1199
+ parent.append(card);
1200
+ }
1201
+ }
1202
+ }
1203
+ function sourceCard(source) {
1204
+ const card = el('section', 'source-card');
1205
+ 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}`));
1206
+ textBlock('Usage', source.usage, card);
1207
+ textBlock('Compatibilité & limites', source.compatibility, card);
1208
+ textBlock('Provenance', source.provenance, card);
1209
+ link('Ouvrir la référence', source.url, card);
1210
+ return card;
1211
+ }
1212
+ function renderSources(parent) {
1213
+ parent.append(el('h2', 'panel-title', 'Sources & technologies'));
1214
+ for (const t of review.technologies)
1215
+ parent.append(el('p', 'technology', `${t.name} ${t.version} — ${t.detectedFrom}`));
1216
+ if (!review.sources.length)
1217
+ parent.append(el('p', 'empty small', 'Aucune source consultée enregistrée.'));
1218
+ for (const source of review.sources)
1219
+ parent.append(sourceCard(source));
1220
+ }
1221
+ function renderDetail(parent) {
1222
+ const back = button('← Retour aux résultats', 'back-button', () => { state.mobileDetail = false; render(); (document.querySelector('.finding.selected') || document.getElementById('view-panel-findings'))?.focus(); });
1223
+ parent.append(back);
1224
+ const f = review.findings.find(f => f.id === state.selected);
1225
+ if (!f) {
1226
+ 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.'));
1227
+ return;
1228
+ }
1229
+ const filterNote = el('p', 'notice', 'Ce constat est hors de la sélection filtrée. Son détail reste disponible.');
1230
+ filterNote.id = 'detail-filter-note';
1231
+ filterNote.hidden = filterFindings(review, state.filters).some(item => item.id === f.id);
1232
+ parent.append(filterNote);
1233
+ const title = el('h2', 'detail-title', `${f.id} · ${f.title}`);
1234
+ title.id = 'detail-title';
1235
+ title.tabIndex = -1;
1236
+ parent.append(title);
1237
+ const badges = el('div', 'detail-badges');
1238
+ badges.append(badge(severityLabels[f.severity], f.severity, '!'), badge(confidenceLabels[f.confidence], f.confidence, f.confidence === 'confirmed' ? '✓' : '?'), badge(resolutionLabels[f.resolution], 'neutral', '◌'));
1239
+ 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}` : ''}`));
1240
+ parent.append(tabs([['evidence', 'Preuve'], ['correction', 'Correction'], ['references', 'Références']], state.section, id => { state.section = id; render(); }, 'Détail du constat', 'detail-content'));
1241
+ const content = el('div', 'detail-content');
1242
+ content.id = 'detail-content';
1243
+ content.setAttribute('role', 'tabpanel');
1244
+ content.setAttribute('aria-labelledby', `detail-content-${state.section}`);
1245
+ if (state.section === 'evidence')
1246
+ evidenceDetail(f, content);
1247
+ if (state.section === 'correction') {
1248
+ textBlock('Correction recommandée', f.correction, content);
1249
+ textBlock('Conséquences & compromis', f.tradeoffs, content);
1250
+ textBlock('Vérification après correction', f.verification, content);
1251
+ textBlock('État de résolution', `${resolutionLabels[f.resolution]} · Preuves de résolution : ${f.resolutionEvidenceIds.join(', ') || 'aucune'}`, content);
1252
+ ticketLinks(f, content);
1253
+ }
1254
+ if (state.section === 'references') {
1255
+ textBlock('Justification de la gravité', f.severityReason, content);
1256
+ if (!f.sourceIds.length)
1257
+ content.append(el('p', 'empty small', 'Aucune référence pertinente renseignée.'));
1258
+ for (const id of f.sourceIds)
1259
+ content.append(sourceCard(review.sources.find(s => s.id === id)));
1260
+ }
1261
+ parent.append(content);
1262
+ const actions = el('footer', 'detail-actions');
1263
+ 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(); }));
1264
+ if (f.evidenceIds.length)
1265
+ actions.append(button('▤ Voir la preuve', 'button primary', () => { state.section = 'evidence'; render(); document.getElementById('evidence-full')?.scrollIntoView({ block: 'nearest' }); document.getElementById('evidence-full')?.focus(); }));
1266
+ parent.append(actions);
1267
+ }
1268
+ function ticketLinks(f, parent) {
1269
+ if (!f.ticketIds.length)
1270
+ parent.append(el('p', 'muted', 'Aucun ticket associé.'));
1271
+ for (const id of f.ticketIds) {
1272
+ const t = review.tickets.find(t => t.id === id);
1273
+ parent.append(el('p', '', `${t.id} · ${t.title}`));
1274
+ link('Ouvrir le ticket', t.url, parent, 'button primary');
1275
+ }
1276
+ }
1277
+ function evidenceDetail(f, parent) {
1278
+ textBlock('Scénario déclencheur', f.trigger, parent);
1279
+ const compare = el('div', 'compare');
1280
+ textBlock('Attendu', f.expected, compare);
1281
+ textBlock('Observé', f.observed, compare);
1282
+ parent.append(compare);
1283
+ const steps = el('ol', 'reproduction');
1284
+ for (const step of f.reproduction)
1285
+ steps.append(el('li', '', step));
1286
+ parent.append(steps);
1287
+ if (!f.evidenceIds.length)
1288
+ parent.append(el('p', 'notice', 'Preuve non disponible : ce constat repose sur les étapes et limites décrites ci-dessus.'));
1289
+ for (const [index, id] of f.evidenceIds.entries()) {
1290
+ const e = review.evidence.find(e => e.id === id);
1291
+ const card = el('section', 'evidence-card');
1292
+ if (index === 0) {
1293
+ card.id = 'evidence-full';
1294
+ card.tabIndex = -1;
1295
+ }
1296
+ card.append(el('h3', '', e.title));
1297
+ if (e.kind === 'diagram')
1298
+ card.append(el('span', 'eyebrow', 'SCHÉMA EXPLICATIF · PAS UNE CAPTURE D’EXÉCUTION'));
1299
+ if (e.image) {
1300
+ const img = el('img', 'evidence-image');
1301
+ img.src = `data:${e.image.mime};base64,${e.image.base64}`;
1302
+ img.alt = e.image.alt;
1303
+ 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);
1304
+ img.addEventListener('error', () => { img.replaceWith(el('p', 'notice', `Image indisponible. Alternative : ${e.image.alt}`)); });
1305
+ }
1306
+ card.append(el('pre', e.kind === 'diagram' ? 'diagram' : '', e.content));
1307
+ if (e.url)
1308
+ link('Ouvrir la preuve complète', e.url, card);
1309
+ else
1310
+ card.append(el('p', 'muted', 'Preuve incluse dans ce rapport ; aucun lien externe renseigné.'));
1311
+ parent.append(card);
1312
+ }
1313
+ const correction = el('div', 'correction-preview');
1314
+ correction.append(el('strong', '', '⌁ Correction proposée'), el('p', '', f.correction));
1315
+ parent.append(correction);
1316
+ }
1317
+ function hashSelection() {
1318
+ const id = new URLSearchParams(location.hash.slice(1)).get('finding');
1319
+ if (id) {
1320
+ state.selected = id;
1321
+ state.view = 'findings';
1322
+ state.mobileDetail = true;
1323
+ }
1324
+ }
1325
+ window.addEventListener('hashchange', () => { hashSelection(); render(); });
1326
+ window.addEventListener('popstate', () => { hashSelection(); render(); });
1327
+ try {
1328
+ const payload = JSON.parse(data.textContent || '{}');
1329
+ if (payload.review != null)
1330
+ review = sanitizedReview(payload.review);
1331
+ if (typeof payload.legacy === 'string')
1332
+ legacy = redactReviewText(payload.legacy);
1333
+ currentRevision = typeof payload.currentRevision === 'string' ? payload.currentRevision : null;
1334
+ changedTargets = Array.isArray(payload.changedTargets) && payload.changedTargets.every(v => typeof v === 'string') ? payload.changedTargets : [];
1335
+ // Saved UI state is only a convenience; validate before using it in selectors/rendering.
1336
+ const saved = payload.uiState;
1337
+ 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'))
1338
+ state = saved;
1339
+ if (!state.selected && review?.findings[0])
1340
+ state.selected = review.findings[0].id;
1341
+ hashSelection();
1342
+ render();
1343
+ }
1344
+ catch {
1345
+ review = null;
1346
+ legacy = null;
1347
+ showError('Résultats invalides ou version non supportée. Ouvrez une review format 1 ou un rapport Markdown historique.');
1348
+ }
1349
+
1350
+ })();
1351
+ </script></body></html>