eklavya 1.72.0 → 1.74.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.
@@ -338,7 +338,7 @@
338
338
  /* The correction bar: pinned to the window's bottom edge while you read, and
339
339
  resting under the page at its end, so it never covers the last lines. The
340
340
  left rule is the state: warning while open, verdigris once corrected. */
341
- .fixbar { position:sticky; bottom:0; z-index:5; min-height:64px; display:flex; align-items:center;
341
+ .fixbar { position:sticky; bottom:0; z-index:5; min-height:88px; display:flex; align-items:center;
342
342
  justify-content:space-between; gap:var(--space-4); padding:12px var(--space-5);
343
343
  background:var(--panel); border-top:1px solid var(--line-2); border-left:3px solid transparent; }
344
344
  .fixbar.is-open { border-left-color:var(--warning); }
@@ -346,18 +346,21 @@
346
346
  .fixbar.is-fresh > * { animation:loader-in 180ms var(--ease); }
347
347
  .fixbar p { margin:0; }
348
348
  .fixbar .loader { padding:0; }
349
- /* Two lines at 1.4 fit the 64px row, so open and corrected are one height and nothing moves. */
350
- .fixbar__text { min-width:0; line-height:1.4; }
351
- .fixbar__lead { color:var(--ink); font-size:14px; }
349
+ /* The Eklavya face sits at the left, its expression the bar's state. The 88px row holds every state, so nothing moves. */
350
+ .fixbar eklavya-mascot { flex:none; display:block; }
351
+ .fixbar__text { min-width:0; flex:1; line-height:1.4; }
352
+ .fixbar__lead { color:var(--ink); font-size:15px; font-weight:600; }
352
353
  .fixbar__note { color:var(--dim); font-size:13px; }
353
354
  .fixbar__lead, .fixbar__note, .fixbar__done { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
354
- .fixbar__done { display:flex; align-items:center; gap:var(--space-2); color:var(--ink); min-width:0; }
355
+ .fixbar__done { display:flex; align-items:center; gap:var(--space-2); color:var(--ink); min-width:0; flex:1; }
355
356
  .fixbar__done svg { width:16px; height:16px; color:var(--spot); flex:none; }
356
357
  .fixbar__done span { color:var(--dim); }
357
- .fixbar__err { color:var(--warning); overflow-wrap:anywhere; }
358
+ .fixbar__err { color:var(--warning); overflow-wrap:anywhere; flex:1; }
358
359
  .fixbar .btn, .fixbar .set__btn { flex:none; }
359
360
  .fixbar .set__btn { padding:8px 14px; }
360
361
  @media (max-width:560px) {
362
+ .fixbar { min-height:72px; }
363
+ .fixbar eklavya-mascot svg { width:40px; height:40px; }
361
364
  .fixbar__note { display:none; }
362
365
  .fixbar .btn { padding:10px 12px; }
363
366
  }
@@ -394,6 +397,21 @@
394
397
  .fix__msg.wrong { color:var(--error); }
395
398
  .fix__msg.warn { color:var(--warning); }
396
399
  .fix__msg.ok { color:var(--spot); }
400
+ /* After a right answer the dialog closes itself in 3s. Close carries the count and a fill that sweeps across it;
401
+ Stay open (first in tab order) cancels. Both are hidden unless a countdown runs. */
402
+ #fix-stay { display:none; }
403
+ .fix__foot.is-closing #fix-stay { display:inline-block; }
404
+ #fix-close { position:relative; overflow:hidden; isolation:isolate; min-width:116px; }
405
+ .fix__foot.is-closing #fix-close { border-color:var(--spot); }
406
+ .fix__foot.is-closing #fix-close::before { content:''; position:absolute; inset:0; z-index:-1; background:var(--spot-soft);
407
+ transform-origin:left; animation:fix-fill 3s linear both; }
408
+ @keyframes fix-fill { from { transform:scaleX(0); } to { transform:scaleX(1); } }
409
+ .fix__foot.is-paused #fix-close::before { animation-play-state:paused; }
410
+ @media (prefers-reduced-motion:reduce) { .fix__foot.is-closing #fix-close::before { display:none; } }
411
+ @media (max-width:560px) {
412
+ .fix__foot { flex-wrap:wrap; }
413
+ .fix__msg { flex-basis:100%; }
414
+ }
397
415
  .g2 > *, .g3 > * { margin-top:0 !important; }
398
416
 
399
417
  /* ---------- stat tiles ---------- */
@@ -700,37 +718,146 @@
700
718
  .fb__rule { border:1px solid var(--line-2); border-left:2px solid var(--spot); background:var(--panel);
701
719
  padding:var(--space-4) var(--space-5); }
702
720
  .fb__rule p { margin:0; color:var(--ink); font-size:14px; max-width:72ch; }
703
- .fb__rule--near { margin:0 0 var(--space-4); }
704
- .fb { display:grid; gap:var(--space-6); }
705
- .fb__grid { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:var(--space-5); }
721
+ /* The item: a story column and a decision rail that stays in view; a fixed bar replaces the rail's button at 900px and below. */
722
+ .fb { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:var(--space-6); align-items:start; }
723
+ .fb__story { display:grid; gap:var(--space-6); min-width:0; }
724
+ .fb__rail { position:sticky; top:var(--space-6); display:grid; gap:var(--space-4); min-width:0; }
725
+ .fb__rail.is-tall { position:static; }
726
+ .fb__card { padding:var(--space-5); }
706
727
  .fb__h { font-family:var(--font-mono); font-size:10.5px; letter-spacing:var(--tracking-caps); text-transform:uppercase;
707
728
  color:var(--faint); font-weight:var(--weight-semibold); margin:0 0 var(--space-2); }
708
- .fb__quote { margin:0; padding-left:var(--space-4); border-left:2px solid var(--spot); color:var(--ink);
709
- white-space:pre-wrap; overflow-wrap:anywhere; font-size:14px; }
729
+ .fb__cap { margin:var(--space-1) 0 0; color:var(--faint); font-size:12.5px; }
730
+ .fb__note { margin:0; color:var(--dim); font-size:13.5px; overflow-wrap:anywhere; }
731
+ /* ① The verdict */
732
+ .fb__verdict { display:flex; gap:var(--space-5); align-items:flex-start; }
733
+ .fb__verdict > eklavya-mascot { flex:none; }
734
+ .fb__vb { flex:1; min-width:0; }
735
+ .fb__vh { font-family:var(--font-disp); font-weight:800; font-size:var(--text-xl); line-height:1.15; letter-spacing:-0.02em;
736
+ margin:0; color:var(--ink); overflow-wrap:anywhere; }
737
+ .fb__vh--old { font-size:var(--text-md); }
738
+ .fb__vs { margin:var(--space-2) 0 0; color:var(--dim); font-size:13.5px; }
739
+ .fb__cover { list-style:none; margin:var(--space-5) 0 0; padding:0; display:grid; grid-template-columns:repeat(4, minmax(0, 1fr));
740
+ border:1px solid var(--line-2); background:var(--bg); }
741
+ .fb__cell { display:grid; grid-template-columns:auto minmax(0, 1fr); column-gap:var(--space-2); align-items:center;
742
+ padding:var(--space-3) var(--space-4); color:var(--faint); }
743
+ .fb__cell + .fb__cell { border-left:1px solid var(--line-2); }
744
+ .fb__cell svg { width:16px; height:16px; grid-row:span 2; }
745
+ .fb__cn { color:var(--ink); font-size:13px; font-weight:var(--weight-semibold); }
746
+ .fb__cs { font-family:var(--font-mono); font-size:11px; letter-spacing:0.04em; }
747
+ .fb__cell.is-miss { color:var(--warning); box-shadow:inset 0 -2px 0 var(--warning); }
748
+ .fb__cell.is-ok { color:var(--spot); box-shadow:inset 0 -2px 0 var(--spot); }
749
+ /* ② The two prompts, one block */
750
+ .fb__grid { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); border:1px solid var(--line-2); }
751
+ .fb__grid > section { padding:var(--space-5); min-width:0; }
752
+ .fb__mine { background:var(--panel); border-right:1px solid var(--line-2); }
753
+ .fb__better { background:var(--bg); }
754
+ .fb__ch { display:flex; align-items:center; justify-content:space-between; gap:var(--space-3); min-height:32px; margin-bottom:var(--space-2); }
755
+ .fb__ch .fb__h { margin:0; }
756
+ .fb__better .fb__h { color:var(--spot); }
757
+ .fb__quote { margin:0; color:var(--ink); white-space:pre-wrap; overflow-wrap:anywhere; font-size:14px; }
758
+ .fb__mine .fb__quote { color:var(--dim); }
710
759
  .cmd--block { margin-top:0; align-items:flex-start; }
711
760
  .cmd--block code { white-space:pre-wrap; overflow-wrap:anywhere; overflow-x:visible; }
712
- .fb__ds, .fb__tips { list-style:none; margin:0; padding:0; display:grid; gap:var(--space-3); }
713
- .fb__ds li { display:grid; grid-template-columns:120px minmax(0, 1fr); gap:var(--space-3); align-items:baseline; }
714
- .fb__dn { font-family:var(--font-mono); font-size:12px; color:var(--dim); }
715
- .fb__ctx { display:grid; grid-template-columns:auto minmax(0, 1fr); gap:var(--space-1) var(--space-4); margin:0; font-size:13px; }
716
- .fb__ctx dt { font-family:var(--font-mono); font-size:12px; color:var(--dim); }
717
- .fb__ctx dd { margin:0; color:var(--ink); overflow-wrap:anywhere; }
718
- .fb__note { margin:0; color:var(--dim); font-size:13.5px; overflow-wrap:anywhere; }
719
- .fb__ev { display:block; color:var(--faint); font-size:12.5px; }
720
- .fb__tips li { color:var(--dim); font-size:13.5px; padding-left:var(--space-4); position:relative; }
721
- .fb__tips li::before { content:'–'; position:absolute; left:0; color:var(--faint); }
722
- .fb__actions { display:flex; gap:var(--space-3); flex-wrap:wrap; align-items:center; }
761
+ .fb__better .cmd--block code { background:none; border:0; padding:0; color:var(--ink); font-size:13px; }
762
+ .fb__ph { background:var(--spot-soft); color:inherit; padding:0 2px; }
763
+ .fb__clamp { position:relative; overflow:hidden; transition:max-height var(--duration-base) var(--ease); }
764
+ .fb__clamp.is-clamped { max-height:15.5em; }
765
+ .fb__clamp.is-clamped::after { content:''; position:absolute; inset:auto 0 0; height:4em; pointer-events:none;
766
+ background:linear-gradient(transparent, var(--bg)); }
767
+ .fb__mine .fb__clamp.is-clamped::after { background:linear-gradient(transparent, var(--panel)); }
768
+ .fb__copy, .fb__more, .fb__del { font:inherit; font-size:13px; cursor:pointer; background:none; color:var(--dim);
769
+ border:1px solid transparent; display:inline-flex; align-items:center; gap:var(--space-2);
770
+ padding:var(--space-1) var(--space-2); }
771
+ .fb__copy svg { width:14px; height:14px; }
772
+ .fb__copy:hover, .fb__more:hover { color:var(--spot); border-color:var(--spot); }
773
+ .fb__more { margin-top:var(--space-2); padding-inline:0; color:var(--spot); }
774
+ .fb__more:hover { border-color:transparent; text-decoration:underline; }
775
+ .fb__copy:focus-visible, .fb__more:focus-visible, .fb__del:focus-visible { outline:none; box-shadow:var(--ring); }
776
+ /* ③ What worked */
777
+ .fb__worked { display:flex; gap:var(--space-4); align-items:flex-start; padding:var(--space-4) var(--space-5);
778
+ background:var(--panel); border:1px solid var(--line-2); border-left:2px solid var(--spot); }
779
+ .fb__worked > eklavya-mascot, .fb__sh > eklavya-mascot, .fb__tip > eklavya-mascot, .fb__dh > eklavya-mascot { flex:none; }
780
+ .fb__worked .fb__h { color:var(--spot); margin-bottom:var(--space-1); }
781
+ .fb__worked p { margin:0; color:var(--ink); font-size:14px; overflow-wrap:anywhere; }
782
+ /* ④ What it left out and ⑤ next time */
783
+ .fb__sec { display:grid; gap:var(--space-4); }
784
+ .fb__sh { display:flex; gap:var(--space-3); align-items:center; }
785
+ .fb__sh h3, .fb__dh h3 { font-family:var(--font-disp); font-weight:800; font-size:var(--text-md); margin:0; color:var(--ink); }
786
+ .fb__ds, .fb__tips { list-style:none; margin:0; padding:0; }
787
+ .fb__ds { border:1px solid var(--line-2); background:var(--panel); }
788
+ .fb__ds li { display:grid; grid-template-columns:44px minmax(0, 1fr); gap:var(--space-4); padding:var(--space-5) var(--space-6); }
789
+ .fb__ds li + li { border-top:1px solid var(--line); }
790
+ .fb__tile { width:44px; height:44px; display:grid; place-items:center; background:var(--warning-soft); color:var(--warning); }
791
+ .fb__tile svg { width:20px; height:20px; }
792
+ .fb__gb { display:grid; gap:var(--space-1); min-width:0; }
793
+ .fb__gt { margin:0; color:var(--ink); font-weight:var(--weight-semibold); font-size:14px; }
794
+ .fb__gt small { font-family:var(--font-mono); font-weight:400; font-size:11px; color:var(--faint); margin-left:var(--space-2); }
795
+ .fb__ev { display:flex; gap:var(--space-3); align-items:baseline; flex-wrap:wrap; margin:var(--space-2) 0 0; font-size:13px; }
796
+ .fb__evl { font-family:var(--font-mono); font-size:10.5px; letter-spacing:var(--tracking-caps); text-transform:uppercase; color:var(--faint); }
797
+ .fb__ev q { background:var(--mass); color:var(--ink); padding:var(--space-1) var(--space-2); overflow-wrap:anywhere; quotes:'“' '”'; }
798
+ .fb__tips { display:grid; gap:var(--space-3); counter-reset:t; }
799
+ .fb__tips li { counter-increment:t; display:grid; grid-template-columns:auto minmax(0, 1fr); gap:var(--space-4); align-items:baseline;
800
+ background:var(--panel); border:1px solid var(--line-2); padding:var(--space-4) var(--space-5);
801
+ color:var(--ink); font-size:14px; overflow-wrap:anywhere; }
802
+ .fb__tips li::before { content:'0' counter(t); font-family:var(--font-disp); font-weight:900; font-size:var(--text-2xl);
803
+ line-height:1; color:var(--spot); }
804
+ @media (min-width:1100px) {
805
+ .fb__tips[data-n="2"] { grid-template-columns:repeat(2, minmax(0, 1fr)); }
806
+ .fb__tips[data-n="3"] { grid-template-columns:repeat(3, minmax(0, 1fr)); }
807
+ .fb__tips[data-n="2"] li, .fb__tips[data-n="3"] li { grid-template-columns:minmax(0, 1fr); align-content:start; }
808
+ }
809
+ /* ⑥ Found a problem */
810
+ .fb__tip { display:flex; gap:var(--space-3); align-items:center; flex-wrap:wrap; padding-top:var(--space-4); border-top:1px solid var(--line-2); }
811
+ .fb__tip p { flex:1 1 260px; margin:0; color:var(--dim); font-size:13px; }
812
+ .fb__tip code { background:var(--mass); padding:1px 6px; font-size:12px; color:var(--ink); }
813
+ /* The rail */
814
+ .fb__ctx { display:grid; gap:var(--space-3); margin:0; font-size:13px; }
815
+ .fb__ctx dt { font-family:var(--font-mono); font-size:10.5px; letter-spacing:var(--tracking-caps); text-transform:uppercase; color:var(--faint); }
816
+ .fb__ctx dd { margin:var(--space-1) 0 0; color:var(--ink); overflow-wrap:anywhere; }
817
+ .fb__ctx .slug { display:block; font-family:var(--font-mono); font-size:11px; color:var(--faint); overflow-wrap:anywhere; }
818
+ .fb__decide { border-color:var(--spot); display:grid; gap:var(--space-3); }
819
+ .fb__dh { display:flex; gap:var(--space-3); align-items:center; }
820
+ .fb__dp { margin:0; color:var(--dim); font-size:13.5px; }
821
+ .fb__actions { display:grid; gap:var(--space-2); }
822
+ .fb__ack { width:100%; min-height:44px; justify-content:center; display:inline-flex; align-items:center; gap:var(--space-2); }
823
+ .fb__ack svg { width:16px; height:16px; }
824
+ .fb__del { color:var(--faint); justify-content:center; min-height:32px; }
825
+ .fb__del:hover { color:var(--error); }
723
826
  .fb__ask { display:inline-flex; gap:var(--space-3); flex-wrap:wrap; align-items:center; color:var(--dim); font-size:13.5px; }
724
827
  .fb__msg { margin:0; color:var(--warning); font-size:13px; }
828
+ .fb__msg:empty { display:none; }
829
+ .fb__foot { display:flex; justify-content:space-between; margin:0; padding-top:var(--space-3); border-top:1px solid var(--line);
830
+ font-family:var(--font-mono); font-size:11px; color:var(--faint); }
831
+ .fb__acked { margin:0; font-family:var(--font-mono); font-size:12px; color:var(--faint); }
832
+ .fb__bar { display:none; }
725
833
  .fb__sts { display:flex; gap:var(--space-2); flex-wrap:wrap; }
726
- .fb__tip h2 { font-family:var(--font-disp); font-size:var(--text-md); font-weight:var(--weight-semibold); margin:0 0 var(--space-2); }
727
- .fb__tip p { margin:0 0 var(--space-4); color:var(--dim); max-width:70ch; }
728
- .fb__tip .fb__quote { margin-bottom:var(--space-4); }
729
834
  .fb__why p { margin:0 0 var(--space-3); color:var(--dim); max-width:70ch; }
835
+ @media (max-width:900px) {
836
+ .fb { grid-template-columns:minmax(0, 1fr); }
837
+ .fb__rail { position:static; }
838
+ .fb__ctx { grid-template-columns:repeat(2, minmax(0, 1fr)); column-gap:var(--space-5); }
839
+ /* The bar holds Acknowledge here; the rail card keeps Delete and the message. */
840
+ .fb__decide .fb__ack { display:none; }
841
+ .fb__bar { display:flex; align-items:center; gap:var(--space-4); position:fixed; inset:auto 0 0; z-index:20;
842
+ background:var(--panel); border-top:1px solid var(--spot); padding:var(--space-3) var(--space-5); }
843
+ .fb__bar p { flex:1; margin:0; color:var(--dim); font-size:13.5px; }
844
+ .fb__bar .fb__ack { width:auto; flex:none; }
845
+ main:has(.fb__bar) { padding-bottom:120px; }
846
+ }
730
847
  @media (max-width:560px) {
848
+ .fb__verdict > eklavya-mascot { display:none; }
731
849
  .fb__grid { grid-template-columns:minmax(0, 1fr); }
732
- .fb__ds li { grid-template-columns:minmax(0, 1fr); gap:var(--space-1); }
733
- .fb__actions > button, .fb__actions a { flex:1 1 100%; min-height:44px; }
850
+ .fb__mine { border-right:0; border-bottom:1px solid var(--line-2); }
851
+ .fb__cover { grid-template-columns:repeat(2, minmax(0, 1fr)); }
852
+ .fb__cell + .fb__cell { border-left:0; }
853
+ .fb__cell:nth-child(even) { border-left:1px solid var(--line-2); }
854
+ .fb__cell:nth-child(n+3) { border-top:1px solid var(--line-2); }
855
+ .fb__ds li { padding:var(--space-4); gap:var(--space-3); }
856
+ .fb__ctx { grid-template-columns:minmax(0, 1fr); }
857
+ .fb__bar p { display:none; }
858
+ .fb__bar .fb__ack { flex:1; width:100%; }
859
+ .fb__copy, .fb__del, .fb__tip .set__btn { min-height:44px; }
860
+ .fb__del { width:100%; }
734
861
  .fb__ask > button { flex:1 1 40%; min-height:44px; }
735
862
  }
736
863
  .starts__more > summary { cursor:pointer; color:var(--dim); font-size:13px; padding:var(--space-2) 0; }
@@ -1024,6 +1151,7 @@
1024
1151
  <div class="fix__opts" id="fix-opts" role="group" aria-labelledby="fix-q"></div>
1025
1152
  <div class="fix__foot">
1026
1153
  <p class="fix__msg" id="fix-msg" role="status"></p>
1154
+ <button type="button" class="set__btn" id="fix-stay">Stay open</button>
1027
1155
  <button type="button" class="set__btn" id="fix-close">Close</button>
1028
1156
  </div>
1029
1157
  </dialog>
@@ -1538,12 +1666,14 @@ function startBlocks(title, cmds) {
1538
1666
 
1539
1667
  /** Copies a block's command; if the clipboard refuses, selects it for the keyboard shortcut instead. */
1540
1668
  function copyCommand(btn) {
1541
- const code = btn.parentElement.querySelector('code');
1669
+ // `data-copy` may name the text it copies, for a button that is not beside it; its text is plain, without the marks.
1670
+ const code = btn.dataset.copy ? $(btn.dataset.copy) : btn.parentElement.querySelector('code');
1671
+ const label = btn.querySelector('span') ?? btn;
1542
1672
  const live = $('copy-live');
1543
1673
  Promise.resolve().then(() => navigator.clipboard.writeText(code.textContent)).then(() => {
1544
- btn.textContent = 'Copied';
1674
+ label.textContent = 'Copied';
1545
1675
  live.textContent = 'Command copied';
1546
- setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
1676
+ setTimeout(() => { label.textContent = 'Copy'; }, 1500);
1547
1677
  }, () => {
1548
1678
  getSelection().selectAllChildren(code);
1549
1679
  live.textContent = `Press ${/Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘C' : 'Ctrl+C'} to copy`;
@@ -2335,8 +2465,10 @@ function viewConcepts(param) {
2335
2465
  const t = T.concepts;
2336
2466
  t.state = CONCEPT_STATES.includes(param) ? param : 'all';
2337
2467
  t.domain = CUR.query.get('domain') || 'all';
2468
+ t.tag = CUR.query.get('tag') || 'all';
2338
2469
  const all = conceptsInScope();
2339
2470
  const domains = [...new Set(all.map((c) => c.domain))].sort();
2471
+ const tags = [...new Set(all.flatMap((c) => c.tags))].sort();
2340
2472
  const q = t.q.trim().toLowerCase();
2341
2473
 
2342
2474
  let list = all.filter((c) => {
@@ -2345,7 +2477,8 @@ function viewConcepts(param) {
2345
2477
  if (t.state === 'due' && !c.due) return false;
2346
2478
  if (t.state === 'unseen' && c.seen) return false;
2347
2479
  if (t.domain !== 'all' && c.domain !== t.domain) return false;
2348
- if (q && !(`${c.name} ${c.slug} ${c.domain} ${c.context ?? ''}`.toLowerCase().includes(q))) return false;
2480
+ if (t.tag !== 'all' && !c.tags.includes(t.tag)) return false;
2481
+ if (q && !(`${c.name} ${c.slug} ${c.domain} ${c.tags.join(' ')} ${c.context ?? ''}`.toLowerCase().includes(q))) return false;
2349
2482
  return true;
2350
2483
  });
2351
2484
 
@@ -2365,7 +2498,8 @@ function viewConcepts(param) {
2365
2498
  const rows = slice(list, p).map((c) => `
2366
2499
  <tr class="row" data-go="${esc(LH('concept', c.slug))}">
2367
2500
  <td style="min-width:220px"><span class="title">${esc(c.name)}</span>
2368
- <div class="slug">${esc(c.domain)} · ${esc(tierLabel(c.tier))}</div></td>
2501
+ <div class="slug">${esc(c.domain)} · ${esc(tierLabel(c.tier))}</div>
2502
+ ${c.tags.length ? `<div class="tags" style="margin-top:var(--space-2)">${tagPills(c.tags)}</div>` : ''}</td>
2369
2503
  <td style="min-width:104px">${c.seen ? meter(c.score * 100, c.score < 0.5 ? 'warn' : '') +
2370
2504
  `<div class="slug">${Math.round(c.score * 100)}%</div>` : '<span class="slug">—</span>'}</td>
2371
2505
  <td class="num">${c.attempts || '—'}</td>
@@ -2393,6 +2527,10 @@ function viewConcepts(param) {
2393
2527
  <option value="all">All domains</option>
2394
2528
  ${domains.map((d) => `<option value="${esc(d)}"${t.domain === d ? ' selected' : ''}>${esc(d)}</option>`).join('')}
2395
2529
  </select>
2530
+ ${tags.length ? `<select id="ctag" aria-label="Tag">
2531
+ <option value="all">All tags</option>
2532
+ ${tags.map((x) => `<option value="${esc(x)}"${t.tag === x ? ' selected' : ''}>${esc(x)}</option>`).join('')}
2533
+ </select>` : ''}
2396
2534
  <select id="sort" aria-label="Sort">
2397
2535
  ${[['weakest','Weakest first'],['strongest','Strongest first'],['recent','Recently seen'],['asked','Most asked'],['tier','By tier'],['name','A–Z']]
2398
2536
  .map(([v, l]) => `<option value="${v}"${t.sort === v ? ' selected' : ''}>${l}</option>`).join('')}
@@ -2433,6 +2571,7 @@ function viewConcept(slug) {
2433
2571
  `<code class="slug">${esc(c.slug)}</code>`, esc(c.domain), esc(tierLabel(c.tier)),
2434
2572
  esc(c.source === 'seed' ? 'from the seed graph' : 'minted from your work'),
2435
2573
  ], c.description ? esc(c.description) : '', back),
2574
+ c.tags.length ? `<div class="tags">${c.tags.map((x) => `<a class="pill link" data-go="${esc(LH('concepts', null, { tag: x }))}">${esc(x)}</a>`).join(' ')}</div>` : '',
2436
2575
 
2437
2576
  tiles([
2438
2577
  ['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' : ''],
@@ -3495,10 +3634,13 @@ function loadFix(a) {
3495
3634
  .catch((e) => {
3496
3635
  const el = bar();
3497
3636
  if (!el || drawn) return;
3498
- el.innerHTML = `<p class="fixbar__err">${esc(e.message)}</p><button type="button" class="set__btn" id="fix-retry">Retry</button>`;
3637
+ el.innerHTML = `${faceFor('error')}<p class="fixbar__err">${esc(e.message)}</p><button type="button" class="set__btn" id="fix-retry">Retry</button>`;
3499
3638
  });
3500
3639
  }
3501
3640
 
3641
+ /** The bar's Eklavya face: its expression is the mascot's own map from the bar's state. */
3642
+ const faceFor = (state) => `<eklavya-mascot state="${state}" variant="face" size="56"></eklavya-mascot>`;
3643
+
3502
3644
  /** Draws the bar for `d`, /api/attempts/correction's answer; `fresh` crossfades into it. */
3503
3645
  function paintFix(el, a, d, fresh) {
3504
3646
  FIX = { attempt: a.attempt, artifact: a.id, q: d };
@@ -3510,11 +3652,11 @@ function paintFix(el, a, d, fresh) {
3510
3652
  if (dot) dot.className = `tab__dot is-${done ? 'done' : 'open'}`;
3511
3653
  // The button the reader is on (a bar drawn again over its own) is found again in the new one.
3512
3654
  const ui = captureUi(el);
3513
- el.innerHTML = done
3655
+ el.innerHTML = faceFor(done ? 'success' : 'warning') + (done
3514
3656
  ? `<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>`
3515
3657
  : `<div class="fixbar__text"><p class="fixbar__lead">You missed this one.</p>
3516
3658
  <p class="fixbar__note">Read it, then correct your answer. Not now? It stays under To correct.</p></div>
3517
- <button type="button" class="btn" id="fix-open">Correct your answer</button>`;
3659
+ <button type="button" class="btn" id="fix-open">Correct your answer</button>`);
3518
3660
  restoreUi(el, ui);
3519
3661
  }
3520
3662
 
@@ -3540,6 +3682,7 @@ function openFix() {
3540
3682
  $('fix-opts').innerHTML = shuffled(d.options.length).map((i) =>
3541
3683
  `<button type="button" class="fix__opt" data-pick="${esc(d.options[i])}"><span>${esc(d.options[i])}</span>`
3542
3684
  + `${d.option_notes?.[i] ? `<span class="fix__n">${esc(d.option_notes[i])}</span>` : ''}<span class="fix__tag"></span></button>`).join('');
3685
+ stopCount();
3543
3686
  fixSay('', '');
3544
3687
  $('fix').showModal();
3545
3688
  TIP.sync();
@@ -3557,14 +3700,16 @@ function pickFix(btn) {
3557
3700
  if (r.corrected) {
3558
3701
  btn.classList.add('is-right');
3559
3702
  btn.querySelector('.fix__tag').textContent = 'Right answer';
3560
- fixSay('Corrected. Your accuracy now counts this as right.', 'ok');
3703
+ fixSay(FIXDONE, 'ok');
3704
+ // Read out once, politely, with the message; the ticks below only change Close's label.
3705
+ $('fix-msg').insertAdjacentHTML('beforeend', `<span class="sr"> Closing in ${COUNT} seconds. Press Stay open to cancel.</span>`);
3561
3706
  FIX.q = { ...FIX.q, tries: r.tries, corrected_at: new Date().toISOString() };
3562
3707
  const row = S.artifacts.find((x) => x.id === FIX.artifact);
3563
3708
  if (row) row.correction = 'done';
3564
3709
  // Every other count on the page (the tiles, the gallery's tag, the accuracy) follows by itself: the server
3565
3710
  // noticed this write and told the page, which reads the payload again and redraws in place.
3566
3711
  armLeave(false);
3567
- $('fix-close').focus();
3712
+ startCount();
3568
3713
  return;
3569
3714
  }
3570
3715
  btn.classList.add('is-wrong');
@@ -3590,9 +3735,54 @@ $('fix-opts').addEventListener('keydown', (e) => {
3590
3735
  const i = live.indexOf(document.activeElement);
3591
3736
  live[(i + step + live.length) % live.length]?.focus();
3592
3737
  });
3738
+ /**
3739
+ * After a right answer the dialog closes itself, so the reader can read the answer first and still cancel.
3740
+ * One handle, cleared by every way out: Stay open, any close (button, Escape, the countdown) and a re-open.
3741
+ * A hidden tab holds the count and the fill, so the dialog never closes unseen. The message is read out once,
3742
+ * when the count starts; the ticks only change Close's label.
3743
+ */
3744
+ const FIXDONE = 'Corrected. Your accuracy now counts this as right.';
3745
+ const COUNT = 3;
3746
+ let FIXCD = null;
3747
+ const fixFoot = () => $('fix-close').parentElement;
3748
+ function stopCount() {
3749
+ if (!FIXCD) return;
3750
+ clearTimeout(FIXCD.timer);
3751
+ FIXCD = null;
3752
+ fixFoot().classList.remove('is-closing', 'is-paused');
3753
+ $('fix-close').textContent = 'Close';
3754
+ }
3755
+ function runCount() {
3756
+ fixFoot().classList.toggle('is-paused', document.hidden);
3757
+ if (document.hidden) return;
3758
+ FIXCD.timer = setTimeout(() => {
3759
+ if (--FIXCD.n > 0) { $('fix-close').textContent = `Closing in ${FIXCD.n}…`; return runCount(); }
3760
+ stopCount();
3761
+ $('fix').close();
3762
+ }, 1000);
3763
+ }
3764
+ function startCount() {
3765
+ stopCount();
3766
+ FIXCD = { n: COUNT, timer: 0 };
3767
+ fixFoot().classList.add('is-closing');
3768
+ $('fix-close').textContent = `Closing in ${COUNT}…`;
3769
+ runCount();
3770
+ $('fix-stay').focus();
3771
+ }
3772
+ document.addEventListener('visibilitychange', () => {
3773
+ if (!FIXCD) return;
3774
+ clearTimeout(FIXCD.timer);
3775
+ runCount();
3776
+ });
3777
+ $('fix-stay').addEventListener('click', () => {
3778
+ stopCount();
3779
+ fixSay(FIXDONE, 'ok');
3780
+ $('fix-close').focus();
3781
+ });
3593
3782
  $('fix-close').addEventListener('click', () => $('fix').close());
3594
3783
  // However it closes -- the button, Escape -- the bar shows where things stand.
3595
3784
  $('fix').addEventListener('close', () => {
3785
+ stopCount();
3596
3786
  TIP.sync();
3597
3787
  const bar = $('fixbar');
3598
3788
  const a = FIX && S.artifacts.find((x) => x.id === FIX.artifact);
@@ -4203,9 +4393,13 @@ function drawSettings(d, scope) {
4203
4393
  ============================================================ */
4204
4394
  const FB = {
4205
4395
  ruleEmpty: 'Eklavya reviews one prompt at a time. When one is ready it appears here, and the next waits until you press Acknowledge.',
4206
- rulePending: 'Feedback pauses until you acknowledge this one. Read it, then press Acknowledge to get the next.',
4396
+ rulePending: 'The next review waits until you acknowledge this one. It moves to History.',
4397
+ barRule: 'The next review waits until you acknowledge this one.',
4207
4398
  announce: 'Acknowledged. Feedback resumes at your next session.',
4208
4399
  areas: { outcome: 'Outcome', context: 'Context', scope: 'Scope', check: 'Check' },
4400
+ hints: { outcome: 'what done looks like', context: 'files, systems, prior facts', scope: 'the limits', check: 'how the agent proves it worked' },
4401
+ // `unknown` is an item from rubric 2 or older, which named only its gaps.
4402
+ status: { left_out: 'Left out', clear: 'Clear', not_needed: 'Not needed', unknown: 'Not flagged' },
4209
4403
  counted: false,
4210
4404
  observerCmd: 'eklavya config set providers.observer <model>',
4211
4405
  about: `Your prompts are already recorded by memory. After a session ends, in the background and never in the middle of your work,
@@ -4240,16 +4434,17 @@ function fbEmpty(F) {
4240
4434
  return `<div class="card fb__why" id="fb-empty"><div class="ek-mascot-message"><eklavya-mascot state="${state}" size="72"></eklavya-mascot><div>${body}</div></div></div>`;
4241
4435
  }
4242
4436
 
4437
+ /** With no item, the rule is said above the reason; with one, it sits beside Acknowledge in the rail. */
4438
+ const fbNone = (F) => `<div class="fb__rule" id="fb-rule"><p>${esc(FB.ruleEmpty)}</p></div>${fbEmpty(F)}${fbTip()}`;
4439
+
4243
4440
  function viewFeedbackDashboard() {
4244
4441
  const F = S.feedback;
4245
- const item = F.pending ? slot('fb-item', '/api/feedback', (d) => d.item ? drawFbItem(d.item) : fbEmpty(S.feedback), { label: 'Loading your feedback…' }) : '';
4442
+ const item = F.pending ? slot('fb-item', '/api/feedback', (d) => d.item ? drawFbItem(d.item) : fbNone(S.feedback), { label: 'Loading your feedback…' }) : '';
4246
4443
  if (F.pending && !FB.counted) { FB.counted = true; AFTER.push(fbOpened); }
4247
4444
  const counts = [F.pending ? '1 waiting' : 'nothing waiting', plural(F.acknowledged, 'acknowledged item')];
4248
4445
  return [
4249
4446
  pageHead('Feedback', counts, FB.about),
4250
- `<div class="fb__rule" id="fb-rule"><p>${esc(F.pending ? FB.rulePending : FB.ruleEmpty)}</p></div>`,
4251
- F.pending ? item : fbEmpty(F),
4252
- fbTip(),
4447
+ F.pending ? item : fbNone(F),
4253
4448
  `<span class="sr" id="copy-live" aria-live="polite"></span>`,
4254
4449
  ].join('');
4255
4450
  }
@@ -4263,10 +4458,10 @@ function viewFeedbackDashboard() {
4263
4458
  const FB_TIP_KEY = 'eklavya-dash-feedback-tip';
4264
4459
  function fbTip() {
4265
4460
  try { if (localStorage.getItem(FB_TIP_KEY) === '1') return ''; } catch (e) { /* shown */ }
4266
- return `<aside class="card fb__tip" id="fb-tip" aria-labelledby="fb-tip-h">
4267
- <h2 id="fb-tip-h">Found a problem with Eklavya?</h2>
4268
- <p>Tell Claude, in plain words. It drafts a GitHub issue, shows you exactly what it will post with paths, project names and code removed, and files it only after you say yes.</p>
4269
- <blockquote class="fb__quote">That question about retries was wrong. File feedback for Eklavya.</blockquote>
4461
+ return `<aside class="fb__tip" id="fb-tip" aria-labelledby="fb-tip-h">
4462
+ <eklavya-mascot state="info" variant="face" size="28"></eklavya-mascot>
4463
+ <h2 class="sr" id="fb-tip-h">Found a problem with Eklavya?</h2>
4464
+ <p>Found a problem with Eklavya itself? Tell Claude <code>File feedback for Eklavya</code>; it shows you the issue before posting.</p>
4270
4465
  <button type="button" class="set__btn" data-fb-tip-dismiss>Dismiss</button>
4271
4466
  </aside>`;
4272
4467
  }
@@ -4287,57 +4482,162 @@ function viewFeedbackItem(id) {
4287
4482
  ].join('');
4288
4483
  }
4289
4484
 
4485
+ const fbAck = (id) => `<button type="button" class="btn fb__ack" data-fb-ack="${id}">${svg(ICONS.ack)}Acknowledge</button>`;
4290
4486
  const fbActions = (id) => `<div class="fb__actions" id="fb-actions">
4291
- <button type="button" class="btn" data-fb-ack="${id}">Acknowledge</button>
4292
- <button type="button" class="set__btn" data-fb-del="${id}">Delete</button>
4487
+ ${fbAck(id)}
4488
+ <button type="button" class="fb__del" data-fb-del="${id}">Delete this feedback</button>
4293
4489
  <p class="fb__msg" id="fb-msg" role="alert"></p></div>`;
4294
4490
 
4295
4491
  const fmtWhen = (ts) => { const d = parse(ts); return d ? d.toLocaleString(undefined, { month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }) : null; };
4296
4492
 
4297
- /** Where the prompt was written: project, checkout, host, session and when, so the note is not blind. */
4493
+ /** Where the prompt was written: project, checkout, session, when and from which host, so the note is not blind. */
4298
4494
  function fbContext(it) {
4299
4495
  const c = it.context ?? {};
4300
- const sessionTail = [c.began && c.ended ? `${fmtWhen(c.began)} to ${fmtWhen(c.ended)}` : '', c.prompts ? plural(c.prompts, 'prompt') : '']
4301
- .filter(Boolean).map((x) => ' · ' + x).join('');
4496
+ const line = (html) => `<span class="slug">${html}</span>`;
4497
+ const sent = [fmtWhen(c.prompted_at), c.host].filter(Boolean).join(' · ');
4302
4498
  const rows = [
4303
- ['Project', `${esc(pname(it.project))} <span class="slug">${esc(it.project)}</span>`],
4499
+ ['Project', `${esc(pname(it.project))}${line(esc(it.project))}`],
4304
4500
  c.checkout && c.checkout !== it.project ? ['Checkout', esc(c.checkout)] : null,
4501
+ c.session_id ? ['Session', `<a data-go="${esc(MH('session', c.session_id))}">${esc(c.session_id.slice(0, 8))}</a>`
4502
+ + (c.prompts ? esc(' · ' + plural(c.prompts, 'prompt')) : '')
4503
+ + (c.began && c.ended ? line(esc(`${fmtWhen(c.began)} – ${fmtWhen(c.ended)}`)) : '')] : null,
4504
+ sent ? ['Sent', esc(sent)] : null,
4305
4505
  c.topic ? ['About', esc(c.topic)] : null,
4306
- c.host ? ['Host', esc(c.host)] : null,
4307
- fmtWhen(c.prompted_at) ? ['Sent', esc(fmtWhen(c.prompted_at))] : null,
4308
- c.session_id ? ['Session', `<a data-go="${esc(MH('session', c.session_id))}">${esc(c.session_id.slice(0, 8))}</a>${esc(sessionTail)}`] : null,
4309
4506
  ].filter(Boolean);
4310
- return `<section><h2 class="fb__h">Where you said it</h2><dl class="fb__ctx">${rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('')}</dl></section>`;
4507
+ return `<section class="card fb__card" aria-labelledby="fb-ctx-h"><h2 class="fb__h" id="fb-ctx-h">Where you said it</h2>
4508
+ <dl class="fb__ctx">${rows.map(([k, v]) => `<div><dt>${k}</dt><dd>${v}</dd></div>`).join('')}</dl></section>`;
4509
+ }
4510
+
4511
+ /** The order of the coverage strip. The headline lists gaps in the review's own order, costliest first. */
4512
+ const AREA_ORDER = ['outcome', 'context', 'scope', 'check'];
4513
+ const fbList = (xs) => xs.length < 2 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`;
4514
+
4515
+ /** An area's status: the review's word since rubric 3; before it, a gap is "left out" and anything else was never said. */
4516
+ const fbStatus = (review, a) => review.areas?.[a] ?? (review.gaps.some((g) => g.area === a) ? 'left_out' : 'unknown');
4517
+ const FB_CLS = { left_out: 'is-miss', clear: 'is-ok', not_needed: 'is-na', unknown: 'is-na' };
4518
+
4519
+ /** The conclusion first. Every word of it comes from the gap areas, never from model text, and none is a number. */
4520
+ function fbVerdict(it) {
4521
+ const { worked, gaps } = it.review;
4522
+ const date = it.context?.prompted_at ? fmtDate(it.context.prompted_at) : '';
4523
+ const label = `<p class="fb__h">Review of one prompt${date ? ' · ' + esc(date) : ''}</p>`;
4524
+ // Only an item cleared by migration 029 has no `worked` (parseReview requires one).
4525
+ if (!worked) {
4526
+ return `<section class="card fb__verdict" aria-labelledby="fb-verdict-h"><eklavya-mascot state="info" size="88"></eklavya-mascot>
4527
+ <div>${label}<h2 class="fb__vh fb__vh--old" id="fb-verdict-h">An older review: the prompt, the rewrite and the tips are below.</h2></div></section>`;
4528
+ }
4529
+ const areas = [...new Set(gaps.map((g) => g.area))].map((a) => 'the ' + (FB.areas[a] ?? a).toLowerCase());
4530
+ const head = areas.length ? `The agent had to guess ${fbList(areas)}.` : 'Nothing here left the agent guessing.';
4531
+ const cells = AREA_ORDER.map((a) => {
4532
+ const st = fbStatus(it.review, a);
4533
+ return `<li role="listitem" class="fb__cell ${FB_CLS[st]}">${svg(ICONS[a])}<span class="fb__cn">${esc(FB.areas[a])}</span> <span class="fb__cs">${esc(FB.status[st])}</span></li>`;
4534
+ }).join('');
4535
+ return `<section class="card fb__verdict" aria-labelledby="fb-verdict-h">
4536
+ <eklavya-mascot state="${gaps.length ? 'review' : 'complete'}" size="88"></eklavya-mascot>
4537
+ <div class="fb__vb">${label}<h2 class="fb__vh" id="fb-verdict-h">${esc(head)}</h2>
4538
+ ${gaps.length ? '<p class="fb__vs">Of the prompts in that session, this one cost the most rework.</p>' : ''}
4539
+ <ul class="fb__cover" role="list" aria-label="What the prompt covered">${cells}</ul></div></section>`;
4540
+ }
4541
+
4542
+ /** A long text is clamped with its own toggle; `id` names the text the toggle controls. */
4543
+ const FB_CLAMP = { better: 600, mine: 1500 };
4544
+ const fbMore = (key, id, text) => text.length > FB_CLAMP[key]
4545
+ ? `<button type="button" class="fb__more" data-fb-more="${id}" data-focus="more:${key}" aria-expanded="false" aria-controls="${id}">Show the whole prompt</button>` : '';
4546
+
4547
+ function fbToggle(btn) {
4548
+ const el = $(btn.dataset.fbMore);
4549
+ const open = btn.getAttribute('aria-expanded') !== 'true';
4550
+ if (open) {
4551
+ el.classList.remove('is-clamped');
4552
+ // A height to move to, so the expansion is a transition; then no cap, so a resize cannot cut it.
4553
+ el.style.maxHeight = el.scrollHeight + 'px';
4554
+ setTimeout(() => { if (!el.classList.contains('is-clamped')) el.style.maxHeight = 'none'; }, 400);
4555
+ } else {
4556
+ el.style.maxHeight = el.scrollHeight + 'px';
4557
+ el.getBoundingClientRect();
4558
+ el.style.maxHeight = '';
4559
+ el.classList.add('is-clamped');
4560
+ }
4561
+ btn.setAttribute('aria-expanded', String(open));
4562
+ btn.textContent = open ? 'Show less' : 'Show the whole prompt';
4563
+ }
4564
+
4565
+ /** The rewrite, escaped first; each [placeholder] it left for a detail only the developer knows is then marked. */
4566
+ const FB_PH = /\[[^\]\n]{2,200}\]/g;
4567
+
4568
+ function fbCompare(it) {
4569
+ const better = esc(it.better);
4570
+ const marked = better.replace(FB_PH, (m) => `<mark class="fb__ph">${m}</mark>`);
4571
+ const clamp = (key, text) => text.length > FB_CLAMP[key] ? ' is-clamped' : '';
4572
+ return `<div class="fb__grid">
4573
+ <section class="fb__mine" aria-labelledby="fb-mine-h"><div class="fb__ch"><h2 class="fb__h" id="fb-mine-h">Your prompt</h2></div>
4574
+ <div class="fb__clamp${clamp('mine', it.prompt)}" id="fb-mine-t"><blockquote class="fb__quote">${esc(it.prompt)}</blockquote></div>${fbMore('mine', 'fb-mine-t', it.prompt)}</section>
4575
+ <section class="fb__better" aria-labelledby="fb-better-h"><div class="fb__ch"><h2 class="fb__h" id="fb-better-h">A better prompt</h2>
4576
+ <button type="button" class="fb__copy" data-copy="fb-better-c" data-focus="copy:better">${svg(ICONS.copy)}<span>Copy</span></button></div>
4577
+ <div class="cmd cmd--block fb__clamp${clamp('better', it.better)}" id="fb-better-t"><code id="fb-better-c">${marked}</code></div>${fbMore('better', 'fb-better-t', it.better)}
4578
+ ${marked !== better ? '<p class="fb__cap">Highlighted: details only you know. Fill them in before you reuse it.</p>' : ''}</section>
4579
+ </div>`;
4580
+ }
4581
+
4582
+ /** A section's head: its mascot, its heading and one quiet line. */
4583
+ const fbHead = (state, id, title, cap) => `<div class="fb__sh"><eklavya-mascot state="${state}" variant="face" size="40"></eklavya-mascot>
4584
+ <div><h3 id="${id}">${title}</h3>${cap ? `<p class="fb__cap">${cap}</p>` : ''}</div></div>`;
4585
+
4586
+ function fbGaps(gaps) {
4587
+ if (!gaps.length) {
4588
+ return `<section class="fb__sec" aria-labelledby="fb-gaps-h">${fbHead('success', 'fb-gaps-h', 'What it left out', '')}
4589
+ <p class="fb__note">Nothing the agent had to guess.</p></section>`;
4590
+ }
4591
+ const rows = gaps.map((g) => `<li><span class="fb__tile">${svg(ICONS[g.area] ?? '')}</span><div class="fb__gb">
4592
+ <p class="fb__gt">${esc(FB.areas[g.area] ?? g.area)} <small>${esc(FB.hints[g.area] ?? '')}</small></p>
4593
+ <p class="fb__note">${esc(g.missing)}</p>
4594
+ ${g.evidence ? `<p class="fb__ev"><span class="fb__evl" aria-hidden="true">You, later</span><span class="sr">You said later: </span><q>${esc(g.evidence)}</q></p>` : ''}</div></li>`).join('');
4595
+ return `<section class="fb__sec" aria-labelledby="fb-gaps-h">${fbHead('warning', 'fb-gaps-h', 'What it left out', 'What the agent had to guess, costliest first')}
4596
+ <ol class="fb__ds">${rows}</ol></section>`;
4311
4597
  }
4312
4598
 
4313
- /** One item: your prompt, a better one, what worked, what it left out, tips. Actions only while it is pending. */
4599
+ /** The decision, beside the item: the rule, Acknowledge and Delete. Only while it is pending. */
4600
+ const fbDecide = (it) => `<section class="card fb__card fb__decide" aria-labelledby="fb-decide-h">
4601
+ <div class="fb__dh"><eklavya-mascot state="info" variant="face" size="32"></eklavya-mascot><h3 id="fb-decide-h">Done reading?</h3></div>
4602
+ <p class="fb__dp">${esc(FB.rulePending)}</p>
4603
+ ${fbActions(it.id)}
4604
+ <p class="fb__foot"><span>1 waiting</span><span>${esc(S.feedback.acknowledged + ' in history')}</span></p></section>`;
4605
+
4606
+ /**
4607
+ * One item: the verdict, the two prompts, what worked, what it left out and the
4608
+ * habits for next time, with the decision held in view beside them (a fixed bar
4609
+ * at 900px and below). Acknowledge only while it is pending.
4610
+ */
4314
4611
  function drawFbItem(it) {
4315
4612
  const pending = !it.acknowledged_at;
4316
4613
  const { worked, gaps } = it.review;
4317
- const rows = gaps.map((g) => `<li><span class="fb__dn">${esc(FB.areas[g.area] ?? g.area)}</span> `
4318
- + `<span class="fb__note">${esc(g.missing)}`
4319
- + `${g.evidence ? `<span class="fb__ev">You said later: “${esc(g.evidence)}”</span>` : ''}</span></li>`).join('');
4320
- // Only an item cleared by migration 029 has no `worked` (parseReview requires one); its prompt, rewrite and tips still show.
4321
- const review = worked
4322
- ? `<section><h2 class="fb__h">What worked</h2><p class="fb__note">${esc(worked)}</p></section>
4323
- <section><h2 class="fb__h">What it left out</h2>${rows ? `<ul class="fb__ds">${rows}</ul>` : '<p class="fb__note">Nothing the agent had to guess.</p>'}</section>`
4324
- : '';
4325
- // Said again right above the button on the very first item a reader sees.
4326
- const first = pending && S.feedback.acknowledged === 0
4327
- ? `<div class="fb__rule fb__rule--near"><p>${esc(FB.rulePending)}</p></div>` : '';
4328
- return `<article class="card fb" aria-label="Feedback on one of your prompts">
4329
- ${fbContext(it)}
4330
- <div class="fb__grid">
4331
- <section><h2 class="fb__h">Your prompt</h2><blockquote class="fb__quote">${esc(it.prompt)}</blockquote></section>
4332
- <section><h2 class="fb__h">A better prompt</h2>
4333
- <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>
4614
+ chart('fb-rail', fbRailFit);
4615
+ return `<article class="fb" aria-label="Feedback on one of your prompts">
4616
+ <div class="fb__story">
4617
+ ${fbVerdict(it)}
4618
+ ${fbCompare(it)}
4619
+ ${worked ? `<section class="fb__worked" aria-labelledby="fb-worked-h"><eklavya-mascot state="success" variant="face" size="40"></eklavya-mascot>
4620
+ <div><h3 class="fb__h" id="fb-worked-h">What worked</h3><p>${esc(worked)}</p></div></section>` : ''}
4621
+ ${worked ? fbGaps(gaps) : ''}
4622
+ <section class="fb__sec" aria-labelledby="fb-tips-h">${fbHead('tip', 'fb-tips-h', 'Next time', worked ? 'Habits that would have saved those follow-ups' : 'Habits for next time')}
4623
+ <ol class="fb__tips" data-n="${Math.min(it.tips.length, 3)}">${it.tips.map((t) => `<li>${esc(t)}</li>`).join('')}</ol></section>
4624
+ ${pending ? fbTip() : ''}
4334
4625
  </div>
4335
- ${review}
4336
- <section><h2 class="fb__h">Tips</h2><ul class="fb__tips">${it.tips.map((t) => `<li>${esc(t)}</li>`).join('')}</ul></section>
4337
- ${pending ? `<section>${first}${fbActions(it.id)}</section>` : ''}
4626
+ <aside class="fb__rail" id="fb-rail" aria-label="About this prompt">
4627
+ ${fbContext(it)}
4628
+ ${pending ? fbDecide(it) : `<p class="fb__acked">Acknowledged ${esc(fmtDate(it.acknowledged_at))}</p>`}
4629
+ </aside>
4630
+ ${pending ? `<div class="fb__bar" role="region" aria-label="Acknowledge this feedback"><p>${esc(FB.barRule)}</p>${fbAck(it.id)}</div>` : ''}
4338
4631
  </article>`;
4339
4632
  }
4340
4633
 
4634
+ /** The rail stays in view while it fits the window; one taller than that would hide its own end, so it scrolls with the page. */
4635
+ function fbRailFit() {
4636
+ const rail = $('fb-rail');
4637
+ if (rail) rail.classList.toggle('is-tall', rail.offsetHeight > innerHeight - 48);
4638
+ }
4639
+ addEventListener('resize', fbRailFit);
4640
+
4341
4641
  function viewFeedbackHistory() {
4342
4642
  const p = new URLSearchParams();
4343
4643
  if (PROJECT !== null) p.set('project', PROJECT);
@@ -4393,11 +4693,18 @@ function fbFail(e) {
4393
4693
 
4394
4694
  function fbAcknowledge(btn) {
4395
4695
  if (btn.disabled) return;
4396
- btn.disabled = true;
4696
+ // The rail's button and the bar's are one action: pressing either holds both.
4697
+ const both = [...document.querySelectorAll('[data-fb-ack]')];
4698
+ both.forEach((b) => { b.disabled = true; });
4397
4699
  postJson('/api/feedback/acknowledge', { id: +btn.dataset.fbAck })
4398
4700
  // The item, its history row and the next one waiting all changed: what was read of them is gone.
4399
4701
  .then((d) => { S.feedback = d.state; respForget('/api/feedback'); fbDone(FB.announce); })
4400
- .catch((e) => { btn.disabled = false; fbFail(e); });
4702
+ .catch((e) => {
4703
+ both.forEach((b) => { b.disabled = false; });
4704
+ fbFail(e);
4705
+ // Under the bar the message is in the rail card, after the story.
4706
+ if (innerWidth <= 900) $('fb-msg')?.scrollIntoView({ block: 'nearest' });
4707
+ });
4401
4708
  }
4402
4709
 
4403
4710
  function fbAskDelete(btn) {
@@ -4531,6 +4838,13 @@ const ICONS = {
4531
4838
  history: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
4532
4839
  user: '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
4533
4840
  project: '<path d="M3 7l9-4 9 4-9 4-9-4z"/><path d="M3 12l9 4 9-4M3 17l9 4 9-4"/>',
4841
+ // The four areas a prompt can leave the agent to guess, and the Feedback item's two actions.
4842
+ outcome: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
4843
+ context: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
4844
+ scope: '<path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/>',
4845
+ check: '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
4846
+ copy: '<rect width="14" height="14" x="8" y="8"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
4847
+ ack: '<path d="M20 6 9 17l-5-5"/>',
4534
4848
  };
4535
4849
  const svg = (paths, cls = '') =>
4536
4850
  `<svg${cls ? ` class="${cls}"` : ''} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
@@ -4557,7 +4871,7 @@ const WORKFLOWS = {
4557
4871
  ],
4558
4872
  pages: {
4559
4873
  dashboard: { label: 'Dashboard', view: () => viewLearningDashboard() },
4560
- concepts: { label: 'Concepts', view: (p) => viewConcepts(p), query: ['domain'], inPage: true },
4874
+ concepts: { label: 'Concepts', view: (p) => viewConcepts(p), query: ['domain', 'tag'], inPage: true },
4561
4875
  concept: { parent: 'concepts', view: (p) => viewConcept(p) },
4562
4876
  review: { label: 'Review', view: (p) => viewReview(p), inPage: true },
4563
4877
  sessions: { label: 'Sessions', view: () => viewSessions() },
@@ -5624,7 +5938,7 @@ function catchUp() {
5624
5938
  * opens (`detailSelector`). A control with none is not carried over a redraw, so every control the reader can Tab to has one.
5625
5939
  */
5626
5940
  const FOCUS_KEYS = ['data-focus', 'data-tab', 'data-tab-id', 'data-state', 'data-unset', 'data-go',
5627
- 'data-fb-ack', 'data-fb-del', 'data-fb-yes', 'data-fb-no', 'data-fb-tip-dismiss'];
5941
+ 'data-fb-ack', 'data-fb-del', 'data-fb-more', 'data-fb-yes', 'data-fb-no', 'data-fb-tip-dismiss'];
5628
5942
  function focusKey(el) {
5629
5943
  if (el.id) return `#${CSS.escape(el.id)}`;
5630
5944
  for (const a of FOCUS_KEYS) {
@@ -5732,6 +6046,8 @@ function onActivate(e) {
5732
6046
  if (e.target.closest('[data-slot][aria-busy="true"][data-kept]')) return;
5733
6047
  const copy = e.target.closest('[data-copy]');
5734
6048
  if (copy) return copyCommand(copy);
6049
+ const more = e.target.closest('[data-fb-more]');
6050
+ if (more) return fbToggle(more);
5735
6051
  if (e.target.closest('[data-fb-tip-dismiss]')) return fbTipDismiss();
5736
6052
  const ack = e.target.closest('[data-fb-ack]');
5737
6053
  if (ack) return fbAcknowledge(ack);
@@ -5759,7 +6075,7 @@ function onActivate(e) {
5759
6075
  if (chipState) {
5760
6076
  T.concepts.page = 1;
5761
6077
  const s = chipState.dataset.state;
5762
- return goTo(LH('concepts', s === 'all' ? null : s, { domain: CUR.query.get('domain') }));
6078
+ return goTo(LH('concepts', s === 'all' ? null : s, { domain: CUR.query.get('domain'), tag: CUR.query.get('tag') }));
5763
6079
  }
5764
6080
  const tab = e.target.closest('[data-tab]');
5765
6081
  if (tab) { T.review.page = 1; return goTo(LH('review', tab.dataset.tab === 'due' ? null : tab.dataset.tab)); }
@@ -5833,6 +6149,7 @@ $('view').addEventListener('change', (e) => {
5833
6149
  return;
5834
6150
  }
5835
6151
  if (e.target.id === 'dom') { T.concepts.page = 1; setQuery({ domain: e.target.value }); }
6152
+ if (e.target.id === 'ctag') { T.concepts.page = 1; setQuery({ tag: e.target.value }); }
5836
6153
  if (e.target.id === 'sort') { T.concepts.sort = e.target.value; T.concepts.page = 1; render(); }
5837
6154
  if (e.target.id === 'mtag') { T.memory.page = 1; setQuery({ tag: e.target.value }); }
5838
6155
  if (e.target.id === 'mtype') {