@gamaze/hicortex 0.20.10 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,20 +6,31 @@
6
6
  visual on real endpoints, zero new write paths (Update/Remove ride the
7
7
  existing /update + /delete; identity Save rides PUT /identity).
8
8
 
9
+ NAMING (owner ruling 2026-09-14): this surface is "the Hicortex server
10
+ dashboard" — the word "console" never appears in USER-VISIBLE strings
11
+ (titles, tooltips, aria-labels, placeholders, toasts, empty states).
12
+ Historical "console" wording in these comments is internal shorthand and
13
+ stays.
14
+
9
15
  PORTED FROM mockups/409-r6-flight.html (the design of record — its banner
10
16
  documents every behavior; the engine below is that page with the seeded
11
17
  data layer replaced by fetches). Live-data substitutions:
12
- /dashboard/data?range=90d — band numbers, snapshot series, digest
18
+ /dashboard/data?range=Rd — band numbers, snapshot series, digest
13
19
  /dashboard/field — the whole field (memories + real links)
14
- /dashboard/events?days=90 — the replay ledger (real nights)
20
+ /dashboard/events?days=R — the replay ledger (real nights)
15
21
  /health + /health/detail — live dot (30 s poll) + model label
16
22
  /account + /account/token — the account menu (harvested from 0.20.4)
17
23
  /search /memory /update /delete /identity — the browser + agent rail
24
+ (R = the range selector's value: 30 by default, 7/90/180 opt-in — #452)
18
25
 
19
26
  PHASE 2 (#422 — operator surfaces) LANDED:
20
27
  - Capture health: real /distill accounting rows (posts · sessions · MB ·
21
28
  held per machine × harness) via /dashboard/data capture_health; the
22
29
  phase-1 counts-only rows remain as the degrade.
30
+ Fix round 7 (#409, owner ruling 2026-09-14 — "the normal 30 day as the
31
+ other cards"): the card's face is the ROLLING window (window_rows,
32
+ window_days), the newest night rides as a per-row secondary suffix;
33
+ retention == window (CAPTURE_HEALTH_WINDOW_DAYS).
23
34
  - Activity bars: five segments (added + merged + the three graduation
24
35
  deltas from snapshot stage_counts; two-segment rendering when buckets
25
36
  are absent).
@@ -87,6 +98,28 @@
87
98
  within ~2–2.5× (owner: no huge green balls, nothing barely visible);
88
99
  stage colors untouched.
89
100
 
101
+ FIX ROUND 6 (owner-approved proto of record mockups/progress-bar.html,
102
+ 2026-09-14 — variant 3 + copy A): ENTRY LOADING SCREEN. Pure inline
103
+ HTML+CSS at the very top of the body, BEFORE the app markup and the
104
+ engine script — it paints on URL entry with ZERO script (the real
105
+ page is black for seconds while the engine parses/boots; the splash
106
+ must beat that). All motion is opacity/transform only, so the
107
+ compositor keeps it animating straight through the engine's main-
108
+ thread freeze. Fully opaque black in BOTH themes (the proto's light
109
+ "paper twin" was review chrome, not shipped behavior): a breathing
110
+ green dot above an indeterminate flowing line (two brand-orange
111
+ segments, uneven pace, looping — never a percentage, never an ETA)
112
+ under the copy line "Entering the Hicortex fleet brain". The engine
113
+ only ever fades it away: entrySplashDismiss hooks the boot REVEAL —
114
+ the existing single handover (data landed → world built → app
115
+ unhidden), NOT DOMContentLoaded — fades .4s, then removes the node.
116
+ Failure seams swap the copy line honestly and never loop silently:
117
+ both core sources rejected → "could not reach the Hicortex server…"
118
+ (held readable ≥2.8 s); boot() throws → same treatment + the static
119
+ shell; 30 s without any settlement → "still waiting…" (no dismiss —
120
+ data may still land). #still removes it synchronously (screenshot
121
+ contract); reduced motion rests on the bar's static base transform.
122
+
90
123
  FIX ROUND 3 (owner findings on the live console, 2026-09-14):
91
124
  - FIELD LINKS (owner: "the white lines … get very thick — they kind of
92
125
  overtake most of the image"): settled strokes are hairline-floored and
@@ -104,6 +137,30 @@
104
137
  stays marked; the visible word failed to communicate). Vocabulary
105
138
  amendment: Low / HEALTHY / Overfetching. Marker + trend stay.
106
139
 
140
+ REPLAY + RANGE (#452, owner directives 2026-09-16):
141
+ - 30-DAY DEFAULT FOR ALL VIEWS: the replay ledger, the band and the
142
+ snapshot series all default to 30 days (server EVENTS_DEFAULT_DAYS
143
+ 90 → 30; the client's every fetch is selector-driven). Longer
144
+ ranges are OPT-IN via a compact 7 / 30 / 90 / 180 segmented control
145
+ in the replay foot — explicit click only, never auto-loaded
146
+ (SUPERSEDES the 2026-09-12 no-dropdowns ruling). No persistence:
147
+ every load starts at 30 (loads fast).
148
+ - PLAY ONCE, THEN REST: the replay auto-starts as before (~3.6 s after
149
+ boot), walks the ledger ONCE to today, and STOPS there — no loop
150
+ (SUPERSEDES the 2026-09-13 auto-loop/no-buttons ruling). Controls in
151
+ the replay foot, styled like "return to now": pause/resume (holds
152
+ position mid-cruise; hides once the playback completed — nothing is
153
+ playing) and "replay again" (appears once completed; restarts from
154
+ the range start). Drag/scrub behavior is UNCHANGED: a manual take
155
+ pauses the cruise and re-engages ~12 s after idle, continuing
156
+ toward today — where it now stops instead of looping.
157
+ - DATE LABEL ON THE SCRUBBER: the cursor dot carries a small mono
158
+ label above it (the night being viewed, "now" at the end).
159
+ - TRUTH-MANAGEMENT CAPTIONS: Superseded / Retracted / Rewritten ride
160
+ the events ledger alongside learned/enriched/merged (server-side
161
+ sources in dashboard.ts; superseded/retracted bloom in the fading
162
+ color — demoted — rewritten in the truth color).
163
+
107
164
  REMAINING DEGRADATIONS (planned, per the #409 phase table — not gaps):
108
165
  - Recall card: no raw shown/used counts (the per-memory adoption sums
109
166
  are not in any payload yet).
@@ -442,6 +499,22 @@
442
499
  padding:2px 11px; white-space:nowrap; width:fit-content;
443
500
  }
444
501
  #lim-ag .limit-cta:hover{ border-color:var(--amber); }
502
+ /* #409 fix round 4 — the Model card's configure pill: the mock of record
503
+ puts it at the card's BOTTOM-RIGHT, grey/muted (the neutral twin of the
504
+ Agents Connect pill above). margin-top:auto pins it to the card's foot
505
+ however tall the row stretches — row-2 siblings flex-fill, so
506
+ align-self:flex-end alone would leave it floating mid-card. The
507
+ :not([hidden]) guard keeps the degrade (old server / GET failure)
508
+ working: a plain display rule would override the hidden attribute. All
509
+ colors ride theme vars — both themes come free. */
510
+ #model-card .limit-cta:not([hidden]){
511
+ display:inline-block; align-self:flex-end; margin-top:auto;
512
+ color:var(--dim); border:1px solid var(--line2);
513
+ background:color-mix(in srgb, var(--text) 4%, transparent);
514
+ font-size:10.5px; font-weight:600; border-radius:999px;
515
+ padding:2px 11px; white-space:nowrap; width:fit-content;
516
+ }
517
+ #model-card .limit-cta:not([hidden]):hover{ border-color:var(--dim); color:var(--text); }
445
518
 
446
519
  /* ---- row-2 operator cards share the .bseg base; .gh is THE card-header
447
520
  component (titles + non-numeric descriptors only) ---- */
@@ -517,11 +590,25 @@
517
590
  pointer-events:none; transition:opacity 1.4s; z-index:5;
518
591
  }
519
592
  .hint em{ font-style:normal; color:var(--dim); }
520
- /* replay — inside the brain panel; drag-only (no play button anywhere) */
593
+ /* replay — inside the brain panel; drag to scrub, or let the cruise walk
594
+ the ledger ONCE to today (#452: range pills + pause / replay again) */
521
595
  .replay-foot{ border-top:1px solid var(--line); padding:7px 12px 8px; flex:none; background:color-mix(in srgb, var(--panel) 88%, transparent); }
522
596
  #replay{ display:flex; align-items:center; gap:12px; }
523
597
  #replay.off{ opacity:.45; pointer-events:none; }
598
+ /* the range selector stays LIVE even when the instrument is off — a wider
599
+ window can reveal ≥2 nights and revive the replay (#452) */
600
+ #replay.off #rp-range{ opacity:1; pointer-events:auto; }
524
601
  #rp-label{ font-size:10px; color:var(--faint); text-transform:uppercase; letter-spacing:.14em; white-space:nowrap; flex:none; }
602
+ /* #452 range selector — the .idpill.seg idiom (mono pills, active = green) */
603
+ #rp-range{ display:inline-flex; gap:3px; flex:none; }
604
+ .rp-seg{
605
+ font-size:9px; font-family:var(--mono); letter-spacing:.05em; color:var(--faint);
606
+ border:1px solid var(--line2); border-radius:999px; padding:3px 8px; white-space:nowrap;
607
+ transition:color .15s, border-color .15s;
608
+ }
609
+ .rp-seg:hover{ color:var(--dim); }
610
+ .rp-seg[aria-pressed="true"]{ color:var(--green-mid); border-color:color-mix(in srgb, var(--green) 40%, transparent); }
611
+ html:not([data-theme="light"]) .rp-seg[aria-pressed="true"]{ box-shadow:0 0 10px rgba(61,255,122,.12); }
525
612
  #rp-track{ flex:1; position:relative; height:26px; cursor:pointer; touch-action:none; min-width:60px; }
526
613
  #rp-rail{ position:absolute; left:0; right:0; top:11px; height:3px; border-radius:2px; background:var(--tbar-bg); }
527
614
  #rp-fill{ position:absolute; left:0; top:11px; height:3px; border-radius:2px; width:100%;
@@ -533,14 +620,23 @@
533
620
  }
