eklavya 1.66.1 → 1.67.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.
@@ -163,6 +163,18 @@
163
163
  animation:loader-in var(--duration-base) var(--ease) 150ms both; }
164
164
  .loader--page { justify-content:center; flex-direction:column; min-height:40vh; gap:var(--space-5); }
165
165
  @keyframes loader-in { from { opacity:0; } to { opacity:1; } }
166
+ /* A fetched region (`slot()`) is in one of three states and none of them moves the page.
167
+ Drawn: its content. Waiting with nothing to show: a box that holds the height the content
168
+ is expected to take, the loader at its top (its own 150ms delay applies). Waiting while it
169
+ still shows an earlier answer (a pager, a filter, a refetch, and every live update): that
170
+ content stays, out of reach of the pointer at once, and dims only if the answer has not come
171
+ within 150ms, the loader's own delay. The page is refreshed whenever something changes, and
172
+ an answer that comes from the server's memo in a few milliseconds would otherwise make every
173
+ refresh flash. The dim is a step, not a fade (this work adds no animation): a transition of no duration that
174
+ starts after the same delay, so an answer that lands first cancels it before anything shows. */
175
+ .slot__wait { display:block; }
176
+ [data-slot][aria-busy="true"][data-kept] { opacity:.55; pointer-events:none;
177
+ transition:opacity 0s linear 150ms; }
166
178
 
167
179
  /* ---------- the sidebar navigation ---------- */
168
180
  nav.side__nav { display:flex; flex-direction:column; gap:var(--space-4); }
@@ -233,7 +245,7 @@
233
245
  /* ---------- the column ---------- */
234
246
  main { padding:var(--space-8) var(--space-10) var(--space-20); min-width:0; }
235
247
  .inner { max-width:1080px; }
236
- /* Centred on a wide window; `main >` outranks the margins `.page__eyebrow` and `.stale` set. */
248
+ /* Centred on a wide window; `main >` outranks the margin `.page__eyebrow` sets. */
237
249
  main > .inner { margin-inline:auto; }
238
250
  .stack > * + * { margin-top:var(--space-6); }
239
251
 
@@ -265,7 +277,9 @@
265
277
  .art__open { display:flex; flex-direction:column; gap:var(--space-3); color:var(--ink); text-decoration:none; }
266
278
  .art__open:focus-visible { outline:2px solid var(--spot); outline-offset:2px; }
267
279
  .art__open:hover .title { color:var(--spot); }
268
- .art__thumb { display:block; aspect-ratio:16/10; background:var(--panel); border:1px solid var(--line-2); padding:var(--space-3); }
280
+ /* The box is the 16:10 the aspect ratio says, whether or not its image has arrived: without `min-height:0`
281
+ the picture's own height pushes the card taller when it lands (10px at 1280), and the page under it moves. */
282
+ .art__thumb { display:block; aspect-ratio:16/10; min-height:0; background:var(--panel); border:1px solid var(--line-2); padding:var(--space-3); }
269
283
  .art__open:hover .art__thumb { border-color:var(--spot); }
270
284
  .art__thumb img { display:block; width:100%; height:100%; object-fit:contain; }
271
285
  .tag { font-family:var(--font-mono); font-size:11px; letter-spacing:var(--tracking-caps); text-transform:uppercase; }
@@ -275,9 +289,14 @@
275
289
  /* The explainer viewer: the agent's page, framed sandboxed, then this page's
276
290
  own correction bar. The server adds a script to every framed page that
277
291
  reports its height; the frame takes it, so the window is the one scrollbar.
278
- Until the first report the frame is 0 tall behind a loader; with none in
279
- 1.5s (script blocked) it falls back to 80vh and scrolls inside. */
280
- .viewer { display:block; width:100%; height:0; border:0; background:var(--bg); }
292
+ Until the first report the frame holds the height this tab last reported (else
293
+ 80vh) with the loader over its top, so opening a page reflows once, when the
294
+ real height arrives; with no report in 1.5s (script blocked) it falls back to
295
+ 80vh and scrolls inside. `--wait-h` is that reserved height: the frame's own
296
+ `height` is set only by a report, which is how the page tells the two apart. */
297
+ .viewer { display:block; width:100%; height:var(--wait-h, 0); border:0; background:var(--bg); }
298
+ .viewer__box { position:relative; }
299
+ .viewer__box > #art-wait { position:absolute; top:0; left:0; right:0; z-index:1; pointer-events:none; }
281
300
  /* Artifact tabs: the gallery, then up to TAB_MAX open pages. Pinned to the
282
301
  top of the window (under the top bar on a phone) so switching never needs
283
302
  a scroll up. One row; it scrolls sideways rather than wrapping. */
@@ -404,21 +423,13 @@
404
423
  .ctx { color:var(--dim); }
405
424
  .empty { color:var(--faint); padding:var(--space-5) 0; font-size:13.5px; }
406
425
 
407
- /* A bar above the view, not a banner over it: the reader is mid-page and the
408
- notice must not move what they are reading. */
409
- .stale { display:block; width:100%; text-align:left; margin-bottom:var(--space-6);
410
- font-family:var(--font-mono); font-size:11.5px; letter-spacing:.04em;
411
- text-transform:uppercase; background:none; border:1px solid var(--spot);
412
- color:var(--spot); padding:7px 10px; cursor:pointer;
413
- transition:background var(--duration-fast) var(--ease), color var(--duration-fast) var(--ease); }
414
- .stale:hover { background:var(--spot); color:var(--spot-ink); }
415
- .stale[hidden] { display:none; }
416
-
417
426
  /* ---------- controls ---------- */
418
427
  .filters { display:flex; gap:var(--space-3); flex-wrap:wrap; align-items:center;
419
428
  margin-bottom:var(--space-4); }
420
429
  .filters .grow { flex:1 1 220px; min-width:180px; }
421
430
  .filters select { width:auto; }
431
+ /* The controls beside a search box are a region of their own (a redraw replaces them, never the box the reader is typing in); it adds no box. */
432
+ .filters > [data-region] { display:contents; }
422
433
  .chips { display:flex; gap:var(--space-2); flex-wrap:wrap; }
