kaizen-agent 1.24.0 → 1.25.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
@@ -10,17 +10,17 @@
10
10
  :root[data-theme=dark]{--bg:#0c162f;--bg-2:#121e3c;--line:#22305a;--ink:#eef2fa;--ink-hi:#deeeff;--dim:#96aacd;--accent:#486ed9;--run:#4fd6e6;--ok:#5fd28a;--warn:#e6b450;--danger:#e85050;color-scheme:dark}
11
11
  *{box-sizing:border-box}
12
12
  /* Shape, type and depth. --shade is the one shadow colour, themed like the rest. */
13
- :root{--r:10px;--r-s:6px;--r-pill:999px;--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;--mono:ui-monospace,"JetBrains Mono","Cascadia Code","SF Mono",Menlo,Consolas,monospace;--on-accent:#fff;--hover:color-mix(in srgb,var(--dim) 12%,transparent);--shade:rgb(12 22 47/.1);--shadow-1:0 1px 2px var(--shade);--shadow-2:0 2px 6px var(--shade),0 16px 40px -12px var(--shade)}
14
- @media(prefers-color-scheme:dark){:root:not([data-theme=light]){--shade:rgb(0 0 0/.45)}}
15
- :root[data-theme=dark]{--shade:rgb(0 0 0/.45)}
16
- body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 var(--font);-webkit-font-smoothing:antialiased;height:100vh;height:100dvh;overflow:hidden;display:flex}
13
+ :root{--r:12px;--r-s:8px;--r-pill:999px;--font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;--mono:ui-monospace,"JetBrains Mono","Cascadia Code","SF Mono",Menlo,Consolas,monospace;--on-accent:#fff;--hover:color-mix(in srgb,var(--dim) 12%,transparent);--shade:rgb(12 22 47/.08);--shadow-1:0 1px 2px var(--shade);--shadow-2:0 0 0 1px var(--shade),0 8px 24px -6px var(--shade);--canvas:color-mix(in srgb,var(--bg) 95.5%,var(--ink))}
14
+ @media(prefers-color-scheme:dark){:root:not([data-theme=light]){--shade:rgb(0 0 0/.45);--canvas:color-mix(in srgb,var(--bg) 70%,black)}}
15
+ :root[data-theme=dark]{--shade:rgb(0 0 0/.45);--canvas:color-mix(in srgb,var(--bg) 70%,black)}
16
+ body{margin:0;background:var(--canvas);color:var(--ink);font:13px/1.5 var(--font);-webkit-font-smoothing:antialiased;height:100vh;height:100dvh;overflow:hidden;display:flex}
17
17
  button,input,select{font:inherit;color:inherit;background:none;border:0;padding:0}
18
18
  button{cursor:pointer}
19
19
  :focus-visible{outline:2px solid var(--accent);outline-offset:2px}
20
20
  svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;display:block}
21
21
 
22
22
  /* side menu: the same lines in board.html and notes.html (a test holds them equal) */
23
- .rail{width:220px;flex:none;background:var(--bg-2);border-right:1px solid var(--line);display:flex;flex-direction:column;gap:2px;padding:18px 12px 14px;overflow:hidden auto;transition:width .18s}
23
+ .rail{width:220px;flex:none;display:flex;flex-direction:column;gap:2px;padding:18px 12px 14px;overflow:hidden auto;transition:width .18s}
24
24
  .rail .top{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 0 18px 10px}
25
25
  .rail .home{display:block;color:inherit;text-decoration:none}
26
26
  .rail .top .mascot{display:none;width:36px;height:34px}
@@ -44,12 +44,14 @@ svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.6;stroke
44
44
  [data-rail=min] .theme button{width:34px;flex:none}
45
45
  @media(max-width:640px){#fold{display:none}}
46
46
  /* end side menu */
47
- .app{flex:1;min-width:0;display:flex;flex-direction:column}
47
+ /* the work area: an inset rounded panel on the canvas the side menu sits on */
48
+ .app{flex:1;min-width:0;display:flex;flex-direction:column;margin:8px 8px 8px 0;background:var(--bg);border:1px solid var(--line);border-radius:var(--r);overflow:hidden}
49
+ @media(max-width:640px){.app{margin:0;border:0;border-radius:0}}
48
50
  /* header */
49
- .hdr{display:flex;align-items:center;gap:16px;padding:14px 28px;border-bottom:1px solid var(--line);background:var(--bg-2);flex-wrap:wrap}
51
+ .hdr{display:flex;align-items:center;gap:12px;padding:10px 20px 10px 28px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);flex-wrap:wrap}
50
52
  pre.mark{margin:0;font:700 8px/1 var(--mono)}
51
53
  pre.mark span:nth-child(1){color:var(--ink-hi)}pre.mark span:nth-child(2){color:color-mix(in srgb,var(--ink-hi),var(--accent))}pre.mark span:nth-child(3){color:var(--accent)}
52
- .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}
54
+ .sel{border:1px solid var(--line);border-radius:var(--r-s);padding:4px 10px;background-color:var(--bg-2);color:var(--ink);font-size:13px}
53
55
  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}
54
56
  .sel:hover{border-color:var(--dim)}
55
57
  .agents{display:flex;flex-wrap:wrap;gap:6px}
@@ -57,7 +59,8 @@ select.sel,button.sel{cursor:pointer;appearance:none;padding-right:30px;max-widt
57
59
  .agent svg{width:14px;height:14px}.agent:hover{border-color:var(--dim)}
58
60
  .yolo{display:inline-flex;align-items:center;gap:8px;margin-right:auto;font-size:13px;color:var(--ink);cursor:pointer;white-space:nowrap}
59
61
  .agent[aria-checked=true]{border-color:var(--accent);color:var(--ink);background:color-mix(in srgb,var(--accent) 14%,var(--bg-2))}
60
- .hdr h1{margin:0;font-size:17px;font-weight:600}
62
+ .hdr h1{margin:0;font-size:14px;font-weight:600}
63
+ .hdr>.combo>.sel:not(:hover):not(:focus){border-color:transparent;background-color:transparent}
61
64
  .hdr .sel{max-width:min(320px,100%);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:left}
62
65
  /* project picker: a select-shaped button over a searchable list */
63
66
  .combo{position:relative;max-width:100%;display:flex}
@@ -81,39 +84,39 @@ select.sel,button.sel{cursor:pointer;appearance:none;padding-right:30px;max-widt
81
84
  /* the mic button turns into a level meter while listening: five bars, each weighted so the set reads as a wave */
82
85
  .vu{display:inline-flex;gap:2px;align-items:center;height:100%}.vu i{width:2px;border-radius:1px;background:currentColor;height:calc(3px + 12px*min(1,var(--lvl,0)*var(--w)));transition:height .08s}
83
86
  .ib.mic.on{color:var(--accent)}.ib.mic.busy{color:var(--accent)}.ib.mic i{font-style:normal;font-size:10px;font-variant-numeric:tabular-nums}.row .mic{margin-right:auto;align-self:center}
84
- .btn{border:1px solid var(--line);border-radius:var(--r-s);padding:6px 14px;color:var(--ink);background:var(--bg-2);min-height:32px;font-size:13px;font-weight:500;box-shadow:var(--shadow-1);transition:background .12s,border-color .12s,color .12s}
87
+ .btn{border:1px solid var(--line);border-radius:var(--r-s);padding:4px 12px;color:var(--ink);background:var(--bg-2);min-height:30px;font-size:13px;font-weight:500;box-shadow:var(--shadow-1);transition:background .12s,border-color .12s,color .12s}
85
88
  .btn:hover{border-color:var(--dim)}
86
- .btn.pri{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}.btn.pri:hover{box-shadow:var(--shadow-2)}
89
+ .btn.pri{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}.btn.pri:hover{background:color-mix(in srgb,var(--accent) 88%,var(--ink))}
87
90
  .btn.warn{border-color:var(--warn);color:var(--warn)}.btn.warn:hover{background:var(--warn);color:var(--bg)}
88
91
  .btn.danger{color:var(--danger)}.btn.danger:hover{border-color:var(--danger)}
89
92
  .btn.ghost{background:none;border-color:transparent;color:var(--dim);box-shadow:none}.btn.ghost:hover{color:var(--ink);background:var(--hover)}
90
93
 
91
94
  /* summary strip: each count is a pill that filters the board */
92
95
  .strip{display:inline-flex;gap:8px;flex-wrap:wrap;align-items:center;font-size:13px;color:var(--dim)}
93
- .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}
96
+ .strip button{display:inline-flex;align-items:center;gap:6px;color:var(--dim);padding:2px 10px;border-radius:var(--r-pill);box-shadow:inset 0 0 0 1px var(--line);transition:background .12s,color .12s}
94
97
  .strip button:hover,.strip button.on{color:var(--ink);background:var(--hover)}
95
98
  .strip button.attn{color:var(--warn);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--warn) 45%,transparent)}
96
99
  .strip svg{width:14px;height:14px;stroke-width:1.8}
97
100
 
98
101
  /* board */
99
- 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}
102
+ main{flex:1;min-height:0;display:grid;grid-template-columns:repeat(5,minmax(200px,1fr));gap:12px;padding:12px 28px 20px;overflow:auto;align-content:start}
100
103
  section h2::before{content:"";display:block;height:16px;margin-top:-16px}
101
- section{padding-top:16px}
102
- 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}
103
- section h2 i{font-style:normal;font-weight:500;font-size:12px;color:var(--ink);background:var(--hover);border-radius:var(--r-pill);padding:0 7px;min-width:20px;text-align:center}
104
+ section{padding-top:8px}
105
+ section h2{margin:0;font-size:13px;color:var(--ink);font-weight:500;display:flex;align-items:center;gap:8px;height:32px;padding-inline:2px;position:sticky;top:0;background:var(--bg);z-index:1}
106
+ section h2 i{font-style:normal;font-weight:400;font-size:12px;color:var(--dim)}
104
107
  section h2 i:empty{display:none}
105
108
  section h2 .ib{margin-left:auto}
106
- ul{list-style:none;margin:0;padding:8px 0 0;display:flex;flex-direction:column;gap:10px}
109
+ ul{list-style:none;margin:0;padding:4px 0 0;display:flex;flex-direction:column;gap:6px}
107
110
 
108
111
  /* card, Linear grammar in our palette
109
112
  line 1 status icon · id (dim) · ⋯ menu on hover
110
113
  line 2 title, medium weight, wraps to 2 lines
111
114
  line 3 chips, only when set
112
115
  Card ground is --bg-2 on a --bg column, lifted by --shadow-1. Actions never sit on the card. */
113
- .card{background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r);padding:12px 14px;cursor:pointer;display:flex;flex-direction:column;gap:6px;position:relative;box-shadow:var(--shadow-1);transition:box-shadow .12s,border-color .12s}
114
- .card:hover{box-shadow:var(--shadow-2);border-color:color-mix(in srgb,var(--dim) 50%,var(--line))}
116
+ .card{background:var(--bg-2);border:1px solid var(--line);border-radius:var(--r-s);padding:12px 14px;cursor:pointer;display:flex;flex-direction:column;gap:6px;position:relative;box-shadow:var(--shadow-1);transition:border-color .12s,box-shadow .12s}
117
+ .card:hover{border-color:color-mix(in srgb,var(--dim) 45%,var(--line));box-shadow:var(--shadow-2)}
115
118
  .card.menu{z-index:2}
116
- .card.open{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}
119
+ .card.open{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 22%,transparent)}
117
120
  .card.archived .c-title,.card.archived .c-id{opacity:.7}
