kaizen-agent 1.9.4 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/board.html CHANGED
@@ -26,6 +26,11 @@ pre.mark span:nth-child(1){color:var(--ink-hi)}pre.mark span:nth-child(2){color:
26
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
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
28
  .sel:hover{border-color:var(--dim)}
29
+ .agents{display:flex;flex-wrap:wrap;gap:6px}
30
+ .agent{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:var(--r-s);padding:6px 12px;background:var(--bg-2);color:var(--dim);font-size:13px;cursor:pointer}
31
+ .agent svg{width:14px;height:14px}.agent:hover{border-color:var(--dim)}
32
+ .yolo{display:inline-flex;align-items:center;gap:8px;margin-right:auto;font-size:13px;color:var(--ink);cursor:pointer;white-space:nowrap}
33
+ .agent[aria-checked=true]{border-color:var(--accent);color:var(--ink);background:color-mix(in srgb,var(--accent) 14%,var(--bg-2))}
29
34
  .hdr .sel{max-width:min(320px,100%);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left}
30
35
  /* project picker: a select-shaped button over a searchable list */
31
36
  .combo{position:relative;max-width:100%;display:flex}
@@ -55,14 +60,14 @@ select.sel,button.sel{cursor:pointer;appearance:none;padding-right:30px;max-widt
55
60
  .btn.ghost{background:none;border-color:transparent;color:var(--dim);box-shadow:none}.btn.ghost:hover{color:var(--ink);background:var(--hover)}
56
61
 
57
62
  /* 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)}
63
+ .strip{display:inline-flex;gap:8px;flex-wrap:wrap;align-items:center;font-size:13px;color:var(--dim)}
59
64
  .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
65
  .strip button:hover,.strip button.on{color:var(--ink);background:var(--hover)}
61
66
  .strip button.attn{color:var(--warn);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--warn) 45%,transparent)}
62
67
  .strip svg{width:14px;height:14px;stroke-width:1.8}
63
68
 
64
69
  /* board */
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}
70
+ main{flex:1;min-height:0;display:grid;grid-template-columns:repeat(5,minmax(200px,1fr));gap:24px;padding:16px 28px 20px;overflow:auto;align-content:start}
66
71
  section h2::before{content:"";display:block;height:16px;margin-top:-16px}
67
72
  section{padding-top:16px}
68
73
  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}
@@ -108,7 +113,7 @@ section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
108
113
  .chip.accent{color:var(--accent);background:color-mix(in srgb,var(--accent) 14%,transparent)}.chip.accent i{background:var(--accent)}
109
114
  .chip.ok{color:color-mix(in srgb,var(--ok) 80%,var(--ink));background:color-mix(in srgb,var(--ok) 14%,transparent)}
110
115
  .chip.run{color:var(--run);background:color-mix(in srgb,var(--run) 14%,transparent)}.chip.run i{background:var(--run)}
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)}
116
+ .st.d-idea{color:var(--dim)}.st.d-start{color:var(--run)}.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)}
112
117
  .d-run{color:var(--run)}.d-wait{color:var(--warn)}.d-done{color:var(--ok)}
113
118
  .pulse{display:inline-block;width:7px;height:7px;border-radius:50%;background:currentColor;vertical-align:middle}
114
119
  .d-run .pulse,.st.d-run svg{animation:pl 1.6s infinite}
@@ -118,7 +123,7 @@ section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
118
123
  .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
124
  .menu-pop button:hover{background:var(--hover)}
120
125
  .menu-pop button.danger{color:var(--danger)}
121
- .menu-pop svg{width:15px;height:15px}
126
+ .menu-pop svg{width:15px;height:15px;flex-shrink:0}
122
127
  .menu-pop hr{border:0;border-top:1px solid var(--line);margin:4px 0}
123
128
  .more-btn{width:100%;padding:8px;color:var(--dim);font-size:12px;border:1px dashed var(--line);border-radius:var(--r)}
124
129
  .more-btn:hover{color:var(--ink);border-color:var(--dim)}
@@ -136,6 +141,7 @@ section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
136
141
  .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)}
137
142
  .inl pre.files{margin:0;max-height:140px;overflow:auto;font:12px/1.5 var(--mono);color:var(--dim)}
138
143
  .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}
144
+ .inl.qa{max-height:60vh;overflow:auto}.inl.qa fieldset{border:0;padding:0;margin:0;display:flex;flex-direction:column;gap:4px}.inl.qa legend{font-size:13px;font-weight:500;padding:0;margin-bottom:4px}.inl.qa label{display:flex;gap:8px;align-items:baseline;font-size:13px;cursor:pointer}.inl.qa label input{flex:none;width:auto;margin:0;accent-color:var(--accent);position:relative;top:2px}
139
145
  .inl .q{font-size:14px;font-weight:500}.inl .q.dim{color:var(--dim);font-size:13px;font-weight:400}
140
146
  .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
147
  .empty b{font-size:16px;font-weight:600;color:var(--ink)}.empty p{margin:0 0 6px;max-width:44ch}
@@ -144,8 +150,10 @@ section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
144
150
 
145
151
  /* footer */
146
152
  .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}
147
- .ftr span+span{margin-left:14px}.ftr .st svg{width:12px;height:12px}
148
- .ftr a{color:var(--dim);cursor:pointer;text-decoration:underline;text-underline-offset:2px}.ftr a:hover{color:var(--ink)}
153
+ .ftr span+span,.ftr a+a{margin-left:14px}.ftr .strip{margin-right:14px;vertical-align:middle;font-size:12px}
154
+ /* The footer is out of the eye's path, so the waiting alert is filled, not just outlined. */
155
+ .ftr .strip .attn{color:var(--ink);background:color-mix(in srgb,var(--warn) 18%,transparent);box-shadow:inset 0 0 0 1px var(--warn)}.ftr .strip .attn:hover,.ftr .strip .attn.on{background:color-mix(in srgb,var(--warn) 30%,transparent)}.ftr .strip .attn svg{color:var(--warn)}.ftr .st svg{width:12px;height:12px}
156
+ .ftr a{color:var(--dim);cursor:pointer;white-space:nowrap;text-decoration:underline;text-underline-offset:2px}.ftr a:hover{color:var(--ink)}
149
157
 
150
158
  /* side panel */
151
159
  .scrim{position:fixed;inset:0;z-index:10;background:color-mix(in srgb,var(--bg) 60%,transparent);backdrop-filter:blur(3px);display:none}
@@ -164,11 +172,30 @@ aside nav button.on{color:var(--ink);box-shadow:inset 0 -2px var(--accent)}
164
172
  aside .body{padding:24px;overflow:auto;flex:1}
165
173
  aside .foot{padding:16px 24px;border-top:1px solid var(--line);display:flex;gap:8px;flex-wrap:wrap;align-items:center}
166
174
  aside .foot:empty{display:none}
175
+ /* The footer's message takes its own line; housekeeping sits left, the decision right. */
176
+ aside .foot .note{flex-basis:100%;color:var(--dim);font-size:12px;line-height:1.5}
177
+ /* Kept together and right-aligned even when it wraps under the rest. */
178
+ aside .foot .dec{margin-left:auto;display:flex;gap:8px}
179
+ /* Housekeeping folded away while a decision is on screen; opens upward from the footer. */
180
+ aside .foot .more{position:relative}.more>summary{list-style:none;cursor:pointer}.more>summary::-webkit-details-marker{display:none}
181
+ aside .foot .more .menu-pop{left:0;right:auto;top:auto;bottom:calc(100% + 4px)}.menu-pop button:disabled{color:var(--dim);cursor:default;background:none}
167
182
  .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
183
  .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
184
  .md .lnk a{color:var(--accent);text-underline-offset:3px}
170
185
  .md .hit{outline:2px solid var(--accent);outline-offset:3px;border-radius:var(--r-s);transition:outline-color .6s}
171
186
  .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}
187
+ /* A building run's progress.md: the work list as done, working on, to do. Only a
188
+ changed item animates (.chg); the bar slides from its last width. */
189
+ .prog{margin:0 0 18px}.prog .ph{display:flex;justify-content:space-between;font-size:13px;color:var(--dim);margin-bottom:6px}.prog .ph b{color:var(--ink)}
190
+ .pbar{height:6px;border-radius:3px;background:var(--line);overflow:hidden}.pbar i{display:block;height:100%;background:var(--accent);border-radius:3px;transition:width .26s cubic-bezier(.22,.61,.36,1)}
191
+ .md .prog ol{list-style:none;padding:0;margin:12px 0 0;display:flex;flex-direction:column;gap:2px}
192
+ .prog li{display:flex;gap:10px;align-items:baseline;margin:0;padding:6px 8px;border-radius:var(--r-s);transition:background .12s,color .12s}
193
+ .prog .pi{flex:none;width:16px;height:16px;border-radius:50%;display:inline-grid;place-items:center;font-size:11px;position:relative;top:3px}
194
+ .prog .p-done{color:var(--dim)}.prog .p-done .pi{background:var(--ok);color:var(--bg-2)}
195
+ .prog .p-doing{background:color-mix(in srgb,var(--accent) 10%,transparent);font-weight:600}.prog .p-doing .pi{color:var(--accent);border:1.5px solid currentColor}.prog .p-doing .pulse{animation:pl 1.6s infinite}
196
+ .prog .p-todo{color:var(--dim)}.prog .p-todo .pi{border:1.5px solid var(--line)}
197
+ .prog li.chg{animation:fi .26s}.prog li.chg .pi{animation:pop .26s cubic-bezier(.22,.61,.36,1)}
198
+ @keyframes pop{from{transform:scale(.4)}}
172
199
  .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)}
173
200
  .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}
174
201
  .find{border-bottom:1px solid var(--line);padding:10px 0;display:flex;gap:10px;align-items:flex-start}
@@ -182,6 +209,12 @@ aside .foot:empty{display:none}
182
209
  .srow>:nth-child(2){grid-column:2;grid-row:1/span 2;align-self:center;justify-self:end}
183
210
  .srow .hint{grid-column:1;margin:0;font-size:12px;color:var(--dim);line-height:1.45;max-width:46ch}
184
211
  .srow input.sel{width:170px}
212
+ .fpick{display:inline-flex;gap:6px;align-items:center}
213
+ .srow[hidden],.sg[hidden],.sjump button[hidden]{display:none}
214
+ /* search and section chips: pinned to the top of the panel while the rows scroll under it */
215
+ .sq{position:sticky;top:-24px;z-index:1;background:var(--bg-2);padding:24px 0 12px;margin:-24px 0 4px;border-bottom:1px solid var(--line)}.sq input{width:100%;max-width:none}.sq+.sg{margin-top:16px}
216
+ .sjump{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}.sjump button{padding:3px 10px;border:1px solid var(--line);border-radius:var(--r-pill);font-size:12px;color:var(--dim)}.sjump button:hover{background:var(--hover);color:var(--ink)}
217
+ .snone{color:var(--dim);margin-top:20px}
185
218
  /* a switch: the same <button role="switch"> the save wiring binds to, drawn as a track and a thumb */
