kaizen-agent 1.7.9 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +694 -658
  3. package/agents/kaizen-builder.md +82 -77
  4. package/agents/kaizen-planner.md +250 -250
  5. package/agents/kaizen-reviewer.md +127 -127
  6. package/cli/board.test.ts +55 -0
  7. package/cli/dashboard.ts +1188 -1187
  8. package/cli/install.ts +76 -3
  9. package/cli/publish-gh.sh +27 -27
  10. package/cli/release.sh +45 -45
  11. package/cli/settings.ts +7 -3
  12. package/cli/sources.test.ts +494 -0
  13. package/cli/sources.ts +323 -0
  14. package/cli/state.test.ts +427 -197
  15. package/cli/state.ts +1013 -700
  16. package/cli/web.ts +188 -11
  17. package/commands/kaizen-abort.md +5 -5
  18. package/commands/kaizen-approve.md +5 -5
  19. package/commands/kaizen-auto.md +6 -6
  20. package/commands/kaizen-backlog.md +5 -5
  21. package/commands/kaizen-config.md +5 -5
  22. package/commands/kaizen-full.md +5 -5
  23. package/commands/kaizen-gather.md +5 -0
  24. package/commands/kaizen-init.md +5 -5
  25. package/commands/kaizen-install.md +5 -5
  26. package/commands/kaizen-lite.md +5 -5
  27. package/commands/kaizen-plan.md +6 -6
  28. package/commands/kaizen-reject.md +6 -6
  29. package/commands/kaizen-review.md +6 -6
  30. package/commands/kaizen-run.md +5 -5
  31. package/commands/kaizen-status.md +5 -5
  32. package/package.json +3 -1
  33. package/skills/kaizen/README.md +283 -281
  34. package/skills/kaizen/SKILL.md +334 -440
  35. package/skills/kaizen/adapters.md +84 -77
  36. package/skills/kaizen/config.default.yml +181 -167
  37. package/skills/kaizen/gather.md +50 -0
  38. package/skills/kaizen/install.md +42 -0
  39. package/skills/kaizen/settings.md +52 -0
  40. package/skills/kaizen/spec-build.md +131 -119
  41. package/skills/kaizen/spec-main.md +109 -104
  42. package/skills/kaizen/spec-plan.md +201 -201
  43. package/skills/kaizen/spec-review.md +121 -121
  44. package/skills/kaizen/spec.md +154 -153
  45. package/skills/kaizen/status.md +43 -0
  46. package/skills/kaizen-help/SKILL.md +131 -129
  47. package/web/board.html +296 -125
package/web/board.html CHANGED
@@ -9,21 +9,36 @@
9
9
  @media(prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:#0c162f;--bg-2:#121e3c;--line:#22305a;--ink:#eef2fa;--ink-hi:#deeeff;--dim:#96aacd;--accent:#486ed9;--run:#4fd6e6;--ok:#5fd28a;--warn:#e6b450;--danger:#e85050;color-scheme:dark}}
10
10
  :root[data-theme=dark]{--bg:#0c162f;--bg-2:#121e3c;--line:#22305a;--ink:#eef2fa;--ink-hi:#deeeff;--dim:#96aacd;--accent:#486ed9;--run:#4fd6e6;--ok:#5fd28a;--warn:#e6b450;--danger:#e85050;color-scheme:dark}
11
11
  *{box-sizing:border-box}
12
- :root{--r:6px;--r-s:4px}
13
- body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 ui-monospace,"JetBrains Mono","Cascadia Code","SF Mono",Menlo,Consolas,monospace;height:100vh;height:100dvh;overflow:hidden;display:flex;flex-direction:column}
12
+ /* Shape, type and depth. --shade is the one shadow colour, themed like the rest. */
13
+ :root{--r:10px;--r-s:6px;--r-pill:999px;--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;--mono:ui-monospace,"JetBrains Mono","Cascadia Code","SF Mono",Menlo,Consolas,monospace;--on-accent:#fff;--hover:color-mix(in srgb,var(--dim) 12%,transparent);--shade:rgb(12 22 47/.1);--shadow-1:0 1px 2px var(--shade);--shadow-2:0 2px 6px var(--shade),0 16px 40px -12px var(--shade)}
14
+ @media(prefers-color-scheme:dark){:root:not([data-theme=light]){--shade:rgb(0 0 0/.45)}}
15
+ :root[data-theme=dark]{--shade:rgb(0 0 0/.45)}
16
+ body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 var(--font);-webkit-font-smoothing:antialiased;height:100vh;height:100dvh;overflow:hidden;display:flex;flex-direction:column}
14
17
  button,input,select{font:inherit;color:inherit;background:none;border:0;padding:0}
15
18
  button{cursor:pointer}
16
19
  :focus-visible{outline:2px solid var(--accent);outline-offset:2px}
17
20
  svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;display:block}
18
21
 
19
22
  /* header */
20
- .hdr{display:flex;align-items:center;gap:16px;padding:10px 24px;border-bottom:1px solid var(--line);flex-wrap:wrap}
21
- pre.mark{margin:0;font-size:8px;line-height:1;font-weight:700}
23
+ .hdr{display:flex;align-items:center;gap:16px;padding:14px 28px;border-bottom:1px solid var(--line);background:var(--bg-2);flex-wrap:wrap}
24
+ pre.mark{margin:0;font:700 8px/1 var(--mono)}
22
25
  pre.mark span:nth-child(1){color:var(--ink-hi)}pre.mark span:nth-child(2){color:color-mix(in srgb,var(--ink-hi),var(--accent))}pre.mark span:nth-child(3){color:var(--accent)}
23
- .sel{border:1px solid var(--line);border-radius:var(--r);padding:5px 8px;background:var(--bg-2);cursor:pointer;color:var(--ink);appearance:auto;font-size:13px}
26
+ .sel{border:1px solid var(--line);border-radius:var(--r-s);padding:6px 12px;background-color:var(--bg-2);color:var(--ink);font-size:13px}
27
+ select.sel,button.sel{cursor:pointer;appearance:none;padding-right:30px;max-width:100%;background-image:linear-gradient(45deg,transparent 50%,var(--dim) 50%),linear-gradient(135deg,var(--dim) 50%,transparent 50%);background-position:calc(100% - 16px) 50%,calc(100% - 11px) 50%;background-size:5px 5px;background-repeat:no-repeat}
28
+ .sel:hover{border-color:var(--dim)}
29
+ .hdr .sel{max-width:min(320px,100%);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left}
30
+ /* project picker: a select-shaped button over a searchable list */
31
+ .combo{position:relative;max-width:100%;display:flex}
32
+ .combo .menu-pop{left:0;right:auto;top:calc(100% + 4px);width:320px;max-width:calc(100vw - 32px);gap:6px;z-index:9}
33
+ .combo .menu-pop[hidden]{display:none}
34
+ .combo input.sel{width:100%;max-width:none}
35
+ #projs{display:flex;flex-direction:column;max-height:300px;overflow-y:auto}
36
+ #projs button{display:block;flex:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
37
+ #projs button.on{background:var(--hover)}
38
+ #projs .grp{padding:8px 10px 4px;font-size:11px;color:var(--dim)}
24
39
  .sp{flex:1}
25
- .ib{width:34px;height:34px;display:grid;place-items:center;color:var(--dim);border:1px solid transparent;border-radius:var(--r)}
26
- .ib:hover{color:var(--ink);border-color:var(--line)}
40
+ .ib{width:34px;height:34px;display:grid;place-items:center;color:var(--dim);border-radius:var(--r-s);transition:background .12s,color .12s}
41
+ .ib:hover{color:var(--ink);background:var(--hover)}
27
42
  .ib.pri{color:var(--accent)}
28
43
  .ib.sm{width:26px;height:26px}.ib.sm svg{width:15px;height:15px}
29
44
  /* dictation mic: accent while listening (pulsing) or still transcribing; digits while the model downloads */
@@ -32,45 +47,47 @@ pre.mark span:nth-child(1){color:var(--ink-hi)}pre.mark span:nth-child(2){color:
32
47
  /* the mic button turns into a level meter while listening: five bars, each weighted so the set reads as a wave */
33
48
  .vu{display:inline-flex;gap:2px;align-items:center;height:100%}.vu i{width:2px;border-radius:1px;background:currentColor;height:calc(3px + 12px*min(1,var(--lvl,0)*var(--w)));transition:height .08s}
34
49
  .ib.mic.on{color:var(--accent)}.ib.mic.busy{color:var(--accent)}.ib.mic i{font-style:normal;font-size:10px;font-variant-numeric:tabular-nums}.row .mic{margin-right:auto;align-self:center}
35
- .btn{border:1px solid var(--line);border-radius:var(--r);padding:5px 12px;color:var(--ink);background:var(--bg-2);min-height:30px;font-size:13px}
50
+ .btn{border:1px solid var(--line);border-radius:var(--r-s);padding:6px 14px;color:var(--ink);background:var(--bg-2);min-height:32px;font-size:13px;font-weight:500;box-shadow:var(--shadow-1);transition:background .12s,border-color .12s,color .12s}
36
51
  .btn:hover{border-color:var(--dim)}
37
- .btn.pri{background:var(--accent);border-color:var(--accent);color:#fff}
52
+ .btn.pri{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}.btn.pri:hover{box-shadow:var(--shadow-2)}
38
53
  .btn.warn{border-color:var(--warn);color:var(--warn)}.btn.warn:hover{background:var(--warn);color:var(--bg)}
39
54
  .btn.danger{color:var(--danger)}.btn.danger:hover{border-color:var(--danger)}
40
- .btn.ghost{background:none;border-color:transparent;color:var(--dim)}.btn.ghost:hover{color:var(--ink);border-color:var(--line)}
55
+ .btn.ghost{background:none;border-color:transparent;color:var(--dim);box-shadow:none}.btn.ghost:hover{color:var(--ink);background:var(--hover)}
41
56
 
42
- /* summary strip */
43
- .strip{display:flex;gap:18px;padding:8px 24px;border-bottom:1px solid var(--line);flex-wrap:wrap;align-items:center;font-size:13px;color:var(--dim)}
44
- .strip button{color:var(--dim);padding:2px 8px;border-radius:var(--r-s)}
45
- .strip button:hover,.strip button.on{color:var(--ink);background:color-mix(in srgb,var(--dim) 12%,transparent)}
46
- .strip button.attn{color:var(--warn)}
57
+ /* summary strip: each count is a pill that filters the board */
58
+ .strip{display:flex;gap:8px;padding:10px 28px;flex-wrap:wrap;align-items:center;font-size:13px;color:var(--dim)}
59
+ .strip button{display:inline-flex;align-items:center;gap:6px;color:var(--dim);padding:4px 12px;border-radius:var(--r-pill);box-shadow:inset 0 0 0 1px var(--line);transition:background .12s,color .12s}
60
+ .strip button:hover,.strip button.on{color:var(--ink);background:var(--hover)}
61
+ .strip button.attn{color:var(--warn);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--warn) 45%,transparent)}
62
+ .strip svg{width:14px;height:14px;stroke-width:1.8}
47
63
 
48
64
  /* board */
49
- main{flex:1;min-height:0;display:grid;grid-template-columns:repeat(5,minmax(200px,1fr));gap:20px;padding:0 24px 16px;overflow:auto;align-content:start}
65
+ main{flex:1;min-height:0;display:grid;grid-template-columns:repeat(5,minmax(200px,1fr));gap:24px;padding:0 28px 20px;overflow:auto;align-content:start}
50
66
  section h2::before{content:"";display:block;height:16px;margin-top:-16px}
51
67
  section{padding-top:16px}
52
- section h2{margin:0;font-size:12px;color:var(--ink);font-weight:600;display:flex;align-items:center;gap:8px;height:30px;position:sticky;top:0;background:var(--bg);z-index:1}
53
- section h2 i{font-style:normal;font-weight:400}
68
+ section h2{margin:0;font-size:13px;color:var(--ink);font-weight:600;display:flex;align-items:center;gap:8px;height:30px;position:sticky;top:0;background:var(--bg);z-index:1}
69
+ section h2 i{font-style:normal;font-weight:500;font-size:12px;color:var(--ink);background:var(--hover);border-radius:var(--r-pill);padding:0 7px;min-width:20px;text-align:center}
70
+ section h2 i:empty{display:none}
54
71
  section h2 .ib{margin-left:auto}
55
- ul{list-style:none;margin:0;padding:8px 0 0;display:flex;flex-direction:column;gap:8px}
72
+ ul{list-style:none;margin:0;padding:8px 0 0;display:flex;flex-direction:column;gap:10px}
56
73
 
57
74
  /* card, Linear grammar in our palette
58
- line 1 status icon · id (dim mono) · ⋯ menu on hover
59
- line 2 title, regular weight, wraps to 2 lines
75
+ line 1 status icon · id (dim) · ⋯ menu on hover
76
+ line 2 title, medium weight, wraps to 2 lines
60
77
  line 3 chips, only when set
61
- Card ground is --bg-2 on a --bg column. Actions never sit on the card. */
62
- .card{background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r);padding:10px 12px;cursor:pointer;display:flex;flex-direction:column;gap:6px;position:relative}
63
- .card:hover{border-color:var(--dim)}
64
- .card.open{border-color:var(--accent)}
78
+ Card ground is --bg-2 on a --bg column, lifted by --shadow-1. Actions never sit on the card. */
79
+ .card{background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r);padding:12px 14px;cursor:pointer;display:flex;flex-direction:column;gap:6px;position:relative;box-shadow:var(--shadow-1);transition:box-shadow .12s,border-color .12s}
80
+ .card:hover{box-shadow:var(--shadow-2);border-color:color-mix(in srgb,var(--dim) 50%,var(--line))}
81
+ .card.menu{z-index:2}
82
+ .card.open{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}
65
83
  .card.archived .c-title,.card.archived .c-id{opacity:.7}