118
121
  section h2 .ord{min-height:22px;padding:0 8px;font-weight:500}
119
122
  section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
@@ -125,7 +128,7 @@ section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
125
128
  .card .pick{position:absolute;right:14px;top:14px;margin:0;accent-color:var(--accent)}
126
129
  .card.picked{border-color:var(--accent)}
127
130
  .card:has(.pick) .age,.card:has(.pick) .more{display:none}
128
- .c-id{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dim);min-height:20px}
131
+ .c-id{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dim);min-height:18px}
129
132
  .c-id .st{width:14px;height:14px;display:grid;place-items:center;flex-shrink:0}
130
133
  .c-id .st svg{width:14px;height:14px;stroke-width:1.8}
131
134
  .c-id .idt{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}
@@ -133,16 +136,16 @@ section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
133
136
  .c-id .more{display:none;margin:-4px -6px -4px 0}
134
137
  .card:hover .more,.card:focus-within .more,.card.menu .more{display:grid}
135
138
  .card:hover .age,.card.menu .age{display:none}
136
- .c-title{font-size:14px;font-weight:500;line-height:1.45;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;color:var(--ink)}
137
- .c-chips{display:flex;gap:6px;flex-wrap:wrap;margin-top:2px}
139
+ .c-title{font-size:13px;font-weight:500;line-height:1.45;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;color:var(--ink)}
140
+ .c-chips{display:flex;gap:4px;flex-wrap:wrap;margin-top:2px}
138
141
  .c-chips:empty{display:none}
139
- .chip{display:inline-flex;align-items:center;gap:5px;min-height:22px;padding:2px 9px;border-radius:var(--r);overflow-wrap:anywhere;font-size:11px;font-weight:500;color:var(--dim);background:var(--hover)}
142
+ .chip{display:inline-flex;align-items:center;gap:5px;min-height:20px;padding:1px 8px;border-radius:11px;overflow-wrap:anywhere;font-size:11px;font-weight:500;color:var(--dim);box-shadow:inset 0 0 0 1px var(--line)}
140
143
  .chip i{width:6px;height:6px;border-radius:50%;background:var(--dim)}
141
144
  .chip svg{width:11px;height:11px;stroke-width:1.7}
142
- .chip.warn{color:var(--warn);background:color-mix(in srgb,var(--warn) 14%,transparent)}.chip.warn i{background:var(--warn)}
143
- .chip.accent{color:var(--accent);background:color-mix(in srgb,var(--accent) 14%,transparent)}.chip.accent i{background:var(--accent)}
144
- .chip.ok{color:color-mix(in srgb,var(--ok) 80%,var(--ink));background:color-mix(in srgb,var(--ok) 14%,transparent)}
145
- .chip.run{color:var(--run);background:color-mix(in srgb,var(--run) 14%,transparent)}.chip.run i{background:var(--run)}
145
+ .chip.warn{color:var(--ink)}.chip.warn i{background:var(--warn)}.chip.warn svg{color:var(--warn)}
146
+ .chip.accent i{background:var(--accent)}.chip.accent svg{color:var(--accent)}
147
+ .chip.ok svg{color:var(--ok)}
148
+ .chip.run{color:var(--ink)}.chip.run i{background:var(--run)}
146
149
  .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)}
147
150
  .d-run{color:var(--run)}.d-wait{color:var(--warn)}.d-done{color:var(--ok)}
148
151
  .pulse{display:inline-block;width:7px;height:7px;border-radius:50%;background:currentColor;vertical-align:middle}
@@ -155,11 +158,11 @@ section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
155
158
  .menu-pop button.danger{color:var(--danger)}
156
159
  .menu-pop svg{width:15px;height:15px;flex-shrink:0}
157
160
  .menu-pop hr{border:0;border-top:1px solid var(--line);margin:4px 0}
158
- .more-btn{width:100%;padding:8px;color:var(--dim);font-size:12px;border:1px dashed var(--line);border-radius:var(--r)}
159
- .more-btn:hover{color:var(--ink);border-color:var(--dim)}
161
+ .more-btn{width:100%;padding:6px;color:var(--dim);font-size:12px;border-radius:var(--r-pill)}.more-btn:hover{background:var(--hover)}
162
+ .more-btn:hover{color:var(--ink)}
160
163
 
161
164
  /* inline forms replace a card while editing */
162
- .inl{display:flex;flex-direction:column;gap:10px;padding:14px;background:var(--bg-2);border:1px solid var(--accent);border-radius:var(--r);box-shadow:var(--shadow-2)}
165
+ .inl{display:flex;flex-direction:column;gap:8px;padding:12px;background:var(--bg-2);border:1px solid var(--accent);border-radius:var(--r-s);box-shadow:var(--shadow-2)}
163
166
  .inl input,.inl textarea,.notes textarea{width:100%;border:1px solid var(--line);border-radius:var(--r-s);padding:8px 12px;background:var(--bg-2);font:inherit;color:var(--ink)}
164
167
  .inl input:focus,.inl textarea:focus,.notes textarea:focus,.sel:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 22%,transparent)}
165
168
  .inl textarea,.notes textarea{min-height:72px;resize:vertical}
@@ -168,6 +171,8 @@ section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
168
171
  .inl .row,.notes .row{display:flex;gap:6px;justify-content:flex-end}
169
172
  .notes{display:flex;flex-direction:column;gap:8px}
170
173
  #pbody img{max-width:100%;border-radius:var(--r-s)}
174
+ /* the in-card agent: a wider panel, and the terminal filling its body */
175
+ aside.wide{width:min(960px,100%)}#pbody .term{height:100%;min-height:320px;background:var(--bg);border-radius:var(--r-s)}#pbody .term .xterm{height:100%;padding:4px}#pbody .term .xterm-viewport{background:transparent!important}
171
176
  .desc{border-bottom:1px solid var(--line);padding-bottom:6px;margin-bottom:14px}.desc h3{margin-top:14px}
172
177
  .chat{display:flex;flex-direction:column;gap:10px;margin:0 0 14px}.msg{border:1px solid var(--line);border-radius:var(--r);padding:10px 12px;background:var(--bg)}.msg time{display:block;font-size:11px;color:var(--dim);margin-bottom:4px}.msg p:last-child{margin-bottom:0}.msg img{max-width:100%;max-height:260px;border-radius:var(--r-s);display:block;margin:4px 0}
173
178
  .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)}
@@ -181,23 +186,23 @@ section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
181
186
  @keyframes fi{from{opacity:0}}
182
187
 
183
188
  /* footer */
184
- .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}
189
+ .ftr{display:flex;justify-content:space-between;gap:16px;padding:6px 28px;border-top:1px solid var(--line);color:var(--dim);font-size:12px;flex-wrap:wrap;align-items:center}
185
190
  .ftr span+span,.ftr a+a{margin-left:14px}.ftr .strip{margin-right:14px;vertical-align:middle;font-size:12px}
186
191
  /* The footer is out of the eye's path, so the waiting alert is filled, not just outlined. */
187
192
  .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}
188
- .ftr a{color:var(--dim);cursor:pointer;white-space:nowrap;text-decoration:underline;text-underline-offset:2px}.ftr a:hover{color:var(--ink)}
193
+ .ftr a{color:var(--dim);cursor:pointer;white-space:nowrap;text-decoration:none}.ftr a:hover{color:var(--ink)}
189
194
 
190
195
  /* side panel */
191
196
  .scrim{position:fixed;inset:0;z-index:10;background:color-mix(in srgb,var(--bg) 60%,transparent);backdrop-filter:blur(3px);display:none}
192
197
  .scrim.on{display:block}
193
198
  aside{position:fixed;top:0;right:0;bottom:0;z-index:11;width:520px;max-width:100%;background:var(--bg-2);border-left:1px solid var(--line);box-shadow:var(--shadow-2);display:flex;flex-direction:column;transform:translateX(100%);visibility:hidden;transition:transform .18s,visibility .18s}
194
199
  aside.open{transform:none;visibility:visible}
195
- /* Settings and Sources are pages: the same panel, docked into the app column, its content in a centred column */
200
+ /* Settings and the Icebox are pages: the same panel, docked into the app column, its content in a centred column */
196
201
  body.paged #board,body.paged .ftr,body.paged .hdr,body.paged .scrim{display:none}
197
- aside.docked{position:static;flex:1;min-height:0;width:auto;transform:none;visibility:visible;transition:none;box-shadow:none;border:0}
198
- aside.docked header,aside.docked .body{padding-inline:max(28px,calc((100% - 760px)/2))}
199
- aside.docked header{border-bottom:0;padding-top:28px;padding-bottom:12px}
200
- aside.docked header b{font-size:20px}
202
+ aside.docked{position:static;flex:1;min-height:0;width:auto;transform:none;visibility:visible;transition:none;box-shadow:none;border:0;background:none}
203
+ aside.docked>header,aside.docked>.body{padding-inline:max(28px,calc((100% - 760px)/2))}
204
+ aside.docked>header{border-bottom:0;padding-top:28px;padding-bottom:12px}
205
+ aside.docked>header b{font-size:20px}
201
206
  aside.docked #pclose{display:none}
202
207
  aside header{display:flex;align-items:flex-start;gap:12px;padding:20px 24px 16px;border-bottom:1px solid var(--line)}
203
208
  aside header .t{flex:1;min-width:0}
@@ -275,14 +280,45 @@ aside .foot .more .menu-pop{left:0;right:auto;top:auto;bottom:calc(100% + 4px)}.
275
280
  .help p{margin:0 0 16px;color:var(--dim)}.help dl{display:grid;grid-template-columns:auto 1fr;gap:10px 20px;align-items:center;margin:0}.help dt{display:flex;gap:6px;justify-content:flex-end}.help dd{margin:0}
276
281
  .help kbd{font:12px/1 ui-monospace,monospace;padding:4px 7px;border:1px solid var(--line);border-bottom-width:2px;border-radius:var(--r-s);background:var(--bg);color:var(--ink-hi)}
277
282
  /* request sources: cards, status badges, top bar, and add-box */
