eklavya 1.33.3 → 1.35.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.
@@ -3,8 +3,10 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <!-- Filled in by the server on each load; the Settings pages send it with a write. -->
7
+ <meta name="eklavya-token" content="">
6
8
  <title>Eklavya — dashboard</title>
7
- <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' fill='%230E6E66'/%3E%3Cg fill='none' stroke='%23fff' stroke-width='1.8' stroke-linecap='round'%3E%3Cpath d='M7 5a11 11 0 0 1 0 14'/%3E%3Cpath d='M6 12h12'/%3E%3Cpath d='M14 9l3 3-3 3'/%3E%3C/g%3E%3C/svg%3E">
9
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' fill='%230E6E66'/%3E%3Cg fill='none' stroke='%23fff' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 4c5 2.5 5 13.5 0 16'/%3E%3Cpath d='M6 4v16M4 12h14'/%3E%3Cpath d='M14.5 8.5 18 12l-3.5 3.5'/%3E%3C/g%3E%3C/svg%3E">
8
10
  <script>
9
11
  /* Ground selection, shared with the site: same localStorage key, same default.
10
12
  Runs before the stylesheet so the page never paints one ground then swaps.
@@ -106,10 +108,50 @@
106
108
  padding:var(--space-2) var(--space-4); cursor:pointer; }
107
109
  .picker button:hover, .picker button:focus-visible { background:var(--mass); color:var(--ink); box-shadow:none; outline:none; }
108
110
  .picker button:focus-visible { box-shadow:inset 2px 0 0 var(--spot); } /* inset: the picker scrolls, so an outset ring would clip */
109
- .picker button[aria-checked="true"] { color:var(--ink); }
110
- .picker button span { flex:1; }
111
+ .picker button[aria-checked="true"], .picker button[aria-selected="true"] { color:var(--ink); background:var(--spot-soft); }
112
+ .picker button span { flex:1; min-width:0; overflow-wrap:anywhere; }
111
113
  .picker .check { width:15px; height:15px; color:var(--spot); visibility:hidden; }
112
- .picker button[aria-checked="true"] .check { visibility:visible; }
114
+ .picker button[aria-checked="true"] .check, .picker button[aria-selected="true"] .check { visibility:visible; }
115
+ .picker[role="listbox"] { max-width:min(320px, calc(100vw - 16px)); }
116
+
117
+ /* ---------- the project selector: a button that opens a listbox ---------- */
118
+ /* Not a native <select>: its menu is drawn by the OS (translucent, blue,
119
+ rounded) and ignores every token on the page. Same chrome as the workflow
120
+ control, same picker underneath. */
121
+ .combo { display:flex; align-items:center; gap:var(--space-2); width:100%; min-height:36px; text-align:left;
122
+ font:inherit; font-size:13.5px; background:var(--bg); color:var(--ink); border:1px solid var(--line-2);
123
+ border-radius:0; padding:var(--space-2) var(--space-3); cursor:pointer; }
124
+ .combo:hover:not(:disabled), .combo[aria-expanded="true"] { background:var(--mass); }
125
+ .combo:disabled { cursor:progress; }
126
+ .combo__value { flex:1; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
127
+ .combo > svg { width:14px; height:14px; flex:none; color:var(--dim); transition:transform var(--duration-fast) var(--ease); }
128
+ .combo[aria-expanded="true"] > svg { transform:rotate(180deg); }
129
+ /* Every other dropdown is a native <select> kept as the value, hidden, with
130
+ one of these standing in for it (see `combos()`). */
131
+ .filters .combo { width:auto; max-width:240px; }
132
+ /* Until the inventory lands there is no honest label, so the box says it is
133
+ waiting rather than showing an empty field. */
134
+ .skel { display:block; height:10px; width:70%; background:var(--mass);
135
+ animation:skel 1.4s var(--ease) infinite alternate; }
136
+ @keyframes skel { from { opacity:.55; } to { opacity:1; } }
137
+
138
+ /* ---------- the loader: the mark, held still, with its tile traced ---------- */
139
+ /* One loader for every wait on the page: `loader(label)`. The mark is the
140
+ brand mark and never moves; a short --spot segment travels round a square
141
+ outline just outside its tile. It fades in after 150ms so a fast loopback
142
+ answer never flashes it; reduced motion stops the trace and keeps the label. */
143
+ .loader { display:flex; align-items:center; gap:var(--space-4); padding:var(--space-6) 0; color:var(--dim);
144
+ font-family:var(--font-mono); font-size:12px; letter-spacing:.04em;
145
+ animation:loader-in var(--duration-base) var(--ease) 150ms both; }
146
+ .loader--page { justify-content:center; flex-direction:column; min-height:40vh; gap:var(--space-5); }
147
+ .loader .mark { position:relative; overflow:visible; }
148
+ .loader__trace { position:absolute; inset:-5px; width:calc(100% + 10px); height:calc(100% + 10px); overflow:visible; }
149
+ .loader__trace rect { fill:none; stroke:var(--spot); stroke-width:1.5; stroke-dasharray:32 128;
150
+ animation:trace 1.6s linear infinite; }
151
+ .loader--page .mark { width:40px; height:40px; }
152
+ .loader--page .mark svg:first-child { width:24px; height:24px; }
153
+ @keyframes loader-in { from { opacity:0; } to { opacity:1; } }
154
+ @keyframes trace { to { stroke-dashoffset:-160; } }
113
155
 
114
156
  /* ---------- the sidebar navigation ---------- */
115
157
  nav.side__nav { display:flex; flex-direction:column; gap:var(--space-4); }
@@ -153,6 +195,19 @@
153
195
  font:inherit; font-size:13.5px; background:var(--bg); color:var(--ink);
154
196
  border:1px solid var(--line-2); border-radius:0; padding:7px var(--space-3); width:100%;
155
197
  }
198
+ /* Native selects are hidden behind a `.combo` by `combos()`; this chrome is
199
+ only what shows if the script has not run. */
200
+ select { appearance:none; -webkit-appearance:none; padding-right:calc(var(--space-3) + 18px); cursor:pointer;
201
+ background-image:linear-gradient(45deg, transparent 50%, var(--dim) 50%),
202
+ linear-gradient(135deg, var(--dim) 50%, transparent 50%);
203
+ background-position:calc(100% - 17px) 50%, calc(100% - 12px) 50%;
204
+ background-size:5px 5px; background-repeat:no-repeat; }
205
+ select:hover { background-color:var(--mass); }
206
+ select option { background:var(--panel); color:var(--ink); }
207
+ /* Before the first payload there is nothing true to put in the chrome, so the
208
+ rail's controls and the footer wait with the loader rather than showing
209
+ empty boxes. */
210
+ .booting #wf, .booting #nav, .booting .side__controls, .booting .foot, .booting #crumb { visibility:hidden; }
156
211
  select:focus-visible, input:focus-visible, button:focus-visible, a:focus-visible,
157
212
  [tabindex]:focus-visible { outline:none; box-shadow:var(--ring); }
158
213
 
@@ -537,6 +592,32 @@
537
592
  .lines .ln__v .meter { width:56px; }
538
593
  .page__title { font-size:var(--text-xl); }
