@echomem/mcp 1.4.8 → 1.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +23 -3
  2. package/assets/canonical-scorer/README.md +18 -0
  3. package/assets/canonical-scorer/analyze-10-problems.mjs +857 -0
  4. package/assets/canonical-scorer/build-session-waste-dashboard.mjs +1628 -0
  5. package/assets/canonical-scorer/golden_anchors.mjs +83 -0
  6. package/assets/canonical-scorer/optimizable_detail.mjs +633 -0
  7. package/dist/city/chaos-to-clarity-pencil.html +582 -0
  8. package/dist/city/echo-ai-city-only.html +1104 -105
  9. package/dist/city/echo-ai-city-only.template.html +1104 -105
  10. package/dist/city/pencil-pie-generator.html +883 -0
  11. package/dist/city/pencil-webgl-landscape.html +1239 -0
  12. package/dist/city/spatial-fan-story.html +479 -0
  13. package/dist/codex-session-files.js +283 -0
  14. package/dist/codex-sync.js +7 -2
  15. package/dist/context-analysis/canonical-golden.js +47 -0
  16. package/dist/context-analysis/claude-native-canonical.js +1193 -0
  17. package/dist/context-analysis/vendored-canonical.js +793 -0
  18. package/dist/context-analysis/workspace-report.js +1838 -0
  19. package/dist/context-metrics/calculate.js +56 -0
  20. package/dist/context-metrics/model-limits.js +26 -0
  21. package/dist/context-metrics/types.js +1 -0
  22. package/dist/forensics-10-problems.js +7 -6
  23. package/dist/forensics.js +863 -132
  24. package/dist/hud/adapters.js +8 -4
  25. package/dist/hud/metric.js +13 -4
  26. package/dist/hud/monitor.js +135 -16
  27. package/dist/hud/web.js +344 -298
  28. package/dist/index.js +7 -3
  29. package/dist/local-data-paths.js +87 -0
  30. package/dist/migrate.js +37 -29
  31. package/dist/report.js +101 -40
  32. package/dist/setup-page.js +3290 -196
  33. package/dist/setup-preview.js +245 -0
  34. package/dist/setup.js +432 -34
  35. package/package.json +5 -4
  36. package/templates/echomem-recall.md +2 -2
@@ -165,6 +165,11 @@
165
165
  box-shadow:0 16px 36px rgba(20,24,40,.18);cursor:pointer;}
