← 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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,'"')}">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 →