539
594
  }
595
+ /* ---------- Settings: one row per setting, the control on the right ---------- */
596
+ .set { display:flex; gap:var(--space-5); align-items:flex-start; flex-wrap:wrap;
597
+ padding:var(--space-4) 0; border-bottom:1px solid var(--line); }
598
+ .set:last-child { border-bottom:none; padding-bottom:0; }
599
+ .set:first-child { padding-top:0; }
600
+ .set__main { flex:1 1 260px; min-width:0; }
601
+ .set__main label { font-weight:var(--weight-bold); display:block; }
602
+ .set__main p { margin:2px 0 0; color:var(--dim); font-size:13.5px; max-width:62ch; }
603
+ .set__src { margin:var(--space-2) 0 0; font-family:var(--font-mono); font-size:11.5px; color:var(--faint);
604
+ display:flex; gap:var(--space-3); align-items:center; flex-wrap:wrap; overflow-wrap:anywhere; }
605
+ .set__src .here { color:var(--spot); }
606
+ .set__src .warn { color:var(--warning); }
607
+ .set__ctl { flex:0 1 240px; min-width:0; }
608
+ .set__ctl input[type="number"], .set__ctl textarea { font:inherit; font-size:13.5px; background:var(--bg); color:var(--ink);
609
+ border:1px solid var(--line-2); border-radius:0; padding:7px var(--space-3); width:100%; }
610
+ .set__ctl textarea { font-family:var(--font-mono); font-size:12.5px; resize:vertical; min-height:64px; }
611
+ .set__ctl input[type="checkbox"] { width:18px; height:18px; accent-color:var(--spot); margin:4px 0; cursor:pointer; }
612
+ .set__ctl :disabled { opacity:.55; cursor:not-allowed; }
613
+ .set__rule { color:var(--faint); }
614
+ .set__err { margin:var(--space-2) 0 0; color:var(--warning); font-size:12.5px; overflow-wrap:anywhere; }
615
+ .set__ctl [aria-invalid="true"] { border-color:var(--warning); outline-color:var(--warning); }
616
+ .set__btn { font-family:var(--font-mono); font-size:11px; letter-spacing:.06em; text-transform:uppercase;
617
+ background:none; border:1px solid var(--line-2); color:var(--dim); padding:2px 8px; cursor:pointer; }
618
+ .set__btn:hover { color:var(--ink); border-color:var(--spot); }
619
+ /* A config path is one unbroken word; on a phone it has to break anywhere. */
620
+ .counts .mono, .lines .mono { overflow-wrap:anywhere; }
540
621
  @media (prefers-reduced-motion:reduce) {
541
622
  html { scroll-behavior:auto; }
542
623
  * { transition:none !important; animation:none !important; }
@@ -544,7 +625,7 @@
544
625
  </style>
545
626
  </head>
546
627
  <body>
547
- <div class="app">
628
+ <div class="app booting" id="app">
548
629
  <!-- Phones only: the rail is a drawer behind this bar. -->
549
630
  <header class="topbar" id="topbar">
550
631
  <button type="button" class="topbar__menu" id="menu" aria-controls="side" aria-expanded="false" aria-label="Open navigation">
@@ -554,7 +635,7 @@
554
635
  <div class="mark" aria-hidden="true">
555
636
  <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
556
637
  stroke-linecap="round" stroke-linejoin="round">
557
- <path d="M5 3a13 13 0 0 1 0 18"/><path d="M5 3l14 9L5 21"/><path d="M3 12h18"/><path d="M15 8l4 4-4 4"/>
638
+ <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"/>
558
639
  </svg>
559
640
  </div><span>Eklavya</span>
560
641
  </a>
@@ -570,7 +651,7 @@
570
651
  <div class="mark" aria-hidden="true">
571
652
  <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
572
653
  stroke-linecap="round" stroke-linejoin="round">
573
- <path d="M5 3a13 13 0 0 1 0 18"/><path d="M5 3l14 9L5 21"/><path d="M3 12h18"/><path d="M15 8l4 4-4 4"/>
654
+ <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"/>
574
655
  </svg>
575
656
  </div>
576
657
  <div>
@@ -590,7 +671,8 @@
590
671
  <div class="side__controls">
591
672
  <div class="side__group">
592
673
  <p class="side__label" id="proj-label">Project</p>
593
- <select id="proj" aria-labelledby="proj-label"><option value="">All projects</option></select>
674
+ <button type="button" class="combo" id="proj" data-value="" disabled aria-haspopup="listbox"
675
+ aria-expanded="false" aria-controls="proj-menu" aria-labelledby="proj-label proj-name"><span class="combo__value" id="proj-name"><span class="skel" aria-label="Loading projects"></span></span><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>
594
676
  </div>
595
677
 
596
678
  <div class="side__group">
@@ -609,8 +691,9 @@
609
691
  New activity since this page loaded — refresh
610
692
  </button>
611
693
  <p class="page__eyebrow crumb inner" id="crumb" aria-label="Workflow and scope"></p>
612
- <div class="inner stack" id="view">
613
- <p class="empty">Loading your history…</p>
694
+ <div class="inner stack" id="view" aria-busy="true">
695
+ <!-- The same markup `loader()` writes; the script has not run yet. -->
696
+ <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>
614
697
  </div>
615
698
  <footer class="foot inner">
616
699
  <span>Read from <code id="dbline">~/.eklavya/knowledge.db</code> — nothing on this page leaves your machine.<span id="foot-note"></span></span>
@@ -620,6 +703,8 @@
620
703
  </div>
621
704
 
622
705
  <div class="picker" id="wf-menu" role="menu" aria-label="Workflows" hidden></div>
706
+ <div class="picker" id="proj-menu" role="listbox" aria-labelledby="proj-label" hidden></div>
707
+ <div class="picker" id="sel-menu" role="listbox" hidden></div>
623
708
 
624
709
  <script>
625
710
  /* ============================================================
@@ -1182,9 +1267,20 @@ let GEN = 0;
1182
1267
  * through `AFTER` like a chart, for the same reason — the target element does
1183
1268
  * not exist until `render()` has written the view's `innerHTML`.
1184
1269
  */
1270
+ /**
1271
+ * The one loading state: the mark loosing its arrow, and what is being waited
1272
+ * for. `page: true` is the full-height version the first paint uses. Every
1273
+ * placeholder a `fill()` target starts with is one of these, so a wait looks
1274
+ * the same wherever it happens. `label` is plain text; it is escaped here.
1275
+ */
1276
+ function loader(label, { page = false } = {}) {
1277
+ return `<div class="loader${page ? ' 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>${esc(label)}</span></div>`;
1278
+ }
1279
+
1185
1280
  function fill(id, url, draw) {
1186
1281
  const gen = GEN;
1187
1282
  AFTER.push(() => {
1283
+ $(id)?.setAttribute('aria-busy', 'true');
1188
1284
  fetch(url).then((r) => {
1189
1285
  if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
1190
1286
  return r.json();
@@ -1195,11 +1291,12 @@ function fill(id, url, draw) {
1195
1291
  // lookup does); the render that queued this one has already run its hooks.
1196
1292
  AFTER.length = 0;
1197
1293
  el.innerHTML = draw(d);
1294
+ el.removeAttribute('aria-busy');
1198
1295
  focusable(el);
1199
1296
  for (const fn of AFTER.splice(0)) fn();
1200
1297
  }).catch((e) => {
1201
1298
  const el = $(id);
1202
- if (el && gen === GEN) el.innerHTML = `<p class="empty">Could not load: ${esc(e.message)}</p>`;
1299
+ if (el && gen === GEN) { el.innerHTML = `<p class="empty">Could not load: ${esc(e.message)}</p>`; el.removeAttribute('aria-busy'); }
1203
1300
  });
1204
1301
  });
1205
1302
  }
@@ -1214,6 +1311,9 @@ function focusable(root) {
1214
1311
  if (el.tabIndex < 0) el.tabIndex = 0;
1215
1312
  if (el.tagName === 'A' && !el.hasAttribute('href')) el.setAttribute('role', 'link');
1216
1313
  });
1314
+ // Runs after every render and every fill, so it is also where new markup's
1315
+ // selects get their stand-ins.
1316
+ combos(root);
1217
1317
  }
