eklavya 1.74.0 → 1.75.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 CHANGED
@@ -136,8 +136,9 @@ it cannot parse gets a 400 without stopping the server
136
136
  Its Learning page shows your streak and its calendar under the summary tiles; a
137
137
  **To be corrected** tile there and on the Artifacts page counts missed answers still
138
138
  waiting for a correction; correcting one closes its dialog after three seconds unless you choose **Stay open**. The
139
- review queue gives each project a command to copy that starts Claude Code on its due
140
- concepts, in the project's folder when that folder is available.
139
+ review page lists due concepts as cards; opening one shows the steps to quiz that single concept in
140
+ Claude Code or Claude desktop, in the project's folder when that folder is available. Every Copy button
141
+ answers with a toast.
141
142
  Its feature tips show the archer companion and use Driver.js, bundled in `dist/` at build and served by the
142
143
  dashboard itself at `/vendor/driver-hints.js` and `/vendor/driver-hints.css`, so
143
144
  the page still requests nothing from another host. [Configuration](https://eklavya-run.web.app/docs/configuration/) lists
@@ -714,6 +714,104 @@
714
714
  .cmd__copy:focus-visible { outline:none; box-shadow:var(--ring); }
715
715
  .cmd__note { margin:var(--space-2) 0 0; color:var(--dim); font-size:13px; }
716
716
  .starts > .cmd__note { margin-top:var(--space-3); }
717
+
718
+ /* ---------- Core: toast and dialog (use these, never a page-local popup) ---------- */
719
+ .toasts { position:fixed; right:var(--space-4); bottom:var(--space-4); z-index:90; display:flex; flex-direction:column;
720
+ gap:var(--space-3); width:min(400px, calc(100vw - 32px)); pointer-events:none; }
721
+ .toast { pointer-events:auto; display:grid; grid-template-columns:auto 1fr auto; gap:var(--space-3); align-items:start;
722
+ padding:var(--space-3) var(--space-2) var(--space-3) var(--space-3); background:var(--panel);
723
+ border:1px solid var(--line-2); border-left:4px solid var(--spot); animation:toast-in var(--duration-slow) var(--ease); }
724
+ .toast[data-k="caution"] { border-left-color:var(--warning); }
725
+ .toast[data-k="alert"] { border-left-color:var(--error); }
726
+ .toast b { display:block; font-size:14px; }
727
+ .toast p { margin:2px 0 0; color:var(--dim); font-size:13px; }
728
+ .toast__act { margin-top:var(--space-2); border:0; background:none; padding:0; color:var(--spot); font:600 12.5px var(--font-mono);
729
+ text-decoration:underline; text-underline-offset:3px; cursor:pointer; }
730
+ .toast__x { width:28px; height:28px; border:0; background:none; color:var(--dim); font-size:18px; line-height:1; cursor:pointer; }
731
+ .toast__x:hover { color:var(--ink); }
732
+ .toast__x:focus-visible, .toast__act:focus-visible { outline:none; box-shadow:var(--ring); }
733
+ .toast.is-out { opacity:0; transform:translateY(8px); transition:opacity var(--duration-base) var(--ease), transform var(--duration-base) var(--ease); }
734
+ @keyframes toast-in { from { opacity:0; transform:translateY(10px); } }
735
+ dialog.dlg { width:min(460px, calc(100vw - 32px)); margin:auto; padding:0; border:1px solid var(--line-2); border-radius:0;
736
+ background:var(--panel); color:var(--ink); }
737
+ dialog.dlg::backdrop { background:color-mix(in srgb, var(--ink) 40%, transparent); backdrop-filter:blur(6px); }
738
+ .dlg__b { display:grid; grid-template-columns:auto 1fr; gap:var(--space-4); padding:var(--space-5) var(--space-5) var(--space-2); }
739
+ .dlg h2 { margin:0 0 4px; font:900 18px var(--font-disp); }
740
+ .dlg p { margin:0; color:var(--dim); font-size:13.5px; }
741
+ .dlg textarea { grid-column:1 / -1; width:100%; min-height:80px; background:var(--mass); border:1px solid var(--line-2); color:var(--ink); padding:var(--space-3); font:inherit; }
742
+ .dlg textarea:focus-visible { outline:none; box-shadow:var(--ring); }
743
+ .dlg__f { display:flex; justify-content:flex-end; gap:var(--space-3); padding:var(--space-3) var(--space-5) var(--space-5); }
744
+ .btn--spot { background:var(--spot); border-color:var(--spot); color:var(--spot-ink); }
745
+ .btn--danger { background:var(--error); border-color:var(--error); color:var(--bg); }
746
+ @media (max-width:760px) { .toasts { right:0; left:0; bottom:0; width:100%; padding:0 var(--space-4) var(--space-4); } }
747
+ @media (prefers-reduced-motion:reduce) { .toast { animation:none; } .toast.is-out { transition:none; } }
748
+
749
+ /* ---------- Review: one concept at a time ---------- */
750
+ .rv-intro { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,2fr); gap:var(--space-6); }
751
+ .rv-intro h2 { margin:0 0 var(--space-2); font:800 15px var(--font-disp); }
752
+ .rv-intro p { margin:0; color:var(--dim); font-size:13.5px; }
753
+ .rv-steps { display:grid; grid-template-columns:repeat(3, minmax(0,1fr)); gap:var(--space-4); margin:0; padding:0; list-style:none; counter-reset:rv; }
754
+ .rv-steps li { counter-increment:rv; position:relative; padding-top:28px; color:var(--dim); font-size:13px; }
755
+ .rv-steps li::before { content:counter(rv); position:absolute; top:0; left:0; width:20px; height:20px; border:1px solid var(--spot);
756
+ color:var(--spot); font:600 11px/18px var(--font-mono); text-align:center; }
757
+ .rv-steps b { display:block; color:var(--ink); font-weight:600; }
758
+ .rv-hide { margin-top:var(--space-3); border:0; background:none; padding:0; color:var(--faint); font:11px var(--font-mono); text-decoration:underline; text-underline-offset:3px; cursor:pointer; }
759
+ .rv-hide:focus-visible { outline:none; box-shadow:var(--ring); }
760
+ .rv-list { display:grid; gap:var(--space-3); }
761
+ .rv-first { position:relative; margin-left:132px; min-height:150px; display:flex; align-items:center; }
762
+ .rv-first > .rv-card { flex:1; }
763
+ .rv-guide { position:absolute; right:calc(100% + 6px); top:50%; transform:translateY(-50%); width:128px; display:flex; flex-direction:column; align-items:center; pointer-events:none; }
764
+ .rv-tag { font:600 10.5px var(--font-mono); letter-spacing:var(--tracking-caps); text-transform:uppercase; background:var(--spot); color:var(--spot-ink); padding:2px 8px; white-space:nowrap; }
765
+ .rv-card { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:4px var(--space-4); align-items:center; width:100%; text-align:left;
766
+ padding:var(--space-4) var(--space-5); background:var(--panel); border:1px solid var(--line); border-left:3px solid var(--line-2);
767
+ color:inherit; font:inherit; cursor:pointer; transition:border-color var(--duration-base) var(--ease), background var(--duration-base) var(--ease); }
768
+ .rv-card:hover { border-left-color:var(--spot); background:var(--mass); }
769
+ .rv-card:focus-visible { outline:none; box-shadow:var(--ring); }
770
+ .rv-first .rv-card { border-color:var(--spot); background:var(--spot-soft); }
771
+ .rv-card__n { font:700 16px var(--font-disp); }
772
+ .rv-card__m { font:12px var(--font-mono); color:var(--dim); }
773
+ .rv-card__m .warn { color:var(--warning); }
774
+ .rv-card__go { grid-row:1 / 3; grid-column:2; color:var(--spot); font:600 12px var(--font-mono); white-space:nowrap; }
775
+ .rv-more > summary { cursor:pointer; color:var(--dim); font-size:13px; padding:var(--space-2) 0; }
776
+ .rv-more > summary:hover { color:var(--spot); }
777
+ .rv-more > summary:focus-visible { outline:none; box-shadow:var(--ring); }
778
+ .rv-more[open] > summary { margin-bottom:var(--space-2); }
779
+ .rv-tips { display:grid; grid-template-columns:repeat(3, minmax(0,1fr)); gap:var(--space-3); }
780
+ .rv-tips div { border:1px solid var(--line); padding:var(--space-3) var(--space-4); color:var(--dim); font-size:13px; }
781
+ .rv-tips b { display:block; color:var(--ink); font-size:13.5px; margin-bottom:2px; }
782
+ dialog.sheet { position:fixed; inset:0 0 0 auto; margin:0; width:min(520px, 100vw); height:100vh; max-height:none; padding:0;
783
+ border:0; border-left:1px solid var(--line-2); border-radius:0; background:var(--bg); color:var(--ink); }
784
+ dialog.sheet::backdrop { background:color-mix(in srgb, var(--ink) 40%, transparent); }
785
+ dialog.sheet[open] { animation:sheet-in var(--duration-slow) var(--ease); }
786
+ @keyframes sheet-in { from { transform:translateX(24px); opacity:0; } }
787
+ .sheet__h { display:flex; justify-content:space-between; gap:var(--space-3); padding:var(--space-5) var(--space-5) var(--space-3); border-bottom:1px solid var(--line); }
788
+ .sheet__h h2 { margin:0; font:900 22px/1.15 var(--font-disp); }
789
+ .sheet__h p { margin:3px 0 0; color:var(--dim); font:12px var(--font-mono); }
790
+ .sheet__x { width:32px; height:32px; border:1px solid var(--line-2); background:none; color:var(--ink); font-size:18px; line-height:1; cursor:pointer; flex:none; }
791
+ .sheet__x:focus-visible { outline:none; box-shadow:var(--ring); }
792
+ .sheet__b { padding:var(--space-4) var(--space-5) var(--space-6); }
793
+ .sheet__seg { display:grid; grid-template-columns:1fr 1fr; border:1px solid var(--line-2); margin-bottom:var(--space-4); }
794
+ .sheet__seg button { border:0; background:none; color:inherit; font:inherit; text-align:left; padding:10px 12px; cursor:pointer; }
795
+ .sheet__seg button + button { border-left:1px solid var(--line-2); }
796
+ .sheet__seg button[aria-pressed="true"] { background:var(--spot-soft); box-shadow:inset 0 -2px 0 var(--spot); }
797
+ .sheet__seg button:focus-visible { outline:none; box-shadow:var(--ring); }
798
+ .sheet__seg b { display:block; font-size:13.5px; }
799
+ .sheet__seg small { font:11px var(--font-mono); color:var(--faint); }
800
+ .sheet__how { list-style:none; margin:0; padding:0; counter-reset:sh; }
801
+ .sheet__how > li { counter-increment:sh; position:relative; padding:0 0 var(--space-4) 38px; }
802
+ .sheet__how > li::before { content:counter(sh); position:absolute; left:0; top:0; width:24px; height:24px; border:1px solid var(--spot); color:var(--spot); font:600 12px/22px var(--font-mono); text-align:center; }
803
+ .sheet__how h3 { margin:0 0 2px; font-size:14px; }
804
+ .sheet__how p { margin:0 0 var(--space-2); color:var(--dim); font-size:13px; }
805
+ .sheet__how .cmd { margin-top:0; }
806
+ .sheet__why { margin-top:var(--space-4); }
807
+ .sheet__why h3 { margin:0 0 var(--space-2); font:500 10.5px var(--font-mono); letter-spacing:var(--tracking-caps); text-transform:uppercase; color:var(--faint); }
808
+ @media (max-width:760px) {
809
+ .rv-intro, .rv-steps, .rv-tips { grid-template-columns:1fr; }
810
+ .rv-first { margin-left:0; margin-top:60px; }
811
+ .rv-guide { right:auto; left:0; top:-56px; transform:none; flex-direction:row; width:auto; gap:6px; }
812
+ .rv-card { grid-template-columns:1fr; } .rv-card__go { grid-row:auto; grid-column:1; }
813
+ dialog.sheet { inset:auto 0 0 0; width:100vw; height:88vh; border-left:0; border-top:1px solid var(--line-2); }
814
+ }
717
815
  /* ---------- Feedback: one reviewed prompt at a time ---------- */
