kaizen-agent 1.33.0 → 1.34.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/web.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  launchMode, readSessions, ownerOf, resumePrompt, runAgent, firstSession,
11
11
  } from "./state.ts";
12
12
  import { attachPty, writePty, resizePty, ptyState, ptyKeys, adoptLoginEnv } from "./pty.ts";
13
- import { readSources, addSource, removeSource, moveSource, setCategory, readIcebox, promote, dismiss, markReplied, gatherSource, gatherAll, gatherDirs, due, socialUrl, socialOf } from "./sources.ts";
13
+ import { readSources, addSource, removeSource, moveSource, setCategory, readIcebox, promote, dismiss, restore, markReplied, gatherSource, gatherAll, gatherDirs, due, socialUrl, socialOf } from "./sources.ts";
14
14
  import * as chatgpt from "./chatgpt.ts";
15
15
  import { postLink, sendReply } from "./browser.ts";
16
16
  import { listNotes, saveNote, renameNote, deleteNote, boardIndex, linksTo } from "./notes.ts";
@@ -130,6 +130,11 @@ export async function web(repoDir: string, opts: Opts = {}) {
130
130
  : [state]
131
131
  ).filter((d, i, arr) => arr.indexOf(d) === i && existsSync(d));
132
132
 
133
+ // Hostnames the user put in KAIZEN_WEB_HOSTS (e.g. a `tailscale serve` name):
134
+ // their explicit opt-in, matched exactly, empty when unset.
135
+ const extraHosts = new Set((process.env.KAIZEN_WEB_HOSTS ?? "").split(",")
136
+ .map((s) => s.trim().toLowerCase().replace(/:\d+$/, "")).filter(Boolean));
137
+
133
138
  // Any tab on this machine can reach loopback. GET only reads the user's own
134
139
  // files, but a POST from a page on the wider web must not write them.
135
140
  const local = (req: Request) => {
@@ -137,7 +142,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
137
142
  if (!origin) return true; // curl, same-origin fetch on old browsers
138
143
  try {
139
144
  const h = new URL(origin).hostname;
140
- return h === "127.0.0.1" || h === "localhost" || h === "[::1]" || h === "::1";
145
+ return h === "127.0.0.1" || h === "localhost" || h === "[::1]" || h === "::1" || extraHosts.has(h);
141
146
  } catch { return false; }
142
147
  };
143
148
  const json = (body: unknown, status = 200) =>
