eklavya 1.45.0 → 1.47.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.
Files changed (36) hide show
  1. package/README.md +11 -1
  2. package/dist/artifacts.js +17 -0
  3. package/dist/artifacts.js.map +1 -1
  4. package/dist/assets/artifact-template.html +3 -0
  5. package/dist/assets/dashboard.html +476 -30
  6. package/dist/assets/tutor/SKILL.md +2 -1
  7. package/dist/assets/tutor/references/writing-mcq.md +11 -1
  8. package/dist/cli.js +44 -12
  9. package/dist/cli.js.map +1 -1
  10. package/dist/dashboard.js +201 -47
  11. package/dist/dashboard.js.map +1 -1
  12. package/dist/hooks/checkpoint-quiz.js +3 -2
  13. package/dist/hooks/checkpoint-quiz.js.map +1 -1
  14. package/dist/hooks/memory-lib.js +1 -1
  15. package/dist/hooks/memory-lib.js.map +1 -1
  16. package/dist/hooks/stop-quiz-check.js +3 -2
  17. package/dist/hooks/stop-quiz-check.js.map +1 -1
  18. package/dist/memory/recall.js +1 -1
  19. package/dist/migrations/019_attempt_corrections.sql +41 -0
  20. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  21. package/dist/plugin/agents/explainer.md +11 -6
  22. package/dist/plugin/skills/tutor/SKILL.md +2 -1
  23. package/dist/plugin/skills/tutor/references/writing-mcq.md +11 -1
  24. package/dist/store.js +142 -14
  25. package/dist/store.js.map +1 -1
  26. package/dist/telemetry-send.js +9 -7
  27. package/dist/telemetry-send.js.map +1 -1
  28. package/dist/tools/get_learner_profile.js +5 -3
  29. package/dist/tools/get_learner_profile.js.map +1 -1
  30. package/dist/tools/get_session_quiz_plan.js +1 -1
  31. package/dist/tools/get_session_quiz_plan.js.map +1 -1
  32. package/dist/tools/record_attempt.js +48 -8
  33. package/dist/tools/record_attempt.js.map +1 -1
  34. package/dist/user-skill/eklavya/SKILL.md +7 -3
  35. package/dist/user-skill/eklavya-artifacts/SKILL.md +12 -4
  36. package/package.json +1 -1
@@ -262,6 +262,104 @@
262
262
  .art__thumb { display:block; aspect-ratio:16/10; background:var(--panel); border:1px solid var(--line-2); padding:var(--space-3); }
263
263
  .art__open:hover .art__thumb { border-color:var(--spot); }
264
264
  .art__thumb img { display:block; width:100%; height:100%; object-fit:contain; }