718
816
  .fb__rule { border:1px solid var(--line-2); border-left:2px solid var(--spot); background:var(--panel);
719
817
  padding:var(--space-4) var(--space-5); }
@@ -1145,6 +1243,16 @@
1145
1243
  </main>
1146
1244
  </div>
1147
1245
 
1246
+ <div class="toasts" id="toasts"></div>
1247
+ <dialog class="dlg" id="dlg" aria-labelledby="dlg-h"><form method="dialog">
1248
+ <div class="dlg__b"><span id="dlg-av"></span><div><h2 id="dlg-h"></h2><p id="dlg-p"></p></div><textarea id="dlg-in" hidden></textarea></div>
1249
+ <div class="dlg__f"><button class="btn" value="cancel" id="dlg-c"></button><button class="btn" value="ok" id="dlg-ok"></button></div>
1250
+ </form></dialog>
1251
+ <dialog class="sheet" id="sheet" aria-labelledby="sheet-h">
1252
+ <div class="sheet__h"><div><h2 id="sheet-h"></h2><p id="sheet-s"></p></div><button type="button" class="sheet__x" data-sheet-close aria-label="Close">×</button></div>
1253
+ <div class="sheet__b" id="sheet-b"></div>
1254
+ </dialog>
1255
+
1148
1256
  <dialog class="fix" id="fix" aria-labelledby="fix-q">
