eklavya 1.56.0 → 1.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +6 -2
  2. package/dist/assets/dashboard.html +294 -6
  3. package/dist/cli-memory.js +94 -81
  4. package/dist/cli-memory.js.map +1 -1
  5. package/dist/cli.js +98 -35
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config-path.js +10 -1
  8. package/dist/config-path.js.map +1 -1
  9. package/dist/config.js +7 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/dashboard.js +69 -0
  12. package/dist/dashboard.js.map +1 -1
  13. package/dist/feedback-review.js +177 -0
  14. package/dist/feedback-review.js.map +1 -0
  15. package/dist/feedback.js +275 -0
  16. package/dist/feedback.js.map +1 -0
  17. package/dist/hooks/prompt-submit-nudge.js +1 -15
  18. package/dist/hooks/prompt-submit-nudge.js.map +1 -1
  19. package/dist/hooks/session-start.js +33 -5
  20. package/dist/hooks/session-start.js.map +1 -1
  21. package/dist/install.js +16 -16
  22. package/dist/install.js.map +1 -1
  23. package/dist/memory/provider.js +4 -4
  24. package/dist/memory/provider.js.map +1 -1
  25. package/dist/migrations/026_feedback.sql +51 -0
  26. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  27. package/dist/plugin/skills/CLAUDE.md +1 -1
  28. package/dist/prompt-text.js +28 -0
  29. package/dist/prompt-text.js.map +1 -0
  30. package/dist/relocate.js +1 -0
  31. package/dist/relocate.js.map +1 -1
  32. package/dist/telemetry-send.js +12 -4
  33. package/dist/telemetry-send.js.map +1 -1
  34. package/dist/theme.js +22 -0
  35. package/dist/theme.js.map +1 -1
  36. package/dist/tools/config_tools.js +4 -0
  37. package/dist/tools/config_tools.js.map +1 -1
  38. package/dist/user-skill/eklavya/SKILL.md +71 -5
  39. package/package.json +2 -1
package/README.md CHANGED
@@ -104,12 +104,16 @@ npx eklavya doctor
104
104
  npx eklavya memory status
105
105
  npx eklavya config get
106
106
  npx eklavya dashboard
107
+ npx eklavya feedback generate
107
108
  npx eklavya db-path
