kaizen-agent 1.42.2 → 1.43.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/agents/kaizen-reviewer.md +1 -1
  2. package/cli/backlog.test.ts +133 -0
  3. package/cli/board.test.ts +10 -4
  4. package/cli/browser.test.ts +1 -1
  5. package/cli/browser.ts +12 -7
  6. package/cli/html.test.ts +19 -0
  7. package/cli/notes.ts +2 -6
  8. package/cli/pty.ts +2 -1
  9. package/cli/settings.test.ts +38 -0
  10. package/cli/settings.ts +37 -30
  11. package/cli/state.test.ts +86 -3
  12. package/cli/state.ts +245 -45
  13. package/cli/web.ts +67 -16
  14. package/package.json +1 -1
  15. package/skills/kaizen/README.md +0 -4
  16. package/skills/kaizen/SKILL.md +4 -1
  17. package/skills/kaizen/config.default.yml +1 -50
  18. package/skills/kaizen/docs.md +3 -2
  19. package/skills/kaizen/settings.md +10 -3
  20. package/skills/kaizen/spec-build.md +3 -2
  21. package/skills/kaizen/spec-main.md +40 -5
  22. package/skills/kaizen/spec-review.md +2 -1
  23. package/skills/kaizen-help/SKILL.md +0 -4
  24. package/web/board/agent-term.ts +1 -1
  25. package/web/board/api.ts +2 -0
  26. package/web/board/approvals.ts +3 -3
  27. package/web/board/cards.ts +284 -108
  28. package/web/board/chat.ts +4 -4
  29. package/web/board/dom.ts +17 -5
  30. package/web/board/fixes.ts +6 -8
  31. package/web/board/git.ts +3 -3
  32. package/web/board/icebox.ts +52 -70
  33. package/web/board/keys.ts +9 -6
  34. package/web/board/live.ts +3 -2
  35. package/web/board/main.ts +1 -0
  36. package/web/board/panel.ts +22 -16
  37. package/web/board/projects.ts +11 -16
  38. package/web/board/settings.ts +60 -29
  39. package/web/board/store.ts +2 -0
  40. package/web/board/terms.ts +279 -0
  41. package/web/board/update.ts +7 -7
  42. package/web/board/views.ts +19 -5
  43. package/web/board.html +40 -2
  44. package/web/board.js +25 -24
  45. package/web/notebook.js +1 -1
  46. package/web/notes.html +2 -0
  47. package/web/term.js +1 -1