278
- .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}
279
- .src-topbar .meta{display:flex;flex-direction:column;gap:2px}
280
- .src-topbar .count{font-size:13px;font-weight:600;color:var(--ink)}
281
- .src-topbar .sched{font-size:12px;color:var(--dim)}
282
- .src-topbar .sched a{color:var(--accent);text-decoration:none;cursor:pointer}
283
- .src-topbar .sched a:hover{text-decoration:underline}
284
- .src-topbar .btn{display:inline-flex;align-items:center;gap:6px}
285
- .src-topbar .btn svg{width:14px;height:14px}
283
+ /* the icebox as an inbox: toolbar, filter chips, one row per request, bulk bar */
284
+ .ice-bar{display:flex;align-items:center;gap:8px;margin:8px 0 8px}
285
+ .ice-bar .count{font-size:13px;font-weight:600;color:var(--ink)}
286
+ .ice-bar .end{flex:1}
287
+ .ice-bar .btn,.ice-bulk .btn,.ice-ra .btn{display:inline-flex;align-items:center;gap:6px}
288
+ .ice-bar .btn svg,.ice-bulk .btn svg,.ice-ra .btn svg{width:14px;height:14px}
289
+ .ice-bar input,.ice-row input,.ice-bulk input{accent-color:var(--accent);width:15px;height:15px;margin:0;flex-shrink:0}
290
+ .ice-bar .ice-sel{width:auto;max-width:170px;height:28px;font-size:12px;padding:2px 26px 2px 8px}
291
+ @media(max-width:640px){.ice-bar{flex-wrap:wrap}.ice-bar .ice-sel{max-width:none;min-width:0;width:0;flex:1 1 40%}}
292
+ .ice-bulk{position:sticky;top:0;z-index:1;display:flex;align-items:center;gap:8px;padding:6px 8px 6px 14px;margin:0 0 8px;border-radius:var(--r-pill);background:color-mix(in srgb,var(--dim) 12%,var(--bg-2));font-size:13px;color:var(--ink)}
293
+ .ice-bulk .end{flex:1}
294
+ .ice-list{display:flex;flex-direction:column;gap:1px}
295
+ .ice-list .ice-cat{margin:0;padding:14px 12px 4px;font-size:12px;font-weight:500}.ice-cat i{font-style:normal;font-weight:400;margin-left:6px}.ice-list .ice-cat:first-child{padding-top:4px}
296
+ .ice-row{border-radius:var(--r-s)}.ice-open{border-radius:0 0 var(--r-s) var(--r-s);margin-top:-1px;background:var(--hover)}.ice-row:has(+.ice-open){border-radius:var(--r-s) var(--r-s) 0 0;background:var(--hover)}
297
+ .ice-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;gap:10px;min-height:36px;padding:2px 8px 2px 12px;cursor:pointer}
298
+ .ice-row:hover,.ice-row.cur{background:var(--hover)}
299
+ .ice-row.cur{box-shadow:inset 0 0 0 1px var(--line)}
300
+ .ice-row.sel{background:color-mix(in srgb,var(--accent) 8%,transparent)}
301
+ .ice-t{font:inherit;font-size:13px;color:var(--ink);background:none;border:0;padding:6px 0;text-align:left;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
302
+ .ice-t[aria-expanded=true]{font-weight:500}
303
+ .ice-src{font-size:11px;color:var(--dim);max-width:150px;display:inline-flex;align-items:center;gap:6px;min-width:0}.ice-src .nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
304
+ .ice-age{font-size:11px;color:var(--dim);font-variant-numeric:tabular-nums;white-space:nowrap;min-width:64px;text-align:right}
305
+ .ice-ra{display:none;gap:2px;min-width:64px;justify-content:flex-end}
306
+ .ice-ra .btn{padding:0 6px}
307
+ .ice-row:hover .ice-age,.ice-row.cur .ice-age,.ice-row:focus-within .ice-age{display:none}
308
+ .ice-row:hover .ice-ra,.ice-row.cur .ice-ra,.ice-row:focus-within .ice-ra{display:flex}
309
+ .ice-open{display:grid;gap:8px;padding:6px 12px 12px 37px}
310
+ .ice-zero{padding:40px 16px;text-align:center;border:1px solid var(--line);border-radius:var(--r);background:var(--bg-2)}
311
+ .ice-zero h4{margin:0 0 6px;font-size:14px;font-weight:600;color:var(--ink)}
312
+ .ice-zero p{margin:0;font-size:12px;color:var(--dim);line-height:1.5}
313
+ .ice-open .hint{font-size:12px;color:var(--dim)}
314
+ /* the sources: the run panel's drawer (aside rules), sliding over the icebox page */
315
+ body:not(.paged) .src-sec,body:not(.paged) .src-scrim{display:none}
316
+ .src-scrim{position:fixed;inset:0;z-index:10;background:color-mix(in srgb,var(--bg) 60%,transparent);backdrop-filter:blur(3px);opacity:0;visibility:hidden;transition:opacity .18s,visibility .18s}
317
+ .src-scrim.on{opacity:1;visibility:visible}
318
+ .src-sec .sub a{color:var(--accent);text-decoration:none;cursor:pointer}
319
+ .src-sec .sub a:hover{text-decoration:underline}
320
+ @media(hover:none){.ice-ra{display:flex}.ice-age{display:none}}
321
+ @media(max-width:640px){.ice-src .nm{display:none}.ice-open{padding-left:12px}}
286
322
  .src-list{display:flex;flex-direction:column;gap:10px}
287
323
  .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}
288
324
  .src-card:hover{border-color:color-mix(in srgb,var(--line) 70%,var(--ink))}
@@ -290,6 +326,9 @@ aside .foot .more .menu-pop{left:0;right:auto;top:auto;bottom:calc(100% + 4px)}.
290
326
  .src-info{display:flex;align-items:center;gap:8px;min-width:0}
291
327
  .src-ico{width:20px;height:20px;flex-shrink:0;color:var(--accent);display:inline-flex;align-items:center;justify-content:center}
292
328
  .src-ico svg{width:18px;height:18px}
329
+ /* a site's own favicon; the generic icon after it shows only once the image fails and removes itself */
330
+ .fav{display:inline-flex;align-items:center;flex-shrink:0}.fav img{width:16px;height:16px;border-radius:4px;padding:1px;background:#fff;box-sizing:border-box}.fav img+svg{display:none}
331
+ .ice-src .fav svg{width:14px;height:14px}
293
332
  .src-title{font-size:14px;font-weight:600;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
294
333
  .src-acts{display:flex;align-items:center;gap:4px;flex-shrink:0}
295
334
  .src-acts .btn{display:inline-flex;align-items:center;gap:5px}
@@ -306,8 +345,18 @@ aside .foot .more .menu-pop{left:0;right:auto;top:auto;bottom:calc(100% + 4px)}.
306
345
  .src-badge.dim{background:color-mix(in srgb,var(--dim) 14%,transparent);color:var(--dim)}
307
346
  .src-badge svg{width:12px;height:12px;stroke-width:2;flex-shrink:0}
308
347
  .src-dest{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dim);padding-top:8px;border-top:1px solid var(--line)}
309
- .src-dest .sel{flex:1;max-width:240px;height:28px;font-size:12px;padding:2px 8px}
310
- .src-dest .btn{height:28px;padding:0 10px;font-size:12px}
348
+ .src-dest .sel{flex:1;min-width:0;max-width:240px;height:28px;font-size:12px;padding:2px 8px}
349
+ .src-dest .btn{height:28px;padding:0 10px;font-size:12px;display:inline-flex;align-items:center;gap:5px}
350
+ .src-dest .btn svg{width:14px;height:14px;flex-shrink:0}
351
+ .ice-hd{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin:22px 0 4px}
352
+ .ice-hd h3{margin:0;font-size:14px;font-weight:600;color:var(--ink)}
353
+ .ice-cat{font-size:12px;font-weight:600;color:var(--dim);margin:14px 0 6px}
354
+ .ice-form{display:grid;gap:6px;padding-top:8px;border-top:1px solid var(--line)}
355
+ .ice-form .sel{width:100%;max-width:none}
356
+ .ice-form textarea.sel{height:auto;min-height:64px;font:inherit;font-size:13px;resize:vertical}
357
+ .ice-form-acts{display:flex;align-items:center;gap:8px}
358
+ .ice-form-acts .hint{flex:1;margin:0;font-size:11px}
359
+ .ice-notes{font-size:12px;color:var(--dim);line-height:1.45;overflow-wrap:anywhere;white-space:pre-wrap}
311
360
  .src-empty{padding:26px 16px;text-align:center;background:var(--bg);border:1px dashed var(--line);border-radius:var(--r);margin:12px 0 16px}
312
361
  .src-empty-ico{width:36px;height:36px;margin:0 auto 10px;color:var(--dim);display:flex;align-items:center;justify-content:center}
313
362
  .src-empty-ico svg{width:32px;height:32px}
@@ -316,10 +365,23 @@ aside .foot .more .menu-pop{left:0;right:auto;top:auto;bottom:calc(100% + 4px)}.
316
365
  .src-empty-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px;margin-top:12px;text-align:left}
317
366
  .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}
318
367
  .src-empty-item strong{display:block;color:var(--ink);font-weight:500;margin-bottom:2px}
319
- .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)}
320
- .src-add-box h3{margin:0 0 12px;font-size:13px;font-weight:600;color:var(--ink);display:flex;align-items:center;gap:6px}
321
- .src-add-box h3 svg{width:16px;height:16px}
322
- @media(max-width:900px){main{grid-template-columns:1fr;padding-inline:16px;gap:8px}.hdr,.ftr,aside.docked header,aside.docked .body{padding-inline:16px}.more{display:grid}.card:hover .age{display:inline}.toast{left:16px;max-width:calc(100% - 32px)}}
368
+ .src-add{display:grid;gap:12px;margin-top:20px;padding:16px;background:var(--bg);border:1px solid var(--line);border-radius:var(--r)}
369
+ .src-add h4{margin:0;font-size:13px;font-weight:600;color:var(--ink)}
370
+ .src-add .two{display:grid;grid-template-columns:1fr 1fr;gap:12px}
371
+ .src-add .two.plat{grid-template-columns:140px 1fr}
372
+ .src-add [hidden]{display:none}
373
+ .src-kind{display:inline-flex;gap:2px;padding:2px;border-radius:var(--r-pill);background:color-mix(in srgb,var(--dim) 12%,var(--bg-2));justify-self:start}
374
+ .src-kind button{font:inherit;font-size:12px;padding:3px 12px;border:0;border-radius:var(--r-pill);background:transparent;color:var(--dim);cursor:pointer}
375
+ .src-kind button[aria-pressed=true]{background:var(--bg-2);color:var(--ink);font-weight:500;box-shadow:var(--shadow-1)}
376
+ .src-add .hd{display:flex;align-items:center;justify-content:space-between;gap:12px}
377
+ .src-add label{display:grid;gap:4px;font-size:12px;font-weight:500;color:var(--ink)}
378
+ .src-add label i{font-style:normal;font-weight:400;color:var(--dim)}
379
+ .src-add .sel{width:100%;max-width:none}
380
+ .src-add .acts{display:flex;align-items:center;justify-content:space-between;gap:12px}
381
+ .src-add .acts .hint{margin:0;font-size:11px;color:var(--dim);line-height:1.4}
382
+ .src-add .acts .btn{flex-shrink:0}
383
+ @media(max-width:480px){.src-add .two,.src-add .two.plat{grid-template-columns:1fr}}
384
+ @media(max-width:900px){main{grid-template-columns:1fr;padding-inline:16px;gap:8px}.hdr,.ftr,aside.docked>header,aside.docked>.body{padding-inline:16px}.more{display:grid}.card:hover .age{display:inline}.toast{left:16px;max-width:calc(100% - 32px)}}
323
385
  @media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}
324
386
  </style>
325
387
  </head>
@@ -328,6 +390,7 @@ aside .foot .more .menu-pop{left:0;right:auto;top:auto;bottom:calc(100% + 4px)}.
328
390
  <svg style="display:none"><defs>
329
391
  <symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
330
392
  <symbol id="i-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></symbol>
393
+ <symbol id="i-inbox" viewBox="0 0 24 24"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.5 5.1L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.5-6.9A2 2 0 0 0 16.8 4H7.2a2 2 0 0 0-1.7 1.1z"/></symbol>
331
394
  <symbol id="i-link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.1.4l3-3a5 5 0 0 0-7.1-7.1l-1.7 1.7M14 11a5 5 0 0 0-7.1-.4l-3 3a5 5 0 0 0 7.1 7.1l1.7-1.7"/></symbol>
332
395
  <symbol id="i-play" viewBox="0 0 24 24"><path d="M6 4l14 8-14 8z"/></symbol>
333
396
  <symbol id="i-mic" viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v3M9 21h6"/></symbol>
@@ -340,6 +403,9 @@ aside .foot .more .menu-pop{left:0;right:auto;top:auto;bottom:calc(100% + 4px)}.
340
403
  <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>
341
404
  <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>
342
405
  <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>