66
84
  section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
67
- section h2{position:sticky}
68
85
  .clear-pop{top:30px;right:0;min-width:200px}
69
- .clear-pop button i{font-style:normal;color:var(--dim);margin-left:auto}
86
+ .clear-pop button i{font-style:normal;color:var(--dim);margin-left:auto;background:none;padding:0}
70
87
  .pickbar{margin-left:auto;display:flex;gap:4px}
71
88
  .pickbar .btn.sm{min-height:22px;padding:0 8px}
72
- .btn[disabled]{opacity:.5;cursor:default}
73
- .card .pick{position:absolute;right:12px;top:12px;margin:0;accent-color:var(--accent)}
89
+ .btn[disabled],.chk[disabled]{opacity:.5;cursor:default}
90
+ .card .pick{position:absolute;right:14px;top:14px;margin:0;accent-color:var(--accent)}
74
91
  .card.picked{border-color:var(--accent)}
75
92
  .card:has(.pick) .age,.card:has(.pick) .more{display:none}
76
93
  .c-id{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dim);min-height:20px}
@@ -81,14 +98,15 @@ section h2{position:sticky}
81
98
  .c-id .more{display:none;margin:-4px -6px -4px 0}
82
99
  .card:hover .more,.card:focus-within .more,.card.menu .more{display:grid}
83
100
  .card:hover .age,.card.menu .age{display:none}
84
- .c-title{font-size:13px;line-height:1.45;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;color:var(--ink)}
85
- .c-chips{display:flex;gap:6px;flex-wrap:wrap}
101
+ .c-title{font-size:14px;font-weight:500;line-height:1.45;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;color:var(--ink)}
102
+ .c-chips{display:flex;gap:6px;flex-wrap:wrap;margin-top:2px}
86
103
  .c-chips:empty{display:none}
87
- .chip{display:inline-flex;align-items:center;gap:5px;height:20px;padding:0 7px;border-radius:var(--r-s);font-size:11px;color:var(--dim);background:color-mix(in srgb,var(--dim) 12%,transparent)}
104
+ .chip{display:inline-flex;align-items:center;gap:5px;min-height:22px;padding:2px 9px;border-radius:var(--r);overflow-wrap:anywhere;font-size:11px;font-weight:500;color:var(--dim);background:var(--hover)}
88
105
  .chip i{width:6px;height:6px;border-radius:50%;background:var(--dim)}
89
106
  .chip svg{width:11px;height:11px;stroke-width:1.7}
90
107
  .chip.warn{color:var(--warn);background:color-mix(in srgb,var(--warn) 14%,transparent)}.chip.warn i{background:var(--warn)}
91
108
  .chip.accent{color:var(--accent);background:color-mix(in srgb,var(--accent) 14%,transparent)}.chip.accent i{background:var(--accent)}
109
+ .chip.ok{color:color-mix(in srgb,var(--ok) 80%,var(--ink));background:color-mix(in srgb,var(--ok) 14%,transparent)}
92
110
  .chip.run{color:var(--run);background:color-mix(in srgb,var(--run) 14%,transparent)}.chip.run i{background:var(--run)}
93
111
  .st.d-idea{color:var(--dim)}.st.d-run{color:var(--run)}.st.d-stall{color:var(--dim)}.st.d-wait{color:var(--warn)}.st.d-done{color:var(--ok)}.st.d-aband{color:var(--dim)}
94
112
  .d-run{color:var(--run)}.d-wait{color:var(--warn)}.d-done{color:var(--ok)}
@@ -96,75 +114,87 @@ section h2{position:sticky}
96
114
  .d-run .pulse,.st.d-run svg{animation:pl 1.6s infinite}
97
115
  @keyframes pl{50%{opacity:.35}}
98
116
  /* hover menu */
99
- .menu-pop{position:absolute;right:8px;top:30px;background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r);padding:4px;z-index:3;min-width:140px;display:flex;flex-direction:column}
100
- .menu-pop button{text-align:left;padding:6px 10px;font-size:12px;color:var(--ink);border-radius:var(--r-s);display:flex;gap:8px;align-items:center}
101
- .menu-pop button:hover{background:color-mix(in srgb,var(--dim) 12%,transparent)}
117
+ .menu-pop{position:absolute;right:8px;top:34px;background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r);padding:6px;z-index:3;min-width:160px;display:flex;flex-direction:column;box-shadow:var(--shadow-2);animation:fi .12s}
118
+ .menu-pop button{text-align:left;padding:7px 10px;font-size:13px;font-weight:400;color:var(--ink);border-radius:var(--r-s);display:flex;gap:8px;align-items:center}
119
+ .menu-pop button:hover{background:var(--hover)}
102
120
  .menu-pop button.danger{color:var(--danger)}
121
+ .menu-pop svg{width:15px;height:15px}
103
122
  .menu-pop hr{border:0;border-top:1px solid var(--line);margin:4px 0}
104
123
  .more-btn{width:100%;padding:8px;color:var(--dim);font-size:12px;border:1px dashed var(--line);border-radius:var(--r)}
105
124
  .more-btn:hover{color:var(--ink);border-color:var(--dim)}
106
125
 
107
126
  /* inline forms replace a card while editing */
108
- .inl{display:flex;flex-direction:column;gap:8px;padding:10px 12px;background:var(--bg-2);border:1px solid var(--accent);border-radius:var(--r)}
109
- .inl input,.inl textarea,.notes textarea{width:100%;border:1px solid var(--line);border-radius:var(--r);padding:7px 10px;background:var(--bg-2);font:inherit;color:var(--ink)}
127
+ .inl{display:flex;flex-direction:column;gap:10px;padding:14px;background:var(--bg-2);border:1px solid var(--accent);border-radius:var(--r);box-shadow:var(--shadow-2)}
128
+ .inl input,.inl textarea,.notes textarea{width:100%;border:1px solid var(--line);border-radius:var(--r-s);padding:8px 12px;background:var(--bg-2);font:inherit;color:var(--ink)}
129
+ .inl input:focus,.inl textarea:focus,.notes textarea:focus,.sel:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 22%,transparent)}
110
130
  .inl textarea,.notes textarea{min-height:72px;resize:vertical}
111
131
  .notes textarea{min-height:160px}
112
132
  .inl input::placeholder,.inl textarea::placeholder,.notes textarea::placeholder{color:var(--dim)}
113
133
  .inl .row,.notes .row{display:flex;gap:6px;justify-content:flex-end}
114
134
  .notes{display:flex;flex-direction:column;gap:8px}
115
- .chat{display:flex;flex-direction:column;gap:10px;margin:0 0 14px}.msg{border:1px solid var(--line);border-radius:var(--r);padding:8px 10px;background:var(--bg-2)}.msg time{display:block;font-size:11px;color:var(--dim);margin-bottom:4px}.msg p:last-child{margin-bottom:0}.msg img{max-width:100%;max-height:260px;border-radius:4px;display:block;margin:4px 0}
135
+ .chat{display:flex;flex-direction:column;gap:10px;margin:0 0 14px}.msg{border:1px solid var(--line);border-radius:var(--r);padding:10px 12px;background:var(--bg)}.msg time{display:block;font-size:11px;color:var(--dim);margin-bottom:4px}.msg p:last-child{margin-bottom:0}.msg img{max-width:100%;max-height:260px;border-radius:var(--r-s);display:block;margin:4px 0}
116
136
  .notes .tools{display:flex;gap:6px;align-items:center}.notes .tools span{flex:1;font-size:11px;color:var(--dim)}.notes .att{font-size:11px;color:var(--dim)}
