kaizen-agent 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/board.test.ts CHANGED
@@ -168,3 +168,33 @@ test("card menu items: appropriate actions and distinct icons across phases", ()
168
168
  const archIdea = getItems(idea, true);
169
169
  expect(archIdea.map((i: any) => i ? i[1] : null)).toEqual(["Restore", "Open", null, "Delete"]);
170
170
  });
171
+
172
+ // brief() lifted the same way: a one-line structured idea split for display only.
173
+ const bat = html.indexOf("function brief(");
174
+ const brief = new Function(`${html.slice(bat, html.indexOf("\n\n", bat))}\nreturn brief`)() as (t: string) => { head: string; parts: { label: string; body: string }[] };
175
+
176
+ test("a labelled idea splits into a head and one part per label", () => {
177
+ const b = brief("Improve previews Problem: one dense block. Non-goals: no rewrites. Acceptance criteria: (1) scannable. (2) full text kept.");
178
+ expect(b.head).toBe("Improve previews");
179
+ expect(b.parts.map(p => p.label)).toEqual(["Problem", "Non-goals", "Acceptance criteria"]);
180
+ expect(b.parts[2].body).toBe("1. scannable.\n2. full text kept.");
181
+ expect(md(`### ${b.parts[2].label}\n${b.parts[2].body}`)).toBe("<h3>Acceptance criteria</h3><ol><li>scannable.</li><li>full text kept.</li></ol>");
182
+ });
183
+
184
+ test("plain ideas, and a single label, are left whole", () => {
185
+ for (const t of ["fix the typo in README", "Bug: clicking twice opens two panels"]) expect(brief(t)).toEqual({ head: t, parts: [] });
186
+ });
187
+
188
+ test("an idea that opens with a label heads with its first part", () => {
189
+ expect(brief("Goal: faster board. Scope: cards only.").head).toBe("Goal: faster board.");
190
+ });
191
+
192
+ test("a capitalised phrase mid-sentence is prose, not a label", () => {
193
+ const b = brief("Fix cards Problem: the Board shows: nothing useful. Scope: cards.");
194
+ expect(b.parts.map(p => p.label)).toEqual(["Problem", "Scope"]);
195
+ expect(b.parts[0].body).toBe("the Board shows: nothing useful.");
196
+ });
197
+
198
+ test("criteria not numbered from 1 keep their own numbers", () => {
199
+ expect(brief("X Goal: g. Criteria: (2) two. (3) three.").parts[1].body).toBe("- (2) two.\n- (3) three.");
200
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "Staged workflow for AI agents — code, writing, research, operations: plan, human approval, build, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -86,6 +86,9 @@ commands to start it locally and the URL to open, copied from the material's own
86
86
  where one exists. For anything else — a document, copy, a runbook, a memo — the path
87
87
  to open, or the result itself when it fits in a screen. The board shows this section on
88
88
  the run's card, so a person can try the work without reading the rest of the report.
89
+ Where the result can be seen, put screenshots of it here: save each under
90
+ `runs/<id>/attachments/` (letters, digits, `.`, `-`, `_` only) and embed it as
91
+ `![what it shows](attachments/<name>.png)`; the board shows them in the Preview tab.
89
92
 
90
93
  It also says how to test the result, under a `### How to test` subhead: numbered steps a
91
94
  person follows to check the run did what was asked, each an action and what they should
package/web/board.html CHANGED
@@ -35,11 +35,12 @@ select.sel,button.sel{cursor:pointer;appearance:none;padding-right:30px;max-widt
35
35
  /* project picker: a select-shaped button over a searchable list */
36
36
  .combo{position:relative;max-width:100%;display:flex}
37
37
  .combo .menu-pop{left:0;right:auto;top:calc(100% + 4px);width:320px;max-width:calc(100vw - 32px);gap:6px;z-index:9}
38
- .combo .menu-pop[hidden]{display:none}
38
+ .combo[hidden],.combo .menu-pop[hidden]{display:none}
39
39
  .combo input.sel{width:100%;max-width:none}
40
40
  #projs{display:flex;flex-direction:column;max-height:300px;overflow-y:auto}
41
41
  #projs button{display:block;flex:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
42
42
  #projs button.on{background:var(--hover)}
43
+ #shows{display:flex;flex-direction:column;max-height:300px;overflow-y:auto}#shows label{display:flex;gap:8px;align-items:center;padding:6px 10px;font-size:13px;border-radius:var(--r-s);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#shows label:hover{background:var(--hover)}#shows .grp{padding:8px 10px 4px;font-size:11px;color:var(--dim)}
43
44
  #projs .grp{padding:8px 10px 4px;font-size:11px;color:var(--dim)}
44
45
  .sp{flex:1}
45
46
  .ib{width:34px;height:34px;display:grid;place-items:center;color:var(--dim);border-radius:var(--r-s);transition:background .12s,color .12s}
@@ -86,6 +87,7 @@ ul{list-style:none;margin:0;padding:8px 0 0;display:flex;flex-direction:column;g
86
87
  .card.menu{z-index:2}
87
88
  .card.open{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)}
88
89
  .card.archived .c-title,.card.archived .c-id{opacity:.7}
90
+ section h2 .ord{min-height:22px;padding:0 8px;font-weight:500}
89
91
  section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
90
92
  .clear-pop{top:30px;right:0;min-width:200px}
91
93
  .clear-pop button i{font-style:normal;color:var(--dim);margin-left:auto;background:none;padding:0}
@@ -137,6 +139,8 @@ section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
137
139
  .inl input::placeholder,.inl textarea::placeholder,.notes textarea::placeholder{color:var(--dim)}
138
140
  .inl .row,.notes .row{display:flex;gap:6px;justify-content:flex-end}
139
141
  .notes{display:flex;flex-direction:column;gap:8px}
142
+ #pbody img{max-width:100%;border-radius:var(--r-s)}
143
+ .desc{border-bottom:1px solid var(--line);padding-bottom:6px;margin-bottom:14px}.desc h3{margin-top:14px}
140
144
  .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}
141
145
  .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)}
