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.
- package/README.md +11 -1
- package/dist/artifacts.js +17 -0
- package/dist/artifacts.js.map +1 -1
- package/dist/assets/artifact-template.html +3 -0
- package/dist/assets/dashboard.html +476 -30
- package/dist/assets/tutor/SKILL.md +2 -1
- package/dist/assets/tutor/references/writing-mcq.md +11 -1
- package/dist/cli.js +44 -12
- package/dist/cli.js.map +1 -1
- package/dist/dashboard.js +201 -47
- package/dist/dashboard.js.map +1 -1
- package/dist/hooks/checkpoint-quiz.js +3 -2
- package/dist/hooks/checkpoint-quiz.js.map +1 -1
- package/dist/hooks/memory-lib.js +1 -1
- package/dist/hooks/memory-lib.js.map +1 -1
- package/dist/hooks/stop-quiz-check.js +3 -2
- package/dist/hooks/stop-quiz-check.js.map +1 -1
- package/dist/memory/recall.js +1 -1
- package/dist/migrations/019_attempt_corrections.sql +41 -0
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/agents/explainer.md +11 -6
- package/dist/plugin/skills/tutor/SKILL.md +2 -1
- package/dist/plugin/skills/tutor/references/writing-mcq.md +11 -1
- package/dist/store.js +142 -14
- package/dist/store.js.map +1 -1
- package/dist/telemetry-send.js +9 -7
- package/dist/telemetry-send.js.map +1 -1
- package/dist/tools/get_learner_profile.js +5 -3
- package/dist/tools/get_learner_profile.js.map +1 -1
- package/dist/tools/get_session_quiz_plan.js +1 -1
- package/dist/tools/get_session_quiz_plan.js.map +1 -1
- package/dist/tools/record_attempt.js +48 -8
- package/dist/tools/record_attempt.js.map +1 -1
- package/dist/user-skill/eklavya/SKILL.md +7 -3
- package/dist/user-skill/eklavya-artifacts/SKILL.md +12 -4
- 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
|
-
/**
|
|
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
|
-
|
|
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
|
|
2464
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) => {
|