186
219
  .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}
187
220
  .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}
@@ -190,12 +223,62 @@ aside .foot:empty{display:none}
190
223
  .bar{display:inline-flex;border:1px solid var(--line);border-radius:var(--r-s);overflow:hidden}
191
224
  .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)}
192
225
  .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)}
226
+ /* the preset: a game's quick-preset carousel — arrows either side, the tier lit underneath */
227
+ .car{display:inline-grid;grid-template-columns:32px 96px 32px;align-items:center;border:1px solid var(--line);border-radius:var(--r-s);background:var(--bg-2)}
228
+ .car button{height:32px;color:var(--dim);font-size:12px}.car button:hover{background:var(--hover);color:var(--accent)}.car button:focus-visible{outline-offset:-2px}
229
+ .car span{display:flex;flex-direction:column;align-items:center;gap:3px;font-weight:600;text-transform:capitalize}
230
+ .car i{display:flex;gap:3px}.car u{width:14px;height:3px;border-radius:2px;background:var(--line)}.car u.on{background:var(--accent)}
193
231
  .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}
194
232
  .toast.on{opacity:1}
233
+ .toast.on.rep{pointer-events:auto}.toast a{color:inherit;margin-left:8px;text-decoration:underline;text-underline-offset:2px}
195
234
  .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)}
196
235
  .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)}
197
- .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)}
198
- @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)}}
236
+ /* request sources: cards, status badges, top bar, and add-box */
237
+ .src-topbar{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;background:var(--bg);border:1px solid var(--line);border-radius:var(--r);margin:14px 0 16px;gap:12px}
238
+ .src-topbar .meta{display:flex;flex-direction:column;gap:2px}
239
+ .src-topbar .count{font-size:13px;font-weight:600;color:var(--ink)}
240
+ .src-topbar .sched{font-size:12px;color:var(--dim)}
241
+ .src-topbar .sched a{color:var(--accent);text-decoration:none;cursor:pointer}
242
+ .src-topbar .sched a:hover{text-decoration:underline}
243
+ .src-topbar .btn{display:inline-flex;align-items:center;gap:6px}
244
+ .src-topbar .btn svg{width:14px;height:14px}
245
+ .src-list{display:flex;flex-direction:column;gap:10px}
246
+ .src-card{background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r);padding:14px;box-shadow:var(--shadow-1);display:flex;flex-direction:column;gap:9px;transition:border-color .15s}
247
+ .src-card:hover{border-color:color-mix(in srgb,var(--line) 70%,var(--ink))}
248
+ .src-hd{display:flex;justify-content:space-between;align-items:center;gap:10px}
249
+ .src-info{display:flex;align-items:center;gap:8px;min-width:0}
250
+ .src-ico{width:20px;height:20px;flex-shrink:0;color:var(--accent);display:inline-flex;align-items:center;justify-content:center}
251
+ .src-ico svg{width:18px;height:18px}
252
+ .src-title{font-size:14px;font-weight:600;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
253
+ .src-acts{display:flex;align-items:center;gap:4px;flex-shrink:0}
254
+ .src-acts .btn{display:inline-flex;align-items:center;gap:5px}
255
+ .src-acts .btn svg{width:14px;height:14px}
256
+ .src-url{font-size:12px;color:var(--dim);overflow-wrap:anywhere;line-height:1.4}
257
+ .src-url a{color:inherit;text-decoration:none;display:inline-flex;align-items:center;gap:4px}
258
+ .src-url a:hover{color:var(--ink);text-decoration:underline}
259
+ .src-url svg{width:12px;height:12px;stroke-width:1.8;flex-shrink:0}
260
+ .src-status{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:12px}
261
+ .src-badge{display:inline-flex;align-items:center;gap:5px;padding:2px 8px;border-radius:var(--r-pill);font-size:11px;font-weight:500}
262
+ .src-badge.ok{background:color-mix(in srgb,var(--ok) 14%,transparent);color:var(--ok)}
263
+ .src-badge.warn{background:color-mix(in srgb,var(--warn) 20%,transparent);color:var(--ink);font-weight:500}
264
+ .src-badge.warn svg{color:var(--warn)}
265
+ .src-badge.dim{background:color-mix(in srgb,var(--dim) 14%,transparent);color:var(--dim)}
266
+ .src-badge svg{width:12px;height:12px;stroke-width:2;flex-shrink:0}
267
+ .src-dest{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dim);padding-top:8px;border-top:1px solid var(--line)}
268
+ .src-dest .sel{flex:1;max-width:240px;height:28px;font-size:12px;padding:2px 8px}
269
+ .src-dest .btn{height:28px;padding:0 10px;font-size:12px}
270
+ .src-empty{padding:26px 16px;text-align:center;background:var(--bg);border:1px dashed var(--line);border-radius:var(--r);margin:12px 0 16px}
271
+ .src-empty-ico{width:36px;height:36px;margin:0 auto 10px;color:var(--dim);display:flex;align-items:center;justify-content:center}
272
+ .src-empty-ico svg{width:32px;height:32px}
273
+ .src-empty h4{margin:0 0 6px;font-size:14px;font-weight:600;color:var(--ink)}
274
+ .src-empty p{margin:0 0 12px;font-size:12px;color:var(--dim);line-height:1.5}
275
+ .src-empty-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px;margin-top:12px;text-align:left}
276
+ .src-empty-item{background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r-s);padding:8px 10px;font-size:11px;color:var(--dim);line-height:1.4}
277
+ .src-empty-item strong{display:block;color:var(--ink);font-weight:500;margin-bottom:2px}
278
+ .src-add-box{background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r);padding:16px;margin-top:16px;box-shadow:var(--shadow-1)}
279
+ .src-add-box h3{margin:0 0 12px;font-size:13px;font-weight:600;color:var(--ink);display:flex;align-items:center;gap:6px}
280
+ .src-add-box h3 svg{width:16px;height:16px}
281
+ @media(max-width:900px){main{grid-template-columns:1fr;padding-inline:16px;gap:8px}.hdr,.ftr{padding-inline:16px}.more{display:grid}.card:hover .age{display:inline}.toast{left:16px;max-width:calc(100% - 32px)}}
199
282
  @media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}
200
283
  </style>
201
284
  </head>
@@ -213,7 +296,15 @@ aside .foot:empty{display:none}
213
296
  <symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14M10 11v6M14 11v6"/></symbol>
214
297
  <symbol id="i-close" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></symbol>
215
298
  <symbol id="i-more" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.2" fill="currentColor"/><circle cx="12" cy="12" r="1.2" fill="currentColor"/><circle cx="19" cy="12" r="1.2" fill="currentColor"/></symbol>
299
+ <symbol id="i-open" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M15 3v18"/></symbol>
300
+ <symbol id="i-archive" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4"/></symbol>
301
+ <symbol id="i-restore" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></symbol>
302
+ <symbol id="i-sheet" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18"/></symbol>
303
+ <symbol id="i-doc" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/></symbol>
304
+ <symbol id="i-refresh" viewBox="0 0 24 24"><path d="M21 3v5h-5M3 21v-5h5M20.5 13A9 9 0 0 1 5.7 18.3L3 16M3.5 11A9 9 0 0 1 18.3 5.7L21 8"/></symbol>
305
+ <symbol id="i-ext" viewBox="0 0 24 24"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14L21 3"/></symbol>
216
306
  <symbol id="s-idea" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" stroke-dasharray="2 2.5"/></symbol>
307
+ <symbol id="s-start" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" stroke-dasharray="2 2.5"/><path d="M6.5 5.5v5l4-2.5z" fill="currentColor" stroke="none"/></symbol>
217
308
  <symbol id="s-run" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/><path d="M8 2a6 6 0 0 1 0 12z" fill="currentColor" stroke="none"/></symbol>
218
309
  <symbol id="s-stall" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/><path d="M8 2a6 6 0 0 1 0 12z" fill="currentColor" stroke="none" opacity=".35"/></symbol>
219
310
  <symbol id="s-wait" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/><path d="M8 5v3.5M8 11v.1"/></symbol>
@@ -242,11 +333,9 @@ aside .foot:empty{display:none}
242
333
  <button class="ib" id="sources" title="Request sources" aria-label="Request sources"><svg><use href="#i-link"/></svg></button>
243
334
  <button class="ib" id="settings" title="Settings" aria-label="Settings"><svg><use href="#i-gear"/></svg></button>
244
335
  </div>
245
- <div class="strip" id="strip"></div>
246
-
247
336
  <main id="board" aria-live="polite"></main>
248
337
 
249
- <div class="ftr"><span id="legend"></span><span><a id="kbd">Keyboard shortcuts</a></span></div>
338
+ <div class="ftr"><span id="legend"></span><span><span class="strip" id="strip"></span><a href="/issue" target="_blank" rel="noopener">Report an issue</a><a id="kbd">Keyboard shortcuts</a></span></div>
250
339
 
251
340
  <div class="scrim" id="scrim"></div>
252
341
  <aside id="panel" aria-label="detail">
@@ -258,7 +347,7 @@ aside .foot:empty{display:none}
258
347
  <div class="toast" id="toast" role="status"></div>
259
348
  <div class="help" id="help"><div>
260
349
  Optional. Everything here is also a button.<br>
261
- <kbd>n</kbd>new idea<br><kbd>e</kbd>edit<br><kbd>x</kbd>reject / abandon<br><kbd>r</kbd>run<br><kbd>a</kbd>all projects<br><kbd>Esc</kbd>close<br><kbd>?</kbd>this list</div></div>
350
+ <kbd>←↓↑→</kbd><kbd>hjkl</kbd>move between cards<br><kbd>Enter</kbd>open<br><kbd>←→</kbd><kbd>hl</kbd>tabs, in an open run<br><kbd>↑↓</kbd><kbd>jk</kbd>scroll, in an open run<br><kbd>n</kbd>new idea<br><kbd>e</kbd>edit<br><kbd>x</kbd>reject / abandon<br><kbd>r</kbd>run<br><kbd>a</kbd>all projects<br><kbd>Esc</kbd>close<br><kbd>?</kbd>this list</div></div>
262
351
 
263
352
  <script>
264
353
  // The page never touches the filesystem. Everything comes from /state and the run