117
- .inl .q{font-size:13px}.inl .q.dim{color:var(--dim)}
118
- .empty{grid-column:1/-1;display:flex;flex-direction:column;align-items:center;gap:14px;padding-block:60px;color:var(--dim)}
137
+ .inl pre.files{margin:0;max-height:140px;overflow:auto;font:12px/1.5 var(--mono);color:var(--dim)}
138
+ .inl.fix{margin-top:16px}.inl.fix label{display:flex;gap:8px;align-items:baseline;font-size:13px;cursor:pointer}.inl.fix label.off{color:var(--dim);cursor:default}.inl.fix input{flex:none;width:auto;margin:0;accent-color:var(--accent);position:relative;top:2px}
139
+ .inl .q{font-size:14px;font-weight:500}.inl .q.dim{color:var(--dim);font-size:13px;font-weight:400}
140
+ .empty{grid-column:1/-1;display:flex;flex-direction:column;align-items:center;gap:12px;padding-block:60px;color:var(--dim);text-align:center}
141
+ .empty b{font-size:16px;font-weight:600;color:var(--ink)}.empty p{margin:0 0 6px;max-width:44ch}
119
142
  .mascot{width:120px;height:112px;background:var(--ink);-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 428 400%22%3E%3Cpath fill=%22currentColor%22 fill-rule=%22evenodd%22 d=%22M130 381L122 362L119 346L119 325L124 305L123 294L119 288L117 288L115 285L109 282L89 264L89 262L82 256L82 254L79 252L79 250L76 248L76 246L73 244L70 237L68 236L58 216L53 201L49 182L48 166L45 166L39 170L19 177L7 178L2 176L0 173L0 163L4 157L4 154L7 150L7 147L10 143L16 126L20 120L26 103L30 97L30 94L34 88L37 78L41 72L44 62L48 56L48 53L52 47L52 44L56 38L59 28L64 20L67 10L70 7L247 0L252 4L253 11L250 18L243 25L243 27L209 60L207 60L203 65L201 65L197 70L195 70L191 75L189 75L186 79L180 82L169 92L164 93L161 90L162 85L169 81L171 78L173 78L176 74L178 74L181 70L183 70L186 66L193 62L197 57L199 57L205 50L207 50L235 23L235 21L242 14L244 9L241 9L226 14L220 18L217 18L199 27L198 29L190 32L189 34L183 36L182 38L178 39L177 41L173 42L167 47L163 48L156 54L152 55L151 57L149 57L148 59L146 59L145 61L143 61L142 63L140 63L139 65L137 65L136 67L134 67L133 69L131 69L130 71L116 79L114 82L106 86L104 89L99 91L93 97L88 99L86 102L84 102L82 105L80 105L78 108L76 108L74 111L72 111L70 114L68 114L66 117L64 117L62 120L56 123L53 127L51 127L48 131L41 135L37 140L35 140L15 159L15 161L11 164L9 169L21 167L30 164L34 161L37 161L45 156L48 156L50 140L57 132L71 131L75 133L79 138L81 138L87 135L88 133L94 131L95 129L104 126L107 129L106 134L82 147L82 156L85 172L92 188L94 189L94 194L92 196L88 196L84 192L79 182L74 164L73 144L69 140L62 140L58 144L58 182L62 200L68 217L76 232L82 239L82 241L92 252L92 254L107 269L109 269L112 273L114 273L128 285L132 292L133 307L128 325L128 345L131 361L140 382L149 390L160 392L191 381L228 377L238 373L251 362L251 360L254 358L255 354L258 351L261 341L261 322L259 315L253 304L243 294L232 288L220 285L203 286L188 292L176 303L176 305L170 313L167 323L168 345L178 363L187 370L187 375L184 377L179 376L169 366L161 352L158 341L158 323L162 309L167 300L170 298L170 296L181 286L194 279L207 276L221 276L234 279L244 284L249 289L251 289L263 303L270 322L270 342L266 355L260 363L260 365L257 367L257 369L247 378L243 379L240 382L253 386L263 391L277 391L281 389L290 379L298 358L300 347L300 323L296 307L297 298L302 292L318 287L333 279L335 276L337 276L339 273L341 273L345 268L349 266L349 264L355 259L351 251L349 242L345 243L341 247L329 253L309 258L290 258L276 255L274 257L262 261L236 266L192 266L154 257L130 245L129 243L121 239L119 236L117 236L101 220L101 218L98 216L96 212L95 207L107 192L113 180L119 159L121 157L121 151L118 146L118 142L115 135L114 112L117 101L124 92L129 90L138 90L146 92L159 99L169 109L184 103L204 99L231 100L244 103L259 109L264 103L266 103L268 100L270 100L271 98L280 93L290 90L302 91L309 97L314 112L314 130L311 143L307 152L307 156L310 161L316 182L325 198L332 205L333 210L328 219L322 224L322 226L317 231L315 231L310 237L308 237L306 240L296 245L292 249L309 249L325 245L341 236L347 230L350 210L360 193L360 187L355 178L355 174L357 172L387 172L389 177L384 187L385 195L387 194L391 182L394 180L401 180L415 187L426 190L428 193L428 202L423 211L423 214L421 216L421 219L419 221L419 224L417 226L417 229L415 231L415 234L413 236L413 239L411 241L405 258L402 260L395 260L390 257L388 259L366 259L361 264L361 266L340 285L318 297L307 300L305 302L305 307L309 325L318 323L325 319L334 310L334 308L337 306L341 298L354 283L356 283L362 277L377 271L394 271L400 273L407 277L415 285L420 294L423 306L421 323L416 335L408 344L408 346L401 353L399 353L397 356L395 356L388 362L369 370L353 373L330 373L304 367L301 377L297 385L294 387L294 389L288 395L279 400L261 400L240 391L216 387L192 390L167 400L150 400L139 394L132 386ZM130 155L119 189L116 192L112 201L106 207L107 210L119 207L126 200L134 185L136 184L137 180L149 166L163 158L171 156L180 156L191 160L198 166L202 173L204 182L203 192L195 205L194 216L196 220L205 227L212 229L221 228L227 225L233 219L234 207L225 191L225 177L233 163L235 163L237 160L241 158L248 156L262 157L278 165L289 177L289 179L295 186L297 192L299 193L300 197L309 207L316 210L323 209L313 196L307 184L304 171L297 154L302 142L305 129L304 107L301 101L298 99L291 99L278 104L261 119L257 119L243 112L224 108L197 109L185 112L171 119L167 119L163 114L161 114L157 109L155 109L150 104L138 99L130 99L126 103L123 113L123 128L125 138L131 152ZM380 280L372 282L361 289L361 306L364 310L375 312L382 317L384 321L384 337L386 339L390 341L400 341L408 331L413 319L413 301L409 292L400 283L393 280ZM144 187L141 193L139 194L138 198L134 202L134 204L131 206L131 208L125 214L123 214L120 217L113 218L113 220L124 230L126 230L128 233L138 238L139 240L156 248L180 255L201 258L226 258L257 253L277 246L291 239L292 237L303 231L315 220L315 218L306 216L298 209L298 207L291 199L290 195L288 194L287 190L285 189L281 181L271 171L257 165L245 166L241 168L235 175L234 190L242 203L243 217L240 224L234 231L221 237L207 237L201 235L194 229L192 229L186 220L185 207L187 201L193 193L195 185L193 175L187 168L179 165L171 165L164 167L158 172L156 172L148 180ZM367 179L364 179L367 185L367 195L362 204L360 205L355 219L355 240L357 247L361 253L383 253L388 245L390 236L389 218L383 203L377 195L377 185L380 179ZM319 362L330 364L360 363L376 358L387 352L389 349L382 347L376 341L375 323L371 320L362 319L359 316L357 316L353 310L351 300L348 306L343 311L343 313L333 324L331 324L329 327L320 332L309 334L309 348L307 358ZM130 59L132 59L133 57L147 49L149 46L76 18L66 105L72 100L74 100L76 97L78 97L80 94L82 94L84 91L89 89L91 86L93 86L95 83L97 83L105 76L113 72L115 69L129 61ZM213 204L218 204L225 208L224 213L217 218L211 218L207 216L203 210L205 206ZM39 96L20 142L26 136L28 136L33 130L35 130L38 126L40 126L43 122L45 122L48 118L50 118L53 114L56 113L64 41L56 57L56 60L52 66L49 76L45 82L42 92ZM194 11L101 14L95 16L98 16L100 18L103 18L105 20L108 20L110 22L113 22L115 24L118 24L120 26L123 26L125 28L128 28L130 30L133 30L135 32L138 32L140 34L143 34L145 36L148 36L159 41L164 37L168 36L169 34L173 33L174 31L178 30L179 28L213 11ZM409 203L413 193L412 191L404 189L400 186L396 186L394 190L390 203L395 213L397 230L401 223L407 205ZM418 210L421 200L423 198L423 196L418 195L412 212L407 221L407 224L405 226L405 229L403 231L403 234L401 236L401 239L399 241L399 244L397 246L397 249L395 251L396 254L400 254ZM182 191L178 195L170 194L167 190L168 182L172 179L180 180L183 183ZM256 194L250 195L245 190L245 184L250 179L256 179L261 184L261 190L258 194Z%22/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 428 400%22%3E%3Cpath fill=%22currentColor%22 fill-rule=%22evenodd%22 d=%22M130 381L122 362L119 346L119 325L124 305L123 294L119 288L117 288L115 285L109 282L89 264L89 262L82 256L82 254L79 252L79 250L76 248L76 246L73 244L70 237L68 236L58 216L53 201L49 182L48 166L45 166L39 170L19 177L7 178L2 176L0 173L0 163L4 157L4 154L7 150L7 147L10 143L16 126L20 120L26 103L30 97L30 94L34 88L37 78L41 72L44 62L48 56L48 53L52 47L52 44L56 38L59 28L64 20L67 10L70 7L247 0L252 4L253 11L250 18L243 25L243 27L209 60L207 60L203 65L201 65L197 70L195 70L191 75L189 75L186 79L180 82L169 92L164 93L161 90L162 85L169 81L171 78L173 78L176 74L178 74L181 70L183 70L186 66L193 62L197 57L199 57L205 50L207 50L235 23L235 21L242 14L244 9L241 9L226 14L220 18L217 18L199 27L198 29L190 32L189 34L183 36L182 38L178 39L177 41L173 42L167 47L163 48L156 54L152 55L151 57L149 57L148 59L146 59L145 61L143 61L142 63L140 63L139 65L137 65L136 67L134 67L133 69L131 69L130 71L116 79L114 82L106 86L104 89L99 91L93 97L88 99L86 102L84 102L82 105L80 105L78 108L76 108L74 111L72 111L70 114L68 114L66 117L64 117L62 120L56 123L53 127L51 127L48 131L41 135L37 140L35 140L15 159L15 161L11 164L9 169L21 167L30 164L34 161L37 161L45 156L48 156L50 140L57 132L71 131L75 133L79 138L81 138L87 135L88 133L94 131L95 129L104 126L107 129L106 134L82 147L82 156L85 172L92 188L94 189L94 194L92 196L88 196L84 192L79 182L74 164L73 144L69 140L62 140L58 144L58 182L62 200L68 217L76 232L82 239L82 241L92 252L92 254L107 269L109 269L112 273L114 273L128 285L132 292L133 307L128 325L128 345L131 361L140 382L149 390L160 392L191 381L228 377L238 373L251 362L251 360L254 358L255 354L258 351L261 341L261 322L259 315L253 304L243 294L232 288L220 285L203 286L188 292L176 303L176 305L170 313L167 323L168 345L178 363L187 370L187 375L184 377L179 376L169 366L161 352L158 341L158 323L162 309L167 300L170 298L170 296L181 286L194 279L207 276L221 276L234 279L244 284L249 289L251 289L263 303L270 322L270 342L266 355L260 363L260 365L257 367L257 369L247 378L243 379L240 382L253 386L263 391L277 391L281 389L290 379L298 358L300 347L300 323L296 307L297 298L302 292L318 287L333 279L335 276L337 276L339 273L341 273L345 268L349 266L349 264L355 259L351 251L349 242L345 243L341 247L329 253L309 258L290 258L276 255L274 257L262 261L236 266L192 266L154 257L130 245L129 243L121 239L119 236L117 236L101 220L101 218L98 216L96 212L95 207L107 192L113 180L119 159L121 157L121 151L118 146L118 142L115 135L114 112L117 101L124 92L129 90L138 90L146 92L159 99L169 109L184 103L204 99L231 100L244 103L259 109L264 103L266 103L268 100L270 100L271 98L280 93L290 90L302 91L309 97L314 112L314 130L311 143L307 152L307 156L310 161L316 182L325 198L332 205L333 210L328 219L322 224L322 226L317 231L315 231L310 237L308 237L306 240L296 245L292 249L309 249L325 245L341 236L347 230L350 210L360 193L360 187L355 178L355 174L357 172L387 172L389 177L384 187L385 195L387 194L391 182L394 180L401 180L415 187L426 190L428 193L428 202L423 211L423 214L421 216L421 219L419 221L419 224L417 226L417 229L415 231L415 234L413 236L413 239L411 241L405 258L402 260L395 260L390 257L388 259L366 259L361 264L361 266L340 285L318 297L307 300L305 302L305 307L309 325L318 323L325 319L334 310L334 308L337 306L341 298L354 283L356 283L362 277L377 271L394 271L400 273L407 277L415 285L420 294L423 306L421 323L416 335L408 344L408 346L401 353L399 353L397 356L395 356L388 362L369 370L353 373L330 373L304 367L301 377L297 385L294 387L294 389L288 395L279 400L261 400L240 391L216 387L192 390L167 400L150 400L139 394L132 386ZM130 155L119 189L116 192L112 201L106 207L107 210L119 207L126 200L134 185L136 184L137 180L149 166L163 158L171 156L180 156L191 160L198 166L202 173L204 182L203 192L195 205L194 216L196 220L205 227L212 229L221 228L227 225L233 219L234 207L225 191L225 177L233 163L235 163L237 160L241 158L248 156L262 157L278 165L289 177L289 179L295 186L297 192L299 193L300 197L309 207L316 210L323 209L313 196L307 184L304 171L297 154L302 142L305 129L304 107L301 101L298 99L291 99L278 104L261 119L257 119L243 112L224 108L197 109L185 112L171 119L167 119L163 114L161 114L157 109L155 109L150 104L138 99L130 99L126 103L123 113L123 128L125 138L131 152ZM380 280L372 282L361 289L361 306L364 310L375 312L382 317L384 321L384 337L386 339L390 341L400 341L408 331L413 319L413 301L409 292L400 283L393 280ZM144 187L141 193L139 194L138 198L134 202L134 204L131 206L131 208L125 214L123 214L120 217L113 218L113 220L124 230L126 230L128 233L138 238L139 240L156 248L180 255L201 258L226 258L257 253L277 246L291 239L292 237L303 231L315 220L315 218L306 216L298 209L298 207L291 199L290 195L288 194L287 190L285 189L281 181L271 171L257 165L245 166L241 168L235 175L234 190L242 203L243 217L240 224L234 231L221 237L207 237L201 235L194 229L192 229L186 220L185 207L187 201L193 193L195 185L193 175L187 168L179 165L171 165L164 167L158 172L156 172L148 180ZM367 179L364 179L367 185L367 195L362 204L360 205L355 219L355 240L357 247L361 253L383 253L388 245L390 236L389 218L383 203L377 195L377 185L380 179ZM319 362L330 364L360 363L376 358L387 352L389 349L382 347L376 341L375 323L371 320L362 319L359 316L357 316L353 310L351 300L348 306L343 311L343 313L333 324L331 324L329 327L320 332L309 334L309 348L307 358ZM130 59L132 59L133 57L147 49L149 46L76 18L66 105L72 100L74 100L76 97L78 97L80 94L82 94L84 91L89 89L91 86L93 86L95 83L97 83L105 76L113 72L115 69L129 61ZM213 204L218 204L225 208L224 213L217 218L211 218L207 216L203 210L205 206ZM39 96L20 142L26 136L28 136L33 130L35 130L38 126L40 126L43 122L45 122L48 118L50 118L53 114L56 113L64 41L56 57L56 60L52 66L49 76L45 82L42 92ZM194 11L101 14L95 16L98 16L100 18L103 18L105 20L108 20L110 22L113 22L115 24L118 24L120 26L123 26L125 28L128 28L130 30L133 30L135 32L138 32L140 34L143 34L145 36L148 36L159 41L164 37L168 36L169 34L173 33L174 31L178 30L179 28L213 11ZM409 203L413 193L412 191L404 189L400 186L396 186L394 190L390 203L395 213L397 230L401 223L407 205ZM418 210L421 200L423 198L423 196L418 195L412 212L407 221L407 224L405 226L405 229L403 231L403 234L401 236L401 239L399 241L399 244L397 246L397 249L395 251L396 254L400 254ZM182 191L178 195L170 194L167 190L168 182L172 179L180 180L183 183ZM256 194L250 195L245 190L245 184L250 179L256 179L261 184L261 190L258 194Z%22/%3E%3C/svg%3E") center/contain no-repeat}
120
143
  @keyframes fi{from{opacity:0}}
121
144
 
122
145
  /* footer */
123
- .ftr{display:flex;justify-content:space-between;gap:16px;padding:8px 24px;border-top:1px solid var(--line);color:var(--dim);font-size:12px;flex-wrap:wrap;align-items:center}
146
+ .ftr{display:flex;justify-content:space-between;gap:16px;padding:10px 28px;border-top:1px solid var(--line);color:var(--dim);font-size:12px;flex-wrap:wrap;align-items:center}
124
147
  .ftr span+span{margin-left:14px}.ftr .st svg{width:12px;height:12px}
125
- .ftr a{color:var(--dim);cursor:pointer;text-decoration:underline}
148
+ .ftr a{color:var(--dim);cursor:pointer;text-decoration:underline;text-underline-offset:2px}.ftr a:hover{color:var(--ink)}
126
149
 
127
150
  /* side panel */
128
- .scrim{position:fixed;inset:0;z-index:10;background:color-mix(in srgb,var(--bg) 55%,transparent);display:none}
151
+ .scrim{position:fixed;inset:0;z-index:10;background:color-mix(in srgb,var(--bg) 60%,transparent);backdrop-filter:blur(3px);display:none}
129
152
  .scrim.on{display:block}
130
- aside{position:fixed;top:0;right:0;bottom:0;z-index:11;width:480px;max-width:100%;background:var(--bg-2);border-left:1px solid var(--line);display:flex;flex-direction:column;transform:translateX(100%);transition:transform .18s}
131
- aside.open{transform:none}
132
- aside header{display:flex;align-items:flex-start;gap:12px;padding:16px 20px;border-bottom:1px solid var(--line)}
153
+ 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}
154
+ aside.open{transform:none;visibility:visible}
155
+ aside header{display:flex;align-items:flex-start;gap:12px;padding:20px 24px 16px;border-bottom:1px solid var(--line)}
133
156
  aside header .t{flex:1;min-width:0}
134
- aside header b{display:block;font-size:15px}
135
- aside header .sub{color:var(--dim);font-size:12px;margin-top:2px}
136
- aside nav{display:flex;border-bottom:1px solid var(--line);padding-inline:8px}
157
+ aside header b{display:block;font-size:17px;font-weight:600;line-height:1.35}
158
+ aside header .sub{color:var(--dim);font-size:12px;margin-top:4px}
159
+ aside nav{display:flex;border-bottom:1px solid var(--line);padding-inline:12px;overflow:auto hidden;scrollbar-width:none}
137
160
  aside nav:empty{display:none}
138
- aside nav button{padding:10px 12px;color:var(--dim);border-bottom:2px solid transparent;margin-bottom:-1px}
161
+ aside nav button{padding:12px;font-size:13px;font-weight:500;color:var(--dim);white-space:nowrap}
139
162
  aside nav button:hover{color:var(--ink)}