142
146
  .inl pre.files{margin:0;max-height:140px;overflow:auto;font:12px/1.5 var(--mono);color:var(--dim)}
@@ -329,6 +333,7 @@ aside .foot .more .menu-pop{left:0;right:auto;top:auto;bottom:calc(100% + 4px)}.
329
333
  <span>█▀▄ █▀█ █ ▄▀ ██▄ █ ▀█</span>
330
334
  <span>█ █ █ █ █ █▄▄ █▄▄ █ █</span></pre>
331
335
  <div class="combo" id="combo"><button class="sel" id="proj" aria-label="project" aria-haspopup="listbox" aria-expanded="false"></button><div class="menu-pop" id="projpop" hidden><input class="sel" id="projq" aria-label="Search projects" placeholder="Search projects…" spellcheck="false" autocomplete="off"><div id="projs" role="listbox"></div></div></div>
336
+ <div class="combo" id="showc" hidden><button class="sel" id="show" aria-label="Projects shown" aria-haspopup="true" aria-expanded="false"></button><div class="menu-pop" id="showpop" hidden><input class="sel" id="showq" aria-label="Search projects" placeholder="Search projects…" spellcheck="false" autocomplete="off"><div id="shows"></div><button id="showall">Show all</button></div></div>
332
337
  <span class="sp"></span>
333
338
  <button class="btn pri" id="newtop">New idea</button>
334
339
  <a class="ib" href="/notebook" title="Notebook" aria-label="Notebook"><svg><use href="#i-note"/></svg></a>
@@ -446,6 +451,8 @@ let S={project:null,projects:[],cards:[],now:Date.now(),all:false};
446
451
  let all=new URLSearchParams(location.search).get("all")!=="0",openKey=null,mode=null,modeKey=null,filter=null,menuKey=null,tab=0,runDoc=null;
447
452
  // A column shows this many cards, then a "more" line; Done is where it matters.
448
453
  const FOLD=6;const unfolded=new Set();