406
+ <!-- the social platforms' marks, from simple-icons (CC0): their own favicons refuse to load on another site -->
407
+ <symbol id="i-b-x" viewBox="0 0 24 24"><path fill="currentColor" stroke="none" d="M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z"/></symbol><symbol id="i-b-tiktok" viewBox="0 0 24 24"><path fill="currentColor" stroke="none" d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></symbol><symbol id="i-b-instagram" viewBox="0 0 24 24"><path fill="currentColor" stroke="none" d="M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077"/></symbol><symbol id="i-b-threads" viewBox="0 0 24 24"><path fill="currentColor" stroke="none" d="M18.263 11.097c-.03-3.486-1.92-5.586-5.111-5.586-2.13 0-3.922.963-4.863 2.499l2.062 1.438c.535-.843 1.272-1.543 2.628-1.543 1.528 0 2.318.85 2.544 2.431a15 15 0 0 0-2.236-.173c-4.125 0-6.068 1.867-6.068 4.336s1.943 3.99 4.804 3.99c3.139 0 5.013-2.115 5.781-4.735.798.361 1.348 1.204 1.348 2.47 0 3.387-3.907 5.232-7.22 5.232-4.885 0-8.077-3.207-8.077-8.424 0-6.392 4.223-10.487 9.9-10.487 3.808 0 5.69 1.671 6.97 3.914l2.108-1.475C21.44 2.078 18.331 0 13.663 0 6.227 0 1.168 5.277 1.168 12.934c0 7 4.953 11.066 10.856 11.066 4.878 0 9.809-2.846 9.809-7.716 0-2.545-1.46-4.231-3.569-5.187m-6.33 4.855c-1.077 0-2.026-.512-2.026-1.453 0-1.483 1.822-1.934 3.606-1.934.678 0 1.34.045 1.927.173-.422 1.927-1.671 3.215-3.508 3.214Z"/></symbol>
408
+ <symbol id="i-at" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.9 7.9"/></symbol>
343
409
  <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>
344
410
  <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>
345
411
  <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>
@@ -373,7 +439,7 @@ aside .foot .more .menu-pop{left:0;right:auto;top:auto;bottom:calc(100% + 4px)}.
373
439
  <span>█ █ █ █ █ █▄▄ █▄▄ █ █</span></pre><span class="mascot"></span></a><button class="ib" id="fold" aria-label="Collapse menu" title="Collapse menu"><svg><use href="#i-side"/></svg></button></div>
374
440
  <a class="it" href="/" data-view="board" title="Board"><svg><use href="#i-board"/></svg><span class="lbl">Board</span></a>
375
441
  <a class="it" href="/notebook" data-view="notes" title="Notes"><svg><use href="#i-note"/></svg><span class="lbl">Notes</span></a>
376
- <a class="it" href="/?view=sources" data-view="sources" title="Request sources"><svg><use href="#i-link"/></svg><span class="lbl">Sources</span></a>
442
+ <a class="it" href="/?view=icebox" data-view="sources" title="Icebox: requests gathered from your sources"><svg><use href="#i-inbox"/></svg><span class="lbl">Icebox</span></a>
377
443
  <a class="it" href="/?view=settings" data-view="settings" title="Settings"><svg><use href="#i-gear"/></svg><span class="lbl">Settings</span></a>
378
444
  <span class="end"></span>
379
445
  <div class="theme" role="group" aria-label="Theme"><button data-t="light" title="Light theme"><svg><use href="#i-sun"/></svg><span class="lbl">Light</span></button><button data-t="dark" title="Dark theme"><svg><use href="#i-moon"/></svg><span class="lbl">Dark</span></button></div>
@@ -540,8 +606,10 @@ async function load(quiet){
540
606
  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)}
541
607
  // A redraw under a form being typed in would reset its fields and its focus.
542
608
  if(!(quiet&&mode&&$("board").querySelector(".inl input")))render();
609
+ // Run on an idea closed the panel: open the card on its agent once the board knows the session.
610
+ if(wantTerm&&!openKey&&!mode){const n=S.cards.find(x=>key(x)===wantTerm&&x.pty);if(n){wantTerm=null;open(n);return}}
543
611
  if(openKey){const c=S.cards.find(x=>key(x)===openKey),ta=$("pbody").querySelector(".notes textarea");if(ta&&document.activeElement===ta)return;
544
- 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")}}
612
+ if(!c){if(was&&was.kind==="idea")follow(was);return}if(runDoc)openRun(c,true,true);else if(c.kind==="idea")ideaBody(c)}}
545
613
  // An idea's card goes when its run starts. Follow it to that run rather than keep
546
614
  // offering "Run again": the newest run in its project whose request holds the idea's
547
615
  // text, the rule boardCards() retires it by. No such run: close the panel.
@@ -563,10 +631,12 @@ function card(c){
563
631
  const chips=[];
564
632
  if(c.status==="waiting")chips.push(`<span class="chip warn"><i></i>${esc(need(c.awaiting)||"needs you")}</span>`);
565
633
  else if(c.status==="running")chips.push(`<span class="chip run"><i></i>running</span>`);
634
+ if(c.pty&&c.pty.alive)chips.push(`<span class="chip run" title="The agent is running in this card: open it, Terminal tab">in card</span>`);
566
635
  if(all)chips.push(`<span class="chip">${esc(c.where)}</span>`);
567
636
  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>`}));
568
- // A gathered idea's notes open with the stamp ingest() writes; its chip names the source.
569
- 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>`);
637
+ // A gathered idea's notes open with the stamp promote() writes (ingest() wrote it before
638
+ // the icebox); its chip names the source.
639
+ const g=c.notes&&/^\[[^\]]*\] (?:Gathered from|Promoted from the icebox:) (.+?)\. 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>`);
570
640
  // Information only: the panel's button is where it is committed and pushed.
571
641
  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>`)}
572
642
  const pick=picking&&!archive&&c.column===4;
@@ -641,7 +711,7 @@ async function startRun(c,kind){
641
711
  const p=runPick&&runPick.k===key(c)?runPick:null;runPick=null;
642
712
  const yolo=!!(p&&p.yolo&&!$("run-yolo")?.hidden);
643
713
  const r=await post("/run",{dir:c.state,text:c.text,kind,agent:p?p.agent:undefined,yolo});
644
- if(r.ok)toast((kind==="full"?"Full":"Lite")+(yolo?" yolo":"")+" run opened in your terminal: "+via(r.agent));
714
+ if(r.ok){toast((kind==="full"?"Full":"Lite")+(yolo?" yolo":"")+" run "+where(r)+": "+via(r.agent));if(r.pty)wantTerm=key(c)}
645
715
  load()}
646
716
 
647
717
  function render(){
@@ -725,10 +795,12 @@ function open(c,keepTab){
725
795
  if(view!=="board")go("board",true);
726
796
  stopDictation(true); // the panel body is about to be replaced
727
797
  fixOff.clear();openKey=key(c);$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent=c.kind==="idea"?brief(c.text).head:c.text;render();
728
- if(c.kind==="idea"){runDoc=null;$("psub").textContent="Idea · "+c.where;$("ptabs").innerHTML="";chat(c,c.notes,c.archived||c.status!=="idea");
729
- 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>`;
798
+ if(c.kind==="idea"){runDoc=null;$("psub").textContent="Idea · "+c.where;$("ptabs").innerHTML="";ideaBody(c);
799
+ 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">${c.pty?"Starting: the agent runs below.":"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>`;
730
800
  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}
731
801
  if(!keepTab)tab=tabOf(c.status==="abandoned"?"plan":({1:"plan",2:"backlog",3:"review",4:"preview"})[c.column]||"request");
802
+ // A run its agent is working on, in this board, opens on that agent.
803
+ if(!keepTab&&c.pty&&c.pty.alive&&c.status==="running")tab=TABS.length;
732
804
  openRun(c,false)}
733
805
  // Notes are a chat: one entry per message, files attached by paste or picker.
734
806
  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)}
@@ -787,19 +859,42 @@ function showProgress(c){
787
859
  // again: something changed on the server, so the doc is read afresh: a note sent, a plan
788
860
  // written or a commit made elsewhere shows in nothing the page already holds. One /run
789
861
  // fetch per change while a run panel is open; a tab switch reuses the doc.
862
+ // ---- the in-card agent (agent.launch: card). xterm.js is loaded the first time a
863
+ // terminal is shown. One terminal is mounted at a time, in #pbody; a refresh that
864
+ // finds it already showing this session leaves it alone, so it never reconnects.
865
+ let wantTerm=null,termKey=null,termOff=null,termLib=null;
866
+ const where=r=>r.pty?"running in its card":"opened in your terminal";
867
+ const termLoad=()=>termLib||=new Promise((ok,no)=>{const l=document.createElement("link");l.rel="stylesheet";l.href="/term.css";document.head.append(l);const sc=document.createElement("script");sc.src="/term.js";sc.onload=()=>ok(window.kzTerm);sc.onerror=()=>{termLib=null;no()};document.head.append(sc)});
868
+ function termStop(){if(termOff)termOff();termOff=null;termKey=null;$("panel").classList.remove("wide")}
869
+ async function showTerm(c){const k=c.pty.key;if(termKey===k&&$("pbody").querySelector(".term"))return;
870
+ termStop();termKey=k;$("panel").classList.add("wide");$("pbody").innerHTML=`<div class="term" aria-label="Agent terminal"></div>`;
871
+ let mount;try{mount=await termLoad()}catch{$("pbody").innerHTML=`<p style="color:var(--dim)">The terminal could not load.</p>`;return}
872
+ if(termKey!==k)return;termOff=mount($("pbody").querySelector(".term"),k,()=>load(true))}
873
+ // An idea's panel: its terminal while the run it started is being born there, else its notes.
874
+ function ideaBody(c){if(c.pty)showTerm(c);else{termStop();chat(c,c.notes,c.archived||c.status!=="idea")}}
790
875
  async function openRun(c,refresh,again){
791
876
  if(!refresh||!runDoc||runDoc.id!==c.id||again){const d=await fetch(`/run/${encodeURIComponent(c.id)}?dir=${encodeURIComponent(c.state)}`).then(r=>r.json());
792
877
  // A file list being confirmed, or a push under way, keeps the doc it was drawn from.
793
878
  if(refresh&&$("pfoot").querySelector(".inl,[data-busy],.more[open]"))return;runDoc=d}
794
879
  if(openKey!==key(c))return;
880
+ // The fetch above may have finished after a newer /state: draw the card as it is now
881
+ // (a resumed agent's terminal, not the one that ended).
882
+ c=S.cards.find(x=>key(x)===openKey)||c;
795
883
  // A refresh while notes are being typed would wipe the textarea.
796
- const ta=$("pbody").querySelector("textarea");if(refresh&&ta&&document.activeElement===ta)return;
884
+ const ta=$("pbody").querySelector(".notes textarea");if(refresh&&ta&&document.activeElement===ta)return;
797
885
  $("psub").textContent=c.id+" · "+(WORD[c.status]||c.status)+(c.awaiting?" · "+need(c.awaiting):"");
798
- const tabs=TABS;
886
+ // A card whose agent runs in the board gets its terminal as one more tab, chosen once
887
+ // right after a launch from here.
888
+ const tabs=c.pty?[...TABS,["Terminal","term"]]:TABS;
889
+ if(c.pty&&wantTerm===key(c)){wantTerm=null;tab=TABS.length}
890
+ if(tab>=tabs.length)tab=0;
891
+ $("panel").classList.toggle("wide",tabs[tab][1]==="term");
799
892
  $("ptabs").innerHTML=tabs.map((t,i)=>`<button class="${i===tab?"on":""}" data-i="${i}">${t[0]}</button>`).join("");
800
893
  [...$("ptabs").children].forEach(b=>b.onclick=()=>{tab=+b.dataset.i;openRun(c,true)});
801
894
  const k=tabs[tab][1],doc=k==="preview"?(preview(runDoc.impl)&&attach(preview(runDoc.impl),c)):runDoc[k];
802
- if(k==="notes"){const ro=c.archived||c.status==="done"||c.status==="abandoned";
895
+ if(k!=="term")termStop();
896
+ if(k==="term")showTerm(c);
897
+ else if(k==="notes"){const ro=c.archived||c.status==="done"||c.status==="abandoned";
803
898
  chat(c,doc,ro);}
804
899
  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>`;
805
900
  if(k==="backlog")showProgress(c);
@@ -820,21 +915,29 @@ async function openRun(c,refresh,again){
820
915
  // gitBtn's buttons restyled as menu rows.
821
916
  const more=gitBtn(c).replace(/class="btn( pri)?"/g,"")+`<button data-a="archive">Archive</button><hr><button data-a="ab" class="danger">Abandon</button>`;
822
917
  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":""}>${c.awaiting==="findings"?"Approve as-is":"Approve"}</button></span>`}
823
- 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>`;
918
+ else if(c.status==="waiting")foot.innerHTML=`<span class="note">Waiting on you: ${esc(need(c.awaiting))}. Answer in the ${c.pty&&c.pty.alive?"Terminal tab":"terminal running this run"}.</span>${arch}<button class="btn ghost" data-a="ab">Abandon</button>`;
824
919
  else if(c.status==="done"||c.status==="abandoned")foot.innerHTML=arch;
825
- 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>`;
826
- 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()})}
920
+ else foot.innerHTML=`<span class="note">${c.pty&&!c.pty.alive?"Its agent ended before the stage finished. Resume continues it here.":c.status==="stalled"?"No progress for a while. Resume it, or abandon it.":c.pty?"Running here: Terminal tab.":"Running in your agent."}</span>${arch}<button class="btn ghost" data-a="ab">Abandon run</button>${resumable(c)?`<button class="btn pri" data-a="resume">Resume</button>`:""}`;
921
+ 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}if(a==="resume"){resume(c,b);return}close();mode="reject";modeKey=key(c);render()})}
827
922
  // Answering an approval from the panel opens your agent on the run, told the decision and,