@@ -343,7 +432,7 @@ function mic(fields){
343
432
  return b}
344
433
  const m=60e3,h=3600e3,d=86400e3;
345
434
  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"};
346
- const NAMES={claude:"Claude Code",codex:"Codex",agy:"Antigravity",opencode:"OpenCode",gemini:"Gemini",cursor:"Cursor",unrechecked:"fix not rechecked"};
435
+ const NAMES={claude:"Claude Code",codex:"Codex",agy:"Antigravity",opencode:"OpenCode",gemini:"Gemini",cursor:"Cursor",yolo:"yolo",unrechecked:"fix not rechecked"};
347
436
  const MARKS={claude:1,codex:1,agy:1,opencode:1,gemini:1,cursor:1,full:1,lite:1};
348
437
  const COLS=["Ideas","Planning","Building","Review","Done"];
349
438
  // state.ts statuses onto the icon set in the <defs> above.
@@ -374,41 +463,64 @@ function draft(el,k){try{const v=sessionStorage.getItem(DK+k);if(v!=null)el.valu
374
463
  function undraft(p){try{Object.keys(sessionStorage).forEach(k=>{if(k.startsWith(DK+p))sessionStorage.removeItem(k)})}catch(e){}}
375
464
  // quiet: the server said something changed, nobody asked for a redraw.
376
465
  async function load(quiet){
377
- let r;try{r=await fetch("/state"+(all?"?all=1":""))}catch{toast(GONE);return}S=await r.json();
466
+ let r;try{r=await fetch("/state"+(all?"?all=1":""))}catch{toast(GONE);return}const was=openKey&&S.cards.find(x=>key(x)===openKey);S=await r.json();
378
467
  {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}}
379
468
  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)}
380
469
  // A redraw under a form being typed in would reset its fields and its focus.
381
470
  if(!(quiet&&mode&&$("board").querySelector(".inl input")))render();
382
- 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")}}
471
+ if(openKey){const c=S.cards.find(x=>key(x)===openKey),ta=$("pbody").querySelector(".notes textarea");if(ta&&document.activeElement===ta)return;
472
+ if(!c){if(was&&was.kind==="idea")follow(was);return}if(runDoc)openRun(c,true,true);else if(c.kind==="idea")chat(c,c.notes,c.archived||c.status!=="idea")}}
473
+ // An idea's card goes when its run starts. Follow it to that run rather than keep
474
+ // offering "Run again": the newest run in its project whose request holds the idea's
475
+ // text, the rule boardCards() retires it by. No such run: close the panel.
476
+ // ponytail: checks the five newest runs, one /run fetch each; a run that took the idea
477
+ // further back than that closes the panel instead.
478
+ async function follow(idea){
479
+ const k=key(idea),norm=s=>s.toLowerCase().replace(/\s+/g," ").trim(),t=norm(idea.text);
480
+ for(const r of S.cards.filter(x=>x.kind==="run"&&x.state===idea.state).sort((a,b)=>(b.moved??0)-(a.moved??0)).slice(0,5)){
481
+ const d=await fetch(`/run/${encodeURIComponent(r.id)}?dir=${encodeURIComponent(r.state)}`).then(x=>x.json()).catch(()=>null);
482
+ if(openKey!==k)return;if(norm(d?.request||"").includes(t)){open(r);return}}
483
+ if(openKey===k)close()}
383
484
  // A tab outliving its server would otherwise swallow every click: say so.
384
485
  const GONE="The board server is not running — start it with kaizen web (or the shortcut), then reload.";
385
- 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}
486
+ 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){const m=j.error||j.why||"That did not work";toast(m,{title:m.slice(0,80),what:`POST ${path} failed (HTTP ${r.status}): ${m}`});if(j.manual)console.log("run manually:",j.manual)}return j}
386
487
 
387
488
  function card(c){
388
- 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");
489
+ const k=key(c),el=document.createElement("div");el.className="card"+(k===openKey?" open":"")+(k===menuKey?" menu":"")+(c.archived?" archived":"");el.tabIndex=0;el.dataset.k=k;el.setAttribute("role","button");
389
490
  const st=ICON[c.status]||"idea",idt=c.kind==="idea"?"idea":c.text;
390
491
  const chips=[];
391
492
  if(c.status==="waiting")chips.push(`<span class="chip warn"><i></i>${esc(need(c.awaiting)||"needs you")}</span>`);
392
493
  else if(c.status==="running")chips.push(`<span class="chip run"><i></i>running</span>`);
393
494
  if(all)chips.push(`<span class="chip">${esc(c.where)}</span>`);
394
- 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>`}));
395
- if(c.notes)chips.push(`<span class="chip">notes</span>`);
495
+ 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":t==="yolo"?" warn":""}"${t==="yolo"?' title="Started in Yolo: the agent runs commands without asking"':""}>${mark}${esc(NAMES[t]||t)}</span>`}));
496
+ // A gathered idea's notes open with the stamp ingest() writes; its chip names the source.
497
+ const g=c.notes&&/^\[[^\]]*\] Gathered from (.+?)\. Outside text/.exec(c.notes);if(g)chips.push(`<span class="chip" title="From ${esc(g[1])}">gathered</span>`);else if(c.notes)chips.push(`<span class="chip">notes</span>`);
396
498
  // Information only: the panel's button is where it is committed and pushed.
397
499
  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>`)}
398
500
  const pick=picking&&!archive&&c.column===4;
399
- 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>
501
+ 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" aria-haspopup="menu" aria-expanded="${k===menuKey}">${ico("more")}</button></div>
400
502
  <div class="c-title">${esc(c.kind==="idea"?c.text:(c.title||c.id))}</div>
401
503
  <div class="c-chips">${chips.join("")}</div>`;
402
504
  if(k===menuKey){
403
505
  const items=[];
404
- if(archive){items.push(["restore","Restore",ico("play")]);if(c.kind==="run")items.push(["open","Open",ico("play")]);}
405
- else if(c.kind==="idea"&&c.status==="idea")items.push(["run","Run",ico("play")],["edit","Edit",ico("pen")],["reject","Reject",ico("x")],null,["delete","Delete",ico("trash"),"danger"]);
406
- else if(c.kind==="idea")items.push(["run","Run again",ico("play")],null,["delete","Remove",ico("trash"),"danger"]);
407
- else if(c.status==="waiting"||c.status==="running"||c.status==="stalled")items.push(["open","Open",ico("play")],null,["reject","Abandon",ico("x"),"danger"]);
408
- else items.push(["open","Open",ico("play")]);
409
- if(!archive&&!(c.kind==="idea"&&c.status!=="idea"))items.push(null,["archive","Archive",ico("x")]);
410
- el.innerHTML+=`<div class="menu-pop">${items.map(i=>i?`<button data-a="${i[0]}" class="${i[3]||""}">${i[2]}${i[1]}</button>`:"<hr>").join("")}</div>`}
411
- el.querySelectorAll("[data-a]").forEach(bt=>bt.onclick=async e=>{e.stopPropagation();const a=bt.dataset.a;menuKey=null;if(a==="menu"){menuKey=k;render();return}if(a==="open"){open(c);return}
506
+ if(archive){
507
+ items.push(["restore","Restore",ico("restore")]);
508
+ items.push(["open","Open",ico("open")]);
509
+ if(c.kind==="idea")items.push(null,["delete","Delete",ico("trash"),"danger"]);
510
+ }else if(c.kind==="idea"&&c.status==="idea"){
511
+ items.push(["run","Run",ico("play")],["edit","Edit",ico("pen")],["archive","Archive",ico("archive")],null,["reject","Reject",ico("x")],["delete","Delete",ico("trash"),"danger"]);
512
+ }else if(c.kind==="idea"){
513
+ items.push(["open","Open",ico("open")],["run","Run again",ico("play")],["archive","Archive",ico("archive")],null,["delete","Remove",ico("trash"),"danger"]);
514
+ }else{
515
+ const openLabel=c.status==="waiting"?(c.awaiting==="approvals.plan"?"Review plan":c.awaiting==="findings"?"See findings":c.awaiting==="approvals.review"?"Review work":"Open"):"Open";
516
+ items.push(["open",openLabel,ico("open")]);
517
+ items.push(["archive","Archive",ico("archive")]);
518
+ if(c.status==="waiting"||c.status==="running"||c.status==="stalled"){
519
+ items.push(null,["reject","Abandon",ico("x"),"danger"]);
520
+ }
521
+ }
522
+ el.innerHTML+=`<div class="menu-pop" role="menu">${items.map(i=>i?`<button role="menuitem" data-a="${i[0]}" class="${i[3]||""}">${i[2]}${i[1]}</button>`:"<hr>").join("")}</div>`}
523
+ el.querySelectorAll("[data-a]").forEach(bt=>bt.onclick=async e=>{e.stopPropagation();const a=bt.dataset.a;const was=menuKey;menuKey=null;if(a==="menu"){menuKey=was===k?null:k;render();return}if(a==="open"){open(c);return}
412
524
  if(a==="archive"||a==="restore"){toggleArchive(c,a==="archive");return}
413
525
  mode=a;modeKey=k;render()});
414
526
  if(pick){el.classList.toggle("picked",picked.has(k));el.onclick=()=>{picked.has(k)?picked.delete(k):picked.add(k);render()};return el}
@@ -420,7 +532,7 @@ function inlineForm(c){
420
532
  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()}}
421
533
  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()}}
422
534
  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()}}
423
- 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")}
535
+ 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 id="run-pick" style="display:grid;gap:6px"><div id="run-agent" class="agents" role="radiogroup" aria-label="Agent"></div></div><div class="row"><label class="yolo" id="run-yolo" title="Yolo: the agent runs every command and edit without asking" hidden><button type="button" class="chk" role="switch" aria-checked="false"></button>Yolo</label><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");runForm(li,c)}
424
536
  li.querySelector('[data-a=c]').onclick=()=>{mode=null;render()};
425
537
  // Edit and reject take dictation; the run prompt has no field.
426
538
  li.querySelectorAll("input,textarea").forEach((f,i)=>draft(f,"f:"+mode+":"+modeKey+":"+i));
@@ -428,17 +540,45 @@ function inlineForm(c){
428
540
  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())}
429
541
  return li;}
430
542
 