454
+ // Per column: "old" puts the oldest first; anything else is newest first.
455
+ let order=(()=>{try{return JSON.parse(localStorage.getItem("kz-order"))||{}}catch(e){return{}}})();
449
456
  let archive=false;
450
457
  let clearMenu=false,picking=false;const picked=new Set(); // Clear on Done: menu, then cherry-pick mode // showing the archive instead of the board
451
458
  // The server writes notifier() from state.ts over the marker: one rule for both UIs.
@@ -501,7 +508,7 @@ function card(c){
501
508
  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>`)}
502
509
  const pick=picking&&!archive&&c.column===4;
503
510
  el.innerHTML=(pick?`<input type="checkbox" class="pick" ${picked.has(k)?"checked":""} aria-label="Select">`:"")+`<div class="c-id"><span class="st d-${st}"><svg><use href="#s-${st}"/></svg></span><span class="idt">${esc(idt)}</span><span class="age">${c.kind==="run"?age(c.moved||S.now,S.now):""}</span><button class="ib sm more" data-a="menu" title="Actions" aria-label="Actions" aria-haspopup="menu" aria-expanded="${k===menuKey}">${ico("more")}</button></div>
504
- <div class="c-title">${esc(c.kind==="idea"?c.text:(c.title||c.id))}</div>
511
+ <div class="c-title"${c.kind==="idea"&&brief(c.text).parts.length?` title="${esc(c.text)}"`:""}>${esc(c.kind==="idea"?brief(c.text).head:(c.title||c.id))}</div>
505
512
  <div class="c-chips">${chips.join("")}</div>`;
506
513
  if(k===menuKey){
507
514
  const items=[];
@@ -581,7 +588,9 @@ function render(){
581
588
  if(!mode)undraft("f:");try{sessionStorage.setItem("kz-form",JSON.stringify(["new","edit","reject"].includes(mode)?[mode,modeKey]:null))}catch(e){}
582
589
  // The board is rebuilt below; the card that had focus gets it back.
583
590
  const fk=focusedKey();
584
- const cs=S.cards.filter(c=>c.archived===archive),b=$("board");b.innerHTML="";
591
+ // All projects: only the ones ticked in the filter, or every one when none is.
592
+ const only=all&&shown.size&&showable().some(p=>shown.has(p.dir));
593
+ const cs=S.cards.filter(c=>c.archived===archive&&(!only||shown.has(c.state))),b=$("board");b.innerHTML="";
585
594
  const nArch=S.cards.filter(c=>c.archived).length;
586
595
  const n=s=>cs.filter(c=>c.status===s).length;
587
596
  const strip=[];
@@ -595,16 +604,21 @@ function render(){
595
604
  PL=[["All projects","all"],...S.projects.map(p=>[p.label,p.dir]),...S.fresh.map(p=>[p.label,p.dir,1]),["Find projects on this PC…","__find"]];
596
605
  $("proj").textContent=all||!S.dir?"All projects":(PL.find(p=>p[1]===S.dir)||["All projects"])[0];
597
606
  if(!$("projpop").hidden)list();
607
+ $("showc").hidden=!all;const sp=showable(),ns=sp.filter(p=>shown.has(p.dir)).length;$("show").textContent=ns?`Showing ${ns} of ${sp.length}`:"Showing all";
608
+ if(!$("showpop").hidden)showList();
598
609
  if(!cs.length&&mode!=="new"){b.innerHTML=archive?'<div class="empty"><div class="mascot"></div><b>Nothing archived</b></div>':'<div class="empty"><div class="mascot"></div><b>Nothing here yet</b><p>Add an idea and kaizen will plan it, build it, and check the result.</p><button class="btn pri" id="newempty">New idea</button></div>';if(!archive)$("newempty").onclick=()=>{mode="new";render()};}
599
610
  COLS.forEach((name,ci)=>{
600
611
  const s=document.createElement("section");const list=cs.filter(c=>c.column===ci&&(!filter||c.status===filter));
612
+ // Runs age by their last move, ideas by inbox position (new ones are appended).
613
+ if(ci<4){const at=c=>c.kind==="run"?c.moved||0:S.cards.indexOf(c),old=order[ci]==="old";list.sort((a,b)=>old?at(a)-at(b):at(b)-at(a))}
601
614
  const clearable=ci===4&&!archive&&list.length;
602
615
  const olderThan=days=>list.filter(c=>S.now-(c.moved||S.now)>days*d);
603
- let head=`<h2>${name}<i>${list.length||""}</i>${ci===0&&!archive?`<button class="ib sm" title="New idea" aria-label="New idea">${ico("plus")}</button>`:""}`;
616
+ let head=`<h2>${name}<i>${list.length||""}</i>${ci<4&&list.length>1?`<button class="btn sm ghost ord" title="Sort order">${order[ci]==="old"?"Oldest":"Newest"}</button>`:""}${ci===0&&!archive?`<button class="ib sm" title="New idea" aria-label="New idea">${ico("plus")}</button>`:""}`;
604
617
  if(clearable&&picking)head+=`<span class="pickbar"><button class="btn sm ghost" data-p="cancel">Cancel</button><button class="btn sm pri" data-p="go" ${picked.size?"":"disabled"}>Archive ${picked.size||""}</button></span>`;
605
618
  else if(clearable)head+=`<button class="btn sm ghost clear" title="Archive finished runs">Clear ▾</button>`;
606
619
  if(clearable&&clearMenu)head+=`<div class="menu-pop clear-pop"><button data-c="7">Older than a week <i>${olderThan(7).length}</i></button><button data-c="3">Older than 3 days <i>${olderThan(3).length}</i></button><button data-c="0">Everything <i>${list.length}</i></button><hr><button data-c="pick">Pick which…</button></div>`;
607
620
  s.innerHTML=head+`</h2>`;
621
+ {const o=s.querySelector(".ord");if(o)o.onclick=e=>{e.stopPropagation();order[ci]=order[ci]==="old"?"new":"old";try{localStorage.setItem("kz-order",JSON.stringify(order))}catch(e){}render()}}
608
622
  if(ci===0&&!archive)s.querySelector(".ib").onclick=()=>{mode="new";render()};
609
623
  if(clearable){
610
624
  const cl=s.querySelector(".clear");if(cl)cl.onclick=e=>{e.stopPropagation();clearMenu=!clearMenu;render()};
@@ -645,7 +659,7 @@ const tabOf=k=>TABS.findIndex(t=>t[1]===k);
645
659
  // keepTab: a link already chose the tab.
646
660
  function open(c,keepTab){
647
661
  stopDictation(true); // the panel body is about to be replaced
648
- fixOff.clear();openKey=key(c);$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent=c.text;render();
662
+ 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();
649
663
  if(c.kind==="idea"){runDoc=null;$("psub").textContent="Idea · "+c.where;$("ptabs").innerHTML="";chat(c,c.notes,c.archived||c.status!=="idea");
650
664
  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>`;
651
665
  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}