423
434
  .chip { font-family:var(--font-mono); font-size:11.5px; letter-spacing:.04em; text-transform:uppercase;
424
435
  background:none; border:1px solid var(--line-2); color:var(--faint); padding:5px 10px;
@@ -829,6 +840,8 @@
829
840
  .sw__scope[aria-current="page"] svg { color:var(--spot); }
830
841
  .sw__none { margin:var(--space-1) var(--space-2) var(--space-2); color:var(--faint); font-size:12.5px; }
831
842
  .sw__pane { min-width:0; display:flex; flex-direction:column; gap:var(--space-5); }
843
+ /* The pane's three regions add no box: their contents are the pane's flex items, as they were before there were regions. */
844
+ .sw__pane > [data-region] { display:contents; }
832
845
  .sw__tabs { display:flex; gap:0; border-bottom:1px solid var(--line-2); overflow-x:auto; scrollbar-width:none; }
833
846
  .sw__tabs::-webkit-scrollbar { display:none; }
834
847
  .sw__tab { display:flex; align-items:center; gap:6px; padding:10px 9px; margin-bottom:-1px; white-space:nowrap;
@@ -857,7 +870,8 @@
857
870
  .sw__scopes::-webkit-scrollbar { display:none; }
858
871
  .sw__cap { display:none; }
859
872
  .sw__projects { flex-direction:row; max-height:none; overflow:visible; }
860
- .sw__scope { white-space:nowrap; }
873
+ /* A row that scrolls sideways lets each link keep its width: a shrinking one clipped its label to nothing. */
874
+ .sw__scope { white-space:nowrap; flex:none; }
861
875
  .sw__none { margin:0; white-space:nowrap; }
862
876
  }
863
877
  @media (max-width:560px) {
@@ -894,6 +908,8 @@
894
908
  @media (prefers-reduced-motion:reduce) {
895
909
  html { scroll-behavior:auto; }
896
910
  * { transition:none !important; animation:none !important; }
911
+ /* The dim of a region waiting for its next answer is a step already; the rule above would take its delay too. */
912
+ [data-slot][aria-busy="true"][data-kept] { transition:opacity 0s linear 150ms !important; }
897
913
  }
898
914
  </style>
899
915
  </head>
@@ -968,9 +984,6 @@
968
984
  </aside>
969
985
 
970
986
  <main id="main">
971
- <button type="button" class="stale inner" id="stale" hidden aria-live="polite">
972
- New activity since this page loaded — refresh
973
- </button>
974
987
  <p class="page__eyebrow crumb inner" id="crumb" aria-label="Workflow and scope"></p>
975
988
  <div class="inner stack" id="view" aria-busy="true">
976
989
  <!-- The same markup `loader()` writes; the script has not run yet. -->
@@ -1023,7 +1036,13 @@ const fromKey = (k) => new Date(k + 'T00:00:00Z');
1023
1036
  const localKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
1024
1037
  const todayKey = () => localKey(new Date());
1025
1038
 
1026
- const fmtDay = (k) => fromKey(k).toLocaleDateString(undefined, { month:'short', day:'numeric', timeZone:'UTC' });
1039
+ /* One formatter per shape, made once. `toLocaleDateString(undefined, options)` builds a new Intl formatter on
1040
+ every call, and the heatmap's 371 labels alone were about 20 ms of drawing the Learning dashboard; the same
1041
+ options give the same text from `format()`. UTC, because a day key is a calendar date, not an instant. */
1042
+ const FMT_DAY = new Intl.DateTimeFormat(undefined, { month:'short', day:'numeric', timeZone:'UTC' });
1043
+ const FMT_DAY_YEAR = new Intl.DateTimeFormat(undefined, { month:'short', day:'numeric', year:'numeric', timeZone:'UTC' });
1044
+ const FMT_MONTH = new Intl.DateTimeFormat(undefined, { month:'short', timeZone:'UTC' });
1045
+ const fmtDay = (k) => FMT_DAY.format(fromKey(k));
1027
1046
  const fmtDate = (ts) => {
1028
1047
  const d = parse(ts); if (!d) return '—';
1029
1048
  const sameYear = d.getFullYear() === new Date().getFullYear();
@@ -1061,6 +1080,21 @@ let SCOPE = null; // Set of slugs touched in scope, or null for all
1061
1080
  const AFTER = []; // render hooks that need a laid-out DOM (charts)
1062
1081
  /** The screen being shown, as the URL says it: `{ wf, page, param, query }`. */
1063
1082
  let CUR = { wf:'learning', page:'dashboard', param:'', query:new URLSearchParams() };
1083
+ /**
1084
+ * What the view was last drawn from, so the next draw can tell a navigation from
1085
+ * an adjustment and, for an adjustment, redraw only what changed. `null` until the
1086
+ * first draw. See `render()`.
1087
+ */
1088
+ let DRAWN = null;
1089
+ /**
1090
+ * What the draw in progress is, for the few views and hooks whose work depends on it (both are set by `render()`
1091
+ * and `liveAdopt()` and are only ever true for the length of one synchronous draw). `ADJUSTING`: the screen on show is
1092
+ * the one being drawn again (see `render`), so what belongs to the screen (the viewer's frame, a remembered scroll) is
1093
+ * still there and is left alone. `LIVE_PASS`: the draw is the page's redraw for new data (`liveAdopt`), so it also
1094
+ * keeps what the reader is in the middle of (a control being edited, the heatmap's page) and not only what they had open.
1095
+ */
1096
+ let ADJUSTING = false;
1097
+ let LIVE_PASS = false;
1064
1098
 
1065
1099
  /* ---------- per-view control state, kept across re-renders ---------- */
1066
1100
  const T = {
@@ -1098,31 +1132,89 @@ const inScope = (raw, sid) => PROJECT === null || pid(raw, sid) === PROJECT;
1098
1132
  const projectById = (id) => INV.projects.find((p) => p.id === id) ?? null;
1099
1133
  const pname = (raw, sid) => { const id = pid(raw, sid); return projectById(id)?.name ?? base(id); };
1100
1134
 
1135
+ /**
1136
+ * The one place the page's data is swapped: boot, and the arrival of new data (`liveAdopt`, when the server's
1137
+ * stream says the cursor moved). It sets `S` and `INV`, recomputes the scope (which drops every derived list, below),
1138
+ * and drops what was read under the old data (`invalidateData`: the settings read and the response cache). Nothing
1139
+ * else assigns `S` or `INV` after boot, which is what lets the derived lists be kept. What boot also writes from them
1140
+ * outside the view (`paintChrome`) and redrawing the screen are the caller's.
1141
+ */
1142
+ function adoptState(s, inv) {
1143
+ S = s;
1144
+ INV = inv;
1145
+ recomputeScope();
1146
+ invalidateData();
1147
+ }
1148
+
1149
+ /**
1150
+ * Called wherever `S`, `INV` or `PROJECT` has just been assigned: `adoptState`, and a change of project.
1151
+ *
1152
+ * A project's concepts are the inventory's own list for it (`learning.concept_slugs`: every concept an
1153
+ * answer or a logged line of any origin names for the project, read by the server from every row and
1154
+ * attributed as `pid()` does). They are not read from `S.attempts` and `S.logged`, which hold only the
1155
+ * newest rows (`attempts_shown`, `logged_shown`): a concept whose only rows are old would drop out of
1156
+ * its project, and Concepts, Review, Domains and the sidebar's counts would all lose it.
1157
+ */
1101
1158
  function recomputeScope() {
1102
- if (PROJECT === null) { SCOPE = null; return; }
1103
- SCOPE = new Set();
1104
- for (const a of S.attempts) if (inScope(a.repo, a.session_id)) SCOPE.add(a.slug);
1105
- for (const l of S.logged) if (inScope(l.repo, l.session_id)) SCOPE.add(l.slug);
1106
- }
1107
- const conceptsInScope = () => (SCOPE ? S.concepts.filter((c) => SCOPE.has(c.slug)) : S.concepts);
1108
- const seenConcepts = () => conceptsInScope().filter((c) => c.seen);
1109
- const attemptsInScope = () => S.attempts.filter((a) => inScope(a.repo, a.session_id));
1110
- const loggedInScope = () => S.logged.filter((l) => inScope(l.repo, l.session_id));
1159
+ dropDerived();
1160
+ SCOPE = PROJECT === null ? null : new Set(projectById(PROJECT)?.learning.concept_slugs ?? []);
1161
+ }
1162
+
1163
+ /**
1164
+ * Whether the payload holds fewer rows than there are. `attempts` and `logged` are cut at the newest
1165
+ * `ATTEMPT_LIMIT` and `LOGGED_LIMIT` rows (the server says how many it sent and how many there are), and
1166
+ * the footer says so (`paintChrome`). Everything the page derives from a *list* of rows is then a reading of
1167
+ * the newest ones: the sessions, a concept's question history, the context lines, the skipped answers.
1168
+ * Totals, the per-day rows, the catalogue and the inventory are aggregated over every row and are not.
1169
+ */
1170
+ const cutAnswers = () => S.attempts_shown < S.attempts_total;
1171
+ const cutLines = () => S.logged_shown < S.logged_total;
1172
+ const rowsCut = () => cutAnswers() || cutLines();
1173
+
1174
+ /* ---------- derived lists, kept ----------
1175
+ What the views read of the payload (the concepts in scope, the sessions, the daily buckets, the streak, the
1176
+ heatmap's days, the sidebar's counts) is a function of `S`, `INV`, `PROJECT` and the local day, which the streak
1177
+ and the heatmap are read against, and of nothing else. So each is built once and kept: a navigation, a pager, a
1178
+ keystroke and a resize read it again instead of rebuilding it from every row.
1179
+
1180
+ They are dropped in one place, `dropDerived()`, from `recomputeScope()`, which runs whenever `S`/`INV` are swapped
1181
+ (`adoptState`) and whenever the project changes: the only places any of the three is assigned. The day is
1182
+ checked on every read, so a page left open past midnight reads the new day's streak, not yesterday's.
1183
+
1184
+ The lists are frozen. A caller that sorted or pushed onto one would change what every other view reads, so it
1185
+ throws instead: copy first (`[...list].sort(...)`). The rows inside them are the payload's own and are never
1186
+ written to. The one Map (`dailyInScope`) and the sets in a streak are read-only by the same rule. */
1187
+ let DERIVED = new Map();
1188
+ let DERIVED_DAY = '';
1189
+ function derived(name, build) {
1190
+ const day = todayKey();
1191
+ if (day !== DERIVED_DAY) { DERIVED = new Map(); DERIVED_DAY = day; }
1192
+ if (!DERIVED.has(name)) DERIVED.set(name, build());
1193
+ return DERIVED.get(name);
1194
+ }
1195
+ const dropDerived = () => { DERIVED = new Map(); };
1196
+
1197
+ const conceptsInScope = () => derived('concepts', () => Object.freeze(SCOPE ? S.concepts.filter((c) => SCOPE.has(c.slug)) : [...S.concepts]));
1198
+ const seenConcepts = () => derived('seen', () => Object.freeze(conceptsInScope().filter((c) => c.seen)));
1199
+ const attemptsInScope = () => derived('attempts', () => Object.freeze(S.attempts.filter((a) => inScope(a.repo, a.session_id))));
1200
+ const loggedInScope = () => derived('logged', () => Object.freeze(S.logged.filter((l) => inScope(l.repo, l.session_id))));
1111
1201
  const scopeName = () => (PROJECT === null ? 'all projects' : projectById(PROJECT)?.name ?? PROJECT);
1112
1202
  /** Inventory rows in scope — one, or all of them. */
1113
1203
  const projectsInScope = () => INV.projects.filter((p) => PROJECT === null || p.id === PROJECT);
1114
1204
 
1115
- /** One row per calendar day in scope: { day, passed, corrected, missed, skipped, total }. */
1205
+ /** One row per calendar day in scope: { day, passed, corrected, missed, skipped, total }. Read-only. */
1116
1206
  function dailyInScope() {
1117
- const m = new Map();
1118
- for (const d of S.daily) {
1119
- if (!inScope(d.repo)) continue;
1120
- const cur = m.get(d.day) ?? { day:d.day, passed:0, corrected:0, missed:0, skipped:0, total:0 };
1121
- cur.passed += d.passed; cur.corrected += d.corrected ?? 0; cur.missed += d.missed; cur.skipped += d.skipped;
1122
- cur.total = cur.passed + cur.corrected + cur.missed + cur.skipped;
1123
- m.set(d.day, cur);
1124
- }
1125
- return m;
1207
+ return derived('daily', () => {
1208
+ const m = new Map();
1209
+ for (const d of S.daily) {
1210
+ if (!inScope(d.repo)) continue;
1211
+ const cur = m.get(d.day) ?? { day:d.day, passed:0, corrected:0, missed:0, skipped:0, total:0 };
1212
+ cur.passed += d.passed; cur.corrected += d.corrected ?? 0; cur.missed += d.missed; cur.skipped += d.skipped;
1213
+ cur.total = cur.passed + cur.corrected + cur.missed + cur.skipped;
1214
+ m.set(d.day, cur);
1215
+ }
1216
+ return m;
1217
+ });
1126
1218
  }
1127
1219
 
1128
1220
  /** Fills every day in a window, including the empty ones — the gaps are data. */
@@ -1138,8 +1230,15 @@ function fillDays(map, n, endKey) {
1138
1230
  return out;
1139
1231
  }
1140
1232
 
1141
- /** Consecutive days with at least one answer, counting back from today. */
1233
+ /** The 30 days ending today, in scope, empty ones included: the activity chart's days. */
1234
+ const lastThirty = () => derived('last30', () => Object.freeze(fillDays(dailyInScope(), 30)));
1235
+
1236
+ /**
1237
+ * Consecutive days with at least one answer, counting back from today. With no argument it is the streak in scope
1238
+ * (`dailyInScope`), which is kept; given a map of days it is a plain reading of that map.
1239
+ */
1142
1240
  function streaks(map) {
1241
+ if (!map) return derived('streaks', () => streaks(dailyInScope()));
1143
1242
  const active = new Set([...map.values()].filter((d) => d.total > 0).map((d) => d.day));
1144
1243
  const run = new Set();
1145
1244
  const cur = fromKey(todayKey());
@@ -1153,47 +1252,111 @@ function streaks(map) {
1153
1252
  best = Math.max(best, len);
1154
1253
  prev = k;
1155
1254
  }
1156
- return { current: run.size, best, days: active.size, run, today };
1255
+ return Object.freeze({ current: run.size, best, days: active.size, run, today });
1157
1256
  }
1158
1257
 
1159
- /** Sessions, rebuilt from the two row sets that mention a session id. */
1160
- function sessions() {
1258
+ /**
1259
+ * Sessions in scope, from the two row sets that mention a session id, newest first. When the payload's lists
1260
+ * are cut (`rowsCut`) these are the sessions of the newest rows: a session older than every row the payload holds
1261
+ * is not here, and the oldest one that is may hold only part of its rows. `sessionTotal` is how many there are,
1262
+ * and a session's own page reads it whole (`viewSession`).
1263
+ */
1264
+ const sessions = () => derived('sessions', () => Object.freeze(buildSessions()));
1265
+ /** Learning's sessions: the ones that logged a concept or asked a question. A memory-only one belongs to the other workflow. */
1266
+ const learningSessions = () => derived('learning-sessions', () => Object.freeze(sessions().filter((x) => x.logged.length || x.asked.length)));
1267
+
1268
+ /**
1269
+ * How many learning sessions there are in scope. While the payload holds every row, it is the list's length. Once
1270
+ * it is cut, the list is only the sessions of the newest rows, and the count is the server's, read from every row: the
1271
+ * inventory's distinct sessions with no project selected (a session that touched two checkouts is one session, so the
1272
+ * cards cannot be added up), the project's own card with one. The sidebar, the Sessions page, the dashboard's link and the
1273
+ * Projects page then say one number. Never fewer than the list: a session the rows name and the card attributes to another
1274
+ * project (a repository-less answer is attributed by the page's `pid()`) is still a session in the list.
1275
+ */
1276
+ const sessionTotal = () => derived('session-total', () => {
1277
+ const listed = learningSessions().length;
1278
+ if (!rowsCut()) return listed;
1279
+ return Math.max(listed, PROJECT === null ? INV.totals.sessions : projectsInScope().reduce((n, p) => n + p.learning.sessions, 0));
1280
+ });
1281
+
1282
+ /**
1283
+ * The counts the Learning pages state about the answers and the concepts in scope, read from the server's aggregates
1284
+ * over every row, never from `attemptsInScope()` and `loggedInScope()`, which are only the newest rows once a list is
1285
+ * cut: the project cards' sums (every answer is in exactly one card, so with no project selected they are the
1286
+ * database's totals, and a corrected miss counts as right as it does on the Accuracy tile), and for the concepts the
1287
+ * work recorded, the inventory's distinct counts, which two projects' cards could not be added up to.
1288
+ */
1289
+ const scopeTotals = () => derived('scope-totals', () => {
1290
+ const ps = projectsInScope();
1291
+ const sum = (k) => ps.reduce((n, p) => n + p.learning[k], 0);
1292
+ const all = PROJECT === null;
1293
+ return Object.freeze({
1294
+ answers: sum('answers'), passed: sum('passed'), skipped: sum('skipped'),
1295
+ logged: all ? INV.totals.logged_concepts : sum('logged_concepts'),
1296
+ loggedAssessed: all ? INV.totals.logged_assessed_concepts : sum('logged_assessed_concepts'),
1297
+ });
1298
+ });
1299
+
1300
+ /* Two timestamp shapes meet in a session: SQLite's `2026-09-05 21:04:00` from
1301
+ attempts, and the ISO string evidence carries. Compared as text, 'T' sorts
1302
+ after ' ', so the same instant from the two halves would order wrongly —
1303
+ flatten both to one shape before comparing. */
1304
+ const flatTs = (ts) => String(ts ?? '').replace('T', ' ').replace('Z', '');
1305
+ const newSession = (id) => ({ id, repo:null, logged:[], asked:[], mem:null, first:null, last:null });
1306
+ function touchSession(s, ts) {
1307
+ const t = flatTs(ts);
1308
+ if (!t) return;
1309
+ if (!s.first || t < s.first) s.first = t;
1310
+ if (!s.last || t > s.last) s.last = t;
1311
+ }
1312
+ const addLogged = (s, l) => { s.logged.push(l); s.repo = s.repo ?? l.repo; touchSession(s, l.ts); };
1313
+ const addAsked = (s, a) => { s.asked.push(a); s.repo = s.repo ?? a.repo; touchSession(s, a.ts); };
1314
+ const addMemory = (s, mem) => { s.mem = mem; s.repo = s.repo ?? mem.project; touchSession(s, mem.first); touchSession(s, mem.last); };
1315
+ /** A miss corrected from its explainer counts as right, as on the Accuracy tile. */
1316
+ const withPassed = (s) => ({ ...s, passed: s.asked.filter((a) => a.grade >= 3 || a.corrected_at).length });
1317
+
1318
+ function buildSessions() {
1161
1319
  const m = new Map();
1162
1320
  const get = (id) => {
1163
- if (!m.has(id)) m.set(id, { id, repo:null, logged:[], asked:[], mem:null, first:null, last:null });
1321
+ if (!m.has(id)) m.set(id, newSession(id));
1164
1322
  return m.get(id);
1165
1323
  };
1166
- /* Two timestamp shapes meet here: SQLite's `2026-09-05 21:04:00` from
1167
- attempts, and the ISO string evidence carries. Compared as text, 'T' sorts
1168
- after ' ', so the same instant from the two halves would order wrongly —
1169
- flatten both to one shape before comparing. */
1170
- const flat = (ts) => String(ts ?? '').replace('T', ' ').replace('Z', '');
1171
- const touch = (s, ts) => {
1172
- const t = flat(ts);
1173
- if (!t) return;
1174
- if (!s.first || t < s.first) s.first = t;
1175
- if (!s.last || t > s.last) s.last = t;
1176
- };
1177
- for (const l of loggedInScope()) { const s = get(l.session_id); s.logged.push(l); s.repo = s.repo ?? l.repo; touch(s, l.ts); }
1324
+ for (const l of loggedInScope()) addLogged(get(l.session_id), l);
1178
1325
  for (const a of attemptsInScope()) {
1179
1326
  if (!a.session_id) continue;
1180
- const s = get(a.session_id); s.asked.push(a); s.repo = s.repo ?? a.repo; touch(s, a.ts);
1327
+ addAsked(get(a.session_id), a);
1181
1328
  }
1182
1329
  // A session that captured evidence but logged no concept is still a session:
1183
1330
  // leaving it out would make the memory half invisible in the one place the
1184
1331
  // two halves are supposed to line up.
1185
1332
  for (const mem of S.memory_sessions ?? []) {
1186
1333
  if (!inScope(mem.project, mem.session_id)) continue;
1187
- const s = get(mem.session_id);
1188
- s.mem = mem; s.repo = s.repo ?? mem.project;
1189
- touch(s, mem.first); touch(s, mem.last);
1334
+ addMemory(get(mem.session_id), mem);
1190
1335
  }
1191
1336
  return [...m.values()]
1192
- // A miss corrected from its explainer counts as right, as on the Accuracy tile.
1193
- .map((s) => ({ ...s, passed: s.asked.filter((a) => a.grade >= 3 || a.corrected_at).length }))
1337
+ .map(withPassed)
1194
1338
  .sort((a, b) => String(b.last).localeCompare(String(a.last)));
1195
1339
  }
1196
1340
 
1341
+ /**
1342
+ * One session from what `/api/memory/sessions?session=` answers (`d`): its logged lines and its answers
1343
+ * whole, however old, in the payload's own row shapes, and its memory half. The same session `buildSessions`
1344
+ * makes, from the same helpers, drawn from rows the payload's cut did not leave out. The lines and answers
1345
+ * are not narrowed by the server to a project (which project a row is in is `pid()`'s to say), so they are narrowed
1346
+ * here as the payload's rows are. `null`: nothing of it is in scope. `cutAsked` says when the session has more
1347
+ * answers than the server sends for one session (`ATTEMPT_LIMIT`).
1348
+ */
1349
+ function sessionFromLookup(id, d) {
1350
+ const s = newSession(id);
1351
+ const L = d.learning;
1352
+ for (const l of L.logged) if (inScope(l.repo, l.session_id)) addLogged(s, l);
1353
+ for (const a of L.attempts) if (inScope(a.repo, a.session_id)) addAsked(s, a);
1354
+ const mem = d.rows[0] ?? null;
1355
+ if (mem) addMemory(s, mem);
1356
+ if (!s.logged.length && !s.asked.length && !mem) return null;
1357
+ return { ...withPassed(s), cutAsked: L.attempts_total > L.attempts.length ? { shown: L.attempts.length, total: L.attempts_total } : null };
1358
+ }
1359
+
1197
1360
  /**
1198
1361
  * What a session is called in a list. The context line the agent wrote is the
1199
1362
  * only human description there is; a session that logged nothing readable is
@@ -1247,6 +1410,19 @@ function card(title, hint, body, opts = {}) {
1247
1410
  return `<section class="card">${head}${body}</section>`;
1248
1411
  }
1249
1412
 
1413
+ /**
1414
+ * A part of a screen that an adjustment (a tab, a chip, a select, a pager) redraws
1415
+ * on its own. The element and everything around it keep their place in the page;
1416
+ * only its contents are replaced, wholesale, when what the view returns for it
1417
+ * changed. Name it for what it holds. Regions never nest. `html` is escaped by the
1418
+ * caller. See `drawAdjusted`.
1419
+ *
1420
+ * A list that is fetched is a region too: `slot()` writes `data-region` on its target (`region: true`).
1421
+ * Its contents are the answer when the page holds it, and otherwise the box that reserves its room,
1422
+ * which a redraw never puts over rows that are on screen: they stay, dimmed, until the new ones arrive.
1423
+ */
1424
+ const region = (id, html, attrs = '') => `<div data-region="${id}"${attrs ? ' ' + attrs : ''}>${html}</div>`;
1425
+
1250
1426
  /* ---------- progressive disclosure ---------- */
1251
1427
 
1252
1428
  /**
@@ -1268,10 +1444,13 @@ const openAttr = (id, dflt) => (isOpen(id, dflt) ? ' open' : '');
1268
1444
  * the numbers are counted folded under a "How this works" line. `title` and
1269
1445
  * every count are HTML the caller escaped.
1270
1446
  */
1271
- function pageHead(title, counts = [], about = '', back = '') {
1447
+ function pageHead(title, counts = [], about = '', back = '', inRegion = false) {
1272
1448
  const id = `about:${CUR.wf}:${CUR.page}`;
1273
- return `<div>${back}<h1 class="page__title">${title}</h1>
1274
- ${counts.length ? `<p class="counts">${counts.join(' · ')}</p>` : ''}
1449
+ // The counts are the part of a head that new data changes, so they are a region of their own: a count that moves
1450
+ // replaces the line, and not the screen around it (an open list, a typed date, a selection). Regions never nest,
1451
+ // so a head that is itself in one (`inRegion`: Settings') is drawn with the rest.
1452
+ return `<div class="page__head">${back}<h1 class="page__title">${title}</h1>
1453
+ ${counts.length ? `<p class="counts"${inRegion ? '' : ' data-region="head-counts"'}>${counts.join(' · ')}</p>` : ''}
1275
1454
  ${about ? `<details class="about" data-fold="${esc(id)}"${openAttr(id, false)}><summary>How this works</summary><p>${about}</p></details>` : ''}</div>`;
1276
1455
  }
1277
1456
 
@@ -1326,12 +1505,12 @@ function startBlocks(title, cmds) {
1326
1505
  if (!cmds.length) return '';
1327
1506
  const block = (x) => `<div class="next start">
1328
1507
  <p><strong>${esc(x.name)}</strong> · ${x.count} due</p>
1329
- <div class="cmd"><code>${esc(x.command)}</code><button type="button" class="cmd__copy" data-copy>Copy</button></div>
1508
+ <div class="cmd"><code>${esc(x.command)}</code><button type="button" class="cmd__copy" data-copy data-focus="copy:${esc(x.name)}">Copy</button></div>
1330
1509
  ${x.note ? `<p class="cmd__note">${esc(x.note)}</p>` : ''}</div>`;
1331
1510
  const rest = cmds.slice(START_SHOWN);
1332
1511
  return `<section class="starts"><div class="head"><h2>${esc(title)}</h2></div>`
1333
1512
  + cmds.slice(0, START_SHOWN).map(block).join('')
1334
- + (rest.length ? `<details class="starts__more"><summary>Show ${rest.length} more · ${rest.reduce((n, x) => n + x.count, 0)} due</summary>${rest.map(block).join('')}</details>` : '')
1513
+ + (rest.length ? `<details class="starts__more" data-key="starts-more"><summary>Show ${rest.length} more · ${rest.reduce((n, x) => n + x.count, 0)} due</summary>${rest.map(block).join('')}</details>` : '')
1335
1514
  + `<p class="cmd__note">Each run asks up to your questions-per-task limit, weakest first. Run it again for the rest.</p>
1336
1515
  <span class="sr" id="copy-live" aria-live="polite"></span></section>`;
1337
1516
  }
@@ -1378,7 +1557,7 @@ function lines(items, empty = 'Nothing here yet.') {
1378
1557
  function qaLine(a, lead, linkConcept = false) {
1379
1558
  let opts = [];
1380
1559
  try { opts = a.options ? JSON.parse(a.options) : []; } catch { opts = []; }
1381
- return `<details class="qa">
1560
+ return `<details class="qa"${a.id != null ? ` data-key="qa:${esc(a.id)}"` : ''}>
1382
1561
  <summary class="qa__meta">${lead}<span class="qa__line">${esc(a.question)}</span>${gradeBadge(a)}</summary>
1383
1562
  <div class="qa__meta" style="margin-top:var(--space-3)"><span>${esc(tierLabel(a.tier))}</span>
1384
1563
  ${a.format ? `<span>${esc(a.format)}</span>` : ''}${a.level ? `<span>${esc(a.level)}</span>` : ''}
@@ -1412,14 +1591,16 @@ function pager(key, total, page, per = PER) {
1412
1591
  push(1); for (let i = page - 2; i <= page + 2; i++) push(i); push(pages);
1413
1592
  win.sort((a, b) => a - b);
1414
1593
 
1415
- let btns = `<button data-pager="${key}" data-page="${page - 1}"${page === 1 ? ' disabled' : ''} aria-label="Previous page">‹</button>`;
1594
+ // `data-focus` names a button by what it is, not by the page it goes to: after a press the Next
1595
+ // button goes to another page, and focus has to find it again (see `captureUi`).
1596
+ let btns = `<button data-pager="${key}" data-page="${page - 1}" data-focus="${key}:prev"${page === 1 ? ' disabled' : ''} aria-label="Previous page">‹</button>`;
1416
1597
  let last = 0;
1417
1598
  for (const n of win) {
1418
1599
  if (n - last > 1) btns += `<button disabled aria-hidden="true">…</button>`;
1419
- btns += `<button data-pager="${key}" data-page="${n}"${n === page ? ' aria-current="true"' : ''}>${n}</button>`;
1600
+ btns += `<button data-pager="${key}" data-page="${n}" data-focus="${key}:${n}"${n === page ? ' aria-current="true"' : ''}>${n}</button>`;
1420
1601
  last = n;
1421
1602
  }
1422
- btns += `<button data-pager="${key}" data-page="${page + 1}"${page === pages ? ' disabled' : ''} aria-label="Next page">›</button>`;
1603
+ btns += `<button data-pager="${key}" data-page="${page + 1}" data-focus="${key}:next"${page === pages ? ' disabled' : ''} aria-label="Next page">›</button>`;
1423
1604
  return `<div class="pager"><span class="pager__count">${from}–${to} of ${total}</span><div class="pager__btns">${btns}</div></div>`;
1424
1605
  }
1425
1606
 
@@ -1506,10 +1687,45 @@ function chartActivity(el, days) {
1506
1687
  */
1507
1688
  const HEAT_STEP = [0, 30, 55, 80, 100]; // % of --spot over --mass for steps 1..4
1508
1689
  const heatFill = (s) => `color-mix(in srgb, var(--spot) ${HEAT_STEP[s]}%, var(--mass))`;
1509
- let HEAT_OFF = 0; // weeks paged back; a view position, reset by render()
1690
+ let HEAT_OFF = 0; // weeks paged back; a view position, reset by render() (a redraw for new data keeps it)
1510
1691
  const HEAT_ARROWS = 64; // the two arrow buttons beside the grid
1511
1692
 
1512
- function chartHeatmap(el, map, run = new Set()) {
1693
+ /**
1694
+ * The heatmap's days, bucketed once per (S, PROJECT, day) and kept (`derived`): a day's count, its tooltip (said
1695
+ * with the date, but not whether it is in the current streak, which the drawing adds) and, for a week's first
1696
+ * day, its month, which is set when a column first needs it. A visit, a resize and a ‹ › page all draw some window
1697
+ * of the same year, so each draws from these instead of bucketing and formatting the dates again; only the SVG for
1698
+ * the visible window is built. Cells are made as days are asked for, so the 53 weeks the payload carries, and the
1699
+ * weeks paged back past them, are each made once.
1700
+ */
1701
+ function heatCell(grid, k) {
1702
+ let c = grid.cells.get(k);
1703
+ if (c) return c;
1704
+ const d = grid.daily.get(k);
1705
+ const total = d ? d.total : 0;
1706
+ const date = fromKey(k);
1707
+ const label = (date.getUTCFullYear() === grid.year ? FMT_DAY : FMT_DAY_YEAR).format(date);
1708
+ const right = d ? d.passed + d.corrected : 0;
1709
+ c = { day: k, total, future: k > grid.today, month: null,
1710
+ tip: esc(`${label} — ${total ? `${plural(total, 'answer')} (${right} right)` : 'nothing'}`) };
1711
+ grid.cells.set(k, c);
1712
+ return c;
1713
+ }
1714
+
1715
+ /** `n` days ending at `endKey`, as heat cells: `fillDays` for the heatmap. */
1716
+ function heatDays(n, endKey) {
1717
+ const grid = derived('heat', () => ({ year: fromKey(todayKey()).getUTCFullYear(), today: todayKey(), daily: dailyInScope(), cells: new Map() }));
1718
+ const out = [];
1719
+ const cur = new Date(fromKey(endKey).getTime() - (n - 1) * DAY_MS);
1720
+ for (let i = 0; i < n; i++) {
1721
+ out.push(heatCell(grid, dayKey(cur)));
1722
+ cur.setUTCDate(cur.getUTCDate() + 1);
1723
+ }
1724
+ return out;
1725
+ }
1726
+
1727
+ function chartHeatmap(el) {
1728
+ const run = streaks().run;
1513
1729
  const CELL = 13, GAP = 3, PAD_T = 18, PAD_L = 26;
1514
1730
  // The window is chosen from the width rather than the width from the window:
1515
1731
  // a wide card shows a year, a phone shows a quarter, and the cell stays the
@@ -1524,18 +1740,15 @@ function chartHeatmap(el, map, run = new Set()) {
1524
1740
  const end = new Date(today);
1525
1741
  end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()) - HEAT_OFF * 7); // to the end of this week, paged back
1526
1742
  const total = weeks * 7;
1527
- const days = fillDays(map, total, dayKey(end));
1743
+ const days = heatDays(total, dayKey(end));
1528
1744
  const max = Math.max(...days.map((d) => d.total), 1);
1529
- const year = today.getUTCFullYear();
1530
- const label = (k) => fromKey(k).toLocaleDateString(undefined,
1531
- { month:'short', day:'numeric', year: fromKey(k).getUTCFullYear() === year ? undefined : 'numeric', timeZone:'UTC' });
1532
1745
 
1533
1746
  let out = '', lastMonth = '', lastCol = -9;
1534
1747
  days.forEach((d, i) => {
1535
1748
  const col = Math.floor(i / 7), row = i % 7;
1536
1749
  const x = PAD_L + col * (CELL + GAP), y = PAD_T + row * (CELL + GAP);
1537
1750
  if (row === 0) {
1538
- const m = fromKey(d.day).toLocaleDateString(undefined, { month:'short', timeZone:'UTC' });
1751
+ const m = d.month ??= FMT_MONTH.format(fromKey(d.day));
1539
1752
  // Not in the last two columns: a month label starting there runs off the
1540
1753
  // right edge of the card. Nor within three columns of the previous label, which it would overprint.
1541
1754
  if (m !== lastMonth) {
@@ -1544,12 +1757,11 @@ function chartHeatmap(el, map, run = new Set()) {
1544
1757
  }
1545
1758
  }
1546
1759
  const step = d.total ? Math.min(4, Math.ceil((4 * d.total) / max)) : 0;
1547
- const fill = step ? heatFill(step) : d.day > todayKey() ? 'transparent' : 'var(--mass)';
1760
+ const fill = step ? heatFill(step) : d.future ? 'transparent' : 'var(--mass)';
1548
1761
  // The current run is outlined inside its own cell, so neighbours never touch.
1549
1762
  const on = run.has(d.day), inset = on ? 0.75 : 0;
1550
- const title = `${label(d.day)} — ${d.total ? `${plural(d.total, 'answer')} (${d.passed + d.corrected} right)` : 'nothing'}${on ? ' · current streak' : ''}`;
1551
1763
  out += `<rect class="cell${on ? ' is-run' : ''}" data-step="${step}" x="${x + inset}" y="${y + inset}" width="${CELL - 2 * inset}" height="${CELL - 2 * inset}" fill="${fill}">`
1552
- + `<title>${esc(title)}</title></rect>`;
1764
+ + `<title>${d.tip}${on ? ' · current streak' : ''}</title></rect>`;
1553
1765
  });
1554
1766
  ['Mon', 'Wed', 'Fri'].forEach((label, n) => {
1555
1767
  const row = [1, 3, 5][n];
@@ -1572,8 +1784,8 @@ function chartHeatmap(el, map, run = new Set()) {
1572
1784
  }
1573
1785
 
1574
1786
  /** The streak, up front: the run, the longest, and the calendar it is counted from. */
1575
- function streakCard(daily, st) {
1576
- chart('c-heat', (el) => chartHeatmap(el, daily, st.run));
1787
+ function streakCard(st) {
1788
+ chart('c-heat', chartHeatmap);
1577
1789
  const sub = st.current && !st.today ? `Answer one today to make it ${st.current + 1}.`
1578
1790
  : !st.current && st.best ? `Your longest was ${plural(st.best, 'day')}.`
1579
1791
  : !st.best ? 'Answer one question to start one.' : '';
@@ -1679,7 +1891,8 @@ const chart = (id, fn) => {
1679
1891
  Everything above is derived from one payload. The corpus is the exception:
1680
1892
  an observation carries a narrative and the tool output behind it, so the
1681
1893
  timeline is paged over `/api/memory` instead of shipped whole on every load.
1682
- That makes exactly two views asynchronous, and both go through `fill()`.
1894
+ Those views, and the other pages that read a resource of their own, are
1895
+ asynchronous, and every one of them draws its target through `slot()`.
1683
1896
  ============================================================ */
1684
1897
 
1685
1898
  /**
@@ -1690,46 +1903,256 @@ const chart = (id, fn) => {
1690
1903
  */
1691
1904
  let GEN = 0;
1692
1905
 
1693
- /**
1694
- * Renders a placeholder now and the real markup when the fetch lands. Queued
1695
- * through `AFTER` like a chart, for the same reason — the target element does
1696
- * not exist until `render()` has written the view's `innerHTML`.
1697
- */
1698
1906
  /**
1699
1907
  * The one loading state: the companion drawing and releasing its arrow, and what is being waited
1700
1908
  * for. `page: true` is the full-height version the first paint uses. Every
1701
- * placeholder a `fill()` target starts with is one of these, so a wait looks
1909
+ * placeholder a `slot()` starts with is one of these, so a wait looks
1702
1910
  * the same wherever it happens. `label` is plain text; it is escaped here.
1703
1911
  */
1704
1912
  function loader(label, { page = false } = {}) {
1705
1913
  return `<div class="loader${page ? ' loader--page' : ''}" role="status" aria-live="polite"><eklavya-mascot state="loading" variant="loading" size="${page ? 96 : 56}"></eklavya-mascot><span>${esc(label)}</span></div>`;
1706
1914
  }
1707
1915
 
1916
+ /* ---------- the response cache ----------
1917
+ What a fetched region asked for, kept by URL, so a page the reader has seen is drawn again
1918
+ with no request and no loader: a revisit, Back and Forward, a pager or a filter returned to.
1919
+ It holds the parsed body, never markup, so a draw runs on it again as it would on a fresh
1920
+ answer (today's "Yesterday" is not the day it was cached).
1921
+
1922
+ The key is the URL, and the URL carries everything the answer depends on: the project scope
1923
+ (`memScope()`, `?project=`), the page, the filters, the id. A different project is a different
1924
+ key, so a project switch leaves the cache alone and switching back is instant. An answer that
1925
+ does not depend on the scope (an entry, a session's rows, a feedback item, a correction) is
1926
+ keyed by the one thing it does depend on.
1927
+
1928
+ An entry is good while the page's `S.cursor` is the one it was read under, and it is dropped
1929
+ by `invalidateData()` (a save, and the arrival of new data: `adoptState`) and, for the one
1930
+ route a write changes, by `respForget()`. A page reload starts with nothing. A read already out when the cache is invalidated is not stored
1931
+ when it lands (`respAt`): it may have been answered before whatever made the cache stale.
1932
+ Bounded at `RESP_MAX`, least recently used out first. An answer that failed is never cached. */
1933
+ const RESP_MAX = 50;
1934
+ const RESP = new Map(); // url -> { cursor, body }, least recently used first
1935
+
1936
+ function respGet(url) {
1937
+ const e = RESP.get(url);
1938
+ if (!e) return null;
1939
+ RESP.delete(url);
1940
+ if (e.cursor !== S.cursor) return null;
1941
+ RESP.set(url, e);
1942
+ return e;
1943
+ }
1944
+ /** What a read is taken under, noted when it is made, not when it lands. */
1945
+ const respAt = () => ({ cursor: S.cursor, gen: DATA_GEN });
1946
+ function respPut(url, body, at) {
1947
+ if (at.cursor !== S.cursor || at.gen !== DATA_GEN) return;
1948
+ RESP.delete(url);
1949
+ RESP.set(url, { cursor: at.cursor, body });
1950
+ if (RESP.size > RESP_MAX) RESP.delete(RESP.keys().next().value);
1951
+ }
1952
+ /** A write the page made changed what these answer: every cached URL that starts with `prefix` goes. */
1953
+ function respForget(prefix) {
1954
+ for (const url of [...RESP.keys()]) if (url.startsWith(prefix)) RESP.delete(url);
1955
+ }
1956
+
1957
+ /* ---------- slots: a fetched region that never moves the page ----------
1958
+ A view that reads a resource returns `slot(id, url, draw, opts)` where the target element goes.
1959
+ On a cache hit the element already holds `draw(body)`: nothing is inserted, nothing is
1960
+ requested, and the screen is drawn in one go. On a miss it holds a box of the height that
1961
+ region is expected to take, with the loader in it (the mascot still waits its 150ms before it
1962
+ shows), and the fetch is queued. When the same region is already on screen with an earlier
1963
+ answer in it (a pager, a filter, a refetch of the same page), the box is never drawn: the old
1964
+ content stays, dimmed and out of reach (`aria-busy` + `data-kept`), until the answer draws. */
1965
+
1966
+ /** Where the box is looked for in markup. */
1967
+ const WAIT_OPEN = '<div class="slot__wait"';
1968
+ const isWait = (html) => html.startsWith(WAIT_OPEN);
1969
+
1970
+ /**
1971
+ * What a slot reserves the first time it is drawn, in px, before this page has seen what it holds. A list is
1972
+ * sized from how many rows the page already knows it has (`SLOT_ROWS`: the counts the payload and the inventory carry),
1973
+ * at most the rows one page holds, so a reader with three sessions is not given room for twenty and the footer
1974
+ * does not drop a thousand pixels and come back. The rest are what they take at a year of use (the issue's medium
1975
+ * database, measured at 1280px): an observation, an item of feedback, both folds of a session's memory half, the
1976
+ * settings page, whose fields are fixed. These are estimates for a first visit and nothing more: the footer moves
1977
+ * by however far one misses, and twice if the page being left was taller than the estimate and shorter than the
1978
+ * answer, or the other way round. The height a region had when it last drew (`SLOT_H`) replaces them, and a page
1979
+ * seen again has no box at all.
1980
+ */
1981
+ const SLOT_MIN = {
1982
+ 'entry-body': 650, 'sess-lookup': 360, 'sess-mem': 160, 'fb-item': 520, settings: 1580,
1983
+ };
1984
+ /** A list's room: [how many rows the page knows of, the most one page holds, px a row takes, px around the rows (pager, rule)]. Measured at 1280px. */
1985
+ const SLOT_ROWS = {
1986
+ 'mem-rows': [() => memCounts().entries, 25, 48, 143],
1987
+ 'msess-rows': [() => memCounts().sessions, PER, 70, 51],
1988
+ 'mem-recent': [() => memCounts().entries, 6, 60, 0],
1989
+ 'mem-sessions': [() => memCounts().sessions, 5, 60, 0],
1990
+ 'fb-rows': [() => S.feedback.acknowledged, PER, 46, 110],
1991
+ };
1992
+ /** What a list with no rows takes: its one line of explanation. */
1993
+ const SLOT_EMPTY = 94;
1994
+ const SLOT_FALLBACK = 160;
1995
+ /** What `#id` reserves while it waits and the page has not drawn it before. */
1996
+ function slotMin(id) {
1997
+ const rows = SLOT_ROWS[id];
1998
+ const n = rows ? rows[0]() : NaN;
1999
+ if (n === 0) return SLOT_EMPTY;
2000
+ if (n > 0) return Math.round(rows[3] + rows[2] * Math.min(n, rows[1]));
2001
+ return SLOT_MIN[id] ?? SLOT_FALLBACK;
2002
+ }
2003
+ const SLOT_H = new Map(); // id -> the height of its content the last time it was drawn
2004
+
2005
+ /** The height of the content of `el`, without the padding it carries. */
2006
+ function contentHeight(el) {
2007
+ const cs = getComputedStyle(el);
2008
+ return Math.round(el.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom));
2009
+ }
2010
+ /** Notes how tall `#id` is now that it holds real content, for the next time it waits. */
2011
+ function remember(id) {
2012
+ const el = $(id);
2013
+ const h = el ? contentHeight(el) : 0;
2014
+ if (h > 0) SLOT_H.set(id, h);
2015
+ }
2016
+
2017
+ const waitBox = (id, label) => `${WAIT_OPEN} style="min-height:${SLOT_H.get(id) ?? slotMin(id)}px">${loader(label)}</div>`;
2018
+
2019
+ const failure = (e) => `<div class="ek-mascot-message"><eklavya-mascot state="error" size="56"></eklavya-mascot><p class="empty">Could not load: ${esc(e.message)}</p></div>`;
2020
+
2021
+ /** The target element of a slot. Its attributes are the same whatever it holds, so a redraw can tell a change of content from a change of structure. */
2022
+ const slotHost = (id, inner, { cls = '', region = false } = {}) =>
2023
+ `<div id="${id}" data-slot${cls ? ` class="${cls}"` : ''}${region ? ` data-region="${id}"` : ''}>${inner}</div>`;
2024
+
2025
+ /** The state a fetch leaves on its target while it is out; gone once the answer, or the failure, is drawn. */
2026
+ const slotDone = (el) => { el.removeAttribute('aria-busy'); el.removeAttribute('data-kept'); el.removeAttribute('data-failed'); };
2027
+ /** Whether `el` shows something worth keeping under a new request: not the box, and not a failure. */
2028
+ const holdsContent = (el) => !el.hasAttribute('data-failed') && !el.querySelector(':scope > .slot__wait');
2029
+
2030
+ /**
2031
+ * `opts`: `label` (what the loader says), `cls` (a class for the target), `region` (the target is
2032
+ * a `data-region`, so a redraw replaces its contents alone). `draw` is a pure function of the
2033
+ * response, and must not change it: a cached body is drawn again. It may queue its own charts and slots.
2034
+ */
2035
+ function slot(id, url, draw, { label, ...host } = {}) {
2036
+ const hit = respGet(url);
2037
+ if (!hit) {
2038
+ fill(id, url, draw);
2039
+ return slotHost(id, waitBox(id, label), host);
2040
+ }
2041
+ AFTER.push(() => remember(id));
2042
+ let html;
2043
+ try {
2044
+ html = draw(hit.body);
2045
+ } catch (e) {
2046
+ html = failure(e);
2047
+ AFTER.push(() => $(id)?.setAttribute('data-failed', ''));
2048
+ }
2049
+ return slotHost(id, html, host);
2050
+ }
2051
+
2052
+ /**
2053
+ * What a fetched region was last drawn from: the markup the record of the screen (`DRAWN`) holds for `#id`, which
2054
+ * every draw into it keeps (`recordFill`, `drawAdjusted`, `recordStrip`); null when there is none. A redraw for new
2055
+ * data compares an answer with it: the same markup replaces nothing, and markup made of regions of its own (Settings)
2056
+ * with the same skeleton around them replaces only the regions that changed, so what the new data did not change is
2057
+ * not touched (the reader's text selection, an open row, a tip beacon, a field being edited).
2058
+ */
2059
+ function drawnSlot(id) {
2060
+ if (!DRAWN) return null;
2061
+ const t = document.createElement('template');
2062
+ t.innerHTML = DRAWN.html;
2063
+ return t.content.getElementById(id)?.innerHTML ?? null;
2064
+ }
2065
+ /** `html` as the browser keeps it, which is the form `drawnSlot` and the record of the screen are in. */
2066
+ function normalized(html) {
2067
+ const t = document.createElement('template');
2068
+ t.innerHTML = html;
2069
+ return t.innerHTML;
2070
+ }
2071
+
2072
+ /**
2073
+ * Fetches `url` for the slot `#id` and draws the answer in. Queued through `AFTER` like a chart,
2074
+ * for the same reason: the target does not exist until `render()` has written the view. If the
2075
+ * target already shows an earlier answer, that stays under `aria-busy` until this one draws.
2076
+ *
2077
+ * A fill made for new data (`LIVE_PASS`) is the reader's own screen being brought up to date, so it also leaves
2078
+ * alone the regions that hold a control the reader is using (`swapRegions`), and when the server cannot be reached
2079
+ * it leaves what is on screen rather than putting an error over it.
2080
+ */
1708
2081
  function fill(id, url, draw) {
1709
2082
  const gen = GEN;
2083
+ const at = respAt();
2084
+ const live = LIVE_PASS;
1710
2085
  AFTER.push(() => {
1711
- $(id)?.setAttribute('aria-busy', 'true');
2086
+ const was = $(id);
2087
+ const kept = !!was && holdsContent(was);
2088
+ if (was) {
2089
+ // The dim waits its 150ms (CSS) only on an element whose style has been computed before it is set: one the same task just put in
2090
+ // the document would start dimmed.
2091
+ void getComputedStyle(was).opacity;
2092
+ was.setAttribute('aria-busy', 'true');
2093
+ if (kept) was.setAttribute('data-kept', '');
2094
+ }
1712
2095
  fetch(url).then((r) => {
1713
2096
  if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
1714
2097
  return r.json();
1715
2098
  }).then((d) => {
2099
+ respPut(url, d, at);
1716
2100
  const el = $(id);
1717
2101
  if (!el || gen !== GEN) return;
1718
2102
  // A draw can queue its own charts and fills (a session page drawn from a
1719
2103
  // lookup does); the render that queued this one has already run its hooks.
1720
2104
  AFTER.length = 0;
1721
- el.innerHTML = draw(d);
1722
- el.removeAttribute('aria-busy');
2105
+ const html = draw(d);
2106
+ if (live && kept && holdsContent(el) && redrawKept(el, html, live)) {
2107
+ // What is on screen was brought up to date, or already was: the record of the screen follows.
2108
+ recordFill(id, html);
2109
+ slotDone(el);
2110
+ remember(id);
2111
+ for (const fn of AFTER.splice(0)) fn();
2112
+ TIP.sync();
2113
+ return;
2114
+ }
2115
+ // What the reader had open or focused in the rows being replaced (a timeline entry, a pager
2116
+ // button) is found again in the new ones.
2117
+ const ui = captureUi(el);
2118
+ el.innerHTML = html;
2119
+ // This region is as the answer says. Another that was put off for the reader's control is still put off.
2120
+ HELD_BACK.delete(id);
2121
+ // The view returns this same markup on its own from now on (from the cache), so the record
2122
+ // of the screen is brought up to date with it and a later redraw leaves it alone.
2123
+ recordFill(id, html);
2124
+ slotDone(el);
1723
2125
  focusable(el);
2126
+ restoreUi(el, ui);
2127
+ remember(id);
1724
2128
  for (const fn of AFTER.splice(0)) fn();
1725
2129
  TIP.sync();
1726
2130
  }).catch((e) => {
1727
2131
  const el = $(id);
1728
- if (el && gen === GEN) { el.innerHTML = `<div class="ek-mascot-message"><eklavya-mascot state="error" size="56"></eklavya-mascot><p class="empty">Could not load: ${esc(e.message)}</p></div>`; el.removeAttribute('aria-busy'); }
2132
+ if (!el || gen !== GEN) return;
2133
+ if (live && kept) { slotDone(el); return; }
2134
+ el.innerHTML = failure(e);
2135
+ slotDone(el);
2136
+ el.setAttribute('data-failed', '');
1729
2137
  });
1730
2138
  });
1731
2139
  }
1732
2140
 
2141
+ /**
2142
+ * An answer for a region that shows an earlier one, for a redraw for new data: true when what is on screen is now in line
2143
+ * with `html` without replacing the region. That is when it is the same markup, or it is made of regions of its own (the
2144
+ * same skeleton around them, as Settings is) and only some of them changed, which are replaced and the others never
2145
+ * touched. The reader's own change of a list (a pager, a filter) always draws the answer it asked for.
2146
+ */
2147
+ function redrawKept(el, html, live) {
2148
+ const was = drawnSlot(el.id);
2149
+ if (was === null) return false;
2150
+ if (normalized(html) === was && !HELD_BACK.has(el.id)) return true;
2151
+ const next = scanRegions(html);
2152
+ const prev = scanRegions(was);
2153
+ return next.skeleton !== null && next.skeleton === prev.skeleton && swapRegions(el, prev, next, live);
2154
+ }
2155
+
1733
2156
  /**
1734
2157
  * Anything with a `data-go` is a link, so it has to be reachable by Tab: rows,
1735
2158
  * tiles and the `<a class="link">` shorthand, which has no `href` and so is
@@ -1737,7 +2160,9 @@ function fill(id, url, draw) {
1737
2160
  */
1738
2161
  function focusable(root) {
1739
2162
  root.querySelectorAll('tr.row, [data-go]').forEach((el) => {
1740
- if (el.tabIndex < 0) el.tabIndex = 0;
2163
+ // `tabIndex` is not the question for the `<a class="link" data-go>` shorthand: Chromium reports 0 for an anchor
2164
+ // with no `href` although it cannot take focus, so it was never given the attribute that makes it Tab-reachable.
2165
+ if (!el.hasAttribute('tabindex') || el.tabIndex < 0) el.tabIndex = 0;
1741
2166
  if (el.tagName === 'A' && !el.hasAttribute('href')) el.setAttribute('role', 'link');
1742
2167
  });
1743
2168
  // Runs after every render and every fill, so it is also where new markup's
@@ -1790,27 +2215,22 @@ function savingsText(s, receipts) {
1790
2215
  ============================================================ */
1791
2216
 
1792
2217
  function viewLearningDashboard() {
1793
- const daily = dailyInScope();
1794
- const st = streaks(daily);
2218
+ const st = streaks();
1795
2219
  const seen = seenConcepts();
1796
2220
  const scoped = conceptsInScope();
1797
- const atts = attemptsInScope();
1798
- const answered = atts.length;
1799
- // A miss corrected from its explainer counts as right: fixing it is learning.
1800
- const passed = atts.filter((a) => a.grade >= 3 || a.corrected_at).length;
2221
+ // The server counted these over every answer, so a cap on the rows the page holds changes none of them. A miss
2222
+ // corrected from its explainer counts as right: fixing it is learning.
2223
+ const { answers: answered, passed, logged: loggedCount, loggedAssessed } = scopeTotals();
1801
2224
  const due = seen.filter((c) => c.due);
1802
2225
  const mastered = seen.filter((c) => c.mastered);
1803
2226
  // Learning's sessions; a memory-only one belongs to the other workflow.
1804
- const sess = sessions().filter((x) => x.logged.length || x.asked.length);
1805
- const last30 = fillDays(daily, 30);
2227
+ const sess = learningSessions();
2228
+ const last30 = lastThirty();
1806
2229
 
1807
2230
  const weakest = seen.filter((c) => !c.mastered).sort((a, b) => a.score - b.score || b.attempts - a.attempts).slice(0, 5);
1808
2231
 
1809
2232
  // Logged is what the work touched; assessed is what a question was actually
1810
2233
  // asked about. They are different claims and the page keeps them apart.
1811
- const logged = new Set(loggedInScope().filter((l) => (l.origin ?? 'work') === 'work').map((l) => l.slug));
1812
- const asked = new Set(atts.map((a) => a.slug));
1813
- const loggedAsked = [...logged].filter((x) => asked.has(x)).length;
1814
2234
  const inv = projectsInScope();
1815
2235
  const unassessed = inv.filter((p) => !p.learning.answers && p.learning.logged_concepts);
1816
2236
 
@@ -1822,14 +2242,14 @@ function viewLearningDashboard() {
1822
2242
  Open the <a class="link" data-go="${esc(LH('review'))}">review queue</a> for the command to run.`)
1823
2243
  : PROJECT !== null && !answered
1824
2244
  ? nextStep(`<strong>No assessments yet in ${esc(scopeName())}.</strong>
1825
- ${logged.size ? `${plural(logged.size, 'concept')} recorded from its work will come up in a quiz.` : 'Nothing has been logged here either.'}`, true)
2245
+ ${loggedCount ? `${plural(loggedCount, 'concept')} recorded from its work will come up in a quiz.` : 'Nothing has been logged here either.'}`, true)
1826
2246
  : '';
1827
2247
 
1828
2248
  return [
1829
2249
  pageHead('Am I actually getting better?', [
1830
2250
  `${plural(seen.length, 'concept')} asked of ${S.totals.catalogue}`,
1831
2251
  plural(answered, 'answer'),
1832
- `${logged.size} recorded from your work, ${loggedAsked} assessed`,
2252
+ `${loggedCount} recorded from your work, ${loggedAssessed} assessed`,
1833
2253
  `since ${esc(fmtDate(S.totals.first_answer))}`,
1834
2254
  ...(PROJECT === null && unassessed.length
1835
2255
  ? [`<a class="link" data-go="${esc(LH('projects'))}">${plural(unassessed.length, 'project')} not assessed yet</a>`] : []),
@@ -1847,7 +2267,7 @@ function viewLearningDashboard() {
1847
2267
 
1848
2268
  next,
1849
2269
 
1850
- streakCard(daily, st),
2270
+ streakCard(st),
1851
2271
 
1852
2272
  card('Answers, last 30 days', null,
1853
2273
  `<div class="legend">
@@ -1875,7 +2295,7 @@ function viewLearningDashboard() {
1875
2295
  v: `${s.asked.length ? `${s.passed}/${s.asked.length} right` : plural(s.logged.length, 'concept')}<div class="slug">${esc(ago(s.last))}</div>`,
1876
2296
  go: sessHref(s.id),
1877
2297
  })), 'No sessions in this scope yet.'),
1878
- { aside: `<a class="link" data-go="${esc(href(CUR.wf, 'sessions'))}">All ${sess.length} →</a>` })}
2298
+ { aside: `<a class="link" data-go="${esc(href(CUR.wf, 'sessions'))}">All ${sessionTotal()} →</a>` })}
1879
2299
  </div>`,
1880
2300
 
1881
2301
  fold('learn:stand', 'Where you stand', `${mastered.length} mastered · ${seen.length - mastered.length} learning`,
@@ -1944,25 +2364,24 @@ function viewConcepts(param) {
1944
2364
  pageHead('Concepts', [plural(all.length, 'concept'), `${counts.all - counts.unseen} asked`, `${counts.mastered} mastered`],
1945
2365
  `Everything in your graph — what you have been asked, and what is still just catalogue. Open a concept
1946
2366
  for every question you were given on it and the code that logged it.`),
2367
+ // The search box is outside every region, so a keystroke replaces the list and leaves the box, the selects and the chips alone.
1947
2368
  card(null, null,
1948
2369
  `<div class="filters">
1949
2370
  <div class="grow"><input type="search" id="q" value="${esc(t.q)}" placeholder="Search concepts" aria-label="Search concepts"></div>
1950
- <select id="dom" aria-label="Domain">
2371
+ ${region('concepts-selects', `<select id="dom" aria-label="Domain">
1951
2372
  <option value="all">All domains</option>
1952
2373
  ${domains.map((d) => `<option value="${esc(d)}"${t.domain === d ? ' selected' : ''}>${esc(d)}</option>`).join('')}
1953
2374
  </select>
1954
2375
  <select id="sort" aria-label="Sort">
1955
2376
  ${[['weakest','Weakest first'],['strongest','Strongest first'],['recent','Recently seen'],['asked','Most asked'],['tier','By tier'],['name','A–Z']]
1956
2377
  .map(([v, l]) => `<option value="${v}"${t.sort === v ? ' selected' : ''}>${l}</option>`).join('')}
1957
- </select>
1958
- </div>
1959
- <div class="chips" id="chips">
1960
- ${chip('all','All')}${chip('due','Due')}${chip('learning','Learning')}${chip('mastered','Mastered')}${chip('unseen','Not asked')}
2378
+ </select>`)}
1961
2379
  </div>
1962
- <div style="margin-top:var(--space-4)">
1963
- ${table([{t:'Concept'},{t:'Score'},{t:'Asked',n:1},{t:'State'}], rows)}
1964
- ${pager('concepts', list.length, p)}
1965
- </div>`),
2380
+ ${region('concepts-chips',
2381
+ `${chip('all','All')}${chip('due','Due')}${chip('learning','Learning')}${chip('mastered','Mastered')}${chip('unseen','Not asked')}`,
2382
+ 'class="chips" id="chips"')}
2383
+ ${region('concepts', `${table([{t:'Concept'},{t:'Score'},{t:'Asked',n:1},{t:'State'}], rows)}${pager('concepts', list.length, p)}`,
2384
+ 'style="margin-top:var(--space-4)"')}`),
1966
2385
  ].join('');
1967
2386
  }
1968
2387
 
@@ -2003,8 +2422,8 @@ function viewConcept(slug) {
2003
2422
  startBlocks('This one is due', startCommand([c])),
2004
2423
 
2005
2424
  card('Every question you were asked', atts.length ? `${plural(atts.length, 'attempt')}, newest first. Open one for the options and the feedback.` : '',
2006
- atts.length ? history + pager('concept', atts.length, p, PER_Q)
2007
- : mascotMessage('<p class="empty">Not asked yet. It will come up when your work touches it.</p>')),
2425
+ region('history', atts.length ? history + pager('concept', atts.length, p, PER_Q)
2426
+ : mascotMessage('<p class="empty">Not asked yet. It will come up when your work touches it.</p>'))),
2008
2427
 
2009
2428
  atts.length
2010
2429
  ? fold('concept:grades', 'How the grades went', plural(atts.length, 'grade'),
@@ -2038,6 +2457,8 @@ function viewReview(param) {
2038
2457
  const upcoming = seen.filter((c) => c.owed && !c.due && c.next_review)
2039
2458
  .sort((a, b) => String(a.next_review).localeCompare(String(b.next_review)));
2040
2459
  const skipped = attemptsInScope().filter((a) => a.outcome === 'declined' || a.outcome === 'dont_know');
2460
+ // How many there are is the server's count over every answer; the list is only the skipped ones among the rows the page holds.
2461
+ const skippedTotal = Math.max(skipped.length, scopeTotals().skipped);
2041
2462
 
2042
2463
  // Fourteen days of scheduled reviews, with everything already overdue in one
2043
2464
  // column — a backlog that grows off the left of the chart is a backlog nobody
@@ -2057,7 +2478,7 @@ function viewReview(param) {
2057
2478
  const week = buckets.slice(1, 8).reduce((a, b) => a + b.n, 0);
2058
2479
  const longest = due.length ? Math.max(...due.map((c) => c.overdue_days ?? 0)) : 0;
2059
2480
 
2060
- const tabs = [['due', `Due now ${due.length}`], ['upcoming', `Upcoming ${upcoming.length}`], ['skipped', `Skipped ${skipped.length}`]];
2481
+ const tabs = [['due', `Due now ${due.length}`], ['upcoming', `Upcoming ${upcoming.length}`], ['skipped', `Skipped ${skippedTotal}`]];
2061
2482
  const list = t.tab === 'due' ? due : t.tab === 'upcoming' ? upcoming : skipped;
2062
2483
  const p = page('review', list.length);
2063
2484
  const rows = slice(list, p).map((x) => t.tab === 'skipped'
@@ -2078,7 +2499,7 @@ function viewReview(param) {
2078
2499
  return [
2079
2500
  pageHead('Review queue', [
2080
2501
  due.length ? `<span class="warn">${due.length} due now</span>` : 'nothing due',
2081
- `${week} in the next 7 days`, `${skipped.length} skipped`,
2502
+ `${week} in the next 7 days`, `${skippedTotal} skipped`,
2082
2503
  ...(due.length ? [`longest overdue ${plural(longest, 'day')}`] : []),
2083
2504
  ], `SM-2 decides when a concept comes back: one day after a miss, then 6, then the interval times its
2084
2505
  ease. A score decays about 5% a week while a review is overdue, so this list is also the list of
@@ -2089,10 +2510,11 @@ function viewReview(param) {
2089
2510
  card('The next fortnight', null,
2090
2511
  `<div class="chart"><svg id="c-forecast" role="img" aria-label="Scheduled reviews for the next fourteen days"></svg></div>`),
2091
2512
 
2092
- card(null, null,
2513
+ card(null, null, region('review',
2093
2514
  `<div class="chips" id="tabs">` + tabs.map(([k, label]) =>
2094
2515
  `<button class="chip" data-tab="${k}" aria-pressed="${t.tab === k}">${esc(label)}</button>`).join('') + `</div>
2095
- <div style="margin-top:var(--space-4)">${table(cols, rows)}${pager('review', list.length, p)}</div>`),
2516
+ <div style="margin-top:var(--space-4)">${table(cols, rows)}${pager('review', list.length, p)}
2517
+ ${t.tab === 'skipped' && skipped.length < skippedTotal ? `<p class="slug" style="margin:var(--space-4) 0 0">${plural(skippedTotal, 'answer')} ${skippedTotal === 1 ? 'was' : 'were'} skipped; the list holds the ${skipped.length} among the newest ${S.attempts_shown} answers.</p>` : ''}</div>`)),
2096
2518
  ].join('');
2097
2519
  }
2098
2520
 
@@ -2100,9 +2522,8 @@ function viewSessions() {
2100
2522
  // Learning's sessions: the ones that logged a concept or asked a question.
2101
2523
  // A session that only captured memory is the Memory workflow's, and is
2102
2524
  // counted here so it is never silently missing.
2103
- const every = sessions();
2104
- const all = every.filter((s) => s.logged.length || s.asked.length);
2105
- const memOnly = every.length - all.length;
2525
+ const all = learningSessions();
2526
+ const memOnly = sessions().length - all.length;
2106
2527
  const p = page('sessions', all.length);
2107
2528
  const rows = slice(all, p).map((s) => {
2108
2529
  const acc = s.asked.length ? pct(s.passed, s.asked.length) : null;
@@ -2116,18 +2537,28 @@ function viewSessions() {
2116
2537
  });
2117
2538
 
2118
2539
  const withQuestions = all.filter((s) => s.asked.length).length;
2540
+ const answers = all.reduce((a, s) => a + s.asked.length, 0);
2541
+ // While the payload's lists are cut the list is the sessions of the newest rows. The count is every session (the
2542
+ // project cards' number), the other two figures are about the listed ones, and the page says which it is saying.
2543
+ const cut = rowsCut();
2544
+ const counts = cut
2545
+ ? [plural(sessionTotal(), 'session'), `newest ${all.length} listed`, `${withQuestions} of those with questions`, `${plural(answers, 'answer')} in them`]
2546
+ : [plural(all.length, 'session'), `${withQuestions} with questions`, plural(answers, 'answer')];
2547
+ const built = [
2548
+ ...(cutAnswers() ? [`the most recent ${S.attempts_shown} of ${S.attempts_total} answers`] : []),
2549
+ ...(cutLines() ? [`the most recent ${S.logged_shown} of ${S.logged_total} logged lines`] : []),
2550
+ ].join(' and ');
2119
2551
  return [
2120
- pageHead('Sessions', [
2121
- plural(all.length, 'session'), `${withQuestions} with questions`,
2122
- plural(all.reduce((a, s) => a + s.asked.length, 0), 'answer'),
2123
- ], `One row per Claude Code session that logged a concept or asked a question. Open one for the whole
2552
+ pageHead('Sessions', counts, `One row per Claude Code session that logged a concept or asked a question. Open one for the whole
2124
2553
  thread: the concepts the work touched, every question you were asked, and — joined on the same
2125
2554
  session id — what the session captured and remembered.`),
2126
- card(null, null,
2555
+ card(null, null, region('sessions',
2127
2556
  table([{t:'Session'},{t:'Concepts',n:1},{t:'Answers'},{t:'When',n:1}], rows)
2128
2557
  + pager('sessions', all.length, p)
2558
+ + (cut ? `<p class="slug" style="margin:var(--space-4) 0 0">This list is built from ${built}, so older sessions are counted above
2559
+ but not listed, and the oldest one listed may show only part of its work. Open a session to read it whole.</p>` : '')
2129
2560
  + (memOnly ? `<p class="slug" style="margin:var(--space-4) 0 0">${plural(memOnly, 'session')} captured memory and logged no concept —
2130
- <a class="link" data-go="${esc(MH('sessions'))}">see them in Memory → Sessions</a>.</p>` : '')),
2561
+ <a class="link" data-go="${esc(MH('sessions'))}">see them in Memory → Sessions</a>.</p>` : ''))),
2131
2562
  ].join('');
2132
2563
  }
2133
2564
 
@@ -2142,16 +2573,19 @@ const noSession = (id) => mascotMessage(`<p class="empty">No session <code>${esc
2142
2573
  * came from.
2143
2574
  */
2144
2575
  function viewSession(id) {
2145
- const s = sessions().find((x) => x.id === id);
2576
+ // The payload's rows are every row there is only while its lists are not cut (`rowsCut`), and then a session in it is
2577
+ // whole and is drawn with no request. Once they are cut, the rows of a session may be only the newest of its rows, and
2578
+ // nothing in the payload says which ones: a session that began before the cut and ran past it looks like any other
2579
+ // (its older rows are the ones left out). So every session is read from the server, whole.
2580
+ const s = rowsCut() ? null : sessions().find((x) => x.id === id);
2146
2581
  if (s) return sessionBody(s);
2147
- // Not in the payload: a memory-only session past `memory_sessions`' cap, or
2148
- // one outside the current scope. The paged resource answers for it exactly.
2149
- fill('sess-lookup', '/api/memory/sessions?' + memScope(new URLSearchParams({ session: id })), (d) => {
2150
- const row = d.rows[0];
2151
- if (!row) return noSession(id);
2152
- return sessionBody({ id, repo: row.project, logged: [], asked: [], mem: row, first: row.first, last: row.last, passed: 0 });
2153
- });
2154
- return `<div class="stack" id="sess-lookup">${loader('Looking up this session…')}</div>`;
2582
+ // Not in the payload (a memory-only session past `memory_sessions`' cap, one outside the current scope, one whose
2583
+ // rows are older than the payload's) or not known to be whole. The paged resource answers for it exactly: its memory
2584
+ // half, and its lines and answers whatever their age (`learning`).
2585
+ return slot('sess-lookup', '/api/memory/sessions?' + memScope(new URLSearchParams({ session: id })), (d) => {
2586
+ const found = sessionFromLookup(id, d);
2587
+ return found ? sessionBody(found) : noSession(id);
2588
+ }, { label: 'Looking up this session…', cls: 'stack' });
2155
2589
  }
2156
2590
 
2157
2591
  function sessionBody(s) {
@@ -2172,28 +2606,26 @@ function sessionBody(s) {
2172
2606
 
2173
2607
  // The memory half of this session, folded: what it remembered and what it
2174
2608
  // proposed are the audit trail behind the learning, not the headline.
2175
- if (s.mem) {
2176
- fill('sess-mem', '/api/memory?per=100&session=' + encodeURIComponent(id), (d) => {
2177
- const entries = d.rows.map((e) => ({
2178
- t: `<span${e.deleted_at || e.superseded_by ? ' class="gone-title"' : ''}>${esc(e.title)}</span>`,
2179
- s: `${esc(stamp(e.occurred_at))} · ${esc(e.type ?? e.kind)} · ${plural(e.event_count, 'evidence event')}`,
2180
- v: entryMark(e),
2181
- go: MH('entry', e.id),
2182
- }));
2183
- const cands = (d.candidates ?? []).map((c) => ({
2184
- t: esc(c.name ?? c.slug),
2185
- s: `${esc(c.domain ?? '—')} · ${Math.round((c.confidence ?? 0) * 100)}% confidence`,
2186
- v: `<span class="pill${c.status === 'accepted' ? ' ok' : ''}">${esc(c.status)}</span>`,
2187
- go: c.concept_slug ? LH('concept', c.concept_slug) : null,
2188
- }));
2189
- return fold('sess:mem', 'What the session remembered', plural(entries.length, 'observation'), lines(entries),
2190
- { hint: 'Observations distilled from the evidence this session captured.' })
2191
- + (cands.length
2192
- ? fold('sess:cands', 'Concepts it proposed', plural(cands.length, 'candidate'), lines(cands),
2193
- { hint: 'Derived from evidence, and still only a proposal — nothing here moved mastery.' })
2194
- : '');
2195
- });
2196
- }
2609
+ const memory = !s.mem ? '' : slot('sess-mem', '/api/memory?per=100&session=' + encodeURIComponent(id), (d) => {
2610
+ const entries = d.rows.map((e) => ({
2611
+ t: `<span${e.deleted_at || e.superseded_by ? ' class="gone-title"' : ''}>${esc(e.title)}</span>`,
2612
+ s: `${esc(stamp(e.occurred_at))} · ${esc(e.type ?? e.kind)} · ${plural(e.event_count, 'evidence event')}`,
2613
+ v: entryMark(e),
2614
+ go: MH('entry', e.id),
2615
+ }));
2616
+ const cands = (d.candidates ?? []).map((c) => ({
2617
+ t: esc(c.name ?? c.slug),
2618
+ s: `${esc(c.domain ?? '—')} · ${Math.round((c.confidence ?? 0) * 100)}% confidence`,
2619
+ v: `<span class="pill${c.status === 'accepted' ? ' ok' : ''}">${esc(c.status)}</span>`,
2620
+ go: c.concept_slug ? LH('concept', c.concept_slug) : null,
2621
+ }));
2622
+ return fold('sess:mem', 'What the session remembered', plural(entries.length, 'observation'), lines(entries),
2623
+ { hint: 'Observations distilled from the evidence this session captured.' })
2624
+ + (cands.length
2625
+ ? fold('sess:cands', 'Concepts it proposed', plural(cands.length, 'candidate'), lines(cands),
2626
+ { hint: 'Derived from evidence, and still only a proposal — nothing here moved mastery.' })
2627
+ : '');
2628
+ }, { label: 'Loading what this session remembered…', cls: 'stack' });
2197
2629
 
2198
2630
  const back = `<a class="page__back" data-go="${esc(href(CUR.wf, 'sessions'))}">← Sessions</a>`;
2199
2631
  return [
@@ -2201,6 +2633,7 @@ function sessionBody(s) {
2201
2633
  `${esc(stamp(s.first))} → ${esc(stamp(s.last))}`,
2202
2634
  `${plural(work.length, 'concept')} logged`,
2203
2635
  s.asked.length ? `${s.passed}/${s.asked.length} right (${pct(s.passed, s.asked.length)}%)` : 'no questions',
2636
+ ...(s.cutAsked ? [`the newest ${s.cutAsked.shown} of ${s.cutAsked.total} answers`] : []),
2204
2637
  ...(s.mem ? [`${s.mem.events} captured${s.mem.redacted ? `, ${s.mem.redacted} redacted` : ''}`, `${s.mem.entries} remembered`] : []),
2205
2638
  ], `Session <code class="slug">${esc(s.id)}</code>. The learning half and the memory half are joined on the
2206
2639
  session id the host handed both — the one association between them that is recorded rather than inferred.`, back),
@@ -2209,14 +2642,18 @@ function sessionBody(s) {
2209
2642
  ? card('What the work taught', 'Each line is what the agent said the concept was for, in the code it wrote.', lines(work.map(conceptLine)))
2210
2643
  : '',
2211
2644
 
2212
- s.asked.length ? card('What you were asked', `${plural(s.asked.length, 'question')}, in order. Open one for the options and the feedback.`, qa) : '',
2645
+ s.asked.length
2646
+ ? card('What you were asked', s.cutAsked
2647
+ ? `The newest ${s.cutAsked.shown} of this session's ${s.cutAsked.total} questions, in order. Open one for the options and the feedback.`
2648
+ : `${plural(s.asked.length, 'question')}, in order. Open one for the options and the feedback.`, qa)
2649
+ : '',
2213
2650
 
2214
2651
  review.length
2215
2652
  ? fold('sess:review', 'Pulled in for review', plural(review.length, 'concept'), lines(review.map(conceptLine)),
2216
2653
  { hint: 'Not from this diff — the tutor widened to prerequisites and neighbours.' })
2217
2654
  : '',
2218
2655
 
2219
- s.mem ? `<div class="stack" id="sess-mem">${loader('Loading what this session remembered…')}</div>` : '',
2656
+ memory,
2220
2657
  ].join('');
2221
2658
  }
2222
2659
 
@@ -2229,7 +2666,6 @@ function viewProjects() {
2229
2666
  const inv = projectsInScope();
2230
2667
  const list = inv.filter((p) => p.learning.answers || p.learning.logged_concepts);
2231
2668
  const other = inv.filter((p) => !p.learning.answers && !p.learning.logged_concepts);
2232
- const daily = dailyInScope();
2233
2669
 
2234
2670
  // One fold per project. Its summary line is the standing — level, accuracy,
2235
2671
  // answers, last active — and it opens to the runway and thirty days of work.
@@ -2293,7 +2729,7 @@ function viewProjects() {
2293
2729
  return [
2294
2730
  pageHead('Projects', [
2295
2731
  plural(list.length, 'project'), `${assessed.length} assessed`,
2296
- plural(list.reduce((a, p) => a + p.learning.answers, 0), 'answer'), plural(streaks(daily).days, 'active day'),
2732
+ plural(list.reduce((a, p) => a + p.learning.answers, 0), 'answer'), plural(streaks().days, 'active day'),
2297
2733
  ], `Difficulty is earned per codebase, not per person: the same developer is a different learner on their
2298
2734
  fourth Node service and their first Rust one. Everyone starts at easy (tiers 1–2) and climbs on
2299
2735
  evidence — a promotion needs passing answers <em>and</em> accuracy <em>and</em> distinct concepts. A
@@ -2368,7 +2804,7 @@ function viewDomain(name) {
2368
2804
  { label:'Mastered', n:seen.filter((c) => c.mastered).length, color:'var(--spot)' },
2369
2805
  { label:'Learning', n:seen.filter((c) => !c.mastered).length, color:'color-mix(in srgb, var(--spot) 45%, var(--mass))' },
2370
2806
  { label:'Not asked', n:all.length - seen.length, color:'var(--mass)' },
2371
- ]) + `<div style="margin-top:var(--space-5)">${table([{t:'Concept'},{t:'Score'},{t:'Asked',n:1},{t:'State'}], rows)}${pager('domain', list.length, p)}</div>`),
2807
+ ]) + `<div style="margin-top:var(--space-5)" data-region="domain">${table([{t:'Concept'},{t:'Score'},{t:'Asked',n:1},{t:'State'}], rows)}${pager('domain', list.length, p)}</div>`),
2372
2808
  ].join('');
2373
2809
  }
2374
2810
 
@@ -2447,7 +2883,7 @@ function drawMemory(d) {
2447
2883
  }
2448
2884
  const inner = kids.get(e.session_id);
2449
2885
  if (e.kind === 'session_summary' && inner) {
2450
- out.push(`<li><details class="tl-item tl-session">
2886
+ out.push(`<li><details class="tl-item tl-session" data-key="session:${esc(e.session_id)}">
2451
2887
  <summary><span class="tl-time">${at ? esc(clock(at)) : '—'}</span>
2452
2888
  <span class="tl-title">${esc(e.title.replace(/^Session:\s*/, ''))}</span>
2453
2889
  <span class="tl-type">Session · ${plural(inner.length, 'observation')}</span></summary>
@@ -2507,7 +2943,7 @@ function viewMemory(param) {
2507
2943
  t.tag = CUR.query.get('tag') || 'all';
2508
2944
  t.since = CUR.query.get('from') || '';
2509
2945
  t.until = CUR.query.get('to') || '';
2510
- fill('mem-rows', '/api/memory?' + memoryQuery(type), drawMemory);
2946
+ const rows = slot('mem-rows', '/api/memory?' + memoryQuery(type), drawMemory, { label: 'Loading the timeline…', region: true });
2511
2947
 
2512
2948
  // A preset is only a `from` date, so the URL stays the one date format and a
2513
2949
  // shared link reads the same; anything else is a custom range.
@@ -2529,7 +2965,7 @@ function viewMemory(param) {
2529
2965
  `<div>
2530
2966
  <div class="filters">
2531
2967
  <div class="grow"><input type="search" id="mq" value="${esc(t.q)}" placeholder="Search memory" aria-label="Search memory"></div>
2532
- <select id="mtype" aria-label="Type">${typeOpts}</select>
2968
+ ${region('timeline-filters', `<select id="mtype" aria-label="Type">${typeOpts}</select>
2533
2969
  <select id="mtag" aria-label="Tag">
2534
2970
  <option value="all">All tags</option>
2535
2971
  ${(M.tags ?? []).map((x) => opt(x.tag, `${x.tag} (${x.n})`, t.tag === x.tag)).join('')}
@@ -2540,9 +2976,9 @@ function viewMemory(param) {
2540
2976
  ${opt('custom', 'Custom range', when === 'custom')}
2541
2977
  </select>
2542
2978
  ${when === 'custom' ? `<input type="date" id="mfrom" value="${esc(t.since)}" aria-label="From date">
2543
- <input type="date" id="mto" value="${esc(t.until)}" aria-label="To date">` : ''}
2979
+ <input type="date" id="mto" value="${esc(t.until)}" aria-label="To date">` : ''}`)}
2544
2980
  </div>
2545
- <div id="mem-rows">${loader('Loading the timeline…')}</div>
2981
+ ${rows}
2546
2982
  </div>`,
2547
2983
  ].join('');
2548
2984
  }
@@ -2551,19 +2987,19 @@ function viewMemoryDashboard() {
2551
2987
  const R = S.reuse, H = S.health, Q = H.queue, M = S.memory;
2552
2988
  const scope = () => memScope(new URLSearchParams());
2553
2989
  const q6 = scope(); q6.set('per', '6');
2554
- fill('mem-recent', '/api/memory?' + q6, (d) => lines(d.rows.map((e) => ({
2990
+ const recent = slot('mem-recent', '/api/memory?' + q6, (d) => lines(d.rows.map((e) => ({
2555
2991
  t: `<span${e.deleted_at || e.superseded_by ? ' class="gone-title"' : ''}>${esc(e.title)}</span>`,
2556
2992
  s: `${esc(stamp(e.occurred_at))} · ${esc(e.type ?? e.kind)} · ${esc(pname(e.project))}`,
2557
2993
  v: entryMark(e),
2558
2994
  go: MH('entry', e.id),
2559
- })), 'Nothing remembered in this scope yet.'));
2995
+ })), 'Nothing remembered in this scope yet.'), { label: 'Loading observations…' });
2560
2996
  const q5 = scope(); q5.set('per', '5');
2561
- fill('mem-sessions', '/api/memory/sessions?' + q5, (d) => lines(d.rows.map((m) => ({
2997
+ const recentSessions = slot('mem-sessions', '/api/memory/sessions?' + q5, (d) => lines(d.rows.map((m) => ({
2562
2998
  t: esc(pname(m.project, m.session_id)),
2563
2999
  s: `${m.events} captured · ${m.entries} remembered`,
2564
3000
  v: esc(ago(m.last)),
2565
3001
  go: MH('session', m.session_id),
2566
- })), 'No session has captured anything in this scope.'));
3002
+ })), 'No session has captured anything in this scope.'), { label: 'Loading sessions…' });
2567
3003
 
2568
3004
  // The pipeline in two lines: is reuse paying for itself, and is capture alive.
2569
3005
  // Each opens its page; the arithmetic lives there.
@@ -2586,9 +3022,9 @@ function viewMemoryDashboard() {
2586
3022
  memTiles(),
2587
3023
  card('Pipeline', null, pipeline),
2588
3024
  `<div class="g2">
2589
- ${card('Recent observations', null, `<div id="mem-recent">${loader('Loading observations…')}</div>`,
3025
+ ${card('Recent observations', null, recent,
2590
3026
  { aside: `<a class="link" data-go="${esc(MH('timeline'))}">Timeline →</a>` })}
2591
- ${card('Recent sessions', null, `<div id="mem-sessions">${loader('Loading sessions…')}</div>`,
3027
+ ${card('Recent sessions', null, recentSessions,
2592
3028
  { aside: `<a class="link" data-go="${esc(MH('sessions'))}">All sessions →</a>` })}
2593
3029
  </div>`,
2594
3030
  ].join('');
@@ -2600,7 +3036,7 @@ function viewMemorySessions() {
2600
3036
  q.set('page', t.page); q.set('per', PER);
2601
3037
  // The learning half of the same session, joined on its id, where there is one.
2602
3038
  const learned = new Map(sessions().map((x) => [x.id, x]));
2603
- fill('msess-rows', '/api/memory/sessions?' + q, (d) => {
3039
+ const rows = slot('msess-rows', '/api/memory/sessions?' + q, (d) => {
2604
3040
  t.page = d.page;
2605
3041
  return `<p class="counts" style="margin:0 0 var(--space-4)">${plural(d.total, 'session')} in scope</p>`
2606
3042
  + table([{t:'Session'},{t:'Remembered',n:1},{t:'Asked',n:1},{t:'When',n:1}],
@@ -2614,14 +3050,17 @@ function viewMemorySessions() {
2614
3050
  <td class="num">${m.entries}</td>
2615
3051
  <td class="num">${l && l.asked.length ? l.asked.length : '—'}</td>
2616
3052
  <td class="num slug">${esc(ago(m.last))}</td></tr>`;
2617
- })) + pager('msessions', d.total, d.page, d.per);
2618
- });
3053
+ })) + pager('msessions', d.total, d.page, d.per)
3054
+ // Asked is read from the answers the payload holds, so once those are cut a session older than them shows no count.
3055
+ + (cutAnswers() ? `<p class="slug" style="margin:var(--space-4) 0 0">Asked counts the most recent ${S.attempts_shown} of ${S.attempts_total} answers:
3056
+ a session older than those shows —, and the oldest one that is counted may show only part of its answers.</p>` : '');
3057
+ }, { label: 'Loading sessions…', region: true });
2619
3058
  return [
2620
3059
  pageHead('Sessions', [],
2621
3060
  `One row per session that captured evidence or wrote an observation. Pending is evidence no observation
2622
3061
  job has processed yet. Asked is the same session's questions, joined on its session id — a session can
2623
3062
  be remembered without ever being assessed.`),
2624
- card(null, null, `<div id="msess-rows">${loader('Loading sessions…')}</div>`),
3063
+ card(null, null, rows),
2625
3064
  ].join('');
2626
3065
  }
2627
3066
 
@@ -2660,7 +3099,7 @@ function drawEntry(d) {
2660
3099
  const files = jsonList(e.files);
2661
3100
  const link = (x) => `<a class="link" data-go="${esc(MH('entry', x.id))}">${esc(x.title)}</a> <span class="slug">${esc(fmtDate(x.occurred_at))}</span>`;
2662
3101
  const evidence = d.events.map((ev) => `
2663
- <details class="qa">
3102
+ <details class="qa" data-key="ev:${esc(ev.event_uid)}">
2664
3103
  <summary class="qa__meta">
2665
3104
  <span>${esc(ev.kind)}</span>${ev.tool ? `<span>${esc(ev.tool)}</span>` : ''}
2666
3105
  <span>${esc(stamp(ev.occurred_at))}</span><span>${esc(ev.source)}</span>
@@ -2722,10 +3161,10 @@ function drawEntry(d) {
2722
3161
  }
2723
3162
 
2724
3163
  function viewEntry(id) {
2725
- fill('entry-body', '/api/memory/entry?id=' + encodeURIComponent(id), (d) =>
2726
- d && d.entry ? drawEntry(d) : mascotMessage(`<p class="empty">No observation <code>${esc(id)}</code>.</p>`, 'notFound'));
2727
- return `<div><a class="page__back" data-go="${esc(MH('timeline'))}">← Timeline</a></div>
2728
- <div class="stack" id="entry-body">${loader('Loading the observation…')}</div>`;
3164
+ const body = slot('entry-body', '/api/memory/entry?id=' + encodeURIComponent(id), (d) =>
3165
+ d && d.entry ? drawEntry(d) : mascotMessage(`<p class="empty">No observation <code>${esc(id)}</code>.</p>`, 'notFound'),
3166
+ { label: 'Loading the observation…', cls: 'stack' });
3167
+ return `<div><a class="page__back" data-go="${esc(MH('timeline'))}">← Timeline</a></div>${body}`;
2729
3168
  }
2730
3169
 
2731
3170
  /* ---------- Reuse: the savings ledger, receipt by receipt ---------- */
@@ -2774,11 +3213,11 @@ function viewReuse() {
2774
3213
  ['D — delivered', R.delivered, 'estimated tokens actually injected'],
2775
3214
  ]),
2776
3215
  card('Every receipt', `${plural(rows.length, 'receipt')} in scope. One row per injection.`,
2777
- table([{t:'When'},{t:'Scope'},{t:'B',n:1},{t:'D',n:1},{t:'Saved',n:1},{t:'Delivery'}], body)
3216
+ region('receipts', table([{t:'When'},{t:'Scope'},{t:'B',n:1},{t:'D',n:1},{t:'Saved',n:1},{t:'Delivery'}], body)
2778
3217
  + pager('reuse', rows.length, p)
2779
3218
  + (R.receipts > R.receipts_shown
2780
3219
  ? `<p class="slug" style="margin:var(--space-4) 0 0">Showing the most recent ${R.receipts_shown} of ${R.receipts} receipts. The totals above cover all of them.</p>`
2781
- : '')),
3220
+ : ''))),
2782
3221
  ].join('');
2783
3222
  }
2784
3223
 
@@ -2874,7 +3313,7 @@ function viewHealth() {
2874
3313
  question carries the correction bar there.
2875
3314
  ============================================================ */
2876
3315
  const ARTIFACT_KINDS = ['all', 'explainer', 'artifact', 'to-correct'];
2877
- const artifactsInScope = () => (S.artifacts ?? []).filter((a) => inScope(a.project));
3316
+ const artifactsInScope = () => derived('artifacts', () => Object.freeze((S.artifacts ?? []).filter((a) => inScope(a.project))));
2878
3317
  const artifactUrl = (a) => '/artifacts/' + a.id.split('/').map(encodeURIComponent).join('/');
2879
3318
 
2880
3319
  // The thumbnail's URL names the ground, and the size busts its cache when a page is rewritten.
@@ -2894,7 +3333,7 @@ function artifactCard(a) {
2894
3333
  const tag = a.correction === 'open' ? ' · <span class="tag warn">To correct</span>'
2895
3334
  : a.correction === 'done' ? ' · <span class="tag ok">Corrected</span>' : '';
2896
3335
  return `<article class="art">
2897
- <a class="art__open" href="${esc(AH('view', a.id))}">
3336
+ <a class="art__open" href="${esc(AH('view', a.id))}" data-focus="art:${esc(a.id)}">
2898
3337
  <span class="art__thumb"><img src="${esc(thumbUrl(a, window.eklavyaGround.read()))}" data-thumb="${esc(thumbUrl(a, '@'))}" alt="" loading="lazy" decoding="async"></span>
2899
3338
  <span class="title">${esc(a.title)}</span></a>
2900
3339
  ${a.description ? `<p class="ctx">${esc(a.description)}</p>` : ''}
@@ -2945,12 +3384,13 @@ function viewArtifacts(param) {
2945
3384
  ['Explainers', counts.explainer, 'one concept each', '', AH('dashboard', 'explainer')],
2946
3385
  toCorrectTile(),
2947
3386
  ]),
3387
+ // The search box is outside every region: a keystroke replaces the gallery and leaves the box and the chips alone.
2948
3388
  card(null, null,
2949
3389
  `<div class="filters">
2950
3390
  <div class="grow"><input type="search" id="aq" value="${esc(t.q)}" placeholder="Search artifacts" aria-label="Search artifacts"></div>
2951
3391
  </div>
2952
- <div class="chips">${chip('all','All')}${chip('explainer','Explainers')}${chip('to-correct','To correct')}${chip('artifact','Other pages')}</div>
2953
- <div style="margin-top:var(--space-4)">${body}</div>`),
3392
+ ${region('artifacts-chips', `${chip('all','All')}${chip('explainer','Explainers')}${chip('to-correct','To correct')}${chip('artifact','Other pages')}`, 'class="chips"')}
3393
+ ${region('artifacts', body, 'style="margin-top:var(--space-4)"')}`),
2954
3394
  ].join('');
2955
3395
  }
2956
3396
 
@@ -2966,42 +3406,74 @@ function viewArtifact(id) {
2966
3406
  const a = (S.artifacts ?? []).find((x) => x.id === id);
2967
3407
  if (!a) return viewNotFound(id, false);
2968
3408
  if (a.correction) AFTER.push(() => loadFix(a));
2969
- // The page's own ground follows this one; a page made before the viewer ignores it.
3409
+ // The frame that follows the ground and the fallback for a page that never reports its height are made once, for
3410
+ // the frame, not once per draw of the screen around it: the frame is not drawn again with every redraw (below).
2970
3411
  AFTER.push(() => {
2971
3412
  const f = $('art-frame');
2972
- f?.addEventListener('load', () => f.contentWindow?.postMessage({ type:'eklavya:mode', mode:window.eklavyaGround.read() }, '*'));
2973
- });
2974
- AFTER.push(() => {
2975
- const f = $('art-frame'), gen = GEN;
2976
- setTimeout(() => { if (gen === GEN && !f.style.height) sized(f, '80vh'); }, 1500);
3413
+ if (!f || f.dataset.watched) return;
3414
+ f.dataset.watched = '1';
3415
+ setTimeout(() => { if (f.isConnected && !f.style.height) sized(f, '80vh'); }, 1500);
2977
3416
  });
2978
3417
  TABS.open(id);
2979
- TABS.reading = null;
3418
+ // A screen that is drawn again keeps its frame, so it keeps where its scroll is being remembered as well.
3419
+ if (!ADJUSTING) TABS.reading = null;
3420
+ // The frame is the box: it holds the height this tab last reported (else 80vh) from the first paint, so
3421
+ // the page reflows once, when the real height arrives, and not once for a loader and again for the frame.
3422
+ // A tab opened before has no loader: the frame fills the box as soon as its page paints, and the box is
3423
+ // already the right size. The question behind the correction bar is the same when the cache has it.
3424
+ //
3425
+ // The box and the bar are held (`data-hold`): once drawn, a redraw of this screen (new data, the ground) never
3426
+ // replaces them, because a frame that leaves the document loads its page again. The tab strip is a region, and
3427
+ // is the part of the screen that changes with the payload (a tab's title, its dot).
3428
+ const height = TABS.height.get(id);
3429
+ const asked = a.correction && respGet(fixUrl(a));
2980
3430
  return `${drawTabs(id)}
2981
- <div class="viewer__bar"><a href="${esc(artifactUrl(a))}" target="_blank" rel="noopener">${ICON_OUT}Open in new browser tab</a></div>
2982
- <div id="art-wait">${loader('Opening the page…')}</div>
2983
- <iframe class="viewer" id="art-frame" src="${esc(artifactUrl(a))}?embed" title="${esc(a.title)}" sandbox="${FRAME_SANDBOX}"></iframe>
2984
- ${a.correction ? `<div class="fixbar" id="fixbar" role="region" aria-label="Correction">${loader('Loading the question…')}</div>` : ''}`;
3431
+ <div class="viewer__bar"><a href="${esc(artifactUrl(a))}" target="_blank" rel="noopener" data-focus="art-open">${ICON_OUT}Open in new browser tab</a></div>
3432
+ <div class="viewer__box" data-hold>
3433
+ ${height ? '' : `<div id="art-wait">${loader('Opening the page…')}</div>`}
3434
+ <iframe class="viewer" id="art-frame" style="--wait-h:${esc(height ?? '80vh')}" src="${esc(artifactUrl(a))}?embed" title="${esc(a.title)}" sandbox="${FRAME_SANDBOX}"></iframe>
3435
+ </div>
3436
+ ${a.correction ? `<div class="fixbar" id="fixbar" role="region" aria-label="Correction" data-hold>${asked ? '' : loader('Loading the question…')}</div>` : ''}`;
2985
3437
  }
2986
3438
 
3439
+ /** The page's own ground follows this one; a page made before the viewer ignores it. Said when the frame loads and when the ground changes. */
3440
+ const frameMode = (f) => f?.contentWindow?.postMessage({ type:'eklavya:mode', mode:window.eklavyaGround.read() }, '*');
3441
+ // A `load` does not bubble, but a listener in the capture phase on an ancestor hears it, so one listener serves every frame there will be.
3442
+ $('view').addEventListener('load', (e) => { if (e.target.id === 'art-frame') frameMode(e.target); }, true);
3443
+
3444
+ const fixUrl = (a) => `/api/attempts/correction?id=${a.attempt}`;
3445
+
2987
3446
  /**
2988
- * Asks the server for the question behind the bar. Not `fill()`: a refusal
2989
- * shows the server's own words and a Retry, in the bar's one row.
3447
+ * Asks the server for the question behind the bar, or takes it from the cache. Not `slot()`: a refusal
3448
+ * shows the server's own words and a Retry, in the bar's one row, and the bar's markup is the viewer's.
3449
+ *
3450
+ * A bar that is already drawn for this question and says what the payload says (open, or corrected) is left exactly
3451
+ * as it is when the screen is drawn again for new data: it is the reader's, and may be open in front of the dialog.
3452
+ * One that disagrees is read again with the bar still showing and drawn over once the answer is in, with no loader.
2990
3453
  */
2991
3454
  function loadFix(a) {
2992
3455
  const gen = GEN;
2993
3456
  const bar = () => (gen === GEN ? $('fixbar') : null);
2994
- bar().className = 'fixbar';
2995
- bar().innerHTML = loader('Loading the question…');
2996
- fetch(`/api/attempts/correction?id=${a.attempt}`)
3457
+ const url = fixUrl(a);
3458
+ const drawn = bar().dataset.attempt === String(a.attempt);
3459
+ if (drawn && (a.correction === 'done') === (FIX?.q.corrected_at != null)) return;
3460
+ const kept = respGet(url);
3461
+ if (kept) return paintFix(bar(), a, kept.body);
3462
+ const at = respAt();
3463
+ if (!drawn) {
3464
+ bar().className = 'fixbar';
3465
+ bar().innerHTML = loader('Loading the question…');
3466
+ }
3467
+ fetch(url)
2997
3468
  .then((r) => r.json().catch(() => ({})).then((d) => {
2998
3469
  if (!r.ok) throw new Error(d.error || `${r.status} ${r.statusText}`);
3470
+ respPut(url, d, at);
2999
3471
  const el = bar();
3000
3472
  if (el) paintFix(el, a, d);
3001
3473
  }))
3002
3474
  .catch((e) => {
3003
3475
  const el = bar();
3004
- if (!el) return;
3476
+ if (!el || drawn) return;
3005
3477
  el.innerHTML = `<p class="fixbar__err">${esc(e.message)}</p><button type="button" class="set__btn" id="fix-retry">Retry</button>`;
3006
3478
  });
3007
3479
  }
@@ -3011,14 +3483,18 @@ function paintFix(el, a, d, fresh) {
3011
3483
  FIX = { attempt: a.attempt, artifact: a.id, q: d };
3012
3484
  const done = d.corrected_at !== null;
3013
3485
  armLeave(!done);
3486
+ el.dataset.attempt = String(a.attempt);
3014
3487
  el.className = `fixbar ${done ? 'is-done' : 'is-open'}${fresh ? ' is-fresh' : ''}`;
3015
3488
  const dot = document.querySelector('#view [aria-selected="true"] .tab__dot');
3016
3489
  if (dot) dot.className = `tab__dot is-${done ? 'done' : 'open'}`;
3490
+ // The button the reader is on (a bar drawn again over its own) is found again in the new one.
3491
+ const ui = captureUi(el);
3017
3492
  el.innerHTML = done
3018
3493
  ? `<p class="fixbar__done"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6L9 17l-5-5"/></svg>Corrected on try ${d.tries} <span>· ${esc(stamp(d.corrected_at))}</span></p>`
3019
3494
  : `<div class="fixbar__text"><p class="fixbar__lead">You missed this one.</p>
3020
3495
  <p class="fixbar__note">Read it, then correct your answer. Not now? It stays under To correct.</p></div>
3021
3496
  <button type="button" class="btn" id="fix-open">Correct your answer</button>`;
3497
+ restoreUi(el, ui);
3022
3498
  }
3023
3499
 
3024
3500
  /** The options in a new order each time: the page above shows the right one at its letter. */
@@ -3055,6 +3531,8 @@ function pickFix(btn) {
3055
3531
  all.forEach((b) => { b.disabled = true; });
3056
3532
  postJson('/api/attempts/retry', { attempt_id: FIX.attempt, picked: btn.dataset.pick })
3057
3533
  .then((r) => {
3534
+ // Tries went up, and a right pick closed the question: what the bar was drawn from is out of date.
3535
+ respForget('/api/attempts/correction');
3058
3536
  if (r.corrected) {
3059
3537
  btn.classList.add('is-right');
3060
3538
  btn.querySelector('.fix__tag').textContent = 'Right answer';
@@ -3062,9 +3540,8 @@ function pickFix(btn) {
3062
3540
  FIX.q = { ...FIX.q, tries: r.tries, corrected_at: new Date().toISOString() };
3063
3541
  const row = S.artifacts.find((x) => x.id === FIX.artifact);
3064
3542
  if (row) row.correction = 'done';
3065
- // Every other count on the page was drawn from the payload it booted with:
3066
- // say so now rather than waiting for the poll.
3067
- $('stale').hidden = false;
3543
+ // Every other count on the page (the tiles, the gallery's tag, the accuracy) follows by itself: the server
3544
+ // noticed this write and told the page, which reads the payload again and redraws in place.
3068
3545
  armLeave(false);
3069
3546
  $('fix-close').focus();
3070
3547
  return;
@@ -3124,6 +3601,7 @@ function armLeave(on) {
3124
3601
  */
3125
3602
  function sized(f, h) {
3126
3603
  f.style.height = h;
3604
+ TABS.height.set(CUR.param, h);
3127
3605
  $('art-wait')?.remove();
3128
3606
  if (TABS.reading === CUR.param) return;
3129
3607
  const want = TABS.scroll.get(CUR.param) ?? 0;
@@ -3218,7 +3696,11 @@ const TIP = {
3218
3696
  return node?.getClientRects().length && !(narrow.matches && side.contains(node)) ? [[t, node]] : [];
3219
3697
  });
3220
3698
  // Same features, same nodes: leave a bubble the reader opened alone.
3699
+ // Setting the hints closes the bubble that is open. A redraw that replaced some other feature's element is no reason to
3700
+ // close the one the reader is reading, so it is opened again when its tip still applies (`reopen`, below).
3701
+ let reopen = null;
3221
3702
  if (eligible.length !== this.shown.length || eligible.some(([t, n], i) => this.shown[i][0] !== t.id || this.shown[i][1] !== n)) {
3703
+ reopen = h.getActive()?.id ?? null;
3222
3704
  this.shown = eligible.map(([t, n]) => [t.id, n]);
3223
3705
  h.setHints(eligible.map(([t, n]) => ({
3224
3706
  id: t.id,
@@ -3245,6 +3727,11 @@ const TIP = {
3245
3727
  })));
3246
3728
  }
3247
3729
  h.show();
3730
+ if (reopen && eligible.some(([t]) => t.id === reopen)) {
3731
+ const was = document.activeElement;
3732
+ h.open(reopen);
3733
+ if (was?.isConnected && was !== document.body) was.focus({ preventScroll: true });
3734
+ }
3248
3735
  // Only the screen's first tip opens by itself, and only once; the rest stay beacons.
3249
3736
  const first = eligible[0];
3250
3737
  if (!first || st.opened.includes(first[0].id)) return;
@@ -3292,6 +3779,7 @@ const TABS = {
3292
3779
  ids: null, // open pages, in strip order
3293
3780
  recent: [], // the same ids, least recently viewed first
3294
3781
  scroll: new Map(), // id → scrollY
3782
+ height: new Map(), // id → the height its page last reported, the box it is opened in next time
3295
3783
  reading: null, // the tab whose scroll is being remembered
3296
3784
  focus: false, // put focus back on the selected tab after the next draw
3297
3785
  active: null, // the page the strip last showed as selected; new tabs open right of it
@@ -3326,6 +3814,7 @@ const TABS = {
3326
3814
  this.ids.splice(at, 1);
3327
3815
  this.recent = this.recent.filter((x) => x !== id);
3328
3816
  this.scroll.delete(id);
3817
+ this.height.delete(id);
3329
3818
  this.save();
3330
3819
  const next = this.ids[at] ?? this.ids[at - 1];
3331
3820
  return next ? AH('view', next) : AH('dashboard');
@@ -3358,7 +3847,9 @@ function drawTabs(active) {
3358
3847
  const dot = a?.correction ? `<i class="tab__dot is-${a.correction}" aria-hidden="true"></i>` : '';
3359
3848
  return tab(id, a?.title ?? id, dot, true);
3360
3849
  });
3361
- return `<div class="tabs" role="tablist" aria-label="Open artifacts">${tab(null, 'All artifacts', '', false)}${rows.join('')}</div>`;
3850
+ // The strip is a region of its own: what the payload changes in it (a title, a dot) is redrawn here, and the
3851
+ // frame under it is not touched.
3852
+ return `<div class="tabs" role="tablist" aria-label="Open artifacts" data-region="art-tabs">${tab(null, 'All artifacts', '', false)}${rows.join('')}</div>`;
3362
3853
  }
3363
3854
 
3364
3855
  /** Closes a tab. The visible one hands over to its neighbour; any other just leaves the strip, and the page stays put. */
@@ -3367,7 +3858,9 @@ function closeTab(id, fromKeys) {
3367
3858
  if (CUR.page === 'view' && CUR.param === id) { TABS.focus = fromKeys; location.replace(next); return; }
3368
3859
  const strip = document.querySelector('#view .tabs');
3369
3860
  const active = CUR.page === 'view' ? CUR.param : null;
3370
- strip.outerHTML = active || TABS.ids.length ? drawTabs(active) : '';
3861
+ const html = active || TABS.ids.length ? drawTabs(active) : '';
3862
+ strip.outerHTML = html;
3863
+ recordStrip(html);
3371
3864
  if (fromKeys) document.querySelector('#view [role="tab"][aria-selected="true"]')?.focus();
3372
3865
  }
3373
3866
 
@@ -3451,8 +3944,50 @@ function viewArtifactProjects() {
3451
3944
  registry of fields lives on the server (`SETTINGS` in dashboard.ts).
3452
3945
  ============================================================ */
3453
3946
  let SETS = null; // the last /api/settings response
3947
+ let SETS_AT = null; // what it was read under: `{ cursor, project, gen }`; null once something made it stale
3948
+ let DATA_GEN = 0; // counts the times what the page holds was dropped, so a read already out when that happens is not trusted
3454
3949
  const SH = (page, param, q) => href('settings', page, param, q);
3455
3950
  const settingsUrl = () => '/api/settings' + (PROJECT ? '?project=' + encodeURIComponent(PROJECT) : '');
3951
+ const settingsNow = () => ({ cursor: S.cursor, project: PROJECT, gen: DATA_GEN });
3952
+
3953
+ /** Keeps a response, and what it was read under. `at` is taken when the request is made, not when it lands. */
3954
+ function keepSettings(d, at) {
3955
+ SETS = d;
3956
+ SETS_AT = at;
3957
+ }
3958
+
3959
+ /**
3960
+ * Whether `SETS` can be drawn from as it is. One response holds both scopes (the user file, and the
3961
+ * project's when one was asked for), so it serves any tab of either, for as long as the project and
3962
+ * the cursor it was read under are still the page's, and nothing has dropped it since it was asked for.
3963
+ */
3964
+ const settingsFresh = () => !!SETS && !!SETS_AT && SETS_AT.cursor === S.cursor && SETS_AT.project === PROJECT && SETS_AT.gen === DATA_GEN;
3965
+
3966
+ /**
3967
+ * `SETS` is no longer drawn from: a project change (it holds one project's answer), and everything
3968
+ * `invalidateData()` does. `SETS` itself stays (its field list still serves a control being changed).
3969
+ * A read that was out when this ran is not trusted either: it may have been answered before whatever
3970
+ * made this necessary.
3971
+ */
3972
+ function invalidateSettings() {
3973
+ DATA_GEN += 1;
3974
+ SETS_AT = null;
3975
+ }
3976
+
3977
+ /**
3978
+ * Drops what the page holds that was read under data it has since replaced, so the next draw reads
3979
+ * it again: after a save, and when new data arrives (`adoptState`: the stream said the server's cursor
3980
+ * moved on, and the page's `S` is now the payload that goes with it). That is the settings read and every
3981
+ * answer in the response cache.
3982
+ *
3983
+ * A project change is not here. The cache's keys carry the project, so what was read for another
3984
+ * project is simply not asked for, and kept for when the reader switches back; only the one settings
3985
+ * read, which holds a single project's answer, is dropped (`invalidateSettings`).
3986
+ */
3987
+ function invalidateData() {
3988
+ invalidateSettings();
3989
+ RESP.clear();
3990
+ }
3456
3991
 
3457
3992
  const showValue = (v) =>
3458
3993
  v === true ? 'on' : v === false ? 'off' : v === null || v === undefined ? 'none'
@@ -3565,7 +4100,6 @@ const SW_ICON = {
3565
4100
  * what this scope's file actually sets.
3566
4101
  */
3567
4102
  function drawSettings(d, scope) {
3568
- SETS = d;
3569
4103
  const tabParam = (id) => (id === swTab(d.fields[0].group) ? null : id);
3570
4104
  const page = scope === 'project' ? 'project' : 'dashboard';
3571
4105
  const file = scope === 'project' ? d.project : d.user;
@@ -3578,8 +4112,10 @@ function drawSettings(d, scope) {
3578
4112
  const here = tabs.find((t) => t.id === CUR.param) ?? tabs[0];
3579
4113
 
3580
4114
  const scopeLink = (label, icon, to, current) =>
3581
- `<a class="sw__scope" href="${esc(to)}"${current ? ' aria-current="page"' : ''}>${svg(icon)}<span>${esc(label)}</span></a>`;
3582
- const rail = `<nav class="sw__scopes" aria-label="Settings scope">
4115
+ `<a class="sw__scope" href="${esc(to)}" data-focus="scope:${esc(label)}"${current ? ' aria-current="page"' : ''}>${svg(icon)}<span>${esc(label)}</span></a>`;
4116
+ // The rail is a region of its own: its links carry the tab (picking another scope keeps it), so
4117
+ // they are redrawn when the tab changes, while the element and the pane's neighbours stay.
4118
+ const rail = `<nav class="sw__scopes" aria-label="Settings scope" data-region="sw-rail">
3583
4119
  <p class="sw__cap">Everywhere</p>
3584
4120
  ${scopeLink('User', SW_ICON.user, SH('dashboard', tabParam(here.id)), scope === 'user')}
3585
4121
  <p class="sw__cap">Projects</p>
@@ -3594,17 +4130,19 @@ function drawSettings(d, scope) {
3594
4130
  <code>eklavya config set &lt;key&gt; &lt;value&gt;</code> (with <code>--project</code> for a project), and
3595
4131
  <b>Inherit</b> / <b>Reset</b> is <code>eklavya config unset</code>. Nothing is written inside a repository.`;
3596
4132
 
4133
+ // The pane is three regions and the rail a fourth, which never nest. A tab redraws the three it changes and keeps the
4134
+ // frame around them; new data redraws what changed, and leaves the body alone while the reader is using a control in it
4135
+ // (`swapRegions`), so the tab strip and the counts beside it are still brought up to date.
4136
+ const pane = (head, tabs, body) => `<div class="sw"><div class="sw__pane">${region('sw-head', head)}${region('sw-tabs', tabs)}${region('sw-body', body)}</div>${rail}</div>`;
4137
+
3597
4138
  if (!file) {
3598
- return `<div class="sw"><div class="sw__pane">${[
3599
- pageHead('Project settings', [], about),
3600
- nextStep(PROJECT
3601
- ? `<strong>${esc(scopeName())}</strong> has no git checkout on this machine, so there is no project file to change. Pick another project on the right.`
3602
- : 'Pick a project on the right to change its settings.'),
3603
- ].join('')}</div>${rail}</div>`;
4139
+ return pane(pageHead('Project settings', [], about) + nextStep(PROJECT
4140
+ ? `<strong>${esc(scopeName())}</strong> has no git checkout on this machine, so there is no project file to change. Pick another project on the right.`
4141
+ : 'Pick a project on the right to change its settings.'), '', '');
3604
4142
  }
3605
4143
 
3606
- const tabBar = `<nav class="sw__tabs" aria-label="Setting categories">${tabs.map((t) =>
3607
- `<a class="sw__tab" href="${esc(SH(page, tabParam(t.id)))}"${t.id === here.id ? ' aria-current="page"' : ''}>${esc(t.label)}${t.n ? `<i title="${t.n} set in this scope">${t.n}</i>` : ''}</a>`).join('')}</nav>`;
4144
+ const tabBar = `<nav class="sw__tabs" aria-label="Setting categories" data-keep-scroll="sw-tabs">${tabs.map((t) =>
4145
+ `<a class="sw__tab" href="${esc(SH(page, tabParam(t.id)))}" data-focus="tab:${esc(t.id)}"${t.id === here.id ? ' aria-current="page"' : ''}>${esc(t.label)}${t.n ? `<i title="${t.n} set in this scope">${t.n}</i>` : ''}</a>`).join('')}</nav>`;
3608
4146
 
3609
4147
  let body;
3610
4148
  if (here.id === 'terminal') {
@@ -3626,13 +4164,11 @@ function drawSettings(d, scope) {
3626
4164
  body = `<section class="card sw__list" aria-label="${esc(here.label)} settings">${here.fields.map((f) => settingRow(f, d, scope)).join('')}</section>`;
3627
4165
  }
3628
4166
 
3629
- return `<div class="sw"><div class="sw__pane">${[
4167
+ return pane(
3630
4168
  pageHead(scope === 'project' ? `${esc(d.project.name)} settings` : 'User settings',
3631
- [`<span class="mono">${esc(file.path)}</span>`, `${Object.keys(file.set).length} set here`], about),
3632
- d.problem ? nextStep(`<strong>A config file is not being read.</strong> ${esc(d.problem)}`) : '',
3633
- tabBar,
3634
- body,
3635
- ].join('')}</div>${rail}</div>`;
4169
+ [`<span class="mono">${esc(file.path)}</span>`, `${Object.keys(file.set).length} set here`], about, '', true)
4170
+ + (d.problem ? nextStep(`<strong>A config file is not being read.</strong> ${esc(d.problem)}`) : ''),
4171
+ tabBar, body);
3636
4172
  }
3637
4173
 
3638
4174
  /* ============================================================
@@ -3660,7 +4196,7 @@ const FB = {
3660
4196
  };
3661
4197
 
3662
4198
  /** A command in a copyable block, as the start-review commands are. */
3663
- const fbCmd = (text) => `<div class="cmd"><code>${esc(text)}</code><button type="button" class="cmd__copy" data-copy>Copy</button></div>`;
4199
+ const fbCmd = (text) => `<div class="cmd"><code>${esc(text)}</code><button type="button" class="cmd__copy" data-copy data-focus="copy:${esc(text)}">Copy</button></div>`;
3664
4200
 
3665
4201
  /** Why there is no item: the one reason, in plain words. */
3666
4202
  function fbEmpty(F) {
@@ -3685,13 +4221,13 @@ function fbEmpty(F) {
3685
4221
 
3686
4222
  function viewFeedbackDashboard() {
3687
4223
  const F = S.feedback;
3688
- if (F.pending) fill('fb-item', '/api/feedback', (d) => d.item ? drawFbItem(d.item) : fbEmpty(S.feedback));
4224
+ const item = F.pending ? slot('fb-item', '/api/feedback', (d) => d.item ? drawFbItem(d.item) : fbEmpty(S.feedback), { label: 'Loading your feedback…' }) : '';
3689
4225
  if (F.pending && !FB.counted) { FB.counted = true; AFTER.push(fbOpened); }
3690
4226
  const counts = [F.pending ? '1 waiting' : 'nothing waiting', plural(F.acknowledged, 'acknowledged item')];
3691
4227
  return [
3692
4228
  pageHead('Feedback', counts, FB.about),
3693
4229
  `<div class="fb__rule" id="fb-rule"><p>${esc(F.pending ? FB.rulePending : FB.ruleEmpty)}</p></div>`,
3694
- F.pending ? `<div id="fb-item">${loader('Loading your feedback…')}</div>` : fbEmpty(F),
4230
+ F.pending ? item : fbEmpty(F),
3695
4231
  fbTip(),
3696
4232
  `<span class="sr" id="copy-live" aria-live="polite"></span>`,
3697
4233
  ].join('');
@@ -3722,10 +4258,10 @@ function fbTipDismiss() {
3722
4258
  }
3723
4259
 
3724
4260
  function viewFeedbackItem(id) {
3725
- fill('fb-item', '/api/feedback?id=' + encodeURIComponent(id), (d) => drawFbItem(d.item));
4261
+ const item = slot('fb-item', '/api/feedback?id=' + encodeURIComponent(id), (d) => drawFbItem(d.item), { label: 'Loading your feedback…' });
3726
4262
  return [
3727
4263
  pageHead('Feedback item', [], FB.about, `<a class="page__back" data-go="${esc(href('feedback', 'history'))}">← History</a>`),
3728
- `<div id="fb-item">${loader('Loading your feedback…')}</div>`,
4264
+ item,
3729
4265
  `<span class="sr" id="copy-live" aria-live="polite"></span>`,
3730
4266
  ].join('');
3731
4267
  }
@@ -3754,7 +4290,7 @@ function drawFbItem(it) {
3754
4290
  <div class="fb__grid">
3755
4291
  <section><h2 class="fb__h">Your prompt</h2><blockquote class="fb__quote">${esc(it.prompt)}</blockquote></section>
3756
4292
  <section><h2 class="fb__h">A better prompt</h2>
3757
- <div class="cmd cmd--block"><code>${esc(it.better)}</code><button type="button" class="cmd__copy" data-copy>Copy</button></div></section>
4293
+ <div class="cmd cmd--block"><code>${esc(it.better)}</code><button type="button" class="cmd__copy" data-copy data-focus="copy:better">Copy</button></div></section>
3758
4294
  </div>
3759
4295
  ${review}
3760
4296
  <section><h2 class="fb__h">Tips</h2><ul class="fb__tips">${it.tips.map((t) => `<li>${esc(t)}</li>`).join('')}</ul></section>
@@ -3766,10 +4302,10 @@ function viewFeedbackHistory() {
3766
4302
  const p = new URLSearchParams();
3767
4303
  if (PROJECT !== null) p.set('project', PROJECT);
3768
4304
  p.set('page', T.fbhistory.page);
3769
- fill('fb-rows', '/api/feedback/list?' + p, drawFbHistory);
4305
+ const rows = slot('fb-rows', '/api/feedback/list?' + p, drawFbHistory, { label: 'Loading history…', cls: 'card card--flush', region: true });
3770
4306
  return [
3771
4307
  pageHead('History', [plural(S.feedback.acknowledged, 'acknowledged item')], FB.about),
3772
- `<div class="card card--flush" id="fb-rows">${loader('Loading history…')}</div>`,
4308
+ rows,
3773
4309
  ].join('');
3774
4310
  }
3775
4311
 
@@ -3802,7 +4338,8 @@ function fbOpened() {
3802
4338
 
3803
4339
  /** After a press: redraw from the new state, put focus on the heading and say so. */
3804
4340
  function fbDone(text) {
3805
- render();
4341
+ // What the screen is about has changed (the item is gone), so it is drawn afresh, from the top.
4342
+ render({ nav: true });
3806
4343
  const h = document.querySelector('#view h1');
3807
4344
  if (h) { h.tabIndex = -1; h.focus({ preventScroll: true }); }
3808
4345
  // The region is outside the view, so it exists before the text does.
@@ -3818,7 +4355,8 @@ function fbAcknowledge(btn) {
3818
4355
  if (btn.disabled) return;
3819
4356
  btn.disabled = true;
3820
4357
  postJson('/api/feedback/acknowledge', { id: +btn.dataset.fbAck })
3821
- .then((d) => { S.feedback = d.state; fbDone(FB.announce); })
4358
+ // The item, its history row and the next one waiting all changed: what was read of them is gone.
4359
+ .then((d) => { S.feedback = d.state; respForget('/api/feedback'); fbDone(FB.announce); })
3822
4360
  .catch((e) => { btn.disabled = false; fbFail(e); });
3823
4361
  }
3824
4362
 
@@ -3834,13 +4372,22 @@ function fbDelete(btn) {
3834
4372
  if (btn.disabled) return;
3835
4373
  btn.disabled = true;
3836
4374
  postJson('/api/feedback/delete', { id: +btn.dataset.fbYes })
3837
- .then((d) => { S.feedback = d.state; fbDone('Deleted.'); })
4375
+ .then((d) => { S.feedback = d.state; respForget('/api/feedback'); fbDone('Deleted.'); })
3838
4376
  .catch((e) => { btn.disabled = false; fbFail(e); });
3839
4377
  }
3840
4378
 
4379
+ /**
4380
+ * Drawn from `SETS` when it holds what is asked for, so a tab is instant and makes no request; read
4381
+ * from the server only when it is missing or stale.
4382
+ */
3841
4383
  function viewSettings(scope) {
3842
- fill('settings', settingsUrl(), (d) => drawSettings(d, scope));
3843
- return `<div id="settings">${loader('Reading your settings…')}</div>`;
4384
+ if (settingsFresh()) {
4385
+ AFTER.push(() => remember('settings'));
4386
+ return slotHost('settings', drawSettings(SETS, scope));
4387
+ }
4388
+ // Not what `SETS` holds, but possibly what the page read for this project earlier: the cache answers that.
4389
+ const at = settingsNow();
4390
+ return slot('settings', settingsUrl(), (d) => { keepSettings(d, at); return drawSettings(d, scope); }, { label: 'Reading your settings…' });
3844
4391
  }
3845
4392
 
3846
4393
  /** Reads a control back into the value its field stores. */
@@ -3867,8 +4414,9 @@ function postJson(path, body) {
3867
4414
  }
3868
4415
 
3869
4416
  /**
3870
- * Saves one setting and redraws the settings in place — not through `render()`,
3871
- * which would scroll to the top and lose the control the reader just used.
4417
+ * Saves one setting and redraws the settings in place, from what the server reads back, and gives
4418
+ * focus back to the control the reader used. It does not go through `render()`: a refused choice
4419
+ * has to be put back to what is on disk, and `render()` leaves markup alone that did not change.
3872
4420
  */
3873
4421
  function saveSetting(key, change) {
3874
4422
  const scope = CUR.page === 'project' ? 'project' : 'user';
@@ -3876,14 +4424,29 @@ function saveSetting(key, change) {
3876
4424
  const say = (text, warn) => { if (msg) { msg.innerHTML = text ? mascotStatus(text, warn ? 'error' : 'loading') : ''; msg.className = warn ? 'warn' : 'here'; } };
3877
4425
  fieldError(key, null);
3878
4426
  say('saving…');
3879
- const gen = GEN;
4427
+ // The screen the reader is on: another tab or scope is another screen, but a redraw of this one (new data arrives
4428
+ // while the save is out) is not, and must not stop the save from saying that it saved.
4429
+ const here = location.hash;
3880
4430
  postJson('/api/settings', { scope, project: SETS?.project?.id, key, ...change })
3881
- .then(() => fetch(settingsUrl()).then((r) => r.json()))
3882
- .then((d) => {
4431
+ .then(() => {
4432
+ // The file has changed: what was read before is stale from here, and read again now.
4433
+ invalidateData();
4434
+ const at = settingsNow(), url = settingsUrl();
4435
+ return fetch(url).then((r) => r.json()).then((d) => [d, at, url]);
4436
+ })
4437
+ .then(([d, at, url]) => {
4438
+ keepSettings(d, at);
4439
+ // What was just read is the page's answer for this URL too: the next visit to it is free.
4440
+ respPut(url, d, at);
3883
4441
  const el = $('settings');
3884
- if (!el || gen !== GEN) return;
4442
+ if (!el) return;
4443
+ // The reader moved to another tab while this was saving, and was shown what was read before it:
4444
+ // draw that tab again from what was just read.
4445
+ if (location.hash !== here) { if (CUR.wf === 'settings' && settingsFresh()) render(); return; }
3885
4446
  const focusId = document.activeElement?.id;
3886
- el.innerHTML = drawSettings(d, scope);
4447
+ const html = drawSettings(d, scope);
4448
+ el.innerHTML = html;
4449
+ recordFill('settings', html);
3887
4450
  focusable(el);
3888
4451
  const back = (focusId && $(focusId)) || document.querySelector(`[data-set="${CSS.escape(key)}"]`);
3889
4452
  back?.focus({ preventScroll: true });
@@ -3896,8 +4459,10 @@ function saveSetting(key, change) {
3896
4459
  // are kept, so the reader can fix them where they are.
3897
4460
  const el = document.querySelector(`[data-set="${CSS.escape(key)}"]`);
3898
4461
  const box = $('settings');
3899
- if (SETS && box && gen === GEN && (el?.type === 'checkbox' || el?.tagName === 'SELECT')) {
3900
- box.innerHTML = drawSettings(SETS, scope);
4462
+ if (SETS && box && location.hash === here && (el?.type === 'checkbox' || el?.tagName === 'SELECT')) {
4463
+ const html = drawSettings(SETS, scope);
4464
+ box.innerHTML = html;
4465
+ recordFill('settings', html);
3901
4466
  focusable(box);
3902
4467
  const again = document.querySelector(`[data-set="${CSS.escape(key)}"]`);
3903
4468
  (again && ($(again.id + '-combo') || again))?.focus({ preventScroll: true });
@@ -3936,6 +4501,12 @@ const svg = (paths, cls = '') =>
3936
4501
  * `{ parent, view }` for a detail page: a detail keeps its parent highlighted.
3937
4502
  * `query` lists the query keys that page reads, so a workflow switch or a link
3938
4503
  * to another page carries the project and drops filters that mean nothing there.
4504
+ * `inPage` says the page's `param` and `query` are tabs and filters of the one screen
4505
+ * (a Review tab, a concept state, a timeline type, a Settings category), so changing
4506
+ * them adjusts it. On any other page a different `param` is a different subject (another
4507
+ * concept, another entry), which is a navigation. `screen` names the page whose screen this
4508
+ * one is, when two routes draw the same one (user settings are `#/settings/user` and
4509
+ * `#/settings/dashboard`): going between them is no change of screen.
3939
4510
  */
3940
4511
  const WORKFLOWS = {
3941
4512
  learning: {
@@ -3946,9 +4517,9 @@ const WORKFLOWS = {
3946
4517
  ],
3947
4518
  pages: {
3948
4519
  dashboard: { label: 'Dashboard', view: () => viewLearningDashboard() },
3949
- concepts: { label: 'Concepts', view: (p) => viewConcepts(p), query: ['domain'] },
4520
+ concepts: { label: 'Concepts', view: (p) => viewConcepts(p), query: ['domain'], inPage: true },
3950
4521
  concept: { parent: 'concepts', view: (p) => viewConcept(p) },
3951
- review: { label: 'Review', view: (p) => viewReview(p) },
4522
+ review: { label: 'Review', view: (p) => viewReview(p), inPage: true },
3952
4523
  sessions: { label: 'Sessions', view: () => viewSessions() },
3953
4524
  session: { parent: 'sessions', view: (p) => viewSession(p) },
3954
4525
  projects: { label: 'Projects', view: () => viewProjects() },
@@ -3964,7 +4535,7 @@ const WORKFLOWS = {
3964
4535
  ],
3965
4536
  pages: {
3966
4537
  dashboard: { label: 'Dashboard', view: () => viewMemoryDashboard() },
3967
- timeline: { label: 'Timeline', view: (p) => viewMemory(p), query: ['tag', 'from', 'to'] },
4538
+ timeline: { label: 'Timeline', view: (p) => viewMemory(p), query: ['tag', 'from', 'to'], inPage: true },
3968
4539
  entry: { parent: 'timeline', view: (p) => viewEntry(p) },
3969
4540
  sessions: { label: 'Sessions', view: () => viewMemorySessions() },
3970
4541
  session: { parent: 'sessions', view: (p) => viewSession(p) },
@@ -3979,7 +4550,7 @@ const WORKFLOWS = {
3979
4550
  { id: 'library', label: 'Library', items: ['projects'] },
3980
4551
  ],
3981
4552
  pages: {
3982
- dashboard: { label: 'Dashboard', view: (p) => viewArtifacts(p) },
4553
+ dashboard: { label: 'Dashboard', view: (p) => viewArtifacts(p), inPage: true },
3983
4554
  view: { parent: 'dashboard', view: (p) => viewArtifact(p) },
3984
4555
  projects: { label: 'Projects', view: () => viewArtifactProjects() },
3985
4556
  },
@@ -3997,9 +4568,9 @@ const WORKFLOWS = {
3997
4568
  name: 'Settings',
3998
4569
  groups: [],
3999
4570
  pages: {
4000
- dashboard: { label: 'Preferences', view: () => viewSettings('user') },
4001
- user: { parent: 'dashboard', view: () => viewSettings('user') },
4002
- project: { parent: 'dashboard', view: () => viewSettings('project') },
4571
+ dashboard: { label: 'Preferences', view: () => viewSettings('user'), inPage: true },
4572
+ user: { parent: 'dashboard', view: () => viewSettings('user'), inPage: true, screen: 'dashboard' },
4573
+ project: { parent: 'dashboard', view: () => viewSettings('project'), inPage: true },
4003
4574
  },
4004
4575
  },
4005
4576
  };
@@ -4115,15 +4686,15 @@ function writeCollapsed(wf, set) {
4115
4686
  try { localStorage.setItem(collapseKey(wf), JSON.stringify([...set])); } catch (e) {}
4116
4687
  }
4117
4688
 
4118
- function navCounts(wf) {
4689
+ /** The numbers in the sidebar's badges, per workflow: a few lengths of lists kept for the scope (`derived`). */
4690
+ const navCounts = (wf) => derived('nav:' + wf, () => {
4119
4691
  const inv = projectsInScope();
4120
4692
  const sum = (f) => inv.reduce((a, p) => a + f(p), 0);
4121
4693
  if (wf === 'learning') {
4122
- const seen = seenConcepts();
4123
4694
  return {
4124
4695
  concepts: conceptsInScope().length,
4125
- review: seen.filter((c) => c.due).length,
4126
- sessions: sessions().filter((s) => s.logged.length || s.asked.length).length,
4696
+ review: seenConcepts().filter((c) => c.due).length,
4697
+ sessions: sessionTotal(),
4127
4698
  projects: inv.filter((p) => p.learning.answers || p.learning.logged_concepts).length,
4128
4699
  domains: new Set(conceptsInScope().map((c) => c.domain)).size,
4129
4700
  };
@@ -4139,16 +4710,35 @@ function navCounts(wf) {
4139
4710
  projects: inv.filter((p) => p.memory.events || p.memory.entries).length,
4140
4711
  reuse: sum((p) => p.memory.receipts),
4141
4712
  };
4713
+ });
4714
+
4715
+ /**
4716
+ * The collapsed group ids for `wf` as a navigation to `active` finds them. A direct link into a collapsed
4717
+ * group opens it: the reader has to be able to see where they are. A collapse they make afterwards still takes effect.
4718
+ */
4719
+ function collapsedFor(wf, active) {
4720
+ const collapsed = readCollapsed(wf);
4721
+ const home = WORKFLOWS[wf].groups.find((g) => g.items.includes(active));
4722
+ if (home && collapsed.has(home.id)) { collapsed.delete(home.id); writeCollapsed(wf, collapsed); }
4723
+ return collapsed;
4142
4724
  }
4143
4725
 
4726
+ /**
4727
+ * The sidebar for a navigation. It is built when the workflow changes (the first draw, and a switch to the
4728
+ * other workflow: another set of groups, links and icons) and kept otherwise: another page of the same workflow, a
4729
+ * project switch and a pager keep the elements, and `syncNav` brings the links, the current page, the counts and what
4730
+ * is collapsed in line in place. So a focused link stays focused and nothing in the rail is torn down to change one number.
4731
+ */
4144
4732
  function renderNav(wf, active) {
4733
+ const nav = $('nav');
4734
+ if (nav.dataset.workflow === wf) {
4735
+ syncNav(wf, active);
4736
+ syncCollapsed(collapsedFor(wf, active));
4737
+ return;
4738
+ }
4145
4739
  const W = WORKFLOWS[wf];
4146
4740
  const counts = navCounts(wf);
4147
- const collapsed = readCollapsed(wf);
4148
- // A direct link into a collapsed group opens it: the reader has to be able to
4149
- // see where they are. A collapse they make afterwards still takes effect.
4150
- const home = W.groups.find((g) => g.items.includes(active));
4151
- if (home && collapsed.has(home.id)) { collapsed.delete(home.id); writeCollapsed(wf, collapsed); }
4741
+ const collapsed = collapsedFor(wf, active);
4152
4742
 
4153
4743
  const item = (key) => {
4154
4744
  const pg = W.pages[key];
@@ -4157,8 +4747,8 @@ function renderNav(wf, active) {
4157
4747
  ${svg(ICONS[key] ?? ICONS.dashboard)}${esc(pg.label)}${n !== undefined ? `<i class="${key === 'review' && n ? 'hot' : ''}">${n}</i>` : ''}
4158
4748
  </a>`;
4159
4749
  };
4160
- $('nav').setAttribute('aria-label', `${W.name} sections`);
4161
- $('nav').innerHTML = `<div>${item('dashboard')}</div>` + W.groups.map((g) => {
4750
+ nav.setAttribute('aria-label', `${W.name} sections`);
4751
+ nav.innerHTML = `<div>${item('dashboard')}</div>` + W.groups.map((g) => {
4162
4752
  const open = !collapsed.has(g.id);
4163
4753
  const id = `grp-${wf}-${g.id}`;
4164
4754
  return `<div class="nav__group">
@@ -4168,6 +4758,35 @@ function renderNav(wf, active) {
4168
4758
  <div class="nav__items" id="${id}" role="group" aria-label="${esc(g.label)}"${open ? '' : ' hidden'}>${g.items.map(item).join('')}</div>
4169
4759
  </div>`;
4170
4760
  }).join('');
4761
+ nav.dataset.workflow = wf;
4762
+ }
4763
+
4764
+ /**
4765
+ * The sidebar's links, current page and counts, brought in line with the screen in place: the elements stay. The links
4766
+ * are written too, because they carry the project scope (`href`), which a navigation may have changed. An adjustment
4767
+ * (a tab, a chip, a keystroke) calls only this; a navigation also applies what is collapsed (`syncCollapsed`).
4768
+ */
4769
+ function syncNav(wf, active) {
4770
+ const counts = navCounts(wf);
4771
+ for (const a of $('nav').querySelectorAll('a[data-nav]')) {
4772
+ const key = a.dataset.nav;
4773
+ const to = href(wf, key);
4774
+ if (a.getAttribute('href') !== to) a.setAttribute('href', to);
4775
+ if (key === active) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current');
4776
+ const n = counts[key];
4777
+ const badge = a.querySelector('i');
4778
+ if (badge && n !== undefined) { badge.textContent = n; badge.className = key === 'review' && n ? 'hot' : ''; }
4779
+ }
4780
+ }
4781
+
4782
+ /** Opens and closes the rail's groups to match `collapsed`: a group the link in front of the reader sits in opens, and a collapse made in another tab applies. */
4783
+ function syncCollapsed(collapsed) {
4784
+ for (const tog of $('nav').querySelectorAll('[data-group]')) {
4785
+ const open = !collapsed.has(tog.dataset.group);
4786
+ if ((tog.getAttribute('aria-expanded') === 'true') === open) continue;
4787
+ tog.setAttribute('aria-expanded', String(open));
4788
+ $(tog.getAttribute('aria-controls')).hidden = !open;
4789
+ }
4171
4790
  }
4172
4791
 
4173
4792
  $('nav').addEventListener('click', (e) => {
@@ -4193,7 +4812,11 @@ function renderShell() {
4193
4812
  $(id).setAttribute('aria-label', `Eklavya, ${W.name} dashboard`);
4194
4813
  }
4195
4814
  $('wf-main').setAttribute('href', home);
4196
- $('wf-main').innerHTML = `${svg(ICONS[CUR.wf])}<span>${esc(W.name)}</span>`;
4815
+ // The workflow control's content is the workflow's alone: written when it changes, so its elements stay across pages.
4816
+ if ($('wf-main').dataset.workflow !== CUR.wf) {
4817
+ $('wf-main').innerHTML = `${svg(ICONS[CUR.wf])}<span>${esc(W.name)}</span>`;
4818
+ $('wf-main').dataset.workflow = CUR.wf;
4819
+ }
4197
4820
  $('wf-main').setAttribute('aria-label', `${W.name} dashboard`);
4198
4821
  $('topbar-wf').textContent = W.name;
4199
4822
  syncBadge();
@@ -4291,16 +4914,25 @@ let typed = '', typedAt = 0;
4291
4914
 
4292
4915
  function projOptions() { return [...pmenu.querySelectorAll('[role="option"]')]; }
4293
4916
 
4294
- /** Built once from the inventory; `syncProjects()` only moves the selection. */
4917
+ /**
4918
+ * Built from the inventory, and again only when what it lists changed (a new project, a renamed one, a checkout
4919
+ * that went): `syncProjects()` moves the selection. An open list keeps its focus on the same option.
4920
+ */
4921
+ let PROJECTS_LISTED = '';
4295
4922
  function buildProjects(inv) {
4923
+ const rows = inv.projects.map((p) => [p.id, p.name + (p.available === false ? ' (checkout gone)' : '')]);
4924
+ const listed = JSON.stringify(rows);
4925
+ if (listed === PROJECTS_LISTED) return;
4926
+ PROJECTS_LISTED = listed;
4296
4927
  const row = (value, name) =>
4297
4928
  `<button type="button" role="option" aria-selected="false" data-value="${esc(value)}" tabindex="-1">
4298
4929
  <span>${esc(name)}</span>
4299
4930
  <svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12l5 5 9-10"/></svg>
4300
4931
  </button>`;
4301
- pmenu.innerHTML = row('', 'All projects') +
4302
- inv.projects.map((p) => row(p.id, p.name + (p.available === false ? ' (checkout gone)' : ''))).join('');
4932
+ const on = pmenu.contains(document.activeElement) ? document.activeElement.dataset.value : null;
4933
+ pmenu.innerHTML = row('', 'All projects') + rows.map(([id, name]) => row(id, name)).join('');
4303
4934
  pbtn.disabled = false;
4935
+ if (on !== null) projOptions().find((o) => o.dataset.value === on)?.focus();
4304
4936
  }
4305
4937
 
4306
4938
  function syncProjects() {
@@ -4449,6 +5081,9 @@ function closeSelect(restore = true) {
4449
5081
  b.setAttribute('aria-expanded', 'false');
4450
5082
  TIP.sync();
4451
5083
  if ((restore || hadFocus) && b.isConnected) b.focus();
5084
+ // A redraw for new data that waited for this list to close (`unitsInUse`) is drawn now; focus coming back to the button
5085
+ // is no `focusout` of the view's, which is what asks otherwise.
5086
+ setTimeout(catchUp, 0);
4452
5087
  }
4453
5088
 
4454
5089
  function chooseOption(i) {
@@ -4509,7 +5144,7 @@ function switchWorkflow(wf) {
4509
5144
  // Reached from the unread badge: said so, so the count can tell the three ways in apart.
4510
5145
  const target = wf === 'feedback' && fbUnread() ? href(wf, 'dashboard', null, { via: 'badge' }) : href(wf, 'dashboard');
4511
5146
  closeDrawer(false);
4512
- if (location.hash === target) render(); else location.hash = target;
5147
+ if (location.hash === target) render({ nav: true }); else location.hash = target;
4513
5148
  (narrow.matches ? $('menu') : caret).focus();
4514
5149
  }
4515
5150
 
@@ -4615,51 +5250,424 @@ function syncFromUrl() {
4615
5250
  const project = r.query.get('project');
4616
5251
  const known = project !== null && INV.projects.some((p) => p.id === project);
4617
5252
  const next = known ? project : null;
4618
- if (next !== PROJECT) { PROJECT = next; for (const k of Object.keys(T)) T[k].page = 1; recomputeScope(); }
5253
+ if (next !== PROJECT) { PROJECT = next; for (const k of Object.keys(T)) T[k].page = 1; recomputeScope(); invalidateSettings(); }
4619
5254
  CUR = { wf: r.wf, page: r.page, param: r.param, query: r.query, missing: r.missing, bad: r.bad };
4620
5255
  syncProjects();
4621
5256
  render();
4622
5257
  }
4623
5258
 
4624
- function render(keepFocus) {
5259
+ /**
5260
+ * Draws the screen the URL names. There are two kinds of draw, and the difference is what
5261
+ * the reader is doing.
5262
+ *
5263
+ * A **navigation** is a different screen: another page, another workflow, another project,
5264
+ * another concept or entry. It brings the shell and the sidebar in line (the sidebar is built only when the
5265
+ * workflow changes, and updated in place otherwise: `renderNav`), replaces the view and scrolls to the top.
5266
+ *
5267
+ * An **adjustment** changes the screen the reader is on: a tab, a chip, a select, a date
5268
+ * range, a pager, a search keystroke, a resize, the ground. The URL still changes where it
5269
+ * says something, and Back still works, but nothing the reader was looking at moves. It keeps
5270
+ * the scroll position, leaves the shell alone, updates the sidebar's current page and counts in
5271
+ * place, and redraws only the regions of the view whose markup changed (`drawAdjusted`),
5272
+ * keeping focus, the caret and open folds. The view's markup is still the one pure function of
5273
+ * (payload, scope, controls) it always was.
5274
+ *
5275
+ * It is an adjustment when the screen on show is the same page (or another route to the same
5276
+ * screen, `screenOf`), in the same project, and either nothing in the URL moved or the page says
5277
+ * its `param` and `query` are filters of the one screen (`inPage`). A project switch is a
5278
+ * navigation: it changes every count in the sidebar and the crumb. `{ nav: true }` forces the
5279
+ * navigation for a caller that replaces what the screen is about (a feedback item that was just
5280
+ * acknowledged).
5281
+ */
5282
+ function render(opts) {
4625
5283
  const W = WORKFLOWS[CUR.wf];
4626
5284
  const pg = CUR.missing ? null : W.pages[CUR.page];
4627
5285
  const active = pg ? (pg.parent ?? CUR.page) : null;
5286
+ const adjusting = !opts?.nav && isAdjustment(pg);
4628
5287
  GEN += 1;
4629
- renderShell();
4630
- renderNav(CUR.wf, active);
4631
- const label = pg ? (W.pages[pg.parent ?? CUR.page].label) : 'Not found';
4632
- $('crumb').innerHTML = `<span>${esc(W.name)}</span><span class="dim">${esc(label)}</span><span class="dim">${esc(scopeName())}</span>`;
4633
- document.title = `${label} · ${W.name} — Eklavya`;
5288
+ if (adjusting) {
5289
+ syncNav(CUR.wf, active);
5290
+ syncBadge();
5291
+ } else {
5292
+ renderShell();
5293
+ renderNav(CUR.wf, active);
5294
+ const label = pg ? (W.pages[pg.parent ?? CUR.page].label) : 'Not found';
5295
+ $('crumb').innerHTML = `<span>${esc(W.name)}</span><span class="dim">${esc(label)}</span><span class="dim">${esc(scopeName())}</span>`;
5296
+ document.title = `${label} · ${W.name} — Eklavya`;
5297
+ // A bubble never survives a navigation.
5298
+ TIP.hints?.close();
5299
+ }
4634
5300
 
4635
5301
  AFTER.length = 0;
4636
5302
  LAZY.clear();
4637
- HEAT_OFF = 0;
5303
+ // The heatmap's page is where the reader paged it to, and new data does not turn it back.
5304
+ if (!LIVE_PASS) HEAT_OFF = 0;
4638
5305
  // Leaving the Feedback page ends the opening that fbOpened counted.
4639
5306
  if (!(CUR.wf === 'feedback' && CUR.page === 'dashboard')) FB.counted = false;
4640
- // A bubble never survives a navigation.
4641
- TIP.hints?.close();
4642
- // A screen that still has a correction open re-arms this when it draws its bar.
4643
- armLeave(false);
4644
- $('view').innerHTML = !pg ? viewNotFound('#/' + CUR.wf + '/' + CUR.page, false)
4645
- : CUR.bad ? viewNotFound(CUR.param, true)
4646
- : pg.view(CUR.param);
4647
- // A clickable row has to be reachable by Tab as well as by mouse.
4648
- focusable($('view'));
5307
+ // A screen that has a correction open re-arms this when it draws its bar. The bar of a screen that is
5308
+ // only being drawn again stays as it is (`data-hold`), and so does the prompt that goes with it.
5309
+ if (!adjusting) armLeave(false);
5310
+ ADJUSTING = adjusting;
5311
+ let html;
5312
+ try {
5313
+ html = !pg ? viewNotFound('#/' + CUR.wf + '/' + CUR.page, false)
5314
+ : CUR.bad ? viewNotFound(CUR.param, true)
5315
+ : pg.view(CUR.param);
5316
+ } finally {
5317
+ ADJUSTING = false;
5318
+ }
5319
+ if (adjusting) {
5320
+ drawAdjusted(html);
5321
+ } else {
5322
+ $('view').innerHTML = html;
5323
+ HELD_BACK.clear();
5324
+ // A clickable row has to be reachable by Tab as well as by mouse.
5325
+ focusable($('view'));
5326
+ DRAWN = { wf: CUR.wf, page: CUR.page, param: CUR.param, query: CUR.query.toString(), project: PROJECT, fault: !pg || !!CUR.bad, ...recordOf(html) };
5327
+ }
4649
5328
  for (const fn of AFTER) fn();
4650
5329
  TIP.sync();
4651
5330
 
4652
- if (keepFocus) {
4653
- const el = $(keepFocus.id);
4654
- if (el) { el.focus(); if (el.setSelectionRange && keepFocus.pos != null) el.setSelectionRange(keepFocus.pos, keepFocus.pos); }
4655
- } else {
4656
- window.scrollTo({ top: 0, behavior: 'instant' in window ? 'instant' : 'auto' });
4657
- }
5331
+ // A new screen opens at its top, at once. `html` asks for smooth scrolling (for in-page jumps), and a scroll that
5332
+ // does not name its behavior follows it, so a reader coming from the bottom of a long page would see the new
5333
+ // one glide up for half a second, while its content is already being drawn.
5334
+ if (!adjusting) window.scrollTo({ top: 0, behavior: 'instant' });
4658
5335
  // Which URL the screen was last drawn for. The tests wait on it rather than on
4659
5336
  // a timer, and it costs nothing to leave in.
4660
5337
  document.documentElement.dataset.rendered = location.hash;
4661
5338
  }
4662
5339
 
5340
+ /** The page a route's screen belongs to: itself, unless the registry says another route draws the same screen. */
5341
+ const screenOf = (wf, page) => WORKFLOWS[wf].pages[page]?.screen ?? page;
5342
+
5343
+ /** Whether drawing now changes the screen on show (see `render`) rather than showing another one. */
5344
+ function isAdjustment(pg) {
5345
+ const d = DRAWN;
5346
+ if (!d || d.fault || !pg || CUR.bad) return false;
5347
+ if (d.wf !== CUR.wf || screenOf(d.wf, d.page) !== screenOf(CUR.wf, CUR.page) || d.project !== PROJECT) return false;
5348
+ return !!pg.inPage || (d.param === CUR.param && d.query === CUR.query.toString());
5349
+ }
5350
+
5351
+ /* ---------- redrawing a screen without moving the reader ----------
5352
+ A view marks the parts a tab, chip, select or pager changes with
5353
+ `data-region="<id>"` (`region()`, or a fetched list's `slot()`). The markup with every region emptied is its *skeleton*. If
5354
+ the skeleton the view returns now equals the one the screen was drawn from, only the
5355
+ regions whose contents changed are replaced, each wholesale, in the element that is
5356
+ already on the page; nothing outside them is touched, so a head, a chart, a rail, a
5357
+ list being fetched or a text box in the skeleton keeps its identity. Anything else
5358
+ (no regions, a different set, a different skeleton) replaces the view's content, still
5359
+ without moving the scroll; a fetched list that is only the box this time (`slot()`) is then
5360
+ carried over from the old view, node for node, and stays dimmed until its answer draws. The markup the
5361
+ screen was drawn from, drawn again, replaces nothing. A part that must never leave the document (the viewer's
5362
+ frame, the bar of its correction) is marked `data-hold`: the skeleton leaves what it holds out, and a redraw
5363
+ that keeps the skeleton keeps it. A redraw for new data (`liveAdopt`) goes through the same path. */
5364
+
5365
+ /**
5366
+ * The regions of a screen's markup and what is left once they are emptied; no skeleton when it has none, or two
5367
+ * could not be told apart. `regions` is each one's markup as the view drew it, and what a swap draws. `settled`
5368
+ * is the same markup with every fold's open state and every search box's text taken out, and what is compared:
5369
+ * whether a fold is open is the reader's (`FOLD` holds it, and the page already shows it), so opening or closing
5370
+ * one is no change to the screen, and neither is typing in a search box (`T` holds the text, and so does the box).
5371
+ * Without that, the first adjustment after a toggle, or every keystroke, would see a different skeleton, or a
5372
+ * different region, and replace what the reader was looking at.
5373
+ */
5374
+ function scanRegions(html) {
5375
+ const none = { skeleton: null, regions: new Map(), settled: new Map(), calm: new Map() };
5376
+ if (!html.includes('data-region="')) return none;
5377
+ const t = document.createElement('template');
5378
+ t.innerHTML = html;
5379
+ const found = [...t.content.querySelectorAll('[data-region]')];
5380
+ const regions = new Map();
5381
+ for (const el of found) {
5382
+ if (regions.has(el.dataset.region) || el.parentElement?.closest('[data-region]')) return none;
5383
+ regions.set(el.dataset.region, el.innerHTML);
5384
+ }
5385
+ for (const d of t.content.querySelectorAll('details[data-fold]')) d.removeAttribute('open');
5386
+ // The text in a search box is the reader's too: it is what was typed, and the box on the page already holds it.
5387
+ // Without this, every keystroke would make the screen look changed and the whole view would be replaced under the caret.
5388
+ for (const i of t.content.querySelectorAll('input[type="search"]')) i.removeAttribute('value');
5389
+ const settled = new Map(found.map((el) => [el.dataset.region, el.innerHTML]));
5390
+ // `calm` is `settled` without the value of a date box: what a swap checks to tell a region whose box the reader is
5391
+ // typing a date into (`typingDate`) from one that changed.
5392
+ for (const i of t.content.querySelectorAll('input[type="date"]')) i.removeAttribute('value');
5393
+ const calm = new Map(found.map((el) => [el.dataset.region, el.innerHTML]));
5394
+ for (const el of found) el.replaceChildren();
5395
+ // A held element (`data-hold`) is part of the screen that a redraw never touches (the viewer's frame, whose page
5396
+ // would load again if it left the document): what it holds is whatever it came to hold, so it is not compared.
5397
+ for (const el of t.content.querySelectorAll('[data-hold]')) el.replaceChildren();
5398
+ return { skeleton: t.innerHTML, regions, settled, calm };
5399
+ }
5400
+ const recordOf = (html) => ({ html, ...scanRegions(html) });
5401
+
5402
+ /**
5403
+ * A fill drew `html` into `#id`, and the view returns that same markup itself from now on (the
5404
+ * answer is in the cache, and Settings draws from `SETS`): bring the record in line, so the regions
5405
+ * inside it are known and a redraw that returns it again finds nothing to change.
5406
+ */
5407
+ function recordFill(id, html) {
5408
+ if (!DRAWN) return;
5409
+ const t = document.createElement('template');
5410
+ t.innerHTML = DRAWN.html;
5411
+ const el = t.content.getElementById(id);
5412
+ if (!el) return;
5413
+ el.innerHTML = html;
5414
+ DRAWN = { ...DRAWN, ...recordOf(t.innerHTML) };
5415
+ }
5416
+
5417
+ /**
5418
+ * The tab strip of the Artifacts pages was replaced on the page by `html` (a tab closed; empty when the
5419
+ * last one went): the record is brought in line the same way, so the next adjustment finds the screen it
5420
+ * is drawing from as it stands, instead of a skeleton that still has the tab.
5421
+ */
5422
+ function recordStrip(html) {
5423
+ if (!DRAWN) return;
5424
+ const t = document.createElement('template');
5425
+ t.innerHTML = DRAWN.html;
5426
+ const strip = t.content.querySelector('.tabs');
5427
+ if (!strip) return;
5428
+ const now = document.createElement('template');
5429
+ now.innerHTML = html;
5430
+ strip.replaceWith(...now.content.childNodes);
5431
+ DRAWN = { ...DRAWN, ...recordOf(t.innerHTML) };
5432
+ }
5433
+
5434
+ /**
5435
+ * The regions whose redraw was put off because the reader was using a control in them (`swapRegions`): what is on screen
5436
+ * for each is older than the record of the screen, so the next draw that can replaces it, whether or not its markup
5437
+ * differs from the record's. A `focusout` asks for that draw at once (`catchUp`).
5438
+ */
5439
+ const HELD_BACK = new Set();
5440
+
5441
+ /** The date box whose `change` the page is acting on, for as long as it does: the reader is in it, whatever `document.activeElement` says. */
5442
+ let DATE_BOX = null;
5443
+
5444
+ /**
5445
+ * Whether `host`, a region, holds a date box the reader is typing in, and the markup that would replace it differs from
5446
+ * the one on screen in nothing but that box's value, which is what the box already says. A date box changes (and the page
5447
+ * writes the range into the URL) each time what is typed is a whole date, which is after the first digit of the year, so
5448
+ * the box that says so is the box the rest of the year is still going into: replacing it with a new one would put the
5449
+ * remaining digits in the first segment of an empty box.
5450
+ */
5451
+ function typingDate(host, id, prev, next) {
5452
+ // The box that just said it changed, else the one with focus: while a box moves from one part of the date to the next
5453
+ // (the month is complete) the document has no focused element for a moment, and that is when it says so.
5454
+ const box = DATE_BOX ?? document.activeElement;
5455
+ if (!(box instanceof HTMLInputElement) || box.type !== 'date' || !box.id || !host.contains(box)) return false;
5456
+ if (prev.calm.get(id) !== next.calm.get(id)) return false;
5457
+ const t = document.createElement('template');
5458
+ t.innerHTML = next.regions.get(id);
5459
+ return (t.content.getElementById(box.id)?.getAttribute('value') ?? null) === box.value;
5460
+ }
5461
+
5462
+ /**
5463
+ * Replaces, in `root`, the regions of `next` whose markup differs from `prev`'s (the record of the screen), each wholesale in
5464
+ * the element that is on the page, and nothing else. False, before anything is touched, when a region to replace is not
5465
+ * there. A region that is only the box and the loader never goes over what it shows (the fetch dims it); over a failure it
5466
+ * does (that is asking again). With `keep` (a redraw for new data), a region that holds a control the reader is using (the one with
5467
+ * focus, an open list, or one holding a value that is typed and not saved) is left as it is: a control that leaves the
5468
+ * document while it has focus is committed by the browser, and the reader has not said they are done. It is put off, not dropped.
5469
+ */
5470
+ function swapRegions(root, prev, next, keep) {
5471
+ const swaps = [];
5472
+ for (const [id, inner] of next.regions) {
5473
+ const host = root.querySelector(`[data-region="${id}"]`);
5474
+ // Unchanged markup is left alone, whatever the reader has done to what is on screen since (a fold opened, say).
5475
+ if (!HELD_BACK.has(id) && prev.settled.get(id) === next.settled.get(id)) {
5476
+ // Except that a request an earlier draw left out is over for it: its answer is in hand, and is what is on screen.
5477
+ if (host?.hasAttribute('aria-busy') && !isWait(inner)) slotDone(host);
5478
+ continue;
5479
+ }
5480
+ if (!host) return false;
5481
+ if (isWait(inner) && !host.hasAttribute('data-failed')) continue;
5482
+ // The reader's own typing is theirs, in any draw: the range they typed is what the box already says. (A region put off
5483
+ // for new data stays put off, to be drawn when focus leaves.)
5484
+ if (typingDate(host, id, prev, next)) continue;
5485
+ swaps.push([id, host, inner]);
5486
+ }
5487
+ for (const [id, host, inner] of swaps) {
5488
+ if (keep && unitsInUse(host).length) { HELD_BACK.add(id); continue; }
5489
+ HELD_BACK.delete(id);
5490
+ const ui = captureUi(host);
5491
+ host.innerHTML = inner;
5492
+ // Whatever an earlier request left on it (busy, a failure) was about the content it replaced.
5493
+ slotDone(host);
5494
+ focusable(host);
5495
+ restoreUi(host, ui);
5496
+ }
5497
+ return true;
5498
+ }
5499
+
5500
+ /** Whether two views' markup is the same once the browser has read both (a record that was filled in is in that form, a fresh one is not). */
5501
+ const sameMarkup = (a, b) => a === b || normalized(a) === normalized(b);
5502
+
5503
+ /**
5504
+ * The markup with each fetched region that is only its box this time, over one that shows an answer on screen, drawn as
5505
+ * what it shows: the answer stays, and the redraw finds nothing to change there. (`html` as it is when there is none.)
5506
+ */
5507
+ function withKept(html) {
5508
+ if (!html.includes(WAIT_OPEN)) return html;
5509
+ const t = document.createElement('template');
5510
+ t.innerHTML = html;
5511
+ let any = false;
5512
+ for (const el of t.content.querySelectorAll('[data-slot]')) {
5513
+ if (!el.querySelector(':scope > .slot__wait')) continue;
5514
+ const on = $(el.id);
5515
+ const was = on && holdsContent(on) ? drawnSlot(el.id) : null;
5516
+ if (was === null) continue;
5517
+ el.innerHTML = was;
5518
+ any = true;
5519
+ }
5520
+ return any ? t.innerHTML : html;
5521
+ }
5522
+
5523
+ /**
5524
+ * Draws an adjustment's markup: its changed regions in place, else the view's content; never touching the scroll.
5525
+ * A redraw for new data (`LIVE_PASS`) also draws nothing when the markup is what is on screen, and counts a fetched
5526
+ * region that is only its box as what it shows, so what the new data did not change is not touched.
5527
+ */
5528
+ function drawAdjusted(html) {
5529
+ const view = $('view');
5530
+ const prev = DRAWN;
5531
+ // The page is the same screen, though it may be reached by another route (`screen`): the record keeps the one on show.
5532
+ const at = { page: CUR.page, param: CUR.param, query: CUR.query.toString() };
5533
+ // Fetched regions that are only their box this time are what they show: the screen is the same where they are, and
5534
+ // it is the regions around them that may have changed. (A view's markup holds the box for every fetched region of
5535
+ // a screen whose answers were dropped with the old data, which is every screen on new data.)
5536
+ const shown = LIVE_PASS ? withKept(html) : html;
5537
+ // The markup the screen was drawn from, again: new data that changed nothing this screen shows replaces nothing in it,
5538
+ // not even where the view has no regions, and so the text the reader selected, the row they opened and the control
5539
+ // they are on stay (a region that failed is the exception: drawing again is asking again).
5540
+ if (LIVE_PASS && HELD_BACK.size === 0 && !view.querySelector('[data-failed]') && sameMarkup(shown, prev.html)) {
5541
+ DRAWN = { ...prev, ...at };
5542
+ return;
5543
+ }
5544
+ const next = scanRegions(shown);
5545
+ if (next.skeleton !== null && next.skeleton === prev.skeleton && swapRegions(view, prev, next, LIVE_PASS)) {
5546
+ DRAWN = { ...prev, ...at, html: shown, ...next };
5547
+ return;
5548
+ }
5549
+ // The view is replaced; a fetched region that is only the box this time still shows what it did, moved
5550
+ // into the new element (the same nodes, so whatever the reader had open or focused in them is still there).
5551
+ const held = new Map();
5552
+ for (const el of view.querySelectorAll('[data-slot]')) if (holdsContent(el)) held.set(el.id, el);
5553
+ // What the old view held that never leaves the document (the viewer's frame, and the bar of its correction) goes
5554
+ // with it: the prompt that guarded the open correction, and the scroll that was waiting for the frame, are over.
5555
+ if (view.querySelector('[data-hold]')) { armLeave(false); TABS.reading = null; }
5556
+ const ui = captureUi(view);
5557
+ view.innerHTML = html;
5558
+ HELD_BACK.clear();
5559
+ for (const el of view.querySelectorAll('[data-slot]')) {
5560
+ const was = held.get(el.id);
5561
+ if (was && el.querySelector(':scope > .slot__wait')) el.replaceChildren(...was.childNodes);
5562
+ }
5563
+ focusable(view);
5564
+ restoreUi(view, ui);
5565
+ // What is on screen is `shown`: the regions that kept their answer carry it in the record too, so the answer that
5566
+ // arrives for them is compared with what they show, and the same one replaces nothing.
5567
+ DRAWN = { ...prev, ...at, html: shown, ...next };
5568
+ }
5569
+
5570
+ /** A control the reader was using has been let go of: what was put off for it is drawn now, unless they are on another one. */
5571
+ function catchUp() {
5572
+ if (!HELD_BACK.size || !DRAWN) return;
5573
+ LIVE_PASS = true;
5574
+ try {
5575
+ render();
5576
+ } finally {
5577
+ LIVE_PASS = false;
5578
+ }
5579
+ }
5580
+
5581
+ /**
5582
+ * What a control is called when it is looked for again in redrawn markup: its id, else the first of these it has (Copy
5583
+ * names the command it copies, and the Feedback buttons the item they act on), else, for a <summary>, the <details> it
5584
+ * opens (`detailSelector`). A control with none is not carried over a redraw, so every control the reader can Tab to has one.
5585
+ */
5586
+ const FOCUS_KEYS = ['data-focus', 'data-tab', 'data-tab-id', 'data-state', 'data-unset', 'data-go',
5587
+ 'data-fb-ack', 'data-fb-del', 'data-fb-yes', 'data-fb-no', 'data-fb-tip-dismiss'];
5588
+ function focusKey(el) {
5589
+ if (el.id) return `#${CSS.escape(el.id)}`;
5590
+ for (const a of FOCUS_KEYS) {
5591
+ const v = el.getAttribute(a);
5592
+ if (v !== null) return `[${a}="${CSS.escape(v)}"]`;
5593
+ }
5594
+ const sel = el.tagName === 'SUMMARY' && el.parentElement?.tagName === 'DETAILS' ? detailSelector(el.parentElement) : null;
5595
+ return sel ? `${sel} > summary` : null;
5596
+ }
5597
+ /** The stable key of a <details>: a fold's id, a timeline entry's id, or an explicit `data-key`. */
5598
+ const detailKey = (d) => (d.dataset.fold ? 'fold:' + d.dataset.fold : d.dataset.entry ? 'entry:' + d.dataset.entry : d.dataset.key ? 'key:' + d.dataset.key : null);
5599
+ /** A selector for the <details> that `detailKey` names, to look it (or its summary) up again; null when it has no key. */
5600
+ function detailSelector(d) {
5601
+ for (const a of ['data-fold', 'data-entry', 'data-key']) {
5602
+ const v = d.getAttribute(a);
5603
+ if (v !== null) return `details[${a}="${CSS.escape(v)}"]`;
5604
+ }
5605
+ return null;
5606
+ }
5607
+
5608
+ /**
5609
+ * What the reader is in the middle of inside `root`, taken before it is redrawn: the focused
5610
+ * element with its caret or selection, every open <details> that has a stable key, and the
5611
+ * offset of a strip marked `data-keep-scroll`. `restoreUi` puts them back on the new markup.
5612
+ * The page's scroll is not here because a redraw never moves it, and the lazy charts are not
5613
+ * because the draw's own hooks queue them again (`LAZY`). `liveAdopt` redraws through this pair too.
5614
+ */
5615
+ function captureUi(root) {
5616
+ const ui = { focus: null, open: new Set(), scrolls: new Map() };
5617
+ // A strip that scrolls sideways (the Settings tabs on a phone) keeps its offset, so the tab just
5618
+ // pressed does not slide out of sight.
5619
+ for (const el of root.querySelectorAll('[data-keep-scroll]')) ui.scrolls.set(el.dataset.keepScroll, el.scrollLeft);
5620
+ const el = document.activeElement;
5621
+ if (el && root.contains(el)) {
5622
+ const key = focusKey(el);
5623
+ if (key) {
5624
+ let sel = null;
5625
+ // Only a text control has a caret; reading it throws on some other inputs.
5626
+ try { if (typeof el.selectionStart === 'number') sel = [el.selectionStart, el.selectionEnd, el.selectionDirection]; } catch (e) { /* none */ }
5627
+ ui.focus = { key, sel };
5628
+ }
5629
+ }
5630
+ for (const d of root.querySelectorAll('details[open]')) { const k = detailKey(d); if (k) ui.open.add(k); }
5631
+ return ui;
5632
+ }
5633
+
5634
+ /**
5635
+ * What in `root` the reader is using, for a redraw that is about new data and not about them. The Settings controls: the
5636
+ * one with focus (the box, or the button that stands in for a select), the one whose list is open, and any that holds
5637
+ * what was typed and not saved (a number field, a text box, a list, or a choice a refusal put back); each is a `.set__ctl`, the control with its own
5638
+ * error line. And, anywhere, the list of a select that is open (its button, which the list would be left without) and a date box with focus
5639
+ * (a date typed into it is not a value yet): a redraw that replaced either would close the list, or start the date again.
5640
+ */
5641
+ function unitsInUse(root) {
5642
+ const used = [];
5643
+ for (const unit of root.querySelectorAll('.set__ctl')) {
5644
+ const c = unit.querySelector('[data-set]');
5645
+ if (!c) continue;
5646
+ const edited = c.type === 'checkbox' ? c.checked !== c.defaultChecked
5647
+ : c.tagName === 'SELECT' ? [...c.options].some((o) => o.selected !== o.defaultSelected)
5648
+ : c.value !== c.defaultValue;
5649
+ // The list of a select that is open has the focus, not the button it opened from.
5650
+ if (edited || unit.contains(document.activeElement) || (sOpen && unit.contains(sOpen))) used.push(unit);
5651
+ }
5652
+ if (sOpen && root.contains(sOpen) && !used.some((u) => u.contains(sOpen))) used.push(sOpen);
5653
+ const box = document.activeElement;
5654
+ if (box instanceof HTMLInputElement && box.type === 'date' && root.contains(box)) used.push(box);
5655
+ return used;
5656
+ }
5657
+
5658
+ function restoreUi(root, ui) {
5659
+ for (const el of root.querySelectorAll('[data-keep-scroll]')) el.scrollLeft = ui.scrolls.get(el.dataset.keepScroll) ?? 0;
5660
+ for (const d of root.querySelectorAll('details:not([open])')) { const k = detailKey(d); if (k && ui.open.has(k)) d.open = true; }
5661
+ if (!ui.focus) return;
5662
+ let el = null;
5663
+ try { el = root.querySelector(ui.focus.key); } catch (e) { /* a key that is not a selector finds nothing */ }
5664
+ // The Next button of the last page is disabled and cannot hold focus: land on the page you are on.
5665
+ if (el?.disabled) el = el.closest('.pager')?.querySelector('[aria-current]') ?? null;
5666
+ if (!el) return;
5667
+ el.focus({ preventScroll: true });
5668
+ if (ui.focus.sel) { try { el.setSelectionRange(...ui.focus.sel); } catch (e) { /* not a text control any more */ } }
5669
+ }
5670
+
4663
5671
  /* ---------- events, all delegated ---------- */
4664
5672
  /** Navigates, re-rendering in place when the hash is already what we want. */
4665
5673
  function goTo(hash) {
@@ -4680,6 +5688,8 @@ function setQuery(changes) {
4680
5688
  }
4681
5689
 
4682
5690
  function onActivate(e) {
5691
+ // A region waiting for its next answer is dimmed and out of the pointer's reach (CSS); the keyboard is held back the same way.
5692
+ if (e.target.closest('[data-slot][aria-busy="true"][data-kept]')) return;
4683
5693
  const copy = e.target.closest('[data-copy]');
4684
5694
  if (copy) return copyCommand(copy);
4685
5695
  if (e.target.closest('[data-fb-tip-dismiss]')) return fbTipDismiss();
@@ -4699,8 +5709,7 @@ function onActivate(e) {
4699
5709
  if (arr) {
4700
5710
  if (arr.disabled) return;
4701
5711
  HEAT_OFF = Math.max(0, HEAT_OFF + (arr.id === 'heat-prev' ? 1 : -1) * +arr.dataset.weeks);
4702
- const st = streaks(dailyInScope());
4703
- return chartHeatmap($('c-heat'), dailyInScope(), st.run);
5712
+ return chartHeatmap($('c-heat'));
4704
5713
  }
4705
5714
  const pg = e.target.closest('[data-page]');
4706
5715
  if (pg && !pg.disabled) { T[pg.dataset.pager].page = +pg.dataset.page; render(); return; }
@@ -4748,20 +5757,24 @@ $('view').addEventListener('input', (e) => {
4748
5757
  if (e.target.id === 'q') {
4749
5758
  T.concepts.q = e.target.value;
4750
5759
  T.concepts.page = 1;
4751
- render({ id:'q', pos:e.target.selectionStart });
5760
+ render();
4752
5761
  }
4753
5762
  if (e.target.id === 'aq') {
4754
5763
  T.artifacts.q = e.target.value;
4755
5764
  T.artifacts.page = 1;
4756
- render({ id:'aq', pos:e.target.selectionStart });
5765
+ render();
4757
5766
  }
4758
5767
  if (e.target.id === 'mq') {
4759
5768
  T.memory.q = e.target.value;
4760
5769
  T.memory.page = 1;
4761
- render({ id:'mq', pos:e.target.selectionStart });
5770
+ render();
4762
5771
  }
4763
5772
  });
4764
5773
 
5774
+ // A control the reader was using has been let go of (a later focus is checked by `catchUp`): what a redraw for new data
5775
+ // put off for it is drawn now.
5776
+ $('view').addEventListener('focusout', () => setTimeout(catchUp, 0));
5777
+
4765
5778
  // Settings: a control saves on change (a text box on blur or Enter), and the
4766
5779
  // Inherit / Reset button removes the key from that scope's file.
4767
5780
  $('view').addEventListener('click', (e) => {
@@ -4795,8 +5808,11 @@ $('view').addEventListener('change', (e) => {
4795
5808
  if (v === 'custom') setQuery({ from: CUR.query.get('from') || daysAgo(6), to: daysAgo(0) });
4796
5809
  else setQuery({ from: v === 'any' ? null : daysAgo(WHEN[v]), to: null });
4797
5810
  }
4798
- if (e.target.id === 'mfrom') { T.memory.page = 1; setQuery({ from: e.target.value }); }
4799
- if (e.target.id === 'mto') { T.memory.page = 1; setQuery({ to: e.target.value }); }
5811
+ if (e.target.id === 'mfrom' || e.target.id === 'mto') {
5812
+ T.memory.page = 1;
5813
+ DATE_BOX = e.target;
5814
+ try { setQuery({ [e.target.id === 'mfrom' ? 'from' : 'to']: e.target.value }); } finally { DATE_BOX = null; }
5815
+ }
4800
5816
  });
4801
5817
 
4802
5818
  addEventListener('hashchange', () => { if (INV) { closePicker(false); syncFromUrl(); } });
@@ -4804,73 +5820,41 @@ addEventListener('hashchange', () => { if (INV) { closePicker(false); syncFromUr
4804
5820
  let resizeTimer = 0;
4805
5821
  addEventListener('resize', () => {
4806
5822
  clearTimeout(resizeTimer);
4807
- // Not the viewer: a re-render would reload the framed page and lose its scroll.
4808
- resizeTimer = setTimeout(() => { if (S && CUR.page !== 'view') render({ id:'q', pos:null }); }, 120);
5823
+ // Not the viewer: nothing on it depends on the width, and its frame reports its own height.
5824
+ resizeTimer = setTimeout(() => { if (S && CUR.page !== 'view') render(); }, 120);
4809
5825
  });
4810
5826
 
4811
5827
  document.querySelectorAll('[data-ground]').forEach((b) => {
4812
- b.addEventListener('click', () => { window.eklavyaGround.set(b.dataset.ground); if (S) render(); });
5828
+ b.addEventListener('click', () => {
5829
+ window.eklavyaGround.set(b.dataset.ground);
5830
+ if (S) render();
5831
+ // The frame is not drawn again with the screen, so its page is told the ground itself.
5832
+ frameMode($('art-frame'));
5833
+ });
4813
5834
  });
4814
5835
 
4815
- /* ---------- staying honest about how old this page is ---------- */
4816
- // ponytail: a poll, not SSE. Ceiling: a change shows up to POLL_MS late. Each
4817
- // tick asks `/api/cursor` for the one string, which costs a single-row read and
4818
- // a stat of the artifact files, never the payload. The upgrade is SSE over the
4819
- // same cursor (PRD DASH-01), so that swap is this function, not the page.
4820
- const POLL_MS = 60000;
4821
- let polling = false;
4822
-
4823
- /**
4824
- * Announced, never silent, and that is the whole design.
4825
- *
4826
- * A silent re-render moves the table the reader is halfway down, scrolls them
4827
- * back to the top and drops the caret out of the search box — the page would
4828
- * be fighting the person reading it. So the poll touches nothing but a button:
4829
- * the view keeps rendering from the payload it booted with until the reader
4830
- * asks for the new one. That also means there is no live state to reconcile
4831
- * with a view derived from the old snapshot; the reload does that.
4832
- */
4833
- function poll() {
4834
- // Nothing to do if we never loaded, a request is already out, the notice is
4835
- // already up, or nobody is looking — a background tab must not poll at all.
4836
- if (!S || polling || !$('stale').hidden || document.visibilityState !== 'visible') return;
4837
- polling = true;
4838
- fetch('/api/cursor')
4839
- .then((r) => r.json())
4840
- .then((s) => { if (s.cursor !== S.cursor) $('stale').hidden = false; })
4841
- // A failed poll changes nothing: the page keeps working on the data it has.
4842
- // One unreachable server must not blank a dashboard that is merely stale.
4843
- .catch(() => {})
4844
- .then(() => { polling = false; });
4845
- }
4846
-
4847
- setInterval(poll, POLL_MS);
4848
- // Coming back to a tab left open all afternoon should not mean waiting out the
4849
- // interval before the page admits it is stale.
4850
- addEventListener('visibilitychange', poll);
4851
- $('stale').addEventListener('click', () => location.reload());
4852
-
4853
- /* ---------- boot ---------- */
4854
- // The head script runs before the toggle exists, so its aria-pressed state has
4855
- // to be applied again once the markup is there — otherwise a reader on paper is
4856
- // shown "ink" as the active ground.
4857
- window.eklavyaGround.apply(window.eklavyaGround.read());
4858
-
4859
5836
  const json = (url) => fetch(url).then((r) => {
4860
5837
  if (!r.ok) throw new Error(`${url} answered ${r.status}`);
4861
5838
  return r.json();
4862
5839
  });
4863
5840
 
4864
- Promise.all([json('/api/state'), json('/api/projects')]).then(([s, inv]) => {
4865
- S = s;
4866
- INV = inv;
5841
+ /**
5842
+ * What boot writes from `S` and `INV` outside the view, and a redraw for new data writes again: the footer's database
5843
+ * path and the line that discloses a cap, the dial line, the project picker and the tips switch. `adoptState` does
5844
+ * not: it swaps the data and leaves the screen to the caller.
5845
+ */
5846
+ function paintChrome(s, inv) {
4867
5847
  $('dbline').textContent = s.db_path;
4868
- // Totals are aggregated in SQL over the whole table, so a cap can only
4869
- // shorten a history list — but say so rather than let it look complete.
4870
- if (s.attempts_shown < s.attempts_total) {
4871
- $('foot-note').textContent =
4872
- ` Question histories show the most recent ${s.attempts_shown} of ${s.attempts_total} answers.`;
4873
- }
5848
+ // Totals, the per-day rows, the concept catalogue and the project inventory are aggregated in SQL over the whole
5849
+ // tables, so a cap can only shorten a list of rows — but say which, rather than let it look complete.
5850
+ $('foot-note').textContent = [
5851
+ s.attempts_shown < s.attempts_total
5852
+ ? `Question histories show the most recent ${s.attempts_shown} of ${s.attempts_total} answers.` : '',
5853
+ s.logged_shown < s.logged_total
5854
+ ? `Logged context lines show the most recent ${s.logged_shown} of ${s.logged_total}.` : '',
5855
+ s.attempts_shown < s.attempts_total || s.logged_shown < s.logged_total
5856
+ ? 'The Sessions list shows the most recent sessions; each session opens whole.' : '',
5857
+ ].filter(Boolean).map((t) => ` ${t}`).join('');
4874
5858
  const c = s.config;
4875
5859
  // Same rule as the status bar in `statusline.ts`: `enforced` is a word worth
4876
5860
  // a slot, unenforced quizzing is not -- it was the default on three bars in
@@ -4890,10 +5874,132 @@ Promise.all([json('/api/state'), json('/api/projects')]).then(([s, inv]) => {
4890
5874
  // captured memory, is a project and is listed.
4891
5875
  buildProjects(inv);
4892
5876
  TIP.paint();
5877
+ }
5878
+
5879
+ /* ---------- live: the open page is told, and never asks ----------
5880
+ Live, and never moves the reader. While the page is open and its tab is in view it holds one
5881
+ `EventSource` on `/api/events`, which says one thing: the change cursor, once on connect and
5882
+ again whenever anything the page shows changed (a hook, the MCP server, the CLI or another tab
5883
+ wrote). The stream says *that* something changed and the payload says *what*, so the page keeps
5884
+ one source of truth: on a cursor that is not its own it reads `/api/state` and `/api/projects`
5885
+ (the server has already built them, and answers from memory), swaps them in (`adoptState`),
5886
+ writes what boot writes outside the view (`paintChrome`) and draws the screen it is on again as
5887
+ an adjustment (`syncFromUrl` -> `render`): the same route, the scroll where it was, focus and the
5888
+ caret, the folds that are open, a Settings control being edited, the viewer's frame as the same
5889
+ element. Numbers, rows, badges and the sidebar's counts change in place; nothing moves.
5890
+
5891
+ A write the page made itself (a saved setting, a correction, an acknowledge) still redraws from
5892
+ its own answer, and the event that follows draws what is already there, which replaces nothing.
5893
+
5894
+ - Events that arrive while a read or a redraw is out are one more read after it, not one each.
5895
+ - A hidden tab closes the stream and makes no request at all. Coming back opens it again, and its
5896
+ first event is the current cursor, so a tab left open all afternoon catches up in one read.
5897
+ - A server that goes away is the browser's to retry (the stream stays `CONNECTING` and the page
5898
+ keeps what it has, with no banner). A stream the server refuses (the 33rd, a 503) is not:
5899
+ the browser gives up on it for good, so the page opens a new one after a pause that doubles from
5900
+ five seconds to a minute and starts again from five once a stream opens. */
5901
+ const LIVE_RETRY_MIN = 5000;
5902
+ const LIVE_RETRY_MAX = 60000;
5903
+ const LIVE = {
5904
+ es: null, // the open stream; null while the tab is hidden, or the last one was refused
5905
+ timer: 0, // the pending reopen of a stream that was refused
5906
+ delay: LIVE_RETRY_MIN, // how long that reopen waits
5907
+ heard: '', // the newest cursor the stream has said
5908
+ busy: false, // a read of the new data is out
5909
+ more: false, // a cursor that is not the page's arrived while it was
5910
+ };
5911
+ const inView = () => document.visibilityState === 'visible';
5912
+
5913
+ function liveOpen() {
5914
+ clearTimeout(LIVE.timer);
5915
+ if (LIVE.es || !S || !inView()) return;
5916
+ let es;
5917
+ try {
5918
+ es = new EventSource('/api/events');
5919
+ } catch (e) {
5920
+ return liveReopen();
5921
+ }
5922
+ LIVE.es = es;
5923
+ es.addEventListener('open', () => { LIVE.delay = LIVE_RETRY_MIN; });
5924
+ es.addEventListener('cursor', (e) => liveHeard(e.data));
5925
+ es.addEventListener('error', () => {
5926
+ // While the server is away the stream is CONNECTING and the browser asks again by itself. Closed means it will not.
5927
+ if (LIVE.es === es && es.readyState === EventSource.CLOSED) { LIVE.es = null; liveReopen(); }
5928
+ });
5929
+ }
5930
+
5931
+ function liveReopen() {
5932
+ clearTimeout(LIVE.timer);
5933
+ if (!inView()) return;
5934
+ LIVE.timer = setTimeout(liveOpen, LIVE.delay);
5935
+ LIVE.delay = Math.min(LIVE.delay * 2, LIVE_RETRY_MAX);
5936
+ }
4893
5937
 
5938
+ function liveClose() {
5939
+ clearTimeout(LIVE.timer);
5940
+ LIVE.es?.close();
5941
+ LIVE.es = null;
5942
+ }
5943
+
5944
+ /** The stream said `cursor`. */
5945
+ function liveHeard(cursor) {
5946
+ LIVE.heard = cursor;
5947
+ if (!S || cursor === S.cursor) return;
5948
+ if (LIVE.busy) { LIVE.more = true; return; }
5949
+ liveRefresh();
5950
+ }
5951
+
5952
+ /**
5953
+ * Reads the new payload and draws it. A failure changes nothing: the page keeps what it has, and the next cursor the
5954
+ * stream says (or a reconnect, whose first event is the current one) asks again.
5955
+ */
5956
+ function liveRefresh() {
5957
+ LIVE.busy = true;
5958
+ LIVE.more = false;
5959
+ Promise.all([json('/api/state'), json('/api/projects')]).then(([s, inv]) => {
5960
+ try {
5961
+ // The same cursor is the page's own write, or a read that raced the stream: there is nothing new to draw.
5962
+ if (s.cursor !== S.cursor) liveAdopt(s, inv);
5963
+ } finally {
5964
+ LIVE.busy = false;
5965
+ }
5966
+ if (LIVE.more && LIVE.heard !== S.cursor && inView()) liveRefresh();
5967
+ }, () => { LIVE.busy = false; });
5968
+ }
5969
+
5970
+ /**
5971
+ * Swaps the new payload in and draws the current screen again in place. Through `syncFromUrl`, not a bare
5972
+ * `render()`: a project that is no longer in the inventory is dropped the way boot drops one. `LIVE_PASS` makes
5973
+ * the redraw keep what the reader is in the middle of, besides what they had open (see `captureUi`).
5974
+ */
5975
+ function liveAdopt(s, inv) {
5976
+ LIVE_PASS = true;
5977
+ try {
5978
+ adoptState(s, inv);
5979
+ paintChrome(s, inv);
5980
+ syncFromUrl();
5981
+ } finally {
5982
+ LIVE_PASS = false;
5983
+ }
5984
+ }
5985
+
5986
+ document.addEventListener('visibilitychange', () => {
5987
+ if (inView()) { LIVE.delay = LIVE_RETRY_MIN; liveOpen(); } else liveClose();
5988
+ });
5989
+
5990
+ /* ---------- boot ---------- */
5991
+ // The head script runs before the toggle exists, so its aria-pressed state has
5992
+ // to be applied again once the markup is there — otherwise a reader on paper is
5993
+ // shown "ink" as the active ground.
5994
+ window.eklavyaGround.apply(window.eklavyaGround.read());
5995
+
5996
+ Promise.all([json('/api/state'), json('/api/projects')]).then(([s, inv]) => {
5997
+ adoptState(s, inv);
5998
+ paintChrome(s, inv);
4894
5999
  syncFromUrl();
4895
6000
  $('app').classList.remove('booting');
4896
6001
  $('view').removeAttribute('aria-busy');
6002
+ liveOpen();
4897
6003
  }).catch((e) => {
4898
6004
  $('app').classList.remove('booting');
4899
6005
  $('view').removeAttribute('aria-busy');