543
+ // The agent and Basic/Yolo picked in the open Run form. Kept here, not in the form:
544
+ // the board is rebuilt on every server change, and the choice must outlive that.
545
+ // Yolo never outlives the form: render() drops it once the form is closed.
546
+ let runPick=null;
547
+ // The tool a launch opened, and why when it is not the one asked for.
548
+ const via=a=>a.name+(a.note?". "+a.note:"");
549
+ async function runForm(li,c){
550
+ const k=key(c),box=li.querySelector("#run-agent");
551
+ const d=await fetch("/agents?dir="+encodeURIComponent(c.state)).then(r=>r.json()).catch(()=>null);
552
+ if(!d||!d.agents||!d.agents.length){li.querySelector("#run-pick").hidden=true;return}
553
+ // Antigravity and Gemini CLI are both Google's agent on Gemini; offer one of them.
554
+ if(d.agents.some(a=>a.cmd==="agy"))d.agents=d.agents.filter(a=>a.cmd!=="gemini");
555
+ if(!runPick||runPick.k!==k)runPick={k,agent:d.agents.some(a=>a.cmd===d.default)?d.default:d.agents[0].cmd};
556
+ box.innerHTML=d.agents.map(a=>`<button type="button" class="agent" role="radio" aria-checked="${a.cmd===runPick.agent}" data-cmd="${esc(a.cmd)}"><svg><use href="#c-${esc(a.cmd)}"/></svg>${esc(a.name)}</button>`).join("");
557
+ const yb=li.querySelector("#run-yolo");
558
+ // Yolo only where the picked tool has a skip-permissions mode; Basic otherwise.
559
+ const sw=yb.querySelector(".chk");
560
+ const showYolo=()=>{const can=!!d.agents.find(a=>a.cmd===runPick.agent)?.yolo,on=!!runPick.yolo;yb.hidden=!can;sw.classList.toggle("on",on);sw.setAttribute("aria-checked",on)};
561
+ box.querySelectorAll(".agent").forEach(b=>b.onclick=()=>{runPick.agent=b.dataset.cmd;box.querySelectorAll(".agent").forEach(x=>x.setAttribute("aria-checked",x===b));showYolo()});
562
+ sw.onclick=()=>{runPick.yolo=!runPick.yolo;showYolo()};
563
+ showYolo();
564
+ box.onkeydown=sw.onkeydown=e=>{if(e.key==="Escape"){mode=null;render()}}}
565
+
431
566
  async function startRun(c,kind){
432
567
  mode=null;askNotify();
433
- const r=await post("/run",{dir:c.state,text:c.text,kind});
434
- if(r.ok)toast((kind==="full"?"Full":"Lite")+" run opened in your terminal: "+r.agent.name);
568
+ const p=runPick&&runPick.k===key(c)?runPick:null;runPick=null;
569
+ const yolo=!!(p&&p.yolo&&!$("run-yolo")?.hidden);
570
+ const r=await post("/run",{dir:c.state,text:c.text,kind,agent:p?p.agent:undefined,yolo});
571
+ if(r.ok)toast((kind==="full"?"Full":"Lite")+(yolo?" yolo":"")+" run opened in your terminal: "+via(r.agent));
435
572
  load()}
436
573
 
437
574
  function render(){
438
575
  // A closed form leaves no draft behind; an open one is reopened after a reload.
439
576
  // A form whose card is gone (restored after a reload, or deleted elsewhere) has nowhere to show.
440
577
  if(mode&&mode!=="new"&&!S.cards.some(c=>key(c)===modeKey))mode=null;
578
+ if(mode!=="run"&&runPick)runPick.yolo=false;
441
579
  if(!mode)undraft("f:");try{sessionStorage.setItem("kz-form",JSON.stringify(["new","edit","reject"].includes(mode)?[mode,modeKey]:null))}catch(e){}
580
+ // The board is rebuilt below; the card that had focus gets it back.
581
+ const fk=focusedKey();
442
582
  const cs=S.cards.filter(c=>c.archived===archive),b=$("board");b.innerHTML="";
443
583
  const nArch=S.cards.filter(c=>c.archived).length;
444
584
  const n=s=>cs.filter(c=>c.status===s).length;
@@ -446,7 +586,7 @@ function render(){
446
586
  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>`);
447
587
  strip.push(`<button class="${filter==="running"?"on":""}" data-f="running"><span class="d-run"><span class="pulse"></span></span>${n("running")} running</button>`);
448
588
  if(n("stalled"))strip.push(`<button class="${filter==="stalled"?"on":""}" data-f="stalled"><svg><use href="#s-stall"/></svg>${n("stalled")} stalled</button>`);
449
- 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>`);
589
+ strip.push(`<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>`);
450
590
  $("strip").innerHTML=strip.join("");
451
591
  $("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()});
452
592
  // [label, folder, from the projects folder]: the button names the current one, the open list is redrawn.
@@ -484,18 +624,34 @@ function render(){
484
624
  const seen=new Map();cs.forEach(c=>seen.set(c.status,1));
485
625
  $("legend").innerHTML=[...seen.keys()].map(s=>`<span class="st d-${ICON[s]}" style="display:inline-grid;vertical-align:-2px"><svg><use href="#s-${ICON[s]}"/></svg></span> ${WORD[s]||s}`).join(" ");
486
626
  stopDictation(false); // forms were just rebuilt; a listener whose field is gone would write into nothing
627
+ if(fk&&document.activeElement===document.body)cardEl(fk)?.focus({preventScroll:true});
487
628
  }
629
+ const focusedKey=()=>document.activeElement?.closest?.("#board .card")?.dataset.k;
630
+ const cardEl=k=>k&&$("board").querySelector('.card[data-k="'+CSS.escape(k)+'"]');
631
+ // Arrows and hjkl, as on the terminal board: up/down within a column, left/right to the
632
+ // nearest column with cards, at the same row or its last card.
633
+ function nav(dx,dy){
634
+ const cols=[...$("board").querySelectorAll("section")].map(s=>[...s.querySelectorAll(".card")]).filter(l=>l.length);if(!cols.length)return;
635
+ const cur=document.activeElement.closest(".card");let ci=cols.findIndex(l=>l.includes(cur)),ri=0;
636
+ if(ci<0)ci=0;else{ri=cols[ci].indexOf(cur);if(dy)ri=Math.max(0,Math.min(cols[ci].length-1,ri+dy));else{ci=Math.max(0,Math.min(cols.length-1,ci+dx));ri=Math.min(ri,cols[ci].length-1)}}
637
+ const t=cols[ci][ri];t.focus({preventScroll:true});t.scrollIntoView({block:"nearest",inline:"nearest"})}
488
638
 
489
639
  // ---- detail panel
490
- function open(c){
640
+ // A run's tabs, its phases in board order. A card opens on its column's tab.
641
+ const TABS=[["Request","request"],["Notes","notes"],["Plan","plan"],["Backlog","backlog"],["Work","impl"],["Review","review"],["Preview","preview"]];
642
+ const tabOf=k=>TABS.findIndex(t=>t[1]===k);
643
+ // keepTab: a link already chose the tab.
644
+ function open(c,keepTab){
491
645
  stopDictation(true); // the panel body is about to be replaced
492
646
  fixOff.clear();openKey=key(c);$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent=c.text;render();
493
647
  if(c.kind==="idea"){runDoc=null;$("psub").textContent="Idea · "+c.where;$("ptabs").innerHTML="";chat(c,c.notes,c.archived||c.status!=="idea");
494
- 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>`;
648
+ const foot=$("pfoot");foot.innerHTML=c.archived?`<button class="btn pri" data-a="restore">Restore</button>`:c.status==="idea"?`<button class="btn ghost danger" data-a="delete">Delete</button><button class="btn ghost" data-a="archive">Archive</button><span class="dec"><button class="btn" data-a="reject">Reject</button><button class="btn" data-a="edit">Edit</button><button class="btn pri" data-a="run">Run</button></span>`:`<span class="note">Starting: a run was launched for this idea. No terminal with your agent? Run it again.</span><span class="dec"><button class="btn" data-a="run">Run again</button></span>`;
495
649
  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}
650
+ if(!keepTab)tab=tabOf(c.status==="abandoned"?"plan":({1:"plan",2:"backlog",3:"review",4:"preview"})[c.column]||"request");
496
651
  openRun(c,false)}
497
652
  // Notes are a chat: one entry per message, files attached by paste or picker.
498
653
  function parseNotes(src){const out=[];for(const raw of (src||"").split("\n")){const m=/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]\s?(.*)$/.exec(raw);if(m){out.push({at:m[1],text:m[2]});continue}const last=out[out.length-1];if(last)last.text+="\n"+raw.replace(/^\s{2}/,"");else if(raw.trim())out.push({at:"",text:raw})}return out.map(n=>({...n,text:n.text.trim()})).filter(n=>n.text)}
654
+ let nfocus=null;
499
655
  function chat(c,src,ro){
500
656
  const base=c.kind==="run"?`runs/${c.id}/`:"";
501
657
  const fix=t=>t.replace(/\]\((attachments\/[^)]+)\)/g,(_,p)=>`](/file?dir=${encodeURIComponent(c.state)}&p=${encodeURIComponent(base+p)})`);
@@ -506,37 +662,64 @@ function chat(c,src,ro){
506
662
  if(ro)return;
507
663
  const ta=$("pbody").querySelector("textarea"),files=[];
508
664
  draft(ta,"n:"+key(c));
665
+ // Enter leaves focus in the box, and a focused box blocks the redraw: send blurs it
666
+ // so the note shows, and the new box takes focus back for the next note.
667
+ if(nfocus===key(c)){nfocus=null;ta.focus()}
509
668
  {const m=mic([ta]);if(m)$("npick").before(m)}
510
669
  const show=()=>{$("natt").textContent=files.map(f=>f.name).join(" · ")};
511
670
  const add=f=>{files.push(f);show()};
512
671
  $("npick").onclick=()=>$("nfile").click();$("nfile").onchange=e=>{[...e.target.files].forEach(add);e.target.value=""};
513
672
  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()}};
514
673
  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)})));
515
- 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()}};
674
+ 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");nfocus=key(c);ta.blur();load()}};
516
675
  $("nsend").onclick=send;ta.onkeydown=e=>{if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();send()}};
517
676
  $("pbody").scrollTop=0}
518
- // The builder's "## Preview" section of 03-impl.md: how to run the app, or the result itself.
677
+ // The builder's "## Preview" section of 03-impl.md: how to run the app, or the result itself,
678
+ // and its "### How to test" steps.
519
679
  // "## Commit" is the message the builder wrote for the work, offered at commit and push.
520
680
  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}
521
681
  const preview=impl=>section(impl,"Preview");
522
682
  const commitMsg=impl=>(section(impl,"Commit")||"").replace(/^```\w*\n|\n```$/g,"").trim();
523
- // again: something changed on the server. Where the footer offers git, the doc is read
524
- // afresh: a commit or push made in a terminal shows in nothing the page already holds.
683
+ // A plan's Open questions as a pick list: `Q: question` lines with `- option` lines under
684
+ // each, "(recommended)" marking the default and "multi-select" in the question allowing
685
+ // several. A blank or other line ends a question's options. The format is pinned in
686
+ // spec-plan.md; a section with content in any other shape is null, none at all is [].
687
+ function planQuestions(src){const lines=(src||"").split("\n"),at=lines.findIndex(l=>/^#+\s*(\d+\.\s*)?open questions/i.test(l.trim()));if(at<0)return[];
688
+ const lvl=/^#+/.exec(lines[at].trim())[0].length,out=[];let cur=null,said=false;
689
+ for(const raw of lines.slice(at+1)){const l=raw.trim(),h=/^#+(?=\s)/.exec(l);if(h&&h[0].length<=lvl)break;
690
+ if(!h&&l&&!/^```/.test(l)&&!/^none\.?$/i.test(l))said=true;
691
+ const q=/^\**Q\d*\s*[:.)]\**\s*(.+)$/.exec(l);if(q){out.push(cur={q:q[1].trim(),multi:/multi-?select/i.test(q[1]),options:[]});continue}
692
+ const o=/^[-*]\s+(.+)$/.exec(l);
693
+ if(o&&cur){const t=o[1].trim();cur.options.push({text:t,rec:/\(recommended\)/i.test(t),label:t.replace(/\s*\(recommended\)/i,"").split(/\s+[—–-]\s+|:\s/)[0].trim()})}
694
+ else if(cur&&cur.options.length)cur=null}
695
+ const qs=out.filter(x=>x.options.length);return qs.length||!said?qs:null}
696
+ // A building run's Backlog tab opens with its progress, above the deferred items. The
697
+ // first draw of a run animates nothing; later ones animate what moved, the bar included.
698
+ let lastProg=null;
699
+ function showProgress(c){
700
+ const src=c.column===2&&runDoc.progress,prev=lastProg&&lastProg.id===c.id?lastProg:null,h=src?progress(src,prev&&prev.st):"";
701
+ if(!h)return;lastProg={id:c.id,st:[...src.matchAll(/^\s*-\s*(todo|doing|done):/gm)].map(m=>m[1]),w:+(/width:(\d+)%/.exec(h)[1])};
702
+ $("pbody").innerHTML=h+(runDoc.backlog?"<h3>Backlog</h3>"+md(runDoc.backlog,true):"");
703
+ const bar=$("pbody").querySelector(".pbar i");if(prev&&prev.w!==lastProg.w){bar.style.width=prev.w+"%";bar.offsetWidth;bar.style.width=lastProg.w+"%"}}
704
+ // again: something changed on the server, so the doc is read afresh: a note sent, a plan
705
+ // written or a commit made elsewhere shows in nothing the page already holds. One /run
706
+ // fetch per change while a run panel is open; a tab switch reuses the doc.
525
707
  async function openRun(c,refresh,again){
526
- 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());
708
+ if(!refresh||!runDoc||runDoc.id!==c.id||again){const d=await fetch(`/run/${encodeURIComponent(c.id)}?dir=${encodeURIComponent(c.state)}`).then(r=>r.json());
527
709
  // A file list being confirmed, or a push under way, keeps the doc it was drawn from.
528
- if(refresh&&$("pfoot").querySelector(".inl,[data-busy]"))return;runDoc=d}
710
+ if(refresh&&$("pfoot").querySelector(".inl,[data-busy],.more[open]"))return;runDoc=d}
529
711
  if(openKey!==key(c))return;