109
+ npx eklavya --version
108
110
  ```
109
111
 
110
112
  The [CLI reference](https://eklavya-run.web.app/docs/cli/) lists all commands and
111
- flags. The dashboard reads the database and has two guarded writes: a setting
112
- change and a correction of a missed answer, which can also complete a level. An open page polls `/api/cursor`, a
113
+ flags. `feedback generate` reviews one of your earlier prompts when `feedback.enabled`,
114
+ memory and `providers.observer` are set. The dashboard reads the database and has five guarded writes: a setting
115
+ change, a correction of a missed answer (which can also complete a level), and acknowledging, deleting or counting the opening of a
116
+ feedback item (`POST /api/feedback/acknowledge`, `/delete`, `/opened`; the reads are `GET /api/feedback` and `/api/feedback/list`). An open page polls `/api/cursor`, a
113
117
  counter that moves on every write it would show, and offers a refresh. A request
114
118
  it cannot parse gets a 400 without stopping the server
115
119
  ([what it is, underneath](https://eklavya-run.web.app/docs/dashboard/#what-it-is-underneath)).
@@ -100,7 +100,16 @@
100
100
  .wf__main svg, .picker svg { width:17px; height:17px; flex:none; color:var(--spot); }
101
101
  .wf__caret { flex:none; width:36px; background:none; border:0; border-left:1px solid var(--line-2);
102
102
  color:var(--dim); cursor:pointer; display:grid; place-items:center; }
103
+ .wf__caret { position:relative; }
103
104
  .wf__caret:hover, .wf__caret[aria-expanded="true"] { background:var(--mass); color:var(--ink); }
105
+ /* The unread count: the attention role (`--warning`) as a fill, `--bg` text on it. The number is
106
+ visible text, so forced-colours mode keeps it; the labels carry it for a screen reader. */
107
+ .wf__badge, .picker .badge { display:inline-grid; place-items:center; min-width:16px; height:16px; padding:0 4px;
108
+ border-radius:50%; background:var(--warning); color:var(--bg); font-style:normal;
109
+ font-family:var(--font-mono); font-size:10px; font-weight:var(--weight-semibold); line-height:1; }
110
+ .wf__badge { position:absolute; top:2px; right:2px; }
111
+ .wf__badge[hidden] { display:none; }
112
+ .picker .badge { flex:none; }
104
113
  .wf__caret svg { width:14px; height:14px; transition:transform var(--duration-fast) var(--ease); }
105
114
  .wf__caret[aria-expanded="true"] svg { transform:rotate(180deg); }
106
115
 
@@ -675,6 +684,40 @@
675
684
  .cmd__copy:focus-visible { outline:none; box-shadow:var(--ring); }
676
685
  .cmd__note { margin:var(--space-2) 0 0; color:var(--dim); font-size:13px; }
677
686
  .starts > .cmd__note { margin-top:var(--space-3); }
687
+ /* ---------- Feedback: one reviewed prompt at a time ---------- */
688
+ .fb__rule { border:1px solid var(--line-2); border-left:2px solid var(--spot); background:var(--panel);
689
+ padding:var(--space-4) var(--space-5); }
690
+ .fb__rule p { margin:0; color:var(--ink); font-size:14px; max-width:72ch; }
691
+ .fb__rule--near { margin:0 0 var(--space-4); }
692
+ .fb { display:grid; gap:var(--space-6); }
693
+ .fb__grid { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:var(--space-5); }
694
+ .fb__h { font-family:var(--font-mono); font-size:10.5px; letter-spacing:var(--tracking-caps); text-transform:uppercase;
695
+ color:var(--faint); font-weight:var(--weight-semibold); margin:0 0 var(--space-2); }
696
+ .fb__quote { margin:0; padding-left:var(--space-4); border-left:2px solid var(--spot); color:var(--ink);
697
+ white-space:pre-wrap; overflow-wrap:anywhere; font-size:14px; }
698
+ .cmd--block { margin-top:0; align-items:flex-start; }
699
+ .cmd--block code { white-space:pre-wrap; overflow-wrap:anywhere; overflow-x:visible; }
700
+ .fb__ds, .fb__tips { list-style:none; margin:0; padding:0; display:grid; gap:var(--space-3); }
701
+ .fb__ds li { display:grid; grid-template-columns:120px max-content minmax(0, 1fr); gap:var(--space-3); align-items:baseline; }
702
+ .fb__dn { font-family:var(--font-mono); font-size:12px; color:var(--dim); }
703
+ .fb__note { color:var(--dim); font-size:13.5px; overflow-wrap:anywhere; }
704
+ .fb__ev { display:block; color:var(--faint); font-size:12.5px; }
705
+ .fb__tips li { color:var(--dim); font-size:13.5px; padding-left:var(--space-4); position:relative; }
706
+ .fb__tips li::before { content:'–'; position:absolute; left:0; color:var(--faint); }
707
+ .fb__actions { display:flex; gap:var(--space-3); flex-wrap:wrap; align-items:center; }
708
+ .fb__ask { display:inline-flex; gap:var(--space-3); flex-wrap:wrap; align-items:center; color:var(--dim); font-size:13.5px; }
709
+ .fb__msg { margin:0; color:var(--warning); font-size:13px; }
710
+ .fb__sts { display:flex; gap:var(--space-2); flex-wrap:wrap; }
711
+ .fb__tip h2 { font-family:var(--font-disp); font-size:var(--text-md); font-weight:var(--weight-semibold); margin:0 0 var(--space-2); }
712
+ .fb__tip p { margin:0 0 var(--space-4); color:var(--dim); max-width:70ch; }
713
+ .fb__tip .fb__quote { margin-bottom:var(--space-4); }
714
+ .fb__why p { margin:0 0 var(--space-3); color:var(--dim); max-width:70ch; }
715
+ @media (max-width:560px) {
716
+ .fb__grid { grid-template-columns:minmax(0, 1fr); }
717
+ .fb__ds li { grid-template-columns:minmax(0, 1fr); gap:var(--space-1); }
718
+ .fb__actions > button, .fb__actions a { flex:1 1 100%; min-height:44px; }
719
+ .fb__ask > button { flex:1 1 40%; min-height:44px; }
720
+ }
678
721
  .starts__more > summary { cursor:pointer; color:var(--dim); font-size:13px; padding:var(--space-2) 0; }
679
722
  .starts__more > summary:hover { color:var(--spot); }
680
723
  .starts__more > summary:focus-visible { outline:none; box-shadow:var(--ring); }
@@ -850,7 +893,7 @@
850
893
  <div class="wf" id="wf">
851
894
  <a class="wf__main" id="wf-main" href="#/learning/dashboard"></a>
852
895
  <button type="button" class="wf__caret" id="wf-caret" aria-label="Switch workflow"
853
- aria-haspopup="menu" aria-expanded="false" aria-controls="wf-menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg></button>
896
+ aria-haspopup="menu" aria-expanded="false" aria-controls="wf-menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg><span class="wf__badge" id="wf-badge" aria-hidden="true" hidden>1</span></button>
854
897
  </div>
855
898
 
856
899
  <nav class="side__nav" id="nav" aria-label="Sections"></nav>
@@ -890,6 +933,7 @@
890
933
  <!-- The same markup `loader()` writes; the script has not run yet. -->
891
934
  <div class="loader loader--page" role="status" aria-live="polite"><div class="mark" aria-hidden="true"><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4c5 2.5 5 13.5 0 16"/><line x1="6" y1="4" x2="6" y2="20"/><line x1="4" y1="12" x2="18" y2="12"/><polyline points="14.5 8.5 18 12 14.5 15.5"/></svg><svg class="loader__trace" viewBox="0 0 40 40" aria-hidden="true"><rect x="0.75" y="0.75" width="38.5" height="38.5" pathLength="160"/></svg></div><span>Loading your history…</span></div>
892
935
  </div>
936
+ <span class="sr" id="fb-live" role="status" aria-live="polite"></span>
893
937
  <footer class="foot inner">
894
938
  <span>Read from <code id="dbline">~/.eklavya/knowledge.db</code> — nothing on this page leaves your machine.<span id="foot-note"></span></span>
895
939
  <span id="dials"></span>
@@ -988,6 +1032,7 @@ const T = {
988
1032
  memory: { page:1, q:'', tag:'all', since:'', until:'' },
989
1033
  reuse: { page:1, only:'all' },
990
1034
  artifacts:{ page:1, q:'' },
1035
+ fbhistory:{ page:1 },
991
1036
  };
992
1037
  const PER = 20;
993
1038
 
@@ -2762,7 +2807,7 @@ function viewHealth() {
2762
2807
  /* ============================================================
2763
2808
  Workflows and routes
2764
2809
 
2765
- Four workflows, one shell. The registry below is the only description of
2810
+ Five workflows, one shell. The registry below is the only description of
2766
2811
  any of them: the sidebar, the picker, the route resolver and the page renderer all
2767
2812
  read it, so the selector, the highlighted item and the screen cannot disagree
2768
2813
  about where the reader is.
@@ -3057,7 +3102,7 @@ for (const ev of ['wheel', 'touchmove', 'keydown']) {
3057
3102
  registry guard checks every row against the fixture. */
3058
3103
  const TIPS = [
3059
3104
  { id: 'wf-switch', where: ['learning/dashboard'], el: '#wf-caret', side: 'right', title: 'Switch workflow',
3060
- text: 'Eklavya has four workflows: Learning, Memory, Artifacts and Settings. Switch between them here.' },
3105
+ text: 'Eklavya has five workflows: Learning, Memory, Artifacts, Feedback and Settings. Switch between them here.' },
3061
3106
  { id: 'how-this-works', where: ['learning/dashboard', 'memory/dashboard'], el: '#view details.about > summary', title: 'How this works',
3062
3107
  text: 'Every page explains where its numbers come from. Open this to read it.' },
3063
3108
  { id: 'project-scope', where: ['learning/sessions', 'memory/timeline'], el: '#proj', side: 'right', title: 'Project scope',
@@ -3488,6 +3533,207 @@ function drawSettings(d, scope) {
3488
3533
  ].join('');
3489
3534
  }
3490
3535
 
3536
+ /* ============================================================
3537
+ Feedback: one reviewed prompt at a time
3538
+
3539
+ The rule is the product: nothing new is made until the reader presses
3540
+ Acknowledge, and only a press (a POST the server saw) acknowledges. Opening
3541
+ the page, reloading it or following a link to an item never does. The state
3542
+ payload says whether an item waits (never its text); the page that shows it
3543
+ asks `/api/feedback`.
3544
+ ============================================================ */
3545
+ const FB = {
3546
+ ruleEmpty: 'Eklavya reviews one prompt at a time. When one is ready it appears here, and the next waits until you press Acknowledge.',
3547
+ rulePending: 'Feedback pauses until you acknowledge this one. Read it, then press Acknowledge to get the next.',
3548
+ announce: 'Acknowledged. Feedback resumes at your next session.',
3549
+ dims: [['delegation', 'Delegation'], ['description', 'Description'], ['discernment', 'Discernment'], ['diligence', 'Diligence']],
3550
+ status: { strong: 'Strong', mixed: 'Mixed', missing: 'Missing', not_visible: "Can't tell from this prompt" },
3551
+ // `not_visible` is the muted role, never a weakness.
3552
+ pill: { strong: 'pill ok', mixed: 'pill', missing: 'pill due', not_visible: 'pill gone' },
3553
+ counted: false,
3554
+ observerCmd: 'eklavya config set providers.observer <model>',
3555
+ about: `Your prompts are already recorded by memory. After a session ends, in the background and never in the middle of your work,
3556
+ Eklavya asks your observer model (the one in <code>providers.observer</code>, running through Claude Code on your own subscription)
3557
+ to review one of that session's prompts against Anthropic's 4D framework: Delegation, Description, Discernment and Diligence.
3558
+ Only the text of up to eight of your own prompts from that one session goes to that model, and nothing else leaves your machine.
3559
+ Discernment and Diligence can only be seen in what you did after the prompt, so they say "Can't tell from this prompt" when nothing shows them.
3560
+ There are no scores, and nothing here changes your mastery.`,
3561
+ };
3562
+
3563
+ /** A command in a copyable block, as the start-review commands are. */
3564
+ const fbCmd = (text) => `<div class="cmd"><code>${esc(text)}</code><button type="button" class="cmd__copy" data-copy>Copy</button></div>`;
3565
+
3566
+ /** Why there is no item: the one reason, in plain words. */
3567
+ function fbEmpty(F) {
3568
+ let body;
3569
+ if (!F.enabled) {
3570
+ body = `<p>Prompt feedback is off. Turn on <code>feedback.enabled</code> in Settings to start.</p>
3571
+ <a class="btn" data-go="${esc(href('settings', 'user'))}">Open Settings</a>`;
3572
+ } else if (!F.memory) {
3573
+ body = `<p>Prompt feedback needs memory. Turn on <code>memory.enabled</code>.</p>`;
3574
+ } else if (!F.observer) {
3575
+ body = `<p>Prompt feedback needs an observer model. Run <code>eklavya config set providers.observer &lt;model&gt;</code>.</p>${fbCmd(FB.observerCmd)}
3576
+ <p class="cmd__note">Put the model's name in place of &lt;model&gt;, for example claude-haiku-4-5. Setting it lets Eklavya send your prompts to that model, through your Claude Code login.</p>`;
3577
+ } else if (F.failed) {
3578
+ body = `<p>Couldn't review the last session. It will try again at a later start.</p>`;
3579
+ } else {
3580
+ body = `<p>Nothing to review yet. When a session ends, the next one you start reviews one of its prompts.</p>`;
3581
+ }
3582
+ return `<div class="card fb__why" id="fb-empty">${body}</div>`;
3583
+ }
3584
+
3585
+ function viewFeedbackDashboard() {
3586
+ const F = S.feedback;
3587
+ if (F.pending) fill('fb-item', '/api/feedback', (d) => d.item ? drawFbItem(d.item) : fbEmpty(S.feedback));
3588
+ if (F.pending && !FB.counted) { FB.counted = true; AFTER.push(fbOpened); }
3589
+ const counts = [F.pending ? '1 waiting' : 'nothing waiting', plural(F.acknowledged, 'acknowledged item')];
3590
+ return [
3591
+ pageHead('Feedback', counts, FB.about),
3592
+ `<div class="fb__rule" id="fb-rule"><p>${esc(F.pending ? FB.rulePending : FB.ruleEmpty)}</p></div>`,
3593
+ F.pending ? `<div id="fb-item">${loader('Loading your feedback…')}</div>` : fbEmpty(F),
3594
+ fbTip(),
3595
+ `<span class="sr" id="copy-live" aria-live="polite"></span>`,
3596
+ ].join('');
3597
+ }
3598
+
3599
+ /**
3600
+ * The card that says feedback about Eklavya itself goes through Claude. It is a
3601
+ * card and not a tip bubble: the example and the privacy line run past what a
3602
+ * bubble holds, and it is information rather than a nudge, so storage that
3603
+ * throws means it is shown (the tips engine hides in that case).
3604
+ */
3605
+ const FB_TIP_KEY = 'eklavya-dash-feedback-tip';
3606
+ function fbTip() {
3607
+ try { if (localStorage.getItem(FB_TIP_KEY) === '1') return ''; } catch (e) { /* shown */ }
3608
+ return `<aside class="card fb__tip" id="fb-tip" aria-labelledby="fb-tip-h">
3609
+ <h2 id="fb-tip-h">Found a problem with Eklavya?</h2>
3610
+ <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>
3611
+ <blockquote class="fb__quote">That question about retries was wrong. File feedback for Eklavya.</blockquote>
3612
+ <button type="button" class="set__btn" data-fb-tip-dismiss>Dismiss</button>
3613
+ </aside>`;
3614
+ }
3615
+
3616
+ function fbTipDismiss() {
3617
+ try { localStorage.setItem(FB_TIP_KEY, '1'); } catch (e) { /* hidden for now, shown again next time */ }
3618
+ $('fb-tip')?.remove();
3619
+ const h = document.querySelector('#view h1');
3620
+ if (h) { h.tabIndex = -1; h.focus({ preventScroll: true }); }
3621
+ }
3622
+
3623
+ function viewFeedbackItem(id) {
3624
+ fill('fb-item', '/api/feedback?id=' + encodeURIComponent(id), (d) => drawFbItem(d.item));
3625
+ return [
3626
+ pageHead('Feedback item', [], FB.about, `<a class="page__back" data-go="${esc(href('feedback', 'history'))}">← History</a>`),
3627
+ `<div id="fb-item">${loader('Loading your feedback…')}</div>`,
3628
+ `<span class="sr" id="copy-live" aria-live="polite"></span>`,
3629
+ ].join('');
3630
+ }
3631
+
3632
+ const fbActions = (id) => `<div class="fb__actions" id="fb-actions">
3633
+ <button type="button" class="btn" data-fb-ack="${id}">Acknowledge</button>
3634
+ <button type="button" class="set__btn" data-fb-del="${id}">Delete</button>
3635
+ <p class="fb__msg" id="fb-msg" role="alert"></p></div>`;
3636
+
3637
+ /** One item: your prompt, a better one, a note per D, tips. Actions only while it is pending. */
3638
+ function drawFbItem(it) {
3639
+ const pending = !it.acknowledged_at;
3640
+ const rows = FB.dims.map(([key, name]) => {
3641
+ const d = it.review[key];
3642
+ return `<li><span class="fb__dn">${esc(name)}</span> <span class="${FB.pill[d.status] ?? 'pill'}">${esc(FB.status[d.status] ?? d.status)}</span> `
3643
+ + `<span class="fb__note">${d.status === 'not_visible' ? '' : esc(d.note)}`
3644
+ + `${d.evidence ? `<span class="fb__ev">From your later prompt: “${esc(d.evidence)}”</span>` : ''}</span></li>`;
3645
+ }).join('');
3646
+ // Said again right above the button on the very first item a reader sees.
3647
+ const first = pending && S.feedback.acknowledged === 0
3648
+ ? `<div class="fb__rule fb__rule--near"><p>${esc(FB.rulePending)}</p></div>` : '';
3649
+ return `<article class="card fb" aria-label="Feedback on one of your prompts">
3650
+ <div class="fb__grid">
3651
+ <section><h2 class="fb__h">Your prompt</h2><blockquote class="fb__quote">${esc(it.prompt)}</blockquote></section>
3652
+ <section><h2 class="fb__h">A better prompt</h2>
3653
+ <div class="cmd cmd--block"><code>${esc(it.better)}</code><button type="button" class="cmd__copy" data-copy>Copy</button></div></section>
3654
+ </div>
3655
+ <section><h2 class="fb__h">The four Ds</h2><ul class="fb__ds">${rows}</ul></section>
3656
+ <section><h2 class="fb__h">Tips</h2><ul class="fb__tips">${it.tips.map((t) => `<li>${esc(t)}</li>`).join('')}</ul></section>
3657
+ ${pending ? `<section>${first}${fbActions(it.id)}</section>` : ''}
3658
+ </article>`;
3659
+ }
3660
+
3661
+ function viewFeedbackHistory() {
3662
+ const p = new URLSearchParams();
3663
+ if (PROJECT !== null) p.set('project', PROJECT);
3664
+ p.set('page', T.fbhistory.page);
3665
+ fill('fb-rows', '/api/feedback/list?' + p, drawFbHistory);
3666
+ return [
3667
+ pageHead('History', [plural(S.feedback.acknowledged, 'acknowledged item')], FB.about),
3668
+ `<div class="card card--flush" id="fb-rows">${loader('Loading history…')}</div>`,
3669
+ ].join('');
3670
+ }
3671
+
3672
+ function drawFbHistory(d) {
3673
+ T.fbhistory.page = d.page;
3674
+ const pills = (st) => `<span class="fb__sts">${FB.dims.map(([k, n]) =>
3675
+ `<span class="${FB.pill[st[k]] ?? 'pill'}" title="${esc(n)}: ${esc(FB.status[st[k]] ?? st[k])}">${esc(n)} · ${st[k] === 'not_visible' ? '—' : esc(FB.status[st[k]] ?? st[k])}</span>`).join('')}</span>`;
3676
+ const rows = d.items.map((i) => `<tr class="row" data-go="${esc(href('feedback', 'item', i.id))}">
3677
+ <td>${esc(fmtDate(i.acknowledged_at))}</td><td>${esc(pname(i.project))}</td>
3678
+ <td>${esc(i.prompt)}${i.prompt.length >= 80 ? '…' : ''}</td><td>${pills(i.statuses)}</td></tr>`);
3679
+ return table([{ t: 'Acknowledged' }, { t: 'Project' }, { t: 'Your prompt' }, { t: 'The four Ds' }], rows)
3680
+ + pager('fbhistory', d.total, d.page, d.per);
3681
+ }
3682
+
3683
+ /**
3684
+ * Counted once per opening with an item waiting: a re-render of the same page
3685
+ * (a resize) is not another opening. `via` says how the reader got here
3686
+ * (the greeting link, the badge) and is dropped from the URL once counted, so a
3687
+ * link they share does not carry it. Anything else is a typed or bookmarked link.
3688
+ */
3689
+ function fbOpened() {
3690
+ const via = CUR.query.get('via');
3691
+ postJson('/api/feedback/opened', { via: via === 'greeting' || via === 'badge' ? via : 'direct' }).catch(() => {});
3692
+ if (via === null) return;
3693
+ const q = new URLSearchParams(CUR.query);
3694
+ q.delete('via');
3695
+ const s = q.toString();
3696
+ history.replaceState(history.state, '', location.pathname + location.search + location.hash.replace(/\?.*$/, '') + (s ? '?' + s : ''));
3697
+ }
3698
+
3699
+ /** After a press: redraw from the new state, put focus on the heading and say so. */
3700
+ function fbDone(text) {
3701
+ render();
3702
+ const h = document.querySelector('#view h1');
3703
+ if (h) { h.tabIndex = -1; h.focus({ preventScroll: true }); }
3704
+ // The region is outside the view, so it exists before the text does.
3705
+ setTimeout(() => { $('fb-live').textContent = text; }, 50);
3706
+ }
3707
+
3708
+ function fbFail(e) {
3709
+ const m = $('fb-msg');
3710
+ if (m) m.textContent = `Couldn't do that: ${e.message}`;
3711
+ }
3712
+
3713
+ function fbAcknowledge(btn) {
3714
+ if (btn.disabled) return;
3715
+ btn.disabled = true;
3716
+ postJson('/api/feedback/acknowledge', { id: +btn.dataset.fbAck })
3717
+ .then((d) => { S.feedback = d.state; fbDone(FB.announce); })
3718
+ .catch((e) => { btn.disabled = false; fbFail(e); });
3719
+ }
3720
+
3721
+ function fbAskDelete(btn) {
3722
+ const id = +btn.dataset.fbDel;
3723
+ $('fb-actions').innerHTML = `<span class="fb__ask" role="group" aria-label="Confirm delete"><span>Delete this feedback for good?</span>
3724
+ <button type="button" class="btn" data-fb-yes="${id}">Delete</button>
3725
+ <button type="button" class="set__btn" data-fb-no="${id}">Cancel</button></span><p class="fb__msg" id="fb-msg" role="alert"></p>`;
3726
+ document.querySelector('[data-fb-no]').focus();
3727
+ }
3728
+
3729
+ function fbDelete(btn) {
3730
+ if (btn.disabled) return;
3731
+ btn.disabled = true;
3732
+ postJson('/api/feedback/delete', { id: +btn.dataset.fbYes })
3733
+ .then((d) => { S.feedback = d.state; fbDone('Deleted.'); })
3734
+ .catch((e) => { btn.disabled = false; fbFail(e); });
3735
+ }
3736
+
3491
3737
  function viewSettings(scope) {
3492
3738
  fill('settings', settingsUrl(), (d) => drawSettings(d, scope));
3493
3739
  return `<div class="stack" id="settings">${loader('Reading your settings…')}</div>`;
@@ -3595,6 +3841,8 @@ const ICONS = {
3595
3841
  reuse: '<path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/>',
3596
3842
  health: '<path d="M3 12h4l2-6 3 12 2-6h7"/>',
3597
3843
  settings: '<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>',
3844
+ feedback: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M7 7h10M7 11h6"/>',
3845
+ history: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
3598
3846
  user: '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
3599
3847
  project: '<path d="M3 7l9-4 9 4-9 4-9-4z"/><path d="M3 12l9 4 9-4M3 17l9 4 9-4"/>',
3600
3848
  };
@@ -3655,6 +3903,15 @@ const WORKFLOWS = {
3655
3903
  projects: { label: 'Projects', view: () => viewArtifactProjects() },
3656
3904
  },
3657
3905
  },
