kaizen-agent 1.43.0 → 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.
@@ -97,13 +97,48 @@ tab from staying empty; the reviewer flagging it later is a second net, not the
97
97
 
98
98
  ---
99
99
 
100
+ ## Docs gate
101
+
102
+ Before a run can be `done` — in every runner and every mode, `auto` included — bring
103
+ the project's docs vault up to date with what the run changed. Apply it once the review
104
+ is settled and before the final approval report, so the user approves the notes with
105
+ the work; where the user's answer sends findings back to be fixed, apply it again over
106
+ those fixes before setting `stage: "done"`. In a run with a `worktree`, edit the notes
107
+ in the worktree, where the vault is committed with the code. Where
108
+ `notes/Project/Index.md` does not exist in the state directory, there is no vault and
109
+ nothing to do. The builder already updates the notes it can see during the build; this
110
+ is the step that catches what it could not: fix iterations after it, and changes no
111
+ note covers.
112
+
113
+ 1. **What changed:** every path named in `03-impl.md` and in each
114
+ `05-iterations/*-fix.md`, leaving out `.kaizen/` itself.
115
+ 2. **Covered:** for each note whose `sources` include one of those paths (a folder in
116
+ `sources` covers everything under it), check the note against the changed files.
117
+ Where the change made it untrue or left something out, correct it as *Keep the
118
+ project's docs vault current* in [`spec-build.md`](spec-build.md) says — never the
119
+ `## Notes` section — set `updated` to today, and append the `Log.md` line, unless the
120
+ builder already wrote this run's line, in which case add the notes to it.
121
+ 3. **Not covered:** a changed path no note's `sources` reach. Add it to the `sources`
122
+ of the note whose area it belongs to and describe it there; where it belongs to no
123
+ area, write a note for it as step 4 of *Update* in [`docs.md`](docs.md) says and
124
+ list it in `Index.md`. A path that is not part of the project's material — a lock
125
+ file, a generated bundle — needs no note.
126
+ 4. **Lint** the notes touched, as *Lint* in [`docs.md`](docs.md) lists, and fix what
127
+ this run broke.
128
+
129
+ Name the notes touched in the final report; the commit at done includes them. A run
130
+ whose changes leave every note true touches nothing and says so in one line.
131
+
132
+ ---
133
+
100
134
  ## Final approval
101
135
 
102
136
  Controlled by `approvals.review`, skipped in `auto`.
103
137
 
104
138
  Report: what was built (for a code run, show its Preview proof), verification results, findings fixed, findings still open,
105
139
  every criterion the reviewer answered `CANNOT VERIFY` with the check it says a human
106
- must make, and anything the builder noted as out of scope. Where the run used `runner: lite`, say
140
+ must make, anything the builder noted as out of scope, and the notes the *Docs gate*
141
+ touched. Where the run used `runner: lite`, say
107
142
  so in that report — the reviewer saw the work, and nobody should read a same-session
108
143
  review as an independent one because the output looked the same.
109
144
  Later build requests in the same session follow *Follow-ups are runs too* in [`spec.md`](spec.md): a revise of this run until it is `done`, then a new linked run.
@@ -133,10 +168,10 @@ Set `stage: "done"` in `state.json` once the user accepts.
133
168
 
134
169
  **Commit at done, when `git.auto_commit` is `true`.** Setting `stage: "done"` — the
135
170
  user approving at review, or an `auto` run finishing — is then the ask to commit, under
136
- either runner. Commit only the files this run changed (the paths `03-impl.md` names,
137
- that `git status` shows changed), by path: `git add -A -- <paths>` then
138
- `git commit -m <message> -- <paths>`, never the whole tree, since other sessions edit
139
- the same checkout. The message is `03-impl.md`'s `## Commit`. In a run with a
171
+ either runner. Commit only the files this run changed (the paths `03-impl.md` names
172
+ and the notes the *Docs gate* touched, that `git status` shows changed), by path:
173
+ `git add -A -- <paths>` then `git commit -m <message> -- <paths>`, never the whole
174
+ tree, since other sessions edit the same checkout. The message is `03-impl.md`'s `## Commit`. In a run with a
140
175
  `worktree`, commit there. Then write `runs/<id>/07-commit.md` as `commit: <short sha>`
141
176
  and `pushed: no`, so the board shows it committed and offers the push. Never push. A
142
177
  failed commit is reported as printed; the run is still `done`. With the setting
@@ -125,7 +125,8 @@ and no criterion is `CANNOT VERIFY`; otherwise
125
125
  `; <n> cannot verify` when any criterion was — even when every count is 0, so an open