828
923
  // for a revise, the comment. Answered approvals are remembered outside the footer, which
829
924
  // every refresh rebuilds, so a second press cannot open a second agent. Keyed by when the
830
925
  // run last moved: the same approval reached again after a revise is a new question.
831
926
  // The footer's More menu is a <details>: close it on a click anywhere else.
832
927
  document.addEventListener("click",e=>{for(const d of document.querySelectorAll(".more[open]"))if(!d.contains(e.target))d.open=false});
928
+ // Resume: a run whose in-card agent ended, or one stalled with no progress, gets its agent
929
+ // back on the run (its conversation, where the tool can resume one), in its card.
930
+ const resumable=c=>c.status==="stalled"||!!(c.pty&&!c.pty.alive);
931
+ async function resume(c,b){b.disabled=true;askNotify();const r=await post("/resume",{dir:c.state,id:c.id});
932
+ if(!r.ok){b.disabled=false;return}
933
+ toast("Resumed, "+where(r)+": "+via(r.agent));if(r.pty)wantTerm=key(c);
934
+ if(openKey===key(c)){$("pfoot").innerHTML="";openRun(c,true)}}
833
935
  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 ready to check. Approve to finish it, or revise with what to change.","findings":"The review has open findings. Approve as-is to accept them and finish, or fix selected findings first."},decided=new Set(),answer=c=>key(c)+":"+c.awaiting+":"+c.moved;
834
936
  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}:{})});
835
937
  // A failed send keeps the footer, and with it a typed comment.
836
938
  if(!r.ok){b.disabled=false;return}
837
- decided.add(answer(c));toast((why===undefined?"Approved":"Sent back")+" in your terminal: "+via(r.agent));
939
+ decided.add(answer(c));toast((why===undefined?"Approved":"Sent back")+", "+where(r)+": "+via(r.agent));
940
+ if(r.pty)wantTerm=key(c);
838
941
  if(openKey===key(c)){$("pfoot").innerHTML="";openRun(c,true)}}