530
712
  // A refresh while notes are being typed would wipe the textarea.
531
713
  const ta=$("pbody").querySelector("textarea");if(refresh&&ta&&document.activeElement===ta)return;
532
714
  $("psub").textContent=c.id+" · "+(WORD[c.status]||c.status)+(c.awaiting?" · "+need(c.awaiting):"");
533
- const tabs=[["Request","request"],["Notes","notes"],["Plan","plan"],["Work","impl"],["Preview","preview"],["Review","review"],["Backlog","backlog"]];
715
+ const tabs=TABS;
534
716
  $("ptabs").innerHTML=tabs.map((t,i)=>`<button class="${i===tab?"on":""}" data-i="${i}">${t[0]}</button>`).join("");
535
717
  [...$("ptabs").children].forEach(b=>b.onclick=()=>{tab=+b.dataset.i;openRun(c,true)});
536
718
  const k=tabs[tab][1],doc=k==="preview"?preview(runDoc.impl):runDoc[k];
537
719
  if(k==="notes"){const ro=c.archived||c.status==="done"||c.status==="abandoned";
538
720
  chat(c,doc,ro);}
539
721
  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>`;
722
+ if(k==="backlog")showProgress(c);
540
723
  if(k==="review")fixForm(c);
541
724
  // The notebook's notes that link here, each opening that note: above the run's own notes.
542
725
  if(k==="notes"&&runDoc.linkedFrom&&runDoc.linkedFrom.length)$("pbody").insertAdjacentHTML("afterbegin",`<h3>Linked from notes</h3><ul class="lnk">${runDoc.linkedFrom.map(n=>`<li><a href="/notebook?dir=${encodeURIComponent(c.state)}&amp;note=${encodeURIComponent(n)}">${esc(n)}</a></li>`).join("")}</ul>`);
@@ -544,13 +727,45 @@ async function openRun(c,refresh,again){
544
727
  if(hit&&!refresh){if(hit.k===key(c))mark(hit);else hit=null}
545
728
  const foot=$("pfoot");
546
729
  // A refresh while the commit is being confirmed would wipe the message.
547
- if(refresh&&foot.querySelector(".inl,[data-busy]"))return;
730
+ if(refresh&&foot.querySelector(".inl,[data-busy],.more[open]"))return;
548
731
  const arch=gitBtn(c)+(c.archived?`<button class="btn pri" data-a="restore">Restore</button>`:`<button class="btn ghost" data-a="archive">Archive</button>`);
732
+ // A plan that asks questions takes their answers with the approval.
733
+ // One the board cannot read is said, so it is answered in the terminal instead of skipped.
734
+ const pq=c.status==="waiting"&&c.awaiting==="approvals.plan"?planQuestions(runDoc.plan):[],qs=pq||[];
549
735
  if(c.archived)foot.innerHTML=arch;
550
- 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>`;
736
+ else if(c.status==="waiting"&&APPROVAL[c.awaiting]){const sent=decided.has(answer(c)),asks=!pq?" It has open questions the board cannot show: answer them in the terminal running this run, or approve and your agent asks them.":qs.length?` It asks ${qs.length===1?"one question":qs.length+" questions"}: Approve opens ${qs.length===1?"it":"them"}.`:"";
737
+ // gitBtn's buttons restyled as menu rows.
738
+ const more=gitBtn(c).replace(/class="btn( pri)?"/g,"")+`<button data-a="archive">Archive</button><hr><button data-a="ab" class="danger">Abandon</button>`;
739
+ foot.innerHTML=`<span class="note">${sent?"Sent to your agent.":APPROVAL[c.awaiting]+asks+" Answering here opens a new agent session."}</span><details class="more"><summary class="btn ghost">More</summary><div class="menu-pop">${more}</div></details><span class="dec"><button class="btn" data-a="revise"${sent?" disabled":""}>Revise</button><button class="btn pri" data-a="approve"${sent?" disabled":""}>Approve</button></span>`}
740
+ else if(c.status==="waiting")foot.innerHTML=`<span class="note">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>`;
551
741
  else if(c.status==="done"||c.status==="abandoned")foot.innerHTML=arch;
552
- else foot.innerHTML=`${arch}<button class="btn ghost" data-a="ab">Abandon run</button>`;
553
- 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()})}
742
+ else foot.innerHTML=`<span class="note">${c.status==="stalled"?"No progress for a while. Resume it with <code>/kaizen run</code> in your agent, or abandon it.":"Running in your agent."}</span>${arch}<button class="btn ghost" data-a="ab">Abandon run</button>`;
743
+ 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}if(a==="approve"){if(qs.length)confirmAnswers(c,qs);else decide(c,b);return}if(a==="revise"){confirmRevise(c);return}close();mode="reject";modeKey=key(c);render()})}
744
+ // Answering an approval from the panel opens your agent on the run, told the decision and,
745
+ // for a revise, the comment. Answered approvals are remembered outside the footer, which
746
+ // every refresh rebuilds, so a second press cannot open a second agent. Keyed by when the
747
+ // run last moved: the same approval reached again after a revise is a new question.
748
+ // The footer's More menu is a <details>: close it on a click anywhere else.
749
+ document.addEventListener("click",e=>{for(const d of document.querySelectorAll(".more[open]"))if(!d.contains(e.target))d.open=false});
750
+ const APPROVAL={"approvals.plan":"The plan is ready. Read it in the Plan tab, then approve or send it back.","approvals.review":"The work is reviewed. Approve to finish it, or revise with what to change."},decided=new Set(),answer=c=>key(c)+":"+c.awaiting+":"+c.moved;
751
+ async function decide(c,b,why,answers){b.disabled=true;askNotify();const r=await post("/decide",{dir:c.state,id:c.id,awaiting:c.awaiting,...(why===undefined?{}:{why}),...(answers?{answers}:{})});
752
+ // A failed send keeps the footer, and with it a typed comment.
753
+ if(!r.ok){b.disabled=false;return}
754
+ decided.add(answer(c));toast((why===undefined?"Approved":"Sent back")+" in your terminal: "+via(r.agent));
755
+ if(openKey===key(c)){$("pfoot").innerHTML="";openRun(c,true)}}
756
+ function confirmRevise(c){const foot=$("pfoot"),plan=c.awaiting==="approvals.plan";
757
+ foot.innerHTML=`<div class="inl" style="flex:1"><div class="q">${plan?"What should the plan do differently?":"What should change in the work?"}</div><div class="q dim">Opens your agent on this run with your comment.${plan?" It revises the plan and comes back here for approval.":" It changes the work, rechecks it and comes back here."}</div><textarea aria-label="Revise comment"></textarea><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Send back</button></div></div>`;
758
+ const inp=foot.querySelector("textarea");inp.focus();
759
+ foot.querySelector("[data-a=c]").onclick=()=>{foot.innerHTML="";openRun(c,true)};
760
+ foot.querySelector("[data-a=s]").onclick=e=>{const why=inp.value.trim();if(!why){toast("Say what should change");inp.focus();return}decide(c,e.target,why)}}
761
+ // The plan's questions, answered with the approval: the recommended option preselected,
762
+ // a note for what the options miss. Each answer reaches the agent as one line.
763
+ function confirmAnswers(c,qs){const foot=$("pfoot");
764
+ foot.innerHTML=`<div class="inl qa" style="flex:1"><div class="q">The plan asks ${qs.length===1?"one question":qs.length+" questions"}.</div><div class="q dim">Your answers go to your agent with the approval. It records them and builds the plan.</div>${qs.map((x,i)=>`<fieldset><legend>${esc(x.q)}</legend>${x.options.map((o,j)=>`<label><input type="${x.multi?"checkbox":"radio"}" name="q${i}" value="${j}"${o.rec||!x.multi&&!j&&!x.options.some(p=>p.rec)?" checked":""}> ${esc(o.text)}</label>`).join("")}<input type="text" data-n="${i}" placeholder="Note (optional)" aria-label="Note: ${esc(x.q)}"></fieldset>`).join("")}<div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Approve with these answers</button></div></div>`;
765
+ foot.querySelector("[data-a=c]").onclick=()=>{foot.innerHTML="";openRun(c,true)};
766
+ foot.querySelector("[data-a=s]").onclick=e=>decide(c,e.target,undefined,qs.map((x,i)=>{
767
+ const picked=[...foot.querySelectorAll(`[name=q${i}]:checked`)].map(n=>`"${x.options[+n.value].label}"`).join(", ")||"none of the options",note=foot.querySelector(`[data-n="${i}"]`).value.trim();
768
+ return `${x.q} — ${picked}${note?"; "+note:""}`}))}
554
769
  // A link from a note (?run= or ?idea=, with dir, tab, item) opens that card's panel;