1149
1257
  <p class="fix__eyebrow">Correct your answer</p>
1150
1258
  <h2 class="fix__q" id="fix-q"></h2>
@@ -1627,6 +1735,10 @@ const isWindows = () => navigator.userAgent.includes('Windows');
1627
1735
  * to the concept's last project, or to `into` (the selected project). Slugs
1628
1736
  * are kebab-case already; anything else is dropped rather than quoted.
1629
1737
  */
1738
+ /** The shell line that enters a project folder, quoted for this machine's shell. */
1739
+ const cdTo = (path) => isWindows() ? `Set-Location -LiteralPath '${path.replace(/'/g, "''")}'`
1740
+ : `cd '${path.replace(/'/g, "'\\''")}'`;
1741
+
1630
1742
  function startCommand(concepts, into = null) {
1631
1743
  const groups = new Map();
1632
1744
  for (const c of concepts) {
@@ -1640,9 +1752,7 @@ function startCommand(concepts, into = null) {
1640
1752
  const quiz = `claude "/eklavya:quiz ${slugs.join(' ')}"`;
1641
1753
  const p = projectById(id);
1642
1754
  const here = p?.path && p.available;
1643
- const command = !here ? quiz
1644
- : isWindows() ? `Set-Location -LiteralPath '${p.path.replace(/'/g, "''")}'; ${quiz}`
1645
- : `cd '${p.path.replace(/'/g, "'\\''")}' && ${quiz}`;
1755
+ const command = !here ? quiz : `${cdTo(p.path)}${isWindows() ? '; ' : ' && '}${quiz}`;
1646
1756
  return { name: p?.name ?? base(id), count: cs.length, command,
1647
1757
  note: here ? '' : 'Run it inside the project you want these answers counted in.' };
1648
1758
  }).sort((a, b) => b.count - a.count);
@@ -1664,19 +1774,73 @@ function startBlocks(title, cmds) {
1664
1774
  <span class="sr" id="copy-live" aria-live="polite"></span></section>`;
1665
1775
  }
1666
1776
 
1777
+
1778
+ /* ---------- core components: toast and dialog ----------
1779
+ Every page uses these for "something happened" and "are you sure"; none writes its own popup.
1780
+ toast.success|caution|alert(text, { title, id, action: { label, run } }), toast.dismiss(id);
1781
+ ekDialog({ state, title, text, ok, cancel, input, danger, single }) resolves { ok, value }. */
1782
+ const TOAST_FACE = { success: 'success', caution: 'warning', alert: 'error' };
1783
+ const toast = (() => {
1784
+ const show = (kind, text, o = {}) => {
1785
+ const host = $('toasts');
1786
+ const id = o.id ?? `${kind}:${text}`;
1787
+ host.querySelectorAll('.toast').forEach((x) => { if (x.dataset.id === id) x.remove(); });
1788
+ const el = document.createElement('div');
1789
+ el.className = 'toast'; el.dataset.k = kind; el.dataset.id = id;
1790
+ el.setAttribute('role', kind === 'alert' ? 'alert' : 'status');
1791
+ const title = o.title ?? { success: 'Done', caution: 'Careful', alert: 'Something went wrong' }[kind];
1792
+ el.innerHTML = `<eklavya-mascot state="${TOAST_FACE[kind]}" variant="face" size="44"></eklavya-mascot>
1793
+ <div><b>${esc(title)}</b><p>${esc(text)}</p>${o.action ? `<button type="button" class="toast__act">${esc(o.action.label)}</button>` : ''}</div>
1794
+ <button type="button" class="toast__x" aria-label="Dismiss">×</button>`;
1795
+ let timer;
1796
+ const leave = () => { clearTimeout(timer); el.classList.add('is-out'); setTimeout(() => el.remove(), 200); };
1797
+ const arm = () => { clearTimeout(timer); timer = setTimeout(leave, kind === 'alert' ? 8000 : 4000); };
1798
+ el.querySelector('.toast__x').onclick = leave;
1799
+ if (o.action) el.querySelector('.toast__act').onclick = () => { o.action.run(); leave(); };
1800
+ el.addEventListener('mouseenter', () => clearTimeout(timer));
1801
+ el.addEventListener('focusin', () => clearTimeout(timer));
1802
+ el.addEventListener('mouseleave', arm);
1803
+ el.addEventListener('focusout', arm);
1804
+ host.append(el);
1805
+ while (host.children.length > 3) host.firstChild.remove();
1806
+ arm();
1807
+ };
1808
+ return {
1809
+ success: (t, o) => show('success', t, o), caution: (t, o) => show('caution', t, o), alert: (t, o) => show('alert', t, o),
1810
+ dismiss: (id) => $('toasts').querySelectorAll('.toast').forEach((x) => { if (x.dataset.id === id) x.remove(); }),
1811
+ };
1812
+ })();
1813
+
1814
+ function ekDialog({ state = 'info', title, text = '', ok = 'OK', cancel = 'Cancel', input = null, danger = false, single = false }) {
1815
+ const d = $('dlg'), field = $('dlg-in'), yes = $('dlg-ok'), no = $('dlg-c');
1816
+ $('dlg-av').innerHTML = `<eklavya-mascot state="${state}" variant="face" size="56"></eklavya-mascot>`;
1817
+ $('dlg-h').textContent = title; $('dlg-p').textContent = text;
1818
+ field.hidden = !input; field.value = ''; if (input) field.setAttribute('aria-label', input);
1819
+ yes.textContent = ok; yes.className = `btn ${danger ? 'btn--danger' : 'btn--spot'}`;
1820
+ no.textContent = cancel; no.hidden = single;
1821
+ d.returnValue = ''; d.showModal();
1822
+ (danger ? no : input ? field : yes).focus();
1823
+ return new Promise((done) => d.addEventListener('close', () => done({ ok: d.returnValue === 'ok', value: field.value }), { once: true }));
1824
+ }
1825
+
1667
1826
  /** Copies a block's command; if the clipboard refuses, selects it for the keyboard shortcut instead. */
1668
1827
  function copyCommand(btn) {
1669
1828
  // `data-copy` may name the text it copies, for a button that is not beside it; its text is plain, without the marks.
1829
+ // `data-hint` says what to do next, in the toast.
1670
1830
  const code = btn.dataset.copy ? $(btn.dataset.copy) : btn.parentElement.querySelector('code');
1671
1831
  const label = btn.querySelector('span') ?? btn;
1672
1832
  const live = $('copy-live');
1833
+ const say = (t) => { if (live) live.textContent = t; };
1673
1834
  Promise.resolve().then(() => navigator.clipboard.writeText(code.textContent)).then(() => {
1674
1835
  label.textContent = 'Copied';
1675
- live.textContent = 'Command copied';
1836
+ say('Command copied');
1837
+ toast.success(btn.dataset.hint ?? 'It is on your clipboard.', { id: 'copy', title: 'Copied' });
1676
1838
  setTimeout(() => { label.textContent = 'Copy'; }, 1500);
1677
1839
  }, () => {
1678
1840
  getSelection().selectAllChildren(code);
1679
- live.textContent = `Press ${/Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘C' : 'Ctrl+C'} to copy`;
1841
+ const key = /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘C' : 'Ctrl+C';
1842
+ say(`Press ${key} to copy`);
1843
+ toast.caution(`The browser refused the copy. The text is selected: press ${key}.`, { id: 'copy', title: 'Not copied yet' });
1680
1844
  });
1681
1845
  }