839
942
  function confirmRevise(c){const foot=$("pfoot"),plan=c.awaiting==="approvals.plan";
840
943
  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>`;
@@ -883,7 +986,7 @@ function fixForm(c){const k=n=>c.id+":"+n,sent=f=>fixSent.has(k(f.n)),fs=runDoc.
883
986
  allb.onclick=()=>{const none=picks().length===open.length;open.forEach(f=>none?fixOff.add(k(f.n)):fixOff.delete(k(f.n)));sync()};
884
987
  go.onclick=async()=>{const nums=picks();go.disabled=true;askNotify();const r=await post("/fix",{dir:c.state,id:c.id,nums});
885
988
  // Sent findings lock as sent, so a second press cannot open a second agent on them.
886
- 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()};
989
+ if(r.ok){toast("Fixing "+nums.length+" finding"+(nums.length===1?"":"s")+", "+where(r)+": "+via(r.agent));if(r.pty)wantTerm=key(c);nums.forEach(n=>fixSent.add(k(n)));try{sessionStorage.setItem(SK,JSON.stringify([...fixSent]))}catch(e){}openRun(c,true)}else sync()};
887
990
  sync();$("pbody").appendChild(el)}
888
991
  // Commit and push, for a finished run in a project with a git remote. What the board
889
992
  // did is recorded per run, so the settled label outlives later changes to the tree.
@@ -910,7 +1013,7 @@ async function commitPush(c,message,b){b.disabled=true;b.dataset.busy=1;b.textCo
910
1013
  // A refused message comes back for another try, beside the current file list.
911
1014
  if(!r.ok&&message&&openKey===key(c)&&runDoc.git.files&&runDoc.git.files.length)confirmCommit(c,message)}
912
1015
  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()}
913
- function close(){if(view!=="board")return;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})}
1016
+ function close(){if(view!=="board")return;const k=openKey;stopDictation(true);termStop();openKey=null;runDoc=null;$("panel").classList.remove("open");$("scrim").classList.remove("on");render();if(k)cardEl(k)?.focus({preventScroll:true})}
914
1017
  $("pclose").onclick=close;$("scrim").onclick=close;
915
1018
  // An idea is one inbox line, so a structured request arrives as one wall:
916
1019
  // "Fix x Problem: … Scope: … Acceptance criteria: (1) … (2) …". Split it, for display
@@ -960,7 +1063,7 @@ const groups=rows=>{const m=new Map();for(const r of rows){if(!m.has(r.group))m.
960
1063
  let setQ="";
961
1064
  async function openSettings(){
962
1065
  stopDictation(true);
963
- openKey=null;runDoc=null;$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent="Settings";$("ptabs").innerHTML="";$("pfoot").innerHTML="";
1066
+ termStop();openKey=null;runDoc=null;$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent="Settings";$("ptabs").innerHTML="";$("pfoot").innerHTML="";
964
1067
  const cur=document.documentElement.dataset.theme||"system";
965
1068
  const cfg=await fetch("/settings").then(r=>r.json()).catch(()=>null);
966
1069
  $("psub").textContent="Theme applies to this browser. Everything else is saved to your kaizen settings.";$("psub").title=cfg?cfg.file:"";
@@ -1001,11 +1104,11 @@ async function openSettings(){
1001
1104
  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);
1002
1105
  // The system's own folder dialog, opened by the board: a page cannot read a real path.
1003
1106
  if(r.folder)$(el.id+"-pick").onclick=async()=>{const j=await post("/pick-folder",{});if(j.path){await save(j.path);load()}}})}
1004
- // ---- views: the board, or Settings and Sources as pages in the docked panel, each with its own address
1107
+ // ---- views: the board, or Settings and the Icebox as pages in the docked panel, each with its own address
1005
1108
  let view="board";
1006
1109
  const markView=v=>document.querySelectorAll(".rail a.it").forEach(a=>a.dataset.view===v?a.setAttribute("aria-current","page"):a.removeAttribute("aria-current"));
1007
- function go(v,push){if(!["settings","sources"].includes(v))v="board";const was=view;view=v;markView(v);document.body.classList.toggle("paged",v!=="board");
1008
- if(push&&v!==was)history.pushState(null,"",v==="board"?location.pathname:"?view="+v);
1110
+ function go(v,push){if(v==="icebox")v="sources";if(!["settings","sources"].includes(v))v="board";const was=view;view=v;markView(v);document.body.classList.toggle("paged",v!=="board");
1111
+ if(push&&v!==was)history.pushState(null,"",v==="board"?location.pathname:"?view="+(v==="sources"?"icebox":v));
1009
1112
  const p=$("panel");
1010
1113
  if(v==="board"){if(was==="board")return;
1011
1114
  // Undocked without the slide: it was a page, not a drawer leaving.
@@ -1016,63 +1119,196 @@ document.querySelector(".rail .home").onclick=e=>{if(e.ctrlKey||e.metaKey||e.shi
1016
1119
  addEventListener("popstate",()=>go(new URLSearchParams(location.search).get("view")));
1017
1120
  // The menu's theme switch while Settings shows: its Theme row follows.
1018
1121
  addEventListener("kz-theme",()=>{if(view==="settings")openSettings()});
1019
- // ---- request sources: links kaizen reads for new ideas. Same panel as settings.
1020
- const SRC="Request sources";
1122
+ // ---- the icebox: requests gathered from request sources, read like an inbox, until
1123
+ // someone promotes them to ideas or dismisses them. The sources themselves sit in a
1124
+ // section below, folded. Same panel as settings.
1125
+ const SRC="Icebox";
1126
+ // What the page keeps across the rebuild every server change brings: the item whose
1127
+ // promote form is open and the one the cursor is on (both "<dir> <id>"), the checked
1128
+ // items, the source filter ("<dir> <url>"), whether the sources section is open, and
1129
+ // whether a bulk action is still posting.
1130
+ // The category filter, and whether the add form takes a link or a social account/tag. The
1131
+ // project filter is the board's own `shown`, so the board and the icebox narrow alike.
1132
+ let iceForm=null,iceCur=null,iceSrc="",iceCat="",srcKind="link",srcShow=null,iceBusy=false,iceNav=null;const iceSel=new Set();
1133
+ const PLAT={x:"X",tiktok:"TikTok",instagram:"Instagram",threads:"Threads"};
1021
1134
  const srcOpen=()=>$("panel").classList.contains("open")&&$("ptitle").textContent===SRC;
1022
1135
  const ago=at=>{const m=Math.round((Date.now()-Date.parse(at))/60000);return m<1?"just now":m<60?m+" min ago":m<1440?Math.round(m/60)+" h ago":Math.round(m/1440)+" d ago"};
1023
1136
  async function openSources(){
1024
1137
  stopDictation(true);
1025
1138
  // What is half typed survives a refresh from the server.
1026
- const typed=srcOpen()?[$("src-url")?.value||"",$("src-label")?.value||"",document.activeElement?.id,$("src-dir")?.value]:["",""];
1139
+ const typed=srcOpen()?[$("src-url")?.value||"",$("src-label")?.value||"",document.activeElement?.id,$("src-dir")?.value,$("src-cat")?.value||"",$("src-plat")?.value,$("src-q")?.value||""]:["",""];
1027
1140
  // So does a project picked on a row but not yet moved.
1028
1141
  const picked=new Map(srcOpen()?[...$("pbody").querySelectorAll("[data-mv]")].map(m=>[m.dataset.k,m.value]):[]);
1029
- openKey=null;runDoc=null;$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent=SRC;$("ptabs").innerHTML="";$("pfoot").innerHTML="";
1030
- $("psub").textContent="Links kaizen checks for new requests. What it finds lands in Ideas; nothing starts on its own.";$("psub").title="";
1142
+ // And a promote form half filled, for the item it was typed on.
1143
+ // And which control of which inbox row had focus, by item key: row indices shift when a row goes.
1144
+ const fRow=srcOpen()&&document.activeElement?.closest?.(".ice-row"),fPick=fRow?[fRow.dataset.k,document.activeElement.id.replace(/-\d+$/,"")]:null;
1145
+ const iceDraft=srcOpen()&&$("ice-title")?[$("ice-title").dataset.k,$("ice-title").value,$("ice-details").value]:null;
1146
+ // And a category typed on a row but not yet saved.
1147
+ const cats=new Map(srcOpen()?[...$("pbody").querySelectorAll("[data-ct]")].map(m=>[m.dataset.k,m.value]):[]);
1148
+ termStop();openKey=null;runDoc=null;$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent=SRC;$("ptabs").innerHTML="";$("pfoot").innerHTML="";
1149
+ $("psub").textContent="Requests gathered from your sources wait here until you promote them to Ideas or dismiss them; nothing starts on its own.";$("psub").title="";
1031
1150
  const dir=all?null:S.dir,q=dir?"?dir="+encodeURIComponent(dir):"";
1032
1151
  const d=await fetch("/sources"+q).then(r=>r.json()).catch(()=>null);
1033
1152
  if(!srcOpen())return;
1034
1153
  const host=u=>{try{return new URL(u).hostname}catch{return u}};
1035
- 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"};
1036
- const list=d&&d.sources||[];
1154
+ // The favicon of the site a link is on, asked of that site itself (Google's own static host for
1155
+ // Sheets and Docs, whose site icon is generic): nothing but the host reaches anyone. A social
1156
+ // platform's mark is drawn here instead: Meta's favicons are same-origin only.
1157
+ const fav=(u,fb,soc)=>{if(PLAT[soc])return `<span class="fav">${ico("b-"+soc)}</span>`;let h;try{const x=new URL(u);if(/^https?:$/.test(x.protocol))h=x.origin}catch{}
1158
+ const src=!h?"":u.includes("docs.google.com/spreadsheets")?"https://ssl.gstatic.com/docs/spreadsheets/favicon3.ico":u.includes("docs.google.com/document")?"https://ssl.gstatic.com/docs/documents/images/kix-favicon7.ico":h+"/favicon.ico";
1159
+ return `<span class="fav">${src?`<img src="${esc(src)}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.remove()">`:""}${ico(fb)}</span>`};
1160
+ const srcType=s=>{if(s.social)return "social";const u=s.url;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"};
1161
+ const list=d&&d.sources||[],ice=d&&d.icebox||[];
1037
1162
  // A source belongs to one project, named on its row. Picking another only arms Move: a select
1038
1163
  // changes on every arrow key, and each change would have moved the source. The names are the picker's;
1039
1164
  // a row whose project the picker does not list still gets its own, selected, so Move never points elsewhere.
1040
1165
  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("");
1041
- 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>`;
1042
1166
  const cards=list.map((s,i)=>{
1043
- const st=srcType(s.url),title=s.label||host(s.url);
1044
- 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>`;
1045
- 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>`;
1046
- }).join("")+(d&&d.failed||[]).map(f=>`<p style="color:var(--dim)">The sources of ${esc(f)} could not be read.</p>`).join("");
1047
- 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>`;
1048
- 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>`;
1049
- $("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;
1050
- $("src-url").value=typed[0];$("src-label").value=typed[1];if(typed[3]!==undefined&&$("src-dir"))$("src-dir").value=typed[3];
1167
+ const st=srcType(s),title=s.label||host(s.url);
1168
+ // A social source is never read by kaizen, so its not-ok is the agent's turn, not a failure.
1169
+ const statusHtml=s.social&&!s.last?.ok?`<span class="src-badge dim">${ico("at")}Gathered by your agent</span><span class="hint" style="margin:0">Run /kaizen gather</span>`:!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>`;
1170
+ return `<div class="src-card" id="src-card-${i}"><div class="src-hd"><div class="src-info"><span class="src-ico" title="${st==="social"?esc(PLAT[s.social]||"social"):st}">${fav(s.url,st==="sheet"?"sheet":st==="doc"?"doc":st==="social"?"at":"link",s.social)}</span><span class="src-title" title="${esc(title)}">${esc(title)}</span></div><div class="src-acts">${s.social?"":`<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>Category</span><input class="sel" id="src-ct-${i}" data-ct="${i}" data-k="${esc(s.dir+" "+s.url)}" value="${esc(s.category||"")}" placeholder="e.g. Feedback" maxlength="40" aria-label="Category of ${esc(title)}"><button class="btn sm" id="src-cs-${i}" aria-label="Save the category of ${esc(title)}" disabled>Save</button></div><div class="src-dest"><span>Project</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>`;
1171
+ }).join("");
1172
+ const fails=(d&&d.failed||[]).map(f=>`<p style="color:var(--dim)">The sources of ${esc(f)} could not be read.</p>`).join("");
1173
+ 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 the icebox, where you choose which become ideas. 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 class="src-empty-item"><strong>Social media</strong>A keyword, #hashtag or @account on X, TikTok, Instagram or Threads, read by your agent.</div></div></div>`;
1174
+ const social=srcKind==="social";
1175
+ const addBox=`<div class="src-add"><div class="hd"><h4>Add a source</h4><div class="src-kind" role="group" aria-label="Kind of source"><button id="src-k-link" data-kind="link" aria-pressed="${!social}">Link</button><button id="src-k-social" data-kind="social" aria-pressed="${social}">Social</button></div></div><label for="src-url"${social?" hidden":""}><span>Link</span><input class="sel" id="src-url" placeholder="https://docs.google.com/spreadsheets/d/… or any public link" spellcheck="false" autocomplete="off"></label><div class="two plat"${social?"":" hidden"}><label for="src-plat"><span>Platform</span><select class="sel" id="src-plat">${Object.entries(PLAT).map(([k,v])=>`<option value="${k}">${v}</option>`).join("")}</select></label><label for="src-q"><span>Follow</span><input class="sel" id="src-q" placeholder="keyword, #hashtag or @account" maxlength="100" spellcheck="false" autocomplete="off"></label></div><div class="two"><label for="src-label"><span>Label <i>optional</i></span><input class="sel" id="src-label" placeholder="${social?"e.g. Mentions on X":"e.g. Feedback Q4"}"></label><label for="src-cat"><span>Category <i>optional</i></span><input class="sel" id="src-cat" placeholder="${social?"e.g. Social":"e.g. Bugs"}" maxlength="40"></label></div><label for="src-dir"><span>Project</span><select class="sel" id="src-dir">${dir?"":`<option value="">Choose project…</option>`}${opts(dir)}</select></label><div class="acts"><span class="hint">${social?"Kaizen signs in nowhere. Your agent reads public posts when you run /kaizen gather.":"Public links only. For private docs, run /kaizen gather."}</span><button class="btn pri" id="src-add">Add source</button></div></div>`;
1176
+ // The inbox: one row per request, by project when showing all, then by category,
1177
+ // narrowed to one source by its chip. Checked, open and cursor items are kept by key.
1178
+ const srcName=it=>{const s=list.find(x=>x.dir===it.dir&&x.url===it.source);return s?s.label||host(s.url):host(it.source)};
1179
+ const iceKey=it=>it.dir+" "+it.id,srcKey=it=>it.dir+" "+it.source;
1180
+ // Filters narrow in turn: project (the board's own picker, all projects only), then
1181
+ // category, then source; each chip counts what the filters before it leave.
1182
+ const catOf=it=>it.category||"Uncategorised",count=(xs,f)=>{const m=new Map();xs.forEach(it=>{const k=f(it);m.set(k,{it,n:(m.get(k)?.n||0)+1})});return m};
1183
+ const byProj=dir?new Map():count(ice,it=>it.dir);if(!dir)for(const pd of shown)if(!byProj.has(pd)){const p=showable().find(x=>x.dir===pd);if(p)byProj.set(pd,{it:{dir:pd,project:p.label},n:0})}
1184
+ const inProj=it=>dir||!shown.size||shown.has(it.dir),pv=ice.filter(inProj);
1185
+ const byCat=count(pv,catOf);if(iceCat&&!byCat.has(iceCat))iceCat="";
1186
+ const cv=pv.filter(it=>!iceCat||catOf(it)===iceCat);
1187
+ const bySrc=count(cv,srcKey);if(iceSrc&&!bySrc.has(iceSrc))iceSrc="";
1188
+ const keys=new Set(ice.map(iceKey));
1189
+ if(iceForm&&!keys.has(iceForm))iceForm=null;
1190
+ const groups=new Map();ice.forEach((it,i)=>{if(!inProj(it)||iceCat&&catOf(it)!==iceCat||iceSrc&&srcKey(it)!==iceSrc)return;const k=catOf(it);groups.has(k)||groups.set(k,[]);groups.get(k).push(i)});
1191
+ // Rows in the order they are drawn, which is the order j and k walk.
1192
+ const order=[...groups.values()].flat();
1193
+ // A checked request a filter hides is unchecked: a bulk action only ever acts on what shows.
1194
+ {const vis=new Set(order.map(i=>iceKey(ice[i])));[...iceSel].forEach(k=>vis.has(k)||iceSel.delete(k))}
1195
+ if(!order.some(i=>iceKey(ice[i])===iceCur))iceCur=order.length?iceKey(ice[order[0]]):null;
1196
+ const nSel=order.filter(i=>iceSel.has(iceKey(ice[i]))).length;
1197
+ // Filters are plain dropdowns, each shown only when it has a choice to make. The project one
1198
+ // is the board's own picker: one project, or all; several picked on the board read as such.
1199
+ const many=byProj.size>1,opt=(v,on,t)=>`<option value="${esc(v)}"${on?" selected":""}>${esc(t)}</option>`;
1200
+ const pick=(id,label,xs)=>`<select class="sel ice-sel" id="${id}" aria-label="${label}">${xs.join("")}</select>`;
1201
+ const chips=(many?pick("ice-fp","Project",[opt("",!shown.size,"All projects"),...(shown.size>1?[opt("*",true,shown.size+" projects")]:[]),...[...byProj].map(([k,{it}])=>opt(k,shown.size===1&&shown.has(k),it.project||k))]):"")
1202
+ +(byCat.size>1?pick("ice-fc","Category",[opt("",!iceCat,"All categories"),...[...byCat.keys()].map(k=>opt(k,iceCat===k,k))]):"")
1203
+ +(bySrc.size>1?pick("ice-fs","Source",[opt("",!iceSrc,"All sources"),...[...bySrc].map(([k,{it}])=>opt(k,iceSrc===k,srcName(it)))]):"");
1204
+ const bar=`<div class="ice-bar">${order.length?`<input type="checkbox" id="ice-all" aria-label="Select every request shown"${nSel&&nSel===order.length?" checked":""}>`:""}<span class="count">${order.length<ice.length?`${order.length} of ${ice.length}`:ice.length} waiting</span>${chips}<span class="end"></span><button class="btn sm" id="src-go"${list.length?"":" disabled"}>${ico("refresh")} Gather all</button><button class="btn ghost sm" id="src-tog" aria-expanded="${srcShow??!list.length}" aria-controls="src-sec">${ico("link")} Sources · ${list.length}</button></div>`;
1205
+ const bulk=iceSel.size?`<div class="ice-bulk" role="toolbar" aria-label="Selected requests"><span>${iceSel.size} selected</span><span class="end"></span><button class="btn sm" id="ice-bp" title="Each becomes its own idea, titled as gathered (p)">${ico("plus")} Promote</button><button class="btn ghost sm" id="ice-bd" title="Dismiss (d)">${ico("x")} Dismiss</button><button class="btn ghost sm" id="ice-bc" title="Clear selection (Esc)">Clear</button></div>`:"";
1206
+ // The open item's form: a clean title for the idea's card and the person's own details;
1207
+ // what was gathered is added to the idea's notes by the server.
1208
+ const form=it=>`<div class="ice-form"><label for="ice-title" class="hint" style="margin:0">Idea title</label><input class="sel" id="ice-title" data-k="${esc(iceKey(it))}" maxlength="200" value="${esc(it.text)}" aria-label="Idea title"><label for="ice-details" class="hint" style="margin:0">Details (optional)</label><textarea class="sel" id="ice-details" rows="3" placeholder="In your own words: what is needed, for whom, why" aria-label="Details (optional)"></textarea><div class="ice-form-acts"><span class="hint">What was gathered is added to the idea's notes.</span><button class="btn ghost sm" id="ice-cancel">Cancel</button><button class="btn pri sm" id="ice-ok">Promote</button></div></div>`;
1209
+ const row=i=>{const it=ice[i],k=iceKey(it),open=iceForm===k;
1210
+ return `<div class="ice-row${iceCur===k?" cur":""}${iceSel.has(k)?" sel":""}" data-i="${i}" data-k="${esc(k)}"><input type="checkbox" id="ice-ck-${i}" data-ck="${i}"${iceSel.has(k)?" checked":""} aria-label="Select: ${esc(it.text)}"><button class="ice-t" id="ice-o-${i}" data-o="${i}" aria-expanded="${open}" title="${esc(it.text)}">${esc(it.text)}</button><span class="ice-src" title="${esc(srcName(it))}">${fav(it.source,"link",list.find(x=>x.dir===it.dir&&x.url===it.source)?.social)}<span class="nm">${esc(many?it.project||srcName(it):srcName(it))}</span></span><span class="ice-age">${ago(it.at)}</span><span class="ice-ra"><button class="btn ghost sm" id="ice-pr-${i}" data-pr="${i}" title="Promote to Ideas as gathered (p)" aria-label="Promote to Ideas: ${esc(it.text)}">${ico("plus")}</button><button class="btn ghost sm" id="ice-ds-${i}" data-ds="${i}" title="Dismiss (d)" aria-label="Dismiss: ${esc(it.text)}">${ico("x")}</button></span></div>`
1211
+ +(open?`<div class="ice-open">${it.notes?`<div class="ice-notes">${esc(it.notes)}</div>`:""}<span class="hint" style="margin:0">From ${esc(srcName(it))} · ${ago(it.at)}</span>${form(it)}</div>`:"")};
1212
+ const inbox=ice.length&&!order.length?`<div class="ice-zero"><h4>Nothing matches</h4><p>${ice.length} waiting outside these filters.</p><p style="margin-top:10px"><button class="btn sm" id="ice-unf">Clear filters</button></p></div>`:ice.length?`<div class="ice-list">${[...groups].map(([g,ix])=>`<div class="ice-cat">${esc(g)}<i>${ix.length}</i></div>${ix.map(row).join("")}`).join("")}</div>`
1213
+ :`<div class="ice-zero"><h4>Inbox zero</h4><p>Nothing waiting. Gather all checks your sources for new requests; /kaizen discover in your agent sorts what comes in.</p></div>`;
1214
+ // The sources, in the right-hand drawer: shut until asked for, open by itself while there
1215
+ // are none until closed. Slid in only when opened; a rebuild while open shows it in place.
1216
+ const drawer=srcShow??!list.length,slide=drawer&&!$("src-sec")?.classList.contains("open");
1217
+ const sec=`<div class="src-scrim${drawer&&!slide?" on":""}" id="src-scrim"></div><aside class="src-sec${drawer&&!slide?" open":""}" id="src-sec" aria-label="Sources"${drawer?"":" inert"}><header><div class="t"><b>Sources</b><div class="sub">${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></div></div><button class="ib" id="src-x" aria-label="Close sources" title="Close">${ico("close")}</button></header><div class="body">${list.length?`<div class="src-list">${cards}</div>`:emptyState}${addBox}</div></aside>`;
1218
+ $("pbody").innerHTML=d&&d.error?`<p style="color:var(--dim)">${esc(d.error)}</p>`:!d?`<p style="color:var(--dim)">The icebox could not be read.</p>`:bar+bulk+fails+inbox+sec;
1219
+ if(!d||d.error)return;
1220
+ if(iceDraft&&iceDraft[0]===iceForm&&$("ice-title")){$("ice-title").value=iceDraft[1];$("ice-details").value=iceDraft[2]}
1221
+ if($("src-add")){if(typed[5])$("src-plat").value=typed[5];$("src-q").value=typed[6]||"";$("src-url").value=typed[0];$("src-label").value=typed[1];$("src-cat").value=typed[4]||"";if(typed[3]!==undefined&&$("src-dir"))$("src-dir").value=typed[3]}
1051
1222
  if($("src-to-settings"))$("src-to-settings").onclick=()=>{setQ="sources";go("settings",true)};
1223
+ if(slide){$("src-sec").offsetWidth;$("src-sec").classList.add("open");$("src-scrim").classList.add("on")}
1224
+ const srcShut=()=>{srcShow=false;$("src-sec").classList.remove("open");$("src-scrim").classList.remove("on");$("src-sec").inert=true;$("src-tog").setAttribute("aria-expanded","false");$("src-tog").focus()};
1225
+ $("src-x").onclick=$("src-scrim").onclick=srcShut;$("src-sec").onkeydown=e=>{if(e.key==="Escape"){e.stopPropagation();srcShut()}};
1226
+ $("src-tog").onclick=()=>{if($("src-sec").classList.contains("open"))return srcShut();srcShow=true;openSources().then(()=>srcShow&&$("src-url")?.focus())};
1227
+ if($("ice-fs"))$("ice-fs").onchange=e=>{iceSrc=e.target.value;openSources()};
1228
+ if($("ice-fc"))$("ice-fc").onchange=e=>{iceCat=e.target.value;openSources()};
1229
+ if($("ice-fp"))$("ice-fp").onchange=e=>{const v=e.target.value;if(v==="*")return;shown=new Set(v?[v]:[]);setShown();openSources()};
1230
+ if($("ice-unf"))$("ice-unf").onclick=()=>{iceCat=iceSrc="";if(!dir)shown.clear();setShown();openSources()};
1231
+ $("pbody").querySelectorAll("[data-kind]").forEach(b=>b.onclick=()=>{if(srcKind===b.dataset.kind)return;srcKind=b.dataset.kind;openSources().then(()=>$(srcKind==="social"?"src-q":"src-url")?.focus())});
1052
1232
  const act=async(body,done)=>{const j=await post("/sources",body);if(j.ok){done(j);load()}if(srcOpen())openSources()};
1053
1233
  $("pbody").querySelectorAll("[data-mv]").forEach(m=>{const s=list[+m.dataset.mv],b=$("src-mb-"+m.dataset.mv),to=picked.get(m.dataset.k);
1054
1234
  if(to&&[...m.options].some(o=>o.value===to))m.value=to;
1055
1235
  (m.onchange=()=>{b.disabled=m.value===s.dir})();
1056
- 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"}.`))});
1236
+ b.onclick=()=>act({op:"move",dir:s.dir,to:m.value,url:s.url},()=>toast(`Source moved. Requests already gathered stay in ${s.project||"the old project"}.`))});
1237
+ // The category saves on its button or Enter, never per keystroke.
1238
+ $("pbody").querySelectorAll("[data-ct]").forEach(m=>{const s=list[+m.dataset.ct],b=$("src-cs-"+m.dataset.ct),was=cats.get(m.dataset.k);
1239
+ if(was!==undefined)m.value=was;
1240
+ (m.oninput=()=>{b.disabled=m.value.trim()===(s.category||"")})();
1241
+ m.onkeydown=e=>{if(e.key==="Enter"&&!b.disabled)b.click()};
1242
+ b.onclick=()=>{b.disabled=true;act({op:"category",dir:s.dir,url:s.url,category:m.value.trim()},()=>toast(m.value.trim()?`New requests from ${s.label||host(s.url)} go under ${m.value.trim()}`:"Category cleared"))}});
1243
+ // The acting button is disabled as it is pressed, which drops focus to the page; it goes back to the cursor row.
1244
+ const focusCur=()=>{if(!document.activeElement||document.activeElement===document.body)$("pbody").querySelector(".ice-row.cur .ice-t")?.focus()};
1245
+ // Before rows go, the cursor steps to the next row that stays, else the one before.
1246
+ const stepPast=gone=>{const at=order.findIndex(i=>iceKey(ice[i])===iceCur);if(at<0||!gone.has(iceCur))return;
1247
+ const stay=i=>!gone.has(iceKey(ice[i])),n=order.slice(at+1).find(stay)??order.slice(0,at).reverse().find(stay);iceCur=n===undefined?null:iceKey(ice[n])};
1248
+ const iceAct=(op,it,msg,extra={})=>(async()=>{stepPast(new Set([iceKey(it)]));const j=await post("/icebox",{op,dir:it.dir,ids:[it.id],...extra});if(j.ok){if(op==="promote"&&iceForm===iceKey(it))iceForm=null;toast(msg);load()}if(srcOpen())openSources().then(focusCur)})();
1249
+ // Bulk: one dismiss per project; promote one request at a time, each its own idea
1250
+ // titled as gathered (one promote call with several ids would merge them into one).
1251
+ const bulkDo=async op=>{const its=ice.filter(it=>iceSel.has(iceKey(it)));if(!its.length)return;stepPast(new Set(its.map(iceKey)));iceBusy=true;$("ice-bp").disabled=$("ice-bd").disabled=true;let ok=0;
1252
+ try{if(op==="dismiss")for(const dd of new Set(its.map(it=>it.dir))){const ids=its.filter(it=>it.dir===dd).map(it=>it.id);if((await post("/icebox",{op,dir:dd,ids})).ok)ok+=ids.length}
1253
+ else for(const it of its)if((await post("/icebox",{op,dir:it.dir,ids:[it.id]})).ok)ok++}
1254
+ finally{iceBusy=false}
1255
+ iceSel.clear();if(ok)toast(`${ok} ${op==="promote"?"promoted to Ideas":"dismissed"}${ok<its.length?`, ${its.length-ok} not`:""}`);load();if(srcOpen())openSources().then(focusCur)};
1256
+ if(bulk){$("ice-bp").onclick=()=>bulkDo("promote");$("ice-bd").onclick=()=>bulkDo("dismiss");$("ice-bc").onclick=()=>{iceSel.clear();openSources()}}
1257
+ if($("ice-all"))$("ice-all").onchange=()=>{const k=order.map(i=>iceKey(ice[i]));if(nSel===order.length)k.forEach(x=>iceSel.delete(x));else k.forEach(x=>iceSel.add(x));openSources()};
1258
+ if($("ice-all"))$("ice-all").indeterminate=nSel>0&&nSel<order.length;
1259
+ $("pbody").querySelectorAll("[data-ck]").forEach(c=>c.onchange=()=>{const k=iceKey(ice[+c.dataset.ck]);iceCur=k;c.checked?iceSel.add(k):iceSel.delete(k);openSources()});
1260
+ // A row opens on its title or anywhere not a control; open shows what was gathered and the promote form.
1261
+ const toggle=i=>{const k=iceKey(ice[i]);iceCur=k;iceForm=iceForm===k?null:k;openSources().then(()=>{const t=$("ice-title");if(t&&iceForm===k){t.focus();t.select()}else $("ice-o-"+i)?.focus()})};
1262
+ $("pbody").querySelectorAll(".ice-row").forEach(r=>r.onclick=e=>{if(!e.target.closest("input,button"))toggle(+r.dataset.i)});
1263
+ $("pbody").querySelectorAll("[data-o]").forEach(b=>b.onclick=()=>toggle(+b.dataset.o));
1264
+ $("pbody").querySelectorAll("[data-pr]").forEach(b=>b.onclick=()=>{b.disabled=true;iceAct("promote",ice[+b.dataset.pr],"Promoted to Ideas")});
1265
+ $("pbody").querySelectorAll("[data-ds]").forEach(b=>b.onclick=()=>{b.disabled=true;iceAct("dismiss",ice[+b.dataset.ds],"Dismissed")});
1266
+ const open1=ice.find(it=>iceKey(it)===iceForm);
1267
+ if($("ice-ok")){
1268
+ const ok=()=>{const title=$("ice-title").value.trim();if(!title){$("ice-title").focus();return}$("ice-ok").disabled=true;iceAct("promote",open1,"Promoted to Ideas",{text:title,details:$("ice-details").value})};
1269
+ $("ice-ok").onclick=ok;
1270
+ $("ice-cancel").onclick=()=>{iceForm=null;openSources()};
1271
+ $("ice-title").onkeydown=e=>{if(e.key==="Enter")ok();if(e.key==="Escape")$("ice-cancel").click()};
1272
+ $("ice-details").onkeydown=e=>{if(e.key==="Enter"&&(e.ctrlKey||e.metaKey))ok();if(e.key==="Escape")$("ice-cancel").click()};
1273
+ }
1274
+ // Keys, read by the page's keydown while the icebox shows. True when the key was used.
1275
+ iceNav=e=>{if(e.altKey||e.ctrlKey||e.metaKey)return false;
1276
+ const k=e.key,at=order.findIndex(i=>iceKey(ice[i])===iceCur),i=order[at];
1277
+ if(k==="Escape"){if($("src-sec")?.classList.contains("open")){srcShut();return true}if(iceForm){iceForm=null;openSources();return true}if(iceSel.size){iceSel.clear();openSources();return true}return false}
1278
+ if(at<0)return false;
1279
+ if(k==="j"||k==="k"){e.preventDefault();const n=order[Math.max(0,Math.min(order.length-1,at+(k==="j"?1:-1)))];iceCur=iceKey(ice[n]);
1280
+ $("pbody").querySelectorAll(".ice-row").forEach(r=>r.classList.toggle("cur",+r.dataset.i===n));$("pbody").querySelector(`.ice-row[data-i="${n}"]`)?.scrollIntoView({block:"nearest"});return true}
1281
+ const b=k==="o"?$("ice-o-"+i):k==="x"?$("ice-ck-"+i):k==="p"?(iceSel.size?$("ice-bp"):$("ice-pr-"+i)):k==="d"?(iceSel.size?$("ice-bd"):$("ice-ds-"+i)):null;
1282
+ if(!b||b.disabled)return false;e.preventDefault();b.click();return true};
1057
1283
  $("pbody").querySelectorAll("[data-chk]").forEach(b=>{const s=list[+b.dataset.chk];
1058
1284
  b.onclick=()=>{b.disabled=true;b.innerHTML=ico("refresh")+" Checking…";
1059
1285
  act({op:"gather",dir:s.dir,url:s.url},j=>{const r=j.results&&j.results[0];
1060
- toast(r?(r.ok?`${s.label||host(s.url)}: ${r.note}`:`Failed: ${r.note}`):"Checked")})}});
1286
+ toast(r?(r.ok?`${s.label||host(s.url)}: ${r.note}${r.added?" in the icebox":""}`:`Failed: ${r.note}`):"Checked")})}});
1061
1287
  $("pbody").querySelectorAll("[data-rm]").forEach(b=>{const s=list[+b.dataset.rm];
1062
- 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;
1288
+ b.onclick=()=>{if(!confirm(`Remove ${s.label||host(s.url)}? What it gathered stays; added again later, it may bring back ones you deleted or dismissed.`))return;
1063
1289
  act({op:"remove",dir:list[+b.dataset.rm].dir,url:list[+b.dataset.rm].url},()=>toast("Source removed"))}});