140
- aside nav button.on{color:var(--ink);border-bottom-color:var(--accent)}
141
- aside .body{padding:20px;overflow:auto;flex:1}
142
- aside .foot{padding:14px 20px;border-top:1px solid var(--line);display:flex;gap:8px;flex-wrap:wrap}
163
+ aside nav button.on{color:var(--ink);box-shadow:inset 0 -2px var(--accent)}
164
+ aside .body{padding:24px;overflow:auto;flex:1}
165
+ aside .foot{padding:16px 24px;border-top:1px solid var(--line);display:flex;gap:8px;flex-wrap:wrap;align-items:center}
143
166
  aside .foot:empty{display:none}
144
- .md h3{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin:18px 0 6px}.md h3:first-child{margin-top:0}
145
- .md p{margin:0 0 10px;max-width:62ch}.md ol,.md ul{padding-left:1.4em;margin:0 0 10px}.md li{margin:3px 0}
146
- .md pre{background:var(--bg);border:1px solid var(--line);border-radius:var(--r-s);padding:8px 10px;overflow-x:auto;margin:0 0 8px;font-size:12px}
147
- .md h1,.md h2{font-size:13px;margin:16px 0 6px}.md h1:first-child,.md h2:first-child{margin-top:0}.md code{background:var(--bg);padding:0 4px;border-radius:var(--r-s)}.md table{border-collapse:collapse;font-size:12px;margin:0 0 10px}.md td,.md th{border:1px solid var(--line);padding:3px 8px;text-align:left}.md hr{border:0;border-top:1px solid var(--line);margin:12px 0}
167
+ .md h3{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin:20px 0 8px}.md h3:first-child{margin-top:0}
168
+ .md p{margin:0 0 10px;max-width:62ch}.md ol,.md ul{display:block;padding:0 0 0 1.4em;margin:0 0 10px;list-style:revert}.md li{margin:3px 0}
169
+ .md li.tk{list-style:none;margin-left:-1.4em}.md li.tk label{display:flex;gap:8px;align-items:baseline}.md li.tk input{flex:none;margin:0;accent-color:var(--accent);position:relative;top:2px;cursor:default}.md li.rej{color:var(--dim);text-decoration:line-through}
170
+ .md pre{background:var(--bg);border:1px solid var(--line);border-radius:var(--r-s);padding:10px 12px;overflow-x:auto;margin:0 0 10px;font:12px/1.5 var(--mono)}
171
+ .md h1,.md h2{font-size:15px;font-weight:600;margin:20px 0 8px}.md h1:first-child,.md h2:first-child{margin-top:0}.md code{font:12px var(--mono);background:var(--bg);padding:1px 5px;border-radius:var(--r-s)}.md table{border-collapse:collapse;font-size:12px;margin:0 0 10px}.md td,.md th{border:1px solid var(--line);padding:4px 8px;text-align:left}.md hr{border:0;border-top:1px solid var(--line);margin:14px 0}
148
172
  .find{border-bottom:1px solid var(--line);padding:10px 0;display:flex;gap:10px;align-items:flex-start}
149
173
  .find input{margin-top:4px}
150
174
  .find .sev{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--warn)}.find .sev.hi{color:var(--danger)}
151
175
  .find .loc{color:var(--dim);font-size:12px;margin-left:8px}
152
- /* settings: the TUI screen, one row per setting — label, then the value in its own grammar */
153
- .sg{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin:18px 0 6px;font-weight:600}.sg:first-child{margin-top:0}
154
- .srow{display:grid;grid-template-columns:200px 1fr;gap:0 16px;align-items:center;font-size:13px;padding:4px 8px;margin:0 -8px;border-radius:var(--r)}
155
- .srow:hover,.srow:focus-within{background:color-mix(in srgb,var(--dim) 8%,transparent)}
156
- .srow label{color:var(--dim)}
157
- .srow .hint{display:none;grid-column:1/-1;margin:6px 0 2px;font-size:12px;color:var(--dim);line-height:1.45;max-width:60ch}.srow:hover .hint,.srow:focus-within .hint{display:block}.srow .hint code{background:none;padding:0;color:var(--dim)}
158
- .srow .sel{padding:2px 6px;font-size:13px;justify-self:start;border-color:transparent}.srow:hover .sel,.srow .sel:focus{border-color:var(--line)}
159
- .chk{justify-self:start;color:var(--ink);padding:2px 0;font-variant-numeric:tabular-nums}.chk:not(.on){color:var(--dim)}.chk i{font-style:normal;color:var(--ink)}
160
- .bar{display:inline-flex;align-items:center;gap:2px;justify-self:start}.bar button{width:10px;height:14px;border:1px solid var(--dim);border-radius:2px;background:none}.bar button.on{background:var(--ink);border-color:var(--ink)}.bar button:hover{border-color:var(--accent)}.bar b{font-weight:400;margin-left:6px;font-variant-numeric:tabular-nums}
161
- .toast{position:fixed;z-index:12;left:24px;bottom:52px;background:var(--ink);color:var(--bg);border-radius:var(--r);padding:8px 14px;font-size:13px;opacity:0;transition:opacity .2s;pointer-events:none}
176
+ /* settings: one row per setting — name and what it does on the left, its control on the right */
177
+ .sg{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin:24px 0 2px;font-weight:600}.sg:first-child{margin-top:0}
178
+ .srow{display:grid;grid-template-columns:1fr auto;gap:2px 16px;padding:12px 0;border-bottom:1px solid var(--line)}
179
+ .srow label{font-weight:500}
180
+ .srow>:nth-child(2){grid-column:2;grid-row:1/span 2;align-self:center;justify-self:end}
181
+ .srow .hint{grid-column:1;margin:0;font-size:12px;color:var(--dim);line-height:1.45;max-width:46ch}
182
+ .srow input.sel{width:170px}
183
+ /* a switch: the same <button role="switch"> the save wiring binds to, drawn as a track and a thumb */
184
+ .chk{width:36px;height:20px;flex-shrink:0;border-radius:var(--r-pill);background:color-mix(in srgb,var(--dim) 40%,transparent);position:relative;transition:background .12s}
185
+ .chk::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--on-accent);box-shadow:var(--shadow-1);transition:transform .12s}
186
+ .chk.on{background:var(--accent)}.chk.on::after{transform:translateX(16px)}
187
+ /* a small number: one segment per value */
188
+ .bar{display:inline-flex;border:1px solid var(--line);border-radius:var(--r-s);overflow:hidden}
189
+ .bar button{min-width:30px;height:28px;font-size:13px;color:var(--dim);font-variant-numeric:tabular-nums}.bar button+button{border-left:1px solid var(--line)}
190
+ .bar button:hover{background:var(--hover);color:var(--ink)}.bar button.on,.bar button.on:hover{background:var(--accent);color:var(--on-accent)}.bar button:focus-visible{outline-offset:-2px;outline-color:var(--ink)}
191
+ .toast{position:fixed;z-index:12;left:28px;bottom:56px;max-width:calc(100% - 56px);background:var(--ink);color:var(--bg);border-radius:var(--r);padding:10px 16px;font-size:13px;box-shadow:var(--shadow-2);opacity:0;transition:opacity .2s;pointer-events:none}
162
192
  .toast.on{opacity:1}
163
- .help{position:fixed;inset:0;z-index:12;display:none;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bg) 70%,transparent)}
164
- .help.on{display:flex}.help div{background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r);padding:20px 24px;line-height:2}
165
- .help kbd{display:inline-block;width:7ch;color:var(--accent)}
166
- @media(max-width:900px){main{grid-template-columns:1fr;padding-inline:16px;gap:8px}.hdr,.strip,.ftr{padding-inline:16px}section h2{position:sticky;top:0;background:var(--bg);z-index:1}.more{display:grid}.card:hover .age{display:inline}}
167
- @media(prefers-reduced-motion:reduce){aside,.toast,.mascot,.d-run .pulse,.ib.mic.on,.dictating,.vu i{transition:none;animation:none}}
193
+ .help{position:fixed;inset:0;z-index:12;display:none;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bg) 60%,transparent);backdrop-filter:blur(3px)}
194
+ .help.on{display:flex}.help div{background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r);padding:24px 28px;line-height:2.2;box-shadow:var(--shadow-2)}
195
+ .help kbd{display:inline-block;min-width:40px;margin-right:12px;padding:0 6px;text-align:center;border:1px solid var(--line);border-radius:var(--r-s);background:var(--bg);font:12px/20px var(--mono);color:var(--ink)}
196
+ @media(max-width:900px){main{grid-template-columns:1fr;padding-inline:16px;gap:8px}.hdr,.strip,.ftr{padding-inline:16px}.more{display:grid}.card:hover .age{display:inline}.toast{left:16px;max-width:calc(100% - 32px)}}
197
+ @media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}
168
198
  </style>
169
199
  </head>
170
200
  <body>
@@ -172,6 +202,7 @@ aside .foot:empty{display:none}
172
202
  <svg style="display:none"><defs>
173
203
  <symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
174
204
  <symbol id="i-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></symbol>
205
+ <symbol id="i-link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.1.4l3-3a5 5 0 0 0-7.1-7.1l-1.7 1.7M14 11a5 5 0 0 0-7.1-.4l-3 3a5 5 0 0 0 7.1 7.1l1.7-1.7"/></symbol>
175
206
  <symbol id="i-play" viewBox="0 0 24 24"><path d="M6 4l14 8-14 8z"/></symbol>
176
207
  <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>
177
208
  <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>
@@ -194,15 +225,17 @@ aside .foot:empty{display:none}
194
225
  <symbol id="c-cursor" viewBox="0 0 16 16"><path d="M4 3l9 5-4 1-1 4z"/></symbol>
195
226
  <symbol id="c-full" viewBox="0 0 16 16"><circle cx="5.5" cy="8" r="3"/><circle cx="11" cy="8" r="3"/></symbol>
196
227
  <symbol id="c-lite" viewBox="0 0 16 16"><circle cx="8" cy="8" r="3"/></symbol>
228
+ <symbol id="c-git" viewBox="0 0 16 16"><circle cx="4.5" cy="3.5" r="1.5"/><circle cx="4.5" cy="12.5" r="1.5"/><circle cx="11.5" cy="5.5" r="1.5"/><path d="M4.5 5v6M11.5 7c0 2.5-7 1.5-7 4"/></symbol>
197
229
  </defs></svg>
198
230
 
199
231
  <div class="hdr">
200
232
  <pre class="mark" aria-label="kaizen"><span>█ █ ▄▀█ █ ▀▀█ █▀▀ █▄ █</span>
201
233
  <span>█▀▄ █▀█ █ ▄▀ ██▄ █ ▀█</span>
202
234
  <span>█ █ █ █ █ █▄▄ █▄▄ █ █</span></pre>
203
- <select class="sel" id="proj" aria-label="project"></select>
235
+ <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>
204
236
  <span class="sp"></span>
205
237
  <button class="btn pri" id="newtop">New idea</button>
238
+ <button class="ib" id="sources" title="Request sources" aria-label="Request sources"><svg><use href="#i-link"/></svg></button>
206
239
  <button class="ib" id="settings" title="Settings" aria-label="Settings"><svg><use href="#i-gear"/></svg></button>
207
240
  </div>
208
241
  <div class="strip" id="strip"></div>
@@ -306,11 +339,12 @@ function mic(fields){
306
339
  return b}
307
340
  const m=60e3,h=3600e3,d=86400e3;
308
341
  const age=(t,now)=>{const x=now-t;return x<h?Math.round(x/m)+"m":x<d?Math.round(x/h)+"h":Math.round(x/d)+"d"};
309
- const NAMES={claude:"Claude Code",codex:"Codex",agy:"Antigravity",opencode:"OpenCode",gemini:"Gemini",cursor:"Cursor"};
342
+ const NAMES={claude:"Claude Code",codex:"Codex",agy:"Antigravity",opencode:"OpenCode",gemini:"Gemini",cursor:"Cursor",unrechecked:"fix not rechecked"};
310
343
  const MARKS={claude:1,codex:1,agy:1,opencode:1,gemini:1,cursor:1,full:1,lite:1};
311
344
  const COLS=["Ideas","Planning","Building","Review","Done"];
312
345
  // state.ts statuses onto the icon set in the <defs> above.
313
346
  const ICON={idea:"idea",starting:"start",running:"run",stalled:"stall",waiting:"wait",done:"done",abandoned:"aband"};
347
+ const NEED={"approvals.plan":"plan needs your approval","approvals.review":"review needs your approval"},need=a=>NEED[a]||a;
314
348
  const WORD={idea:"idea",starting:"starting",running:"running",stalled:"stalled",waiting:"waiting on you",done:"done",abandoned:"abandoned"};
315
349
 
316
350
  let S={project:null,projects:[],cards:[],now:Date.now(),all:false};
@@ -319,29 +353,44 @@ let all=new URLSearchParams(location.search).get("all")!=="0",openKey=null,mode=
319
353
  const FOLD=6;const unfolded=new Set();
320
354
  let archive=false;
321
355
  let clearMenu=false,picking=false;const picked=new Set(); // Clear on Done: menu, then cherry-pick mode // showing the archive instead of the board
322
- const wasAwaiting=new Map();let first=true;
356
+ // The server writes notifier() from state.ts over the marker: one rule for both UIs.
357
+ const due=(/*notifier*/)();
323
358
 
324
- const key=c=>c.kind==="idea"?"idea:"+c.state+":"+c.text:"run:"+c.state+":"+c.id;
359
+ // Two ideas may carry the same text; load() numbers the repeats so a key names one card.
360
+ const key=c=>c._k||(c.kind==="idea"?"idea:"+c.state+":"+c.text:"run:"+c.state+":"+c.id);
325
361
 