1682
1846
 
@@ -2607,6 +2771,98 @@ function viewConcept(slug) {
2607
2771
  ].join('');
2608
2772
  }
2609
2773
 
2774
+
2775
+ /* ---------- the review page: one concept at a time ---------- */
2776
+ const REVIEW_TOOL_KEY = 'eklavya-dash-review-tool';
2777
+ const REVIEW_INTRO_KEY = 'eklavya-dash-review-intro';
2778
+ const REVIEW_SHOWN = 5, REVIEW_FOLDED = 20;
2779
+ const reviewTool = () => { try { return localStorage.getItem(REVIEW_TOOL_KEY) === 'desktop' ? 'desktop' : 'code'; } catch (e) { return 'code'; } };
2780
+
2781
+ /** Where and how to start a quiz on one concept: the project, its folder if it is on this machine, and the slash command. */
2782
+ function conceptStart(c) {
2783
+ const p = projectById(PROJECT ?? pid(c.repo));
2784
+ const path = p?.path && p.available ? p.path : null;
2785
+ return { project: p?.name ?? null, path, slash: `/eklavya:quiz ${c.slug}`, cd: path ? cdTo(path) : null };
2786
+ }
2787
+
2788
+ function reviewCard(c, first) {
2789
+ const s = conceptStart(c);
2790
+ const btn = `<button type="button" class="rv-card" data-start="${esc(c.slug)}" data-focus="start:${esc(c.slug)}" aria-haspopup="dialog">
2791
+ <span class="rv-card__n">${esc(c.name)}</span>
2792
+ <span class="rv-card__m">${esc(s.project ?? 'no project recorded')} · ${esc(c.domain)} · ${esc(tierLabel(c.tier))} · <span class="warn">${plural(c.overdue_days ?? 0, 'day')} overdue</span> · ${Math.round(c.score * 100)}%</span>
2793
+ <span class="rv-card__go">${first ? 'Start' : 'Open'} →</span></button>`;
2794
+ return first
2795
+ ? `<div class="rv-first"><div class="rv-guide"><eklavya-mascot state="review" size="110"></eklavya-mascot><span class="rv-tag">Start here</span></div>${btn}</div>`
2796
+ : btn;
2797
+ }
2798
+
2799
+ function reviewPicker(due) {
2800
+ if (!due.length) return mascotMessage('<p><strong>Nothing is due.</strong> Eklavya will list a concept here the day it comes back.</p>', 'complete');
2801
+ const rest = due.slice(1 + REVIEW_SHOWN - 1);
2802
+ const folded = rest.slice(0, REVIEW_FOLDED);
2803
+ return `<section class="starts"><div class="head"><h2>Pick a concept to review</h2><p>${plural(due.length, 'concept')} due, most overdue first.</p></div>
2804
+ <div class="rv-list">${due.slice(0, REVIEW_SHOWN).map((c, i) => reviewCard(c, i === 0)).join('')}
2805
+ ${rest.length ? `<details class="rv-more" data-key="starts-more"><summary>Show ${plural(Math.min(rest.length, REVIEW_FOLDED), 'more concept')}${rest.length > REVIEW_FOLDED ? `. The queue below lists all ${due.length}` : ''}</summary>
2806
+ <div class="rv-list">${folded.map((c) => reviewCard(c, false)).join('')}</div></details>` : ''}</div>
2807
+ <p class="cmd__note">Each session quizzes that one concept. Come back for the next.</p></section>`;
2808
+ }
2809
+
2810
+ function reviewIntro() {
2811
+ try { if (localStorage.getItem(REVIEW_INTRO_KEY) === '1') return ''; } catch (e) { /* shown */ }
2812
+ return `<section class="card rv-intro" aria-labelledby="rv-h"><div><h2 id="rv-h">Bring older concepts back before they fade</h2>
2813
+ <p>Concepts you were quizzed on return on a schedule. When one is due, a short quiz firms it up and moves its next date.</p>
2814
+ <button type="button" class="rv-hide" data-rv-hide data-focus="rv-hide">Hide this, I know how it works</button></div>
2815
+ <ol class="rv-steps"><li><b>Pick one concept</b>Open a card. The one marked Start here is the longest overdue.</li>
2816
+ <li><b>Start a session for it</b>Copy one line into Claude Code or Claude desktop. The steps are on the card.</li>
2817
+ <li><b>Answer, then pick the next</b>One concept per session keeps it short.</li></ol></section>`;
2818
+ }
2819
+
2820
+ const reviewTips = () => `<div class="rv-tips"><div><b>Run it inside the project</b>Answers are counted for the folder you start the session in, so use the project named on the card.</div>
2821
+ <div><b>Short and often beats long and rare</b>A few minutes a few times a week keeps scores up better than one big catch-up.</div>
2822
+ <div><b>Skipped is not forgotten</b>A skipped question comes back a tier lower. You can quiz any concept by name from its page.</div></div>`;
2823
+
2824
+ function reviewSheetBody(c) {
2825
+ const s = conceptStart(c), tool = reviewTool();
2826
+ const cmd = (text, hint, label = 'Copy') => `<div class="cmd"><code>${esc(text)}</code><button type="button" class="cmd__copy" data-copy data-hint="${esc(hint)}" data-focus="copy:${esc(text.slice(0, 40))}">${label}</button></div>`;
2827
+ const noPath = `<div class="next"><p>This project's folder is not on this machine, so no path is shown. Open the folder where you work on it.</p></div>`;
2828
+ const who = esc(s.project ?? 'the project you ran it in');
2829
+ const steps = tool === 'code'
2830
+ ? [['Open a terminal in the project', s.cd ? 'Skip this if you are already in it.' : '', s.cd ? cmd(s.cd, 'Paste it in your terminal, then copy the next step.') : noPath],
2831
+ ['Start Claude Code with this one concept', 'It opens a session and asks the first question.', cmd(`claude "${s.slash}"`, 'Paste it in your terminal and press Enter.')],
2832
+ ['Answer in the session', `The answer is counted for ${who}. When you finish, this page updates by itself.`, '']]
2833
+ : [['Open the project in the Code tab', 'Choose this folder, then start a new session.', s.path ? cmd(s.path, 'Choose this folder in the Code tab.', 'Copy path') : noPath],
2834
+ ['Paste this in the message box and send', 'It asks the first question on this concept.', cmd(s.slash, 'Paste it in the Code tab and send.')],
2835
+ ['Answer in the chat', `The answer is counted for ${who}. When you finish, this page updates by itself.`, '']];
2836
+ return `<div class="sheet__seg" role="group" aria-label="Where do you run Claude?">
2837
+ <button type="button" data-sheet-tool="code" data-focus="tool:code" aria-pressed="${tool === 'code'}"><b>Claude Code</b><small>terminal</small></button>
2838
+ <button type="button" data-sheet-tool="desktop" data-focus="tool:desktop" aria-pressed="${tool === 'desktop'}"><b>Claude desktop</b><small>Code tab</small></button></div>
2839
+ <ol class="sheet__how">${steps.map(([h, p, x]) => `<li><h3>${h}</h3>${p ? `<p>${p}</p>` : ''}${x}</li>`).join('')}</ol>
2840
+ ${tool === 'desktop' ? `<div class="next"><p><strong>Use a new session.</strong> A long-running session keeps its old context, so the quiz is cleaner in a fresh one.</p></div>` : ''}
2841
+ <div class="sheet__why"><h3>Why it is due</h3><p class="cmd__note">${Math.round(c.score * 100)}% score, ${plural(c.overdue_days ?? 0, 'day')} overdue, ${esc(c.domain)}, ${esc(tierLabel(c.tier))}.</p></div>
2842
+ <span class="sr" id="copy-live" aria-live="polite"></span>`;
2843
+ }
2844
+
2845
+ const SHEET = { slug: null, opener: null };
2846
+ function openSheet(slug, opener) {
2847
+ const c = seenConcepts().find((x) => x.slug === slug);
2848
+ if (!c) return;
2849
+ SHEET.slug = slug; SHEET.opener = opener ?? SHEET.opener;
2850
+ $('sheet-h').textContent = c.name;
2851
+ $('sheet-s').textContent = (conceptStart(c).project ?? 'no project recorded') + ' · ' + plural(c.overdue_days ?? 0, 'day') + ' overdue';
2852
+ $('sheet-b').innerHTML = reviewSheetBody(c);
2853
+ if (!$('sheet').open) $('sheet').showModal();
2854
+ TIP.sync();
2855
+ }
2856
+ function closeSheet() { if ($('sheet').open) $('sheet').close(); }
2857
+ $('sheet').addEventListener('close', () => { $('sheet-b').innerHTML = ''; SHEET.slug = null; SHEET.opener?.focus?.(); });
2858
+ $('sheet').addEventListener('click', (e) => {
2859
+ if (e.target === $('sheet') || e.target.closest('[data-sheet-close]')) return closeSheet();
2860
+ const tool = e.target.closest('[data-sheet-tool]');
2861
+ if (tool) { try { localStorage.setItem(REVIEW_TOOL_KEY, tool.dataset.sheetTool); } catch (x) { /* this load only */ } openSheet(SHEET.slug); $('sheet').querySelector(`[data-sheet-tool="${tool.dataset.sheetTool}"]`)?.focus(); return; }
2862
+ const copy = e.target.closest('[data-copy]');
2863
+ if (copy) copyCommand(copy);
2864
+ });
2865
+
2610
2866
  const REVIEW_TABS = ['due', 'upcoming', 'skipped'];