265
+ .tag { font-family:var(--font-mono); font-size:11px; letter-spacing:var(--tracking-caps); text-transform:uppercase; }
266
+ .tag.warn { color:var(--warning); }
267
+ .tag.ok { color:var(--spot); }
268
+
269
+ /* The explainer viewer: the agent's page, framed sandboxed, then this page's
270
+ own correction bar. The server adds a script to every framed page that
271
+ reports its height; the frame takes it, so the window is the one scrollbar.
272
+ Until the first report the frame is 0 tall behind a loader; with none in
273
+ 1.5s (script blocked) it falls back to 80vh and scrolls inside. */
274
+ .viewer { display:block; width:100%; height:0; border:0; background:var(--bg); }
275
+ /* Artifact tabs: the gallery, then up to TAB_MAX open pages. Pinned to the
276
+ top of the window (under the top bar on a phone) so switching never needs
277
+ a scroll up. One row; it scrolls sideways rather than wrapping. */
278
+ .tabs { position:sticky; top:0; z-index:6; display:flex; height:40px; background:var(--bg); overscroll-behavior-x:contain;
279
+ border-bottom:1px solid var(--line-2); overflow-x:auto; overflow-y:hidden; }
280
+ @media (hover:none) { .tabs { scrollbar-width:none; } .tabs::-webkit-scrollbar { display:none; } }
281
+ .tabw { flex:none; display:flex; align-items:center; max-width:220px; border-bottom:2px solid transparent; }
282
+ .tabw.is-active { border-bottom-color:var(--spot); }
283
+ .tab { display:flex; align-items:center; gap:var(--space-2); min-width:0; height:100%; padding:0 var(--space-3);
284
+ font-family:var(--font-mono); font-size:12px; color:var(--dim); text-decoration:none; white-space:nowrap; }
285
+ .tab span { overflow:hidden; text-overflow:ellipsis; }
286
+ .tab:hover, .tabw.is-active .tab { color:var(--ink); }
287
+ .tab:focus-visible, .tab__x:focus-visible { outline:none; box-shadow:inset 0 0 0 2px var(--spot); }
288
+ .tab__dot { width:6px; height:6px; border-radius:50%; flex:none; }
289
+ .tab__dot.is-open { background:var(--warning); }
290
+ .tab__dot.is-done { background:var(--spot); }
291
+ .tab__x { flex:none; width:20px; height:20px; margin-right:var(--space-2); display:grid; place-items:center;
292
+ background:none; border:0; padding:0; color:var(--dim); cursor:pointer; visibility:hidden; }
293
+ .tab__x svg { width:12px; height:12px; }
294
+ .tab__x:hover { color:var(--spot); }
295
+ .tabw.is-active .tab__x, .tabw:hover .tab__x, .tabw:focus-within .tab__x { visibility:visible; }
296
+ /* Above the frame, on the right: the page on its own, for anyone who wants that. */
297
+ .viewer__bar { display:flex; justify-content:flex-end; }
298
+ .viewer__bar a { display:inline-flex; align-items:center; gap:var(--space-2); font-family:var(--font-mono);
299
+ font-size:11.5px; color:var(--dim); text-decoration:none; }
300
+ .viewer__bar a:hover { color:var(--spot); }
301
+ .viewer__bar svg { width:14px; height:14px; }
302
+ .stack > .viewer__bar + * { margin-top:var(--space-2); }
303
+
304
+ /* The correction bar: pinned to the window's bottom edge while you read, and
305
+ resting under the page at its end, so it never covers the last lines. The
306
+ left rule is the state: warning while open, verdigris once corrected. */
307
+ .fixbar { position:sticky; bottom:0; z-index:5; min-height:64px; display:flex; align-items:center;
308
+ justify-content:space-between; gap:var(--space-4); padding:12px var(--space-5);
309
+ background:var(--panel); border-top:1px solid var(--line-2); border-left:3px solid transparent; }
310
+ .fixbar.is-open { border-left-color:var(--warning); }
311
+ .fixbar.is-done { border-left-color:var(--spot); }
312
+ .fixbar.is-fresh > * { animation:loader-in 180ms var(--ease); }
313
+ .fixbar p { margin:0; }
314
+ .fixbar .loader { padding:0; }
315
+ /* Two lines at 1.4 fit the 64px row, so open and corrected are one height and nothing moves. */
316
+ .fixbar__text { min-width:0; line-height:1.4; }
317
+ .fixbar__lead { color:var(--ink); font-size:14px; }
318
+ .fixbar__note { color:var(--dim); font-size:13px; }
319
+ .fixbar__lead, .fixbar__note, .fixbar__done { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
320
+ .fixbar__done { display:flex; align-items:center; gap:var(--space-2); color:var(--ink); min-width:0; }
321
+ .fixbar__done svg { width:16px; height:16px; color:var(--spot); flex:none; }
322
+ .fixbar__done span { color:var(--dim); }
323
+ .fixbar__err { color:var(--warning); overflow-wrap:anywhere; }
324
+ .fixbar .btn, .fixbar .set__btn { flex:none; }
325
+ .fixbar .set__btn { padding:8px 14px; }
326
+ @media (max-width:560px) {
327
+ .fixbar__note { display:none; }
328
+ .fixbar .btn { padding:10px 12px; }
329
+ }
330
+ .btn { font-family:var(--font-mono); font-size:12px; letter-spacing:.06em; text-transform:uppercase;
331
+ background:var(--spot); color:var(--spot-ink); border:1px solid var(--spot); padding:10px 16px; cursor:pointer;
332
+ transition:background var(--duration-fast) var(--ease), color var(--duration-fast) var(--ease); }
333
+ .btn:hover { background:var(--mass); color:var(--spot); }
334
+ .btn:focus-visible, .fix__opt:focus-visible, .set__btn:focus-visible { outline:none; box-shadow:var(--ring); }
335
+
336
+ /* The correction modal: a native <dialog>, so focus, Escape and the top
337
+ layer come with it. Square, hairline, no shadow; the backdrop blurs. */
338
+ dialog.fix { width:min(560px, calc(100vw - 32px)); max-height:calc(100vh - 32px); overflow:auto; margin:auto;
339
+ padding:var(--space-6); border:1px solid var(--line-2); border-radius:0; background:var(--panel); color:var(--ink); }
340
+ dialog.fix::backdrop { background:color-mix(in srgb, var(--ink) 40%, transparent); backdrop-filter:blur(6px); }
341
+ dialog.fix[open] { animation:fix-in 180ms var(--ease); }
342
+ @keyframes fix-in { from { opacity:0; transform:translateY(8px); } }
343
+ .fix__eyebrow { margin:0 0 var(--space-3); font:400 11px/1 var(--font-mono); letter-spacing:var(--tracking-caps-wide);
344
+ text-transform:uppercase; color:var(--spot); }
345
+ .fix__q { margin:0 0 var(--space-5); font:800 clamp(20px,3vw,26px)/1.15 var(--font-disp); letter-spacing:var(--tracking-tight); }
346
+ .fix__opts { display:flex; flex-direction:column; gap:var(--space-2); }
347
+ .fix__opt { display:flex; flex-direction:column; gap:2px; text-align:left; width:100%; padding:10px 14px;
348
+ border:1px solid var(--line-2); background:var(--bg); color:var(--ink); font:inherit; cursor:pointer; }
349
+ .fix__opt:hover:not(:disabled) { border-color:var(--spot); }
350
+ .fix__opt:disabled { cursor:default; }
351
+ .fix__n { color:var(--dim); font-size:13px; }
352
+ .fix__tag { font:400 11px var(--font-mono); letter-spacing:var(--tracking-caps); text-transform:uppercase; }
353
+ .fix__tag:empty { display:none; }
354
+ .fix__opt.is-wrong { border-color:var(--error); }
355
+ .fix__opt.is-wrong .fix__tag { color:var(--error); }
356
+ .fix__opt.is-right { border-color:var(--spot); background:var(--spot-soft); }
357
+ .fix__opt.is-right .fix__tag { color:var(--spot); }
358
+ .fix__foot { display:flex; align-items:center; gap:var(--space-4); margin-top:var(--space-5); }
359
+ .fix__msg { flex:1; margin:0; font-size:13.5px; }
360
+ .fix__msg.wrong { color:var(--error); }
361
+ .fix__msg.warn { color:var(--warning); }
362
+ .fix__msg.ok { color:var(--spot); }
265
363
  .g2 > *, .g3 > * { margin-top:0 !important; }
266
364
 
267
365
  /* ---------- stat tiles ---------- */
@@ -569,6 +667,7 @@
569
667
  `inert` while closed, so a hidden drawer is never in the Tab order. */
570
668
  @media (max-width:900px) {
571
669
  .app { grid-template-columns:minmax(0,1fr); }
670
+ .tabs { top:55px; }
572
671
  .topbar { display:flex; align-items:center; gap:var(--space-3); position:sticky; top:0; z-index:20;
573
672
  background:var(--panel); border-bottom:1px solid var(--line-2); padding:var(--space-2) var(--space-4); }
574
673
  .topbar__menu { background:none; border:1px solid var(--line-2); color:var(--ink); width:38px; height:38px;
@@ -720,6 +819,16 @@
720
819
  </main>
721
820
  </div>
722
821
 
822
+ <dialog class="fix" id="fix" aria-labelledby="fix-q">
823
+ <p class="fix__eyebrow">Correct your answer</p>
824
+ <h2 class="fix__q" id="fix-q"></h2>
825
+ <div class="fix__opts" id="fix-opts" role="group" aria-labelledby="fix-q"></div>
826
+ <div class="fix__foot">
827
+ <p class="fix__msg" id="fix-msg" role="status"></p>
828
+ <button type="button" class="set__btn" id="fix-close">Close</button>
829
+ </div>
830
+ </dialog>
831
+
723
832
  <div class="picker" id="wf-menu" role="menu" aria-label="Workflows" hidden></div>
724
833
  <div class="picker" id="proj-menu" role="listbox" aria-labelledby="proj-label" hidden></div>
725
834
  <div class="picker" id="sel-menu" role="listbox" hidden></div>
@@ -832,14 +941,14 @@ const scopeName = () => (PROJECT === null ? 'all projects' : projectById(PROJECT
832
941
  /** Inventory rows in scope — one, or all of them. */
833
942
  const projectsInScope = () => INV.projects.filter((p) => PROJECT === null || p.id === PROJECT);
834
943
 
835
- /** One row per calendar day in scope: { day, passed, missed, skipped, total }. */
944
+ /** One row per calendar day in scope: { day, passed, corrected, missed, skipped, total }. */
836
945
  function dailyInScope() {
837
946
  const m = new Map();
838
947
  for (const d of S.daily) {
839
948
  if (!inScope(d.repo)) continue;
840
- const cur = m.get(d.day) ?? { day:d.day, passed:0, missed:0, skipped:0, total:0 };
841
- cur.passed += d.passed; cur.missed += d.missed; cur.skipped += d.skipped;
842
- cur.total = cur.passed + cur.missed + cur.skipped;
949
+ const cur = m.get(d.day) ?? { day:d.day, passed:0, corrected:0, missed:0, skipped:0, total:0 };
950
+ cur.passed += d.passed; cur.corrected += d.corrected ?? 0; cur.missed += d.missed; cur.skipped += d.skipped;
951
+ cur.total = cur.passed + cur.corrected + cur.missed + cur.skipped;
843
952
  m.set(d.day, cur);
844
953
  }
845
954
  return m;
@@ -853,7 +962,7 @@ function fillDays(map, n, endKey) {
853
962
  const cur = new Date(end.getTime() - (n - 1) * DAY_MS);
854
963
  for (let i = 0; i < n; i++) {
855
964
  const k = dayKey(cur);
856
- out.push(map.get(k) ?? { day:k, passed:0, missed:0, skipped:0, total:0 });
965
+ out.push(map.get(k) ?? { day:k, passed:0, corrected:0, missed:0, skipped:0, total:0 });
857
966
  cur.setUTCDate(cur.getUTCDate() + 1);
858
967
  }
859
968
  return out;
@@ -935,6 +1044,8 @@ function sessionLabel(s) {
935
1044
  ============================================================ */
936
1045
  const meter = (p, kind) => `<div class="meter${kind ? ' ' + kind : ''}"><i style="width:${Math.max(0, Math.min(100, p))}%"></i></div>`;
937
1046
  const gradeBadge = (a) => {
1047
+ // A miss corrected from its explainer: the row stays, labelled for what it became.
1048
+ if (a.corrected_at) return `<span class="grade pass">corrected${a.corrected_try ? ` · try ${a.corrected_try}` : ''}</span>`;
938
1049
  if (a.outcome === 'declined') return '<span class="grade">skipped</span>';
939
1050
  if (a.outcome === 'dont_know') return '<span class="grade">blanked</span>';
940
1051
  return `<span class="grade ${a.grade >= 3 ? 'pass' : 'miss'}">${a.grade}/5</span>`;
@@ -1084,7 +1195,11 @@ const slice = (arr, p, per = PER) => arr.slice((p - 1) * per, p * per);
1084
1195
  Charts — hand-rolled SVG. No library, no network, both grounds.
1085
1196
  ============================================================ */
1086
1197
 
1087
- /** Stacked daily bars: right at the base, then missed, then skipped. */
1198
+ /**
1199
+ * Stacked daily bars: right at the base, then corrected (a miss answered right
1200
+ * later from its explainer, counted once, on the day of the miss), then
1201
+ * missed, then skipped.
1202
+ */
1088
1203
  function chartActivity(el, days) {
1089
1204
  const tip = el.parentElement.querySelector('.tip');
1090
1205
  const W = Math.max(320, el.parentElement.clientWidth || 900);
@@ -1093,7 +1208,7 @@ function chartActivity(el, days) {
1093
1208
  const step = (W - PAD_L) / days.length;
1094
1209
  const bw = Math.min(26, Math.max(4, step - 5));
1095
1210
  const y = (v) => (H - PAD_B) - (v / max) * (H - PAD_B - 10);
1096
- const SEGMENTS = [['passed', 'var(--spot)'], ['missed', 'var(--warning)'], ['skipped', 'var(--faint-2)']];
1211
+ const SEGMENTS = [['passed', 'var(--spot)'], ['corrected', 'var(--spot-soft)'], ['missed', 'var(--warning)'], ['skipped', 'var(--faint-2)']];
1097
1212
 
1098
1213
  let out = '';
1099
1214
  for (let i = 0; i <= 2; i++) {
@@ -1109,7 +1224,9 @@ function chartActivity(el, days) {
1109
1224
  if (!d[key]) continue;
1110
1225
  const h = (d[key] / max) * (H - PAD_B - 10);
1111
1226
  const inset = acc ? 2 : 0; // a 2px surface gap between stacked fills
1112
- seg += `<rect x="${x}" y="${y(acc) - h + inset}" width="${bw}" height="${Math.max(1, h - inset)}" fill="${color}"/>`;
1227
+ // The soft fill gets a hairline in the accent, or it vanishes against the panel.
1228
+ const edge = key === 'corrected' ? ' stroke="var(--spot)" stroke-width="1"' : '';
1229
+ seg += `<rect x="${x}" y="${y(acc) - h + inset}" width="${bw}" height="${Math.max(1, h - inset)}" fill="${color}"${edge}/>`;
1113
1230
  acc += d[key]; top = color;
1114
1231
  }
1115
1232
  out += `<rect class="hit" x="${PAD_L + i * step}" y="0" width="${step}" height="${H - PAD_B}" data-i="${i}" tabindex="0"/>`
@@ -1127,7 +1244,7 @@ function chartActivity(el, days) {
1127
1244
  const show = () => {
1128
1245
  const d = days[+hit.dataset.i];
1129
1246
  tip.innerHTML = `<div class="mono">${esc(fmtDay(d.day))}</div>`
1130
- + [['Right', d.passed], ['Missed', d.missed], ['Skipped', d.skipped]]
1247
+ + [['Right', d.passed], ['Corrected', d.corrected], ['Missed', d.missed], ['Skipped', d.skipped]]
1131
1248
  .map(([k, v]) => `<div class="r"><span>${k}</span><span>${v}</span></div>`).join('');
1132
1249
  const r = hit.getBoundingClientRect(), p = el.parentElement.getBoundingClientRect();
1133
1250
  tip.style.left = (r.left - p.left + r.width / 2) + 'px';
@@ -1440,11 +1557,12 @@ function viewLearningDashboard() {
1440
1557
  card('Answers, last 30 days', null,
1441
1558
  `<div class="legend">
1442
1559
  <span><b style="background:var(--spot)"></b>Right</span>
1560
+ <span><b style="background:var(--spot-soft);box-shadow:inset 0 0 0 1px var(--spot)"></b>Corrected</span>
1443
1561
  <span><b style="background:var(--warning)"></b>Missed</span>
1444
1562
  <span><b style="background:var(--faint-2)"></b>Skipped</span>
1445
1563
  </div>
1446
1564
  <div class="chart"><div class="tip" id="tip"></div><svg id="c-activity" role="img"
1447
- aria-label="Daily answers, split into right, missed and skipped"></svg></div>`),
1565
+ aria-label="Daily answers, split into right, corrected, missed and skipped"></svg></div>`),
1448
1566
 
1449
1567
  `<div class="g2">
1450
1568
  ${card('Weakest five', null,
@@ -1587,7 +1705,7 @@ function viewConcept(slug) {
1587
1705
 
1588
1706
  tiles([
1589
1707
  ['Score', c.seen ? Math.round(c.score * 100) + '%' : '—', decayed ? `decayed from ${Math.round(c.stored_score * 100)}%` : 'last five grades, recent first', c.mastered ? 'good' : c.score < 0.5 && c.seen ? 'warn' : ''],
1590
- ['State', c.mastered ? 'mastered' : c.seen ? (c.due ? 'due' : 'learning') : 'not asked', c.seen ? `${c.passed} of ${c.attempts} right` : 'no questions yet', c.due ? 'warn' : ''],
1708
+ ['State', c.mastered ? 'mastered' : c.seen ? (c.due ? 'due' : 'learning') : 'not asked', c.seen ? `${c.passed} of ${c.attempts} right${c.corrected ? ` · ${c.corrected} corrected` : ''}` : 'no questions yet', c.due ? 'warn' : ''],
1591
1709
  ['Next review', c.owed ? whenDue(c.next_review) : '—', c.interval_d ? `every ${Math.round(c.interval_d)}d · ease ${c.ease ? c.ease.toFixed(2) : '—'} · ${plural(c.reps, 'rep')}` : 'not scheduled', c.due ? 'warn' : ''],
1592
1710
  ]),
1593
1711
 
@@ -1856,9 +1974,9 @@ function viewProjects() {
1856
1974
  const m = new Map();
1857
1975
  for (const d of S.daily) {
1858
1976
  if (pid(d.repo) !== p.id) continue;
1859
- const cur = m.get(d.day) ?? { day:d.day, passed:0, missed:0, skipped:0, total:0 };
1860
- cur.passed += d.passed; cur.missed += d.missed; cur.skipped += d.skipped;
1861
- cur.total = cur.passed + cur.missed + cur.skipped;
1977
+ const cur = m.get(d.day) ?? { day:d.day, passed:0, corrected:0, missed:0, skipped:0, total:0 };
1978
+ cur.passed += d.passed; cur.corrected += d.corrected ?? 0; cur.missed += d.missed; cur.skipped += d.skipped;
1979
+ cur.total = cur.passed + cur.corrected + cur.missed + cur.skipped;
1862
1980
  m.set(d.day, cur);
1863
1981
  }
1864
1982
  chartActivity(el, fillDays(m, 30));
@@ -2460,10 +2578,11 @@ function viewHealth() {
2460
2578
  ============================================================ */
2461
2579
  /* ============================================================
2462
2580
  Artifacts — the pages an agent wrote under ~/.eklavya/artifacts/.
2463
- `S.artifacts` is read from the files on every load; opening one is a plain
2464
- link to `/artifacts/<id>`, which the server serves sandboxed, in a new tab.
2581
+ `S.artifacts` is read from the files on every load. Every page opens in the
2582
+ viewer (`#/artifacts/view/<id>`) as a tab; an explainer for a missed
2583
+ question carries the correction bar there.
2465
2584
  ============================================================ */
2466
- const ARTIFACT_KINDS = ['all', 'explainer', 'artifact'];
2585
+ const ARTIFACT_KINDS = ['all', 'explainer', 'artifact', 'to-correct'];
2467
2586
  const artifactsInScope = () => (S.artifacts ?? []).filter((a) => inScope(a.project));
2468
2587
  const artifactUrl = (a) => '/artifacts/' + a.id.split('/').map(encodeURIComponent).join('/');
2469
2588
 
@@ -2475,12 +2594,14 @@ function artifactCard(a) {
2475
2594
  const concept = !a.concept ? ''
2476
2595
  : known ? ` · <a class="link" data-go="${esc(LH('concept', a.concept))}">${esc(a.concept)}</a>`
2477
2596
  : ` · ${esc(a.concept)}`;
2597
+ const tag = a.correction === 'open' ? ' · <span class="tag warn">To correct</span>'
2598
+ : a.correction === 'done' ? ' · <span class="tag ok">Corrected</span>' : '';
2478
2599
  return `<article class="art">
2479
- <a class="art__open" href="${esc(artifactUrl(a))}" target="_blank" rel="noopener">
2600
+ <a class="art__open" href="${esc(AH('view', a.id))}">
2480
2601
  <span class="art__thumb"><img src="${esc(thumbUrl(a, window.eklavyaGround.read()))}" data-thumb="${esc(thumbUrl(a, '@'))}" alt="" loading="lazy" decoding="async"></span>
2481
2602
  <span class="title">${esc(a.title)}</span></a>
2482
2603
  ${a.description ? `<p class="ctx">${esc(a.description)}</p>` : ''}
2483
- <p class="slug">${esc(a.kind)} · ${esc(stamp(a.created))}${concept}</p>
2604
+ <p class="slug">${esc(a.kind)} · ${esc(stamp(a.created))}${concept}${tag}</p>
2484
2605
  </article>`;
2485
2606
  }
2486
2607
 
@@ -2490,7 +2611,7 @@ function viewArtifacts(param) {
2490
2611
  const all = artifactsInScope();
2491
2612
  const q = t.q.trim().toLowerCase();
2492
2613
  const list = all.filter((a) => {
2493
- if (kind !== 'all' && a.kind !== kind) return false;
2614
+ if (kind === 'to-correct' ? a.correction !== 'open' : kind !== 'all' && a.kind !== kind) return false;
2494
2615
  if (q && ![a.title, a.description, a.concept ?? '', pname(a.project), a.id].join(' ').toLowerCase().includes(q)) return false;
2495
2616
  return true;
2496
2617
  });
@@ -2504,6 +2625,7 @@ function viewArtifacts(param) {
2504
2625
  }
2505
2626
  const counts = { all: all.length, explainer: all.filter((a) => a.kind === 'explainer').length };
2506
2627
  counts.artifact = all.length - counts.explainer;
2628
+ counts['to-correct'] = all.filter((a) => a.correction === 'open').length;
2507
2629
  const projects = new Set(all.map((a) => pid(a.project))).size;
2508
2630
  const chip = (k, label) => `<button class="chip" data-go="${esc(AH('dashboard', k === 'all' ? null : k))}" aria-pressed="${kind === k}">${label} ${counts[k]}</button>`;
2509
2631
  const body = !list.length
@@ -2512,20 +2634,332 @@ function viewArtifacts(param) {
2512
2634
  `<h3 class="slug" style="margin:var(--space-5) 0 var(--space-2)">${esc(projectById(id)?.name ?? (id === '~' ? 'no project' : base(id)))}</h3>`
2513
2635
  + `<div class="gallery">${rows.map(artifactCard).join('')}</div>`).join('') + pager('artifacts', list.length, p);
2514
2636
  return [
2637
+ (TABS.ids ?? TABS.load()).length ? drawTabs(null) : '',
2515
2638
  pageHead('Artifacts', [
2516
2639
  plural(all.length, 'page'), plural(counts.explainer, 'explainer'),
2517
2640
  `<a class="link" data-go="${esc(AH('projects'))}">${plural(projects, 'project')}</a>`,
2518
2641
  ], `Pages your agent wrote for you — explainers after a missed question, and anything you asked to have
2519
- turned into a page. Each opens in a new tab, from <code>~/.eklavya/artifacts/</code>.`),
2642
+ turned into a page, from <code>~/.eklavya/artifacts/</code>. Each opens here, as a tab; up to
2643
+ ${TAB_MAX} stay open. An explainer for a missed question carries a <b>Correct your answer</b> bar: pick the right
2644
+ option and the miss shows as corrected. That counts as a pass for the concept's score, but not toward a
2645
+ commit gate or a level, and the concept still comes back for review.`),
2520
2646
  card(null, null,
2521
2647
  `<div class="filters">
2522
2648
  <div class="grow"><input type="search" id="aq" value="${esc(t.q)}" placeholder="Search artifacts" aria-label="Search artifacts"></div>
2523
2649
  </div>
2524
- <div class="chips">${chip('all','All')}${chip('explainer','Explainers')}${chip('artifact','Other pages')}</div>
2650
+ <div class="chips">${chip('all','All')}${chip('explainer','Explainers')}${chip('to-correct','To correct')}${chip('artifact','Other pages')}</div>
2525
2651
  <div style="margin-top:var(--space-4)">${body}</div>`),
2526
2652
  ].join('');
2527
2653
  }
2528
2654
 
2655
+ /* ---------- the explainer viewer and its correction ----------
2656
+ The agent's page is framed sandboxed without same-origin, so it can never
2657
+ read or write the API; the bar and the modal around it are this page's own
2658
+ code, and the server grades every pick. */
2659
+ const FRAME_SANDBOX = 'allow-scripts allow-modals allow-downloads allow-popups allow-popups-to-escape-sandbox';
2660
+ /** The correction on screen: `{ attempt, artifact, q }`, `q` being /api/attempts/correction's answer. */
2661
+ let FIX = null;
2662
+
2663
+ function viewArtifact(id) {
2664
+ const a = (S.artifacts ?? []).find((x) => x.id === id);
2665
+ if (!a) return viewNotFound(id, false);
2666
+ if (a.correction) AFTER.push(() => loadFix(a));
2667
+ // The page's own ground follows this one; a page made before the viewer ignores it.
2668
+ AFTER.push(() => {
2669
+ const f = $('art-frame');
2670
+ f?.addEventListener('load', () => f.contentWindow?.postMessage({ type:'eklavya:mode', mode:window.eklavyaGround.read() }, '*'));
2671
+ });
2672
+ AFTER.push(() => {
2673
+ const f = $('art-frame'), gen = GEN;
2674
+ setTimeout(() => { if (gen === GEN && !f.style.height) sized(f, '80vh'); }, 1500);
2675
+ });
2676
+ TABS.open(id);
2677
+ TABS.reading = null;
2678
+ return `${drawTabs(id)}
2679
+ <div class="viewer__bar"><a href="${esc(artifactUrl(a))}" target="_blank" rel="noopener">${ICON_OUT}Open in new browser tab</a></div>
2680
+ <div id="art-wait">${loader('Opening the page…')}</div>
2681
+ <iframe class="viewer" id="art-frame" src="${esc(artifactUrl(a))}?embed" title="${esc(a.title)}" sandbox="${FRAME_SANDBOX}"></iframe>
2682
+ ${a.correction ? `<div class="fixbar" id="fixbar" role="region" aria-label="Correction">${loader('Loading the question…')}</div>` : ''}`;
2683
+ }
2684
+
2685
+ /**
2686
+ * Asks the server for the question behind the bar. Not `fill()`: a refusal
2687
+ * shows the server's own words and a Retry, in the bar's one row.
2688
+ */
2689
+ function loadFix(a) {
2690
+ const gen = GEN;
2691
+ const bar = () => (gen === GEN ? $('fixbar') : null);
2692
+ bar().className = 'fixbar';
2693
+ bar().innerHTML = loader('Loading the question…');
2694
+ fetch(`/api/attempts/correction?id=${a.attempt}`)
2695
+ .then((r) => r.json().catch(() => ({})).then((d) => {
2696
+ if (!r.ok) throw new Error(d.error || `${r.status} ${r.statusText}`);
2697
+ const el = bar();
2698
+ if (el) paintFix(el, a, d);
2699
+ }))
2700
+ .catch((e) => {
2701
+ const el = bar();
2702
+ if (!el) return;
2703
+ el.innerHTML = `<p class="fixbar__err">${esc(e.message)}</p><button type="button" class="set__btn" id="fix-retry">Retry</button>`;
2704
+ });
2705
+ }
2706
+
2707
+ /** Draws the bar for `d`, /api/attempts/correction's answer; `fresh` crossfades into it. */
2708
+ function paintFix(el, a, d, fresh) {
2709
+ FIX = { attempt: a.attempt, artifact: a.id, q: d };
2710
+ const done = d.corrected_at !== null;
2711
+ armLeave(!done);
2712
+ el.className = `fixbar ${done ? 'is-done' : 'is-open'}${fresh ? ' is-fresh' : ''}`;
2713
+ const dot = document.querySelector('#view [aria-selected="true"] .tab__dot');
2714
+ if (dot) dot.className = `tab__dot is-${done ? 'done' : 'open'}`;
2715
+ el.innerHTML = done
2716
+ ? `<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>`
2717
+ : `<div class="fixbar__text"><p class="fixbar__lead">You missed this one.</p>
2718
+ <p class="fixbar__note">Read it, then correct your answer. Not now? It stays under To correct.</p></div>
2719
+ <button type="button" class="btn" id="fix-open">Correct your answer</button>`;
2720
+ }
2721
+
2722
+ /** The options in a new order each time: the page above shows the right one at its letter. */
2723
+ function shuffled(n) {
2724
+ const order = [...Array(n).keys()];
2725
+ for (let tries = 0; tries < 5; tries++) {
2726
+ for (let i = n - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [order[i], order[j]] = [order[j], order[i]]; }
2727
+ if (order.some((v, i) => v !== i)) break;
2728
+ }
2729
+ return order;
2730
+ }
2731
+
2732
+ function fixSay(text, kind) {
2733
+ const m = $('fix-msg');
2734
+ m.textContent = text;
2735
+ m.className = 'fix__msg' + (kind ? ' ' + kind : '');
2736
+ }
2737
+
2738
+ function openFix() {
2739
+ const d = FIX.q;
2740
+ $('fix-q').textContent = d.question;
2741
+ $('fix-opts').innerHTML = shuffled(d.options.length).map((i) =>
2742
+ `<button type="button" class="fix__opt" data-pick="${esc(d.options[i])}"><span>${esc(d.options[i])}</span>`
2743
+ + `${d.option_notes?.[i] ? `<span class="fix__n">${esc(d.option_notes[i])}</span>` : ''}<span class="fix__tag"></span></button>`).join('');
2744
+ fixSay('', '');
2745
+ $('fix').showModal();
2746
+ $('fix-opts').querySelector('button').focus();
2747
+ }
2748
+
2749
+ function pickFix(btn) {
2750
+ const all = [...$('fix-opts').querySelectorAll('button')];
2751
+ const reopen = () => all.forEach((b) => { b.disabled = !!b.dataset.wrong; });
2752
+ all.forEach((b) => { b.disabled = true; });
2753
+ postJson('/api/attempts/retry', { attempt_id: FIX.attempt, picked: btn.dataset.pick })
2754
+ .then((r) => {
2755
+ if (r.corrected) {
2756
+ btn.classList.add('is-right');
2757
+ btn.querySelector('.fix__tag').textContent = 'Right answer';
2758
+ fixSay('Corrected. Your stats now count this as answered.', 'ok');
2759
+ FIX.q = { ...FIX.q, tries: r.tries, corrected_at: new Date().toISOString() };
2760
+ const row = S.artifacts.find((x) => x.id === FIX.artifact);
2761
+ if (row) row.correction = 'done';
2762
+ // Every other count on the page was drawn from the payload it booted with:
2763
+ // say so now rather than waiting for the poll.
2764
+ $('stale').hidden = false;
2765
+ armLeave(false);
2766
+ $('fix-close').focus();
2767
+ return;
2768
+ }
2769
+ btn.classList.add('is-wrong');
2770
+ btn.dataset.wrong = '1';
2771
+ btn.querySelector('.fix__tag').textContent = 'Not this one';
2772
+ reopen();
2773
+ fixSay('Still incorrect. Please correct your answer.', 'wrong');
2774
+ all.find((b) => !b.disabled)?.focus();
2775
+ })
2776
+ .catch((e) => { reopen(); fixSay(e.message, 'warn'); });
2777
+ }
2778
+
2779
+ $('fix-opts').addEventListener('click', (e) => {
2780
+ const b = e.target.closest('.fix__opt');
2781
+ if (b && !b.disabled) pickFix(b);
2782
+ });
2783
+ // Arrow keys walk the options that can still be picked; Enter and Space pick (native buttons).
2784
+ $('fix-opts').addEventListener('keydown', (e) => {
2785
+ const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[e.key];
2786
+ if (!step) return;
2787
+ e.preventDefault();
2788
+ const live = [...$('fix-opts').querySelectorAll('button:not(:disabled)')];
2789
+ const i = live.indexOf(document.activeElement);
2790
+ live[(i + step + live.length) % live.length]?.focus();
2791
+ });
2792
+ $('fix-close').addEventListener('click', () => $('fix').close());
2793
+ // However it closes -- the button, Escape -- the bar shows where things stand.
2794
+ $('fix').addEventListener('close', () => {
2795
+ const bar = $('fixbar');
2796
+ const a = FIX && S.artifacts.find((x) => x.id === FIX.artifact);
2797
+ if (bar && a) { paintFix(bar, a, FIX.q, FIX.q.corrected_at !== null); $('fix-open')?.focus(); }
2798
+ });
2799
+ $('view').addEventListener('click', (e) => {
2800
+ if (e.target.closest('#fix-open')) openFix();
2801
+ if (e.target.closest('#fix-retry')) loadFix(S.artifacts.find((x) => x.id === CUR.param));
2802
+ });
2803
+
2804
+ /**
2805
+ * Closing the tab with a correction still open asks first. Browsers show their
2806
+ * own generic prompt -- a page cannot set its text -- so the bar carries ours.
2807
+ */
2808
+ const holdLeave = (e) => { e.preventDefault(); e.returnValue = ''; };
2809
+ let LEAVE = false;
2810
+ function armLeave(on) {
2811
+ if (on === LEAVE) return;
2812
+ LEAVE = on;
2813
+ window[on ? 'addEventListener' : 'removeEventListener']('beforeunload', holdLeave);
2814
+ }
2815
+
2816
+ /**
2817
+ * Gives the frame its height and takes the loader down. The first time, the
2818
+ * page goes back to where this tab was last read; from then on, scrolling is
2819
+ * remembered for it.
2820
+ */
2821
+ function sized(f, h) {
2822
+ f.style.height = h;
2823
+ $('art-wait')?.remove();
2824
+ if (TABS.reading === CUR.param) return;
2825
+ TABS.reading = CUR.param;
2826
+ window.scrollTo({ top: TABS.scroll.get(CUR.param) ?? 0, behavior: 'instant' });
2827
+ }
2828
+ addEventListener('scroll', () => { if (CUR?.page === 'view' && TABS.reading === CUR.param) TABS.scroll.set(CUR.param, scrollY); }, { passive: true });
2829
+
2830
+ /* ---------- artifact tabs ----------
2831
+ Open pages stay open as tabs. The URL names the active one; the list itself
2832
+ is a per-viewer convenience in localStorage (never the URL, never the
2833
+ server), and scroll positions live only in memory. */
2834
+ const TAB_MAX = 8;
2835
+ const TABS_KEY = 'eklavya-dash-tabs';
2836
+ const ICON_OUT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6"/><path d="M10 14L21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>';
2837
+ const ICON_X = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6L6 18"/><path d="M6 6l12 12"/></svg>';
2838
+ const TABS = {
2839
+ ids: null, // open pages, in strip order
2840
+ recent: [], // the same ids, least recently viewed first
2841
+ scroll: new Map(), // id → scrollY
2842
+ reading: null, // the tab whose scroll is being remembered
2843
+ focus: false, // put focus back on the selected tab after the next draw
2844
+ active: null, // the page the strip last showed as selected; new tabs open right of it
2845
+ load() {
2846
+ let v = null;
2847
+ try { v = JSON.parse(localStorage.getItem(TABS_KEY)); } catch (e) { /* no tabs */ }
2848
+ // A page deleted since drops out here.
2849
+ this.ids = (Array.isArray(v) ? v : []).filter((id) => (S.artifacts ?? []).some((a) => a.id === id)).slice(0, TAB_MAX);
2850
+ this.recent = [...this.ids];
2851
+ return this.ids;
2852
+ },
2853
+ save() { try { localStorage.setItem(TABS_KEY, JSON.stringify(this.ids)); } catch (e) { /* this load only */ } },
2854
+ touch(id) { this.recent = [...this.recent.filter((x) => x !== id), id]; },
2855
+ /** Viewing `id`: its tab, new ones right of the tab you were on, past the limit the stalest one goes. */
2856
+ open(id) {
2857
+ if (!this.ids) this.load();
2858
+ if (!this.ids.includes(id)) {
2859
+ const from = this.ids.indexOf(this.active);
2860
+ this.ids.splice(from < 0 ? this.ids.length : from + 1, 0, id);
2861
+ if (this.ids.length > TAB_MAX) {
2862
+ const open = (x) => (S.artifacts ?? []).find((a) => a.id === x)?.correction === 'open';
2863
+ const old = this.recent.filter((x) => x !== id);
2864
+ this.close(old.find((x) => !open(x)) ?? old[0]);
2865
+ }
2866
+ }
2867
+ this.touch(id);
2868
+ this.save();
2869
+ },
2870
+ /** Drops a tab and returns where to go if it was the visible one: right, else left, else the gallery. */
2871
+ close(id) {
2872
+ const at = this.ids.indexOf(id);
2873
+ this.ids.splice(at, 1);
2874
+ this.recent = this.recent.filter((x) => x !== id);
2875
+ this.scroll.delete(id);
2876
+ this.save();
2877
+ const next = this.ids[at] ?? this.ids[at - 1];
2878
+ return next ? AH('view', next) : AH('dashboard');
2879
+ },
2880
+ };
2881
+
2882
+ /** The strip: All artifacts, then each open page. `active` is the visible page's id, or null for the gallery. */
2883
+ function drawTabs(active) {
2884
+ if (!TABS.ids) TABS.load();
2885
+ TABS.active = active;
2886
+ // On a narrow strip the open tab can sit past the edge; bring it in, sideways only.
2887
+ AFTER.push(() => {
2888
+ const on = document.querySelector('#view .tabw.is-active');
2889
+ const strip = on?.parentElement;
2890
+ if (on) strip.scrollLeft = Math.min(Math.max(strip.scrollLeft, on.offsetLeft + on.offsetWidth - strip.clientWidth), on.offsetLeft);
2891
+ });
2892
+ if (TABS.focus) {
2893
+ TABS.focus = false;
2894
+ AFTER.push(() => document.querySelector('#view [role="tab"][aria-selected="true"]')?.focus());
2895
+ }
2896
+ const tab = (id, label, dot, closable) => {
2897
+ const on = id === active;
2898
+ return `<span class="tabw${on ? ' is-active' : ''}" role="presentation">
2899
+ <a class="tab" role="tab" href="${esc(id ? AH('view', id) : AH('dashboard'))}" aria-selected="${on}" aria-controls="view"
2900
+ tabindex="${on ? 0 : -1}" title="${esc(label)}"${id ? ` data-tab-id="${esc(id)}"` : ''}>${dot}<span>${esc(label)}</span></a>
2901
+ ${closable ? `<button type="button" class="tab__x" tabindex="-1" data-close="${esc(id)}" aria-label="Close ${esc(label)}">${ICON_X}</button>` : ''}</span>`;
2902
+ };
2903
+ const rows = TABS.ids.map((id) => {
2904
+ const a = (S.artifacts ?? []).find((x) => x.id === id);
2905
+ const dot = a?.correction ? `<i class="tab__dot is-${a.correction}" aria-hidden="true"></i>` : '';
2906
+ return tab(id, a?.title ?? id, dot, true);
2907
+ });
2908
+ return `<div class="tabs" role="tablist" aria-label="Open artifacts">${tab(null, 'All artifacts', '', false)}${rows.join('')}</div>`;
2909
+ }
2910
+
2911
+ /** Closes a tab. The visible one hands over to its neighbour; any other just leaves the strip, and the page stays put. */
2912
+ function closeTab(id, fromKeys) {
2913
+ const next = TABS.close(id);
2914
+ if (CUR.page === 'view' && CUR.param === id) { TABS.focus = fromKeys; location.replace(next); return; }
2915
+ const strip = document.querySelector('#view .tabs');
2916
+ const active = CUR.page === 'view' ? CUR.param : null;
2917
+ strip.outerHTML = active || TABS.ids.length ? drawTabs(active) : '';
2918
+ if (fromKeys) document.querySelector('#view [role="tab"][aria-selected="true"]')?.focus();
2919
+ }
2920
+
2921
+ $('view').addEventListener('click', (e) => {
2922
+ const x = e.target.closest('[data-close]');
2923
+ if (x) { e.preventDefault(); closeTab(x.dataset.close, false); }
2924
+ });
2925
+ // A middle click closes, as it does on a browser tab, instead of opening another window.
2926
+ $('view').addEventListener('auxclick', (e) => {
2927
+ const t = e.target.closest('[data-tab-id]');
2928
+ if (t && e.button === 1) { e.preventDefault(); closeTab(t.dataset.tabId, false); }
2929
+ });
2930
+ $('view').addEventListener('mousedown', (e) => { if (e.button === 1 && e.target.closest('[data-tab-id]')) e.preventDefault(); });
2931
+ // Roving focus: ←/→ and Home/End move, Enter (native) and Space open, Delete closes.
2932
+ $('view').addEventListener('keydown', (e) => {
2933
+ const t = e.target.closest('[role="tab"]');
2934
+ if (!t) return;
2935
+ const all = [...document.querySelectorAll('#view [role="tab"]')];
2936
+ const i = all.indexOf(t);
2937
+ const to = { ArrowLeft: i - 1, ArrowRight: i + 1, Home: 0, End: all.length - 1 }[e.key];
2938
+ if (to !== undefined) {
2939
+ e.preventDefault();
2940
+ const n = all[(to + all.length) % all.length];
2941
+ all.forEach((x) => { x.tabIndex = x === n ? 0 : -1; });
2942
+ n.focus();
2943
+ } else if (e.key === 'Enter' || e.key === ' ') {
2944
+ // Enter follows the link natively; Space is ours. Either way focus comes back to the strip.
2945
+ TABS.focus = true;
2946
+ if (e.key === ' ') { e.preventDefault(); t.click(); }
2947
+ } else if (e.key === 'Delete' && t.dataset.tabId) {
2948
+ e.preventDefault();
2949
+ closeTab(t.dataset.tabId, true);
2950
+ }
2951
+ });
2952
+
2953
+ // The framed page reports its height; only that frame's message counts.
2954
+ // Clamped to [200, 20000]: past that a page scrolls inside its frame (the
2955
+ // server's script lets it), since one giant frame costs memory for nothing.
2956
+ addEventListener('message', (e) => {
2957
+ const f = $('art-frame');
2958
+ if (!f || e.source !== f.contentWindow || e.data?.type !== 'eklavya:height') return;
2959
+ const h = Number(e.data.h);
2960
+ if (Number.isFinite(h)) sized(f, Math.min(20000, Math.max(200, h)) + 'px');
2961
+ });
2962
+
2529
2963
  function viewArtifactProjects() {
2530
2964
  const by = new Map();
2531
2965
  for (const a of artifactsInScope()) {
@@ -2730,6 +3164,20 @@ function controlValue(f, el) {
2730
3164
  return el.value;
2731
3165
  }
2732
3166
 
3167
+ /**
3168
+ * Every write the page makes. The server's `acceptWrite` wants the per-start
3169
+ * token from the page's own meta tag and JSON; this sends both. Resolves with
3170
+ * the parsed reply, rejects with the server's own error text.
3171
+ */
3172
+ function postJson(path, body) {
3173
+ const token = document.querySelector('meta[name="eklavya-token"]')?.content ?? '';
3174
+ return fetch(path, {
3175
+ method: 'POST',
3176
+ headers: { 'content-type': 'application/json', 'x-eklavya-token': token },
3177
+ body: JSON.stringify(body),
3178
+ }).then((r) => r.json().then((d) => { if (!r.ok) throw new Error(d.error || `${r.status}`); return d; }));
3179
+ }
3180
+
2733
3181
  /**
2734
3182
  * Saves one setting and redraws the settings in place — not through `render()`,
2735
3183
  * which would scroll to the top and lose the control the reader just used.
@@ -2740,14 +3188,8 @@ function saveSetting(key, change) {
2740
3188
  const say = (text, warn) => { if (msg) { msg.textContent = text; msg.className = warn ? 'warn' : 'here'; } };
2741
3189
  fieldError(key, null);
2742
3190
  say('saving…');
2743
- const token = document.querySelector('meta[name="eklavya-token"]')?.content ?? '';
2744
3191
  const gen = GEN;
2745
- fetch('/api/settings', {
2746
- method: 'POST',
2747
- headers: { 'content-type': 'application/json', 'x-eklavya-token': token },
2748
- body: JSON.stringify({ scope, project: SETS?.project?.id, key, ...change }),
2749
- })
2750
- .then((r) => r.json().then((d) => { if (!r.ok) throw new Error(d.error || `${r.status}`); }))
3192
+ postJson('/api/settings', { scope, project: SETS?.project?.id, key, ...change })
2751
3193
  .then(() => fetch(settingsUrl()).then((r) => r.json()))
2752
3194
  .then((d) => {
2753
3195
  const el = $('settings');
@@ -2848,6 +3290,7 @@ const WORKFLOWS = {
2848
3290
  ],
2849
3291
  pages: {
2850
3292
  dashboard: { label: 'Dashboard', view: (p) => viewArtifacts(p) },
3293
+ view: { parent: 'dashboard', view: (p) => viewArtifact(p) },
2851
3294
  projects: { label: 'Projects', view: () => viewArtifactProjects() },
2852
3295
  },
2853
3296
  },
@@ -3470,6 +3913,8 @@ function render(keepFocus) {
3470
3913
 
3471
3914
  AFTER.length = 0;
3472
3915
  LAZY.clear();
3916
+ // A screen that still has a correction open re-arms this when it draws its bar.
3917
+ armLeave(false);
3473
3918
  $('view').innerHTML = !pg ? viewNotFound('#/' + CUR.wf + '/' + CUR.page, false)
3474
3919
  : CUR.bad ? viewNotFound(CUR.param, true)
3475
3920
  : pg.view(CUR.param);
@@ -3610,7 +4055,8 @@ addEventListener('hashchange', () => { if (INV) { closePicker(false); syncFromUr
3610
4055
  let resizeTimer = 0;
3611
4056
  addEventListener('resize', () => {
3612
4057
  clearTimeout(resizeTimer);
3613
- resizeTimer = setTimeout(() => { if (S) render({ id:'q', pos:null }); }, 120);
4058
+ // Not the viewer: a re-render would reload the framed page and lose its scroll.
4059
+ resizeTimer = setTimeout(() => { if (S && CUR.page !== 'view') render({ id:'q', pos:null }); }, 120);
3614
4060
  });
3615
4061
 
3616
4062
  document.querySelectorAll('[data-ground]').forEach((b) => {