1064
- const was=typed[2]&&$(typed[2]);if(was)(was.disabled?$(was.id.replace("src-mb-","src-mv-")):was)?.focus();
1065
- $("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}
1290
+ // A row that went takes focus to the cursor row's title, never to whatever now holds its index.
1291
+ const ci=ice.findIndex(it=>iceKey(it)===iceCur);
1292
+ if(fPick){const at=ice.findIndex(it=>iceKey(it)===fPick[0]);(at>=0?$(fPick[1]+"-"+at):ci>=0?$("ice-o-"+ci):null)?.focus()}
1293
+ const was=!fPick&&typed[2]&&$(typed[2]);if(was)(was.disabled?$(was.id.replace("src-mb-","src-mv-").replace("src-cs-","src-ct-")):was)?.focus();
1294
+ if($("src-add")){
1295
+ // A social source is a platform and what to follow; the server builds its link, and
1296
+ // nothing is gathered here: the agent reads it.
1297
+ $("src-add").onclick=()=>{const social=srcKind==="social",url=$("src-url").value.trim(),q=$("src-q").value.trim(),plat=$("src-plat").value;
1298
+ if(social?!q:!url){$(social?"src-q":"src-url").focus();return}const to=$("src-dir").value;if(!to){$("src-dir").focus();return}
1066
1299
  $("src-add").disabled=true;$("src-add").textContent="Adding…";
1067
- (async()=>{const j=await post("/sources",{op:"add",dir:to,url,label:$("src-label").value.trim()});
1068
- 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];
1069
- toast(r?(r.ok?`Source added: ${r.note}`:`Source added, but: ${r.note}`):"Source added");load()}
1300
+ (async()=>{const label=$("src-label").value.trim()||(social?`${PLAT[plat]}: ${q}`:""),category=$("src-cat").value.trim();
1301
+ const j=await post("/sources",social?{op:"add",dir:to,platform:plat,query:q,label,category}:{op:"add",dir:to,url,label,category});
1302
+ if(j.ok&&social){$("src-q").value=$("src-label").value=$("src-cat").value="";toast("Source added. Run /kaizen gather in your agent to read its posts.");load()}
1303
+ else if(j.ok){$("src-url").value=$("src-label").value=$("src-cat").value="";const g=await post("/sources",{op:"gather",dir:to,url}),r=g.results&&g.results[0];
1304
+ toast(r?(r.ok?`Source added: ${r.note} in the icebox`:`Source added, but: ${r.note}`):"Source added");load()}
1070
1305
  else toast(j.error||"Could not add source");
1071
1306
  if(srcOpen())openSources()})()};
1072
- $("src-url").onkeydown=$("src-label").onkeydown=e=>{if(e.key==="Enter")$("src-add").click()};
1307
+ $("src-url").onkeydown=$("src-q").onkeydown=$("src-label").onkeydown=$("src-cat").onkeydown=e=>{if(e.key==="Enter")$("src-add").click()};
1308
+ }
1073
1309
  if($("src-go"))$("src-go").onclick=()=>{$("src-go").disabled=true;$("src-go").innerHTML=ico("refresh")+" Gathering…";
1074
- 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;
1075
- toast((n?`${n} new idea${n===1?"":"s"}`:"Nothing new")+(bad?`, ${bad} source${bad===1?"":"s"} could not be read`:""))})}}
1310
+ act({op:"gather",dir:dir||undefined},j=>{const rs=j.results||[],soc=r=>list.some(s=>s.url===r.url&&s.social),n=rs.reduce((a,r)=>a+r.added,0),bad=rs.filter(r=>!r.ok&&!soc(r)).length,ag=rs.filter(soc).length;
1311
+ toast((n?`${n} new in the icebox`:"Nothing new")+(bad?`, ${bad} source${bad===1?"":"s"} could not be read`:"")+(ag?`; ${ag} social for /kaizen gather`:""))})}}
1076
1312
  // With `report`, the toast carries a link to a prefilled GitHub bug form (/issue) and