@@ -655,10 +669,10 @@ function open(c,keepTab){
655
669
  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)}
656
670
  let nfocus=null;
657
671
  function chat(c,src,ro){
658
- const base=c.kind==="run"?`runs/${c.id}/`:"";
659
- const fix=t=>t.replace(/\]\((attachments\/[^)]+)\)/g,(_,p)=>`](/file?dir=${encodeURIComponent(c.state)}&p=${encodeURIComponent(base+p)})`);
672
+ const fix=t=>attach(t,c);
660
673
  const entries=parseNotes(src);
661
- let h=`<div class="chat">${entries.map(n=>`<div class="msg">${n.at?`<time>${esc(n.at)}</time>`:""}${md(fix(n.text))}</div>`).join("")||`<p style="color:var(--dim)">No notes yet.</p>`}</div>`;
674
+ const b=c.kind==="idea"?brief(c.text):{parts:[]};
675
+ let h=(b.parts.length?`<section class="desc">${md(b.parts.map(p=>`### ${p.label}\n${p.body}`).join("\n\n"))}</section>`:"")+`<div class="chat">${entries.map(n=>`<div class="msg">${n.at?`<time>${esc(n.at)}</time>`:""}${md(fix(n.text))}</div>`).join("")||`<p style="color:var(--dim)">No notes yet.</p>`}</div>`;
662
676
  if(!ro)h+=`<div class="notes"><textarea placeholder="Add a note. Paste a screenshot, or attach files."></textarea><div class="att" id="natt"></div><div class="tools"><input type="file" id="nfile" multiple hidden><button class="btn ghost" id="npick">Attach</button><span>Enter sends, Shift+Enter newline</span><button class="btn pri" id="nsend">Send</button></div></div>`;
663
677
  $("pbody").innerHTML=h;
664
678
  if(ro)return;
@@ -681,6 +695,8 @@ function chat(c,src,ro){
681
695
  // "## Commit" is the message the builder wrote for the work, offered at commit and push.
682
696
  function section(impl,name){const m=impl&&impl.match(new RegExp(`^##\\s+${name}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$(?![\\s\\S]))`,"m"));return m?m[1].trim():null}
683
697
  const preview=impl=>section(impl,"Preview");
698
+ // Links to a card's attachments/ (notes, or screenshots the builder put in Preview) via /file.
699
+ const attach=(t,c)=>t.replace(/\]\((attachments\/[^)]+)\)/g,(_,p)=>`](/file?dir=${encodeURIComponent(c.state)}&p=${encodeURIComponent((c.kind==="run"?`runs/${c.id}/`:"")+p)})`);
684
700
  const commitMsg=impl=>(section(impl,"Commit")||"").replace(/^```\w*\n|\n```$/g,"").trim();
