eklavya 1.33.2 → 1.34.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/dist/assets/dashboard.html +610 -46
- package/dist/assets/tutor/references/focus-and-level.md +4 -3
- package/dist/cli.js +39 -34
- package/dist/cli.js.map +1 -1
- package/dist/config-path.js +166 -1
- package/dist/config-path.js.map +1 -1
- package/dist/config.js +14 -6
- package/dist/config.js.map +1 -1
- package/dist/dashboard.js +266 -14
- package/dist/dashboard.js.map +1 -1
- package/dist/hooks/changes-lib.js +48 -2
- package/dist/hooks/changes-lib.js.map +1 -1
- package/dist/hooks/checkpoint-quiz.js +47 -18
- package/dist/hooks/checkpoint-quiz.js.map +1 -1
- package/dist/hooks/stop-quiz-check.js +14 -4
- package/dist/hooks/stop-quiz-check.js.map +1 -1
- package/dist/memory/learning.js +27 -5
- package/dist/memory/learning.js.map +1 -1
- package/dist/memory/recall.js +7 -6
- package/dist/memory/recall.js.map +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/hooks/CLAUDE.md +10 -4
- package/dist/plugin/skills/progress/SKILL.md +3 -2
- package/dist/plugin/skills/tutor/references/focus-and-level.md +4 -3
- package/dist/session.js +100 -2
- package/dist/session.js.map +1 -1
- package/dist/srs.js +12 -0
- package/dist/srs.js.map +1 -1
- package/dist/store.js +18 -9
- package/dist/store.js.map +1 -1
- package/dist/time.js +13 -0
- package/dist/time.js.map +1 -1
- package/dist/tools/config_tools.js +45 -22
- package/dist/tools/config_tools.js.map +1 -1
- package/dist/tools/get_learner_profile.js +3 -3
- package/dist/tools/get_learner_profile.js.map +1 -1
- package/dist/tools/get_session_quiz_plan.js +24 -13
- package/dist/tools/get_session_quiz_plan.js.map +1 -1
- package/dist/user-skill/eklavya/SKILL.md +6 -5
- package/package.json +1 -1
|
@@ -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='
|
|
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="
|
|
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="
|
|
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
|
-
<
|
|
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
|
-
|
|
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. */
|
|
@@ -1468,7 +1568,7 @@ function viewConcept(slug) {
|
|
|
1468
1568
|
tiles([
|
|
1469
1569
|
['Score', c.seen ? Math.round(c.score * 100) + '%' : '—', decayed ? `decayed from ${Math.round(c.stored_score * 100)}%` : 'last five grades, recent first', c.mastered ? 'good' : c.score < 0.5 && c.seen ? 'warn' : ''],
|
|
1470
1570
|
['State', c.mastered ? 'mastered' : c.seen ? (c.due ? 'due' : 'learning') : 'not asked', c.seen ? `${c.passed} of ${c.attempts} right` : 'no questions yet', c.due ? 'warn' : ''],
|
|
1471
|
-
['Next review', c.
|
|
1571
|
+
['Next review', c.owed ? whenDue(c.next_review) : '—', c.interval_d ? `every ${Math.round(c.interval_d)}d · ease ${c.ease ? c.ease.toFixed(2) : '—'} · ${plural(c.reps, 'rep')}` : 'not scheduled', c.due ? 'warn' : ''],
|
|
1472
1572
|
]),
|
|
1473
1573
|
|
|
1474
1574
|
card('Every question you were asked', atts.length ? `${plural(atts.length, 'attempt')}, newest first. Open one for the options and the feedback.` : '',
|
|
@@ -1504,7 +1604,7 @@ function viewReview(param) {
|
|
|
1504
1604
|
t.tab = REVIEW_TABS.includes(param) ? param : 'due';
|
|
1505
1605
|
const seen = seenConcepts();
|
|
1506
1606
|
const due = seen.filter((c) => c.due).sort((a, b) => (b.overdue_days ?? 0) - (a.overdue_days ?? 0));
|
|
1507
|
-
const upcoming = seen.filter((c) => !c.due && c.next_review)
|
|
1607
|
+
const upcoming = seen.filter((c) => c.owed && !c.due && c.next_review)
|
|
1508
1608
|
.sort((a, b) => String(a.next_review).localeCompare(String(b.next_review)));
|
|
1509
1609
|
const skipped = attemptsInScope().filter((a) => a.outcome === 'declined' || a.outcome === 'dont_know');
|
|
1510
1610
|
|
|
@@ -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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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
|
-
|
|
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 <key> <value></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 =
|
|
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
|
-
|
|
2702
|
-
|
|
2703
|
-
const w =
|
|
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
|
-
|
|
2709
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|