326
- async function load(){
362
+ // The picked project outlives a reload. Saved only where the user changes it, so a
363
+ // second tab refreshing cannot undo it; a view the URL asked for is a one-off.
364
+ const urlAll=new URLSearchParams(location.search).has("all");
365
+ function keep(d){if(urlAll)return;try{d?localStorage.setItem("kz-proj",d):localStorage.removeItem("kz-proj")}catch(e){}}
366
+ // Half-typed text outlives a redraw and a page reload. Per tab, so two tabs cannot
367
+ // overwrite each other; form drafts are under "f:", notes under "n:".
368
+ const DK="kz-d:";
369
+ function draft(el,k){try{const v=sessionStorage.getItem(DK+k);if(v!=null)el.value=v}catch(e){}el.addEventListener("input",()=>{try{sessionStorage.setItem(DK+k,el.value)}catch(e){}})}
370
+ function undraft(p){try{Object.keys(sessionStorage).forEach(k=>{if(k.startsWith(DK+p))sessionStorage.removeItem(k)})}catch(e){}}
371
+ // quiet: the server said something changed, nobody asked for a redraw.
372
+ async function load(quiet){
327
373
  let r;try{r=await fetch("/state"+(all?"?all=1":""))}catch{toast(GONE);return}S=await r.json();
328
- for(const c of S.cards){if(c.kind!=="run")continue;const k=key(c),before=wasAwaiting.get(k);
329
- if(!first&&before===null&&c.awaiting)notifyUser("kaizen — waiting on you",c.text+" · "+c.awaiting);wasAwaiting.set(k,c.awaiting)}
330
- first=false;render();
331
- if(openKey){const c=S.cards.find(x=>key(x)===openKey);if(c&&runDoc)openRun(c,true);else if(c&&c.kind==="idea"){const ta=$("pbody").querySelector("textarea");if(!(ta&&document.activeElement===ta))chat(c,c.notes,c.archived||c.status!=="idea")}}}
374
+ {const seen=new Map();for(const c of S.cards){const b=key(c),n=seen.get(b)||0;seen.set(b,n+1);c._k=n?b+"#"+n:b}}
375
+ for(const c of S.cards){if(c.kind!=="run"||c.archived)continue;if(due(key(c),c.notice,c.status==="done",S.now))notifyUser(c.notice)}
376
+ // A redraw under a form being typed in would reset its fields and its focus.
377
+ if(!(quiet&&mode&&$("board").querySelector(".inl input")))render();
378
+ if(openKey){const c=S.cards.find(x=>key(x)===openKey),ta=$("pbody").querySelector(".notes textarea");if(!c||ta&&document.activeElement===ta)return;if(runDoc)openRun(c,true,true);else if(c.kind==="idea")chat(c,c.notes,c.archived||c.status!=="idea")}}
332
379
  // A tab outliving its server would otherwise swallow every click: say so.
333
380
  const GONE="The board server is not running — start it with kaizen web (or the shortcut), then reload.";
334
- async function post(path,body){let r;try{r=await fetch(path,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(body)})}catch{toast(GONE);return{ok:false}}const j=await r.json().catch(()=>({}));if(!r.ok){toast(j.error||j.why||"failed");if(j.manual)console.log("run manually:",j.manual)}return j}
381
+ async function post(path,body){let r;try{r=await fetch(path,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(body)})}catch{toast(GONE);return{ok:false}}const j=await r.json().catch(()=>({}));if(!r.ok){toast(j.error||j.why||"That did not work");if(j.manual)console.log("run manually:",j.manual)}return j}
335
382
 