@@ -5,7 +5,7 @@ import { APPROVAL, answer, confirmAnswers, confirmRevise, decide, decided, resum
5
5
  import { cardEl, key, render } from "./cards.ts";
6
6
  import { chat } from "./chat.ts";
7
7
  import { stopDictation } from "./dictation.ts";
8
- import { $, esc } from "./dom.ts";
8
+ import { $, html, raw } from "./dom.ts";
9
9
  import { fixForm, fixOff } from "./fixes.ts";
10
10
  import { commitPush, confirmCommit, gitBtn } from "./git.ts";
11
11
  import { ICON, WORD, need } from "./labels.ts";
@@ -51,7 +51,7 @@ export function open(c, keepTab) {
51
51
  $("pterm").hidden = true;
52
52
  if (c.kind === "idea") {
53
53
  store.runDoc = null;
54
- $("psub").innerHTML = `<span class="pst"><span class="st d-idea"><svg><use href="#s-idea"/></svg></span>Idea</span><span class="pst dim">${esc(c.where.split(/[\\/]/).pop() || c.where)}</span>`;
54
+ $("psub").innerHTML = html`<span class="pst"><span class="st d-idea"><svg><use href="#s-idea"/></svg></span>Idea</span><span class="pst dim">${c.where.split(/[\\/]/).pop() || c.where}</span>`;
55
55
  $("ptabs").innerHTML = "";
56
56
  ideaBody(c);
57
57
  const foot = $("pfoot");
@@ -97,7 +97,7 @@ function showProgress(c) {
97
97
  st: [...src.matchAll(/^\s*-\s*(todo|doing|done):/gm)].map((m) => m[1]),
98
98
  w: +/width:(\d+)%/.exec(h)[1],
99
99
  };
100
- $("pbody").innerHTML = h + (store.runDoc.backlog ? "<h3>Backlog</h3>" + md(store.runDoc.backlog, true) : "");
100
+ $("pbody").innerHTML = html`${raw(h)}${store.runDoc.backlog ? html`<h3>Backlog</h3>${raw(md(store.runDoc.backlog, true))}` : ""}`;
101
101
  const bar = $("pbody").querySelector(".pbar i");
102
102
  if (prev && prev.w !== lastProg.w) {
103
103
  bar.style.width = prev.w + "%";
@@ -129,9 +129,9 @@ export async function openRun(c, refresh, again) {
129
129
  if (refresh && ta && document.activeElement === ta) return;
130
130
  // The board's own status mark, then what it waits on: chips, not a dotted line.
131
131
  $("psub").innerHTML =
132
- `<span class="pst"><span class="st d-${ICON[c.status] || "idea"}"><svg><use href="#s-${ICON[c.status] || "idea"}"/></svg></span>${esc(cap(WORD[c.status] || c.status))}</span>` +
133
- (c.awaiting ? `<span class="pst dim">${esc(cap(need(c.awaiting)))}</span>` : "") +
134
- (c.parent ? `<span class="pst link" title="Filed by this run">${esc(c.parent)}</span>` : "");
132
+ html`<span class="pst"><span class="st d-${ICON[c.status] || "idea"}"><svg><use href="#s-${ICON[c.status] || "idea"}"/></svg></span>${cap(WORD[c.status] || c.status)}</span>` +
133
+ (c.awaiting ? html`<span class="pst dim">${cap(need(c.awaiting))}</span>` : "") +
134
+ (c.parent ? html`<span class="pst link" title="Filed by this run">${c.parent}</span>` : "");
135
135
  // Only tabs with something in them show. A card whose agent runs in the board gets its
136
136
  // terminal behind the header's terminal button, chosen once right after a launch from here.
137
137
  const tabs = TABS.filter(
@@ -162,7 +162,7 @@ export async function openRun(c, refresh, again) {
162
162
  openRun(c, true);
163
163
  };
164
164
  $("ptabs").innerHTML = tabs
165
- .map((t) => `<button class="${t[1] === store.tab ? "on" : ""}" data-k="${t[1]}">${t[0]}</button>`)
165
+ .map((t) => html`<button class="${t[1] === store.tab ? "on" : ""}" data-k="${t[1]}">${t[0]}</button>`)
166
166
  .join("");
167
167
  [...$("ptabs").children].forEach(
168
168
  (b) =>
@@ -180,15 +180,15 @@ export async function openRun(c, refresh, again) {
180
180
  chat(c, doc, ro);
181
181
  } else
182
182
  $("pbody").innerHTML = doc
183
- ? md(doc, k === "backlog")
184
- : `<p style="color:var(--dim)">No ${{ request: "request", plan: "plan", impl: "work report", preview: "preview", review: "review", backlog: "backlog" }[k]} yet.</p>`;
183
+ ? raw(md(doc, k === "backlog"))
184
+ : html`<p style="color:var(--dim)">No ${{ request: "request", plan: "plan", impl: "work report", preview: "preview", review: "review", backlog: "backlog" }[k]} yet.</p>`;
185
185
  if (k === "backlog") showProgress(c);
186
186
  if (k === "review") fixForm(c);
187
187
  // The notebook's notes that link here, each opening that note: above the run's own notes.
188
188
  if (k === "notes" && store.runDoc.linkedFrom && store.runDoc.linkedFrom.length)
189
189
  $("pbody").insertAdjacentHTML(
190
190
  "afterbegin",
191
- `<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>`,
191
+ html`<h3>Linked from notes</h3><ul class="lnk">${store.runDoc.linkedFrom.map((n) => html`<li><a href="/notebook?dir=${encodeURIComponent(c.state)}&amp;note=${encodeURIComponent(n)}">${n}</a></li>`)}</ul>`,
192
192
  );
193
193
  // Only the panel the link asked for: another card opened meanwhile drops the mark.
194
194
  if (hit && !refresh) {
@@ -209,7 +209,7 @@ export async function openRun(c, refresh, again) {
209
209
  qs = pq || [];
210
210
  // Its in-card agent ended on a waiting or done run: reopen that conversation, nothing sent.
211
211
  const rs = c.pty && !c.pty.alive ? `<button class="btn" data-a="resume">Resume session</button>` : "";
212
- if (c.archived) foot.innerHTML = arch;
212
+ if (c.archived) foot.innerHTML = raw(arch);
213
213
  else if ((c.status === "waiting" || c.awaiting === "findings") && APPROVAL[c.awaiting]) {
214
214
  const sent = decided.has(answer(c)),
215
215
  asks = !pq
@@ -221,13 +221,13 @@ export async function openRun(c, refresh, again) {
221
221
  const more =
222
222
  gitBtn(c).replace(/class="btn( pri)?"/g, "") +
223
223
  `<button data-a="archive">Archive</button><hr><button data-a="ab" class="danger">Abandon</button>`;
224
- 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>`;
224
+ foot.innerHTML = html`<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">${raw(more)}</div></details>${raw(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>`;
225
225
  } else if (c.status === "waiting")
226
- 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}`;
227
- else if (c.status === "done") foot.innerHTML = arch + rs;
228
- else if (c.status === "abandoned") foot.innerHTML = arch;
226
+ foot.innerHTML = html`<span class="note">Waiting on you: ${need(c.awaiting)}. Answer in the ${c.pty && c.pty.alive ? "terminal (button at the top)" : "terminal running this run"}.</span>${raw(arch)}<button class="btn ghost" data-a="ab">Abandon</button>${raw(rs)}`;
227
+ else if (c.status === "done") foot.innerHTML = raw(arch + rs);
228
+ else if (c.status === "abandoned") foot.innerHTML = raw(arch);
229
229
  else
230
- 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>` : ""}`;
230
+ foot.innerHTML = html`<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>${raw(arch)}<button class="btn ghost" data-a="ab">Abandon run</button>${resumable(c) ? html`<button class="btn pri" data-a="resume">Resume</button>` : ""}`;
231
231
  foot.querySelectorAll("[data-a]").forEach(
232
232
  (b) =>
233
233
  (b.onclick = () => {
@@ -290,6 +290,12 @@ export function deep() {
290
290
  hit = m ? { type: m[1], n: +m[2], k: key(c) } : null;
291
291
  open(c, !!t);
292
292
  }
293
+ // A run's panel on the tab of one of its items, that item scrolled to and marked.
294
+ export function openAt(c, type, n) {
295
+ store.tab = type === "finding" ? "review" : "backlog";
296
+ hit = { type, n, k: key(c) };
297
+ open(c, true);
298
+ }
293
299
  // What md() leaves of a line as text: no backticks or stars, a link reduced to its words.
294
300
  function mark(h) {
295
301
  hit = null;
@@ -1,7 +1,7 @@
1
1
  import { store } from "./store.ts";
2
2
  import { load, post } from "./api.ts";
3
- import { render } from "./cards.ts";
4
- import { $, esc, ico, modal } from "./dom.ts";
3
+ import { newIdea, render } from "./cards.ts";
4
+ import { $, html, ico, modal } from "./dom.ts";
5
5
  import { openSources, srcOpen } from "./icebox.ts";
6
6
  import { toast } from "./toast.ts";
7
7
  import { view } from "./views.ts";
@@ -20,13 +20,13 @@ export const short = (l) => l.split(/[\\/]/).pop() || l;
20
20
  // A project's mark in every picker: its own logo when /state found one, else its initial.
21
21
  // A logo the browser cannot draw falls back to the initial too.
22
22
  export const plogo = (dir, label = "") => {
23
- const ini = `<span class="plogo" aria-hidden="true">${esc((short(label) || "?").charAt(0).toUpperCase())}</span>`;
23
+ const ini = html`<span class="plogo" aria-hidden="true">${(short(label) || "?").charAt(0).toUpperCase()}</span>`;
24
24
  return store.S?.projects?.some((p) => p.dir === dir && p.logo)
25
- ? `<img class="plogo" src="/project-logo?dir=${encodeURIComponent(dir)}" alt="" loading="lazy" onerror="this.outerHTML=this.dataset.f" data-f="${esc(ini)}">`
25
+ ? html`<img class="plogo" src="/project-logo?dir=${encodeURIComponent(dir)}" alt="" loading="lazy" onerror="this.outerHTML=this.dataset.f" data-f="${String(ini)}">`
26
26
  : ini;
27
27
  };
28
28
  // A picker row: the mark, then the folder name; the full path in the tooltip.
29
- const prow = (dir, label) => `${plogo(dir, label)}<span>${esc(short(label))}</span>`;
29
+ const prow = (dir, label) => html`${plogo(dir, label)}<span>${short(label)}</span>`;
30
30
  // The project picker: a button shaped like a select that opens a search field over the list.
31
31
  export function list() {
32
32
  const q = $("projq").value.trim().toLowerCase();
@@ -34,8 +34,7 @@ export function list() {
34
34
  $("projs").innerHTML =
35
35
  store.PL.map((p, i) =>
36
36
  p[0].toLowerCase().includes(q)
37
- ? (p[2] && !g++ ? `<div class="grp">In your projects folder</div>` : "") +
38
- `<button role="option" data-i="${i}" title="${esc(p[0])}">${p[1] === "all" ? `<span class="plogo" aria-hidden="true">${ico("board")}</span><span>${esc(p[0])}</span>` : p[1] === "__new" || p[1] === "__find" || p[1] === "__clone" ? `<span class="plogo" aria-hidden="true">${ico("plus")}</span><span>${esc(p[0])}</span>` : prow(p[1], p[0])}</button>`
37
+ ? html`${p[2] && !g++ ? html`<div class="grp">In your projects folder</div>` : ""}<button role="option" data-i="${i}" title="${p[0]}">${p[1] === "all" ? html`<span class="plogo" aria-hidden="true">${ico("board")}</span><span>${p[0]}</span>` : p[1] === "__new" || p[1] === "__find" || p[1] === "__clone" ? html`<span class="plogo" aria-hidden="true">${ico("plus")}</span><span>${p[0]}</span>` : prow(p[1], p[0])}</button>`
39
38
  : "",
40
39
  ).join("") || `<div class="grp">No match</div>`;
41
40
  const f = $("projs").querySelector("button");
@@ -58,7 +57,7 @@ async function pick(v) {
58
57
  } else if (v === "__new") {
59
58
  // A name, and where it goes: the projects folder unless typed or picked.
60
59
  const dl = modal(
61
- `<b>New project</b><input name="name" placeholder="Project name" aria-label="Project name" spellcheck="false" autocomplete="off"><div class="row"><input name="to" placeholder="Create in (a new folder is made)" aria-label="Create in" spellcheck="false" autocomplete="off"><button type="button" class="btn ghost" data-a="pick">Pick…</button></div><div class="row"><button type="button" class="btn ghost" data-a="x">Cancel</button><button class="btn pri">Create</button></div>`,
60
+ `<b>New project</b><input name="name" placeholder="Project name" aria-label="Project name" spellcheck="false" autocomplete="off"><div class="row"><input name="to" placeholder="Folder to create" aria-label="Create in" spellcheck="false" autocomplete="off"><button type="button" class="btn ghost" data-a="pick">Pick…</button></div><div class="row"><button type="button" class="btn ghost" data-a="x">Cancel</button><button class="btn pri">Create</button></div>`,
62
61
  );
63
62
  const f = dl.querySelector("form"),
64
63
  name = f.querySelector("[name=name]"),
@@ -97,7 +96,7 @@ async function pick(v) {
97
96
  } else if (v === "__clone") {
98
97
  // One form: the link, then where it goes. The folder fills in once the link names a repo.
99
98
  const dl = modal(
100
- `<b>Clone from GitHub</b><input name="url" placeholder="https://github.com/owner/repo or git@github.com:owner/repo" aria-label="GitHub link (https or ssh)" spellcheck="false" autocomplete="off"><div class="row"><input name="to" placeholder="Clone into (a new folder is made)" aria-label="Clone into" spellcheck="false" autocomplete="off"><button type="button" class="btn ghost" data-a="pick">Pick…</button></div><div class="row"><button type="button" class="btn ghost" data-a="x">Cancel</button><button class="btn pri">Clone</button></div>`,
99
+ `<b>Clone from GitHub</b><input name="url" placeholder="https://github.com/owner/repo" aria-label="GitHub link (https or ssh)" spellcheck="false" autocomplete="off"><div class="row"><input name="to" placeholder="Folder to clone into" aria-label="Clone into" spellcheck="false" autocomplete="off"><button type="button" class="btn ghost" data-a="pick">Pick…</button></div><div class="row"><button type="button" class="btn ghost" data-a="x">Cancel</button><button class="btn pri">Clone</button></div>`,
101
100
  );
102
101
  const f = dl.querySelector("form"),
103
102
  url = f.querySelector("[name=url]"),
@@ -175,7 +174,7 @@ export function ideaCombo(f) {
175
174
  .filter((p) => p.label.toLowerCase().includes(t))
176
175
  .map(
177
176
  (p) =>
178
- `<button type="button" role="option" data-d="${esc(p.dir)}" title="${esc(p.label)}" class="${p.dir === newDir ? "on" : ""}">${prow(p.dir, p.label)}</button>`,
177
+ html`<button type="button" role="option" data-d="${p.dir}" title="${p.label}" class="${p.dir === newDir ? "on" : ""}">${prow(p.dir, p.label)}</button>`,
179
178
  )
180
179
  .join("") || `<div class="grp">No match</div>`;
181
180
  box.querySelectorAll("button").forEach(
@@ -231,7 +230,7 @@ export function showList() {
231
230
  $("shows").innerHTML =
232
231
  SP.map((p, i) =>
233
232
  p.label.toLowerCase().includes(q)
234
- ? `<button type="button" role="menuitemcheckbox" data-i="${i}" aria-checked="${store.shown.has(p.dir)}" title="${esc(p.label)}">${prow(p.dir, p.label)}<svg class="ck"><use href="#i-check"/></svg></button>`
233
+ ? html`<button type="button" role="menuitemcheckbox" data-i="${i}" aria-checked="${String(store.shown.has(p.dir))}" title="${p.label}">${prow(p.dir, p.label)}<svg class="ck"><use href="#i-check"/></svg></button>`
235
234
  : "",
236
235
  ).join("") || `<div class="grp">No match</div>`;
237
236
  const n = SP.filter((p) => store.shown.has(p.dir)).length;
@@ -313,9 +312,5 @@ export function initProjects() {
313
312
  $("show").setAttribute("aria-expanded", false);
314
313
  }
315
314
  });
316
- $("newtop").onclick = () => {
317
- store.mode = "new";
318
- render();
319
- $("board").scrollTo(0, 0);
320
- };
315
+ $("newtop").onclick = newIdea;
321
316
  }
@@ -2,7 +2,7 @@ import { store } from "./store.ts";
2
2
  import { termStop } from "./agent-term.ts";
3
3
  import { load, post } from "./api.ts";
4
4
  import { stopDictation } from "./dictation.ts";
5
- import { $, esc, SKEL } from "./dom.ts";
5
+ import { $, html, SKEL } from "./dom.ts";
6
6
  import { close } from "./panel.ts";
7
7
  import { askNotify, toast } from "./toast.ts";
8
8
  import { updBar } from "./update.ts";
@@ -47,59 +47,59 @@ export async function openSettings() {
47
47
  $("psub").textContent = "";
48
48
  $("psub").title = "";
49
49
  // One control per row: a switch for on/off, one segment per value for a small number,
50
- // a select for a choice. The row's label names the control; its help sits under it.
50
+ // ‹ value › for a choice, stepping and wrapping like a game's options screen. The row's
51
+ // label names the control; its help sits under it.
51
52
  const ctl = (id, vals, on) => {
52
53
  // The preset is a segmented control over the tiers; custom, which only an edit to another row makes, lights none.
53
54
  if (id === "s-preset")
54
- return `<span class="bar" id="${id}" role="group" aria-label="Preset">${vals
55
+ return html`<span class="bar" id="${id}" role="group" aria-label="Preset">${vals
55
56
  .filter((v) => v !== "custom")
56
- .map((v) => `<button id="${id}-${v}" data-v="${v}" class="${v === on ? "on" : ""}" aria-pressed="${v === on}">${v}</button>`)
57
- .join("")}</span>`;
57
+ .map((v) => html`<button id="${id}-${v}" data-v="${v}" class="${v === on ? "on" : ""}" aria-pressed="${String(v === on)}">${v}</button>`)}</span>`;
58
58
  if (vals.length === 2 && vals.includes("true") && vals.includes("false")) {
59
59
  const yes = on === "true";
60
- return `<button class="chk${yes ? " on" : ""}" id="${id}" data-v="${yes ? "false" : "true"}" role="switch" aria-checked="${yes}" title="${yes ? "On" : "Off"}"></button>`;
60
+ return html`<button class="chk${yes ? " on" : ""}" id="${id}" data-v="${yes ? "false" : "true"}" role="switch" aria-checked="${String(yes)}" title="${yes ? "On" : "Off"}"></button>`;
61
61
  }
62
62
  if (vals.every((v) => /^\d+$/.test(v)))
63
- return `<span class="bar" id="${id}" role="group">${vals.map((v) => `<button data-v="${v}" class="${v === on ? "on" : ""}" aria-pressed="${v === on}">${v}</button>`).join("")}</span>`;
64
- return `<select class="sel" id="${id}">${vals.map((v) => `<option value="${esc(v)}"${v === on ? " selected" : ""}>${v === "" ? "—" : esc(v)}</option>`).join("")}</select>`;
63
+ return html`<span class="bar" id="${id}" role="group">${vals.map((v) => html`<button data-v="${v}" class="${v === on ? "on" : ""}" aria-pressed="${String(v === on)}">${v}</button>`)}</span>`;
64
+ // Wired by stepper() below; the group takes its name from the row's label.
65
+ return html`<span class="step" id="${id}" role="group" aria-labelledby="${id}-l"><button id="${id}-prev" aria-label="Previous">‹</button><output aria-live="polite">${on === "" ? "—" : on}</output><button id="${id}-next" aria-label="Next">›</button></span>`;
65
66
  };
66
- const row = (label, control, hint, id, q) =>
67
- `<div class="srow" data-q="${esc(q.toLowerCase())}"><label for="${id}">${esc(label)}</label>${control}${hint ? `<p class="hint">${hint}</p>` : ""}</div>`;
67
+ // `off`: the row has no effect while another setting stands as it is; dimmed, still editable.
68
+ const row = (label, control, hint, id, q, off = false) =>
69
+ html`<div class="srow${off ? " off" : ""}" data-q="${q.toLowerCase()}"><label for="${id}" id="${id}-l">${label}</label>${control}${hint ? html`<p class="hint">${hint}</p>` : ""}</div>`;
68
70
  const hadFocus = document.activeElement?.id === "sq";
69
71
  const np = typeof Notification === "undefined" ? null : Notification.permission;
70
72
  const ntHelp = np ? (np === "denied" ? "Blocked by this browser" : "") : "Not supported in this browser";
71
- $("pbody").innerHTML =
72
- `<div class="sq"><input class="sel" id="sq" type="search" placeholder="Search settings" aria-label="Search settings" spellcheck="false" autocomplete="off" value="${esc(store.setQ)}"></div>
73
+ $("pbody").innerHTML = html`<div class="sq"><input class="sel" id="sq" type="search" placeholder="Search settings" aria-label="Search settings" spellcheck="false" autocomplete="off" value="${store.setQ}"></div>
73
74
  ${
74
75
  cfg
75
76
  ? groups(cfg.rows)
76
77
  .map(
77
78
  ([g, rows]) =>
78
- `<h3 class="sg">${esc(g)}</h3><div class="sgrp">${rows
79
+ html`<h3 class="sg">${g}</h3><div class="sgrp">${rows
79
80
  .map((r) => {
80
81
  const id = "s-" + r.key.replace(/\./g, "-");
81
82
  return row(
82
83
  r.label,
83
84
  r.text
84
85
  ? r.folder
85
- ? `<span class="fpick"><input class="sel" id="${id}" value="${esc(r.value)}" placeholder="not set" spellcheck="false"><button class="btn" id="${id}-pick" type="button">Browse…</button></span>`
86
- : `<input class="sel" id="${id}" value="${esc(r.value)}" placeholder="not set" spellcheck="false">`
86
+ ? html`<span class="fpick"><input class="sel" id="${id}" value="${r.value}" placeholder="not set" spellcheck="false"><button class="btn" id="${id}-pick" type="button">Browse…</button></span>`
87
+ : html`<input class="sel" id="${id}" value="${r.value}" placeholder="not set" spellcheck="false">`
87
88
  : ctl(id, r.values, r.value),
88
- esc(r.help),
89
+ [r.help, r.idle].filter(Boolean).join(" · "),
89
90
  id,
90
91
  [r.label, r.help, r.key, g, ...r.values].join(" "),
92
+ !!r.idle,
91
93
  );
92
- })
93
- .join("")}</div>`,
94
+ })}</div>`,
94
95
  )
95
- .join("")
96
- : `<p class="snone">Settings could not be read. Run <code>kaizen settings</code>.</p>`
96
+ : html`<p class="snone">Settings could not be read. Run <code>kaizen settings</code>.</p>`
97
97
  }
98
- <h3 class="sg">Google</h3><div class="sgrp">${row("OAuth client id", `<input class="sel" id="gg-id" placeholder="${gg.hasClient ? "saved; type to replace" : "from Google Cloud, Desktop app"}" spellcheck="false" autocomplete="off">`, "", "gg-id", "google oauth client id sheets write back")}
99
- ${row("OAuth client secret", `<span class="fpick"><input class="sel" id="gg-secret" type="password" placeholder="${gg.hasClient ? "saved; type to replace" : ""}" spellcheck="false" autocomplete="off"><button class="btn" id="gg-save" type="button">Save</button></span>`, "", "gg-secret", "google oauth client secret")}
100
- ${row("Google account", `<button class="btn${gg.connected ? "" : " pri"}" id="gg-conn" type="button"${gg.hasClient || gg.connected ? "" : " disabled"}>${gg.connected ? "Disconnect" : "Connect"}</button>`, "", "gg-conn", "google account connect sheets write back")}</div>
98
+ <h3 class="sg">Google</h3><div class="sgrp">${row("OAuth client id", html`<input class="sel" id="gg-id" placeholder="${gg.hasClient ? "Saved. Paste a new one to replace it" : "Desktop app client id from Google Cloud"}" spellcheck="false" autocomplete="off">`, "", "gg-id", "google oauth client id sheets write back")}
99
+ ${row("OAuth client secret", html`<span class="fpick"><input class="sel" id="gg-secret" type="password" placeholder="${gg.hasClient ? "Saved. Paste a new one to replace it" : ""}" spellcheck="false" autocomplete="off"><button class="btn" id="gg-save" type="button">Save</button></span>`, "", "gg-secret", "google oauth client secret")}
100
+ ${row("Google account", html`<button class="btn${gg.connected ? "" : " pri"}" id="gg-conn" type="button"${gg.hasClient || gg.connected ? "" : " disabled"}>${gg.connected ? "Disconnect" : "Connect"}</button>`, "", "gg-conn", "google account connect sheets write back")}</div>
101
101
  <h3 class="sg">Browser</h3><div class="sgrp">${row("Theme", ctl("th", ["system", "light", "dark"], cur), "", "th", "theme light dark system browser")}
102
- ${row("Notifications", `<button class="chk${np === "granted" ? " on" : ""}" id="nt" role="switch" aria-checked="${np === "granted"}"${np && np !== "denied" ? "" : " disabled"}></button>`, ntHelp, "nt", "notifications " + ntHelp + " browser")}</div>
102
+ ${row("Notifications", html`<button class="chk${np === "granted" ? " on" : ""}" id="nt" role="switch" aria-checked="${String(np === "granted")}"${np && np !== "denied" ? "" : " disabled"}></button>`, ntHelp, "nt", "notifications " + ntHelp + " browser")}</div>
103
103
  <p class="snone" id="snone" hidden></p>`;
104
104
  // Hides rows that do not match, then any group (and its chip) left with none.
105
105
  updBar();
@@ -158,7 +158,32 @@ export async function openSettings() {
158
158
  toast(j.error || "Could not start Google sign-in");
159
159
  }
160
160
  };
161
- $("th").onchange = (e) => setTheme(e.target.value);
161
+ // A stepper keeps its own position, so each click moves one value even before a save
162
+ // re-renders the panel; the shown value changes at once and apply() gets the new one.
163
+ // ←/→ press its arrows; focus moves to the arrow pressed.
164
+ const stepper = (el, vals, on, apply) => {
165
+ let i = Math.max(vals.indexOf(on), 0);
166
+ const step = (d) => {
167
+ i = (i + d + vals.length) % vals.length;
168
+ el.querySelector("output").textContent = vals[i] === "" ? "—" : vals[i];
169
+ apply(vals[i]);
170
+ };
171
+ $(el.id + "-prev").onclick = () => step(-1);
172
+ $(el.id + "-next").onclick = () => step(1);
173
+ el.onkeydown = (e) => {
174
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
175
+ e.preventDefault();
176
+ const b = $(el.id + (e.key === "ArrowLeft" ? "-prev" : "-next"));
177
+ b.focus();
178
+ b.click();
179
+ };
180
+ };
181
+ // Re-rendering replaces the arrows, so the one pressed gets focus back.
182
+ const refocus = async (f) => {
183
+ await openSettings();
184
+ if (f && /^s-preset-|-(prev|next)$/.test(f)) $(f)?.focus();
185
+ };
186
+ stepper($("th"), ["system", "light", "dark"], cur, setTheme);
162
187
  $("nt").onclick = () => {
163
188
  askNotify();
164
189
  setTimeout(openSettings, 300);
@@ -170,16 +195,22 @@ export async function openSettings() {
170
195
  const f = document.activeElement?.id;
171
196
  const j = await post("/settings", { key: r.key, value: v });
172
197
  if (j.ok) toast("Saved");
173
- await openSettings();
174
- if (f?.startsWith("s-preset-")) $(f)?.focus();
198
+ await refocus(f);
175
199
  };
176
- if (el.tagName === "SELECT" || el.tagName === "INPUT")
200
+ if (el.tagName === "INPUT")
177
201
  el.onchange = async () => {
178
202
  await save(el.value.trim());
179
- if (el.tagName === "INPUT") load();
203
+ load();
180
204
  };
181
205
  else el.querySelectorAll("[data-v]").forEach((b) => (b.onclick = () => save(b.dataset.v)));
182
206
  if (el.matches(".chk")) el.onclick = () => save(el.dataset.v);
207
+ // Clicks in quick succession save once, the value they settle on.
208
+ let t;
209
+ if (el.matches(".step"))
210
+ stepper(el, r.values, r.value, (v) => {
211
+ clearTimeout(t);
212
+ t = setTimeout(() => save(v), 300);
213
+ });
183
214
  // The system's own folder dialog, opened by the board: a page cannot read a real path.
184
215
  if (r.folder)
185
216
  $(el.id + "-pick").onclick = async () => {
@@ -14,6 +14,8 @@ export const store = {
14
14
  lastTab: "request",
15
15
  runDoc: null,
16
16
  clearMenu: false,
17
+ // The Ideas column's severity menu, on its Backlog tab.
18
+ sevMenu: false,
17
19
  wantTerm: null,
18
20
  // The settings search survives the re-render each save does; the gear button starts it empty.
19
21
  setQ: "",