[object Object]

← back to Tools Dw Hub

hub UI → internal-line-viewer pattern: left aside with collapsed Category/Status/Type facet tables (values+counts, click-to-filter, teal badge, collapsed on load), right grid opens/launches on card click; dark theme, density type-scale, no <a>-wrapped cards, location.origin fetches, compact/img-only toggle

2c9003627223d29a7cf33811fc1249e028830731 · 2026-07-28 16:31:55 -0700 · Steve

Files touched

Diff

commit 2c9003627223d29a7cf33811fc1249e028830731
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 28 16:31:55 2026 -0700

    hub UI → internal-line-viewer pattern: left aside with collapsed Category/Status/Type facet tables (values+counts, click-to-filter, teal badge, collapsed on load), right grid opens/launches on card click; dark theme, density type-scale, no <a>-wrapped cards, location.origin fetches, compact/img-only toggle
---
 public/index.html | 332 +++++++++++++++++++++++++++++++++---------------------
 1 file changed, 203 insertions(+), 129 deletions(-)

diff --git a/public/index.html b/public/index.html
index f58eec6..4530421 100644
--- a/public/index.html
+++ b/public/index.html
@@ -5,166 +5,240 @@
 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 <title>Designer Wallcoverings · Tools</title>
 <style>
-  :root { --cols: 3; --ink:#1a1a1a; --muted:#6b6b6b; --line:#e6e2da; --bg:#faf8f4; --accent:#8a7a5c; }
-  * { margin:0; padding:0; box-sizing:border-box; }
-  body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--ink); }
-  header { padding:34px 28px 14px; }
-  h1 { font-weight:300; letter-spacing:.14em; text-transform:uppercase; font-size:1.5rem; }
-  h1 b { font-weight:600; }
-  .sub { color:var(--muted); font-size:.85rem; margin-top:6px; letter-spacing:.02em; }
-  .bar { position:sticky; top:0; z-index:5; background:rgba(250,248,244,.94); backdrop-filter:blur(6px);
-         border-bottom:1px solid var(--line); padding:12px 28px; display:flex; gap:16px; align-items:center; flex-wrap:wrap; }
-  .bar input[type=search], .bar select { padding:8px 12px; border:1px solid var(--line); border-radius:8px; background:#fff; font-size:.85rem; color:var(--ink); }
-  .bar input[type=search] { min-width:220px; }
-  .bar label { font-size:.72rem; color:var(--muted); text-transform:uppercase; letter-spacing:.08em; display:flex; align-items:center; gap:8px; }
-  .pills { display:flex; gap:6px; flex-wrap:wrap; padding:12px 28px 0; }
-  .pill { font-size:.72rem; padding:5px 12px; border:1px solid var(--line); border-radius:999px; background:#fff; cursor:pointer; color:var(--muted); }
-  .pill.on { background:var(--ink); color:#fff; border-color:var(--ink); }
-  .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:16px; padding:18px 28px 60px; }
-  .card { background:#fff; border:1px solid var(--line); border-radius:12px; padding:16px 16px 14px; display:flex; flex-direction:column; gap:9px; transition:box-shadow .15s, transform .15s; }
-  .card:hover { box-shadow:0 8px 24px rgba(0,0,0,.07); transform:translateY(-2px); }
-  .ct { display:flex; align-items:center; justify-content:space-between; gap:8px; }
-  .name { font-weight:600; font-size:.98rem; line-height:1.25; }
-  .dot { width:9px; height:9px; border-radius:50%; flex:0 0 auto; background:#c9c4ba; box-shadow:0 0 0 3px rgba(0,0,0,.03); }
-  .dot.up { background:#22c55e; box-shadow:0 0 0 3px rgba(34,197,94,.15); }
-  .dot.down { background:#ef4444; box-shadow:0 0 0 3px rgba(239,68,68,.12); }
-  .dot.na { background:#c9c4ba; }
-  .desc { font-size:.82rem; color:var(--muted); line-height:1.45; flex:1; }
-  .meta { display:flex; align-items:center; gap:10px; flex-wrap:wrap; font-size:.7rem; color:var(--muted); }
-  .cat { text-transform:uppercase; letter-spacing:.08em; color:var(--accent); font-weight:600; }
-  .when { display:inline-flex; align-items:center; gap:4px; }
-  .actions { display:flex; gap:8px; margin-top:4px; }
-  .btn { font-size:.78rem; padding:7px 12px; border-radius:8px; text-decoration:none; border:1px solid var(--line); background:#fff; color:var(--ink); cursor:pointer; }
-  .btn.go { background:var(--ink); color:#fff; border-color:var(--ink); }
-  .btn.go.off { opacity:.45; pointer-events:none; }
-  .btn.copy:active { background:#f0ece3; }
-  .btn.startbtn { border-color:#22c55e; color:#15803d; }
-  .btn.stopbtn { border-color:#ef4444; color:#b91c1c; }
-  .btn:disabled { opacity:.5; cursor:wait; }
-  .foot { padding:0 28px 40px; color:var(--muted); font-size:.72rem; }
-  .count { color:var(--muted); font-size:.75rem; margin-left:auto; }
+  :root{--cols:5;--fs:1;--bg:#0c0d12;--panel:#15171f;--card:#12141b;--ink:#eef0f5;--mut:#9aa0b0;--acc:#5eead4;--line:#262a36;
+        --up:#22c55e;--down:#ef4444;--na:#6b7280}
+  *{box-sizing:border-box}
+  body{margin:0;background:var(--bg);color:var(--ink);font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
+  header{position:sticky;top:0;z-index:30;background:#0e0f15;border-bottom:1px solid var(--line);padding:10px 16px;
+         display:flex;gap:12px;align-items:center;flex-wrap:wrap}
+  header h1{font-size:14px;font-weight:300;letter-spacing:.14em;text-transform:uppercase;margin:0}
+  header h1 b{font-weight:600}
+  .sub{color:var(--mut);font-size:11px;margin-left:2px}
+  header label{font-size:11px;color:var(--mut);text-transform:uppercase;letter-spacing:.06em;display:flex;align-items:center;gap:6px}
+  select,input[type=text]{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 9px;font-size:13px}
+  input[type=range]{accent-color:var(--acc)}
+  .btn{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:6px 12px;cursor:pointer;font-size:12px;text-decoration:none}
+  .btn:hover{border-color:var(--acc)}
+  .count{color:var(--mut);font-size:12px;margin-left:auto}
+  #burger{display:none}
+
+  #layout{display:flex;align-items:flex-start}
+  aside{position:sticky;top:52px;width:236px;min-width:236px;max-height:calc(100vh - 52px);overflow:auto;
+        padding:10px 10px 28px;border-right:1px solid var(--line);background:#0e0f15}
+  aside input[type=text]{width:100%;margin-bottom:7px;font-size:12.5px;padding:5px 8px}
+  #fclose{display:none}
+  aside details{border-bottom:1px solid var(--line)}
+  aside summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:6px;padding:8px 4px;
+                font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut)}
+  aside summary::-webkit-details-marker{display:none}
+  aside summary::before{content:'▸';font-size:9px;color:var(--mut);transition:transform .12s}
+  aside details[open] summary::before{transform:rotate(90deg)}
+  aside summary:hover{color:var(--ink)}
+  aside summary .cur{margin-left:auto;color:var(--acc);text-transform:none;letter-spacing:0;font-weight:600;font-size:11px;
+                     max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+  .fbody{padding:2px 0 8px}
+  .frow{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:3px 8px;border-radius:6px;cursor:pointer;color:var(--mut)}
+  .frow:hover{background:var(--panel);color:var(--ink)}
+  .frow.active{background:var(--panel);color:var(--ink);outline:1px solid var(--acc);outline-offset:-1px}
+  .frow .fl{display:inline-flex;align-items:center;gap:7px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+  .frow .n{font-variant-numeric:tabular-nums;font-size:11px;color:var(--mut);flex:none}
+  .frow .dot{width:9px;height:9px;border-radius:50%;flex:none}
+  #clearf{width:100%;margin-top:10px;display:none}
+
+  main{flex:1;min-width:0;padding:12px 16px 60px}
+  .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:8px}
+  .card{background:var(--card);border:1px solid var(--line);border-radius:2px;padding:calc(11px*var(--fs));
+        display:flex;flex-direction:column;gap:calc(7px*var(--fs));position:relative;cursor:pointer}
+  .card.launchable:hover{border-color:var(--acc)}
+  .ct{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}
+  .name{font-weight:600;font-size:calc(13px*var(--fs));line-height:1.25}
+  .dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto;margin-top:3px}
+  .dot.up{background:var(--up);box-shadow:0 0 0 3px rgba(34,197,94,.15)}
+  .dot.down{background:var(--down)}
+  .dot.na{background:var(--na)}
+  .desc{font-size:calc(11.5px*var(--fs));color:var(--mut);line-height:1.45;flex:1}
+  body.imgonly .desc{display:none}
+  .meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:calc(10px*var(--fs));color:var(--mut)}
+  .cat{text-transform:uppercase;letter-spacing:.07em;color:var(--acc);font-weight:600}
+  .badge{border:1px solid var(--line);border-radius:999px;padding:1px 7px}
+  .actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:2px}
+  .act{font-size:calc(11px*var(--fs));font-weight:600;border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:7px;padding:4px 10px;cursor:pointer;text-decoration:none}
+  .act:hover{border-color:var(--acc)}
+  .act.go{background:var(--ink);color:#0c0d12;border-color:var(--ink)}
+  .act.go.off{opacity:.4;pointer-events:none}
+  .act.start{border-color:var(--up);color:#6ee7b7}
+  .act.stop{border-color:var(--down);color:#fca5a5}
+  .act:disabled{opacity:.5;cursor:wait}
+  #empty{display:none;color:var(--mut);padding:40px;text-align:center}
+  .foot{color:var(--mut);font-size:11px;padding:18px 4px 0}
+  #toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%) translateY(20px);max-width:min(560px,92vw);
+         background:#1b1e24;color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:10px 16px;font-size:13px;
+         box-shadow:0 8px 30px #0008;opacity:0;pointer-events:none;transition:.25s;z-index:60}
+  #toast.on{opacity:1;transform:translateX(-50%) translateY(0)}
+  @media(max-width:760px){
+    #burger{display:inline-block}
+    #layout{display:block}
+    aside{display:none;position:fixed;inset:0;top:0!important;z-index:40;width:auto;min-width:0;max-height:none!important;border-right:0;background:var(--bg)}
+    body.filters aside{display:block}
+    #fclose{display:block;position:sticky;top:0;width:100%;background:var(--acc);color:#062a24;border:none;border-radius:8px;padding:11px;font-weight:700;cursor:pointer;margin-bottom:10px}
+  }
 </style>
 </head>
 <body>
 <header>
+  <button id="burger" class="btn">☰ Filters</button>
   <h1>Designer <b>Wallcoverings</b> · Tools</h1>
-  <div class="sub" id="sub">Every DW build tool, one launcher</div>
-</header>
-
-<div class="bar">
-  <input type="search" id="q" placeholder="Search tools…" />
+  <span class="sub" id="sub"></span>
   <label>Sort
     <select id="sort">
       <option value="newest">Newest</option>
       <option value="oldest">Oldest</option>
       <option value="name">Name A→Z</option>
-      <option value="category">Category</option>
       <option value="status">Status (up first)</option>
     </select>
   </label>
-  <label>Density
-    <input type="range" id="density" min="2" max="5" step="1" value="3" />
-  </label>
+  <label>Density<input type="range" id="density" min="3" max="8" step="1" value="5"></label>
+  <label><input type="checkbox" id="imgonly"> Compact</label>
   <span class="count" id="count"></span>
-</div>
-<div class="pills" id="pills"></div>
+</header>
 
-<div class="grid" id="grid"></div>
-<div class="foot" id="foot"></div>
+<div id="layout">
+<aside aria-label="filters">
+  <button id="fclose" type="button">✕ Done — show tools</button>
+  <input type="text" id="q" placeholder="Search tools…">
+  <div id="facetbox"><!-- one collapsed <details> table per field --></div>
+  <button class="btn" id="clearf">✕ Clear filters</button>
+</aside>
+<main>
+  <div class="grid" id="grid"></div>
+  <div id="empty">No tools match.</div>
+  <div class="foot" id="foot"></div>
+</main>
+</div>
+<div id="toast"></div>
 
 <script>
-let TOOLS = [], STATUS = {}, CAT = 'All';
-const $ = (s) => document.querySelector(s);
-const LS = { get:(k,d)=>{try{return JSON.parse(localStorage.getItem('dwtools_'+k))??d}catch{return d}}, set:(k,v)=>localStorage.setItem('dwtools_'+k,JSON.stringify(v)) };
+const $=s=>document.querySelector(s);
+const O=location.origin;
+let TOOLS=[], STATUS={}, TOOLHOST='';
+const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+const LS={get:(k,d)=>{try{return JSON.parse(localStorage.getItem('dwtools_'+k))??d}catch{return d}},set:(k,v)=>localStorage.setItem('dwtools_'+k,JSON.stringify(v))};
 
-function fmtDate(iso){
-  try { const d = new Date(iso+'T12:00:00'); return d.toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}); }
-  catch { return iso; }
+// facet fields — each a collapsed table in the left panel
+const FIELDS=[['category','Category'],['status','Status'],['type','Type']];
+const FIL={category:'',status:'',type:''};
+const typeOf=t=>t.missingSource?'Missing source':t.cli?'CLI':t.static?'Static':t.onDemand?'On-demand':'Web app';
+const statusOf=t=>{const s=STATUS[t.slug];return s==='up'?'Up':s==='down'?'Down':'Idle';};
+const valOf=(t,k)=>k==='status'?statusOf(t):k==='type'?typeOf(t):(t[k]||'—');
+
+function counts(k){
+  const m={};
+  for(const t of TOOLS){const v=valOf(t,k); m[v]=(m[v]||0)+1;}
+  return Object.entries(m).sort((a,b)=>b[1]-a[1]);
+}
+function fmtDate(iso){try{return new Date(iso+'T12:00:00').toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'})}catch{return iso}}
+const rank=s=>s==='up'?0:s==='down'?1:2;
+const SDOT={Up:'var(--up)',Down:'var(--down)',Idle:'var(--na)'};
+
+function renderSide(){
+  const openNow=new Set([...document.querySelectorAll('#facetbox details[open]')].map(d=>d.dataset.f));
+  $('#facetbox').innerHTML=FIELDS.map(([k,label])=>{
+    const rows=counts(k).map(([v,n])=>{
+      const dot=k==='status'?`<span class="dot" style="background:${SDOT[v]||'var(--na)'}"></span>`:'';
+      return `<div class="frow${FIL[k]===String(v)?' active':''}" data-k="${k}" data-v="${esc(v)}">
+        <span class="fl">${dot}${esc(v)}</span><span class="n">${n}</span></div>`;
+    }).join('');
+    const cur=FIL[k]?`<span class="cur" title="${esc(FIL[k])}">${esc(FIL[k])}</span>`:'';
+    return `<details data-f="${k}"${openNow.has(k)?' open':''}><summary>${label}${cur}</summary><div class="fbody">${rows}</div></details>`;
+  }).join('');
+  $('#clearf').style.display=(Object.values(FIL).some(Boolean)||$('#q').value)?'block':'none';
 }
-const rank = (s)=> s==='up'?0 : s==='down'?1 : 2;
 
 function render(){
-  const q = $('#q').value.trim().toLowerCase();
-  const sort = $('#sort').value;
-  let list = TOOLS.filter(t => (CAT==='All'||t.category===CAT) &&
-    (!q || (t.name+' '+t.desc+' '+t.category).toLowerCase().includes(q)));
+  const q=$('#q').value.trim().toLowerCase();
+  const sort=$('#sort').value;
+  let list=TOOLS.filter(t=>
+    (!FIL.category||valOf(t,'category')===FIL.category)&&
+    (!FIL.status||valOf(t,'status')===FIL.status)&&
+    (!FIL.type||valOf(t,'type')===FIL.type)&&
+    (!q||(t.name+' '+t.desc+' '+t.category).toLowerCase().includes(q)));
   list.sort((a,b)=>{
-    if(sort==='newest') return (b.created||'').localeCompare(a.created||'');
-    if(sort==='oldest') return (a.created||'').localeCompare(b.created||'');
-    if(sort==='name') return a.name.localeCompare(b.name);
-    if(sort==='category') return a.category.localeCompare(b.category)||a.name.localeCompare(b.name);
-    if(sort==='status') return rank(STATUS[a.slug])-rank(STATUS[b.slug])||a.name.localeCompare(b.name);
-    return 0;
-  });
-  $('#count').textContent = list.length+' of '+TOOLS.length+' tools';
-  $('#grid').innerHTML = list.map(t=>{
-    const st = STATUS[t.slug]||'na';
-    const cls = st==='up'?'up':st==='down'?'down':'na';
-    const host = (window.__TOOLHOST__ || location.hostname);
-    const url = t.port ? ('http://'+host+':'+t.port+'/') : null;
-    const go = url ? `<a class="btn go ${st==='up'?'':'off'}" href="${url}" target="_blank" rel="noopener noreferrer">Launch ↗</a>`
-                   : `<span class="btn go off">Local app</span>`;
-    return `<div class="card">
-      <div class="ct"><span class="name">${t.name}</span><span class="dot ${cls}" title="${st}"></span></div>
-      <div class="desc">${t.desc}</div>
-      <div class="meta">
-        <span class="cat">${t.category}</span>
-        <span class="when" title="${t.created}">🕓 ${fmtDate(t.created)}</span>
-        ${t.port?`<span>:${t.port}</span>`:''}
-        ${t.onDemand?'<span title="kept stopped; start on demand">💤 on-demand</span>':''}
-        ${t.cli?'<span title="command-line tool — no web UI">⌨️ CLI</span>':''}
-        ${t.static?'<span title="static files — serve to view">📄 static</span>':''}
-        ${t.missingSource?'<span title="source never existed / empty dir">⚠️ missing source</span>':''}
-      </div>
-      <div class="actions">
-        ${go}
-        ${(!t.cli && !t.missingSource && t.port) ? (st==='up'
-          ? `<button class="btn stopbtn" data-slug="${t.slug}">Stop ■</button>`
-          : `<button class="btn startbtn" data-slug="${t.slug}">Start ▶</button>`) : ''}
-        <button class="btn copy" data-cmd="${(t.start||'').replace(/"/g,'&quot;')}">Copy start ⌘</button>
-      </div>
+    if(sort==='newest')return(b.created||'').localeCompare(a.created||'');
+    if(sort==='oldest')return(a.created||'').localeCompare(b.created||'');
+    if(sort==='name')return a.name.localeCompare(b.name);
+    if(sort==='status')return rank(STATUS[a.slug])-rank(STATUS[b.slug])||a.name.localeCompare(b.name);
+    return 0;});
+  $('#count').textContent=list.length+' / '+TOOLS.length;
+  $('#empty').style.display=list.length?'none':'block';
+  $('#grid').innerHTML=list.map(t=>{
+    const st=STATUS[t.slug]||'na', cls=st==='up'?'up':st==='down'?'down':'na';
+    const host=TOOLHOST||location.hostname;
+    const url=t.port?`http://${host}:${t.port}/`:null;
+    const launchable=st==='up'&&url;
+    const go=url?`<a class="act go ${st==='up'?'':'off'}" href="${url}" target="_blank" rel="noopener noreferrer">Launch ↗</a>`:'';
+    const sb=(!t.cli&&!t.missingSource&&t.port)?(st==='up'
+      ?`<button class="act stop" data-verb="stop" data-slug="${t.slug}">Stop ■</button>`
+      :`<button class="act start" data-verb="start" data-slug="${t.slug}">Start ▶</button>`):'';
+    const badges=[t.onDemand?'💤 on-demand':'',t.cli?'⌨️ CLI':'',t.static?'📄 static':'',t.missingSource?'⚠️ missing source':'']
+      .filter(Boolean).map(b=>`<span class="badge">${b}</span>`).join('');
+    return `<div class="card${launchable?' launchable':''}" ${launchable?`data-href="${url}"`:''}>
+      <div class="ct"><span class="name">${esc(t.name)}</span><span class="dot ${cls}" title="${st}"></span></div>
+      <div class="desc">${esc(t.desc)}</div>
+      <div class="meta"><span class="cat">${esc(t.category)}</span><span title="${t.created}">🕓 ${fmtDate(t.created)}</span>${t.port?`<span>:${t.port}</span>`:''}${badges}</div>
+      <div class="actions">${go}${sb}<button class="act copy" data-cmd="${esc(t.start||'')}">Copy start ⌘</button></div>
     </div>`;
   }).join('');
-  document.querySelectorAll('.btn.copy').forEach(b=>b.onclick=()=>{
-    navigator.clipboard.writeText(b.dataset.cmd); const o=b.textContent; b.textContent='Copied ✓'; setTimeout(()=>b.textContent=o,1200);
-  });
-  document.querySelectorAll('.btn.startbtn,.btn.stopbtn').forEach(b=>b.onclick=async()=>{
-    const verb = b.classList.contains('startbtn')?'start':'stop';
-    b.disabled = true; b.textContent = verb==='start'?'Starting…':'Stopping…';
-    try{
-      const r = await (await fetch(`/api/${verb}/${b.dataset.slug}`,{method:'POST'})).json();
-      if(!r.ok){ alert(`${verb} failed: ${r.error||'unknown'}`); }
-    }catch(e){ alert(`${verb} failed: ${e.message}`); }
-    // poll status a few times while the tool boots (Next dev servers take ~10-20s)
-    for(let i=0;i<8;i++){ await new Promise(r=>setTimeout(r,3000)); await loadStatus();
-      if((STATUS[b.dataset.slug]==='up')===(verb==='start')) break; }
-  });
 }
 
-function renderPills(cats){
-  $('#pills').innerHTML = ['All',...cats].map(c=>`<span class="pill ${c===CAT?'on':''}" data-c="${c}">${c}</span>`).join('');
-  document.querySelectorAll('.pill').forEach(p=>p.onclick=()=>{CAT=p.dataset.c; renderPills(cats); render();});
-}
+// ONE delegated click on the grid (never wrap cards in <a> — the checkerboard bug)
+$('#grid').addEventListener('click',async e=>{
+  const btn=e.target.closest('button,a'); const card=e.target.closest('.card');
+  if(btn){
+    if(btn.classList.contains('copy')){navigator.clipboard.writeText(btn.dataset.cmd);toast('Start command copied');return;}
+    if(btn.dataset.verb){await toolAction(btn);return;}
+    return; // Launch anchor handles itself
+  }
+  if(card&&card.dataset.href) window.open(card.dataset.href,'_blank','noopener');
+});
 
-async function loadStatus(){
-  try{ STATUS = await (await fetch('/api/status')).json(); render(); }catch{}
+async function toolAction(btn){
+  const {verb,slug}=btn.dataset;
+  btn.disabled=true; const o=btn.textContent; btn.textContent=verb==='start'?'Starting…':'Stopping…';
+  try{const r=await(await fetch(`${O}/api/${verb}/${slug}`,{method:'POST'})).json();
+    if(!r.ok)toast(`${verb} failed: ${r.error||'unknown'}`);}
+  catch(e){toast(`${verb} failed: ${e.message}`);}
+  for(let i=0;i<8;i++){await new Promise(r=>setTimeout(r,3000));await loadStatus();
+    if((STATUS[slug]==='up')===(verb==='start'))break;}
+  btn.textContent=o;
 }
 
+function toast(m){const el=$('#toast');el.textContent=m;el.classList.add('on');clearTimeout(el._t);el._t=setTimeout(()=>el.classList.remove('on'),2600);}
+
+// facet click (delegated)
+$('#facetbox').addEventListener('click',e=>{const row=e.target.closest('.frow');if(!row)return;
+  const{k,v}=row.dataset; FIL[k]=FIL[k]===v?'':v; renderSide(); render();});
+$('#clearf').onclick=()=>{FIELDS.forEach(([k])=>FIL[k]='');$('#q').value='';renderSide();render();};
+
+async function loadStatus(){try{STATUS=await(await fetch(O+'/api/status')).json();renderSide();render();}catch{}}
+
 async function init(){
-  const m = await (await fetch('/api/tools')).json();
-  window.__TOOLHOST__ = m.hub.toolHost || '';
-  TOOLS = m.tools; $('#sub').textContent = m.hub.subtitle;
-  renderPills(m.categories);
-  // restore prefs
-  $('#sort').value = LS.get('sort','newest');
-  const dens = LS.get('density',3); $('#density').value = dens; document.documentElement.style.setProperty('--cols',dens);
-  $('#q').oninput = render;
-  $('#sort').onchange = ()=>{LS.set('sort',$('#sort').value); render();};
-  $('#density').oninput = ()=>{const v=$('#density').value; document.documentElement.style.setProperty('--cols',v); LS.set('density',v);};
-  render(); loadStatus(); setInterval(loadStatus, 20000);
-  const up = Object.values(STATUS).filter(s=>s==='up').length;
-  $('#foot').textContent = 'Live status refreshes every 20s. Tools run as separate apps — a red dot means that app isn’t running; use “Copy start” to launch it.';
+  const m=await(await fetch(O+'/api/tools')).json();
+  TOOLS=m.tools; TOOLHOST=m.hub.toolHost||''; $('#sub').textContent=m.hub.subtitle||'';
+  // prefs
+  $('#sort').value=LS.get('sort','newest');
+  const dens=LS.get('density',5); $('#density').value=dens; document.documentElement.style.setProperty('--cols',dens); setFs(dens);
+  if(LS.get('imgonly',false)){$('#imgonly').checked=true;document.body.classList.add('imgonly');}
+  $('#q').oninput=()=>{renderSide();render();};
+  $('#sort').onchange=()=>{LS.set('sort',$('#sort').value);render();};
+  $('#density').oninput=()=>{const v=+$('#density').value;document.documentElement.style.setProperty('--cols',v);setFs(v);LS.set('density',v);};
+  $('#imgonly').onchange=()=>{document.body.classList.toggle('imgonly',$('#imgonly').checked);LS.set('imgonly',$('#imgonly').checked);};
+  $('#burger').onclick=()=>document.body.classList.add('filters');
+  $('#fclose').onclick=()=>document.body.classList.remove('filters');
+  renderSide(); render(); await loadStatus(); setInterval(loadStatus,20000);
+  $('#foot').textContent='Live status refreshes every 20s. Green = running (marker-verified over tailnet) — click a card or Launch to open it. Red = stopped; use Start.';
 }
+// density → type scale (fewer/larger cards → bigger type)
+function setFs(cols){const fs=Math.max(.7,Math.min(1.25,cols<=5?1+(5-cols)*.06:1-(cols-5)*.05));document.documentElement.style.setProperty('--fs',fs.toFixed(3));}
 init();
 </script>
 </body>

← 080da47 start-fleet: inject DW_BASIC_AUTH so room-setting-app passwo  ·  back to Tools Dw Hub  ·  hub: Start/Stop buttons local-only (public site is launch-on 366d33f →