kaizen-agent 1.31.3 → 1.33.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.
@@ -0,0 +1,331 @@
1
+ import { store } from "./store.ts";
2
+ import { showTerm, termStop } from "./agent-term.ts";
3
+ import { load, post } from "./api.ts";
4
+ import { APPROVAL, answer, confirmAnswers, confirmRevise, decide, decided, resumable, resume } from "./approvals.ts";
5
+ import { cardEl, key, render } from "./cards.ts";
6
+ import { chat } from "./chat.ts";
7
+ import { stopDictation } from "./dictation.ts";
8
+ import { $, esc } from "./dom.ts";
9
+ import { fixForm, fixOff } from "./fixes.ts";
10
+ import { commitPush, confirmCommit, gitBtn } from "./git.ts";
11
+ import { WORD, need } from "./labels.ts";
12
+ import { attach, brief, md, planQuestions, preview, progress } from "./markdown.ts";
13
+ import { toast } from "./toast.ts";
14
+ import { go, view } from "./views.ts";
15
+
16
+ // ---- detail panel
17
+ // A run's tabs, its phases in board order. A card opens on its column's tab.
18
+ const TABS = [
19
+ ["Request", "request"],
20
+ ["Notes", "notes"],
21
+ ["Plan", "plan"],
22
+ ["Backlog", "backlog"],
23
+ ["Work", "impl"],
24
+ ["Review", "review"],
25
+ ["Preview", "preview"],
26
+ ];
27
+ // keepTab: a link already chose the tab.
28
+ export function open(c, keepTab) {
29
+ if (view !== "board") go("board", true);
30
+ stopDictation(true); // the panel body is about to be replaced
31
+ fixOff.clear();
32
+ store.openKey = key(c);
33
+ $("panel").classList.add("open");
34
+ $("scrim").classList.add("on");
35
+ $("ptitle").textContent = c.kind === "idea" ? brief(c.text).head : c.text;
36
+ render();
37
+ $("pterm").hidden = true;
38
+ if (c.kind === "idea") {
39
+ store.runDoc = null;
40
+ $("psub").textContent = "Idea · " + c.where;
41
+ $("ptabs").innerHTML = "";
42
+ ideaBody(c);
43
+ const foot = $("pfoot");
44
+ foot.innerHTML = c.archived
45
+ ? `<button class="btn pri" data-a="restore">Restore</button>`
46
+ : c.status === "idea"
47
+ ? `<button class="btn ghost danger" data-a="delete">Delete</button><button class="btn ghost" data-a="archive">Archive</button><span class="dec"><button class="btn" data-a="reject">Reject</button><button class="btn" data-a="edit">Edit</button><button class="btn pri" data-a="run">Run</button></span>`
48
+ : `<span class="note">${c.pty ? "Starting: the agent runs below." : "Starting: a run was launched for this idea. No terminal with your agent? Run it again."}</span><span class="dec"><button class="btn" data-a="run">Run again</button></span>`;
49
+ foot.querySelectorAll("[data-a]").forEach(
50
+ (b) =>
51
+ (b.onclick = () => {
52
+ const a = b.dataset.a;
53
+ if (a === "archive" || a === "restore") {
54
+ toggleArchive(c, a === "archive");
55
+ return;
56
+ }
57
+ const k = key(c);
58
+ close();
59
+ store.mode = a;
60
+ store.modeKey = k;
61
+ render();
62
+ }),
63
+ );
64
+ return;
65
+ }
66
+ if (!keepTab)
67
+ store.lastTab = store.tab =
68
+ c.status === "abandoned" ? "plan" : { 1: "plan", 2: "backlog", 3: "review", 4: "preview" }[c.column] || "request";
69
+ // A run its agent is working on in this board, while planning, building or reviewing, opens on that agent.
70
+ if (!keepTab && c.pty && c.pty.alive && c.column >= 1 && c.column <= 3) store.tab = "term";
71
+ openRun(c, false);
72
+ }
73
+ // A building run's Backlog tab opens with its progress, above the deferred items. The
74
+ // first draw of a run animates nothing; later ones animate what moved, the bar included.
75
+ let lastProg = null;
76
+ function showProgress(c) {
77
+ const src = c.column === 2 && store.runDoc.progress,
78
+ prev = lastProg && lastProg.id === c.id ? lastProg : null,
79
+ h = src ? progress(src, prev && prev.st) : "";
80
+ if (!h) return;
81
+ lastProg = {
82
+ id: c.id,
83
+ st: [...src.matchAll(/^\s*-\s*(todo|doing|done):/gm)].map((m) => m[1]),
84
+ w: +/width:(\d+)%/.exec(h)[1],
85
+ };
86
+ $("pbody").innerHTML = h + (store.runDoc.backlog ? "<h3>Backlog</h3>" + md(store.runDoc.backlog, true) : "");
87
+ const bar = $("pbody").querySelector(".pbar i");
88
+ if (prev && prev.w !== lastProg.w) {
89
+ bar.style.width = prev.w + "%";
90
+ bar.offsetWidth;
91
+ bar.style.width = lastProg.w + "%";
92
+ }
93
+ }
94
+ // An idea's panel: its terminal while the run it started is being born there, else its notes.
95
+ export function ideaBody(c) {
96
+ if (c.pty) showTerm(c);
97
+ else {
98
+ termStop();
99
+ chat(c, c.notes, c.archived || c.status !== "idea");
100
+ }
101
+ }
102
+ export async function openRun(c, refresh, again) {
103
+ if (!refresh || !store.runDoc || store.runDoc.id !== c.id || again) {
104
+ const d = await fetch(`/run/${encodeURIComponent(c.id)}?dir=${encodeURIComponent(c.state)}`).then((r) => r.json());
105
+ // A file list being confirmed, or a push under way, keeps the doc it was drawn from.
106
+ if (refresh && $("pfoot").querySelector(".inl,[data-busy],.more[open]")) return;
107
+ store.runDoc = d;
108
+ }
109
+ if (store.openKey !== key(c)) return;
110
+ // The fetch above may have finished after a newer /state: draw the card as it is now
111
+ // (a resumed agent's terminal, not the one that ended).
112
+ c = store.S.cards.find((x) => key(x) === store.openKey) || c;
113
+ // A refresh while notes are being typed would wipe the textarea.
114
+ const ta = $("pbody").querySelector(".notes textarea");
115
+ if (refresh && ta && document.activeElement === ta) return;
116
+ $("psub").textContent = c.id + " · " + (WORD[c.status] || c.status) + (c.awaiting ? " · " + need(c.awaiting) : "");
117
+ // Only tabs with something in them show. A card whose agent runs in the board gets its
118
+ // terminal behind the header's terminal button, chosen once right after a launch from here.
119
+ const tabs = TABS.filter(
120
+ (t) =>
121
+ t[1] === "request" ||
122
+ t[1] === "notes" ||
123
+ (t[1] === "preview"
124
+ ? preview(store.runDoc.impl)
125
+ : store.runDoc[t[1]] || (t[1] === "backlog" && c.column === 2 && store.runDoc.progress)),
126
+ );
127
+ if (c.pty && store.wantTerm === key(c)) {
128
+ store.wantTerm = null;
129
+ store.tab = "term";
130
+ }
131
+ if (store.tab === "term" && !c.pty) store.tab = store.lastTab;
132
+ // A tab not written yet falls back to the latest one that is.
133
+ if (store.tab !== "term" && !tabs.some((t) => t[1] === store.tab))
134
+ store.tab = [...tabs].reverse().find((t) => t[1] !== "notes")[1];
135
+ if (store.tab !== "term") store.lastTab = store.tab;
136
+ $("panel").classList.toggle("wide", store.tab === "term");
137
+ const pt = $("pterm");
138
+ pt.hidden = !c.pty;
139
+ pt.classList.toggle("on", store.tab === "term");
140
+ pt.setAttribute("aria-pressed", store.tab === "term");
141
+ pt.onclick = () => {
142
+ store.tab = store.tab === "term" ? store.lastTab : "term";
143
+ openRun(c, true);
144
+ };
145
+ $("ptabs").innerHTML = tabs
146
+ .map((t) => `<button class="${t[1] === store.tab ? "on" : ""}" data-k="${t[1]}">${t[0]}</button>`)
147
+ .join("");
148
+ [...$("ptabs").children].forEach(
149
+ (b) =>
150
+ (b.onclick = () => {
151
+ store.tab = b.dataset.k;
152
+ openRun(c, true);
153
+ }),
154
+ );
155
+ const k = store.tab,
156
+ doc = k === "preview" ? preview(store.runDoc.impl) && attach(preview(store.runDoc.impl), c) : store.runDoc[k];
157
+ if (k !== "term") termStop();
158
+ if (k === "term") showTerm(c);
159
+ else if (k === "notes") {
160
+ const ro = c.archived || c.status === "done" || c.status === "abandoned";
161
+ chat(c, doc, ro);
162
+ } else
163
+ $("pbody").innerHTML = doc
164
+ ? md(doc, k === "backlog")
165
+ : `<p style="color:var(--dim)">No ${{ request: "request", plan: "plan", impl: "work report", preview: "preview", review: "review", backlog: "backlog" }[k]} yet.</p>`;
166
+ if (k === "backlog") showProgress(c);
167
+ if (k === "review") fixForm(c);
168
+ // The notebook's notes that link here, each opening that note: above the run's own notes.
169
+ if (k === "notes" && store.runDoc.linkedFrom && store.runDoc.linkedFrom.length)
170
+ $("pbody").insertAdjacentHTML(
171
+ "afterbegin",
172
+ `<h3>Linked from notes</h3><ul class="lnk">${store.runDoc.linkedFrom.map((n) => `<li><a href="/notebook?dir=${encodeURIComponent(c.state)}&amp;note=${encodeURIComponent(n)}">${esc(n)}</a></li>`).join("")}</ul>`,
173
+ );
174
+ // Only the panel the link asked for: another card opened meanwhile drops the mark.
175
+ if (hit && !refresh) {
176
+ if (hit.k === key(c)) mark(hit);
177
+ else hit = null;
178
+ }
179
+ const foot = $("pfoot");
180
+ // A refresh while the commit is being confirmed would wipe the message.
181
+ if (refresh && foot.querySelector(".inl,[data-busy],.more[open]")) return;
182
+ const arch =
183
+ gitBtn(c) +
184
+ (c.archived
185
+ ? `<button class="btn pri" data-a="restore">Restore</button>`
186
+ : `<button class="btn ghost" data-a="archive">Archive</button>`);
187
+ // A plan that asks questions takes their answers with the approval.
188
+ // One the board cannot read is said, so it is answered in the terminal instead of skipped.
189
+ const pq = c.status === "waiting" && c.awaiting === "approvals.plan" ? planQuestions(store.runDoc.plan) : [],
190
+ qs = pq || [];
191
+ // Its in-card agent ended on a waiting or done run: reopen that conversation, nothing sent.
192
+ const rs = c.pty && !c.pty.alive ? `<button class="btn" data-a="resume">Resume session</button>` : "";
193
+ if (c.archived) foot.innerHTML = arch;
194
+ else if ((c.status === "waiting" || c.awaiting === "findings") && APPROVAL[c.awaiting]) {
195
+ const sent = decided.has(answer(c)),
196
+ asks = !pq
197
+ ? " It has open questions the board cannot show: answer them in the terminal running this run, or approve and your agent asks them."
198
+ : qs.length
199
+ ? ` It asks ${qs.length === 1 ? "one question" : qs.length + " questions"}: Approve opens ${qs.length === 1 ? "it" : "them"}.`
200
+ : "";
201
+ // gitBtn's buttons restyled as menu rows.
202
+ const more =
203
+ gitBtn(c).replace(/class="btn( pri)?"/g, "") +
204
+ `<button data-a="archive">Archive</button><hr><button data-a="ab" class="danger">Abandon</button>`;
205
+ foot.innerHTML = `<span class="note">${sent ? "Sent to your agent." : APPROVAL[c.awaiting] + asks + " Answering here opens a new agent session."}</span><details class="more"><summary class="btn ghost">More</summary><div class="menu-pop">${more}</div></details>${rs}<span class="dec"><button class="btn" data-a="revise"${sent ? " disabled" : ""}>Revise</button><button class="btn pri" data-a="approve"${sent ? " disabled" : ""}>${c.awaiting === "findings" ? "Approve as-is" : "Approve"}</button></span>`;
206
+ } else if (c.status === "waiting")
207
+ foot.innerHTML = `<span class="note">Waiting on you: ${esc(need(c.awaiting))}. Answer in the ${c.pty && c.pty.alive ? "terminal (button at the top)" : "terminal running this run"}.</span>${arch}<button class="btn ghost" data-a="ab">Abandon</button>${rs}`;
208
+ else if (c.status === "done") foot.innerHTML = arch + rs;
209
+ else if (c.status === "abandoned") foot.innerHTML = arch;
210
+ else
211
+ foot.innerHTML = `<span class="note">${c.pty && !c.pty.alive ? "Its agent ended before the stage finished. Resume continues it here." : c.status === "stalled" ? "No progress for a while. Resume it, or abandon it." : c.pty ? "" : "Running in your agent."}</span>${arch}<button class="btn ghost" data-a="ab">Abandon run</button>${resumable(c) ? `<button class="btn pri" data-a="resume">Resume</button>` : ""}`;
212
+ foot.querySelectorAll("[data-a]").forEach(
213
+ (b) =>
214
+ (b.onclick = () => {
215
+ const a = b.dataset.a;
216
+ if (a === "archive" || a === "restore") {
217
+ toggleArchive(c, a === "archive");
218
+ return;
219
+ }
220
+ if (a === "push") {
221
+ commitPush(c, "", b);
222
+ return;
223
+ }
224
+ if (a === "commit") {
225
+ confirmCommit(c);
226
+ return;
227
+ }
228
+ if (a === "approve") {
229
+ if (qs.length) confirmAnswers(c, qs);
230
+ else decide(c, b);
231
+ return;
232
+ }
233
+ if (a === "revise") {
234
+ confirmRevise(c);
235
+ return;
236
+ }
237
+ if (a === "resume") {
238
+ resume(c, b);
239
+ return;
240
+ }
241
+ close();
242
+ store.mode = "reject";
243
+ store.modeKey = key(c);
244
+ render();
245
+ }),
246
+ );
247
+ }
248
+ // A link from a note (?run= or ?idea=, with dir, tab, item) opens that card's panel;
249
+ // a finding or backlog item named is scrolled to and marked. Found by its text, since
250
+ // the rendered list does not carry the numbers.
251
+ let hit = null;
252
+ export function deep() {
253
+ const p = new URLSearchParams(location.search),
254
+ d = p.get("dir"),
255
+ id = p.get("run"),
256
+ idea = p.get("idea");
257
+ if (!id && idea === null) return;
258
+ history.replaceState(null, "", location.pathname + "?all=1");
259
+ const c = store.S.cards.find(
260
+ (x) => x.state === d && (id ? x.kind === "run" && x.id === id : x.kind === "idea" && x.text === idea),
261
+ );
262
+ if (!c) {
263
+ toast(id ? "That run is not on this board" : "That idea is not on this board");
264
+ return;
265
+ }
266
+ const m = /^(finding|backlog)-(\d+)$/.exec(p.get("item") || "");
267
+ // An item's tab is its own when the link does not name one.
268
+ const n = p.get("tab") || (m ? (m[1] === "finding" ? "review" : "backlog") : ""),
269
+ t = TABS.find((x) => x[1] === (n === "work" ? "impl" : n));
270
+ if (t) store.tab = t[1];
271
+ hit = m ? { type: m[1], n: +m[2], k: key(c) } : null;
272
+ open(c, !!t);
273
+ }
274
+ // What md() leaves of a line as text: no backticks or stars, a link reduced to its words.
275
+ function mark(h) {
276
+ hit = null;
277
+ const flat = (t) =>
278
+ String(t)
279
+ .replace(/!?\[([^\]]*)\]\([^)\s]+\)/g, "$1")
280
+ .replace(/[`*]/g, "")
281
+ .replace(/\s+/g, " ")
282
+ .trim();
283
+ const b = [...(store.runDoc.backlog || "").matchAll(/^\s*-\s*(?:open|done|rejected):\s*(.*)$/gm)];
284
+ const text = h.type === "finding" ? (store.runDoc.findings || []).find((f) => f.n === h.n)?.text : b[h.n - 1]?.[1];
285
+ if (!text) {
286
+ toast("That item is not in this run");
287
+ return;
288
+ }
289
+ const want = flat(text).slice(0, 60),
290
+ el = [...$("pbody").querySelectorAll("li,p")].find(
291
+ (e) => !e.closest(".prog") && flat(e.textContent).includes(want),
292
+ );
293
+ if (!el) return;
294
+ el.scrollIntoView({ block: "center" });
295
+ el.classList.add("hit");
296
+ setTimeout(() => el.classList.remove("hit"), 2400);
297
+ }
298
+ export async function toggleArchive(c, archived) {
299
+ const r = await post(
300
+ "/archive",
301
+ c.kind === "idea" ? { dir: c.state, text: c.text, archived } : { dir: c.state, id: c.id, archived },
302
+ );
303
+ if (r.ok) toast(archived ? "Archived" : "Restored");
304
+ close();
305
+ load();
306
+ }
307
+ export function close() {
308
+ if (view !== "board") return;
309
+ const k = store.openKey;
310
+ stopDictation(true);
311
+ termStop();
312
+ store.openKey = null;
313
+ store.runDoc = null;
314
+ $("panel").classList.remove("open");
315
+ $("scrim").classList.remove("on");
316
+ render();
317
+ if (k) cardEl(k)?.focus({ preventScroll: true });
318
+ }
319
+
320
+ export function initPanel() {
321
+ // Answering an approval from the panel opens your agent on the run, told the decision and,
322
+ // for a revise, the comment. Answered approvals are remembered outside the footer, which
323
+ // every refresh rebuilds, so a second press cannot open a second agent. Keyed by when the
324
+ // run last moved: the same approval reached again after a revise is a new question.
325
+ // The footer's More menu is a <details>: close it on a click anywhere else.
326
+ document.addEventListener("click", (e) => {
327
+ for (const d of document.querySelectorAll(".more[open]")) if (!d.contains(e.target)) d.open = false;
328
+ });
329
+ $("pclose").onclick = close;
330
+ $("scrim").onclick = close;
331
+ }
@@ -0,0 +1,220 @@
1
+ import { store } from "./store.ts";
2
+ import { load, post } from "./api.ts";
3
+ import { render } from "./cards.ts";
4
+ import { $, esc } from "./dom.ts";
5
+ import { openSources } from "./icebox.ts";
6
+ import { toast } from "./toast.ts";
7
+ import { view } from "./views.ts";
8
+
9
+ // The picked project outlives a reload. Saved only where the user changes it, so a
10
+ // second tab refreshing cannot undo it; a view the URL asked for is a one-off.
11
+ export const urlAll = new URLSearchParams(location.search).has("all");
12
+ export function keep(d) {
13
+ if (urlAll) return;
14
+ try {
15
+ d ? localStorage.setItem("kz-proj", d) : localStorage.removeItem("kz-proj");
16
+ } catch (e) {}
17
+ }
18
+ // A project shows by its folder name alone; the full path stays in the tooltip.
19
+ export const short = (l) => l.split(/[\\/]/).pop() || l;
20
+ // The project picker: a button shaped like a select that opens a search field over the list.
21
+ export function list() {
22
+ const q = $("projq").value.trim().toLowerCase();
23
+ let g = 0;
24
+ $("projs").innerHTML =
25
+ store.PL.map((p, i) =>
26
+ p[0].toLowerCase().includes(q)
27
+ ? (p[2] && !g++ ? `<div class="grp">In your projects folder</div>` : "") +
28
+ `<button role="option" data-i="${i}" title="${esc(p[0])}">${esc(short(p[0]))}</button>`
29
+ : "",
30
+ ).join("") || `<div class="grp">No match</div>`;
31
+ const f = $("projs").querySelector("button");
32
+ if (f) f.classList.add("on");
33
+ }
34
+ function pop(on) {
35
+ $("projpop").hidden = !on;
36
+ $("proj").setAttribute("aria-expanded", on);
37
+ if (on) {
38
+ $("projq").value = "";
39
+ list();
40
+ $("projq").focus();
41
+ }
42
+ }
43
+ async function pick(v) {
44
+ if (v === "__find") {
45
+ toast("Searching…");
46
+ const j = await post("/find", {});
47
+ if (j.ok) toast(j.added ? `Found ${j.added} new project${j.added === 1 ? "" : "s"}` : "No new projects found");
48
+ } else if (v === "all") {
49
+ store.all = true;
50
+ keep();
51
+ } else {
52
+ const j = await post("/cd", { dir: v });
53
+ if (j.ok) {
54
+ store.all = false;
55
+ keep(v);
56
+ }
57
+ }
58
+ await load();
59
+ if (view === "sources") openSources();
60
+ }
61
+ // Known projects, plus any folder a card comes from that is not one (the global inbox).
62
+ // New idea in All projects: the projects being shown, else every one.
63
+ export let newDir = null;
64
+ // Same combo as the header's project picker: a button, then a searchable list.
65
+ export function ideaCombo(f) {
66
+ const ps = ideaDirs(),
67
+ btn = f.querySelector("#newdir"),
68
+ pp = f.querySelector("#ndc .menu-pop"),
69
+ q = pp.querySelector("input"),
70
+ box = f.querySelector("#nds");
71
+ if (!ps.some((p) => p.dir === newDir)) newDir = ps[0]?.dir ?? null;
72
+ const name = () => {
73
+ btn.textContent = (ps.find((p) => p.dir === newDir) || { label: "Choose project" }).label;
74
+ };
75
+ const draw = () => {
76
+ const t = q.value.trim().toLowerCase();
77
+ box.innerHTML =
78
+ ps
79
+ .filter((p) => p.label.toLowerCase().includes(t))
80
+ .map(
81
+ (p) =>
82
+ `<button type="button" role="option" data-d="${esc(p.dir)}" title="${esc(p.label)}" class="${p.dir === newDir ? "on" : ""}">${esc(p.label)}</button>`,
83
+ )
84
+ .join("") || `<div class="grp">No match</div>`;
85
+ box.querySelectorAll("button").forEach(
86
+ (b) =>
87
+ (b.onclick = () => {
88
+ newDir = b.dataset.d;
89
+ name();
90
+ open(false);
91
+ f.querySelector("#newidea").focus();
92
+ }),
93
+ );
94
+ };
95
+ const open = (on) => {
96
+ pp.hidden = !on;
97
+ btn.setAttribute("aria-expanded", on);
98
+ if (on) {
99
+ q.value = "";
100
+ draw();
101
+ q.focus();
102
+ }
103
+ };
104
+ btn.onclick = (e) => {
105
+ e.preventDefault();
106
+ open(pp.hidden);
107
+ };
108
+ q.oninput = draw;
109
+ q.onkeydown = (e) => {
110
+ if (e.key === "Escape") {
111
+ e.stopPropagation();
112
+ open(false);
113
+ }
114
+ if (e.key === "Enter") {
115
+ e.preventDefault();
116
+ box.querySelector("button")?.click();
117
+ }
118
+ };
119
+ name();
120
+ }
121
+ function ideaDirs() {
122
+ const sp = showable(),
123
+ pick = sp.filter((p) => store.shown.has(p.dir));
124
+ return pick.length ? pick : sp;
125
+ }
126
+ export function showable() {
127
+ const l = [...store.S.projects];
128
+ for (const c of store.S.cards) if (!l.some((p) => p.dir === c.state)) l.push({ dir: c.state, label: c.where });
129
+ return l;
130
+ }
131
+ let SP = [];
132
+ export function showList() {
133
+ const q = $("showq").value.trim().toLowerCase();
134
+ SP = showable();
135
+ $("shows").innerHTML =
136
+ SP.map((p, i) =>
137
+ p.label.toLowerCase().includes(q)
138
+ ? `<label title="${esc(p.label)}"><input type="checkbox" data-i="${i}" ${store.shown.has(p.dir) ? "checked" : ""}>${esc(p.label)}</label>`
139
+ : "",
140
+ ).join("") || `<div class="grp">No match</div>`;
141
+ }
142
+ export function setShown() {
143
+ try {
144
+ localStorage.setItem("kz-show", JSON.stringify([...store.shown]));
145
+ } catch (e) {}
146
+ render();
147
+ }
148
+
149
+ export function initProjects() {
150
+ $("proj").onclick = () => pop($("projpop").hidden);
151
+ $("projq").oninput = list;
152
+ $("projq").onkeydown = (e) => {
153
+ const bs = [...$("projs").querySelectorAll("button")],
154
+ i = bs.findIndex((b) => b.classList.contains("on"));
155
+ if (e.key === "Escape") {
156
+ pop(false);
157
+ $("proj").focus();
158
+ } else if (e.key === "Enter") {
159
+ if (bs[i]) bs[i].click();
160
+ } else if ((e.key === "ArrowDown" || e.key === "ArrowUp") && bs[i]) {
161
+ e.preventDefault();
162
+ const n = bs[(i + (e.key === "ArrowDown" ? 1 : bs.length - 1)) % bs.length];
163
+ bs[i].classList.remove("on");
164
+ n.classList.add("on");
165
+ n.scrollIntoView({ block: "nearest" });
166
+ }
167
+ };
168
+ $("projs").onclick = (e) => {
169
+ const b = e.target.closest("button");
170
+ if (b) {
171
+ pop(false);
172
+ pick(store.PL[b.dataset.i][1]);
173
+ }
174
+ };
175
+ document.addEventListener("click", (e) => {
176
+ if (!e.target.closest("#combo")) pop(false);
177
+ });
178
+ $("show").onclick = () => {
179
+ const on = $("showpop").hidden;
180
+ $("showpop").hidden = !on;
181
+ $("show").setAttribute("aria-expanded", on);
182
+ if (on) {
183
+ $("showq").value = "";
184
+ showList();
185
+ $("showq").focus();
186
+ }
187
+ };
188
+ $("showq").oninput = showList;
189
+ $("showq").onkeydown = (e) => {
190
+ if (e.key === "Escape") {
191
+ $("showpop").hidden = true;
192
+ $("show").setAttribute("aria-expanded", false);
193
+ $("show").focus();
194
+ }
195
+ };
196
+ // render() redraws the list; the ticked box keeps keyboard focus.
197
+ $("shows").onchange = (e) => {
198
+ const i = e.target.dataset.i,
199
+ d = SP[i]?.dir;
200
+ if (!d) return;
201
+ e.target.checked ? store.shown.add(d) : store.shown.delete(d);
202
+ setShown();
203
+ $("shows").querySelector(`[data-i="${i}"]`)?.focus();
204
+ };
205
+ $("showall").onclick = () => {
206
+ store.shown.clear();
207
+ setShown();
208
+ };
209
+ document.addEventListener("click", (e) => {
210
+ if (!e.target.closest("#showc")) {
211
+ $("showpop").hidden = true;
212
+ $("show").setAttribute("aria-expanded", false);
213
+ }
214
+ });
215
+ $("newtop").onclick = () => {
216
+ store.mode = "new";
217
+ render();
218
+ $("board").scrollTo(0, 0);
219
+ };
220
+ }