kaizen-agent 1.38.1 → 1.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/board.test.ts CHANGED
@@ -8,6 +8,56 @@ import { draft, undraft } from "../web/board/drafts.ts";
8
8
  const html = readFileSync(join(dirname(import.meta.dir), "web", "board.html"), "utf8");
9
9
  const cards = readFileSync(join(dirname(import.meta.dir), "web", "board", "cards.ts"), "utf8");
10
10
 
11
+ test("Settings rechecks an existing update alert and clears or refreshes it", async () => {
12
+ const classes = new Set<string>();
13
+ const link = { classList: { toggle(name: string, on: boolean) { on ? classes.add(name) : classes.delete(name); } }, title: "Settings" };
14
+ const previous = globalThis.document;
15
+ const previousLocation = globalThis.location;
16
+ const previousFetch = globalThis.fetch;
17
+ const elements = new Map<string, any>();
18
+ const body = { row: null as any, prepend(row: any) { this.row = row; row.isConnected = true; } };
19
+ (globalThis as any).location = { search: "" };
20
+ (globalThis as any).document = {
21
+ querySelector: () => link,
22
+ getElementById: (id: string) => id === "pbody" ? body : elements.get(id),
23
+ createElement: () => ({
24
+ isConnected: false,
25
+ set innerHTML(html: string) {
26
+ this.html = html;
27
+ elements.clear();
28
+ for (const [, id] of html.matchAll(/id="([^"]+)"/g)) elements.set(id, { textContent: "", disabled: false, focus() {} });
29
+ },
30
+ remove() { this.isConnected = false; },
31
+ }),
32
+ };
33
+ try {
34
+ const { gotVer, updBar } = await import("../web/board/update.ts");
35
+ expect(gotVer({ version: "1.38.1", latest: "1.39.0", update: true })).toBe(true);
36
+ updBar();
37
+ expect(body.row.html).toContain('id="upd-chk"');
38
+ expect(classes.has("upd")).toBe(true);
39
+ expect(link.title).toContain("1.39.0");
40
+ globalThis.fetch = (async () => ({ json: async () => ({ version: "1.39.0", latest: "1.39.0", update: false }) })) as any;
41
+ await elements.get("upd-chk").onclick();
42
+ expect(classes.has("upd")).toBe(false);
43
+ expect(link.title).toBe("Settings");
44
+ expect(body.row.className).toContain("quiet");
45
+ expect(elements.get("upd-msg").textContent).toBe("kaizen 1.39.0 is up to date.");
46
+ globalThis.fetch = (async () => ({ json: async () => ({ version: "1.39.0", latest: "1.40.0", update: true }) })) as any;
47
+ await elements.get("upd-chk").onclick();
48
+ expect(classes.has("upd")).toBe(true);
49
+ expect(body.row.html).toContain("1.40.0");
50
+ globalThis.fetch = (async () => { throw Error("offline"); }) as any;
51
+ await elements.get("upd-chk").onclick();
52
+ expect(classes.has("upd")).toBe(true);
53
+ expect(body.row.html).toContain("1.40.0");
54
+ } finally {
55
+ globalThis.document = previous;
56
+ globalThis.location = previousLocation;
57
+ globalThis.fetch = previousFetch;
58
+ }
59
+ });
60
+
11
61
  test("backlog items: a checkbox each, ticked only when done", () => {
12
62
  const out = md('- open: one | why\n- done: two <b>x</b> | fixed\n- rejected: three | no\n- plain\n', true);
13
63
  const boxes = out.match(/<li[^>]*>.*?<\/li>/g)!;
@@ -105,7 +155,9 @@ test("card menu items: appropriate actions and distinct icons across phases", ()
105
155
  const ico = (n: string) => `<svg><use href="#i-${n}"/></svg>`;
106
156
  const from = cards.indexOf("if (k === store.menuKey) {");
107
157
  const menuSnippet = (cards.slice(from, cards.indexOf('el.innerHTML += `<div class="menu-pop"', from)) + "}").replace("const items = [];", "items = [];");
108
- const getItems = new Function("c", "archive", "S", `const ico = ${ico.toString()};\nconst store = { S, menuKey: 1 };\nlet k = 1, items = [];\n${menuSnippet}\nreturn items;`);
158
+ const a0 = cards.indexOf("const actionOf =");
159
+ const actionOf = cards.slice(a0, cards.indexOf("\n\n", a0));
160
+ const getItems = new Function("c", "archive", "S", `const ico = ${ico.toString()};\n${actionOf}\nconst store = { S, menuKey: 1 };\nlet k = 1, items = [];\n${menuSnippet}\nreturn items;`);
109
161
 
110
162
  // Phase 0: Ideas (idea card)
111
163
  const idea = { kind: "idea", status: "idea", column: 0 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.38.1",
3
+ "version": "1.39.0",
4
4
  "description": "Staged workflow for AI agents — code, writing, research, operations: plan, human approval, build, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -273,6 +273,9 @@ first dispatch of the run. `inherit` means leave the file alone.
273
273
 
274
274
  After a subagent returns, update `state.json` yourself in the main thread. Subagents
275
275
  report; the main thread owns the state machine.
276
+ Before moving any run from `build` to `review`, under either runner, apply the
277
+ *Preview gate* in [`spec-main.md`](spec-main.md): no `## Preview` with a screenshot
278
+ (or a one-line reason there is none) means the build is not done.
276
279
 
277
280
  **Name files, do not narrate them.** The prompt says which paths to read, which
278
281
  decisions bind the stage, and what to report back. It does not summarize those files:
@@ -83,6 +83,19 @@ subagents at all.
83
83
 
84
84
  ---
85
85
 
86
+ ## Preview gate
87
+
88
+ Before setting `stage: "review"` after a build, in either runner, open `03-impl.md` and
89
+ check its `## Preview`: it has `### How to test` steps, and — when the result shows on a
90
+ screen — at least one `![…](attachments/<name>.png)` embed whose file exists under
91
+ `runs/<id>/attachments/`. A result with no screen needs one line under `## Preview`
92
+ saying why there is no screenshot. Anything missing means the build is not finished:
93
+ under `lite`, take the screenshot and write the section now; under `full`, send the
94
+ builder back to add it. Then advance. This is the step that keeps the board's Preview
95
+ tab from staying empty; the reviewer flagging it later is a second net, not the first.
96
+
97
+ ---
98
+
86
99
  ## Final approval
87
100
 
88
101
  Controlled by `approvals.review`, skipped in `auto`.
@@ -9,8 +9,10 @@ import { brief } from "./markdown.ts";
9
9
  import { open, toggleArchive } from "./panel.ts";
10
10
  import { ideaCombo, list, newDir, plogo, short, showList, showable } from "./projects.ts";
11
11
  import { askNotify, toast } from "./toast.ts";
12
+ import { resumable, resume } from "./approvals.ts";
12
13
 
13
14
  let filter = null;
15
+ let query = ""; // the header filter box, matched against a card's title and request
14
16
  // A column shows this many cards, then a "more" line; Done is where it matters.
15
17
  const FOLD = 6;
16
18
  const unfolded = new Set();
@@ -39,6 +41,12 @@ const nested = (c) =>
39
41
  store.S.cards.some((p) => p.kind === "run" && p.id === c.parent && p.state === c.state && !p.archived && p.column !== 4);
40
42
  const kidsOpen = new Set();
41
43
 
44
+ // What opening a waiting run does, in words: the card's button and its menu say the same.
45
+ const actionOf = (c) =>
46
+ c.status !== "waiting"
47
+ ? "Open"
48
+ : { "approvals.plan": "Review plan", findings: "See findings", "approvals.review": "Review work" }[c.awaiting] || "Open";
49
+
42
50
  function card(c) {
43
51
  const k = key(c),
44
52
  el = document.createElement("div");
@@ -51,7 +59,7 @@ function card(c) {
51
59
  el.dataset.k = k;
52
60
  el.setAttribute("role", "button");
53
61
  const st = ICON[c.status] || "idea",
54
- idt = c.kind === "idea" ? "idea" : c.text.replace(/-/g, " ");
62
+ idt = c.kind === "idea" ? "" : c.text.replace(/-/g, " ");
55
63
  // The status icon carries the state; what a waiting run needs is its tooltip.
56
64
  const sttl = c.status === "waiting" ? need(c.awaiting) || "needs you" : c.status || "idea";
57
65
  // Footer: words on the left (project, what it needs), icon-only marks on the right.
@@ -98,10 +106,22 @@ function card(c) {
98
106
  );
99
107
  if (c.open) marks.push(mk("i-bug", `${c.open} open finding${c.open === 1 ? "" : "s"} to fix`, " warn"));
100
108
  const pick = picking && !archive && c.column === 4;
109
+ // Done cards are one line on the board; the panel still shows everything.
110
+ if (c.column === 4 && !archive) el.classList.add("compact");
111
+ // The one thing a card asks of you, on its face: what a waiting run needs, or resuming a stalled one.
112
+ const act =
113
+ c.kind === "run" && !archive && !pick
114
+ ? c.status === "waiting"
115
+ ? `<button class="btn sm pri act" data-a="open">${actionOf(c)}</button>`
116
+ : c.status === "stalled" && resumable(c)
117
+ ? `<button class="btn sm act" data-a="resume">Resume</button>`
118
+ : ""
119
+ : "";
101
120
  el.innerHTML =
102
121
  (pick ? `<input type="checkbox" class="pick" ${picked.has(k) ? "checked" : ""} aria-label="Select">` : "") +
103
122
  `<div class="c-id"><span class="st d-${st}" role="img" title="${esc(sttl)}" aria-label="${esc(sttl)}"><svg><use href="#s-${st}"/></svg></span><span class="idt">${esc(idt)}</span><span class="age">${c.kind === "run" ? age(c.moved || store.S.now, store.S.now) : ""}</span><button class="ib sm more" data-a="menu" title="Actions" aria-label="Actions" aria-haspopup="menu" aria-expanded="${k === store.menuKey}">${ico("more")}</button></div>
104
123
  <div class="c-title"${c.kind === "idea" && brief(c.text).parts.length ? ` title="${esc(c.text)}"` : ""}>${esc(c.kind === "idea" ? brief(c.text).head : c.title || c.id)}</div>
124
+ ${act}
105
125
  ${chips.length || marks.length ? `<div class="c-foot"><span class="c-chips">${chips.join("")}</span><span class="c-marks">${marks.join("")}</span></div>` : ""}`;
106
126
  const kids = kidsOf(c);
107
127
  if (kids.length) {
@@ -160,17 +180,7 @@ function card(c) {
160
180
  ["delete", "Remove", ico("trash"), "danger"],
161
181
  );
162
182
  } else {
163
- const openLabel =
164
- c.status === "waiting"
165
- ? c.awaiting === "approvals.plan"
166
- ? "Review plan"
167
- : c.awaiting === "findings"
168
- ? "See findings"
169
- : c.awaiting === "approvals.review"
170
- ? "Review work"
171
- : "Open"
172
- : "Open";
173
- items.push(["open", openLabel, ico("open")]);
183
+ items.push(["open", actionOf(c), ico("open")]);
174
184
  items.push(["archive", "Archive", ico("archive")]);
175
185
  if (c.status === "waiting" || c.status === "running" || c.status === "stalled") {
176
186
  items.push(null, ["reject", "Abandon", ico("x"), "danger"]);
@@ -194,6 +204,10 @@ function card(c) {
194
204
  open(c);
195
205
  return;
196
206
  }
207
+ if (a === "resume") {
208
+ resume(c, bt);
209
+ return;
210
+ }
197
211
  if (a === "archive" || a === "restore") {
198
212
  toggleArchive(c, a === "archive");
199
213
  return;
@@ -383,6 +397,8 @@ async function startRun(c, kind) {
383
397
  load();
384
398
  }
385
399
 
400
+ const HINT = { 1: "Ideas you run are planned here", 2: "Approved plans are built here", 3: "Finished builds are checked here" };
401
+
386
402
  export function render() {
387
403
  // A closed form leaves no draft behind; an open one is reopened after a reload.
388
404
  // A form whose card is gone (restored after a reload, or deleted elsewhere) has nowhere to show.
@@ -422,6 +438,10 @@ export function render() {
422
438
  `<button class="${archive ? "on" : ""}" data-f="__archive" title="${cs.filter((c) => c.kind === "idea").length} ideas · ${cs.filter((c) => c.kind === "run").length} runs on the board">${archive ? "Back to board" : "Archive " + nArch}</button>`,
423
439
  );
424
440
  $("strip").innerHTML = strip.join("");
441
+ // The phone's status button: amber dot while a run waits, accent while a filter or the archive is on.
442
+ $("fbtn").hidden = false;
443
+ $("fbtn").classList.toggle("on", !!filter || archive);
444
+ $("fbtn").classList.toggle("wait", !filter && !archive && n("waiting") > 0);
425
445
  $("strip")
426
446
  .querySelectorAll("button")
427
447
  .forEach(
@@ -468,12 +488,21 @@ export function render() {
468
488
  }
469
489
  COLS.forEach((name, ci) => {
470
490
  const s = document.createElement("section");
471
- const list = cs.filter((c) => c.column === ci && (!filter || c.status === filter) && (archive || !nested(c)));
491
+ const q = query.trim().toLowerCase();
492
+ const list = cs.filter(
493
+ (c) =>
494
+ c.column === ci &&
495
+ (!filter || c.status === filter) &&
496
+ (archive || !nested(c)) &&
497
+ (!q || `${c.title || ""} ${c.text || ""} ${c.id || ""}`.toLowerCase().includes(q)),
498
+ );
472
499
  // Runs age by their last move, ideas by inbox position (new ones are appended).
473
500
  if (ci < 4) {
474
501
  const at = (c) => (c.kind === "run" ? c.moved || 0 : store.S.cards.indexOf(c)),
475
502
  old = order[ci] === "old";
476
- list.sort((a, b) => (old ? at(a) - at(b) : at(b) - at(a)));
503
+ // Runs waiting on you go first, then the column's own order.
504
+ const w = (c) => (c.status === "waiting" ? 1 : 0);
505
+ list.sort((a, b) => w(b) - w(a) || (old ? at(a) - at(b) : at(b) - at(a)));
477
506
  }
478
507
  const clearable = ci === 4 && !archive && list.length;
479
508
  const olderThan = (days) => list.filter((c) => store.S.now - (c.moved || store.S.now) > days * d);
@@ -652,12 +681,51 @@ export function render() {
652
681
  };
653
682
  ul.appendChild(li);
654
683
  }
684
+ // An empty middle column says what lands in it, so it does not look broken.
685
+ if (!list.length && ci >= 1 && ci <= 3 && cs.length && !filter && !q && !archive)
686
+ ul.innerHTML = `<li class="hint">${HINT[ci]}</li>`;
655
687
  s.appendChild(ul);
656
688
  b.appendChild(s);
657
689
  });
690
+ // The tab says how many runs wait on you, so a background tab still tells you.
691
+ const nw = store.S.cards.filter((c) => c.kind === "run" && c.status === "waiting" && !c.archived).length;
692
+ document.title = nw ? `(${nw}) kaizen` : "kaizen";
658
693
  stopDictation(false); // forms were just rebuilt; a listener whose field is gone would write into nothing
659
694
  if (fk && document.activeElement === document.body) cardEl(fk)?.focus({ preventScroll: true });
660
695
  }
696
+ // The header filter box lives outside #board, so a redraw keeps what is typed; draft() keeps it over a reload.
697
+ export function filterBox() {
698
+ const el = $("bq");
699
+ draft(el, "bq");
700
+ query = el.value;
701
+ el.addEventListener("input", () => {
702
+ query = el.value;
703
+ render();
704
+ });
705
+ el.addEventListener("keydown", (e) => {
706
+ if (e.key === "Escape") {
707
+ e.stopPropagation();
708
+ el.value = "";
709
+ el.dispatchEvent(new Event("input"));
710
+ el.blur();
711
+ }
712
+ });
713
+ }
714
+ // Phones: the footer's status strip opens as a sheet under the filter button; a press outside closes it.
715
+ export function statusSheet() {
716
+ const b = $("fbtn"),
717
+ f = document.querySelector(".ftr");
718
+ const set = (on) => {
719
+ f.classList.toggle("on", on);
720
+ b.setAttribute("aria-expanded", on);
721
+ if (on) f.style.top = b.getBoundingClientRect().bottom + 6 + "px";
722
+ };
723
+ b.onclick = () => set(!f.classList.contains("on"));
724
+ document.addEventListener("pointerdown", (e) => {
725
+ if (f.classList.contains("on") && !f.contains(e.target) && !b.contains(e.target)) set(false);
726
+ });
727
+ document.addEventListener("keydown", (e) => e.key === "Escape" && f.classList.contains("on") && set(false));
728
+ }
661
729
  export const focusedKey = () => document.activeElement?.closest?.("#board .card")?.dataset.k;
662
730
  export const cardEl = (k) => k && $("board").querySelector('.card[data-k="' + CSS.escape(k) + '"]');
663
731
  // Arrows and hjkl, as on the terminal board: up/down within a column, left/right to the
package/web/board/keys.ts CHANGED
@@ -39,6 +39,11 @@ export function initKeys() {
39
39
  $("sq").focus();
40
40
  return;
41
41
  }
42
+ if (e.key === "/" && view === "board" && $("bq")) {
43
+ e.preventDefault();
44
+ $("bq").focus();
45
+ return;
46
+ }
42
47
  if (e.key === "Escape") {
43
48
  $("help").classList.remove("on");
44
49
  if (store.mode) {
package/web/board/main.ts CHANGED
@@ -9,6 +9,7 @@ import { initViews } from "./views.ts";
9
9
  import { initProjects } from "./projects.ts";
10
10
  import { initUpdate } from "./update.ts";
11
11
  import { initKeys } from "./keys.ts";
12
+ import { filterBox, statusSheet } from "./cards.ts";
12
13
  import { initLive } from "./live.ts";
13
14
  import { store } from "./store.ts";
14
15
 
@@ -18,6 +19,8 @@ initProjects();
18
19
  initUpdate();
19
20
  initKeys();
20
21
  initLive();
22
+ filterBox();
23
+ statusSheet();
21
24
 
22
25
  // One popup at a time: a press anywhere first shuts every open popup it is not inside,
23
26
  // before any handler runs (several stop propagation), so the one it opens is the only one
@@ -9,53 +9,52 @@ import { view } from "./views.ts";
9
9
  // is a quiet row whose Check for updates asks npm now instead of the server's 6 h cache.
10
10
  let upd = null,
11
11
  ver = null;
12
- function gotVer(v) {
12
+ export function gotVer(v) {
13
13
  ver = v;
14
- if (!v.update) return false;
15
- upd = v;
14
+ upd = v.update ? v : null;
16
15
  const a = document.querySelector('.rail a[data-view="settings"]');
17
- a.classList.add("upd");
18
- a.title = `Settings: kaizen ${v.latest} is available`;
19
- return true;
16
+ a.classList.toggle("upd", !!upd);
17
+ a.title = upd ? `Settings: kaizen ${v.latest} is available` : "Settings";
18
+ return !!upd;
20
19
  }
21
20
  export function updBar() {
21
+ const d = document.createElement("div");
22
+ d.className = `updbar${upd ? "" : " quiet"}`;
22
23
  if (!upd) {
23
- const d = document.createElement("div");
24
- d.className = "updbar quiet";
25
24
  d.innerHTML = `<span id="upd-msg" aria-live="polite">${ver ? `You are running kaizen ${esc(ver.version)}.` : "kaizen"}</span><button class="btn" id="upd-chk">Check for updates</button>`;
26
- $("pbody").prepend(d);
27
- const b = $("upd-chk");
28
- b.onclick = async () => {
29
- b.disabled = true;
30
- b.textContent = "Checking…";
31
- const v = await fetch("/version?fresh=1")
32
- .then((r) => r.json())
33
- .catch(() => null);
34
- if (!d.isConnected) return;
35
- b.disabled = false;
36
- b.textContent = "Check for updates";
37
- if (v && gotVer(v)) {
38
- d.remove();
39
- updBar();
40
- $("upd-go").focus();
41
- return;
42
- }
43
- $("upd-msg").textContent =
44
- !v || !v.latest ? "Could not reach npm. Try again later." : `kaizen ${v.version} is up to date.`;
45
- };
46
- return;
25
+ } else {
26
+ // What's new: each newer release's one-line title, shown on hover, focus or click.
27
+ const notes = (upd.notes || []).map((n) => `<li><b>${esc(n.v)}</b> ${esc(n.text)}</li>`).join("");
28
+ const news = notes
29
+ ? `<span class="updnew"><button class="btn" aria-describedby="upd-notes">What's new</button><span class="updpop" id="upd-notes" role="tooltip"><ul>${notes}</ul><a href="https://github.com/hfadhlullah/kaizen/releases" target="_blank" rel="noopener">All release notes</a></span></span>`
30
+ : "";
31
+ d.innerHTML = `<span>kaizen <b>${esc(upd.latest)}</b> is available. You are running ${esc(upd.version)}.</span><span class="updact">${news}<button class="btn" id="upd-chk">Check for updates</button><button class="btn pri" id="upd-go">Update kaizen</button></span>`;
47
32
  }
48
- const d = document.createElement("div");
49
- d.className = "updbar";
50
- // What's new: each newer release's one-line title, shown on hover, focus or click.
51
- const notes = (upd.notes || [])
52
- .map((n) => `<li><b>${esc(n.v)}</b> ${esc(n.text)}</li>`)
53
- .join("");
54
- const news = notes
55
- ? `<span class="updnew"><button class="btn" aria-describedby="upd-notes">What's new</button><span class="updpop" id="upd-notes" role="tooltip"><ul>${notes}</ul><a href="https://github.com/hfadhlullah/kaizen/releases" target="_blank" rel="noopener">All release notes</a></span></span>`
56
- : "";
57
- d.innerHTML = `<span>kaizen <b>${esc(upd.latest)}</b> is available. You are running ${esc(upd.version)}.</span><span class="updact">${news}<button class="btn pri" id="upd-go">Update kaizen</button></span>`;
58
33
  $("pbody").prepend(d);
34
+ const check = $("upd-chk");
35
+ check.onclick = async () => {
36
+ check.disabled = true;
37
+ check.textContent = "Checking…";
38
+ const v = await fetch("/version?fresh=1")
39
+ .then((r) => r.json())
40
+ .catch(() => null);
41
+ if (!d.isConnected) return;
42
+ check.disabled = false;
43
+ if (!v || !v.latest) {
44
+ if (upd) check.textContent = "Could not check. Try again";
45
+ else $("upd-msg").textContent = "Could not reach npm. Try again later.";
46
+ return;
47
+ }
48
+ gotVer(v);
49
+ d.remove();
50
+ updBar();
51
+ if (upd) $("upd-go").focus();
52
+ else {
53
+ $("upd-msg").textContent = `kaizen ${v.version} is up to date.`;
54
+ $("upd-chk").focus();
55
+ }
56
+ };
57
+ if (!upd) return;
59
58
  const b = $("upd-go");
60
59
  b.onclick = async () => {
61
60
  if (!confirm(`Update kaizen to ${upd.latest}? The board restarts, and agents running inside cards stop.`)) return;
package/web/board.html CHANGED
@@ -59,6 +59,14 @@ svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.6;stroke
59
59
  [data-rail=min] .theme{flex-direction:column}
60
60
  [data-rail=min] .theme button{width:34px;flex:none}
61
61
  @media(max-width:640px){#fold{display:none}}
62
+ /* phones: the side menu is a bottom tab bar, in the page flow so nothing sits under it; it owns the bottom safe area */
63
+ @media(max-width:640px){body{flex-direction:column;padding-bottom:0}.app{flex:1;min-height:0}
64
+ .rail,[data-rail=min] .rail{order:2;width:auto;flex-direction:row;align-items:stretch;gap:0;padding:4px 6px calc(4px + env(safe-area-inset-bottom));border-top:1px solid var(--line);background:var(--bg-2);overflow:visible}
65
+ .rail .top,.rail .end,.rail .theme{display:none}
66
+ .rail a.it,[data-rail=min] .rail a.it{flex:1;width:auto;flex-direction:column;justify-content:center;gap:2px;height:auto;min-height:48px;padding:4px 0;font-size:11px}
67
+ .rail a.it[aria-current=page]{background:none;color:var(--accent)}
68
+ [data-rail=min] .rail .lbl{position:static;width:auto;height:auto;overflow:visible;clip-path:none}
69
+ body .rail a.upd::after{left:calc(50% + 6px);top:4px}body .toast{left:16px;bottom:calc(68px + env(safe-area-inset-bottom))}}
62
70
  /* end side menu */
63
71
  /* the work area: an inset rounded panel on the canvas the side menu sits on */
64
72
  .app{flex:1;min-width:0;display:flex;flex-direction:column;margin:8px 8px 8px 0;background:var(--bg);border:1px solid var(--line);border-radius:var(--r);overflow:hidden}
@@ -81,6 +89,8 @@ select.sel,button.sel{cursor:pointer;appearance:none;padding-right:30px;max-widt
81
89
  /* project picker: a select-shaped button over a searchable list */
82
90
  .combo{position:relative;max-width:100%;display:flex}
83
91
  .combo .menu-pop{left:0;right:auto;top:calc(100% + 4px);width:320px;max-width:calc(100vw - 32px);gap:6px;z-index:9}
92
+ /* phones: the right-hand combo opens leftward, so its list stays on screen */
93
+ @media(max-width:640px){#showc .menu-pop{left:auto;right:0}}
84
94
  .combo[hidden],.combo .menu-pop[hidden]{display:none}
85
95
  .combo input.sel{width:100%;max-width:none}
86
96
  #ndc>.sel{width:100%;max-width:none;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -166,6 +176,10 @@ section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
166
176
  .card:hover .age,.card.menu .age{display:none}
167
177
  .c-title{font-size:13px;font-weight:500;line-height:1.45;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;color:var(--ink)}
168
178
  .c-foot{display:flex;align-items:center;gap:8px;margin-top:2px}
179
+ .card .act{align-self:flex-start;min-height:24px;padding:0 10px;font-size:12px}
180
+ .card.compact{flex-direction:row;align-items:center;gap:8px;padding:7px 12px}.card.compact .c-id{display:contents}.card.compact .idt{display:none}.card.compact .c-title{order:1;flex:1;min-width:0;-webkit-line-clamp:1}.card.compact .age{order:2}.card.compact .more{order:3}.card.compact .c-foot,.card.compact .kids{display:none}
181
+ section li.hint{display:block;padding:14px;border:1px dashed var(--line);border-radius:var(--r-s);color:var(--dim);font-size:12px;text-align:center}
182
+ #bq{width:220px;min-height:30px}
169
183
  .c-chips{display:flex;gap:4px;flex-wrap:wrap;flex:1;min-width:0}
170
184
  .c-marks{display:flex;align-items:center;gap:6px;color:var(--dim);margin-left:auto}
171
185
  .mk{display:grid;place-items:center;font-size:11px;font-weight:500}
@@ -226,6 +240,9 @@ aside.wide{width:100%;border-left:0}#pbody:has(.term){display:flex;flex-directio
226
240
  .ftr{display:flex;justify-content:space-between;gap:16px;padding:6px 28px;border-top:1px solid var(--line);color:var(--dim);font-size:12px;flex-wrap:wrap;align-items:center}
227
241
  .ftr span+span,.ftr a+a{margin-left:14px}.ftr .strip{font-size:12px}.ftr .strip .st{display:inline-grid}
228
242
  /* The footer is out of the eye's path, so the waiting alert is filled, not just outlined. */
243
+ /* phones: the status strip moves into a sheet under the header, opened by #fbtn; the dot says a run waits or a filter is on */
244
+ #fbtn{position:relative;display:none}body.paged #fbtn{display:none!important}#fbtn.wait::after,#fbtn.on::after{content:"";position:absolute;right:6px;top:6px;width:7px;height:7px;border-radius:50%;background:var(--warn);box-shadow:0 0 0 2px var(--bg)}#fbtn.on::after{background:var(--accent)}
245
+ @media(max-width:640px){#fbtn:not([hidden]){display:grid;order:2;flex:none}body .hdr .combo{flex-basis:calc(50% - 30px)}.ftr{display:none}.ftr.on{display:flex;position:fixed;z-index:10;left:12px;right:12px;padding:12px;background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r);box-shadow:var(--shadow-2)}}
229
246
  .ftr .strip .attn{color:var(--ink);background:color-mix(in srgb,var(--warn) 18%,transparent);box-shadow:inset 0 0 0 1px var(--warn)}.ftr .strip .attn:hover,.ftr .strip .attn.on{background:color-mix(in srgb,var(--warn) 30%,transparent)}.ftr .strip .attn svg{color:var(--warn)}.ftr .st svg{width:12px;height:12px}
230
247
  .ftr a{color:var(--dim);cursor:pointer;white-space:nowrap;text-decoration:none;display:inline-flex;align-items:center;gap:5px}.ftr a>svg{width:13px;height:13px}.ftr a:hover{color:var(--ink)}
231
248
 
@@ -235,7 +252,7 @@ aside.wide{width:100%;border-left:0}#pbody:has(.term){display:flex;flex-directio
235
252
  aside{position:fixed;top:0;right:0;bottom:0;z-index:11;width:520px;max-width:100%;background:var(--bg-2);border-left:1px solid var(--line);box-shadow:var(--shadow-2);display:flex;flex-direction:column;transform:translateX(100%);visibility:hidden;transition:transform .18s,visibility .18s}
236
253
  aside.open{transform:none;visibility:visible}
237
254
  /* Settings and the Icebox are pages: the same panel, docked into the app column, full width like the board */
238
- body.paged #board,body.paged .ftr,body.paged .scrim{display:none}
255
+ body.paged #board,body.paged .ftr,body.paged .scrim,body.paged #bq{display:none}
239
256
  /* The Icebox keeps the project bar with its own title, without New idea or the panel's header; Settings has none */
240
257
  body.paged:not(.ice) .hdr,body.ice aside.docked>header{display:none}body.ice #newtop{visibility:hidden}
241
258
  aside.docked{position:static;z-index:auto;flex:1;min-height:0;width:auto;transform:none;visibility:visible;transition:none;box-shadow:none;border:0;background:none}
@@ -448,7 +465,7 @@ a.schip:hover{color:var(--ink);border-color:color-mix(in srgb,var(--line) 60%,va
448
465
  .src-add .acts .btn{flex-shrink:0}
449
466
  @media(max-width:480px){.src-add .two,.src-add .two.plat{grid-template-columns:1fr}}
450
467
  @media(max-width:900px){main{grid-template-columns:1fr;padding-inline:16px;gap:8px}.hdr,.ftr,aside.docked>header,aside.docked>.body{padding-inline:16px}.more{display:grid}.card:hover .age{display:inline}.toast{left:16px;max-width:calc(100% - 32px)}}
451
- @media(max-width:640px){.hdr{gap:8px 12px}.hdr h1{order:0;flex:1 0 50%}#newtop{order:1}.hdr .combo{order:2;flex:1 1 calc(50% - 6px);min-width:0}.hdr .combo>.sel{width:100%;max-width:none}.hdr .sp{display:none}.ftr{gap:6px 12px}}
468
+ @media(max-width:640px){.hdr{gap:8px 12px}.hdr h1{order:0;flex:1 0 50%}#newtop{order:1}.hdr .combo{order:2;flex:1 1 calc(50% - 6px);min-width:0}.hdr .combo>.sel{width:100%;max-width:none}.hdr .sp{display:none}#bq{order:3;flex:1 0 100%;width:auto}.ftr{gap:6px 12px}}
452
469
  @media(hover:none){#kbd{display:none}}
453
470
  .skel{display:block;border-radius:6px;background:linear-gradient(90deg,var(--line) 25%,color-mix(in srgb,var(--line) 45%,transparent) 50%,var(--line) 75%);background-size:200% 100%;animation:skel 1.2s ease-in-out infinite}@keyframes skel{from{background-position:200% 0}to{background-position:-200% 0}}
454
471
  @media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}
@@ -473,6 +490,7 @@ a.schip:hover{color:var(--ink);border-color:color-mix(in srgb,var(--line) 60%,va
473
490
  <symbol id="i-mic" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3M9 21h6"/></symbol>
474
491
  <symbol id="i-pen" viewBox="0 0 24 24"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></symbol>
475
492
  <symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></symbol>
493
+ <symbol id="i-filter" viewBox="0 0 24 24"><path d="M4 5h16l-6 8v6l-4-2v-4z"/></symbol>
476
494
  <symbol id="i-note" viewBox="0 0 24 24"><path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h6M9 17h6"/></symbol>
477
495
  <symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14M10 11v6M14 11v6"/></symbol>
478
496
  <symbol id="i-term" viewBox="0 0 24 24"><path d="M4 17l6-5-6-5M12 19h8"/></symbol>
@@ -543,6 +561,8 @@ a.schip:hover{color:var(--ink);border-color:color-mix(in srgb,var(--line) 60%,va
543
561
  <h1>Board</h1>
544
562
  <div class="combo" id="combo"><button class="sel" id="proj" aria-label="project" aria-haspopup="listbox" aria-expanded="false"></button><div class="menu-pop" id="projpop" hidden><input class="sel" id="projq" aria-label="Search projects" placeholder="Search projects…" spellcheck="false" autocomplete="off"><div id="projs" role="listbox"></div></div></div>
545
563
  <div class="combo" id="showc" hidden><button class="sel" id="show" aria-label="Projects shown" aria-haspopup="true" aria-expanded="false"></button><div class="menu-pop" id="showpop" hidden><input class="sel" id="showq" aria-label="Search projects" placeholder="Search projects…" spellcheck="false" autocomplete="off"><div id="shows"></div><div class="pop-ft"><span id="showcount"></span><button id="showall">Show all</button></div></div></div>
564
+ <button class="ib" id="fbtn" aria-label="Filter by status" title="Filter by status" aria-expanded="false" hidden><svg><use href="#i-filter"/></svg></button>
565
+ <input type="search" class="sel" id="bq" placeholder="Filter cards /" aria-label="Filter cards" spellcheck="false" autocomplete="off">
546
566
  <span class="sp"></span>
547
567
  <button class="btn pri" id="newtop">New idea</button>
548
568
  </div>