1218
1318
 
1219
1319
  /** Everything stored is untrusted text: a title is whatever a tool printed. */
@@ -1622,7 +1722,7 @@ function viewSession(id) {
1622
1722
  if (!row) return noSession(id);
1623
1723
  return sessionBody({ id, repo: row.project, logged: [], asked: [], mem: row, first: row.first, last: row.last, passed: 0 });
1624
1724
  });
1625
- return `<div class="stack" id="sess-lookup"><p class="empty">Looking up this session…</p></div>`;
1725
+ return `<div class="stack" id="sess-lookup">${loader('Looking up this session…')}</div>`;
1626
1726
  }
1627
1727
 
1628
1728
  function sessionBody(s) {
@@ -1687,7 +1787,7 @@ function sessionBody(s) {
1687
1787
  { hint: 'Not from this diff — the tutor widened to prerequisites and neighbours.' })
1688
1788
  : '',
1689
1789
 
1690
- s.mem ? `<div class="stack" id="sess-mem"><p class="empty">Loading what this session remembered…</p></div>` : '',
1790
+ s.mem ? `<div class="stack" id="sess-mem">${loader('Loading what this session remembered…')}</div>` : '',
1691
1791
  ].join('');
1692
1792
  }
1693
1793
 
@@ -2013,7 +2113,7 @@ function viewMemory(param) {
2013
2113
  ${when === 'custom' ? `<input type="date" id="mfrom" value="${esc(t.since)}" aria-label="From date">
2014
2114
  <input type="date" id="mto" value="${esc(t.until)}" aria-label="To date">` : ''}
2015
2115
  </div>
2016
- <div id="mem-rows"><p class="empty">Loading the timeline…</p></div>
2116
+ <div id="mem-rows">${loader('Loading the timeline…')}</div>
2017
2117
  </div>`,
2018
2118
  ].join('');
2019
2119
  }
@@ -2057,9 +2157,9 @@ function viewMemoryDashboard() {
2057
2157
  memTiles(),
2058
2158
  card('Pipeline', null, pipeline),
2059
2159
  `<div class="g2">
2060
- ${card('Recent observations', null, `<div id="mem-recent"><p class="empty">Loading…</p></div>`,
2160
+ ${card('Recent observations', null, `<div id="mem-recent">${loader('Loading observations…')}</div>`,
2061
2161
  { aside: `<a class="link" data-go="${esc(MH('timeline'))}">Timeline →</a>` })}
2062
- ${card('Recent sessions', null, `<div id="mem-sessions"><p class="empty">Loading…</p></div>`,
2162
+ ${card('Recent sessions', null, `<div id="mem-sessions">${loader('Loading sessions…')}</div>`,
2063
2163
  { aside: `<a class="link" data-go="${esc(MH('sessions'))}">All sessions →</a>` })}
2064
2164
  </div>`,
2065
2165
  ].join('');
@@ -2092,7 +2192,7 @@ function viewMemorySessions() {
2092
2192
  `One row per session that captured evidence or wrote an observation. Pending is evidence no observation
2093
2193
  job has processed yet. Asked is the same session's questions, joined on its session id — a session can
2094
2194
  be remembered without ever being assessed.`),
2095
- card(null, null, `<div id="msess-rows"><p class="empty">Loading sessions…</p></div>`),
2195
+ card(null, null, `<div id="msess-rows">${loader('Loading sessions…')}</div>`),
2096
2196
  ].join('');
2097
2197
  }
2098
2198
 
@@ -2196,7 +2296,7 @@ function viewEntry(id) {
2196
2296
  fill('entry-body', '/api/memory/entry?id=' + encodeURIComponent(id), (d) =>
2197
2297
  d && d.entry ? drawEntry(d) : `<p class="empty">No observation <code>${esc(id)}</code>.</p>`);
2198
2298
  return `<div><a class="page__back" data-go="${esc(MH('timeline'))}">← Timeline</a></div>
2199
- <div class="stack" id="entry-body"><p class="empty">Loading the observation…</p></div>`;
2299
+ <div class="stack" id="entry-body">${loader('Loading the observation…')}</div>`;
2200
2300
  }
2201
2301
 
2202
2302
  /* ---------- Reuse: the savings ledger, receipt by receipt ---------- */
@@ -2324,7 +2424,7 @@ function viewHealth() {
2324
2424
  /* ============================================================
2325
2425
  Workflows and routes
2326
2426
 
2327
- Three workflows, one shell. The registry below is the only description of
2427
+ Four workflows, one shell. The registry below is the only description of
2328
2428
  any of them: the sidebar, the picker, the route resolver and the page renderer all
2329
2429
  read it, so the selector, the highlighted item and the screen cannot disagree
2330
2430
  about where the reader is.
@@ -2425,6 +2525,231 @@ function viewArtifactProjects() {
2425
2525
  ].join('');
2426
2526
  }
2427
2527
 
2528
+ /* ============================================================
2529
+ Settings — the same configuration `eklavya config set|unset` writes, at two
2530
+ scopes: the user file (~/.eklavya/config.json) and one project's file under
2531
+ ~/.eklavya/projects/. `/api/settings` reads both; a change is a POST that
2532
+ goes through the CLI's own write path, so the two cannot disagree. The
2533
+ registry of fields lives on the server (`SETTINGS` in dashboard.ts).
2534
+ ============================================================ */
2535
+ let SETS = null; // the last /api/settings response
2536
+ const SH = (page, param, q) => href('settings', page, param, q);
2537
+ const settingsUrl = () => '/api/settings' + (PROJECT ? '?project=' + encodeURIComponent(PROJECT) : '');
2538
+
2539
+ const showValue = (v) =>
2540
+ v === true ? 'on' : v === false ? 'off' : v === null || v === undefined ? 'none'
2541
+ : Array.isArray(v) ? (v.length ? v.join(', ') : 'none') : String(v);
2542
+ const cliFor = (scope, key, v) =>
2543
+ `eklavya config ${v === undefined ? 'unset' : 'set'} ${key}${v === undefined ? '' : ' ' + (Array.isArray(v) ? v.join(',') : v === null ? 'null' : String(v))}${scope === 'project' ? ' --project' : ''}`;
2544
+
2545
+ function settingControl(f, v, disabled) {
2546
+ const id = 'set-' + f.key.replace(/\W/g, '-');
2547
+ const d = disabled ? ' disabled' : '';
2548
+ const attrs = `id="${id}" data-set="${esc(f.key)}"${d} aria-describedby="${id}-h ${id}-e"`;
2549
+ if (f.type === 'bool') return `<input type="checkbox" ${attrs}${v ? ' checked' : ''}>`;
2550
+ if (f.type === 'enum') {
2551
+ return `<select ${attrs}>${f.options.map((o) =>
2552
+ `<option value="${esc(o)}"${o === v ? ' selected' : ''}>${esc(o)}</option>`).join('')}</select>`;
2553
+ }
2554
+ if (f.type === 'number') {
2555
+ return `<input type="number" ${attrs} min="${f.min}" max="${f.max}" step="${f.step ?? 1}" value="${v ?? ''}"
2556
+ inputmode="${f.int ? 'numeric' : 'decimal'}"${f.nullable ? ' placeholder="none"' : ' required'}>`;
2557
+ }
2558
+ if (f.type === 'list') return `<textarea ${attrs} rows="3">${esc((v ?? []).join('\n'))}</textarea>`;
2559
+ return `<input type="text" ${attrs} value="${esc(v ?? '')}" maxlength="${f.maxLength}" placeholder="none">`;
2560
+ }
2561
+
2562
+ /** What a field accepts, said under its help: the range is part of the setting. */
2563
+ function ruleHint(f) {
2564
+ if (f.type === 'number') {
2565
+ return `${f.int ? 'Whole number' : 'Number'}, ${f.min} to ${f.max}${f.nullable ? ', or empty' : ''}.`;
2566
+ }
2567
+ if (f.type === 'text') return `Up to ${f.maxLength} characters${f.nullable ? ', or empty' : ''}.`;
2568
+ if (f.type === 'list') return `Up to ${f.maxItems} lines of ${f.maxLength} characters${f.regex ? ', each a regular expression' : ''}.`;
2569
+ return '';
2570
+ }
2571
+
2572
+ /**
2573
+ * The server's `settingProblem` (config-path.ts), in the same words, run before
2574
+ * anything is sent. The server checks again and has the last word -- this only
2575
+ * spares a round trip and keeps the typed value in the box.
2576
+ */
2577
+ function fieldProblem(f, v) {
2578
+ const k = f.key;
2579
+ if (v === null) return f.nullable ? null : `${k} cannot be empty.`;
2580
+ if (f.type === 'number') {
2581
+ const range = `${f.int ? 'a whole number' : 'a number'} from ${f.min} to ${f.max}${f.nullable ? ', or empty' : ''}`;
2582
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < f.min || v > f.max || (f.int && !Number.isInteger(v))) return `${k} is ${range}.`;
2583
+ return null;
2584
+ }
2585
+ if (f.type === 'text') {
2586
+ if (!v.trim()) return `${k} needs some text${f.nullable ? ', or empty to clear it' : ''}.`;
2587
+ return v.length > f.maxLength ? `${k} is at most ${f.maxLength} characters.` : null;
2588
+ }
2589
+ if (f.type === 'list') {
2590
+ if (v.length > f.maxItems) return `${k} holds at most ${f.maxItems} lines.`;
2591
+ for (const item of v) {
2592
+ if (item.length > f.maxLength) return `${k} lines are at most ${f.maxLength} characters.`;
2593
+ if (f.regex) { try { new RegExp(item, 'g'); } catch { return `${k}: ${JSON.stringify(item)} is not a valid regular expression.`; } }
2594
+ }
2595
+ }
2596
+ return null;
2597
+ }
2598
+
2599
+ /** Shows (or clears, with null) a field's error under its control. */
2600
+ function fieldError(key, text) {
2601
+ const el = document.querySelector(`[data-set="${CSS.escape(key)}"]`);
2602
+ const box = el && $(el.id + '-e');
2603
+ if (!box) return;
2604
+ box.textContent = text || '';
2605
+ box.hidden = !text;
2606
+ if (text) el.setAttribute('aria-invalid', 'true'); else el.removeAttribute('aria-invalid');
2607
+ }
2608
+
2609
+ /** One setting at one scope: its control, and where the value in force comes from. */
2610
+ function settingRow(f, d, scope) {
2611
+ const id = 'set-' + f.key.replace(/\W/g, '-');
2612
+ const globalOnly = scope === 'project' && d.global_only.includes(f.key);
2613
+ const file = scope === 'project' ? d.project : d.user;
2614
+ const here = Object.hasOwn(file.set, f.key);
2615
+ const v = file.effective[f.key];
2616
+ let src;
2617
+ if (globalOnly) {
2618
+ src = `<span>set once for this machine — <a class="link" data-go="${esc(SH('user'))}">user settings</a></span>`;
2619
+ } else if (here) {
2620
+ src = `<span class="here">${scope === 'project' ? 'set for this project' : 'set by you'}</span>
2621
+ <button type="button" class="set__btn" data-unset="${esc(f.key)}">${scope === 'project' ? 'Inherit' : 'Reset to default'}</button>`;
2622
+ } else {
2623
+ src = `<span>${scope === 'project'
2624
+ ? (Object.hasOwn(d.user.set, f.key) ? 'inherited from user settings' : 'default')
2625
+ : 'default'}</span>`;
2626
+ }
2627
+ return `<div class="set">
2628
+ <div class="set__main"><label for="${id}">${esc(f.label)}</label>
2629
+ <p id="${id}-h">${esc(f.help)}${ruleHint(f) ? ` <span class="set__rule">${esc(ruleHint(f))}</span>` : ''}</p>
2630
+ <p class="set__src"><code>${esc(f.key)}</code>${src}<span data-msg="${esc(f.key)}" role="status"></span></p></div>
2631
+ <div class="set__ctl">${settingControl(f, v, globalOnly)}<p class="set__err" id="${id}-e" role="alert" hidden></p></div>
2632
+ </div>`;
2633
+ }
2634
+
2635
+ function drawSettings(d, scope) {
2636
+ SETS = d;
2637
+ const W = scope === 'project' ? 'Project settings' : 'User settings';
2638
+ const about = `User settings apply everywhere; a project setting wins in that project only, and <b>Inherit</b>
2639
+ removes it so the user setting applies again. Every change here is the same write as
2640
+ <code>eklavya config set &lt;key&gt; &lt;value&gt;</code> (with <code>--project</code> for a project), and
2641
+ <b>Inherit</b> / <b>Reset</b> is <code>eklavya config unset</code>. Nothing is written inside a repository.`;
2642
+ if (scope === 'project' && !d.project) {
2643
+ const known = d.projects.filter((p) => projectById(p.inventory));
2644
+ return [
2645
+ pageHead(W, [plural(known.length, 'project')], about),
2646
+ nextStep(PROJECT
2647
+ ? `<strong>${esc(scopeName())}</strong> has no git checkout on this machine, so there is no project file to change. Pick one below.`
2648
+ : 'Pick a project to change its settings, or use the project selector.'),
2649
+ card(null, null, lines(known.map((p) => ({ t: esc(p.name), s: `<span class="mono">${esc(p.id)}</span>`,
2650
+ go: SH('project', null, { project: p.inventory }) })), 'No project with a checkout on this machine yet.')),
2651
+ ].join('');
2652
+ }
2653
+ const file = scope === 'project' ? d.project : d.user;
2654
+ const groups = [...new Set(d.fields.map((f) => f.group))];
2655
+ const setHere = Object.keys(file.set).length;
2656
+ return [
2657
+ pageHead(scope === 'project' ? `${esc(d.project.name)} settings` : W,
2658
+ [`<span class="mono">${esc(file.path)}</span>`, `${setHere} set here`], about),
2659
+ d.problem ? nextStep(`<strong>A config file is not being read.</strong> ${esc(d.problem)}`) : '',
2660
+ ...groups.map((g) => card(g, null, d.fields.filter((f) => f.group === g).map((f) => settingRow(f, d, scope)).join(''))),
2661
+ fold(`settings:${scope}:cli`, 'Changed from the terminal', plural(d.cli_only.length, 'setting'),
2662
+ lines(d.cli_only.map((c) => ({ t: `<code>${esc(c.key)}</code>`, s: esc(c.why),
2663
+ v: `<span class="mono">${esc(showValue(file.effective[c.key]))}</span>` }))),
2664
+ { hint: 'These send work off this machine or run a command, so they stay a typed command: eklavya config set <key> <value>.' }),
2665
+ ].join('');
2666
+ }
2667
+
2668
+ function viewSettings(scope) {
2669
+ fill('settings', settingsUrl(), (d) => drawSettings(d, scope));
2670
+ return `<div class="stack" id="settings">${loader('Reading your settings…')}</div>`;
2671
+ }
2672
+
2673
+ function viewSettingsHome() {
2674
+ fill('settings', settingsUrl(), (d) => {
2675
+ SETS = d;
2676
+ const proj = d.project;
2677
+ const overrides = proj ? Object.keys(proj.set).filter((k) => !d.global_only.includes(k)) : [];
2678
+ return [
2679
+ pageHead('Settings', [plural(Object.keys(d.user.set).length, 'user setting'),
2680
+ proj ? `${plural(overrides.length, 'project setting')} in ${esc(proj.name)}` : 'no project selected'],
2681
+ `Two files, one precedence: the project's file wins over yours, key by key, and anything neither sets is the
2682
+ default. The terminal writes the same files — <code>eklavya config get</code> shows what is in force.`),
2683
+ card(null, null, lines([
2684
+ { t: 'User settings', s: `<span class="mono">${esc(d.user.path)}</span>`, v: esc(`${Object.keys(d.user.set).length} set`), go: SH('user') },
2685
+ { t: 'Project settings', s: proj ? `<span class="mono">${esc(proj.path)}</span>` : 'choose a project',
2686
+ v: proj ? esc(`${overrides.length} set`) : '', go: SH('project') },
2687
+ ])),
2688
+ proj && overrides.length ? card(`What ${proj.name} changes`, null, lines(overrides.map((k) => ({
2689
+ t: `<code>${esc(k)}</code>`, s: esc(`user: ${showValue(d.user.effective[k])}`),
2690
+ v: `<span class="mono">${esc(showValue(proj.set[k]))}</span>`, go: SH('project') })))) : '',
2691
+ ].join('');
2692
+ });
2693
+ return `<div class="stack" id="settings">${loader('Reading your settings…')}</div>`;
2694
+ }
2695
+
2696
+ /** Reads a control back into the value its field stores. */
2697
+ function controlValue(f, el) {
2698
+ if (f.type === 'bool') return el.checked;
2699
+ if (f.type === 'number') return el.value.trim() === '' ? (f.nullable ? null : NaN) : Number(el.value);
2700
+ if (f.type === 'list') return el.value.split('\n').map((s) => s.trim()).filter(Boolean);
2701
+ if (f.type === 'text') return el.value.trim() === '' && f.nullable ? null : el.value.trim();
2702
+ return el.value;
2703
+ }
2704
+
2705
+ /**
2706
+ * Saves one setting and redraws the settings in place — not through `render()`,
2707
+ * which would scroll to the top and lose the control the reader just used.
2708
+ */
2709
+ function saveSetting(key, change) {
2710
+ const scope = CUR.page === 'project' ? 'project' : 'user';
2711
+ const msg = document.querySelector(`[data-msg="${CSS.escape(key)}"]`);
2712
+ const say = (text, warn) => { if (msg) { msg.textContent = text; msg.className = warn ? 'warn' : 'here'; } };
2713
+ fieldError(key, null);
2714
+ say('saving…');
2715
+ const token = document.querySelector('meta[name="eklavya-token"]')?.content ?? '';
2716
+ const gen = GEN;
2717
+ fetch('/api/settings', {
2718
+ method: 'POST',
2719
+ headers: { 'content-type': 'application/json', 'x-eklavya-token': token },
2720
+ body: JSON.stringify({ scope, project: SETS?.project?.id, key, ...change }),
2721
+ })
2722
+ .then((r) => r.json().then((d) => { if (!r.ok) throw new Error(d.error || `${r.status}`); }))
2723
+ .then(() => fetch(settingsUrl()).then((r) => r.json()))
2724
+ .then((d) => {
2725
+ const el = $('settings');
2726
+ if (!el || gen !== GEN) return;
2727
+ const focusId = document.activeElement?.id;
2728
+ el.innerHTML = drawSettings(d, scope);
2729
+ focusable(el);
2730
+ const back = (focusId && $(focusId)) || document.querySelector(`[data-set="${CSS.escape(key)}"]`);
2731
+ back?.focus({ preventScroll: true });
2732
+ const m = document.querySelector(`[data-msg="${CSS.escape(key)}"]`);
2733
+ if (m) { m.textContent = change.unset ? 'inherited' : 'saved'; m.className = 'here'; }
2734
+ })
2735
+ .catch((e) => {
2736
+ // A refused choice or switch is put back to what is on disk, so the
2737
+ // control never shows a value that was not saved. Typed text and numbers
2738
+ // are kept, so the reader can fix them where they are.
2739
+ const el = document.querySelector(`[data-set="${CSS.escape(key)}"]`);
2740
+ const box = $('settings');
2741
+ if (SETS && box && gen === GEN && (el?.type === 'checkbox' || el?.tagName === 'SELECT')) {
2742
+ box.innerHTML = drawSettings(SETS, scope);
2743
+ focusable(box);
2744
+ const again = document.querySelector(`[data-set="${CSS.escape(key)}"]`);
2745
+ (again && ($(again.id + '-combo') || again))?.focus({ preventScroll: true });
2746
+ } else {
2747
+ say('');
2748
+ }
2749
+ fieldError(key, e.message);
2750
+ });
2751
+ }
2752
+
2428
2753
  const ICONS = {
2429
2754
  artifacts: '<path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5"/><path d="M9 13h7M9 17h5"/>',
2430
2755
  learning: '<path d="M4 19V6a2 2 0 0 1 2-2h13v13H6a2 2 0 0 0-2 2z"/><path d="M6 21h13v-4"/><path d="M9 8h6"/>',
@@ -2438,6 +2763,9 @@ const ICONS = {
2438
2763
  timeline: '<path d="M4 5h16v14H4z"/><path d="M8 9h8M8 13h5"/>',
2439
2764
  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"/>',
2440
2765
  health: '<path d="M3 12h4l2-6 3 12 2-6h7"/>',
2766
+ 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"/>',
2767
+ user: '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
2768
+ project: '<path d="M3 7l9-4 9 4-9 4-9-4z"/><path d="M3 12l9 4 9-4M3 17l9 4 9-4"/>',
2441
2769
  };
2442
2770
  const svg = (paths, cls = '') =>
2443
2771
  `<svg${cls ? ` class="${cls}"` : ''} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
@@ -2495,6 +2823,17 @@ const WORKFLOWS = {
2495
2823
  projects: { label: 'Projects', view: () => viewArtifactProjects() },
2496
2824
  },
2497
2825
  },
2826
+ settings: {
2827
+ name: 'Settings',
2828
+ groups: [
2829
+ { id: 'scopes', label: 'Scopes', items: ['user', 'project'] },
2830
+ ],
2831
+ pages: {
2832
+ dashboard: { label: 'Overview', view: () => viewSettingsHome() },
2833
+ user: { label: 'User', view: () => viewSettings('user') },
2834
+ project: { label: 'Project', view: () => viewSettings('project') },
2835
+ },
2836
+ },
2498
2837
  };
2499
2838
 
2500
2839
  /**
@@ -2622,6 +2961,8 @@ function navCounts(wf) {
2622
2961
  };
2623
2962
  }
2624
2963
  if (wf === 'artifacts') return { projects: new Set(artifactsInScope().map((a) => pid(a.project))).size };
2964
+ // No honest count for a settings page: how many keys a file sets is not a to-do.
2965
+ if (wf === 'settings') return {};
2625
2966
  return {
2626
2967
  timeline: sum((p) => p.memory.entries),
2627
2968
  sessions: sum((p) => p.memory.sessions),
@@ -2693,20 +3034,20 @@ const caret = $('wf-caret');
2693
3034
 
2694
3035
  function pickerItems() { return [...menu.querySelectorAll('[role="menuitemradio"]')]; }
2695
3036
 
2696
- function placePicker() {
2697
- const anchor = $('wf').getBoundingClientRect();
3037
+ function placePicker(el = menu, at = $('wf')) {
3038
+ const anchor = at.getBoundingClientRect();
2698
3039
  const pad = 8;
2699
3040
  const vw = document.documentElement.clientWidth;
2700
3041
  const vh = window.innerHeight;
2701
- menu.style.maxHeight = `${vh - pad * 2}px`;
2702
- menu.style.minWidth = `${Math.min(Math.max(220, anchor.width), vw - pad * 2)}px`;
2703
- const w = menu.offsetWidth, h = menu.offsetHeight;
3042
+ el.style.maxHeight = `${Math.min(360, vh - pad * 2)}px`;
3043
+ el.style.minWidth = `${Math.min(Math.max(220, anchor.width), vw - pad * 2)}px`;
3044
+ const w = el.offsetWidth, h = el.offsetHeight;
2704
3045
  const left = Math.max(pad, Math.min(anchor.left, vw - w - pad));
2705
3046
  let top = anchor.bottom + 4;
2706
3047
  // Below if it fits, above if that fits, otherwise as high as the window allows.
2707
3048
  if (top + h > vh - pad) top = anchor.top - h - 4 >= pad ? anchor.top - h - 4 : Math.max(pad, vh - h - pad);
2708
- menu.style.left = `${left}px`;
2709
- menu.style.top = `${top}px`;
3049
+ el.style.left = `${left}px`;
3050
+ el.style.top = `${top}px`;
2710
3051
  }
2711
3052
 
2712
3053
  function openPicker() {
@@ -2752,7 +3093,222 @@ menu.addEventListener('keydown', (e) => {
2752
3093
  document.addEventListener('pointerdown', (e) => {
2753
3094
  if (!menu.hidden && !menu.contains(e.target) && !caret.contains(e.target)) closePicker(false);
2754
3095
  });
2755
- addEventListener('resize', () => { if (!menu.hidden) placePicker(); });
3096
+ addEventListener('resize', () => { if (!menu.hidden) placePicker(); if (!pmenu.hidden) placePicker(pmenu, pbtn); });
3097
+
3098
+ /* ---------- the project selector ---------- */
3099
+ // A button and a listbox rather than a <select>: the native menu is drawn by
3100
+ // the OS and ignores the page's tokens. It reuses the workflow picker's
3101
+ // placement, so it is outside the rail's scroll clip and clamped to the window.
3102
+ // The value lives on the button as `data-value` ('' = all projects).
3103
+ const pmenu = $('proj-menu');
3104
+ const pbtn = $('proj');
3105
+ let typed = '', typedAt = 0;
3106
+
3107
+ function projOptions() { return [...pmenu.querySelectorAll('[role="option"]')]; }
3108
+
3109
+ /** Built once from the inventory; `syncProjects()` only moves the selection. */
3110
+ function buildProjects(inv) {
3111
+ const row = (value, name) =>
3112
+ `<button type="button" role="option" aria-selected="false" data-value="${esc(value)}" tabindex="-1">
3113
+ <span>${esc(name)}</span>
3114
+ <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>
3115
+ </button>`;
3116
+ pmenu.innerHTML = row('', 'All projects') +
3117
+ inv.projects.map((p) => row(p.id, p.name + (p.available === false ? ' (checkout gone)' : ''))).join('');
3118
+ pbtn.disabled = false;
3119
+ }
3120
+
3121
+ function syncProjects() {
3122
+ const v = PROJECT ?? '';
3123
+ pbtn.dataset.value = v;
3124
+ let name = 'All projects';
3125
+ for (const o of projOptions()) {
3126
+ const on = o.dataset.value === v;
3127
+ o.setAttribute('aria-selected', String(on));
3128
+ if (on) name = o.textContent.trim();
3129
+ }
3130
+ $('proj-name').textContent = name;
3131
+ }
3132
+
3133
+ function openProjects() {
3134
+ if (pbtn.disabled) return;
3135
+ pmenu.hidden = false;
3136
+ pbtn.setAttribute('aria-expanded', 'true');
3137
+ placePicker(pmenu, pbtn);
3138
+ const cur = projOptions().find((o) => o.getAttribute('aria-selected') === 'true') ?? projOptions()[0];
3139
+ cur.focus();
3140
+ cur.scrollIntoView({ block: 'nearest' });
3141
+ }
3142
+
3143
+ function closeProjects(restore = true) {
3144
+ if (pmenu.hidden) return;
3145
+ const hadFocus = pmenu.contains(document.activeElement);
3146
+ pmenu.hidden = true;
3147
+ pbtn.setAttribute('aria-expanded', 'false');
3148
+ if (restore || hadFocus) pbtn.focus();
3149
+ }
3150
+
3151
+ function chooseProject(value) {
3152
+ closeProjects();
3153
+ const v = value || null;
3154
+ try { if (v) localStorage.setItem('eklavya-dash-project', v); else localStorage.removeItem('eklavya-dash-project'); } catch (e) {}
3155
+ // A real navigation: the scope is part of the URL, so Back undoes it.
3156
+ goTo(href(CUR.wf, CUR.page, CUR.param || null, { project: v, ...Object.fromEntries(
3157
+ (WORKFLOWS[CUR.wf].pages[CUR.page]?.query ?? []).map((k) => [k, CUR.query.get(k)])) }));
3158
+ }
3159
+
3160
+ pbtn.addEventListener('click', () => (pmenu.hidden ? openProjects() : closeProjects()));
3161
+ pbtn.addEventListener('keydown', (e) => {
3162
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); openProjects(); }
3163
+ });
3164
+ pmenu.addEventListener('click', (e) => {
3165
+ const o = e.target.closest('[role="option"]');
3166
+ if (o) chooseProject(o.dataset.value);
3167
+ });
3168
+ pmenu.addEventListener('keydown', (e) => {
3169
+ const items = projOptions();
3170
+ const i = items.indexOf(document.activeElement);
3171
+ const to = (n) => { e.preventDefault(); items[n].focus(); items[n].scrollIntoView({ block: 'nearest' }); };
3172
+ if (e.key === 'ArrowDown') to(Math.min(i + 1, items.length - 1));
3173
+ else if (e.key === 'ArrowUp') to(Math.max(i - 1, 0));
3174
+ else if (e.key === 'Home') to(0);
3175
+ else if (e.key === 'End') to(items.length - 1);
3176
+ else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (i >= 0) chooseProject(items[i].dataset.value); }
3177
+ else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeProjects(); }
3178
+ else if (e.key === 'Tab') { e.preventDefault(); closeProjects(); }
3179
+ else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
3180
+ // Type-ahead: letters typed within a second jump to the first match.
3181
+ typed = (Date.now() - typedAt > 1000 ? '' : typed) + e.key.toLowerCase();
3182
+ typedAt = Date.now();
3183
+ const n = items.findIndex((o) => o.textContent.trim().toLowerCase().startsWith(typed));
3184
+ if (n >= 0) to(n);
3185
+ }
3186
+ });
3187
+ document.addEventListener('pointerdown', (e) => {
3188
+ if (!pmenu.hidden && !pmenu.contains(e.target) && !pbtn.contains(e.target)) closeProjects(false);
3189
+ });
3190
+
3191
+ /* ---------- every other dropdown ---------- */
3192
+ // Views keep writing plain <select>s, and the delegated `change` handler keeps
3193
+ // reading them: the select stays the value, hidden from sight and from
3194
+ // assistive technology, and a `.combo` beside it opens the one shared listbox.
3195
+ // Choosing sets `select.value` and fires a bubbling `change`, so nothing that
3196
+ // reads a select needs to know this exists.
3197
+ const smenu = $('sel-menu');
3198
+ let sOpen = null; // the combo whose listbox is showing
3199
+ let sRefocus = null; // a combo id to take focus back after a re-render
3200
+ let selSeq = 0;
3201
+
3202
+ const CHEVRON = '<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>';
3203
+ const selectOf = (combo) => document.getElementById(combo.dataset.for);
3204
+
3205
+ function combos(root) {
3206
+ root.querySelectorAll('select:not([data-combo])').forEach((s) => {
3207
+ s.dataset.combo = '1';
3208
+ if (!s.id) s.id = `sel-${++selSeq}`;
3209
+ s.hidden = true;
3210
+ s.setAttribute('aria-hidden', 'true');
3211
+ s.tabIndex = -1;
3212
+ const name = s.getAttribute('aria-label') || s.labels?.[0]?.textContent.trim() || '';
3213
+ const b = document.createElement('button');
3214
+ b.type = 'button';
3215
+ b.className = 'combo';
3216
+ b.id = `${s.id}-combo`;
3217
+ b.dataset.for = s.id;
3218
+ b.disabled = s.disabled;
3219
+ b.setAttribute('role', 'combobox');
3220
+ b.setAttribute('aria-haspopup', 'listbox');
3221
+ b.setAttribute('aria-expanded', 'false');
3222
+ b.setAttribute('aria-controls', 'sel-menu');
3223
+ if (name) b.setAttribute('aria-label', name);
3224
+ if (s.getAttribute('aria-describedby')) b.setAttribute('aria-describedby', s.getAttribute('aria-describedby'));
3225
+ b.innerHTML = `<span class="combo__value">${esc(s.selectedOptions[0]?.textContent.trim() ?? '')}</span>${CHEVRON}`;
3226
+ s.after(b);
3227
+ });
3228
+ if (sOpen && !sOpen.isConnected) closeSelect(false);
3229
+ const back = sRefocus && document.getElementById(sRefocus);
3230
+ if (back && (document.activeElement === document.body || !document.activeElement)) back.focus({ preventScroll: true });
3231
+ if (back) sRefocus = null;
3232
+ }
3233
+
3234
+ function selOptions() { return [...smenu.querySelectorAll('[role="option"]')]; }
3235
+
3236
+ function openSelect(b) {
3237
+ const s = selectOf(b);
3238
+ if (!s || b.disabled) return;
3239
+ if (sOpen) closeSelect(false);
3240
+ smenu.innerHTML = [...s.options].map((o, i) =>
3241
+ `<button type="button" role="option" aria-selected="${o.selected}" data-i="${i}" tabindex="-1">
3242
+ <span>${esc(o.textContent.trim())}</span>
3243
+ <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>
3244
+ </button>`).join('');
3245
+ smenu.setAttribute('aria-label', b.getAttribute('aria-label') || '');
3246
+ smenu.hidden = false;
3247
+ sOpen = b;
3248
+ b.setAttribute('aria-expanded', 'true');
3249
+ placePicker(smenu, b);
3250
+ const cur = selOptions()[Math.max(0, s.selectedIndex)];
3251
+ cur?.focus();
3252
+ cur?.scrollIntoView({ block: 'nearest' });
3253
+ }
3254
+
3255
+ function closeSelect(restore = true) {
3256
+ if (!sOpen) return;
3257
+ const b = sOpen;
3258
+ const hadFocus = smenu.contains(document.activeElement);
3259
+ smenu.hidden = true;
3260
+ sOpen = null;
3261
+ b.setAttribute('aria-expanded', 'false');
3262
+ if ((restore || hadFocus) && b.isConnected) b.focus();
3263
+ }
3264
+
3265
+ function chooseOption(i) {
3266
+ const b = sOpen;
3267
+ const s = b && selectOf(b);
3268
+ closeSelect();
3269
+ if (!s || s.selectedIndex === i) return;
3270
+ s.selectedIndex = i;
3271
+ b.querySelector('.combo__value').textContent = s.options[i].textContent.trim();
3272
+ // The handler often re-renders, which replaces this combo; the next pass of
3273
+ // `combos()` hands focus to its replacement.
3274
+ sRefocus = b.id;
3275
+ s.dispatchEvent(new Event('change', { bubbles: true }));
3276
+ }
3277
+
3278
+ document.addEventListener('click', (e) => {
3279
+ const b = e.target.closest('.combo[data-for]');
3280
+ if (b) (sOpen === b ? closeSelect() : openSelect(b));
3281
+ });
3282
+ document.addEventListener('keydown', (e) => {
3283
+ const b = e.target.closest?.('.combo[data-for]');
3284
+ if (b && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { e.preventDefault(); openSelect(b); }
3285
+ });
3286
+ smenu.addEventListener('click', (e) => {
3287
+ const o = e.target.closest('[role="option"]');
3288
+ if (o) chooseOption(Number(o.dataset.i));
3289
+ });
3290
+ smenu.addEventListener('keydown', (e) => {
3291
+ const items = selOptions();
3292
+ const i = items.indexOf(document.activeElement);
3293
+ const to = (n) => { e.preventDefault(); items[n].focus(); items[n].scrollIntoView({ block: 'nearest' }); };
3294
+ if (e.key === 'ArrowDown') to(Math.min(i + 1, items.length - 1));
3295
+ else if (e.key === 'ArrowUp') to(Math.max(i - 1, 0));
3296
+ else if (e.key === 'Home') to(0);
3297
+ else if (e.key === 'End') to(items.length - 1);
3298
+ else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (i >= 0) chooseOption(i); }
3299
+ else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeSelect(); }
3300
+ else if (e.key === 'Tab') { e.preventDefault(); closeSelect(); }
3301
+ else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
3302
+ typed = (Date.now() - typedAt > 1000 ? '' : typed) + e.key.toLowerCase();
3303
+ typedAt = Date.now();
3304
+ const n = items.findIndex((o) => o.textContent.trim().toLowerCase().startsWith(typed));
3305
+ if (n >= 0) to(n);
3306
+ }
3307
+ });
3308
+ document.addEventListener('pointerdown', (e) => {
3309
+ if (sOpen && !smenu.contains(e.target) && !sOpen.contains(e.target)) closeSelect(false);
3310
+ });
3311
+ addEventListener('resize', () => { if (sOpen) placePicker(smenu, sOpen); });
2756
3312
 
2757
3313
  /**
2758
3314
  * Opens the other workflow's Dashboard. The project and the ground carry over;
@@ -2815,6 +3371,7 @@ function openDrawer() {
2815
3371
  function closeDrawer(restore = true) {
2816
3372
  if (!drawerOpen) return;
2817
3373
  closePicker(false);
3374
+ closeProjects(false);
2818
3375
  drawerOpen = false;
2819
3376
  syncDrawer();
2820
3377
  if (restore) $('menu').focus();
@@ -2826,9 +3383,9 @@ $('scrim').addEventListener('click', () => closeDrawer());
2826
3383
  narrow.addEventListener('change', syncDrawer);
2827
3384
  document.addEventListener('keydown', (e) => {
2828
3385
  if (!drawerOpen) return;
2829
- if (e.key === 'Escape' && menu.hidden) { e.preventDefault(); closeDrawer(); return; }
3386
+ if (e.key === 'Escape' && menu.hidden && pmenu.hidden) { e.preventDefault(); closeDrawer(); return; }
2830
3387
  // Hold focus inside the open drawer.
2831
- if (e.key === 'Tab' && menu.hidden) {
3388
+ if (e.key === 'Tab' && menu.hidden && pmenu.hidden) {
2832
3389
  const f = focusables(side);
2833
3390
  if (!f.length) return;
2834
3391
  const first = f[0], last = f[f.length - 1];
@@ -2868,7 +3425,7 @@ function syncFromUrl() {
2868
3425
  const next = known ? project : null;
2869
3426
  if (next !== PROJECT) { PROJECT = next; for (const k of Object.keys(T)) T[k].page = 1; recomputeScope(); }
2870
3427
  CUR = { wf: r.wf, page: r.page, param: r.param, query: r.query, missing: r.missing, bad: r.bad };
2871
- $('proj').value = PROJECT ?? '';
3428
+ syncProjects();
2872
3429
  render();
2873
3430
  }
2874
3431
 
@@ -2983,7 +3540,23 @@ $('view').addEventListener('input', (e) => {
2983
3540
  }
2984
3541
  });
2985
3542
 
3543
+ // Settings: a control saves on change (a text box on blur or Enter), and the
3544
+ // Inherit / Reset button removes the key from that scope's file.
3545
+ $('view').addEventListener('click', (e) => {
3546
+ const b = e.target.closest('[data-unset]');
3547
+ if (b) saveSetting(b.dataset.unset, { unset: true });
3548
+ });
2986
3549
  $('view').addEventListener('change', (e) => {
3550
+ const c = e.target.closest('[data-set]');
3551
+ if (c && SETS) {
3552
+ const f = SETS.fields.find((x) => x.key === c.dataset.set);
3553
+ if (!f) return;
3554
+ const value = controlValue(f, c);
3555
+ const problem = fieldProblem(f, value);
3556
+ if (problem) fieldError(f.key, problem);
3557
+ else saveSetting(f.key, { value });
3558
+ return;
3559
+ }
2987
3560
  if (e.target.id === 'dom') { T.concepts.page = 1; setQuery({ domain: e.target.value }); }
2988
3561
  if (e.target.id === 'sort') { T.concepts.sort = e.target.value; T.concepts.page = 1; render(); }
2989
3562
  if (e.target.id === 'mtag') { T.memory.page = 1; setQuery({ tag: e.target.value }); }
@@ -3092,23 +3665,14 @@ Promise.all([json('/api/state'), json('/api/projects')]).then(([s, inv]) => {
3092
3665
  // One selector for both workflows, built from the one inventory: a project
3093
3666
  // that logged concepts but was never asked a question, or that only ever
3094
3667
  // captured memory, is a project and is listed.
3095
- const sel = $('proj');
3096
- for (const p of inv.projects) {
3097
- const o = document.createElement('option');
3098
- o.value = p.id;
3099
- o.textContent = p.name + (p.available === false ? ' (checkout gone)' : '');
3100
- sel.appendChild(o);
3101
- }
3102
- sel.addEventListener('change', () => {
3103
- const v = sel.value || null;
3104
- try { if (v) localStorage.setItem('eklavya-dash-project', v); else localStorage.removeItem('eklavya-dash-project'); } catch (e) {}
3105
- // A real navigation: the scope is part of the URL, so Back undoes it.
3106
- goTo(href(CUR.wf, CUR.page, CUR.param || null, { project: v, ...Object.fromEntries(
3107
- (WORKFLOWS[CUR.wf].pages[CUR.page]?.query ?? []).map((k) => [k, CUR.query.get(k)])) }));
3108
- });
3668
+ buildProjects(inv);
3109
3669
 
3110
3670
  syncFromUrl();
3671
+ $('app').classList.remove('booting');
3672
+ $('view').removeAttribute('aria-busy');
3111
3673
  }).catch((e) => {
3674
+ $('app').classList.remove('booting');
3675
+ $('view').removeAttribute('aria-busy');
3112
3676
  $('view').innerHTML = `<p class="empty">Could not read the database: ${esc(e.message)}</p>`;
3113
3677
  });
3114
3678
  </script>