555
770
  // a finding or backlog item named is scrolled to and marked. Found by its text, since
556
771
  // the rendered list does not carry the numbers.
@@ -561,29 +776,31 @@ function deep(){const p=new URLSearchParams(location.search),d=p.get("dir"),id=p
561
776
  if(!c){toast(id?"That run is not on this board":"That idea is not on this board");return}
562
777
  const m=/^(finding|backlog)-(\d+)$/.exec(p.get("item")||"");
563
778
  // An item's tab is its own when the link does not name one.
564
- const t=["request","notes","plan","work","preview","review","backlog"].indexOf(p.get("tab")||(m?(m[1]==="finding"?"review":"backlog"):""));tab=t<0?0:t;hit=m?{type:m[1],n:+m[2],k:key(c)}:null;open(c)}
779
+ const n=p.get("tab")||(m?(m[1]==="finding"?"review":"backlog"):""),t=tabOf(n==="work"?"impl":n);if(t>=0)tab=t;hit=m?{type:m[1],n:+m[2],k:key(c)}:null;open(c,t>=0)}
565
780
  // What md() leaves of a line as text: no backticks or stars, a link reduced to its words.
566
781
  function mark(h){hit=null;const flat=t=>String(t).replace(/!?\[([^\]]*)\]\([^)\s]+\)/g,"$1").replace(/[`*]/g,"").replace(/\s+/g," ").trim();
567
782
  const b=[...(runDoc.backlog||"").matchAll(/^\s*-\s*(?:open|done|rejected):\s*(.*)$/gm)];
568
783
  const text=h.type==="finding"?(runDoc.findings||[]).find(f=>f.n===h.n)?.text:b[h.n-1]?.[1];
569
784
  if(!text){toast("That item is not in this run");return}
570
- const want=flat(text).slice(0,60),el=[...$("pbody").querySelectorAll("li,p")].find(e=>flat(e.textContent).includes(want));
785
+ const want=flat(text).slice(0,60),el=[...$("pbody").querySelectorAll("li,p")].find(e=>!e.closest(".prog")&&flat(e.textContent).includes(want));
571
786
  if(!el)return;el.scrollIntoView({block:"center"});el.classList.add("hit");setTimeout(()=>el.classList.remove("hit"),2400)}
572
- // Fix picked findings, under the review: open ones ticked, closed ones listed but settled.
573
- // Unticked numbers live outside the form, which every refresh rebuilds. A finding is one
574
- // line, so md() gives one paragraph; its wrapper is dropped to sit inline in the label.
575
- const fixOff=new Set();
576
- function fixForm(c){const fs=runDoc.findings||[],open=fs.filter(f=>!f.done);
577
- if(c.archived||!open.length||!(c.status==="done"||c.awaiting==="findings"||c.awaiting==="approvals.review"))return;
787
+ // Fix picked findings, under the review: open ones ticked, closed and sent ones ticked but
788
+ // locked. Unticked and sent findings live outside the form, which every refresh rebuilds,
789
+ // keyed "run:number"; sent ones also survive a reload of this tab. A finding is one line,
790
+ // so md() gives one paragraph; its wrapper is dropped to sit inline in the label.
791
+ const SK="kz-sent",fixOff=new Set(),fixSent=new Set((()=>{try{return JSON.parse(sessionStorage.getItem(SK))||[]}catch(e){return[]}})());
792
+ function fixForm(c){const k=n=>c.id+":"+n,sent=f=>fixSent.has(k(f.n)),fs=runDoc.findings||[],open=fs.filter(f=>!f.done&&!sent(f));
793
+ if(c.archived||!fs.some(f=>!f.done)||!(c.status==="done"||c.awaiting==="findings"||c.awaiting==="approvals.review"))return;
578
794
  const el=document.createElement("div");el.className="inl fix";
579
- 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>`;
580
- 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);
581
- 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))};
582
- el.onchange=e=>{const n=+e.target.dataset.n;e.target.checked?fixOff.delete(n):fixOff.add(n);sync()};
583
- allb.onclick=()=>{const none=picks().length===open.length;fixOff.clear();if(none)open.forEach(f=>fixOff.add(f.n));sync()};
795
+ 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=>{const x=f.done?" (closed)":sent(f)?" (sent)":"";return`<label class="${x?"off":""}"><input type="checkbox" data-n="${f.n}" ${x?"checked disabled":fixOff.has(k(f.n))?"":"checked"}><span>${f.n}. ${md(f.text).replace(/^<p>|<\/p>$/g,"")}${x}</span></label>`}).join("")+(open.length?`<div class="row"><button class="btn ghost" data-a="all"></button><button class="btn pri" data-a="s"></button></div>`:"");
796
+ if(!open.length)return $("pbody").appendChild(el);
797
+ const go=el.querySelector("[data-a=s]"),allb=el.querySelector("[data-a=all]"),picks=()=>open.filter(f=>!fixOff.has(k(f.n))).map(f=>f.n);
798
+ 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(k(i.dataset.n)))};
799
+ el.onchange=e=>{const n=k(e.target.dataset.n);e.target.checked?fixOff.delete(n):fixOff.add(n);sync()};
800
+ allb.onclick=()=>{const none=picks().length===open.length;open.forEach(f=>none?fixOff.add(k(f.n)):fixOff.delete(k(f.n)));sync()};
584
801
  go.onclick=async()=>{const nums=picks();go.disabled=true;askNotify();const r=await post("/fix",{dir:c.state,id:c.id,nums});
585
- // Sent findings come unticked, so a second press cannot open a second agent on them.
586
- 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()};
802
+ // Sent findings lock as sent, so a second press cannot open a second agent on them.
803
+ if(r.ok){toast("Fixing "+nums.length+" finding"+(nums.length===1?"":"s")+" in your terminal: "+via(r.agent));nums.forEach(n=>fixSent.add(k(n)));try{sessionStorage.setItem(SK,JSON.stringify([...fixSent]))}catch(e){}openRun(c,true)}else sync()};
587
804
  sync();$("pbody").appendChild(el)}
588
805
  // Commit and push, for a finished run in a project with a git remote. What the board
589
806
  // did is recorded per run, so the settled label outlives later changes to the tree.
@@ -610,7 +827,7 @@ async function commitPush(c,message,b){b.disabled=true;b.dataset.busy=1;b.textCo
610
827
  // A refused message comes back for another try, beside the current file list.
611
828
  if(!r.ok&&message&&openKey===key(c)&&runDoc.git.files&&runDoc.git.files.length)confirmCommit(c,message)}
612
829
  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()}
613
- function close(){stopDictation(true);openKey=null;runDoc=null;$("panel").classList.remove("open");$("scrim").classList.remove("on");render()}
830
+ function close(){const k=openKey;stopDictation(true);openKey=null;runDoc=null;$("panel").classList.remove("open");$("scrim").classList.remove("on");render();if(k)cardEl(k)?.focus({preventScroll:true})}
614
831
  $("pclose").onclick=close;$("scrim").onclick=close;
615
832
 
616
833
  // Enough markdown for kaizen's own files: headings, lists, fences, tables,