@@ -281,8 +286,8 @@ export async function web(repoDir: string, opts: Opts = {}) {
281
286
  async fetch(req, srv) {
282
287
  // Host first: a hostname rebound to 127.0.0.1 by an attacker's DNS still
283
288
  // arrives with that hostname in Host, and gets nothing.
284
- const hostname = (req.headers.get("host") ?? "").replace(/:\d+$/, "");
285
- if (hostname !== "127.0.0.1" && hostname !== "localhost") return new Response("forbidden", { status: 403 });
289
+ const hostname = (req.headers.get("host") ?? "").toLowerCase().replace(/:\d+$/, "");
290
+ if (hostname !== "127.0.0.1" && hostname !== "localhost" && !extraHosts.has(hostname)) return new Response("forbidden", { status: 403 });
286
291
  const url = new URL(req.url);
287
292
  const path = url.pathname;
288
293
 
@@ -355,6 +360,15 @@ export async function web(repoDir: string, opts: Opts = {}) {
355
360
  return new Response(Bun.file(LOGO), { headers: { "content-type": "image/png", "cache-control": "max-age=86400" } });
356
361
  }
357
362
 
363
+ // Lets a phone add the board to its home screen as a standalone app.
364
+ if (req.method === "GET" && path === "/manifest.webmanifest") {
365
+ return new Response(JSON.stringify({
366
+ name: "kaizen", short_name: "kaizen", start_url: "/", scope: "/", display: "standalone",
367
+ background_color: "#0c162f", theme_color: "#0c162f",
368
+ icons: [{ src: "/logo.png", sizes: "1024x1024", type: "image/png", purpose: "any" }],
369
+ }), { headers: { "content-type": "application/manifest+json" } });
370
+ }
371
+
358
372
  if (req.method === "GET" && path === "/state") {
359
373
  const all = url.searchParams.get("all") === "1";
360
374
  // Before the project list: it registers folders that turn out to be set up.
@@ -417,16 +431,21 @@ export async function web(repoDir: string, opts: Opts = {}) {
417
431
  // says which project it belongs to. Nothing is assumed from where the board started.
418
432
  const dir = url.searchParams.get("dir");
419
433
  if (dir && !states(true).includes(dir)) return bad("unknown state dir", 404);
420
- const sources = [], icebox = [], failed = [];
434
+ const sources = [], icebox = [], dismissed = [], failed = [];
421
435
  for (const d of dir ? [dir] : states(true)) {
422
436
  // The seen list is bookkeeping, not something the page shows.
423
437
  try { sources.push(...readSources(d).map(({ seen, ...s }) => ({ ...s, social: socialOf(s.url), dir: d, project: label(d) }))); }
424
438
  catch { failed.push(label(d)); }
425
439
  // Only what still waits for a decision; promoted and dismissed items are record.
426
- try { icebox.push(...readIcebox(d).filter((s) => s.status === "new").map((s) => ({ ...s, post: postLink(s.notes), dir: d, project: label(d) }))); }
440
+ // Dismissed ones come apart, for the Dismissed category, where they can be restored.
441
+ try {
442
+ const all = readIcebox(d).map((s) => ({ ...s, post: postLink(s.notes), dir: d, project: label(d) }));
443
+ icebox.push(...all.filter((s) => s.status === "new"));
444
+ dismissed.push(...all.filter((s) => s.status === "dismissed"));
445
+ }
427
446
  catch { failed.push(label(d) + " (icebox)"); }
428
447
  }
429
- return json({ sources, icebox, failed, every: await every(), gatherer: { use: await setting("sources.agent"), ...chatgpt.status() } });
448
+ return json({ sources, icebox, dismissed, failed, every: await every(), gatherer: { use: await setting("sources.agent"), ...chatgpt.status() } });
430
449
  }
431
450
 
432
451
  // The Run form's choices for one project: installed tools and their models.
@@ -690,7 +709,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
690
709
  const ids = Array.isArray(body.ids) ? body.ids.filter((i: unknown) => typeof i === "string") : [];
691
710
  try {
692
711
  const why = body.op === "promote" ? promote(body.dir, ids, typeof body.text === "string" ? body.text : undefined, typeof body.details === "string" ? body.details : undefined)
693
- : body.op === "dismiss" ? dismiss(body.dir, ids) : "unknown op";
712
+ : body.op === "dismiss" ? dismiss(body.dir, ids) : body.op === "restore" ? restore(body.dir, ids) : "unknown op";
694
713
  if (why) return bad(why, why === "no such item" ? 404 : why === "unknown op" ? 400 : 409);
695
714
  } catch (e) { return bad((e as Error).message, 500); }
696
715
  changed();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.33.0",
3
+ "version": "1.34.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": {
@@ -31,7 +31,10 @@ cannot judge on its own.
31
31
  account or a key the user has not set up for this.
32
32
  3. Hand what you read to kaizen. With your file-writing tool, not the shell, write the
33
33
  requests to a new temporary file outside the project, under a name you choose: one
34
- request per line, optionally a tab and then notes for that request. Leave out any
34
+ request per line, optionally a tab and then notes for that request. A picture the source
35
+ shows beside a request goes in its notes as `img https://its-address`, one token per
36
+ image; kaizen keeps those as the item's images. Lines from `kaizen gather --posts`
37
+ already carry theirs: copy them unchanged. Leave out any
35
38
  request the source marks Done or Finish. Then run:
36
39
 
37
40
  ```
@@ -86,7 +86,9 @@ Record the track in `state.json` as `track`, with the three answers written into
86
86
 
87
87
  Create the run id as `YYYY-MM-DD-<short-kebab-slug>`. Write the request verbatim —
88
88
  do not summarize it, since later stages read this instead of the conversation and a
89
- summary is where requirements quietly disappear.
89
+ summary is where requirements quietly disappear. The board also finds a run's card by
90
+ this text: a request that does not contain the user's words leaves their idea as a
91
+ second card. Context you add goes below the verbatim text, never in place of it.
90
92
 
91
93
  Verbatim includes the mess. Requests arrive as thinking out loud: half-formed,
92
94
  out of order, self-correcting, with the decisive constraint tucked into an aside near
@@ -110,6 +112,8 @@ Write into `00-request.md`: the verbatim request, the deliverable, what done mea
110
112
  and what wrong means. Record in `state.json`: `id`, `mode`, `runner`, `agent`, `track`,
111
113
  `stage: "plan"`, `awaiting: null`, `iteration: 0`, and `updated` — the time of this
112
114
  write, refreshed on every later one.
115
+ Where the environment has `CLAUDE_CODE_SESSION_ID`, also record it as `session`: the
116
+ board keeps the run on the card it was launched from by that id.
113
117
 
114
118
  `stage` takes exactly one of five words, and boards key their columns on them:
115
119
  `plan`, `build`, `review`, `done`, `abandoned`. Set it when the stage starts —
@@ -134,7 +134,9 @@ export async function openSources() {
134
134
  return "link";
135
135
  };
136
136
  const list = (d && d.sources) || [],
137
- ice = (d && d.icebox) || [],
137
+ // Dismissed items ride along, shown only under their own category.
138
+ ice = [...((d && d.icebox) || []), ...((d && d.dismissed) || [])],
139
+ waiting = ((d && d.icebox) || []).length,
138
140
  G = (d && d.gatherer) || {},
139
141
  chatOn = G.use === "chatgpt" && G.connected;
140
142
  // A source belongs to one project, named on its row. Picking another only arms Move: a select
@@ -186,7 +188,7 @@ export async function openSources() {
186
188
  srcKey = (it) => it.dir + " " + it.source;
187
189
  // Filters narrow in turn: project (the board's own picker, all projects only), then
188
190
  // category, then source; each chip counts what the filters before it leave.
189
- const catOf = (it) => it.category || "Uncategorised",
191
+ const catOf = (it) => (it.status === "dismissed" ? "Dismissed" : it.category || "Uncategorised"),
190
192
  count = (xs, f) => {
191
193
  const m = new Map();
192
194
  xs.forEach((it) => {
@@ -213,7 +215,7 @@ export async function openSources() {
213
215
  if (iceForm && !keys.has(iceForm)) iceForm = null;
214
216
  const groups = new Map();
215
217
  ice.forEach((it, i) => {
216
- if (!inProj(it) || (iceCat && catOf(it) !== iceCat) || (iceSrc && srcKey(it) !== iceSrc)) return;
218
+ if (!inProj(it) || (iceCat ? catOf(it) !== iceCat : it.status === "dismissed") || (iceSrc && srcKey(it) !== iceSrc)) return;
217
219
  const k = catOf(it);
218
220
  groups.has(k) || groups.set(k, []);
219
221
  groups.get(k).push(i);
@@ -227,20 +229,13 @@ export async function openSources() {
227
229
  }
228
230
  if (!order.some((i) => iceKey(ice[i]) === iceCur)) iceCur = order.length ? iceKey(ice[order[0]]) : null;
229
231
  const nSel = order.filter((i) => iceSel.has(iceKey(ice[i]))).length;
230
- // Filters are plain dropdowns, each shown only when it has a choice to make. The project one
231
- // is the board's own picker: one project, or all; several picked on the board read as such.
232
+ // Filters are plain dropdowns, each shown only when it has a choice to make. Projects are
233
+ // narrowed by the board's own Showing picker above, not here.
232
234
  const many = byProj.size > 1,
233
235
  opt = (v, on, t) => `<option value="${esc(v)}"${on ? " selected" : ""}>${esc(t)}</option>`;
234
236
  const pick = (id, label, xs) =>
235
237
  `<select class="sel ice-sel" id="${id}" aria-label="${label}">${xs.join("")}</select>`;
236
238
  const chips =
237
- (many
238
- ? pick("ice-fp", "Project", [
239
- opt("", !store.shown.size, "All projects"),
240
- ...(store.shown.size > 1 ? [opt("*", true, store.shown.size + " projects")] : []),
241
- ...[...byProj].map(([k, { it }]) => opt(k, store.shown.size === 1 && store.shown.has(k), it.project || k)),
242
- ])
243
- : "") +
244
239
  (byCat.size > 1
245
240
  ? pick("ice-fc", "Category", [
246
241
  opt("", !iceCat, "All categories"),
@@ -253,7 +248,7 @@ export async function openSources() {
253
248
  ...[...bySrc].map(([k, { it }]) => opt(k, iceSrc === k, srcName(it))),
254
249
  ])
255
250
  : "");
256
- const bar = `<div class="ice-bar">${order.length ? `<input type="checkbox" id="ice-all" aria-label="Select every request shown"${nSel && nSel === order.length ? " checked" : ""}>` : ""}<span class="count">${order.length < ice.length ? `${order.length} of ${ice.length}` : ice.length} waiting</span>${chips}<span class="end"></span><button class="btn sm" id="src-go"${list.length ? "" : " disabled"}>${ico("refresh")} Gather all</button><button class="btn ghost sm" id="src-tog" aria-expanded="${srcShow ?? !list.length}" aria-controls="src-sec">${ico("link")} Sources · ${list.length}</button></div>`;
251
+ const bar = `<div class="ice-bar">${order.length ? `<input type="checkbox" id="ice-all" aria-label="Select every request shown"${nSel && nSel === order.length ? " checked" : ""}>` : ""}<span class="count">${iceCat === "Dismissed" ? `${order.length} dismissed` : `${order.length < waiting ? `${order.length} of ${waiting}` : waiting} waiting`}</span>${chips}<span class="end"></span><button class="btn sm" id="src-go"${list.length ? "" : " disabled"}>${ico("refresh")} Gather all</button><button class="btn ghost sm" id="src-tog" aria-expanded="${srcShow ?? !list.length}" aria-controls="src-sec">${ico("link")} Sources · ${list.length}</button></div>`;
257
252
  const bulk = iceSel.size
258
253
  ? `<div class="ice-bulk" role="toolbar" aria-label="Selected requests"><span>${iceSel.size} selected</span><span class="end"></span><button class="btn sm" id="ice-bp" title="Each becomes its own idea, titled as gathered (p)">${ico("plus")} Promote</button><button class="btn ghost sm" id="ice-bd" title="Dismiss (d)">${ico("x")} Dismiss</button><button class="btn ghost sm" id="ice-bc" title="Clear selection (Esc)">Clear</button></div>`
259
254
  : "";
@@ -282,16 +277,16 @@ export async function openSources() {
282
277
  k = iceKey(it),
283
278
  open = iceForm === k;
284
279
  return (
285
- `<div class="ice-row${iceCur === k ? " cur" : ""}${iceSel.has(k) ? " sel" : ""}" data-i="${i}" data-k="${esc(k)}"><input type="checkbox" id="ice-ck-${i}" data-ck="${i}"${iceSel.has(k) ? " checked" : ""} aria-label="Select: ${esc(it.text)}"><span class="ice-src" title="${esc(srcName(it))}">${fav(it.source, "link", list.find((x) => x.dir === it.dir && x.url === it.source)?.social)}</span><button class="ice-t" id="ice-o-${i}" data-o="${i}" aria-expanded="${open}" title="${esc(it.text)}">${esc(it.text)}</button><span class="ice-meta">${many ? `<span class="nm">${esc(it.project || "")}</span>` : ""}${it.replied ? `<span title="Replied ${esc(ago(it.replied))}">replied</span>` : ""}<span class="ice-age">${ago(it.at)}</span><span class="ice-ra"><button class="btn ghost sm" id="ice-pr-${i}" data-pr="${i}" title="Promote to Ideas as gathered (p)" aria-label="Promote to Ideas: ${esc(it.text)}">${ico("plus")}</button><button class="btn ghost sm" id="ice-ds-${i}" data-ds="${i}" title="Dismiss (d)" aria-label="Dismiss: ${esc(it.text)}">${ico("x")}</button></span></span></div>` +
280
+ `<div class="ice-row${iceCur === k ? " cur" : ""}${iceSel.has(k) ? " sel" : ""}" data-i="${i}" data-k="${esc(k)}"><input type="checkbox" id="ice-ck-${i}" data-ck="${i}"${iceSel.has(k) ? " checked" : ""} aria-label="Select: ${esc(it.text)}"><span class="ice-src" title="${esc(srcName(it))}">${fav(it.source, "link", list.find((x) => x.dir === it.dir && x.url === it.source)?.social)}</span><button class="ice-t" id="ice-o-${i}" data-o="${i}" aria-expanded="${open}" title="${esc(it.text)}">${esc(it.text)}</button><span class="ice-meta">${many ? `<span class="nm">${esc(it.project || "")}</span>` : ""}${it.replied ? `<span title="Replied ${esc(ago(it.replied))}">replied</span>` : ""}<span class="ice-age">${ago(it.at)}</span><span class="ice-ra"><button class="btn ghost sm" id="ice-pr-${i}" data-pr="${i}" title="Promote to Ideas as gathered (p)" aria-label="Promote to Ideas: ${esc(it.text)}">${ico("plus")}</button>${it.status === "dismissed" ? `<button class="btn ghost sm" id="ice-rs-${i}" data-rs="${i}" title="Restore to waiting" aria-label="Restore: ${esc(it.text)}">Restore</button>` : `<button class="btn ghost sm" id="ice-ds-${i}" data-ds="${i}" title="Dismiss (d)" aria-label="Dismiss: ${esc(it.text)}">${ico("x")}</button>`}</span></span></div>` +
286
281
  (open
287
- ? `<div class="ice-open">${it.notes ? `<div class="ice-notes">${esc(it.notes)}</div>` : ""}<span class="hint" style="margin:0">From ${esc(srcName(it))} · ${ago(it.at)}${it.post ? ` · <a href="${esc(it.post)}" target="_blank" rel="noopener">open post</a>` : ""}</span>${replyBox(it)}${form(it)}</div>`
282
+ ? `<div class="ice-open">${it.notes ? `<div class="ice-notes">${esc(it.notes)}</div>` : ""}${(it.images || []).filter((u) => /^https:\/\//.test(u)).length ? `<details class="ice-imgs"><summary>Show images (${it.images.filter((u) => /^https:\/\//.test(u)).length}) — loads them from the source's site</summary>${it.images.filter((u) => /^https:\/\//.test(u)).map((u) => `<a href="${esc(u)}" target="_blank" rel="noopener noreferrer"><img data-src="${esc(u)}" alt="" referrerpolicy="no-referrer"></a>`).join("")}</details>` : ""}<span class="hint" style="margin:0">From ${esc(srcName(it))} · ${ago(it.at)}${it.post ? ` · <a href="${esc(it.post)}" target="_blank" rel="noopener">open post</a>` : ""}</span>${replyBox(it)}${form(it)}</div>`
288
283
  : "")
289
284
  );
290
285
  };
291
286
  const inbox =
292
- ice.length && !order.length
293
- ? `<div class="ice-zero"><h4>Nothing matches</h4><p>${ice.length} waiting outside these filters.</p><p style="margin-top:10px"><button class="btn sm" id="ice-unf">Clear filters</button></p></div>`
294
- : ice.length
287
+ waiting && !order.length
288
+ ? `<div class="ice-zero"><h4>Nothing matches</h4><p>${waiting} waiting outside these filters.</p><p style="margin-top:10px"><button class="btn sm" id="ice-unf">Clear filters</button></p></div>`
289
+ : order.length
295
290
  ? `<div class="ice-list">${[...groups].map(([g, ix]) => `<div class="ice-cat">${esc(g)}<i>${ix.length}</i></div>${ix.map(row).join("")}`).join("")}</div>`
296
291
  : `<div class="ice-zero"><h4>Inbox zero</h4><p>Nothing waiting. Gather all checks your sources for new requests; /kaizen discover in your agent sorts what comes in.</p></div>`;
297
292
  // The sources, in the right-hand drawer: shut until asked for, open by itself while there
@@ -398,14 +393,6 @@ export async function openSources() {
398
393
  iceCat = e.target.value;
399
394
  openSources();
400
395
  };
401
- if ($("ice-fp"))
402
- $("ice-fp").onchange = (e) => {
403
- const v = e.target.value;
404
- if (v === "*") return;
405
- store.shown = new Set(v ? [v] : []);
406
- setShown();
407
- openSources();
408
- };
409
396
  if ($("ice-unf"))
410
397
  $("ice-unf").onclick = () => {
411
398
  iceCat = iceSrc = "";
@@ -573,6 +560,10 @@ export async function openSources() {
573
560
  $("pbody")
574
561
  .querySelectorAll("[data-o]")
575
562
  .forEach((b) => (b.onclick = () => toggle(+b.dataset.o)));
563
+ // Images from a source load only when asked: fetching one tells its host the item was opened.
564
+ $("pbody")
565
+ .querySelectorAll("details.ice-imgs")
566
+ .forEach((d) => (d.ontoggle = () => d.open && d.querySelectorAll("img[data-src]").forEach((i) => (i.src = i.dataset.src))));
576
567
  $("pbody")
577
568
  .querySelectorAll("[data-pr]")
578
569
  .forEach(
@@ -591,6 +582,15 @@ export async function openSources() {
591
582
  iceAct("dismiss", ice[+b.dataset.ds], "Dismissed");
592
583
  }),
593
584
  );
585
+ $("pbody")
586
+ .querySelectorAll("[data-rs]")
587
+ .forEach(
588
+ (b) =>
589
+ (b.onclick = () => {
590
+ b.disabled = true;
591
+ iceAct("restore", ice[+b.dataset.rs], "Restored");
592
+ }),
593
+ );
594
594
  const open1 = ice.find((it) => iceKey(it) === iceForm);
595
595
  if ($("ice-rp"))
596
596
  $("ice-rp").onclick = async () => {
@@ -2,7 +2,7 @@ import { store } from "./store.ts";
2
2
  import { load, post } from "./api.ts";
3
3
  import { render } from "./cards.ts";
4
4
  import { $, esc } from "./dom.ts";
5
- import { openSources } from "./icebox.ts";
5
+ import { openSources, srcOpen } from "./icebox.ts";
6
6
  import { toast } from "./toast.ts";
7
7
  import { view } from "./views.ts";
8
8
 
@@ -144,6 +144,8 @@ export function setShown() {
144
144
  localStorage.setItem("kz-show", JSON.stringify([...store.shown]));
145
145
  } catch (e) {}
146
146
  render();
147
+ // The Icebox narrows by the same picker, so it redraws too.
148
+ if (srcOpen()) openSources();
147
149
  }
148
150
 
149
151
  export function initProjects() {
package/web/board.html CHANGED
@@ -2,8 +2,15 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width,initial-scale=1">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
6
6
  <title>kaizen</title>
7
+ <link rel="manifest" href="/manifest.webmanifest">
8
+ <meta name="theme-color" content="#f6f8fc" media="(prefers-color-scheme: light)">
9
+ <meta name="theme-color" content="#0c162f" media="(prefers-color-scheme: dark)">
10
+ <meta name="mobile-web-app-capable" content="yes">
11
+ <meta name="apple-mobile-web-app-capable" content="yes">
12
+ <meta name="apple-mobile-web-app-title" content="kaizen">
13
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
7
14
  <link rel="icon" href="/favicon.ico" sizes="any">
8
15
  <link rel="icon" type="image/png" href="/logo.png">
9
16
  <link rel="apple-touch-icon" href="/logo.png">
@@ -49,7 +56,7 @@ svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.6;stroke
49
56
  /* end side menu */
50
57
  /* the work area: an inset rounded panel on the canvas the side menu sits on */
51
58
  .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}
52
- @media(max-width:640px){.app{margin:0;border:0;border-radius:0}}
59
+ @media(max-width:640px){.app{margin:0;border:0;border-radius:0}body{padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}aside:not(.docked){padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) 0}}
53
60
  /* header */
54
61
  .hdr{display:flex;align-items:center;gap:12px;padding:10px 20px 10px 28px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);flex-wrap:wrap}
55
62
  pre.mark{margin:0;font:700 8px/1 var(--mono)}
@@ -378,6 +385,7 @@ body:not(.paged) .src-sec,body:not(.paged) .src-scrim{display:none}
378
385
  .ice-form-acts .sel{width:auto;flex:none}
379
386
  .ice-form-acts .end{flex:1}
380
387
  .ice-form-acts .btn{white-space:nowrap}
388
+ .ice-imgs{font-size:12px;color:var(--dim)}.ice-imgs[open]{display:flex;gap:6px;flex-wrap:wrap}.ice-imgs summary{cursor:pointer;flex-basis:100%}.ice-imgs img{height:72px;max-width:120px;object-fit:cover;border-radius:var(--r-s);border:1px solid var(--line)}
381
389
  .ice-notes{font-size:12px;color:var(--dim);line-height:1.45;overflow-wrap:anywhere;white-space:pre-wrap}
382
390
  .src-empty{padding:26px 16px;text-align:center;background:var(--bg);border:1px dashed var(--line);border-radius:var(--r);margin:12px 0 16px}
383
391
  .src-empty-ico{width:36px;height:36px;margin:0 auto 10px;color:var(--dim);display:flex;align-items:center;justify-content:center}