534
621
  html:not([data-theme="light"]) #rp-cursor{ background:#0d1f14; box-shadow:0 0 12px rgba(61,255,122,.7); }
535
622
  html[data-theme="light"] #rp-cursor{ box-shadow:0 1px 4px rgba(56,54,42,.3); }
623
+ /* #452: the night being viewed, floating above the scrub dot (the
624
+ #rp-dates span styling; overflow is visible — it may overlap the canvas
625
+ bottom edge like .hint does) */
626
+ #rp-date{
627
+ position:absolute; top:-13px; transform:translateX(-50%); white-space:nowrap;
628
+ font-size:9px; color:var(--faint); font-family:var(--mono); pointer-events:none; z-index:3;
629
+ display:none;
630
+ }
631
+ #rp-date.show{ display:block; }
536
632
  .rp-dot{ position:absolute; top:9px; width:5px; height:5px; border-radius:50%; margin-left:-2.5px; background:var(--line2); }
537
633
  .rp-dot.g{ background:var(--green-mid); }
538
634
  .rp-dot.w{ background:var(--dim); }
539
635
  #rp-dates{ position:absolute; left:0; right:0; top:21px; height:12px; }
540
636
  #rp-dates span{ position:absolute; transform:translateX(-50%); font-size:9px; color:var(--faint); font-family:var(--mono); }
541
- #rp-now{ display:none; font-size:10.5px; color:var(--green-mid); border:1px solid color-mix(in srgb, var(--green) 40%, transparent); border-radius:999px; padding:5px 11px; white-space:nowrap; }
542
- #rp-now.show{ display:inline-block; }
543
- html:not([data-theme="light"]) #rp-now{ box-shadow:0 0 12px rgba(61,255,122,.15); }
637
+ #rp-now,#rp-pause,#rp-again{ display:none; font-size:10.5px; color:var(--green-mid); border:1px solid color-mix(in srgb, var(--green) 40%, transparent); border-radius:999px; padding:5px 11px; white-space:nowrap; }
638
+ #rp-now.show,#rp-pause.show,#rp-again.show{ display:inline-block; }
639
+ html:not([data-theme="light"]) #rp-now,html:not([data-theme="light"]) #rp-pause,html:not([data-theme="light"]) #rp-again{ box-shadow:0 0 12px rgba(61,255,122,.15); }
544
640
 
545
641
  /* ---- agents rail — the ONLY thing beside the brain, rightmost ---- */
546
642
  #agentscol{
@@ -724,7 +820,7 @@
724
820
  /* capture health — phase 2: per machine × harness rows from /distill
725
821
  accounting (posts/sessions/MB/held); the phase-1 counts-only rows remain
726
822
  as the degrade for old servers / nothing recorded yet */
727
- .cap-row2{ display:flex; align-items:center; gap:9px; padding:2.5px 0; border-bottom:1px solid color-mix(in srgb, var(--line) 60%, transparent); }
823
+ .cap-row2{ display:flex; flex-wrap:wrap; align-items:center; gap:1px 9px; padding:2.5px 0; border-bottom:1px solid color-mix(in srgb, var(--line) 60%, transparent); }
728
824
  .cap-row2:last-of-type{ border-bottom:none; }
729
825
  .cap-dot{ width:6px; height:6px; border-radius:50%; flex:none; background:var(--green-mid); }