2611
2867
 
2612
2868
  function viewReview(param) {
@@ -2665,7 +2921,10 @@ function viewReview(param) {
2665
2921
  ease. A score decays about 5% a week while a review is overdue, so this list is also the list of
2666
2922
  things quietly getting weaker. A skipped question comes back a tier lower.`),
2667
2923
 
2668
- startBlocks('Start your reviews', startCommand(due, PROJECT)),
2924
+ reviewIntro(),
2925
+ reviewPicker(due),
2926
+
2927
+ fold('review:tips', 'Tips for reviewing', '3 tips', reviewTips(), { open: true }),
2669
2928
 
2670
2929
  card('The next fortnight', null,
2671
2930
  `<div class="chart"><svg id="c-forecast" role="img" aria-label="Scheduled reviews for the next fourteen days"></svg></div>`),
@@ -6046,6 +6305,12 @@ function onActivate(e) {
6046
6305
  if (e.target.closest('[data-slot][aria-busy="true"][data-kept]')) return;
6047
6306
  const copy = e.target.closest('[data-copy]');
6048
6307
  if (copy) return copyCommand(copy);
6308
+ const start = e.target.closest('[data-start]');
6309
+ if (start) return openSheet(start.dataset.start, start);
6310
+ if (e.target.closest('[data-rv-hide]')) {
6311
+ try { localStorage.setItem(REVIEW_INTRO_KEY, '1'); } catch (x) { /* hidden until reload */ }
6312
+ return render();
6313
+ }
6049
6314
  const more = e.target.closest('[data-fb-more]');
6050
6315
  if (more) return fbToggle(more);
6051
6316
  if (e.target.closest('[data-fb-tip-dismiss]')) return fbTipDismiss();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "eklavya",
3
3
  "displayName": "Eklavya",
4
- "version": "1.74.0",
4
+ "version": "1.75.0",
5
5
  "description": "Learn while your agent works. Turns coding-agent generation time into adaptive, Socratic learning grounded in the code being written.",
6
6
  "author": {
7
7
  "name": "Ajay Kumar"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eklavya",
3
- "version": "1.74.0",
3
+ "version": "1.75.0",
4
4
  "description": "Learn while your agent works — local knowledge graph, spaced repetition, and quiz gating for agent-assisted development.",
5
5
  "license": "MIT",
6
6
  "repository": {