prooflist 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,853 @@
1
+ <!doctype html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>ProofList — 신뢰 원장</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
10
+ <style>
11
+ :root{
12
+ /* paper + ink */
13
+ --paper:#EFEDE4;
14
+ --paper-2:#E7E4D8;
15
+ --surface:#FBFAF6;
16
+ --surface-2:#F6F4EC;
17
+ --ink:#26241F;
18
+ --ink-2:#56524A;
19
+ --ink-3:#8C887D;
20
+ --hair:#DEDACE;
21
+ --hair-2:#CFCABA;
22
+ /* Claude signature */
23
+ --coral:#D97757;
24
+ --coral-deep:#C4633F;
25
+ --coral-wash:#F4E2D8;
26
+ /* status */
27
+ --todo:#8C887D;
28
+ --prog:#3A6FA5;
29
+ --done:#3F8A60;
30
+ --blocked:#B23B33;
31
+ --human:#7A57B8;
32
+ /* verification */
33
+ --ev:#3F8A60;
34
+ --ev-wash:#E6F0E8;
35
+ --alarm:#C5362F;
36
+ --alarm-deep:#9E241E;
37
+ --alarm-wash:#FBE6E2;
38
+ --hum:#7A57B8;
39
+ --hum-wash:#ECE5F7;
40
+ --mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
41
+ --sans:"IBM Plex Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
42
+ }
43
+ *{box-sizing:border-box}
44
+ html,body{margin:0}
45
+ body{
46
+ font-family:var(--sans);
47
+ background:var(--paper);
48
+ color:var(--ink);
49
+ -webkit-font-smoothing:antialiased;
50
+ font-size:14px;
51
+ line-height:1.45;
52
+ word-break:keep-all;
53
+ }
54
+ /* faint grid texture for the "ledger / mission-control" feel */
55
+ body::before{
56
+ content:"";position:fixed;inset:0;pointer-events:none;z-index:0;
57
+ background-image:linear-gradient(var(--hair) 1px,transparent 1px);
58
+ background-size:100% 28px;opacity:.18;
59
+ }
60
+ .app{position:relative;z-index:1;max-width:1360px;margin:0 auto;padding:0 20px 64px}
61
+
62
+ /* ───────────── header ───────────── */
63
+ header.top{position:sticky;top:0;z-index:30;background:var(--paper);
64
+ border-bottom:1px solid var(--hair-2);margin:0 -20px;padding:14px 20px 0}
65
+ .brandrow{display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap}
66
+ .mark{width:30px;height:30px;border-radius:7px;background:var(--coral);
67
+ display:grid;place-items:center;flex:none;box-shadow:0 1px 0 var(--coral-deep)}
68
+ .mark svg{width:18px;height:18px;display:block}
69
+ .brand{display:flex;flex-direction:column;line-height:1.05}
70
+ .brand b{font-size:20px;font-weight:700;letter-spacing:-.01em}
71
+ .brand small{font-family:var(--mono);font-size:10.5px;color:var(--ink-3);
72
+ letter-spacing:.02em;margin-top:3px}
73
+ .live{margin-left:auto;display:flex;align-items:center;gap:7px;
74
+ font-family:var(--mono);font-size:11px;color:var(--ink-3)}
75
+ .live .pulse{width:7px;height:7px;border-radius:50%;background:var(--done);
76
+ box-shadow:0 0 0 0 rgba(63,138,96,.5);animation:beat 3s ease-out infinite}
77
+ @keyframes beat{0%{box-shadow:0 0 0 0 rgba(63,138,96,.45)}70%{box-shadow:0 0 0 7px rgba(63,138,96,0)}100%{box-shadow:0 0 0 0 rgba(63,138,96,0)}}
78
+
79
+ /* metrics log line */
80
+ .metrics{display:flex;flex-wrap:wrap;align-items:baseline;gap:0;
81
+ font-family:var(--mono);font-size:13px;margin:14px 0 0;
82
+ padding:9px 12px;background:var(--surface-2);border:1px solid var(--hair);
83
+ border-bottom:none;border-radius:8px 8px 0 0}
84
+ .metrics .seg{display:inline-flex;align-items:baseline;gap:6px;padding:0 14px;
85
+ border-right:1px solid var(--hair-2)}
86
+ .metrics .seg:first-child{padding-left:2px}
87
+ .metrics .seg:last-child{border-right:none}
88
+ .metrics .k{color:var(--ink-3);font-size:11px;text-transform:uppercase;letter-spacing:.06em;white-space:nowrap}
89
+ .metrics .v{font-weight:600;font-size:15px;color:var(--ink);white-space:nowrap}
90
+ .metrics .seg.warn .v{color:var(--alarm)}
91
+ .metrics .seg.warn .k{color:var(--alarm-deep)}
92
+ .metrics .seg.hum .v{color:var(--human)}
93
+
94
+ /* filter bar */
95
+ .filters{display:flex;flex-wrap:wrap;gap:8px;align-items:center;
96
+ padding:10px 12px;background:var(--surface-2);border:1px solid var(--hair);
97
+ border-radius:0 0 8px 8px;margin-bottom:18px}
98
+ .fgroup{display:flex;gap:4px;align-items:center}
99
+ .flabel{font-family:var(--mono);font-size:10px;text-transform:uppercase;
100
+ letter-spacing:.08em;color:var(--ink-3);margin-right:4px}
101
+ .chip{font-family:var(--mono);font-size:12px;padding:4px 10px;border-radius:6px;
102
+ border:1px solid var(--hair-2);background:var(--surface);color:var(--ink-2);
103
+ cursor:pointer;user-select:none;transition:.12s;white-space:nowrap}
104
+ .chip:hover{border-color:var(--ink-3)}
105
+ .chip[aria-pressed="true"]{background:var(--ink);color:var(--paper);border-color:var(--ink)}
106
+ .chip.danger[aria-pressed="true"]{background:var(--alarm);border-color:var(--alarm);color:#fff}
107
+ .fsep{width:1px;height:20px;background:var(--hair-2);margin:0 4px}
108
+
109
+ /* ───────────── layout ───────────── */
110
+ .cols{display:grid;grid-template-columns:1fr 348px;gap:22px;align-items:start}
111
+ .panel-h{display:flex;align-items:center;gap:8px;margin:0 0 10px;
112
+ font-family:var(--mono);font-size:11px;text-transform:uppercase;
113
+ letter-spacing:.09em;color:var(--ink-3);white-space:nowrap}
114
+ .panel-h .n{margin-left:auto;background:var(--paper-2);border:1px solid var(--hair-2);
115
+ border-radius:20px;padding:1px 8px;font-weight:600;color:var(--ink-2)}
116
+
117
+ /* ───────────── tree node ───────────── */
118
+ .tree{display:flex;flex-direction:column;gap:7px}
119
+ .node{display:flex;flex-direction:column}
120
+ .row{display:flex;align-items:stretch;gap:10px;background:var(--surface);
121
+ border:1px solid var(--hair);border-radius:9px;padding:9px 12px 9px 10px;
122
+ position:relative;transition:border-color .12s,box-shadow .12s}
123
+ .row:hover{border-color:var(--hair-2)}
124
+ .row .twisty{flex:none;width:18px;height:18px;border:none;background:none;
125
+ cursor:pointer;color:var(--ink-3);font-size:11px;display:grid;place-items:center;
126
+ margin-top:1px;border-radius:4px}
127
+ .row .twisty:hover{background:var(--surface-2);color:var(--ink)}
128
+ .row .twisty.leaf{visibility:hidden;cursor:default}
129
+ .statwrap{flex:none;display:flex;align-items:center;padding-top:2px}
130
+ .dot{width:9px;height:9px;border-radius:50%;flex:none}
131
+ .dot.todo{background:var(--todo)}
132
+ .dot.prog{background:var(--prog);box-shadow:0 0 0 3px rgba(58,111,165,.15)}
133
+ .dot.done{background:var(--done)}
134
+ .dot.blocked{background:var(--blocked);box-shadow:0 0 0 3px rgba(178,59,51,.15)}
135
+ .dot.needshuman{background:var(--human);box-shadow:0 0 0 3px rgba(122,87,184,.15)}
136
+
137
+ .rmain{flex:1;min-width:0}
138
+ .tline{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
139
+ .ttext{font-weight:600;font-size:14px;letter-spacing:-.005em;color:var(--ink);white-space:nowrap}
140
+ @media (max-width:560px){.ttext{white-space:normal}}
141
+ .tag{font-family:var(--mono);font-size:10px;letter-spacing:.03em;
142
+ padding:1.5px 6px;border-radius:5px;border:1px solid var(--hair-2);
143
+ color:var(--ink-2);background:var(--surface-2);text-transform:lowercase;white-space:nowrap;flex:none}
144
+ .tag.kind-logic{color:#3A6FA5;border-color:#bcd0e3}
145
+ .tag.kind-screen{color:#7a5a2e;border-color:#ddccab}
146
+ .tag.kind-humangate{color:var(--human);border-color:#d2c2ee}
147
+ .tag.plat{color:var(--ink-3)}
148
+ .lock{font-size:11px;color:var(--ink-3);display:inline-flex;align-items:center;gap:2px}
149
+ .statename{font-family:var(--mono);font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.05em}
150
+
151
+ /* auto progress bar */
152
+ .prog-row{display:flex;align-items:center;gap:9px;margin-top:7px}
153
+ .track{flex:1;height:5px;border-radius:3px;background:var(--paper-2);overflow:hidden;
154
+ border:1px solid var(--hair)}
155
+ .fill{height:100%;background:var(--ink-3);border-radius:3px;transition:width .6s ease}
156
+ .fill.done{background:var(--done)}
157
+ .fill.prog{background:var(--prog)}
158
+ .fill.blocked{background:var(--blocked)}
159
+ .fill.needshuman{background:var(--human)}
160
+ .pct{font-family:var(--mono);font-size:10.5px;color:var(--ink-3);min-width:34px;text-align:right}
161
+ .autotag{font-family:var(--mono);font-size:9px;color:var(--ink-3);
162
+ border:1px dashed var(--hair-2);border-radius:4px;padding:0 4px;letter-spacing:.03em}
163
+
164
+ /* verification indicator (right) */
165
+ .verify{flex:none;align-self:flex-start;display:inline-flex;align-items:center;gap:6px;
166
+ font-family:var(--mono);font-size:11px;font-weight:500;padding:5px 9px;border-radius:7px;
167
+ white-space:nowrap;line-height:1}
168
+ .verify .ic{font-size:12px}
169
+ .verify.ev{background:var(--ev-wash);color:#2f6b49;border:1px solid #c3ddca}
170
+ .verify.hum{background:var(--hum-wash);color:var(--hum);border:1px solid #d6c8ef}
171
+ .verify.none{background:var(--surface-2);color:var(--ink-3);border:1px solid var(--hair)}
172
+ .verify.alarm{background:var(--alarm);color:#fff;border:1px solid var(--alarm-deep);
173
+ box-shadow:0 1px 4px rgba(197,54,47,.35)}
174
+
175
+ /* ALARM row — loudest thing on screen */
176
+ .row.alarm{border:2px solid var(--alarm);background:var(--alarm-wash);
177
+ box-shadow:0 0 0 4px rgba(197,54,47,.10),0 4px 16px rgba(197,54,47,.14);
178
+ animation:alarmglow 2.4s ease-in-out infinite}
179
+ @keyframes alarmglow{0%,100%{box-shadow:0 0 0 4px rgba(197,54,47,.08),0 4px 14px rgba(197,54,47,.10)}
180
+ 50%{box-shadow:0 0 0 6px rgba(197,54,47,.16),0 6px 20px rgba(197,54,47,.22)}}
181
+ .row.alarm .ttext{color:var(--alarm-deep)}
182
+ .row.alarm .twisty:hover{background:rgba(197,54,47,.1)}
183
+ @media (prefers-reduced-motion:reduce){
184
+ .row.alarm{animation:none}.live .pulse{animation:none}
185
+ }
186
+
187
+ /* children indent */
188
+ .children{display:none;flex-direction:column;gap:7px;margin-top:7px;
189
+ margin-left:17px;padding-left:13px;border-left:1.5px dotted var(--hair-2)}
190
+ .node.open>.children{display:flex}
191
+ .node.open>.row .twisty .ar{transform:rotate(90deg)}
192
+ .twisty .ar{display:inline-block;transition:transform .15s}
193
+
194
+ /* ───────────── evidence panel ───────────── */
195
+ .evpanel{display:none;margin:7px 0 2px 39px;background:var(--surface-2);
196
+ border:1px solid var(--hair);border-radius:8px;padding:11px 13px}
197
+ .node.open-ev>.evpanel{display:block}
198
+ .ev-h{font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:.07em;
199
+ color:var(--ink-3);display:flex;align-items:center;gap:8px;margin-bottom:9px}
200
+ .ev-h .res{padding:1px 7px;border-radius:5px;font-weight:600}
201
+ .ev-h .res.pass{background:var(--ev-wash);color:#2f6b49}
202
+ .ev-h .res.fail{background:var(--alarm-wash);color:var(--alarm-deep)}
203
+ .shots{display:flex;gap:9px;overflow-x:auto;padding-bottom:6px;scrollbar-width:thin}
204
+ .shot{flex:none;width:74px}
205
+ .phone{width:74px;height:148px;border-radius:9px;border:1px solid var(--hair-2);
206
+ background:linear-gradient(170deg,#fff,#f1eee5);position:relative;overflow:hidden;
207
+ box-shadow:0 2px 5px rgba(0,0,0,.06)}
208
+ .phone .notch{position:absolute;top:5px;left:50%;transform:translateX(-50%);
209
+ width:22px;height:3px;border-radius:3px;background:var(--hair-2)}
210
+ .phone .pbar{position:absolute;left:9px;right:9px;height:6px;border-radius:3px;background:var(--paper-2)}
211
+ .phone .pblk{position:absolute;left:9px;right:9px;border-radius:5px;background:var(--surface-2);border:1px solid var(--hair)}
212
+ .phone .pbtn{position:absolute;left:9px;right:9px;bottom:10px;height:14px;border-radius:7px;background:var(--coral);opacity:.85}
213
+ .shot .cap{font-family:var(--mono);font-size:9px;color:var(--ink-3);margin-top:5px;
214
+ display:flex;align-items:center;gap:4px;justify-content:space-between}
215
+ .rchip{font-size:8.5px;padding:1px 5px;border-radius:4px;font-weight:600}
216
+ .rchip.pass{background:var(--ev-wash);color:#2f6b49}
217
+ .rchip.fail{background:var(--alarm-wash);color:var(--alarm-deep)}
218
+ .ev-meta{display:flex;flex-wrap:wrap;gap:14px;margin-top:11px;
219
+ font-family:var(--mono);font-size:10.5px;color:var(--ink-3)}
220
+ .ev-meta b{color:var(--ink-2);font-weight:600}
221
+ .hashpill{background:var(--paper);border:1px solid var(--hair-2);border-radius:5px;padding:1px 6px;color:var(--ink-2)}
222
+
223
+ /* evidence panel — logic (test run) */
224
+ .ev-line{display:flex;align-items:center;gap:10px;font-family:var(--mono);
225
+ font-size:11.5px;color:var(--ink-2)}
226
+ .ev-line .tick{color:var(--ev);font-weight:600}
227
+
228
+ /* evidence panel — MISSING (the audit punch) */
229
+ .evpanel.missing{background:var(--alarm-wash);border-color:var(--alarm)}
230
+ .evpanel.missing .ev-h{color:var(--alarm-deep)}
231
+ .missing-body{display:flex;gap:11px;align-items:flex-start}
232
+ .missing-body .big{font-size:20px;line-height:1}
233
+ .missing-body p{margin:0;font-size:12.5px;color:var(--alarm-deep);line-height:1.5}
234
+ .missing-body p .claim{font-family:var(--mono);background:#fff;border:1px solid #e7b9b3;
235
+ border-radius:4px;padding:0 5px;color:var(--alarm-deep)}
236
+
237
+ /* ───────────── human-gate panel ───────────── */
238
+ .gatecard{background:var(--surface);border:1px solid var(--hair-2);border-radius:11px;
239
+ padding:14px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
240
+ .gate-banner{display:flex;gap:9px;align-items:flex-start;background:var(--hum-wash);
241
+ border:1px solid #d6c8ef;border-radius:8px;padding:10px 11px;margin-bottom:13px}
242
+ .gate-banner .gi{flex:none;font-size:16px;color:var(--human)}
243
+ .gate-banner p{margin:0;font-size:11.5px;line-height:1.5;color:#4a3a72}
244
+ .gate-banner b{color:var(--human)}
245
+ .gitem{border:1px solid var(--hair);border-radius:9px;padding:11px;margin-bottom:10px;background:var(--surface)}
246
+ .gitem:last-child{margin-bottom:0}
247
+ .gitem .gt{font-weight:600;font-size:13px;margin-bottom:3px}
248
+ .gitem .gmeta{font-family:var(--mono);font-size:10px;color:var(--ink-3);
249
+ display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}
250
+ .gitem .gmeta span{white-space:nowrap}
251
+ .humanonly{display:inline-flex;align-items:center;gap:5px;font-family:var(--mono);
252
+ font-size:9px;text-transform:uppercase;letter-spacing:.06em;color:var(--human);
253
+ background:var(--hum-wash);border:1px solid #d6c8ef;border-radius:5px;padding:1.5px 6px;
254
+ margin-bottom:9px;white-space:nowrap}
255
+ .signbtn,.ai-cant,.live,.statename,.autotag,.verify .lbl{white-space:nowrap}
256
+ .signbtn{width:100%;border:1.5px solid var(--human);background:var(--human);color:#fff;
257
+ font-family:var(--mono);font-size:12px;font-weight:600;letter-spacing:.02em;
258
+ padding:9px;border-radius:8px;cursor:pointer;display:flex;align-items:center;
259
+ justify-content:center;gap:7px;transition:.13s}
260
+ .signbtn:hover{background:#6847a8}
261
+ .signbtn:active{transform:translateY(1px)}
262
+ .signbtn .who{opacity:.85;font-weight:400}
263
+ .signed{background:var(--ev-wash);border:1px solid #c3ddca;border-radius:8px;
264
+ padding:9px 11px;font-family:var(--mono);font-size:11px;color:#2f6b49;
265
+ display:flex;align-items:center;gap:7px}
266
+ .ai-cant{font-family:var(--mono);font-size:9.5px;color:var(--ink-3);
267
+ text-align:center;margin-top:7px;display:flex;align-items:center;justify-content:center;gap:5px}
268
+ .ai-cant s{text-decoration:line-through;opacity:.7}
269
+
270
+ .empty{font-family:var(--mono);font-size:11px;color:var(--ink-3);text-align:center;padding:20px 0}
271
+
272
+ /* ───────────── strength badges (4-tier) ───────────── */
273
+ .verify.s-ran{background:var(--ev-wash);color:#2f6b49;border:1px solid #c3ddca}
274
+ .verify.s-vis{background:#1E5A38;color:#EAFBF0;border:1px solid #143f28}
275
+ .verify.s-vis .ic{font-size:11px}
276
+ .verify.s-scn{background:#FBEBD9;color:#94560F;border:1px solid #E9C99B}
277
+ .verify.s-none{background:var(--surface-2);color:var(--ink-3);border:1px dashed var(--hair-2)}
278
+
279
+ /* metric strength colors */
280
+ .metrics .seg.vis .v{color:#1E5A38}
281
+ .metrics .seg.scn .v{color:#94560F}
282
+ .metrics .seg.scn .k{color:#a06314}
283
+
284
+ /* ───────────── Tier1 framework panel ───────────── */
285
+ .rail{display:flex;flex-direction:column;gap:22px}
286
+ .fwcard{background:var(--surface);border:1px solid var(--hair-2);border-radius:11px;
287
+ padding:13px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
288
+ .fwlegend{font-family:var(--mono);font-size:9.5px;color:var(--ink-3);
289
+ letter-spacing:.01em;margin:0 0 11px;line-height:1.55}
290
+ .fwlegend b{color:#2f6b49}
291
+ .fwgrid{display:flex;flex-direction:column;gap:7px}
292
+ .fwchip{display:flex;align-items:center;gap:9px;border:1px solid var(--hair);
293
+ border-radius:8px;padding:7px 10px;background:var(--surface)}
294
+ .fwchip.fw-unused{opacity:.55}
295
+ .fwdot{width:9px;height:9px;border-radius:50%;flex:none;background:var(--ink-3)}
296
+ .fwdot.on{background:var(--done);box-shadow:0 0 0 3px rgba(63,138,96,.16)}
297
+ .fwdot.scn{background:#C9761F;box-shadow:0 0 0 3px rgba(201,118,31,.16)}
298
+ .fwdot.off{background:var(--hair-2);box-shadow:none}
299
+ .fwname{font-weight:600;font-size:12.5px;flex:none}
300
+ .fwstatus{font-family:var(--mono);font-size:9.5px;color:var(--ink-3);white-space:nowrap;
301
+ overflow:hidden;text-overflow:ellipsis}
302
+ .fwmark{margin-left:auto;font-family:var(--mono);font-size:9px;text-transform:uppercase;
303
+ letter-spacing:.05em;padding:2px 6px;border-radius:5px;white-space:nowrap;flex:none}
304
+ .fwmark.t2{color:#2f6b49;background:var(--ev-wash);border:1px solid #c3ddca}
305
+ .fwmark.t1{color:var(--ink-3);background:var(--surface-2);border:1px solid var(--hair)}
306
+ .fwwarns{margin-top:11px;display:flex;flex-direction:column;gap:6px}
307
+ .fwwarn{display:flex;gap:7px;align-items:flex-start;font-size:11px;line-height:1.45;
308
+ font-family:var(--mono);color:#94560F;background:#FBEBD9;border:1px solid #E9C99B;
309
+ border-radius:7px;padding:7px 9px}
310
+ .fwwarn.red{color:var(--alarm-deep);background:var(--alarm-wash);border-color:#e7b9b3}
311
+ .fwwarn .wi{flex:none}
312
+
313
+ /* ───────────── inline video evidence ───────────── */
314
+ .evvideo{margin-top:12px;border-top:1px dashed var(--hair-2);padding-top:12px}
315
+ .evvideo .vplayer{width:200px;height:400px;border-radius:10px;border:1px solid var(--hair-2);
316
+ background-color:#FBFAF6;background-repeat:no-repeat;background-position:0 0;
317
+ box-shadow:0 2px 6px rgba(0,0,0,.10)}
318
+ .vpbar{display:flex;align-items:center;gap:9px;width:200px;margin-top:8px}
319
+ .vp-tier{font-family:var(--mono);font-size:8.5px;color:#2f6b49;background:var(--ev-wash);
320
+ border:1px solid #c3ddca;border-radius:5px;padding:1px 5px;white-space:nowrap;flex:none}
321
+ .evvideo .vcap{font-family:var(--mono);font-size:10px;color:var(--ink-3);margin:0 0 8px;
322
+ display:flex;align-items:center;gap:7px}
323
+ .vact{display:flex;flex-direction:column;align-items:flex-start;gap:7px;margin-top:11px}
324
+ .vbtn{border:1.5px solid var(--human);background:var(--human);color:#fff;
325
+ font-family:var(--mono);font-size:11.5px;font-weight:600;letter-spacing:.01em;
326
+ padding:8px 12px;border-radius:8px;cursor:pointer;display:inline-flex;align-items:center;
327
+ gap:7px;transition:.13s}
328
+ .vbtn:hover{background:#6847a8}
329
+ .vbtn:active{transform:translateY(1px)}
330
+ .vbtn .ck{font-size:13px}
331
+ .vconfirmed{display:inline-flex;align-items:center;gap:8px;font-family:var(--mono);
332
+ font-size:11px;color:#1E5A38;background:#E4F3EA;border:1px solid #b9dcc6;
333
+ border-radius:8px;padding:8px 11px}
334
+ .vconfirmed .stamp{color:#5b8c70;font-size:9.5px}
335
+
336
+ /* ───────────── watch analysis — reference only, deliberately weak ───────────── */
337
+ .watchnote{margin-top:11px;display:flex;gap:9px;align-items:flex-start;
338
+ background:repeating-linear-gradient(45deg,#F1EFE8,#F1EFE8 7px,#EBE8DF 7px,#EBE8DF 14px);
339
+ border:1px dashed var(--hair-2);border-radius:7px;padding:9px 10px}
340
+ .watchnote .wtag{flex:none;font-family:var(--mono);font-size:8.5px;text-transform:uppercase;
341
+ letter-spacing:.04em;color:var(--ink-3);background:var(--paper);border:1px solid var(--hair-2);
342
+ border-radius:5px;padding:2px 6px;line-height:1.3}
343
+ .watchnote .wtxt{font-size:11px;color:var(--ink-3);line-height:1.5;font-style:italic}
344
+
345
+ /* ───────────── responsive ───────────── */
346
+ @media (max-width:880px){
347
+ .cols{grid-template-columns:1fr}
348
+ .gatepanel{position:static}
349
+ .verify .lbl{display:none}
350
+ .verify{padding:5px 7px}
351
+ body::before{display:none}
352
+ }
353
+ @media (max-width:560px){
354
+ .metrics{font-size:12px}.metrics .seg{padding:0 9px}
355
+ .ttext{font-size:13px}
356
+ }
357
+ </style>
358
+ </head>
359
+ <body>
360
+ <div class="app">
361
+ <header class="top">
362
+ <div class="brandrow">
363
+ <div class="mark" aria-hidden="true">
364
+ <svg viewBox="0 0 24 24" fill="none"><path d="M5 12.5l4.2 4.2L19 7" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
365
+ </div>
366
+ <div class="brand">
367
+ <b>ProofList</b>
368
+ <small>trust ledger · AI 주장 ↔ 증거 대조</small>
369
+ </div>
370
+ <div class="live"><span class="pulse"></span><span id="liveClock">--:--:--</span> · <span id="liveAgo">동기화 중</span></div>
371
+ </div>
372
+ <div class="metrics" id="metrics"></div>
373
+ <div class="filters" id="filters"></div>
374
+ </header>
375
+
376
+ <div class="cols">
377
+ <main>
378
+ <div class="panel-h">작업 트리 · 자동 집계 <span class="n" id="treeCount">0</span></div>
379
+ <div class="tree" id="tree"></div>
380
+ </main>
381
+ <aside class="rail">
382
+ <section>
383
+ <div class="panel-h">Tier1 프레임워크 탐지 <span class="n" id="fwCount">·</span></div>
384
+ <div class="fwcard" id="fw"></div>
385
+ </section>
386
+ <section class="gatepanel">
387
+ <div class="panel-h">human-gate · 사람 검증 대기 <span class="n" id="gateCount">0</span></div>
388
+ <div class="gatecard" id="gate"></div>
389
+ </section>
390
+ </aside>
391
+ </div>
392
+ </div>
393
+ <script>
394
+ "use strict";
395
+ /* ───────────── embedded fallback ledger (used if /api/checklist is unreachable) ───────────── */
396
+ const SAMPLE = { generatedBy:"claude-agent",
397
+ tier1:{
398
+ frameworks:[
399
+ { name:"Maestro", status:"active", tier2:true, note:"3개 플로우 실행 중" },
400
+ { name:"Detox", status:"scenario", tier2:false, note:"시나리오만 · 미실행" },
401
+ { name:"Playwright", status:"active", tier2:false, note:"web e2e 탐지됨" },
402
+ { name:"Appium", status:"unused", tier2:false, note:"미설정" },
403
+ { name:"XCUITest", status:"unused", tier2:false, note:"미설정" },
404
+ { name:"Espresso", status:"unused", tier2:false, note:"미설정" },
405
+ { name:"Cypress", status:"unused", tier2:false, note:"미설정" }
406
+ ],
407
+ warnings:[
408
+ { level:"orange", text:"Detox 시나리오가 마지막 실행보다 28일 오래됨 — 결과 신뢰 불가" },
409
+ { level:"red", text:"실제 실행(Tier2) 가능 = Maestro 1개뿐. 나머지는 탐지만 가능." }
410
+ ]
411
+ },
412
+ groups:[
413
+ { id:"g-auth", title:"인증 · 온보딩", status:"in-progress", kind:"logic", platform:"ios", children:[
414
+ { id:"a1", title:"로그인 검증 로직", kind:"logic", platform:"cli", status:"done",
415
+ evidence:{type:"test", framework:"Jest", scenario:true, ran:true, result:"pass", runAt:"오늘 14:21:07", hash:"9f3c1a4b…e2", tests:"52 passed · 0 failed"} },
416
+ { id:"a2", title:"로그인 화면", kind:"screen", platform:"ios", status:"in-progress", children:[
417
+ { id:"a2a", title:"이메일 / 비밀번호 입력 검증", kind:"logic", platform:"cli", status:"done",
418
+ evidence:{type:"test", framework:"Jest", scenario:true, ran:true, result:"pass", runAt:"오늘 14:19:55", hash:"77b0d9c1…3a", tests:"19 passed · 0 failed"} },
419
+ { id:"a2b", title:"소셜 로그인 (OAuth)", kind:"screen", platform:"ios", status:"done",
420
+ evidence:{type:"screen", framework:"Maestro", scenario:true, ran:true, result:"pass", runAt:"오늘 14:24:31", hash:"a1b2c3d4…f9", video:true,
421
+ shots:[{cap:"01 진입",result:"pass"},{cap:"02 동의",result:"pass"},{cap:"03 복귀",result:"pass"}],
422
+ watchNote:"화면 전환 3회·동의 버튼 탭 감지. OCR 'Sign in with Apple' 일치 추정 — 판정 아님."} } ]},
423
+ { id:"a3", title:"비밀번호 재설정 플로우", kind:"screen", platform:"ios", status:"done", locked:true,
424
+ claim:"Maestro 플로우 5/5 통과",
425
+ evidence:{type:"screen", framework:"Maestro", scenario:true, ran:false, result:null} },
426
+ { id:"a4", title:"생체 인증 잠금해제", kind:"logic", platform:"android", status:"in-progress", pct:40,
427
+ evidence:{type:"test", framework:"Detox", scenario:true, ran:false, result:null} },
428
+ { id:"a5", title:"세션 만료 자동 로그아웃", kind:"logic", platform:"cli", status:"done",
429
+ evidence:{type:"test", framework:"Jest", scenario:true, ran:true, result:"pass", runAt:"오늘 14:02:12", hash:"2c9d10aa…b7", tests:"11 passed · 0 failed"} } ]},
430
+ { id:"g-pay", title:"결제", status:"in-progress", kind:"logic", platform:"web", children:[
431
+ { id:"b1", title:"체크아웃 API", kind:"logic", platform:"cli", status:"done",
432
+ evidence:{type:"test", framework:"Jest", scenario:true, ran:true, result:"pass", runAt:"오늘 13:58:40", hash:"4d77e2f0…1c", tests:"88 passed · 0 failed"} },
433
+ { id:"b2", title:"체크아웃 화면", kind:"screen", platform:"web", status:"done", humanVerified:true, verifiedAt:"오늘 14:12:40",
434
+ evidence:{type:"screen", framework:"Maestro", scenario:true, ran:true, result:"pass", runAt:"오늘 14:11:03", hash:"bb19f07e…d5", video:true,
435
+ shots:[{cap:"01 장바구니",result:"pass"},{cap:"02 결제수단",result:"pass"},{cap:"03 완료",result:"pass"}]} },
436
+ { id:"b3", title:"영수증 이메일 발송", kind:"logic", platform:"cli", status:"done",
437
+ claim:"전송 큐 통합 완료" /* 증거 없음 */ },
438
+ { id:"b4", title:"환불 정책 문구 (법무 검토)", kind:"human-gate", platform:"web", status:"needs-human" },
439
+ { id:"b5", title:"결제 실패 재시도", kind:"logic", platform:"cli", status:"in-progress", pct:65 } ]},
440
+ { id:"g-comp", title:"컴플라이언스 · 접근성", status:"in-progress", kind:"logic", platform:"web", children:[
441
+ { id:"c1", title:"GDPR 동의 화면", kind:"screen", platform:"web", status:"done", humanVerified:true, verifiedAt:"오늘 13:45:30",
442
+ evidence:{type:"screen", framework:"Maestro", scenario:true, ran:true, result:"pass", runAt:"오늘 13:44:19", hash:"7e0a5531…8e", video:true,
443
+ shots:[{cap:"01 배너",result:"pass"},{cap:"02 설정",result:"pass"},{cap:"03 저장",result:"pass"}]} },
444
+ { id:"c2", title:"쿠키 배너", kind:"screen", platform:"web", status:"done",
445
+ evidence:{type:"screen", framework:"Playwright", scenario:true, ran:true, result:"pass", runAt:"오늘 13:40:02", hash:"15ccab90…2f",
446
+ shots:[{cap:"01 노출",result:"pass"},{cap:"02 거부",result:"pass"}]} },
447
+ { id:"c3", title:"데이터 삭제 요청 처리", kind:"logic", platform:"cli", status:"blocked" },
448
+ { id:"c4", title:"개인정보 처리방침 검토", kind:"human-gate", platform:"web", status:"needs-human" },
449
+ { id:"c5", title:"접근성 감사 (WCAG 2.2 AA)", kind:"human-gate", platform:"ios", status:"needs-human", locked:true },
450
+ { id:"c6", title:"회원 탈퇴 플로우", kind:"screen", platform:"android", status:"done",
451
+ claim:"Detox 전체 통과",
452
+ evidence:{type:"screen", framework:"Detox", scenario:true, ran:true, result:"pass", stale:true, runAt:"28일 전 09:12:55", hash:"3aa9f1c0…7d"} } ]}
453
+ ]};
454
+
455
+ /* ───────────── session state ─────────────
456
+ 진실원장은 서버(checklist.json)다. localStorage 가 아니다 — 서명/영상확인 상태는 모두
457
+ 폴링 데이터(node.humanVerified/verifiedAt, human-gate status==="done")에서 파생된다. (F1) */
458
+ const openSet = new Set(); // node ids with children expanded
459
+ const evSet = new Set(); // node ids with evidence panel open
460
+ let initialized=false;
461
+ let liveData=null, lastSync=0;
462
+ const filt={plat:"all", status:"all", unv:false};
463
+
464
+ function clone(o){ return JSON.parse(JSON.stringify(o)); }
465
+ const esc=s=>String(s).replace(/[&<>]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
466
+
467
+ /* ───────────── normalize: compute verify + auto progress ───────────── */
468
+ function nodesOf(d){ return d.groups||[]; }
469
+ function normalize(node){
470
+ (node.children||[]).forEach(normalize);
471
+ // auto progress
472
+ if(node.children&&node.children.length){
473
+ const ch=node.children;
474
+ node.progress=Math.round(ch.reduce((s,c)=>s+c.progress,0)/ch.length);
475
+ } else {
476
+ node.progress = node.status==="done"?100
477
+ : node.status==="in-progress"?(node.pct??50)
478
+ : node.status==="blocked"?(node.pct??0)
479
+ : node.status==="needs-human"?(node.pct??0):0;
480
+ }
481
+ // verify + 4-tier evidence strength.
482
+ // human-gate 서명 진실원장은 서버 — signoff 가 human-gate status 를 "done" 으로 뒤집는다(kind 유지).
483
+ const isHumanGate = node.kind==="human-gate" || node.status==="needs-human";
484
+ const isSigned = node.kind==="human-gate" && node.status==="done";
485
+ node.claimed = node.status==="done";
486
+
487
+ if(isHumanGate && !isSigned){
488
+ node.verify="hum"; node.strength=null; node.alarm=false;
489
+ } else if(isSigned){
490
+ node.signed=true; node.verify="ev"; node.strength="human-signed"; node.alarm=false;
491
+ } else if(typeof node.strength==="string"){
492
+ // 서버 권위(F1): strength/alarm 을 그대로 신뢰한다 (증거에서 재도출하지 않음).
493
+ const s=node.strength;
494
+ node.verify = (s==="visual-verified"||s==="ran-pass") ? "ev" : (s==="scenario" ? "scn" : "none");
495
+ node.alarm = !!node.alarm;
496
+ } else {
497
+ // fallback: 내장 SAMPLE(오프라인)용 클라 도출.
498
+ let s;
499
+ if(node.humanVerified) s="visual-verified";
500
+ else {
501
+ const e=node.evidence;
502
+ if(!e) s="none";
503
+ else if(e.ran && e.result==="pass" && !e.stale) s="ran-pass";
504
+ else if(e.scenario) s="scenario"; // 시나리오만 / 미실행 / stale / fail
505
+ else s="none";
506
+ }
507
+ node.strength=s;
508
+ node.verify = (s==="visual-verified"||s==="ran-pass") ? "ev" : (s==="scenario" ? "scn" : "none");
509
+ node.alarm = node.claimed && (s==="none" || s==="scenario");
510
+ }
511
+ }
512
+
513
+ /* ───────────── metrics ───────────── */
514
+ function tally(node,acc){
515
+ if(node.verify==="hum") acc.hum++;
516
+ if(node.claimed){
517
+ acc.claims++;
518
+ if(node.strength==="visual-verified") acc.vis++;
519
+ else if(node.strength==="ran-pass") acc.ran++;
520
+ else if(node.strength==="scenario") acc.scn++;
521
+ else if(node.strength==="none") acc.none++;
522
+ }
523
+ (node.children||[]).forEach(c=>tally(c,acc));
524
+ }
525
+
526
+ /* ───────────── filter matching ───────────── */
527
+ function selfMatch(n){
528
+ const p = filt.plat==="all" || n.platform===filt.plat;
529
+ const s = filt.status==="all" || n.status===filt.status;
530
+ const u = !filt.unv || n.alarm;
531
+ return p&&s&&u;
532
+ }
533
+ function anyMatch(n){
534
+ if(selfMatch(n)) return true;
535
+ return (n.children||[]).some(anyMatch);
536
+ }
537
+ const filterActive=()=>filt.plat!=="all"||filt.status!=="all"||filt.unv;
538
+
539
+ /* ───────────── render ───────────── */
540
+ const kindClass={ "logic":"kind-logic","screen":"kind-screen","human-gate":"kind-humangate" };
541
+ const statClass={ "todo":"todo","in-progress":"prog","done":"done","blocked":"blocked","needs-human":"needshuman" };
542
+ const statName={ "todo":"todo","in-progress":"진행","done":"done","blocked":"blocked","needs-human":"needs-human" };
543
+
544
+ function phone(i){
545
+ return `<div class="phone"><div class="notch"></div>`+
546
+ `<div class="pbar" style="top:14px"></div>`+
547
+ `<div class="pblk" style="top:30px;height:${28+(i%3)*9}px"></div>`+
548
+ `<div class="pblk" style="top:${70+(i%2)*7}px;height:30px"></div>`+
549
+ `<div class="pbtn"></div></div>`;
550
+ }
551
+
552
+ function verifyHTML(n){
553
+ if(n.verify==="hum") return `<span class="verify hum"><span class="ic">🔒</span><span class="lbl">사람 검증 대기</span></span>`;
554
+ if(n.alarm){
555
+ const t = n.strength==="none" ? "증거 없음" : "미실행 · done 주장";
556
+ return `<span class="verify alarm"><span class="ic">⚠</span><span class="lbl">${t}</span></span>`;
557
+ }
558
+ switch(n.strength){
559
+ case "visual-verified": return `<span class="verify s-vis"><span class="ic">🎬</span><span class="lbl">영상 확인됨</span></span>`;
560
+ case "human-signed": return `<span class="verify ev"><span class="ic">✓</span><span class="lbl">사람 서명됨</span></span>`;
561
+ case "ran-pass": return `<span class="verify s-ran"><span class="ic">✓</span><span class="lbl">실행 통과</span></span>`;
562
+ case "scenario": return `<span class="verify s-scn"><span class="ic">◐</span><span class="lbl">시나리오만</span></span>`;
563
+ default: return `<span class="verify s-none"><span class="ic">·</span><span class="lbl">테스트 없음</span></span>`;
564
+ }
565
+ }
566
+
567
+ function watchHTML(t){
568
+ return `<div class="watchnote"><span class="wtag">AI 분석 · 참고용 · 게이트 미반영</span><span class="wtxt">${esc(t)}</span></div>`;
569
+ }
570
+ function videoBlockHTML(n,e){
571
+ // 확인 상태는 서버 권위 — node.humanVerified/verifiedAt (localStorage 아님). (F1)
572
+ let action;
573
+ if(n.humanVerified){
574
+ let stamp = esc(n.verifiedAt||"확인됨");
575
+ try{ if(n.verifiedAt) stamp = new Date(n.verifiedAt).toLocaleTimeString("ko-KR",{hour:"2-digit",minute:"2-digit"}); }catch(_){}
576
+ action = `<div class="vconfirmed">🎬 ✓ 승기: 의도한 화면 맞음 확인<span class="stamp">${stamp}</span></div>`;
577
+ } else {
578
+ action = `<span class="humanonly">👤 사람 전용 · human only</span>`+
579
+ `<button class="vbtn" data-vsign="${n.id}"><span class="ck">🎬</span> 이 영상이 의도한 화면 맞음 <span class="ck">✓</span></button>`+
580
+ `<div class="ai-cant">🚫 <s>AI는 이 확인을 누를 수 없음</s></div>`;
581
+ }
582
+ // e.video 가 사용 가능한 경로(비어있지 않은 문자열)일 때만 <video> 렌더 — 오프라인 SAMPLE 은
583
+ // video:true(boolean)라 src="" 빈 엘리먼트가 되므로 대신 "영상 없음" placeholder 를 둔다.
584
+ const hasSrc = typeof e.video==="string" && e.video.length>0;
585
+ const player = hasSrc
586
+ ? `<video class="vplayer" controls preload="metadata" src="/artifacts/${esc(e.video)}"></video>`
587
+ : `<div class="vplayer">영상 없음</div>`;
588
+ return `<div class="evvideo">
589
+ <div class="vcap">▶ 실행 영상 · 인라인 재생 · 스크럽 가능</div>
590
+ ${player}
591
+ <div class="vpbar">
592
+ <span class="vp-tier">Tier2 · ${esc(e.framework||"Maestro")}</span>
593
+ </div>
594
+ <div class="vact">${action}</div>
595
+ </div>`;
596
+ }
597
+ function evidencePanelHTML(n){
598
+ if(n.alarm){
599
+ const scn = n.strength==="scenario";
600
+ const e=n.evidence;
601
+ const head = scn ? "증거 대조 · 미실행/오래됨" : "증거 대조 · 실패";
602
+ const tag = scn ? `<span class="res fail">NOT RUN</span>` : `<span class="res fail">NO EVIDENCE</span>`;
603
+ const body = scn
604
+ ? `<p>AI가 <span class="claim">"${esc(n.claim||"완료")}"</span> 라고 주장했지만, <b>${esc((e&&e.framework)||"시나리오")} 시나리오는 존재해도 실제 실행 기록이 없습니다${e&&e.stale?" (시나리오가 마지막 실행보다 최신)":""}.</b><br/>시나리오 작성 ≠ 실행 통과 — 실행 또는 사람 확인 전엔 완료로 집계할 수 없습니다.</p>`
605
+ : `<p>AI가 <span class="claim">"${esc(n.claim||"완료")}"</span> 라고 주장했으나, 이 주장을 뒷받침하는 <b>실행 아티팩트(테스트·스크린샷·로그)가 원장에 존재하지 않습니다.</b><br/>완료로 집계할 수 없습니다 — 사람의 확인 또는 증거 제출이 필요합니다.</p>`;
606
+ return `<div class="evpanel missing"><div class="ev-h">${head} ${tag}</div><div class="missing-body"><span class="big">⚠</span>${body}</div></div>`;
607
+ }
608
+ const e=n.evidence; if(!e) return "";
609
+ if(e.type==="screen"){
610
+ const fw=e.framework||"Maestro";
611
+ const shots=(e.shots||[]).map((s,i)=>`<div class="shot">${phone(i)}<div class="cap"><span>${esc(s.cap)}</span><span class="rchip ${s.result}">${s.result==="pass"?"PASS":"FAIL"}</span></div></div>`).join("");
612
+ return `<div class="evpanel">
613
+ <div class="ev-h">${esc(fw)} 스크린샷 증거 <span class="res ${e.result}">${e.result==="pass"?"FLOW PASS":"FLOW FAIL"}</span></div>
614
+ <div class="shots">${shots}</div>
615
+ <div class="ev-meta"><span>실행 <b>${esc(e.runAt)}</b></span><span>아티팩트 <span class="hashpill">${esc(e.hash)}</span></span></div>
616
+ ${e.video?videoBlockHTML(n,e):""}
617
+ ${e.watchNote?watchHTML(e.watchNote):""}
618
+ </div>`;
619
+ }
620
+ const fw=e.framework?` · ${esc(e.framework)}`:"";
621
+ return `<div class="evpanel">
622
+ <div class="ev-h">단위 테스트 증거${fw} <span class="res ${e.result}">${e.result==="pass"?"PASS":"FAIL"}</span></div>
623
+ <div class="ev-line"><span class="tick">▣</span><span>${esc(e.tests||"")}</span></div>
624
+ <div class="ev-meta"><span>실행 <b>${esc(e.runAt)}</b></span><span>아티팩트 <span class="hashpill">${esc(e.hash)}</span></span></div>
625
+ ${e.watchNote?watchHTML(e.watchNote):""}
626
+ </div>`;
627
+ }
628
+
629
+ function nodeHTML(n, depth){
630
+ if(filterActive() && !anyMatch(n)) return "";
631
+ const hasCh=!!(n.children&&n.children.length);
632
+ const hasEv=n.alarm||!!n.evidence;
633
+ const forceOpen = filterActive() && hasCh;
634
+ const open = openSet.has(n.id) || forceOpen;
635
+ const evOpen = evSet.has(n.id);
636
+ const sc=statClass[n.status]||"todo";
637
+
638
+ const twisty = (hasCh||hasEv)
639
+ ? `<button class="twisty" data-act="${hasCh?'toggle':'toggleev'}" data-id="${n.id}" aria-label="펼치기"><span class="ar">▸</span></button>`
640
+ : `<button class="twisty leaf" tabindex="-1"></button>`;
641
+
642
+ const lock = n.locked ? `<span class="lock" title="사람이 분류를 잠금">🔒</span>` : "";
643
+ const kindTag = `<span class="tag ${kindClass[n.kind]||""}">${esc(n.kind)}</span>`;
644
+ const platTag = `<span class="tag plat">${esc(n.platform)}</span>`;
645
+
646
+ const row = `<div class="row ${n.alarm?"alarm":""}">
647
+ ${twisty}
648
+ <div class="statwrap"><span class="dot ${sc}"></span></div>
649
+ <div class="rmain">
650
+ <div class="tline">
651
+ <span class="ttext">${esc(n.title)}</span>
652
+ ${kindTag}${lock}${platTag}
653
+ <span class="statename">${statName[n.status]||n.status}</span>
654
+ </div>
655
+ <div class="prog-row">
656
+ <span class="autotag">자동</span>
657
+ <div class="track"><div class="fill ${sc}" style="width:${n.progress}%"></div></div>
658
+ <span class="pct">${n.progress}%</span>
659
+ </div>
660
+ </div>
661
+ ${hasCh?"":verifyHTML(n)}
662
+ </div>`;
663
+
664
+ const evPanel = hasEv ? evidencePanelHTML(n) : "";
665
+ const children = hasCh ? `<div class="children">${n.children.map(c=>nodeHTML(c,depth+1)).join("")}</div>` : "";
666
+
667
+ const classes = ["node"];
668
+ if(open) classes.push("open");
669
+ if(evOpen && hasEv) classes.push("open-ev");
670
+
671
+ return `<div class="${classes.join(" ")}" data-id="${n.id}">${row}${evPanel}${children}</div>`;
672
+ }
673
+
674
+ function renderMetrics(acc){
675
+ const el=document.getElementById("metrics");
676
+ el.innerHTML =
677
+ `<span class="seg"><span class="k">주장</span><span class="v">${acc.claims}</span></span>`+
678
+ `<span class="seg vis"><span class="k">영상확인</span><span class="v">${acc.vis}</span></span>`+
679
+ `<span class="seg"><span class="k">실행통과</span><span class="v">${acc.ran}</span></span>`+
680
+ `<span class="seg scn"><span class="k">시나리오만</span><span class="v">${acc.scn}</span></span>`+
681
+ `<span class="seg warn"><span class="k">증거없음</span><span class="v">${acc.none}</span></span>`+
682
+ `<span class="seg hum"><span class="k">사람대기</span><span class="v">${acc.hum}</span></span>`;
683
+ }
684
+
685
+ function renderFw(t1){
686
+ const el=document.getElementById("fw");
687
+ if(!el) return;
688
+ if(!t1){ el.innerHTML=`<div class="empty">프레임워크 정보 없음</div>`; return; }
689
+ const meta={ active:{dot:"on"}, scenario:{dot:"scn"}, unused:{dot:"off"} };
690
+ const chips=(t1.frameworks||[]).map(f=>{
691
+ const d=(meta[f.status]||meta.unused).dot;
692
+ return `<div class="fwchip fw-${f.status}">
693
+ <span class="fwdot ${d}"></span>
694
+ <span class="fwname">${esc(f.name)}</span>
695
+ <span class="fwstatus">${esc(f.note||"")}</span>
696
+ <span class="fwmark ${f.tier2?"t2":"t1"}">${f.tier2?"실행가능":"탐지만"}</span>
697
+ </div>`;
698
+ }).join("");
699
+ const warns=(t1.warnings||[]).map(w=>`<div class="fwwarn ${w.level==="red"?"red":""}"><span class="wi">⚠</span><span>${esc(w.text)}</span></div>`).join("");
700
+ el.innerHTML=
701
+ `<div class="fwlegend">실제 실행(Tier2) = <b>실행가능</b> 마크만 · 나머지는 탐지만 가능(사용 여부·시나리오 존재만 확인).</div>`+
702
+ `<div class="fwgrid">${chips}</div>`+
703
+ (warns?`<div class="fwwarns">${warns}</div>`:"");
704
+ const active=(t1.frameworks||[]).filter(f=>f.status==="active").length;
705
+ const fc=document.getElementById("fwCount"); if(fc) fc.textContent=`사용 ${active}`;
706
+ }
707
+
708
+ function renderFilters(){
709
+ const el=document.getElementById("filters");
710
+ if(el.dataset.built) { syncFilterUI(); return; }
711
+ const plats=[["all","전체"],["ios","iOS"],["android","AOS"],["web","web"],["cli","cli"]];
712
+ const stats=[["all","전체"],["todo","todo"],["in-progress","진행"],["done","done"],["blocked","blocked"],["needs-human","needs-human"]];
713
+ el.innerHTML =
714
+ `<div class="fgroup"><span class="flabel">플랫폼</span>${plats.map(p=>`<button class="chip" data-f="plat" data-v="${p[0]}">${p[1]}</button>`).join("")}</div>`+
715
+ `<span class="fsep"></span>`+
716
+ `<div class="fgroup"><span class="flabel">상태</span>${stats.map(s=>`<button class="chip" data-f="status" data-v="${s[0]}">${s[1]}</button>`).join("")}</div>`+
717
+ `<span class="fsep"></span>`+
718
+ `<button class="chip danger" data-f="unv" id="unvBtn">⚠ 증거없음·미실행만 보기</button>`;
719
+ el.dataset.built="1";
720
+ syncFilterUI();
721
+ }
722
+ function syncFilterUI(){
723
+ document.querySelectorAll('#filters .chip[data-f="plat"]').forEach(b=>b.setAttribute("aria-pressed", b.dataset.v===filt.plat));
724
+ document.querySelectorAll('#filters .chip[data-f="status"]').forEach(b=>b.setAttribute("aria-pressed", b.dataset.v===filt.status));
725
+ const u=document.getElementById("unvBtn"); if(u) u.setAttribute("aria-pressed", filt.unv);
726
+ }
727
+
728
+ // 서명 여부는 서버 진실원장 — human-gate 가 done 이면 서명됨. (F1)
729
+ function isGateSigned(n){ return n.kind==="human-gate" && (n.status==="done" || n.signed); }
730
+ function renderGate(root){
731
+ const items=[]; (function walk(n){ if(n.kind==="human-gate"||n.status==="needs-human") items.push(n); (n.children||[]).forEach(walk); })({children:root});
732
+ const pending=items.filter(n=>!isGateSigned(n));
733
+ document.getElementById("gateCount").textContent=pending.length;
734
+ const el=document.getElementById("gate");
735
+ const banner=`<div class="gate-banner"><span class="gi">🔒</span><p><b>이 관문은 사람만 통과시킬 수 있습니다.</b> AI 에이전트는 아래 항목을 스스로 '완료'로 표시할 수 없습니다. 승기의 서명만이 검증으로 집계됩니다.</p></div>`;
736
+ if(items.length===0){ el.innerHTML=banner+`<div class="empty">대기 중인 human-gate 항목이 없습니다.</div>`; return; }
737
+ const list=items.map(n=>{
738
+ if(isGateSigned(n)){
739
+ return `<div class="gitem"><div class="gt">${esc(n.title)}</div>
740
+ <div class="gmeta"><span>${esc(n.platform)}</span><span>${esc(n.kind)}</span></div>
741
+ <div class="signed">✓ 승기 확인 서명 완료</div></div>`;
742
+ }
743
+ return `<div class="gitem"><div class="gt">${esc(n.title)}</div>
744
+ <div class="gmeta"><span>${esc(n.platform)}</span><span>${esc(n.kind)}</span><span>승인 대기</span></div>
745
+ <span class="humanonly">👤 사람 전용 · human only</span>
746
+ <button class="signbtn" data-sign="${n.id}">내가 확인함 <span class="who">— 승기</span></button>
747
+ <div class="ai-cant">🚫 <s>AI는 이 버튼을 누를 수 없음</s></div></div>`;
748
+ }).join("");
749
+ el.innerHTML=banner+list;
750
+ }
751
+
752
+ function render(){
753
+ if(!liveData) return;
754
+ const groups=nodesOf(liveData);
755
+ groups.forEach(normalize);
756
+ // first run: open all groups; auto-open evidence on alarms
757
+ if(!initialized){
758
+ groups.forEach(g=>{ openSet.add(g.id); (g.children||[]).forEach(c=>{ if(c.children) openSet.add(c.id); }); });
759
+ groups.forEach(g=>{(function fa(n){ if(n.alarm) evSet.add(n.id); (n.children||[]).forEach(fa);})(g);});
760
+ initialized=true;
761
+ }
762
+ const acc={claims:0,vis:0,ran:0,scn:0,none:0,hum:0}; groups.forEach(g=>tally(g,acc));
763
+ renderMetrics(acc);
764
+ renderFw(liveData.tier1);
765
+ renderFilters();
766
+ document.getElementById("treeCount").textContent=acc.claims+acc.hum;
767
+ document.getElementById("tree").innerHTML=groups.map(g=>nodeHTML(g,0)).join("") || `<div class="empty">필터에 해당하는 항목이 없습니다.</div>`;
768
+ renderGate(groups);
769
+ }
770
+
771
+ /* ───────────── events ───────────── */
772
+ document.addEventListener("click",e=>{
773
+ const tw=e.target.closest(".twisty");
774
+ if(tw && !tw.classList.contains("leaf")){
775
+ const id=tw.dataset.id, act=tw.dataset.act;
776
+ if(act==="toggle"){ openSet.has(id)?openSet.delete(id):openSet.add(id); }
777
+ else if(act==="toggleev"){ evSet.has(id)?evSet.delete(id):evSet.add(id); }
778
+ render(); return;
779
+ }
780
+ // clicking a non-leaf row toggles too (the title area)
781
+ const fchip=e.target.closest("#filters .chip");
782
+ if(fchip){
783
+ const f=fchip.dataset.f;
784
+ if(f==="unv"){ filt.unv=!filt.unv; }
785
+ else { filt[f]=fchip.dataset.v; }
786
+ syncFilterUI(); render(); return;
787
+ }
788
+ // human-gate 서명 — 서버가 진실원장. POST 후 poll() 로 갱신. (F1)
789
+ const sign=e.target.closest("[data-sign]");
790
+ if(sign){
791
+ const id=sign.dataset.sign;
792
+ (async()=>{
793
+ await fetch("/api/signoff",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({id})});
794
+ await poll();
795
+ })();
796
+ return;
797
+ }
798
+ // 영상 확인 — 사람 전용 서버 라우트. POST 후 poll(). (F1)
799
+ const vsign=e.target.closest("[data-vsign]");
800
+ if(vsign){
801
+ const id=vsign.dataset.vsign;
802
+ (async()=>{
803
+ await fetch("/api/verify-video",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({id})});
804
+ await poll();
805
+ })();
806
+ return;
807
+ }
808
+ // toggle row open by clicking its title (if it has a toggle twisty)
809
+ const row=e.target.closest(".row");
810
+ if(row && !e.target.closest("a,button,.verify")){
811
+ const node=row.closest(".node"); const t=node&&node.querySelector(":scope > .row > .twisty");
812
+ if(t && !t.classList.contains("leaf")){ t.click(); }
813
+ }
814
+ });
815
+
816
+ /* 실행 영상은 실제 <video controls> 로 재생한다 — 스프라이트 플레이어 불필요. */
817
+
818
+ /* ───────────── live clock + 3s polling ───────────── */
819
+ function tickClock(){
820
+ const d=new Date(), p=n=>String(n).padStart(2,"0");
821
+ document.getElementById("liveClock").textContent=`${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
822
+ const ago=lastSync?Math.max(0,Math.round((Date.now()-lastSync)/1000)):0;
823
+ document.getElementById("liveAgo").textContent=lastSync?`${ago}초 전 동기화`:"동기화 중";
824
+ }
825
+ setInterval(tickClock,1000);
826
+
827
+ function simulate(d){
828
+ // gentle liveness on in-progress leaves so polling feels real when API is absent
829
+ (function walk(n){
830
+ if(!n.children && n.status==="in-progress"){
831
+ n.pct=Math.min(95,(n.pct??50)+Math.floor(Math.random()*4));
832
+ }
833
+ (n.children||[]).forEach(walk);
834
+ })({children:nodesOf(d)});
835
+ }
836
+
837
+ async function poll(){
838
+ try{
839
+ const r=await fetch("/api/checklist",{cache:"no-store"});
840
+ if(!r.ok) throw new Error("bad status");
841
+ liveData=await r.json();
842
+ }catch(err){
843
+ if(!liveData) liveData=clone(SAMPLE);
844
+ else simulate(liveData);
845
+ }
846
+ lastSync=Date.now();
847
+ render(); tickClock();
848
+ }
849
+ poll();
850
+ setInterval(poll,3000);
851
+ </script>
852
+ </body>
853
+ </html>