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.
- package/agents/kaizen-reviewer.md +1 -1
- package/cli/backlog.test.ts +133 -0
- package/cli/board.test.ts +10 -4
- package/cli/browser.test.ts +1 -1
- package/cli/browser.ts +12 -7
- package/cli/html.test.ts +19 -0
- package/cli/notes.ts +2 -6
- package/cli/pty.ts +2 -1
- package/cli/settings.test.ts +38 -0
- package/cli/settings.ts +37 -30
- package/cli/state.test.ts +86 -3
- package/cli/state.ts +245 -45
- package/cli/web.ts +67 -16
- package/package.json +1 -1
- package/skills/kaizen/README.md +0 -4
- package/skills/kaizen/SKILL.md +4 -1
- package/skills/kaizen/config.default.yml +1 -50
- package/skills/kaizen/docs.md +3 -2
- package/skills/kaizen/settings.md +10 -3
- package/skills/kaizen/spec-build.md +3 -2
- package/skills/kaizen/spec-main.md +40 -5
- package/skills/kaizen/spec-review.md +2 -1
- package/skills/kaizen-help/SKILL.md +0 -4
- package/web/board/agent-term.ts +1 -1
- package/web/board/api.ts +2 -0
- package/web/board/approvals.ts +3 -3
- package/web/board/cards.ts +284 -108
- package/web/board/chat.ts +4 -4
- package/web/board/dom.ts +17 -5
- package/web/board/fixes.ts +6 -8
- package/web/board/git.ts +3 -3
- package/web/board/icebox.ts +52 -70
- package/web/board/keys.ts +9 -6
- package/web/board/live.ts +3 -2
- package/web/board/main.ts +1 -0
- package/web/board/panel.ts +22 -16
- package/web/board/projects.ts +11 -16
- package/web/board/settings.ts +60 -29
- package/web/board/store.ts +2 -0
- package/web/board/terms.ts +279 -0
- package/web/board/update.ts +7 -7
- package/web/board/views.ts +19 -5
- package/web/board.html +40 -2
- package/web/board.js +25 -24
- package/web/notebook.js +1 -1
- package/web/notes.html +2 -0
- package/web/term.js +1 -1
package/web/board/panel.ts
CHANGED
|
@@ -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 { $,
|
|
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">${
|
|
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
|
|
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>${
|
|
133
|
-
(c.awaiting ? `<span class="pst dim">${
|
|
134
|
-
(c.parent ? `<span class="pst link" title="Filed by this run">${
|
|
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)}&note=${encodeURIComponent(n)}">${
|
|
191
|
+
html`<h3>Linked from notes</h3><ul class="lnk">${store.runDoc.linkedFrom.map((n) => html`<li><a href="/notebook?dir=${encodeURIComponent(c.state)}&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: ${
|
|
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;
|
package/web/board/projects.ts
CHANGED
|
@@ -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 { $,
|
|
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">${
|
|
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="${
|
|
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>${
|
|
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
|
-
?
|
|
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="
|
|
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
|
|
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="${
|
|
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="${
|
|
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
|
}
|
package/web/board/settings.ts
CHANGED
|
@@ -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 { $,
|
|
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
|
-
//
|
|
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>`)
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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">${
|
|
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="${
|
|
86
|
-
: `<input class="sel" id="${id}" value="${
|
|
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
|
-
|
|
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
|
-
|
|
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 ? "
|
|
99
|
-
${row("OAuth client secret", `<span class="fpick"><input class="sel" id="gg-secret" type="password" placeholder="${gg.hasClient ? "
|
|
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
|
-
|
|
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
|
|
174
|
-
if (f?.startsWith("s-preset-")) $(f)?.focus();
|
|
198
|
+
await refocus(f);
|
|
175
199
|
};
|
|
176
|
-
if (el.tagName === "
|
|
200
|
+
if (el.tagName === "INPUT")
|
|
177
201
|
el.onchange = async () => {
|
|
178
202
|
await save(el.value.trim());
|
|
179
|
-
|
|
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 () => {
|
package/web/board/store.ts
CHANGED
|
@@ -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: "",
|