126
126
  human check never reads as a clean pass.
127
127
 
128
- The reviewer also appends any durable lesson about this material to `memory.md` —
128
+ Where `review.write_memory` is true (the default), the reviewer also appends any
129
+ durable lesson about this material to `memory.md` —
129
130
  a recurring bug pattern, a non-obvious constraint, a convention worth keeping. Not
130
131
  run-specific detail; only what the next run would want to know.
131
132
 
@@ -88,15 +88,11 @@ Each iteration is written to `runs/<id>/05-iterations/`.
88
88
  | `auto_fix.enabled` | `true` | Auto-fix findings vs. escalate everything |
89
89
  | `auto_fix.min_severity` | `high` | Threshold for auto-fix (`critical\|high\|medium\|low`) |
90
90
  | `auto_fix.max_iterations` | `2` | Fix/recheck rounds before escalating remaining findings |
91
- | `track.default` | `auto` (inferred per run) | Pin a track for a repo that is always one kind of work |
92
91
  | `review.write_memory` | `true` | Let the reviewer append lessons to `.kaizen/memory.md` |
93
92
  | `backlog.enabled` | `true` | Capture what a run deliberately did not do |
94
- | `backlog.file` | `06-backlog.md` | Per-run backlog file |
95
- | `backlog.orphan_file` | `backlog.md` | Where a pruned run's open items are rescued to |
96
93
  | `state.dir` | `.kaizen` | State directory |
97
94
  | `state.keep_runs` | `20` | Completed runs kept before pruning oldest (rescues open backlog first) |
98
95
  | `git.auto_commit` | `false` | `true`: commit the run's changed files when it is done; never pushes |
99
- | `git.branch_before_implement` | `true` | Branch before implement stage when on the default branch |
100
96
  | `subagents.model` | `inherit` | Model the stage subagents run on (opus/sonnet/haiku, Claude Code only) |
101
97
  | `subagents.effort` | `inherit` | Reasoning effort for the stage subagents (low/medium/high, Claude Code only) |
102
98
  | `sources.every` | `24h` | How often the web board checks request sources for new ideas (`off\|1h\|6h\|24h`) |
@@ -11,7 +11,7 @@ let termKey = null,
11
11
  termOff = null,
12
12
  termLib = null;
13
13
  export const where = (r) => (r.pty ? "running in its card" : "opened in your terminal");