@@ -630,9 +847,18 @@ function md(src,tick){
630
847
  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>`)}
631
848
  endList();return out.join("")}
632
849
 
850
+ // A building run's progress.md, one `- todo|doing|done: text` line per Work-list item.
851
+ // prev: the statuses last drawn for this run; an item whose status moved gets .chg.
852
+ function progress(src,prev){
853
+ const items=[...src.matchAll(/^\s*-\s*(todo|doing|done):\s*(.*)$/gm)].map(m=>[m[1],m[2]]);if(!items.length)return "";
854
+ const n=items.filter(t=>t[0]==="done").length,pct=Math.round(n/items.length*100),ic={done:"✓",doing:'<span class="pulse"></span>',todo:""};
855
+ return `<div class="prog"><div class="ph"><b>${n} of ${items.length} done</b><span>${pct}%</span></div><div class="pbar"><i style="width:${pct}%"></i></div><ol>${items.map(([st,t],i)=>`<li class="p-${st}${prev&&prev[i]&&prev[i]!==st?" chg":""}"><span class="pi">${ic[st]}</span><span>${esc(t).replace(/`([^`]+)`/g,"<code>$1</code>")}</span></li>`).join("")}</ol></div>`}
856
+
633
857
  // ---- settings, toast, notifications, keys
634
858
  // Rows in config order, grouped as the TUI groups them.
635
859
  const groups=rows=>{const m=new Map();for(const r of rows){if(!m.has(r.group))m.set(r.group,[]);m.get(r.group).push(r)}return [...m]};
860
+ // The search survives the re-render each save does; the gear button starts it empty.
861
+ let setQ="";
636
862
  async function openSettings(){
637
863
  stopDictation(true);
638
864
  openKey=null;runDoc=null;$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent="Settings";$("ptabs").innerHTML="";$("pfoot").innerHTML="";
@@ -642,19 +868,41 @@ async function openSettings(){
642
868
  // One control per row: a switch for on/off, one segment per value for a small number,
643
869
  // a select for a choice. The row's label names the control; its help sits under it.
644
870
  const ctl=(id,vals,on)=>{
871
+ // The preset steps like a game's quick preset: ◁ ▷ go to the neighbouring tier,
872
+ // wrapping, and never to custom, which only an edit to another row makes.
873
+ if(id==="s-preset"){const t=vals.filter(v=>v!=="custom"),at=t.indexOf(on),go=d=>t[at<0?(d>0?0:t.length-1):(at+d+t.length)%t.length];
874
+ return `<span class="car" id="${id}" role="group" aria-label="Preset"><button id="${id}-prev" data-v="${go(-1)}" aria-label="Previous preset: ${go(-1)}" title="${go(-1)}">◁</button><span aria-live="polite">${esc(on)}<i aria-hidden="true">${t.map(v=>`<u class="${v===on?"on":""}"></u>`).join("")}</i></span><button id="${id}-next" data-v="${go(1)}" aria-label="Next preset: ${go(1)}" title="${go(1)}">▷</button></span>`}
645
875
  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>`}
646
876
  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>`;
647
877
  return `<select class="sel" id="${id}">${vals.map(v=>`<option value="${esc(v)}"${v===on?" selected":""}>${v===""?"—":esc(v)}</option>`).join("")}</select>`};
648
- const row=(label,control,hint,id)=>`<div class="srow"><label for="${id}">${esc(label)}</label>${control}<p class="hint">${hint}</p></div>`;
878
+ const row=(label,control,hint,id,q)=>`<div class="srow" data-q="${esc(q.toLowerCase())}"><label for="${id}">${esc(label)}</label>${control}<p class="hint">${hint}</p></div>`;
879
+ const hadFocus=document.activeElement?.id==="sq";
649
880
  const np=typeof Notification==="undefined"?null:Notification.permission;
650
- $("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")}
651
- ${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")}
652
- ${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>`}`;
881
+ const ntHelp="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.");
882
+ const names=[...(cfg?groups(cfg.rows).map(([g])=>g):[]),"Browser"];
883
+ $("pbody").innerHTML=`<div class="sq"><input class="sel" id="sq" type="search" placeholder="Search settings" aria-label="Search settings" spellcheck="false" autocomplete="off" value="${esc(setQ)}"><div class="sjump" role="group" aria-label="Jump to section">${names.map(g=>`<button data-g="${esc(g)}">${esc(g)}</button>`).join("")}</div></div>
884
+ ${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?(r.folder?`<span class="fpick"><input class="sel" id="${id}" value="${esc(r.value)}" placeholder="not set" spellcheck="false"><button class="btn" id="${id}-pick" type="button">Browse…</button></span>`:`<input class="sel" id="${id}" value="${esc(r.value)}" placeholder="not set" spellcheck="false">`):ctl(id,r.values,r.value),esc(r.help),id,[r.label,r.help,r.key,g,...r.values].join(" "))}).join("")}`).join(""):`<p class="snone">Settings could not be read. Run <code>kaizen settings</code>.</p>`}
885
+ <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","theme light dark system browser")}
886
+ ${row("Notifications",`<button class="chk${np==="granted"?" on":""}" id="nt" role="switch" aria-checked="${np==="granted"}"${np&&np!=="denied"?"":" disabled"}></button>`,ntHelp,"nt","notifications "+ntHelp+" browser")}
887
+ <p class="snone" id="snone" hidden></p>`;
888
+ // Hides rows that do not match, then any group (and its chip) left with none.
889
+ const heads=[...$("pbody").querySelectorAll(".sg")],chips=[...$("pbody").querySelectorAll(".sjump button")];
890
+ const filter=()=>{const q=setQ.trim().toLowerCase();let any=false;
891
+ heads.forEach((h,i)=>{let n=h.nextElementSibling,vis=false;for(;n&&n.matches(".srow");n=n.nextElementSibling){n.hidden=!!q&&!n.dataset.q.includes(q);vis||=!n.hidden}h.hidden=!vis;if(chips[i])chips[i].hidden=!vis;any||=vis});
892
+ $("snone").hidden=any;$("snone").textContent=`No setting matches “${setQ.trim()}”.`};
893
+ // Scrolled by measure: the pinned bar's height changes as the chips wrap.
894
+ chips.forEach((b,i)=>b.onclick=()=>{const pb=$("pbody");pb.scrollTo({top:pb.scrollTop+heads[i].getBoundingClientRect().top-pb.querySelector(".sq").getBoundingClientRect().bottom-8,behavior:"smooth"})});
895
+ $("sq").oninput=e=>{setQ=e.target.value;filter()};
896
+ // Esc clears a search; with nothing to clear it closes the panel, as it does outside the field.
897
+ $("sq").onkeydown=e=>{if(e.key!=="Escape")return;e.preventDefault();if(setQ){setQ="";e.target.value="";filter()}else close()};
898
+ filter();if(hadFocus){const f=$("sq");f.focus();f.setSelectionRange(f.value.length,f.value.length)}
653
899
  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){}};
654
900
  $("th").onchange=e=>setTheme(e.target.value);
655
901
  $("nt").onclick=()=>{askNotify();setTimeout(openSettings,300)};
656
- 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()};
657
- 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)})}
902
+ if(cfg)cfg.rows.forEach(r=>{const el=$("s-"+r.key.replace(/\./g,"-"));const save=async v=>{const f=document.activeElement?.id;const j=await post("/settings",{key:r.key,value:v});if(j.ok)toast("Saved");await openSettings();if(f?.startsWith("s-preset-"))$(f)?.focus()};
903
+ 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);
904
+ // The system's own folder dialog, opened by the board: a page cannot read a real path.
905
+ if(r.folder)$(el.id+"-pick").onclick=async()=>{const j=await post("/pick-folder",{});if(j.path){await save(j.path);load()}}})}
658
906
  // ---- request sources: links kaizen reads for new ideas. Same panel as settings.
659
907
  const SRC="Request sources";
660
908
  const srcOpen=()=>$("panel").classList.contains("open")&&$("ptitle").textContent===SRC;
@@ -671,27 +919,50 @@ async function openSources(){
671
919
  const d=await fetch("/sources"+q).then(r=>r.json()).catch(()=>null);
672
920
  if(!srcOpen())return;
673
921
  const host=u=>{try{return new URL(u).hostname}catch{return u}};
922
+ const srcType=u=>{if(u.includes("docs.google.com/spreadsheets")||u.endsWith(".csv")||u.endsWith(".tsv"))return "sheet";if(u.includes("docs.google.com/document"))return "doc";return "link"};
674
923
  const list=d&&d.sources||[];
675
924
  // A source belongs to one project, named on its row. Picking another only arms Move: a select
676
- // changes on every arrow key, and each change would have moved the source. The names are the picker's.
677
- const opts=on=>S.projects.map(p=>`<option value="${esc(p.dir)}"${p.dir===on?" selected":""}>${esc(p.label)}</option>`).join("");
678
- 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("");
679
- $("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>`)
680
- +`<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>`;
681
- $("src-url").value=typed[0];$("src-label").value=typed[1];if(typed[3]!==undefined)$("src-dir").value=typed[3];
925
+ // changes on every arrow key, and each change would have moved the source. The names are the picker's;
926
+ // a row whose project the picker does not list still gets its own, selected, so Move never points elsewhere.
927
+ const opts=(on,name)=>(on&&!S.projects.some(p=>p.dir===on)?`<option value="${esc(on)}" selected>${esc(name||on)}</option>`:"")+S.projects.map(p=>`<option value="${esc(p.dir)}"${p.dir===on?" selected":""}>${esc(p.label)}</option>`).join("");
928
+ const topBar=`<div class="src-topbar"><div class="meta"><span class="count">${list.length} source${list.length===1?"":"s"}${dir?` in ${esc(S.projects.find(p=>p.dir===dir)?.label||dir)}`:""}</span><span class="sched">${d&&d.every&&d.every!=="off"?`Auto-check: every ${esc(d.every)}`:"Auto-check: off"} · <a id="src-to-settings" role="button" tabindex="0">Settings</a></span></div><button class="btn sm" id="src-go"${list.length?"":" disabled"}>${ico("refresh")} Gather all</button></div>`;
929
+ const cards=list.map((s,i)=>{
930
+ const st=srcType(s.url),title=s.label||host(s.url);
931
+ const statusHtml=!s.last?`<span class="src-badge dim">Not gathered yet</span>`:s.last.ok?`<span class="src-badge ok"><svg viewBox="0 0 16 16"><path d="M3.5 8.5l3 3 6-6" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>${esc(s.last.note)}</span><span class="hint" style="margin:0">${ago(s.last.at)}</span>`:`<span class="src-badge warn"><svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" stroke="currentColor" fill="none" stroke-width="1.8"/><path d="M8 5v3.5M8 11v.1" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round"/></svg>Failed: ${esc(s.last.note)}</span><span class="hint" style="margin:0">${ago(s.last.at)}</span>`;
932
+ return `<div class="src-card" id="src-card-${i}"><div class="src-hd"><div class="src-info"><span class="src-ico" title="${st}">${ico(st==="sheet"?"sheet":st==="doc"?"doc":"link")}</span><span class="src-title" title="${esc(title)}">${esc(title)}</span></div><div class="src-acts"><button class="btn ghost sm" id="src-chk-${i}" data-chk="${i}" aria-label="Check ${esc(title)} now" title="Check this source now">${ico("refresh")} Check</button><button class="btn ghost danger sm" id="src-rm-${i}" data-rm="${i}" aria-label="Remove ${esc(title)}" title="Remove source">${ico("trash")}</button></div></div><div class="src-url"><a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer" title="${esc(s.url)}"><span>${esc(s.url)}</span>${ico("ext")}</a></div><div class="src-status">${statusHtml}</div><div class="src-dest"><span>Ideas go to</span><select class="sel" id="src-mv-${i}" data-mv="${i}" data-k="${esc(s.dir+" "+s.url)}" aria-label="Project of ${esc(title)}">${opts(s.dir,s.project)}</select><button class="btn sm" id="src-mb-${i}" aria-label="Move ${esc(title)} to the chosen project" disabled>Move</button></div></div>`;
933
+ }).join("")+(d&&d.failed||[]).map(f=>`<p style="color:var(--dim)">The sources of ${esc(f)} could not be read.</p>`).join("");
934
+ const emptyState=`<div class="src-empty"><div class="src-empty-ico">${ico("link")}</div><h4>No request sources yet</h4><p>Connect shared links to gather requests into your Ideas backlog. Nothing runs automatically until you start it.</p><div class="src-empty-grid"><div class="src-empty-item"><strong>📊 Google Sheets</strong>Public sheets with idea or task rows. Rows marked Done are skipped.</div><div class="src-empty-item"><strong>📄 Google Docs</strong>Shared public docs with items or notes written line by line.</div><div class="src-empty-item"><strong>🌐 Public links</strong>TSV, CSV, or raw text links containing requests.</div></div></div>`;
935
+ const addBox=`<div class="src-add-box"><h3>${ico("plus")} Add a source</h3><div style="display:grid;gap:8px"><div><label for="src-url" class="hint" style="display:block;margin-bottom:4px;font-weight:500;color:var(--ink)">Link</label><input class="sel" id="src-url" aria-label="Link" placeholder="https://docs.google.com/spreadsheets/d/… or public URL" spellcheck="false" autocomplete="off" style="width:100%;max-width:none"></div><div style="display:grid;grid-template-columns:1fr 1fr;gap:8px"><div><label for="src-label" class="hint" style="display:block;margin-bottom:4px">Label (optional)</label><input class="sel" id="src-label" aria-label="Label (optional)" placeholder="e.g. Feedback Q4" style="width:100%;max-width:none"></div><div><label for="src-dir" class="hint" style="display:block;margin-bottom:4px">Target project</label><select class="sel" id="src-dir" aria-label="Project this source belongs to" style="width:100%;max-width:none">${dir?"":`<option value="">Choose project…</option>`}${opts(dir)}</select></div></div><div style="display:flex;align-items:center;justify-content:space-between;margin-top:6px"><button class="btn pri" id="src-add">Add source</button><span class="hint" style="font-size:11px;color:var(--dim);margin:0">Public links only. For private docs, run /kaizen gather.</span></div></div></div>`;
936
+ $("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>`:topBar+(list.length?`<div class="src-list">${cards}</div>`:emptyState))+addBox;
937
+ $("src-url").value=typed[0];$("src-label").value=typed[1];if(typed[3]!==undefined&&$("src-dir"))$("src-dir").value=typed[3];
938
+ if($("src-to-settings"))$("src-to-settings").onclick=()=>{setQ="sources";openSettings()};
939
+ const act=async(body,done)=>{const j=await post("/sources",body);if(j.ok){done(j);load()}if(srcOpen())openSources()};
682
940
  $("pbody").querySelectorAll("[data-mv]").forEach(m=>{const s=list[+m.dataset.mv],b=$("src-mb-"+m.dataset.mv),to=picked.get(m.dataset.k);
683
941
  if(to&&[...m.options].some(o=>o.value===to))m.value=to;
684
942
  (m.onchange=()=>{b.disabled=m.value===s.dir})();
685
- b.onclick=()=>act({op:"move",dir:s.dir,to:m.value,url:s.url},()=>toast("Source moved"))});
686
- // A Move button that went back to disabled cannot take focus; its select can.
687
- const was=typed[2]&&$(typed[2]);if(was)(was.disabled?$(was.id.replace("src-mb-","src-mv-")):was).focus();
688
- // Every action names the project it acts on; only "gather everything" goes without one.
689
- const act=async(body,done)=>{const j=await post("/sources",body);if(j.ok){done(j);load()}if(srcOpen())openSources()};
690
- $("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")})};
943
+ b.onclick=()=>act({op:"move",dir:s.dir,to:m.value,url:s.url},()=>toast(`Source moved. Ideas already gathered stay in ${s.project||"the old project"}.`))});
944
+ $("pbody").querySelectorAll("[data-chk]").forEach(b=>{const s=list[+b.dataset.chk];
945
+ b.onclick=()=>{b.disabled=true;b.innerHTML=ico("refresh")+" Checking…";
946
+ act({op:"gather",dir:s.dir,url:s.url},j=>{const r=j.results&&j.results[0];
947
+ toast(r?(r.ok?`${s.label||host(s.url)}: ${r.note}`:`Failed: ${r.note}`):"Checked")})}});
948
+ $("pbody").querySelectorAll("[data-rm]").forEach(b=>{const s=list[+b.dataset.rm];
949
+ b.onclick=()=>{if(!confirm(`Remove ${s.label||host(s.url)}? Ideas it gathered stay; added again later, it may bring back ones you deleted.`))return;
950
+ act({op:"remove",dir:list[+b.dataset.rm].dir,url:list[+b.dataset.rm].url},()=>toast("Source removed"))}});
951
+ const was=typed[2]&&$(typed[2]);if(was)(was.disabled?$(was.id.replace("src-mb-","src-mv-")):was)?.focus();
952
+ $("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}
953
+ $("src-add").disabled=true;$("src-add").textContent="Adding…";
954
+ (async()=>{const j=await post("/sources",{op:"add",dir:to,url,label:$("src-label").value.trim()});
955
+ if(j.ok){$("src-url").value=$("src-label").value="";const g=await post("/sources",{op:"gather",dir:to,url}),r=g.results&&g.results[0];
956
+ toast(r?(r.ok?`Source added: ${r.note}`:`Source added, but: ${r.note}`):"Source added");load()}
957
+ else toast(j.error||"Could not add source");
958
+ if(srcOpen())openSources()})()};
691
959
  $("src-url").onkeydown=$("src-label").onkeydown=e=>{if(e.key==="Enter")$("src-add").click()};