730
826
  .cap-row2 .hn{ font:11px var(--mono); color:var(--text); flex:0 0 148px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
@@ -735,6 +831,12 @@
735
831
  .cap-bar .fill{ height:100%; border-radius:2.5px; background:var(--green-mid); }
736
832
  .cap-row2 .st{ font-size:10px; color:var(--faint); font-family:var(--mono); white-space:nowrap; }
737
833
  .cap-row2 .st b{ color:var(--dim); font-weight:500; }
834
+ /* fix round 7: the newest-night sub-line on the rolling-window rows — a
835
+ full-basis child wraps under the row's primary line (the suffix does NOT
836
+ fit beside "N sess · X MB" without pushing past the card edge); same
837
+ faint mono tone, theme token only, both themes free. Rows without a
838
+ newest night stay single-line. */
839
+ .cap-row2 .cap-ton{ flex-basis:100%; text-align:right; font:10px var(--mono); color:var(--faint); line-height:1.1; }
738
840
  .cap-held{ color:var(--amber); margin-left:5px; }
739
841
  /* #423 phase 3: the capture-pause badge — the same amber family as the
740
842
  held badge (an operator state, not an error) */
@@ -919,6 +1021,7 @@
919
1021
  .brow{ grid-template-columns:1fr; }
920
1022
  .mrow .sn{ font-size:10px; }
921
1023
  #rp-label{ display:none; }
1024
+ #replay{ flex-wrap:wrap; } /* #452: range pills + controls wrap rather than crush the track */
922
1025
  #rp-dates span:nth-child(n+3){ display:none; }
923
1026
  .acct-trigger .who{ display:none; }
924
1027
  .field-head{ padding:8px 12px 7px; }
@@ -943,6 +1046,62 @@
943
1046
  </script>
944
1047
  </head>
945
1048
  <body class="booting">
1049
+ <!-- ============ entry loading screen (#409 fix round 6 — the owner-
1050
+ approved proto of record mockups/progress-bar.html, variant 3 + copy
1051
+ A). Sits at the very top of the body with its OWN style block: it paints
1052
+ on URL entry with ZERO script, fully opaque black in both themes, and
1053
+ the engine only ever fades it away (entrySplashDismiss at the boot
1054
+ reveal). On failure the copy line swaps to an honest error — never an
1055
+ infinite loop. All motion is opacity/transform only: the compositor
1056
+ keeps it animating while the engine below blocks the main thread. ============ -->
1057
+ <style>
1058
+ #entry-load{
1059
+ position:fixed; inset:0; z-index:1000; background:#0a0a0a;
1060
+ display:flex; flex-direction:column; align-items:center; justify-content:center; gap:17px;
1061
+ transition:opacity .4s ease;
1062
+ }
1063
+ #entry-load.gone{ opacity:0; pointer-events:none; }
1064
+ /* variant 3's dot: the header healthy mark's look, breathing */
1065
+ #entry-load .el-dot{
1066
+ width:10px; height:10px; border-radius:50%;
1067
+ background:radial-gradient(circle at 35% 35%, color-mix(in srgb, #3dff7a 95%, white), color-mix(in srgb, #3dff7a 25%, transparent) 55%, transparent 70%);
1068
+ box-shadow:0 0 12px color-mix(in srgb, #3dff7a 55%, transparent);
1069
+ animation:el-breathe 3.4s ease-in-out infinite;
1070
+ }
1071
+ @keyframes el-breathe{ 0%,100%{opacity:1} 50%{opacity:.5} }
1072
+ #entry-load .el-line{ font-size:13.5px; color:#9a9a9a; text-align:center; padding:0 24px; }
1073
+ /* the indeterminate line: two brand-orange segments on a faint track,
1074
+ uneven pace, varying length, looping forever; the base transform is
1075
+ the reduced-motion rest pose. Never a percentage, never an ETA. */
1076
+ #entry-load .el-bar{
1077
+ width:min(300px,72%); height:2.5px; border-radius:99px;
1078
+ background:#1e1e1e; overflow:hidden; position:relative;
1079
+ }
1080
+ #entry-load .el-bar i{
1081
+ position:absolute; top:0; bottom:0; left:0; width:100%;
1082
+ background:#ff9f43; border-radius:99px;
1083
+ transform-origin:0 50%; transform:translateX(32%) scaleX(.42);
1084
+ box-shadow:0 0 8px color-mix(in srgb, #ff9f43 45%, transparent);
1085
+ }
1086
+ #entry-load .el-bar i.a{ animation:el-indet-a 2.2s cubic-bezier(.55,.1,.7,.5) infinite backwards; }
1087
+ #entry-load .el-bar i.b{ opacity:.6; animation:el-indet-b 2.2s cubic-bezier(.65,.05,.75,.5) 1.15s infinite backwards; }
1088
+ @keyframes el-indet-a{
1089
+ 0% { transform:translateX(-38%) scaleX(.12); animation-timing-function:cubic-bezier(.6,.05,.8,.5); }
1090
+ 55% { transform:translateX(46%) scaleX(.62); animation-timing-function:cubic-bezier(.25,.65,.35,1); }
1091
+ 80% { transform:translateX(84%) scaleX(.68); animation-timing-function:cubic-bezier(.55,.05,.8,.45); }
1092
+ 100% { transform:translateX(140%) scaleX(.16); }
1093
+ }
1094
+ @keyframes el-indet-b{
1095
+ 0% { transform:translateX(-24%) scaleX(.08); animation-timing-function:cubic-bezier(.65,.05,.8,.5); }
1096
+ 70% { transform:translateX(52%) scaleX(.30); animation-timing-function:cubic-bezier(.2,.6,.45,1); }
1097
+ 100% { transform:translateX(116%) scaleX(.10); }
1098
+ }
1099
+ </style>
1100
+ <div id="entry-load" role="status" aria-live="polite">
1101
+ <span class="el-dot" aria-hidden="true"></span>
1102
+ <div class="el-line" id="entry-load-line">Entering the Hicortex fleet brain</div>
1103
+ <div class="el-bar" aria-hidden="true"><i class="a"></i><i class="b"></i></div>
1104
+ </div>
946
1105
  <header>
947
1106
  <div class="h-left">
948
1107
  <div class="h-search">
@@ -1045,10 +1204,11 @@
1045
1204
  <div id="feel-rows"></div>
1046
1205
  </div>
1047
1206
  <div class="bseg c-grey" id="model-card">
1048
- <div class="gh"><span class="t">Model</span><button class="chip" id="model-cfg" hidden title="edit the model knobs (config.json llm* keys)">configure</button></div>
1049
- <div class="big"><span class="n" id="model-provider">—</span><span class="l" id="model-tail"></span></div>
1207
+ <div class="gh"><span class="t">Model</span></div>
1208
+ <div class="big"><span class="n" id="model-provider">—</span></div>
1050
1209
  <div class="csub mono" id="model-id">&nbsp;</div>
1051
1210
  <div class="csub">detected from config</div>
1211
+ <button class="limit-cta" id="model-cfg" hidden title="edit the model knobs (config.json llm* keys)">configure &#8594;</button>
1052
1212
  </div>
1053
1213
  </div>
1054
1214
  </div>
@@ -1079,13 +1239,17 @@
1079
1239
  </div>
1080
1240
  <div class="replay-foot">
1081
1241
  <div id="replay">
1242
+ <div id="rp-range" role="group" aria-label="range"></div>
1082
1243
  <span id="rp-label">nights</span>
1083
1244
  <div id="rp-track">
1084
1245
  <div id="rp-rail"></div>
1085
1246
  <div id="rp-fill"></div>
1086
1247
  <div id="rp-dates"></div>
1087
1248
  <div id="rp-cursor"></div>
1249
+ <span id="rp-date"></span>
1088
1250
  </div>
1251
+ <button id="rp-pause">pause</button>
1252
+ <button id="rp-again">replay again</button>
1089
1253
  <button id="rp-now">return to now</button>
1090
1254
  </div>
1091
1255
  </div>
@@ -1151,14 +1315,62 @@
1151
1315
  </div>
1152
1316
  <script>
1153
1317
  'use strict';
1318
+ /* =====================================================================
1319
+ ENTRY SPLASH (#409 fix round 6) — the loading screen is already painted
1320
+ by the static markup at the top of the body; the engine only ever fades it
1321
+ away. entrySplashDismiss hooks the boot REVEAL (reveal() in boot(): the
1322
+ single handover — data landed, world built, app unhidden — NOT
1323
+ DOMContentLoaded). State rides the node's dataset so both functions stay
1324
+ pure w.r.t. the DOM (extract-and-run test convention). Failure seams
1325
+ swap the copy line honestly and NEVER loop silently: total data failure
1326
+ holds the error line readable >= ENTRY_ERROR_HOLD before fading (even if
1327
+ the reveal fires earlier — and on its own if the reveal never comes); a
1328
+ boot() throw does the same and clears the booting class; a 30 s deadline
1329
+ (below) flags a server that never answered without dismissing — data
1330
+ may still land, and the reveal then owns the dismissal.
1331
+ ===================================================================== */
1332
+ const ENTRY_FAIL_COPY='could not reach the Hicortex server — check that it is running, then reload';
1333
+ const ENTRY_BOOTFAIL_COPY='the dashboard failed to start — reload to retry';
1334
+ const ENTRY_SLOW_COPY='still waiting for the server — it has not answered yet';
1335
+ const ENTRY_ERROR_HOLD=2800; /* ms the failure line stays readable */
1336
+ function entrySplashFail(msg){
1337
+ const s=document.getElementById('entry-load'), l=document.getElementById('entry-load-line');
1338
+ if(!s || !l || s.dataset.gone==='1') return;
1339
+ l.textContent=msg;
1340
+ s.dataset.failed='1'; s.dataset.failAt=String(Date.now());
1341
+ setTimeout(entrySplashDismiss, ENTRY_ERROR_HOLD); /* owns the beat even if the reveal never comes */
1342
+ }
1343
+ function entrySplashDismiss(){
1344
+ const s=document.getElementById('entry-load');
1345
+ if(!s) return;
1346
+ if(s.dataset.failed==='1'){ /* hold the error line readable */
1347
+ const remain=ENTRY_ERROR_HOLD-(Date.now()-Number(s.dataset.failAt||0));
1348
+ if(remain>0){ setTimeout(entrySplashDismiss, remain); return; }
1349
+ }
1350
+ if(s.dataset.gone==='1') return; /* idempotent */
1351
+ s.dataset.gone='1';
1352
+ const instant=document.body.classList.contains('instant') ||
1353
+ (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
1354
+ if(instant){ s.remove(); return; } /* #still / reduced motion: no fade */
1355
+ s.classList.add('gone');
1356
+ s.addEventListener('transitionend', function(){ s.remove(); }, {once:true});
1357
+ setTimeout(function(){ s.remove(); }, 900); /* fallback — transitionend is not guaranteed */
1358
+ }
1359
+ /* honest slow-wait flag: boot fetches still unsettled 30 s in → say so on
1360
+ the splash (no dismissal — the reveal still owns that if data lands) */
1361
+ setTimeout(function(){
1362
+ const s=document.getElementById('entry-load'), l=document.getElementById('entry-load-line');
1363
+ if(s && l && s.dataset.gone!=='1' && s.dataset.failed!=='1') l.textContent=ENTRY_SLOW_COPY;
1364
+ }, 30000);
1365
+
1154
1366
  /* #still (or ?still): skip the boot reveal — deterministic screenshots/tests. */
1155
1367
  const STILL = /(?:#|[?&])still\b/.test(location.hash + ' ' + location.search);
1156
- if(STILL){ document.body.classList.remove('booting'); document.body.classList.add('instant'); }
1368
+ if(STILL){ document.body.classList.remove('booting'); document.body.classList.add('instant'); entrySplashDismiss(); }
1157
1369
 
1158
1370
  /* =====================================================================
1159
1371
  The approved #409 r6-flight engine, ported to live data. One canvas,
1160
1372
  three verbs (look / travel / time) plus the quiet fourth: WATCH — ambient
1161
- signals loop slowly along REAL stored links, never behind a play button.
1373
+ signals loop slowly along REAL stored links, always on.
1162
1374
  Stages are DERIVED presentation (server-side via /dashboard/field) over
1163
1375
  the one numeric strength signal — never stored state.
1164
1376
  ===================================================================== */
@@ -1275,9 +1487,12 @@ function bubbleRadius(s){ return 6 + Math.sqrt(clamp(s??0,0,1))*3; }
1275
1487
  function depthSizeDivisor(dz){ return Math.pow(Math.max(.14,dz),0.35); }
1276
1488
 
1277
1489
  /* ---------- live-data state ---------- */
1278
- let D=null; // /dashboard/data?range=90d
1490
+ let RANGE=30; // days — the range selector's value (#452; 30-day default for
1491
+ // ALL views, owner directive 2026-09-16). NO persistence: every
1492
+ // load starts at 30 — it loads fast; longer is opt-in per click.
1493
+ let D=null; // /dashboard/data?range=RANGE d — band, series, digest
1279
1494
  let F=null; // /dashboard/field
1280
- let EV=null; // /dashboard/events?days=90
1495
+ let EV=null; // /dashboard/events?days=RANGE — the replay ledger
1281
1496
  let M=null; // /dashboard/model (#422) — null on failure: label-only degrade, no configure pill
1282
1497
  let TOTAL=0, removedNow=0;
1283
1498
  /* the store's static LIVE count (headline.live_memories ?? total): the
@@ -1488,13 +1703,19 @@ function buildWorld(){
1488
1703
 
1489
1704
  // the flattened replay ledger (events within a night fire in cruise order);
1490
1705
  // added/merged reuse the staggered born/merge times so blooms and births
1491
- // coincide; enriched/linked carry their own salted staggers.
1706
+ // coincide; enriched/linked carry their own salted staggers. #452 adds the
1707
+ // truth-management captions — stagger order enriched .55 < retracted .65 <
1708
+ // superseded .70 < rewritten .75 < learned .80 keeps the cruise sane (each
1709
+ // new type gets its own salt, so per-id positions never collide).
1492
1710
  EVENTS = [];
1493
1711
  NIGHTS.forEach((n,i)=>{
1494
1712
  for(const id of n.added) EVENTS.push({t:bornOf.get(id), type:'learned', id, cap:'Learned — “'+trunc(titleOf(id),52)+'”'});
1495
1713
  for(const id of n.enriched) EVENTS.push({t:i+.55+stag(id,19)*.08, type:'enriched', id, cap:'Enriched — “'+trunc(titleOf(id),52)+'”'});
1496
1714
  for(const mg of n.merged) EVENTS.push({t:mergeOf.get(mg.loser).t, type:'merged', loser:mg.loser, id:mg.loser, cap:'Merged — “'+trunc(titleOf(mg.loser),40)+'”'});
1497
1715
  for(const lk of n.linked) EVENTS.push({t:i+.90+stag(lk.a+lk.b,23)*.08, type:'linked', a:lk.a, b:lk.b, cap:null});
1716
+ for(const sp of n.superseded) EVENTS.push({t:i+.70+stag(sp.old,29)*.08, type:'superseded', id:sp.old, cap:'Superseded — “'+trunc(titleOf(sp.old),52)+'”'});
1717
+ for(const rt of n.retracted) EVENTS.push({t:i+.65+stag(rt.old,31)*.08, type:'retracted', id:rt.old, cap:'Retracted — “'+trunc(titleOf(rt.old),52)+'”'});
1718
+ for(const id of n.rewritten) EVENTS.push({t:i+.75+stag(id,37)*.08, type:'rewritten', id, cap:'Rewritten — “'+trunc(titleOf(id),52)+'”'});
1498
1719
  });
1499
1720
  EVENTS.sort((x,y)=>x.t-y.t);
1500
1721
 
@@ -2338,14 +2559,21 @@ function toast(msg, ms){
2338
2559
  function wake(){ lastActivity=performance.now(); }
2339
2560
 
2340
2561
  /* ---------- replay — time travel: drag to take the helm, else the G1
2341
- auto-cruise walks the REAL event ledger (one memory action at a time,
2342
- looping; still NO play button — cruising is the ambient default).
2562
+ auto-cruise walks the REAL event ledger (one memory action at a time)
2563
+ ONCE to today, then rests there (#452, owner ruling 2026-09-16 —
2564
+ supersedes the 2026-09-13 auto-loop). Pause/resume + "replay again" sit
2565
+ in the foot beside "return to now"; a manual take still pauses the
2566
+ cruise and re-engages ~12 s after idle, continuing toward today — where
2567
+ it now stops instead of looping.
2343
2568
  With fewer than 2 nights the instrument renders its "now" state and
2344
2569
  disables itself (degrade quietly). ---------- */
2345
2570
  const track=el('rp-track');
2346
2571
  function trackFrac(e){ const r=track.getBoundingClientRect(); return clamp((e.clientX-r.left)/r.width,0,1); }
2347
2572
  let autoPlay=false, autoEvIdx=0, autoNextAt=0, manualUntil=3600, pendingApply=null;
2573
+ let paused=false; /* #452: pause holds the cruise position; resume continues */
2574
+ let played=false; /* #452: a full playback completed → rest at today, never loop */
2348
2575
  function manualTake(){ autoPlay=false; pendingApply=null; manualUntil=performance.now()+12000; wake(); }
2576
+ function setPaused(v){ paused=v; el('rp-pause').textContent=paused?'resume':'pause'; }
2349
2577
  /* HERALD — before each cruise step's change, a bright pulse flies in along
2350
2578
  a REAL link touching the target (else from its nearest same-domain
2351
2579
  neighbor) and LANDS: the update we see is the arrival */
@@ -2369,18 +2597,25 @@ function heraldTo(id){
2369
2597
  signals.push({ path:p, i:0, s:0, dur:.45, tail:[], showcase:true, herald:true }); /* dur is seconds */
2370
2598
  }
2371
2599
  function autoTick(now){
2372
- if(REDUCED||booting||!REPLAY_OK) return;
2600
+ if(REDUCED||booting||!REPLAY_OK||paused) return;
2373
2601
  if(!autoPlay){ if(now>manualUntil && !scrubbing) autoPlay=true; else return; }
2374
2602
  if(pendingApply){
2375
2603
  if(now>=pendingApply.at){ seek(pendingApply.t); pendingApply=null; autoNextAt=now+820; }
2376
2604
  return; /* the herald is in flight */
2377
2605
  }
2378
2606
  if(now<autoNextAt) return;
2379
- if(T>=NOW_T-.001){ /* a beat at "now", then loop from the start */
2607
+ if(T>=NOW_T-.001){
2608
+ /* a beat at "now": re-kick to the range start ONLY before the first
2609
+ full playback; after it, REST here — plays once, never loops (#452) */
2610
+ if(played){ autoPlay=false; autoNextAt=now+3200; return; }
2380
2611
  autoEvIdx=0; autoNextAt=now+3200; seek(.001); return;
2381
2612
  }
2382
2613
  while(autoEvIdx<EVENTS.length && EVENTS[autoEvIdx].t<=T+.0001) autoEvIdx++;
2383
- if(autoEvIdx>=EVENTS.length){ autoNextAt=now+3200; seek(WINDOW); return; }
2614
+ if(autoEvIdx>=EVENTS.length){
2615
+ /* ledger exhausted: arrive at today and complete the playback (the
2616
+ flags go BEFORE the seek so onTChanged's control swap sees them) */
2617
+ autoNextAt=now+3200; played=true; autoPlay=false; seek(WINDOW); return;
2618
+ }
2384
2619
  const ev=EVENTS[autoEvIdx++];
2385
2620
  if(ev.id && signals.length<8){ heraldTo(ev.id); pendingApply={t:ev.t+.001, at:now+430}; }
2386
2621
  else { seek(ev.t+.001); autoNextAt=now+820; }
@@ -2401,7 +2636,10 @@ function onTChanged(fromSeek){
2401
2636
  const b=mems.get(ev.id);
2402
2637
  if(!b||!aliveAt(b,ev.t)||b._sx===undefined) continue;
2403
2638
  if(blooms.length>=6) break;
2404
- const rgb= ev.type==='merged'? PAL.stages.fading.rgb : (ev.type==='learned'? PAL.stages.forming.rgb : PAL.stages.truth.rgb);
2639
+ /* #452: superseded/retracted bloom in the FADING color (demoted
2640
+ claims); rewritten falls to the default truth color (a rewrite is
2641
+ the claim surviving, corrected) */
2642
+ const rgb= ev.type==='merged'||ev.type==='superseded'||ev.type==='retracted'? PAL.stages.fading.rgb : (ev.type==='learned'? PAL.stages.forming.rgb : PAL.stages.truth.rgb);
2405
2643
  blooms.push({x:b._sx,y:b._sy,r0:2,r1:24+b._sr,t0:performance.now(),dur:1400,cap:ev.cap,rgb});
2406
2644
  }
2407
2645
  }
@@ -2410,6 +2648,18 @@ function onTChanged(fromSeek){
2410
2648
  el('rp-cursor').style.left=(T/WINDOW*100)+'%';
2411
2649
  const isNow=T>=NOW_T-.001;
2412
2650
  el('rp-now').classList.toggle('show',!isNow&&REPLAY_OK);
2651
+ /* #452 date label above the scrub dot — same left % as the cursor, the
2652
+ night being viewed ("now" at the end); hidden when there is no ledger */
2653
+ const dlab=el('rp-date');
2654
+ if(REPLAY_OK&&NIGHTS.length){
2655
+ dlab.style.left=(T/WINDOW*100)+'%';
2656
+ dlab.textContent=isNow?'now':fmtDay(NIGHTS[Math.min(Math.floor(T),NIGHTS.length-1)].date);
2657
+ dlab.classList.add('show');
2658
+ }else dlab.classList.remove('show');
2659
+ /* #452 lifecycle pills: pause while a playback is live, "replay again"
2660
+ once it completed — at most ONE of them beside "return to now" */
2661
+ el('rp-pause').classList.toggle('show',REPLAY_OK&&!played);
2662
+ el('rp-again').classList.toggle('show',REPLAY_OK&&played);
2413
2663
  updateMeasures();
2414
2664
  /* #422 owner ruling: the overview counter is STATIC (the store's live
2415
2665
  count, set once at boot + after writes) — scrubbing the replay no longer
@@ -2417,10 +2667,13 @@ function onTChanged(fromSeek){
2417
2667
  dirty=true;
2418
2668
  }
2419
2669
  /* warp-to-now — accelerates through every remaining event, blooms firing,
2420
- and arrives ~2.4 s later (ease in-out) */
2670
+ and arrives ~2.4 s later (ease in-out). #452: arriving at now by hand
2671
+ completes the playback too — the cruise rests instead of looping after */
2421
2672
  let warpUntil=0, warpFrom=0;
2422
2673
  function warpToNow(){
2423
2674
  manualTake();
2675
+ setPaused(false);
2676
+ played=true;
2424
2677
  if(REDUCED||T>=NOW_T-.001||!REPLAY_OK){ seek(WINDOW); return; }
2425
2678
  warpFrom=T; warpUntil=performance.now()+2400;
2426
2679
  }
@@ -2433,6 +2686,72 @@ function warpTick(now){
2433
2686
  }
2434
2687
  el('rp-now').onclick=warpToNow;
2435
2688
 
2689
+ /* #452 — pause/resume + replay again. Pause holds the cruise position
2690
+ (autoTick returns early, pending heralds included); resume continues
2691
+ toward today. Replay again restarts from the range start and plays once.
2692
+ Visibility is onTChanged's job: pause renders only while a playback is
2693
+ live, "replay again" only once one completed — least clutter. */
2694
+ el('rp-pause').onclick=()=>{
2695
+ setPaused(!paused);
2696
+ if(!paused) autoPlay=true; /* resume continues the cruise immediately */
2697
+ wake();
2698
+ };
2699
+ el('rp-again').onclick=()=>{
2700
+ manualTake(); setPaused(false);
2701
+ played=false; autoEvIdx=0; pendingApply=null; autoPlay=true;
2702
+ seek(.001); wake();
2703
+ };
2704
+
2705
+ /* #452 — the range selector: 7/30/90/180 days (the owner-fixed option
2706
+ set), default 30, NO persistence — every load starts at 30 (loads fast).
2707
+ A click refetches BOTH range-scoped sources and rebuilds the world
2708
+ exactly like boot — the agents rail included (its "last night" line
2709
+ reads NIGHTS, so it must follow the new window); the replay then
2710
+ auto-starts on the new range via the idle re-engage (mirrors boot).
2711
+ Fail-soft: a failed fetch toasts, keeps the OLD data and the OLD pills
2712
+ — never a half-switched view. One switch at a time (refreshAfterWrite's
2713
+ refreshInFlight pattern): a click while a switch is in flight is
2714
+ IGNORED — a racing second fetch could resolve out of order and leave
2715
+ pills and data disagreeing. */
2716
+ const RANGE_OPTIONS=[7,30,90,180];
2717
+ function renderRangePills(){
2718
+ const host=el('rp-range'); host.innerHTML='';
2719
+ for(const n of RANGE_OPTIONS){
2720
+ const b=document.createElement('button');
2721
+ b.type='button'; b.className='rp-seg'; b.textContent=n+'d';
2722
+ b.title='last '+n+' days';
2723
+ b.setAttribute('aria-pressed', String(n===RANGE));
2724
+ b.onclick=()=>{ if(n!==RANGE) switchRange(n); };
2725
+ host.appendChild(b);
2726
+ }
2727
+ }
2728
+ let rangeSwitchInFlight=false;
2729
+ async function switchRange(n){
2730
+ if(rangeSwitchInFlight) return; rangeSwitchInFlight=true;
2731
+ const prev=RANGE; RANGE=n; renderRangePills();
2732
+ try{
2733
+ const [dd,ee]=await Promise.all([
2734
+ api('/dashboard/data?range='+RANGE+'d'),
2735
+ api('/dashboard/events?days='+RANGE),
2736
+ ]);
2737
+ D=dd;
2738
+ if(ee&&Array.isArray(ee.nights)){ EV=ee; NIGHTS=EV.nights; } else { NIGHTS=[]; }
2739
+ }catch(e){
2740
+ RANGE=prev; renderRangePills();
2741
+ toast('Range switch failed: '+e.message);
2742
+ return;
2743
+ }finally{
2744
+ rangeSwitchInFlight=false;
2745
+ }
2746
+ buildWorld(); buildPaths(); buildTimeline(); renderBand(); buildRoll(); buildCapture(); buildAgents();
2747
+ /* reset the replay over the new window (boot's pattern): rest at "now"
2748
+ first, the idle re-engage then walks the new range once from the start */
2749
+ T=WINDOW; lastT=WINDOW; played=false; autoEvIdx=0; pendingApply=null; autoPlay=false;
2750
+ setPaused(false);
2751
+ onTChanged(true);
2752
+ }
2753
+ renderRangePills();
2754
+
2436
2755
  /* the timeline: dots per night (green when busy adding, grey when the
2437
2756
  night merged ≥3 — the prototype's thresholds), date labels at the ends */
2438
2757
  function buildTimeline(){
@@ -2445,6 +2764,7 @@ function buildTimeline(){
2445
2764
  dots.style.cssText='position:absolute;inset:0;pointer-events:none;';
2446
2765
  if(REPLAY_OK){
2447
2766
  label.textContent=NIGHTS.length+' nights';
2767
+ el('replay').classList.remove('off'); /* a range switch can revive the instrument (#452) */
2448
2768
  const s1=document.createElement('span'); s1.style.left='0%'; s1.style.transform='none';
2449
2769
  s1.textContent=fmtDay(NIGHTS[0].date); dates.appendChild(s1);
2450
2770
  const s2=document.createElement('span'); s2.style.left='100%'; s2.style.transform='translateX(-100%)';
@@ -2454,7 +2774,10 @@ function buildTimeline(){
2454
2774
  d.style.left=((i+.5)/WINDOW*100)+'%';
2455
2775
  if(mergedL[i]>=3) d.classList.add('w');
2456
2776
  else if(learnedL[i]>=8) d.classList.add('g');
2457
- d.title=fmtDay(n.date)+' — learned '+(learnedL[i]||0)+(enrichedL[i]?' · enriched '+enrichedL[i]:'')+(mergedL[i]?' · merged '+mergedL[i]:'');
2777
+ d.title=fmtDay(n.date)+' — learned '+(learnedL[i]||0)+(enrichedL[i]?' · enriched '+enrichedL[i]:'')+(mergedL[i]?' · merged '+mergedL[i]:'')
2778
+ +(n.superseded.length?' · superseded '+n.superseded.length:'')
2779
+ +(n.retracted.length?' · retracted '+n.retracted.length:'')
2780
+ +(n.rewritten.length?' · rewritten '+n.rewritten.length:'');
2458
2781
  frag.appendChild(d);
2459
2782
  });
2460
2783
  }else{
@@ -2904,26 +3227,49 @@ function renderRecBand(ups){
2904
3227
  }
2905
3228
 
2906
3229
  let MODEL_LABEL='';
3230
+ /* #409 fix round 5 — owner ruling 2026-09-14 (verbatim): "I do NOT want to
3231
+ know what it is compatible with, I want to know the fucking model." The
3232
+ card face shows THE MODEL, nothing else: hero = the namespace-stripped
3233
+ model name (fit-scaled, floor 20px), mono line = the raw config id,
3234
+ caption "detected from config", configure pill. NO protocol line, NO
3235
+ provider line — local and cloud endpoints render identically. (The
3236
+ configure modal keeps its technical fields: that is the operator surface.)
3237
+ The degrade fires ONLY when there is no model AT ALL — neither the config
3238
+ echo (M.model) nor the runtime label (/health/detail) carries one. A
3239
+ configured model with null provider/backend still renders: the daemon may
3240
+ legitimately run no LLM (resolveExplicitLlmConfig requires llmApiKey
3241
+ alongside llmBaseUrl; a keyless local endpoint is recall-only) while the
3242
+ config declares the model — the card keys on the MODEL, not the runtime. */
2907
3243
  function renderModelCard(){
2908
3244
  /* configure pill only when GET /dashboard/model succeeded (M truthy) —
2909
3245
  an old server / load error degrades to the phase-1 label-only card */
2910
3246
  el('model-cfg').hidden=!M;
2911
- if(!MODEL_LABEL||MODEL_LABEL==='not configured'){
3247
+ const hero=el('model-provider');
3248
+ hero.style.fontSize=''; // undo a previous hero fit (below)
3249
+ /* The config echo is the display truth (the RAW config id, never a
3250
+ provider-prefixed composition); the runtime label is only the fallback
3251
+ for pre-#422 servers without /dashboard/model. */
3252
+ const raw=(M&&M.model)||(MODEL_LABEL&&MODEL_LABEL!=='not configured'?MODEL_LABEL:'');
3253
+ if(!raw){
2912
3254
  /* #409 fix round — the no-LLM state says so ON THE FACE (a muted hint),
2913
3255
  not a bare dash: correct degraded state, self-explanatory rendering */
2914
- el('model-provider').textContent='no model configured';
2915
- el('model-provider').classList.add('noconf');
2916
- el('model-tail').textContent='';
3256
+ hero.textContent='no model configured';
3257
+ hero.classList.add('noconf');
2917
3258
  el('model-id').textContent='';
2918
3259
  return;
2919
3260
  }
2920
- el('model-provider').classList.remove('noconf');
2921
- const slash=MODEL_LABEL.indexOf('/');
2922
- const provider=slash>0?MODEL_LABEL.slice(0,slash):MODEL_LABEL;
2923
- const model=slash>0?MODEL_LABEL.slice(slash+1):'';
2924
- el('model-provider').textContent=provider;
2925
- el('model-tail').textContent=model?trunc(model,18):'';
2926
- el('model-id').textContent=MODEL_LABEL;
3261
+ hero.classList.remove('noconf');
3262
+ const ns=raw.lastIndexOf('/');
3263
+ hero.textContent=ns>=0?raw.slice(ns+1):raw; // the model name = hero
3264
+ /* Long ids are the norm (mlx-community/Qwen3.8-27B-4bit et al); the hero
3265
+ NEVER clips — it scales down to the slot (floor 20px) while the full id
3266
+ rides the mono line. One pass is exact: text width is linear in
3267
+ font-size. */
3268
+ if(hero.scrollWidth>hero.clientWidth){
3269
+ const fs=parseFloat(getComputedStyle(hero).fontSize);
3270
+ hero.style.fontSize=Math.max(20,Math.floor(fs*hero.clientWidth/hero.scrollWidth))+'px';
3271
+ }
3272
+ el('model-id').textContent=raw;
2927
3273
  }
2928
3274
 
2929
3275
  /* ---------- model settings modal (#422 phase 2) — the configure pill's
@@ -3059,7 +3405,14 @@ function buildRoll(){
3059
3405
 
3060
3406
  /* ---------- capture health — phase 2: per machine × harness rows from the
3061
3407
  /dashboard/data capture_health accounting; the counts-only rows remain
3062
- the degrade for old servers / nothing recorded yet ---------- */
3408
+ the degrade for old servers / nothing recorded yet.
3409
+ Fix round 7 (owner ruling 2026-09-14 — "the normal 30 day as the other
3410
+ cards"): the card's face is the ROLLING window (capture_health
3411
+ window_rows, summed over the last window_days calendar days); the newest
3412
+ night rides as a per-row secondary suffix ("tonight: N sess" / the
3413
+ night's date when it is not today). Degradation ladder: pre-round-7
3414
+ server (no window_rows) → the newest-night view below; no activity rows
3415
+ at all → the counts-only rows. ---------- */
3063
3416
  /* ---------- machine × harness grouping (#424 review fixes 4+5 + the owner's
3064
3417
  model-B direction): source_agent is "harness/profile" and each context may
3065
3418
  carry a source_machine stamp (migration v15+). The rows a stats card and
@@ -3171,6 +3524,72 @@ function buildCapture(){
3171
3524
  host.innerHTML='';
3172
3525
  const sub=el('cap-sub');
3173
3526
  const ch=D&&D.capture_health?D.capture_health:null;
3527
+ /* fix round 7: the rolling-window view — the card shows the normal 30 day
3528
+ like the band's other cards. window_rows is ABSENT on a pre-round-7
3529
+ server (and empty when nothing is recorded) → fall through to the
3530
+ newest-night view / counts degrade below. */
3531
+ if(ch&&Array.isArray(ch.window_rows)&&ch.window_rows.length){
3532
+ const wd=ch.window_days||30;
3533
+ if(sub) sub.textContent='last '+wd+' days';
3534
+ /* group the window's machine × agent rows into machine × harness
3535
+ bundles — same grouping as every other card surface */
3536
+ const groups=new Map();
3537
+ for(const r of ch.window_rows){
3538
+ const agent=String(r.agent||'unknown');
3539
+ const harness=agent.split('/')[0]||agent;
3540
+ const key=(r.machine||'')+'|'+harness;
3541
+ let g=groups.get(key);
3542
+ if(!g){ g={machine:r.machine||null, harness, posts:0, sessions:0, bytes:0, held:0, retried:0, agents:[]}; groups.set(key,g); }
3543
+ g.posts+=r.posts||0; g.sessions+=r.sessions||0; g.bytes+=r.bytes||0;
3544
+ g.held+=r.held||0; g.retried+=r.retried||0;
3545
+ g.agents.push(agent+' — '+r.posts+' post'+(r.posts===1?'':'s')+' · '+r.sessions+' sess · '+mbFmt(r.bytes||0)
3546
+ +(r.held?' · '+r.held+' held':'')+(r.retried?' · '+r.retried+' retried':''));
3547
+ }
3548
+ /* newest-night secondary: per-bundle sessions + held off capture_health
3549
+ rows (the newest day with rows). held keys the badge — a held post is
3550
+ actionable NOW (the client retries it next run); the window's own
3551
+ held/retried live in the row tooltip. */
3552
+ const night=new Map();
3553
+ if(ch.day&&Array.isArray(ch.rows)) for(const r of ch.rows){
3554
+ const agent=String(r.agent||'unknown');
3555
+ const harness=agent.split('/')[0]||agent;
3556
+ const key=(r.machine||'')+'|'+harness;
3557
+ const prev=night.get(key)||{sessions:0,held:0};
3558
+ prev.sessions+=r.sessions||0; prev.held+=r.held||0;
3559
+ night.set(key,prev);
3560
+ }
3561
+ const today=new Date().toISOString().slice(0,10);
3562
+ const nightLabel=ch.day===today?'tonight':fmtDay(ch.day);
3563
+ const gs2=[...groups.values()].sort((a,b)=>b.bytes-a.bytes);
3564
+ const maxB=gs2[0].bytes;
3565
+ const pausedKeys=fleetPausesByKey();
3566
+ for(const g of gs2){
3567
+ const n=night.get((g.machine||'')+'|'+g.harness);
3568
+ const row=document.createElement('div');
3569
+ row.className='cap-row2';
3570
+ const label=g.machine?g.machine+' · '+g.harness:g.harness;
3571
+ const paused=pausedKeys.has((g.machine||'')+'|'+g.harness);
3572
+ row.title=label+' — last '+wd+' days: '+fmt(g.sessions)+' sessions · '+mbFmt(g.bytes)+' posted'
3573
+ +(g.held>0?' · '+g.held+' held in window':'')
3574
+ +(g.retried>0?' · '+g.retried+' retried in window':'')
3575
+ +(n?' · '+nightLabel+': '+n.sessions+' sess'+(n.held>0?' ('+n.held+' held)':''):'')
3576
+ +(paused?' · CAPTURE PAUSED (posts are skipped — sessions are not backfilled)':'')
3577
+ +':\n'+g.agents.join('\n');
3578
+ row.innerHTML='<span class="cap-dot"></span>'+
3579
+ '<span class="hn" title="'+esc(label)+'">'+esc(label)+'</span>'+
3580
+ '<span class="cap-bar"><span class="fill" style="width:'+(g.bytes/Math.max(maxB,1)*100)+'%"></span></span>'+
3581
+ '<span class="st"><b>'+fmt(g.sessions)+'</b> sess · '+mbFmt(g.bytes)
3582
+ +(paused?'<span class="cap-paused" title="capture paused — posts are skipped (200) and sessions are not backfilled">PAUSED</span>':'')
3583
+ +(n&&n.held>0?'<span class="cap-held" title="'+nightLabel+' — failed posts the client will retry — cursor held">&#9888; '+n.held+' held</span>':'')
3584
+ +'</span>'
3585
+ /* the newest-night sub-line — a full-basis flex child, so it wraps
3586
+ UNDER the primary line (the suffix never fits beside it; see the
3587
+ .cap-ton rule) and only exists for bundles with newest-night rows */
3588
+ +(n?'<span class="cap-ton" title="newest capture night ('+nightLabel+') — '+n.sessions+' session'+(n.sessions===1?'':'s')+' posted that night">'+nightLabel+': '+n.sessions+' sess</span>':'');
3589
+ host.appendChild(row);
3590
+ }
3591
+ return;
3592
+ }
3174
3593
  if(ch&&Array.isArray(ch.rows)&&ch.rows.length){
3175
3594
  /* phase-2 live view: the /distill accounting rows, grouped machine ×
3176
3595
  harness — width encodes capture volume (each bundle's byte share of
@@ -3249,7 +3668,9 @@ function buildCapture(){
3249
3668
  function lastNightByAgent(){
3250
3669
  for(let i=NIGHTS.length-1;i>=0;i--){
3251
3670
  const n=NIGHTS[i];
3252
- if(n.added.length+n.enriched.length+n.merged.length+n.linked.length>0) return {date:n.date,by:n.by_agent};
3671
+ /* #452: the truth-management events count too — a superseded-only night
3672
+ is a real night */
3673
+ if(n.added.length+n.enriched.length+n.merged.length+n.linked.length+n.superseded.length+n.retracted.length+n.rewritten.length>0) return {date:n.date,by:n.by_agent};
3253
3674
  }
3254
3675
  return null;
3255
3676
  }
@@ -3487,7 +3908,7 @@ function buildAgents(){
3487
3908
  * counts — the paused state and dots re-derive from the fresh payload. */
3488
3909
  async function refreshFleet(){
3489
3910
  try{
3490
- const dd=await api('/dashboard/data?range=90d');
3911
+ const dd=await api('/dashboard/data?range='+RANGE+'d');
3491
3912
  D=dd;
3492
3913
  buildAgents();
3493
3914
  buildCapture();
@@ -3868,7 +4289,7 @@ let refreshInFlight=false;
3868
4289
  async function refreshAfterWrite(){
3869
4290
  if(refreshInFlight) return; refreshInFlight=true;
3870
4291
  try{
3871
- const [ff,dd]=await Promise.all([api('/dashboard/field'),api('/dashboard/data?range=90d').catch(()=>null)]);
4292
+ const [ff,dd]=await Promise.all([api('/dashboard/field'),api('/dashboard/data?range='+RANGE+'d').catch(()=>null)]);
3872
4293
  if(ff){ F=ff; buildWorld(); }
3873
4294
  if(dd){ D=dd; }
3874
4295
  renderBand(); buildRoll(); buildCapture(); renderLegend(); renderFeel();
@@ -4179,9 +4600,9 @@ async function pollHealth(){
4179
4600
  ===================================================================== */
4180
4601
  async function boot(){
4181
4602
  const [dd,ff,ee,hh,hd,mm]=await Promise.allSettled([
4182
- api('/dashboard/data?range=90d'),
4603
+ api('/dashboard/data?range='+RANGE+'d'),
4183
4604
  api('/dashboard/field'),
4184
- api('/dashboard/events?days=90'),
4605
+ api('/dashboard/events?days='+RANGE),
4185
4606
  fetch('/health'),
4186
4607
  api('/health/detail'),
4187
4608
  api('/dashboard/model'),
@@ -4194,6 +4615,12 @@ async function boot(){
4194
4615
  if(hd.status==='fulfilled'&&hd.value&&typeof hd.value.llm==='string') MODEL_LABEL=hd.value.llm;
4195
4616
  else if(hd.status==='fulfilled'&&hd.value&&hd.value.model) MODEL_LABEL=String(hd.value.model);
4196
4617
  if(hh.status==='fulfilled'&&hh.value&&hh.value.status) { /* health body shape varies; the poll handles the dot */ }
4618
+ /* entry splash (#409 fix round 6): BOTH core sources rejected = total
4619
+ failure — swap the splash line to the honest error (the dismissal holds
4620
+ it readable, then fades into this degraded page, whose own error
4621
+ surfaces — the toast above, the unreachable dot — carry the detail). */
4622
+ if(dd.status==='rejected' && ff.status==='rejected')
4623
+ entrySplashFail(ENTRY_FAIL_COPY);
4197
4624
 
4198
4625
  buildWorld(); // no-op when F failed — the empty field still renders
4199
4626
  buildPaths();
@@ -4219,13 +4646,21 @@ async function boot(){
4219
4646
  loadAccount();
4220
4647
  pollHealth();
4221
4648
 
4222
- const reveal=()=>{ booting=false; document.body.classList.remove('booting'); dirty=true; };
4649
+ const reveal=()=>{ booting=false; document.body.classList.remove('booting'); dirty=true; entrySplashDismiss(); };
4223
4650
  if(REDUCED||STILL) reveal();
4224
4651
  else setTimeout(reveal, 1400);
4225
4652
  setTimeout(()=>{ const h=el('hint'); if(h) h.style.opacity='0'; }, 9500);
4226
4653
  setTimeout(()=>{ if(!REDUCED&&!STILL){ spawnSignal(true); nextSpawnAt=performance.now()+4200; } }, (REDUCED||STILL)?0:2650);
4227
4654
  }
4228
- boot();
4655
+ boot().catch(err=>{
4656
+ /* a thrown boot must never leave the splash looping: honest line on the
4657
+ splash, the static shell visible after the readable hold, toast per the
4658
+ page's error pattern (the catch mirrors reveal minus the redraw). */
4659
+ try{ console.error('dashboard boot failed', err); }catch(e2){}
4660
+ booting=false; document.body.classList.remove('booting');
4661
+ toast('Boot failed — '+(err && err.message ? err.message : 'unknown error'));
4662
+ entrySplashFail(ENTRY_BOOTFAIL_COPY);
4663
+ });
4229
4664
  rafId=requestAnimationFrame(frame);
4230
4665
  resize();
4231
4666
  draw(performance.now(),16); /* one synchronous paint — the field exists before the first rAF */