14
- const termLoad = () =>
14
+ export const termLoad = () =>
15
15
  (termLib ||= new Promise((ok, no) => {
16
16
  const l = document.createElement("link");
17
17
  l.rel = "stylesheet";
package/web/board/api.ts CHANGED
@@ -3,6 +3,7 @@ import { key, render } from "./cards.ts";
3
3
  import { $ } from "./dom.ts";
4
4
  import { close, ideaBody, open, openRun } from "./panel.ts";
5
5
  import { notifyUser, toast } from "./toast.ts";
6
+ import { openTerms, termsShowing } from "./terms.ts";
6
7
 
7
8
  // The server writes notifier() from state.ts over a marker in board.html: one rule for both UIs.
8
9
  declare const due: (key: string, say: string | null, done: boolean, now: number) => boolean;
@@ -36,6 +37,7 @@ export async function load(quiet) {
36
37
  }
37
38
  // A redraw under a form being typed in would reset its fields and its focus.
38
39
  if (!(quiet && store.mode && $("board").querySelector(".inl input"))) render();
40
+ if (termsShowing()) openTerms();
39
41
  // Run on an idea closed the panel: open the card on its agent once the board knows the session.
40
42
  if (store.wantTerm && !store.openKey && !store.mode) {
41
43
  const n = store.S.cards.find((x) => key(x) === store.wantTerm && x.pty);
@@ -237,6 +237,7 @@ function card(c) {
237
237
  } else {
238
238
  items.push(["open", actionOf(c), ico("open")]);
239
239
  items.push(["archive", "Archive", ico("archive")]);
240
+ if (c.column === 1 && c.status !== "abandoned") items.push(["toidea", "Back to idea", ico("restore")]);
240
241
  if (c.status === "waiting" || c.status === "running" || c.status === "stalled") {
241
242
  items.push(null, ["reject", "Abandon", ico("x"), "danger"]);
242
243
  }
@@ -267,6 +268,12 @@ function card(c) {
267
268
  toggleArchive(c, a === "archive");
268
269
  return;
269
270
  }
271
+ if (a === "toidea") {
272
+ const r = await post("/to-idea", { dir: c.state, id: c.id });
273
+ if (r.ok) toast("Back in Ideas");
274
+ load();
275
+ return;
276
+ }
270
277
  store.mode = a;
271
278
  store.modeKey = k;
272
279
  render();
@@ -302,7 +309,7 @@ function inlineForm(c) {
302
309
  li.className = "inl";
303
310
  const dir = c.state;
304
311
  if (store.mode === "edit") {
305
- li.innerHTML = html`<input value="${c.text}"><textarea placeholder="Notes: links, paths, background">${c.notes || ""}</textarea><div class="q dim" id="clong" hidden>The first line is over 72 characters; logs will cut it short.</div><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Save</button></div>`;
312
+ li.innerHTML = html`<input value="${c.text}"><textarea placeholder="Anything the planner should know">${c.notes || ""}</textarea><div class="q dim" id="clong" hidden>The first line is over 72 characters; logs will cut it short.</div><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Save</button></div>`;
306
313
  li.querySelector("[data-a=s]").onclick = async () => {
307
314
  const next = li.querySelector("input").value.trim(),
308
315
  notes = li.querySelector("textarea").value.trim();
@@ -314,7 +321,7 @@ function inlineForm(c) {
314
321
  }
315
322
  if (store.mode === "reject") {
316
323
  const idea = c.kind !== "run";
317
- li.innerHTML = html`<div class="q">${c.kind === "backlog" ? "Not doing this backlog item?" : idea ? "Reject this idea?" : "Abandon this run?"} The reason is recorded.</div><input placeholder="Reason"><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn warn" data-a="s">${idea ? "Reject" : "Abandon"}</button></div>`;
324
+ li.innerHTML = html`<div class="q">${c.kind === "backlog" ? "Not doing this backlog item?" : idea ? "Reject this idea?" : "Abandon this run?"} The reason is recorded.</div><input placeholder="Why not?"><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn warn" data-a="s">${idea ? "Reject" : "Abandon"}</button></div>`;
318
325
  li.querySelector("[data-a=s]").onclick = async () => {
319
326
  const why = li.querySelector("input").value.trim();
320
327
  if (!why) {
@@ -450,7 +457,7 @@ async function startRun(c, kind) {
450
457
  const backlog = c.kind === "backlog" ? { source: c.source, n: c.n } : undefined;
451
458
  const r = await post("/run", { dir: c.state, text: c.text, backlog, kind, agent: p ? p.agent : undefined, yolo });
452
459
  if (r.ok) {
453
- toast((kind === "full" ? "Full" : "Lite") + (yolo ? " yolo" : "") + " run " + where(r) + ": " + via(r.agent));
460
+ toast((kind === "full" ? "Full" : kind === "lite" ? "Lite" : "Started") + (yolo ? " yolo" : "") + " run " + where(r) + ": " + via(r.agent));
454
461
  // A backlog card has no terminal of its own; the run's card gets it once the run exists.
455
462
  if (r.pty && !backlog) store.wantTerm = key(c);
456
463
  }
@@ -461,12 +468,13 @@ const HINT = { 1: "Ideas you run are planned here", 2: "Approved plans are built
461
468
 
462
469
  // The new-idea form, wired into f: inline at the top of Ideas, or in a modal.
463
470
  function ideaForm(f, close) {
464
- f.innerHTML = html`${store.all ? html`<div class="combo" id="ndc"><button type="button" class="sel" id="newdir" aria-label="Project for this idea" aria-haspopup="listbox" aria-expanded="false"></button><div class="menu-pop" hidden><input class="sel" aria-label="Search projects" placeholder="Search projects…" spellcheck="false" autocomplete="off"><div id="nds" role="listbox"></div></div></div>` : ""}<input id="newidea" placeholder="What would you like done?"><textarea id="newnotes" placeholder="Notes: links, paths, background (optional)"></textarea><div class="att" id="newatt"></div><input type="file" id="newfile" multiple hidden><div class="row"><button type="button" class="ib" id="newpick" aria-label="Attach files" title="Attach files">${ico("clip")}</button><button type="button" class="btn ghost" id="nc">Cancel</button><button type="button" class="btn pri" id="ns">Add</button></div>`;
471
+ f.innerHTML = html`${store.all ? html`<div class="combo" id="ndc"><button type="button" class="sel" id="newdir" aria-label="Project for this idea" aria-haspopup="listbox" aria-expanded="false"></button><div class="menu-pop" hidden><input class="sel" aria-label="Search projects" placeholder="Search projects…" spellcheck="false" autocomplete="off"><div id="nds" role="listbox"></div></div></div>` : ""}<input id="newidea" placeholder="What do you want to build?"><textarea id="newnotes" placeholder="Anything the planner should know (optional)"></textarea><div class="att" id="newatt"></div><input type="file" id="newfile" multiple hidden><div class="row"><button type="button" class="ib" id="newpick" aria-label="Attach files" title="Attach files">${ico("clip")}</button><button type="button" class="btn ghost" id="nc">Cancel</button><div class="split"><button type="button" class="btn pri" id="ns">Add</button><button type="button" class="btn pri" id="nm" aria-label="More ways to add" aria-haspopup="menu" aria-expanded="false">${ico("chev")}</button><div class="menu-pop" role="menu" hidden><button type="button" role="menuitem" id="nr" title="Add the idea and run it now">${ico("play")}Start run</button></div></div></div>`;
465
472
  const inp = f.querySelector("#newidea"),
466
473
  files = [];
467
474
  draft(inp, "f:new:0");
468
475
  draft(f.querySelector("textarea"), "f:new:1");
469
- const save = async () => {
476
+ // run: add the idea, then launch it at once with the project's runner and agent.
477
+ const save = async (run) => {
470
478
  const text = inp.value.trim();
471
479
  if (!text) return;
472
480
  const notes = f.querySelector("textarea").value.trim();
@@ -481,6 +489,11 @@ function ideaForm(f, close) {
481
489
  const n = await post("/note", { dir, for: text, text: "", files: enc });
482
490
  if (!n.ok) toast("Idea added, files not attached");
483
491
  }
492
+ if (run) {
493
+ await load();
494
+ const c = store.S.cards.find((x) => x.kind === "idea" && x.text === text && (!dir || x.state === dir));
495
+ if (c) return startRun(c);
496
+ }
484
497
  }
485
498
  load();
486
499
  };
@@ -503,7 +516,37 @@ function ideaForm(f, close) {
503
516
  }
504
517
  };
505
518
  if (store.all) ideaCombo(f);
506
- f.querySelector("#ns").onclick = save;
519
+ f.querySelector("#ns").onclick = () => save(false);
520
+ f.querySelector("#nr").onclick = () => save(true);
521
+ {
522
+ const mb = f.querySelector("#nm"),
523
+ menu = f.querySelector(".split .menu-pop");
524
+ // Focus moves into the menu only when it was opened from the keyboard.
525
+ const show = (on, kbd) => {
526
+ menu.hidden = !on;
527
+ mb.setAttribute("aria-expanded", String(on));
528
+ if (on && kbd) setTimeout(() => menu.querySelector("button").focus());
529
+ };
530
+ mb.onclick = (e) => {
531
+ e.stopPropagation();
532
+ show(menu.hidden, e.detail === 0);
533
+ };
534
+ menu.onkeydown = (e) => {
535
+ if (e.key === "Escape") {
536
+ e.preventDefault();
537
+ e.stopPropagation();
538
+ show(false);
539
+ mb.focus();
540
+ }
541
+ };
542
+ f.addEventListener("click", (e) => {
543
+ if (!menu.hidden && !e.target.closest(".split")) show(false);
544
+ });
545
+ const sp = f.querySelector(".split");
546
+ sp.addEventListener("focusout", (e) => {
547
+ if (!sp.contains(e.relatedTarget)) show(false);
548
+ });
549
+ }
507
550
  f.querySelector("#nc").onclick = close;
508
551
  {
509
552
  const m = mic([inp, f.querySelector("textarea")]);
@@ -512,7 +555,7 @@ function ideaForm(f, close) {
512
555
  inp.onkeydown = (e) => {
513
556
  if (e.key === "Enter") {
514
557
  e.preventDefault();
515
- save();
558
+ save(false);
516
559
  }
517
560
  if (e.key === "Escape") {
518
561
  e.preventDefault();
package/web/board/git.ts CHANGED
@@ -31,7 +31,7 @@ export function gitBtn(c) {
31
31
  export function confirmCommit(c, msg) {
32
32
  const g = store.runDoc.git,
33
33
  foot = $("pfoot");
34
- foot.innerHTML = html`<div class="inl" style="flex:1"><div class="q">Commit ${g.files.length} changed file${g.files.length === 1 ? "" : "s"} on ${g.branch || "this branch"} and push to ${g.remote}?</div><div class="q dim">Only the files this run's report names.${g.others ? ` ${g.others} other changed file${g.others === 1 ? " is" : "s are"} left alone.` : ""}</div><pre class="files">${g.files.join("\n")}</pre>${g.shared && g.shared.length ? html`<div class="q">These files also hold other runs' changes, and a file is committed whole:</div><pre class="files">${g.shared.join("\n")}</pre>` : ""}<textarea aria-label="Commit message" placeholder="feat(scope): what changed">${msg || commitMsg(store.runDoc.impl) || c.title || c.text}</textarea><div class="q dim" id="clong" hidden>The first line is over 72 characters; logs will cut it short.</div><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Commit &amp; push</button></div></div>`;
34
+ foot.innerHTML = html`<div class="inl" style="flex:1"><div class="q">Commit ${g.files.length} changed file${g.files.length === 1 ? "" : "s"} on ${g.branch || "this branch"} and push to ${g.remote}?</div><div class="q dim">Only the files this run's report names.${g.others ? ` ${g.others} other changed file${g.others === 1 ? " is" : "s are"} left alone.` : ""}</div><pre class="files">${g.files.join("\n")}</pre>${g.shared && g.shared.length ? html`<div class="q">These files also hold other runs' changes, and a file is committed whole:</div><pre class="files">${g.shared.join("\n")}</pre>` : ""}<textarea aria-label="Commit message" placeholder="Describe what changed">${msg || commitMsg(store.runDoc.impl) || c.title || c.text}</textarea><div class="q dim" id="clong" hidden>The first line is over 72 characters; logs will cut it short.</div><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Commit &amp; push</button></div></div>`;
35
35
  const inp = foot.querySelector("textarea"),
36
36
  go = foot.querySelector("[data-a=s]");
37
37
  inp.focus();
@@ -975,7 +975,7 @@ export async function openSources() {
975
975
  if (!dir && !showable().length) return toast("Pick a project first");
976
976
  // The new-idea form, cut down: the request, files, dictation and, in All projects, the project.
977
977
  const dl = modal(
978
- `${all ? `<div class="combo" id="ndc"><button type="button" class="sel" id="newdir" aria-label="Project for this request" aria-haspopup="listbox" aria-expanded="false"></button><div class="menu-pop" hidden><input class="sel" aria-label="Search projects" placeholder="Search projects…" spellcheck="false" autocomplete="off"><div id="nds" role="listbox"></div></div></div>` : ""}<textarea id="newidea" placeholder="New request for the icebox" aria-label="Request"></textarea><div class="att"></div><input type="file" multiple hidden><div class="row"><button type="button" class="ib" id="newpick" aria-label="Attach files" title="Attach files">${ico("clip")}</button><button type="button" class="btn ghost" value="">Cancel</button><button class="btn pri">Add</button></div>`,
978
+ `${all ? `<div class="combo" id="ndc"><button type="button" class="sel" id="newdir" aria-label="Project for this request" aria-haspopup="listbox" aria-expanded="false"></button><div class="menu-pop" hidden><input class="sel" aria-label="Search projects" placeholder="Search projects…" spellcheck="false" autocomplete="off"><div id="nds" role="listbox"></div></div></div>` : ""}<textarea id="newidea" placeholder="What did someone ask for?" aria-label="Request"></textarea><div class="att"></div><input type="file" multiple hidden><div class="row"><button type="button" class="ib" id="newpick" aria-label="Attach files" title="Attach files">${ico("clip")}</button><button type="button" class="btn ghost" value="">Cancel</button><button class="btn pri">Add</button></div>`,
979
979
  );
980
980
  const f = dl.querySelector("form"),
981
981
  inp = f.querySelector("textarea"),
package/web/board/keys.ts CHANGED
@@ -54,6 +54,8 @@ export function initKeys() {
54
54
  } else close();
55
55
  return;
56
56
  }
57
+ // The Terminal page has no cards to move between; its keys are the terminals'.
58
+ if (view === "terminal") return;
57
59
  if (e.key === "n" && !document.querySelector("dialog[open]")) {
58
60
  if (view !== "board") go("board", true);
59
61
  newIdea();
package/web/board/live.ts CHANGED
@@ -34,9 +34,10 @@ const v0 = ((v) => (v === "icebox" ? "sources" : v))(new URLSearchParams(locatio
34
34
  export function initLive() {
35
35
  // Back from a terminal: what was committed or pushed there touched nothing the server watches.
36
36
  addEventListener("focus", () => load(true));
37
- markView(["settings", "sources"].includes(v0) ? v0 : "board");
38
- document.body.classList.toggle("paged", ["settings", "sources"].includes(v0));
37
+ markView(["settings", "sources", "terminal"].includes(v0) ? v0 : "board");
38
+ document.body.classList.toggle("paged", ["settings", "sources", "terminal"].includes(v0));
39
39
  document.body.classList.toggle("ice", v0 === "sources");
40
+ document.body.classList.toggle("termv", v0 === "terminal");
40
41
  (async () => {
41
42
  let d;
42
43
  try {
@@ -57,7 +57,7 @@ async function pick(v) {
57
57
  } else if (v === "__new") {
58
58
  // A name, and where it goes: the projects folder unless typed or picked.
59
59
  const dl = modal(
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="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>`,
61
61
  );
62
62
  const f = dl.querySelector("form"),
63
63
  name = f.querySelector("[name=name]"),
@@ -96,7 +96,7 @@ async function pick(v) {
96
96
  } else if (v === "__clone") {
97
97
  // One form: the link, then where it goes. The folder fills in once the link names a repo.
98
98
  const dl = modal(
99
- `<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>`,
100
100
  );
101
101
  const f = dl.querySelector("form"),
102
102
  url = f.querySelector("[name=url]"),
@@ -47,7 +47,8 @@ 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")
@@ -60,10 +61,12 @@ export async function openSettings() {
60
61
  }
61
62
  if (vals.every((v) => /^\d+$/.test(v)))
62
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>`;
63
- return html`<select class="sel" id="${id}">${vals.map((v) => html`<option value="${v}"${v === on ? " selected" : ""}>${v === "" ? "—" : v}</option>`)}</select>`;
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>`;
64
66
  };
65
- const row = (label, control, hint, id, q) =>
66
- html`<div class="srow" data-q="${q.toLowerCase()}"><label for="${id}">${label}</label>${control}${hint ? html`<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>`;
67
70
  const hadFocus = document.activeElement?.id === "sq";
68
71
  const np = typeof Notification === "undefined" ? null : Notification.permission;
69
72
  const ntHelp = np ? (np === "denied" ? "Blocked by this browser" : "") : "Not supported in this browser";
@@ -83,16 +86,17 @@ export async function openSettings() {
83
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>`
84
87
  : html`<input class="sel" id="${id}" value="${r.value}" placeholder="not set" spellcheck="false">`
85
88
  : ctl(id, r.values, r.value),
86
- r.help,
89
+ [r.help, r.idle].filter(Boolean).join(" · "),
87
90
  id,
88
91
  [r.label, r.help, r.key, g, ...r.values].join(" "),
92
+ !!r.idle,
89
93
  );
90
94
  })}</div>`,
91
95
  )
92
96
  : html`<p class="snone">Settings could not be read. Run <code>kaizen settings</code>.</p>`
93
97
  }
94
- <h3 class="sg">Google</h3><div class="sgrp">${row("OAuth client id", html`<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")}
95
- ${row("OAuth client secret", html`<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")}
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")}
96
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>
97
101
  <h3 class="sg">Browser</h3><div class="sgrp">${row("Theme", ctl("th", ["system", "light", "dark"], cur), "", "th", "theme light dark system browser")}
98
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>
@@ -154,7 +158,32 @@ export async function openSettings() {
154
158
  toast(j.error || "Could not start Google sign-in");
155
159
  }
156
160
  };
157
- $("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);
158
187
  $("nt").onclick = () => {
159
188
  askNotify();
160
189
  setTimeout(openSettings, 300);
@@ -166,16 +195,22 @@ export async function openSettings() {
166
195
  const f = document.activeElement?.id;
167
196
  const j = await post("/settings", { key: r.key, value: v });
168
197
  if (j.ok) toast("Saved");
169
- await openSettings();
170
- if (f?.startsWith("s-preset-")) $(f)?.focus();
198
+ await refocus(f);
171
199
  };
172
- if (el.tagName === "SELECT" || el.tagName === "INPUT")
200
+ if (el.tagName === "INPUT")
173
201
  el.onchange = async () => {
174
202
  await save(el.value.trim());
175
- if (el.tagName === "INPUT") load();
203
+ load();
176
204
  };
177
205
  else el.querySelectorAll("[data-v]").forEach((b) => (b.onclick = () => save(b.dataset.v)));
178
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
+ });
179
214
  // The system's own folder dialog, opened by the board: a page cannot read a real path.
180
215
  if (r.folder)
181
216
  $(el.id + "-pick").onclick = async () => {