336
383
  function card(c){
337
384
  const k=key(c),el=document.createElement("div");el.className="card"+(k===openKey?" open":"")+(k===menuKey?" menu":"")+(c.archived?" archived":"");el.tabIndex=0;el.setAttribute("role","button");
338
385
  const st=ICON[c.status]||"idea",idt=c.kind==="idea"?"idea":c.text;
339
386
  const chips=[];
340
- if(c.status==="waiting")chips.push(`<span class="chip warn"><i></i>${esc(c.awaiting||"needs you")}</span>`);
387
+ if(c.status==="waiting")chips.push(`<span class="chip warn"><i></i>${esc(need(c.awaiting)||"needs you")}</span>`);
341
388
  else if(c.status==="running")chips.push(`<span class="chip run"><i></i>running</span>`);
342
389
  if(all)chips.push(`<span class="chip">${esc(c.where)}</span>`);
343
390
  chips.push(...(c.tags||[]).map(t=>{const mark=MARKS[t]?`<svg><use href="#c-${t}"/></svg>`:"";return `<span class="chip${t==="full"||t==="lite"?" accent":""}">${mark}${esc(NAMES[t]||t)}</span>`}));
344
391
  if(c.notes)chips.push(`<span class="chip">notes</span>`);
392
+ // Information only: the panel's button is where it is committed and pushed.
393
+ if(c.git){const t=c.git==="pushed"?"Committed & pushed":"Not committed & pushed yet";chips.push(`<span class="chip ${c.git==="pushed"?"ok":"warn"}" role="img" title="${t}" aria-label="${t}"><svg><use href="#c-git"/></svg></span>`)}
345
394
  const pick=picking&&!archive&&c.column===4;
346
395
  el.innerHTML=(pick?`<input type="checkbox" class="pick" ${picked.has(k)?"checked":""} aria-label="Select">`:"")+`<div class="c-id"><span class="st d-${st}"><svg><use href="#s-${st}"/></svg></span><span class="idt">${esc(idt)}</span><span class="age">${c.kind==="run"?age(c.moved||S.now,S.now):""}</span><button class="ib sm more" data-a="menu" title="Actions" aria-label="Actions">${ico("more")}</button></div>
347
396
  <div class="c-title">${esc(c.kind==="idea"?c.text:(c.title||c.id))}</div>
@@ -364,12 +413,13 @@ function card(c){
364
413
 
365
414
  function inlineForm(c){
366
415
  const li=document.createElement("li");li.className="inl";const dir=c.state;
367
- if(mode==="edit"){li.innerHTML=`<input value="${esc(c.text)}"><textarea placeholder="Notes: links, paths, background">${esc(c.notes||"")}</textarea><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Save</button></div>`;li.querySelector('[data-a=s]').onclick=async()=>{const next=li.querySelector("input").value.trim(),notes=li.querySelector("textarea").value.trim();mode=null;if(next&&(next!==c.text||notes!==(c.notes||"")))await post("/inbox",{op:"edit",dir,text:c.text,next,notes});load()}}
416
+ if(mode==="edit"){li.innerHTML=`<input value="${esc(c.text)}"><textarea placeholder="Notes: links, paths, background">${esc(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>`;li.querySelector('[data-a=s]').onclick=async()=>{const next=li.querySelector("input").value.trim(),notes=li.querySelector("textarea").value.trim();mode=null;if(next&&(next!==c.text||notes!==(c.notes||"")))await post("/inbox",{op:"edit",dir,text:c.text,next,notes});load()}}
368
417
  if(mode==="reject"){const idea=c.kind==="idea";li.innerHTML=`<div class="q">${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>`;li.querySelector('[data-a=s]').onclick=async()=>{const why=li.querySelector("input").value.trim();if(!why){toast("A reason is required");return}mode=null;const r=idea?await post("/inbox",{op:"reject",dir,text:c.text,reason:why}):await post("/abort",{dir,id:c.id,why});if(r.ok)toast(idea?"Rejected":"Abandoned, reason recorded");load()}}
369
418
  if(mode==="delete"){li.innerHTML=`<div class="q">Delete "${esc(c.text)}"?</div><div class="q dim">Not recorded anywhere.</div><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn danger" data-a="s">Delete</button></div>`;li.querySelector('[data-a=s]').onclick=async()=>{mode=null;const r=await post("/inbox",{op:"delete",dir,text:c.text});if(r.ok)toast("Deleted");load()}}
370
- if(mode==="run"){li.innerHTML=`<div class="q">Start a run for "${esc(c.text)}"</div><div class="q dim">Opens your agent in a new terminal with the request typed in.</div><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn" data-a="l">Lite</button><button class="btn pri" data-a="s">Full run</button></div>`;li.querySelector('[data-a=s]').onclick=()=>startRun(c,"full");li.querySelector('[data-a=l]').onclick=()=>startRun(c,"lite")}
419
+ if(mode==="run"){li.innerHTML=`<div class="q">Start a run for "${esc(c.text)}"</div><div class="q dim">Opens your agent in a new terminal, or a herdr tab or tmux window when one is running, with the request typed in.</div><div class="q dim">Full: planned, built, then checked by a separate reviewer. Lite: one session does it all, faster and cheaper.</div><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn" data-a="l">Lite</button><button class="btn pri" data-a="s">Full run</button></div>`;li.querySelector('[data-a=s]').onclick=()=>startRun(c,"full");li.querySelector('[data-a=l]').onclick=()=>startRun(c,"lite")}
371
420
  li.querySelector('[data-a=c]').onclick=()=>{mode=null;render()};
372
421
  // Edit and reject take dictation; the run prompt has no field.
422
+ li.querySelectorAll("input,textarea").forEach((f,i)=>draft(f,"f:"+mode+":"+modeKey+":"+i));
373
423
  if(mode!=="run"){const m=mic([...li.querySelectorAll("input,textarea")]);if(m)li.querySelector(".row").prepend(m)}
374
424
  const inp=li.querySelector("input");if(inp){inp.onkeydown=e=>{if(e.key==="Enter")li.querySelector('[data-a=s]').click();if(e.key==="Escape"){mode=null;render()}};setTimeout(()=>inp.focus())}
375
425
  return li;}
@@ -377,22 +427,29 @@ function inlineForm(c){
377
427
  async function startRun(c,kind){
378
428
  mode=null;askNotify();
379
429
  const r=await post("/run",{dir:c.state,text:c.text,kind});
380
- if(r.ok)toast(kind+" run opened in your terminal: "+r.agent.name);
430
+ if(r.ok)toast((kind==="full"?"Full":"Lite")+" run opened in your terminal: "+r.agent.name);
381
431
  load()}
382
432
 
383
433
  function render(){
434
+ // A closed form leaves no draft behind; an open one is reopened after a reload.
435
+ // A form whose card is gone (restored after a reload, or deleted elsewhere) has nowhere to show.
436
+ if(mode&&mode!=="new"&&!S.cards.some(c=>key(c)===modeKey))mode=null;
437
+ if(!mode)undraft("f:");try{sessionStorage.setItem("kz-form",JSON.stringify(["new","edit","reject"].includes(mode)?[mode,modeKey]:null))}catch(e){}
384
438
  const cs=S.cards.filter(c=>c.archived===archive),b=$("board");b.innerHTML="";
385
439
  const nArch=S.cards.filter(c=>c.archived).length;
386
440
  const n=s=>cs.filter(c=>c.status===s).length;
387
441
  const strip=[];
388
- if(n("waiting"))strip.push(`<button class="attn ${filter==="waiting"?"on":""}" data-f="waiting">● ${n("waiting")} waiting on you</button>`);
389
- strip.push(`<button class="${filter==="running"?"on":""}" data-f="running"><span class="d-run"><span class="pulse"></span></span> ${n("running")} running</button>`);
390
- if(n("stalled"))strip.push(`<button class="${filter==="stalled"?"on":""}" data-f="stalled">◐ ${n("stalled")} stalled</button>`);
442
+ if(n("waiting"))strip.push(`<button class="attn ${filter==="waiting"?"on":""}" data-f="waiting"><svg><use href="#s-wait"/></svg>${n("waiting")} waiting on you</button>`);
443
+ strip.push(`<button class="${filter==="running"?"on":""}" data-f="running"><span class="d-run"><span class="pulse"></span></span>${n("running")} running</button>`);
444
+ if(n("stalled"))strip.push(`<button class="${filter==="stalled"?"on":""}" data-f="stalled"><svg><use href="#s-stall"/></svg>${n("stalled")} stalled</button>`);
391
445
  strip.push(`<span class="sp"></span><span>${cs.filter(c=>c.kind==="idea").length} ideas · ${cs.filter(c=>c.kind==="run").length} runs</span>`,`<button class="${archive?"on":""}" data-f="__archive">${archive?"Back to board":"Archive "+nArch}</button>`);
392
446
  $("strip").innerHTML=strip.join("");
393
447
  $("strip").querySelectorAll("button").forEach(c=>c.onclick=()=>{if(c.dataset.f==="__archive"){archive=!archive;filter=null;render();return}filter=filter===c.dataset.f?null:c.dataset.f;render()});
394
- const sel=$("proj");sel.innerHTML=`<option value="all">All projects</option>`+(S.project?`<option value="cur">${esc(S.project)}</option>`:"");sel.value=all||!S.project?"all":"cur";
395
- if(!cs.length&&mode!=="new"){b.innerHTML=archive?'<div class="empty"><div class="mascot"></div><div>Nothing archived.</div></div>':'<div class="empty"><div class="mascot"></div><div>Nothing here yet.</div><button class="btn pri" id="newempty">New idea</button></div>';if(!archive)$("newempty").onclick=()=>{mode="new";render()};}
448
+ // [label, folder, from the projects folder]: the button names the current one, the open list is redrawn.
449
+ PL=[["All projects","all"],...S.projects.map(p=>[p.label,p.dir]),...S.fresh.map(p=>[p.label,p.dir,1]),["Find projects on this PC…","__find"]];
450
+ $("proj").textContent=all||!S.dir?"All projects":(PL.find(p=>p[1]===S.dir)||["All projects"])[0];
451
+ if(!$("projpop").hidden)list();
452
+ if(!cs.length&&mode!=="new"){b.innerHTML=archive?'<div class="empty"><div class="mascot"></div><b>Nothing archived</b></div>':'<div class="empty"><div class="mascot"></div><b>Nothing here yet</b><p>Add an idea and kaizen will plan it, build it, and check the result.</p><button class="btn pri" id="newempty">New idea</button></div>';if(!archive)$("newempty").onclick=()=>{mode="new";render()};}
396
453
  COLS.forEach((name,ci)=>{
397
454
  const s=document.createElement("section");const list=cs.filter(c=>c.column===ci&&(!filter||c.status===filter));
398
455
  const clearable=ci===4&&!archive&&list.length;
@@ -412,8 +469,8 @@ function render(){
412
469
  s.querySelectorAll("[data-p]").forEach(bt=>bt.onclick=async e=>{e.stopPropagation();if(bt.dataset.p==="cancel"){picking=false;picked.clear();render();return}
413
470
  const set=list.filter(c=>picked.has(key(c)));picking=false;picked.clear();for(const c of set)await post("/archive",{dir:c.state,id:c.id});toast("Archived "+set.length);load()});}
414
471
  const ul=document.createElement("ul");
415
- if(ci===0&&mode==="new"){const f=document.createElement("li");f.className="inl";f.innerHTML=`<input id="newidea" placeholder="One line, like a commit subject"><textarea id="newnotes" placeholder="Notes: links, paths, background (optional)"></textarea><div class="row"><button class="btn ghost" id="nc">Cancel</button><button class="btn pri" id="ns">Add to inbox</button></div>`;ul.appendChild(f);
416
- const inp=f.querySelector("input");const save=async()=>{const text=inp.value.trim();if(!text)return;const notes=f.querySelector("textarea").value.trim();mode=null;const r=await post("/inbox",{op:"add",text,notes});if(r.ok)toast("Added to inbox.md");load()};
472
+ if(ci===0&&mode==="new"){const f=document.createElement("li");f.className="inl";f.innerHTML=`<input id="newidea" placeholder="What would you like done?"><textarea id="newnotes" placeholder="Notes: links, paths, background (optional)"></textarea><div class="row"><button class="btn ghost" id="nc">Cancel</button><button class="btn pri" id="ns">Add idea</button></div>`;ul.appendChild(f);
473
+ const inp=f.querySelector("input");draft(inp,"f:new:0");draft(f.querySelector("textarea"),"f:new:1");const save=async()=>{const text=inp.value.trim();if(!text)return;const notes=f.querySelector("textarea").value.trim();mode=null;const r=await post("/inbox",{op:"add",text,notes});if(r.ok)toast("Idea added");load()};
417
474
  f.querySelector("#ns").onclick=save;f.querySelector("#nc").onclick=()=>{mode=null;render()};{const m=mic([inp,f.querySelector("textarea")]);if(m)f.querySelector(".row").prepend(m)}inp.onkeydown=e=>{if(e.key==="Enter")save();if(e.key==="Escape"){mode=null;render()}};setTimeout(()=>inp.focus())}
418
475
  const shown=unfolded.has(ci)||list.length<=FOLD+1?list:list.slice(0,FOLD);
419
476
  shown.forEach(c=>{if(modeKey===key(c)&&mode){ul.appendChild(inlineForm(c));return}const li=document.createElement("li");li.appendChild(card(c));ul.appendChild(li)});
@@ -428,8 +485,8 @@ function render(){
428
485
  // ---- detail panel
429
486
  function open(c){
430
487
  stopDictation(true); // the panel body is about to be replaced
431
- openKey=key(c);$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent=c.text;render();
432
- if(c.kind==="idea"){runDoc=null;$("psub").textContent="idea · inbox.md · "+c.where;$("ptabs").innerHTML="";chat(c,c.notes,c.archived||c.status!=="idea");
488
+ fixOff.clear();openKey=key(c);$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent=c.text;render();
489
+ if(c.kind==="idea"){runDoc=null;$("psub").textContent="Idea · "+c.where;$("ptabs").innerHTML="";chat(c,c.notes,c.archived||c.status!=="idea");
433
490
  const foot=$("pfoot");foot.innerHTML=c.archived?`<button class="btn pri" data-a="restore">Restore</button>`:c.status==="idea"?`<button class="btn pri" data-a="run">Run</button><button class="btn" data-a="edit">Edit</button><button class="btn" data-a="reject">Reject</button><button class="btn ghost" data-a="archive">Archive</button><button class="btn ghost danger" data-a="delete">Delete</button>`:`<span style="color:var(--dim);font-size:12px;flex:1">Starting: a run was launched for this idea. No terminal with your agent? Run it again.</span><button class="btn" data-a="run">Run again</button>`;
434
491
  foot.querySelectorAll("[data-a]").forEach(b=>b.onclick=()=>{const a=b.dataset.a;if(a==="archive"||a==="restore"){toggleArchive(c,a==="archive");return}const k=key(c);close();mode=a;modeKey=k;render()});return}
435
492
  openRun(c,false)}
@@ -444,44 +501,96 @@ function chat(c,src,ro){
444
501
  $("pbody").innerHTML=h;
445
502
  if(ro)return;
446
503
  const ta=$("pbody").querySelector("textarea"),files=[];
504
+ draft(ta,"n:"+key(c));
447
505
  {const m=mic([ta]);if(m)$("npick").before(m)}
448
506
  const show=()=>{$("natt").textContent=files.map(f=>f.name).join(" · ")};
449
507
  const add=f=>{files.push(f);show()};
450
508
  $("npick").onclick=()=>$("nfile").click();$("nfile").onchange=e=>{[...e.target.files].forEach(add);e.target.value=""};
451
509
  ta.onpaste=e=>{for(const it of e.clipboardData.items)if(it.kind==="file"){const f=it.getAsFile();add(new File([f],f.name==="image.png"?`paste-${Date.now()}.png`:f.name,{type:f.type}));e.preventDefault()}};
452
510
  const send=async()=>{stopDictation(true);const text=ta.value.trim();if(!text&&!files.length)return;const enc=await Promise.all(files.map(f=>new Promise(r=>{const rd=new FileReader();rd.onload=()=>r({name:f.name,data:rd.result});rd.readAsDataURL(f)})));
453
- const body=c.kind==="run"?{dir:c.state,id:c.id,text,files:enc}:{dir:c.state,for:c.text,text,files:enc};const r=await post("/note",body);if(r.ok){files.length=0;ta.value="";toast("Noted");load()}};
511
+ const body=c.kind==="run"?{dir:c.state,id:c.id,text,files:enc}:{dir:c.state,for:c.text,text,files:enc};const r=await post("/note",body);if(r.ok){files.length=0;ta.value="";try{sessionStorage.removeItem(DK+"n:"+key(c))}catch(e){}toast("Noted");load()}};
454
512
  $("nsend").onclick=send;ta.onkeydown=e=>{if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();send()}};
455
513
  $("pbody").scrollTop=0}
456
514
  // The builder's "## Preview" section of 03-impl.md: how to run the app, or the result itself.
457
- function preview(impl){const m=impl&&impl.match(/^##\s+Preview\s*\n([\s\S]*?)(?=\n##\s|$(?![\s\S]))/m);return m?m[1].trim():null}
458
- async function openRun(c,refresh){
459
- if(!refresh||!runDoc||runDoc.id!==c.id){runDoc=await fetch(`/run/${encodeURIComponent(c.id)}?dir=${encodeURIComponent(c.state)}`).then(r=>r.json())}
515
+ // "## Commit" is the message the builder wrote for the work, offered at commit and push.
516
+ function section(impl,name){const m=impl&&impl.match(new RegExp(`^##\\s+${name}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$(?![\\s\\S]))`,"m"));return m?m[1].trim():null}
517
+ const preview=impl=>section(impl,"Preview");
518
+ const commitMsg=impl=>(section(impl,"Commit")||"").replace(/^```\w*\n|\n```$/g,"").trim();
519
+ // again: something changed on the server. Where the footer offers git, the doc is read
520
+ // afresh: a commit or push made in a terminal shows in nothing the page already holds.
521
+ async function openRun(c,refresh,again){
522
+ if(!refresh||!runDoc||runDoc.id!==c.id||again&&gitBtn(c)){const d=await fetch(`/run/${encodeURIComponent(c.id)}?dir=${encodeURIComponent(c.state)}`).then(r=>r.json());
523
+ // A file list being confirmed, or a push under way, keeps the doc it was drawn from.
524
+ if(refresh&&$("pfoot").querySelector(".inl,[data-busy]"))return;runDoc=d}
460
525
  if(openKey!==key(c))return;
461
526
  // A refresh while notes are being typed would wipe the textarea.
462
527
  const ta=$("pbody").querySelector("textarea");if(refresh&&ta&&document.activeElement===ta)return;
463
- $("psub").textContent=c.id+" · "+(WORD[c.status]||c.status)+(c.awaiting?" · "+c.awaiting:"");
464
- const tabs=[["Request","request"],["Notes","notes"],["Plan","plan"],["Impl","impl"],["Preview","preview"],["Review","review"],["Backlog","backlog"]];
528
+ $("psub").textContent=c.id+" · "+(WORD[c.status]||c.status)+(c.awaiting?" · "+need(c.awaiting):"");
529
+ const tabs=[["Request","request"],["Notes","notes"],["Plan","plan"],["Work","impl"],["Preview","preview"],["Review","review"],["Backlog","backlog"]];
465
530
  $("ptabs").innerHTML=tabs.map((t,i)=>`<button class="${i===tab?"on":""}" data-i="${i}">${t[0]}</button>`).join("");
466
531
  [...$("ptabs").children].forEach(b=>b.onclick=()=>{tab=+b.dataset.i;openRun(c,true)});
467
532
  const k=tabs[tab][1],doc=k==="preview"?preview(runDoc.impl):runDoc[k];
468
533
  if(k==="notes"){const ro=c.archived||c.status==="done"||c.status==="abandoned";
469
534
  chat(c,doc,ro);}
470
- else $("pbody").innerHTML=doc?md(doc):`<p style="color:var(--dim)">No ${({request:"00-request.md",plan:"01-plan.md",impl:"03-impl.md",preview:"'## Preview' in 03-impl.md",review:"04-review.md",backlog:"06-backlog.md"})[k]} yet.</p>`;
535
+ else $("pbody").innerHTML=doc?md(doc,k==="backlog"):`<p style="color:var(--dim)">No ${({request:"request",plan:"plan",impl:"work report",preview:"preview",review:"review",backlog:"backlog"})[k]} yet.</p>`;
536
+ if(k==="review")fixForm(c);
471
537
  const foot=$("pfoot");
472
- const arch=c.archived?`<button class="btn pri" data-a="restore">Restore</button>`:`<button class="btn ghost" data-a="archive">Archive</button>`;
538
+ // A refresh while the commit is being confirmed would wipe the message.
539
+ if(refresh&&foot.querySelector(".inl,[data-busy]"))return;
540
+ const arch=gitBtn(c)+(c.archived?`<button class="btn pri" data-a="restore">Restore</button>`:`<button class="btn ghost" data-a="archive">Archive</button>`);
473
541
  if(c.archived)foot.innerHTML=arch;
474
- else if(c.status==="waiting")foot.innerHTML=`<span style="color:var(--dim);font-size:12px;flex:1">Waiting on <b>${esc(c.awaiting)}</b>. Answer in the terminal running this run.</span>${arch}<button class="btn ghost" data-a="ab">Abandon</button>`;
542
+ else if(c.status==="waiting")foot.innerHTML=`<span style="color:var(--dim);font-size:12px;flex:1">Waiting on you: ${esc(need(c.awaiting))}. Answer in the terminal running this run.</span>${arch}<button class="btn ghost" data-a="ab">Abandon</button>`;
475
543
  else if(c.status==="done"||c.status==="abandoned")foot.innerHTML=arch;
476
544
  else foot.innerHTML=`${arch}<button class="btn ghost" data-a="ab">Abandon run</button>`;
477
- foot.querySelectorAll("[data-a]").forEach(b=>b.onclick=()=>{const a=b.dataset.a;if(a==="archive"||a==="restore"){toggleArchive(c,a==="archive");return}close();mode="reject";modeKey=key(c);render()})}
545
+ foot.querySelectorAll("[data-a]").forEach(b=>b.onclick=()=>{const a=b.dataset.a;if(a==="archive"||a==="restore"){toggleArchive(c,a==="archive");return}if(a==="push"){commitPush(c,"",b);return}if(a==="commit"){confirmCommit(c);return}close();mode="reject";modeKey=key(c);render()})}
546
+ // Fix picked findings, under the review: open ones ticked, closed ones listed but settled.
547
+ // Unticked numbers live outside the form, which every refresh rebuilds. A finding is one
548
+ // line, so md() gives one paragraph; its wrapper is dropped to sit inline in the label.
549
+ const fixOff=new Set();
550
+ function fixForm(c){const fs=runDoc.findings||[],open=fs.filter(f=>!f.done);
551
+ if(c.archived||!open.length||!(c.status==="done"||c.awaiting==="findings"||c.awaiting==="approvals.review"))return;
552
+ const el=document.createElement("div");el.className="inl fix";
553
+ el.innerHTML=`<div class="q">Fix findings</div><div class="q dim">Opens your agent in this project, told to fix the ticked findings and recheck them.</div>${c.status==="waiting"?`<div class="q dim">A session is already waiting on this run in a terminal; this opens another one.</div>`:""}`+fs.map(f=>`<label class="${f.done?"off":""}"><input type="checkbox" data-n="${f.n}" ${f.done?"disabled":fixOff.has(f.n)?"":"checked"}><span>${f.n}. ${md(f.text).replace(/^<p>|<\/p>$/g,"")}${f.done?" (closed)":""}</span></label>`).join("")+`<div class="row"><button class="btn ghost" data-a="all"></button><button class="btn pri" data-a="s"></button></div>`;
554
+ const go=el.querySelector("[data-a=s]"),allb=el.querySelector("[data-a=all]"),picks=()=>open.filter(f=>!fixOff.has(f.n)).map(f=>f.n);
555
+ const sync=()=>{const n=picks().length;go.textContent=n===open.length?"Fix all "+n:"Fix "+n+" selected";go.disabled=!n;allb.textContent=n===open.length?"Select none":"Select all";el.querySelectorAll("input:not(:disabled)").forEach(i=>i.checked=!fixOff.has(+i.dataset.n))};
556
+ el.onchange=e=>{const n=+e.target.dataset.n;e.target.checked?fixOff.delete(n):fixOff.add(n);sync()};
557
+ allb.onclick=()=>{const none=picks().length===open.length;fixOff.clear();if(none)open.forEach(f=>fixOff.add(f.n));sync()};
558
+ go.onclick=async()=>{const nums=picks();go.disabled=true;askNotify();const r=await post("/fix",{dir:c.state,id:c.id,nums});
559
+ // Sent findings come unticked, so a second press cannot open a second agent on them.
560
+ if(r.ok){toast("Fixing "+nums.length+" finding"+(nums.length===1?"":"s")+" in your terminal: "+r.agent.name);nums.forEach(n=>fixOff.add(n))}sync()};
561
+ sync();$("pbody").appendChild(el)}
562
+ // Commit and push, for a finished run in a project with a git remote. What the board
563
+ // did is recorded per run, so the settled label outlives later changes to the tree.
564
+ function gitBtn(c){const g=runDoc.git,k=runDoc.commit;
565
+ if(c.archived||!g||!g.repo||!(c.status==="done"||c.awaiting==="approvals.review"))return"";
566
+ // A run already pushed keeps saying so; work changed since is offered beside it.
567
+ if(k&&k.pushed)return `<button class="btn" disabled title="${esc(k.sha)}">Committed &amp; pushed</button>`+(g.files.length?`<button class="btn" data-a="commit">Commit new changes</button>`:g.ahead>0?`<button class="btn" data-a="push">Push</button>`:"");
568
+ if(g.files.length)return `<button class="btn pri" data-a="commit">Commit &amp; push</button>`;
569
+ if(g.ahead>0)return `<button class="btn pri" data-a="push">Push</button>`;
570
+ // None of its files changed and level with the remote: it was committed and pushed
571
+ // somewhere else. With no record and no report, nothing says it was ever committed.
572
+ return k||runDoc.impl?`<button class="btn" disabled>Committed &amp; pushed</button>`:""}
573
+ function confirmCommit(c,msg){const g=runDoc.git,foot=$("pfoot");
574
+ foot.innerHTML=`<div class="inl" style="flex:1"><div class="q">Commit ${g.files.length} changed file${g.files.length===1?"":"s"} on ${esc(g.branch||"this branch")} and push to ${esc(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">${esc(g.files.join("\n"))}</pre>${g.shared&&g.shared.length?`<div class="q">These files also hold other runs' changes, and a file is committed whole:</div><pre class="files">${esc(g.shared.join("\n"))}</pre>`:""}<textarea aria-label="Commit message" placeholder="feat(scope): what changed">${esc(msg||commitMsg(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>`;
575
+ const inp=foot.querySelector("textarea"),go=foot.querySelector("[data-a=s]");inp.focus();
576
+ // Tall enough to show the whole message, up to half the window.
577
+ const fit=()=>{inp.style.height="auto";inp.style.height=Math.min(inp.scrollHeight+2,innerHeight/2)+"px";$("clong").hidden=inp.value.split("\n")[0].length<=72};inp.oninput=fit;fit();
578
+ foot.querySelector("[data-a=c]").onclick=()=>{foot.innerHTML="";openRun(c,true)};
579
+ go.onclick=()=>{if(!inp.value.trim()){toast("A commit message is required");return}commitPush(c,inp.value,go)};
580
+ inp.onkeydown=e=>{if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();go.click()}}}
581
+ async function commitPush(c,message,b){b.disabled=true;b.dataset.busy=1;b.textContent="Pushing…";
582
+ const r=await post("/commit",{dir:c.state,id:c.id,message,files:runDoc.git.files});if(r.ok)toast("Committed & pushed "+r.sha);
583
+ $("pfoot").innerHTML="";if(openKey!==key(c))return;await openRun(c,false);
584
+ // A refused message comes back for another try, beside the current file list.
585
+ if(!r.ok&&message&&openKey===key(c)&&runDoc.git.files&&runDoc.git.files.length)confirmCommit(c,message)}
478
586
  async function toggleArchive(c,archived){const r=await post("/archive",c.kind==="idea"?{dir:c.state,text:c.text,archived}:{dir:c.state,id:c.id,archived});if(r.ok)toast(archived?"Archived":"Restored");close();load()}
479
587
  function close(){stopDictation(true);openKey=null;runDoc=null;$("panel").classList.remove("open");$("scrim").classList.remove("on");render()}
480
588
  $("pclose").onclick=close;$("scrim").onclick=close;
481
589
 
482
590
  // Enough markdown for kaizen's own files: headings, lists, fences, tables,
483
- // inline code, bold. Everything is escaped first.
484
- function md(src){
591
+ // inline code, bold. Everything is escaped first. With `tick`, a backlog item
592
+ // (`- open: …`, `- done: …`, `- rejected: …`) shows its status as a checkbox.
593
+ function md(src,tick){
485
594
  const lines=src.split("\n"),out=[];let i=0,list=null;
486
595
  const inl=s=>esc(s).replace(/`([^`]+)`/g,"<code>$1</code>").replace(/\*\*([^*]+)\*\*/g,"<b>$1</b>").replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g,'<a href="$2" target="_blank"><img src="$2" alt="$1"></a>').replace(/\[([^\]]+)\]\(([^)\s]+)\)/g,'<a href="$2" target="_blank">$1</a>');
487
596
  const endList=()=>{if(list){out.push(`</${list}>`);list=null}};
@@ -490,7 +599,7 @@ function md(src){
490
599
  const hm=/^(#{1,6})\s+(.*)$/.exec(l);if(hm){endList();out.push(`<h${Math.min(hm[1].length,3)}>${inl(hm[2])}</h${Math.min(hm[1].length,3)}>`);i++;continue}
491
600
  if(/^---+$/.test(l.trim())){endList();out.push("<hr>");i++;continue}
492
601
  if(/^\|/.test(l)){endList();const rows=[];while(i<lines.length&&/^\|/.test(lines[i]))rows.push(lines[i++]);const cells=r=>r.replace(/^\||\|$/g,"").split("|").map(c=>inl(c.trim()));out.push("<table>"+rows.filter(r=>!/^\|\s*-/.test(r)).map((r,n)=>`<tr>${cells(r).map(c=>n?`<td>${c}</td>`:`<th>${c}</th>`).join("")}</tr>`).join("")+"</table>");continue}
493
- const li=/^\s*(?:[-*]|\d+\.)\s+(.*)$/.exec(l);if(li){const t=/^\s*\d+\./.test(l)?"ol":"ul";if(list!==t){endList();out.push(`<${t}>`);list=t}out.push(`<li>${inl(li[1])}</li>`);i++;continue}
602
+ const li=/^\s*(?:[-*]|\d+\.)\s+(.*)$/.exec(l);if(li){const t=/^\s*\d+\./.test(l)?"ol":"ul";if(list!==t){endList();out.push(`<${t}>`);list=t}const tk=tick&&/^(open|done|rejected):\s*(.*)$/.exec(li[1]);out.push(tk?`<li class="tk${tk[1]==="rejected"?" rej":""}"><label><input type="checkbox" tabindex="-1" aria-disabled="true" onclick="return false"${tk[1]==="done"?" checked":""} aria-label="${tk[1]}"><span>${inl(tk[2])}</span></label></li>`:`<li>${inl(li[1])}</li>`);i++;continue}
494
603
  if(!l.trim()){endList();i++;continue}
495
604
  endList();const buf=[l];i++;while(i<lines.length&&lines[i].trim()&&!/^(#|```|\||\s*[-*]\s|\s*\d+\.\s|---)/.test(lines[i]))buf.push(lines[i++]);out.push(`<p>${inl(buf.join(" "))}</p>`)}
496
605
  endList();return out.join("")}
@@ -503,28 +612,83 @@ async function openSettings(){
503
612
  openKey=null;runDoc=null;$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent="Settings";$("ptabs").innerHTML="";$("pfoot").innerHTML="";
504
613
  const cur=document.documentElement.dataset.theme||"system";
505
614
  const cfg=await fetch("/settings").then(r=>r.json()).catch(()=>null);
506
- $("psub").textContent=cfg?`theme is this browser's; the rest is ${cfg.file}`:"theme is this browser's";
507
- // One control per row, in the TUI's grammar: [✓] on / [ ] off for a switch, ▮▮▯ bars for a
508
- // number, the word itself (a select) for a choice. Help shows for the row under the pointer.
615
+ $("psub").textContent="Theme applies to this browser. Everything else is saved to your kaizen settings.";$("psub").title=cfg?cfg.file:"";
616
+ // One control per row: a switch for on/off, one segment per value for a small number,
617
+ // a select for a choice. The row's label names the control; its help sits under it.
509
618
  const ctl=(id,vals,on)=>{
510
- if(vals.length===2&&vals.includes("true")&&vals.includes("false")){const yes=on==="true";return `<button class="chk${yes?" on":""}" id="${id}" data-v="${yes?"false":"true"}" role="switch" aria-checked="${yes}"><i>[${yes?"✓":" "}]</i> ${yes?"on":"off"}</button>`}
511
- if(vals.every(v=>/^\d+$/.test(v))){const at=vals.indexOf(on);return `<span class="bar" id="${id}">${vals.map((v,i)=>`<button data-v="${v}" class="${i<=at?"on":""}" title="${v}"></button>`).join("")}<b>${esc(on)}</b></span>`}
619
+ if(vals.length===2&&vals.includes("true")&&vals.includes("false")){const yes=on==="true";return `<button class="chk${yes?" on":""}" id="${id}" data-v="${yes?"false":"true"}" role="switch" aria-checked="${yes}" title="${yes?"On":"Off"}"></button>`}
620
+ if(vals.every(v=>/^\d+$/.test(v)))return `<span class="bar" id="${id}" role="group">${vals.map(v=>`<button data-v="${v}" class="${v===on?"on":""}" aria-pressed="${v===on}">${v}</button>`).join("")}</span>`;
512
621
  return `<select class="sel" id="${id}">${vals.map(v=>`<option value="${esc(v)}"${v===on?" selected":""}>${v===""?"—":esc(v)}</option>`).join("")}</select>`};
513
- const row=(label,control,hint)=>`<div class="srow"><label>${esc(label)}</label>${control}<p class="hint">${hint}</p></div>`;
514
- $("pbody").innerHTML=`<h3 class="sg">Browser</h3>${row("Theme",ctl("th",["system","light","dark"],cur),"Light or dark for this browser only; system follows the OS.")}
515
- ${row("Notifications",`<button class="chk" id="nt"><i>[${typeof Notification!=="undefined"&&Notification.permission==="granted"?"✓":" "}]</i> ${typeof Notification==="undefined"?"unsupported":Notification.permission==="granted"?"on":"ask"}</button>`,"A desktop notification when a run starts waiting on you.")}
516
- ${cfg?groups(cfg.rows).map(([g,rows])=>`<h3 class="sg">${esc(g)}</h3>${rows.map(r=>row(r.label,ctl("s-"+r.key.replace(/\./g,"-"),r.values,r.value),`<code>${esc(r.key)}</code> options: ${r.values.map(v=>v||'""').map(esc).join(" / ")}<br>${esc(r.help)}`)).join("")}`).join(""):`<p style="color:var(--dim);margin-top:20px">Settings could not be read. Run <code>kaizen settings</code>.</p>`}`;
622
+ const row=(label,control,hint,id)=>`<div class="srow"><label for="${id}">${esc(label)}</label>${control}<p class="hint">${hint}</p></div>`;
623
+ const np=typeof Notification==="undefined"?null:Notification.permission;
624
+ $("pbody").innerHTML=`<h3 class="sg">Browser</h3>${row("Theme",ctl("th",["system","light","dark"],cur),"Light or dark for this browser only; system follows your device.","th")}
625
+ ${row("Notifications",`<button class="chk${np==="granted"?" on":""}" id="nt" role="switch" aria-checked="${np==="granted"}"${np&&np!=="denied"?"":" disabled"}></button>`,"A desktop notification when a run starts waiting on you."+(np?np==="denied"?" Blocked in this browser's site settings.":"":" Not supported in this browser."),"nt")}
626
+ ${cfg?groups(cfg.rows).map(([g,rows])=>`<h3 class="sg">${esc(g)}</h3>${rows.map(r=>{const id="s-"+r.key.replace(/\./g,"-");return row(r.label,r.text?`<input class="sel" id="${id}" value="${esc(r.value)}" placeholder="~/Projects" spellcheck="false">`:ctl(id,r.values,r.value),esc(r.help),id)}).join("")}`).join(""):`<p style="color:var(--dim);margin-top:20px">Settings could not be read. Run <code>kaizen settings</code>.</p>`}`;
517
627
  const setTheme=t=>{if(t==="system")delete document.documentElement.dataset.theme;else document.documentElement.dataset.theme=t;try{t==="system"?localStorage.removeItem("kz-theme"):localStorage.setItem("kz-theme",t)}catch(e){}};
518
628
  $("th").onchange=e=>setTheme(e.target.value);
519
629
  $("nt").onclick=()=>{askNotify();setTimeout(openSettings,300)};
520
- if(cfg)cfg.rows.forEach(r=>{const el=$("s-"+r.key.replace(/\./g,"-"));const save=async v=>{const j=await post("/settings",{key:r.key,value:v});if(j.ok)toast(`saved ${r.key} = ${v||'""'}`);openSettings()};
521
- if(el.tagName==="SELECT")el.onchange=()=>save(el.value);else el.querySelectorAll("[data-v]").forEach(b=>b.onclick=()=>save(b.dataset.v));if(el.matches(".chk"))el.onclick=()=>save(el.dataset.v)})}
630
+ if(cfg)cfg.rows.forEach(r=>{const el=$("s-"+r.key.replace(/\./g,"-"));const save=async v=>{const j=await post("/settings",{key:r.key,value:v});if(j.ok)toast("Saved");openSettings()};
631
+ if(el.tagName==="SELECT"||el.tagName==="INPUT")el.onchange=async()=>{await save(el.value.trim());if(el.tagName==="INPUT")load()};else el.querySelectorAll("[data-v]").forEach(b=>b.onclick=()=>save(b.dataset.v));if(el.matches(".chk"))el.onclick=()=>save(el.dataset.v)})}
632
+ // ---- request sources: links kaizen reads for new ideas. Same panel as settings.
633
+ const SRC="Request sources";
634
+ const srcOpen=()=>$("panel").classList.contains("open")&&$("ptitle").textContent===SRC;
635
+ const ago=at=>{const m=Math.round((Date.now()-Date.parse(at))/60000);return m<1?"just now":m<60?m+" min ago":m<1440?Math.round(m/60)+" h ago":Math.round(m/1440)+" d ago"};
636
+ async function openSources(){
637
+ stopDictation(true);
638
+ // What is half typed survives a refresh from the server.
639
+ const typed=srcOpen()?[$("src-url")?.value||"",$("src-label")?.value||"",document.activeElement?.id,$("src-dir")?.value]:["",""];
640
+ // So does a project picked on a row but not yet moved.
641
+ const picked=new Map(srcOpen()?[...$("pbody").querySelectorAll("[data-mv]")].map(m=>[m.dataset.k,m.value]):[]);
642
+ openKey=null;runDoc=null;$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent=SRC;$("ptabs").innerHTML="";$("pfoot").innerHTML="";
643
+ $("psub").textContent="Links kaizen checks for new requests. What it finds lands in Ideas; nothing starts on its own.";$("psub").title="";
644
+ const dir=all?null:S.dir,q=dir?"?dir="+encodeURIComponent(dir):"";
645
+ const d=await fetch("/sources"+q).then(r=>r.json()).catch(()=>null);
646
+ if(!srcOpen())return;
647
+ const host=u=>{try{return new URL(u).hostname}catch{return u}};
648
+ const list=d&&d.sources||[];
649
+ // A source belongs to one project, named on its row. Picking another only arms Move: a select
650
+ // changes on every arrow key, and each change would have moved the source. The names are the picker's.
651
+ const opts=on=>S.projects.map(p=>`<option value="${esc(p.dir)}"${p.dir===on?" selected":""}>${esc(p.label)}</option>`).join("");
652
+ const rows=list.map((s,i)=>`<div class="srow"><label>${esc(s.label||host(s.url))}</label><button class="btn ghost" id="src-rm-${i}" data-rm="${i}" aria-label="Remove ${esc(s.label||host(s.url))}">Remove</button><p class="hint" style="overflow-wrap:anywhere">${s.url.startsWith("https://")?`<a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer" style="color:inherit">${esc(s.url)}</a>`:esc(s.url)}<br>${s.last?(s.last.ok?esc(s.last.note):`<span style="color:var(--ink);font-weight:500">Failed: ${esc(s.last.note)}</span>`)+" · "+ago(s.last.at):"Not gathered yet"}</p><p class="hint" style="margin-top:6px">Ideas go to <select class="sel" id="src-mv-${i}" data-mv="${i}" data-k="${esc(s.dir+" "+s.url)}" aria-label="Project of ${esc(s.label||host(s.url))}">${opts(s.dir)}</select> <button class="btn" id="src-mb-${i}" aria-label="Move ${esc(s.label||host(s.url))} to the chosen project" disabled>Move</button></p></div>`).join("")+(d&&d.failed||[]).map(f=>`<p style="color:var(--dim)">The sources of ${esc(f)} could not be read.</p>`).join("");
653
+ $("pbody").innerHTML=(d&&d.error?`<p style="color:var(--dim)">${esc(d.error)}</p>`:!d?`<p style="color:var(--dim)">Sources could not be read.</p>`:rows||`<p style="color:var(--dim)">No sources yet. Paste a link to a Google Sheet, a Doc, or a shared note.</p>`)
654
+ +`<h3 class="sg">Add a source</h3><div style="display:grid;gap:8px;margin-top:10px"><input class="sel" id="src-url" aria-label="Link" placeholder="https://docs.google.com/…" spellcheck="false" autocomplete="off" style="width:100%;max-width:none"><input class="sel" id="src-label" aria-label="Label (optional)" placeholder="Label (optional)" style="width:100%;max-width:none"><select class="sel" id="src-dir" aria-label="Project this source belongs to" style="width:100%;max-width:none">${dir?"":`<option value="">Choose the project it belongs to…</option>`}${opts(dir)}</select><div style="display:flex;gap:8px"><button class="btn pri" id="src-add">Add source</button><button class="btn" id="src-go"${list.length?"":" disabled"}>Gather now</button></div><p class="hint" style="font-size:12px;color:var(--dim);margin:0">${d&&d.every&&d.every!=="off"?`Checked every ${esc(d.every)} while the board is running.`:"Automatic checks are off."} Change it in Settings.</p></div>`;
655
+ $("src-url").value=typed[0];$("src-label").value=typed[1];if(typed[3]!==undefined)$("src-dir").value=typed[3];
656
+ $("pbody").querySelectorAll("[data-mv]").forEach(m=>{const s=list[+m.dataset.mv],b=$("src-mb-"+m.dataset.mv),to=picked.get(m.dataset.k);
657
+ if(to&&[...m.options].some(o=>o.value===to))m.value=to;
658
+ (m.onchange=()=>{b.disabled=m.value===s.dir})();
659
+ b.onclick=()=>act({op:"move",dir:s.dir,to:m.value,url:s.url},()=>toast("Source moved"))});
660
+ // A Move button that went back to disabled cannot take focus; its select can.
661
+ const was=typed[2]&&$(typed[2]);if(was)(was.disabled?$(was.id.replace("src-mb-","src-mv-")):was).focus();
662
+ // Every action names the project it acts on; only "gather everything" goes without one.
663
+ const act=async(body,done)=>{const j=await post("/sources",body);if(j.ok){done(j);load()}if(srcOpen())openSources()};
664
+ $("src-add").onclick=()=>{const url=$("src-url").value.trim();if(!url){$("src-url").focus();return}const to=$("src-dir").value;if(!to){$("src-dir").focus();return}act({op:"add",dir:to,url,label:$("src-label").value.trim()},()=>{$("src-url").value=$("src-label").value="";toast("Source added")})};
665
+ $("src-url").onkeydown=$("src-label").onkeydown=e=>{if(e.key==="Enter")$("src-add").click()};
666
+ $("src-go").onclick=()=>{$("src-go").disabled=true;$("src-go").textContent="Gathering…";act({op:"gather",dir:dir||undefined},j=>{const n=(j.results||[]).reduce((a,r)=>a+r.added,0),bad=(j.results||[]).filter(r=>!r.ok).length;toast((n?`${n} new idea${n===1?"":"s"}`:"Nothing new")+(bad?`, ${bad} source${bad===1?"":"s"} could not be read`:""))})};
667
+ $("pbody").querySelectorAll("[data-rm]").forEach(b=>b.onclick=()=>act({op:"remove",dir:list[+b.dataset.rm].dir,url:list[+b.dataset.rm].url},()=>toast("Source removed")))}
522
668
  let tt;function toast(s){const t=$("toast");t.textContent=s;t.classList.add("on");clearTimeout(tt);tt=setTimeout(()=>t.classList.remove("on"),4000)}
523
669
  function askNotify(){try{if(typeof Notification!=="undefined"&&Notification.permission==="default")Notification.requestPermission()}catch(e){}}
524
- function notifyUser(title,body){try{if(typeof Notification!=="undefined"&&Notification.permission==="granted")new Notification(title,{body})}catch(e){}}
525
- $("proj").onchange=e=>{all=e.target.value==="all";load()};
670
+ function notifyUser(body){try{if(typeof Notification!=="undefined"&&Notification.permission==="granted")new Notification("kaizen",{body,icon:"/logo.png"})}catch(e){}}
671
+ // The project picker: a button shaped like a select that opens a search field over the list.
672
+ let PL=[];
673
+ function list(){const q=$("projq").value.trim().toLowerCase();let g=0;
674
+ $("projs").innerHTML=PL.map((p,i)=>p[0].toLowerCase().includes(q)?(p[2]&&!g++?`<div class="grp">In your projects folder</div>`:"")+`<button role="option" data-i="${i}" title="${esc(p[0])}">${esc(p[0])}</button>`:"").join("")||`<div class="grp">No match</div>`;
675
+ const f=$("projs").querySelector("button");if(f)f.classList.add("on")}
676
+ function pop(on){$("projpop").hidden=!on;$("proj").setAttribute("aria-expanded",on);if(on){$("projq").value="";list();$("projq").focus()}}
677
+ $("proj").onclick=()=>pop($("projpop").hidden);
678
+ $("projq").oninput=list;
679
+ $("projq").onkeydown=e=>{const bs=[...$("projs").querySelectorAll("button")],i=bs.findIndex(b=>b.classList.contains("on"));
680
+ if(e.key==="Escape"){pop(false);$("proj").focus()}
681
+ else if(e.key==="Enter"){if(bs[i])bs[i].click()}
682
+ else if((e.key==="ArrowDown"||e.key==="ArrowUp")&&bs[i]){e.preventDefault();const n=bs[(i+(e.key==="ArrowDown"?1:bs.length-1))%bs.length];bs[i].classList.remove("on");n.classList.add("on");n.scrollIntoView({block:"nearest"})}};
683
+ $("projs").onclick=e=>{const b=e.target.closest("button");if(b){pop(false);pick(PL[b.dataset.i][1])}};
684
+ document.addEventListener("click",e=>{if(!e.target.closest("#combo"))pop(false)});
685
+ async function pick(v){
686
+ if(v==="__find"){toast("Searching…");const j=await post("/find",{});if(j.ok)toast(j.added?`Found ${j.added} new project${j.added===1?"":"s"}`:"No new projects found")}
687
+ else if(v==="all"){all=true;keep()}
688
+ else{const j=await post("/cd",{dir:v});if(j.ok){all=false;keep(v)}}
689
+ load()}
526
690
  $("newtop").onclick=()=>{mode="new";render();$("board").scrollTo(0,0)};
527
- $("settings").onclick=openSettings;
691
+ $("settings").onclick=openSettings;$("sources").onclick=openSources;
528
692
  try{const t=localStorage.getItem("kz-theme");if(t)document.documentElement.dataset.theme=t}catch(e){}
529
693
  $("kbd").onclick=()=>$("help").classList.add("on");$("help").onclick=()=>$("help").classList.remove("on");
530
694
  document.addEventListener("click",()=>{if(menuKey||clearMenu){menuKey=null;clearMenu=false;render()}});
@@ -533,7 +697,7 @@ document.onkeydown=e=>{
533
697
  if(e.key==="?"){$("help").classList.toggle("on");return}
534
698
  if(e.key==="Escape"){$("help").classList.remove("on");if(mode){mode=null;render()}else close();return}
535
699
  if(e.key==="n"){mode="new";render()}
536
- if(e.key==="a"){all=!all;load()}
700
+ if(e.key==="a"){all=!all;keep(all?null:S.dir);load()}
537
701
  const oc=openKey&&S.cards.find(c=>key(c)===openKey);if(!oc)return;
538
702
  if(e.key==="e"&&oc.kind==="idea"){close();mode="edit";modeKey=key(oc);render()}
539
703
  if(e.key==="x"&&!["done","abandoned","starting"].includes(oc.status)){close();mode="reject";modeKey=key(oc);render()}
@@ -541,8 +705,15 @@ document.onkeydown=e=>{
541
705
 
542
706
  // ---- live
543
707
  let boot=null;
544
- function listen(){const es=new EventSource("/events");es.onmessage=ev=>{if(ev.data==="changed")load();else if(ev.data==="reload")location.reload();else if(ev.data.startsWith("hello ")){const b=ev.data.slice(6);if(boot&&b!==boot)location.reload();boot=b}};es.onerror=()=>{es.close();setTimeout(listen,3000)}}
545
- load();listen();
708
+ // Back from a terminal: what was committed or pushed there touched nothing the server watches.
709
+ addEventListener("focus",()=>load(true));
710
+ function listen(){const es=new EventSource("/events");es.onmessage=ev=>{if(ev.data==="changed"){load(true);if(srcOpen()&&!$("src-go")?.disabled)openSources()}else if(ev.data==="reload")location.reload();else if(ev.data.startsWith("hello ")){const b=ev.data.slice(6);if(boot&&b!==boot)location.reload();boot=b}};es.onerror=()=>{es.close();setTimeout(listen,3000)}}
711
+ // Back to the project picked last time, unless the URL says which view it wants. A
712
+ // plain fetch: a project that is gone should fall back to all, not raise a toast.
713
+ (async()=>{let d;try{d=localStorage.getItem("kz-proj")}catch(e){}
714
+ if(d&&!urlAll)try{if((await fetch("/cd",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({dir:d})})).ok)all=false;else keep()}catch(e){}
715
+ try{const f=JSON.parse(sessionStorage.getItem("kz-form"));if(f)[mode,modeKey]=f}catch(e){}
716
+ load();listen()})();
546
717
  </script>
547
718
  </body>
548
719
  </html>