@mneme-ai/xray 2.163.0 → 2.174.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.
package/public/index.html CHANGED
@@ -66,11 +66,25 @@
66
66
  .g-C{background:linear-gradient(135deg,#eaa83a,#c9821a)} .g-D{background:linear-gradient(135deg,#f0742e,#d4571a)}
67
67
  .g-F{background:linear-gradient(135deg,#f43f5e,#be123c)}
68
68
  .top .repo{font-size:22px;font-weight:640;letter-spacing:-.02em;color:var(--ink);word-break:break-all;line-height:1.2}
69
+ .top .repobr{font-size:13px;font-weight:600;color:var(--a);background:var(--a-soft);border-radius:6px;padding:1px 7px;vertical-align:middle}
70
+ .top .repourl{display:inline-block;font-size:12.5px;color:var(--a);text-decoration:none;margin-top:3px;word-break:break-all;font-family:ui-monospace,Menlo,monospace}
71
+ .top .repourl:hover{text-decoration:underline}
69
72
  .top .head{color:var(--sub);font-size:14px;margin-top:4px}
70
73
  .trustbar{display:flex;align-items:center;gap:14px;padding:13px 30px;background:#f0fdf4;border-bottom:1px solid #dcfce7;flex-wrap:wrap}
71
74
  .hgauge{display:inline-flex;align-items:center;gap:8px;font-weight:680;color:#15803d;font-size:14px;white-space:nowrap}
72
75
  .hdot{width:10px;height:10px;border-radius:50%;background:#16a34a;box-shadow:0 0 0 4px rgba(22,163,74,.16)}
73
76
  .htext{font-size:12.5px;color:#3f6b4e;line-height:1.5}.htext b{color:#14532d;font-weight:640}
77
+ .membrane{display:flex;border-bottom:1px solid #eef0f2;background:#fafbfc;flex-wrap:wrap}
78
+ .mp{flex:1;min-width:180px;padding:11px 18px;border-right:1px solid #eef0f2;display:flex;flex-direction:column;gap:3px}.mp:last-child{border-right:0}
79
+ .mpk{font-size:10.5px;letter-spacing:.06em;color:#8a909a;font-weight:700}
80
+ .mpv{font-size:12.5px;color:#2b2f36;font-weight:560;display:flex;align-items:center;gap:7px}
81
+ .triage{padding:14px 30px 4px}
82
+ .triage-h{font-size:13px;font-weight:680;color:#b45309;margin-bottom:9px}.triage-sub{font-weight:400;color:#9aa0a6;font-size:11.5px}
83
+ .triage.clearall{padding:13px 30px;color:#15803d;font-weight:560;font-size:13px;background:#f0fdf4;border-bottom:1px solid #dcfce7}
84
+ .ti{display:flex;gap:11px;align-items:flex-start;padding:8px 0;border-top:1px solid #f1f2f4}
85
+ .ti-sev{flex:0 0 auto;font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;padding:3px 8px;border-radius:6px;min-width:62px;text-align:center}
86
+ .ti.critical .ti-sev{background:#fee2e2;color:#b91c1c}.ti.warn .ti-sev{background:#fef3c7;color:#b45309}.ti.info .ti-sev{background:#e0e7ff;color:#4338ca}
87
+ .ti-body{flex:1;min-width:0}.ti-f{font-size:13px;color:#1f2329}.ti-p{font-size:11px;color:#9aa0a6;margin-top:2px;font-family:ui-monospace,Menlo,monospace}
74
88
  .rows{padding:6px 30px 16px}
75
89
  .row{display:flex;gap:18px;padding:17px 0;border-bottom:1px solid var(--line2);align-items:baseline}
76
90
  .row:last-child{border-bottom:0}
@@ -81,7 +95,8 @@
81
95
  .row .v .big{font-weight:660;color:var(--ink);font-size:16px}
82
96
  .muted{color:var(--sub)}
83
97
  .spark{font-family:ui-monospace,Menlo,monospace;letter-spacing:1px;color:var(--a)}
84
- .chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
98
+ .chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;max-height:148px;overflow-y:auto}
99
+ .chips::-webkit-scrollbar{width:8px}.chips::-webkit-scrollbar-thumb{background:#d7dbe0;border-radius:8px}
85
100
  .chip{font-size:12px;padding:4px 10px;border-radius:8px;background:var(--soft);border:1px solid var(--line);color:#5c616b;font-weight:450}
86
101
  .chip.bad{background:#fff1f3;border-color:#ffe0e6;color:var(--red)}
87
102
  .chip.warn{background:#fff8ed;border-color:#fde6cc;color:#b45309}
@@ -131,6 +146,8 @@
131
146
  .listfoot{display:flex;justify-content:space-between;align-items:center;padding:14px 4px 0;font-size:13px}
132
147
  .moreb{height:38px;padding:0 18px;border:1px solid var(--line);background:#fff;border-radius:10px;cursor:pointer;font-size:13px;font-weight:540;transition:border-color .15s}
133
148
  .moreb:hover{border-color:var(--ink)}
149
+ .moreb:disabled{opacity:.4;cursor:not-allowed;border-color:var(--line)}
150
+ .pagenav{display:inline-flex;gap:8px}
134
151
  /* share + buttons */
135
152
  .share{display:flex;flex-wrap:wrap;align-items:center;gap:10px;padding:18px 30px;border-top:1px solid var(--line2)}
136
153
  .badgeimg{height:20px}
@@ -151,14 +168,50 @@
151
168
  .paste-hint{align-self:center;font-size:12.5px;color:var(--sub)}
152
169
  .paste-hint b{color:var(--ink2);font-weight:560}
153
170
  /* local bridge panel */
154
- .localbox{max-width:560px;margin:22px auto 0;border:1px solid var(--line);border-radius:var(--rs);padding:18px 20px;background:var(--soft);display:none;text-align:left}
155
- .localbox.on{display:block}
171
+ .localbox{max-width:560px;margin:22px auto 0;border:1px solid var(--line);border-radius:var(--rs);padding:18px 20px;background:var(--soft);display:block;text-align:left}
156
172
  .localbox .lh{display:flex;align-items:center;gap:9px;font-size:13.5px;font-weight:560;color:var(--ink2)}
157
- .localdot{width:9px;height:9px;border-radius:50%;background:var(--green);box-shadow:0 0 0 3px rgba(22,163,74,.14)}
173
+ .localdot{width:9px;height:9px;border-radius:50%;background:#c8ccd2;box-shadow:0 0 0 3px rgba(140,140,150,.12)}
174
+ .localbox.connected .localdot{background:var(--green);box-shadow:0 0 0 3px rgba(22,163,74,.14)}
175
+ .pickbtn{height:44px;padding:0 18px;border:1.5px solid var(--a);border-radius:10px;background:var(--a);color:#fff;cursor:pointer;font-size:13.5px;font-weight:600}
176
+ .cmdrow{display:flex;gap:8px;align-items:center;margin-top:8px;flex-wrap:wrap}
177
+ .cmdrow code{background:#0b0b0f;color:#e6e6ea;border-radius:8px;padding:8px 12px;font-size:12.5px;font-family:ui-monospace,Menlo,monospace}
178
+ .orline{display:flex;align-items:center;gap:10px;margin:14px 0;color:var(--sub);font-size:12px}
179
+ .orline::before,.orline::after{content:"";flex:1;height:1px;background:var(--line)}
158
180
  .localrow{display:flex;gap:8px;margin-top:12px}
159
181
  .localrow input{flex:1;height:44px;padding:0 14px;border:1px solid var(--line);border-radius:10px;font-size:13.5px;font-family:ui-monospace,Menlo,monospace}
160
182
  .localrow button{height:44px;padding:0 16px;border:0;border-radius:10px;background:var(--ink);color:#fff;cursor:pointer;font-size:13.5px}
161
- .localhint{font-size:12px;color:var(--sub);margin-top:9px}
183
+ .localhint{font-size:12px;color:var(--sub);margin-top:9px;line-height:1.5}
184
+ .bridgebox{margin-top:14px;border-top:1px solid var(--line);padding-top:12px}
185
+ .bridgebox summary{cursor:pointer;font-size:12.5px;color:var(--a);font-weight:560;list-style:none}
186
+ .lscard{margin-top:12px;border:1px solid var(--line);border-radius:12px;background:#fff;padding:14px 16px;text-align:left}
187
+ .lscard .lstop{display:flex;align-items:center;gap:14px;margin-bottom:8px}
188
+ .lscard .lsgrade{width:46px;height:46px;border-radius:12px;display:flex;align-items:center;justify-content:center;font-size:23px;font-weight:700;color:#fff;flex-shrink:0;letter-spacing:-.02em}
189
+ .lscard .lsh{font-size:15px;color:var(--ink)}
190
+ .lscard .lsrow{display:flex;gap:12px;padding:7px 0;border-top:1px solid var(--line2);font-size:13px}
191
+ .lscard .lsk{width:110px;color:var(--sub);font-weight:560;flex-shrink:0}
192
+ .lscard .lsv{color:var(--ink2)} .lscard .lsv.bad{color:var(--red)} .lscard .lsv.ok{color:var(--green)}
193
+ .lscard .lshits{margin:6px 0;font-size:12px;color:var(--red);font-family:ui-monospace,Menlo,monospace;line-height:1.6}
194
+ .lscard .lsok{margin:6px 0;font-size:12.5px;color:var(--green)}
195
+ .lscard .pill{display:inline-block;font-size:11px;background:var(--soft);border:1px solid var(--line);border-radius:20px;padding:1px 8px;color:var(--ink2)}
196
+ .lscard .lsnote{margin-top:10px;font-size:11.5px;color:var(--sub);line-height:1.5;border-top:1px solid var(--line2);padding-top:9px}
197
+ /* SHOWCASE — selling points; the graphic sits to the SIDE (grid areas, no markup move) */
198
+ .showcase{max-width:1060px;margin:64px auto 0;padding:0 20px;text-align:left;
199
+ display:grid;column-gap:36px;row-gap:18px;align-items:center;
200
+ grid-template-columns:1fr 360px;grid-template-areas:"title art" "sub art" "cards cards"}
201
+ .showcase .sctitle{grid-area:title;font-size:30px;font-weight:700;letter-spacing:-.02em;color:var(--ink);margin:0;line-height:1.2}
202
+ .showcase .sctitle .hl{color:var(--a)}
203
+ .showcase .scsub{grid-area:sub;font-size:15px;color:var(--sub);margin:0;line-height:1.6}
204
+ .showcase .bigart{grid-area:art;display:flex;justify-content:center;align-self:center;opacity:.95}
205
+ .showcase .sc3{grid-area:cards;display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:14px}
206
+ .showcase .sccard{background:var(--soft);border:1px solid var(--line);border-radius:16px;padding:20px}
207
+ .showcase .scico{font-size:26px;margin-bottom:8px}
208
+ .showcase .sccard b{display:block;font-size:15px;color:var(--ink);margin-bottom:6px}
209
+ .showcase .sccard span{font-size:13px;color:var(--sub);line-height:1.55}
210
+ @media(max-width:820px){
211
+ .showcase{grid-template-columns:1fr;grid-template-areas:"title" "sub" "art" "cards"}
212
+ .showcase .sc3{grid-template-columns:1fr}
213
+ .showcase .bigart svg{width:300px;height:auto}
214
+ }
162
215
  .picker{display:none;margin-top:12px;border:1px solid var(--line);border-radius:10px;background:#fff;overflow:hidden}
163
216
  .picker.on{display:block}
164
217
  .pkbar{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--line2);font-size:12.5px}
@@ -169,6 +222,33 @@
169
222
  .pkrow:last-child{border-bottom:0}.pkrow:hover{background:var(--soft)}
170
223
  .pkrow .ic{width:18px;text-align:center}
171
224
  .pkrow .repobadge{margin-left:auto;font-size:10.5px;color:var(--a);background:var(--a-soft);border-radius:6px;padding:2px 7px;font-weight:600}
225
+ /* ── REAL-TIME TRACKING panel — clean + clear ── */
226
+ .track{max-width:760px;margin:18px auto 0;background:var(--soft);border:1px solid var(--line);border-top:2px solid var(--a);border-radius:var(--rs);padding:16px 18px;box-shadow:var(--sh-sm)}
227
+ .track .thead{display:flex;align-items:center;gap:8px;font-size:15px;color:var(--ink)}
228
+ .track .ticon{font-size:17px}
229
+ .track .tnew{font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--a);background:var(--a-soft);border-radius:6px;padding:2px 7px}
230
+ .track .trow{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-top:12px}
231
+ .track .tlabel{font-size:13px;color:var(--sub);font-weight:560}
232
+ .track select{height:38px;border:1px solid var(--line);border-radius:9px;padding:0 12px;font-size:13.5px;background:#fff;color:var(--ink2);max-width:260px}
233
+ .track .tbtn{height:38px;padding:0 16px;border-radius:9px;border:1.5px solid var(--a);background:var(--a);color:#fff;font-weight:600;font-size:13.5px;cursor:pointer}
234
+ .track .tbtn.off{background:#fff;color:var(--a)}
235
+ .track .tbtn.stop{border-color:var(--red);background:#fff;color:var(--red)}
236
+ .track .live{display:inline-flex;align-items:center;gap:7px;font-size:13px;color:var(--green);font-weight:600}
237
+ .track .dot{width:9px;height:9px;border-radius:50%;background:var(--green);box-shadow:0 0 0 0 rgba(22,163,74,.5);animation:pulse 1.8s infinite}
238
+ @keyframes pulse{0%{box-shadow:0 0 0 0 rgba(22,163,74,.45)}70%{box-shadow:0 0 0 8px rgba(22,163,74,0)}100%{box-shadow:0 0 0 0 rgba(22,163,74,0)}}
239
+ .track .drift{margin-top:12px;border-radius:10px;padding:10px 14px;font-size:13.5px;font-weight:560;display:none}
240
+ .track .drift.degraded{background:#fff1f3;border:1px solid #fecdd6;color:var(--red)}
241
+ .track .drift.improved{background:#f0fdf4;border:1px solid #bbf7d0;color:var(--green)}
242
+ .track .drift.changed,.track .drift.stable{background:var(--a-soft);border:1px solid #dfe2ff;color:var(--a)}
243
+ .track .tl{margin-top:10px;display:flex;gap:6px;align-items:center;flex-wrap:wrap;font-family:ui-monospace,Menlo,monospace;font-size:12px}
244
+ .track .tl .pill{padding:2px 8px;border-radius:20px;border:1px solid var(--line);background:#fff;color:var(--ink2)}
245
+ .track .tl .pill.deg{border-color:#fecdd6;color:var(--red)} .track .tl .pill.imp{border-color:#bbf7d0;color:var(--green)}
246
+ .track .thelp{margin-top:8px;font-size:12px;color:var(--sub);line-height:1.5}
247
+ .track .tfeat{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:12px}
248
+ @media(max-width:620px){.track .tfeat{grid-template-columns:1fr}}
249
+ .track .tf{background:#fff;border:1px solid var(--line);border-radius:10px;padding:10px 12px;font-size:12px}
250
+ .track .tf b{display:block;font-size:12.5px;color:var(--ink);margin-bottom:3px}
251
+ .track .tf span{color:var(--sub);line-height:1.45}
172
252
  footer{padding:40px 0 70px;text-align:center;color:#aab0b8;font-size:13px}
173
253
  .spin{display:inline-block;width:15px;height:15px;border:2px solid currentColor;border-top-color:transparent;
174
254
  border-radius:50%;animation:s .7s linear infinite;vertical-align:-2px;margin-right:7px;opacity:.9}
@@ -198,8 +278,8 @@
198
278
  </style>
199
279
  </head>
200
280
  <body>
201
- <!-- grand meteor-impact illustration — desktop / iPad only, behind content -->
202
- <div class="comet comet-a" aria-hidden="true">
281
+ <!-- grand meteor-impact illustration — moved to the bottom showcase -->
282
+ <div class="comet comet-a" aria-hidden="true" style="display:none">
203
283
  <svg viewBox="0 0 360 420" width="360" height="420">
204
284
  <defs>
205
285
  <linearGradient id="mtail" x1="1" y1="0" x2="0" y2="1">
@@ -258,10 +338,10 @@
258
338
  <span><b>X-Ray</b> — instant health report: dependencies, secrets, risk &amp; who-to-ask.</span>
259
339
  <span><b>📦 AI Pack</b> — bundle the whole repo into one text to paste into any AI chat.</span>
260
340
  </div>
261
- <p class="hint">Every number is <b>reproducible</b> from git, code &amp; package metadata and sealed with an <b>offline-verifiable</b> signature. <span class="muted">Private folder? run <code>npx @mneme-ai/xray bridge</code> and Browse below.</span></p>
341
+ <p class="hint">Every number is <b>reproducible</b> from git, code &amp; package metadata and sealed with an <b>offline-verifiable</b> signature. <span class="muted">Private repo or a local folder with no public URL? Run <code>npx @mneme-ai/xray bridge</code> on your machine — a <b>Scan a local folder</b> box appears here + your code never leaves your computer.</span></p>
262
342
  <details class="keybox">
263
343
  <summary>🔖 Optional — save your reports</summary>
264
- <div class="keyhelp">A “key” is simply a password you make up (no sign-up). Use the same one any time to find your reports under <b>My repos</b>.</div>
344
+ <div class="keyhelp">A “key” is just a password you make up (no sign-up). <b>How it works:</b> type a key + Save · ② X-Ray any repo above it's filed under your key automatically · ③ open <b>My repos</b> below to find it anytime, on any device with the same key.</div>
265
345
  <div class="keyrow">
266
346
  <input id="key" placeholder="make up a password — e.g. my-secret-123" autocomplete="off" />
267
347
  <button id="savekey" type="button">Save</button>
@@ -270,39 +350,145 @@
270
350
  <div id="keymsg" class="keymsg" style="display:none"></div>
271
351
  </details>
272
352
  <div class="localbox" id="localbox">
273
- <div class="lh"><span class="localdot"></span>Local agent detected — scan a folder on <b>this machine</b> (source never leaves it)</div>
353
+ <div class="lh"><span class="localdot" id="localdot"></span><b>Or scan a folder on this computer</b> <span class="muted" id="localhead">— nothing is uploaded</span></div>
274
354
  <div class="localrow">
275
- <input id="localpath" placeholder="/absolute/path/to/your/repo (git or not)" autocomplete="off" spellcheck="false" />
276
- <button id="localbrowse" type="button" class="ghostbtn">📂 Browse</button>
277
- <button id="localgo" type="button">Scan</button>
278
- <button id="localpack" type="button" class="ghostbtn">📦 Pack</button>
355
+ <button id="pickfolder" type="button" class="pickbtn">📂 Choose a folder…</button>
356
+ <span class="muted" id="picknote">No install, no terminal — your browser asks which folder, the scan runs right here (secrets · dependencies · size).</span>
279
357
  </div>
280
- <div class="picker" id="picker"></div>
281
- <div class="localhint">Not on git? No problem — Browse to any folder. It reads locally and shows the signed result here; nothing is uploaded.</div>
358
+ <div id="localout"></div>
359
+ <details class="bridgebox">
360
+ <summary>Want the FULL report on a local repo (git history, bus factor, vitality)?</summary>
361
+ <div class="localhint">Run this once on your machine — a tiny local agent reads git + files locally and shows the <b>signed</b> result here. Nothing is uploaded.
362
+ <div class="cmdrow"><code>npx @mneme-ai/xray bridge</code><button id="copybridge" type="button" class="ghostbtn">Copy</button></div>
363
+ </div>
364
+ <div class="localrow" id="bridgerow" style="display:none">
365
+ <input id="localpath" placeholder="/absolute/path/to/your/repo" autocomplete="off" spellcheck="false" />
366
+ <button id="localbrowse" type="button" class="ghostbtn">📂 Browse</button>
367
+ <button id="localgo" type="button">Scan</button>
368
+ <button id="localpack" type="button" class="ghostbtn">📦 Pack</button>
369
+ </div>
370
+ <div class="picker" id="picker"></div>
371
+ </details>
282
372
  </div>
283
373
  <p class="err" id="err" style="display:none"></p>
284
374
  </header>
285
375
 
286
376
  <div id="out"></div>
287
377
  <div id="packout"></div>
378
+ <div id="track" class="track" style="display:none"></div>
288
379
 
289
380
  <div class="board">
290
381
  <div class="tabs"><button id="tabBoard" class="tab on">Recently X-rayed</button><button id="tabMine" class="tab" style="display:none">My repos</button></div>
291
382
  <p class="boardnote" id="boardnote">🌍 <b>Public</b> — recent X-Rays of <b>public</b> repos, visible to everyone. Private repos never appear here.</p>
292
383
  <div id="board"></div>
293
384
  </div>
385
+
386
+ <!-- SHOWCASE — the selling points (real: this is what Live tracking does) + the grand graphic -->
387
+ <section class="showcase">
388
+ <h2 class="sctitle">From a one-shot check to an <span class="hl">AI Auditor for your team</span></h2>
389
+ <p class="scsub">Turn on Live tracking and X-Ray watches a branch 24/7 — every push, human or AI, is re-scanned and the drift surfaces here. No re-click.</p>
390
+ <div class="sc3">
391
+ <div class="sccard"><div class="scico">🛡</div><b>AI Code Drift Detection</b><span>The moment anyone — or an AI — pushes, catch a newly-leaked secret, a destructive CI command, or a grade drop. Malicious or off-architecture code is flagged on arrival.</span></div>
392
+ <div class="sccard"><div class="scico">🔁</div><b>Continuous Autonomous Audit</b><span>Re-checked on every push (or every 30s) — a 24/7 security &amp; health audit that runs itself. Nobody has to remember to click.</span></div>
393
+ <div class="sccard"><div class="scico">🕰</div><b>Time-Machine Indexing</b><span>A signed timeline of how code-health drifted, commit by commit — see exactly when a risk entered and who pushed it.</span></div>
394
+ </div>
395
+ <div class="bigart" aria-hidden="true">
396
+ <svg viewBox="0 0 360 420" width="440" height="513">
397
+ <defs>
398
+ <linearGradient id="mtail2" x1="1" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#0b0b0f" stop-opacity=".5"/><stop offset="1" stop-color="#0b0b0f" stop-opacity="0"/></linearGradient>
399
+ <radialGradient id="dust2" cx="50%" cy="60%"><stop offset="0" stop-color="#5b5bf6" stop-opacity=".18"/><stop offset="1" stop-color="#5b5bf6" stop-opacity="0"/></radialGradient>
400
+ <linearGradient id="rock2" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#2b2c4a"/><stop offset="1" stop-color="#0b0b0f"/></linearGradient>
401
+ </defs>
402
+ <path d="M-20 322 Q90 300 150 330 Q175 344 200 330 Q280 300 380 320" fill="none" stroke="#5b5bf6" stroke-width="2" opacity=".4"/>
403
+ <ellipse cx="172" cy="332" rx="96" ry="24" fill="url(#dust2)"/>
404
+ <path d="M300 24 Q236 96 196 250 Q244 110 296 40 Z" fill="url(#mtail2)"/>
405
+ <g transform="translate(196 250)">
406
+ <polygon points="-34,-6 -18,-34 14,-40 36,-18 30,18 2,34 -28,22" fill="url(#rock2)"/>
407
+ <g stroke="#5b5bf6" stroke-width="1.3" opacity=".6" fill="none"><path d="M-18 -34 L2 -6 L36 -18"/><path d="M2 -6 L-28 22"/><path d="M2 -6 L2 34"/><path d="M2 -6 L30 18"/></g>
408
+ <polygon points="-18,-34 14,-40 2,-6" fill="#34356a"/>
409
+ </g>
410
+ <g>
411
+ <polygon points="86,178 110,150 128,170 116,210 92,206" fill="url(#rock2)"/><polygon points="248,170 276,150 296,184 272,206 250,194" fill="url(#rock2)"/>
412
+ <polygon points="150,128 168,104 186,124 178,156 154,152" fill="url(#rock2)"/>
413
+ <polygon points="44,256 64,242 74,266 56,278" fill="#0b0b0f"/><polygon points="300,250 318,238 326,262 306,272" fill="#0b0b0f"/>
414
+ <polygon points="208,150 222,134 234,150 226,170" fill="#0b0b0f" opacity=".82"/>
415
+ </g>
416
+ <g fill="#5b5bf6" opacity=".5"><polygon points="130,96 140,90 142,102 132,104"/><polygon points="232,108 242,102 244,114 234,116"/><polygon points="70,150 79,145 81,156 71,158"/><polygon points="286,138 295,133 297,144 287,146"/></g>
417
+ </svg>
418
+ </div>
419
+ </section>
294
420
  </div>
295
421
  <footer>Mneme — the trust &amp; cost layer for code. Deterministic · Signed · Local-first.<br/><span id="copyr">© Mneme</span></footer>
296
422
 
297
423
  <script src="/card.js"></script>
424
+ <script src="/local-scan.js"></script>
298
425
  <script>
299
426
  const gC = g => "g-" + (("ABCDEF".includes(g)) ? g : "C");
300
427
  const esc = s => String(s==null?"":s).replace(/[&<>]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
301
428
 
302
- function render(signed){
429
+ function renderCard(signed){
303
430
  document.getElementById("out").innerHTML = window.MnemeXRay.xrayCardHTML(signed, { share: true });
304
431
  mountShare(signed.report);
305
432
  }
433
+ function render(signed){
434
+ renderCard(signed);
435
+ mountTracking(signed.report);
436
+ }
437
+
438
+ // ── REAL-TIME TRACKING (branch-aware · poll+webhook · live SSE · drift) ──
439
+ const brank = n => /^(main)$/.test(n)?0 : /^(master)$/.test(n)?1 : /^(develop|dev)$/.test(n)?2 : 9;
440
+ function mountTracking(r){
441
+ const panel = document.getElementById("track");
442
+ if(!r || r.subject.kind !== "git-url"){ panel.style.display="none"; return; }
443
+ const gitUrl = r.subject.ref;
444
+ panel.style.display="block";
445
+ panel.innerHTML = `<div class="thead"><span class="ticon">🛰</span><b>Live tracking</b><span class="tnew">new</span></div>
446
+ <div class="thelp">Pick a branch and press <b>Track live</b> — when you, a teammate, or an AI pushes, this report <b>re-scans itself</b> and shows what changed. No re-click. <span class="muted">(What you get → see “AI Auditor for your team” below.)</span></div>
447
+ <div class="trow">
448
+ <span class="tlabel">Branch</span>
449
+ <select id="tbranch"><option value="">loading branches…</option></select>
450
+ <button id="ttoggle" class="tbtn off">● Track live</button>
451
+ <span id="tstate" class="tlabel"></span>
452
+ </div>
453
+ <div id="tdrift" class="drift"></div>
454
+ <div id="ttl" class="tl"></div>
455
+ <div id="tnote" class="thelp" style="display:none"></div>`;
456
+ fetch("/api/branches?url="+encodeURIComponent(gitUrl)).then(x=>x.json()).then(d=>{
457
+ const sel=document.getElementById("tbranch"); if(!sel) return;
458
+ const names=((d&&d.branches)||[]).map(b=>b.name);
459
+ if(!names.length){ sel.innerHTML='<option value="">default branch</option>'; return; }
460
+ names.sort((a,b)=>brank(a)-brank(b)||a.localeCompare(b));
461
+ sel.innerHTML=names.map(n=>`<option value="${esc(n)}"${n===r.subject.branch?" selected":""}>${esc(n)}</option>`).join("");
462
+ }).catch(()=>{ const sel=document.getElementById("tbranch"); if(sel) sel.innerHTML='<option value="">default branch</option>'; });
463
+
464
+ let es=null; const history=[];
465
+ const toggle=document.getElementById("ttoggle");
466
+ const stateEl=()=>document.getElementById("tstate");
467
+ const noteEl=()=>document.getElementById("tnote");
468
+ function stop(){ if(es){es.close();es=null;} toggle.className="tbtn off"; toggle.textContent="● Track live"; stateEl().textContent="stopped"; const n=noteEl(); if(n) n.style.display="none"; const sel=document.getElementById("tbranch"); if(sel) sel.disabled=false; }
469
+ function showDrift(d){ const el=document.getElementById("tdrift"); if(!el||!d) return; el.className="drift "+d.drift; el.style.display="block"; el.textContent=((d.highlights&&d.highlights[0])||("changed → grade "+d.gradeTo))+" · just now"; }
470
+ function pushTL(d){ history.push(d); const el=document.getElementById("ttl"); if(!el) return; el.innerHTML='<span class="tlabel">Timeline</span> '+history.slice(-14).map(h=>{const c=h.drift==="degraded"?"deg":h.drift==="improved"?"imp":""; return '<span class="pill '+c+'">'+esc(h.gradeTo)+(h.newSecretLeaks>0?" 🔴":"")+'</span>';}).join(" "); }
471
+ function startSSE(id, branch){
472
+ es=new EventSource("/api/track/"+id+"/stream");
473
+ toggle.className="tbtn stop"; toggle.textContent="Stop"; toggle.disabled=false;
474
+ const sel=document.getElementById("tbranch"); if(sel) sel.disabled=true; // lock branch while LIVE
475
+ stateEl().innerHTML='<span class="live"><span class="dot"></span>LIVE · '+esc(branch||"default")+'</span>';
476
+ const n=noteEl(); if(n){ n.style.display="block"; n.innerHTML='✓ <b>Now monitoring '+esc(branch||"the default branch")+'.</b> Leave this tab open to watch changes appear here live. The monitor keeps running on the server even if you close the tab — come back and X-Ray this same repo to reconnect. <span class="muted">Switching repo or branch starts a separate monitor; a refresh reconnects this view.</span>'; }
477
+ es.addEventListener("update", ev=>{ try{ const p=JSON.parse(ev.data); renderCard(p.signed); showDrift(p.delta); pushTL(p.delta);}catch{} });
478
+ // EventSource auto-reconnects on error; keep the LIVE state.
479
+ }
480
+ toggle.addEventListener("click", async ()=>{
481
+ if(es){ stop(); return; }
482
+ const branch=document.getElementById("tbranch").value||undefined;
483
+ toggle.disabled=true; toggle.textContent="starting…";
484
+ try{
485
+ const res=await fetch("/api/track",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({gitUrl,branch})});
486
+ const data=await res.json();
487
+ if(!res.ok){ stateEl().textContent=data.error||"failed"; toggle.disabled=false; toggle.textContent="● Track live"; return; }
488
+ startSSE(data.trackId, branch);
489
+ }catch(e){ stateEl().textContent=String(e); toggle.disabled=false; toggle.textContent="● Track live"; }
490
+ });
491
+ }
306
492
 
307
493
  // share row: permalink + an embeddable badge (the viral loop)
308
494
  function mountShare(r){
@@ -367,7 +553,7 @@ document.getElementById("savekey").addEventListener("click", ()=>{
367
553
  });
368
554
 
369
555
  let tab="board", offset=0, loaded=[];
370
- const PAGE=20;
556
+ const PAGE=5;
371
557
  function fmtDate(s){ if(!s) return "—"; const d=new Date(s); return isNaN(d)?"—":d.toLocaleDateString(undefined,{year:"numeric",month:"short",day:"numeric"}); }
372
558
  function rowHTML(b){
373
559
  const lock = b.visibility==="private" ? '<span class="lock" title="private — only you can open this">🔒</span>' : "";
@@ -397,16 +583,18 @@ async function loadList(reset=true){
397
583
  }
398
584
  try{
399
585
  const data=await fetchPage(offset);
400
- loaded=loaded.concat(data.items||[]);
401
- if(loaded.length===0){
402
- el.innerHTML=`<div class="bitem muted">${tab==="mine"?"Nothing here yet — run an <b>X-Ray</b> or <b>📦 AI Pack</b> above and it appears here automatically. (Private folder? use the local Browse panel.)":"No reports yet — be the first."}</div>`;
586
+ const items=data.items||[];
587
+ if((data.total||0)===0){
588
+ el.innerHTML=`<div class="bitem muted">${tab==="mine"?"Nothing here yet — run an <b>X-Ray</b> or <b>📦 AI Pack</b> above and it appears here automatically. (Private folder? use the Choose-a-folder panel.)":"No reports yet — be the first."}</div>`;
403
589
  return;
404
590
  }
405
- const more = (data.offset+data.limit) < data.total;
406
- el.innerHTML = `<div class="listbox">${loaded.map(rowHTML).join("")}</div>`
407
- + `<div class="listfoot"><span class="muted">${loaded.length} of ${data.total} repos</span>${more?'<button class="moreb" id="moreb">Load more</button>':""}</div>`;
591
+ const start=data.offset+1, end=data.offset+items.length;
592
+ const hasPrev=data.offset>0, hasNext=(data.offset+data.limit)<data.total;
593
+ el.innerHTML = `<div class="listbox">${items.map(rowHTML).join("")}</div>`
594
+ + `<div class="listfoot"><span class="muted">${start}–${end} of ${data.total} repos</span><span class="pagenav"><button class="moreb" id="prevb"${hasPrev?"":" disabled"}>← Prev</button><button class="moreb" id="nextb"${hasNext?"":" disabled"}>Next →</button></span></div>`;
408
595
  el.querySelectorAll(".bitem[data-fp]").forEach(n=>n.addEventListener("click",()=>openReport(n.dataset.fp, n.dataset.priv==="1")));
409
- const mb=document.getElementById("moreb"); if(mb) mb.addEventListener("click",()=>{ offset+=PAGE; loadList(false); });
596
+ const pb=document.getElementById("prevb"); if(pb&&hasPrev) pb.addEventListener("click",()=>{ offset=Math.max(0,offset-PAGE); loadList(false); window.scrollTo({top:document.getElementById("board").offsetTop-80,behavior:"smooth"}); });
597
+ const nb=document.getElementById("nextb"); if(nb&&hasNext) nb.addEventListener("click",()=>{ offset+=PAGE; loadList(false); window.scrollTo({top:document.getElementById("board").offsetTop-80,behavior:"smooth"}); });
410
598
  }catch{ el.innerHTML='<div class="bitem muted">Could not load.</div>'; }
411
599
  }
412
600
  async function openReport(fp, isPrivate){
@@ -453,9 +641,51 @@ async function detectBridge(){
453
641
  try{
454
642
  const ctrl=new AbortController(); const t=setTimeout(()=>ctrl.abort(),1200);
455
643
  const r=await fetch(BRIDGE+"/bridge/ping",{signal:ctrl.signal}); clearTimeout(t);
456
- if(r.ok){ document.getElementById("localbox").classList.add("on"); }
457
- }catch{ /* no local agent — silent */ }
644
+ if(r.ok){ // full-report bridge is up → light up the green dot + the bridge row
645
+ document.getElementById("localbox").classList.add("connected");
646
+ const h=document.getElementById("localhead"); if(h) h.textContent="— local agent connected · full signed report available";
647
+ const br=document.getElementById("bridgerow"); if(br) br.style.display="flex";
648
+ }
649
+ }catch{ /* no local agent — the zero-install Choose-folder path still works */ }
650
+ }
651
+ // ---- ZERO-INSTALL local scan (File System Access API) — no terminal, no upload ----
652
+ const copyBtn=document.getElementById("copybridge");
653
+ if(copyBtn) copyBtn.addEventListener("click",async()=>{ try{ await navigator.clipboard.writeText("npx @mneme-ai/xray bridge"); const o=copyBtn.textContent; copyBtn.textContent="Copied ✓"; setTimeout(()=>copyBtn.textContent=o,1200);}catch{} });
654
+ function renderLocalScan(r){
655
+ const s=r.summary, out=document.getElementById("localout"); if(!out) return;
656
+ const sec=s.secrets, secCls=sec.totalFindings>0?"bad":"ok";
657
+ const langs=s.langs.map(([e,n])=>`<span class="pill">${esc(e)} ${n}</span>`).join(" ");
658
+ const g=s.git, vit = !g.has ? "" : (s.dormantDays>365?"dormant":s.dormantDays>120?"slowing":"active");
659
+ const gitRows = g.has ? `
660
+ <div class="lsrow"><span class="lsk">Bus factor</span><span class="lsv ${g.authors===1?"bad":""}"><b>${g.authors}</b> author${g.authors===1?" — single point of failure":"s"} · top author ${Math.round(g.topShare*100)}% of ${g.commits} commits</span></div>
661
+ <div class="lsrow"><span class="lsk">Vitality</span><span class="lsv ${vit==="dormant"?"bad":""}"><b>${vit}</b> · ${s.ageDays}d span · last activity ${s.dormantDays}d ago</span></div>`
662
+ : `<div class="lsrow"><span class="lsk">Git history</span><span class="lsv muted">— no .git reflog in this folder (bus factor / vitality unavailable)</span></div>`;
663
+ out.innerHTML=`<div class="lscard">
664
+ <div class="lstop"><div class="lsgrade g-${("ABCDEF".includes(s.grade)?s.grade:"C")}">${esc(s.grade)}</div>
665
+ <div><div class="lsh"><b>📂 ${esc(r.folder)}</b></div><div class="muted" style="font-size:12px">scanned in your browser · ${r.files} files · nothing uploaded</div></div></div>
666
+ <div class="lsrow"><span class="lsk">Secrets</span><span class="lsv ${secCls}"><b>${sec.totalFindings}</b> in production code${sec.excludedTestHits?` · ${sec.excludedTestHits} in tests (excluded)`:""}</span></div>
667
+ ${sec.hits.length?`<div class="lshits">${sec.hits.slice(0,6).map(h=>`<div>🔴 ${esc(h.kind)} — ${esc(h.file)}:${h.line}</div>`).join("")}</div>`:`<div class="lsok">✓ no leaked credentials in production code</div>`}
668
+ ${gitRows}
669
+ <div class="lsrow"><span class="lsk">Dependencies</span><span class="lsv"><b>${s.deps.total}</b> total · ${s.deps.deps} deps · ${s.deps.devDeps} dev</span></div>
670
+ <div class="lsrow"><span class="lsk">Complexity</span><span class="lsv"><b>${(s.symbols||0).toLocaleString()}</b> symbols · ${s.filesScanned} files · ${s.loc.toLocaleString()} lines</span></div>
671
+ <div class="lsrow"><span class="lsk">Languages</span><span class="lsv">${langs||"—"}</span></div>
672
+ <div class="lsnote">Computed <b>in your browser</b> (unsigned, nothing uploaded). Secrets · dependencies · complexity from your files; bus factor &amp; vitality from <code>.git</code> reflog. The full <b>signed</b> report (dep mortality, hotspots, coupling) needs a public URL above or the bridge.</div>
673
+ </div>`;
458
674
  }
675
+ const pick=document.getElementById("pickfolder");
676
+ if(pick) pick.addEventListener("click", async ()=>{
677
+ const note=document.getElementById("picknote");
678
+ if(!window.MnemeLocalScan || !window.MnemeLocalScan.supported){
679
+ if(note) note.textContent="Your browser does not support in-page folder access (try Chrome or Edge), or use the FULL report option below.";
680
+ return;
681
+ }
682
+ pick.disabled=true; const o=pick.textContent; pick.innerHTML='<span class="spin"></span>Scanning locally…';
683
+ try{
684
+ const r=await window.MnemeLocalScan.pickAndScan();
685
+ renderLocalScan(r);
686
+ }catch(e){ if(String(e&&e.name)!=="AbortError" && String(e&&e.message)!=="UNSUPPORTED" && note) note.textContent="Couldn't read that folder: "+String(e&&e.message||e); }
687
+ finally{ pick.disabled=false; pick.textContent=o; }
688
+ });
459
689
  document.getElementById("localgo").addEventListener("click", async ()=>{
460
690
  const p=document.getElementById("localpath").value.trim();
461
691
  const err=document.getElementById("err"), btn=document.getElementById("localgo");
@@ -0,0 +1,153 @@
1
+ /**
2
+ * In-browser LOCAL FOLDER scan — zero install, no terminal, NOTHING uploaded.
3
+ *
4
+ * A website cannot read your disk on its own (browser security). The File System
5
+ * Access API is the honest exception: the user clicks "Choose folder", the OS
6
+ * shows a native picker, and ONLY the folder they grant is readable — and only by
7
+ * code running in their own tab. We read the files in the browser, run the
8
+ * deterministic file-content analyzers (secrets · dependencies · size), and render
9
+ * the result locally. The bytes never leave the machine.
10
+ *
11
+ * Honest scope: git-history signals (bus factor · vitality · hotspots · coupling)
12
+ * need git, which a browser cannot run — those need the public URL or the bridge.
13
+ * This is the instant, no-install "what's in this folder" scan.
14
+ */
15
+ (function (g) {
16
+ // ── pure, deterministic analyzers (unit-testable, no DOM) ──────────────────
17
+ const SECRET_PATTERNS = [
18
+ { kind: "AWS access key", re: /\bAKIA[0-9A-Z]{16}\b/ },
19
+ { kind: "AWS secret key", re: /\b[A-Za-z0-9/+]{40}\b(?=.*aws|.*secret)/i },
20
+ { kind: "GitHub token", re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/ },
21
+ { kind: "OpenAI key", re: /\bsk-[A-Za-z0-9]{20,}\b/ },
22
+ { kind: "Anthropic key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
23
+ { kind: "Slack token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
24
+ { kind: "Google API key", re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
25
+ { kind: "private key block", re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/ },
26
+ { kind: "JWT", re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ },
27
+ ];
28
+ // test/fixture/doc files are sample data, not leaks — excluded from the count
29
+ const TEST_RE = /(^|\/)(test|tests|__tests__|fixtures?|examples?|docs?|spec|mocks?)(\/|$)|\.(test|spec)\.|\.md$|\.lock$/i;
30
+ const TEXT_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|c|h|cpp|cc|rb|php|cs|kt|swift|scala|sh|bash|env|json|ya?ml|toml|ini|cfg|xml|gradle|properties|tf|sql|vue|svelte)$/i;
31
+ const SKIP_DIR = /(^|\/)(node_modules|\.git|dist|build|vendor|\.next|coverage|__pycache__|\.venv|target)(\/|$)/;
32
+
33
+ function scanSecretsText(text, relPath) {
34
+ const isTest = TEST_RE.test(relPath || "");
35
+ const hits = [];
36
+ const lines = String(text).split("\n");
37
+ for (let i = 0; i < lines.length; i++) {
38
+ for (const p of SECRET_PATTERNS) {
39
+ if (p.re.test(lines[i])) { hits.push({ kind: p.kind, file: relPath, line: i + 1, isTest }); }
40
+ }
41
+ }
42
+ return hits;
43
+ }
44
+
45
+ function parseDeps(pkgJsonText) {
46
+ try {
47
+ const p = JSON.parse(pkgJsonText);
48
+ const d = Object.keys(p.dependencies || {});
49
+ const dev = Object.keys(p.devDependencies || {});
50
+ return { total: d.length + dev.length, deps: d.length, devDeps: dev.length, names: [...d, ...dev].slice(0, 60) };
51
+ } catch { return { total: 0, deps: 0, devDeps: 0, names: [] }; }
52
+ }
53
+
54
+ // structural complexity — count declared symbols (deterministic, regex-based)
55
+ const SYMBOL_RE = /\b(function\s+\w+|class\s+\w+|interface\s+\w+|def\s+\w+|func\s+\w+|fn\s+\w+|export\s+(?:const|function|class|default)|=>\s*[{(])/g;
56
+ function countSymbols(text) { const m = String(text).match(SYMBOL_RE); return m ? m.length : 0; }
57
+
58
+ // REAL git signals from .git/logs/HEAD (the reflog — plain text the browser can read).
59
+ // Each line: <old> <new> <Name> <email> <unixTs> <tz>\t<message>. Honest: this is
60
+ // HEAD-movement history (commits/resets/merges), a sound approximation of authorship
61
+ // + activity window without running git or parsing packfiles.
62
+ function parseGitLog(text) {
63
+ const authors = {}; let commits = 0, firstTs = 0, lastTs = 0;
64
+ for (const line of String(text || "").split("\n")) {
65
+ const m = line.match(/^[0-9a-f]+\s+[0-9a-f]+\s+(.+?)\s+<[^>]*>\s+(\d+)\s/);
66
+ if (!m) continue;
67
+ commits++;
68
+ authors[m[1]] = (authors[m[1]] || 0) + 1;
69
+ const ts = parseInt(m[2], 10) * 1000;
70
+ if (!firstTs || ts < firstTs) firstTs = ts;
71
+ if (ts > lastTs) lastTs = ts;
72
+ }
73
+ const names = Object.keys(authors);
74
+ const top = names.length ? Math.max(...names.map((n) => authors[n])) : 0;
75
+ return { commits, authors: names.length, topShare: commits ? top / commits : 0, firstTs, lastTs, has: commits > 0 };
76
+ }
77
+
78
+ const GRADES = ["A", "B", "C", "D", "F"];
79
+ /** A deterministic grade from the signals available IN-BROWSER. Honest: lighter
80
+ * than the full server grade (no dep-mortality / hotspots), but real. */
81
+ function gradeLocal(s) {
82
+ let pen = 0;
83
+ if (s.secrets.totalFindings > 0) pen += s.secrets.totalFindings >= 3 ? 4 : s.secrets.totalFindings >= 1 ? 3 : 0; // a leaked secret is severe
84
+ if (s.git.has) { if (s.git.authors === 1) pen += 1; if (s.git.topShare > 0.8) pen += 1; if (s.ageDays > 0 && s.dormantDays > 365) pen += 1; }
85
+ return GRADES[Math.min(pen, 4)];
86
+ }
87
+
88
+ /** Compose a deterministic local report from already-read files + .git reflog. Pure. */
89
+ function summarize(files, gitLog, nowMs) {
90
+ // files: [{ rel, text }]
91
+ let loc = 0, scanned = 0, testHits = 0, symbols = 0;
92
+ const prodHits = [];
93
+ const langs = {};
94
+ let deps = { total: 0, deps: 0, devDeps: 0, names: [] };
95
+ for (const f of files) {
96
+ if (SKIP_DIR.test(f.rel)) continue;
97
+ if (/(^|\/)package\.json$/.test(f.rel) && !/node_modules/.test(f.rel)) deps = parseDeps(f.text);
98
+ if (!TEXT_EXT.test(f.rel)) continue;
99
+ scanned++;
100
+ loc += f.text.split("\n").length;
101
+ symbols += countSymbols(f.text);
102
+ const ext = (f.rel.match(/\.([a-z0-9]+)$/i) || [, "?"])[1].toLowerCase();
103
+ langs[ext] = (langs[ext] || 0) + 1;
104
+ for (const h of scanSecretsText(f.text, f.rel)) { if (h.isTest) testHits++; else prodHits.push(h); }
105
+ }
106
+ const git = parseGitLog(gitLog);
107
+ const now = nowMs || (git.lastTs || 0);
108
+ const ageDays = git.has && git.firstTs ? Math.max(0, Math.round((git.lastTs - git.firstTs) / 86400000)) : 0;
109
+ const dormantDays = git.has && git.lastTs && now ? Math.max(0, Math.round((now - git.lastTs) / 86400000)) : 0;
110
+ const s = {
111
+ filesScanned: scanned, loc, symbols, deps, git, ageDays, dormantDays,
112
+ secrets: { totalFindings: prodHits.length, excludedTestHits: testHits, hits: prodHits.slice(0, 20) },
113
+ langs: Object.entries(langs).sort((a, b) => b[1] - a[1]).slice(0, 8),
114
+ };
115
+ s.grade = gradeLocal(s);
116
+ return s;
117
+ }
118
+
119
+ g.MnemeLocalScan = { scanSecretsText, parseDeps, countSymbols, parseGitLog, gradeLocal, summarize, _patterns: SECRET_PATTERNS };
120
+
121
+ // ── File System Access glue (browser-only; needs a user gesture + HTTPS) ────
122
+ g.MnemeLocalScan.supported = typeof g.showDirectoryPicker === "function";
123
+
124
+ async function readDir(dirHandle, prefix, out, cap) {
125
+ for await (const [name, handle] of dirHandle.entries()) {
126
+ const rel = prefix ? prefix + "/" + name : name;
127
+ if (SKIP_DIR.test(rel) || name.startsWith(".")) continue;
128
+ if (out.length >= cap) return;
129
+ if (handle.kind === "directory") { await readDir(handle, rel, out, cap); }
130
+ else if (TEXT_EXT.test(rel) || /(^|\/)package\.json$/.test(rel)) {
131
+ try { const file = await handle.getFile(); if (file.size <= 2 * 1024 * 1024) out.push({ rel, text: await file.text() }); } catch { /* skip unreadable */ }
132
+ }
133
+ }
134
+ }
135
+
136
+ /** Open the OS folder picker, read text files in-browser, return the summary +
137
+ * the folder name. Throws on user-cancel (caller catches). cap bounds the walk. */
138
+ g.MnemeLocalScan.pickAndScan = async function pickAndScan(cap = 4000) {
139
+ if (!g.MnemeLocalScan.supported) throw new Error("UNSUPPORTED");
140
+ const dir = await g.showDirectoryPicker(); // native picker — user grants ONE folder
141
+ const files = [];
142
+ await readDir(dir, "", files, cap);
143
+ // REAL git signals: read .git/logs/HEAD (plain text) for authors / commits / age
144
+ let gitLog = "";
145
+ try {
146
+ const gitDir = await dir.getDirectoryHandle(".git");
147
+ const logsDir = await gitDir.getDirectoryHandle("logs");
148
+ const headFile = await logsDir.getFileHandle("HEAD");
149
+ gitLog = await (await headFile.getFile()).text();
150
+ } catch { /* not a git repo, or no reflog — file signals only */ }
151
+ return { folder: dir.name || "folder", files: files.length, summary: summarize(files, gitLog, Date.now()) };
152
+ };
153
+ })(typeof window !== "undefined" ? window : globalThis);
@@ -22,11 +22,22 @@
22
22
  .grade{width:72px;height:72px;border-radius:16px;display:grid;place-items:center;font-size:36px;font-weight:740;color:#fff;flex:none;box-shadow:inset 0 -2px 6px rgba(0,0,0,.12)}
23
23
  .g-A{background:linear-gradient(135deg,#1fb255,#15863f)}.g-B{background:linear-gradient(135deg,#7bb736,#5c8f1e)}
24
24
  .g-C{background:linear-gradient(135deg,#eaa83a,#c9821a)}.g-D{background:linear-gradient(135deg,#f0742e,#d4571a)}.g-F{background:linear-gradient(135deg,#f43f5e,#be123c)}
25
- .top .repo{font-size:22px;font-weight:640;letter-spacing:-.02em;color:var(--ink);word-break:break-all;line-height:1.2}.top .head{color:var(--sub);font-size:14px;margin-top:4px}
25
+ .top .repo{font-size:22px;font-weight:640;letter-spacing:-.02em;color:var(--ink);word-break:break-all;line-height:1.2}.top .repobr{font-size:13px;font-weight:600;color:var(--a);background:var(--a-soft);border-radius:6px;padding:1px 7px;vertical-align:middle}.top .repourl{display:inline-block;font-size:12.5px;color:var(--a);text-decoration:none;margin-top:3px;word-break:break-all;font-family:ui-monospace,Menlo,monospace}.top .repourl:hover{text-decoration:underline}.top .head{color:var(--sub);font-size:14px;margin-top:4px}
26
26
  .trustbar{display:flex;align-items:center;gap:14px;padding:13px 30px;background:#f0fdf4;border-bottom:1px solid #dcfce7;flex-wrap:wrap}
27
27
  .hgauge{display:inline-flex;align-items:center;gap:8px;font-weight:680;color:#15803d;font-size:14px}
28
28
  .hdot{width:10px;height:10px;border-radius:50%;background:#16a34a;box-shadow:0 0 0 4px rgba(22,163,74,.16)}
29
29
  .htext{font-size:12.5px;color:#3f6b4e;line-height:1.5}.htext b{color:#14532d;font-weight:640}
30
+ .membrane{display:flex;border-bottom:1px solid #eef0f2;background:#fafbfc}
31
+ .mp{flex:1;padding:11px 18px;border-right:1px solid #eef0f2;display:flex;flex-direction:column;gap:3px}.mp:last-child{border-right:0}
32
+ .mpk{font-size:10.5px;letter-spacing:.06em;color:#8a909a;font-weight:700}
33
+ .mpv{font-size:12.5px;color:#2b2f36;font-weight:560;display:flex;align-items:center;gap:7px}
34
+ .triage{padding:14px 30px 4px}
35
+ .triage-h{font-size:13px;font-weight:680;color:#b45309;margin-bottom:9px}.triage-sub{font-weight:400;color:#9aa0a6;font-size:11.5px}
36
+ .triage.clearall{padding:13px 30px;color:#15803d;font-weight:560;font-size:13px;background:#f0fdf4;border-bottom:1px solid #dcfce7}
37
+ .ti{display:flex;gap:11px;align-items:flex-start;padding:8px 0;border-top:1px solid #f1f2f4}
38
+ .ti-sev{flex:0 0 auto;font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;padding:3px 8px;border-radius:6px;min-width:62px;text-align:center}
39
+ .ti.critical .ti-sev{background:#fee2e2;color:#b91c1c}.ti.warn .ti-sev{background:#fef3c7;color:#b45309}.ti.info .ti-sev{background:#e0e7ff;color:#4338ca}
40
+ .ti-body{flex:1;min-width:0}.ti-f{font-size:13px;color:#1f2329}.ti-p{font-size:11px;color:#9aa0a6;margin-top:2px;font-family:ui-monospace,Menlo,monospace}
30
41
  .rows{padding:6px 30px 16px}
31
42
  .row{display:flex;gap:18px;padding:17px 0;border-bottom:1px solid var(--line2);align-items:baseline}.row:last-child{border-bottom:0}
32
43
  .row .k{width:138px;flex:none;display:flex;flex-direction:column;gap:4px;font-size:12.5px;letter-spacing:.05em;text-transform:uppercase;color:var(--ink);font-weight:700}
@@ -34,7 +45,8 @@
34
45
  .spark{font-family:ui-monospace,Menlo,monospace;letter-spacing:1px;color:var(--a)}
35
46
  .muted{color:var(--sub)}
36
47
  .row .v{font-size:15px;color:var(--ink2)}.row .v .big{font-weight:660;color:var(--ink);font-size:16px}
37
- .chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
48
+ .chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;max-height:148px;overflow-y:auto}
49
+ .chips::-webkit-scrollbar{width:8px}.chips::-webkit-scrollbar-thumb{background:#d7dbe0;border-radius:8px}
38
50
  .chip{font-size:12px;padding:4px 10px;border-radius:8px;background:var(--soft);border:1px solid var(--line);color:#5c616b}
39
51
  .chip.bad{background:#fff1f3;border-color:#ffe0e6;color:var(--red)}.chip.warn{background:#fff8ed;border-color:#fde6cc;color:#b45309}
40
52
  .foot{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 30px;background:var(--soft);font-size:13px;color:var(--sub);flex-wrap:wrap}