166
166
  .setup-connect:hover{background:#223f9b;}
167
167
  body.setup-mode{background:#fff;}
168
+ /* figure-mode: embedded as an inline article figure — city only, chrome hidden, paper shows through */
169
+ body.figure-mode{background:transparent;}
170
+ body.figure-mode .share-card{background:transparent;box-shadow:none;}
171
+ body.figure-mode .fr-panel,body.figure-mode .corner-ribbon,body.figure-mode .city-toggle,
172
+ body.figure-mode .city-split,body.figure-mode .city-timeline,body.figure-mode .v7-band{display:none!important;}
168
173
  body.setup-mode .share-card{width:100vw;height:100vh;border-radius:0;box-shadow:none;overflow:hidden;}
169
174
  body.setup-mode .compute-cost,
170
175
  body.setup-mode .tool-split,
@@ -265,6 +270,139 @@
265
270
  body.setup-mode .v7-foot{display:none;}
266
271
  }
267
272
 
273
+ /* setup split report: city on the left, field report on the right */
274
+ /* Old full-screen setup overlay reserves 620px for the right-hand text panel.
275
+ figure-mode embeds (city as its own column) must NOT reserve anything: the
276
+ canvas fills the iframe, matching the full-width render buffer (VIZ_W). */
277
+ body.setup-mode:not(.figure-mode) #repo-visualizer{right:auto;width:calc(100vw - min(40vw,620px));}
278
+ body.with-echo .fr-metric .fr-v{color:#2a8f7d;}
279
+ body.with-echo .fr-pull{border-left-color:#2a8f7d;}
280
+ .city-toggle{display:none;transition:opacity .4s ease;}
281
+ body.setup-mode .city-toggle{display:inline-flex;position:absolute;z-index:9;
282
+ left:calc((100vw - min(40vw,620px)) / 2);top:clamp(92px,12.5vh,104px);transform:translateX(-50%);gap:2px;
283
+ background:rgba(255,255,255,.92);border:1px solid var(--line);border-radius:999px;padding:4px;
284
+ box-shadow:0 8px 22px rgba(20,24,40,.10);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);}
285
+ .ct-thumb{position:absolute;left:0;top:0;z-index:0;background:#1a3a8f;border-radius:999px;
286
+ transition:transform .32s cubic-bezier(.4,0,.2,1),width .32s cubic-bezier(.4,0,.2,1),height .32s ease;}
287
+ .city-toggle button{position:relative;z-index:1;appearance:none;border:0;background:transparent;cursor:pointer;
288
+ font-family:var(--mono);font-size:11px;font-weight:800;letter-spacing:.06em;text-transform:uppercase;
289
+ color:#80838c;padding:7px 14px;border-radius:999px;transition:color .25s ease;}
290
+ .city-toggle button.on{color:#fff;}
291
+ .city-toggle button:not(.on):hover{color:#1b1c1f;}
292
+ .corner-ribbon{display:none;}
293
+ body.setup-mode .corner-ribbon{display:block;position:absolute;top:0;left:0;width:180px;height:180px;
294
+ overflow:hidden;z-index:12;pointer-events:none;}
295
+ .corner-ribbon>span{position:absolute;left:-70px;top:51px;width:280px;transform:rotate(-45deg);
296
+ background:#1a3a8f;color:#fff;text-align:center;padding:7px 0;line-height:1.2;
297
+ box-shadow:0 6px 16px rgba(20,24,40,.18);}
298
+ .corner-ribbon .l1{display:block;font-family:var(--mono);font-size:12px;font-weight:800;letter-spacing:.14em;text-transform:uppercase;}
299
+ .corner-ribbon .l2{display:block;font-family:var(--mono);font-size:8.5px;font-weight:700;letter-spacing:.08em;
300
+ text-transform:uppercase;color:rgba(255,255,255,.86);margin-top:2px;}
301
+ .city-split{display:none;transition:opacity .4s ease;}
302
+ body.setup-mode .city-split{display:block;position:absolute;z-index:9;
303
+ left:clamp(28px,3vw,48px);bottom:clamp(72px,9vh,104px);width:min(260px,24vw);}
304
+ .city-split .cs-bar{display:flex;height:8px;border-radius:999px;overflow:hidden;background:#eceef2;
305
+ box-shadow:inset 0 0 0 1px rgba(20,24,40,.05);}
306
+ .city-split .cs-bar i{height:100%;display:block;transition:width .4s cubic-bezier(.4,0,.2,1);}
307
+ .city-split .cs-claude{background:#d97757;}
308
+ .city-split .cs-codex{background:#10a37f;}
309
+ .city-split .cs-legend{display:flex;justify-content:space-between;margin-top:9px;
310
+ font-family:var(--mono);font-size:10.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#80838c;}
311
+ .city-split .cs-l{display:flex;align-items:center;gap:6px;}
312
+ .city-split .cs-l i{width:8px;height:8px;border-radius:50%;}
313
+ .city-split .cs-l.claude i{background:#d97757;}
314
+ .city-split .cs-l.codex i{background:#10a37f;}
315
+ .city-split .cs-l b{color:#1b1c1f;font-weight:800;margin-left:2px;}
316
+ .city-timeline{display:none;transition:opacity .4s ease;}
317
+ body.setup-mode .city-timeline{display:block;position:absolute;z-index:9;
318
+ left:clamp(28px,3vw,48px);bottom:clamp(20px,2.6vh,32px);
319
+ width:calc(100vw - min(40vw,620px) - 2 * clamp(28px,3vw,48px));touch-action:none;user-select:none;}
320
+ .city-timeline .tl-track{position:relative;height:18px;cursor:pointer;}
321
+ .city-timeline .tl-line{position:absolute;left:0;right:0;top:50%;transform:translateY(-50%);
322
+ height:3px;border-radius:3px;background:#d8dbe1;}
323
+ .city-timeline .tl-progress{position:absolute;left:0;top:50%;transform:translateY(-50%);
324
+ width:100%;height:3px;border-radius:3px;background:#1a3a8f;pointer-events:none;}
325
+ .city-timeline .tl-dot{position:absolute;top:50%;transform:translate(-50%,-50%);width:9px;height:9px;border-radius:50%;}
326
+ .city-timeline .tl-dot.start{left:0;background:#9aa0aa;}
327
+ .city-timeline .tl-dot.end{left:100%;background:#1a3a8f;}
328
+ .city-timeline .tl-dot.end::after{content:'';position:absolute;inset:-4px;border-radius:50%;
329
+ border:2px solid rgba(26,58,143,.4);animation:tlPulse 2.2s ease-out infinite;}
330
+ @keyframes tlPulse{0%{transform:scale(.6);opacity:.9}100%{transform:scale(1.85);opacity:0}}
331
+ .city-timeline .tl-handle{position:absolute;left:100%;top:50%;transform:translate(-50%,-50%);
332
+ width:16px;height:16px;border-radius:50%;background:#1a3a8f;border:3px solid #fff;
333
+ box-shadow:0 3px 10px rgba(20,24,40,.28);z-index:3;cursor:grab;}
334
+ .city-timeline.dragging .tl-handle{cursor:grabbing;box-shadow:0 3px 14px rgba(26,58,143,.42);}
335
+ .city-timeline .tl-selected{position:absolute;left:100%;top:-17px;transform:translateX(-50%);
336
+ font-family:var(--mono);font-size:9px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;
337
+ color:#1a3a8f;white-space:nowrap;pointer-events:none;}
338
+ .city-timeline .tl-labels{display:flex;justify-content:space-between;align-items:baseline;margin-top:8px;
339
+ font-family:var(--mono);font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#80838c;}
340
+ .city-timeline .tl-labels .tl-span{color:#1b1c1f;font-weight:800;}
341
+ body.setup-mode .v7-persona,
342
+ body.setup-mode .v7-head,
343
+ body.setup-mode .v7-band,
344
+ body.setup-mode .v7-ctaWrap,
345
+ body.setup-mode .v7-foot{display:none;}
346
+
347
+ .fr-panel{display:none;transition:opacity .6s ease;}
348
+ body.setup-mode .fr-panel{display:flex;flex-direction:column;align-items:center;position:absolute;z-index:8;
349
+ right:0;top:0;width:min(40vw,620px);height:100vh;border-left:1px solid var(--line);background:#fff;
350
+ padding:clamp(44px,6vh,90px) clamp(36px,3.2vw,60px);}
351
+ .fr{width:min(54ch,100%);flex:1;display:flex;flex-direction:column;justify-content:space-between;
352
+ gap:clamp(22px,3.4vh,46px);}
353
+ .fr-dateline{display:flex;align-items:center;gap:12px;font-family:var(--mono);font-size:11px;
354
+ font-weight:700;letter-spacing:.16em;text-transform:uppercase;color:#9aa0aa;}
355
+ .fr-dateline .fr-rule{flex:1;height:1px;background:var(--line);}
356
+ .fr-intro{display:flex;flex-direction:column;position:relative;
357
+ padding-bottom:clamp(20px,3vh,38px);border-bottom:1px solid var(--line);}
358
+ .fr-avatar{width:clamp(92px,9.5vw,128px);height:auto;display:block;margin-bottom:10px;}
359
+ .fr-greet{font-size:clamp(34px,3.2vw,52px);font-weight:800;letter-spacing:-.035em;line-height:.95;
360
+ text-transform:lowercase;}
361
+ .fr-greet .fr-name{color:#1a3a8f;}
362
+ .fr-greet .fr-dot{color:var(--amber);}
363
+ .fr-rank{margin-top:13px;font-family:var(--mono);font-size:clamp(12px,1vw,14px);font-weight:600;
364
+ letter-spacing:.04em;color:#9296a0;}
365
+ .fr-rank b{font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:#3a3c43;}
366
+ .fr-readBtn{position:absolute;z-index:9;top:clamp(2px,0.5vw,8px);right:0;
367
+ display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;
368
+ cursor:pointer;font-family:var(--mono);font-size:14px;font-weight:800;line-height:1;
369
+ color:#5a5e66;background:#fafafb;border:1px solid var(--line);border-radius:50%;
370
+ transition:background .15s ease,border-color .15s ease,color .15s ease;}
371
+ .fr-readBtn:hover,.fr-readBtn:focus-visible{background:#fff;border-color:#d7dade;color:var(--ink);outline:none;}
372
+ .fr-readBtn[aria-expanded="true"]{background:#1a3a8f;border-color:#1a3a8f;color:#fff;}
373
+ .fr-personaHover{position:absolute;z-index:32;top:clamp(42px,4.6vw,56px);right:0;
374
+ width:min(340px,100%);max-height:min(72vh,540px);overflow:auto;
375
+ background:rgba(255,255,255,.98);border:1px solid var(--line);border-radius:16px;padding:15px 17px;
376
+ box-shadow:0 18px 46px rgba(20,24,40,.18);opacity:0;visibility:hidden;transform:translateY(-4px);
377
+ transition:opacity .14s ease,transform .14s ease;pointer-events:none;}
378
+ .fr-personaHover.open{opacity:1;visibility:visible;transform:none;pointer-events:auto;}
379
+ .fr-personaHover .ph-line{font-size:13.5px;line-height:1.5;color:#42444b;margin-bottom:9px;}
380
+ .fr-personaHover .ph-line:last-of-type{margin-bottom:0;}
381
+ .fr-personaHover .ph-line b{color:var(--ink);font-weight:800;}
382
+ .fr-personaHover .ph-meta{font-size:12px;line-height:1.45;color:#73767e;margin-top:9px;
383
+ padding-top:9px;border-top:1px solid var(--line);}
384
+ .fr-personaHover .ph-meta b{color:var(--blue);font-weight:700;}
385
+ .fr-mid{display:flex;flex-direction:column;gap:clamp(20px,3vh,40px);}
386
+ .fr-lede{font-size:clamp(16px,1.1vw,18px);line-height:1.68;color:#3c3e45;}
387
+ .fr-lede mark{background:linear-gradient(transparent 58%, rgba(240,168,63,.42) 58%);
388
+ color:var(--ink);font-weight:800;padding:0 1px;}
389
+ .fr-pull{padding-left:20px;border-left:3px solid var(--red);}
390
+ .fr-metric{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;
391
+ transition:opacity .5s ease,transform .5s ease;}
392
+ .fr-metric .fr-v{font-size:clamp(32px,2.8vw,44px);font-weight:800;color:var(--red);letter-spacing:-.03em;line-height:.9;}
393
+ .fr-metric .fr-l{font-size:clamp(16px,1.2vw,19px);font-weight:700;color:var(--ink);letter-spacing:-.01em;}
394
+ .fr-say{font-size:clamp(14px,.9vw,15px);color:#56585f;margin-top:9px;line-height:1.5;max-width:46ch;
395
+ min-height:4.6em;transition:opacity .5s ease,transform .5s ease;}
396
+ .fr-say b{color:var(--ink);font-weight:700;}
397
+ .fr-metric.is-swapping,.fr-say.is-swapping{opacity:0;transform:translateY(6px);}
398
+ .fr-signoff{display:flex;align-items:center;gap:18px;flex-wrap:wrap;}
399
+ .fr-cta{display:inline-flex;align-items:center;gap:10px;background:#1a3a8f;color:#fff;border:0;
400
+ border-radius:13px;font-family:var(--mono);font-size:clamp(13px,1vw,15px);font-weight:800;
401
+ letter-spacing:.08em;text-transform:uppercase;padding:16px 34px;
402
+ box-shadow:0 18px 40px rgba(26,58,143,.30);cursor:pointer;transition:background .18s ease,transform .18s ease;}
403
+ .fr-cta:hover{background:#223f9b;transform:translateY(-2px);}
404
+ .fr-micro{font-size:12.5px;color:#8b8d94;line-height:1.45;max-width:24ch;}
405
+
268
406
  /* ===== in-canvas scan loader (setup mode) — loading lives inside the report, matches its type ===== */
269
407
  .scan-loading{position:absolute;inset:0;z-index:6;display:flex;flex-direction:column;align-items:center;justify-content:center;
270
408
  text-align:center;gap:14px;padding:24px;opacity:1;transition:opacity .5s ease;pointer-events:none;}
@@ -278,10 +416,14 @@
278
416
  /* during scan: hide the city + the value-bearing chrome (so demo numbers never flash), then fade in together */
279
417
  #repo-visualizer{transition:opacity .7s ease;}
280
418
  body.scanning #repo-visualizer{opacity:0;}
281
- .setup-header,.dirty-context,.counterfactual,.setup-connect,.v7-z{transition:opacity .6s ease;}
282
- body.scanning .setup-header,body.scanning .dirty-context,body.scanning .counterfactual,body.scanning .setup-connect,body.scanning .v7-z{opacity:0;pointer-events:none;}
419
+ .setup-header,.dirty-context,.counterfactual,.setup-connect,.v7-z,.city-toggle,.city-split,.city-timeline,.fr-panel{transition:opacity .6s ease;}
420
+ body.scanning .setup-header,body.scanning .dirty-context,body.scanning .counterfactual,body.scanning .setup-connect,body.scanning .v7-z,body.scanning .city-toggle,body.scanning .city-split,body.scanning .city-timeline,body.scanning .fr-panel{opacity:0;pointer-events:none;}
283
421
  body.setup-mode:not(.scanning) #repo-visualizer,
284
- body.setup-mode:not(.scanning) .v7-z{opacity:1;}
422
+ body.setup-mode:not(.scanning) .v7-z,
423
+ body.setup-mode:not(.scanning) .city-toggle,
424
+ body.setup-mode:not(.scanning) .city-split,
425
+ body.setup-mode:not(.scanning) .city-timeline,
426
+ body.setup-mode:not(.scanning) .fr-panel{opacity:1;}
285
427
 
286
428
  /* hover-to-preview repo detail (interaction overlay, not part of the static export) */
287
429
  #repo-detail{position:fixed;display:none;z-index:50;width:286px;background:rgba(255,255,255,.96);
@@ -308,10 +450,38 @@
308
450
  </style>
309
451
  </head>
310
452
  <body>
311
- <script>try{const p=new URLSearchParams(location.search);if(p.get('setup')==='1'){document.body.classList.add('setup-mode');if(p.get('nonce'))document.body.classList.add('scanning');}}catch(e){}</script>
453
+ <script>try{const p=new URLSearchParams(location.search);if(p.get('setup')==='1'){document.body.classList.add('setup-mode','scanning');}if(p.get('figure')==='1'){document.body.classList.add('figure-mode');window.addEventListener('message',function(e){if(e.origin!==location.origin)return;var d=e.data||{};if(d.type==='echomem:city-figure')document.body.classList.toggle('figure-mode',!d.expanded);});}}catch(e){}</script>
312
454
  <div class="share-card">
313
455
  <canvas id="repo-visualizer"></canvas>
314
456
 
457
+ <div class="city-toggle" id="city-toggle" role="group" aria-label="City metric">
458
+ <span class="ct-thumb" aria-hidden="true"></span>
459
+ <button type="button" data-metric="time" class="on">Time</button>
460
+ <button type="button" data-metric="tokens">Tokens</button>
461
+ <button type="button" data-metric="cost">API $</button>
462
+ </div>
463
+
464
+ <div class="corner-ribbon" aria-hidden="true"><span><span class="l1">Local Scan</span><span class="l2">100% private &amp; safe</span></span></div>
465
+
466
+ <div class="city-split" id="city-split">
467
+ <div class="cs-bar"><i class="cs-claude" style="width:74%"></i><i class="cs-codex" style="width:26%"></i></div>
468
+ <div class="cs-legend">
469
+ <span class="cs-l claude"><i></i>Claude <b>74%</b></span>
470
+ <span class="cs-l codex"><i></i>Codex <b>26%</b></span>
471
+ </div>
472
+ </div>
473
+
474
+ <div class="city-timeline" id="city-timeline">
475
+ <div class="tl-track">
476
+ <span class="tl-line"></span>
477
+ <span class="tl-progress"></span>
478
+ <span class="tl-dot start"></span><span class="tl-dot end"></span>
479
+ <span class="tl-selected">Today</span>
480
+ <span class="tl-handle" role="slider" tabindex="0" aria-label="Coding history date" aria-valuemin="0" aria-valuemax="100" aria-valuenow="100"></span>
481
+ </div>
482
+ <div class="tl-labels"><span class="tl-start">Dec 2024</span><span class="tl-span">AI coding history</span><span class="tl-end">Today</span></div>
483
+ </div>
484
+
315
485
  <div class="v7-z v7-persona" tabindex="0" aria-describedby="v7-persona-hover">
316
486
  <div class="v7-eyebrow">AI Coding City</div>
317
487
  <div class="v7-personaName">bossy</div>
@@ -333,25 +503,48 @@
333
503
  <div class="eyebrow">What we found</div>
334
504
  <img class="v7-echo" src="echo-face-cutout.png" alt="" />
335
505
  <div class="v7-bandCopy">
336
- <div class="bignum" id="v7-metric"><span id="v7-metric-value">85.0%</span><span id="v7-metric-label">repeated reads</span><button class="v7-infoBtn" id="v7-info-btn" type="button" aria-label="How we read your city" aria-expanded="false" aria-controls="v7-info-sheet">i</button></div>
337
- <div class="say" id="v7-metric-say">Your agents re-load context they've already seen unchanged files, the same rules, stale maps every session. That's context rot. It's <b>how coding agents work today</b>, and it's the haze on your city.</div>
506
+ <div class="bignum" id="v7-metric"><span id="v7-metric-value">85.0%</span><span id="v7-metric-label">wasteful context</span><button class="v7-infoBtn" id="v7-info-btn" type="button" aria-label="How we read your city" aria-expanded="false" aria-controls="v7-info-sheet">i</button></div>
507
+ <div class="say" id="v7-metric-say">The golden-standard model separates useful context from wasteful context, then attributes the waste to concrete problem types across your workspace.</div>
338
508
  </div>
339
509
  </div>
340
510
  <div class="v7-z v7-ctaWrap">
341
511
  <div class="pre">Ready when you are</div>
342
512
  <button class="v7-cta" id="v7-connect" type="button">Connect Echo →</button>
343
- <div class="micro">Clears the dirty context, so agents stop relearning your workspace.</div>
344
513
  </div>
345
514
  <div class="v7-z v7-foot"><span>Generated by Echo · No transcripts shown</span><span>Local scan · Share-safe city mode</span></div>
515
+
516
+ <div class="fr-panel">
517
+ <div class="fr">
518
+ <div class="fr-dateline"><span>Echo field report</span><span class="fr-rule"></span></div>
519
+ <div class="fr-intro">
520
+ <button class="fr-readBtn" id="fr-read-btn" type="button" aria-expanded="false" aria-controls="fr-persona-hover" aria-label="Your full coding-personality read">?</button>
521
+ <div class="fr-personaHover" id="fr-persona-hover" role="region" aria-label="Your full coding-personality read"></div>
522
+ <img class="fr-avatar" id="fr-avatar" src="./personality_stickers/bossy.png" alt="" />
523
+ <h1 class="fr-greet">hey, <span class="fr-name" id="fr-name">bossy</span><span class="fr-dot">.</span></h1>
524
+ <div class="fr-rank" id="fr-rank">You are <b>the architect · marathoner</b></div>
525
+ </div>
526
+ <div class="fr-mid">
527
+ <p class="fr-lede">Across <mark id="fr-repos">13 repos</mark> you poured in <mark id="fr-hours">191.1 hours</mark> and <mark id="fr-tokens">7.49B tokens</mark> — about <mark id="fr-cost">$13,689</mark> of API-equivalent compute. That whole skyline on the left? You built it.</p>
528
+ <div class="fr-pull">
529
+ <div class="fr-metric" id="fr-metric"><span class="fr-v" id="fr-metric-value">85.0%</span><span class="fr-l" id="fr-metric-label">wasteful context</span></div>
530
+ <div class="fr-say" id="fr-metric-say">The golden-standard model separates useful context from wasteful context, then attributes the waste to concrete problem types.</div>
531
+ </div>
532
+ </div>
533
+ <div class="fr-signoff">
534
+ <button class="fr-cta" id="fr-connect" type="button">Connect Echo →</button>
535
+ </div>
536
+ </div>
537
+ </div>
538
+
346
539
  <div class="v7-infoSheet" id="v7-info-sheet" aria-hidden="true">
347
540
  <div class="v7-infoCard" role="dialog" aria-modal="true" aria-labelledby="v7-info-title">
348
541
  <button class="v7-infoClose" id="v7-info-close" type="button" aria-label="Close">×</button>
349
542
  <div class="v7-infoTitle" id="v7-info-title">How we read your city</div>
350
543
  <div class="v7-infoSub">Every number comes from your local Codex + Claude Code logs. Nothing here is an estimate of you — it's a readout of the tools.</div>
351
- <div class="v7-infoRow"><div class="sk">Dirty context</div><div class="sv">Agents re-read files they've already seen — unchanged code, the same rules, stale maps every new session. Nothing new is learned; it's re-remembering. That build-up is <b>context rot</b>.</div></div>
352
- <div class="v7-infoRow"><div class="sk">The 85.0%</div><div class="sv">Share of your agents' total reads that were <b>repeats</b> of context already loaded earlier, across your sessions.</div></div>
544
+ <div class="v7-infoRow"><div class="sk">Wasteful context</div><div class="sv">The headline comes from the golden-standard workspace rollup: waste tokens divided by official input tokens.</div></div>
545
+ <div class="v7-infoRow"><div class="sk">Useful context</div><div class="sv">Useful context is the portion worth keeping: active task state, product facts, current files, and required overhead.</div></div>
353
546
  <div class="v7-infoRow"><div class="sk">$13,689</div><div class="sv"><b>API-equivalent</b> — what this usage would cost at list API rates. Most of it is cache / input reads, so it's a measure of <b>volume, not a bill you paid</b>.</div></div>
354
- <div class="v7-infoRow"><div class="sk">18 days</div><div class="sv">With the repeated context cleared, the same workspace state is reached <b>~18 days sooner</b> derived from your attention hours vs. the re-read overhead.</div></div>
547
+ <div class="v7-infoRow"><div class="sk">Evidence</div><div class="sv">Repeated reads are still evidence, but the top-line waste number now includes duplicate context, re-finding, dead work, and residue.</div></div>
355
548
  <div class="v7-infoNote">Computed locally · no transcripts leave your machine.</div>
356
549
  </div>
357
550
  </div>
@@ -405,8 +598,8 @@
405
598
 
406
599
  <!-- dirty context -->
407
600
  <div class="card-zone dirty-context">
408
- <div class="pct" id="setup-metric"><span id="setup-metric-value">85.0%</span><span class="word" id="setup-metric-label">repeated reads</span><button class="v7-infoBtn" id="setup-info-btn" type="button" aria-label="How we read your city" aria-expanded="false" aria-controls="v7-info-sheet">i</button></div>
409
- <div class="desc" id="setup-metric-say">Your agents re-load context they've already seen unchanged files, the same rules, stale maps — every session. That's context rot.</div>
601
+ <div class="pct" id="setup-metric"><span id="setup-metric-value">85.0%</span><span class="word" id="setup-metric-label">wasteful context</span><button class="v7-infoBtn" id="setup-info-btn" type="button" aria-label="How we read your city" aria-expanded="false" aria-controls="v7-info-sheet">i</button></div>
602
+ <div class="desc" id="setup-metric-say">The golden-standard model separates useful context from wasteful context across your workspace.</div>
410
603
  </div>
411
604
 
412
605
  <!-- counterfactual: one line — 18 days saved -->
@@ -435,6 +628,30 @@
435
628
 
436
629
  <div id="repo-detail"></div>
437
630
 
631
+ <script>
632
+ // The poster shares this artifact, but setup must never expose poster labels while it is waiting or
633
+ // failed. Scrub personalized defaults after the DOM exists; the validated report repopulates them.
634
+ try{
635
+ if(new URLSearchParams(location.search).get('setup')==='1'){
636
+ const pendingText={
637
+ 'v7-repo-count':'—','v7-attention':'—','v7-cost':'—','v7-total-tokens':'—',
638
+ 'v7-metric-value':'—','fr-name':'—','fr-rank':'Waiting for local scan',
639
+ 'fr-repos':'— repos','fr-hours':'— hours','fr-tokens':'— tokens','fr-cost':'—',
640
+ 'fr-metric-value':'—','setup-metric-value':'—'
641
+ };
642
+ for(const [id,value] of Object.entries(pendingText)){ const el=document.getElementById(id); if(el) el.textContent=value; }
643
+ document.querySelectorAll('.v7-personaName,.setup-persona-name,.persona-card .name').forEach((el)=>{ el.textContent='—'; });
644
+ document.querySelectorAll('.v7-rank,.setup-rank,.persona-card .rankrhythm').forEach((el)=>{ el.textContent='Waiting for local scan'; });
645
+ document.querySelectorAll('.v7-combo,.setup-combo,.persona-card .combo,.setup-split,#tool-split').forEach((el)=>{ el.replaceChildren(); });
646
+ document.querySelectorAll('.setup-stats .amount,.compute-cost .amount').forEach((el)=>{ el.textContent='—'; });
647
+ document.querySelectorAll('.setup-stats .detail,.compute-cost .detail').forEach((el)=>{ el.textContent='Waiting for validated local data.'; });
648
+ document.querySelectorAll('#v7-metric-say,#fr-metric-say,#setup-metric-say').forEach((el)=>{ el.textContent='Waiting for a validated local report.'; });
649
+ const frAvatar=document.getElementById('fr-avatar'); if(frAvatar) frAvatar.removeAttribute('src');
650
+ document.querySelectorAll('.share-card > :not(#scan-loading)').forEach((el)=>{ el.setAttribute('aria-hidden','true'); el.setAttribute('data-setup-pending-hidden',''); });
651
+ }
652
+ }catch(_){}
653
+ </script>
654
+
438
655
  <script type="importmap">
439
656
  { "imports": { "three": "./vendor/three.module.min.js" } }
440
657
  </script>
@@ -445,11 +662,41 @@ import { OrbitControls } from './vendor/OrbitControls.js';
445
662
 
446
663
  const pageParams=new URLSearchParams(location.search);
447
664
  const setupMode=pageParams.get('setup')==='1';
665
+ const previewMode=pageParams.get('preview')==='1';
666
+ const figureModeEmbed=pageParams.get('figure')==='1';
448
667
  if(setupMode) document.body.classList.add('setup-mode');
449
- let VIZ_W=setupMode ? window.innerWidth : 920;
450
- let VIZ_H=setupMode ? window.innerHeight : 1180; // repo-visualizer canvas = full card in share mode, viewport in setup mode
451
- const TRAY_COL = 0xece5d3; // warm IVORY not pure white, not cool grey; separates from the white card
668
+ // figure-mode embeds the city as its own column beside the text: the canvas owns the whole
669
+ // iframe. The 620px text reservation only applies to the old full-screen setup overlay.
670
+ let VIZ_W=setupMode ? (figureModeEmbed ? window.innerWidth : Math.round(window.innerWidth - Math.min(window.innerWidth*0.4, 620))) : 920;
671
+ let VIZ_H=setupMode ? window.innerHeight : 1180; // repo-visualizer canvas = full card in share mode, left city in setup mode
672
+ const TRAY_COL = 0xd9d5ca; // worn warm concrete; keeps the corrupted city distinct from the white report panel
452
673
  const TOOL = { codex:0x10a37f, claude:0xd97757 }; // ONLY two cube colours — exact OpenAI / Claude chip-icon hexes
674
+ const CORRUPTION = 0.82;
675
+ const corruptionFloaters=[];
676
+ const repairGroups=[];
677
+ const repairPieces=[];
678
+ const corruptionFades=[];
679
+ const corruptionSurfaces=[];
680
+ const buildingMaterials=[];
681
+
682
+ function hash01(value, salt=0){
683
+ let h=2166136261 ^ salt;
684
+ for(let i=0;i<String(value).length;i++){
685
+ h^=String(value).charCodeAt(i);
686
+ h=Math.imul(h,16777619);
687
+ }
688
+ return (h>>>0)/4294967295;
689
+ }
690
+ function corruptionProfile(o){
691
+ const pressure=Math.min(1, Math.max(0.28, Math.log10(Math.max(10, o.tokens||10))/10));
692
+ return {
693
+ amount:CORRUPTION*(0.58+pressure*0.42),
694
+ leanX:(hash01(o.name,11)-0.5)*0.25,
695
+ leanZ:(hash01(o.name,19)-0.5)*0.25,
696
+ phase:hash01(o.name,29)*Math.PI*2,
697
+ side:hash01(o.name,37)<0.5?-1:1,
698
+ };
699
+ }
453
700
 
454
701
  // ── volume normalization ─────────────────────────────────────────────────────
455
702
  // Each cube's VOLUME is proportional to that repo's token usage, measured in a fixed unit so
@@ -464,6 +711,8 @@ const LABEL_MIN_BASE = 24; // cubes with footprint ≥ this get a top-face
464
711
  const fmtTok=(n)=> n>=1e9?`${(n/1e9).toFixed(2)}B` : n>=1e6?`${Math.round(n/1e6)}M` : `${Math.round(n/1e3)}K`;
465
712
  const money=(n)=>'$'+Math.round(Number(n)||0).toLocaleString('en-US');
466
713
  const oneDec=(n)=>Number(n||0).toFixed(1).replace(/\.0$/,'');
714
+ const REPEATED_READS_SAY="Your agents re-read unchanged files, re-run the same searches, and check git again before they can move. Echo turns those repeated proofs into recall.";
715
+ const GOLDEN_WASTE_SAY="The golden-standard model labels every analyzed input token as useful or wasteful, then attributes waste to duplicate context, re-finding, dead work, and residue across Codex and Claude Code.";
467
716
  const monthDay=(iso)=>{
468
717
  const d=new Date(iso);
469
718
  if(Number.isNaN(d.getTime())) return {month:'JUN', day:'2', label:'Jun 2'};
@@ -481,9 +730,71 @@ function providerSplit(report){
481
730
  const codexPct=Math.round(codex/total*100);
482
731
  return {codexPct, claudePct:100-codexPct, dominant:codex>=claude?'codex':'claude'};
483
732
  }
733
+ function clampScore(value){
734
+ return Math.max(0, Math.min(100, Math.round(Number(value)||0)));
735
+ }
736
+ function reportContextMetrics(report){
737
+ const metrics=report?.contextWindow?.metrics;
738
+ return metrics && typeof metrics==='object' ? metrics : null;
739
+ }
740
+ function contextHealthScore(report){
741
+ const metrics=reportContextMetrics(report);
742
+ const direct=Number(metrics?.healthScorePct);
743
+ if(Number.isFinite(direct)) return clampScore(direct);
744
+ const stale=Number(report?.cleanliness?.staleReadSharePct);
745
+ return report && Number.isFinite(stale) ? clampScore(100-stale) : null;
746
+ }
747
+ function pctText(value){
748
+ const n=Number(value);
749
+ return Number.isFinite(n) ? `${Math.round(Math.max(0, Math.min(1, n))*100)}%` : null;
750
+ }
751
+ function contextPressureText(metrics){
752
+ const projected=pctText(metrics?.projectedNextFullnessPct);
753
+ if(projected) return `${projected} projected window pressure`;
754
+ const fullness=pctText(metrics?.contextFullnessPct);
755
+ if(fullness) return `${fullness} window full`;
756
+ const latest=Number(metrics?.latestInputTokens);
757
+ return Number.isFinite(latest) && latest>0 ? `${fmtTok(latest)} latest input` : 'current window pressure';
758
+ }
759
+ function contextNoiseText(report){
760
+ const metrics=reportContextMetrics(report);
761
+ const noise=pctText(metrics?.noisePct);
762
+ if(noise) return `${noise} resident noise`;
763
+ const stale=Number(report?.cleanliness?.staleReadSharePct);
764
+ return Number.isFinite(stale) ? `${oneDec(stale)}% repeated-read signal` : 'resident noise';
765
+ }
766
+ function contextScoreSay(report){
767
+ const score=contextHealthScore(report);
768
+ const metrics=reportContextMetrics(report);
769
+ return `This is the same <b>${score}% context score</b> the HUD uses: ${contextNoiseText(report)} and ${contextPressureText(metrics)}, folded into one live setup readout.`;
770
+ }
771
+ function goldenReport(report){
772
+ const gs=report?.canonicalGoldenStandard || report?.goldenStandard;
773
+ return gs && typeof gs==='object' && !gs.error && gs.summary ? gs : null;
774
+ }
775
+ function goldenSummary(report){
776
+ return goldenReport(report)?.summary || null;
777
+ }
778
+ function goldenWastePct(report){
779
+ const pct=Number(goldenSummary(report)?.wastePct);
780
+ return Number.isFinite(pct) ? pct : Number(report?.cleanliness?.staleReadSharePct)||0;
781
+ }
782
+ function goldenUsefulPct(report){
783
+ const pct=Number(goldenSummary(report)?.usefulPct);
784
+ return Number.isFinite(pct) ? pct : Math.max(0, 100-goldenWastePct(report));
785
+ }
786
+ function goldenWasteSay(report){
787
+ const s=goldenSummary(report);
788
+ if(!s) return REPEATED_READS_SAY;
789
+ const p=report?.billedWasteProjection;
790
+ if(p && p.method==='canonical-window-share-over-provider-input'){
791
+ return `Across <b>${Number(s.sessionsAnalyzed||0).toLocaleString('en-US')} sessions</b>, the canonical windows found <b>${oneDec(s.wastePct)}% wasteful context</b>: a provider-billed projection of ${fmtTok(Number(p.projectedWasteTokens)||0)} waste tokens out of ${fmtTok(Number(p.billedInputTokens)||0)} input.`;
792
+ }
793
+ return `Across <b>${Number(s.sessionsAnalyzed||0).toLocaleString('en-US')} sessions</b>, the canonical windows found <b>${oneDec(s.wastePct)}% wasteful context</b>.`;
794
+ }
484
795
  // ---- coding-persona engine (Phase 1: only signals the scan actually has — no commits / accept-all yet) ----
485
796
  const PERSONA_TRAITS={
486
- loopy:{label:'loopy', blurb:"opened the same file 14 times and still doesn't know what's in it. it's not working, it's re-remembering. context rot undefeated and the bill's yours. 🗿"},
797
+ loopy:{label:'loopy', blurb:"opened the same file 14 times and still had to prove it again. not a skill issue the session forgot what it had already seen."},
487
798
  lusty:{label:'lusty', blurb:"burned a car payment in tokens for demos that'll never ship. zero regrets. Anthropic's about to send a thank-you card. 💸"},
488
799
  maxxy:{label:'maxxy', blurb:"Opus to rename a variable, Opus to fix a typo. the small models are right there — but you don't do small models, you do consequences."},
489
800
  tabby:{label:'tabby', blurb:"tabs everywhere, half-built features, breadth over depth. your brain is a browser with a memory leak. 🫠"},
@@ -515,7 +826,7 @@ function derivePersona(report){
515
826
  const turns=Math.max(1, Number(uwh.realUserTurns)||0);
516
827
  const autonomy=assistant/turns; // AI turns per human prompt → how hands-off you run
517
828
  const repoCount=Number(scale.repoCount)||repos.length;
518
- const stale=Number(cl.staleReadSharePct)||0, spend=Number(cost.total)||0;
829
+ const stale=goldenWastePct(report), spend=Number(cost.total)||0;
519
830
  // Phase-2 git signal: commits landed vs building activity. Gated on commits>0 so a missing git pass can't false-fire ghosty.
520
831
  const commits=Number(scale.totalCommits)||0, attention=Number(uwh.userAttentionHours)||0;
521
832
  const activeDaysG=Number(uwh.activeAttentionDays)||0;
@@ -578,6 +889,8 @@ function renderV7PersonaHover(persona){
578
889
  host.innerHTML = line(persona.primary) + (persona.sides||[]).map(line).join('') +
579
890
  (persona.rank?`<div class="ph-meta"><b>${esc(persona.rank.label)}</b> · ${esc(persona.rank.blurb)}</div>`:'') +
580
891
  (persona.rhythm?`<div class="ph-meta"><b>${esc(persona.rhythm.label)}</b> · ${esc(persona.rhythm.blurb)}</div>`:'');
892
+ const frHost=document.getElementById('fr-persona-hover');
893
+ if(frHost) frHost.innerHTML=host.innerHTML;
581
894
  }
582
895
  function buildSetupRepos(report){
583
896
  const split=providerSplit(report);
@@ -609,6 +922,7 @@ function buildSetupRepos(report){
609
922
  sessions:Number(repo.sessions)||0,
610
923
  codexPct, claudePct:100-codexPct,
611
924
  pin:i<2,
925
+ daily:Array.isArray(repo.daily)?repo.daily:null,
612
926
  };
613
927
  });
614
928
  }
@@ -618,13 +932,17 @@ function applySetupReport(report){
618
932
  const persona=derivePersona(report);
619
933
  const cost=money(report?.cost?.total);
620
934
  const attention=oneDec(report?.userWorkingHours?.userAttentionHours);
621
- const dirty=oneDec(report?.cleanliness?.staleReadSharePct);
935
+ const dirty=oneDec(goldenWastePct(report));
936
+ const metricValue=`${dirty}%`;
937
+ const metricLabel='wasteful context';
938
+ const metricSay=goldenWasteSay(report);
622
939
  const cf=report?.counterfactual || {};
623
940
  const reach=monthDay(cf.counterfactualDate);
624
941
  const instead=monthDay(report?.scanEndDate).label;
625
- const days=Math.max(1, Math.round(Number(cf.daysGained)||0));
942
+ const days=Math.max(0, Math.round(Number(cf.daysGained)||0));
626
943
  document.querySelectorAll('.setup-persona-name,.persona-card .name,.v7-personaName').forEach((el)=>{ el.textContent=persona.primary.label; });
627
944
  document.querySelectorAll('.setup-rank,.persona-card .rankrhythm,.v7-rank').forEach((el)=>{ el.textContent=`${persona.rank.label} · ${persona.rhythm.label}`; });
945
+ const frRank=document.getElementById('fr-rank'); if(frRank) frRank.innerHTML=`You are <b>${persona.rank.label}</b> on <b>${persona.rhythm.label}</b>`;
628
946
  document.querySelectorAll('.setup-combo,.persona-card .combo,.v7-combo').forEach((el)=>{ el.innerHTML=persona.sides.map((s)=>`<span class="ct">${s.label}</span>`).join(''); });
629
947
  renderPersonaHover(persona);
630
948
  renderV7PersonaHover(persona);
@@ -635,45 +953,343 @@ function applySetupReport(report){
635
953
  const repoCount=Number(report?.scale?.repoCount) || (Array.isArray(report?.repos) ? report.repos.length : 0);
636
954
  const v7RepoCount=document.getElementById('v7-repo-count'); if(v7RepoCount && repoCount) v7RepoCount.textContent=String(repoCount);
637
955
  const v7Tokens=document.getElementById('v7-total-tokens'); if(v7Tokens) v7Tokens.textContent=fmtTok(Number(report?.scale?.totalTokens)||0);
638
- const v7MetricValue=document.getElementById('v7-metric-value'); if(v7MetricValue) v7MetricValue.textContent=`${dirty}%`;
639
- const v7MetricLabel=document.getElementById('v7-metric-label'); if(v7MetricLabel) v7MetricLabel.textContent='repeated reads';
956
+ const v7MetricValue=document.getElementById('v7-metric-value'); if(v7MetricValue) v7MetricValue.textContent=metricValue;
957
+ const v7MetricLabel=document.getElementById('v7-metric-label'); if(v7MetricLabel) v7MetricLabel.textContent=metricLabel;
640
958
  const v7MetricSay=document.getElementById('v7-metric-say');
641
- if(v7MetricSay) v7MetricSay.innerHTML="Your agents re-load context they've already seen — unchanged files, the same rules, stale maps — every session. That's context rot. It's <b>how coding agents work today</b>, and it's the haze on your city.";
959
+ if(v7MetricSay) v7MetricSay.innerHTML=metricSay;
960
+ const frName=document.getElementById('fr-name'); if(frName) frName.textContent=persona.primary.label;
961
+ const frAvatar=document.getElementById('fr-avatar');
962
+ if(frAvatar){
963
+ const key=String(persona.primary?.label||'').toLowerCase();
964
+ if(PERSONA_TRAITS[key]){ frAvatar.src=`./personality_stickers/${key}.png`; frAvatar.alt=`${key} avatar`; }
965
+ }
966
+ const frRepos=document.getElementById('fr-repos'); if(frRepos && repoCount) frRepos.textContent=`${repoCount} ${repoCount===1?'repo':'repos'}`;
967
+ const frHours=document.getElementById('fr-hours'); if(frHours) frHours.textContent=`${attention} hours`;
968
+ const frTokens=document.getElementById('fr-tokens'); if(frTokens) frTokens.textContent=`${fmtTok(Number(report?.scale?.totalTokens)||0)} tokens`;
969
+ const frCost=document.getElementById('fr-cost'); if(frCost) frCost.textContent=cost;
970
+ const frMetricValue=document.getElementById('fr-metric-value'); if(frMetricValue) frMetricValue.textContent=metricValue;
971
+ const frMetricLabel=document.getElementById('fr-metric-label'); if(frMetricLabel) frMetricLabel.textContent=metricLabel;
972
+ const frMetricSay=document.getElementById('fr-metric-say'); if(frMetricSay) frMetricSay.innerHTML=metricSay;
973
+ try{ window.parent?.postMessage({type:'echomem:city-fieldnote',
974
+ avatar:(frAvatar&&frAvatar.src)||'',
975
+ name:String(persona?.primary?.label||''),
976
+ rank:(document.getElementById('fr-rank')||{}).innerHTML||'',
977
+ lede:(document.querySelector('.fr-lede')||{}).innerHTML||'',
978
+ guide:{
979
+ current:{primary:String(persona?.primary?.label||''), rank:String(persona?.rank?.label||''), rhythm:String(persona?.rhythm?.label||'')},
980
+ traits:Object.values(PERSONA_TRAITS).map(({label,blurb})=>({label,blurb})),
981
+ ranks:Object.values(PERSONA_RANKS).map(({label,blurb})=>({label,blurb})),
982
+ rhythms:Object.values(PERSONA_RHYTHMS).map(({label,blurb})=>({label,blurb}))
983
+ }
984
+ }, location.origin); }catch(_){}
642
985
  const splitHtml=`<span>${split.codexPct}% Codex</span><span>${split.claudePct}% Claude</span>`;
643
986
  const setupSplit=document.querySelector('.setup-split'); if(setupSplit) setupSplit.innerHTML=splitHtml;
987
+ const csClaude=document.querySelector('#city-split .cs-claude'); if(csClaude) csClaude.style.width=`${split.claudePct}%`;
988
+ const csCodex=document.querySelector('#city-split .cs-codex'); if(csCodex) csCodex.style.width=`${split.codexPct}%`;
989
+ const csClB=document.querySelector('#city-split .cs-l.claude b'); if(csClB) csClB.textContent=`${split.claudePct}%`;
990
+ const csCdB=document.querySelector('#city-split .cs-l.codex b'); if(csCdB) csCdB.textContent=`${split.codexPct}%`;
991
+ const tlStartDate=new Date(report?.scanStartDate);
992
+ if(!Number.isNaN(tlStartDate.getTime())){
993
+ const tlStartEl=document.querySelector('#city-timeline .tl-start');
994
+ if(tlStartEl) tlStartEl.textContent=tlStartDate.toLocaleString('en-US',{month:'short', year:'numeric', timeZone:'UTC'});
995
+ }
996
+ const spanDays=daysBetween(report?.scanStartDate, report?.scanEndDate);
997
+ const spanLabel=spanDays>=60 ? `${Math.round(spanDays/30.44)} months with AI` : `${spanDays} day${spanDays===1?'':'s'} with AI`;
998
+ const tlSpanEl=document.querySelector('#city-timeline .tl-span'); if(tlSpanEl) tlSpanEl.textContent=spanLabel;
644
999
  const dirtyPct=document.querySelector('.dirty-context .pct');
645
- if(dirtyPct) dirtyPct.innerHTML=`<span id="setup-metric-value">${dirty}%</span><span class="word" id="setup-metric-label">repeated reads</span><button class="v7-infoBtn" id="setup-info-btn" type="button" aria-label="How we read your city" aria-expanded="false" aria-controls="v7-info-sheet">i</button>`;
1000
+ if(dirtyPct) dirtyPct.innerHTML=`<span id="setup-metric-value">${metricValue}</span><span class="word" id="setup-metric-label">${metricLabel}</span><button class="v7-infoBtn" id="setup-info-btn" type="button" aria-label="How we read your city" aria-expanded="false" aria-controls="v7-info-sheet">i</button>`;
646
1001
  const dirtyDesc=document.querySelector('.dirty-context .desc');
647
- if(dirtyDesc) dirtyDesc.id='setup-metric-say';
1002
+ if(dirtyDesc){ dirtyDesc.id='setup-metric-say'; dirtyDesc.innerHTML=metricSay; }
648
1003
  const monthEl=document.querySelector('.counterfactual text:first-of-type'); if(monthEl) monthEl.textContent=reach.month;
649
1004
  const dayEl=document.querySelector('.counterfactual text:last-of-type'); if(dayEl) dayEl.textContent=reach.day;
650
1005
  const cfHero=document.querySelector('.cf-hero'); if(cfHero) cfHero.innerHTML=`Echo saves you <b>${days} days</b>`;
651
1006
  const cfSay=document.querySelector('.cf-say'); if(cfSay) cfSay.innerHTML=`You'd have reached today's workspace on <b>${reach.label}</b>, not ${instead}.`;
652
1007
  signpostTokens=fmtTok(Number(report?.scale?.totalTokens)||0);
653
- const inputPct=Number(report?.scale?.totalTokens) ? (Number(report.scale.totalInputTokens||0)/Number(report.scale.totalTokens)*100).toFixed(2) : '0.00';
654
- signpostInputPct=`${inputPct}% INPUT`;
1008
+ signpostHours=`${attention}h`;
1009
+ signpostCost=cost;
1010
+ const dirtyShare=Math.max(0, Number(dirty)||0)/100;
1011
+ signpostWastedTime=`≈${Math.round((Number(attention)||0)*dirtyShare)}h WASTED`;
1012
+ signpostWastedPct=`${Math.round(Number(dirty))||0}% WASTED`;
1013
+ signpostWastedCost=`≈${money((Number(report?.cost?.total)||0)*dirtyShare)} WASTED`;
655
1014
  }
656
1015
  async function loadSetupReport(){
657
1016
  const nonce=pageParams.get('nonce');
658
- if(!setupMode || !nonce) return null;
1017
+ if(!setupMode) return null;
659
1018
  const countEl=document.getElementById('scan-count');
660
- for(let i=0;i<80;i++){
1019
+ const loadingEl=document.getElementById('scan-loading');
1020
+ const setLoadState=(kind, title, detail)=>{
1021
+ const titleEl=loadingEl?.querySelector('.title');
1022
+ const eyebrowEl=loadingEl?.querySelector('.eyebrow');
1023
+ const barEl=loadingEl?.querySelector('.bar');
1024
+ if(titleEl) titleEl.textContent=title;
1025
+ if(eyebrowEl && kind!=='running') eyebrowEl.textContent=kind==='empty'?'Local scan complete':'Local scan error';
1026
+ if(barEl) barEl.hidden=kind!=='running';
1027
+ if(countEl) countEl.textContent=String(detail||'').slice(0, 240);
1028
+ };
1029
+ const reportShape=(report)=>{
1030
+ if(!report || typeof report!=='object') return {ok:false, code:'REPORT_NOT_OBJECT'};
1031
+ if(report.schemaVersion!==1) return {ok:false, code:'REPORT_SCHEMA_UNSUPPORTED'};
1032
+ const origin=report.dataOrigin;
1033
+ if((previewMode && origin!=='design-preview') || (!previewMode && origin!=='local-workspace-scan')){
1034
+ return {ok:false, code:'REPORT_ORIGIN_REJECTED'};
1035
+ }
1036
+ const finiteNonNegative=(value)=>typeof value==='number' && Number.isFinite(value) && value>=0;
1037
+ const scale=report.scale;
1038
+ const cost=report.cost;
1039
+ const hours=report.userWorkingHours;
1040
+ const summary=goldenSummary(report);
1041
+ const projection=report.billedWasteProjection;
1042
+ const cf=report.counterfactual;
1043
+ if(!scale || typeof scale!=='object' || !finiteNonNegative(scale.totalTokens) || Number(scale.totalTokens)<=0 ||
1044
+ !finiteNonNegative(scale.totalInputTokens) || Number(scale.totalInputTokens)<=0 ||
1045
+ !finiteNonNegative(scale.sessionCount) || Number(scale.sessionCount)<=0 ||
1046
+ !finiteNonNegative(scale.repoCount) || Number(scale.repoCount)<=0){
1047
+ return {ok:false, code:'REPORT_SCALE_INVALID'};
1048
+ }
1049
+ if(!cost || typeof cost!=='object' || !finiteNonNegative(cost.total) || !cost.byModel || typeof cost.byModel!=='object'){
1050
+ return {ok:false, code:'REPORT_COST_INVALID'};
1051
+ }
1052
+ if(!hours || typeof hours!=='object' || !finiteNonNegative(hours.userAttentionHours) ||
1053
+ !finiteNonNegative(hours.averageDailyAttentionHours)){
1054
+ return {ok:false, code:'REPORT_ATTENTION_INVALID'};
1055
+ }
1056
+ if(!summary || !finiteNonNegative(summary.sessionsAnalyzed) || Number(summary.sessionsAnalyzed)<=0 ||
1057
+ !finiteNonNegative(summary.officialInputTokens) || Number(summary.officialInputTokens)<=0 ||
1058
+ !finiteNonNegative(summary.wasteTokens) || !finiteNonNegative(summary.usefulTokens) ||
1059
+ !finiteNonNegative(summary.wastePct) || Number(summary.wastePct)>100){
1060
+ return {ok:false, code:'REPORT_CANONICAL_INVALID'};
1061
+ }
1062
+ if(!projection || typeof projection!=='object' || projection.method!=='canonical-window-share-over-provider-input' ||
1063
+ !finiteNonNegative(projection.billedInputTokens) || projection.billedInputTokens!==scale.totalInputTokens ||
1064
+ !finiteNonNegative(projection.canonicalInputTokens) || projection.canonicalInputTokens!==summary.officialInputTokens ||
1065
+ !finiteNonNegative(projection.canonicalWasteTokens) || projection.canonicalWasteTokens!==summary.wasteTokens ||
1066
+ !finiteNonNegative(projection.projectedUsefulTokens) || !finiteNonNegative(projection.projectedWasteTokens) ||
1067
+ projection.projectedUsefulTokens+projection.projectedWasteTokens!==projection.billedInputTokens){
1068
+ return {ok:false, code:'REPORT_PROJECTION_INVALID'};
1069
+ }
1070
+ if(!Array.isArray(report.repos) || !report.repos.some((repo)=>repo && typeof repo.name==='string' && repo.name.trim() &&
1071
+ typeof repo.tokens==='number' && Number.isFinite(repo.tokens) && repo.tokens>0)){
1072
+ return {ok:false, code:'REPORT_REPOS_EMPTY'};
1073
+ }
1074
+ if(!Number.isFinite(contextHealthScore(report)) || !cf || typeof cf!=='object' ||
1075
+ !finiteNonNegative(cf.daysGained) || !finiteNonNegative(cf.targetScore) || cf.targetScore>100 ||
1076
+ typeof report.scanStartDate!=='string' || Number.isNaN(Date.parse(report.scanStartDate)) ||
1077
+ typeof report.scanEndDate!=='string' || Number.isNaN(Date.parse(report.scanEndDate)) ||
1078
+ typeof cf.counterfactualDate!=='string' || Number.isNaN(Date.parse(cf.counterfactualDate))){
1079
+ return {ok:false, code:'REPORT_DERIVED_FIELDS_INVALID'};
1080
+ }
1081
+ return {ok:true};
1082
+ };
1083
+ const classifyEnvelope=(envelope)=>{
1084
+ if(!envelope || typeof envelope!=='object' || envelope.schemaVersion!==1 ||
1085
+ typeof envelope.scanId!=='string' || !envelope.scanId.trim()){
1086
+ return {kind:'invalid', code:'REPORT_ENVELOPE_INVALID'};
1087
+ }
1088
+ if(envelope.kind==='empty') return {kind:'empty'};
1089
+ if(envelope.kind==='failed'){
1090
+ const error=envelope.error && typeof envelope.error==='object' ? envelope.error : {};
1091
+ return {kind:'failed', code:String(error.code||'REPORT_BUILD_FAILED'), message:String(error.message||'The local report could not be built.')};
1092
+ }
1093
+ if(envelope.kind!=='ready') return {kind:'invalid', code:'REPORT_ENVELOPE_KIND_INVALID'};
1094
+ const shape=reportShape(envelope.report);
1095
+ if(!shape.ok) return {kind:'invalid', code:shape.code};
1096
+ const origin=envelope.report.dataOrigin;
1097
+ if(origin==='local-workspace-scan' && envelope.mode!=='production') return {kind:'invalid', code:'REPORT_MODE_INVALID'};
1098
+ if(origin==='design-preview' && (!previewMode || envelope.mode!=='preview')) return {kind:'invalid', code:'REPORT_PREVIEW_REJECTED'};
1099
+ return {kind:'ready', report:envelope.report};
1100
+ };
1101
+ if(!nonce){
1102
+ setLoadState('failed', 'Your local report could not be shown', 'REPORT_NONCE_MISSING: Rerun Echo setup to open a verified local scan.');
1103
+ return new Promise(()=>{});
1104
+ }
1105
+ let cleanupParentListener=()=>{};
1106
+ let readyTimer=null;
1107
+ const parentEnvelope=new Promise((resolve)=>{
1108
+ const postReady=()=>{ try{ window.parent?.postMessage({type:'echomem:city-ready', nonce}, location.origin); }catch(_){} };
1109
+ const onMessage=(event)=>{
1110
+ if(event.origin!==location.origin) return;
1111
+ const data=event.data||{};
1112
+ if(data.type==='echomem:setup-report' && data.nonce===nonce && Object.prototype.hasOwnProperty.call(data,'envelope')){
1113
+ resolve({source:'parent', envelope:data.envelope});
1114
+ }
1115
+ };
1116
+ cleanupParentListener=()=>{ window.removeEventListener('message', onMessage); if(readyTimer) window.clearInterval(readyTimer); };
1117
+ window.addEventListener('message', onMessage);
1118
+ postReady();
1119
+ readyTimer=window.setInterval(postReady, 500);
1120
+ });
1121
+ const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
1122
+ const fetchOnce=async()=>{
1123
+ // A design-preview iframe may consume only its explicit parent envelope. It must
1124
+ // never race the production /report endpoint and accidentally display local data.
1125
+ if(previewMode) return null;
1126
+ const controller=typeof AbortController!=='undefined' ? new AbortController() : null;
1127
+ const timer=controller ? window.setTimeout(()=>controller.abort(), 3500) : null;
661
1128
  try{
662
- const res=await fetch(`/report?nonce=${encodeURIComponent(nonce)}`, {credentials:'omit', cache:'no-store'});
663
- if(res.status===200) return await res.json();
664
- if(res.status!==202) return null;
665
- // 202 = still scanning. Surface live "read N / M sessions" so heavy histories never look frozen.
666
- try{ const p=await res.json(); if(countEl && p && Number(p.total)>0){ countEl.textContent=`read ${Number(p.scanned||0).toLocaleString()} / ${Number(p.total).toLocaleString()} sessions`; } }catch(_){}
667
- }catch(_){ return null; }
668
- await new Promise((resolve)=>setTimeout(resolve, 500));
1129
+ const res=await fetch(`/report?nonce=${encodeURIComponent(nonce)}`, {credentials:'omit', cache:'no-store', signal:controller?.signal});
1130
+ if(res.status===200){
1131
+ try{ return {source:'server', envelope:await res.json()}; }
1132
+ catch(_){ return {source:'failure', code:'REPORT_RESPONSE_INVALID', message:'The local scanner returned an unreadable report.'}; }
1133
+ }
1134
+ if(res.status!==202){
1135
+ let payload=null;
1136
+ try{ payload=await res.json(); }catch(_){}
1137
+ if(payload && payload.kind==='failed') return {source:'server', envelope:payload};
1138
+ const error=payload?.error && typeof payload.error==='object' ? payload.error : {};
1139
+ return {
1140
+ source:'failure',
1141
+ code:String(error.code||`REPORT_HTTP_${res.status}`),
1142
+ message:String(error.message||'The local scanner could not provide this report.'),
1143
+ };
1144
+ }
1145
+ // 202 = still scanning. This is a file-processing counter; final session totals are
1146
+ // deduplicated separately once the report has finished parsing.
1147
+ try{
1148
+ const p=await res.json();
1149
+ if(p && p.kind==='failed') return {source:'server', envelope:p};
1150
+ const progress=p?.progress || p;
1151
+ if(countEl && progress && Number(progress.total)>0){
1152
+ const scanned=Number(progress.scanned||0);
1153
+ const total=Number(progress.total||0);
1154
+ const elapsed=Number(progress.stageElapsedMs||0)>0 ? ` · ${Math.round(Number(progress.stageElapsedMs)/1000)}s` : '';
1155
+ if(progress.stage && progress.stage!=='reading-transcripts'){
1156
+ countEl.textContent=progress.stage==='classifying-repeated-context'
1157
+ ? `${progress.label || 'classifying repeated context'} ${scanned.toLocaleString()} / ${total.toLocaleString()}${elapsed}`
1158
+ : `${progress.label || 'building report'}${elapsed}`;
1159
+ }else{
1160
+ countEl.textContent=`read ${scanned.toLocaleString()} / ${total.toLocaleString()} transcript files`;
1161
+ }
1162
+ }
1163
+ }catch(_){ return {source:'failure', code:'REPORT_PROGRESS_INVALID', message:'The local scanner returned an unreadable progress update.'}; }
1164
+ return null;
1165
+ }catch(error){
1166
+ if(error?.name!=='AbortError') setLoadState('running', 'Reading your local history', 'Local scanner connection interrupted · retrying');
1167
+ return null;
1168
+ }finally{
1169
+ if(timer) window.clearTimeout(timer);
1170
+ }
1171
+ };
1172
+ const stopClosed=(kind, code, message)=>{
1173
+ cleanupParentListener();
1174
+ if(kind==='empty'){
1175
+ setLoadState('empty', 'No local workspace data found', 'No demo data was substituted.');
1176
+ }else{
1177
+ setLoadState('failed', 'Your local report could not be shown', `${code}: ${message||'Rerun Echo setup to try again.'}`);
1178
+ }
1179
+ return new Promise(()=>{});
1180
+ };
1181
+ while(true){
1182
+ const result=await Promise.race([parentEnvelope, fetchOnce()]);
1183
+ if(result){
1184
+ if(result.source==='failure') return stopClosed('failed', result.code, result.message);
1185
+ const classified=classifyEnvelope(result.envelope);
1186
+ if(classified.kind==='ready'){
1187
+ cleanupParentListener();
1188
+ return result.envelope;
1189
+ }
1190
+ if(classified.kind==='empty') return stopClosed('empty');
1191
+ return stopClosed('failed', classified.code, classified.message || 'The report failed provenance or structure validation.');
1192
+ }
1193
+ const duringSleep=await Promise.race([parentEnvelope, sleep(500).then(()=>null)]);
1194
+ if(duringSleep){
1195
+ const classified=classifyEnvelope(duringSleep.envelope);
1196
+ if(classified.kind==='ready'){
1197
+ cleanupParentListener();
1198
+ return duringSleep.envelope;
1199
+ }
1200
+ if(classified.kind==='empty') return stopClosed('empty');
1201
+ return stopClosed('failed', classified.code, classified.message || 'The report failed provenance or structure validation.');
1202
+ }
669
1203
  }
670
- return null;
671
1204
  }
672
- let signpostTokens='7.49B';
673
- let signpostInputPct='99.39% INPUT';
674
- const setupReport=await loadSetupReport();
1205
+ let signpostTokens=setupMode?'—':'7.49B';
1206
+ let signpostHours=setupMode?'—':'191.1h';
1207
+ let signpostCost=setupMode?'—':'$13,689';
1208
+ let signpostWastedTime=setupMode?'—':'≈162h WASTED';
1209
+ let signpostWastedPct=setupMode?'—':'85% WASTED';
1210
+ let signpostWastedCost=setupMode?'—':'≈$11,636 WASTED';
1211
+ const setupEnvelope=await loadSetupReport();
1212
+ const setupReport=setupMode ? setupEnvelope.report : null;
1213
+ const setupLiveRepos=setupMode ? buildSetupRepos(setupReport) : [];
675
1214
  applySetupReport(setupReport);
676
1215
  renderV7PersonaHover();
1216
+ let comparisonBefore=contextHealthScore(setupReport);
1217
+ if(!Number.isFinite(comparisonBefore)) comparisonBefore=setupMode?0:58;
1218
+ const reportTargetScore=Number(setupReport?.counterfactual?.targetScore);
1219
+ let comparisonAfter=Number.isFinite(reportTargetScore) ? clampScore(reportTargetScore) : (setupMode?comparisonBefore:clampScore(comparisonBefore+28));
1220
+ comparisonAfter=Math.max(comparisonBefore,comparisonAfter);
1221
+ let cityComparison='without';
1222
+ let repairTarget=0;
1223
+ let repairProgress=0;
1224
+ let dirty=true;
1225
+ let shadowFrame=0;
1226
+
1227
+ function setupWasteShare(){
1228
+ const pct=Number(goldenWastePct(setupReport) || setupReport?.cleanliness?.staleReadSharePct || 0);
1229
+ if(Number.isFinite(pct) && pct>0) return Math.max(0, Math.min(0.95, pct/100));
1230
+ return Math.max(0, Math.min(0.95, (100-comparisonBefore)/100));
1231
+ }
1232
+ function setupAttentionHours(){
1233
+ const direct=Number(setupReport?.userWorkingHours?.userAttentionHours);
1234
+ if(Number.isFinite(direct) && direct>=0) return direct;
1235
+ return all.reduce((sum,o)=>sum+(Number(o.hours)||0),0);
1236
+ }
1237
+ function setupInputTokens(){
1238
+ const direct=Number(setupReport?.billedWasteProjection?.billedInputTokens);
1239
+ if(Number.isFinite(direct) && direct>0) return direct;
1240
+ return 0;
1241
+ }
1242
+ function setupUsefulTokens(){
1243
+ const direct=Number(setupReport?.billedWasteProjection?.projectedUsefulTokens);
1244
+ if(Number.isFinite(direct) && direct>=0) return direct;
1245
+ return 0;
1246
+ }
1247
+ function setupApiCost(){
1248
+ const direct=Number(setupReport?.cost?.total);
1249
+ if(Number.isFinite(direct) && direct>=0) return direct;
1250
+ return all.reduce((sum,o)=>sum+(Number(o.cost)||0),0);
1251
+ }
1252
+ function fmtInt(n){ return Math.round(Number(n)||0).toLocaleString('en-US'); }
1253
+ function cityMetricReadout(){
1254
+ const waste=setupWasteShare();
1255
+ const attention=setupAttentionHours();
1256
+ const reportedDaily=Number(setupReport?.userWorkingHours?.averageDailyAttentionHours);
1257
+ const daily=Number.isFinite(reportedDaily) && reportedDaily>0 ? reportedDaily : (attention>0?attention:0);
1258
+ const currentDays=daily>0?Math.ceil(attention/daily):0;
1259
+ const withHours=Math.max(0, attention*(1-waste));
1260
+ const withDays=daily>0?Math.ceil(withHours/daily):0;
1261
+ const inputTokens=setupInputTokens();
1262
+ const usefulTokens=Math.max(0, Math.min(inputTokens, setupUsefulTokens()));
1263
+ const apiCost=setupApiCost();
1264
+ const withCost=Math.max(0, apiCost*(1-waste));
1265
+ const savedDays=Math.max(0, currentDays-withDays);
1266
+ const savedTokens=Math.max(0, inputTokens-usefulTokens);
1267
+ const savedCost=Math.max(0, apiCost-withCost);
1268
+ const mode=cityComparison==='with'?'with':'without';
1269
+ const metric=cityMetric==='tokens'||cityMetric==='cost'?cityMetric:'time';
1270
+ const data={
1271
+ time:{
1272
+ without:{value:`${fmtInt(currentDays)} days`,label:'time without Echo',say:`${fmtInt(attention)}h of agent work at <b>${oneDec(daily)}h per active day</b>. This is the pace that built the current city.`},
1273
+ with:{value:`${fmtInt(withDays)} days`,label:'time with Echo',say:`Same project state, with repeat context cleared. That frees about <b>${fmtInt(savedDays)} working days</b> for everything else.`}
1274
+ },
1275
+ tokens:{
1276
+ without:{value:fmtTok(inputTokens),label:'tokens without Echo',say:`The current scan analyzed <b>${fmtTok(inputTokens)} input tokens</b>, including context the agent had to reload again.`},
1277
+ with:{value:fmtTok(usefulTokens),label:'tokens with Echo',say:`Echo carries the useful checkpoint forward, so about <b>${fmtTok(savedTokens)}</b> repeat tokens do not need to be reloaded.`}
1278
+ },
1279
+ cost:{
1280
+ without:{value:money(apiCost),label:'API cost without Echo',say:`API-equivalent list-price scale for the context volume in this scan. This is a volume estimate, not your subscription bill.`},
1281
+ with:{value:money(withCost),label:'API cost with Echo',say:`Modeled after removing repeat context from the scan, avoiding about <b>${money(savedCost)}</b> of reloaded context volume.`}
1282
+ }
1283
+ };
1284
+ return data[metric][mode];
1285
+ }
1286
+ function updateFieldReportMetric(){
1287
+ if(!setupMode) return;
1288
+ const readout=cityMetricReadout();
1289
+ const metricValue=document.getElementById('fr-metric-value'); if(metricValue) metricValue.textContent=readout.value;
1290
+ const metricLabel=document.getElementById('fr-metric-label'); if(metricLabel) metricLabel.textContent=readout.label;
1291
+ const metricSay=document.getElementById('fr-metric-say'); if(metricSay) metricSay.innerHTML=readout.say;
1292
+ }
677
1293
 
678
1294
  const renderer = new THREE.WebGLRenderer({canvas:document.getElementById('repo-visualizer'), antialias:true, alpha:true});
679
1295
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
@@ -705,8 +1321,9 @@ function buildEnv(){
705
1321
  }
706
1322
  scene.environment = buildEnv();
707
1323
 
708
- // real per-repo token usage (from city-data.json); x/z layout + tool colour stay hand-tuned, sizes are derived.
709
- let REPOS=[
1324
+ // Frozen poster data is available only outside setup. Setup starts empty and can be
1325
+ // populated solely by the validated local report envelope above.
1326
+ const POSTER_REPOS=[
710
1327
  {name:'WebPageReactVersion', x:-30, z:-60, tool:'claude', pin:true, tokens:3436946364, cost:8896, days:18.6, hours:92.8, sessions:74},
711
1328
  {name:'EchoMem-Chrome', x:140, z:-14, tool:'claude', pin:true, tokens:2863590896, cost:6959, days:14.0, hours:70.1, sessions:38},
712
1329
  {name:'echo-design-system', x:30, z:30, tool:'codex', tokens:382642012, cost:1106, days:4.2, hours:21.2, sessions:29},
@@ -721,12 +1338,12 @@ let REPOS=[
721
1338
  {name:'dist', x:-210,z:118, tool:'claude', tokens:4550877, cost:8, days:0, hours:0, sessions:2},
722
1339
  {name:'Daimler Engineering', x:-168,z:160, tool:'codex', tokens:4439902, cost:15, days:0, hours:0.2, sessions:2},
723
1340
  ];
1341
+ let REPOS=setupMode ? [] : POSTER_REPOS;
724
1342
  // per-repo codex/claude split (frozen poster values from city-data.json) → drives the stacked two-colour buildings
725
1343
  const REPO_SPLIT={'WebPageReactVersion':[31,69],'EchoMem-Chrome':[19,81],'echo-design-system':[44,56],'sleepy-merkle-3d0470':[0,100],'DG-Web':[0,100],'Kobe Life':[100,0],'public':[0,100],'EchoMemory_log':[63,37],'production-prompt':[0,100],'brands':[0,100],'components':[0,100],'dist':[0,100],'Daimler Engineering':[100,0]};
726
1344
  for(const o of REPOS){ const s=REPO_SPLIT[o.name]; if(s){ o.codexPct=s[0]; o.claudePct=s[1]; } else { o.codexPct=o.tool==='codex'?100:0; o.claudePct=100-o.codexPct; } }
727
- if(setupMode && setupReport){
728
- const liveRepos=buildSetupRepos(setupReport);
729
- if(liveRepos.length) REPOS=liveRepos;
1345
+ if(setupMode){
1346
+ REPOS=setupLiveRepos;
730
1347
  window.__ECHOMEM_CITY_REPORT_REPO_COUNT=Array.isArray(setupReport?.repos) ? setupReport.repos.length : -1;
731
1348
  window.__ECHOMEM_CITY_REPOS=REPOS.map(({name,tokens,cost,hours,sessions,tool})=>({name,tokens,cost,hours,sessions,tool}));
732
1349
  }
@@ -829,17 +1446,21 @@ const fill = new THREE.DirectionalLight(0xffffff, 0.4); fill.position.set(CX+420
829
1446
  const floor = new THREE.Mesh(new THREE.PlaneGeometry(6000,6000), new THREE.ShadowMaterial({opacity:0.22}));
830
1447
  floor.rotation.x=-Math.PI/2; floor.position.y=0; floor.receiveShadow=true; scene.add(floor);
831
1448
 
832
- // frosted clay-glass: colour comes from inside the volume (attenuation), not a flat surface
1449
+ // Comparison material: corrupted buildings are dark and chalky; repaired buildings regain colour
1450
+ // without returning to the glossy toy-plastic finish.
833
1451
  function clayGlass(hex){
834
- const c=new THREE.Color(hex);
835
- return new THREE.MeshPhysicalMaterial({
836
- color:c,
837
- roughness:0.42, metalness:0.0,
838
- transmission:0.85, ior:1.34, thickness:70,
839
- attenuationColor:c.clone(), attenuationDistance:95,
840
- clearcoat:0.28, clearcoatRoughness:0.45,
841
- envMapIntensity:1.05, transparent:true,
1452
+ const clean=new THREE.Color(hex).multiplyScalar(0.82);
1453
+ const damaged=clean.clone().multiplyScalar(0.58);
1454
+ const material=new THREE.MeshPhysicalMaterial({
1455
+ color:damaged,
1456
+ roughness:0.9, metalness:0.01,
1457
+ transmission:0.05, ior:1.25, thickness:45,
1458
+ attenuationColor:damaged.clone(), attenuationDistance:58,
1459
+ clearcoat:0.015, clearcoatRoughness:0.94,
1460
+ envMapIntensity:0.24, transparent:true,
842
1461
  });
1462
+ buildingMaterials.push({material,damaged,clean});
1463
+ return material;
843
1464
  }
844
1465
  function selectiveRoundedBoxGeometry(width, height, depth, segments=8, radius=0.1, opts={}){
845
1466
  const roundTop=opts.roundTop!==false, roundBottom=opts.roundBottom!==false;
@@ -881,6 +1502,7 @@ function selectiveRoundedBoxGeometry(width, height, depth, segments=8, radius=0.
881
1502
  }
882
1503
  function addCube(o){
883
1504
  const {x,z,w,h,d,tool}=o;
1505
+ const damage=corruptionProfile(o);
884
1506
  const r=Math.min(Math.min(w,h,d)*0.12, 12);
885
1507
  // proportional split: Claude (orange) on the bottom, Codex (green) on top; segment heights ∝ each tool's token share
886
1508
  let cl=Number(o.claudePct), cx=Number(o.codexPct);
@@ -888,48 +1510,156 @@ function addCube(o){
888
1510
  const total=cl+cx, segs=[];
889
1511
  if(cl>0) segs.push([h*cl/total, TOOL.claude]);
890
1512
  if(cx>0) segs.push([h*cx/total, TOOL.codex]);
1513
+ const slices=[];
1514
+ for(const [hSeg,color] of segs){
1515
+ const count=hSeg>78?4:hSeg>44?3:hSeg>24?2:1;
1516
+ const gap=count>1?Math.min(3.2,hSeg*0.055):0;
1517
+ const sliceH=(hSeg-gap*(count-1))/count;
1518
+ for(let i=0;i<count;i++) slices.push({h:sliceH,color,gap:i===count-1?0:gap});
1519
+ }
891
1520
  const group=new THREE.Group();
1521
+ group.position.set(x,0,z);
1522
+ group.rotation.x=damage.leanX*damage.amount;
1523
+ group.rotation.z=damage.leanZ*damage.amount;
1524
+ repairGroups.push({group,damageX:group.rotation.x,damageZ:group.rotation.z});
892
1525
  let yBase=TRAY_TOP - 3;
893
- const mixed=segs.length>1;
894
- for(let i=0;i<segs.length;i++){
895
- const [hSeg, color] = segs[i];
1526
+ let cleanY=TRAY_TOP - 3;
1527
+ const fractured=slices.length>1;
1528
+ for(let i=0;i<slices.length;i++){
1529
+ const {h:hSeg,color,gap}=slices[i];
896
1530
  const mat=clayGlass(color);
897
- const geom=mixed
898
- ? selectiveRoundedBoxGeometry(w,hSeg,d,8,Math.min(r,hSeg*0.45), {roundBottom:i===0, roundTop:i===segs.length-1})
1531
+ const geom=fractured
1532
+ ? selectiveRoundedBoxGeometry(w,hSeg,d,8,Math.min(r,hSeg*0.45), {roundBottom:i===0, roundTop:i===slices.length-1})
899
1533
  : new RoundedBoxGeometry(w,hSeg,d,8,Math.min(r,hSeg*0.45));
900
1534
  const m=new THREE.Mesh(geom, mat);
901
- m.position.set(x, yBase + hSeg/2, z);
1535
+ const stagger=i===0?0:(hash01(o.name,i+61)-0.5)*2;
1536
+ const splitShift=stagger*Math.min(w*0.24, 13)*damage.amount;
1537
+ m.position.set(splitShift, yBase + hSeg/2, -splitShift*(0.25+hash01(o.name,i+67)*0.45));
1538
+ if(i>0) m.rotation.y=(hash01(o.name,i+71)-0.5)*0.30*damage.amount;
1539
+ repairPieces.push({
1540
+ mesh:m,
1541
+ damagePos:m.position.clone(),
1542
+ cleanPos:new THREE.Vector3(0,cleanY+hSeg/2,0),
1543
+ damageRotY:m.rotation.y,
1544
+ });
902
1545
  m.castShadow=true; m.receiveShadow=true; m.userData=o;
903
1546
  group.add(m);
904
- yBase+=hSeg;
1547
+ yBase+=hSeg+gap;
1548
+ cleanY+=hSeg;
1549
+ }
1550
+ const scarMat=new THREE.MeshStandardMaterial({color:0x343238,roughness:0.92,metalness:0.08,transparent:true});
1551
+ const scarCount=Math.max(1,Math.round(2+damage.amount*3));
1552
+ for(let i=0;i<scarCount;i++){
1553
+ const sw=Math.max(3,w*(0.10+hash01(o.name,101+i)*0.20));
1554
+ const sh=Math.max(2,Math.min(6,h*0.045));
1555
+ const sd=Math.max(2,d*0.06);
1556
+ const scar=new THREE.Mesh(new THREE.BoxGeometry(sw,sh,sd),scarMat);
1557
+ scar.position.set((hash01(o.name,131+i)-0.5)*w*0.72,TRAY_TOP+h*(0.18+hash01(o.name,151+i)*0.67),d*0.5+1.2);
1558
+ scar.rotation.z=(hash01(o.name,171+i)-0.5)*0.55;
1559
+ scar.castShadow=true; scar.userData=o; group.add(scar);
1560
+ corruptionFades.push({mesh:scar,material:scar.material,damagePos:scar.position.clone(),cleanPos:scar.position.clone(),baseScale:scar.scale.clone()});
1561
+ }
1562
+ const chunkCount=Math.max(4,Math.round(4+damage.amount*5));
1563
+ for(let i=0;i<chunkCount;i++){
1564
+ const size=Math.max(3,Math.min(w,d)*(0.07+hash01(o.name,201+i)*0.10));
1565
+ const chunk=new THREE.Mesh(
1566
+ i%3===0?new THREE.TetrahedronGeometry(size,0):new THREE.BoxGeometry(size,size*(0.5+hash01(o.name,221+i)),size*0.72),
1567
+ i%2?scarMat:clayGlass(tool==='codex'?TOOL.codex:TOOL.claude)
1568
+ );
1569
+ const angle=hash01(o.name,241+i)*Math.PI*2;
1570
+ const radius=Math.max(w,d)*(0.62+hash01(o.name,261+i)*1.05);
1571
+ const baseY=TRAY_TOP+2+hash01(o.name,281+i)*Math.min(h*0.72,58);
1572
+ chunk.position.set(Math.cos(angle)*radius,baseY,Math.sin(angle)*radius);
1573
+ chunk.rotation.set(hash01(o.name,301+i)*2.4,hash01(o.name,321+i)*2.4,hash01(o.name,341+i)*2.4);
1574
+ chunk.castShadow=true; chunk.receiveShadow=true; chunk.userData=o; group.add(chunk);
1575
+ chunk.material.transparent=true;
1576
+ corruptionFades.push({
1577
+ mesh:chunk,
1578
+ material:chunk.material,
1579
+ damagePos:chunk.position.clone(),
1580
+ cleanPos:new THREE.Vector3(0,Math.max(TRAY_TOP+4,Math.min(TRAY_TOP+h-5,baseY)),0),
1581
+ baseScale:chunk.scale.clone(),
1582
+ });
1583
+ if(i===0 && h>42) corruptionFloaters.push({mesh:chunk,baseY,phase:damage.phase,speed:0.65+hash01(o.name,361)*0.55});
905
1584
  }
906
1585
  group.userData=o; scene.add(group); return group;
907
1586
  }
908
1587
  // repo summary as an upright billboard above each bar — white text, ALWAYS faces the camera (never skewed).
909
- // name = small tag just above the bar top; tokens + $ fly higher above it, clear of the cube.
1588
+ // setup mode uses the selected city metric; share-card mode keeps the original tokens + $ poster readout.
1589
+ let cityMetric=setupMode ? 'time' : 'tokens';
1590
+ let timelineProgress=1;
1591
+ const repoLabels=[];
1592
+ function fmtMetric(hours, tokens, cost){
1593
+ if(cityMetric==='tokens') return fmtTok(tokens);
1594
+ if(cityMetric==='cost') return '$'+Math.round(cost).toLocaleString('en-US');
1595
+ return `${(Number(hours)||0).toFixed(1)}h`;
1596
+ }
1597
+ function repoMetricText(o){
1598
+ const fraction=Number.isFinite(o.timelineFraction)?o.timelineFraction:1;
1599
+ return fmtMetric((Number(o.hours)||0)*fraction,(Number(o.tokens)||0)*fraction,(Number(o.cost)||0)*fraction*(setupMode?1:COST_SCALE));
1600
+ }
1601
+ // GitHub octocat mark (24x24) — same glyph as the report's repo chips.
1602
+ const GH_MARK=new Path2D('M12 .7a11.5 11.5 0 0 0-3.64 22.41c.58.11.79-.25.79-.56v-2.23c-3.22.7-3.9-1.37-3.9-1.37-.53-1.34-1.29-1.7-1.29-1.7-1.05-.72.08-.71.08-.71 1.17.08 1.78 1.2 1.78 1.2 1.04 1.78 2.72 1.27 3.38.97.1-.75.41-1.27.74-1.56-2.57-.29-5.27-1.29-5.27-5.68 0-1.26.45-2.28 1.19-3.09-.12-.29-.52-1.47.11-3.05 0 0 .97-.31 3.16 1.18A10.97 10.97 0 0 1 12 6.12c.98 0 1.96.13 2.88.39 2.2-1.49 3.16-1.18 3.16-1.18.63 1.58.23 2.76.11 3.05.74.81 1.19 1.83 1.19 3.09 0 4.4-2.71 5.38-5.29 5.67.42.36.79 1.06.79 2.14v3.27c0 .31.21.68.8.56A11.5 11.5 0 0 0 12 .7Z');
1603
+ function drawRepoLabel(e){
1604
+ const {o, g, tex, PW, PH, numbers}=e;
1605
+ g.clearRect(0,0,PW,PH);
1606
+ g.textAlign='center';
1607
+ if(numbers){
1608
+ // dark ink + white halo: stays readable over the light grid AND the coloured roofs
1609
+ g.save();
1610
+ g.shadowColor='rgba(255,255,255,0.92)'; g.shadowBlur=12; g.shadowOffsetY=0;
1611
+ g.fillStyle='#171717'; g.font=`800 ${Math.round(PW*0.19)}px "IBM Plex Mono", monospace`;
1612
+ g.fillText(setupMode ? repoMetricText(o) : fmtTok(o.tokens), PW/2, setupMode ? PH*0.66 : PH*0.18);
1613
+ if(!setupMode){
1614
+ g.fillStyle='rgba(23,23,23,0.85)'; g.font=`700 ${Math.round(PW*0.11)}px "IBM Plex Mono", monospace`;
1615
+ g.fillText('$'+Math.round(o.cost*COST_SCALE).toLocaleString('en-US'), PW/2, PH*0.40);
1616
+ }
1617
+ g.restore();
1618
+ }
1619
+ // repo name rides a white chip with the octocat, matching .audit-repo-chip in the report
1620
+ const fs=Math.round(PW*0.075);
1621
+ g.font=`700 ${fs}px "IBM Plex Mono", monospace`;
1622
+ const icon=Math.round(fs*1.25), gap=Math.round(fs*0.45), padX=Math.round(fs*0.7);
1623
+ const tw=g.measureText(o.name).width;
1624
+ const maxText=PW*0.94-icon-gap-padX*2;
1625
+ const drawTw=Math.min(tw,maxText);
1626
+ const chipW=drawTw+icon+gap+padX*2;
1627
+ const chipH=Math.round(icon+fs*0.85);
1628
+ const cx=PW/2, cyMid=PH*0.86-fs*0.35;
1629
+ const rx=cx-chipW/2, ry=cyMid-chipH/2;
1630
+ g.save();
1631
+ g.shadowColor='rgba(31,35,40,0.25)'; g.shadowBlur=8; g.shadowOffsetY=2;
1632
+ g.beginPath();
1633
+ if(g.roundRect) g.roundRect(rx,ry,chipW,chipH,chipH/2); else g.rect(rx,ry,chipW,chipH);
1634
+ g.fillStyle='rgba(255,255,255,0.96)'; g.fill();
1635
+ g.shadowColor='transparent';
1636
+ g.lineWidth=1.5; g.strokeStyle='rgba(24,23,23,0.32)'; g.stroke();
1637
+ g.translate(rx+padX, cyMid-icon/2); g.scale(icon/24, icon/24);
1638
+ g.fillStyle='#181717'; g.fill(GH_MARK);
1639
+ g.restore();
1640
+ g.save();
1641
+ g.fillStyle='#171717'; g.textAlign='left'; g.textBaseline='middle';
1642
+ g.font=`700 ${fs}px "IBM Plex Mono", monospace`;
1643
+ g.translate(rx+padX+icon+gap, cyMid+fs*0.06);
1644
+ if(tw>maxText) g.scale(maxText/tw,1);
1645
+ g.fillText(o.name,0,0);
1646
+ g.restore();
1647
+ tex.needsUpdate=true;
1648
+ }
910
1649
  function addTopLabel(o, numbers=true, lift=0){
911
1650
  const PW=320, PH=280;
912
1651
  const cv=document.createElement('canvas'); cv.width=PW; cv.height=PH;
913
- const g=cv.getContext('2d'); g.textAlign='center';
914
- g.shadowColor='rgba(0,0,0,0.34)'; g.shadowBlur=9; g.shadowOffsetY=1; // subtle contrast over light areas
915
- if(numbers){
916
- // tokens — the headline, flies highest
917
- g.fillStyle='#ffffff'; g.font=`800 ${Math.round(PW*0.19)}px "IBM Plex Mono", monospace`;
918
- g.fillText(fmtTok(o.tokens), PW/2, PH*0.18);
919
- // dollars — just under the tokens, also up high
920
- g.fillStyle='rgba(255,255,255,0.92)'; g.font=`700 ${Math.round(PW*0.11)}px "IBM Plex Mono", monospace`;
921
- g.fillText('$'+Math.round(o.cost*(setupMode?1:COST_SCALE)).toLocaleString('en-US'), PW/2, PH*0.40);
922
- }
923
- // name — small tag sitting just above the bar top
924
- g.fillStyle='rgba(255,255,255,0.95)'; g.font=`600 ${Math.round(PW*0.075)}px "IBM Plex Mono", monospace`;
925
- const fw=g.measureText(o.name).width, maxw=PW*0.94;
926
- g.save(); g.translate(PW/2, PH*0.86); if(fw>maxw){ const s=maxw/fw; g.scale(s,s); } g.fillText(o.name, 0, 0); g.restore();
1652
+ const g=cv.getContext('2d');
927
1653
  const tex=new THREE.CanvasTexture(cv); tex.colorSpace=THREE.SRGBColorSpace; tex.anisotropy=8;
928
1654
  const sprite=new THREE.Sprite(new THREE.SpriteMaterial({map:tex, transparent:true, toneMapped:false, depthWrite:false}));
929
1655
  const wWorld=Math.min(Math.max(o.w*0.95, 42), 128), hWorld=wWorld*PH/PW; // scales with bar size, clamped legible
1656
+ const labelOffset=-3+6+0.36*hWorld+lift;
930
1657
  sprite.scale.set(wWorld, hWorld, 1);
931
- sprite.position.set(o.x, TRAY_TOP + o.h - 3 + 6 + 0.36*hWorld + lift, o.z); // name tags the top; tokens/$ fly higher; lift staggers neighbors
1658
+ sprite.position.set(o.x, TRAY_TOP + o.h + labelOffset, o.z); // name tags the top; tokens/$ fly higher; lift staggers neighbors
932
1659
  scene.add(sprite);
1660
+ const entry={o, g, tex, PW, PH, sprite, numbers, labelOffset};
1661
+ repoLabels.push(entry);
1662
+ drawRepoLabel(entry);
933
1663
  }
934
1664
  // fine ivory grain — textures EVERY face (incl. the curved side walls), so the tray isn't flat plastic
935
1665
  const grain=(function(){
@@ -944,9 +1674,9 @@ const grain=(function(){
944
1674
  })();
945
1675
  const trayGeo=new RoundedBoxGeometry(trayW, TRAY_TOP, trayD, 8, 12);
946
1676
  const tray=new THREE.Mesh(trayGeo, new THREE.MeshPhysicalMaterial({
947
- color:TRAY_COL, roughness:0.6, metalness:0.0,
948
- clearcoat:0.3, clearcoatRoughness:0.5, envMapIntensity:0.6,
949
- bumpMap:grain, bumpScale:0.4,
1677
+ color:TRAY_COL, roughness:0.84, metalness:0.02,
1678
+ clearcoat:0.08, clearcoatRoughness:0.82, envMapIntensity:0.4,
1679
+ bumpMap:grain, bumpScale:0.9,
950
1680
  }));
951
1681
  tray.position.set(CX, TRAY_TOP/2, CZ); tray.castShadow=true; tray.receiveShadow=true; scene.add(tray);
952
1682
  // grid + perimeter border on ONE decal: the grid is CLIPPED to the border's rounded rect, so
@@ -973,6 +1703,37 @@ tray.position.set(CX, TRAY_TOP/2, CZ); tray.castShadow=true; tray.receiveShadow=
973
1703
  const tex=new THREE.CanvasTexture(cv); tex.colorSpace=THREE.SRGBColorSpace; tex.anisotropy=8;
974
1704
  const plane=new THREE.Mesh(new THREE.PlaneGeometry(trayW, trayD), new THREE.MeshBasicMaterial({map:tex, transparent:true, depthWrite:false, toneMapped:false}));
975
1705
  plane.rotation.x=-Math.PI/2; plane.position.set(CX, TRAY_TOP+0.6, CZ); scene.add(plane);
1706
+
1707
+ // Keep corruption on its own decal so the same city can visibly heal without losing its clean grid.
1708
+ const damageCv=document.createElement('canvas'); damageCv.width=W; damageCv.height=H;
1709
+ const dg=damageCv.getContext('2d');
1710
+ dg.save(); dg.beginPath(); dg.roundRect(m,m,rw,rh,rad); dg.clip();
1711
+ let seed=0xdecafbad;
1712
+ const rnd=()=>{ seed=(Math.imul(seed,1664525)+1013904223)>>>0; return seed/4294967296; };
1713
+ for(let i=0;i<16;i++){
1714
+ const x=m+rnd()*rw, y=m+rnd()*rh, ww=(18+rnd()*54)*scale, hh=(8+rnd()*24)*scale;
1715
+ dg.save(); dg.translate(x,y); dg.rotate((rnd()-0.5)*0.7);
1716
+ dg.fillStyle=i%3===0?'rgba(170,59,51,0.18)':'rgba(43,43,49,0.16)';
1717
+ dg.fillRect(-ww/2,-hh/2,ww,hh); dg.restore();
1718
+ }
1719
+ dg.lineCap='square';
1720
+ for(let i=0;i<22;i++){
1721
+ let x=m+rnd()*rw, y=m+rnd()*rh;
1722
+ dg.strokeStyle=i%5===0?'rgba(176,58,51,0.62)':'rgba(45,44,49,0.55)';
1723
+ dg.lineWidth=(0.9+rnd()*1.5)*scale; dg.beginPath(); dg.moveTo(x,y);
1724
+ const steps=2+Math.floor(rnd()*4);
1725
+ for(let j=0;j<steps;j++){
1726
+ x+=((rnd()-0.5)*42)*scale; y+=((rnd()-0.5)*42)*scale; dg.lineTo(x,y);
1727
+ if(j===1 && rnd()>0.45){ dg.moveTo(x,y); dg.lineTo(x+(rnd()-0.5)*24*scale,y+(rnd()-0.5)*24*scale); dg.moveTo(x,y); }
1728
+ }
1729
+ dg.stroke();
1730
+ }
1731
+ dg.restore();
1732
+ const damageTex=new THREE.CanvasTexture(damageCv); damageTex.colorSpace=THREE.SRGBColorSpace; damageTex.anisotropy=8;
1733
+ const damageMat=new THREE.MeshBasicMaterial({map:damageTex,transparent:true,depthWrite:false,toneMapped:false});
1734
+ const damagePlane=new THREE.Mesh(new THREE.PlaneGeometry(trayW,trayD),damageMat);
1735
+ damagePlane.rotation.x=-Math.PI/2; damagePlane.position.set(CX,TRAY_TOP+0.72,CZ); scene.add(damagePlane);
1736
+ corruptionSurfaces.push(damageMat);
976
1737
  })();
977
1738
 
978
1739
  // De-crowd the skyline: only the top token movers fly their numbers; every other building keeps just
@@ -982,9 +1743,9 @@ const numberedOrder=all.slice().sort((a,b)=>(b.tokens||0)-(a.tokens||0)).slice(0
982
1743
  const numberedLift=new Map(numberedOrder.map((o,i)=>[o.name, (i%2)*16])); // alternate heights so neighbors' numbers never collide
983
1744
  all.forEach(o=>{ o.mesh=addCube(o); o.mesh.userData=o; if(o.w>=LABEL_MIN_BASE) addTopLabel(o, numberedLift.has(o.name), numberedLift.get(o.name)||0); });
984
1745
 
985
- // bottom-right standing "signpost": tray summary total tokens + how much is input
1746
+ // bottom-right standing "signpost": tray summary follows the selected city metric.
986
1747
  let signpostCanvas=null, signpostTexture=null;
987
- function drawSignpost(tokenText, bottomText, accent='#e8473f'){
1748
+ function drawSignpost(bigText, subLabel, bottomText, accent='#e8473f'){
988
1749
  if(!signpostCanvas) return;
989
1750
  const W=signpostCanvas.width, H=signpostCanvas.height;
990
1751
  const g=signpostCanvas.getContext('2d');
@@ -992,18 +1753,44 @@ function drawSignpost(tokenText, bottomText, accent='#e8473f'){
992
1753
  g.fillStyle='rgba(244,241,233,0.97)'; g.beginPath(); g.roundRect(8,8,W-16,H-16,30); g.fill();
993
1754
  g.strokeStyle='rgba(120,126,140,0.30)'; g.lineWidth=3; g.beginPath(); g.roundRect(8,8,W-16,H-16,30); g.stroke();
994
1755
  g.textAlign='center';
995
- g.fillStyle='#1a1a1a'; g.font='800 124px "IBM Plex Mono", monospace'; g.fillText(tokenText, W/2, 150);
996
- g.fillStyle='#6b6b6b'; g.font='600 36px "IBM Plex Mono", monospace'; g.fillText('TOTAL TOKENS', W/2, 204);
1756
+ g.fillStyle='#1a1a1a'; g.font='800 124px "IBM Plex Mono", monospace'; g.fillText(bigText, W/2, 150);
1757
+ g.fillStyle='#6b6b6b'; g.font='600 36px "IBM Plex Mono", monospace'; g.fillText(subLabel, W/2, 204);
997
1758
  g.fillStyle=accent; g.font='800 62px "IBM Plex Mono", monospace'; g.fillText(bottomText, W/2, 280);
998
1759
  if(signpostTexture) signpostTexture.needsUpdate=true;
999
1760
  }
1761
+ function signpostBig(){
1762
+ if(setupMode && cityComparison==='with') return cityMetricReadout().value;
1763
+ if(timelineProgress>=0.999) return cityMetric==='tokens'?signpostTokens : cityMetric==='cost'?signpostCost : signpostHours;
1764
+ const totals=all.reduce((sum,o)=>{
1765
+ const fraction=Number.isFinite(o.timelineFraction)?o.timelineFraction:1;
1766
+ sum.tokens+=(Number(o.tokens)||0)*fraction;
1767
+ sum.cost+=(Number(o.cost)||0)*fraction*(setupMode?1:COST_SCALE);
1768
+ sum.hours+=(Number(o.hours)||0)*fraction;
1769
+ return sum;
1770
+ },{tokens:0,cost:0,hours:0});
1771
+ return cityMetric==='tokens'?fmtTok(totals.tokens):cityMetric==='cost'?money(totals.cost):`${totals.hours.toFixed(1)}h`;
1772
+ }
1773
+ function signpostSub(){
1774
+ if(setupMode && cityComparison==='with') return cityMetric==='tokens'?'MODELED TOKENS' : cityMetric==='cost'?'MODELED API COST' : 'MODELED TIME';
1775
+ return cityMetric==='tokens'?'TOTAL TOKENS' : cityMetric==='cost'?'TOTAL SPENT' : 'TOTAL TIME';
1776
+ }
1777
+ function signpostBottom(){
1778
+ if(cityComparison==='with') return `${comparisonAfter} CONTEXT SCORE`;
1779
+ if(timelineProgress<0.999) return 'HISTORY AT DATE';
1780
+ return cityMetric==='tokens'?signpostWastedPct : cityMetric==='cost'?signpostWastedCost : signpostWastedTime;
1781
+ }
1782
+ function redrawCityMetric(){
1783
+ repoLabels.forEach(drawRepoLabel);
1784
+ drawSignpost(signpostBig(), signpostSub(), signpostBottom(),cityComparison==='with'?'#2a8f7d':'#e8473f');
1785
+ dirty=true;
1786
+ }
1000
1787
  (function(){
1001
1788
  const W=640, H=320;
1002
1789
  const cv=document.createElement('canvas'); cv.width=W; cv.height=H;
1003
1790
  signpostCanvas=cv;
1004
1791
  const tex=new THREE.CanvasTexture(cv); tex.colorSpace=THREE.SRGBColorSpace; tex.anisotropy=8;
1005
1792
  signpostTexture=tex;
1006
- drawSignpost(signpostTokens, signpostInputPct);
1793
+ drawSignpost(signpostBig(), signpostSub(), signpostBottom());
1007
1794
  const SIGN_W=152, SIGN_H=76, SIGN_Y=TRAY_TOP+64;
1008
1795
  const sign=new THREE.Mesh(
1009
1796
  new THREE.PlaneGeometry(SIGN_W, SIGN_H),
@@ -1020,6 +1807,114 @@ function drawSignpost(tokenText, bottomText, accent='#e8473f'){
1020
1807
  post.position.set(sx, TRAY_TOP+postH/2, sz); post.castShadow=true; scene.add(post);
1021
1808
  })();
1022
1809
 
1810
+ (function(){
1811
+ const ct=document.getElementById('city-toggle');
1812
+ if(!ct) return;
1813
+ const thumb=ct.querySelector('.ct-thumb');
1814
+ const moveThumb=(btn)=>{ if(!thumb||!btn) return;
1815
+ thumb.style.width=btn.offsetWidth+'px'; thumb.style.height=btn.offsetHeight+'px';
1816
+ thumb.style.transform=`translate(${btn.offsetLeft}px, ${btn.offsetTop}px)`;
1817
+ };
1818
+ requestAnimationFrame(()=>{
1819
+ const on=ct.querySelector('button.on')||ct.querySelector('button[data-metric]');
1820
+ if(thumb) thumb.style.transition='none';
1821
+ moveThumb(on);
1822
+ requestAnimationFrame(()=>{ if(thumb) thumb.style.transition=''; });
1823
+ });
1824
+ ct.addEventListener('click', (e)=>{
1825
+ const b=e.target.closest('button[data-metric]'); if(!b) return;
1826
+ cityMetric=b.getAttribute('data-metric') || 'time';
1827
+ ct.querySelectorAll('button').forEach((x)=>x.classList.toggle('on', x===b));
1828
+ moveThumb(b);
1829
+ redrawCityMetric();
1830
+ updateFieldReportMetric();
1831
+ });
1832
+ })();
1833
+
1834
+ function setCityComparison(mode){
1835
+ cityComparison=mode==='with'?'with':'without';
1836
+ repairTarget=cityComparison==='with'?1:0;
1837
+ document.body.classList.toggle('with-echo',cityComparison==='with');
1838
+ updateFieldReportMetric();
1839
+ redrawCityMetric();
1840
+ dirty=true;
1841
+ }
1842
+ setCityComparison('without');
1843
+ // Parent page (setup report fullscreen modal) drives the dirty/clean comparison remotely.
1844
+ window.addEventListener('message',(e)=>{if(e.origin!==location.origin)return;const d=e.data||{};if(d.type==='echomem:city-comparison')setCityComparison(d.mode==='with'?'with':'without');if(d.type==='echomem:city-metric'){const m=({time:1,tokens:1,cost:1})[d.metric]?d.metric:'time';const b=document.querySelector('#city-toggle button[data-metric="'+m+'"]');if(b)b.click();}});
1845
+
1846
+ const timelineStartMs=Date.parse(setupReport?.scanStartDate||'2024-12-01T00:00:00Z');
1847
+ const timelineEndMs=Math.max(timelineStartMs+86400000,Date.parse(setupReport?.scanEndDate||new Date().toISOString()));
1848
+ function repoFractionAt(o,cutoffMs){
1849
+ if(timelineProgress>=0.999) return 1;
1850
+ if(!Array.isArray(o.daily)||!o.daily.length) return timelineProgress;
1851
+ let total=0,visible=0;
1852
+ for(const point of o.daily){
1853
+ if(!Array.isArray(point)||point.length<2) continue;
1854
+ const amount=Math.max(0,Number(point[1])||0);
1855
+ const day=Date.parse(String(point[0])+'T23:59:59Z');
1856
+ total+=amount;
1857
+ if(Number.isFinite(day)&&day<=cutoffMs) visible+=amount;
1858
+ }
1859
+ return total>0?Math.max(0,Math.min(1,visible/total)):timelineProgress;
1860
+ }
1861
+ function setTimelineProgress(value){
1862
+ timelineProgress=Math.max(0,Math.min(1,Number(value)||0));
1863
+ const cutoffMs=timelineStartMs+(timelineEndMs-timelineStartMs)*timelineProgress;
1864
+ const handle=document.querySelector('#city-timeline .tl-handle');
1865
+ const progress=document.querySelector('#city-timeline .tl-progress');
1866
+ const selected=document.querySelector('#city-timeline .tl-selected');
1867
+ const pct=timelineProgress*100;
1868
+ if(handle){ handle.style.left=`${pct}%`; handle.setAttribute('aria-valuenow',String(Math.round(pct))); }
1869
+ if(progress) progress.style.width=`${pct}%`;
1870
+ if(selected){
1871
+ selected.style.left=`${Math.max(7,Math.min(93,pct))}%`;
1872
+ selected.textContent=timelineProgress>=0.995?'Today':new Date(cutoffMs).toLocaleString('en-US',{month:'short',day:'numeric',year:'numeric',timeZone:'UTC'});
1873
+ }
1874
+ for(const o of all){
1875
+ const fraction=repoFractionAt(o,cutoffMs);
1876
+ o.timelineFraction=fraction;
1877
+ if(o.mesh){
1878
+ o.mesh.visible=fraction>0.002;
1879
+ o.mesh.scale.y=Math.max(0.001,fraction);
1880
+ }
1881
+ }
1882
+ for(const entry of repoLabels){
1883
+ const fraction=Number.isFinite(entry.o.timelineFraction)?entry.o.timelineFraction:1;
1884
+ entry.sprite.visible=fraction>0.08;
1885
+ entry.sprite.material.opacity=Math.max(0,Math.min(1,(fraction-0.08)/0.18));
1886
+ entry.sprite.position.y=TRAY_TOP+entry.o.h*fraction+entry.labelOffset;
1887
+ }
1888
+ redrawCityMetric();
1889
+ key.shadow.needsUpdate=true;
1890
+ dirty=true;
1891
+ }
1892
+ (function(){
1893
+ const timeline=document.getElementById('city-timeline');
1894
+ const track=timeline?.querySelector('.tl-track');
1895
+ const handle=timeline?.querySelector('.tl-handle');
1896
+ if(!timeline||!track||!handle) return;
1897
+ let dragging=false;
1898
+ const updateFromPointer=(event)=>{
1899
+ const rect=track.getBoundingClientRect();
1900
+ setTimelineProgress((event.clientX-rect.left)/Math.max(1,rect.width));
1901
+ };
1902
+ track.addEventListener('pointerdown',(event)=>{
1903
+ dragging=true; timeline.classList.add('dragging'); updateFromPointer(event); event.preventDefault();
1904
+ });
1905
+ window.addEventListener('pointermove',(event)=>{ if(dragging){ updateFromPointer(event); event.preventDefault(); } });
1906
+ window.addEventListener('pointerup',()=>{ dragging=false; timeline.classList.remove('dragging'); });
1907
+ handle.addEventListener('keydown',(event)=>{
1908
+ if(event.key==='ArrowLeft'||event.key==='ArrowDown') setTimelineProgress(timelineProgress-0.025);
1909
+ else if(event.key==='ArrowRight'||event.key==='ArrowUp') setTimelineProgress(timelineProgress+0.025);
1910
+ else if(event.key==='Home') setTimelineProgress(0);
1911
+ else if(event.key==='End') setTimelineProgress(1);
1912
+ else return;
1913
+ event.preventDefault();
1914
+ });
1915
+ setTimelineProgress(1);
1916
+ })();
1917
+
1023
1918
  const MINZ=0.4, MAXZ=2.8; // lower floor → can zoom out to ~2/3 of the previous limit
1024
1919
  const controls=new OrbitControls(camera, renderer.domElement);
1025
1920
  controls.target.copy(TARGET);
@@ -1027,16 +1922,63 @@ controls.enableDamping=true; controls.dampingFactor=0.08; controls.enablePan=fal
1027
1922
  controls.minPolarAngle=0.18; controls.maxPolarAngle=Math.PI*0.47; controls.rotateSpeed=0.85;
1028
1923
  controls.update();
1029
1924
  let targetZoom=camera.zoom;
1925
+ // Hover auto-focus: wheel + drag behave differently over the city, so cue it —
1926
+ // the camera eases into a slight zoom when the pointer enters, back out on leave.
1927
+ let hoverFocus=1, hoverFocusTarget=1;
1928
+ renderer.domElement.addEventListener('pointerenter',()=>{ hoverFocusTarget=1.05; });
1929
+ renderer.domElement.addEventListener('pointerleave',()=>{ hoverFocusTarget=1; });
1030
1930
  renderer.domElement.addEventListener('wheel', (e)=>{
1031
1931
  e.preventDefault();
1032
1932
  const dy=e.deltaY * (e.deltaMode===1 ? 16 : 1);
1033
1933
  targetZoom=Math.min(MAXZ, Math.max(MINZ, targetZoom * Math.pow(0.999, dy)));
1034
1934
  }, {passive:false});
1035
1935
 
1036
- let dirty=true;
1037
1936
  function render(){ renderer.render(scene,camera); }
1038
1937
  function loop(){ requestAnimationFrame(loop); let need=controls.update();
1039
- if(Math.abs(targetZoom-camera.zoom) > 0.0006){ camera.zoom += (targetZoom-camera.zoom)*0.16; if(Math.abs(targetZoom-camera.zoom)<0.001) camera.zoom=targetZoom; camera.updateProjectionMatrix(); need=true; }
1938
+ let shadowNeedsUpdate=false;
1939
+ if(Math.abs(hoverFocusTarget-hoverFocus)>0.0004){ hoverFocus+=(hoverFocusTarget-hoverFocus)*0.1; if(Math.abs(hoverFocusTarget-hoverFocus)<0.0005) hoverFocus=hoverFocusTarget; }
1940
+ const zoomGoal=Math.min(MAXZ, Math.max(MINZ, targetZoom*hoverFocus));
1941
+ if(Math.abs(zoomGoal-camera.zoom) > 0.0006){ camera.zoom += (zoomGoal-camera.zoom)*0.16; if(Math.abs(zoomGoal-camera.zoom)<0.001) camera.zoom=zoomGoal; camera.updateProjectionMatrix(); need=true; }
1942
+ if(Math.abs(repairTarget-repairProgress)>0.0005){
1943
+ repairProgress+=(repairTarget-repairProgress)*0.085;
1944
+ if(Math.abs(repairTarget-repairProgress)<0.001) repairProgress=repairTarget;
1945
+ need=true; shadowNeedsUpdate=true;
1946
+ }
1947
+ const repairEase=repairProgress*repairProgress*(3-2*repairProgress);
1948
+ for(const entry of repairGroups){
1949
+ entry.group.rotation.x=entry.damageX*(1-repairEase);
1950
+ entry.group.rotation.z=entry.damageZ*(1-repairEase);
1951
+ }
1952
+ for(const entry of repairPieces){
1953
+ entry.mesh.position.copy(entry.damagePos).lerp(entry.cleanPos,repairEase);
1954
+ entry.mesh.rotation.y=entry.damageRotY*(1-repairEase);
1955
+ }
1956
+ for(const entry of buildingMaterials){
1957
+ entry.material.color.copy(entry.damaged).lerp(entry.clean,repairEase);
1958
+ entry.material.attenuationColor.copy(entry.damaged).lerp(entry.clean,repairEase);
1959
+ entry.material.roughness=0.9-(0.2*repairEase);
1960
+ entry.material.transmission=0.05+(0.11*repairEase);
1961
+ entry.material.clearcoat=0.015+(0.035*repairEase);
1962
+ entry.material.clearcoatRoughness=0.94-(0.14*repairEase);
1963
+ entry.material.envMapIntensity=0.24+(0.21*repairEase);
1964
+ }
1965
+ for(const entry of corruptionFades){
1966
+ entry.mesh.position.copy(entry.damagePos).lerp(entry.cleanPos,repairEase);
1967
+ const scale=1-repairEase*0.92;
1968
+ entry.mesh.scale.copy(entry.baseScale).multiplyScalar(scale);
1969
+ entry.material.opacity=1-repairEase;
1970
+ entry.mesh.visible=repairEase<0.985;
1971
+ }
1972
+ for(const material of corruptionSurfaces) material.opacity=1-repairEase;
1973
+ const now=performance.now()*0.001;
1974
+ for(const floater of corruptionFloaters){
1975
+ if(repairEase<0.985){
1976
+ floater.mesh.position.y+=Math.sin(now*floater.speed+floater.phase)*3.2*(1-repairEase);
1977
+ floater.mesh.rotation.y+=0.0025*floater.speed*(1-repairEase);
1978
+ }
1979
+ }
1980
+ if(corruptionFloaters.length && repairEase<0.985) need=true;
1981
+ if(shadowNeedsUpdate && (++shadowFrame%3===0 || repairProgress===repairTarget)) key.shadow.needsUpdate=true;
1040
1982
  if(need||dirty){ render(); dirty=false; }
1041
1983
  }
1042
1984
  loop();
@@ -1045,7 +1987,7 @@ loop();
1045
1987
  const cardEl=document.querySelector('.share-card');
1046
1988
  function fit(){
1047
1989
  if(setupMode){
1048
- VIZ_W=window.innerWidth; VIZ_H=window.innerHeight;
1990
+ VIZ_W=figureModeEmbed ? window.innerWidth : Math.round(window.innerWidth - Math.min(window.innerWidth*0.4, 620)); VIZ_H=window.innerHeight;
1049
1991
  cardEl.style.transform='none';
1050
1992
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
1051
1993
  renderer.setSize(VIZ_W,VIZ_H,false);
@@ -1063,51 +2005,79 @@ function fit(){
1063
2005
  fit();
1064
2006
  window.addEventListener('resize', fit);
1065
2007
 
1066
- // Loading lives inside the report: once the city has rendered + framed, fade the scan loader out and
1067
- // the city + real chrome in together (one surface, smooth). Runs unconditionally so it never sticks.
2008
+ // Loading lives inside the report: reveal only after a provenance-checked report has
2009
+ // populated at least one real repo. Empty, failed, and invalid setup states stay closed.
1068
2010
  function finishSetupReveal(){
2011
+ document.querySelectorAll('[data-setup-pending-hidden]').forEach((el)=>{ el.removeAttribute('aria-hidden'); el.removeAttribute('data-setup-pending-hidden'); });
1069
2012
  document.body.classList.remove('scanning');
1070
2013
  }
1071
- if(setupMode){
2014
+ if(setupMode && setupReport && setupLiveRepos.length && all.length){
1072
2015
  requestAnimationFrame(()=>requestAnimationFrame(finishSetupReveal));
1073
2016
  window.setTimeout(finishSetupReveal, 1200);
1074
2017
  }
1075
2018
 
1076
- document.querySelectorAll('#setup-connect,#v7-connect').forEach((connectButton)=>{
2019
+ (function(){
2020
+ const btn=document.getElementById('fr-read-btn');
2021
+ const panel=document.getElementById('fr-persona-hover');
2022
+ if(!btn || !panel) return;
2023
+ const setOpen=(open)=>{
2024
+ panel.classList.toggle('open', open);
2025
+ btn.setAttribute('aria-expanded', String(open));
2026
+ };
2027
+ btn.addEventListener('click', (event)=>{
2028
+ event.stopPropagation();
2029
+ setOpen(!panel.classList.contains('open'));
2030
+ });
2031
+ document.addEventListener('click', (event)=>{
2032
+ if(event.target!==btn && !panel.contains(event.target)) setOpen(false);
2033
+ });
2034
+ window.addEventListener('keydown', (event)=>{ if(event.key==='Escape') setOpen(false); });
2035
+ })();
2036
+
2037
+ document.querySelectorAll('#setup-connect,#v7-connect,#fr-connect').forEach((connectButton)=>{
1077
2038
  connectButton.addEventListener('click', (event)=>{
1078
2039
  event.stopPropagation();
1079
2040
  if(setupMode) window.parent?.postMessage({type:'echomem:connect-click'}, window.location.origin);
1080
2041
  });
1081
2042
  });
1082
2043
 
1083
- // ---- rotating cost-of-forgetting readout: share, time, and API-equivalent waste ----
2044
+ // ---- rotating context-health readout: HUD score, share, time, and API-equivalent waste ----
1084
2045
  const parseScalar=(value)=>{
1085
2046
  const match=String(value||'').match(/[0-9][0-9,]*(?:\.[0-9]+)?/);
1086
2047
  return match ? Number(match[0].replace(/,/g,''))||0 : 0;
1087
2048
  };
1088
2049
  const formatWasteMoney=(value)=>`$${Math.round(value).toLocaleString('en-US')}`;
1089
- function buildMetricSlides(dirtyPct, attentionHours, computeCost){
1090
- const dirtyShare=Math.max(0, parseScalar(dirtyPct))/100;
2050
+ function buildMetricSlides(dirtyPct, attentionHours, computeCost, report){
2051
+ const wastePct=goldenWastePct(report);
2052
+ const usefulPct=goldenUsefulPct(report);
2053
+ const dirtyShare=Math.max(0, Number.isFinite(wastePct) ? wastePct : parseScalar(dirtyPct))/100;
1091
2054
  const wastedHours=Math.max(0, parseScalar(attentionHours))*dirtyShare;
1092
2055
  const wastedDays=wastedHours/8;
1093
2056
  const wastedCost=Math.max(0, parseScalar(computeCost))*dirtyShare;
1094
- return [
2057
+ const slides=[];
2058
+ slides.push(
2059
+ {
2060
+ value:`${oneDec(wastePct)}%`,
2061
+ label:'wasteful context',
2062
+ say:goldenWasteSay(report)
2063
+ },
1095
2064
  {
1096
- value:`${parseScalar(dirtyPct).toFixed(1)}%`,
1097
- label:'repeated reads',
1098
- say:"Your agents re-load context they've already seen unchanged files, the same rules, stale maps every session. That's context rot. It's <b>how coding agents work today</b>, and it's the haze on your city."
2065
+ value:`${oneDec(usefulPct)}%`,
2066
+ label:'useful context',
2067
+ say:'Useful context is the portion of the analyzed window that still helps the agent: active task state, product facts, current files, and required overhead.'
1099
2068
  },
1100
2069
  {
1101
2070
  value:`≈${Math.round(wastedHours)}h`,
1102
- label:'spent rereading',
1103
- say:`That is roughly <b>${wastedDays.toFixed(1)} workdays</b> of agent attention spent loading old context again instead of moving the workspace forward.`
2071
+ label:'waste-weighted time',
2072
+ say:`That is roughly <b>${wastedDays.toFixed(1)} workdays</b> of agent attention weighted by the golden-standard wasteful-context share.`
1104
2073
  },
1105
2074
  {
1106
2075
  value:`≈${formatWasteMoney(wastedCost)}`,
1107
2076
  label:'API-equiv waste',
1108
- say:`At list API-equivalent rates, repeated reads account for about <b>${formatWasteMoney(wastedCost)}</b> of the compute volume shown above. Not your bill — the scale of the waste.`
2077
+ say:`At list API-equivalent rates, the wasteful-context share represents about <b>${formatWasteMoney(wastedCost)}</b> of the compute volume shown above. Not your bill — the scale of the waste.`
1109
2078
  },
1110
- ];
2079
+ );
2080
+ return slides;
1111
2081
  }
1112
2082
  function bindMetricCarousel({metric,value,label,say,slides}){
1113
2083
  if(!metric || !value || !label || !say || !slides?.length) return;
@@ -1124,7 +2094,7 @@ function bindMetricCarousel({metric,value,label,say,slides}){
1124
2094
  say.classList.remove('is-swapping');
1125
2095
  }, 520);
1126
2096
  }
1127
- if(slides.length>1 && !window.matchMedia('(prefers-reduced-motion: reduce)').matches){
2097
+ if(!setupMode && slides.length>1 && !window.matchMedia('(prefers-reduced-motion: reduce)').matches){
1128
2098
  window.setInterval(()=>{
1129
2099
  index=(index+1)%slides.length;
1130
2100
  show(index);
@@ -1137,11 +2107,40 @@ bindMetricCarousel({
1137
2107
  label:document.getElementById('v7-metric-label'),
1138
2108
  say:document.getElementById('v7-metric-say'),
1139
2109
  slides:buildMetricSlides(
1140
- document.getElementById('v7-metric-value')?.textContent || '85.0',
2110
+ goldenWastePct(setupReport) || setupReport?.cleanliness?.staleReadSharePct || '85.0',
1141
2111
  document.getElementById('v7-attention')?.textContent || '191.1',
1142
- document.getElementById('v7-cost')?.textContent || '$13,689'
2112
+ document.getElementById('v7-cost')?.textContent || '$13,689',
2113
+ setupReport
1143
2114
  ),
1144
2115
  });
2116
+ bindMetricCarousel({
2117
+ metric:document.getElementById('fr-metric'),
2118
+ value:document.getElementById('fr-metric-value'),
2119
+ label:document.getElementById('fr-metric-label'),
2120
+ say:document.getElementById('fr-metric-say'),
2121
+ slides:buildMetricSlides(
2122
+ goldenWastePct(setupReport) || setupReport?.cleanliness?.staleReadSharePct || '85.0',
2123
+ document.getElementById('v7-attention')?.textContent || '191.1',
2124
+ document.getElementById('v7-cost')?.textContent || '$13,689',
2125
+ setupReport
2126
+ ),
2127
+ });
2128
+ function reserveFrSayHeight(){
2129
+ if(!setupMode) return;
2130
+ const say=document.getElementById('fr-metric-say'); if(!say) return;
2131
+ const slides=buildMetricSlides(
2132
+ goldenWastePct(setupReport) || setupReport?.cleanliness?.staleReadSharePct || '85.0',
2133
+ document.getElementById('v7-attention')?.textContent || '191.1',
2134
+ document.getElementById('v7-cost')?.textContent || '$13,689',
2135
+ setupReport
2136
+ );
2137
+ const prev=say.innerHTML; say.style.minHeight='0px'; let max=0;
2138
+ for(const slide of slides){ say.innerHTML=slide.say; max=Math.max(max, say.offsetHeight); }
2139
+ say.innerHTML=prev;
2140
+ if(max>0) say.style.minHeight=max+'px';
2141
+ }
2142
+ reserveFrSayHeight();
2143
+ window.addEventListener('resize', reserveFrSayHeight);
1145
2144
 
1146
2145
  // ---- repeated-reads info sheet: v7 "how we read your city" disclosure, moved behind the info button ----
1147
2146
  const v7InfoBtns=Array.from(document.querySelectorAll('[aria-controls="v7-info-sheet"]'));