1077
1313
  // stays long enough to click it.
1078
1314
  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)}
@@ -1128,6 +1364,8 @@ $("newtop").onclick=()=>{mode="new";render();$("board").scrollTo(0,0)};
1128
1364
  $("kbd").onclick=()=>$("help").classList.add("on");$("help").onclick=()=>$("help").classList.remove("on");
1129
1365
  document.addEventListener("click",()=>{if(menuKey||clearMenu){menuKey=null;clearMenu=false;render()}});
1130
1366
  document.onkeydown=e=>{
1367
+ // The icebox reads its own keys first; a checkbox there keeps them, for x then j.
1368
+ if(view==="sources"&&iceNav&&srcOpen()&&!$("help").classList.contains("on")&&!e.target.matches("input:not([type=checkbox]),select,textarea,[contenteditable]")&&!e.target.closest("#src-sec")&&iceNav(e))return;
1131
1369
  if(e.target.matches("input,select,textarea,[contenteditable]"))return;
1132
1370
  if(e.key==="?"){$("help").classList.toggle("on");return}
1133
1371
  if(e.key==="/"&&$("sq")&&$("panel").classList.contains("open")){e.preventDefault();$("sq").focus();return}
@@ -1149,11 +1387,11 @@ document.onkeydown=e=>{
1149
1387
  let boot=null;
1150
1388
  // Back from a terminal: what was committed or pushed there touched nothing the server watches.
1151
1389
  addEventListener("focus",()=>load(true));
1152
- 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)}}
1390
+ function listen(){const es=new EventSource("/events");es.onmessage=ev=>{if(ev.data==="changed"){load(true);if(srcOpen()&&!iceBusy&&!$("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)}}
1153
1391
  // Back to the project picked last time, unless the URL says which view it wants. A
1154
1392
  // plain fetch: a project that is gone should fall back to all, not raise a toast.
1155
1393
  // A page named in the address shows from the first paint; it is filled once the board has loaded.
1156
- const v0=new URLSearchParams(location.search).get("view");markView(["settings","sources"].includes(v0)?v0:"board");document.body.classList.toggle("paged",["settings","sources"].includes(v0));
1394
+ const v0=(v=>v==="icebox"?"sources":v)(new URLSearchParams(location.search).get("view"));markView(["settings","sources"].includes(v0)?v0:"board");document.body.classList.toggle("paged",["settings","sources"].includes(v0));
1157
1395
  (async()=>{let d;try{d=localStorage.getItem("kz-proj")}catch(e){}
1158
1396
  if(d&&!urlAll)try{if((await fetch("/cd",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({dir:d})})).ok)all=false;else keep()}catch(e){}
1159
1397
  try{const f=JSON.parse(sessionStorage.getItem("kz-form"));if(f)[mode,modeKey]=f}catch(e){}