3906
+ feedback: {
3907
+ name: 'Feedback',
3908
+ groups: [{ id: 'feedback', label: 'Feedback', items: ['history'] }],
3909
+ pages: {
3910
+ dashboard: { label: 'Dashboard', view: () => viewFeedbackDashboard(), query: ['via'] },
3911
+ history: { label: 'History', view: () => viewFeedbackHistory() },
3912
+ item: { parent: 'history', view: (p) => viewFeedbackItem(p) },
3913
+ },
3914
+ },
3658
3915
  settings: {
3659
3916
  name: 'Settings',
3660
3917
  groups: [
@@ -3793,6 +4050,8 @@ function navCounts(wf) {
3793
4050
  };
3794
4051
  }
3795
4052
  if (wf === 'artifacts') return { projects: new Set(artifactsInScope().map((a) => pid(a.project))).size };
4053
+ // A count of acknowledged items is a tally, not a to-do: the badge is what asks for attention.
4054
+ if (wf === 'feedback') return {};
3796
4055
  // No honest count for a settings page: how many keys a file sets is not a to-do.
3797
4056
  if (wf === 'settings') return {};
3798
4057
  return {
@@ -3858,6 +4117,19 @@ function renderShell() {
3858
4117
  $('wf-main').innerHTML = `${svg(ICONS[CUR.wf])}<span>${esc(W.name)}</span>`;
3859
4118
  $('wf-main').setAttribute('aria-label', `${W.name} dashboard`);
3860
4119
  $('topbar-wf').textContent = W.name;
4120
+ syncBadge();
4121
+ }
4122
+
4123
+ /**
4124
+ * The unread count on the workflow control, on every workflow and not only
4125
+ * Feedback. The labels say it in words (`aria-hidden` badge), and the number is
4126
+ * also visible text, so it survives forced-colours mode.
4127
+ */
4128
+ const fbUnread = () => (S?.feedback?.notify ? 1 : 0);
4129
+ function syncBadge() {
4130
+ const n = fbUnread();
4131
+ $('wf-badge').hidden = !n;
4132
+ $('wf-caret').setAttribute('aria-label', n ? `Switch workflow, ${n} feedback unread` : 'Switch workflow');
3861
4133
  }
3862
4134
 
3863
4135
  /* ---------- the workflow picker ---------- */
@@ -3884,8 +4156,8 @@ function placePicker(el = menu, at = $('wf')) {
3884
4156
 
3885
4157
  function openPicker() {
3886
4158
  menu.innerHTML = `<p class="picker__label" aria-hidden="true">Workflow</p>` + Object.entries(WORKFLOWS).map(([key, W]) =>
3887
- `<button type="button" role="menuitemradio" aria-checked="${key === CUR.wf}" data-wf="${key}" tabindex="-1">
3888
- ${svg(ICONS[key])}<span>${esc(W.name)}</span>
4159
+ `<button type="button" role="menuitemradio" aria-checked="${key === CUR.wf}" data-wf="${key}" tabindex="-1"${key === 'feedback' && fbUnread() ? ` aria-label="${esc(W.name)}, ${fbUnread()} unread"` : ''}>
4160
+ ${svg(ICONS[key])}<span>${esc(W.name)}</span>${key === 'feedback' && fbUnread() ? `<i class="badge" aria-hidden="true">${fbUnread()}</i>` : ''}
3889
4161
  <svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12l5 5 9-10"/></svg>
3890
4162
  </button>`).join('');
3891
4163
  menu.hidden = false;
@@ -4155,7 +4427,8 @@ addEventListener('resize', () => { if (sOpen) placePicker(smenu, sOpen); });
4155
4427
  */
4156
4428
  function switchWorkflow(wf) {
4157
4429
  closePicker(false);
4158
- const target = href(wf, 'dashboard');
4430
+ // Reached from the unread badge: said so, so the count can tell the three ways in apart.
4431
+ const target = wf === 'feedback' && fbUnread() ? href(wf, 'dashboard', null, { via: 'badge' }) : href(wf, 'dashboard');
4159
4432
  closeDrawer(false);
4160
4433
  if (location.hash === target) render(); else location.hash = target;
4161
4434
  (narrow.matches ? $('menu') : caret).focus();
@@ -4282,6 +4555,8 @@ function render(keepFocus) {
4282
4555
  AFTER.length = 0;
4283
4556
  LAZY.clear();
4284
4557
  HEAT_OFF = 0;
4558
+ // Leaving the Feedback page ends the opening that fbOpened counted.
4559
+ if (!(CUR.wf === 'feedback' && CUR.page === 'dashboard')) FB.counted = false;
4285
4560
  // A bubble never survives a navigation.
4286
4561
  TIP.hints?.close();
4287
4562
  // A screen that still has a correction open re-arms this when it draws its bar.
@@ -4327,6 +4602,19 @@ function setQuery(changes) {
4327
4602
  function onActivate(e) {
4328
4603
  const copy = e.target.closest('[data-copy]');
4329
4604
  if (copy) return copyCommand(copy);
4605
+ if (e.target.closest('[data-fb-tip-dismiss]')) return fbTipDismiss();
4606
+ const ack = e.target.closest('[data-fb-ack]');
4607
+ if (ack) return fbAcknowledge(ack);
4608
+ const ask = e.target.closest('[data-fb-del]');
4609
+ if (ask) return fbAskDelete(ask);
4610
+ const yes = e.target.closest('[data-fb-yes]');
4611
+ if (yes) return fbDelete(yes);
4612
+ const no = e.target.closest('[data-fb-no]');
4613
+ if (no) {
4614
+ const id = +no.dataset.fbNo;
4615
+ $('fb-actions').outerHTML = fbActions(id);
4616
+ return document.querySelector(`[data-fb-del="${id}"]`).focus();
4617
+ }
4330
4618
  const arr = e.target.closest('.streak__arr');
4331
4619
  if (arr) {
4332
4620
  if (arr.disabled) return;