692
- $("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`:""))})};
693
- $("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")))}
694
- let tt;function toast(s){const t=$("toast");t.textContent=s;t.classList.add("on");clearTimeout(tt);tt=setTimeout(()=>t.classList.remove("on"),4000)}
960
+ if($("src-go"))$("src-go").onclick=()=>{$("src-go").disabled=true;$("src-go").innerHTML=ico("refresh")+" Gathering…";
961
+ 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;
962
+ toast((n?`${n} new idea${n===1?"":"s"}`:"Nothing new")+(bad?`, ${bad} source${bad===1?"":"s"} could not be read`:""))})}}
963
+ // With `report`, the toast carries a link to a prefilled GitHub bug form (/issue) and
964
+ // stays long enough to click it.
965
+ let tt;function toast(s,report){const t=$("toast");t.textContent=s;t.classList.toggle("rep",!!report);if(report){const a=document.createElement("a");a.href="/issue?"+new URLSearchParams(report);a.target="_blank";a.rel="noopener";a.textContent="Report";t.append(" ",a)}t.classList.add("on");clearTimeout(tt);tt=setTimeout(()=>t.classList.remove("on"),report?8000:4000)}
695
966
  function askNotify(){try{if(typeof Notification!=="undefined"&&Notification.permission==="default")Notification.requestPermission()}catch(e){}}
696
967
  function notifyUser(body){try{if(typeof Notification!=="undefined"&&Notification.permission==="granted")new Notification("kaizen",{body,icon:"/logo.png"})}catch(e){}}
697
968
  // The project picker: a button shaped like a select that opens a search field over the list.
@@ -714,17 +985,24 @@ async function pick(v){
714
985
  else{const j=await post("/cd",{dir:v});if(j.ok){all=false;keep(v)}}
715
986
  load()}
716
987
  $("newtop").onclick=()=>{mode="new";render();$("board").scrollTo(0,0)};
717
- $("settings").onclick=openSettings;$("sources").onclick=openSources;
988
+ $("settings").onclick=()=>{setQ="";openSettings()};$("sources").onclick=openSources;
718
989
  try{const t=localStorage.getItem("kz-theme");if(t)document.documentElement.dataset.theme=t}catch(e){}
719
990
  $("kbd").onclick=()=>$("help").classList.add("on");$("help").onclick=()=>$("help").classList.remove("on");
720
991
  document.addEventListener("click",()=>{if(menuKey||clearMenu){menuKey=null;clearMenu=false;render()}});
721
992
  document.onkeydown=e=>{
722
993
  if(e.target.matches("input,select,textarea,[contenteditable]"))return;
723
994
  if(e.key==="?"){$("help").classList.toggle("on");return}
995
+ if(e.key==="/"&&$("sq")&&$("panel").classList.contains("open")){e.preventDefault();$("sq").focus();return}
724
996
  if(e.key==="Escape"){$("help").classList.remove("on");if(mode){mode=null;render()}else close();return}
725
997
  if(e.key==="n"){mode="new";render()}
726
998
  if(e.key==="a"){all=!all;keep(all?null:S.dir);load()}
727
- const oc=openKey&&S.cards.find(c=>key(c)===openKey);if(!oc)return;
999
+ const mv={ArrowUp:[0,-1],k:[0,-1],ArrowDown:[0,1],j:[0,1],ArrowLeft:[-1,0],h:[-1,0],ArrowRight:[1,0],l:[1,0]}[e.key];
1000
+ // In a run's panel: left/right between its tabs, up/down scroll it. Other panels keep the browser's arrows.
1001
+ const rc=mv&&openKey&&!e.altKey&&!e.ctrlKey&&!e.metaKey&&S.cards.find(c=>key(c)===openKey&&c.kind==="run");
1002
+ if(rc&&$("ptabs").children.length){e.preventDefault();if(mv[0]){const t=Math.max(0,Math.min($("ptabs").children.length-1,tab+mv[0]));if(t!==tab){tab=t;openRun(rc,true)}}else $("pbody").scrollBy({top:mv[1]*40});return}
1003
+ if(mv&&!$("panel").classList.contains("open")&&!e.altKey&&!e.ctrlKey&&!e.metaKey&&(e.target===document.body||e.target.matches("#board .card"))){e.preventDefault();nav(...mv);return}
1004
+ // The open card, else the focused one.
1005
+ const ok=openKey||focusedKey(),oc=ok&&S.cards.find(c=>key(c)===ok);if(!oc)return;
728
1006
  if(e.key==="e"&&oc.kind==="idea"){close();mode="edit";modeKey=key(oc);render()}
729
1007
  if(e.key==="x"&&!["done","abandoned","starting"].includes(oc.status)){close();mode="reject";modeKey=key(oc);render()}
730
1008
  if(e.key==="r"&&oc.kind==="idea"){close();mode="run";modeKey=key(oc);render()}};
@@ -733,7 +1011,7 @@ document.onkeydown=e=>{
733
1011
  let boot=null;
734
1012
  // Back from a terminal: what was committed or pushed there touched nothing the server watches.
735
1013
  addEventListener("focus",()=>load(true));
736
- 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)}}
1014
+ function listen(){const es=new EventSource("/events");es.onmessage=ev=>{if(ev.data==="changed"){load(true);if(srcOpen()&&!$("src-go")?.disabled&&!$("src-add")?.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)}}
737
1015
  // Back to the project picked last time, unless the URL says which view it wants. A
738
1016
  // plain fetch: a project that is gone should fall back to all, not raise a toast.
739
1017
  (async()=>{let d;try{d=localStorage.getItem("kz-proj")}catch(e){}