685
701
  // A plan's Open questions as a pick list: `Q: question` lines with `- option` lines under
686
702
  // each, "(recommended)" marking the default and "multi-select" in the question allowing
@@ -717,7 +733,7 @@ async function openRun(c,refresh,again){
717
733
  const tabs=TABS;
718
734
  $("ptabs").innerHTML=tabs.map((t,i)=>`<button class="${i===tab?"on":""}" data-i="${i}">${t[0]}</button>`).join("");
719
735
  [...$("ptabs").children].forEach(b=>b.onclick=()=>{tab=+b.dataset.i;openRun(c,true)});
720
- const k=tabs[tab][1],doc=k==="preview"?preview(runDoc.impl):runDoc[k];
736
+ const k=tabs[tab][1],doc=k==="preview"?(preview(runDoc.impl)&&attach(preview(runDoc.impl),c)):runDoc[k];
721
737
  if(k==="notes"){const ro=c.archived||c.status==="done"||c.status==="abandoned";
722
738
  chat(c,doc,ro);}
723
739
  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>`;
@@ -831,6 +847,22 @@ async function commitPush(c,message,b){b.disabled=true;b.dataset.busy=1;b.textCo
831
847
  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()}
832
848
  function close(){const k=openKey;stopDictation(true);openKey=null;runDoc=null;$("panel").classList.remove("open");$("scrim").classList.remove("on");render();if(k)cardEl(k)?.focus({preventScroll:true})}
833
849
  $("pclose").onclick=close;$("scrim").onclick=close;
850
+ // An idea is one inbox line, so a structured request arrives as one wall:
851
+ // "Fix x Problem: … Scope: … Acceptance criteria: (1) … (2) …". Split it, for display
852
+ // only, into the text before the first label and one part per label; inline (1) (2)
853
+ // become a numbered list. Fewer than two labels is plain text and is left whole.
854
+ function brief(t){
855
+ // A label opens a sentence; only the first may follow the head without punctuation,
856
+ // and then only as one word ("… previews Problem:"), so "the Board shows: x" is prose.
857
+ const re=/(^|[.!?)]\s+|\s)([A-Z][\w-]*(?: [a-z][\w-]*){0,3}):\s+/g,cut=[];let m;
858
+ while((m=re.exec(t)))if(!/^\s+$/.test(m[1])||(!cut.length&&!m[2].includes(" ")))cut.push([m.index+m[1].length,m[2],re.lastIndex]);
859
+ if(cut.length<2)return {head:t,parts:[]};
860
+ // Inline (1) (2) … become a list; numbers that do not run 1, 2, 3 keep their own text.
861
+ const parts=cut.map(([,label,from],i)=>{const body=t.slice(from,i+1<cut.length?cut[i+1][0]:t.length).trim(),n=body.split(/\s*\((\d+)\)\s+/);
862
+ if(n.length<5)return {label,body};const its=[];for(let k=1;k<n.length;k+=2)its.push([n[k],n[k+1]]);
863
+ const seq=its.every(([d],k)=>+d===k+1);
864
+ return {label,body:(n[0]?n[0]+"\n":"")+its.map(([d,x],k)=>seq?`${k+1}. ${x}`:`- (${d}) ${x}`).join("\n")}});
865
+ return {head:t.slice(0,cut[0][0]).trim()||`${parts[0].label}: ${t.slice(cut[0][2],cut[1][0]).trim()}`,parts}}
834
866
 
835
867
  // Enough markdown for kaizen's own files: headings, lists, fences, tables,
836
868
  // inline code, bold. Everything is escaped first. With `tick`, a backlog item
@@ -986,6 +1018,21 @@ async function pick(v){
986
1018
  else if(v==="all"){all=true;keep()}
987
1019
  else{const j=await post("/cd",{dir:v});if(j.ok){all=false;keep(v)}}
988
1020
  load()}
1021
+ // The all-projects filter: tick the projects to show; none ticked shows every one. Kept per browser.
1022
+ let shown=new Set((()=>{try{return JSON.parse(localStorage.getItem("kz-show"))||[]}catch(e){return[]}})());
1023
+ // Known projects, plus any folder a card comes from that is not one (the global inbox).
1024
+ function showable(){const l=[...S.projects];for(const c of S.cards)if(!l.some(p=>p.dir===c.state))l.push({dir:c.state,label:c.where});return l}
1025
+ let SP=[];
1026
+ function showList(){const q=$("showq").value.trim().toLowerCase();SP=showable();
1027
+ $("shows").innerHTML=SP.map((p,i)=>p.label.toLowerCase().includes(q)?`<label title="${esc(p.label)}"><input type="checkbox" data-i="${i}" ${shown.has(p.dir)?"checked":""}>${esc(p.label)}</label>`:"").join("")||`<div class="grp">No match</div>`}
1028
+ function setShown(){try{localStorage.setItem("kz-show",JSON.stringify([...shown]))}catch(e){}render()}
1029
+ $("show").onclick=()=>{const on=$("showpop").hidden;$("showpop").hidden=!on;$("show").setAttribute("aria-expanded",on);if(on){$("showq").value="";showList();$("showq").focus()}};
1030
+ $("showq").oninput=showList;
1031
+ $("showq").onkeydown=e=>{if(e.key==="Escape"){$("showpop").hidden=true;$("show").setAttribute("aria-expanded",false);$("show").focus()}};
1032
+ // render() redraws the list; the ticked box keeps keyboard focus.
1033
+ $("shows").onchange=e=>{const i=e.target.dataset.i,d=SP[i]?.dir;if(!d)return;e.target.checked?shown.add(d):shown.delete(d);setShown();$("shows").querySelector(`[data-i="${i}"]`)?.focus()};
1034
+ $("showall").onclick=()=>{shown.clear();setShown()};
1035
+ document.addEventListener("click",e=>{if(!e.target.closest("#showc")){$("showpop").hidden=true;$("show").setAttribute("aria-expanded",false)}});
989
1036
  $("newtop").onclick=()=>{mode="new";render();$("board").scrollTo(0,0)};
990
1037
  $("settings").onclick=()=>{setQ="";openSettings()};$("sources").onclick=openSources;
991
1038
  try{const t=localStorage.getItem("kz-theme");if(t)document.documentElement.dataset.theme=t}catch(e){}