diff options
Diffstat (limited to 'docs/prototypes')
19 files changed, 9781 insertions, 0 deletions
diff --git a/docs/prototypes/2026-07-02-timer-panel-prototype-1.html b/docs/prototypes/2026-07-02-timer-panel-prototype-1.html new file mode 100644 index 0000000..6b199f9 --- /dev/null +++ b/docs/prototypes/2026-07-02-timer-panel-prototype-1.html @@ -0,0 +1,693 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Timer panel — three redesigns · dupre instrument console</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono","Symbols Nerd Font",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 6rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.wrap{max-width:1320px;margin:0 auto} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} +.masthead p{color:var(--dim);font-size:.86rem;max-width:92ch} +.masthead p b{color:var(--silver)} +h2{color:var(--steel);font-size:.8rem;letter-spacing:.22em;text-transform:uppercase; + margin:2.6rem 0 .2rem;display:flex;align-items:center;gap:12px} +h2 .tag{color:var(--panel);background:var(--gold);border-radius:4px;font-size:.62rem;padding:1px 7px;letter-spacing:.12em} +h2::after{content:"";height:1px;background:var(--wash);flex:1} +.blurb{color:var(--dim);font-size:.82rem;max-width:90ch;margin:.5rem 0 1.1rem} +.blurb b{color:var(--steel);font-weight:400} +.desk{display:flex;justify-content:center;padding:1.2rem 0 .4rem} + +/* ---------- faceplate ---------- */ +.panel{width:396px;background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320; + border-radius:14px;padding:15px 15px 16px;position:relative; + box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 14px 34px rgba(0,0,0,.55)} +.panel.wide{width:660px} +.phead{display:flex;align-items:center;gap:10px;margin-bottom:12px} +.phead .brand{color:var(--gold);font-size:.72rem;letter-spacing:.24em;text-transform:uppercase} +.phead .pcount{margin-left:auto;color:var(--dim);font-size:.66rem;letter-spacing:.14em} +.phead .pcount b{color:var(--cream)} + +/* ---------- shared primitives (from the widget gallery) ---------- */ +.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55);flex:none} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} +.lamp.off{background:var(--wash);box-shadow:none} +.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite} +@keyframes pulse{50%{opacity:.25}} + +.key{font:inherit;font-size:11.5px;letter-spacing:.05em;color:var(--silver);cursor:pointer; + background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a; + border-radius:8px;padding:7px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} +.key:hover{color:var(--gold);border-color:var(--gold)} +.key:active{transform:translateY(1px)} +.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} +.key.sm{padding:5px 8px;font-size:10.5px;border-radius:7px} +.key.icon{padding:6px 9px;font-size:14px;line-height:1} +.key.wide{width:100%;text-align:center;padding:9px} + +.chip{color:var(--dim);cursor:pointer;border:1px solid #2a2723;background:#141210; + border-radius:14px;font-size:11.5px;padding:4px 10px;letter-spacing:.02em} +.chip:hover{color:var(--silver);border-color:var(--slate)} +.chip.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.chip .x{color:inherit;opacity:.5;margin-left:5px} +.chip .x:hover{opacity:1;color:var(--fail)} + +.badge{font-size:.6rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px;text-transform:uppercase} +.badge.red{background:var(--fail);color:var(--cream)} +.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)} +.badge.dim{background:var(--wash);color:var(--steel)} + +.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden} +.seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b; + padding:7px 0;cursor:pointer;flex:1;letter-spacing:.02em} +.seg button:last-child{border-right:0} +.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700} +.seg.vert{flex-direction:column} +.seg.vert button{border-right:0;border-bottom:1px solid #33302b;padding:8px 10px} +.seg.vert button:last-child{border-bottom:0} + +.engrave{color:var(--steel);font-size:.6rem;letter-spacing:.28em;text-transform:uppercase; + display:flex;align-items:center;gap:9px;margin:2px 0} +.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.engrave::before{max-width:10px} +.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none} + +.readout{color:var(--cream);font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.03em} +.tin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:7px; + padding:7px 9px;width:100%;outline:none} +.tin:focus{border-color:var(--gold)} +.tin::placeholder{color:var(--dim)} +.tin.bad{border-color:var(--fail);color:var(--fail)} + +.arm{font:inherit;font-size:11px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b; + border-radius:7px;padding:6px 9px} +.arm.armed{background:rgba(203,107,77,.14);border-color:var(--fail);color:var(--fail)} + +/* radial ring */ +.ring{border-radius:50%;background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0); + display:grid;place-items:center;position:relative} +.ring.warn{background:conic-gradient(var(--fail) calc(var(--p)*1%),var(--wash) 0)} +.ring::before{content:"";position:absolute;inset:7px;border-radius:50%;background:var(--well)} +.ring b{position:relative;z-index:1;text-align:center} + +/* linear bar */ +.bar{height:8px;background:#0d0f10;border:1px solid #231f18;border-radius:5px;overflow:hidden;position:relative} +.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold));transition:width .25s linear} +.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))} + +/* create strip common */ +.create{margin-top:13px;background:var(--well);border:1px solid #201d17;border-radius:10px;padding:11px} +.create .row{display:flex;gap:7px;align-items:center;margin-top:8px;flex-wrap:wrap} +.chips{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px} + +/* toast */ +.toasts{position:absolute;left:12px;right:12px;bottom:10px;display:flex;flex-direction:column;gap:6px;pointer-events:none;z-index:5} +.toast{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 10px; + box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .2s ease} +.toast.red{background:linear-gradient(180deg,#b25c43,#8f3f2c)} +.toast.gold{background:linear-gradient(180deg,#b79a34,#8a7524);color:var(--panel)} +@keyframes tin{from{opacity:0;transform:translateY(6px)}} + +/* empty state */ +.empty{color:var(--dim);font-size:12px;text-align:center;padding:18px 6px 12px} + +/* =============== A · RACK UNIT =============== */ +.qlist{display:flex;flex-direction:column;gap:8px} +.qrow{display:flex;align-items:center;gap:10px;background:#141210;border:1px solid #201d17;border-radius:9px;padding:8px 10px} +.qrow.prim{border-color:var(--gold);box-shadow:inset 0 0 0 1px rgba(218,181,61,.25)} +.qrow.fire{animation:firef .6s ease-in-out 3} +@keyframes firef{50%{background:rgba(203,107,77,.22)}} +.qrow .g{color:var(--gold);font-size:16px;width:19px;text-align:center;flex:none} +.qrow .meta{min-width:0;display:flex;flex-direction:column;gap:1px} +.qrow .meta b{color:var(--cream);font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px} +.qrow .meta .ty{color:var(--dim);font-size:.56rem;letter-spacing:.16em;text-transform:uppercase} +.qrow .rd{margin-left:auto;font-size:19px;color:var(--cream);font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap} +.qrow.paused .rd{color:var(--steel)} +.qrow .pomo{font-size:.56rem;color:var(--steel);letter-spacing:.1em;text-transform:uppercase} +.qrow .ctrls{display:flex;gap:5px;flex:none} + +/* =============== B · TRANSPORT DECK =============== */ +.hero{background:var(--well);border:1px solid #201d17;border-radius:11px;padding:15px;display:flex;gap:15px;align-items:center} +.hero.fire{animation:firef .6s ease-in-out 3} +.hero .lhs{flex:none} +.hero .rhs{min-width:0;flex:1;display:flex;flex-direction:column;gap:6px} +.hero .htype{display:flex;align-items:center;gap:8px} +.hero .htype .g{color:var(--gold);font-size:17px} +.hero .hlabel{color:var(--cream);font-size:15px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.hero .hbig{color:var(--cream);font-size:40px;line-height:1;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.02em} +.hero.paused .hbig{color:var(--steel)} +.hero .hsub{color:var(--dim);font-size:11px;letter-spacing:.06em} +.transport{display:flex;gap:7px;margin-top:2px} +.tracks{margin-top:11px;display:flex;flex-direction:column;gap:5px} +.track{display:flex;align-items:center;gap:9px;padding:6px 9px;border-radius:7px;background:#141210;border:1px solid #1c1a16;cursor:pointer;font-size:12px} +.track:hover{background:var(--wash)} +.track.prim{outline:1px solid var(--gold);outline-offset:-1px} +.track .g{color:var(--gold);font-size:14px;width:16px;text-align:center} +.track b{color:var(--cream);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.track .trd{margin-left:auto;color:var(--silver);font-variant-numeric:tabular-nums;font-weight:700} +.track.paused .trd{color:var(--dim)} +.track .tx{color:var(--dim);font-size:14px;padding:0 2px} +.track .tx:hover{color:var(--fail)} + +/* =============== C · CHANNEL STRIP BOARD =============== */ +.board{display:flex;gap:9px;overflow-x:auto;padding:4px 2px 10px} +.strip{flex:none;width:96px;background:#141210;border:1px solid #201d17;border-radius:10px;padding:9px 8px; + display:flex;flex-direction:column;align-items:center;gap:8px} +.strip.prim{border-color:var(--gold);box-shadow:inset 0 0 0 1px rgba(218,181,61,.25)} +.strip.fire{animation:firef .6s ease-in-out 3} +.strip .stitle{width:100%;display:flex;align-items:center;gap:5px;cursor:pointer} +.strip .stitle .g{color:var(--gold);font-size:13px} +.strip .stitle b{color:var(--cream);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.strip .styp{color:var(--dim);font-size:.5rem;letter-spacing:.14em;text-transform:uppercase;width:100%;text-align:left} +.column{width:26px;height:120px;position:relative;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden} +.column .fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,#8a7524,var(--gold));transition:height .25s linear} +.column.warn .fill{background:linear-gradient(0deg,#a35a3f,var(--fail))} +.column .cap{position:absolute;left:-2px;right:-2px;height:3px;background:var(--gold-hi);box-shadow:0 0 5px rgba(255,215,95,.6);transition:bottom .25s linear} +.column.sw .fill{background:linear-gradient(0deg,#3a4a5e,var(--slate-hi));animation:swpulse 1.6s ease-in-out infinite} +@keyframes swpulse{50%{opacity:.6}} +.strip .srd{color:var(--cream);font-size:14px;font-weight:700;font-variant-numeric:tabular-nums} +.strip.paused .srd{color:var(--steel)} +.strip .skeys{display:flex;gap:4px} +.strip.addstrip{justify-content:flex-start;width:150px;background:var(--well)} + +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +</style> +</head> +<body> +<div class="wrap"> +<header class="masthead"> + <div class="eyebrow">archsetup · dupre panel family · timer</div> + <h1>Timer panel — three redesigns</h1> + <p>Three ways to shape the timer dropdown, all in the shipped instrument-console faceplate language + (same tokens, lamps, console keys, engraved labels, and tabular readouts as the net / bt / sound panels). + Each is a <b>working prototype over one shared engine</b> that mirrors wtimer + the PanelModel: add / cancel / pause / + resume, promote to the bar slot, per-type presets (add and delete chips), freeform entry with the same validation, + stopwatch lap + stop-and-save, the soonest-fire queue sort, the 10-item cap, and a real completion + notify on fire. + Try each: add a timer, watch one count down and fire, promote a row, pause a stopwatch, delete a preset chip.</p> +</header> + +<h2><span class="tag">A</span> Rack unit — the faithful list</h2> +<p class="blurb">The closest sibling to the net / audio panels: a vertical stack you scan top-down. Header with the live + count and <b>CLEAR ALL</b>; one output-well row per item, soonest-firing on top; each row carries a lamp, glyph, label, + the big countdown, and inline pause / promote / cancel keys. Create strip lives at the bottom — pick a type, tap a preset + or type a duration, name it, ADD. Safest port of what already shipped.</p> +<div class="desk"><div class="panel" id="panelA"></div></div> + +<h2><span class="tag">B</span> Transport deck — one hero, a track list</h2> +<p class="blurb">A cassette-transport shape. The <b>primary</b> item (the one in the bar glyph slot) gets a hero readout with a + progress ring and chunky transport keys; everything else is a compact track list underneath. Click a track to promote it into + the hero seat; the ‹ › keys cycle the primary. Puts the timer you care about front-and-centre, the rest one glance away.</p> +<div class="desk"><div class="panel" id="panelB"></div></div> + +<h2><span class="tag">C</span> Channel-strip board — a mixing desk of timers</h2> +<p class="blurb">The mixing-console metaphor: every item is a vertical channel strip on a board, its fader draining from the top + as time runs out (a stopwatch fills instead, tinted slate). Read all your timers at once like meters on a desk. Click a strip + header to promote it; the trailing <b>+ NEW</b> strip is the create surface. The most spatial, most stereo of the three.</p> +<div class="desk"><div class="panel wide" id="panelC"></div></div> + +</div> + +<script> +"use strict"; +const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; +const el = (tag, cls, html) => { const n=document.createElement(tag); if(cls)n.className=cls; if(html!=null)n.innerHTML=html; return n; }; + +/* nerd-font glyphs (mirrors timer/viewmodel.py GLYPH) */ +const GL = { + timer:'\u{F051B}', alarm:'\u{F0020}', stopwatch:'\u{F13AB}', + pomo_work:'\u{F051C}', pomo_break:'\u{F0176}', paused:'\u{F03E4}', + play:'\u{F040A}', promote:'\u{F0143}', cancel:'\u{F0156}', add:'\u{F0415}', clear:'\u{F0A79}' +}; + +/* ---------- parsers (mirror parse.py behaviour) ---------- */ +function parseDuration(v){ + if(v==null) return null; + v=String(v).trim().toLowerCase(); + if(v==='') return null; + if(/^\d+$/.test(v)) return parseInt(v,10)*60; // bare number = minutes + if(!/^(\s*\d+\s*[hms])+$/.test(v)) return null; // only h/m/s tokens + let m, tot=0; const re=/(\d+)\s*([hms])/g; + while((m=re.exec(v))) tot += m[2]==='h'?+m[1]*3600 : m[2]==='m'?+m[1]*60 : +m[1]; + return tot>0?tot:null; +} +function resolveAlarm(v, now){ + v=String(v||'').trim().toLowerCase(); + if(v.startsWith('+')){ const s=parseDuration(v.slice(1)); return s==null?null:now+s; } + if(v==='@hour'||v==='top of hour'){ const d=new Date(now*1000); d.setMinutes(0,0,0); d.setHours(d.getHours()+1); return d.getTime()/1000; } + const t=v.match(/^(\d{1,2}):(\d{2})$/); + if(t){ const hh=+t[1], mm=+t[2]; if(hh>23||mm>59) return null; + const d=new Date(now*1000); d.setHours(hh,mm,0,0); let e=d.getTime()/1000; if(e<=now) e+=86400; return e; } + return null; +} +function fmtTime(secs){ + secs=Math.max(0,Math.floor(secs)); + const h=Math.floor(secs/3600), m=Math.floor((secs%3600)/60), s=secs%60; + return h ? `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}` : `${m}:${String(s).padStart(2,'0')}`; +} +function fmtClock(epoch){ const d=new Date(epoch*1000); return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; } + +/* ---------- default presets (mirror panel._default_presets) ---------- */ +const DEFAULT_PRESETS = () => ({ + timer:[{label:'5m',value:'5m'},{label:'25m',value:'25m'},{label:'10m',value:'10m'}, + {label:'15m',value:'15m'},{label:'30m',value:'30m'},{label:'60m',value:'60m'},{label:'2h',value:'2h'}], + alarm:[{label:'+30m',value:'+30m'},{label:'top of hour',value:'@hour'},{label:'07:00',value:'07:00'}], + pomodoro:[{label:'default cycle',value:''}], + stopwatch:[] +}); +const TYPES=['timer','alarm','stopwatch','pomodoro']; +const COUNTDOWN=['timer','alarm','pomodoro']; +const MAX=10; +const POMO={work:25*60, short:5*60, long:15*60, interval:4}; + +/* ---------- the engine (mirrors PanelModel + wtimer state) ---------- */ +class Engine{ + constructor(){ this.items=[]; this.seq=0; this.primary=null; this.presets=DEFAULT_PRESETS(); this.onEvent=()=>{}; } + now(){ return Date.now()/1000; } + count(){ return this.items.length; } + full(){ return this.items.length>=MAX; } + + add(type,value,label){ + if(this.full()) return {ok:false,reason:`queue full (${MAX}/${MAX})`}; + const now=this.now(); this.seq++; const id='t'+this.seq; + const it={id,type,label:label||''}; + if(type==='timer'){ const s=parseDuration(value); if(s==null) return {ok:false,reason:`bad duration: “${value}”`}; it.target=now+s; it.total=s; } + else if(type==='alarm'){ const e=resolveAlarm(value,now); if(e==null) return {ok:false,reason:`bad time: “${value}”`}; it.target=e; it.total=Math.max(1,e-now); } + else if(type==='pomodoro'){ it.phase='work'; it.cycle=1; it.interval=POMO.interval; it.target=now+POMO.work; it.total=POMO.work; } + else if(type==='stopwatch'){ it.start=now; it.laps=[]; } + else return {ok:false,reason:`unknown type: ${type}`}; + this.items.push(it); if(!this.primary) this.primary=id; + return {ok:true, id}; + } + find(id){ return this.items.find(i=>i.id===id); } + isPaused(it){ return it.type==='stopwatch' ? it.paused_elapsed!=null : it.paused_left!=null; } + remaining(it,ref){ + ref=ref==null?this.now():ref; + if(it.type==='stopwatch') return this.isPaused(it)? it.paused_elapsed : ref-it.start; + return this.isPaused(it)? it.paused_left : it.target-ref; + } + toggle(id){ const it=this.find(id); if(!it) return; const now=this.now(); + if(it.type==='stopwatch'){ + if(this.isPaused(it)){ it.start=now-it.paused_elapsed; it.paused_elapsed=null; } + else it.paused_elapsed=now-it.start; + } else { + if(this.isPaused(it)){ it.target=now+it.paused_left; it.paused_left=null; } + else it.paused_left=it.target-now; + } + } + cancel(id){ const i=this.items.findIndex(x=>x.id===id); if(i<0) return; this.items.splice(i,1); + if(this.primary===id) this.primary=null; } + cancelAll(){ this.items=[]; this.primary=null; } + promote(id){ if(this.find(id)) this.primary=id; } + cycle(dir){ const ids=this.items.map(i=>i.id); if(!ids.length) return; + let cur=ids.indexOf(this.effectivePrimary()); cur=cur<0?0:cur; + const n=dir==='prev' ? (cur-1+ids.length)%ids.length : (cur+1)%ids.length; + this.primary=ids[n]; } + lap(id,name){ const it=this.find(id); if(!it||it.type!=='stopwatch') return; + it.laps.push({t:this.remaining(it), name:name||''}); } + stopSave(id){ const it=this.find(id); if(!it||it.type!=='stopwatch') return null; + const run={label:it.label||'run', total:this.remaining(it), laps:it.laps.slice()}; this.cancel(id); return run; } + + effectivePrimary(){ + const items=this.items; if(!items.length) return null; + const ids=items.map(i=>i.id); + if(ids.includes(this.primary)) return this.primary; + const now=this.now(); + const acd=items.filter(i=>COUNTDOWN.includes(i.type)&&!this.isPaused(i)); + if(acd.length) return acd.reduce((a,b)=>this.remaining(a,now)<=this.remaining(b,now)?a:b).id; + const asw=items.filter(i=>i.type==='stopwatch'&&!this.isPaused(i)); + if(asw.length) return asw[0].id; + return ids[0]; + } + /* 4-bucket sort: active countdown < paused countdown < active sw < paused sw */ + sortKey(it){ const now=this.now(), paused=this.isPaused(it), sw=it.type==='stopwatch', rem=this.remaining(it,now); + return sw ? [paused?3:2, -rem, +it.id.slice(1)] : [paused?1:0, rem, +it.id.slice(1)]; } + rows(){ const prim=this.effectivePrimary(); const now=this.now(); + return this.items.slice().sort((a,b)=>{ const ka=this.sortKey(a),kb=this.sortKey(b); + for(let i=0;i<ka.length;i++){ if(ka[i]<kb[i])return -1; if(ka[i]>kb[i])return 1; } return 0; }) + .map(it=>this.row(it,prim,now)); } + row(it,prim,now){ + const rem=this.remaining(it,now), paused=this.isPaused(it); + let disp, sub='', warn=false, prog=null, glyph; + if(it.type==='alarm'){ disp=fmtClock(it.target); sub='at '+fmtClock(it.target); prog=Math.max(0,Math.min(1,rem/it.total)); glyph=GL.alarm; } + else if(it.type==='pomodoro'){ disp=fmtTime(rem); sub=`${it.phase} · cycle ${it.cycle}/${it.interval}`; prog=Math.max(0,Math.min(1,rem/it.total)); + glyph=(it.phase==='work')?GL.pomo_work:GL.pomo_break; } + else if(it.type==='stopwatch'){ disp=fmtTime(rem); sub=it.laps.length?`${it.laps.length} lap${it.laps.length>1?'s':''}`:'running'; glyph=GL.stopwatch; } + else { disp=fmtTime(rem); sub='timer'; prog=Math.max(0,Math.min(1,rem/it.total)); glyph=GL.timer; } + if(prog!=null && rem<=Math.min(30, it.total*0.15)) warn=true; + if(paused) glyph=GL.paused; + return {id:it.id, type:it.type, glyph, label:it.label||({timer:'Timer',alarm:'Alarm',stopwatch:'Stopwatch',pomodoro:'Pomodoro'})[it.type], + typeLabel:it.type, disp, sub, paused, primary:it.id===prim, prog, warn, laps:it.laps?it.laps.length:0}; + } + + /* advance fired items; returns list of fire events for the view to flash/notify */ + tick(){ + const now=this.now(); const fired=[]; + for(const it of this.items.slice()){ + if(!COUNTDOWN.includes(it.type) || this.isPaused(it)) continue; + if(it.target-now>0) continue; + if(it.type==='pomodoro'){ + if(it.phase==='work'){ + const isLong = it.cycle % it.interval === 0; + fired.push({id:it.id, kind:'pomo', title:`Pomodoro · ${isLong?'long':'short'} break`, body:it.label||`cycle ${it.cycle}`}); + it.phase = isLong?'long':'short'; const len = isLong?POMO.long:POMO.short; it.target=now+len; it.total=len; + } else if(it.phase==='long'){ + fired.push({id:it.id, kind:'done', title:'Pomodoro complete', body:it.label||`${it.interval} cycles done`}); + this.cancel(it.id); + } else { // short break over → next work + it.cycle+=1; fired.push({id:it.id, kind:'pomo', title:'Pomodoro · back to work', body:it.label||`cycle ${it.cycle}`}); + it.phase='work'; it.target=now+POMO.work; it.total=POMO.work; + } + } else { + fired.push({id:it.id, kind:'done', title:(it.type==='alarm'?'Alarm':'Timer')+' · '+(it.label|| (it.type==='alarm'?fmtClock(it.target):'done')), body:'time’s up'}); + this.cancel(it.id); + } + } + return fired; + } + /* presets */ + presetsFor(t){ return (this.presets[t]||[]).map(p=>({...p})); } + addPreset(t,label,value){ if(!TYPES.includes(t)) return {ok:false,reason:'bad type'}; + if(t==='timer' && parseDuration(value)==null) return {ok:false,reason:'bad duration'}; + (this.presets[t]||(this.presets[t]=[])).push({label,value}); return {ok:true}; } + deletePreset(t,label){ const a=this.presets[t]||[]; const i=a.findIndex(p=>p.label===label); if(i<0) return {ok:false}; a.splice(i,1); return {ok:true}; } +} + +/* ---------- browser notification (best-effort, mirrors the notify path) ---------- */ +let notifPerm = (typeof Notification!=='undefined') ? Notification.permission : 'denied'; +function tryNotify(title, body){ + if(typeof Notification==='undefined') return; + if(notifPerm==='granted'){ try{ new Notification(title,{body}); }catch(e){} } + else if(notifPerm==='default'){ Notification.requestPermission().then(p=>notifPerm=p); } +} + +/* ---------- toast helper ---------- */ +function toaster(host){ + const wrap=el('div','toasts'); host.appendChild(wrap); + return (msg,kind)=>{ const t=el('div','toast'+(kind?' '+kind:''),msg); wrap.appendChild(t); + setTimeout(()=>{ t.style.transition='opacity .3s'; t.style.opacity='0'; setTimeout(()=>t.remove(),300); }, 2600); }; +} + +/* ---------- create-strip controller (shared by all three views) ---------- */ +function makeCreate(engine, toast, rerender, opts){ + opts=opts||{}; + const box=el('div','create'); + const seg=el('div','seg'); + TYPES.forEach(t=>{ const b=el('button',t==='timer'?'on':'', t[0].toUpperCase()+t.slice(1)); b.dataset.t=t; seg.appendChild(b); }); + const chips=el('div','chips'); + const row=el('div','row'); + const val=el('input','tin'); val.placeholder='5m · 1h30m · 90s'; val.style.flex='2'; + const lab=el('input','tin'); lab.placeholder='label (optional)'; lab.style.flex='2'; + const addk=el('button','key on', GL.add+' ADD'); addk.style.flex='1'; + row.append(val,lab,addk); + box.append(seg,chips,row); + + let selType='timer'; + function paintChips(){ + chips.innerHTML=''; + engine.presetsFor(selType).forEach(p=>{ + const c=el('span','chip', p.label + (opts.editablePresets?` <span class="x" data-del="${encodeURIComponent(p.label)}">×</span>`:'')); + c.dataset.val=p.value; chips.appendChild(c); + }); + if(opts.editablePresets){ const c=el('span','chip','+ chip'); c.dataset.newchip='1'; c.style.opacity='.7'; chips.appendChild(c); } + // type-specific value affordance + val.disabled = (selType==='stopwatch'||selType==='pomodoro'); + val.placeholder = selType==='alarm' ? 'HH:MM · +30m · @hour' + : selType==='stopwatch' ? 'no value — just ADD' + : selType==='pomodoro' ? 'default 25/5 cycle' : '5m · 1h30m · 90s'; + if(val.disabled) val.value=''; + } + seg.addEventListener('click', e=>{ const b=e.target.closest('button'); if(!b) return; + selType=b.dataset.t; [...seg.children].forEach(x=>x.classList.toggle('on',x===b)); val.classList.remove('bad'); paintChips(); }); + chips.addEventListener('click', e=>{ + const del=e.target.closest('[data-del]'); + if(del){ engine.deletePreset(selType, decodeURIComponent(del.dataset.del)); paintChips(); toast('preset removed','gold'); return; } + if(e.target.closest('[data-newchip]')){ + const lb=prompt('Chip label (e.g. 45m):'); if(!lb) return; + let vv=lb; if(selType==='timer'||selType==='alarm'){ vv=prompt('Value for “'+lb+'” (e.g. 45m):', lb)||lb; } + const r=engine.addPreset(selType, lb, vv); paintChips(); toast(r.ok?'preset added':('preset: '+r.reason), r.ok?'gold':'red'); return; + } + const c=e.target.closest('.chip'); if(!c||c.dataset.val==null) return; + val.classList.remove('bad'); val.value=c.dataset.val; + doAdd(); + }); + function doAdd(){ + const r=engine.add(selType, val.value, lab.value.trim()); + if(!r.ok){ val.classList.add('bad'); toast(r.reason,'red'); return; } + val.classList.remove('bad'); if(!val.disabled) val.value=''; lab.value=''; + toast('added '+selType, 'gold'); rerender(); + } + addk.addEventListener('click', doAdd); + val.addEventListener('keydown', e=>{ if(e.key==='Enter') doAdd(); }); + lab.addEventListener('keydown', e=>{ if(e.key==='Enter') doAdd(); }); + paintChips(); + return box; +} + +/* =================================================================== */ +/* VIEW A — RACK UNIT */ +/* =================================================================== */ +function mountRack(host, engine){ + const toast=toaster(host); + const head=el('div','phead', + `<span class="brand">Timer</span><span class="pcount">queue <b class="cnt">0</b>/${MAX}</span>`); + const clear=el('button','key sm', GL.clear+' CLEAR ALL'); clear.style.marginLeft='8px'; + head.appendChild(clear); + clear.addEventListener('click', ()=>{ if(!engine.count())return; engine.cancelAll(); toast('cleared all'); render(); }); + const list=el('div','qlist'); + host.append(head,list); + const create=makeCreate(engine, toast, ()=>render(), {editablePresets:true}); + host.appendChild(create); + + const flashing=new Set(); + list.addEventListener('click', e=>{ + const b=e.target.closest('[data-act]'); if(!b) return; + const id=b.dataset.id, act=b.dataset.act; + if(act==='toggle') engine.toggle(id); + else if(act==='promote') engine.promote(id); + else if(act==='cancel'){ + if(b.dataset.armed){ engine.cancel(id); toast('cancelled'); } + else { b.dataset.armed='1'; b.classList.add('armed'); b.textContent='sure?'; setTimeout(()=>{ if(b.isConnected){b.textContent='×';b.classList.remove('armed');delete b.dataset.armed;} },2000); return; } + } + else if(act==='lap'){ engine.lap(id); toast('lap recorded'); } + else if(act==='stop'){ const run=engine.stopSave(id); if(run) toast(`saved “${run.label}” · ${run.laps.length} laps → org`,'gold'); } + render(); + }); + + function render(){ + head.querySelector('.cnt').textContent=engine.count(); + const rows=engine.rows(); + list.innerHTML=''; + if(!rows.length){ list.appendChild(el('div','empty','No timers running — pick a type below and ADD.')); return; } + rows.forEach(r=>{ + const row=el('div','qrow'+(r.primary?' prim':'')+(r.paused?' paused':'')+(flashing.has(r.id)?' fire':'')); + const ctrls = r.type==='stopwatch' + ? `<button class="key sm" data-act="lap" data-id="${r.id}">LAP</button> + <button class="key sm red" data-act="stop" data-id="${r.id}">STOP</button>` + : `<button class="key icon" data-act="toggle" data-id="${r.id}" title="pause/resume">${r.paused?GL.play:GL.paused}</button>`; + row.innerHTML= + `<span class="lamp ${r.paused?'off':(r.primary?'gold':(r.warn?'red':''))}"></span> + <span class="g">${r.glyph}</span> + <span class="meta"><b>${r.label}</b><span class="ty">${r.sub}</span></span> + <span class="rd">${r.disp}</span> + <span class="ctrls"> + ${ctrls} + <button class="key icon" data-act="promote" data-id="${r.id}" title="to bar slot" ${r.primary?'disabled style=opacity:.4':''}>${GL.promote}</button> + <button class="arm" data-act="cancel" data-id="${r.id}" title="cancel">×</button> + </span>`; + list.appendChild(row); + }); + } + engine._render=render; + engine._flash=(id)=>{ flashing.add(id); setTimeout(()=>{flashing.delete(id);},1800); }; + engine._toast=toast; + render(); +} + +/* =================================================================== */ +/* VIEW B — TRANSPORT DECK */ +/* =================================================================== */ +function mountTransport(host, engine){ + const toast=toaster(host); + const head=el('div','phead', + `<span class="brand">Timer · Transport</span><span class="pcount">queue <b class="cnt">0</b>/${MAX}</span>`); + const clear=el('button','key sm',GL.clear+' CLEAR'); clear.style.marginLeft='8px'; + clear.addEventListener('click',()=>{ if(!engine.count())return; engine.cancelAll(); toast('cleared all'); render(); }); + head.appendChild(clear); + const hero=el('div','hero'); + const tracks=el('div','tracks'); + host.append(head,hero,tracks); + const create=makeCreate(engine,toast,()=>render(),{editablePresets:true}); + host.appendChild(create); + + const flashing=new Set(); + function act(fn){ return e=>{ fn(); render(); }; } + hero.addEventListener('click', e=>{ const b=e.target.closest('[data-act]'); if(!b) return; const id=b.dataset.id,a=b.dataset.act; + if(a==='toggle')engine.toggle(id); else if(a==='cancel'){engine.cancel(id);toast('cancelled');} + else if(a==='cycle')engine.cycle(b.dataset.dir); else if(a==='lap'){engine.lap(id);toast('lap');} + else if(a==='stop'){const r=engine.stopSave(id); if(r)toast(`saved “${r.label}” · ${r.laps.length} laps`,'gold');} + render(); }); + tracks.addEventListener('click', e=>{ + const x=e.target.closest('[data-cancel]'); if(x){ engine.cancel(x.dataset.cancel); toast('cancelled'); render(); return; } + const t=e.target.closest('[data-id]'); if(t){ engine.promote(t.dataset.id); render(); } }); + + function render(){ + head.querySelector('.cnt').textContent=engine.count(); + const rows=engine.rows(); const primId=engine.effectivePrimary(); + // hero = the primary row + const h = rows.find(r=>r.id===primId); + hero.className='hero'+(h&&h.paused?' paused':'')+(h&&flashing.has(h.id)?' fire':''); + if(!h){ hero.innerHTML='<div class="empty" style="width:100%">No timers — add one below to load the deck.</div>'; } + else { + const ringP = h.prog!=null ? Math.round(h.prog*100) : (h.type==='stopwatch'? 100 : 0); + const ringInner = h.type==='stopwatch' + ? `<b style="color:var(--slate-hi);font-size:11px">SW</b>` + : `<b style="color:var(--cream);font-size:15px">${ringP}<small style="font-size:9px;color:var(--dim)">%</small></b>`; + const transport = h.type==='stopwatch' + ? `<button class="key" data-act="toggle" data-id="${h.id}">${h.paused?GL.play+' RESUME':GL.paused+' PAUSE'}</button> + <button class="key" data-act="lap" data-id="${h.id}">LAP</button> + <button class="key red" data-act="stop" data-id="${h.id}">STOP · SAVE</button>` + : `<button class="key icon" data-act="cycle" data-dir="prev">${'‹'}</button> + <button class="key" data-act="toggle" data-id="${h.id}">${h.paused?GL.play+' RESUME':GL.paused+' PAUSE'}</button> + <button class="key red icon" data-act="cancel" data-id="${h.id}">${GL.cancel}</button> + <button class="key icon" data-act="cycle" data-dir="next">${'›'}</button>`; + hero.innerHTML= + `<div class="lhs"><span class="ring${h.warn?' warn':''}" style="--p:${ringP};width:88px;height:88px">${ringInner}</span></div> + <div class="rhs"> + <div class="htype"><span class="g">${h.glyph}</span><span class="badge ${h.paused?'dim':''}">${h.typeLabel}</span> + ${h.primary?'<span class="badge">BAR SLOT</span>':''}</div> + <div class="hlabel">${h.label}</div> + <div class="hbig">${h.disp}</div> + <div class="hsub">${h.sub}</div> + <div class="transport">${transport}</div> + </div>`; + } + // track list = everything except the hero + tracks.innerHTML=''; + const rest=rows.filter(r=>r.id!==primId); + if(rest.length){ tracks.appendChild(el('div','engrave','up next <span class="cnt">· '+rest.length+'</span>')); } + rest.forEach(r=>{ + const t=el('div','track'+(r.paused?' paused':''), + `<span class="g">${r.glyph}</span><b>${r.label}</b> + <span class="trd">${r.disp}</span> + <span class="tx" data-cancel="${r.id}" title="cancel">${GL.cancel}</span>`); + t.dataset.id=r.id; tracks.appendChild(t); + }); + } + engine._render=render; + engine._flash=(id)=>{ flashing.add(id); setTimeout(()=>flashing.delete(id),1800); }; + engine._toast=toast; + render(); +} + +/* =================================================================== */ +/* VIEW C — CHANNEL STRIP BOARD */ +/* =================================================================== */ +function mountBoard(host, engine){ + const toast=toaster(host); + const head=el('div','phead', + `<span class="brand">Timer · Board</span><span class="pcount">channels <b class="cnt">0</b>/${MAX}</span>`); + const clear=el('button','key sm',GL.clear+' CLEAR ALL'); clear.style.marginLeft='8px'; + clear.addEventListener('click',()=>{ if(!engine.count())return; engine.cancelAll(); toast('cleared all'); render(); }); + head.appendChild(clear); + const board=el('div','board'); + host.append(head,board); + + // create controls live in the trailing add-strip; build once, reuse the shared controller inside it + const addStrip=el('div','strip addstrip'); + const create=makeCreate(engine,toast,()=>render(),{editablePresets:true}); + create.style.margin='0'; create.style.background='transparent'; create.style.border='0'; create.style.padding='0'; create.style.width='100%'; + addStrip.append(el('div','styp','+ new channel'), create); + + board.addEventListener('click', e=>{ + const b=e.target.closest('[data-act]'); + if(b){ const id=b.dataset.id,a=b.dataset.act; + if(a==='toggle')engine.toggle(id); else if(a==='cancel'){engine.cancel(id);toast('cancelled');} + else if(a==='lap'){engine.lap(id);toast('lap');} else if(a==='stop'){const r=engine.stopSave(id); if(r)toast(`saved “${r.label}”`,'gold');} + render(); return; } + const h=e.target.closest('[data-promote]'); if(h){ engine.promote(h.dataset.promote); render(); } + }); + + function render(){ + head.querySelector('.cnt').textContent=engine.count(); + const rows=engine.rows(); + board.innerHTML=''; + rows.forEach(r=>{ + const strip=el('div','strip'+(r.primary?' prim':'')+(r.paused?' paused':'')); + const pct = r.prog!=null ? Math.round(r.prog*100) : 100; + const colCls = 'column'+(r.type==='stopwatch'?' sw':'')+(r.warn?' warn':''); + const fillH = r.type==='stopwatch' ? 100 : pct; + const keys = r.type==='stopwatch' + ? `<button class="key sm" data-act="lap" data-id="${r.id}">LAP</button> + <button class="key sm red" data-act="stop" data-id="${r.id}">${GL.cancel}</button>` + : `<button class="key icon" data-act="toggle" data-id="${r.id}">${r.paused?GL.play:GL.paused}</button> + <button class="key icon red" data-act="cancel" data-id="${r.id}">${GL.cancel}</button>`; + strip.innerHTML= + `<div class="stitle" data-promote="${r.id}" title="promote to bar slot"> + <span class="g">${r.glyph}</span><b>${r.label}</b></div> + <div class="styp">${r.typeLabel}${r.primary?' · bar':''}</div> + <div class="${colCls}"><div class="fill" style="height:${fillH}%"></div> + ${r.type!=='stopwatch'?`<div class="cap" style="bottom:${fillH}%"></div>`:''}</div> + <div class="srd">${r.disp}</div> + <div class="skeys">${keys}</div>`; + board.appendChild(strip); + }); + board.appendChild(addStrip); + } + engine._render=render; + engine._flash=(id)=>{ flashing.add(id); setTimeout(()=>flashing.delete(id),1800); }; + const flashing=new Set(); + engine._flashSet=flashing; + engine._toast=toast; + render(); +} + +/* ---------- seed + wire the three panels ---------- */ +function seed(engine){ + engine.add('pomodoro','', 'Deep work'); + engine.add('timer','5m','Tea'); + engine.add('timer','45s','Egg'); // fires ~45s in, demonstrates completion + notify + const sw=engine.add('stopwatch','','Debug run'); engine.lap(sw.id); + engine.add('alarm','@hour','Standup'); +} + +const engines=[]; +function boot(){ + const A=new Engine(), B=new Engine(), C=new Engine(); + seed(A); seed(B); seed(C); + mountRack(document.getElementById('panelA'), A); + mountTransport(document.getElementById('panelB'), B); + mountBoard(document.getElementById('panelC'), C); + engines.push(A,B,C); +} +boot(); + +/* ---------- global tick: fire timers, flash + notify, re-render ---------- */ +function loop(){ + for(const e of engines){ + const fired=e.tick(); + for(const f of fired){ + e._flash && e._flash(f.id); + if(e._flashSet) e._flashSet.add(f.id), setTimeout(()=>e._flashSet.delete(f.id),1800); + e._toast && e._toast((f.kind==='done'?GL.alarm+' ':'')+f.title, f.kind==='done'?'red':'gold'); + tryNotify(f.title, f.body); + } + e._render && e._render(); + } +} +setInterval(loop, reduced?1000:250); +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-02-timer-panel-prototype-2.html b/docs/prototypes/2026-07-02-timer-panel-prototype-2.html new file mode 100644 index 0000000..ffd4521 --- /dev/null +++ b/docs/prototypes/2026-07-02-timer-panel-prototype-2.html @@ -0,0 +1,553 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Timer panel — hero + rack (iteration 2) · dupre instrument console</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono","Symbols Nerd Font",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 5rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.wrap{max-width:1100px;margin:0 auto} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} +.masthead p{color:var(--dim);font-size:.86rem;max-width:92ch} +.masthead p b{color:var(--silver)} +.cols{display:flex;gap:34px;align-items:flex-start;margin-top:1.6rem;flex-wrap:wrap} +.side{flex:1;min-width:300px} +.side h2{color:var(--steel);font-size:.74rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .5rem; + display:flex;align-items:center;gap:10px} +.side h2::after{content:"";height:1px;background:var(--wash);flex:1} +.side ul{list-style:none;font-size:.8rem;color:var(--dim);display:flex;flex-direction:column;gap:7px} +.side li{display:flex;gap:9px} +.side li::before{content:"›";color:var(--gold);flex:none} +.side li b{color:var(--silver);font-weight:400} + +/* ---------- faceplate ---------- */ +.panel{width:420px;flex:none;background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320; + border-radius:14px;padding:15px;position:relative; + box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 14px 34px rgba(0,0,0,.55)} +.phead{display:flex;align-items:center;gap:10px;margin-bottom:12px} +.phead .brand{color:var(--gold);font-size:.72rem;letter-spacing:.24em;text-transform:uppercase} +.phead .pcount{margin-left:auto;color:var(--dim);font-size:.66rem;letter-spacing:.14em} +.phead .pcount b{color:var(--cream)} + +/* ---------- primitives ---------- */ +.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55);flex:none} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} +.lamp.off{background:var(--wash);box-shadow:none} +.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite} +@keyframes pulse{50%{opacity:.25}} + +.key{font:inherit;font-size:11.5px;letter-spacing:.05em;color:var(--silver);cursor:pointer; + background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a; + border-radius:8px;padding:7px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} +.key:hover{color:var(--gold);border-color:var(--gold)} +.key:active{transform:translateY(1px)} +.key:disabled{opacity:.4;cursor:default} +.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} +.key.sm{padding:5px 8px;font-size:10.5px;border-radius:7px} +.key.icon{padding:6px 9px;font-size:14px;line-height:1} + +.chip{color:var(--dim);cursor:pointer;border:1px solid #2a2723;background:#141210; + border-radius:14px;font-size:11.5px;padding:4px 10px;letter-spacing:.02em;display:inline-flex;align-items:center} +.chip:hover{color:var(--silver);border-color:var(--slate)} +.chip.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.chip.lock{padding-right:10px} +.chip .x{color:inherit;opacity:.5;margin-left:6px;font-size:13px} +.chip .x:hover{opacity:1;color:var(--fail)} + +.badge{font-size:.6rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px;text-transform:uppercase} +.badge.red{background:var(--fail);color:var(--cream)} +.badge.dim{background:var(--wash);color:var(--steel)} +.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)} + +.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden} +.seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b; + padding:7px 0;cursor:pointer;flex:1;letter-spacing:.02em} +.seg button:last-child{border-right:0} +.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700} + +.switch{width:38px;height:20px;border-radius:11px;background:var(--wash);border:1px solid var(--slate);position:relative;cursor:pointer;flex:none} +.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--dim);transition:left .15s} +.switch.on{background:var(--slate);border-color:var(--gold)} +.switch.on::after{left:20px;background:var(--gold)} + +.engrave{color:var(--steel);font-size:.58rem;letter-spacing:.26em;text-transform:uppercase; + display:flex;align-items:center;gap:9px;margin:2px 0} +.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.engrave::before{max-width:10px} +.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none} + +.tin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:7px; + padding:7px 9px;width:100%;outline:none} +.tin:focus{border-color:var(--gold)} +.tin::placeholder{color:var(--dim)} +.tin.bad{border-color:var(--fail);color:var(--fail)} +.tin:disabled{opacity:.45} +.numin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:6px; + padding:5px 4px;width:46px;text-align:center;outline:none;font-variant-numeric:tabular-nums} +.numin:focus{border-color:var(--gold)} + +.arm{font:inherit;font-size:11px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b; + border-radius:7px;padding:6px 9px} +.arm.armed{background:rgba(203,107,77,.14);border-color:var(--fail);color:var(--fail)} + +.ring{border-radius:50%;background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0);display:grid;place-items:center;position:relative} +.ring.warn{background:conic-gradient(var(--fail) calc(var(--p)*1%),var(--wash) 0)} +.ring::before{content:"";position:absolute;inset:7px;border-radius:50%;background:var(--well)} +.ring b{position:relative;z-index:1;text-align:center} + +.dots{display:flex;gap:4px;align-items:center} +.dots i{width:7px;height:7px;border-radius:50%;background:var(--wash);flex:none} +.dots i.on{background:var(--steel)} +.dots i.now{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +.dots i.long{outline:1px solid var(--gold);outline-offset:1px} + +/* ---------- HERO (top) ---------- */ +.hero{background:var(--well);border:1px solid #201d17;border-radius:11px;padding:15px;display:flex;gap:15px;align-items:center;margin-bottom:12px} +.hero.fire{animation:firef .6s ease-in-out 3} +@keyframes firef{50%{background:rgba(203,107,77,.22)}} +.hero .rhs{min-width:0;flex:1;display:flex;flex-direction:column;gap:5px} +.hero .htype{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.hero .htype .g{color:var(--gold);font-size:17px} +.hero .hlabel{color:var(--cream);font-size:15px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.hero .hbig{color:var(--cream);font-size:38px;line-height:1;font-weight:700;font-variant-numeric:tabular-nums} +.hero.paused .hbig{color:var(--steel)} +.hero .hsub{color:var(--dim);font-size:11px;letter-spacing:.05em} +.transport{display:flex;gap:7px;margin-top:3px;flex-wrap:wrap} + +/* ---------- CREATE (middle) ---------- */ +.create{background:var(--well);border:1px solid #201d17;border-radius:10px;padding:11px;margin-bottom:12px} +.create .row{display:flex;gap:7px;align-items:center;margin-top:9px;flex-wrap:wrap} +.chips{display:flex;gap:6px;flex-wrap:wrap;margin-top:9px} +.cfg{margin-top:9px;display:flex;flex-direction:column;gap:7px} +.cfg .crow{display:flex;align-items:center;gap:8px} +.cfg .crow .lbl{width:58px;color:var(--steel);font-size:.58rem;letter-spacing:.14em;text-transform:uppercase;flex:none} +.cfg .crow .u{color:var(--dim);font-size:10px} +.cfg .crow .sl{color:var(--steel);font-size:.58rem;letter-spacing:.1em;text-transform:uppercase;width:9px} + +/* ---------- LIST (bottom) ---------- */ +.qlist{display:flex;flex-direction:column;gap:8px} +.qrow{display:flex;align-items:center;gap:10px;background:#141210;border:1px solid #201d17;border-radius:9px;padding:8px 10px} +.qrow.fire{animation:firef .6s ease-in-out 3} +.qrow .g{color:var(--gold);font-size:16px;width:19px;text-align:center;flex:none} +.qrow .meta{min-width:0;display:flex;flex-direction:column;gap:2px} +.qrow .meta b{color:var(--cream);font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:110px} +.qrow .meta .ty{color:var(--dim);font-size:.56rem;letter-spacing:.12em;text-transform:uppercase} +.qrow .rd{margin-left:auto;font-size:18px;color:var(--cream);font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap} +.qrow.paused .rd{color:var(--steel)} +.qrow .ctrls{display:flex;gap:5px;flex:none} +.empty{color:var(--dim);font-size:12px;text-align:center;padding:14px 6px} + +/* toast */ +.toasts{position:absolute;left:12px;right:12px;bottom:10px;display:flex;flex-direction:column;gap:6px;pointer-events:none;z-index:5} +.toast{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 10px;box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .2s ease} +.toast.red{background:linear-gradient(180deg,#b25c43,#8f3f2c)} +.toast.gold{background:linear-gradient(180deg,#b79a34,#8a7524);color:var(--panel)} +@keyframes tin{from{opacity:0;transform:translateY(6px)}} + +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +</style> +</head> +<body> +<div class="wrap"> +<header class="masthead"> + <div class="eyebrow">archsetup · dupre panel family · timer · iteration 2</div> + <h1>Timer panel — hero + rack</h1> + <p>The rack unit reshaped: the <b>hero</b> from the transport deck rides on top (the primary / bar-slot item, big), + the <b>create strip</b> sits under it, and the <b>queue list</b> runs below. Pomodoro is now a real configurable cycle — + work and rest each with a short and a long duration, a long break every N cycles, auto-advance, and progress dots — + with its default preset locked so it can't be deleted. Everything is live: add, count down, fire + notify, pause, promote, + lap / stop-save. Ideas pulled from Pomofocus, Todoist, and the classic technique (see the notes column).</p> +</header> + +<div class="cols"> + <div class="panel" id="panel"></div> + <div class="side"> + <h2>What changed this pass</h2> + <ul> + <li><b>Layout flipped:</b> hero on top → create strip → list (was list → create).</li> + <li><b>Pomodoro is configurable:</b> Work short/long, Rest short/long, long break every N, auto-advance toggle.</li> + <li><b>Deep cycle:</b> every Nth pomodoro uses the long work + long rest; the rest fill mark the long dots.</li> + <li><b>Default cycle is locked</b> — shipped presets have no ×; only chips you add are deletable.</li> + <li><b>Cycle dots</b> in the hero + row show where you are in the set.</li> + </ul> + <h2 style="margin-top:1.6rem">Borrowed from good pomodoro apps</h2> + <ul> + <li><b>Pomofocus:</b> separate work / short-break / long-break lengths + long-break interval.</li> + <li><b>Auto-start next</b> (Pomofocus, Pomodo): auto-advance rolls into the next phase; off = wait and press start.</li> + <li><b>Todoist / the technique:</b> long break of 15–30m after 4 pomodoros; all durations adjustable.</li> + <li><b>Preset cycles:</b> Classic 25/5/15, Deep 50/10/30, Sprint 15/3/10 — one tap loads the fields.</li> + <li><b>Task label</b> on every item; cycle progress shown as dots.</li> + </ul> + </div> +</div> + +</div> + +<script> +"use strict"; +const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; +const el=(t,c,h)=>{const n=document.createElement(t);if(c)n.className=c;if(h!=null)n.innerHTML=h;return n;}; + +const GL={ timer:'\u{F051B}', alarm:'\u{F0020}', stopwatch:'\u{F13AB}', + pomo_work:'\u{F051C}', pomo_break:'\u{F0176}', paused:'\u{F03E4}', + play:'\u{F040A}', promote:'\u{F0143}', cancel:'\u{F0156}', add:'\u{F0415}', clear:'\u{F0A79}' }; + +/* ---- parsers ---- */ +function parseDuration(v){ + if(v==null) return null; v=String(v).trim().toLowerCase(); if(v==='') return null; + if(/^\d+$/.test(v)) return parseInt(v,10)*60; + if(!/^(\s*\d+\s*[hms])+$/.test(v)) return null; + let m,tot=0; const re=/(\d+)\s*([hms])/g; + while((m=re.exec(v))) tot+= m[2]==='h'?+m[1]*3600 : m[2]==='m'?+m[1]*60 : +m[1]; + return tot>0?tot:null; +} +function resolveAlarm(v,now){ + v=String(v||'').trim().toLowerCase(); + if(v.startsWith('+')){const s=parseDuration(v.slice(1));return s==null?null:now+s;} + if(v==='@hour'||v==='top of hour'){const d=new Date(now*1000);d.setMinutes(0,0,0);d.setHours(d.getHours()+1);return d.getTime()/1000;} + const t=v.match(/^(\d{1,2}):(\d{2})$/); + if(t){const hh=+t[1],mm=+t[2];if(hh>23||mm>59)return null;const d=new Date(now*1000);d.setHours(hh,mm,0,0);let e=d.getTime()/1000;if(e<=now)e+=86400;return e;} + return null; +} +const fmtTime=s=>{s=Math.max(0,Math.floor(s));const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),x=s%60; + return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;}; +const fmtClock=e=>{const d=new Date(e*1000);return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;}; + +/* ---- presets: shipped defaults are locked (no delete) ---- */ +const DEFAULT_PRESETS=()=>({ + timer:[{label:'5m',value:'5m',locked:true},{label:'25m',value:'25m',locked:true},{label:'10m',value:'10m',locked:true}, + {label:'15m',value:'15m',locked:true},{label:'30m',value:'30m',locked:true},{label:'60m',value:'60m',locked:true},{label:'2h',value:'2h',locked:true}], + alarm:[{label:'+30m',value:'+30m',locked:true},{label:'top of hour',value:'@hour',locked:true},{label:'07:00',value:'07:00',locked:true}], + stopwatch:[] +}); +/* pomodoro preset = a full config (minutes). "Classic" is the default cycle — locked. */ +const POMO_PRESETS=[ + {label:'Classic', ws:25, wl:50, rs:5, rl:15, iv:4, locked:true}, + {label:'Deep', ws:50, wl:50, rs:10, rl:30, iv:3, locked:true}, + {label:'Sprint', ws:15, wl:25, rs:3, rl:10, iv:4, locked:true}, +]; +const POMO_DEFAULT={ws:25,wl:50,rs:5,rl:15,iv:4,auto:true}; + +const TYPES=['timer','alarm','stopwatch','pomodoro']; +const COUNTDOWN=['timer','alarm','pomodoro']; +const MAX=10; + +class Engine{ + constructor(){ this.items=[]; this.seq=0; this.primary=null; + this.presets=DEFAULT_PRESETS(); this.pomoPresets=POMO_PRESETS.map(p=>({...p})); } + now(){return Date.now()/1000;} + count(){return this.items.length;} + full(){return this.items.length>=MAX;} + add(type,value,label,cfg){ + if(this.full()) return {ok:false,reason:`queue full (${MAX}/${MAX})`}; + const now=this.now(); this.seq++; const id='t'+this.seq; const it={id,type,label:label||''}; + if(type==='timer'){const s=parseDuration(value);if(s==null)return {ok:false,reason:`bad duration: “${value}”`};it.target=now+s;it.total=s;} + else if(type==='alarm'){const e=resolveAlarm(value,now);if(e==null)return {ok:false,reason:`bad time: “${value}”`};it.target=e;it.total=Math.max(1,e-now);} + else if(type==='pomodoro'){ + const c=cfg||POMO_DEFAULT; + it.cfg={ws:c.ws*60,wl:c.wl*60,rs:c.rs*60,rl:c.rl*60,iv:Math.max(1,c.iv),auto:c.auto!==false}; + it.phase='work'; it.cycle=1; const deep=(1%it.cfg.iv===0); + const len=deep?it.cfg.wl:it.cfg.ws; it.target=now+len; it.total=len; + } + else if(type==='stopwatch'){it.start=now;it.laps=[];} + else return {ok:false,reason:`unknown type: ${type}`}; + this.items.push(it); if(!this.primary) this.primary=id; return {ok:true,id}; + } + find(id){return this.items.find(i=>i.id===id);} + isPaused(it){return it.type==='stopwatch'?it.paused_elapsed!=null:it.paused_left!=null;} + remaining(it,ref){ref=ref==null?this.now():ref; + if(it.type==='stopwatch')return this.isPaused(it)?it.paused_elapsed:ref-it.start; + return this.isPaused(it)?it.paused_left:it.target-ref;} + toggle(id){const it=this.find(id);if(!it)return;const now=this.now(); + if(it.type==='stopwatch'){ if(this.isPaused(it)){it.start=now-it.paused_elapsed;it.paused_elapsed=null;} else it.paused_elapsed=now-it.start; } + else { if(this.isPaused(it)){it.target=now+it.paused_left;it.paused_left=null;it.awaiting=false;} else it.paused_left=it.target-now; } } + cancel(id){const i=this.items.findIndex(x=>x.id===id);if(i<0)return;this.items.splice(i,1);if(this.primary===id)this.primary=null;} + cancelAll(){this.items=[];this.primary=null;} + promote(id){if(this.find(id))this.primary=id;} + cycle(dir){const ids=this.items.map(i=>i.id);if(!ids.length)return;let c=ids.indexOf(this.effectivePrimary());c=c<0?0:c; + this.primary=ids[dir==='prev'?(c-1+ids.length)%ids.length:(c+1)%ids.length];} + lap(id){const it=this.find(id);if(!it||it.type!=='stopwatch')return;it.laps.push({t:this.remaining(it)});} + stopSave(id){const it=this.find(id);if(!it||it.type!=='stopwatch')return null;const run={label:it.label||'run',total:this.remaining(it),laps:it.laps.slice()};this.cancel(id);return run;} + effectivePrimary(){const items=this.items;if(!items.length)return null;const ids=items.map(i=>i.id); + if(ids.includes(this.primary))return this.primary;const now=this.now(); + const acd=items.filter(i=>COUNTDOWN.includes(i.type)&&!this.isPaused(i)); + if(acd.length)return acd.reduce((a,b)=>this.remaining(a,now)<=this.remaining(b,now)?a:b).id; + const asw=items.filter(i=>i.type==='stopwatch'&&!this.isPaused(i));if(asw.length)return asw[0].id;return ids[0];} + sortKey(it){const now=this.now(),p=this.isPaused(it),sw=it.type==='stopwatch',r=this.remaining(it,now); + return sw?[p?3:2,-r,+it.id.slice(1)]:[p?1:0,r,+it.id.slice(1)];} + rows(){const prim=this.effectivePrimary(),now=this.now(); + return this.items.slice().sort((a,b)=>{const ka=this.sortKey(a),kb=this.sortKey(b); + for(let i=0;i<ka.length;i++){if(ka[i]<kb[i])return -1;if(ka[i]>kb[i])return 1;}return 0;}).map(it=>this.row(it,prim,now));} + row(it,prim,now){ + const rem=this.remaining(it,now),paused=this.isPaused(it); + let disp,sub='',warn=false,prog=null,glyph,pomo=null; + if(it.type==='alarm'){disp=fmtClock(it.target);sub='fires '+fmtClock(it.target);prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.alarm;} + else if(it.type==='pomodoro'){ + disp=fmtTime(rem); prog=Math.max(0,Math.min(1,rem/it.total)); + const deep=(it.cycle%it.cfg.iv===0); + const phLabel = it.phase==='work' ? (deep?'long work':'work') + : it.phase==='rest' ? ((it.cycle%it.cfg.iv===0)?'long break':'short break') : it.phase; + sub = it.awaiting ? `ready · start ${it.phase==='work'?'work':'break'}` : `${phLabel} · cycle ${it.cycle}/${it.cfg.iv}`; + glyph = it.phase==='work' ? GL.pomo_work : GL.pomo_break; + pomo={cycle:it.cycle, iv:it.cfg.iv, phase:it.phase, awaiting:!!it.awaiting, deep}; + } + else if(it.type==='stopwatch'){disp=fmtTime(rem);sub=it.laps.length?`${it.laps.length} lap${it.laps.length>1?'s':''}`:'running';glyph=GL.stopwatch;} + else {disp=fmtTime(rem);sub='timer';prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.timer;} + if(prog!=null && rem<=Math.min(30,it.total*0.15)) warn=true; + if(paused) glyph=GL.paused; + return {id:it.id,type:it.type,glyph,label:it.label||({timer:'Timer',alarm:'Alarm',stopwatch:'Stopwatch',pomodoro:'Pomodoro'})[it.type], + typeLabel:it.type,disp,sub,paused,primary:it.id===prim,prog,warn,pomo,laps:it.laps?it.laps.length:0}; + } + tick(){ + const now=this.now(),fired=[]; + for(const it of this.items.slice()){ + if(!COUNTDOWN.includes(it.type)||this.isPaused(it))continue; + if(it.target-now>0)continue; + if(it.type==='pomodoro'){ + const c=it.cfg; + if(it.phase==='work'){ + const deep=(it.cycle%c.iv===0); + fired.push({id:it.id,kind:'pomo',title:`Pomodoro · ${deep?'long':'short'} break`,body:it.label||`cycle ${it.cycle}`}); + it.phase='rest'; const len=deep?c.rl:c.rs; it.total=len; + if(c.auto){ it.target=now+len; } else { it.paused_left=len; it.awaiting=true; } + } else { // rest over → next work + it.cycle+=1; const deep=(it.cycle%c.iv===0); + fired.push({id:it.id,kind:'pomo',title:'Pomodoro · back to work',body:it.label||`cycle ${it.cycle}`}); + it.phase='work'; const len=deep?c.wl:c.ws; it.total=len; + if(c.auto){ it.target=now+len; } else { it.paused_left=len; it.awaiting=true; } + } + } else { + fired.push({id:it.id,kind:'done',title:(it.type==='alarm'?'Alarm':'Timer')+' · '+(it.label||(it.type==='alarm'?fmtClock(it.target):'done')),body:'time’s up'}); + this.cancel(it.id); + } + } + return fired; + } + presetsFor(t){return (this.presets[t]||[]).map(p=>({...p}));} + addPreset(t,label,value){if(!TYPES.includes(t)||t==='pomodoro'||t==='stopwatch')return {ok:false,reason:'no custom chip here'}; + if(t==='timer'&&parseDuration(value)==null)return {ok:false,reason:'bad duration'}; + (this.presets[t]||(this.presets[t]=[])).push({label,value,locked:false});return {ok:true};} + deletePreset(t,label){const a=this.presets[t]||[];const i=a.findIndex(p=>p.label===label); + if(i<0)return {ok:false,reason:'not found'}; if(a[i].locked)return {ok:false,reason:'default — locked'}; + a.splice(i,1);return {ok:true};} +} + +/* ---- notifications ---- */ +let notifPerm=(typeof Notification!=='undefined')?Notification.permission:'denied'; +function tryNotify(title,body){ if(typeof Notification==='undefined')return; + if(notifPerm==='granted'){try{new Notification(title,{body});}catch(e){}} + else if(notifPerm==='default'){Notification.requestPermission().then(p=>notifPerm=p);} } +function toaster(host){const wrap=el('div','toasts');host.appendChild(wrap); + return (msg,kind)=>{const t=el('div','toast'+(kind?' '+kind:''),msg);wrap.appendChild(t); + setTimeout(()=>{t.style.transition='opacity .3s';t.style.opacity='0';setTimeout(()=>t.remove(),300);},2600);};} + +function dotsHTML(p){ if(!p)return ''; let h='<span class="dots">'; const pos=(p.cycle-1)%p.iv; + for(let i=0;i<p.iv;i++){ const isLong=(i===p.iv-1); let cls=''; if(i<pos)cls='on'; if(i===pos)cls='now'; if(isLong)cls+=' long'; + h+=`<i class="${cls.trim()}"></i>`; } return h+'</span>'; } + +/* =================================================================== */ +/* THE PANEL: hero (top) · create (middle) · list (bottom) */ +/* =================================================================== */ +function mount(host, engine){ + const toast=toaster(host); + const head=el('div','phead',`<span class="brand">Timer</span><span class="pcount">queue <b class="cnt">0</b>/${MAX}</span>`); + const clear=el('button','key sm',GL.clear+' CLEAR ALL'); clear.style.marginLeft='8px'; + clear.addEventListener('click',()=>{if(!engine.count())return;engine.cancelAll();toast('cleared all');render();}); + head.appendChild(clear); + const hero=el('div','hero'); + const create=buildCreate(); + const list=el('div','qlist'); + host.append(head,hero,create.box,list); + + const flashing=new Set(); + + /* ---- hero + list interactions ---- */ + hero.addEventListener('click',e=>{const b=e.target.closest('[data-act]');if(!b)return;const id=b.dataset.id,a=b.dataset.act; + if(a==='toggle')engine.toggle(id); else if(a==='cancel'){engine.cancel(id);toast('cancelled');} + else if(a==='cycle')engine.cycle(b.dataset.dir); else if(a==='lap'){engine.lap(id);toast('lap');} + else if(a==='stop'){const r=engine.stopSave(id);if(r)toast(`saved “${r.label}” · ${r.laps.length} laps → org`,'gold');} + render();}); + list.addEventListener('click',e=>{const b=e.target.closest('[data-act]');if(!b)return;const id=b.dataset.id,a=b.dataset.act; + if(a==='toggle')engine.toggle(id); else if(a==='promote'){engine.promote(id);toast('to bar slot');} + else if(a==='lap'){engine.lap(id);toast('lap recorded');} else if(a==='stop'){const r=engine.stopSave(id);if(r)toast(`saved “${r.label}” · ${r.laps.length} laps → org`,'gold');} + else if(a==='cancel'){ if(b.dataset.armed){engine.cancel(id);toast('cancelled');} + else{b.dataset.armed='1';b.classList.add('armed');b.textContent='sure?';setTimeout(()=>{if(b.isConnected){b.textContent='×';b.classList.remove('armed');delete b.dataset.armed;}},2000);return;} } + render();}); + + /* ---- create strip (swaps body by type) ---- */ + function buildCreate(){ + const box=el('div','create'); + const seg=el('div','seg'); + TYPES.forEach(t=>{const b=el('button',t==='timer'?'on':'',t[0].toUpperCase()+t.slice(1));b.dataset.t=t;seg.appendChild(b);}); + const body=el('div'); + box.append(seg,body); + let selType='timer'; + // shared value/label/add controls (rebuilt per type) + seg.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return; + selType=b.dataset.t;[...seg.children].forEach(x=>x.classList.toggle('on',x===b));paintBody();}); + function labelAdd(hasVal){ + const row=el('div','row'); + let val=null; + if(hasVal){ val=el('input','tin'); val.placeholder='5m · 1h30m · 90s'; val.style.flex='2'; } + const lab=el('input','tin'); lab.placeholder='label (optional)'; lab.style.flex='2'; + const addk=el('button','key on',GL.add+' ADD'); addk.style.flex='1'; + if(val) row.append(val); row.append(lab,addk); return {row,val,lab,addk}; + } + function paintBody(){ + body.innerHTML=''; + if(selType==='timer'||selType==='alarm'){ + const chips=el('div','chips'); + engine.presetsFor(selType).forEach(p=>{ + const c=el('span','chip'+(p.locked?' lock':''), p.label+(p.locked?'':` <span class="x" data-del="${encodeURIComponent(p.label)}">×</span>`)); + c.dataset.val=p.value; chips.appendChild(c); + }); + const addch=el('span','chip','+ chip'); addch.dataset.newchip='1'; addch.style.opacity='.7'; chips.appendChild(addch); + const {row,val,lab,addk}=labelAdd(true); + val.placeholder = selType==='alarm' ? 'HH:MM · +30m · @hour' : '5m · 1h30m · 90s'; + body.append(chips,row); + chips.addEventListener('click',e=>{ + const del=e.target.closest('[data-del]'); + if(del){const r=engine.deletePreset(selType,decodeURIComponent(del.dataset.del));toast(r.ok?'chip removed':('chip: '+r.reason),r.ok?'gold':'red');paintBody();return;} + if(e.target.closest('[data-newchip]')){const lb=prompt('Chip label:');if(!lb)return; + const vv=prompt('Value for “'+lb+'”:',lb)||lb;const r=engine.addPreset(selType,lb,vv);toast(r.ok?'chip added':('chip: '+r.reason),r.ok?'gold':'red');paintBody();return;} + const c=e.target.closest('.chip');if(!c||c.dataset.val==null)return;val.value=c.dataset.val;doAdd(selType,val,lab); + }); + addk.addEventListener('click',()=>doAdd(selType,val,lab)); + [val,lab].forEach(x=>x.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd(selType,val,lab);})); + } + else if(selType==='stopwatch'){ + const {row,lab,addk}=labelAdd(false); // no time entry — stopwatches count up from zero + body.append(row); + addk.addEventListener('click',()=>doAdd('stopwatch',null,lab)); + lab.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd('stopwatch',null,lab);}); + } + else { // pomodoro config + const chips=el('div','chips'); + engine.pomoPresets.forEach(p=>{const c=el('span','chip lock',p.label);c.dataset.pp=p.label;chips.appendChild(c);}); + const cfg=el('div','cfg'); + const mk=(v)=>{const i=el('input','numin');i.value=v;i.inputMode='numeric';return i;}; + const ws=mk(POMO_DEFAULT.ws),wl=mk(POMO_DEFAULT.wl),rs=mk(POMO_DEFAULT.rs),rl=mk(POMO_DEFAULT.rl),iv=mk(POMO_DEFAULT.iv); + const auto=el('span','switch on'); auto.dataset.on='1'; + const rW=el('div','crow'); rW.append(el('span','lbl','Work'), el('span','sl','S'), ws, el('span','sl','L'), wl, el('span','u','min')); + const rR=el('div','crow'); rR.append(el('span','lbl','Rest'), el('span','sl','S'), rs, el('span','sl','L'), rl, el('span','u','min')); + const rI=el('div','crow'); rI.append(el('span','lbl','Long ev.'), iv, el('span','u','cycles → long work + long break')); + const rA=el('div','crow'); rA.append(el('span','lbl','Auto'), auto, el('span','u','advance into the next phase')); + cfg.append(rW,rR,rI,rA); + const {row,lab,addk}=labelAdd(false); lab.placeholder='label (optional)'; // pomodoro has no single value entry — config fields above + addk.innerHTML=GL.add+' ADD CYCLE'; + body.append(chips,cfg,row); + auto.addEventListener('click',()=>{auto.classList.toggle('on');auto.dataset.on=auto.classList.contains('on')?'1':'';}); + chips.addEventListener('click',e=>{const c=e.target.closest('[data-pp]');if(!c)return; + const p=engine.pomoPresets.find(x=>x.label===c.dataset.pp);if(!p)return; + ws.value=p.ws;wl.value=p.wl;rs.value=p.rs;rl.value=p.rl;iv.value=p.iv; + [...chips.children].forEach(x=>x.classList.toggle('on',x===c));toast('loaded “'+p.label+'”','gold');}); + function pnum(inp,d){const n=parseInt(inp.value,10);return (isNaN(n)||n<1)?d:n;} + addk.addEventListener('click',()=>{ + const cfgv={ws:pnum(ws,25),wl:pnum(wl,50),rs:pnum(rs,5),rl:pnum(rl,15),iv:pnum(iv,4),auto:!!auto.dataset.on}; + const r=engine.add('pomodoro','',lab.value.trim(),cfgv); + if(!r.ok){toast(r.reason,'red');return;} lab.value=''; toast('pomodoro added','gold'); render(); + }); + lab.addEventListener('keydown',e=>{if(e.key==='Enter')addk.click();}); + } + } + function doAdd(type,val,lab){ + const r=engine.add(type,val?val.value:'',lab.value.trim()); + if(!r.ok){if(val)val.classList.add('bad');toast(r.reason,'red');return;} + if(val){val.classList.remove('bad'); if(!val.disabled)val.value='';} lab.value=''; + toast('added '+type,'gold'); render(); + } + paintBody(); + return {box}; + } + + function render(){ + head.querySelector('.cnt').textContent=engine.count(); + const rows=engine.rows(), primId=engine.effectivePrimary(); + // HERO = primary + const h=rows.find(r=>r.id===primId); + hero.className='hero'+(h&&h.paused?' paused':'')+(h&&flashing.has(h.id)?' fire':''); + if(!h){ hero.innerHTML='<div class="empty" style="width:100%">No timers running — add one below.</div>'; } + else { + const ringP=h.prog!=null?Math.round(h.prog*100):(h.type==='stopwatch'?100:0); + const inner=h.type==='stopwatch'?`<b style="color:var(--slate-hi);font-size:11px">SW</b>` + :`<b style="color:var(--cream);font-size:14px">${ringP}<small style="font-size:9px;color:var(--dim)">%</small></b>`; + const startLabel = h.pomo&&h.pomo.awaiting ? (GL.play+' START '+(h.pomo.phase==='work'?'WORK':'BREAK')) : (h.paused?GL.play+' RESUME':GL.paused+' PAUSE'); + const transport = h.type==='stopwatch' + ? `<button class="key" data-act="toggle" data-id="${h.id}">${h.paused?GL.play+' RESUME':GL.paused+' PAUSE'}</button> + <button class="key" data-act="lap" data-id="${h.id}">LAP</button> + <button class="key red" data-act="stop" data-id="${h.id}">STOP · SAVE</button>` + : `<button class="key icon" data-act="cycle" data-dir="prev" title="prev primary">‹</button> + <button class="key" data-act="toggle" data-id="${h.id}">${startLabel}</button> + <button class="key red icon" data-act="cancel" data-id="${h.id}" title="cancel">${GL.cancel}</button> + <button class="key icon" data-act="cycle" data-dir="next" title="next primary">›</button>`; + hero.innerHTML= + `<div><span class="ring${h.warn?' warn':''}" style="--p:${ringP};width:86px;height:86px">${inner}</span></div> + <div class="rhs"> + <div class="htype"><span class="g">${h.glyph}</span><span class="badge ${h.paused?'dim':''}">${h.typeLabel}</span> + <span class="badge">BAR SLOT</span>${h.pomo?dotsHTML(h.pomo):''}</div> + <div class="hlabel">${h.label}</div> + <div class="hbig">${h.disp}</div> + <div class="hsub">${h.sub}</div> + <div class="transport">${transport}</div> + </div>`; + } + // LIST = the rest + list.innerHTML=''; + const rest=rows.filter(r=>r.id!==primId); + if(!rest.length){ list.appendChild(el('div','empty', h?'Only the hero is queued — add more below.':'')); } + list.appendChild(el('div','engrave','queue <span class="cnt">· '+rest.length+'</span>')); + rest.forEach(r=>{ + const row=el('div','qrow'+(r.paused?' paused':'')+(flashing.has(r.id)?' fire':'')); + const ctrls = r.type==='stopwatch' + ? `<button class="key sm" data-act="lap" data-id="${r.id}">LAP</button> + <button class="key sm red" data-act="stop" data-id="${r.id}">STOP</button>` + : `<button class="key icon" data-act="toggle" data-id="${r.id}" title="pause/resume">${r.paused?GL.play:GL.paused}</button>`; + row.innerHTML= + `<span class="lamp ${r.paused?'off':(r.warn?'red':'')}"></span> + <span class="g">${r.glyph}</span> + <span class="meta"><b>${r.label}</b><span class="ty">${r.sub}${r.pomo?' ':''}</span></span> + <span class="rd">${r.disp}</span> + <span class="ctrls">${ctrls} + <button class="key icon" data-act="promote" data-id="${r.id}" title="to bar slot">${GL.promote}</button> + <button class="arm" data-act="cancel" data-id="${r.id}" title="cancel">×</button></span>`; + list.appendChild(row); + }); + } + engine._render=render; engine._flash=id=>{flashing.add(id);setTimeout(()=>flashing.delete(id),1800);}; engine._toast=toast; + render(); +} + +/* ---- seed ---- */ +const engine=new Engine(); +const pomo=engine.add('pomodoro','', 'Deep work', {ws:25,wl:50,rs:5,rl:15,iv:4,auto:true}); +engine.add('timer','45s','Egg'); +engine.add('timer','5m','Tea'); +const sw=engine.add('stopwatch','','Debug run'); engine.lap(sw.id); +engine.add('alarm','@hour','Standup'); +engine.promote(pomo.id); // show the pomodoro in the hero +mount(document.getElementById('panel'), engine); + +/* ---- global tick ---- */ +function loop(){ + const fired=engine.tick(); + for(const f of fired){ engine._flash(f.id); + engine._toast((f.kind==='done'?GL.alarm+' ':'')+f.title, f.kind==='done'?'red':'gold'); tryNotify(f.title,f.body); } + engine._render(); +} +setInterval(loop, reduced?1000:250); +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-02-timer-panel-prototype-3.html b/docs/prototypes/2026-07-02-timer-panel-prototype-3.html new file mode 100644 index 0000000..98778fa --- /dev/null +++ b/docs/prototypes/2026-07-02-timer-panel-prototype-3.html @@ -0,0 +1,556 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Timer panel — iteration 3 (waybar + hero-right) · dupre instrument console</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; --sage:#8a9a5b; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono","Symbols Nerd Font",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 5rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.wrap{max-width:1120px;margin:0 auto} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} +.masthead p{color:var(--dim);font-size:.86rem;max-width:94ch} +.masthead p b{color:var(--silver)} +.cols{display:flex;gap:34px;align-items:flex-start;margin-top:1.4rem;flex-wrap:wrap} +.side{flex:1;min-width:300px} +.side h2{color:var(--steel);font-size:.74rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .5rem;display:flex;align-items:center;gap:10px} +.side h2::after{content:"";height:1px;background:var(--wash);flex:1} +.side ul{list-style:none;font-size:.8rem;color:var(--dim);display:flex;flex-direction:column;gap:7px} +.side li{display:flex;gap:9px}.side li::before{content:"›";color:var(--gold);flex:none} +.side li b{color:var(--silver);font-weight:400} + +/* ---------- waybar preview ---------- */ +.barcap{color:var(--steel);font-size:.58rem;letter-spacing:.26em;text-transform:uppercase;margin-bottom:7px;display:flex;align-items:center;gap:9px} +.barcap::after{content:"";height:1px;background:var(--wash);flex:1} +.wbar{width:420px;background:linear-gradient(180deg,#141312,#0e0d0c);border:1px solid #262320;border-radius:16px; + padding:6px 10px;display:flex;align-items:center;gap:8px;box-shadow:0 8px 20px rgba(0,0,0,.5);margin-bottom:6px} +.wbar .fillspace{flex:1;color:var(--dim);font-size:10px;letter-spacing:.1em;padding-left:4px} +.wmod{display:inline-flex;align-items:center;gap:7px;color:var(--silver);background:transparent;border:1.5px solid var(--gold); + border-radius:14px;padding:4px 12px;cursor:pointer;font-size:12.5px;white-space:nowrap;min-width:78px;justify-content:center} +.wmod:hover{background:var(--wash)} +.wmod .wg{font-size:16px;line-height:1} +.wmod .wt{font-variant-numeric:tabular-nums;font-weight:700} +.wmod .wp{color:var(--dim);font-size:11px} +.wmod.urgent{color:var(--fail)} .wmod.paused{color:var(--dim)} +.wmod.pomodoro-work{color:var(--gold)} .wmod.pomodoro-break{color:var(--sage)} +.wmod.idle{color:var(--silver);min-width:0;border-color:#4a463c} +.wtip{width:420px;background:var(--well);border:1px solid #201d17;border-radius:8px;padding:7px 10px;font-size:11px;color:var(--dim);margin-bottom:16px} +.wtip .th{color:var(--steel);letter-spacing:.14em;text-transform:uppercase;font-size:.56rem;margin-bottom:3px} +.wtip .tl{color:var(--silver);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + +/* ---------- faceplate ---------- */ +.panel{width:420px;flex:none;background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320; + border-radius:14px;padding:15px;position:relative;box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 14px 34px rgba(0,0,0,.55)} +.phead{display:flex;align-items:center;gap:10px;margin-bottom:12px} +.phead .brand{color:var(--gold);font-size:.72rem;letter-spacing:.24em;text-transform:uppercase} +.phead .pcount{margin-left:auto;color:var(--dim);font-size:.66rem;letter-spacing:.14em} +.phead .pcount b{color:var(--cream)} +.x-btn{margin-left:6px;color:var(--dim);border:0;background:transparent;font:inherit;font-size:1rem;cursor:pointer;border-radius:50%;width:26px;height:26px;line-height:1;flex:0 0 auto} +.x-btn:hover{background:var(--wash);color:var(--silver)} +.panel.closed{display:none} +.wbar.reopen{outline:1px dashed var(--slate);outline-offset:2px} + +/* ---------- primitives ---------- */ +.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55);flex:none} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} +.lamp.off{background:var(--wash);box-shadow:none} +.key{font:inherit;font-size:11.5px;letter-spacing:.05em;color:var(--silver);cursor:pointer; + background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a; + border-radius:8px;padding:7px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} +.key:hover{color:var(--gold);border-color:var(--gold)}.key:active{transform:translateY(1px)}.key:disabled{opacity:.4;cursor:default} +.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} +.key.sm{padding:5px 8px;font-size:10.5px;border-radius:7px} +.key.icon{padding:6px 9px;font-size:14px;line-height:1} +.preset{color:var(--dim);cursor:pointer;border:1px solid #2a2723;background:#141210;border-radius:14px;font-size:11.5px;padding:4px 10px;letter-spacing:.02em;display:inline-flex;align-items:center} +.preset:hover{color:var(--silver);border-color:var(--slate)} +.preset.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.preset .x{color:inherit;opacity:.5;margin-left:6px;font-size:13px}.preset .x:hover{opacity:1;color:var(--fail)} +.badge{font-size:.6rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px;text-transform:uppercase} +.badge.red{background:var(--fail);color:var(--cream)}.badge.dim{background:var(--wash);color:var(--steel)}.badge.sage{background:var(--sage);color:var(--panel)} +.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden} +.seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 0;cursor:pointer;flex:1;letter-spacing:.02em} +.seg button:last-child{border-right:0} +.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700} +.switch{width:38px;height:20px;border-radius:11px;background:var(--wash);border:1px solid var(--slate);position:relative;cursor:pointer;flex:none} +.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--dim);transition:left .15s} +.switch.on{background:var(--slate);border-color:var(--gold)}.switch.on::after{left:20px;background:var(--gold)} +.engrave{color:var(--steel);font-size:.58rem;letter-spacing:.26em;text-transform:uppercase;display:flex;align-items:center;gap:9px;margin:2px 0} +.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1}.engrave::before{max-width:10px}.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none} +.tin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:7px;padding:7px 9px;width:100%;outline:none} +.tin:focus{border-color:var(--gold)}.tin::placeholder{color:var(--dim)}.tin.bad{border-color:var(--fail);color:var(--fail)} +.numin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:6px;padding:5px 4px;width:46px;text-align:center;outline:none;font-variant-numeric:tabular-nums} +.numin:focus{border-color:var(--gold)} +@keyframes fieldflash{0%{border-color:var(--gold-hi);background:rgba(218,181,61,.22)}100%{border-color:#231f18;background:#0d0f10}} +.tin.flash,.numin.flash{animation:fieldflash .7s ease} +.arm{font:inherit;font-size:11px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b;border-radius:7px;padding:6px 9px} +.arm.armed{background:rgba(203,107,77,.14);border-color:var(--fail);color:var(--fail)} +.ring{border-radius:50%;background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0);display:grid;place-items:center;position:relative} +.ring.warn{background:conic-gradient(var(--fail) calc(var(--p)*1%),var(--wash) 0)} +.ring::before{content:"";position:absolute;inset:7px;border-radius:50%;background:var(--well)} +.ring b{position:relative;z-index:1;text-align:center} +.dots{display:flex;gap:4px;align-items:center} +.dots i{width:7px;height:7px;border-radius:50%;background:var(--wash);flex:none} +.dots i.on{background:var(--steel)}.dots i.now{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)}.dots i.long{outline:1px solid var(--gold);outline-offset:1px} +.days7{display:flex;gap:4px} +.days7 button{font:inherit;font-size:10px;width:22px;height:22px;border-radius:50%;border:1px solid #33302b;background:#141210;color:var(--dim);cursor:pointer;padding:0} +.days7 button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);border-color:var(--gold-hi);font-weight:700} + +/* ---------- HERO (info left, donut right) ---------- */ +.hero{background:var(--well);border:1px solid #201d17;border-radius:11px;padding:15px;display:flex;flex-direction:column;gap:13px;margin-bottom:12px} +.hero .htop{display:flex;gap:15px;align-items:center} +.hero.fire{animation:firef .6s ease-in-out 3} +.hero.ringing{animation:ringf .9s ease-in-out infinite;border-color:var(--fail)} +@keyframes firef{50%{background:rgba(203,107,77,.22)}} +@keyframes ringf{50%{background:rgba(203,107,77,.16)}} +.hero .rhs{min-width:0;flex:1;display:flex;flex-direction:column;gap:5px} +.hero .htype{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.hero .htype .g{color:var(--gold);font-size:17px} +.hero .hlabel{color:var(--cream);font-size:15px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.hero .hbig{color:var(--cream);font-size:38px;line-height:1;font-weight:700;font-variant-numeric:tabular-nums;display:flex;align-items:center;gap:11px} +.hero.paused .hbig{color:var(--steel)} +.hero .hsub{color:var(--dim);font-size:11px;letter-spacing:.05em} +.hero .lapbadge{font-size:11px;letter-spacing:.12em;color:var(--silver);border:1px solid var(--slate);border-radius:6px; + padding:2px 8px;font-weight:400;font-variant-numeric:tabular-nums;background:transparent;line-height:1;align-self:center} +/* stopwatch sweep dial — analog second-hand, one revolution per minute */ +.swdial{width:86px;height:86px;border-radius:50%;background:var(--well);border:1px solid #201d17;position:relative;display:block} +.swdial::before{content:"";position:absolute;inset:7px;border-radius:50%;border:2px solid var(--wash)} +.swtick{position:absolute;top:5px;left:50%;width:2px;height:8px;margin-left:-1px;background:var(--steel);border-radius:1px;transform-origin:50% 38px} +.swtick.q{transform:rotate(90deg)}.swtick.h{transform:rotate(180deg)}.swtick.t{transform:rotate(270deg)} +.swhand{position:absolute;left:calc(50% - 1px);bottom:50%;width:2px;height:31px;background:var(--gold-hi); + transform-origin:50% 100%;border-radius:1px;box-shadow:0 0 5px rgba(255,215,95,.5)} +.swhub{position:absolute;left:50%;top:50%;width:9px;height:9px;margin:-4.5px 0 0 -4.5px;border-radius:50%; + background:var(--gold);box-shadow:0 0 0 2px var(--well),0 0 6px rgba(218,181,61,.5)} +.hero .donut{flex:none} +.transport{display:flex;gap:7px;flex-wrap:wrap;justify-content:flex-start} + +/* ---------- CREATE (middle) ---------- */ +.create{background:var(--well);border:1px solid #201d17;border-radius:10px;padding:11px;margin-bottom:12px} +.create .row{display:flex;gap:7px;align-items:center;margin-top:9px;flex-wrap:wrap} +.presets{display:flex;gap:6px;flex-wrap:wrap;margin-top:9px} +.cfg{margin-top:9px;display:flex;flex-direction:column;gap:7px} +.cfg .crow{display:flex;align-items:center;gap:8px} +.cfg .crow .lbl{width:58px;color:var(--steel);font-size:.58rem;letter-spacing:.14em;text-transform:uppercase;flex:none} +.cfg .crow .u{color:var(--dim);font-size:10px} +.cfg .crow .sl{color:var(--steel);font-size:.58rem;letter-spacing:.1em;text-transform:uppercase;width:9px} + +/* ---------- LIST (bottom) ---------- */ +.qlist{display:flex;flex-direction:column;gap:8px} +.qrow{display:flex;align-items:center;gap:10px;background:#141210;border:1px solid #201d17;border-radius:9px;padding:8px 10px} +.qrow.fire{animation:firef .6s ease-in-out 3}.qrow.ringing{animation:ringf .9s ease-in-out infinite;border-color:var(--fail)} +.qrow .g{color:var(--gold);font-size:16px;width:19px;text-align:center;flex:none} +.qrow .meta{min-width:0;display:flex;flex-direction:column;gap:2px} +.qrow .meta b{color:var(--cream);font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:104px} +.qrow .meta .ty{color:var(--dim);font-size:.56rem;letter-spacing:.1em;text-transform:uppercase} +.qrow .rd{margin-left:auto;font-size:18px;color:var(--cream);font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap} +.qrow.paused .rd{color:var(--steel)} +.qrow .ctrls{display:flex;gap:5px;flex:none} +.empty{color:var(--dim);font-size:12px;text-align:center;padding:14px 6px} +.toasts{position:absolute;left:12px;right:12px;bottom:10px;display:flex;flex-direction:column;gap:6px;pointer-events:none;z-index:5} +.toast{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 10px;box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .2s ease} +.toast.red{background:linear-gradient(180deg,#b25c43,#8f3f2c)}.toast.gold{background:linear-gradient(180deg,#b79a34,#8a7524);color:var(--panel)} +@keyframes tin{from{opacity:0;transform:translateY(6px)}} +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +</style> +</head> +<body> +<div class="wrap"> +<header class="masthead"> + <div class="eyebrow">archsetup · dupre panel family · timer · iteration 3</div> + <h1>Timer panel — waybar + hero-right</h1> + <p>Third pass. The <b>hero donut moved to the right</b> of the readout; the redundant "bar slot" badge is gone + (the hero <i>is</i> the bar slot). Above the panel sits a <b>live, accurate preview of the actual waybar module</b> — + the same glyph + countdown + "+N" and state colours <code>wtimer render</code> emits, with its hover tooltip. + Presets (renamed from "chips") <b>flash the fields</b> on load instead of toasting, alarms gain a <b>half-past</b> preset, + and each type picked up create-strip ideas from its category's best apps: timers <b>repeat</b>, alarms carry + <b>recurring days + snooze</b> (with a ringing state), pomodoro keeps its configurable cycle.</p> +</header> + +<div class="cols"> + <div> + <div class="barcap">the waybar module · live</div> + <div class="wbar" id="wbar"></div> + <div class="wtip" id="wtip"></div> + <div class="panel" id="panel"></div> + </div> + <div class="side"> + <h2>This pass</h2> + <ul> + <li><b>Donut on the right</b> of the hero info; <b>no "bar slot" label</b>.</li> + <li><b>Live waybar preview</b> — mirrors <code>wtimer render</code>: glyph + countdown + "+N", state colour, tooltip.</li> + <li><b>"Preset"</b> replaces "chip"; loading one <b>flashes the field(s)</b>, no toast.</li> + <li><b>Half-past</b> alarm preset — next X:30, the sibling of top-of-hour's X:00.</li> + </ul> + <h2 style="margin-top:1.5rem">Borrowed per category</h2> + <ul> + <li><b>Timer</b> (MultiTimer, Multi Timer): auto-<b>repeat</b> — restart on finish. Toggle in the create row.</li> + <li><b>Alarm</b> (Alarm Clock Xtreme, Alarmy): <b>recurring weekdays</b> + <b>snooze</b>; fires into a ringing state with SNOOZE / DISMISS.</li> + <li><b>Stopwatch</b> (Stopwatch Timer): sweep dial + infinite <b>laps</b> with the last lap beside the count; run-save deferred to a vNext.</li> + <li><b>Pomodoro</b> (Pomofocus): configurable work/rest short+long, long-break interval, auto-advance, cycle dots.</li> + </ul> + </div> +</div> +</div> + +<script> +"use strict"; +const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches; +const el=(t,c,h)=>{const n=document.createElement(t);if(c)n.className=c;if(h!=null)n.innerHTML=h;return n;}; + +const GL={ timer:'\u{F051B}', alarm:'\u{F0020}', stopwatch:'\u{F13AB}', idle:'\u{F051B}', + pomo_work:'\u{F051C}', pomo_break:'\u{F0176}', paused:'\u{F03E4}', + play:'\u{F040A}', promote:'\u{F0143}', cancel:'\u{F0156}', add:'\u{F0415}', clear:'\u{F0A79}', repeat:'\u{F0456}', bell:'\u{F0020}' }; + +function parseDuration(v){ if(v==null)return null;v=String(v).trim().toLowerCase();if(v==='')return null; + if(/^\d+$/.test(v))return parseInt(v,10)*60; if(!/^(\s*\d+\s*[hms])+$/.test(v))return null; + let m,tot=0;const re=/(\d+)\s*([hms])/g; while((m=re.exec(v)))tot+=m[2]==='h'?+m[1]*3600:m[2]==='m'?+m[1]*60:+m[1]; return tot>0?tot:null; } +function resolveAlarm(v,now){ v=String(v||'').trim().toLowerCase(); + if(v.startsWith('+')){const s=parseDuration(v.slice(1));return s==null?null:now+s;} + if(v==='@hour'||v==='top of hour'){const d=new Date(now*1000);d.setMinutes(0,0,0);d.setHours(d.getHours()+1);return d.getTime()/1000;} + if(v==='@half'||v==='half past'||v==='half-past'){const d=new Date(now*1000);d.setSeconds(0,0);d.setMinutes(30);let e=d.getTime()/1000;if(e<=now){d.setHours(d.getHours()+1);e=d.getTime()/1000;}return e;} + const t=v.match(/^(\d{1,2}):(\d{2})$/); if(t){const hh=+t[1],mm=+t[2];if(hh>23||mm>59)return null;const d=new Date(now*1000);d.setHours(hh,mm,0,0);let e=d.getTime()/1000;if(e<=now)e+=86400;return e;} return null; } +function nextAlarm(hh,mm,days,now){ const base=new Date(now*1000); + for(let d=0;d<=7;d++){const c=new Date(base);c.setDate(base.getDate()+d);c.setHours(hh,mm,0,0);const e=c.getTime()/1000; + if(e<=now)continue; if(!days.length||days.includes(c.getDay()))return e;} return now+86400; } +const fmtTime=s=>{s=Math.max(0,Math.floor(s));const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),x=s%60; + return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;}; +const fmtClock=e=>{const d=new Date(e*1000);return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;}; +const DAYNAMES=['S','M','T','W','T','F','S']; + +const DEFAULT_PRESETS=()=>({ + timer:[{label:'5m',value:'5m',locked:true},{label:'25m',value:'25m',locked:true},{label:'10m',value:'10m',locked:true}, + {label:'15m',value:'15m',locked:true},{label:'30m',value:'30m',locked:true},{label:'60m',value:'60m',locked:true},{label:'2h',value:'2h',locked:true}], + alarm:[{label:'+30m',value:'+30m',locked:true},{label:'top of hour',value:'@hour',locked:true},{label:'half-past',value:'@half',locked:true},{label:'07:00',value:'07:00',locked:true}], + stopwatch:[] +}); +const POMO_PRESETS=[{label:'Classic',ws:25,wl:50,rs:5,rl:15,iv:4,locked:true},{label:'Deep',ws:50,wl:50,rs:10,rl:30,iv:3,locked:true},{label:'Sprint',ws:15,wl:25,rs:3,rl:10,iv:4,locked:true}]; +const POMO_DEFAULT={ws:25,wl:50,rs:5,rl:15,iv:4,auto:true}; +const TYPES=['timer','alarm','stopwatch','pomodoro']; +const COUNTDOWN=['timer','alarm','pomodoro']; +const MAX=10; + +class Engine{ + constructor(){this.items=[];this.seq=0;this.primary=null;this.presets=DEFAULT_PRESETS();this.pomoPresets=POMO_PRESETS.map(p=>({...p}));} + now(){return Date.now()/1000;} + count(){return this.items.length;} + full(){return this.items.length>=MAX;} + add(type,value,label,opts){ + opts=opts||{}; + if(this.full())return {ok:false,reason:`queue full (${MAX}/${MAX})`}; + const now=this.now();this.seq++;const id='t'+this.seq;const it={id,type,label:label||''}; + if(type==='timer'){const s=parseDuration(value);if(s==null)return {ok:false,reason:`bad duration: “${value}”`};it.target=now+s;it.total=s;it.repeat=!!opts.repeat;} + else if(type==='alarm'){const e=resolveAlarm(value,now);if(e==null)return {ok:false,reason:`bad time: “${value}”`}; + const d=new Date(e*1000);it.hh=d.getHours();it.mm=d.getMinutes();it.days=(opts.days||[]).slice();it.snooze=opts.snooze||9; + it.target=it.days.length?nextAlarm(it.hh,it.mm,it.days,now):e;it.total=Math.max(1,it.target-now);} + else if(type==='pomodoro'){const c=opts.ws?opts:POMO_DEFAULT;it.cfg={ws:c.ws*60,wl:c.wl*60,rs:c.rs*60,rl:c.rl*60,iv:Math.max(1,c.iv),auto:c.auto!==false}; + it.phase='work';it.cycle=1;const deep=(1%it.cfg.iv===0);const len=deep?it.cfg.wl:it.cfg.ws;it.target=now+len;it.total=len;} + else if(type==='stopwatch'){it.start=now;it.laps=[];} + else return {ok:false,reason:`unknown type: ${type}`}; + this.items.push(it);if(!this.primary)this.primary=id;return {ok:true,id}; + } + find(id){return this.items.find(i=>i.id===id);} + isPaused(it){return it.type==='stopwatch'?it.paused_elapsed!=null:it.paused_left!=null;} + remaining(it,ref){ref=ref==null?this.now():ref;if(it.type==='stopwatch')return this.isPaused(it)?it.paused_elapsed:ref-it.start;return this.isPaused(it)?it.paused_left:it.target-ref;} + toggle(id){const it=this.find(id);if(!it)return;const now=this.now(); + if(it.type==='stopwatch'){if(this.isPaused(it)){it.start=now-it.paused_elapsed;it.paused_elapsed=null;}else it.paused_elapsed=now-it.start;} + else{if(this.isPaused(it)){it.target=now+it.paused_left;it.paused_left=null;it.awaiting=false;}else it.paused_left=it.target-now;}} + cancel(id){const i=this.items.findIndex(x=>x.id===id);if(i<0)return;this.items.splice(i,1);if(this.primary===id)this.primary=null;} + cancelAll(){this.items=[];this.primary=null;} + promote(id){if(this.find(id))this.primary=id;} + cycle(dir){const ids=this.items.map(i=>i.id);if(!ids.length)return;let c=ids.indexOf(this.effectivePrimary());c=c<0?0:c;this.primary=ids[dir==='prev'?(c-1+ids.length)%ids.length:(c+1)%ids.length];} + lap(id){const it=this.find(id);if(!it||it.type!=='stopwatch')return;it.laps.push({t:this.remaining(it)});} + snooze(id){const it=this.find(id);if(!it)return;it.ringing=false;const m=it.snooze>0?it.snooze:9;it.target=this.now()+m*60;it.total=m*60;} + dismiss(id){const it=this.find(id);if(!it)return;if(it.days&&it.days.length){it.ringing=false;it.target=nextAlarm(it.hh,it.mm,it.days,this.now());it.total=Math.max(1,it.target-this.now());}else this.cancel(id);} + effectivePrimary(){const items=this.items;if(!items.length)return null;const ids=items.map(i=>i.id); + const ring=items.find(i=>i.ringing);if(ring)return ring.id; + if(ids.includes(this.primary))return this.primary;const now=this.now(); + const acd=items.filter(i=>COUNTDOWN.includes(i.type)&&!this.isPaused(i)); + if(acd.length)return acd.reduce((a,b)=>this.remaining(a,now)<=this.remaining(b,now)?a:b).id; + const asw=items.filter(i=>i.type==='stopwatch'&&!this.isPaused(i));if(asw.length)return asw[0].id;return ids[0];} + sortKey(it){const now=this.now(),p=this.isPaused(it),sw=it.type==='stopwatch',r=this.remaining(it,now); + if(it.ringing)return [-1,0,+it.id.slice(1)]; return sw?[p?3:2,-r,+it.id.slice(1)]:[p?1:0,r,+it.id.slice(1)];} + rows(){const prim=this.effectivePrimary(),now=this.now(); + return this.items.slice().sort((a,b)=>{const ka=this.sortKey(a),kb=this.sortKey(b);for(let i=0;i<ka.length;i++){if(ka[i]<kb[i])return -1;if(ka[i]>kb[i])return 1;}return 0;}).map(it=>this.row(it,prim,now));} + daysLabel(it){ if(!it.days||!it.days.length)return 'once'; if(it.days.length===7)return 'daily'; + const wk=[1,2,3,4,5],we=[0,6]; + if(wk.every(d=>it.days.includes(d))&&it.days.length===5)return 'weekdays'; + if(we.every(d=>it.days.includes(d))&&it.days.length===2)return 'weekends'; + return it.days.slice().sort().map(d=>DAYNAMES[d]).join(''); } + row(it,prim,now){ + const rem=this.remaining(it,now),paused=this.isPaused(it); + let disp,sub='',warn=false,prog=null,glyph,pomo=null,ringing=!!it.ringing,badges=[],lastLap=null,sweep=null; + if(it.type==='alarm'){ + if(ringing){disp='RING';sub='alarm ringing';warn=true;prog=1;glyph=GL.alarm;} + else{disp=fmtClock(it.target);sub=`${this.daysLabel(it)} · fires ${fmtClock(it.target)}`;prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.alarm;} + if(it.days&&it.days.length)badges.push({t:this.daysLabel(it),c:'sage'}); + } + else if(it.type==='pomodoro'){disp=fmtTime(rem);prog=Math.max(0,Math.min(1,rem/it.total));const deep=(it.cycle%it.cfg.iv===0); + const ph=it.phase==='work'?(deep?'long work':'work'):(deep?'long break':'short break'); + sub=it.awaiting?`ready · start ${it.phase==='work'?'work':'break'}`:`${ph} · cycle ${it.cycle}/${it.cfg.iv}`; + glyph=it.phase==='work'?GL.pomo_work:GL.pomo_break;pomo={cycle:it.cycle,iv:it.cfg.iv,phase:it.phase,awaiting:!!it.awaiting};} + else if(it.type==='stopwatch'){disp=fmtTime(rem);lastLap=it.laps.length?it.laps[it.laps.length-1].t:null; + sub=it.laps.length?`${it.laps.length} lap${it.laps.length>1?'s':''}`:'running';glyph=GL.stopwatch;sweep=(Math.max(0,rem)%60)/60;} + else {disp=fmtTime(rem);sub=it.repeat?'timer · repeats':'timer';prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.timer;if(it.repeat)badges.push({t:'repeat',c:''});} + if(prog!=null&&!ringing&&rem<=Math.min(30,it.total*0.15))warn=true; + if(paused)glyph=GL.paused; + return {id:it.id,type:it.type,glyph,label:it.label||({timer:'Timer',alarm:'Alarm',stopwatch:'Stopwatch',pomodoro:'Pomodoro'})[it.type], + typeLabel:it.type,disp,sub,paused,ringing,primary:it.id===prim,prog,warn,pomo,badges,lastLap,sweep,laps:it.laps?it.laps.length:0}; + } + /* mirror wtimer render_payload for the bar */ + barPayload(){ const now=this.now(); const items=this.items; + if(!items.length)return {glyph:GL.idle,text:'',plus:0,cls:'idle',tip:['No timers']}; + const pid=this.effectivePrimary(); const p=this.find(pid); + let cls; if(p.ringing)cls='urgent'; else if(this.isPaused(p))cls='paused'; + else if(p.type==='pomodoro')cls=(p.phase==='work'?'pomodoro-work':'pomodoro-break'); + else if((p.type==='timer'||p.type==='alarm')&&this.remaining(p,now)<60)cls='urgent'; else cls=p.type; + const glyph=this.isPaused(p)?GL.paused:(p.type==='pomodoro'?(p.phase==='work'?GL.pomo_work:GL.pomo_break):GL[p.type]); + const text=p.ringing?'RING':fmtTime(this.remaining(p,now)); + const tip=[items.length!==1?`${items.length} active`:'1 timer']; + for(const i of items){const g=this.isPaused(i)?GL.paused:(i.type==='pomodoro'?(i.phase==='work'?GL.pomo_work:GL.pomo_break):GL[i.type]); + const lb=i.label||i.type;const st=this.isPaused(i)?' (paused)':(i.ringing?' (ringing)':''); + const val=i.type==='alarm'?(i.ringing?'RING':fmtClock(i.target)):(i.type==='pomodoro'?`${i.cycle}/${i.cfg.iv} ${fmtTime(this.remaining(i,now))}`:fmtTime(this.remaining(i,now))); + tip.push(`${g} ${lb} ${val}${st}`);} + return {glyph,text,plus:items.length-1,cls,tip}; + } + presetsFor(t){return (this.presets[t]||[]).map(p=>({...p}));} + addPreset(t,label,value){if(!TYPES.includes(t)||t==='pomodoro'||t==='stopwatch')return {ok:false,reason:'no custom preset here'}; + if(t==='timer'&&parseDuration(value)==null)return {ok:false,reason:'bad duration'};(this.presets[t]||(this.presets[t]=[])).push({label,value,locked:false});return {ok:true};} + deletePreset(t,label){const a=this.presets[t]||[];const i=a.findIndex(p=>p.label===label);if(i<0)return {ok:false,reason:'not found'};if(a[i].locked)return {ok:false,reason:'default — locked'};a.splice(i,1);return {ok:true};} +} + +let notifPerm=(typeof Notification!=='undefined')?Notification.permission:'denied'; +function tryNotify(title,body){if(typeof Notification==='undefined')return;if(notifPerm==='granted'){try{new Notification(title,{body});}catch(e){}}else if(notifPerm==='default'){Notification.requestPermission().then(p=>notifPerm=p);}} +function toaster(host){const wrap=el('div','toasts');host.appendChild(wrap);return (msg,kind)=>{const t=el('div','toast'+(kind?' '+kind:''),msg);wrap.appendChild(t);setTimeout(()=>{t.style.transition='opacity .3s';t.style.opacity='0';setTimeout(()=>t.remove(),300);},2600);};} +function flash(...inputs){for(const i of inputs){if(!i)continue;i.classList.remove('flash');void i.offsetWidth;i.classList.add('flash');}} +function dotsHTML(p){if(!p)return '';let h='<span class="dots">';const pos=(p.cycle-1)%p.iv;for(let i=0;i<p.iv;i++){const isLong=(i===p.iv-1);let cls='';if(i<pos)cls='on';if(i===pos)cls='now';if(isLong)cls+=' long';h+=`<i class="${cls.trim()}"></i>`;}return h+'</span>';} +function badgesHTML(bs){return (bs||[]).map(b=>`<span class="badge ${b.c}">${b.t}</span>`).join(' ');} + +function mount(host, engine, bar, tip){ + const toast=toaster(host); + const head=el('div','phead',`<span class="brand">Timer</span><span class="pcount">queue <b class="cnt">0</b>/${MAX}</span>`); + const clear=el('button','key sm',GL.clear+' CLEAR ALL');clear.style.marginLeft='8px'; + clear.addEventListener('click',()=>{if(!engine.count())return;engine.cancelAll();toast('cleared all');render();}); + head.appendChild(clear); + // close button — flat circular ✕ like the net/bt/audio panels (Close/Esc); the + // waybar module reopens it, mirroring the real on-click: timer-panel toggle. + const closeBtn=el('button','x-btn','✕');closeBtn.title='Close (Esc)'; + const barcap=document.querySelector('.barcap'); + function setClosed(c){host.classList.toggle('closed',c);if(barcap)barcap.textContent=c?'the waybar module · click it to reopen the panel':'the waybar module · live';} + closeBtn.addEventListener('click',()=>setClosed(true)); + head.appendChild(closeBtn); + bar.addEventListener('click',()=>setClosed(!host.classList.contains('closed'))); + document.addEventListener('keydown',e=>{if(e.key==='Escape')setClosed(true);}); + const hero=el('div','hero'); + const create=buildCreate(); + const list=el('div','qlist'); + host.append(head,hero,create,list); + const flashing=new Set(); + + function itemClick(e){const b=e.target.closest('[data-act]');if(!b)return;const id=b.dataset.id,a=b.dataset.act; + if(a==='toggle')engine.toggle(id); + else if(a==='promote'){engine.promote(id);toast('to bar slot');} + else if(a==='cycle')engine.cycle(b.dataset.dir); + else if(a==='lap'){engine.lap(id);toast('lap recorded');} + else if(a==='stop'){engine.cancel(id);toast('stopped');} + else if(a==='snooze'){engine.snooze(id);toast('snoozed','gold');} + else if(a==='dismiss'){engine.dismiss(id);toast('dismissed');} + else if(a==='cancel'){if(b.dataset.armed){engine.cancel(id);toast('cancelled');} + else{b.dataset.armed='1';b.classList.add('armed');b.textContent='sure?';setTimeout(()=>{if(b.isConnected){b.textContent='×';b.classList.remove('armed');delete b.dataset.armed;}},2000);return;}} + render();} + hero.addEventListener('click',itemClick); + list.addEventListener('click',itemClick); + + function buildCreate(){ + const box=el('div','create'); + const seg=el('div','seg'); + TYPES.forEach(t=>{const b=el('button',t==='timer'?'on':'',t[0].toUpperCase()+t.slice(1));b.dataset.t=t;seg.appendChild(b);}); + const body=el('div');box.append(seg,body); + let selType='timer'; + seg.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;selType=b.dataset.t;[...seg.children].forEach(x=>x.classList.toggle('on',x===b));paintBody();}); + function doAdd(type,val,lab,opts){const r=engine.add(type,val?val.value:'',lab.value.trim(),opts); + if(!r.ok){if(val)val.classList.add('bad');toast(r.reason,'red');return;} + if(val){val.classList.remove('bad');if(!val.disabled)val.value='';}lab.value='';toast('added '+type,'gold');render();} + function paintBody(){ + body.innerHTML=''; + if(selType==='timer'){ + const ps=el('div','presets'); + engine.presetsFor('timer').forEach(p=>{const c=el('span','preset'+(p.locked?'':''),p.label+(p.locked?'':` <span class="x" data-del="${encodeURIComponent(p.label)}">×</span>`));c.dataset.val=p.value;ps.appendChild(c);}); + ps.appendChild(Object.assign(el('span','preset','+ preset'),{}) );ps.lastChild.dataset.newp='1';ps.lastChild.style.opacity='.7'; + const row=el('div','row'); + const val=el('input','tin');val.placeholder='5m · 1h30m · 90s';val.style.flex='2'; + const lab=el('input','tin');lab.placeholder='label (optional)';lab.style.flex='2'; + const rep=el('span','switch');const reptag=el('span',null,'<span style="color:var(--steel);font-size:.56rem;letter-spacing:.12em;text-transform:uppercase">repeat</span>'); + const addk=el('button','key on',GL.add+' ADD'); + const repwrap=el('span',null,'');repwrap.style.display='inline-flex';repwrap.style.alignItems='center';repwrap.style.gap='6px';repwrap.append(rep,reptag); + row.append(val,lab,repwrap,addk); + body.append(ps,row); + rep.addEventListener('click',()=>rep.classList.toggle('on')); + ps.addEventListener('click',e=>{const del=e.target.closest('[data-del]'); + if(del){const r=engine.deletePreset('timer',decodeURIComponent(del.dataset.del));toast(r.ok?'preset removed':('preset: '+r.reason),r.ok?'gold':'red');paintBody();return;} + if(e.target.closest('[data-newp]')){const lb=prompt('Preset label:');if(!lb)return;const vv=prompt('Value for “'+lb+'”:',lb)||lb;const r=engine.addPreset('timer',lb,vv);toast(r.ok?'preset added':('preset: '+r.reason),r.ok?'gold':'red');paintBody();return;} + const c=e.target.closest('.preset');if(!c||c.dataset.val==null)return;val.value=c.dataset.val;flash(val);}); + addk.addEventListener('click',()=>doAdd('timer',val,lab,{repeat:rep.classList.contains('on')})); + [val,lab].forEach(x=>x.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd('timer',val,lab,{repeat:rep.classList.contains('on')});})); + } + else if(selType==='alarm'){ + const ps=el('div','presets'); + engine.presetsFor('alarm').forEach(p=>{const c=el('span','preset',p.label+(p.locked?'':` <span class="x" data-del="${encodeURIComponent(p.label)}">×</span>`));c.dataset.val=p.value;ps.appendChild(c);}); + const row=el('div','row'); + const val=el('input','tin');val.placeholder='HH:MM · +30m · @hour · @half';val.style.flex='2'; + const lab=el('input','tin');lab.placeholder='label (optional)';lab.style.flex='2'; + const addk=el('button','key on',GL.add+' ADD'); + row.append(val,lab,addk); + const drow=el('div','row');const days=el('div','days7');const sel=new Set(); + DAYNAMES.forEach((d,i)=>{const b=el('button',null,d);b.dataset.d=i;b.title=['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][i];days.appendChild(b);}); + const quick=el('span',null,'');const wkd=el('button','key sm','weekdays');const evd=el('button','key sm','daily'); + quick.style.display='inline-flex';quick.style.gap='5px';quick.append(wkd,evd); + const srow=el('div','row');const slbl=el('span',null,'<span style="color:var(--steel);font-size:.56rem;letter-spacing:.12em;text-transform:uppercase">snooze</span>'); + const sn=el('input','numin');sn.value='9';sn.inputMode='numeric';const smin=el('span',null,'<span style="color:var(--dim);font-size:10px">min</span>'); + srow.append(slbl,sn,smin); + drow.append(days,quick); + body.append(ps,row,drow,srow); + function paintDays(){[...days.children].forEach(b=>b.classList.toggle('on',sel.has(+b.dataset.d)));} + days.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;const d=+b.dataset.d;sel.has(d)?sel.delete(d):sel.add(d);paintDays();}); + wkd.addEventListener('click',()=>{sel.clear();[1,2,3,4,5].forEach(d=>sel.add(d));paintDays();}); + evd.addEventListener('click',()=>{sel.clear();[0,1,2,3,4,5,6].forEach(d=>sel.add(d));paintDays();}); + function opts(){return {days:[...sel],snooze:parseInt(sn.value,10)||9};} + ps.addEventListener('click',e=>{const del=e.target.closest('[data-del]'); + if(del){const r=engine.deletePreset('alarm',decodeURIComponent(del.dataset.del));toast(r.ok?'preset removed':('preset: '+r.reason),r.ok?'gold':'red');paintBody();return;} + const c=e.target.closest('.preset');if(!c||c.dataset.val==null)return;val.value=c.dataset.val;flash(val);}); + addk.addEventListener('click',()=>doAdd('alarm',val,lab,opts())); + [val,lab].forEach(x=>x.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd('alarm',val,lab,opts());})); + } + else if(selType==='stopwatch'){ + const row=el('div','row'); + const lab=el('input','tin');lab.placeholder='label (optional)';lab.style.flex='2'; + const addk=el('button','key on',GL.add+' ADD'); + row.append(lab,addk); + const note=el('div','engrave','counts up from zero · lap while running'); + body.append(row,note); + addk.addEventListener('click',()=>doAdd('stopwatch',null,lab)); + lab.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd('stopwatch',null,lab);}); + } + else { + const ps=el('div','presets'); + engine.pomoPresets.forEach(p=>{const c=el('span','preset',p.label);c.dataset.pp=p.label;ps.appendChild(c);}); + const cfg=el('div','cfg');const mk=v=>{const i=el('input','numin');i.value=v;i.inputMode='numeric';return i;}; + const ws=mk(POMO_DEFAULT.ws),wl=mk(POMO_DEFAULT.wl),rs=mk(POMO_DEFAULT.rs),rl=mk(POMO_DEFAULT.rl),iv=mk(POMO_DEFAULT.iv); + const auto=el('span','switch on');auto.dataset.on='1'; + const rW=el('div','crow');rW.append(el('span','lbl','Work'),el('span','sl','S'),ws,el('span','sl','L'),wl,el('span','u','min')); + const rR=el('div','crow');rR.append(el('span','lbl','Rest'),el('span','sl','S'),rs,el('span','sl','L'),rl,el('span','u','min')); + const rI=el('div','crow');rI.append(el('span','lbl','Long ev.'),iv,el('span','u','cycles → long work + long break')); + const rA=el('div','crow');rA.append(el('span','lbl','Auto'),auto,el('span','u','advance into the next phase')); + cfg.append(rW,rR,rI,rA); + const row=el('div','row');const lab=el('input','tin');lab.placeholder='label (optional)';lab.style.flex='2'; + const addk=el('button','key on',GL.add+' ADD CYCLE');row.append(lab,addk); + body.append(ps,cfg,row); + auto.addEventListener('click',()=>{auto.classList.toggle('on');auto.dataset.on=auto.classList.contains('on')?'1':'';}); + ps.addEventListener('click',e=>{const c=e.target.closest('[data-pp]');if(!c)return;const p=engine.pomoPresets.find(x=>x.label===c.dataset.pp);if(!p)return; + ws.value=p.ws;wl.value=p.wl;rs.value=p.rs;rl.value=p.rl;iv.value=p.iv;[...ps.children].forEach(x=>x.classList.toggle('on',x===c));flash(ws,wl,rs,rl,iv);}); + function pnum(inp,d){const n=parseInt(inp.value,10);return (isNaN(n)||n<1)?d:n;} + addk.addEventListener('click',()=>{const r=engine.add('pomodoro','',lab.value.trim(),{ws:pnum(ws,25),wl:pnum(wl,50),rs:pnum(rs,5),rl:pnum(rl,15),iv:pnum(iv,4),auto:!!auto.dataset.on}); + if(!r.ok){toast(r.reason,'red');return;}lab.value='';toast('pomodoro added','gold');render();}); + lab.addEventListener('keydown',e=>{if(e.key==='Enter')addk.click();}); + } + } + paintBody(); + return box; + } + + function renderBar(){ + const p=engine.barPayload(); + bar.className='wmod '+p.cls;bar.title=p.tip.join('\n'); + bar.innerHTML=`<span class="wg">${p.glyph}</span>`+(p.text?`<span class="wt">${p.text}</span>`:'')+(p.plus>0?`<span class="wp">+${p.plus}</span>`:''); + tip.innerHTML=`<div class="th">hover tooltip</div>`+p.tip.map((l,i)=>`<div class="tl" style="${i===0?'color:var(--steel)':''}">${l}</div>`).join(''); + } + + function render(){ + head.querySelector('.cnt').textContent=engine.count(); + const rows=engine.rows(),primId=engine.effectivePrimary(); + const h=rows.find(r=>r.id===primId); + hero.className='hero'+(h&&h.paused?' paused':'')+(h&&h.ringing?' ringing':'')+(h&&flashing.has(h.id)?' fire':''); + if(!h){hero.innerHTML='<div class="empty" style="width:100%">No timers running — add one below.</div>';} + else{ + const ringP=h.prog!=null?Math.round(h.prog*100):0; + const donutHTML = h.type==='stopwatch' + ? `<span class="swdial" title="seconds sweep"><span class="swtick"></span><span class="swtick q"></span><span class="swtick h"></span><span class="swtick t"></span><span class="swhand" style="transform:rotate(${Math.round((h.sweep||0)*360)}deg)"></span><span class="swhub"></span></span>` + : `<span class="ring${h.warn?' warn':''}" style="--p:${ringP};width:86px;height:86px"><b style="color:var(--cream);font-size:14px">${ringP}<small style="font-size:9px;color:var(--dim)">%</small></b></span>`; + let transport; + if(h.ringing) transport=`<button class="key" data-act="snooze" data-id="${h.id}">${GL.play} SNOOZE ${engine.find(h.id).snooze}m</button><button class="key red" data-act="dismiss" data-id="${h.id}">DISMISS</button>`; + else if(h.type==='stopwatch') transport=`<button class="key icon" data-act="cycle" data-dir="prev" title="prev">‹</button><button class="key" data-act="toggle" data-id="${h.id}">${h.paused?GL.play+' RESUME':GL.paused+' PAUSE'}</button><button class="key" data-act="lap" data-id="${h.id}">LAP</button><button class="key red" data-act="stop" data-id="${h.id}">STOP</button><button class="key icon" data-act="cycle" data-dir="next" title="next">›</button>`; + else {const start=h.pomo&&h.pomo.awaiting?(GL.play+' START '+(h.pomo.phase==='work'?'WORK':'BREAK')):(h.paused?GL.play+' RESUME':GL.paused+' PAUSE'); + transport=`<button class="key icon" data-act="cycle" data-dir="prev" title="prev">‹</button><button class="key" data-act="toggle" data-id="${h.id}">${start}</button><button class="key red icon" data-act="cancel" data-id="${h.id}" title="cancel">${GL.cancel}</button><button class="key icon" data-act="cycle" data-dir="next" title="next">›</button>`;} + hero.innerHTML= + `<div class="htop"> + <div class="rhs"> + <div class="htype"><span class="g">${h.glyph}</span><span class="badge ${h.paused?'dim':''}">${h.typeLabel}</span>${badgesHTML(h.badges)}${h.pomo?dotsHTML(h.pomo):''}</div> + <div class="hlabel">${h.label}</div> + <div class="hbig">${h.disp}${h.type==='stopwatch'&&h.lastLap!=null?`<span class="lapbadge">LAP ${fmtTime(h.lastLap)}</span>`:''}</div> + <div class="hsub">${h.sub}</div> + </div> + <div class="donut">${donutHTML}</div> + </div> + <div class="transport">${transport}</div>`; + } + list.innerHTML=''; + const rest=rows.filter(r=>r.id!==primId); + list.appendChild(el('div','engrave','queue <span class="cnt">· '+rest.length+'</span>')); + if(!rest.length)list.appendChild(el('div','empty',h?'Only one item is queued. Add more above.':'')); + rest.forEach(r=>{ + const row=el('div','qrow'+(r.paused?' paused':'')+(r.ringing?' ringing':'')+(flashing.has(r.id)?' fire':'')); + let ctrls; + if(r.ringing)ctrls=`<button class="key sm" data-act="snooze" data-id="${r.id}">SNOOZE</button><button class="key sm red" data-act="dismiss" data-id="${r.id}">OFF</button>`; + else if(r.type==='stopwatch')ctrls=`<button class="key sm" data-act="lap" data-id="${r.id}">LAP</button><button class="key sm red" data-act="stop" data-id="${r.id}">STOP</button><button class="key icon" data-act="promote" data-id="${r.id}" title="to bar slot">${GL.promote}</button>`; + else ctrls=`<button class="key icon" data-act="toggle" data-id="${r.id}" title="pause/resume">${r.paused?GL.play:GL.paused}</button><button class="key icon" data-act="promote" data-id="${r.id}" title="to bar slot">${GL.promote}</button><button class="arm" data-act="cancel" data-id="${r.id}" title="cancel">×</button>`; + row.innerHTML=`<span class="lamp ${r.ringing?'red':(r.paused?'off':(r.warn?'red':''))}"></span><span class="g">${r.glyph}</span><span class="meta"><b>${r.label}</b><span class="ty">${r.sub}</span></span><span class="rd">${r.disp}</span><span class="ctrls">${ctrls}</span>`; + list.appendChild(row); + }); + renderBar(); + } + engine._render=render;engine._flash=id=>{flashing.add(id);setTimeout(()=>flashing.delete(id),1800);};engine._toast=toast; + render(); +} + +const engine=new Engine(); +const pomo=engine.add('pomodoro','','Deep work',{ws:25,wl:50,rs:5,rl:15,iv:4,auto:true}); +engine.add('timer','45s','Egg',{repeat:false}); +engine.add('timer','5m','Tea',{repeat:true}); +const sw=engine.add('stopwatch','','Debug run'); +{const s=engine.find(sw.id);s.start=engine.now()-215;s.laps=[{t:72},{t:158}];} // ~3:35 elapsed, last lap 2:38 +engine.add('alarm','07:00','Wake',{days:[1,2,3,4,5],snooze:9}); +engine.promote(sw.id); // show the new stopwatch sweep-dial + lap badge in the hero +mount(document.getElementById('panel'), engine, document.getElementById('wbar'), document.getElementById('wtip')); + +function loop(){const fired=engine.tick?engine.tick():tickEngine(engine); + for(const f of fired){engine._flash(f.id);engine._toast((f.kind==='done'?GL.bell+' ':'')+f.title,f.kind==='done'?'red':'gold');tryNotify(f.title,f.body);} + engine._render();} +/* engine.tick lives on the class below via prototype patch to keep add()/tick together readable */ +Engine.prototype.tick=function(){const now=this.now(),fired=[]; + for(const it of this.items.slice()){ + if(!COUNTDOWN.includes(it.type)||this.isPaused(it)||it.ringing)continue; + if(it.target-now>0)continue; + if(it.type==='pomodoro'){const c=it.cfg; + if(it.phase==='work'){const deep=(it.cycle%c.iv===0);fired.push({id:it.id,kind:'pomo',title:`Pomodoro · ${deep?'long':'short'} break`,body:it.label||`cycle ${it.cycle}`}); + it.phase='rest';const len=deep?c.rl:c.rs;it.total=len;if(c.auto)it.target=now+len;else{it.paused_left=len;it.awaiting=true;}} + else{it.cycle+=1;const deep=(it.cycle%c.iv===0);fired.push({id:it.id,kind:'pomo',title:'Pomodoro · back to work',body:it.label||`cycle ${it.cycle}`}); + it.phase='work';const len=deep?c.wl:c.ws;it.total=len;if(c.auto)it.target=now+len;else{it.paused_left=len;it.awaiting=true;}}} + else if(it.type==='alarm'){fired.push({id:it.id,kind:'done',title:'Alarm · '+(it.label||fmtClock(it.target)),body:'alarm ringing'});it.ringing=true;} + else{if(it.repeat){fired.push({id:it.id,kind:'done',title:'Timer · '+(it.label||'done')+' · repeating',body:'restarted'});it.target=now+it.total;} + else{fired.push({id:it.id,kind:'done',title:'Timer · '+(it.label||'done'),body:'time’s up'});this.cancel(it.id);}} + } + return fired;}; +setInterval(loop, reduced?1000:250); +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-03-instrument-console-panels-prototype.html b/docs/prototypes/2026-07-03-instrument-console-panels-prototype.html new file mode 100644 index 0000000..0258f20 --- /dev/null +++ b/docs/prototypes/2026-07-03-instrument-console-panels-prototype.html @@ -0,0 +1,1359 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Instrument consoles — network + bluetooth</title> +<style> +:root { + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 4rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.masthead{max-width:1280px;margin:0 auto 1.8rem} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} +.masthead p{color:var(--dim);font-size:.86rem;max-width:80ch} +.masthead b{color:var(--silver);font-weight:700} + +.stage{display:flex;gap:2.2rem;flex-wrap:wrap;max-width:1280px;margin:0 auto;align-items:flex-start} +.slot{width:400px} +.slot-label{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .55rem .2rem} +.aside{flex:1 1 300px;min-width:280px} +.aside h3{color:var(--steel);font-size:.7rem;letter-spacing:.24em;text-transform:uppercase;margin:1.1rem 0 .5rem} +.aside h3:first-child{margin-top:.2rem} +.aside ul{list-style:none} +.aside li{font-size:.82rem;padding:.22rem 0 .22rem 1.1rem;position:relative} +.aside li::before{content:"·";color:var(--gold);position:absolute;left:.25rem} +.aside li em{color:var(--dim);font-style:normal} +.aside li b{color:var(--cream);font-weight:700} +.demo-box{border:1px dashed var(--wash);border-radius:10px;padding:.8rem 1rem;margin-top:1rem} +.demo-box label{display:flex;gap:.6rem;align-items:center;font-size:.82rem;cursor:pointer;color:var(--silver);margin-top:.45rem} +.demo-box label:first-child{margin-top:0} +.demo-box input{accent-color:#dab53d} +.demo-box .hint{color:var(--dim);font-size:.73rem;margin:.15rem 0 0 1.5rem} +.reset{font:inherit;font-size:.78rem;color:var(--silver);background:transparent;border:1px solid var(--wash); + border-radius:8px;padding:.4rem .9rem;cursor:pointer;margin-top:.8rem} +.reset:hover{background:var(--wash)} + +.panel{background:var(--panel);border:2px solid var(--gold);border-radius:16px;padding:17px 19px; + box-shadow:0 18px 50px rgba(0,0,0,.55);font-size:13.5px;width:380px;position:relative; + transition:opacity .25s,transform .25s} +.panel.closed{opacity:0;transform:translateY(-8px);pointer-events:none} +.reopen{display:none;font:inherit;font-size:.75rem;color:var(--gold);background:transparent; + border:1px dashed var(--gold);border-radius:8px;padding:.5rem 1rem;cursor:pointer;margin-top:.6rem} + +.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);flex:0 0 auto; + box-shadow:0 0 6px 1px rgba(116,147,47,.55)} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.55)} +.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} +.lamp.off{background:var(--wash);box-shadow:none} +.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite} +@keyframes pulse{50%{opacity:.25}} + +.b-face{background:var(--raise);border-radius:12px;border:1px solid #262320;padding:11px 14px} +.b-id{display:flex;align-items:center;gap:9px} +.b-id .state-word{color:var(--gold);font-weight:700;font-size:15px;letter-spacing:.12em} +.b-id .unit{color:var(--steel);font-size:.68rem;letter-spacing:.3em;margin-left:auto} +.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold); + border-radius:4px;padding:1px 6px;display:none} +.badge.show{display:inline-block} +.badge.red{background:var(--fail);color:var(--cream)} +.x-btn{margin-left:6px;color:var(--dim);border:0;background:transparent;font:inherit;font-size:1rem; + cursor:pointer;border-radius:50%;width:26px;height:26px;line-height:1;flex:0 0 auto} +.x-btn:hover{background:var(--wash);color:var(--silver)} +.switch{width:38px;height:20px;border-radius:10px;background:var(--wash); + border:1px solid var(--slate);position:relative;flex:0 0 auto;cursor:pointer} +.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px; + border-radius:50%;background:var(--dim);transition:left .15s} +.switch.on{background:var(--slate);border-color:var(--gold)} +.switch.on::after{left:19px;background:var(--gold)} + +.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.32em;text-transform:uppercase; + display:flex;align-items:center;gap:10px;margin:12px 0 6px} +.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.engrave::before{max-width:12px} +.engrave .act{color:var(--dim);letter-spacing:.06em;text-transform:none;font-size:.72rem;cursor:pointer} +.engrave .act:hover{color:var(--gold)} + +.chan .line1{display:flex;align-items:baseline;gap:9px} +.chan .ssid{color:var(--cream);font-weight:700;font-size:14.5px} +.chan .line2{color:var(--dim);font-size:11.5px;margin-top:2px} +.chan .chip{color:var(--dim);cursor:pointer;border-bottom:1px dotted var(--wash)} +.chan .chip:hover{color:var(--gold)} +.chan .chip.on{color:var(--gold)} +.ladder{display:inline-flex;gap:2px;align-items:flex-end;height:12px} +.ladder i{width:4px;background:var(--wash);border-radius:1px} +.ladder i:nth-child(1){height:4px}.ladder i:nth-child(2){height:7px} +.ladder i:nth-child(3){height:10px}.ladder i:nth-child(4){height:12px} +.ladder.l1 i:nth-child(-n+1){background:var(--gold)} +.ladder.l2 i:nth-child(-n+2){background:var(--gold)} +.ladder.l3 i:nth-child(-n+3){background:var(--gold)} +.ladder.l4 i{background:var(--gold)} + +/* section row budgets: lists never grow the panel — they scroll inside it, + cut at a half row so the peek says "there's more" */ +.sec-scroll{overflow-y:auto;overscroll-behavior:contain} +#networks.sec-scroll{max-height:160px} +#tunnels.sec-scroll{max-height:131px} +#b-paired.sec-scroll{max-height:160px} +#b-nearby.sec-scroll{max-height:131px} +.engrave .cnt{color:var(--dim);letter-spacing:.12em;margin-left:2px} +.lamp-row{display:flex;align-items:center;gap:9px;padding:5px 6px;border-radius:7px;font-size:12.5px;cursor:pointer;position:relative} +.lamp-row:hover{background:var(--wash)} +.lamp-row .who{color:var(--silver);white-space:nowrap} +.lamp-row .who b{color:var(--cream)} +.lamp-row .what{margin-left:auto;color:var(--dim);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.lamp-row.busy{pointer-events:none} +.lamp-row .zap,.lamp-row .pen{display:none;color:var(--dim);border:0;background:transparent;font:inherit;font-size:.85rem; + cursor:pointer;border-radius:5px;padding:0 5px;flex:0 0 auto} +.lamp-row:hover .zap,.lamp-row:hover .pen{display:inline-block} +.lamp-row .zap:hover{color:var(--fail)} +.lamp-row .pen:hover{color:var(--gold)} +.lamp-row.armed-soft{background:rgba(218,181,61,.10)} +.lamp-row.armed-soft .what{color:var(--gold)} +.lamp-row.armed{background:rgba(203,107,77,.12)} +.lamp-row.armed .what{color:var(--fail)} +.lamp-row.armed .zap{display:inline-block;color:var(--fail)} + +.console-btns{display:flex;gap:8px;margin-top:2px} +.c-btn{flex:1;text-align:center;cursor:pointer;font:inherit;font-size:11.5px; + background:linear-gradient(180deg,#23211e,#191715);color:var(--silver); + border:1px solid #33302b;border-bottom-color:#0c0b0a;border-radius:8px;padding:8px 4px; + box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} +.c-btn:hover{color:var(--gold);border-color:var(--gold)} +.c-btn:active{transform:translateY(1px)} +.c-btn:disabled{opacity:.4;pointer-events:none} + +.meters{display:flex;gap:12px;margin-top:10px} +.meter{flex:1;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:9px 10px 7px;cursor:default;position:relative} +.meter.testing{animation:flash 1s ease-in-out infinite;border-color:var(--gold)} +@keyframes flash{50%{box-shadow:0 0 10px 1px rgba(218,181,61,.35)}} +.meter.held{border-color:var(--gold);cursor:pointer} +.meter .hold-tag{display:none;position:absolute;top:6px;right:8px;font-size:.56rem;letter-spacing:.2em; + color:var(--panel);background:var(--gold);border-radius:3px;padding:0 4px} +.meter .mode-tag{position:absolute;top:6px;left:8px;font-size:.56rem;letter-spacing:.2em;color:var(--pass)} +.meter .mode-tag.test{color:var(--gold)} +.meter .mode-tag.off{color:var(--dim)} +.meter.held .hold-tag{display:block} +.meter .dial{position:relative;height:52px;overflow:hidden;margin-top:13px} +.meter .arc{position:absolute;inset:0 0 -52px 0;border:2px solid var(--wash);border-radius:50%} +.meter .tick{position:absolute;left:50%;bottom:0;width:1.5px;height:10px;background:var(--steel);transform-origin:50% 52px} +.meter .needle{position:absolute;left:50%;bottom:0;width:2px;height:44px;background:var(--gold-hi); + transform-origin:50% 100%;transform:rotate(-60deg);border-radius:2px; + box-shadow:0 0 6px rgba(255,215,95,.5);transition:transform .45s cubic-bezier(.3,1.3,.5,1)} +.meter .needle.dead{background:var(--wash);box-shadow:none} +.meter .needle.low{background:var(--fail);box-shadow:0 0 6px rgba(203,107,77,.5)} +.meter .hub{position:absolute;left:50%;bottom:-4px;width:9px;height:9px;margin-left:-4.5px;border-radius:50%;background:var(--gold)} +.meter .m-value{color:var(--cream);font-size:13px;text-align:center;font-weight:700;margin-top:6px;font-variant-numeric:tabular-nums} +.meter .m-value small{color:var(--dim);font-weight:400} +.meter .m-value.low{color:var(--fail)} +.meter .m-label{color:var(--steel);font-size:.62rem;letter-spacing:.26em;text-align:center;margin-top:2px; + white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.meter-note{color:var(--dim);font-size:10px;text-align:center;margin-top:5px;min-height:1.2em} + +.well{border:1px solid var(--wash);border-radius:10px;background:var(--well)} +.outwrap{position:relative;margin-top:10px} +.outwrap .output{margin-top:0} +.o-clear{display:none;position:absolute;top:3px;right:5px;z-index:2;color:var(--dim); + border:0;background:var(--well);font:inherit;font-size:.8rem;cursor:pointer; + border-radius:50%;width:20px;height:20px;line-height:1} +.o-clear:hover{color:var(--silver);background:var(--wash)} +.outwrap.has .o-clear{display:block} +.output{margin-top:10px;padding:8px 10px;max-height:170px;overflow-y:auto;font-size:11.5px} +.output:empty{padding:4px 10px;min-height:10px} +.o-step{display:flex;gap:8px;align-items:flex-start;padding:2.5px 0} +.o-step .lamp{margin-top:4px;width:7px;height:7px} +.o-step .t b{color:var(--cream);font-weight:700} +.o-step .t .why{color:var(--dim);display:block;font-size:10.5px} +.o-step .t .ev{color:var(--steel);display:block;font-size:11px} +.o-step.repair .t b{color:var(--gold)} +.o-line{padding:2px 0;color:var(--silver)} +.o-line b{color:var(--steel);font-weight:400} +.o-verdict{margin-top:5px;padding-top:5px;border-top:1px solid var(--wash);color:var(--gold);font-weight:700} +.o-verdict.ok{color:var(--pass)} +.o-tip{color:var(--dim);font-size:10.5px;margin-top:4px} + +.toast{margin-top:9px;font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px; + padding:5px 10px;opacity:0;transition:opacity .25s;min-height:1.4em} +.toast.show{opacity:1} +.toast.err{background:transparent;border:1px solid var(--fail);color:var(--fail)} + +.overlay{position:absolute;inset:0;background:rgba(10,12,13,.82);border-radius:14px;display:none; + align-items:center;justify-content:center;z-index:5} +.overlay.show{display:flex} +.dlg{background:var(--panel);border:1px solid var(--gold);border-radius:12px;padding:16px 18px;width:300px} +.dlg h4{color:var(--cream);font-size:13px;margin-bottom:4px} +.dlg .sub{color:var(--dim);font-size:11px;margin-bottom:10px} +.dlg .passkey{color:var(--gold-hi);font-size:22px;font-weight:700;letter-spacing:.18em; + text-align:center;margin:6px 0 12px;font-variant-numeric:tabular-nums} +.dlg input{width:100%;font:inherit;font-size:12.5px;color:var(--silver);background:var(--well); + border:1px solid var(--wash);border-radius:7px;padding:7px 9px;margin-bottom:8px;caret-color:var(--gold)} +.dlg input:focus{outline:none;border-color:var(--gold)} +.dlg .dlg-btns{display:flex;gap:8px;justify-content:flex-end;margin-top:4px} +.btn{font:inherit;font-size:12px;cursor:pointer;background:var(--slate);color:var(--cream); + border:1px solid var(--gold);border-radius:8px;padding:5px 12px} +.btn:hover{background:var(--slate-hi)} +.btn.quiet{background:transparent;border-color:var(--wash);color:var(--silver)} +.btn.quiet:hover{background:var(--wash)} + +*{scrollbar-width:thin;scrollbar-color:var(--slate) transparent} +::-webkit-scrollbar{width:6px;height:6px} +::-webkit-scrollbar-track{background:transparent} +::-webkit-scrollbar-thumb{background:var(--slate);border-radius:4px} +::-webkit-scrollbar-thumb:hover{background:var(--slate-hi)} +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +</style> +</head> +<body> + +<header class="masthead"> + <div class="eyebrow">archsetup · dupre panel family · instrument consoles</div> + <h1>Network + Bluetooth — the pair</h1> + <p>Same faceplate, same idioms: lamp rows act on click, hover reveals ✎ rename and the + arm-to-fire ✕, gauges under the console keys (throughput needles on NET, battery fuel + gauges on BT), doctor streams and repairs in the output well. <b>Try the power switch + on BT·01</b> — everything follows it.</p> +</header> + +<div class="stage"> + + <!-- ============================ NET·01 ============================ --> + <div class="slot"> + <div class="slot-label">net·01 — as iterated</div> + <div class="panel" id="p"> + <div class="overlay" id="ov"> + <div class="dlg"> + <h4 id="dlg-title">Join network</h4> + <div class="sub" id="dlg-sub">WPA2 — password required</div> + <input id="dlg-ssid" placeholder="SSID" style="display:none"> + <input id="dlg-pass" type="password" placeholder="password"> + <div class="dlg-btns"> + <button class="btn quiet" onclick="dlgClose()">Cancel</button> + <button class="btn" id="dlg-go" onclick="dlgGo()">Join</button> + </div> + </div> + </div> + + <div class="b-face"> + <div class="b-id"> + <span class="lamp" id="lamp"></span> + <span class="state-word" id="state">ONLINE</span> + <span class="badge" id="badge">TUNNEL</span> + <span class="badge" id="air-badge">AIRPLANE</span> + <span class="unit">NET·01</span> + <span class="switch on" id="n-power" onclick="wifiPower()" title="WiFi radio"></span> + <button class="x-btn" onclick="closePanel()" title="Close (Esc)">✕</button> + </div> + </div> + + <div class="engrave">channel</div> + <div class="chan"> + <div class="line1"><span class="ssid" id="ch-ssid">@Hyatt_WiFi</span> + <span class="ladder l3" id="ch-ladder"><i></i><i></i><i></i><i></i></span> + <span class="dim" style="font-size:11px;white-space:nowrap" id="ch-dbm">-59 dBm · 44 ms</span></div> + <div class="line2" id="ch-route">172.20.2.108/20 · gw 172.20.0.1 · route wlp170s0</div> + </div> + + <div class="engrave">networks<span class="cnt" id="net-count"></span><span class="act" onclick="dlgHidden()" title="Join a hidden SSID">+ hidden</span></div> + <div id="networks" class="sec-scroll"></div> + + <div class="engrave">tunnels<span class="cnt" id="tun-count"></span></div> + <div id="tunnels" class="sec-scroll"></div> + + <div class="engrave">console</div> + <div class="console-btns"> + <button class="c-btn" id="b-doctor" onclick="runDoctor()">DOCTOR</button> + <button class="c-btn" id="b-speed" onclick="runSpeed()">SPEED TEST</button> + </div> + + <div class="meters"> + <div class="meter" id="m-rx" onclick="release('rx')"> + <span class="mode-tag" id="mt-rx">LIVE</span> + <span class="hold-tag">HOLD</span> + <div class="dial"><div class="arc"></div> + <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div> + <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div> + <div class="tick" style="transform:rotate(60deg)"></div> + <div class="needle" id="n-rx"></div><div class="hub"></div></div> + <div class="m-value"><span id="v-rx">0.1</span> <small>Mbps</small></div> + <div class="m-label">RX · DOWN</div> + </div> + <div class="meter" id="m-tx" onclick="release('tx')"> + <span class="mode-tag" id="mt-tx">LIVE</span> + <span class="hold-tag">HOLD</span> + <div class="dial"><div class="arc"></div> + <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div> + <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div> + <div class="tick" style="transform:rotate(60deg)"></div> + <div class="needle" id="n-tx"></div><div class="hub"></div></div> + <div class="m-value"><span id="v-tx">0.1</span> <small>Mbps</small></div> + <div class="m-label">TX · UP</div> + </div> + </div> + <div class="meter-note" id="m-note"></div> + + <div class="outwrap" id="outwrap"> + <button class="o-clear" onclick="clearOut('out')" title="Dismiss results">✕</button> + <div class="well output" id="out"></div> + </div> + <div class="toast" id="toastEl"></div> + </div> + <button class="reopen" id="reopen" onclick="openPanel()">reopen NET·01 (bar click)</button> + </div> + + <!-- ============================ BT·01 ============================ --> + <div class="slot"> + <div class="slot-label">bt·01 — new, same idioms</div> + <div class="panel" id="bp"> + <div class="overlay" id="bov"> + <div class="dlg"> + <h4 id="bdlg-title">Pair device</h4> + <div class="sub" id="bdlg-sub">confirm the passkey matches the device</div> + <div class="passkey" id="bdlg-key" style="display:none">847 291</div> + <input id="bdlg-name" placeholder="device name" style="display:none"> + <div class="dlg-btns"> + <button class="btn quiet" onclick="bdlgClose()">Cancel</button> + <button class="btn" id="bdlg-go" onclick="bdlgGo()">Confirm</button> + </div> + </div> + </div> + + <div class="b-face"> + <div class="b-id"> + <span class="lamp" id="b-lamp"></span> + <span class="state-word" id="b-state">POWERED</span> + <span class="badge red" id="b-badge">LOW BATT</span> + <span class="badge" id="b-air-badge">AIRPLANE</span> + <span class="unit">BT·01</span> + <span class="switch on" id="b-power" onclick="btPower()" title="Adapter power"></span> + <button class="x-btn" onclick="closeBt()" title="Close (Esc)">✕</button> + </div> + </div> + + <div class="engrave">adapter</div> + <div class="chan"> + <div class="line1"><span class="ssid">intel ax211</span> + <span class="dim" style="font-size:11px;margin-left:auto">hci0</span></div> + <div class="line2" id="b-adapter-line"> + <span class="chip" id="b-disco" onclick="btDisco()">discoverable off</span> + <span> · </span><span id="b-conn-count">1 device connected</span></div> + </div> + + <div class="engrave">paired<span class="cnt" id="b-paired-count"></span></div> + <div id="b-paired" class="sec-scroll"></div> + + <div class="engrave">nearby<span class="cnt" id="b-nearby-count"></span><span class="act" id="b-scan-note"></span></div> + <div id="b-nearby" class="sec-scroll"></div> + + <div class="engrave">console</div> + <div class="console-btns"> + <button class="c-btn" id="bb-doctor" onclick="btDoctor()">DOCTOR</button> + <button class="c-btn" id="bb-scan" onclick="btScan()">SCAN</button> + </div> + + <div class="meters"> + <div class="meter" id="bm-0"> + <span class="mode-tag" id="bmt-0">LIVE</span> + <div class="dial"><div class="arc"></div> + <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div> + <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div> + <div class="tick" style="transform:rotate(60deg)"></div> + <div class="needle" id="bn-0"></div><div class="hub"></div></div> + <div class="m-value" id="bvw-0"><span id="bv-0">72</span> <small>%</small></div> + <div class="m-label" id="bl-0">LOGI M650</div> + </div> + <div class="meter" id="bm-1"> + <span class="mode-tag off" id="bmt-1">—</span> + <div class="dial"><div class="arc"></div> + <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div> + <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div> + <div class="tick" style="transform:rotate(60deg)"></div> + <div class="needle dead" id="bn-1"></div><div class="hub"></div></div> + <div class="m-value" id="bvw-1"><span id="bv-1">—</span></div> + <div class="m-label" id="bl-1">NO DEVICE</div> + </div> + </div> + <div class="meter-note">battery · connected devices</div> + + <div class="outwrap" id="b-outwrap"> + <button class="o-clear" onclick="clearOut('b-out')" title="Dismiss results">✕</button> + <div class="well output" id="b-out"></div> + </div> + <div class="toast" id="b-toastEl"></div> + </div> + <button class="reopen" id="b-reopen" onclick="openBt()">reopen BT·01 (bar click)</button> + </div> + + <!-- ============================ NOTES ============================ --> + <div class="aside"> + <h3>The bt mapping</h3> + <ul> + <li><b>Power switch on the faceplate</b> — flip it: devices drop, gauges die, keys disable, state goes OFF. Flip back: the mouse auto-reconnects. <em>(the switch-placement ask, in console form)</em></li> + <li><b>Battery fuel gauges</b> are BT's meters — one per connected device, needle at charge, red under 15% with a LOW BATT badge on the faceplate.</li> + <li><b>Paired rows toggle on click</b> (connect/disconnect), exactly like tunnels. Hover: ✎ rename <em>(the rename ask)</em>, ✕ arm-to-forget.</li> + <li><b>Nearby rows pair on click</b> — passkey confirm dialog, then the device moves up to PAIRED and connects. SCAN refreshes the neighborhood.</li> + <li><b>discoverable off</b> in the adapter line is a click-toggle (gold when on).</li> + <li><b>Disconnect is arm-first on the active row</b> — first click arms in gold ("disconnect? click again"), second fires. Gold, not terracotta: disruptive, not destructive.</li> + <li><b>NET·01 grew the wifi radio switch</b> (faceplate, same spot as BT's). Airplane mode is system-level: both switches drop, AIRPLANE badges light, and a switch flipped under airplane refuses with the way out. A plugged ethernet cable keeps NET·01 online through it.</li> + <li><b>DOCTOR does it all here too</b>: adapter → radio → service → powered → devices → audio profile. Tick the degraded-audio switch and run it: it finds HSP, flips to A2DP, verifies the sink followed.</li> + </ul> + <div class="demo-box"> + <label><input type="checkbox" id="cafe" onchange="setScenario(this.checked)"> net: walk into a new café</label> + <label><input type="checkbox" id="breakdns"> net: broken hotel DNS (then DOCTOR)</label> + <label><input type="checkbox" id="ethercb" onchange="setEther(this.checked)"> net: plug in an ethernet cable</label> + <label><input type="checkbox" id="aircb" onchange="setAirplane(this.checked)"> both: airplane mode (Super+Shift+A)</label> + <label><input type="checkbox" id="airportcb" onchange="setAirport(this.checked)"> both: airport terminal (crowded airspace)</label> + <label><input type="checkbox" id="lowbatt" onchange="btLowBatt(this.checked)"> bt: mouse battery low</label> + <label><input type="checkbox" id="badaudio"> bt: degraded audio profile (then DOCTOR)</label> + <div class="hint">the audio one needs the headphones connected — click WH-1000XM4 first.</div> + <button class="reset" onclick="location.search=''">reset prototypes</button> + </div> + </div> +</div> + +<script> +const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; +const $ = id => document.getElementById(id); +const T = f => reduced ? Math.max(10, f*0.02) : f; + +/* =========================================================== NET·01 */ +let busy = false; +const HOTEL_NETS = () => [ + {id:'hyatt', ssid:'@Hyatt_WiFi', sec:'WPA2', stored:true, range:true, sig:3, active:true}, + {id:'meeting', ssid:'Hyatt_Meeting', sec:'WPA2', stored:false, range:true, sig:3, active:false}, + {id:'roku', ssid:'DIRECT-roku-882',sec:'WPA2',stored:false, range:true, sig:2, active:false}, + {id:'xfinity', ssid:'xfinitywifi', sec:null, stored:false, range:true, sig:1, active:false}, + {id:'home', ssid:'HomeNet', sec:'WPA2', stored:true, range:false, sig:0, active:false}, +]; +const CAFE_NETS = () => [ + {id:'cafe5g', ssid:'CafeAmore_5G', sec:'WPA2', stored:false, range:true, sig:4, active:false, ip:'10.11.4.27/22 · gw 10.11.4.1'}, + {id:'cafeg', ssid:'CafeAmore_Guest',sec:null, stored:false, range:true, sig:3, active:false, ip:'10.11.8.102/22 · gw 10.11.8.1'}, + {id:'iot', ssid:'Neighbor_IoT', sec:'WPA2', stored:false, range:true, sig:1, active:false}, + {id:'hyatt', ssid:'@Hyatt_WiFi', sec:'WPA2', stored:true, range:false, sig:0, active:false}, + {id:'home', ssid:'HomeNet', sec:'WPA2', stored:true, range:false, sig:0, active:false}, +]; +const AIRPORT_NETS = () => [ + {id:'ord', ssid:'ORD Free Wi-Fi', sec:null, stored:false, range:true, sig:4, active:false, ip:'10.40.2.19/18 · gw 10.40.0.1'}, + {id:'boingo',ssid:'Boingo Hotspot', sec:null, stored:false, range:true, sig:3, active:false}, + {id:'united',ssid:'United_Club', sec:'WPA2', stored:false, range:true, sig:3, active:false}, + {id:'sky', ssid:'SkyClub_5G', sec:'WPA2', stored:false, range:true, sig:3, active:false}, + {id:'aa', ssid:'AmericanAir-Lounge',sec:'WPA2', stored:false, range:true, sig:2, active:false}, + {id:'sbux', ssid:'Starbucks WiFi', sec:null, stored:false, range:true, sig:2, active:false}, + {id:'tom', ssid:"Tom's iPhone", sec:'WPA2', stored:false, range:true, sig:2, active:false}, + {id:'hp', ssid:'HP-Print-88-Kiosk', sec:'WPA2', stored:false, range:true, sig:1, active:false}, + {id:'gate', ssid:'Gate B12 Display', sec:'WPA2', stored:false, range:true, sig:1, active:false}, + {id:'clear', ssid:'CLEAR-Kiosk', sec:'WPA2', stored:false, range:true, sig:1, active:false}, + {id:'tsa', ssid:'TSA-Ops', sec:'WPA2', stored:false, range:true, sig:1, active:false}, + {id:'dfw', ssid:'ORD-Employee', sec:'WPA2', stored:false, range:true, sig:1, active:false}, + {id:'hyatt', ssid:'@Hyatt_WiFi', sec:'WPA2', stored:true, range:false, sig:0, active:false}, + {id:'home', ssid:'HomeNet', sec:'WPA2', stored:true, range:false, sig:0, active:false}, +]; +const AIRPORT_NEARBY = () => [ + {id:'ap1', name:'AirPods Pro', kind:'audio', passkey:'118 402'}, + {id:'ap2', name:'AirPods (3rd gen)', kind:'audio', passkey:'220 981'}, + {id:'gb2', name:'Galaxy Buds2', kind:'audio', passkey:'914 555'}, + {id:'pbp', name:'Pixel Buds Pro', kind:'audio', passkey:'551 902'}, + {id:'xm5', name:'Sony WF-1000XM5', kind:'audio', passkey:'774 210'}, + {id:'jab', name:'Jabra Elite 7', kind:'audio', passkey:'333 190'}, + {id:'aw', name:'Apple Watch', kind:'wearable',passkey:'602 118'}, + {id:'gar', name:'Garmin Fenix 8', kind:'wearable',passkey:'488 007'}, + {id:'tile', name:'Tile Tracker', kind:'tracker', passkey:'150 129'}, + {id:'jbl2', name:'JBL Charge 5', kind:'audio', passkey:'847 291'}, + {id:'bose', name:'Bose QC Ultra', kind:'audio', passkey:'962 340'}, + {id:'gate2',name:'[TV] Gate B12', kind:'display', passkey:'302 118'}, +]; +let NETS = HOTEL_NETS(); +const WG = (id, who) => ({id, who, upWhat:'10.2.0.2/32 · route owner', + downWhat:'wireguard (NM) · down', up:false, ownsRoute:true, dev:'wgpvpn'}); +const TUNNELS = [ + {id:'ts', who:'tailscale · velox', upWhat:'100.127.238.103 · 4/6 peers', downWhat:'down', + up:true, ownsRoute:false, dev:'tailscale0'}, + WG('usny','USNY'), WG('usdc','USDC'), WG('uscala','USCALA'), WG('uscasf','USCASF'), + WG('usgaat','USGAAT'), WG('szur1','switzerlan-zurich1'), WG('szur2','switzerlan-zurich2'), + {id:'proton', who:'Proton VPN CLI', upWhat:'', downWhat:'down', up:false, needsLogin:true}, +]; +const tinit = () => ({ts:true, usny:false, usdc:false, uscala:false, uscasf:false, + usgaat:false, szur1:false, szur2:false, proton:false}); +const tstate = tinit(); +let connected = true; +let routeBase = '172.20.2.108/20 · gw 172.20.0.1'; +let ether = { present:false, routed:false, + ip:'172.20.7.44/20 · gw 172.20.0.1', dev:'enp3s0' }; +let armed = null, armTimer = null; +let wifiOn = true, airplane = false; +let armedDisc = null, armDiscTimer = null; +let lastSsid = '@Hyatt_WiFi'; + +function renderNets(){ + const host = $('networks'); host.innerHTML = ''; + if (ether.present){ + const row = document.createElement('div'); + row.className = 'lamp-row'; + row.innerHTML = `<span class="${ether.routed?'lamp':'lamp gold'}" id="eth-lamp"></span>`+ + `<span class="who">${ether.routed?'<b>'+ether.dev+'</b>':ether.dev}</span>`+ + `<span class="what" id="eth-what">${ether.routed?'active · wired · 1.0 Gbps':'wired · standby'}</span>`; + row.onclick = () => toggleEther(); + host.appendChild(row); + } + if (!wifiOn){ + const note = document.createElement('div'); + note.className = 'lamp-row'; + note.style.cursor = 'default'; + note.innerHTML = `<span class="lamp off"></span><span class="who dim">wifi radio off</span>`+ + `<span class="what">${airplane ? 'airplane mode' : 'flip the switch to scan'}</span>`; + host.appendChild(note); + tips('networks'); + return; + } + const inRange = NETS.filter(n => n.range).sort((a,b) => (b.active-a.active) || (b.sig-a.sig)); + const out = NETS.filter(n => !n.range); + $('net-count').textContent = '· ' + inRange.length + ' in range'; + for (const n of [...inRange, ...out]){ + const row = document.createElement('div'); + row.className = 'lamp-row' + (armed===n.id ? ' armed' : '') + + (armedDisc===n.id ? ' armed-soft' : ''); + const lamp = n.active ? 'lamp' : (n.range ? 'lamp gold' : 'lamp off'); + const what = armed===n.id ? 'forget? click ✕ again' + : armedDisc===n.id ? 'disconnect? click again' + : n.active ? (ether.present && ether.routed ? 'connected · standby · ' : 'active · ') + (n.sec || 'open') + : !n.range ? 'stored · out of range' + : (n.stored ? 'stored · ' : '') + (n.sec || 'open') + ' · ' + [null,'22%','44%','61%','78%'][n.sig]; + row.innerHTML = `<span class="${lamp}"></span>`+ + `<span class="who">${n.active?'<b>'+n.ssid+'</b>':n.ssid}</span>`+ + (n.range && !n.active ? `<span class="ladder l${n.sig}" style="margin-left:6px"><i></i><i></i><i></i><i></i></span>` : '')+ + `<span class="what" id="nw-${n.id}">${what}</span>`+ + (n.stored ? `<button class="zap" title="Forget ${n.ssid}">✕</button>` : ''); + if (n.stored) row.querySelector('.zap').onclick = (e) => { e.stopPropagation(); armForget(n.id); }; + row.onclick = n.active ? () => armDisconnect(n.id) : () => joinNet(n.id); + host.appendChild(row); + } + tips('networks'); +} +function armDisconnect(id){ + if (busy) return; + const n = NETS.find(x=>x.id===id); + if (armedDisc === id){ // second click: disconnect + clearTimeout(armDiscTimer); armedDisc = null; + busy = true; + const what = $('nw-'+id); + if (what) what.textContent = 'disconnecting…'; + setTimeout(() => { + busy = false; + n.active = false; connected = false; + if (!(ether.present && ether.routed)) tstate.ts = false; + netFace(); + renderTunnels(); renderNets(); + toast('disconnected from ' + n.ssid); + }, T(1100)); + return; + } + armedDisc = id; renderNets(); // first click: arm (gold — disruptive, not destructive) + clearTimeout(armDiscTimer); + armDiscTimer = setTimeout(() => { armedDisc = null; renderNets(); }, 3000); +} +function armForget(id){ + const n = NETS.find(x=>x.id===id); + if (armed === id){ + clearTimeout(armTimer); armed = null; + NETS.splice(NETS.indexOf(n), 1); renderNets(); + toast(`${n.ssid} forgotten`); + return; + } + armed = id; renderNets(); + clearTimeout(armTimer); + armTimer = setTimeout(() => { armed = null; renderNets(); }, 3000); +} +let joining = null; +function joinNet(id){ + if (busy) return; + if (!wifiOn){ toast('wifi radio is off', true); return; } + const n = NETS.find(x=>x.id===id); + if (!n.range){ toast(n.ssid + ' is out of range', true); return; } + if (n.sec && !n.stored){ joining = n; dlgJoin(n); return; } + doJoin(n); +} +function doJoin(n){ + busy = true; + const what = $('nw-'+n.id); + if (what) what.textContent = 'joining…'; + lampState('JOINING','gold'); + setTimeout(() => { + NETS.forEach(x => x.active = false); + n.active = true; n.stored = true; + busy = false; + $('ch-ssid').textContent = n.ssid; + $('ch-dbm').textContent = ['','-82 dBm','-74 dBm','-63 dBm','-55 dBm'][n.sig] + ' · 41 ms'; + $('ch-ladder').className = 'ladder l'+n.sig; + connected = true; + lastSsid = n.ssid; + routeBase = n.ip || '172.20.2.108/20 · gw 172.20.0.1'; + netFace(); + if (!tstate.ts) tstate.ts = true; + renderTunnels(); renderNets(); + toast('joined ' + n.ssid + ' — saved for next time'); + }, T(1600)); +} +function dlgJoin(n){ + $('dlg-title').textContent = 'Join ' + n.ssid; + $('dlg-sub').textContent = n.sec + ' — password required'; + $('dlg-ssid').style.display = 'none'; + $('dlg-pass').value = ''; + $('ov').classList.add('show'); + $('dlg-pass').focus(); +} +function dlgHidden(){ + joining = 'hidden'; + $('dlg-title').textContent = 'Join hidden network'; + $('dlg-sub').textContent = 'SSID is not broadcast — enter it exactly'; + $('dlg-ssid').style.display = 'block'; $('dlg-ssid').value = ''; + $('dlg-pass').value = ''; + $('ov').classList.add('show'); + $('dlg-ssid').focus(); +} +function dlgClose(){ $('ov').classList.remove('show'); joining = null; } +function dlgGo(){ + if (joining === 'hidden'){ + const ssid = $('dlg-ssid').value.trim() || 'hidden-net'; + const n = {id:'h'+Date.now(), ssid, sec:'WPA2', stored:true, range:true, sig:2, active:false}; + NETS.splice(0,0,n); renderNets(); dlgClose(); doJoin(n); + return; + } + const n = joining; dlgClose(); if (n) doJoin(n); +} + +function renderTunnels(){ + const host = $('tunnels'); host.innerHTML = ''; + for (const t of TUNNELS){ + const up = tstate[t.id]; + const row = document.createElement('div'); + row.className = 'lamp-row'; + row.innerHTML = `<span class="${up?'lamp':'lamp off'}" id="tl-${t.id}"></span>`+ + `<span class="who">${t.who}</span><span class="what" id="tw-${t.id}">${up?(t.upWhat||'up'):t.downWhat}</span>`; + row.onclick = () => toggleTunnel(t.id); + host.appendChild(row); + } + $('tun-count').textContent = '· ' + TUNNELS.filter(t=>tstate[t.id]).length + ' up of ' + TUNNELS.length; + tips('tunnels'); + updateRoute(); +} +function updateRoute(){ + if (!connected && !(ether.present && ether.routed)){ + $('ch-ssid').textContent = wifiOn ? '— not connected' : '— wifi radio off'; + $('ch-ladder').style.display = 'none'; + $('ch-dbm').textContent = ''; + $('ch-route').textContent = !wifiOn ? (airplane ? 'airplane mode' : 'flip the radio switch to scan') + : 'join a network below'; + $('badge').classList.remove('show'); + return; + } + const owner = TUNNELS.find(t => t.ownsRoute && tstate[t.id]); + const wired = ether.present && ether.routed; + const base = wired ? ether.ip : routeBase; + const dev = wired ? ether.dev : 'wlp170s0'; + $('ch-route').textContent = base + ' · route ' + (owner ? owner.dev + ' (tunnel)' : dev); + $('badge').classList.toggle('show', !!owner); + /* channel headline follows the routed link */ + if (wired){ + $('ch-ssid').textContent = ether.dev; + $('ch-ladder').style.display = 'none'; + $('ch-dbm').textContent = 'wired · 1.0 Gbps full-duplex'; + } else if (connected){ + const act = NETS.find(n => n.active); + if (act){ + $('ch-ssid').textContent = act.ssid; + $('ch-ladder').style.display = ''; + $('ch-ladder').className = 'ladder l' + act.sig; + $('ch-dbm').textContent = ['','-82 dBm','-74 dBm','-63 dBm','-55 dBm'][act.sig] + ' · 44 ms'; + } + } else { + $('ch-ssid').textContent = wifiOn ? '— not connected' : '— wifi radio off'; + $('ch-ladder').style.display = 'none'; + $('ch-dbm').textContent = ''; + } +} +/* the faceplate state word, derived from one place */ +function netFace(){ + const wired = ether.present && ether.routed; + $('air-badge').classList.toggle('show', airplane); + if (wired){ lampState('ONLINE'); return; } + if (connected){ lampState('ONLINE'); return; } + if (airplane){ lampState('AIRPLANE','gold'); return; } + if (!wifiOn){ lampState('OFF','off'); $('lamp').className='lamp off'; return; } + lampState('OFFLINE','red'); +} +function toggleTunnel(id){ + if (busy) return; + const t = TUNNELS.find(x=>x.id===id), up = tstate[id]; + const lamp = $('tl-'+id), what = $('tw-'+id); + busy = true; + lamp.className = 'lamp busy'; + what.textContent = t.needsLogin && !up ? 'connecting…' : up ? 'bringing down…' : 'bringing up…'; + setTimeout(() => { + busy = false; + if (t.needsLogin && !up){ + lamp.className = 'lamp red'; + what.textContent = 'sign in first: protonvpn login'; + toast('Proton: no account signed in — run: protonvpn login', true); + setTimeout(() => { lamp.className = 'lamp off'; what.textContent = t.downWhat; }, 2600); + return; + } + tstate[id] = !up; + renderTunnels(); + toast(tstate[id] ? `${t.who} up` + (t.ownsRoute ? ' — default route moved to '+t.dev : '') + : `${t.who} down` + (t.ownsRoute ? ' — route back on wlp170s0' : '')); + }, T(1500)); +} + +let toastTimer; +function toast(msg, err){ + const el = $('toastEl'); + el.textContent = msg; el.className = 'toast show' + (err ? ' err' : ''); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => el.className = 'toast', err ? 4200 : 2600); +} +function lampState(word, cls){ + $('state').textContent = word; + $('lamp').className = 'lamp' + (cls && cls !== 'off' ? ' '+cls : cls === 'off' ? ' off' : ''); +} + +const held = {rx:false, tx:false}; +let testing = false, amb = 0; +function meter(side, val){ + const deg = -60 + Math.min(1, val/100) * 120; + $('n-'+side).style.transform = `rotate(${deg}deg)`; + $('v-'+side).textContent = val.toFixed(1); +} +function setMeterMode(side, mode){ + const m = $('m-'+side), t = $('mt-'+side); + m.classList.toggle('testing', mode==='test'); + m.classList.toggle('held', mode==='hold'); + held[side] = mode==='hold'; + t.textContent = mode==='live' ? 'LIVE' : 'TEST'; + t.className = 'mode-tag' + (mode==='live' ? '' : ' test'); +} +function release(side){ + if (!held[side]) return; + setMeterMode(side, 'live'); + if (!held.rx && !held.tx) $('m-note').textContent = ''; +} + +function runSpeed(){ + if (busy) return; + busy = true; testing = true; + $('b-doctor').disabled = $('b-speed').disabled = true; + const out = $('out'); out.innerHTML = ''; + setMeterMode('rx','test'); setMeterMode('tx','test'); + $('m-note').textContent = 'measuring — needles follow the live rate'; + const line = (k, v) => { + const el = document.createElement('div'); + el.className = 'o-line'; el.innerHTML = `<b>${k}</b> ${v}`; + out.appendChild(el); out.scrollTop = out.scrollHeight; + }; + setTimeout(() => line('location', 'Tulsa, OK (US) by Encore Communications'), T(900)); + setTimeout(() => line('ping', '44.5 ms (jitter 2.1 ms)'), T(1800)); + let dv = 0; const dT = 25.3; + setTimeout(() => { + const dTick = setInterval(() => { + dv = Math.min(dT, dv + 1.3 + Math.random()*2.1); + meter('rx', dv); + if (dv >= dT){ + clearInterval(dTick); + setMeterMode('rx','hold'); meter('rx', dT); + let uv = 0; const uT = 90.8; + const uTick = setInterval(() => { + uv = Math.min(uT, uv + 4.5 + Math.random()*7); + meter('tx', uv); + if (uv >= uT){ + clearInterval(uTick); + setMeterMode('tx','hold'); meter('tx', uT); + line('final', '25.3↓ 90.8↑ Mbps · 44.5 ms · loss 0.0%'); + const tip = document.createElement('div'); + tip.className = 'o-tip'; + tip.textContent = 'tip: download well below upload — typical of a congested or shaped venue network. Try 5 GHz, move closer, or retest off-peak.'; + out.appendChild(tip); out.scrollTop = out.scrollHeight; + $('m-note').textContent = 'result held — click a meter to go back to live'; + testing = false; busy = false; + $('b-doctor').disabled = $('b-speed').disabled = false; + } + }, T(200)); + } + }, T(185)); + }, T(2100)); +} + +const CHECKS = [ + {t:'Link', why:'is the adapter connected to a network (every later check rides the link)', + ev:'wlp170s0 connected (@Hyatt_WiFi)'}, + {t:'DHCP / IPv4', why:'did the network lease us an IP address (nothing routes without one)', + ev:'172.20.2.108/20'}, + {t:'Gateway', why:'does the router (first hop) answer a ping', ev:'172.20.0.1 [5 ms]'}, + {t:'DNS config', why:'is a DNS resolver configured on the link', ev:'172.20.0.1'}, + {t:'DNS resolution', why:'does a known hostname resolve (catches dead DNS and portal hijacks)', + ev:'names resolve (captive.apple.com) [48 ms]', + evBroken:'no resolution (portal may be stalling DNS)', canBreak:true}, + {t:'Internet', why:'does an HTTP probe reach the open internet (the online/captive verdict)', + ev:'open internet (HTTP 204) [112 ms]', + evBroken:'link up but no clean internet (DNS or egress issue)', canBreak:true}, +]; +const REPAIR = {t:'repair: dns-test', why:'points DNS at 1.1.1.1, tests resolution, then reverts (tells a broken venue resolver from blocked egress)', + ev:'1.1.1.1 resolved captive.apple.com — the hotel resolver is broken, not the link'}; +const REPAIR2 = {t:'repair: dns-override', why:'sets 1.1.1.1 on the link until reconnect (works around the broken venue resolver)', + ev:'DNS set to 1.1.1.1 on wlp170s0'}; + +function addStep(host, step, repair){ + const el = document.createElement('div'); + el.className = 'o-step' + (repair ? ' repair' : ''); + el.innerHTML = `<span class="lamp busy"></span><span class="t"><b>${step.t}</b>`+ + `<span class="why">${step.why}</span><span class="ev">…</span></span>`; + host.appendChild(el); host.scrollTop = host.scrollHeight; + return el; +} +function landStep(el, ev, cls){ + el.querySelector('.lamp').className = 'lamp' + (cls ? ' '+cls : ''); + el.querySelector('.ev').textContent = ev; + el.closest('.output').scrollTop = 1e6; +} +function verdict(host, text, ok){ + const v = document.createElement('div'); + v.className = 'o-verdict' + (ok ? ' ok' : ''); + v.textContent = text; + host.appendChild(v); host.scrollTop = host.scrollHeight; +} + +function runDoctor(){ + if (busy) return; + busy = true; + $('b-doctor').disabled = $('b-speed').disabled = true; + const brokenDNS = $('breakdns').checked; + const out = $('out'); out.innerHTML = ''; + lampState('CHECKING','gold'); + const gap = T(620); + let i = 0; + const next = () => { + if (i >= CHECKS.length){ return brokenDNS ? repairPhase() : finish('Overall — everything checks out'); } + const c = CHECKS[i]; + const el = addStep(out, c); + setTimeout(() => { + const broken = brokenDNS && c.canBreak; + landStep(el, broken ? c.evBroken : c.ev, broken ? 'red' : ''); + i++; setTimeout(next, gap*0.35); + }, gap); + }; + const repairPhase = () => { + lampState('FIXING','gold'); + verdict(out, 'DNS not resolving — trying the lightest repair'); + const r1 = addStep(out, REPAIR, true); + setTimeout(() => { + landStep(r1, REPAIR.ev); + const r2 = addStep(out, REPAIR2, true); + setTimeout(() => { + landStep(r2, REPAIR2.ev); + const re = addStep(out, {t:'re-check: Internet', why:'probe again through the repaired resolver', ev:''}); + setTimeout(() => { + landStep(re, 'open internet (HTTP 204) [96 ms]'); + $('breakdns').checked = false; + finish('fixed — back online after dns-override'); + }, gap); + }, gap*1.2); + }, gap*1.4); + }; + const finish = (text) => { + verdict(out, text, true); + lampState('ONLINE'); + busy = false; + $('b-doctor').disabled = $('b-speed').disabled = false; + }; + next(); +} + +function setEther(present){ + if (busy) return; + ether.present = present; + if (present){ + ether.routed = true; // cable wins the route by metric + lampState('ONLINE'); + toast('link detected on ' + ether.dev + ' — route moved to wired'); + } else { + ether.routed = false; + toast(connected ? 'cable unplugged — route back on wlp170s0' + : 'cable unplugged', !connected); + if (!connected) lampState('OFFLINE','red'); + } + renderTunnels(); renderNets(); +} +function toggleEther(){ + if (busy) return; + const lamp = $('eth-lamp'), what = $('eth-what'); + busy = true; + lamp.className = 'lamp busy'; + what.textContent = ether.routed ? 'standing by…' : 'taking the route…'; + setTimeout(() => { + busy = false; + ether.routed = !ether.routed; + renderTunnels(); renderNets(); + toast(ether.routed ? 'route moved to ' + ether.dev + ' (wired)' + : 'route back on wlp170s0 — ' + ether.dev + ' standing by'); + }, T(1200)); +} + +function wifiPower(){ + if (busy) return; + if (airplane){ toast('airplane mode is on — Super+Shift+A to leave it', true); return; } + setWifi(!wifiOn); +} +function setWifi(on, quiet){ + wifiOn = on; + $('n-power').classList.toggle('on', on); + if (!on){ + const act = NETS.find(n => n.active); + if (act){ lastSsid = act.ssid; act.active = false; } + connected = false; + if (!(ether.present && ether.routed)) tstate.ts = false; + netFace(); renderTunnels(); renderNets(); + if (!quiet) toast('wifi radio off'); + } else { + netFace(); renderTunnels(); renderNets(); + if (!quiet) toast('wifi radio on — rejoining ' + lastSsid); + const n = NETS.find(x => x.ssid === lastSsid && x.range); + if (n) setTimeout(() => doJoin(n), T(700)); + } +} +function setAirplane(on){ + airplane = on; + if (on){ + if (wifiOn) setWifi(false, true); + if (bpower) btPowerSet(false, true); + netFace(); + $('b-air-badge').classList.add('show'); + toast('airplane mode — all radios off'); + } else { + $('b-air-badge').classList.remove('show'); + setWifi(true, true); + btPowerSet(true, true); + netFace(); + toast('airplane mode off — radios back up'); + } +} + +function setAirport(on){ + if (busy || bbusy) return; + if (on && $('cafe').checked){ $('cafe').checked = false; } + NETS = on ? AIRPORT_NETS() : HOTEL_NETS(); + NEARBY.length = 0; + NEARBY.push(...(on ? AIRPORT_NEARBY() + : [{id:'jbl', name:'JBL Flip 6', kind:'audio', passkey:'847 291'}, + {id:'tv', name:'[TV] Samsung Q70', kind:'display', passkey:'302 118'}])); + if (on){ + tstate.ts = false; connected = false; + lampState('OFFLINE','red'); + toast('ORD concourse B — pick a network'); + } else { + tstate.ts = true; connected = true; + routeBase = '172.20.2.108/20 · gw 172.20.0.1'; + NETS.find(n=>n.id==='hyatt').active = true; + lampState('ONLINE'); + } + renderTunnels(); renderNets(); renderBt(); +} + +function setScenario(cafe){ + if (busy) return; + if (cafe && $('airportcb') && $('airportcb').checked){ $('airportcb').checked = false; } + NETS = cafe ? CAFE_NETS() : HOTEL_NETS(); + if (cafe){ + tstate.ts = false; connected = false; + $('ch-ssid').textContent = '— not connected'; + $('ch-dbm').textContent = ''; + $('ch-ladder').className = 'ladder'; + lampState('OFFLINE','red'); + } else { + tstate.ts = true; connected = true; + routeBase = '172.20.2.108/20 · gw 172.20.0.1'; + $('ch-ssid').textContent = '@Hyatt_WiFi'; + $('ch-dbm').textContent = '-59 dBm · 44 ms'; + $('ch-ladder').className = 'ladder l3'; + lampState('ONLINE'); + } + renderTunnels(); renderNets(); +} + +function closePanel(){ $('p').classList.add('closed'); $('reopen').style.display='inline-block'; } +function openPanel(){ $('p').classList.remove('closed'); $('reopen').style.display='none'; } + +/* =========================================================== BT·01 */ +let bbusy = false, bpower = true, bdisco = false, blow = false; +const DEVS = [ + {id:'m650', name:'Logi M650', kind:'mouse', paired:true, conn:true, batt:72, audio:false}, + {id:'xm4', name:'WH-1000XM4', kind:'audio', paired:true, conn:false, batt:58, audio:true}, + {id:'k3', name:'Keychron K3', kind:'keyboard', paired:true, conn:false, batt:34, audio:false}, +]; +const NEARBY = [ + {id:'jbl', name:'JBL Flip 6', kind:'audio', passkey:'847 291'}, + {id:'tv', name:'[TV] Samsung Q70', kind:'display', passkey:'302 118'}, +]; +let barmed = null, barmTimer = null; + +function btConnected(){ return DEVS.filter(d => d.conn); } + +function renderBt(){ + /* paired rows */ + const host = $('b-paired'); host.innerHTML = ''; + for (const d of DEVS){ + const row = document.createElement('div'); + row.className = 'lamp-row' + (barmed===d.id ? ' armed' : ''); + const lamp = !bpower ? 'lamp off' : d.conn ? 'lamp' : 'lamp off'; + const battTxt = d.batt !== null && d.conn ? ` · battery ${d.batt}%` : ''; + const what = barmed===d.id ? 'forget? click ✕ again' + : !bpower ? 'adapter off' + : d.conn ? d.kind + battTxt : d.kind + ' · not connected'; + row.innerHTML = `<span class="${lamp}" id="bl-${d.id}"></span>`+ + `<span class="who">${d.conn && bpower ? '<b>'+d.name+'</b>' : d.name}</span>`+ + `<span class="what" id="bw-${d.id}">${what}</span>`+ + `<button class="pen" title="Rename ${d.name}">✎</button>`+ + `<button class="zap" title="Forget ${d.name}">✕</button>`; + row.querySelector('.pen').onclick = (e) => { e.stopPropagation(); bdlgRename(d.id); }; + row.querySelector('.zap').onclick = (e) => { e.stopPropagation(); btArmForget(d.id); }; + if (bpower) row.onclick = () => btToggleDev(d.id); + host.appendChild(row); + } + /* nearby rows */ + const nb = $('b-nearby'); nb.innerHTML = ''; + for (const n of NEARBY){ + const row = document.createElement('div'); + row.className = 'lamp-row'; + row.innerHTML = `<span class="${bpower?'lamp gold':'lamp off'}" id="bnl-${n.id}"></span>`+ + `<span class="who">${n.name}</span><span class="what" id="bnw-${n.id}">${bpower ? n.kind+' · pairable' : 'adapter off'}</span>`; + if (bpower) row.onclick = () => btPair(n.id); + nb.appendChild(row); + } + /* adapter line + faceplate */ + const c = btConnected().length; + $('b-conn-count').textContent = bpower ? (c === 1 ? '1 device connected' : c + ' devices connected') : 'adapter off'; + $('b-paired-count').textContent = '· ' + DEVS.length; + $('b-nearby-count').textContent = '· ' + NEARBY.length; + $('b-disco').textContent = 'discoverable ' + (bdisco && bpower ? 'on' : 'off'); + $('b-disco').className = 'chip' + (bdisco && bpower ? ' on' : ''); + renderGauges(); + updateBtBadge(); + tips('b-paired'); tips('b-nearby'); + for (const el of document.querySelectorAll('#bp .m-label')) el.title = el.textContent; +} + +function renderGauges(){ + const conns = btConnected(); + for (let i = 0; i < 2; i++){ + const d = bpower ? conns[i] : null; + const needle = $('bn-'+i), val = $('bv-'+i), label = $('bl-'+i), + tag = $('bmt-'+i), wrap = $('bvw-'+i); + if (!d){ + needle.className = 'needle dead'; + needle.style.transform = 'rotate(-60deg)'; + val.textContent = '—'; wrap.className = 'm-value'; + label.textContent = bpower ? 'NO DEVICE' : 'ADAPTER OFF'; + tag.textContent = '—'; tag.className = 'mode-tag off'; + continue; + } + const low = d.batt < 15; + needle.className = 'needle' + (low ? ' low' : ''); + needle.style.transform = `rotate(${-60 + (d.batt/100)*120}deg)`; + val.innerHTML = d.batt; wrap.className = 'm-value' + (low ? ' low' : ''); + wrap.innerHTML = `<span id="bv-${i}">${d.batt}</span> <small>%</small>`; + label.textContent = d.name.toUpperCase(); + tag.textContent = 'LIVE'; tag.className = 'mode-tag'; + } +} + +function updateBtBadge(){ + const low = bpower && btConnected().some(d => d.batt < 15); + $('b-badge').classList.toggle('show', low); +} + +function btToggleDev(id){ + if (bbusy || !bpower) return; + const d = DEVS.find(x=>x.id===id); + const lamp = $('bl-'+id), what = $('bw-'+id); + bbusy = true; + lamp.className = 'lamp busy'; + what.textContent = d.conn ? 'disconnecting…' : 'connecting…'; + setTimeout(() => { + bbusy = false; + d.conn = !d.conn; + renderBt(); + btToast(d.conn ? `${d.name} connected` : `${d.name} disconnected`); + }, T(1300)); +} + +function btArmForget(id){ + const d = DEVS.find(x=>x.id===id); + if (barmed === id){ + clearTimeout(barmTimer); barmed = null; + DEVS.splice(DEVS.indexOf(d), 1); renderBt(); + btToast(`${d.name} forgotten`); + return; + } + barmed = id; renderBt(); + clearTimeout(barmTimer); + barmTimer = setTimeout(() => { barmed = null; renderBt(); }, 3000); +} + +/* pairing + rename dialogs */ +let bdlgMode = null, bdlgTarget = null; +function btPair(id){ + if (bbusy) return; + const n = NEARBY.find(x=>x.id===id); + const lamp = $('bnl-'+id), what = $('bnw-'+id); + bbusy = true; + lamp.className = 'lamp busy'; + what.textContent = 'pairing…'; + setTimeout(() => { + bdlgMode = 'pair'; bdlgTarget = n; + $('bdlg-title').textContent = 'Pair ' + n.name; + $('bdlg-sub').textContent = 'confirm this passkey shows on the device'; + $('bdlg-key').style.display = 'block'; + $('bdlg-key').textContent = n.passkey; + $('bdlg-name').style.display = 'none'; + $('bdlg-go').textContent = 'Confirm'; + $('bov').classList.add('show'); + }, T(1200)); +} +function bdlgRename(id){ + const d = DEVS.find(x=>x.id===id); + bdlgMode = 'rename'; bdlgTarget = d; + $('bdlg-title').textContent = 'Rename device'; + $('bdlg-sub').textContent = 'the alias lives on this machine (bluez set-alias)'; + $('bdlg-key').style.display = 'none'; + $('bdlg-name').style.display = 'block'; + $('bdlg-name').value = d.name; + $('bdlg-go').textContent = 'Rename'; + $('bov').classList.add('show'); + $('bdlg-name').focus(); +} +function bdlgClose(){ + $('bov').classList.remove('show'); + if (bdlgMode === 'pair'){ bbusy = false; renderBt(); btToast('pairing cancelled'); } + bdlgMode = null; bdlgTarget = null; +} +function bdlgGo(){ + if (bdlgMode === 'pair'){ + const n = bdlgTarget; + $('bov').classList.remove('show'); + const what = $('bnw-'+n.id); + if (what) what.textContent = 'connecting…'; + setTimeout(() => { + bbusy = false; + NEARBY.splice(NEARBY.indexOf(n), 1); + DEVS.push({id:n.id, name:n.name, kind:n.kind, paired:true, conn:true, + batt:n.kind==='audio' ? 91 : null, audio:n.kind==='audio'}); + renderBt(); + btToast(`${n.name} paired and connected`); + }, T(1100)); + } else if (bdlgMode === 'rename'){ + const d = bdlgTarget; + const name = $('bdlg-name').value.trim() || d.name; + d.name = name; + $('bov').classList.remove('show'); + renderBt(); + btToast(`renamed to ${name}`); + } + bdlgMode = null; bdlgTarget = null; +} + +/* scan */ +const MORE_NEARBY = [ + {id:'buds', name:'Pixel Buds Pro', kind:'audio', passkey:'551 902'}, +]; +function btScan(){ + if (bbusy || !bpower) return; + $('b-scan-note').textContent = 'scanning…'; + $('bb-scan').disabled = true; + setTimeout(() => { + if (MORE_NEARBY.length){ + NEARBY.push(MORE_NEARBY.shift()); + renderBt(); + } + $('b-scan-note').textContent = ''; + $('bb-scan').disabled = false; + btToast('scan complete — ' + NEARBY.length + ' nearby'); + }, T(2200)); +} + +/* discoverable + power */ +function btDisco(){ + if (!bpower) return; + bdisco = !bdisco; + renderBt(); + btToast(bdisco ? 'discoverable for 2 minutes' : 'discoverable off'); +} +function btPower(){ + if (bbusy) return; + if (airplane){ btToast('airplane mode is on — Super+Shift+A to leave it', true); return; } + btPowerSet(!bpower); +} +function btPowerSet(on, quiet){ + if (bbusy) return; + bpower = on; + $('b-power').classList.toggle('on', bpower); + if (!bpower){ + bdisco = false; + DEVS.forEach(d => d._wasConn = d.conn); + DEVS.forEach(d => d.conn = false); + $('b-state').textContent = airplane ? 'AIRPLANE' : 'OFF'; + $('b-lamp').className = airplane ? 'lamp gold' : 'lamp off'; + $('bb-doctor').disabled = $('bb-scan').disabled = true; + renderBt(); + if (!quiet) btToast('adapter powered off'); + } else { + $('b-state').textContent = 'POWERED'; + $('b-lamp').className = 'lamp'; + $('bb-doctor').disabled = $('bb-scan').disabled = false; + renderBt(); + if (quiet === true) { /* airplane restore: quiet */ } + /* the mouse auto-reconnects, like real life */ + const mouse = DEVS.find(d => d._wasConn); + if (mouse){ + setTimeout(() => { + const lamp = $('bl-'+mouse.id), what = $('bw-'+mouse.id); + if (lamp){ lamp.className = 'lamp busy'; what.textContent = 'reconnecting…'; } + setTimeout(() => { mouse.conn = true; renderBt(); btToast(mouse.name + ' reconnected'); }, T(1200)); + }, T(600)); + } + } +} + +let btoastTimer; +function btToast(msg, err){ + const el = $('b-toastEl'); + el.textContent = msg; el.className = 'toast show' + (err ? ' err' : ''); + clearTimeout(btoastTimer); + btoastTimer = setTimeout(() => el.className = 'toast', err ? 4200 : 2600); +} + +/* bt doctor — the real chain: adapter → radio → service → powered → devices → audio */ +function btChecks(){ + const badAudio = $('badaudio').checked && DEVS.some(d => d.audio && d.conn); + return [ + {t:'Adapter', why:'is a bluetooth adapter visible to the stack', ev:'intel ax211 (hci0)'}, + {t:'Radio', why:'rfkill can block the radio in software or hardware', ev:'unblocked'}, + {t:'Service', why:'is the bluez daemon running', ev:'bluetooth.service active'}, + {t:'Powered', why:'radio on and ready to connect', ev:'powered on'}, + {t:'Devices', why:'are paired devices reachable', + ev: btConnected().length ? btConnected().map(d=>d.name).join(', ') + ' connected' : 'none connected'}, + {t:'Audio profile', why:'is a connected audio device on the high-quality profile (A2DP)', + ev: DEVS.some(d=>d.audio && d.conn) ? 'a2dp-sink' : 'no audio device connected', + evBroken:'stuck on headset-head-unit (HSP) — phone-call-grade audio', canBreak:badAudio}, + ]; +} +const BT_REPAIR = {t:'repair: a2dp-switch', why:'flips the card profile to A2DP and verifies the sink followed', + ev:'card profile set to a2dp-sink — sink followed'}; + +function btDoctor(){ + if (bbusy || !bpower) return; + bbusy = true; + $('bb-doctor').disabled = $('bb-scan').disabled = true; + const checks = btChecks(); + const willRepair = checks.some(c => c.canBreak); + const out = $('b-out'); out.innerHTML = ''; + $('b-state').textContent = 'CHECKING'; $('b-lamp').className = 'lamp gold'; + const gap = T(620); + let i = 0; + const next = () => { + if (i >= checks.length){ return willRepair ? repairPhase() : finish('Overall — everything checks out'); } + const c = checks[i]; + const el = addStep(out, c); + setTimeout(() => { + landStep(el, c.canBreak ? c.evBroken : c.ev, c.canBreak ? 'red' : ''); + i++; setTimeout(next, gap*0.35); + }, gap); + }; + const repairPhase = () => { + $('b-state').textContent = 'FIXING'; + verdict(out, 'audio degraded — trying the lightest repair'); + const r = addStep(out, BT_REPAIR, true); + setTimeout(() => { + landStep(r, BT_REPAIR.ev); + const re = addStep(out, {t:'re-check: Audio profile', why:'read the sink profile again after the switch', ev:''}); + setTimeout(() => { + landStep(re, 'a2dp-sink [verified]'); + $('badaudio').checked = false; + finish('fixed — high-quality audio restored'); + }, gap); + }, gap*1.4); + }; + const finish = (text) => { + verdict(out, text, true); + $('b-state').textContent = 'POWERED'; $('b-lamp').className = 'lamp'; + bbusy = false; + $('bb-doctor').disabled = $('bb-scan').disabled = false; + }; + next(); +} + +function btLowBatt(low){ + blow = low; + const m = DEVS.find(d => d.id === 'm650'); + if (m) m.batt = low ? 9 : 72; + renderBt(); + if (low) btToast('Logi M650 battery low (9%)', true); +} + +function closeBt(){ $('bp').classList.add('closed'); $('b-reopen').style.display='inline-block'; } +function openBt(){ $('bp').classList.remove('closed'); $('b-reopen').style.display='none'; } + +/* shared ambience + keys */ +if (!reduced) setInterval(() => { + amb++; + if (!testing){ + if (!held.rx) meter('rx', 0.1 + Math.abs(Math.sin(amb/3))*0.35); + if (!held.tx) meter('tx', 0.08 + Math.abs(Math.cos(amb/4))*0.25); + } +}, 900); +document.addEventListener('keydown', e => { + if (e.key === 'Escape'){ + if ($('ov').classList.contains('show')) return dlgClose(); + if ($('bov').classList.contains('show')) return bdlgClose(); + closePanel(); closeBt(); + } +}); + +function clearOut(id){ $(id).innerHTML = ''; } +function tips(hostId){ + for (const el of $(hostId).querySelectorAll('.what,.who,.m-label')) + el.title = el.textContent; +} +[['out','outwrap'],['b-out','b-outwrap']].forEach(([oid, wid]) => { + new MutationObserver(() => { + $(wid).classList.toggle('has', $(oid).childElementCount > 0); + }).observe($(oid), {childList: true}); +}); + +renderNets(); renderTunnels(); renderBt(); + +/* headless hooks */ +const auto = new URLSearchParams(location.search).get('auto'); +if (auto === 'btdoctor') btDoctor(); +if (auto === 'btdoctorfix'){ + const xm4 = DEVS.find(d=>d.id==='xm4'); xm4.conn = true; renderBt(); + $('badaudio').checked = true; btDoctor(); +} +if (auto === 'btpair'){ btPair('jbl'); setTimeout(() => bdlgGo(), T(1800)); } +if (auto === 'btpower'){ btPower(); } +if (auto === 'btpoweron'){ btPower(); setTimeout(() => btPower(), T(600)); } +if (auto === 'btlow'){ $('lowbatt').checked = true; btLowBatt(true); } +if (auto === 'speed') runSpeed(); +if (auto === 'ether'){ $('ethercb').checked = true; setEther(true); } +if (auto === 'air'){ $('aircb').checked = true; setAirplane(true); } +if (auto === 'airether'){ $('ethercb').checked = true; setEther(true); $('aircb').checked = true; setAirplane(true); } +if (auto === 'disc'){ armDisconnect('hyatt'); setTimeout(() => armDisconnect('hyatt'), T(600)); } +if (auto === 'wifioff'){ setWifi(false); } +if (auto === 'airport'){ $('airportcb').checked = true; setAirport(true); } +if (auto === 'doctorfix'){ $('breakdns').checked = true; runDoctor(); } +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-03-net-panel-rescan-prototype.html b/docs/prototypes/2026-07-03-net-panel-rescan-prototype.html new file mode 100644 index 0000000..3329cdb --- /dev/null +++ b/docs/prototypes/2026-07-03-net-panel-rescan-prototype.html @@ -0,0 +1,251 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Net panel — rescan affordance</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 4rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.masthead{max-width:1200px;margin:0 auto 1.8rem} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} +.masthead p{color:var(--dim);font-size:.86rem;max-width:82ch} +.masthead p b{color:var(--silver)} + +.stage{display:flex;gap:2.2rem;flex-wrap:wrap;max-width:1200px;margin:0 auto;align-items:flex-start} +.slot{width:400px} +.slot-label{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .55rem .2rem} +.panel{background:var(--panel);border:2px solid var(--gold);border-radius:16px;padding:17px 19px; + box-shadow:0 18px 50px rgba(0,0,0,.55);font-size:13.5px;width:380px} + +.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);flex:0 0 auto;box-shadow:0 0 6px 1px rgba(116,147,47,.55)} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.55)} +.lamp.off{background:var(--wash);box-shadow:none} +.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite} +@keyframes pulse{50%{opacity:.25}} + +.b-face{background:var(--raise);border-radius:12px;border:1px solid #262320;padding:11px 14px} +.b-id{display:flex;align-items:center;gap:9px} +.b-id .state-word{color:var(--gold);font-weight:700;font-size:15px;letter-spacing:.12em} +.b-id .unit{color:var(--steel);font-size:.68rem;letter-spacing:.3em;margin-left:auto} + +.chan{margin-top:12px} +.chan .line1{display:flex;align-items:baseline;gap:9px} +.chan .ssid{color:var(--cream);font-weight:700;font-size:14.5px} +.chan .line2{color:var(--dim);font-size:11.5px;margin-top:2px} +.ladder{display:inline-flex;gap:2px;align-items:flex-end;height:12px} +.ladder i{width:4px;background:var(--wash);border-radius:1px} +.ladder i:nth-child(1){height:4px}.ladder i:nth-child(2){height:7px} +.ladder i:nth-child(3){height:10px}.ladder i:nth-child(4){height:12px} +.ladder.l1 i:nth-child(-n+1){background:var(--gold)}.ladder.l2 i:nth-child(-n+2){background:var(--gold)} +.ladder.l3 i:nth-child(-n+3){background:var(--gold)}.ladder.l4 i{background:var(--gold)} + +/* engrave header with the rescan action */ +.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.24em;text-transform:uppercase; + display:flex;align-items:center;gap:8px;margin:14px 0 6px} +.engrave::before{content:"";height:1px;background:var(--wash);width:10px;flex:0 0 auto} +.engrave .cnt{color:var(--dim);letter-spacing:.08em;text-transform:none;cursor:pointer; + border-bottom:1px dotted transparent} +.engrave .cnt:hover{color:var(--gold);border-bottom-color:var(--wash)} +.engrave .cnt.scanning{color:var(--gold);animation:breathe 1.1s ease-in-out infinite;cursor:default;border-bottom-color:transparent} +.engrave .spacer{flex:1;height:1px;background:var(--wash)} +/* compact rescan glyph — sits right after the count, spins while scanning */ +.engrave .ricon{color:var(--dim);cursor:pointer;font-size:.82rem;display:inline-flex;line-height:1} +.engrave .ricon:hover{color:var(--gold)} +.engrave .ricon.spin{color:var(--gold);cursor:default;animation:spin .9s linear infinite} +.engrave .act{color:var(--dim);letter-spacing:.06em;text-transform:none;font-size:.72rem;cursor:pointer} +.engrave .act:hover{color:var(--gold)} +@keyframes spin{to{transform:rotate(360deg)}} +@keyframes breathe{0%,100%{opacity:1}50%{opacity:.35}} + +/* the list; section-breathe busy style pulses the whole well */ +#networks{border-radius:8px;transition:background .3s} +#networks.breathe{animation:sectionbreathe 1.4s ease-in-out infinite} +@keyframes sectionbreathe{0%,100%{background:transparent}50%{background:rgba(218,181,61,.06)}} + +.lamp-row{display:flex;align-items:center;gap:9px;padding:5px 6px;border-radius:7px;font-size:12.5px;cursor:pointer} +.lamp-row:hover{background:var(--wash)} +.lamp-row .who{color:var(--silver);white-space:nowrap} +.lamp-row .who b{color:var(--cream)} +.lamp-row .what{margin-left:auto;color:var(--dim);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.lamp-row.fresh{animation:fadein .5s ease} +@keyframes fadein{from{opacity:0;transform:translateY(-3px);background:rgba(218,181,61,.12)}to{opacity:1}} + +.toast{margin-top:10px;font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px; + padding:5px 10px;opacity:0;transition:opacity .25s;min-height:1.4em} +.toast.show{opacity:1} + +.aside{flex:1 1 320px;min-width:290px} +.aside h3{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:1.1rem 0 .5rem} +.aside h3:first-child{margin-top:.2rem} +.aside ul{list-style:none} +.aside li{font-size:.82rem;padding:.24rem 0 .24rem 1.1rem;position:relative} +.aside li::before{content:"·";color:var(--gold);position:absolute;left:.25rem} +.aside li b{color:var(--cream);font-weight:700} +.aside li em{color:var(--dim);font-style:normal} +.demo{border:1px dashed var(--wash);border-radius:10px;padding:.85rem 1rem;margin-top:1rem} +.demo .lbl{color:var(--steel);font-size:.62rem;letter-spacing:.2em;text-transform:uppercase;margin-bottom:.5rem} +.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden;margin-bottom:.7rem} +.seg button{flex:1;font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 6px;cursor:pointer} +.seg button:last-child{border-right:0} +.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700} +.demo .go{font:inherit;font-size:.8rem;color:var(--silver);background:transparent;border:1px solid var(--gold); + border-radius:8px;padding:.45rem 1rem;cursor:pointer} +.demo .go:hover{background:rgba(218,181,61,.12)} +.rec{border:1px dashed var(--wash);border-radius:10px;padding:.85rem 1rem;margin-top:.9rem;font-size:.82rem} +.rec b{color:var(--gold)} +@media (prefers-reduced-motion:reduce){*{animation:none!important}} +</style> +</head> +<body> + +<header class="masthead"> + <div class="eyebrow">archsetup · dupre panel family · net·01</div> + <h1>Networks — the rescan affordance</h1> + <p>Where does a WiFi rescan live, and how does it show it's working? The count "· N in range" is + really a live status field — the natural home for "scanning…". My lean: an explicit <b>⟳ rescan</b> + action in the engrave line (discoverable, same slot as "+ hidden"), with a <b>flash-and-fade</b> + busy state — the glyph spins, the count breathes, new rows fade in as found. The count is also + click-to-rescan as a shortcut. Use the selector on the right to feel each busy treatment.</p> +</header> + +<div class="stage"> + <div class="slot"> + <div class="slot-label">net·01 — networks section</div> + <div class="panel"> + <div class="b-face"> + <div class="b-id"> + <span class="lamp"></span> + <span class="state-word">ONLINE</span> + <span class="unit">NET·01</span> + </div> + </div> + + <div class="engrave">channel</div> + <div class="chan"> + <div class="line1"><span class="ssid">@Hyatt_WiFi</span> + <span class="ladder l3"><i></i><i></i><i></i><i></i></span> + <span class="dim" style="font-size:11px">-59 dBm · 44 ms</span></div> + <div class="line2">172.20.2.108/20 · gw 172.20.0.1 · route wlp170s0</div> + </div> + + <div class="engrave">networks<span class="cnt" id="cnt" onclick="rescan()">· 5 in range</span> + <span class="ricon" id="rescan" onclick="rescan()" title="Rescan for networks">⟳</span> + <span class="spacer"></span> + <span class="act" onclick="toast('would open the hidden-SSID dialog')">+ hidden</span> + </div> + <div id="networks"></div> + + <div class="toast" id="toast"></div> + </div> + </div> + + <div class="aside"> + <div class="demo"> + <div class="lbl">busy feedback style</div> + <div class="seg" id="styleSeg"> + <button class="on" data-s="all">all (rec)</button> + <button data-s="spin">spinner</button> + <button data-s="count">count</button> + <button data-s="section">section</button> + </div> + <button class="go" onclick="rescan()">▶ run a rescan</button> + </div> + + <h3>The three busy signals</h3> + <ul> + <li><b>Spinner</b> — the ⟳ glyph rotates while scanning. The clearest "working" cue; universal.</li> + <li><b>Count breathe</b> — "· 5 in range" becomes "scanning…" and slow-pulses. Your idea: the status field animates in place, no extra chrome.</li> + <li><b>Section breathe</b> — the whole list gives a faint gold breath while the scan runs; found rows fade in gold. Ambient, ties the animation to what's changing.</li> + <li><b>All (recommended)</b> — spinner + count + fade-in together. The section breathe is optional; it can read as busy on a small panel, so it's off in "all" by default and its own option to try.</li> + </ul> + + <h3>Why an explicit action, not only the count</h3> + <div class="rec"> + Overloading the count as the sole trigger is elegant but a first look doesn't know it's clickable. + An explicit <b>⟳ rescan</b> in the engrave line is discoverable, sits in the same slot as "+ hidden" + (consistent), and doesn't cost a heavy console key. Keeping the count clickable too gives power users + the shortcut without hiding the affordance. A dedicated <b>RESCAN console key</b> (next to DOCTOR / + SPEED TEST) is the third option — heavier, and rescan is a list action, not a diagnostic, so it fits + the engrave line better. + </div> + </div> +</div> + +<script> +const $=id=>document.getElementById(id); +let busy=false, style='all'; +let NETS=[ + {ssid:'@Hyatt_WiFi', sig:3, sec:'WPA2', stored:true, active:true}, + {ssid:'Hyatt_Meeting', sig:3, sec:'WPA2', stored:false, active:false}, + {ssid:'DIRECT-roku-882',sig:2, sec:'WPA2', stored:false, active:false}, + {ssid:'xfinitywifi', sig:1, sec:null, stored:false, active:false}, + {ssid:'HomeNet', sig:0, sec:'WPA2', stored:true, active:false, oor:true}, +]; +const NEWFOUND=[ + {ssid:'Hyatt_Guest', sig:2, sec:null, stored:false, active:false}, + {ssid:'Marriott_CONF', sig:1, sec:'WPA2', stored:false, active:false}, +]; +const pctFor=[null,'22%','44%','61%','78%']; + +$('styleSeg').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return; + style=b.dataset.s;[...$('styleSeg').children].forEach(x=>x.classList.toggle('on',x===b));}); + +function rowEl(n,fresh){ + const r=document.createElement('div'); r.className='lamp-row'+(fresh?' fresh':''); + const lamp=n.active?'lamp':(n.oor?'lamp off':'lamp gold'); + const what=n.active?'active · '+(n.sec||'open') + :n.oor?'stored · out of range' + :(n.stored?'stored · ':'')+(n.sec||'open')+' · '+pctFor[n.sig]; + r.innerHTML=`<span class="${lamp}"></span><span class="who">${n.active?'<b>'+n.ssid+'</b>':n.ssid}</span>`+ + (!n.active&&!n.oor?`<span class="ladder l${n.sig}" style="margin-left:6px"><i></i><i></i><i></i><i></i></span>`:'')+ + `<span class="what">${what}</span>`; + r.onclick=()=>{ if(busy)return; toast(n.active?'already on '+n.ssid:'would join '+n.ssid); }; + return r; +} +function render(freshSet){ + const host=$('networks'); host.innerHTML=''; + const inRange=NETS.filter(n=>!n.oor).sort((a,b)=>(b.active-a.active)||(b.sig-a.sig)); + const oor=NETS.filter(n=>n.oor); + [...inRange,...oor].forEach(n=>host.appendChild(rowEl(n,freshSet&&freshSet.has(n.ssid)))); + if(!busy) $('cnt').textContent='· '+inRange.length+' in range'; +} +function rescan(){ + if(busy) return; busy=true; + const useSpin = style==='all'||style==='spin'; + const useCount= style==='all'||style==='count'; + const useSec = style==='section'; + if(useSpin) $('rescan').classList.add('spin'); + if(useCount){ $('cnt').classList.add('scanning'); $('cnt').textContent='scanning…'; } + if(useSec) $('networks').classList.add('breathe'); + toast('scanning for networks…'); + // networks trickle in as "found" + const fresh=new Set(); + setTimeout(()=>{ NETS.splice(1,0,NEWFOUND[0]); fresh.add(NEWFOUND[0].ssid); render(fresh); },900); + setTimeout(()=>{ NETS.splice(3,0,NEWFOUND[1]); fresh.add(NEWFOUND[1].ssid); render(fresh); },1700); + setTimeout(()=>{ + busy=false; + $('rescan').classList.remove('spin'); + $('cnt').classList.remove('scanning'); + $('networks').classList.remove('breathe'); + const n=NETS.filter(x=>!x.oor).length; + $('cnt').textContent='· '+n+' in range'; + toast('scan complete — '+n+' networks in range'); + // reset for the next run so the demo is repeatable + NETS=NETS.filter(x=>x.ssid!=='Hyatt_Guest'&&x.ssid!=='Marriott_CONF'); + },2600); +} +render(); +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-03-panel-widget-gallery-prototype.html b/docs/prototypes/2026-07-03-panel-widget-gallery-prototype.html new file mode 100644 index 0000000..8e642f4 --- /dev/null +++ b/docs/prototypes/2026-07-03-panel-widget-gallery-prototype.html @@ -0,0 +1,338 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Panel widget gallery — dupre instrument console</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 5rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.wrap{max-width:1320px;margin:0 auto} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} +.masthead p{color:var(--dim);font-size:.86rem;max-width:86ch} +.masthead p b{color:var(--silver)} +h2{color:var(--steel);font-size:.74rem;letter-spacing:.24em;text-transform:uppercase; + margin:2.2rem 0 .2rem;display:flex;align-items:center;gap:12px} +h2::after{content:"";height:1px;background:var(--wash);flex:1} + +.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(232px,1fr));gap:14px;margin-top:1rem} +.card{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:12px; + padding:13px 14px 12px;display:flex;flex-direction:column;gap:9px; + box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 6px 14px rgba(0,0,0,.4)} +.wname{color:var(--cream);font-size:.82rem;font-weight:700;display:flex;align-items:center;gap:8px} +.wname .no{color:var(--panel);background:var(--gold);border-radius:4px;font-size:.6rem;padding:0 5px;font-weight:400} +.stagew{background:var(--well);border:1px solid #201d17;border-radius:9px;padding:14px 12px; + min-height:78px;display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap} +.wnote{color:var(--dim);font-size:.72rem;line-height:1.4} +.wnote b{color:var(--steel);font-weight:400} + +/* ---- shared primitives ---- */ +.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55)} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} +.lamp.off{background:var(--wash);box-shadow:none} +.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite} +@keyframes pulse{50%{opacity:.25}} + +.switch{width:40px;height:21px;border-radius:11px;background:var(--wash);border:1px solid var(--slate); + position:relative;cursor:pointer} +.switch::after{content:"";position:absolute;top:2px;left:2px;width:15px;height:15px;border-radius:50%; + background:var(--dim);transition:left .15s} +.switch.on{background:var(--slate);border-color:var(--gold)} +.switch.on::after{left:21px;background:var(--gold)} +.switch.red{background:rgba(203,107,77,.2);border-color:var(--fail)} +.switch.red::after{background:var(--fail);left:2px} + +.key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer; + background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a; + border-radius:8px;padding:8px 12px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} +.key:hover{color:var(--gold);border-color:var(--gold)} +.key:active{transform:translateY(1px)} +.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} +.key.off{opacity:.4} + +.chip{color:var(--dim);cursor:pointer;border-bottom:1px dotted var(--wash);font-size:12px} +.chip.on{color:var(--gold);border-color:var(--gold)} + +.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px} +.badge.red{background:var(--fail);color:var(--cream)} +.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)} + +/* fader */ +.fader{width:150px;height:16px;position:relative;cursor:pointer} +.fader .slot{position:absolute;top:6px;left:0;right:0;height:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden} +.fader .fill{position:absolute;top:0;left:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold))} +.fader .cap{position:absolute;top:1px;width:7px;height:14px;border-radius:2px;margin-left:-3.5px; + background:linear-gradient(180deg,#f0d879,#caa233);border:1px solid #7a6414;box-shadow:0 1px 2px rgba(0,0,0,.5)} +/* vertical fader */ +.vfader{width:16px;height:64px;position:relative;cursor:pointer} +.vfader .slot{position:absolute;left:6px;top:0;bottom:0;width:4px;border-radius:2px;background:#0d0f10;border:1px solid #231f18;overflow:hidden} +.vfader .fill{position:absolute;left:0;right:0;bottom:0;background:linear-gradient(0deg,#8a7524,var(--gold))} +.vfader .cap{position:absolute;left:1px;height:7px;width:14px;border-radius:2px;margin-top:-3.5px; + background:linear-gradient(90deg,#caa233,#f0d879);border:1px solid #7a6414} + +/* rotary knob */ +.knob{width:52px;height:52px;border-radius:50%;position:relative;cursor:pointer; + background:radial-gradient(circle at 40% 35%,#2a2622,#141210);border:1px solid #3a352c; + box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)} +.knob .ind{position:absolute;left:50%;top:5px;width:2px;height:16px;background:var(--gold-hi); + margin-left:-1px;transform-origin:50% 21px;border-radius:1px;box-shadow:0 0 5px rgba(255,215,95,.6)} +.knob-scale{position:relative;width:64px;height:40px} +.knob-scale .arc{position:absolute;inset:0 0 -24px 0;border:1.5px solid var(--wash);border-top-color:var(--gold);border-radius:50%;opacity:.5} + +/* needle gauge */ +.gauge{width:96px} +.gauge .dial{position:relative;height:48px;overflow:hidden} +.gauge .arc{position:absolute;inset:0 0 -48px 0;border:2px solid var(--wash);border-radius:50%} +.gauge .tk{position:absolute;left:50%;bottom:0;width:1.5px;height:8px;background:var(--steel);transform-origin:50% 48px} +.gauge .ndl{position:absolute;left:50%;bottom:0;width:2px;height:40px;background:var(--gold-hi); + transform-origin:50% 100%;transform:rotate(0deg);border-radius:2px;box-shadow:0 0 6px rgba(255,215,95,.5); + transition:transform .5s cubic-bezier(.3,1.3,.5,1)} +.gauge .hub{position:absolute;left:50%;bottom:-4px;width:8px;height:8px;margin-left:-4px;border-radius:50%;background:var(--gold)} +.gauge .gv{color:var(--cream);text-align:center;font-size:12px;font-weight:700;margin-top:5px;font-variant-numeric:tabular-nums} + +/* segmented VU / LED bar */ +.vu{width:170px;display:flex;flex-direction:column;gap:5px} +.vurow{display:flex;align-items:center;gap:7px} +.vurow .ch{color:var(--steel);font-size:.6rem;width:8px} +.vubar{flex:1;display:flex;gap:2px;height:9px} +.vubar i{flex:1;background:var(--wash);border-radius:1px;opacity:.3} +.vubar i.on{opacity:1;background:var(--pass)}.vubar i.hot{opacity:1;background:var(--gold)} +.vubar i.clip{opacity:1;background:var(--fail)}.vubar i.peak{outline:1px solid var(--gold-hi);outline-offset:-1px} + +/* mini 4-bar signal */ +.sig{display:flex;align-items:flex-end;gap:2px;height:18px} +.sig i{width:4px;background:var(--wash);border-radius:1px} +.sig i:nth-child(1){height:5px}.sig i:nth-child(2){height:9px}.sig i:nth-child(3){height:13px}.sig i:nth-child(4){height:17px} +.sig i.on{background:var(--pass)}.sig i.hot{background:var(--gold)}.sig i.clip{background:var(--fail)} + +/* signal ladder (wifi bars) */ +.ladder{display:inline-flex;gap:3px;align-items:flex-end;height:18px} +.ladder i{width:5px;background:var(--wash);border-radius:1px} +.ladder i:nth-child(1){height:6px}.ladder i:nth-child(2){height:10px} +.ladder i:nth-child(3){height:14px}.ladder i:nth-child(4){height:18px} +.ladder.l3 i:nth-child(-n+3){background:var(--gold)} + +/* linear progress / fuel bar */ +.bar{width:160px;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative} +.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold));border-radius:6px} +.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))} + +/* radial ring */ +.ring{width:60px;height:60px;border-radius:50%; + background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0); + display:grid;place-items:center;position:relative} +.ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)} +.ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums} + +/* tabular readout */ +.readout{color:var(--cream);font-size:24px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.04em} +.readout small{color:var(--dim);font-size:12px;font-weight:400} +.readout .u{color:var(--steel);font-size:.6rem;letter-spacing:.2em;display:block;text-align:center;margin-top:2px} + +/* sparkline */ +.spark{width:170px;height:44px} +.spark svg{display:block;width:100%;height:100%} + +/* lamp row (list item) */ +.lrow{width:190px;display:flex;align-items:center;gap:9px;padding:6px 8px;border-radius:7px;background:#141210;cursor:pointer;font-size:12.5px} +.lrow:hover{background:var(--wash)} +.lrow .who{color:var(--silver)}.lrow .who b{color:var(--cream)} +.lrow .what{margin-left:auto;color:var(--dim);font-size:11px} + +/* arm-to-fire */ +.arm{font:inherit;font-size:11.5px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b; + border-radius:8px;padding:7px 12px} +.arm.armed{background:rgba(203,107,77,.12);border-color:var(--fail);color:var(--fail)} + +/* stepper / segmented selector */ +.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden} +.seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 11px;cursor:pointer} +.seg button:last-child{border-right:0} +.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700} + +/* engraved section label */ +.engrave{width:180px;color:var(--steel);font-size:.62rem;letter-spacing:.3em;text-transform:uppercase; + display:flex;align-items:center;gap:9px} +.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.engrave::before{max-width:10px} +.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none} + +/* waveform strip */ +.wave{width:170px;height:38px} +.wave svg{width:100%;height:100%;display:block} + +/* toast */ +.toastw{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:5px 10px} + +/* output well (log step) */ +.owell{width:200px;background:var(--well);border:1px solid var(--wash);border-radius:8px;padding:7px 9px;font-size:11px} +.ostep{display:flex;gap:7px;align-items:flex-start;padding:2px 0} +.ostep .lamp{margin-top:3px;width:7px;height:7px} +.ostep b{color:var(--cream);font-weight:700}.ostep .ev{color:var(--steel);display:block;font-size:10.5px} + +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +</style> +</head> +<body> +<div class="wrap"> +<header class="masthead"> + <div class="eyebrow">archsetup · dupre panel family</div> + <h1>Widget gallery — the instrument-console kit</h1> + <p>Every control + display idiom we can build in the dupre faceplate language, all rendering from + the same tokens the net / bt / sound panels use. <b>Controls</b> take input; <b>meters & + gauges</b> show a live analog value; <b>indicators & readouts</b> show state or a number. + Live ones animate. Pick what fits each job — most cost pure CSS; the two that need a real + drawing surface (needle gauge, waveform) are flagged in their notes.</p> +</header> + +<h2>Controls — take input</h2> +<div class="grid" id="controls"></div> + +<h2>Meters & gauges — live analog value</h2> +<div class="grid" id="meters"></div> + +<h2>Indicators & readouts — state or number</h2> +<div class="grid" id="indicators"></div> + +</div> +<script> +const $ = id => document.getElementById(id); +const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; +function card(host, no, name, html, note){ + const c=document.createElement('div'); c.className='card'; + c.innerHTML=`<div class="wname"><span class="no">${no}</span>${name}</div>`+ + `<div class="stagew">${html}</div><div class="wnote">${note}</div>`; + host.appendChild(c); return c; +} +function buildBars(el,n){el.innerHTML='';for(let k=0;k<n;k++)el.appendChild(document.createElement('i'));} + +/* ============ CONTROLS ============ */ +const C=$('controls'); +card(C,'01','Toggle switch', + `<span class="switch on" onclick="this.classList.toggle('on')"></span> + <span class="switch red"></span>`, + '<b>on / off / muted.</b> The faceplate master control — wifi radio, bt power, master-mute. Click to flip.'); +card(C,'02','Console key', + `<button class="key on">LIVE</button><button class="key">SCAN</button><button class="key red">MUTED</button>`, + '<b>physical push button.</b> DOCTOR / SPEED TEST / mic mode. Gold = engaged, terracotta = off.'); +card(C,'03','Horizontal fader', + `<div class="fader" id="f1"><div class="slot"><div class="fill" style="width:68%"></div></div><div class="cap" style="left:68%"></div></div>`, + '<b>continuous 0-100.</b> Per-device volume, brightness, kbd backlight. Drag; the gold cap tracks.'); +card(C,'04','Vertical fader', + `<div class="vfader"><div class="slot"><div class="fill" style="height:60%"></div></div><div class="cap" style="bottom:60%"></div></div> + <div class="vfader"><div class="slot"><div class="fill" style="height:35%"></div></div><div class="cap" style="bottom:35%"></div></div>`, + '<b>channel-strip style.</b> A mixer column per device if you want the classic board look.'); +card(C,'05','Rotary knob', + `<span class="knob" id="knob" onclick="bumpKnob()"><span class="ind" id="kind"></span></span>`, + '<b>dial in a value.</b> Volume/gain the analog way. Click to turn; drag in the real build. Pairs with a scale arc.'); +card(C,'06','Segmented selector', + `<div class="seg"><button class="on">TIMER</button><button>ALARM</button><button>POMO</button></div>`, + '<b>pick one of a few.</b> Timer type, layout mode, theme. One press-lit segment.'); +card(C,'07','Chip toggle', + `<span class="chip on" onclick="this.classList.toggle('on')">discoverable on</span>`, + '<b>inline binary.</b> A soft toggle inside a line of text — discoverable, auto-dim, DND. Gold when on.'); +card(C,'08','Arm-to-fire', + `<button class="arm" id="arm" onclick="armFire()">forget</button>`, + '<b>two-stage confirm.</b> Destructive/disruptive actions — forget network, disconnect. First click arms (red), second fires.'); +card(C,'09','Lamp row', + `<div class="lrow"><span class="lamp gold"></span><span class="who"><b>WH-1000XM4</b></span><span class="what">tap to connect</span></div>`, + '<b>actionable list item.</b> The net/bt/sound row: lamp + name + status, click acts. The workhorse.'); + +/* ============ METERS & GAUGES ============ */ +const M=$('meters'); +card(M,'10','Needle gauge', + `<div class="gauge"><div class="dial"><div class="arc"></div> + <div class="tk" style="transform:rotate(-60deg)"></div><div class="tk" style="transform:rotate(0)"></div><div class="tk" style="transform:rotate(60deg)"></div> + <div class="ndl" id="g1"></div><div class="hub"></div></div><div class="gv"><span id="g1v">0</span>%</div></div>`, + '<b>analog dial.</b> Throughput, battery, volume level. <b>Needs a Cairo/GTK drawing area</b> — CSS can fake a fixed angle but not a smooth sweep in waybar.'); +card(M,'11','Stereo VU (LED bar)', + `<div class="vu"><div class="vurow"><span class="ch">L</span><span class="vubar" id="vuL"></span></div> + <div class="vurow"><span class="ch">R</span><span class="vubar" id="vuR"></span></div></div>`, + '<b>live signal level.</b> The sound panel\'s second meter row. Peak-hold outline. Pure CSS — pango/box segments.'); +card(M,'12','Mini signal (4-bar)', + `<span class="sig" id="mini"></span>`, + '<b>compact activity.</b> Per-row "is this device playing" indicator. Cheap enough to sit in every list row.'); +card(M,'13','Signal ladder', + `<span class="ladder l3"><i></i><i></i><i></i><i></i></span>`, + '<b>discrete strength.</b> Wifi bars, bt RSSI — a stepped 0-4. Already in the net panel.'); +card(M,'14','Linear fuel bar', + `<div class="bar"><span style="width:72%"></span></div><div class="bar warn"><span style="width:12%"></span></div>`, + '<b>a single 0-100.</b> Battery, disk, download progress. Warn tint under threshold. Trivial in CSS.'); +card(M,'15','Radial ring', + `<span class="ring" style="--p:68"><b>68</b></span>`, + '<b>percentage as a donut.</b> CPU, battery, a single meter where a needle is overkill. conic-gradient, pure CSS.'); +card(M,'16','Sparkline', + `<span class="spark" id="spark"><svg viewBox="0 0 170 44" preserveAspectRatio="none"><polyline id="sparkp" fill="none" stroke="var(--gold-hi)" stroke-width="1.5"/></svg></span>`, + '<b>recent history.</b> Throughput/CPU over the last minute. SVG here; a drawing area in GTK.'); +card(M,'17','Waveform strip', + `<span class="wave" id="wave"><svg viewBox="0 0 170 38" preserveAspectRatio="none"><path id="wavep" fill="none" stroke="var(--gold)" stroke-width="1.2"/></svg></span>`, + '<b>audio waveform / scope.</b> A richer signal view for the sound panel. <b>Needs a drawing surface.</b>'); + +/* ============ INDICATORS & READOUTS ============ */ +const I=$('indicators'); +card(I,'18','Status lamp', + `<span class="lamp"></span><span class="lamp gold"></span><span class="lamp red"></span><span class="lamp off"></span><span class="lamp busy"></span>`, + '<b>one-glance health.</b> Green ok · gold engaged · red fail · dim off · pulsing busy. The family signature.'); +card(I,'19','Badge / tag', + `<span class="badge">TUNNEL</span> <span class="badge red">LOW BATT</span> <span class="badge ghost">2.4G</span>`, + '<b>a labelled flag.</b> On the faceplate or a row — MUTED, AIRPLANE, DEF, a band tag.'); +card(I,'20','Tabular readout', + `<div style="text-align:center"><div class="readout">24:10</div><span class="u">timer</span></div> + <div style="text-align:center"><div class="readout">68<small>%</small></div></div>`, + '<b>a precise number.</b> Clock, countdown, volume %. BerkeleyMono tabular-nums so digits don\'t jitter.'); +card(I,'21','Engraved label', + `<span class="engrave">outputs<span class="cnt">· 3</span></span>`, + '<b>section divider.</b> The hairline-flanked caps label with a count. Groups a panel into readable blocks.'); +card(I,'22','Output well', + `<div class="owell"><div class="ostep"><span class="lamp"></span><span><b>Link</b><span class="ev">wlp170s0 · @Hyatt</span></span></div> + <div class="ostep"><span class="lamp gold"></span><span><b>DNS</b><span class="ev">resolving…</span></span></div></div>`, + '<b>streaming step log.</b> The doctor/scan output — lamp-per-step with evidence. For any run-and-report action.'); +card(I,'23','Toast / status line', + `<span class="toastw">joined @Hyatt_WiFi — saved</span>`, + '<b>transient confirmation.</b> The one-line result after an action. Auto-dismiss; red variant for errors.'); + +/* ---- live animation ---- */ +let ph=0, kang=140; +function bumpKnob(){ kang=(kang+35)%300-0; $('kind').style.transform=`rotate(${kang-150}deg)`; } +function armFire(){ const a=$('arm'); if(a.classList.contains('armed')){a.classList.remove('armed');a.textContent='forget';} + else{a.classList.add('armed');a.textContent='forget? again';} } +buildBars($('vuL'),16); buildBars($('vuR'),16); buildBars($('mini'),0); +$('mini').innerHTML='<i></i><i></i><i></i><i></i>'; +$('kind').style.transform=`rotate(${kang-150}deg)`; +const hist=Array.from({length:40},()=>0.5); +function paintVU(el,l,pk){const b=el.children,n=b.length,lit=Math.round(l*n); + pk.v=Math.max(lit,(pk.v||0)-0.4);const p=Math.round(pk.v); + for(let k=0;k<n;k++){let c=k<lit?(k>=n-2?'clip':k>=n-4?'hot':'on'):'';if(p>0&&k===p-1)c=(c?c+' ':'')+'peak';b[k].className=c;}} +const pkL={v:0},pkR={v:0}; +function paintMini(el,l){const b=el.children,lit=Math.round(l*4);for(let k=0;k<4;k++)b[k].className=k<lit?(k>=3?'clip':k>=2?'hot':'on'):'';} +function lvl(){return Math.max(0,Math.min(1,0.5+0.4*Math.sin(ph*1.3)+ (Math.random()<0.15?Math.random()*0.4:0) - Math.random()*0.08));} +function tick(){ + ph+=0.09; + const a=lvl(), b=lvl(); + paintVU($('vuL'),a,pkL); paintVU($('vuR'),b,pkR); paintMini($('mini'),a); + // needle sweeps 0..100 + const gv=Math.round(50+45*Math.sin(ph*0.7)); + $('g1').style.transform=`rotate(${-60+gv/100*120}deg)`; $('g1v').textContent=gv; + // sparkline + hist.push(0.5+0.42*Math.sin(ph*0.9)+ (Math.random()-0.5)*0.25); hist.shift(); + $('sparkp').setAttribute('points',hist.map((v,i)=>`${i/(hist.length-1)*170},${44-Math.max(0,Math.min(1,v))*40-2}`).join(' ')); + // waveform + let d='M0 19'; for(let x=0;x<=170;x+=3){const y=19+Math.sin(x*0.18+ph*3)*Math.sin(x*0.05)*14; d+=` L${x} ${y.toFixed(1)}`;} + $('wavep').setAttribute('d',d); +} +if(!reduced) setInterval(tick,80); else tick(); +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-03-sound-panel-prototype.html b/docs/prototypes/2026-07-03-sound-panel-prototype.html new file mode 100644 index 0000000..d75f566 --- /dev/null +++ b/docs/prototypes/2026-07-03-sound-panel-prototype.html @@ -0,0 +1,417 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Sound — instrument console (pulsemixer)</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 4rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.masthead{max-width:1280px;margin:0 auto 1.8rem} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} +.masthead p{color:var(--dim);font-size:.86rem;max-width:82ch} +.masthead p b{color:var(--silver);font-weight:700} + +.stage{display:flex;gap:2.2rem;flex-wrap:wrap;max-width:1280px;margin:0 auto;align-items:flex-start} +.slot{width:400px} +.slot-label{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .55rem .2rem} + +.panel{background:var(--panel);border:2px solid var(--gold);border-radius:16px;padding:17px 19px; + box-shadow:0 18px 50px rgba(0,0,0,.55);font-size:13.5px;width:380px;position:relative;overflow:hidden} + +.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);flex:0 0 auto; + box-shadow:0 0 6px 1px rgba(116,147,47,.55)} +.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} + +.b-face{background:var(--raise);border-radius:12px;border:1px solid #262320;padding:11px 14px} +.b-id{display:flex;align-items:center;gap:9px} +.b-id .state-word{color:var(--gold);font-weight:700;font-size:15px;letter-spacing:.12em} +.b-id .unit{color:var(--steel);font-size:.68rem;letter-spacing:.3em;margin-left:auto} +.b-id .g{font-size:17px;color:var(--cream)} +.badge{font-size:.62rem;letter-spacing:.18em;color:var(--panel);background:var(--gold); + border-radius:4px;padding:1px 6px;display:none} +.badge.show{display:inline-block} +.badge.red{background:var(--fail);color:var(--cream)} +.x-btn{margin-left:6px;color:var(--dim);border:0;background:transparent;font:inherit;font-size:1rem; + cursor:pointer;border-radius:50%;width:26px;height:26px;line-height:1;flex:0 0 auto} +.x-btn:hover{background:var(--wash);color:var(--silver)} +/* faceplate master quick-mute — same switch idiom as net wifi / bt power */ +.switch{width:38px;height:20px;border-radius:10px;background:var(--wash); + border:1px solid var(--slate);position:relative;flex:0 0 auto;cursor:pointer} +.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px; + border-radius:50%;background:var(--dim);transition:left .15s} +.switch.on{background:var(--slate);border-color:var(--gold)} +.switch.on::after{left:19px;background:var(--gold)} +.switch.muted{background:rgba(203,107,77,.2);border-color:var(--fail)} +.switch.muted::after{background:var(--fail);left:2px} + +.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.32em;text-transform:uppercase; + display:flex;align-items:center;gap:10px;margin:13px 0 6px} +.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.engrave::before{max-width:12px} +.engrave .cnt{color:var(--dim);letter-spacing:.12em;margin-left:2px;text-transform:none;font-size:.62rem} + +/* device row — a FIXED grid so nothing can overflow the plate: + [signal] [name] [fader] [pct] [mute]. minmax(0,1fr) lets the name shrink + and ellipsis instead of forcing the row wider than the panel. */ +.dev{display:grid;grid-template-columns:15px minmax(0,1fr) 84px 32px 20px; + align-items:center;gap:8px;padding:6px 5px;border-radius:8px;cursor:default} +.dev:hover{background:var(--wash)} +.dev .who{min-width:0;display:flex;align-items:center;gap:6px;color:var(--silver)} +.dev .who .g{font-size:14px;color:var(--dim);flex:0 0 auto} +.dev.active .who .g{color:var(--gold)} +.dev .who .nm{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.dev.active .who .nm{color:var(--cream);font-weight:700} +.dev.muted .who .nm{color:var(--dim)} +.dev .def{font-size:.5rem;letter-spacing:.14em;color:var(--panel);background:var(--gold); + border-radius:3px;padding:0 4px;flex:0 0 auto;display:none} +.dev.active .def{display:inline-block} +/* fader — machined slot + gold cap; width-based fill, bounded to its cell */ +.fader{height:16px;position:relative;cursor:pointer} +.fader .trk{position:absolute;top:6px;left:0;right:0;height:4px;border-radius:2px;background:var(--well); + border:1px solid #231f18;overflow:hidden} +.fader .fill{position:absolute;top:0;left:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold))} +.fader .cap{position:absolute;top:1px;width:7px;height:14px;border-radius:2px;margin-left:-3.5px; + background:linear-gradient(180deg,#f0d879,#caa233);border:1px solid #7a6414;box-shadow:0 1px 2px rgba(0,0,0,.5)} +.dev.muted .fill{background:var(--wash)} +.dev.muted .cap{background:linear-gradient(180deg,#6a6a6a,#3f3f3f);border-color:#2a2a2a} +.pct{color:var(--cream);font-size:11.5px;font-variant-numeric:tabular-nums;text-align:right} +.dev.muted .pct{color:var(--fail)} +.mute-b{color:var(--dim);border:0;background:transparent;font:inherit;font-size:.9rem;cursor:pointer; + border-radius:5px;padding:0;justify-self:center;line-height:1} +.mute-b:hover{color:var(--silver)} +.dev.muted .mute-b{color:var(--fail)} +/* per-row signal mini-meter — 4 bars that light when THIS device has a live + stream, so the sink/source actually playing is visible before you pick it */ +.sig{display:flex;align-items:flex-end;gap:1.5px;height:14px;justify-self:center} +.sig i{width:2.5px;background:var(--wash);border-radius:1px} +.sig i:nth-child(1){height:4px}.sig i:nth-child(2){height:7px} +.sig i:nth-child(3){height:10px}.sig i:nth-child(4){height:13px} +.sig i.on{background:var(--pass)}.sig i.hot{background:var(--gold)}.sig i.clip{background:var(--fail)} + +/* mic mode — three console keys */ +.modes{display:flex;gap:8px;margin-top:2px} +.mode{flex:1;text-align:center;cursor:pointer;font:inherit;font-size:11px;letter-spacing:.06em; + background:linear-gradient(180deg,#23211e,#191715);color:var(--silver); + border:1px solid #33302b;border-bottom-color:#0c0b0a;border-radius:8px;padding:8px 4px; + box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} +.mode:hover{color:var(--gold);border-color:var(--gold)} +.mode.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.mode.on.mute{background:linear-gradient(180deg,#d98a6f,var(--fail));color:var(--cream)} +.mode .k{display:block;font-size:.54rem;letter-spacing:.14em;color:var(--dim);margin-top:2px} +.mode.on .k{color:rgba(16,15,15,.7)} +.ptt-hint{color:var(--dim);font-size:10.5px;text-align:center;margin-top:6px;min-height:1.2em} +.ptt-hint.live{color:var(--gold)} + +/* meter row 1 — the volume dials you set (needles), OUT + IN */ +.meters{display:flex;gap:12px;margin-top:10px} +.meter{flex:1;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:9px 10px 7px;position:relative} +.meter .mode-tag{position:absolute;top:6px;left:8px;font-size:.56rem;letter-spacing:.2em;color:var(--pass)} +.meter .mode-tag.mut{color:var(--fail)} +.meter .dial{position:relative;height:50px;overflow:hidden;margin-top:13px} +.meter .arc{position:absolute;inset:0 0 -50px 0;border:2px solid var(--wash);border-radius:50%} +.meter .tick{position:absolute;left:50%;bottom:0;width:1.5px;height:9px;background:var(--steel);transform-origin:50% 50px} +.meter .needle{position:absolute;left:50%;bottom:0;width:2px;height:42px;background:var(--gold-hi); + transform-origin:50% 100%;transform:rotate(20deg);border-radius:2px; + box-shadow:0 0 6px rgba(255,215,95,.5);transition:transform .3s cubic-bezier(.3,1.3,.5,1)} +.meter .needle.mut{background:var(--fail);box-shadow:0 0 6px rgba(203,107,77,.5)} +.meter .hub{position:absolute;left:50%;bottom:-4px;width:9px;height:9px;margin-left:-4.5px;border-radius:50%;background:var(--gold)} +.meter .m-value{color:var(--cream);font-size:13px;text-align:center;font-weight:700;margin-top:6px;font-variant-numeric:tabular-nums} +.meter .m-value small{color:var(--dim);font-weight:400} +.meter .m-label{color:var(--steel);font-size:.6rem;letter-spacing:.2em;text-align:center;margin-top:2px; + white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + +/* meter row 2 — the stereo VU pair: the live SIGNAL through the selected + output. Confirms which device is actually carrying the audio. */ +.vupair{margin-top:10px;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:9px 11px 8px} +.vuhead{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:7px} +.vuhead .t{color:var(--steel);font-size:.56rem;letter-spacing:.2em;text-transform:uppercase} +.vuhead .src{color:var(--cream);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%} +.vurow{display:flex;align-items:center;gap:8px;margin:4px 0} +.vurow .ch{color:var(--steel);font-size:.62rem;width:9px;flex:0 0 auto} +.vubar{flex:1;display:flex;gap:2px;height:9px} +.vubar i{flex:1;background:var(--wash);border-radius:1px;opacity:.3} +.vubar i.on{opacity:1;background:var(--pass)} +.vubar i.hot{opacity:1;background:var(--gold)} +.vubar i.clip{opacity:1;background:var(--fail)} +.vubar i.peak{outline:1px solid var(--gold-hi);outline-offset:-1px} +.vupair.mute .vubar i{background:var(--wash);opacity:.22} +.vupair.mute .vuhead .src{color:var(--fail)} + +.toast{margin-top:10px;font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px; + padding:5px 10px;opacity:0;transition:opacity .25s;min-height:1.4em} +.toast.show{opacity:1} +.toast.err{background:transparent;border:1px solid var(--fail);color:var(--fail)} + +.aside{flex:1 1 320px;min-width:300px} +.aside h3{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:1.1rem 0 .5rem} +.aside h3:first-child{margin-top:.2rem} +.aside ul{list-style:none} +.aside li{font-size:.82rem;padding:.24rem 0 .24rem 1.1rem;position:relative} +.aside li::before{content:"·";color:var(--gold);position:absolute;left:.25rem} +.aside li b{color:var(--cream);font-weight:700} +.aside li em{color:var(--dim);font-style:normal} +.barbits{display:flex;gap:14px;margin:.4rem 0 .2rem;align-items:center;flex-wrap:wrap} +.barbits span{display:flex;align-items:center;gap:7px;color:var(--silver);font-size:.82rem} +.barbits .g{font-size:19px;color:var(--cream)} +.barbits .g.mut{color:var(--fail)} +.rec{border:1px dashed var(--wash);border-radius:10px;padding:.85rem 1rem;margin-top:.9rem;font-size:.82rem;color:var(--silver)} +.rec b{color:var(--gold)} +*{scrollbar-width:thin;scrollbar-color:var(--slate) transparent} +::-webkit-scrollbar{width:6px;height:6px} +::-webkit-scrollbar-thumb{background:var(--slate);border-radius:4px} +@media (prefers-reduced-motion:reduce){.needle{transition:none}} +</style> +</head> +<body> + +<header class="masthead"> + <div class="eyebrow">archsetup · dupre panel family · instrument consoles</div> + <h1>Sound — the pulsemixer console</h1> + <p>Same faceplate as net + bluetooth. The bar's <b>sound glyph</b> opens this. Every sink and + source is a row: <b>click the row body to make it default</b>, drag the fader for its volume, + hit the glyph to mute just it. Each row has a <b>live-signal meter</b> — the device actually + carrying audio dances even when it isn't the default, so you can <b>find the one playing the + meeting</b> and click it. The faceplate switch is the <b>master quick-mute</b>; the mic carries + <b>live · muted · push-to-talk</b> (hold Space). Row 1 of gauges is the volume you set; row 2 is + the <b>stereo VU</b> of the selected output's live signal.</p> +</header> + +<div class="stage"> + <div class="slot"> + <div class="slot-label">snd·01 — pulsemixer in console form</div> + <div class="panel"> + + <div class="b-face"> + <div class="b-id"> + <span class="lamp" id="lamp"></span> + <span class="g" id="face-g"></span> + <span class="state-word" id="state">PLAYBACK</span> + <span class="badge red" id="mute-badge">MUTED</span> + <span class="unit">SND·01</span> + <span class="switch on" id="master" onclick="masterMute()" title="Master quick-mute (Super+Shift+M)"></span> + <button class="x-btn" title="Close (Esc)">✕</button> + </div> + </div> + + <div class="engrave">outputs<span class="cnt" id="out-cnt"></span></div> + <div id="outputs"></div> + + <div class="engrave">inputs<span class="cnt" id="in-cnt"></span></div> + <div id="inputs"></div> + + <div class="engrave">mic mode</div> + <div class="modes"> + <button class="mode" id="md-toggle" onclick="micToggle()">LIVE<span class="k">Super+Shift+A</span></button> + <button class="mode" id="md-ptt" onclick="micPtt()">PUSH·TALK<span class="k">hold Space</span></button> + </div> + <div class="ptt-hint" id="ptt-hint"></div> + + <!-- meter row 1 — volume you set --> + <div class="meters"> + <div class="meter"> + <span class="mode-tag" id="vt-out">OUT VOL</span> + <div class="dial"><div class="arc"></div> + <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div> + <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div> + <div class="tick" style="transform:rotate(60deg)"></div> + <div class="needle" id="n-out"></div><div class="hub"></div></div> + <div class="m-value"><span id="v-out">68</span> <small>%</small></div> + <div class="m-label" id="l-out">SPEAKERS</div> + </div> + <div class="meter"> + <span class="mode-tag" id="vt-in">IN VOL</span> + <div class="dial"><div class="arc"></div> + <div class="tick" style="transform:rotate(-60deg)"></div><div class="tick" style="transform:rotate(-30deg)"></div> + <div class="tick" style="transform:rotate(0)"></div><div class="tick" style="transform:rotate(30deg)"></div> + <div class="tick" style="transform:rotate(60deg)"></div> + <div class="needle" id="n-in"></div><div class="hub"></div></div> + <div class="m-value"><span id="v-in">54</span> <small>%</small></div> + <div class="m-label" id="l-in">BUILT-IN MIC</div> + </div> + </div> + + <!-- meter row 2 — stereo VU of the selected output's live signal --> + <div class="vupair" id="vupair"> + <div class="vuhead"><span class="t">signal · VU peak</span><span class="src" id="vu-src">SPEAKERS</span></div> + <div class="vurow"><span class="ch">L</span><span class="vubar" id="vu-l"></span></div> + <div class="vurow"><span class="ch">R</span><span class="vubar" id="vu-r"></span></div> + </div> + + <div class="toast" id="toast"></div> + </div> + </div> + + <div class="aside"> + <h3>The bar glyph</h3> + <div class="barbits"> + <span><span class="g"></span> normal — speaker + arcs</span> + <span><span class="g mut"></span> muted — speaker ✕</span> + <span><span class="g" style="color:var(--gold)"></span> ptt armed</span> + </div> + <h3>Idiom map (same as net / bt)</h3> + <ul> + <li><b>Faceplate switch = master quick-mute</b> — the net wifi / bt power switch, here muting all output. Flip: state → MUTED, lamp red, bar glyph → speaker-✕.</li> + <li><b>Rows are devices</b> — every sink + source. <b>Click the row body</b> to set default (gold DEF moves); the fader sets that device's volume; the trailing glyph mutes just it.</li> + <li><b>Per-row signal meter</b> — the 4 bars at the left of each row read that device's <b>live</b> level. A sink can carry a stream without being default, so the one playing lights up — <em>demo: the music is on WH-1000XM4 while Speakers is still default. Click WH-1000XM4 to move to it.</em></li> + <li><b>Two meter rows</b> — row 1 = the volume you set (OUT + IN needles); row 2 = the <b>stereo VU</b> (L/R) of the selected output's live signal, red when muted.</li> + <li><b>Mic = two console keys</b> — one toggles LIVE↔MUTED (the label flips to show the state), the other is PUSH·TALK: the mic sits muted and un-mutes only while Space is held.</li> + <li><b>Verify-everything</b> — every action re-reads pactl/wpctl state after firing, like net/bt.</li> + </ul> + <h3>Push-to-talk — the one hard part</h3> + <div class="rec"> + Hold-to-talk needs a global key grab under Wayland. Two routes to spec: <b>(a)</b> a Hyprland + <b>bind pair</b> — <em>bindp</em> Space press → unmute, release → re-mute, armed only while PTT + mode is active (so it doesn't steal Space everywhere); or <b>(b)</b> an <b>evdev/libinput + listener</b> reading the key directly. (a) is lighter; (b) survives focus changes but needs + input-group permissions. Feasibility research is phase 1 of the spec. + </div> + </div> +</div> + +<script> +const $ = id => document.getElementById(id); +const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; + +/* sig = base live level 0..1 (what stream is on this device right now). + Note the demo: music is on WH-1000XM4 (sig .72) while Speakers is default. */ +let OUT = [ + {id:'spk', name:'Built-in Speakers', g:'', vol:68, mute:false, def:true, sig:0, ph:0.0}, + {id:'xm4', name:'WH-1000XM4', g:'', vol:80, mute:false, def:false, sig:0.72,ph:1.7}, + {id:'hdmi',name:'HDMI · Dell U2720', g:'', vol:100,mute:true, def:false, sig:0, ph:2.9}, +]; +let IN = [ + {id:'bmic', name:'Built-in Mic', g:'', vol:54, mute:false, def:true, sig:0.16,ph:0.6}, + {id:'yeti', name:'Blue Yeti USB', g:'', vol:70, mute:false, def:false, sig:0, ph:2.1}, + {id:'xm4m', name:'WH-1000XM4 Headset',g:'', vol:60, mute:true, def:false, sig:0, ph:3.3}, +]; +let master = false, micMode = 'live', pttHeld = false; +const pkL={v:0}, pkR={v:0}; + +function outMutedOf(d){ return master || d.mute; } +function inMutedOf(d){ return d.mute || (d.def && (micMode==='mute' || (micMode==='ptt' && !pttHeld))); } + +function fader(dev){ + const w=document.createElement('div'); w.className='fader'; + w.innerHTML=`<div class="trk"><div class="fill" style="width:${dev.vol}%"></div></div>`+ + `<div class="cap" style="left:${dev.vol}%"></div>`; + w.onclick=(e)=>{e.stopPropagation(); + const r=w.getBoundingClientRect(); + dev.vol=Math.max(0,Math.min(100,Math.round((e.clientX-r.left)/r.width*100))); + if(dev.vol>0) dev.mute=false; + render(); toast(`${dev.name} → ${dev.vol}%`); + }; + return w; +} +function row(dev,kind){ + const muted = kind==='out'?outMutedOf(dev):inMutedOf(dev); + const r=document.createElement('div'); + r.className='dev'+(dev.def?' active':'')+(muted?' muted':''); + r.title=dev.name; + const sig=document.createElement('span'); sig.className='sig'; sig.id='sig-'+dev.id; + sig.innerHTML='<i></i><i></i><i></i><i></i>'; + const who=document.createElement('div'); who.className='who'; + who.innerHTML=`<span class="g">${dev.g}</span><span class="nm">${dev.name}</span><span class="def">DEF</span>`; + const pct=document.createElement('span'); pct.className='pct'; pct.textContent=dev.mute?'mute':dev.vol+'%'; + const mb=document.createElement('button'); mb.className='mute-b'; mb.textContent=dev.mute?'':''; + mb.title='mute '+dev.name; + mb.onclick=(e)=>{e.stopPropagation();dev.mute=!dev.mute;render();toast(`${dev.name} ${dev.mute?'muted':'unmuted'}`);}; + r.append(sig,who,fader(dev),pct,mb); + r.onclick=()=>{ if(dev.def) return; + (kind==='out'?OUT:IN).forEach(d=>d.def=false); dev.def=true; render(); + toast(`default ${kind==='out'?'output':'input'} → ${dev.name}`); + }; + return r; +} +function render(){ + const o=$('outputs'); o.innerHTML=''; OUT.forEach(d=>o.appendChild(row(d,'out'))); + const i=$('inputs'); i.innerHTML=''; IN.forEach(d=>i.appendChild(row(d,'in'))); + $('out-cnt').textContent='· '+OUT.length; + $('in-cnt').textContent='· '+IN.length; + const od=OUT.find(d=>d.def), id=IN.find(d=>d.def); + const oM=outMutedOf(od), iM=inMutedOf(id); + $('master').className='switch'+(master?' muted':' on'); + $('state').textContent=oM?'MUTED':'PLAYBACK'; + $('lamp').className='lamp'+(oM?' red':''); + $('face-g').textContent=oM?'':''; + $('mute-badge').classList.toggle('show',oM); + setNeedle('out',oM?0:od.vol,oM); $('v-out').textContent=oM?0:od.vol; $('l-out').textContent=od.name.toUpperCase(); + setNeedle('in',iM?0:id.vol,iM); $('v-in').textContent=iM?0:id.vol; $('l-in').textContent=id.name.toUpperCase(); + $('vt-out').textContent=oM?'OUT·MUTE':'OUT VOL'; $('vt-out').className='mode-tag'+(oM?' mut':''); + $('vt-in').textContent=iM?'IN·MUTE':'IN VOL'; $('vt-in').className='mode-tag'+(iM?' mut':''); + $('vu-src').textContent=od.name.toUpperCase(); + $('vupair').classList.toggle('mute',oM); + // mic controls: one live/muted toggle + one push-to-talk key + const tg=$('md-toggle'); + tg.textContent = micMode==='mute' ? 'MUTED' : 'LIVE'; + tg.appendChild(Object.assign(document.createElement('span'),{className:'k',textContent:'Super+Shift+A'})); + tg.className = 'mode' + (micMode==='mute' ? ' on mute' : micMode==='live' ? ' on' : ''); + $('md-ptt').className='mode'+(micMode==='ptt'?' on':''); + $('ptt-hint').textContent = micMode==='ptt' + ? (pttHeld?'▸ transmitting — Space held':'mic muted — hold Space to talk') + : micMode==='mute' ? 'mic muted' : ''; + $('ptt-hint').className='ptt-hint'+(micMode==='ptt'&&pttHeld?' live':''); +} +function setNeedle(side,val,muted){ + const deg=-60+Math.max(0,Math.min(1,val/100))*120; + const n=$('n-'+side); n.style.transform=`rotate(${deg}deg)`; + n.className='needle'+(muted?' mut':''); +} + +/* live-signal animation — the per-row minis + the stereo VU pair */ +let phase=0; +function level(dev,muted){ + if(muted||!dev.sig) return 0; + const env=0.5+0.5*Math.abs(Math.sin(phase*1.25+dev.ph)); + const trans=Math.random()<0.14?Math.random()*0.4:0; + return Math.max(0,Math.min(1,dev.sig*env+trans-Math.random()*0.07)); +} +function paintMini(el,lvl){ + const b=el.children, lit=Math.round(lvl*4); + for(let k=0;k<4;k++){ b[k].className = k<lit ? (k>=3?'clip':k>=2?'hot':'on') : ''; } +} +function paintVU(el,lvl,pk){ + const b=el.children, n=b.length, lit=Math.round(lvl*n); + pk.v=Math.max(lit,pk.v-0.35); const p=Math.round(pk.v); + for(let k=0;k<n;k++){ let c = k<lit ? (k>=n-2?'clip':k>=n-4?'hot':'on') : ''; + if(p>0 && k===p-1) c=(c?c+' ':'')+'peak'; b[k].className=c; } +} +function buildVU(el,n){ el.innerHTML=''; for(let k=0;k<n;k++) el.appendChild(document.createElement('i')); } +function tick(){ + phase+=0.09; + OUT.forEach(d=>{const el=$('sig-'+d.id); if(el) paintMini(el, d._l=level(d,outMutedOf(d)));}); + IN.forEach(d=>{const el=$('sig-'+d.id); if(el) paintMini(el, d._l=level(d,inMutedOf(d)));}); + const od=OUT.find(d=>d.def), oM=outMutedOf(od); + const base=oM?0:level(od,false); + paintVU($('vu-l'),Math.min(1,base*(0.92+Math.random()*0.16)),pkL); + paintVU($('vu-r'),Math.min(1,base*(0.92+Math.random()*0.16)),pkR); +} + +function masterMute(){ master=!master; render(); toast(master?'ALL OUTPUT MUTED':'output unmuted'); } +/* two mic buttons: toggle flips live<->muted (and leaves ptt); ptt arms/disarms */ +function micToggle(){ micMode = micMode==='mute' ? 'live' : 'mute'; pttHeld=false; render(); + toast(micMode==='mute'?'mic muted':'mic live'); } +function micPtt(){ micMode = micMode==='ptt' ? 'live' : 'ptt'; pttHeld=false; render(); + toast(micMode==='ptt'?'push-to-talk armed — hold Space':'mic live'); } +let tT; +function toast(msg,err){ const t=$('toast'); t.textContent=msg; + t.className='toast show'+(err?' err':''); clearTimeout(tT); tT=setTimeout(()=>t.className='toast',2200); } +addEventListener('keydown',e=>{ if(e.code==='Space'&&micMode==='ptt'&&!e.repeat){e.preventDefault();pttHeld=true;render();}}); +addEventListener('keyup', e=>{ if(e.code==='Space'&&micMode==='ptt'){e.preventDefault();pttHeld=false;render();}}); + +buildVU($('vu-l'),16); buildVU($('vu-r'),16); +render(); +if(!reduced) setInterval(tick,70); else tick(); +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-03-waybar-redesign-prototype.html b/docs/prototypes/2026-07-03-waybar-redesign-prototype.html new file mode 100644 index 0000000..3f3e7c1 --- /dev/null +++ b/docs/prototypes/2026-07-03-waybar-redesign-prototype.html @@ -0,0 +1,321 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Waybar redesign — dupre instrument console</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 5rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.wrap{max-width:1400px;margin:0 auto} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} +.masthead p{color:var(--dim);font-size:.86rem;max-width:88ch} +.masthead p b{color:var(--silver);font-weight:700} + +/* each variation sits on a "desk" — a strip of desktop so the bar reads as a + real top bar floating over a window, exactly how it looks on-screen */ +.desk{margin:1.9rem 0 0;border-radius:14px;overflow:hidden; + border:1px solid #211e1a; + background: + linear-gradient(180deg,#0e0d0c 0 62px,transparent 62px), + repeating-linear-gradient(135deg,#191613 0 14px,#171512 14px 28px); + position:relative} +.desk-label{display:flex;align-items:baseline;gap:.7rem;padding:.5rem .2rem .1rem} +.desk-label .n{color:var(--gold);font-size:.82rem;letter-spacing:.08em} +.desk-label .d{color:var(--dim);font-size:.76rem} +.desk-window{position:absolute;inset:70px 26px 22px;border:1px solid #262320;border-radius:10px; + background:linear-gradient(180deg,#131110,#0e0d0c);opacity:.6} +.desk-window::before{content:"emacs — instrument-console.el";position:absolute;top:8px;left:14px; + color:#3a3630;font-size:.72rem} + +/* the bar frame: matches waybar's -54 top strip. two clusters, gold-bordered, + floating with a gap between them (modules-center is empty in the real config) */ +.bar{position:relative;z-index:2;display:flex;justify-content:space-between;align-items:flex-start; + gap:1rem;padding:10px 12px;height:132px} +.cluster{display:flex;align-items:center;gap:2px; + background:var(--panel);border:1.4px solid var(--gold);border-radius:15px; + padding:2px 9px;box-shadow:0 4px 9px rgba(0,0,0,.5)} +.mod{display:flex;align-items:center;gap:7px;color:var(--silver); + padding:7px 9px;border-radius:11px;font-size:14px;cursor:default;white-space:nowrap;position:relative} +.mod .g{font-size:16px;line-height:1} +.mod .g.xl{font-size:19px} +.mod:hover{background:var(--wash)} +.val{font-variant-numeric:tabular-nums} +.cream{color:var(--cream)}.gold{color:var(--gold)}.dim{color:var(--dim)} +.fail{color:var(--fail)}.pass{color:var(--pass)}.steel{color:var(--steel)} + +/* workspaces — circular tokens like the real ws-icons */ +.ws{display:flex;gap:5px;padding:0 3px} +.ws b{width:30px;height:30px;border-radius:50%;display:grid;place-items:center;font-size:13px; + color:var(--silver);border:1.4px solid var(--slate);background:#141210} +.ws b.on{color:var(--panel);background:var(--gold);border-color:var(--gold);font-weight:700; + box-shadow:0 0 8px 1px rgba(218,181,61,.4)} +.ws b.busy{border-color:var(--steel);color:var(--cream)} +.menu{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;color:var(--gold); + font-size:17px;background:linear-gradient(180deg,#211e19,#151210);border:1px solid #33302b} +.title{color:var(--dim);font-size:13px;max-width:230px;overflow:hidden;text-overflow:ellipsis} + +/* collapse arrows — recessed dim wells, per the current design */ +.arrow{color:var(--dim);font-size:12px;background:rgba(0,0,0,.35); + box-shadow:inset 0 1px 2px rgba(0,0,0,.7);border-radius:7px;padding:8px 7px} + +/* lamp — the panel's signature status dot, glow and all */ +.lamp{width:8px;height:8px;border-radius:50%;background:var(--pass);flex:0 0 auto; + box-shadow:0 0 6px 1px rgba(116,147,47,.55)} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} +.lamp.off{background:var(--wash);box-shadow:none} + +/* engraved hairline divider between functional groups (echoes .engrave rules) */ +.sep{width:1px;align-self:stretch;margin:6px 3px; + background:linear-gradient(180deg,transparent,var(--wash) 22%,var(--wash) 78%,transparent)} + +.notes{margin-top:2.4rem;display:flex;gap:2rem;flex-wrap:wrap} +.note{flex:1 1 300px;min-width:280px} +.note h3{color:var(--steel);font-size:.7rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .5rem} +.note ul{list-style:none} +.note li{font-size:.82rem;padding:.24rem 0 .24rem 1.1rem;position:relative;color:var(--silver)} +.note li::before{content:"·";color:var(--gold);position:absolute;left:.25rem} +.note li b{color:var(--cream);font-weight:700} +.note li em{color:var(--dim);font-style:normal} +.rec{border:1px dashed var(--wash);border-radius:10px;padding:.9rem 1.1rem;margin-top:1.1rem; + font-size:.83rem;color:var(--silver)} +.rec b{color:var(--gold)} + +/* ============ V1 · FACEPLATE ============ */ +/* machined faceplate: vertical gradient + a 1px top highlight + deeper shadow. + otherwise the current layout — lowest-risk, faithful to GTK CSS. */ +.v1 .cluster{background:linear-gradient(180deg,var(--raise),var(--panel)); + box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 6px 14px rgba(0,0,0,.55)} +.v1 .val.clock{color:var(--cream)} + +/* ============ V2 · INSTRUMENT SEGMENTS ============ */ +/* each functional group is its own recessed sub-faceplate with an engraved unit + label underneath, lamps on status modules, gold-hi active values */ +.v2 .cluster{background:linear-gradient(180deg,var(--raise),var(--panel));padding:3px 6px; + box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 6px 14px rgba(0,0,0,.55)} +.v2 .seg{display:flex;flex-direction:column;align-items:center;gap:2px; + background:var(--well);border:1px solid #232019;border-radius:10px;padding:3px 5px 2px;margin:0 2px} +.v2 .seg .row{display:flex;align-items:center;gap:6px} +.v2 .seg .unit{color:var(--steel);font-size:.52rem;letter-spacing:.24em;text-transform:uppercase} +.v2 .mod{padding:5px 7px} +.v2 .mod:hover{background:var(--wash)} +.v2 .menu{background:linear-gradient(180deg,#2a251d,#161310);border-color:var(--gold)} +/* mini gauge for sysmon — a squat needle echoing the panel meters */ +.gauge{width:26px;height:15px;position:relative;overflow:hidden} +.gauge .arc{position:absolute;inset:0 0 -26px 0;border:1.5px solid var(--wash);border-radius:50%} +.gauge .ndl{position:absolute;left:50%;bottom:0;width:1.5px;height:13px;background:var(--gold-hi); + transform-origin:50% 100%;transform:rotate(18deg);border-radius:1px; + box-shadow:0 0 4px rgba(255,215,95,.5)} +.gauge .hub{position:absolute;left:50%;bottom:-2px;width:5px;height:5px;margin-left:-2.5px; + border-radius:50%;background:var(--gold)} + +/* ============ V3 · FULL CONSOLE ============ */ +/* every module a recessed well with a lamp; console-key toggles with full + physical-key gradient + inset; sysmon as twin analog gauges; cream clock */ +.v3 .cluster{background:linear-gradient(180deg,#0d0c0b,#080807);border-width:1.8px; + box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 8px 18px rgba(0,0,0,.6);padding:3px 8px} +.v3 .mod{background:var(--well);border:1px solid #201d17;border-radius:9px;margin:0 2px;padding:6px 9px} +.v3 .mod:hover{background:#141210;border-color:var(--slate)} +/* console-key toggles — the physical key from the panels' .c-btn */ +.v3 .key{background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b; + border-bottom-color:#0c0b0a;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} +.v3 .key.engaged{color:var(--gold);border-color:var(--gold)} +.v3 .key.off{color:var(--fail)} +.v3 .menu{background:linear-gradient(180deg,#2a251d,#141110);border:1px solid var(--gold); + box-shadow:0 0 8px rgba(218,181,61,.25)} +.v3 .ws b{background:var(--well);border-color:#201d17} +.v3 .ws b.on{background:var(--gold);border-color:var(--gold)} +.v3 .val.clock{color:var(--cream);font-weight:700} +.v3 .tz{color:var(--steel);font-size:.55rem;letter-spacing:.2em} +.v3 .twin{display:flex;gap:6px} +.v3 .gauge2{width:22px;height:14px;position:relative;overflow:hidden} +.v3 .gauge2 .arc{position:absolute;inset:0 0 -22px 0;border:1.5px solid var(--wash);border-radius:50%} +.v3 .gauge2 .ndl{position:absolute;left:50%;bottom:0;width:1.5px;height:12px;transform-origin:50% 100%; + border-radius:1px;background:var(--gold-hi);box-shadow:0 0 4px rgba(255,215,95,.5)} +.v3 .gauge2 .ndl.warn{background:var(--gold)} +.v3 .gauge2 .hub{position:absolute;left:50%;bottom:-2px;width:4px;height:4px;margin-left:-2px; + border-radius:50%;background:var(--gold)} +</style> +</head> +<body> +<div class="wrap"> + +<header class="masthead"> + <div class="eyebrow">archsetup · dupre panel family · waybar</div> + <h1>Waybar — three ways to spruce it</h1> + <p>The bar already runs the dupre palette and a gold border. These three push it toward the + <b>instrument-console faceplate</b> language of the net + bluetooth panels — machined + gradient plates, engraved unit labels, glowing status lamps, physical console keys, analog + gauges — dialing the intensity up from left-touch to full console. Same real module set in + each so you're comparing <b>treatment, not content</b>. All three stay inside what GTK3 CSS + (waybar's engine) can actually render.</p> +</header> + +<!-- ============ CURRENT (reference) ============ --> +<div class="desk"> + <div class="bar" style="padding-top:14px"> + <div class="cluster"> + <span class="menu"></span> + <span class="ws"><b class="on">1</b><b class="busy">2</b><b>3</b></span> + <span class="mod"><span class="g"></span></span> + <span class="title">instrument-console.el</span> + <span class="mod arrow"></span> + </div> + <div class="cluster"> + <span class="mod arrow"></span> + <span class="mod"><span class="g gold"></span></span> + <span class="mod"><span class="g xl"></span></span> + <span class="mod"><span class="g xl"></span> <span class="val">62%</span></span> + <span class="mod"><span class="g"></span></span> + <span class="mod"><span class="g"></span></span> + <span class="mod"><span class="g"></span></span> + <span class="mod"><span class="g"></span></span> + <span class="mod"><span class="g"></span> <span class="val">8%</span></span> + <span class="mod"><span class="g"></span> <span class="val">24:10</span></span> + <span class="mod"><span class="g"></span> <span class="val">Fri Jul 3</span></span> + </div> + </div> + <div class="desk-window"></div> +</div> +<div class="desk-label"><span class="n">current</span><span class="d">— flat pills, colour-only states. the baseline these three build on.</span></div> + +<!-- ============ V1 · FACEPLATE ============ --> +<div class="desk v1"> + <div class="bar" style="padding-top:14px"> + <div class="cluster"> + <span class="menu"></span> + <span class="ws"><b class="on">1</b><b class="busy">2</b><b>3</b></span> + <span class="sep"></span> + <span class="mod"><span class="g"></span></span> + <span class="title">instrument-console.el</span> + <span class="mod arrow"></span> + </div> + <div class="cluster"> + <span class="mod arrow"></span> + <span class="mod"><span class="lamp gold"></span><span class="g gold"></span></span> + <span class="sep"></span> + <span class="mod"><span class="g xl"></span></span> + <span class="mod"><span class="g xl"></span> <span class="val cream">62%</span></span> + <span class="sep"></span> + <span class="mod"><span class="g"></span></span> + <span class="mod"><span class="g"></span></span> + <span class="mod"><span class="g gold"></span></span> + <span class="mod"><span class="lamp"></span><span class="g"></span></span> + <span class="sep"></span> + <span class="mod"><span class="g"></span> <span class="val">8%</span></span> + <span class="mod"><span class="g gold"></span> <span class="val gold">24:10</span></span> + <span class="sep"></span> + <span class="mod"><span class="g dim"></span> <span class="val clock">Fri Jul 3</span></span> + </div> + </div> + <div class="desk-window"></div> +</div> +<div class="desk-label"><span class="n">variation 1 · faceplate</span><span class="d">— machined gradient + top highlight, engraved hairline dividers, status lamps on net/bt, cream clock. Nearest to today; drop-in GTK CSS.</span></div> + +<!-- ============ V2 · INSTRUMENT SEGMENTS ============ --> +<div class="desk v2"> + <div class="bar" style="padding-top:12px"> + <div class="cluster"> + <span class="menu"></span> + <span class="seg"><span class="row ws"><b class="on">1</b><b class="busy">2</b><b>3</b></span><span class="unit">wksp</span></span> + <span class="seg"><span class="row"><span class="g"></span><span class="title" style="max-width:180px">instrument-console.el</span></span><span class="unit">layout · window</span></span> + <span class="mod arrow"></span> + </div> + <div class="cluster"> + <span class="mod arrow"></span> + <span class="seg"><span class="row"><span class="lamp gold"></span><span class="g gold"></span><span class="gold">CAPTIVE</span></span><span class="unit">net</span></span> + <span class="seg"><span class="row"><span class="g xl"></span><span class="g xl"></span><span class="val cream">62%</span></span><span class="unit">sound</span></span> + <span class="seg"><span class="row"><span class="g"></span><span class="g"></span><span class="g gold"></span></span><span class="unit">toggles</span></span> + <span class="seg"><span class="row"><span class="lamp"></span><span class="g"></span><span class="dim">M650</span></span><span class="unit">bt</span></span> + <span class="seg"><span class="row"><span class="gauge"><span class="arc"></span><span class="ndl"></span><span class="hub"></span></span><span class="val">8%</span></span><span class="unit">cpu</span></span> + <span class="seg"><span class="row"><span class="g gold"></span><span class="val gold">24:10</span></span><span class="unit">timer</span></span> + <span class="seg"><span class="row"><span class="val cream">Fri Jul 3</span><span class="val">11:23</span></span><span class="unit">clock</span></span> + </div> + </div> + <div class="desk-window"></div> +</div> +<div class="desk-label"><span class="n">variation 2 · instrument segments</span><span class="d">— each group a recessed sub-plate with an engraved unit label; a squat needle gauge for cpu. Reads like a row of instruments. Taller; label row costs a few px.</span></div> + +<!-- ============ V3 · FULL CONSOLE ============ --> +<div class="desk v3"> + <div class="bar" style="padding-top:12px"> + <div class="cluster"> + <span class="menu"></span> + <span class="ws"><b class="on">1</b><b class="busy">2</b><b>3</b></span> + <span class="sep"></span> + <span class="mod key"><span class="g"></span></span> + <span class="title">instrument-console.el</span> + <span class="mod arrow"></span> + </div> + <div class="cluster"> + <span class="mod arrow"></span> + <span class="mod"><span class="lamp gold"></span><span class="g gold"></span> <span class="gold">CAPTIVE</span></span> + <span class="sep"></span> + <span class="mod key"><span class="g xl"></span></span> + <span class="mod"><span class="g xl"></span> <span class="val cream">62%</span></span> + <span class="sep"></span> + <span class="mod key engaged"><span class="g"></span></span> + <span class="mod key"><span class="g"></span></span> + <span class="mod key engaged"><span class="g"></span></span> + <span class="mod"><span class="lamp"></span><span class="g"></span> <span class="dim val">72%</span></span> + <span class="sep"></span> + <span class="mod"><span class="twin"> + <span class="gauge2"><span class="arc"></span><span class="ndl"></span><span class="hub"></span></span> + <span class="gauge2"><span class="arc"></span><span class="ndl warn" style="transform:rotate(38deg)"></span><span class="hub"></span></span> + </span></span> + <span class="mod key"><span class="g gold"></span> <span class="val gold">24:10</span></span> + <span class="sep"></span> + <span class="mod"><span class="g dim"></span> <span class="val clock">Fri Jul 3</span> <span class="val clock">11:23</span><span class="tz"> EDT</span></span> + </div> + </div> + <div class="desk-window"></div> +</div> +<div class="desk-label"><span class="n">variation 3 · full console</span><span class="d">— every module a recessed well, physical console keys for toggles (gold when engaged, terracotta when off), twin cpu/mem gauges, cream tabular clock with engraved TZ. Furthest from today; closest to the panels.</span></div> + +<!-- ============ NOTES ============ --> +<div class="notes"> + <div class="note"> + <h3>What carries over from the panels</h3> + <ul> + <li><b>Lamps</b> — the glowing status dot lands on net + bt so health reads at a glance, not just by glyph colour <em>(gold = captive/engaged, green = ok, red = fail, dim = off)</em>.</li> + <li><b>Machined faceplate</b> — the cluster gets the b-face vertical gradient + 1px top highlight + deeper shadow, so it looks milled rather than printed.</li> + <li><b>Engraved dividers</b> — hairline separators group the right cluster into net · sound · toggles · system · clock, echoing the panels' engraved section rules.</li> + <li><b>Console keys</b> — the toggles (touchpad, dim, caffeine) borrow .c-btn: gradient fill, inset highlight, gold border when engaged.</li> + <li><b>Gauges</b> — sysmon becomes a squat needle (or twin needles for cpu/mem), the same instrument the panels use for throughput and battery.</li> + <li><b>Cream + tabular</b> — the clock and live values shift to cream with tabular-nums, matching the panels' readouts.</li> + </ul> + </div> + <div class="note"> + <h3>GTK3 translation caveats</h3> + <ul> + <li><b>Dividers</b> need real separator modules or per-module borders — waybar can't inject <em>::before</em> content between modules the way this HTML does.</li> + <li><b>Lamps</b> render as a small pango glyph (● with colour + text-shadow glow) prepended in each script, or a tiny bordered box widget — both are GTK-safe.</li> + <li><b>Gauges</b> are the real work: GTK CSS can't draw a rotating needle. Options — a Cairo/GTK drawing area in a custom module, or fake it with a unicode gauge glyph that steps by load band. V2's single gauge is cheaper than V3's twin.</li> + <li><b>V2's unit labels</b> raise the bar height (the label row). Fine at 54px reserved, but worth eyeballing against the -54 margin strip.</li> + <li>Gradients, inset box-shadow, border colour states, tabular-nums — all already proven in the current stylesheet.</li> + </ul> + </div> + <div class="note"> + <h3>My read</h3> + <div class="rec"> + <b>Variation 1 (faceplate)</b> is the one I'd ship first: it lands ~80% of the instrument-console feel — lamps, milled plates, engraved grouping, cream clock — for pure CSS plus a lamp glyph in the net/bt scripts. No custom drawing, no height risk. + <br><br> + <b>Variation 3</b> is the aspirational target once a gauge-drawing module exists (it'd also upgrade the sysmon popup). <b>Variation 2</b> is the middle path if you want the unit labels' legibility but not the full recessed-well density. They're not exclusive — 1 can grow into 3. + </div> + </div> +</div> + +</div> +</body> +</html> diff --git a/docs/prototypes/2026-07-06-ptt-waybar-indicator-prototype.html b/docs/prototypes/2026-07-06-ptt-waybar-indicator-prototype.html new file mode 100644 index 0000000..0b5d6e8 --- /dev/null +++ b/docs/prototypes/2026-07-06-ptt-waybar-indicator-prototype.html @@ -0,0 +1,133 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<title>PTT waybar indicator — prototype</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --bar:#0d0c0b; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono","JetBrainsMono Nerd Font",monospace; +} +*{box-sizing:border-box} +body{font-family:var(--mono);color:var(--silver);margin:0;padding:2.6rem 2rem 5rem;line-height:1.5; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +h1{font-size:1.05rem;letter-spacing:.02em;color:var(--cream);font-weight:700;margin:0 0 .3rem} +.sub{color:var(--dim);font-size:.82rem;margin:0 0 2rem;max-width:60ch} +h2{font-size:.62rem;letter-spacing:.3em;text-transform:uppercase;color:var(--steel); + margin:2.2rem 0 .8rem;display:flex;align-items:center;gap:10px} +h2::after{content:"";height:1px;background:var(--wash);flex:1} + +/* a slice of the real waybar: dark rounded bar, right-aligned modules */ +.bar{display:inline-flex;align-items:center;gap:2px;background:var(--bar); + border:1px solid #201d17;border-radius:10px;padding:5px 8px; + box-shadow:0 2px 10px rgba(0,0,0,.45)} +.mod{display:flex;align-items:center;gap:7px;padding:4px 9px;border-radius:7px;color:var(--silver); + font-size:14px} +.mod .g{font-size:17px;line-height:1} +.mod.muted .g{color:var(--fail)} +.mod.live .g{color:var(--silver)} +.sep{width:1px;height:16px;background:#26231d;margin:0 2px} +.neighbor{color:var(--dim);font-size:13px;padding:4px 8px} + +/* the PTT tag — outline only, ALWAYS present (so the bar never reflows), and + click-to-toggle. Off = barely-visible grey; on = glowing gold; transmitting + = brighter gold. Like the gallery 2.4G ghost tag, lit when engaged. */ +.badge{font-size:15px;line-height:1;border-radius:4px;padding:3px 7px; + background:transparent;border:1px solid #2a2723;color:#54585c; + text-shadow:none;box-shadow:none;cursor:pointer;transition:all .15s ease} +.badge.txt{font-size:.62rem;letter-spacing:.18em;font-weight:700;padding:2px 7px} +.badge.on{color:var(--gold);border-color:var(--gold); + text-shadow:0 0 6px rgba(218,181,61,.75); + box-shadow:0 0 6px rgba(218,181,61,.28),inset 0 0 5px rgba(218,181,61,.15)} +.badge.talk{color:var(--gold-hi);border-color:var(--gold-hi); + text-shadow:0 0 10px rgba(255,215,95,.95); + box-shadow:0 0 11px rgba(255,215,95,.5),inset 0 0 6px rgba(255,215,95,.2)} +.badge.ghost{border-color:var(--wash);color:var(--dim)} + +.row{display:flex;align-items:center;gap:22px;flex-wrap:wrap;margin:10px 0} +.label{width:16rem;color:var(--dim);font-size:.8rem} +.label b{color:var(--cream);font-weight:700} +.note{color:var(--dim);font-size:.78rem;margin:.6rem 0 0;max-width:70ch} +.opt{margin:2.4rem 0 0;padding:14px 16px;border:1px solid #262320;border-radius:10px; + background:linear-gradient(180deg,var(--raise),var(--panel));max-width:70ch} +.opt h3{margin:0 0 .5rem;color:var(--cream);font-size:.86rem;letter-spacing:.04em} +.opt p{margin:.3rem 0;color:var(--silver);font-size:.82rem} +.k{color:var(--gold);font-weight:700} +</style> +</head> +<body> + +<h1>Push-to-talk — waybar indicator</h1> +<p class="sub">How the bar reads in each state, so you can tell at a glance whether you're in PTT mode. +Two elements move together: the <b>mic glyph</b> (live vs muted, the module you already have) and a +<b>PTT tag</b> — an outline flag that's <b>always present</b> (dim grey when off, so the bar never jumps), +glows gold when PTT is engaged, and is itself <b>click-to-toggle</b> for PTT mode.</p> + +<h2>Not in PTT mode — the tag sits dim (present, barely visible)</h2> + +<div class="row"> + <div class="label"><b>Mic live.</b> Tag dim grey.</div> + <div class="bar"> + <span class="neighbor"></span><span class="sep"></span> + <div class="mod live"><span class="badge">󰗋</span> <span class="g"></span></div> + <span class="neighbor">85%</span> + </div> +</div> + +<div class="row"> + <div class="label"><b>Mic muted</b> (plain mic-toggle). Tag still dim.</div> + <div class="bar"> + <span class="neighbor"></span><span class="sep"></span> + <div class="mod muted"><span class="badge">󰗋</span> <span class="g"></span></div> + <span class="neighbor">85%</span> + </div> +</div> + +<p class="note">The tag is <b>always there</b> — dim grey when off — so the bar never reflows when PTT flips. +<b>Click the tag to toggle PTT mode</b> (same as Super+Shift+A). A plain mic-mute leaves the tag dim; only PTT lights it gold.</p> + +<h2>In PTT mode — the tag lights gold</h2> + +<div class="row"> + <div class="label"><b>Armed, not talking.</b> Mic muted, tag glows gold.</div> + <div class="bar"> + <span class="neighbor"></span><span class="sep"></span> + <div class="mod muted"><span class="badge on">󰗋</span> <span class="g"></span></div> + <span class="neighbor">85%</span> + </div> +</div> + +<div class="row"> + <div class="label"><b>Holding the key — transmitting.</b> Mic live, tag brightens.</div> + <div class="bar"> + <span class="neighbor"></span><span class="sep"></span> + <div class="mod live"><span class="badge talk">󰗋</span> <span class="g"></span></div> + <span class="neighbor">85%</span> + </div> +</div> + +<p class="note">Only the letters and outline change colour — the box holds its place, so nothing shifts. While you hold to talk the mic flips live and the tag brightens, so you see you're transmitting, not just armed.</p> + +<h2>The four states side by side — the tag never moves</h2> +<div class="row"> + <div class="bar"> + <div class="mod live"><span class="badge">󰗋</span> <span class="g"></span></div><span class="sep"></span> + <div class="mod muted"><span class="badge">󰗋</span> <span class="g"></span></div><span class="sep"></span> + <div class="mod muted"><span class="badge on">󰗋</span> <span class="g"></span></div><span class="sep"></span> + <div class="mod live"><span class="badge talk">󰗋</span> <span class="g"></span></div> + </div> +</div> +<p class="note">left → right: off + live · off + muted · PTT armed · PTT transmitting. The tag holds its position through all four — no jump.</p> + +<div class="opt"> + <h3>Variants to consider</h3> + <p>• <span class="k">Tag icon</span> — the account-voice glyph (shown). Alternatives if it doesn't read right: radio-handheld , broadcast , or plain "PTT"/"TALK" text.</p> + <p>• <span class="k">Tag colour</span> — gold (shown, matches the panel's PUSH·TALK lamp). A red tag would read more like "mic hot/muted"; gold reads "mode engaged".</p> + <p>• <span class="k">Tag placement</span> — left of the mic glyph (shown) so the flag leads. Could sit right of it instead.</p> + <p>• <span class="k">Transmit cue</span> — brighten + glow the tag while holding (shown), or leave the tag steady and let only the mic glyph carry the live/muted change.</p> +</div> + +</body> +</html> diff --git a/docs/prototypes/2026-07-07-maint-console-A-two-column.html b/docs/prototypes/2026-07-07-maint-console-A-two-column.html new file mode 100644 index 0000000..23d0e60 --- /dev/null +++ b/docs/prototypes/2026-07-07-maint-console-A-two-column.html @@ -0,0 +1,335 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Maintenance console — Approach A · two-column board</title> +<style> +:root{ + --ground:#0a0c0d; --panel:#100f0f; --well:#0a0c0d; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; --wash:#2c2f32; + --ok:#74932f; --warn:#dab53d; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);line-height:1.4; + padding:2.2rem 1.4rem 4rem; + background:radial-gradient(1100px 560px at 72% -12%,#141210 0%,transparent 60%),var(--ground); + display:flex;flex-direction:column;align-items:center;gap:1.5rem} + +/* ---- capsule ---- */ +.capsule{width:1000px;max-width:100%;background:var(--panel);border:1px solid var(--gold); + border-radius:16px;padding:18px 20px;box-shadow:0 20px 55px rgba(0,0,0,.6)} + +/* ---- header strip ---- */ +.hdr{display:flex;align-items:center;gap:14px;padding-bottom:12px;border-bottom:1px solid var(--wash)} +.hdr .mark{color:var(--gold);font-weight:700;font-size:1.15rem;letter-spacing:.12em} +.hdr .mark .host{color:var(--cream)} +.hdr .verdict{margin-left:auto;display:flex;align-items:center;gap:8px;font-size:.92rem} +.hdr .verdict .word{color:var(--ok);font-weight:700;letter-spacing:.08em;text-transform:uppercase} +.subhdr{color:var(--dim);font-size:.72rem;letter-spacing:.24em;text-transform:uppercase;margin-top:8px} + +/* ---- lamps ---- */ +.lamp{font-size:.72rem;line-height:1;flex:0 0 auto;position:relative;top:-.5px} +.lamp.ok{color:var(--ok)} +.lamp.warn{color:var(--warn)} +.lamp.fail{color:var(--fail)} +.lamp.off{color:var(--dim)} +.lamp.run{color:var(--warn)} +.lamp.big{font-size:.85rem} + +/* ---- board ---- */ +.board{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:16px} +.col{min-width:0} +.col-h{display:flex;align-items:baseline;gap:10px;margin-bottom:10px} +.col-h .tag{color:var(--gold);font-weight:700;font-size:.86rem;letter-spacing:.22em} +.col-h .sub{color:var(--dim);font-size:.68rem;letter-spacing:.04em} + +/* doctor buttons */ +.doctor{display:flex;gap:10px;margin-bottom:14px} +.btn{font:inherit;cursor:pointer;border-radius:10px;border:1px solid transparent; + background:var(--slate);color:var(--cream);padding:.5rem .9rem;font-size:.82rem;letter-spacing:.02em; + transition:background .12s,border-color .12s} +.btn:hover{background:var(--slate-hi)} +.btn.big{flex:1;text-align:center;padding:.62rem .8rem;font-size:.86rem} +.btn.big .k{display:block;color:var(--cream);font-weight:700} +.btn.big .d{display:block;color:var(--silver);font-size:.66rem;letter-spacing:.02em;margin-top:2px;opacity:.85} +.btn.ghost{background:transparent;border-color:var(--wash);color:var(--silver)} +.btn.ghost:hover{border-color:var(--slate-hi);background:rgba(66,79,94,.18)} +.btn.lever{padding:.28rem .66rem;font-size:.74rem;border:1px solid var(--slate);background:rgba(66,79,94,.32);color:var(--cream)} +.btn.lever:hover{background:var(--slate);border-color:var(--slate-hi)} +.btn.done{background:var(--slate);color:var(--cream);padding:.5rem 1.4rem} + +/* inset group */ +.group{background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:8px 10px 6px;margin-bottom:11px} +.group-h{color:var(--gold);font-weight:700;font-size:.7rem;letter-spacing:.2em;text-transform:uppercase; + display:flex;align-items:center;gap:8px;padding:1px 2px 7px} +.group-h::after{content:"";height:1px;background:var(--wash);flex:1} + +/* metric row */ +.row{display:grid;grid-template-columns:auto 1fr auto auto;align-items:center;gap:9px; + padding:4px 4px;border-radius:6px;font-size:.82rem} +.row:hover{background:rgba(44,47,50,.4)} +.row .name{color:var(--silver);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.row .cls{color:var(--dim);font-size:.6rem;letter-spacing:.14em;border:1px solid var(--wash); + border-radius:4px;padding:0 4px;flex:0 0 auto;text-transform:uppercase} +.row .val{color:var(--dim);text-align:right;white-space:nowrap;font-size:.78rem} +.row .val.warn{color:var(--warn)} +.row .val.fail{color:var(--fail)} +.row .lev-slot{justify-self:end;min-width:0} +.wf-link{font:inherit;background:transparent;border:0;cursor:pointer;color:var(--warn); + font-size:.72rem;letter-spacing:.02em;white-space:nowrap;padding:.1rem 0} +.wf-link:hover{color:var(--gold-hi);text-decoration:underline} +.wf-link.fail{color:var(--fail)} +.dash{color:var(--wash);font-size:.8rem;padding-right:6px} + +/* ---- updates quarantine strip ---- */ +.updates{margin-top:6px;padding-top:15px;border-top:1px solid var(--gold); + display:flex;align-items:center;gap:16px;flex-wrap:wrap} +.updates .u-tag{color:var(--gold);font-weight:700;font-size:.8rem;letter-spacing:.22em} +.updates .u-body{color:var(--silver);font-size:.82rem;flex:1;min-width:260px} +.updates .u-body .n{color:var(--cream);font-weight:700} +.updates .u-body .cve{color:var(--fail);font-weight:700} +.updates .u-body .pk{color:var(--dim)} +.updates .btn{white-space:nowrap} + +/* ---- running / output-wall capsule ---- */ +.run-cap .hdr .mark .state{color:var(--warn)} +.wall{margin-top:14px;background:var(--well);border:1px solid var(--wash);border-radius:10px;padding:12px 14px; + display:flex;flex-direction:column;gap:9px} +.wall .act{display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:10px;font-size:.84rem} +.wall .act .step{color:var(--silver)} +.wall .act .dots{color:var(--wash);letter-spacing:.1em;overflow:hidden} +.wall .act .res{color:var(--dim);font-size:.76rem;white-space:nowrap;text-align:right} +.wall .act.running .step{color:var(--cream)} +.wall .act.running .res{color:var(--warn)} +.wall .act.done .res{color:var(--ok)} +.wall .act.queued .step{color:var(--dim)} +.run-foot{display:flex;align-items:center;justify-content:space-between;margin-top:14px} +.run-foot .prog{color:var(--dim);font-size:.76rem;letter-spacing:.04em} +.run-foot .prog b{color:var(--cream);font-weight:700} + +/* ---- legend ---- */ +.legend{width:1000px;max-width:100%;display:flex;gap:18px;flex-wrap:wrap; + font-size:.7rem;color:var(--dim);padding:0 4px} +.legend .item b{color:var(--silver);font-weight:700} +.legend .item{white-space:nowrap} +.legend .swatch{color:var(--gold)} + +@media(max-width:1040px){ + .capsule,.legend{width:100%} + .board{grid-template-columns:1fr} +} +</style> +</head> +<body> + +<!-- =================== RESTING BOARD =================== --> +<section class="capsule"> + <div class="hdr"> + <div class="mark">MAINT · <span class="host">ratio</span></div> + <div class="verdict"> + <span class="lamp ok big">●</span> + <span class="word">healthy</span> + </div> + </div> + <div class="subhdr">Approach A — Two-column board</div> + + <div class="board"> + + <!-- ============ LEFT / FIX ============ --> + <div class="col"> + <div class="col-h"><span class="tag">FIX</span><span class="sub">actionable — auto & confirm</span></div> + + <div class="doctor"> + <button class="btn big"> + <span class="k">Clean up</span><span class="d">runs the Auto set unattended</span> + </button> + <button class="btn big ghost"> + <span class="k">Review & fix</span><span class="d">preview the Confirm set</span> + </button> + </div> + + <!-- Storage --> + <div class="group"> + <div class="group-h">Storage</div> + <div class="row"><span class="lamp warn">●</span><span class="name">cache size</span><span class="cls">Auto</span><span class="lev-slot"><button class="btn lever">Clean</button></span></div> + <div class="row"><span class="lamp warn">●</span><span class="name">btrfs scrub age</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Scrub</button></span></div> + <div class="row"><span class="lamp fail">●</span><span class="name">fstrim.timer</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Enable</button></span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">deep trim (keep-1)</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Deep trim</button></span></div> + </div> + + <!-- Snapshots --> + <div class="group"> + <div class="group-h">Snapshots</div> + <div class="row"><span class="lamp ok">●</span><span class="name">snapper count/retention</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Prune</button></span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">snapshot auto-timer</span><span class="cls">Conf</span><span class="val">active</span></div> + </div> + + <!-- Packages --> + <div class="group"> + <div class="group-h">Packages</div> + <div class="row"><span class="lamp warn">●</span><span class="name">orphaned packages</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Review</button></span></div> + <div class="row"><span class="lamp warn">●</span><span class="name">.pacnew files</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Review</button></span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">keyring freshness</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Refresh</button></span></div> + </div> + + <!-- Logs --> + <div class="group"> + <div class="group-h">Logs & coredumps</div> + <div class="row"><span class="lamp warn">●</span><span class="name">coredumps</span><span class="cls">Auto</span><span class="lev-slot"><button class="btn lever">Clear</button></span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">journald disk usage</span><span class="cls">Auto</span><span class="lev-slot"><button class="btn lever">Vacuum</button></span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">app-log cleanup</span><span class="cls">Auto</span><span class="lev-slot"><button class="btn lever">Run</button></span></div> + </div> + + <!-- systemd --> + <div class="group"> + <div class="group-h">systemd</div> + <div class="row"><span class="lamp warn">●</span><span class="name">maintenance timers firing</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Enable</button></span></div> + </div> + + <!-- Network confirm --> + <div class="group"> + <div class="group-h">Network</div> + <div class="row"><span class="lamp ok">●</span><span class="name">firewall active</span><span class="cls">Conf</span><span class="val">active</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">tailscale peers</span><span class="cls">Conf</span><span class="val">4/4</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">fail2ban</span><span class="cls">Conf</span><span class="val">active</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">NTP sync</span><span class="cls">Conf</span><span class="val">synced</span></div> + </div> + + <!-- Services confirm --> + <div class="group"> + <div class="group-h">Services</div> + <div class="row"><span class="lamp warn">●</span><span class="name">docker reclaimable</span><span class="cls">Conf</span><span class="lev-slot"><button class="btn lever">Prune</button></span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">cron</span><span class="cls">Conf</span><span class="val">ok</span></div> + </div> + </div> + + <!-- ============ RIGHT / WATCH ============ --> + <div class="col"> + <div class="col-h"><span class="tag">WATCH</span><span class="sub">read-only — none, human & forensic</span></div> + + <!-- Hardware --> + <div class="group"> + <div class="group-h">Hardware</div> + <div class="row"><span class="lamp ok">●</span><span class="name">SMART health</span><span class="cls">None</span><span class="val">PASSED</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">SMART wear</span><span class="cls">None</span><span class="val">0%</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">btrfs device errors</span><span class="cls">None</span><span class="val">0/0</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">CPU/GPU temps</span><span class="cls">None</span><span class="val">61°/54°</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">thermal throttling</span><span class="cls">None</span><span class="val">no</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">kernel/hardware events</span><span class="cls">None</span><span class="val">clean</span></div> + </div> + + <!-- System --> + <div class="group"> + <div class="group-h">System & disk</div> + <div class="row"><span class="lamp ok">●</span><span class="name">is-system-running</span><span class="cls">None</span><span class="val">running</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">disk usage</span><span class="cls">None</span><span class="val">69%</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">btrfs unallocated</span><span class="cls">None</span><span class="val">118 GiB</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">kernel taint flag</span><span class="cls">None</span><span class="val">0</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">pacman file integrity</span><span class="cls">Wflow</span><span class="val">clean</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">reboot required</span><span class="cls">Human</span><span class="val">no</span></div> + </div> + + <!-- Forensic / systemd --> + <div class="group"> + <div class="group-h">Forensic (workflow)</div> + <div class="row"><span class="lamp warn">●</span><span class="name">failed units</span><span class="cls">Wflow</span><span class="val warn">1</span><span class="lev-slot"><button class="wf-link">→ workflow</button></span></div> + <div class="row"><span class="lamp warn">●</span><span class="name">unclean-shutdown rate</span><span class="cls">Wflow</span><span class="val warn">75%</span><span class="lev-slot"><button class="wf-link">→ workflow</button></span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">journal error count</span><span class="cls">Wflow</span><span class="val">12 real</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">memory free / OOM</span><span class="cls">Wflow</span><span class="val">104 GB free</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">rsyncshot backup</span><span class="cls">Wflow</span><span class="val">3h ago</span></div> + <div class="row"><span class="lamp ok">●</span><span class="name">DNS / NetworkManager</span><span class="cls">Wflow</span><span class="val">ok</span></div> + </div> + + <!-- Power / memory --> + <div class="group"> + <div class="group-h">Memory & power</div> + <div class="row"><span class="lamp ok">●</span><span class="name">swap / zram</span><span class="cls">None</span><span class="val">16 GiB</span></div> + <div class="row"><span class="lamp off">○</span><span class="name">battery health</span><span class="cls">None</span><span class="val">n/a</span></div> + </div> + + <!-- Network posture --> + <div class="group"> + <div class="group-h">Network posture</div> + <div class="row"><span class="lamp ok">●</span><span class="name">unexpected listeners</span><span class="cls">None</span><span class="val">3</span></div> + </div> + + <!-- Services / virt --> + <div class="group"> + <div class="group-h">Services & virt</div> + <div class="row"><span class="lamp ok">●</span><span class="name">docker stopped containers</span><span class="cls">None</span><span class="val">2</span></div> + <div class="row"><span class="lamp off">○</span><span class="name">libvirt VMs</span><span class="cls">None</span><span class="val">off</span></div> + </div> + </div> + + </div><!-- /board --> + + <!-- quarantined updates strip --> + <div class="updates"> + <span class="u-tag">UPDATES</span> + <span class="u-body"><span class="n">47 pending</span> · <span class="pk">mesa, linux-lts, systemd</span> · <span class="cve">3 CVEs</span></span> + <button class="btn">Run workflow</button> + </div> +</section> + +<!-- =================== RUNNING / OUTPUT WALL =================== --> +<section class="capsule run-cap"> + <div class="hdr"> + <div class="mark">MAINT · <span class="host">ratio</span> · <span class="state">CLEANING</span></div> + <div class="verdict"> + <span class="lamp run big">◐</span> + <span class="word" style="color:var(--warn)">working</span> + </div> + </div> + <div class="subhdr">Doctor has the surface — Auto set streaming</div> + + <div class="wall"> + <div class="act running"> + <span class="lamp run">◐</span> + <span class="step">cache trim</span> + <span class="res">running</span> + </div> + <div class="act done"> + <span class="lamp ok">●</span> + <span class="step">journal vacuum</span> + <span class="res">done · reclaimed 0.9 GB</span> + </div> + <div class="act done"> + <span class="lamp ok">●</span> + <span class="step">coredump clear</span> + <span class="res">done · 12 cleared</span> + </div> + <div class="act queued"> + <span class="lamp off">○</span> + <span class="step">app-log cleanup</span> + <span class="res">queued</span> + </div> + </div> + + <div class="run-foot"> + <span class="prog"><b>2</b> of 4 done · 1 running · 1 queued</span> + <button class="btn done">Done</button> + </div> +</section> + +<!-- =================== AUTOMATION-CLASS LEGEND =================== --> +<div class="legend"> + <span class="item"><span class="swatch">●</span> <b>Auto</b> — doctor "Clean up", fires unattended</span> + <span class="item"><span class="swatch">●</span> <b>Confirm</b> — doctor "Review & fix", preview then one click</span> + <span class="item"><span class="swatch">●</span> <b>Human</b> — panel nudges, you act</span> + <span class="item"><span class="swatch">●</span> <b>Workflow</b> — escalate to the agent health-check</span> + <span class="item"><span class="swatch">●</span> <b>None</b> — diagnostic only, no fix</span> +</div> +<div class="legend" style="margin-top:-8px"> + <span class="item"><span class="lamp ok">●</span> ok/pass</span> + <span class="item"><span class="lamp warn">●</span> warn</span> + <span class="item"><span class="lamp fail">●</span> fail</span> + <span class="item"><span class="lamp run">◐</span> running</span> + <span class="item"><span class="lamp off">○</span> off / n/a</span> +</div> + +</body> +</html> diff --git a/docs/prototypes/2026-07-07-maint-console-B-domain-tiles.html b/docs/prototypes/2026-07-07-maint-console-B-domain-tiles.html new file mode 100644 index 0000000..ebc3acf --- /dev/null +++ b/docs/prototypes/2026-07-07-maint-console-B-domain-tiles.html @@ -0,0 +1,382 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>MAINT · ratio — Approach B</title> +<style> + :root{ + --bg:#0a0c0d; --surface:#100f0f; --gold:#dab53d; --silver:#bfc4d0; + --dim:#7c838a; --cream:#f3e7c5; --slate:#424f5e; --slate-h:#54677d; + --ok:#74932f; --warn:#dab53d; --fail:#cb6b4d; --na:#7c838a; + --inset:#0a0c0d; --inset-bd:#2c2f32; + } + *{box-sizing:border-box} + body{ + margin:0; padding:34px 16px; background:var(--bg); + font-family:"BerkeleyMono Nerd Font", ui-monospace, "SF Mono", Menlo, Consolas, monospace; + color:var(--silver); font-size:13px; line-height:1.45; + display:flex; flex-direction:column; align-items:center; gap:20px; + } + .console{ + width:1000px; max-width:100%; background:var(--surface); + border:1px solid var(--gold); border-radius:16px; padding:18px 20px 22px; + } + /* header */ + .hdr{display:flex; align-items:baseline; justify-content:space-between; gap:16px;} + .hdr .title{color:var(--cream); font-size:16px; letter-spacing:.5px;} + .hdr .title b{color:var(--gold);} + .hdr .verdict{font-size:14px; color:var(--ok); font-weight:bold; letter-spacing:.5px;} + .sub{color:var(--dim); font-size:12px; margin-top:2px; margin-bottom:14px;} + + /* global action bar */ + .actionbar{ + display:flex; align-items:center; gap:10px; flex-wrap:wrap; + padding:11px 13px; margin-bottom:16px; + background:var(--inset); border:1px solid var(--inset-bd); border-radius:10px; + } + .actionbar .grp{display:flex; gap:8px;} + .actionbar .spacer{flex:1 1 auto;} + button{ + font-family:inherit; font-size:12px; cursor:pointer; + background:var(--slate); color:var(--cream); border:1px solid #33404d; + border-radius:10px; padding:6px 12px; + } + button:hover{background:var(--slate-h);} + button.ghost{background:transparent; color:var(--silver); border-color:var(--inset-bd); padding:5px 10px;} + button.ghost:hover{background:#161a1d; color:var(--cream);} + button.tiny{font-size:11px; padding:3px 9px; border-radius:8px;} + + /* updates chip */ + .updates-chip{ + display:flex; align-items:center; gap:12px; margin-left:auto; + padding:6px 12px; border:1px solid var(--fail); border-radius:999px; + background:rgba(203,107,77,.09); + } + .updates-chip .u-lamp{color:var(--fail);} + .updates-chip .u-txt{color:var(--cream); font-size:12px;} + .updates-chip .u-txt b{color:var(--warn);} + + /* grid */ + .grid{ + display:grid; grid-template-columns:repeat(3, 1fr); gap:12px; + } + .tile{ + background:var(--inset); border:1px solid var(--inset-bd); border-radius:10px; + padding:11px 12px 12px; display:flex; flex-direction:column; gap:8px; + cursor:pointer; transition:border-color .12s; + } + .tile:hover{border-color:#3c4a58;} + .tile.expanded{grid-column:1 / -1; cursor:default; border-color:var(--slate-h);} + .tile-top{display:flex; align-items:flex-start; gap:9px;} + .rollup{font-size:16px; line-height:1.1; margin-top:1px;} + .tile-head{flex:1 1 auto; min-width:0;} + .tile-name{color:var(--gold); font-weight:bold; letter-spacing:.3px;} + .tile-finding{color:var(--silver); font-size:12px; margin-top:2px; + white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} + .tile-finding.warnt{color:var(--warn);} + .tile-finding.failt{color:var(--fail);} + .tile-foot{display:flex; align-items:center; gap:8px; justify-content:space-between;} + .counts{color:var(--dim); font-size:11px;} + .counts .fix{color:var(--silver);} + .expander{color:var(--dim); font-size:11px;} + + /* lamps */ + .lamp{font-size:13px;} + .l-ok{color:var(--ok);} .l-warn{color:var(--warn);} .l-fail{color:var(--fail);} + .l-na{color:var(--na);} .l-prog{color:var(--warn);} + + /* expanded detail */ + .detail{border-top:1px solid var(--inset-bd); padding-top:9px; margin-top:2px;} + .detail-hint{color:var(--dim); font-size:11px; margin-bottom:7px;} + .row{ + display:grid; grid-template-columns:16px 1fr auto auto; + align-items:center; gap:10px; padding:4px 4px; border-radius:6px; + } + .row:hover{background:#141719;} + .row + .row{border-top:1px solid #17191b;} + .r-name{color:var(--silver);} + .r-val{color:var(--dim); font-size:12px; text-align:right; white-space:nowrap;} + .r-lever{justify-self:end; min-width:96px; text-align:right;} + .r-lever .none{color:#4d5358;} + .r-auto{font-size:10px; color:var(--dim); text-transform:uppercase; letter-spacing:.5px; + border:1px solid var(--inset-bd); border-radius:5px; padding:1px 5px; margin-left:6px;} + + /* running capsule */ + .capsule{ + width:1000px; max-width:100%; background:var(--surface); + border:1px solid var(--gold); border-radius:16px; padding:14px 18px 16px; + } + .cap-hdr{display:flex; align-items:center; justify-content:space-between; margin-bottom:10px;} + .cap-hdr .title{color:var(--cream); font-size:14px; letter-spacing:.5px;} + .cap-hdr .title b{color:var(--gold);} + .cap-hdr .running{color:var(--warn); font-weight:bold;} + .wall{ + background:var(--inset); border:1px solid var(--inset-bd); border-radius:10px; + padding:10px 12px; display:flex; flex-direction:column; gap:6px; + } + .wall .line{display:flex; align-items:center; gap:10px;} + .wall .line .glyph{width:14px; text-align:center;} + .wall .line .act{color:var(--silver); min-width:180px;} + .wall .line .st{color:var(--dim); font-size:12px;} + .wall .line .st b{color:var(--ok);} + .wall .line.done .act{color:var(--dim);} + .wall .line.queued .act{color:var(--dim);} + .cap-foot{display:flex; justify-content:flex-end; margin-top:11px;} + .legend{ + width:1000px; max-width:100%; color:var(--dim); font-size:11px; + padding:0 6px; line-height:1.6; + } + .legend b{color:var(--silver); font-weight:normal;} +</style> +</head> +<body> + +<div class="console"> + <div class="hdr"> + <div> + <div class="title">MAINT · <b>ratio</b></div> + </div> + <div class="verdict">● healthy</div> + </div> + <div class="sub">Approach B — Domain-tile grid</div> + + <!-- global actions --> + <div class="actionbar"> + <div class="grp"> + <button>Clean up</button> + <button class="ghost">Review & fix</button> + </div> + <div class="spacer"></div> + <div class="updates-chip"> + <span class="u-lamp">●</span> + <span class="u-txt"><b>47 pending</b> · 3 CVE · mesa,linux-lts</span> + <button class="tiny">Run workflow</button> + </div> + </div> + + <!-- domain grid --> + <div class="grid"> + + <!-- STORAGE (expanded) --> + <div class="tile expanded" id="t-storage"> + <div class="tile-top"> + <span class="rollup l-fail">●</span> + <div class="tile-head"> + <div class="tile-name">Storage</div> + <div class="tile-finding failt">fstrim off · scrub 34d</div> + </div> + <div class="tile-foot" style="flex-direction:column; align-items:flex-end; gap:6px;"> + <button class="tiny">Clean</button> + <span class="counts"><span class="fix">4 fix</span> · 6 watch</span> + </div> + </div> + <div class="detail"> + <div class="detail-hint">10 checks — lever shown where the doctor can act</div> + + <div class="row"><span class="lamp l-warn">●</span><span class="r-name">cache<span class="r-auto">auto</span></span><span class="r-val">8.8 GB</span><span class="r-lever"><button class="tiny">Clean</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">deep-trim<span class="r-auto">confirm</span></span><span class="r-val">keep-3</span><span class="r-lever"><button class="tiny">Deep trim</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">disk</span><span class="r-val">69%</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">btrfs unalloc</span><span class="r-val">118 GiB</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-warn">●</span><span class="r-name">btrfs scrub<span class="r-auto">confirm</span></span><span class="r-val">34 d</span><span class="r-lever"><button class="tiny">Scrub</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">btrfs device-err</span><span class="r-val">0 / 0</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">SMART</span><span class="r-val">PASSED</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">SMART wear</span><span class="r-val">0%</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-fail">●</span><span class="r-name">fstrim.timer<span class="r-auto">confirm</span></span><span class="r-val">off</span><span class="r-lever"><button class="tiny">Enable</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">pacman integrity<span class="r-auto">workflow</span></span><span class="r-val">clean</span><span class="r-lever"><span class="none">—</span></span></div> + </div> + </div> + + <!-- SNAPSHOTS --> + <div class="tile" onclick="toggle('snap')" id="t-snap"> + <div class="tile-top"> + <span class="rollup l-ok">●</span> + <div class="tile-head"> + <div class="tile-name">Snapshots</div> + <div class="tile-finding">42 snaps · timer active</div> + </div> + </div> + <div class="tile-foot"> + <span class="counts"><span class="fix">1 fix</span> · 1 watch</span> + <span class="expander">▸ detail</span> + </div> + <div class="detail" id="d-snap" style="display:none;"> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">snapper count<span class="r-auto">confirm</span></span><span class="r-val">42</span><span class="r-lever"><button class="tiny">Prune</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">snapshot timer<span class="r-auto">confirm</span></span><span class="r-val">active</span><span class="r-lever"><span class="none">—</span></span></div> + </div> + </div> + + <!-- PACKAGES & SECURITY --> + <div class="tile" onclick="toggle('pkg')" id="t-pkg"> + <div class="tile-top"> + <span class="rollup l-fail">●</span> + <div class="tile-head"> + <div class="tile-name">Packages & Security</div> + <div class="tile-finding failt">3 CVE · 47 updates</div> + </div> + </div> + <div class="tile-foot"> + <span class="counts"><span class="fix">3 fix</span> · 4 watch</span> + <span class="expander">▸ detail</span> + </div> + <div class="detail" id="d-pkg" style="display:none;"> + <div class="row"><span class="lamp l-warn">●</span><span class="r-name">orphans<span class="r-auto">confirm</span></span><span class="r-val">13</span><span class="r-lever"><button class="tiny">Review</button></span></div> + <div class="row"><span class="lamp l-warn">●</span><span class="r-name">.pacnew<span class="r-auto">confirm</span></span><span class="r-val">2</span><span class="r-lever"><button class="tiny">Review</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">keyring<span class="r-auto">confirm</span></span><span class="r-val">12 d</span><span class="r-lever"><button class="tiny">Refresh</button></span></div> + <div class="row"><span class="lamp l-warn">●</span><span class="r-name">pending updates<span class="r-auto">workflow</span></span><span class="r-val">47</span><span class="r-lever"><button class="tiny">Updates</button></span></div> + <div class="row"><span class="lamp l-fail">●</span><span class="r-name">arch-audit<span class="r-auto">workflow</span></span><span class="r-val">3 CVE</span><span class="r-lever"><button class="tiny">Updates</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">AUR staleness<span class="r-auto">workflow</span></span><span class="r-val">5</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">reboot-required<span class="r-auto">human</span></span><span class="r-val">no</span><span class="r-lever"><span class="none">—</span></span></div> + </div> + </div> + + <!-- SYSTEMD & BOOT --> + <div class="tile" onclick="toggle('sd')" id="t-sd"> + <div class="tile-top"> + <span class="rollup l-warn">●</span> + <div class="tile-head"> + <div class="tile-name">systemd & Boot</div> + <div class="tile-finding warnt">1 failed · timers 4/5</div> + </div> + </div> + <div class="tile-foot"> + <span class="counts"><span class="fix">1 fix</span> · 3 watch</span> + <span class="expander">▸ detail</span> + </div> + <div class="detail" id="d-sd" style="display:none;"> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">is-system-running</span><span class="r-val">running</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-warn">●</span><span class="r-name">failed units<span class="r-auto">workflow</span></span><span class="r-val">1</span><span class="r-lever"><button class="tiny">Run workflow</button></span></div> + <div class="row"><span class="lamp l-warn">●</span><span class="r-name">maintenance timers<span class="r-auto">confirm</span></span><span class="r-val">4 / 5</span><span class="r-lever"><button class="tiny">Enable</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">kernel taint</span><span class="r-val">0</span><span class="r-lever"><span class="none">—</span></span></div> + </div> + </div> + + <!-- LOGS & COREDUMPS --> + <div class="tile" onclick="toggle('log')" id="t-log"> + <div class="tile-top"> + <span class="rollup l-warn">●</span> + <div class="tile-head"> + <div class="tile-name">Logs & Coredumps</div> + <div class="tile-finding warnt">18 coredumps / 7d</div> + </div> + <div class="tile-foot" style="flex-direction:column; align-items:flex-end; gap:6px;"> + <button class="tiny" onclick="event.stopPropagation()">Clean</button> + </div> + </div> + <div class="tile-foot"> + <span class="counts"><span class="fix">3 fix</span> · 2 watch</span> + <span class="expander">▸ detail</span> + </div> + <div class="detail" id="d-log" style="display:none;"> + <div class="row"><span class="lamp l-warn">●</span><span class="r-name">coredumps<span class="r-auto">auto</span></span><span class="r-val">18 / 7d</span><span class="r-lever"><button class="tiny">Clear</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">journald<span class="r-auto">auto</span></span><span class="r-val">1.2 GB</span><span class="r-lever"><button class="tiny">Vacuum</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">app-log cleanup<span class="r-auto">auto</span></span><span class="r-val">ok</span><span class="r-lever"><button class="tiny">Run</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">journal errors<span class="r-auto">workflow</span></span><span class="r-val">12 real</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">kernel/hw events</span><span class="r-val">clean</span><span class="r-lever"><span class="none">—</span></span></div> + </div> + </div> + + <!-- MEMORY / THERMAL / POWER --> + <div class="tile" onclick="toggle('mem')" id="t-mem"> + <div class="tile-top"> + <span class="rollup l-warn">●</span> + <div class="tile-head"> + <div class="tile-name">Memory / Thermal / Power</div> + <div class="tile-finding warnt">unclean-shutdown 75%</div> + </div> + </div> + <div class="tile-foot"> + <span class="counts"><span class="fix">0 fix</span> · 6 watch</span> + <span class="expander">▸ detail</span> + </div> + <div class="detail" id="d-mem" style="display:none;"> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">memory<span class="r-auto">workflow</span></span><span class="r-val">104 GB free</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">swap / zram</span><span class="r-val">16 GiB</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">temps</span><span class="r-val">61° / 54°</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">thermal throttle</span><span class="r-val">no</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-na">○</span><span class="r-name">battery</span><span class="r-val">n/a</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-warn">●</span><span class="r-name">unclean-shutdown<span class="r-auto">workflow</span></span><span class="r-val">75%</span><span class="r-lever"><span class="none">—</span></span></div> + </div> + </div> + + <!-- NETWORK & POSTURE --> + <div class="tile" onclick="toggle('net')" id="t-net"> + <div class="tile-top"> + <span class="rollup l-ok">●</span> + <div class="tile-head"> + <div class="tile-name">Network & Posture</div> + <div class="tile-finding">all posture green</div> + </div> + </div> + <div class="tile-foot"> + <span class="counts"><span class="fix">4 fix</span> · 2 watch</span> + <span class="expander">▸ detail</span> + </div> + <div class="detail" id="d-net" style="display:none;"> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">DNS / NM<span class="r-auto">workflow</span></span><span class="r-val">ok</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">firewall<span class="r-auto">confirm</span></span><span class="r-val">active</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">listeners</span><span class="r-val">3</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">tailscale<span class="r-auto">confirm</span></span><span class="r-val">4 / 4</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">fail2ban<span class="r-auto">confirm</span></span><span class="r-val">active</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">NTP<span class="r-auto">confirm</span></span><span class="r-val">synced</span><span class="r-lever"><span class="none">—</span></span></div> + </div> + </div> + + <!-- SERVICES / BACKUPS / VIRT --> + <div class="tile" onclick="toggle('svc')" id="t-svc"> + <div class="tile-top"> + <span class="rollup l-warn">●</span> + <div class="tile-head"> + <div class="tile-name">Services & Backups</div> + <div class="tile-finding warnt">docker reclaim 3 GB</div> + </div> + </div> + <div class="tile-foot"> + <span class="counts"><span class="fix">2 fix</span> · 3 watch</span> + <span class="expander">▸ detail</span> + </div> + <div class="detail" id="d-svc" style="display:none;"> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">rsyncshot<span class="r-auto">workflow</span></span><span class="r-val">3h ago</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-warn">●</span><span class="r-name">docker reclaim<span class="r-auto">confirm</span></span><span class="r-val">3 GB</span><span class="r-lever"><button class="tiny">Prune</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">docker stopped</span><span class="r-val">2</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-na">○</span><span class="r-name">libvirt</span><span class="r-val">off</span><span class="r-lever"><span class="none">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="r-name">cron<span class="r-auto">confirm</span></span><span class="r-val">ok</span><span class="r-lever"><span class="none">—</span></span></div> + </div> + </div> + + </div> +</div> + +<!-- running / output-wall capsule --> +<div class="capsule"> + <div class="cap-hdr"> + <div class="title">MAINT · <b>ratio</b> · <span class="running">CLEANING ◐</span></div> + <span style="color:var(--dim); font-size:12px;">4 Auto actions</span> + </div> + <div class="wall"> + <div class="line"><span class="glyph l-prog">◐</span><span class="act">cache trim</span><span class="st">running…</span></div> + <div class="line done"><span class="glyph l-ok">●</span><span class="act">journal vacuum</span><span class="st">done · reclaimed <b>0.9 GB</b></span></div> + <div class="line done"><span class="glyph l-ok">●</span><span class="act">coredump clear</span><span class="st">done · <b>12 cleared</b></span></div> + <div class="line queued"><span class="glyph l-na">○</span><span class="act">app-log cleanup</span><span class="st">queued</span></div> + </div> + <div class="cap-foot"><button>Done</button></div> +</div> + +<div class="legend"> + Automation — <b>Auto</b> doctor cleans unattended · <b>Confirm</b> doctor previews then you click · <b>Human</b> panel nudges you to act · <b>Workflow</b> escalates to an agent workflow · <b>None</b> diagnostic only. +</div> + +<script> + function toggle(id){ + var t = document.getElementById('t-'+id); + var d = document.getElementById('d-'+id); + if(!d) return; + var open = d.style.display !== 'none'; + d.style.display = open ? 'none' : 'block'; + t.classList.toggle('expanded', !open); + var ex = t.querySelector('.expander'); + if(ex) ex.textContent = open ? '▸ detail' : '▾ detail'; + } +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-07-maint-console-C-severity-feed.html b/docs/prototypes/2026-07-07-maint-console-C-severity-feed.html new file mode 100644 index 0000000..90182d6 --- /dev/null +++ b/docs/prototypes/2026-07-07-maint-console-C-severity-feed.html @@ -0,0 +1,410 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>MAINT · ratio — Approach C · Severity feed</title> +<style> + :root{ + --page:#0a0c0d; --surface:#100f0f; --inset:#0a0c0d; + --gold:#dab53d; --silver:#bfc4d0; --dim:#7c838a; --cream:#f3e7c5; + --slate:#424f5e; --slate-hi:#54677d; --line:#2c2f32; + --ok:#74932f; --warn:#dab53d; --fail:#cb6b4d; + } + *{box-sizing:border-box} + html,body{margin:0;padding:0} + body{ + background:var(--page); + color:var(--silver); + font-family:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; + font-size:13px; line-height:1.45; + display:flex; flex-direction:column; align-items:center; + padding:28px 16px 60px; + -webkit-font-smoothing:antialiased; + } + .console{ + width:1000px; max-width:100%; + background:var(--surface); + border:1px solid var(--gold); + border-radius:16px; + padding:18px; + margin-bottom:20px; + } + + /* ---- header ---- */ + .top{display:flex; align-items:baseline; justify-content:space-between; gap:12px} + .title{font-weight:700; color:var(--cream); font-size:16px; letter-spacing:.5px} + .title .sub-host{color:var(--dim); font-weight:400; font-size:13px} + .verdict{font-weight:700; font-size:14px; color:var(--ok)} + .subtitle{color:var(--dim); font-size:12px; margin-top:2px; letter-spacing:.3px} + + /* ---- action bar ---- */ + .actionbar{ + display:flex; align-items:center; gap:10px; flex-wrap:wrap; + margin:14px 0 6px; + } + .btn{ + background:var(--slate); color:var(--cream); + border:1px solid #2f3a45; border-radius:10px; + padding:7px 14px; font-family:inherit; font-size:12px; font-weight:600; + cursor:pointer; letter-spacing:.3px; + } + .btn:hover{background:var(--slate-hi)} + .btn.small{padding:4px 10px; font-size:11px; font-weight:500} + .btn.wide{padding:7px 18px} + .spacer{flex:1 1 auto} + + /* quarantined updates pill */ + .updates-pill{ + display:flex; align-items:center; gap:12px; + background:var(--inset); border:1px solid var(--line); + border-left:3px solid var(--warn); + border-radius:10px; padding:6px 8px 6px 12px; + } + .updates-pill .u-text{font-size:12px; color:var(--silver)} + .updates-pill .u-text b{color:var(--cream); font-weight:600} + .updates-pill .u-cve{color:var(--fail); font-weight:600} + .updates-pill .u-pkgs{color:var(--dim)} + + /* ---- feed ---- */ + .feed{margin-top:14px} + .band-head{ + display:flex; align-items:center; gap:10px; + color:var(--gold); font-weight:700; font-size:12px; + letter-spacing:1.2px; text-transform:uppercase; + margin:16px 0 6px; padding-bottom:5px; + border-bottom:1px solid var(--line); + } + .band-head .count{color:var(--dim); font-weight:400; letter-spacing:.3px; text-transform:none} + .band-head .accent{ + display:inline-block; width:8px; height:8px; border-radius:2px; + } + .band-fail .accent{background:var(--fail)} + .band-warn .accent{background:var(--warn)} + .band-ok .accent{background:var(--ok)} + + .rows{ + background:var(--inset); border:1px solid var(--line); + border-radius:10px; padding:4px 2px; + } + + .row{ + display:flex; align-items:center; gap:10px; + padding:6px 12px; + border-bottom:1px solid rgba(44,47,50,.5); + } + .row:last-child{border-bottom:none} + .row .lamp{flex:0 0 auto; font-size:13px; width:14px; text-align:center} + .row .name{flex:0 0 auto; color:var(--silver); white-space:nowrap} + .row .dots{ + flex:1 1 auto; min-width:16px; + border-bottom:1px dotted #3a3f43; + transform:translateY(-3px); + margin:0 4px; + } + .row .value{flex:0 0 auto; color:var(--cream); white-space:nowrap; text-align:right} + .row .value.warnval{color:var(--warn)} + .row .value.failval{color:var(--fail)} + .row .chip{ + flex:0 0 auto; width:64px; text-align:center; + font-size:10px; letter-spacing:.4px; text-transform:uppercase; + padding:2px 0; border-radius:6px; border:1px solid var(--line); + color:var(--dim); + } + .chip.auto{color:#a9c05a; border-color:#3c4a24} + .chip.confirm{color:var(--gold); border-color:#4a4023} + .chip.workflow{color:var(--slate-hi); border-color:#33414f} + .chip.human{color:var(--cream); border-color:#4a4436} + .chip.none{color:var(--dim); border-color:var(--line)} + .row .control{flex:0 0 auto; width:118px; text-align:right} + .row .control .lever{ + background:var(--slate); color:var(--cream); + border:1px solid #2f3a45; border-radius:8px; + padding:3px 10px; font-family:inherit; font-size:11px; cursor:pointer; + } + .row .control .lever:hover{background:var(--slate-hi)} + .row .control .wflink{color:var(--slate-hi); font-size:11px; cursor:pointer} + .row .control .wflink:hover{color:var(--cream); text-decoration:underline} + .row .control .nolever{color:#4a5054; font-size:11px} + + /* lamps */ + .l-ok{color:var(--ok)} + .l-warn{color:var(--warn)} + .l-fail{color:var(--fail)} + .l-off{color:var(--dim)} + .l-prog{color:var(--warn)} + + /* OK band quieting */ + .band-ok-summary{ + display:flex; align-items:center; gap:8px; + color:var(--dim); font-size:12px; cursor:pointer; + margin:16px 0 6px; padding-bottom:5px; + border-bottom:1px solid var(--line); + } + .band-ok-summary .tri{color:var(--gold)} + .band-ok .rows{opacity:.72} + .band-ok .row{padding:4px 12px} + .band-ok .row .name{color:var(--dim)} + .band-ok .row .value{color:var(--silver)} + + /* ---- running capsule ---- */ + .capsule{ + width:1000px; max-width:100%; + background:var(--surface); + border:1px solid var(--gold); + border-radius:16px; padding:16px 18px; + } + .cap-head{ + display:flex; align-items:center; justify-content:space-between; + color:var(--cream); font-weight:700; font-size:13px; letter-spacing:.5px; + margin-bottom:10px; + } + .cap-head .run{color:var(--warn)} + .wall{ + background:var(--inset); border:1px solid var(--line); border-radius:10px; + padding:10px 14px; font-size:12px; + } + .wall .line{display:flex; align-items:center; gap:10px; padding:3px 0} + .wall .line .lamp{width:14px; text-align:center} + .wall .line .act{color:var(--silver)} + .wall .line .act .done{color:var(--dim)} + .wall .line .res{color:var(--ok); margin-left:auto} + .wall .line.queued .act{color:var(--dim)} + .cap-foot{display:flex; justify-content:flex-end; margin-top:12px} + + .legend{ + width:1000px; max-width:100%; + color:var(--dim); font-size:11px; line-height:1.6; + margin-top:16px; padding:0 4px; + } + .legend b{color:var(--silver); font-weight:600} +</style> +</head> +<body> + +<div class="console"> + <div class="top"> + <div> + <div class="title">MAINT <span class="sub-host">· ratio</span></div> + <div class="subtitle">Approach C — Severity feed</div> + </div> + <div class="verdict">● healthy</div> + </div> + + <!-- action bar --> + <div class="actionbar"> + <button class="btn wide">Clean up <span style="color:var(--dim);font-weight:400">· 4 Auto</span></button> + <button class="btn wide">Review & fix <span style="color:var(--dim);font-weight:400">· 15 Confirm</span></button> + <div class="spacer"></div> + <div class="updates-pill"> + <span class="u-text"><b>47 pending</b> · <span class="u-cve">3 CVE</span> · <span class="u-pkgs">mesa, linux-lts</span></span> + <button class="btn small">Run workflow</button> + </div> + </div> + + <div class="feed"> + + <!-- ===================== FAIL ===================== --> + <div class="band-head band-fail"><span class="accent"></span>Fail <span class="count">— act now · 2</span></div> + <div class="rows"> + <div class="row"> + <span class="lamp l-fail">●</span> + <span class="name">fstrim.timer</span> + <span class="dots"></span> + <span class="value failval">off</span> + <span class="chip confirm">Confirm</span> + <span class="control"><button class="lever">Enable</button></span> + </div> + <div class="row"> + <span class="lamp l-fail">●</span> + <span class="name">arch-audit</span> + <span class="dots"></span> + <span class="value failval">3 CVE</span> + <span class="chip workflow">Workflow</span> + <span class="control"><span class="wflink">→ workflow</span></span> + </div> + </div> + + <!-- ===================== WARN ===================== --> + <div class="band-head band-warn"><span class="accent"></span>Warn <span class="count">— review · 13</span></div> + <div class="rows"> + <div class="row"> + <span class="lamp l-warn">●</span> + <span class="name">cache</span> + <span class="dots"></span> + <span class="value warnval">8.8 GB</span> + <span class="chip auto">Auto</span> + <span class="control"><button class="lever">Clean</button></span> + </div> + <div class="row"> + <span class="lamp l-warn">●</span> + <span class="name">btrfs scrub</span> + <span class="dots"></span> + <span class="value warnval">34d ago</span> + <span class="chip confirm">Confirm</span> + <span class="control"><button class="lever">Scrub</button></span> + </div> + <div class="row"> + <span class="lamp l-warn">●</span> + <span class="name">orphans</span> + <span class="dots"></span> + <span class="value warnval">13</span> + <span class="chip confirm">Confirm</span> + <span class="control"><button class="lever">Review</button></span> + </div> + <div class="row"> + <span class="lamp l-warn">●</span> + <span class="name">.pacnew files</span> + <span class="dots"></span> + <span class="value warnval">2</span> + <span class="chip confirm">Confirm</span> + <span class="control"><button class="lever">Review</button></span> + </div> + <div class="row"> + <span class="lamp l-warn">●</span> + <span class="name">pending updates</span> + <span class="dots"></span> + <span class="value warnval">47</span> + <span class="chip workflow">Workflow</span> + <span class="control"><span class="wflink">→ workflow</span></span> + </div> + <div class="row"> + <span class="lamp l-warn">●</span> + <span class="name">failed units</span> + <span class="dots"></span> + <span class="value warnval">1</span> + <span class="chip workflow">Workflow</span> + <span class="control"><span class="wflink">→ workflow</span></span> + </div> + <div class="row"> + <span class="lamp l-warn">●</span> + <span class="name">maintenance timers</span> + <span class="dots"></span> + <span class="value warnval">4/5</span> + <span class="chip confirm">Confirm</span> + <span class="control"><button class="lever">Enable</button></span> + </div> + <div class="row"> + <span class="lamp l-warn">●</span> + <span class="name">coredumps</span> + <span class="dots"></span> + <span class="value warnval">18 / 7d</span> + <span class="chip auto">Auto</span> + <span class="control"><button class="lever">Clear</button></span> + </div> + <div class="row"> + <span class="lamp l-warn">●</span> + <span class="name">unclean-shutdown</span> + <span class="dots"></span> + <span class="value warnval">75%</span> + <span class="chip workflow">Workflow</span> + <span class="control"><span class="wflink">→ workflow</span></span> + </div> + <div class="row"> + <span class="lamp l-warn">●</span> + <span class="name">docker reclaim</span> + <span class="dots"></span> + <span class="value warnval">3 GB</span> + <span class="chip confirm">Confirm</span> + <span class="control"><button class="lever">Prune</button></span> + </div> + </div> + + <!-- ===================== OK (quieted) ===================== --> + <div class="band-ok" id="okband"> + <div class="band-ok-summary" onclick="var b=document.getElementById('okrows');b.style.display=b.style.display==='none'?'block':'none';this.querySelector('.tri').textContent=b.style.display==='none'?'▸':'▾';"> + <span class="tri">▾</span> + <span style="color:var(--ok)">●</span> + <span>29 OK — clean, no action</span> + <span style="flex:1"></span> + <span style="color:#565c60">collapse</span> + </div> + <div class="rows" id="okrows"> + <div class="row"><span class="lamp l-ok">●</span><span class="name">deep-trim keep-3</span><span class="dots"></span><span class="value">available</span><span class="chip confirm">Confirm</span><span class="control"><button class="lever">Deep trim</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">disk usage</span><span class="dots"></span><span class="value">69%</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">btrfs unalloc</span><span class="dots"></span><span class="value">118 GiB</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">btrfs device-err</span><span class="dots"></span><span class="value">0 / 0</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">SMART status</span><span class="dots"></span><span class="value">PASSED</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">SMART wear</span><span class="dots"></span><span class="value">0%</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">pacman integrity</span><span class="dots"></span><span class="value">clean</span><span class="chip workflow">Workflow</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">snapper count</span><span class="dots"></span><span class="value">42</span><span class="chip confirm">Confirm</span><span class="control"><button class="lever">Prune</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">snapshot timer</span><span class="dots"></span><span class="value">active</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">keyring</span><span class="dots"></span><span class="value">12d</span><span class="chip confirm">Confirm</span><span class="control"><button class="lever">Refresh</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">AUR staleness</span><span class="dots"></span><span class="value">5</span><span class="chip workflow">Workflow</span><span class="control"><span class="wflink">→ workflow</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">reboot-required</span><span class="dots"></span><span class="value">no</span><span class="chip human">Human</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">is-system-running</span><span class="dots"></span><span class="value">running</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">kernel taint</span><span class="dots"></span><span class="value">0</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">journald size</span><span class="dots"></span><span class="value">1.2 GB</span><span class="chip auto">Auto</span><span class="control"><button class="lever">Vacuum</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">app-log cleanup</span><span class="dots"></span><span class="value">ok</span><span class="chip auto">Auto</span><span class="control"><button class="lever">Run</button></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">journal errors</span><span class="dots"></span><span class="value">12 real</span><span class="chip workflow">Workflow</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">kernel/hw events</span><span class="dots"></span><span class="value">clean</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">memory</span><span class="dots"></span><span class="value">104 GB free</span><span class="chip workflow">Workflow</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">swap/zram</span><span class="dots"></span><span class="value">16 GiB</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">temps</span><span class="dots"></span><span class="value">61° / 54°</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">thermal throttle</span><span class="dots"></span><span class="value">no</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">DNS / NM</span><span class="dots"></span><span class="value">ok</span><span class="chip workflow">Workflow</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">firewall</span><span class="dots"></span><span class="value">active</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">listeners</span><span class="dots"></span><span class="value">3</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">tailscale</span><span class="dots"></span><span class="value">4/4</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">fail2ban</span><span class="dots"></span><span class="value">active</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">NTP</span><span class="dots"></span><span class="value">synced</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">rsyncshot</span><span class="dots"></span><span class="value">3h ago</span><span class="chip workflow">Workflow</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">docker stopped</span><span class="dots"></span><span class="value">2</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="name">cron</span><span class="dots"></span><span class="value">ok</span><span class="chip confirm">Confirm</span><span class="control"><span class="nolever">—</span></span></div> + </div> + </div> + + <!-- ===================== OFF / N-A ===================== --> + <div class="band-ok" style="margin-top:6px"> + <div class="rows" style="opacity:.55"> + <div class="row"><span class="lamp l-off">○</span><span class="name" style="color:var(--dim)">battery</span><span class="dots"></span><span class="value" style="color:var(--dim)">n/a</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + <div class="row"><span class="lamp l-off">○</span><span class="name" style="color:var(--dim)">libvirt</span><span class="dots"></span><span class="value" style="color:var(--dim)">off</span><span class="chip none">None</span><span class="control"><span class="nolever">—</span></span></div> + </div> + </div> + + </div> +</div> + +<!-- =============== RUNNING / OUTPUT-WALL capsule =============== --> +<div class="capsule"> + <div class="cap-head"> + <span>MAINT · ratio · CLEANING <span class="run">◐</span></span> + <span style="color:var(--dim); font-weight:400; font-size:12px">4 Auto actions</span> + </div> + <div class="wall"> + <div class="line"> + <span class="lamp l-prog">◐</span> + <span class="act">cache trim <span style="color:var(--dim)">running…</span></span> + </div> + <div class="line"> + <span class="lamp l-ok">●</span> + <span class="act"><span class="done">journal vacuum — done</span></span> + <span class="res">reclaimed 0.9 GB</span> + </div> + <div class="line"> + <span class="lamp l-ok">●</span> + <span class="act"><span class="done">coredump clear — done</span></span> + <span class="res">12 cleared</span> + </div> + <div class="line queued"> + <span class="lamp l-off">○</span> + <span class="act">app-log cleanup <span style="color:#565c60">queued</span></span> + </div> + </div> + <div class="cap-foot"> + <button class="btn">Done</button> + </div> +</div> + +<div class="legend"> + <b>Automation classes:</b> + <b>Auto</b> — doctor "Clean up" runs unattended · + <b>Confirm</b> — doctor "Review & fix" preview then click · + <b>Human</b> — you act · + <b>Workflow</b> — escalate to an agent workflow · + <b>None</b> — diagnostic only. + <b>Lamps:</b> ● ok / warn / fail · ◐ in-progress · ○ off-or-n/a. ZFS rows omitted (btrfs host). +</div> + +</body> +</html> diff --git a/docs/prototypes/2026-07-07-maint-console-D-doctor-first.html b/docs/prototypes/2026-07-07-maint-console-D-doctor-first.html new file mode 100644 index 0000000..7f2a86e --- /dev/null +++ b/docs/prototypes/2026-07-07-maint-console-D-doctor-first.html @@ -0,0 +1,364 @@ +<!-- Approach D — Doctor-first maintenance console mockup (ratio) --> +<title>MAINT · ratio — Approach D Doctor-first</title> +<style> + :root{ + --bg:#0a0c0d; --surface:#100f0f; --gold:#dab53d; --silver:#bfc4d0; + --dim:#7c838a; --cream:#f3e7c5; --slate:#424f5e; --slate-h:#54677d; + --inset:#0a0c0d; --line:#2c2f32; + --olive:#74932f; --amber:#dab53d; --terra:#cb6b4d; + } + *{box-sizing:border-box} + html,body{margin:0;padding:0} + body{ + background:var(--bg); + color:var(--silver); + font-family:"BerkeleyMono Nerd Font", ui-monospace, monospace; + font-size:13px; line-height:1.45; + display:flex; justify-content:center; + padding:28px 16px 60px; + -webkit-font-smoothing:antialiased; + } + .console{ + width:1000px; max-width:100%; + background:var(--surface); + border:1px solid var(--gold); + border-radius:16px; + padding:18px; + } + + /* ---- header ---- */ + .head{display:flex; align-items:baseline; justify-content:space-between; gap:12px} + .head .title{color:var(--cream); font-size:15px; letter-spacing:.06em; font-weight:700} + .head .title .sub{display:block; color:var(--dim); font-size:11px; font-weight:400; letter-spacing:.02em; margin-top:2px} + .verdict{font-size:14px; font-weight:700; letter-spacing:.03em} + .verdict .lamp{font-size:13px} + + .rule{height:1px; background:var(--line); border:0; margin:14px 0} + + /* ---- lamps ---- */ + .lamp{font-style:normal} + .ok{color:var(--olive)} + .warn{color:var(--amber)} + .fail{color:var(--terra)} + .na{color:var(--dim)} + .run{color:var(--amber)} + + /* ---- generic inset ---- */ + .inset{background:var(--inset); border:1px solid var(--line); border-radius:10px; padding:14px} + .sec-h{color:var(--gold); font-weight:700; letter-spacing:.05em; font-size:12px; text-transform:uppercase; margin:0 0 10px} + + /* ---- buttons ---- */ + button{font-family:inherit; cursor:pointer} + .btn{ + background:var(--slate); color:var(--cream); border:1px solid #33414d; + border-radius:10px; padding:9px 16px; font-size:12px; letter-spacing:.03em; + transition:background .12s; + } + .btn:hover{background:var(--slate-h)} + .btn-lever{padding:4px 10px; font-size:11px; border-radius:8px} + + /* ================= HERO ================= */ + .hero{ + background:linear-gradient(180deg,#141312,#0d0c0c); + border:1px solid var(--line); border-radius:14px; + padding:22px 22px 24px; text-align:center; + } + .hero .stethoscope{font-size:26px; color:var(--gold); letter-spacing:.3em; margin-bottom:6px} + .hero .stat-line{color:var(--silver); font-size:13px; letter-spacing:.04em} + .hero .stat-line b{color:var(--cream); font-weight:700} + .hero .stat-line .fail{font-weight:700} + + /* summary lamp strip */ + .strip{display:flex; flex-wrap:wrap; gap:3px; justify-content:center; margin:14px auto 4px; max-width:720px} + .strip .dot{font-size:11px; line-height:1} + + .dbig{display:flex; gap:16px; justify-content:center; margin-top:20px; flex-wrap:wrap} + .doc-btn{ + background:var(--slate); color:var(--cream); + border:1px solid #33414d; border-radius:12px; + padding:16px 26px; min-width:280px; text-align:center; + transition:background .12s; + } + .doc-btn:hover{background:var(--slate-h)} + .doc-btn .big{display:block; font-size:17px; font-weight:700; letter-spacing:.08em; color:var(--cream)} + .doc-btn .sub{display:block; font-size:11px; color:#cdd7cf; margin-top:6px; letter-spacing:.02em; opacity:.85} + .doc-btn.primary{border-color:var(--gold); box-shadow:0 0 0 1px rgba(218,181,61,.25) inset} + .doc-btn.primary .big{color:var(--cream)} + + .hero-foot{color:var(--dim); font-size:11px; margin-top:18px; letter-spacing:.02em} + + /* ================= DETAILS DISCLOSURE ================= */ + .disc{margin-top:16px} + .disc-head{ + display:flex; align-items:center; gap:8px; + color:var(--gold); font-weight:700; font-size:12px; letter-spacing:.05em; + cursor:pointer; user-select:none; padding:4px 2px; + } + .disc-head .caret{transition:transform .12s} + .disc-body{margin-top:12px} + + .domain{margin-bottom:14px} + .domain-h{color:var(--dim); font-size:10px; letter-spacing:.14em; text-transform:uppercase; margin:0 0 6px; border-bottom:1px solid var(--line); padding-bottom:4px} + table.metrics{width:100%; border-collapse:collapse} + table.metrics td{padding:3px 8px; vertical-align:middle; white-space:nowrap} + table.metrics tr:hover{background:rgba(66,79,94,.14)} + td.mlamp{width:16px; text-align:center} + td.mname{color:var(--silver)} + td.mval{color:var(--dim); text-align:right; width:150px} + td.mlever{width:110px; text-align:right} + .auto-tag{font-size:9px; letter-spacing:.08em; color:var(--dim); text-transform:uppercase; padding-right:6px} + + /* ================= UPDATES QUARANTINE ================= */ + .updates{margin-top:16px; border:1px dashed #4a4535; background:#12100c} + .updates .sec-h{color:var(--amber)} + .updates-row{display:flex; align-items:center; justify-content:space-between; gap:14px; flex-wrap:wrap} + .updates-info b{color:var(--cream)} + .updates-info .cve{color:var(--terra); font-weight:700} + .updates-info .pkgs{color:var(--dim)} + .updates-note{color:var(--dim); font-size:10px; margin-top:8px; letter-spacing:.02em} + + /* ================= OUTPUT WALL ================= */ + .wall{ + margin-top:18px; + background:#0b0a09; border:1px solid var(--gold); border-radius:14px; + padding:18px 20px; + } + .wall-h{display:flex; align-items:center; justify-content:space-between; margin-bottom:4px} + .wall-h .t{color:var(--gold); font-weight:700; letter-spacing:.06em; font-size:13px} + .wall-h .meta{color:var(--dim); font-size:11px} + .wall-sub{color:var(--dim); font-size:11px; margin-bottom:12px} + .prog{height:6px; background:#1a1712; border:1px solid var(--line); border-radius:4px; overflow:hidden; margin-bottom:14px} + .prog i{display:block; height:100%; width:62%; background:linear-gradient(90deg,#74932f,#dab53d)} + .stream{font-size:12.5px; line-height:1.9} + .srow{display:flex; align-items:baseline; gap:10px} + .srow .l{width:14px; text-align:center; flex:0 0 auto} + .srow .task{color:var(--silver); flex:0 0 200px} + .srow .status{color:var(--dim)} + .srow.done .task{color:var(--silver)} + .srow.done .status{color:var(--olive)} + .srow.run .task{color:var(--cream)} + .srow.run .status{color:var(--amber)} + .srow.queued .task{color:var(--dim)} + .srow.queued .status{color:var(--dim)} + .srow .result{color:var(--cream); opacity:.85} + .wall-foot{display:flex; justify-content:space-between; align-items:center; margin-top:16px} + .wall-foot .summary{color:var(--dim); font-size:11px} + .wall-foot .summary b{color:var(--olive)} + + .stagelabel{color:var(--dim); font-size:10px; letter-spacing:.18em; text-transform:uppercase; margin:26px 0 8px; text-align:center} +</style> + +<div class="console"> + + <!-- ================= HEADER ================= --> + <div class="head"> + <div class="title">MAINT · ratio + <span class="sub">Approach D — Doctor-first</span> + </div> + <div class="verdict ok"><span class="lamp">●</span> healthy</div> + </div> + <hr class="rule"> + + <div class="stagelabel">— resting state · the doctor is what you see first —</div> + + <!-- ================= HERO (resting) ================= --> + <div class="hero"> + <div class="stethoscope">✚ ✚ ✚</div> + <div class="stat-line"> + <b>ratio</b> · <span class="fail">1 fail</span> · <span class="warn">8 warnings</span> · <span class="ok">34 ok</span> + </div> + + <!-- summary lamp strip: one tiny lamp per metric --> + <div class="strip" title="one lamp per metric, colored by state"> + <!-- 44 dots; mostly olive, some amber, one terracotta --> + <span class="dot warn">●</span><span class="dot ok">●</span><span class="dot ok">●</span> + <span class="dot warn">●</span><span class="dot ok">●</span><span class="dot ok">●</span> + <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot fail">●</span> + <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot ok">●</span> + <span class="dot warn">●</span><span class="dot warn">●</span><span class="dot ok">●</span> + <span class="dot warn">●</span><span class="dot fail">●</span><span class="dot ok">●</span> + <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot warn">●</span> + <span class="dot warn">●</span><span class="dot ok">●</span><span class="dot warn">●</span> + <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot ok">●</span> + <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot ok">●</span> + <span class="dot ok">●</span><span class="dot na">○</span><span class="dot warn">●</span> + <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot ok">●</span> + <span class="dot ok">●</span><span class="dot ok">●</span><span class="dot ok">●</span> + <span class="dot warn">●</span><span class="dot ok">●</span><span class="dot na">○</span> + <span class="dot ok">●</span><span class="dot ok">●</span> + </div> + + <!-- big doctor actions --> + <div class="dbig"> + <button class="doc-btn primary" onclick="document.getElementById('wall').scrollIntoView({behavior:'smooth'})"> + <span class="big">CLEAN UP</span> + <span class="sub">4 safe tasks · reclaims ~2 GB</span> + </button> + <button class="doc-btn"> + <span class="big">REVIEW & FIX</span> + <span class="sub">9 items need a look</span> + </button> + </div> + + <div class="hero-foot"> + "Should I run the doctor?" — press Clean up for the 4 unattended tasks, or Review & fix to preview the 9 confirm items. + </div> + </div> + + <!-- ================= DETAILS DISCLOSURE (shown expanded statically) ================= --> + <div class="disc inset"> + <div class="disc-head" onclick="var b=document.getElementById('discbody'); var c=this.querySelector('.caret'); if(b.style.display==='none'){b.style.display='block'; c.textContent='▾';} else {b.style.display='none'; c.textContent='▸';}"> + <span class="caret">▾</span> 44 metrics · the fine print + </div> + <div class="disc-body" id="discbody"> + + <!-- STORAGE / FILESYSTEM --> + <div class="domain"> + <div class="domain-h">Storage & filesystem</div> + <table class="metrics"> + <tr><td class="mlamp warn">●</td><td class="mname">cache</td><td class="mval">8.8 GB</td><td class="mlever"><span class="auto-tag">auto</span><button class="btn btn-lever">Clean</button></td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">deep-trim keep-3</td><td class="mval">ready</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Deep trim</button></td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">disk</td><td class="mval">69%</td><td class="mlever">—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">btrfs unalloc</td><td class="mval">118 GiB</td><td class="mlever">—</td></tr> + <tr><td class="mlamp warn">●</td><td class="mname">btrfs scrub</td><td class="mval">34 d ago</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Scrub</button></td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">btrfs device-err</td><td class="mval">0 / 0</td><td class="mlever">—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">SMART</td><td class="mval">PASSED</td><td class="mlever">—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">SMART wear</td><td class="mval">0%</td><td class="mlever">—</td></tr> + <tr><td class="mlamp fail">●</td><td class="mname">fstrim.timer</td><td class="mval">off</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Enable</button></td></tr> + </table> + </div> + + <!-- PACKAGES --> + <div class="domain"> + <div class="domain-h">Packages & snapshots</div> + <table class="metrics"> + <tr><td class="mlamp ok">●</td><td class="mname">pacman integrity</td><td class="mval">clean</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">snapper count</td><td class="mval">42</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Prune</button></td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">snapshot timer</td><td class="mval">active</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr> + <tr><td class="mlamp warn">●</td><td class="mname">orphans</td><td class="mval">13</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Review</button></td></tr> + <tr><td class="mlamp warn">●</td><td class="mname">.pacnew</td><td class="mval">2</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Review</button></td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">keyring</td><td class="mval">12 d</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Refresh</button></td></tr> + <tr><td class="mlamp warn">●</td><td class="mname">pending updates</td><td class="mval">47</td><td class="mlever"><span class="auto-tag">workflow</span>updates</td></tr> + <tr><td class="mlamp fail">●</td><td class="mname">arch-audit</td><td class="mval">3 CVE</td><td class="mlever"><span class="auto-tag">workflow</span>updates</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">AUR staleness</td><td class="mval">5</td><td class="mlever"><span class="auto-tag">workflow</span>updates</td></tr> + </table> + </div> + + <!-- SYSTEM STATE --> + <div class="domain"> + <div class="domain-h">System state</div> + <table class="metrics"> + <tr><td class="mlamp ok">●</td><td class="mname">reboot-required</td><td class="mval">no</td><td class="mlever"><span class="auto-tag">human</span>—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">is-system-running</td><td class="mval">running</td><td class="mlever">—</td></tr> + <tr><td class="mlamp warn">●</td><td class="mname">failed units</td><td class="mval">1</td><td class="mlever"><span class="auto-tag">workflow</span><button class="btn btn-lever">Run</button></td></tr> + <tr><td class="mlamp warn">●</td><td class="mname">maintenance timers</td><td class="mval">4 / 5</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Enable</button></td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">kernel taint</td><td class="mval">0</td><td class="mlever">—</td></tr> + </table> + </div> + + <!-- LOGS --> + <div class="domain"> + <div class="domain-h">Logs & coredumps</div> + <table class="metrics"> + <tr><td class="mlamp warn">●</td><td class="mname">coredumps</td><td class="mval">18 / 7 d</td><td class="mlever"><span class="auto-tag">auto</span><button class="btn btn-lever">Clear</button></td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">journald</td><td class="mval">1.2 GB</td><td class="mlever"><span class="auto-tag">auto</span><button class="btn btn-lever">Vacuum</button></td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">app-log cleanup</td><td class="mval">ok</td><td class="mlever"><span class="auto-tag">auto</span><button class="btn btn-lever">Run</button></td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">journal errors</td><td class="mval">12 real</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">kernel/hw events</td><td class="mval">clean</td><td class="mlever">—</td></tr> + </table> + </div> + + <!-- RESOURCES --> + <div class="domain"> + <div class="domain-h">Resources & thermals</div> + <table class="metrics"> + <tr><td class="mlamp ok">●</td><td class="mname">memory</td><td class="mval">104 GB free</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">swap / zram</td><td class="mval">16 GiB</td><td class="mlever">—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">temps</td><td class="mval">61° / 54°</td><td class="mlever">—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">thermal throttle</td><td class="mval">no</td><td class="mlever">—</td></tr> + <tr><td class="mlamp na">○</td><td class="mname na">battery</td><td class="mval">n/a</td><td class="mlever">—</td></tr> + <tr><td class="mlamp warn">●</td><td class="mname">unclean-shutdown</td><td class="mval">75%</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr> + </table> + </div> + + <!-- NETWORK / SECURITY --> + <div class="domain"> + <div class="domain-h">Network & security</div> + <table class="metrics"> + <tr><td class="mlamp ok">●</td><td class="mname">DNS / NM</td><td class="mval">ok</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">firewall</td><td class="mval">active</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">listeners</td><td class="mval">3</td><td class="mlever">—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">tailscale</td><td class="mval">4 / 4</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">fail2ban</td><td class="mval">active</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">NTP</td><td class="mval">synced</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr> + </table> + </div> + + <!-- SERVICES / BACKUP --> + <div class="domain"> + <div class="domain-h">Backup & services</div> + <table class="metrics"> + <tr><td class="mlamp ok">●</td><td class="mname">rsyncshot</td><td class="mval">3 h ago</td><td class="mlever"><span class="auto-tag">workflow</span>—</td></tr> + <tr><td class="mlamp warn">●</td><td class="mname">docker reclaim</td><td class="mval">3 GB</td><td class="mlever"><span class="auto-tag">confirm</span><button class="btn btn-lever">Prune</button></td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">docker stopped</td><td class="mval">2</td><td class="mlever">—</td></tr> + <tr><td class="mlamp na">○</td><td class="mname na">libvirt</td><td class="mval">off</td><td class="mlever">—</td></tr> + <tr><td class="mlamp ok">●</td><td class="mname">cron</td><td class="mval">ok</td><td class="mlever"><span class="auto-tag">confirm</span>—</td></tr> + </table> + </div> + + </div> + </div> + + <!-- ================= UPDATES QUARANTINE ================= --> + <div class="updates inset"> + <div class="sec-h">Updates — quarantined from the doctor</div> + <div class="updates-row"> + <div class="updates-info"> + <b>47 pending</b> · <span class="cve">3 CVE</span> · <span class="pkgs">mesa, linux-lts, systemd</span> + </div> + <button class="btn">Run workflow</button> + </div> + <div class="updates-note">Updates run through the workflow, never the doctor. Clean up will not touch these.</div> + </div> + + <div class="stagelabel">— running state · press CLEAN UP and the hero becomes the output wall —</div> + + <!-- ================= OUTPUT WALL (the star) ================= --> + <div class="wall" id="wall"> + <div class="wall-h"> + <span class="t">✚ DOCTOR · CLEAN UP</span> + <span class="meta">4 tasks · unattended · started 14:22:07</span> + </div> + <div class="wall-sub">Running the 4 safe (auto) tasks. Confirm items and updates are untouched.</div> + <div class="prog"><i></i></div> + + <div class="stream"> + <div class="srow run"> + <span class="l run">◐</span> + <span class="task">cache trim</span> + <span class="status">running… 8.8 GB → clearing</span> + </div> + <div class="srow done"> + <span class="l ok">●</span> + <span class="task">journald vacuum</span> + <span class="status">done · <span class="result">reclaimed 0.9 GB</span></span> + </div> + <div class="srow done"> + <span class="l ok">●</span> + <span class="task">coredump clear</span> + <span class="status">done · <span class="result">12 cleared</span></span> + </div> + <div class="srow queued"> + <span class="l na">○</span> + <span class="task">app-log cleanup</span> + <span class="status">queued</span> + </div> + </div> + + <div class="wall-foot"> + <span class="summary">3 of 4 complete · <b>reclaimed 0.9 GB</b> so far · ~1.1 GB pending</span> + <button class="btn">Done</button> + </div> + </div> + +</div> diff --git a/docs/prototypes/2026-07-07-maint-console-E-instrument-dashboard.html b/docs/prototypes/2026-07-07-maint-console-E-instrument-dashboard.html new file mode 100644 index 0000000..52ecfad --- /dev/null +++ b/docs/prototypes/2026-07-07-maint-console-E-instrument-dashboard.html @@ -0,0 +1,475 @@ +<meta charset="utf-8"> +<title>MAINT · ratio — Approach E · Instrument Dashboard</title> +<style> + :root{ + --page:#0a0c0d; --surface:#100f0f; --inset:#0a0c0d; + --gold:#dab53d; --silver:#bfc4d0; --dim:#7c838a; --cream:#f3e7c5; + --slate:#424f5e; --slate-hi:#54677d; --line:#2c2f32; + --olive:#74932f; --amber:#dab53d; --terra:#cb6b4d; + --font:"BerkeleyMono Nerd Font", ui-monospace, monospace; + } + *{box-sizing:border-box} + html,body{margin:0;background:var(--page);} + body{ + font-family:var(--font); color:var(--silver); + display:flex; justify-content:center; padding:26px 14px 60px; + -webkit-font-smoothing:antialiased; + } + .console{ + width:1050px; max-width:100%; + background:var(--surface); + border:1px solid var(--gold); border-radius:16px; + padding:18px; + } + + /* ---- header ---- */ + .head{display:flex; align-items:baseline; justify-content:space-between; gap:16px;} + .head .title{font-size:19px; color:var(--cream); letter-spacing:.5px;} + .head .title b{color:var(--gold); font-weight:700;} + .verdict{font-size:15px; color:var(--olive); font-weight:700; white-space:nowrap;} + .sub{color:var(--dim); font-size:12px; margin-top:2px; letter-spacing:.4px;} + + /* ---- top action bar ---- */ + .actionbar{ + display:flex; align-items:center; gap:10px; + margin:14px 0 16px; padding:10px 12px; + background:var(--inset); border:1px solid var(--line); border-radius:10px; + } + .actionbar .lbl{color:var(--dim); font-size:11.5px; letter-spacing:.5px; text-transform:uppercase; margin-right:2px;} + .btn{ + font-family:var(--font); cursor:pointer; + background:var(--slate); color:var(--cream); + border:none; border-radius:10px; padding:7px 14px; font-size:13px; + } + .btn:hover{background:var(--slate-hi);} + .btn.lever{padding:3px 9px; font-size:11px; border-radius:8px;} + .actionbar .spacer{flex:1;} + .legend{color:var(--dim); font-size:11px; line-height:1.5;} + .legend b{color:var(--silver); font-weight:400;} + + /* ---- grid ---- */ + .cluster{display:grid; grid-template-columns:repeat(12,1fr); gap:10px;} + .card{ + background:var(--inset); border:1px solid var(--line); border-radius:10px; + padding:11px 12px 12px; position:relative; min-height:96px; + display:flex; flex-direction:column; + } + .card .lever{position:absolute; top:9px; right:9px;} + .card .cap{ + color:var(--dim); font-size:10.5px; letter-spacing:.6px; + text-transform:uppercase; margin-bottom:8px; padding-right:56px; + } + .card .big{color:var(--cream); font-size:23px; line-height:1; font-weight:700;} + .card .unit{color:var(--dim); font-size:12px; font-weight:400;} + .card .note{color:var(--dim); font-size:11px; margin-top:6px;} + + /* domain band label */ + .band{ + grid-column:1/-1; color:var(--gold); font-weight:700; font-size:12px; + letter-spacing:1.2px; text-transform:uppercase; margin:6px 2px 0; + display:flex; align-items:center; gap:10px; + } + .band::after{content:""; flex:1; height:1px; background:var(--line);} + + /* span helpers */ + .s3{grid-column:span 3;} .s4{grid-column:span 4;} + .s5{grid-column:span 5;} .s6{grid-column:span 6;} + .s7{grid-column:span 7;} .s8{grid-column:span 8;} + .s9{grid-column:span 9;} .s12{grid-column:span 12;} + + /* ---- horizontal capacity meter ---- */ + .meter{margin-top:auto;} + .meter .track{ + position:relative; height:14px; background:#07090a; + border:1px solid var(--line); border-radius:7px; overflow:hidden; + } + .meter .fill{height:100%; border-radius:6px 0 0 6px;} + .fill.ok{background:linear-gradient(90deg,#5c7626,#74932f);} + .fill.warn{background:linear-gradient(90deg,#a98a2c,#dab53d);} + .fill.fail{background:linear-gradient(90deg,#a8543b,#cb6b4d);} + .meter .tick{position:absolute; top:-2px; bottom:-2px; width:2px; background:var(--gold);} + .meter .zone{position:absolute; top:0; bottom:0; opacity:.16;} + .zone.amber{background:var(--amber);} .zone.terra{background:var(--terra);} + .meter .scale{display:flex; justify-content:space-between; color:var(--dim); font-size:9.5px; margin-top:4px;} + + /* ---- lamps ---- */ + .lamp{display:inline-block; width:1em; text-align:center;} + .l-ok{color:var(--olive);} .l-warn{color:var(--amber);} + .l-fail{color:var(--terra);} .l-off{color:var(--dim);} .l-prog{color:var(--amber);} + + /* ---- lamp board ---- */ + .lampboard{ + grid-column:1/-1; background:var(--inset); border:1px solid var(--line); + border-radius:10px; padding:12px 14px; + display:grid; grid-template-columns:repeat(4,1fr); gap:7px 18px; + } + .lampboard .row{display:flex; align-items:center; gap:8px; font-size:12px;} + .lampboard .row .nm{color:var(--silver);} + .lampboard .row .vv{color:var(--dim); margin-left:auto; font-size:11px;} + + /* ---- counter tile ---- */ + .tile .row{display:flex; align-items:center; gap:8px;} + .tile .big{font-size:26px;} + + /* ---- updates quarantine ---- */ + .updates{ + grid-column:1/-1; border:1px solid var(--amber); + background:linear-gradient(180deg,#141210,#0a0c0d); + border-radius:10px; padding:13px 16px; + display:flex; align-items:center; gap:26px; flex-wrap:wrap; + } + .updates .q{ + color:var(--amber); font-size:10.5px; letter-spacing:1.5px; + text-transform:uppercase; border:1px solid var(--amber); + padding:3px 8px; border-radius:6px; align-self:flex-start; + } + .updates .metric{display:flex; flex-direction:column; gap:2px;} + .updates .metric .n{font-size:30px; color:var(--cream); font-weight:700; line-height:1;} + .updates .metric .n.cve{color:var(--terra);} + .updates .metric .k{color:var(--dim); font-size:10.5px; text-transform:uppercase; letter-spacing:.6px;} + .updates .spacer{flex:1;} + + /* ---- svg arc/dial labels ---- */ + .gaugewrap{display:flex; gap:14px; margin-top:2px;} + .gauge{display:flex; flex-direction:column; align-items:center; gap:3px;} + .gauge .rd{color:var(--cream); font-size:13px; font-weight:700;} + .gauge .lb{color:var(--dim); font-size:10px;} + .dialwrap{display:flex; justify-content:center; margin-top:2px;} + + /* ---- output wall ---- */ + .wall{ + margin-top:16px; background:var(--inset); + border:1px solid var(--line); border-radius:10px; padding:13px 15px; + } + .wall .wh{ + display:flex; align-items:center; justify-content:space-between; + color:var(--cream); font-size:13px; letter-spacing:.5px; margin-bottom:10px; + } + .wall .wh .l{color:var(--gold);} + .wall .stream{font-size:12.5px; line-height:1.85;} + .wall .stream .ln{display:flex; gap:9px; align-items:baseline;} + .wall .stream .msg{color:var(--silver);} + .wall .stream .meta{color:var(--dim);} + .wall .stream .queued .msg{color:var(--dim);} + .wall .wfoot{margin-top:11px; display:flex; justify-content:flex-end;} + + @keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}} + .spin{animation:pulse 1.1s ease-in-out infinite;} +</style> + +<div class="console"> + + <!-- HEADER --> + <div class="head"> + <div> + <div class="title">MAINT · <b>ratio</b></div> + <div class="sub">Approach E — Instrument dashboard</div> + </div> + <div class="verdict"><span class="lamp l-ok">●</span> healthy</div> + </div> + + <!-- ACTION BAR --> + <div class="actionbar"> + <span class="lbl">Doctor</span> + <button class="btn">Clean up</button> + <button class="btn">Review & fix</button> + <span class="spacer"></span> + <span class="legend"> + <b>Auto</b> unattended · <b>Confirm</b> preview+click · <b>Human</b> you act · <b>Workflow</b> escalate · <b>None</b> diagnostic + </span> + </div> + + <!-- CLUSTER --> + <div class="cluster"> + + <!-- STORAGE & FILESYSTEM --> + <div class="band">Storage & Filesystem</div> + + <div class="card s4"> + <div class="cap">Disk usage</div> + <div class="big">69<span class="unit">%</span></div> + <div class="note">root · btrfs</div> + <div class="meter"> + <div class="track"> + <div class="zone amber" style="left:80%;right:10%"></div> + <div class="zone terra" style="left:90%;right:0"></div> + <div class="fill ok" style="width:69%"></div> + </div> + <div class="scale"><span>0</span><span>80</span><span>90</span><span>100%</span></div> + </div> + </div> + + <div class="card s4"> + <button class="btn lever">Clean</button> + <div class="cap">Package cache <span class="lamp l-warn">●</span></div> + <div class="big">8.8<span class="unit"> GB</span></div> + <div class="note">Auto · threshold ~10 GB</div> + <div class="meter"> + <div class="track"> + <div class="tick" style="left:88%"></div> + <div class="fill warn" style="width:88%"></div> + </div> + <div class="scale"><span>0</span><span>threshold 10 GB</span></div> + </div> + </div> + + <div class="card s4"> + <button class="btn lever">Deep trim</button> + <div class="cap">Deep-trim keep-3 <span class="lamp l-ok">●</span></div> + <div class="big">keep 3</div> + <div class="note">Confirm · retain 3 versions</div> + <div class="meter"> + <div class="track"><div class="fill ok" style="width:100%"></div></div> + <div class="scale"><span>staged & ready</span></div> + </div> + </div> + + <div class="card s3"> + <button class="btn lever">Scrub</button> + <div class="cap">btrfs scrub</div> + <div class="dialwrap"> + <svg width="98" height="98" viewBox="0 0 98 98"> + <circle cx="49" cy="49" r="40" fill="none" stroke="#1a1d1f" stroke-width="8"/> + <!-- 34d of ~30d cadence => over => amber, ~0.62 of ring --> + <circle cx="49" cy="49" r="40" fill="none" stroke="#dab53d" stroke-width="8" + stroke-linecap="round" stroke-dasharray="251.3" stroke-dashoffset="95" + transform="rotate(-90 49 49)"/> + <text x="49" y="46" text-anchor="middle" fill="#f3e7c5" font-family="monospace" font-size="20" font-weight="700">34</text> + <text x="49" y="63" text-anchor="middle" fill="#7c838a" font-family="monospace" font-size="10">days</text> + </svg> + </div> + <div class="note" style="text-align:center"><span class="lamp l-warn">●</span> past 30d cadence</div> + </div> + + <div class="card s3"> + <div class="cap">btrfs unalloc <span class="lamp l-ok">●</span></div> + <div class="big">118<span class="unit"> GiB</span></div> + <div class="note">unallocated headroom</div> + <div class="meter"> + <div class="track"><div class="fill ok" style="width:46%"></div></div> + <div class="scale"><span>ample slack</span></div> + </div> + </div> + + <div class="card s3"> + <div class="cap">btrfs device-err <span class="lamp l-ok">●</span></div> + <div class="big">0 / 0</div> + <div class="note">read / write errors</div> + </div> + + <div class="card s3"> + <button class="btn lever">Enable</button> + <div class="cap">fstrim.timer <span class="lamp l-fail">●</span></div> + <div class="big" style="color:var(--terra)">OFF</div> + <div class="note">Confirm · SSD discard disabled</div> + </div> + + <!-- THERMAL & POWER --> + <div class="band">Thermal & Power</div> + + <div class="card s5"> + <div class="cap">Temperatures <span class="lamp l-ok">●</span></div> + <div class="gaugewrap"> + <div class="gauge"> + <svg width="112" height="64" viewBox="0 0 112 64"> + <path d="M8 60 A48 48 0 0 1 104 60" fill="none" stroke="#1a1d1f" stroke-width="9"/> + <path d="M8 60 A48 48 0 0 1 79 21" fill="none" stroke="#74932f" stroke-width="9" stroke-linecap="round"/> + <path d="M79 21 A48 48 0 0 1 96 34" fill="none" stroke="#dab53d" stroke-width="9"/> + <path d="M96 34 A48 48 0 0 1 104 60" fill="none" stroke="#cb6b4d" stroke-width="9"/> + <!-- needle @ 61C (~61% of arc) --> + <line x1="56" y1="60" x2="86" y2="30" stroke="#f3e7c5" stroke-width="2.5"/> + <circle cx="56" cy="60" r="4" fill="#dab53d"/> + </svg> + <div class="rd">61°C</div><div class="lb">CPU package</div> + </div> + <div class="gauge"> + <svg width="112" height="64" viewBox="0 0 112 64"> + <path d="M8 60 A48 48 0 0 1 104 60" fill="none" stroke="#1a1d1f" stroke-width="9"/> + <path d="M8 60 A48 48 0 0 1 79 21" fill="none" stroke="#74932f" stroke-width="9" stroke-linecap="round"/> + <path d="M79 21 A48 48 0 0 1 96 34" fill="none" stroke="#dab53d" stroke-width="9"/> + <path d="M96 34 A48 48 0 0 1 104 60" fill="none" stroke="#cb6b4d" stroke-width="9"/> + <!-- needle @ 54C (~54%) --> + <line x1="56" y1="60" x2="79" y2="34" stroke="#f3e7c5" stroke-width="2.5"/> + <circle cx="56" cy="60" r="4" fill="#dab53d"/> + </svg> + <div class="rd">54°C</div><div class="lb">NVMe</div> + </div> + </div> + </div> + + <div class="card s3"> + <div class="cap">Thermal throttle <span class="lamp l-ok">●</span></div> + <div class="big">NO</div> + <div class="note">no throttle events</div> + </div> + + <div class="card s2 s3" style="grid-column:span 2;"> + <div class="cap">Battery <span class="lamp l-off">○</span></div> + <div class="big" style="color:var(--dim)">n/a</div> + <div class="note">desktop</div> + </div> + + <div class="card s2 s3" style="grid-column:span 2;"> + <div class="cap">Swap / zram <span class="lamp l-ok">●</span></div> + <div class="big">16<span class="unit"> GiB</span></div> + <div class="note">zram active</div> + </div> + + <!-- MEMORY --> + <div class="card s6"> + <div class="cap">Memory free <span class="lamp l-ok">●</span></div> + <div class="big">104<span class="unit"> GB free</span></div> + <div class="note">of 125 GB total · Workflow</div> + <div class="meter"> + <div class="track"><div class="fill ok" style="width:83%"></div></div> + <div class="scale"><span>0</span><span>free 104 / 125 GB</span></div> + </div> + </div> + + <div class="card s6"> + <div class="cap">Unclean-shutdown ratio <span class="lamp l-warn">●</span></div> + <div class="big" style="color:var(--amber)">75<span class="unit">%</span></div> + <div class="note">clean boots · Workflow — investigate</div> + <div class="meter"> + <div class="track"> + <div class="zone amber" style="left:0;right:0"></div> + <div class="fill warn" style="width:75%"></div> + </div> + <div class="scale"><span>0</span><span>clean-boot fraction</span><span>100%</span></div> + </div> + </div> + + <!-- UPDATES QUARANTINE --> + <div class="band">Updates — quarantined</div> + <div class="updates"> + <div class="q">Updates</div> + <div class="metric"><span class="n">47</span><span class="k">pending</span></div> + <div class="metric"><span class="n cve">3</span><span class="k">CVE · arch-audit</span></div> + <div class="metric"><span class="n">5</span><span class="k">AUR stale</span></div> + <div class="spacer"></div> + <div class="note" style="max-width:230px; margin:0;"> + <span class="lamp l-fail">●</span> Security advisories present. + Applying updates escalates to the update workflow — not an unattended clean. + </div> + <button class="btn">Open updates</button> + </div> + + <!-- PACKAGES & JOURNAL COUNTERS --> + <div class="band">Packages · Logs · Counters</div> + + <div class="card s3 tile"> + <button class="btn lever">Review</button> + <div class="cap">Orphans</div> + <div class="row"><span class="lamp l-warn">●</span><span class="big">13</span></div> + <div class="note">Confirm · unowned pkgs</div> + </div> + + <div class="card s3 tile"> + <button class="btn lever">Review</button> + <div class="cap">.pacnew files</div> + <div class="row"><span class="lamp l-warn">●</span><span class="big">2</span></div> + <div class="note">Confirm · config merges</div> + </div> + + <div class="card s3 tile"> + <button class="btn lever">Clear</button> + <div class="cap">Coredumps 7d</div> + <div class="row"><span class="lamp l-warn">●</span><span class="big">18</span></div> + <div class="note">Auto · clearable</div> + </div> + + <div class="card s3 tile"> + <button class="btn lever">Run workflow</button> + <div class="cap">Failed units</div> + <div class="row"><span class="lamp l-warn">●</span><span class="big">1</span></div> + <div class="note">Workflow · systemd</div> + </div> + + <div class="card s3"> + <button class="btn lever">Prune</button> + <div class="cap">Snapper snapshots <span class="lamp l-ok">●</span></div> + <div class="big">42</div> + <div class="note">Confirm · retention ok</div> + <div class="meter"> + <div class="track"><div class="fill ok" style="width:52%"></div></div> + <div class="scale"><span>within budget</span></div> + </div> + </div> + + <div class="card s3"> + <button class="btn lever">Refresh</button> + <div class="cap">Keyring age</div> + <div class="dialwrap"> + <svg width="90" height="90" viewBox="0 0 90 90"> + <circle cx="45" cy="45" r="36" fill="none" stroke="#1a1d1f" stroke-width="7"/> + <circle cx="45" cy="45" r="36" fill="none" stroke="#74932f" stroke-width="7" + stroke-linecap="round" stroke-dasharray="226.2" stroke-dashoffset="176" + transform="rotate(-90 45 45)"/> + <text x="45" y="43" text-anchor="middle" fill="#f3e7c5" font-family="monospace" font-size="18" font-weight="700">12</text> + <text x="45" y="59" text-anchor="middle" fill="#7c838a" font-family="monospace" font-size="9">days</text> + </svg> + </div> + <div class="note" style="text-align:center"><span class="lamp l-ok">●</span> Confirm · fresh</div> + </div> + + <div class="card s3 tile"> + <div class="cap">Journal errors</div> + <div class="row"><span class="lamp l-ok">●</span><span class="big">12</span></div> + <div class="note">Workflow · real errors</div> + </div> + + <div class="card s3 tile"> + <div class="cap">Listeners</div> + <div class="row"><span class="lamp l-ok">●</span><span class="big">3</span></div> + <div class="note">None · open sockets</div> + </div> + + <!-- LAMP BOARD --> + <div class="band">Status board — binary & diagnostic</div> + <div class="lampboard"> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">SMART</span><span class="vv">PASSED</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">SMART wear</span><span class="vv">0%</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">pacman integrity</span><span class="vv">clean</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">snapshot timer</span><span class="vv">active</span></div> + + <div class="row"><span class="lamp l-ok">●</span><span class="nm">is-system-running</span><span class="vv">running</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">reboot-required</span><span class="vv">no</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">kernel taint</span><span class="vv">0</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">kernel/hw events</span><span class="vv">clean</span></div> + + <div class="row"><span class="lamp l-ok">●</span><span class="nm">firewall</span><span class="vv">active</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">fail2ban</span><span class="vv">active</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">tailscale</span><span class="vv">4/4</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">NTP</span><span class="vv">synced</span></div> + + <div class="row"><span class="lamp l-ok">●</span><span class="nm">DNS / NM</span><span class="vv">ok</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">rsyncshot</span><span class="vv">3h ago</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">cron</span><span class="vv">ok</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">app-log cleanup</span><span class="vv">ok</span></div> + + <div class="row"><span class="lamp l-ok">●</span><span class="nm">journald size</span><span class="vv">1.2 GB</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm">docker stopped</span><span class="vv">2</span></div> + <div class="row"><span class="lamp l-warn">●</span><span class="nm">maintenance timers</span><span class="vv">4/5</span></div> + <div class="row"><span class="lamp l-warn">●</span><span class="nm">docker reclaim</span><span class="vv">3 GB · Prune</span></div> + + <div class="row"><span class="lamp l-off">○</span><span class="nm">libvirt</span><span class="vv">off</span></div> + </div> + + </div><!-- /cluster --> + + <!-- RUNNING / OUTPUT WALL --> + <div class="wall"> + <div class="wh"> + <span>MAINT · <span class="l">ratio</span> · CLEANING <span class="lamp l-prog spin">◐</span></span> + <span class="meta" style="font-size:11px">4 Auto actions</span> + </div> + <div class="stream"> + <div class="ln"><span class="lamp l-prog spin">◐</span><span class="msg">cache trim</span><span class="meta">running…</span></div> + <div class="ln"><span class="lamp l-ok">●</span><span class="msg">journal vacuum</span><span class="meta">done · reclaimed 0.9 GB</span></div> + <div class="ln"><span class="lamp l-ok">●</span><span class="msg">coredump clear</span><span class="meta">done · 12 cleared</span></div> + <div class="ln queued"><span class="lamp l-off">○</span><span class="msg">app-log cleanup</span><span class="meta">queued</span></div> + </div> + <div class="wfoot"><button class="btn">Done</button></div> + </div> + +</div> diff --git a/docs/prototypes/2026-07-07-maint-console-E2-grouped-dashboard.html b/docs/prototypes/2026-07-07-maint-console-E2-grouped-dashboard.html new file mode 100644 index 0000000..b818006 --- /dev/null +++ b/docs/prototypes/2026-07-07-maint-console-E2-grouped-dashboard.html @@ -0,0 +1,443 @@ +<meta charset="utf-8"> +<title>MAINT · ratio — Approach E2 · Grouped Instrument Dashboard</title> +<style> + :root{ + --page:#0a0c0d; --surface:#100f0f; --inset:#0a0c0d; + --gold:#dab53d; --silver:#bfc4d0; --dim:#7c838a; --cream:#f3e7c5; + --slate:#424f5e; --slate-hi:#54677d; --line:#2c2f32; + --olive:#74932f; --amber:#dab53d; --terra:#cb6b4d; + --font:"BerkeleyMono Nerd Font", ui-monospace, monospace; + } + *{box-sizing:border-box} + html,body{margin:0;background:var(--page);} + body{ + font-family:var(--font); color:var(--silver); + display:flex; justify-content:center; padding:26px 14px 60px; + -webkit-font-smoothing:antialiased; + } + .console{ width:1050px; max-width:100%; background:var(--surface); + border:1px solid var(--gold); border-radius:16px; padding:18px; } + + /* header */ + .head{display:flex; align-items:baseline; justify-content:space-between; gap:16px;} + .head .title{font-size:19px; color:var(--cream); letter-spacing:.5px;} + .head .title b{color:var(--gold); font-weight:700;} + .verdict{font-size:15px; color:var(--olive); font-weight:700; white-space:nowrap;} + .sub{color:var(--dim); font-size:12px; margin-top:2px; letter-spacing:.4px;} + + /* action bar */ + .actionbar{display:flex; align-items:center; gap:10px; margin:14px 0 14px; + padding:10px 12px; background:var(--inset); border:1px solid var(--line); border-radius:10px;} + .actionbar .lbl{color:var(--dim); font-size:11.5px; letter-spacing:.5px; text-transform:uppercase; margin-right:2px;} + .btn{font-family:var(--font); cursor:pointer; background:var(--slate); color:var(--cream); + border:none; border-radius:10px; padding:7px 14px; font-size:13px;} + .btn:hover{background:var(--slate-hi);} + .btn.lever{padding:3px 9px; font-size:11px; border-radius:8px;} + .btn.mini{padding:2px 8px; font-size:10.5px; border-radius:7px;} + .actionbar .spacer{flex:1;} + .legend{color:var(--dim); font-size:11px; line-height:1.5;} + .legend b{color:var(--silver); font-weight:400;} + + /* updates quarantine */ + .updates{border:1px solid var(--amber); + background:linear-gradient(180deg,#141210,#0a0c0d); + border-radius:10px; padding:12px 16px; margin-bottom:14px; + display:flex; align-items:center; gap:26px; flex-wrap:wrap;} + .updates .q{color:var(--amber); font-size:10.5px; letter-spacing:1.5px; text-transform:uppercase; + border:1px solid var(--amber); padding:3px 8px; border-radius:6px; align-self:flex-start;} + .updates .metric{display:flex; flex-direction:column; gap:2px;} + .updates .metric .n{font-size:28px; color:var(--cream); font-weight:700; line-height:1;} + .updates .metric .n.cve{color:var(--terra);} + .updates .metric .k{color:var(--dim); font-size:10.5px; text-transform:uppercase; letter-spacing:.6px;} + .updates .spacer{flex:1;} + + /* ---- category panel ---- */ + .panel{border:1px solid var(--line); border-left:3px solid var(--line); + border-radius:11px; margin-bottom:10px; overflow:hidden; background:#0d0c0c;} + .panel.attn{border-left-color:var(--amber);} + .panel.crit{border-left-color:var(--terra);} + .panel.clear{border-left-color:var(--olive); opacity:.85;} + .phead{display:flex; align-items:center; gap:11px; padding:9px 14px; cursor:pointer;} + .phead:hover{background:#141313;} + .phead .nm{color:var(--gold); font-weight:700; letter-spacing:.9px; text-transform:uppercase; font-size:12.5px;} + .phead .cnt{color:var(--dim); font-size:11px; margin-left:auto;} + .phead .chev{color:var(--dim); font-size:12px; width:1em; text-align:center;} + .phead .attnword{color:var(--amber); font-size:11px;} + .phead .attnword.crit{color:var(--terra);} + .phead .okword{color:var(--olive); font-size:11px;} + .pbody{padding:2px 14px 13px;} + + .heroes{display:grid; grid-template-columns:repeat(12,1fr); gap:10px; margin-bottom:11px;} + .card{background:var(--inset); border:1px solid var(--line); border-radius:10px; + padding:10px 12px 11px; position:relative; min-height:88px; display:flex; flex-direction:column;} + .card .lever{position:absolute; top:8px; right:8px;} + .card .cap{color:var(--dim); font-size:10.5px; letter-spacing:.6px; text-transform:uppercase; + margin-bottom:7px; padding-right:52px;} + .card .big{color:var(--cream); font-size:22px; line-height:1; font-weight:700;} + .card .unit{color:var(--dim); font-size:12px; font-weight:400;} + .card .note{color:var(--dim); font-size:11px; margin-top:6px;} + .s2{grid-column:span 2;} .s3{grid-column:span 3;} .s4{grid-column:span 4;} + .s5{grid-column:span 5;} .s6{grid-column:span 6;} + + /* meter */ + .meter{margin-top:auto;} + .meter .track{position:relative; height:13px; background:#07090a; border:1px solid var(--line); + border-radius:7px; overflow:hidden;} + .meter .fill{height:100%; border-radius:6px 0 0 6px;} + .fill.ok{background:linear-gradient(90deg,#5c7626,#74932f);} + .fill.warn{background:linear-gradient(90deg,#a98a2c,#dab53d);} + .fill.fail{background:linear-gradient(90deg,#a8543b,#cb6b4d);} + .meter .tick{position:absolute; top:-2px; bottom:-2px; width:2px; background:var(--gold);} + .meter .zone{position:absolute; top:0; bottom:0; opacity:.16;} + .zone.amber{background:var(--amber);} .zone.terra{background:var(--terra);} + .meter .scale{display:flex; justify-content:space-between; color:var(--dim); font-size:9.5px; margin-top:4px;} + + /* lamps */ + .lamp{display:inline-block; width:1em; text-align:center;} + .l-ok{color:var(--olive);} .l-warn{color:var(--amber);} + .l-fail{color:var(--terra);} .l-off{color:var(--dim);} .l-prog{color:var(--amber);} + + .tile .row{display:flex; align-items:center; gap:8px;} + .tile .big{font-size:25px;} + + .gaugewrap{display:flex; gap:14px; margin-top:2px;} + .gauge{display:flex; flex-direction:column; align-items:center; gap:3px;} + .gauge .rd{color:var(--cream); font-size:13px; font-weight:700;} + .gauge .lb{color:var(--dim); font-size:10px;} + .dialwrap{display:flex; justify-content:center; margin-top:2px;} + + /* compact strip (the boring/green/binary metrics) */ + .strip{display:grid; grid-template-columns:repeat(3,1fr); gap:5px 22px; + padding-top:9px; border-top:1px solid var(--line);} + .strip .row{display:flex; align-items:center; gap:8px; font-size:12px; min-height:22px;} + .strip .row .nm2{color:var(--silver);} + .strip .row .vv{color:var(--dim); margin-left:auto; font-size:11px;} + .strip .row .lever{margin-left:auto;} + .strip .row .lever + .vv{margin-left:8px;} + + /* output wall */ + .wall{margin-top:16px; background:var(--inset); border:1px solid var(--line); + border-radius:10px; padding:13px 15px;} + .wall .wh{display:flex; align-items:center; justify-content:space-between; + color:var(--cream); font-size:13px; letter-spacing:.5px; margin-bottom:10px;} + .wall .wh .l{color:var(--gold);} + .wall .stream{font-size:12.5px; line-height:1.85;} + .wall .stream .ln{display:flex; gap:9px; align-items:baseline;} + .wall .stream .msg{color:var(--silver);} + .wall .stream .meta{color:var(--dim);} + .wall .stream .queued .msg{color:var(--dim);} + .wall .wfoot{margin-top:11px; display:flex; justify-content:flex-end;} + + .footnote{color:var(--dim); font-size:11px; margin-top:14px; line-height:1.6;} + @keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}} + .spin{animation:pulse 1.1s ease-in-out infinite;} +</style> + +<div class="console"> + + <!-- HEADER --> + <div class="head"> + <div> + <div class="title">MAINT · <b>ratio</b></div> + <div class="sub">Approach E2 — Grouped instrument dashboard (categories collapse when clean)</div> + </div> + <div class="verdict"><span class="lamp l-ok">●</span> healthy</div> + </div> + + <!-- ACTION BAR --> + <div class="actionbar"> + <span class="lbl">Doctor</span> + <button class="btn">Clean up</button> + <button class="btn">Review & fix</button> + <span class="spacer"></span> + <span class="legend"> + <b>Auto</b> unattended · <b>Confirm</b> preview+click · <b>Human</b> you act · <b>Workflow</b> escalate · <b>None</b> diagnostic + </span> + </div> + + <!-- UPDATES (pulled up: the 3 CVEs are the one real fail) --> + <div class="updates"> + <div class="q">Updates</div> + <div class="metric"><span class="n">47</span><span class="k">pending</span></div> + <div class="metric"><span class="n cve">3</span><span class="k">CVE · arch-audit</span></div> + <div class="metric"><span class="n">5</span><span class="k">AUR stale</span></div> + <div class="spacer"></div> + <div class="note" style="max-width:250px; margin:0; color:var(--dim); font-size:11px;"> + <span class="lamp l-fail">●</span> Security advisories present. Updates run through the workflow — never an unattended clean. + </div> + <button class="btn">Run workflow</button> + </div> + + <!-- ============ CATEGORY PANELS (severity-ordered) ============ --> + + <!-- STORAGE (has a fail: fstrim) --> + <div class="panel crit"> + <div class="phead"> + <span class="chev">▾</span><span class="lamp l-fail">●</span> + <span class="nm">Storage & Filesystem</span> + <span class="cnt"><span class="attnword crit">3 need attention</span> · 6 ok</span> + </div> + <div class="pbody"> + <div class="heroes"> + <div class="card s3"> + <div class="cap">Disk usage</div> + <div class="big">69<span class="unit">%</span></div> + <div class="note">root · btrfs</div> + <div class="meter"><div class="track"> + <div class="zone amber" style="left:80%;right:10%"></div> + <div class="zone terra" style="left:90%;right:0"></div> + <div class="fill ok" style="width:69%"></div></div> + <div class="scale"><span>0</span><span>80</span><span>90</span><span>100</span></div> + </div> + </div> + <div class="card s3"> + <button class="btn lever">Clean</button> + <div class="cap">Cache <span class="lamp l-warn">●</span></div> + <div class="big">8.8<span class="unit"> GB</span></div> + <div class="note">Auto · thr ~10 GB</div> + <div class="meter"><div class="track"><div class="tick" style="left:88%"></div> + <div class="fill warn" style="width:88%"></div></div> + <div class="scale"><span>0</span><span>10 GB</span></div> + </div> + </div> + <div class="card s3"> + <button class="btn lever">Scrub</button> + <div class="cap">btrfs scrub</div> + <div class="dialwrap"> + <svg width="86" height="86" viewBox="0 0 98 98"> + <circle cx="49" cy="49" r="40" fill="none" stroke="#1a1d1f" stroke-width="8"/> + <circle cx="49" cy="49" r="40" fill="none" stroke="#dab53d" stroke-width="8" + stroke-linecap="round" stroke-dasharray="251.3" stroke-dashoffset="95" transform="rotate(-90 49 49)"/> + <text x="49" y="46" text-anchor="middle" fill="#f3e7c5" font-family="monospace" font-size="20" font-weight="700">34</text> + <text x="49" y="63" text-anchor="middle" fill="#7c838a" font-family="monospace" font-size="10">days</text> + </svg> + </div> + <div class="note" style="text-align:center"><span class="lamp l-warn">●</span> past 30d</div> + </div> + <div class="card s3"> + <button class="btn lever">Enable</button> + <div class="cap">fstrim.timer <span class="lamp l-fail">●</span></div> + <div class="big" style="color:var(--terra)">OFF</div> + <div class="note">Confirm · SSD discard off</div> + </div> + </div> + <div class="strip"> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">SMART</span><span class="vv">PASSED</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">SMART wear</span><span class="vv">0%</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">device errors</span><span class="vv">0 / 0</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">btrfs unalloc</span><span class="vv">118 GiB</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">pacman integrity</span><span class="vv">clean</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">deep-trim</span><button class="btn mini lever" style="position:static">Deep trim</button><span class="vv">keep 3</span></div> + </div> + </div> + </div> + + <!-- PACKAGES & SECURITY --> + <div class="panel attn"> + <div class="phead"> + <span class="chev">▾</span><span class="lamp l-warn">●</span> + <span class="nm">Packages & Security</span> + <span class="cnt"><span class="attnword">2 need attention</span> · 2 ok · updates above</span> + </div> + <div class="pbody"> + <div class="heroes"> + <div class="card s3 tile"> + <button class="btn lever">Review</button> + <div class="cap">Orphans</div> + <div class="row"><span class="lamp l-warn">●</span><span class="big">13</span></div> + <div class="note">Confirm · unowned pkgs</div> + </div> + <div class="card s3 tile"> + <button class="btn lever">Review</button> + <div class="cap">.pacnew files</div> + <div class="row"><span class="lamp l-warn">●</span><span class="big">2</span></div> + <div class="note">Confirm · config merges</div> + </div> + </div> + <div class="strip"> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">keyring age</span><button class="btn mini lever" style="position:static">Refresh</button><span class="vv">12d</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">reboot required</span><span class="vv">no</span></div> + </div> + </div> + </div> + + <!-- SYSTEMD & BOOT --> + <div class="panel attn"> + <div class="phead"> + <span class="chev">▾</span><span class="lamp l-warn">●</span> + <span class="nm">systemd & Boot</span> + <span class="cnt"><span class="attnword">2 need attention</span> · 2 ok</span> + </div> + <div class="pbody"> + <div class="heroes"> + <div class="card s3 tile"> + <button class="btn lever">Run workflow</button> + <div class="cap">Failed units</div> + <div class="row"><span class="lamp l-warn">●</span><span class="big">1</span></div> + <div class="note">Workflow · systemd</div> + </div> + <div class="card s3 tile"> + <button class="btn lever">Enable</button> + <div class="cap">Maintenance timers</div> + <div class="row"><span class="lamp l-warn">●</span><span class="big">4/5</span></div> + <div class="note">Confirm · one not firing</div> + </div> + </div> + <div class="strip"> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">is-system-running</span><span class="vv">running</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">kernel taint</span><span class="vv">0</span></div> + </div> + </div> + </div> + + <!-- LOGS & COREDUMPS --> + <div class="panel attn"> + <div class="phead"> + <span class="chev">▾</span><span class="lamp l-warn">●</span> + <span class="nm">Logs & Coredumps</span> + <span class="cnt"><span class="attnword">1 needs attention</span> · 4 ok</span> + </div> + <div class="pbody"> + <div class="heroes"> + <div class="card s3 tile"> + <button class="btn lever">Clear</button> + <div class="cap">Coredumps 7d</div> + <div class="row"><span class="lamp l-warn">●</span><span class="big">18</span></div> + <div class="note">Auto · clearable</div> + </div> + </div> + <div class="strip"> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">journald size</span><button class="btn mini lever" style="position:static">Vacuum</button><span class="vv">1.2 GB</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">app-log cleanup</span><button class="btn mini lever" style="position:static">Run</button><span class="vv">ok</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">journal errors</span><span class="vv">12 real</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">kernel/hw events</span><span class="vv">clean</span></div> + </div> + </div> + </div> + + <!-- MEMORY / THERMAL / POWER --> + <div class="panel attn"> + <div class="phead"> + <span class="chev">▾</span><span class="lamp l-warn">●</span> + <span class="nm">Memory · Thermal · Power</span> + <span class="cnt"><span class="attnword">1 needs attention</span> · 4 ok</span> + </div> + <div class="pbody"> + <div class="heroes"> + <div class="card s5"> + <div class="cap">Temperatures <span class="lamp l-ok">●</span></div> + <div class="gaugewrap"> + <div class="gauge"> + <svg width="104" height="60" viewBox="0 0 112 64"> + <path d="M8 60 A48 48 0 0 1 104 60" fill="none" stroke="#1a1d1f" stroke-width="9"/> + <path d="M8 60 A48 48 0 0 1 79 21" fill="none" stroke="#74932f" stroke-width="9" stroke-linecap="round"/> + <path d="M79 21 A48 48 0 0 1 96 34" fill="none" stroke="#dab53d" stroke-width="9"/> + <path d="M96 34 A48 48 0 0 1 104 60" fill="none" stroke="#cb6b4d" stroke-width="9"/> + <line x1="56" y1="60" x2="86" y2="30" stroke="#f3e7c5" stroke-width="2.5"/> + <circle cx="56" cy="60" r="4" fill="#dab53d"/> + </svg> + <div class="rd">61°C</div><div class="lb">CPU</div> + </div> + <div class="gauge"> + <svg width="104" height="60" viewBox="0 0 112 64"> + <path d="M8 60 A48 48 0 0 1 104 60" fill="none" stroke="#1a1d1f" stroke-width="9"/> + <path d="M8 60 A48 48 0 0 1 79 21" fill="none" stroke="#74932f" stroke-width="9" stroke-linecap="round"/> + <path d="M79 21 A48 48 0 0 1 96 34" fill="none" stroke="#dab53d" stroke-width="9"/> + <path d="M96 34 A48 48 0 0 1 104 60" fill="none" stroke="#cb6b4d" stroke-width="9"/> + <line x1="56" y1="60" x2="79" y2="34" stroke="#f3e7c5" stroke-width="2.5"/> + <circle cx="56" cy="60" r="4" fill="#dab53d"/> + </svg> + <div class="rd">54°C</div><div class="lb">NVMe</div> + </div> + </div> + </div> + <div class="card s4"> + <div class="cap">Memory free <span class="lamp l-ok">●</span></div> + <div class="big">104<span class="unit"> GB</span></div> + <div class="note">of 125 GB · Workflow</div> + <div class="meter"><div class="track"><div class="fill ok" style="width:83%"></div></div> + <div class="scale"><span>0</span><span>free 104 / 125</span></div></div> + </div> + <div class="card s3"> + <div class="cap">Unclean boot <span class="lamp l-warn">●</span></div> + <div class="big" style="color:var(--amber)">75<span class="unit">%</span></div> + <div class="note">Workflow · investigate</div> + <div class="meter"><div class="track"><div class="zone amber" style="left:0;right:0"></div> + <div class="fill warn" style="width:75%"></div></div> + <div class="scale"><span>0</span><span>100</span></div></div> + </div> + </div> + <div class="strip"> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">swap / zram</span><span class="vv">16 GiB</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">thermal throttle</span><span class="vv">no</span></div> + <div class="row"><span class="lamp l-off">○</span><span class="nm2">battery</span><span class="vv">n/a · desktop</span></div> + </div> + </div> + </div> + + <!-- SERVICES & BACKUPS --> + <div class="panel attn"> + <div class="phead"> + <span class="chev">▾</span><span class="lamp l-warn">●</span> + <span class="nm">Services & Backups</span> + <span class="cnt"><span class="attnword">1 needs attention</span> · 3 ok</span> + </div> + <div class="pbody"> + <div class="heroes"> + <div class="card s3 tile"> + <button class="btn lever">Prune</button> + <div class="cap">Docker reclaim</div> + <div class="row"><span class="lamp l-warn">●</span><span class="big">3<span class="unit"> GB</span></span></div> + <div class="note">Confirm · reclaimable</div> + </div> + </div> + <div class="strip"> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">rsyncshot backup</span><span class="vv">3h ago</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">docker stopped</span><span class="vv">2</span></div> + <div class="row"><span class="lamp l-ok">●</span><span class="nm2">cron</span><span class="vv">ok</span></div> + <div class="row"><span class="lamp l-off">○</span><span class="nm2">libvirt VMs</span><span class="vv">off</span></div> + </div> + </div> + </div> + + <!-- ===== COLLAPSED (all-green) categories — the space win ===== --> + + <div class="panel clear"> + <div class="phead"> + <span class="chev">▸</span><span class="lamp l-ok">●</span> + <span class="nm">Snapshots</span> + <span class="cnt"><span class="okword">all clear · 2 ✓</span></span> + </div> + </div> + + <div class="panel clear"> + <div class="phead"> + <span class="chev">▸</span><span class="lamp l-ok">●</span> + <span class="nm">Network & Posture</span> + <span class="cnt"><span class="okword">all clear · 6 ✓</span></span> + </div> + </div> + + <div class="footnote"> + Collapsed panels are all-green — one header line each (chevron ▸ to expand). On a fully-healthy day every + panel collapses to this and the console is ~4 header rows plus the updates strip. The board's height tracks + how much needs you, not how many metrics exist. Verdict lamp (top-right) reads the worst <i>diagnostic</i> + state only — the fstrim fail and the 3 CVEs are actionable/updates, so the box still reads "healthy." + </div> + + <!-- RUNNING / OUTPUT WALL --> + <div class="wall"> + <div class="wh"> + <span>MAINT · <span class="l">ratio</span> · CLEANING <span class="lamp l-prog spin">◐</span></span> + <span class="meta" style="font-size:11px">4 Auto actions</span> + </div> + <div class="stream"> + <div class="ln"><span class="lamp l-prog spin">◐</span><span class="msg">cache trim</span><span class="meta">running…</span></div> + <div class="ln"><span class="lamp l-ok">●</span><span class="msg">journal vacuum</span><span class="meta">done · reclaimed 0.9 GB</span></div> + <div class="ln"><span class="lamp l-ok">●</span><span class="msg">coredump clear</span><span class="meta">done · 12 cleared</span></div> + <div class="ln queued"><span class="lamp l-off">○</span><span class="msg">app-log cleanup</span><span class="meta">queued</span></div> + </div> + <div class="wfoot"><button class="btn">Done</button></div> + </div> + +</div> diff --git a/docs/prototypes/2026-07-07-maint-console-E3-hifi-drilldown.html b/docs/prototypes/2026-07-07-maint-console-E3-hifi-drilldown.html new file mode 100644 index 0000000..fbfb03d --- /dev/null +++ b/docs/prototypes/2026-07-07-maint-console-E3-hifi-drilldown.html @@ -0,0 +1,488 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>MAINT · ratio — Approach E3 · Hi-fi drill-down console</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --amber:#dab53d; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);line-height:1.45; + padding:2rem 1.4rem 5rem; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground); + display:flex;justify-content:center} +.console{width:1080px;max-width:100%} + +/* ---- shared primitives (from the widget gallery) ---- */ +.lamp{width:10px;height:10px;border-radius:50%;background:var(--pass); + box-shadow:0 0 6px 1px rgba(116,147,47,.55);display:inline-block;flex:none} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +.lamp.red{background:var(--fail);box-shadow:0 0 7px 1px rgba(203,107,77,.6)} +.lamp.off{background:var(--wash);box-shadow:none} +.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite} +@keyframes pulse{50%{opacity:.25}} + +.key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer; + background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a; + border-radius:8px;padding:6px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} +.key:hover{color:var(--gold);border-color:var(--gold)} +.key:active{transform:translateY(1px)} +.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} +.key.sm{font-size:10.5px;padding:4px 9px;border-radius:7px} + +.badge{font-size:.62rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px} +.badge.red{background:var(--fail);color:var(--cream)} +.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)} + +.bar{width:100%;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative} +.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold));border-radius:6px} +.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))} +.bar .tk{position:absolute;top:-2px;bottom:-2px;width:2px;background:var(--gold-hi)} + +.ring{width:58px;height:58px;border-radius:50%; + background:conic-gradient(var(--rc,var(--gold)) calc(var(--p)*1%),var(--wash) 0); + display:grid;place-items:center;position:relative;flex:none} +.ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)} +.ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1} +.ring small{position:relative;color:var(--dim);font-size:8.5px} + +.readout{color:var(--cream);font-size:22px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.03em} +.readout small{color:var(--dim);font-size:11px;font-weight:400} + +/* engraved section label */ +.engrave{color:var(--steel);font-size:.66rem;letter-spacing:.28em;text-transform:uppercase; + display:flex;align-items:center;gap:10px;flex:1} +.engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.engrave .cnt{color:var(--dim);letter-spacing:.08em;text-transform:none;font-size:.7rem} + +/* lamp row (list item) */ +.lrow{display:flex;align-items:center;gap:9px;padding:6px 9px;border-radius:7px;background:#141210;font-size:12.5px} +.lrow .who{color:var(--silver)} .lrow .who b{color:var(--cream)} +.lrow .what{margin-left:auto;color:var(--dim);font-size:11px;display:flex;align-items:center;gap:8px} + +/* faceplate card */ +.plate{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:12px; + box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 6px 14px rgba(0,0,0,.4)} + +/* ---- masthead ---- */ +.mast{padding:15px 18px;margin-bottom:14px;display:flex;align-items:center;gap:18px;flex-wrap:wrap} +.mast .brand{display:flex;flex-direction:column;gap:2px} +.mast .brand .ey{color:var(--steel);font-size:.64rem;letter-spacing:.28em;text-transform:uppercase} +.mast .brand .ti{color:var(--cream);font-size:19px;letter-spacing:.5px} +.mast .brand .ti b{color:var(--gold)} +.mast .verdict{display:flex;align-items:center;gap:8px;color:var(--pass);font-size:14px;font-weight:700} +.mast .spacer{flex:1} +.mast .doctor{display:flex;align-items:center;gap:8px} +.mast .doctor .lbl{color:var(--steel);font-size:.62rem;letter-spacing:.22em;text-transform:uppercase;margin-right:2px} + +/* updates readout inline in masthead */ +.updbar{display:flex;align-items:center;gap:16px;padding:9px 18px;margin-bottom:16px; + border:1px solid var(--fail);border-radius:11px; + background:linear-gradient(180deg,#181210,#0a0c0d)} +.updbar .q{color:var(--fail);font-size:.6rem;letter-spacing:.2em;text-transform:uppercase; + border:1px solid var(--fail);border-radius:5px;padding:2px 7px} +.updbar .rd{display:flex;flex-direction:column;line-height:1.05} +.updbar .rd .n{color:var(--cream);font-size:20px;font-weight:700;font-variant-numeric:tabular-nums} +.updbar .rd .n.cve{color:var(--fail)} +.updbar .rd .k{color:var(--dim);font-size:.6rem;letter-spacing:.12em;text-transform:uppercase} +.updbar .spacer{flex:1} +.updbar .msg{color:var(--dim);font-size:11px;max-width:300px} + +/* ---- OVERVIEW: station grid ---- */ +.ovhead{margin:0 4px 9px;display:flex;align-items:center;gap:10px} +.overview{display:grid;grid-template-columns:repeat(4,1fr);gap:11px;margin-bottom:22px} +.station{padding:12px 13px;cursor:pointer;display:flex;flex-direction:column;gap:9px; + transition:border-color .12s,transform .06s;border-left:3px solid var(--wash)} +.station:hover{border-color:#3a352c;transform:translateY(-1px)} +.station.attn{border-left-color:var(--amber)} +.station.crit{border-left-color:var(--fail)} +.station.clear{border-left-color:var(--pass)} +.station .top{display:flex;align-items:center;gap:9px} +.station .top .nm{color:var(--cream);font-size:12.5px;font-weight:700;letter-spacing:.02em} +.station .top .chev{margin-left:auto;color:var(--dim);font-size:11px} +.station .st{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--dim)} +.station .st .att{color:var(--amber)} .station .st .att.crit{color:var(--fail)} +.station .st .okc{color:var(--pass)} +.station .spark{margin-top:1px} + +/* ---- CATEGORY drill-down sections ---- */ +.cat{margin-bottom:11px;overflow:hidden} +.cathead{display:flex;align-items:center;gap:12px;padding:11px 16px;cursor:pointer;user-select:none} +.cathead:hover{background:#161412} +.cathead .chev{color:var(--gold);font-size:12px;width:1em;text-align:center;transition:transform .15s} +.cat.open .cathead .chev{transform:rotate(90deg)} +.cathead .nm{color:var(--gold);font-weight:700;letter-spacing:.14em;text-transform:uppercase;font-size:12.5px} +.cathead .cnt{margin-left:auto;color:var(--dim);font-size:11px;display:flex;gap:10px;align-items:center} +.cathead .cnt .att{color:var(--amber)} .cathead .cnt .att.crit{color:var(--fail)} +.catbody{display:none;padding:2px 16px 15px} +.cat.open .catbody{display:block} + +.heroes{display:grid;grid-template-columns:repeat(12,1fr);gap:11px;margin-bottom:12px} +.inst{background:var(--well);border:1px solid #201d17;border-radius:9px;padding:11px 12px; + display:flex;flex-direction:column;gap:8px;position:relative;min-height:84px} +.inst .cap{color:var(--steel);font-size:.62rem;letter-spacing:.16em;text-transform:uppercase; + display:flex;align-items:center;gap:7px} +.inst .cap .key{position:absolute;top:9px;right:9px} +.inst .val{display:flex;align-items:baseline;gap:8px;margin-top:auto} +.inst.s3{grid-column:span 3} .inst.s4{grid-column:span 4} .inst.s5{grid-column:span 5} .inst.s6{grid-column:span 6} +.inst .sub{color:var(--dim);font-size:10.5px} +.gaugepair{display:flex;gap:16px;align-items:center;justify-content:center;margin-top:auto} +.gaugepair .g{display:flex;flex-direction:column;align-items:center;gap:3px} +.gaugepair .g .lb{color:var(--steel);font-size:9px;letter-spacing:.1em} + +.strip{display:grid;grid-template-columns:repeat(3,1fr);gap:6px 12px} + +/* ---- output well ---- */ +.owell{margin-top:18px;padding:13px 15px} +.owell .oh{display:flex;align-items:center;justify-content:space-between;margin-bottom:11px; + color:var(--cream);font-size:13px} +.owell .oh .l{color:var(--gold)} +.owell .ostep{display:flex;gap:9px;align-items:flex-start;padding:3px 0;font-size:12.5px} +.owell .ostep .lamp{margin-top:3px;width:8px;height:8px} +.owell .ostep b{color:var(--cream);font-weight:700} +.owell .ostep .ev{color:var(--steel);font-size:11px} +.owell .queued b{color:var(--dim)} +.owell .ofoot{margin-top:11px;display:flex;justify-content:flex-end} + +.hint{color:var(--dim);font-size:11px;margin:0 4px 14px;display:flex;align-items:center;gap:8px} +.controls-row{display:flex;gap:8px;margin:0 2px 12px} +.footnote{color:var(--dim);font-size:11px;margin-top:16px;line-height:1.6;padding:0 4px} +.footnote i{color:var(--steel);font-style:normal} +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +</style> +</head> +<body> +<div class="console"> + + <!-- ===== MASTHEAD ===== --> + <div class="plate mast"> + <div class="brand"> + <div class="ey">archsetup · maintenance console</div> + <div class="ti">MAINT · <b>ratio</b></div> + </div> + <div class="verdict"><span class="lamp"></span> HEALTHY</div> + <div class="spacer"></div> + <div class="doctor"> + <span class="lbl">Doctor</span> + <button class="key" onclick="runDoctor()">CLEAN UP</button> + <button class="key">REVIEW & FIX</button> + </div> + </div> + + <!-- ===== UPDATES (quarantined, top billing for the 3 CVEs) ===== --> + <div class="updbar"> + <span class="q">Updates</span> + <div class="rd"><span class="n">47</span><span class="k">pending</span></div> + <div class="rd"><span class="n cve">3</span><span class="k">CVE</span></div> + <div class="rd"><span class="n">5</span><span class="k">AUR stale</span></div> + <span class="msg"><span class="lamp red" style="width:8px;height:8px"></span> Security advisories present — updates run through the workflow, never the doctor.</span> + <span class="spacer"></span> + <button class="key red">RUN WORKFLOW</button> + </div> + + <!-- ===== OVERVIEW: all categories at a glance ===== --> + <div class="ovhead"><span class="engrave">Overview<span class="cnt">· 8 systems</span></span></div> + <div class="overview" id="overview"></div> + + <!-- ===== DRILL-DOWN CATEGORY SECTIONS ===== --> + <div class="controls-row"> + <button class="key sm" onclick="allCats(true)">EXPAND ALL</button> + <button class="key sm" onclick="allCats(false)">COLLAPSE ALL</button> + <span class="hint" style="margin:0 0 0 6px">click any category header — or its overview tile — to drill in</span> + </div> + + <!-- STORAGE --> + <div class="cat plate open" id="cat-storage"> + <div class="cathead" onclick="toggleCat('storage')"> + <span class="chev">▸</span><span class="lamp red"></span> + <span class="nm">Storage & Filesystem</span> + <span class="cnt"><span class="att crit">3 attention</span> · 6 ok</span> + </div> + <div class="catbody"> + <div class="heroes"> + <div class="inst s3"> + <div class="cap">Disk usage</div> + <div class="val"><span class="readout">69<small>%</small></span></div> + <div class="bar"><span style="width:69%"></span></div> + <div class="sub">root · btrfs · thr 80 / 90</div> + </div> + <div class="inst s3"> + <button class="key sm">CLEAN</button> + <div class="cap"><span class="lamp gold"></span> Package cache</div> + <div class="val"><span class="readout">8.8<small> GB</small></span></div> + <div class="bar warn"><span style="width:88%"></span><span class="tk" style="left:100%"></span></div> + <div class="sub">Auto · threshold ~10 GB</div> + </div> + <div class="inst s3"> + <button class="key sm">SCRUB</button> + <div class="cap"><span class="lamp gold"></span> btrfs scrub</div> + <div style="display:flex;justify-content:center;margin-top:auto"> + <span class="ring" style="--p:62;--rc:var(--amber)"><b>34</b><small>days</small></span> + </div> + <div class="sub" style="text-align:center">past 30d cadence</div> + </div> + <div class="inst s3"> + <button class="key sm red">ENABLE</button> + <div class="cap"><span class="lamp red"></span> fstrim.timer</div> + <div class="val"><span class="readout" style="color:var(--fail)">OFF</span></div> + <div class="sub">Confirm · SSD discard disabled</div> + </div> + </div> + <div class="strip"> + <div class="lrow"><span class="lamp"></span><span class="who">SMART</span><span class="what">PASSED</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">SMART wear</span><span class="what">0%</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">device errors</span><span class="what">0 / 0</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">btrfs unalloc</span><span class="what">118 GiB</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">pacman integrity</span><span class="what">clean</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">deep-trim</span><span class="what"><button class="key sm">DEEP TRIM</button> keep 3</span></div> + </div> + </div> + </div> + + <!-- PACKAGES --> + <div class="cat plate open" id="cat-packages"> + <div class="cathead" onclick="toggleCat('packages')"> + <span class="chev">▸</span><span class="lamp gold"></span> + <span class="nm">Packages & Security</span> + <span class="cnt"><span class="att">2 attention</span> · 2 ok · updates above</span> + </div> + <div class="catbody"> + <div class="heroes"> + <div class="inst s3"> + <button class="key sm">REVIEW</button> + <div class="cap"><span class="lamp gold"></span> Orphans</div> + <div class="val"><span class="readout">13</span></div> + <div class="sub">Confirm · unowned pkgs</div> + </div> + <div class="inst s3"> + <button class="key sm">REVIEW</button> + <div class="cap"><span class="lamp gold"></span> .pacnew files</div> + <div class="val"><span class="readout">2</span></div> + <div class="sub">Confirm · config merges</div> + </div> + </div> + <div class="strip"> + <div class="lrow"><span class="lamp"></span><span class="who">keyring age</span><span class="what"><button class="key sm">REFRESH</button> 12d</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">reboot required</span><span class="what">no</span></div> + </div> + </div> + </div> + + <!-- SYSTEMD --> + <div class="cat plate open" id="cat-systemd"> + <div class="cathead" onclick="toggleCat('systemd')"> + <span class="chev">▸</span><span class="lamp gold"></span> + <span class="nm">systemd & Boot</span> + <span class="cnt"><span class="att">2 attention</span> · 2 ok</span> + </div> + <div class="catbody"> + <div class="heroes"> + <div class="inst s3"> + <button class="key sm">WORKFLOW</button> + <div class="cap"><span class="lamp gold"></span> Failed units</div> + <div class="val"><span class="readout">1</span></div> + <div class="sub">Workflow · systemd</div> + </div> + <div class="inst s3"> + <button class="key sm">ENABLE</button> + <div class="cap"><span class="lamp gold"></span> Maint timers</div> + <div class="val"><span class="readout">4<small>/5</small></span></div> + <div class="sub">Confirm · one not firing</div> + </div> + </div> + <div class="strip"> + <div class="lrow"><span class="lamp"></span><span class="who">is-system-running</span><span class="what">running</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">kernel taint</span><span class="what">0</span></div> + </div> + </div> + </div> + + <!-- LOGS --> + <div class="cat plate open" id="cat-logs"> + <div class="cathead" onclick="toggleCat('logs')"> + <span class="chev">▸</span><span class="lamp gold"></span> + <span class="nm">Logs & Coredumps</span> + <span class="cnt"><span class="att">1 attention</span> · 4 ok</span> + </div> + <div class="catbody"> + <div class="heroes"> + <div class="inst s3"> + <button class="key sm">CLEAR</button> + <div class="cap"><span class="lamp gold"></span> Coredumps 7d</div> + <div class="val"><span class="readout">18</span></div> + <div class="sub">Auto · clearable</div> + </div> + </div> + <div class="strip"> + <div class="lrow"><span class="lamp"></span><span class="who">journald size</span><span class="what"><button class="key sm">VACUUM</button> 1.2 GB</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">app-log cleanup</span><span class="what"><button class="key sm">RUN</button> ok</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">journal errors</span><span class="what">12 real</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">kernel/hw events</span><span class="what">clean</span></div> + </div> + </div> + </div> + + <!-- MEMORY/THERMAL/POWER --> + <div class="cat plate open" id="cat-power"> + <div class="cathead" onclick="toggleCat('power')"> + <span class="chev">▸</span><span class="lamp gold"></span> + <span class="nm">Memory · Thermal · Power</span> + <span class="cnt"><span class="att">1 attention</span> · 4 ok</span> + </div> + <div class="catbody"> + <div class="heroes"> + <div class="inst s5"> + <div class="cap"><span class="lamp"></span> Temperatures</div> + <div class="gaugepair"> + <div class="g"><span class="ring" style="--p:61;--rc:var(--pass)"><b>61°</b></span><span class="lb">CPU</span></div> + <div class="g"><span class="ring" style="--p:54;--rc:var(--pass)"><b>54°</b></span><span class="lb">NVMe</span></div> + </div> + </div> + <div class="inst s4"> + <div class="cap"><span class="lamp"></span> Memory free</div> + <div class="val"><span class="readout">104<small> GB</small></span></div> + <div class="bar"><span style="width:83%"></span></div> + <div class="sub">of 125 GB · 0 OOM</div> + </div> + <div class="inst s3"> + <div class="cap"><span class="lamp gold"></span> Unclean boot</div> + <div class="val"><span class="readout" style="color:var(--amber)">75<small>%</small></span></div> + <div class="bar warn"><span style="width:75%"></span></div> + <div class="sub">Workflow · investigate</div> + </div> + </div> + <div class="strip"> + <div class="lrow"><span class="lamp"></span><span class="who">swap / zram</span><span class="what">16 GiB</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">thermal throttle</span><span class="what">no</span></div> + <div class="lrow"><span class="lamp off"></span><span class="who">battery</span><span class="what">n/a · desktop</span></div> + </div> + </div> + </div> + + <!-- SERVICES --> + <div class="cat plate open" id="cat-services"> + <div class="cathead" onclick="toggleCat('services')"> + <span class="chev">▸</span><span class="lamp gold"></span> + <span class="nm">Services & Backups</span> + <span class="cnt"><span class="att">1 attention</span> · 3 ok</span> + </div> + <div class="catbody"> + <div class="heroes"> + <div class="inst s3"> + <button class="key sm">PRUNE</button> + <div class="cap"><span class="lamp gold"></span> Docker reclaim</div> + <div class="val"><span class="readout">3<small> GB</small></span></div> + <div class="sub">Confirm · reclaimable</div> + </div> + </div> + <div class="strip"> + <div class="lrow"><span class="lamp"></span><span class="who">rsyncshot backup</span><span class="what">3h ago</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">docker stopped</span><span class="what">2</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">cron</span><span class="what">ok</span></div> + <div class="lrow"><span class="lamp off"></span><span class="who">libvirt VMs</span><span class="what">off</span></div> + </div> + </div> + </div> + + <!-- SNAPSHOTS (collapsed by default — all green) --> + <div class="cat plate" id="cat-snapshots"> + <div class="cathead" onclick="toggleCat('snapshots')"> + <span class="chev">▸</span><span class="lamp"></span> + <span class="nm">Snapshots</span> + <span class="cnt"><span class="okc" style="color:var(--pass)">all clear · 2 ✓</span></span> + </div> + <div class="catbody"> + <div class="strip"> + <div class="lrow"><span class="lamp"></span><span class="who">snapper count</span><span class="what"><button class="key sm">PRUNE</button> 42 · retention ok</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">snapshot timer</span><span class="what">active</span></div> + </div> + </div> + </div> + + <!-- NETWORK (collapsed by default — all green) --> + <div class="cat plate" id="cat-network"> + <div class="cathead" onclick="toggleCat('network')"> + <span class="chev">▸</span><span class="lamp"></span> + <span class="nm">Network & Posture</span> + <span class="cnt"><span class="okc" style="color:var(--pass)">all clear · 6 ✓</span></span> + </div> + <div class="catbody"> + <div class="strip"> + <div class="lrow"><span class="lamp"></span><span class="who">firewall</span><span class="what">active</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">unexpected listeners</span><span class="what">3</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">tailscale</span><span class="what">4/4</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">fail2ban</span><span class="what">active</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">NTP sync</span><span class="what">synced</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">DNS / NM</span><span class="what">ok</span></div> + </div> + </div> + </div> + + <!-- ===== OUTPUT WELL (doctor running) ===== --> + <div class="plate owell" id="owell" style="display:none"> + <div class="oh"><span>MAINT · <span class="l">ratio</span> · CLEANING <span class="lamp busy"></span></span> + <span style="color:var(--dim);font-size:11px">4 Auto actions</span></div> + <div class="ostep"><span class="lamp busy"></span><span><b>cache trim</b> <span class="ev">running…</span></span></div> + <div class="ostep"><span class="lamp"></span><span><b>journal vacuum</b> <span class="ev">done · reclaimed 0.9 GB</span></span></div> + <div class="ostep"><span class="lamp"></span><span><b>coredump clear</b> <span class="ev">done · 12 cleared</span></span></div> + <div class="ostep queued"><span class="lamp off"></span><span><b>app-log cleanup</b> <span class="ev">queued</span></span></div> + <div class="ofoot"><button class="key" onclick="document.getElementById('owell').style.display='none'">DONE</button></div> + </div> + + <div class="footnote"> + Two levels: the <i>Overview</i> grid is every system at a glance — click a tile to jump to and open its section. + Each <i>category</i> below is a drill-down you expand or collapse (click the header, or use Expand/Collapse all). + All-green categories (Snapshots, Network) start collapsed — on a fully-healthy day every section collapses and the + console is the masthead + overview. Verdict lamp reads worst <i>diagnostic</i> state only — the fstrim fail and the + 3 CVEs are actionable/updates, so the box still reads HEALTHY. + </div> + +</div> + +<script> +const CATS = [ + {id:'storage', name:'Storage & FS', cls:'crit', att:'3', ok:'6', crit:true}, + {id:'packages', name:'Packages & Sec', cls:'attn', att:'2', ok:'2'}, + {id:'systemd', name:'systemd & Boot', cls:'attn', att:'2', ok:'2'}, + {id:'logs', name:'Logs & Cores', cls:'attn', att:'1', ok:'4'}, + {id:'power', name:'Mem·Therm·Power', cls:'attn', att:'1', ok:'4'}, + {id:'services', name:'Services & Backup', cls:'attn', att:'1', ok:'3'}, + {id:'snapshots',name:'Snapshots', cls:'clear', att:'0', ok:'2'}, + {id:'network', name:'Network & Posture',cls:'clear', att:'0', ok:'6'}, +]; +const lampFor = c => c.cls==='crit'?'red':c.cls==='attn'?'gold':''; +// build overview station tiles +const ov = document.getElementById('overview'); +CATS.forEach(c=>{ + const el=document.createElement('div'); + el.className='plate station '+c.cls; + const att = c.att!=='0' + ? `<span class="att ${c.crit?'crit':''}">${c.att} attention</span> · <span class="okc">${c.ok} ok</span>` + : `<span class="okc">all clear · ${c.ok} ✓</span>`; + el.innerHTML = + `<div class="top"><span class="lamp ${lampFor(c)}"></span><span class="nm">${c.name}</span><span class="chev">▸</span></div> + <div class="st">${att}</div>`; + el.onclick=()=>openCat(c.id); + ov.appendChild(el); +}); +function toggleCat(id){ document.getElementById('cat-'+id).classList.toggle('open'); } +function openCat(id){ + const el=document.getElementById('cat-'+id); + el.classList.add('open'); + el.scrollIntoView({behavior:'smooth',block:'center'}); +} +function allCats(open){ CATS.forEach(c=>{ + document.getElementById('cat-'+c.id).classList.toggle('open',open); }); } +function runDoctor(){ document.getElementById('owell').style.display='block'; + document.getElementById('owell').scrollIntoView({behavior:'smooth',block:'center'}); } +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-07-maint-console-E4-interactive.html b/docs/prototypes/2026-07-07-maint-console-E4-interactive.html new file mode 100644 index 0000000..c678a8d --- /dev/null +++ b/docs/prototypes/2026-07-07-maint-console-E4-interactive.html @@ -0,0 +1,546 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>MAINT · ratio — Approach E4 · Interactive view-swap console</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --amber:#dab53d; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);line-height:1.45; + padding:2rem 1.4rem 5rem; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground); + display:flex;justify-content:center} +.console{width:1060px;max-width:100%} + +/* ---- primitives (widget-gallery kit) ---- */ +.lamp{width:10px;height:10px;border-radius:50%;background:var(--pass); + box-shadow:0 0 6px 1px rgba(116,147,47,.55);display:inline-block;flex:none} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +.lamp.red{background:var(--fail);box-shadow:0 0 7px 1px rgba(203,107,77,.6)} +.lamp.off{background:var(--wash);box-shadow:none} +.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite} +@keyframes pulse{50%{opacity:.25}} + +.key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer; + background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a; + border-radius:8px;padding:6px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4); + white-space:nowrap} +.key:hover{color:var(--gold);border-color:var(--gold)} +.key:active{transform:translateY(1px)} +.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} +.key.armed{background:rgba(203,107,77,.15);border-color:var(--fail);color:var(--fail)} +.key.sm{font-size:10.5px;padding:4px 9px;border-radius:7px} +.key.done{opacity:.45;pointer-events:none} + +.bar{width:100%;height:12px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative} +.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold));border-radius:6px; + transition:width .5s ease} +.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))} +.bar.okc>span{background:linear-gradient(90deg,#5c7626,var(--pass))} +.bar .tk{position:absolute;top:-2px;bottom:-2px;width:2px;background:var(--gold-hi)} + +.ring{width:58px;height:58px;border-radius:50%; + background:conic-gradient(var(--rc,var(--gold)) calc(var(--p)*1%),var(--wash) 0); + display:grid;place-items:center;position:relative;flex:none} +.ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)} +.ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1} +.ring small{position:relative;color:var(--dim);font-size:8.5px} + +.readout{color:var(--cream);font-size:22px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.03em} +.readout small{color:var(--dim);font-size:11px;font-weight:400} + +.engrave{color:var(--steel);font-size:.66rem;letter-spacing:.28em;text-transform:uppercase; + display:flex;align-items:center;gap:10px;flex:1} +.engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.engrave .cnt{color:var(--dim);letter-spacing:.08em;text-transform:none;font-size:.7rem} + +.lrow{display:flex;align-items:center;gap:9px;padding:6px 9px;border-radius:7px;background:#141210;font-size:12.5px} +.lrow .who{color:var(--silver)} .lrow .who b{color:var(--cream)} +.lrow .what{margin-left:auto;color:var(--dim);font-size:11px;display:flex;align-items:center;gap:8px} + +.plate{background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:12px; + box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 6px 14px rgba(0,0,0,.4)} + +/* masthead */ +.mast{padding:15px 18px;margin-bottom:14px;display:flex;align-items:center;gap:16px;flex-wrap:wrap} +.mast .brand{display:flex;flex-direction:column;gap:2px} +.mast .brand .ey{color:var(--steel);font-size:.64rem;letter-spacing:.28em;text-transform:uppercase} +.mast .brand .ti{color:var(--cream);font-size:19px;letter-spacing:.5px} +.mast .brand .ti b{color:var(--gold)} +.mast .verdict{display:flex;align-items:center;gap:8px;color:var(--pass);font-size:14px;font-weight:700} +.mast .spacer{flex:1} +.mast .doctor{display:flex;align-items:center;gap:8px} +.mast .doctor .lbl{color:var(--steel);font-size:.62rem;letter-spacing:.22em;text-transform:uppercase;margin-right:2px} + +/* updates */ +.updbar{display:flex;align-items:center;gap:16px;padding:9px 18px;margin-bottom:16px; + border:1px solid var(--fail);border-radius:11px; + background:linear-gradient(180deg,#181210,#0a0c0d)} +.updbar .q{color:var(--fail);font-size:.6rem;letter-spacing:.2em;text-transform:uppercase; + border:1px solid var(--fail);border-radius:5px;padding:2px 7px} +.updbar .rd{display:flex;flex-direction:column;line-height:1.05} +.updbar .rd .n{color:var(--cream);font-size:20px;font-weight:700;font-variant-numeric:tabular-nums} +.updbar .rd .n.cve{color:var(--fail)} +.updbar .rd .k{color:var(--dim);font-size:.6rem;letter-spacing:.12em;text-transform:uppercase} +.updbar .spacer{flex:1} +.updbar .msg{color:var(--dim);font-size:11px;max-width:300px} + +/* overview stations */ +.overview{display:grid;grid-template-columns:repeat(4,1fr);gap:11px} +.station{padding:12px 13px;cursor:pointer;display:flex;flex-direction:column;gap:9px; + transition:border-color .12s,transform .06s;border-left:3px solid var(--wash)} +.station:hover{border-color:#3a352c;transform:translateY(-1px)} +.station.attn{border-left-color:var(--amber)} +.station.crit{border-left-color:var(--fail)} +.station.clear{border-left-color:var(--pass)} +.station .top{display:flex;align-items:center;gap:9px} +.station .top .nm{color:var(--cream);font-size:12.5px;font-weight:700} +.station .top .chev{margin-left:auto;color:var(--dim);font-size:11px} +.station .st{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--dim)} +.station .st .att{color:var(--amber)} .station .st .att.crit{color:var(--fail)} +.station .st .okc{color:var(--pass)} +.ovhead{margin:0 4px 9px;display:flex;align-items:center;gap:10px} + +/* category detail view */ +.catplate{padding:0 0 15px} +.cathead{display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid #201d17;margin-bottom:13px} +.cathead .nm{color:var(--gold);font-weight:700;letter-spacing:.14em;text-transform:uppercase;font-size:13px} +.cathead .cnt{color:var(--dim);font-size:11px;display:flex;gap:10px;align-items:center} +.cathead .cnt .att{color:var(--amber)} .cathead .cnt .att.crit{color:var(--fail)} +.cathead .cnt .okc{color:var(--pass)} +.cathead .navs{margin-left:auto;display:flex;gap:7px} +.catbody{padding:0 16px} +.heroes{display:grid;grid-template-columns:repeat(12,1fr);gap:11px;margin-bottom:12px} +.inst{background:var(--well);border:1px solid #201d17;border-radius:9px;padding:11px 12px; + display:flex;flex-direction:column;gap:8px;position:relative;min-height:86px} +.inst .cap{color:var(--steel);font-size:.62rem;letter-spacing:.16em;text-transform:uppercase; + display:flex;align-items:center;gap:7px;padding-right:70px} +.inst .lever{position:absolute;top:9px;right:9px} +.inst .val{display:flex;align-items:baseline;gap:8px;margin-top:auto} +.inst.s3{grid-column:span 3} .inst.s4{grid-column:span 4} .inst.s5{grid-column:span 5} .inst.s6{grid-column:span 6} +.inst .sub{color:var(--dim);font-size:10.5px} +.gaugepair{display:flex;gap:16px;align-items:center;justify-content:center;margin-top:auto} +.gaugepair .g{display:flex;flex-direction:column;align-items:center;gap:3px} +.gaugepair .g .lb{color:var(--steel);font-size:9px;letter-spacing:.1em} +.strip{display:grid;grid-template-columns:repeat(3,1fr);gap:6px 12px} + +/* doctor / review views */ +.owell{padding:13px 16px} +.owell .oh{display:flex;align-items:center;justify-content:space-between;margin-bottom:11px; + color:var(--cream);font-size:13px} +.owell .oh .l{color:var(--gold)} +.ostep{display:flex;gap:9px;align-items:flex-start;padding:4px 0;font-size:12.5px} +.ostep .lamp{margin-top:3px;width:8px;height:8px} +.ostep b{color:var(--cream);font-weight:700} +.ostep .ev{color:var(--steel);font-size:11px;display:block} +.ostep.queued b{color:var(--dim)} +.ofoot{margin-top:12px;display:flex;justify-content:flex-end;gap:8px} + +.rvrow{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;background:#141210; + font-size:12.5px;margin-bottom:6px} +.rvrow .who{color:var(--cream);font-weight:700} +.rvrow .why{color:var(--dim);font-size:11px} +.rvrow .keys{margin-left:auto;display:flex;gap:7px;align-items:center} +.rvrow .res{color:var(--pass);font-size:11px} +.rvnote{color:var(--dim);font-size:11px;margin:10px 2px 0;line-height:1.5} + +/* toast */ +#toast{position:fixed;bottom:26px;right:26px;display:flex;flex-direction:column;gap:8px;z-index:9} +.toastw{font-size:11.5px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 11px; + box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .18s ease} +.toastw.err{background:#5e3a2e} +@keyframes tin{from{opacity:0;transform:translateY(6px)}} + +.crumb{color:var(--dim);font-size:11px;margin:0 4px 9px;display:flex;gap:6px;align-items:center} +.crumb a{color:var(--gold);cursor:pointer;text-decoration:none} +.footnote{color:var(--dim);font-size:11px;margin-top:16px;line-height:1.6;padding:0 4px} +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +</style> +</head> +<body> +<div class="console"> + + <!-- persistent masthead --> + <div class="plate mast"> + <div class="brand"> + <div class="ey">archsetup · maintenance console · E4 interactive</div> + <div class="ti">MAINT · <b>ratio</b></div> + </div> + <div class="verdict" id="verdict"><span class="lamp"></span> HEALTHY</div> + <div class="spacer"></div> + <div class="doctor"> + <button class="key" id="homekey" onclick="go('home')">HOME</button> + <span class="lbl" style="margin-left:8px">Doctor</span> + <button class="key" onclick="go('doctor')">CLEAN UP</button> + <button class="key" onclick="go('review')">REVIEW & FIX</button> + </div> + </div> + + <!-- swapped view --> + <div id="view"></div> + + <div class="footnote"> + Fully interactive: views replace each other (no inline collapse). HOME = overview of all systems; + click a station to drill in; PREV/NEXT walk the categories; CLEAN UP streams the output well and + actually updates the metrics; REVIEW & FIX lists every Confirm item — destructive ones arm on + first press, fire on second. Workflow items always escalate (toast) — the console never runs them. + </div> +</div> +<div id="toast"></div> + +<script> +/* ================= state ================= */ +const S = { + cache:{v:8.8, st:'warn'}, scrub:{v:34, st:'warn'}, fstrim:{v:'OFF', st:'fail'}, + disk:{v:69, st:'ok'}, smart:{v:'PASSED', st:'ok'}, wear:{v:'0%', st:'ok'}, + deverr:{v:'0 / 0', st:'ok'}, unalloc:{v:'118 GiB', st:'ok'}, integ:{v:'clean', st:'ok'}, + deeptrim:{v:'keep 3', st:'ok'}, + orphans:{v:13, st:'warn'}, pacnew:{v:2, st:'warn'}, keyring:{v:12, st:'ok'}, reboot:{v:'no', st:'ok'}, + failed:{v:1, st:'warn'}, timers:{v:'4/5', st:'warn'}, sysrun:{v:'running', st:'ok'}, taint:{v:0, st:'ok'}, + cores:{v:18, st:'warn'}, journald:{v:1.2, st:'ok'}, applog:{v:'ok', st:'ok'}, + jerr:{v:'12 real', st:'ok'}, khw:{v:'clean', st:'ok'}, + temps:{v:'61/54', st:'ok'}, mem:{v:104, st:'ok'}, unclean:{v:75, st:'warn'}, + zram:{v:'16 GiB', st:'ok'}, throttle:{v:'no', st:'ok'}, battery:{v:'n/a', st:'off'}, + docker:{v:3, st:'warn'}, rsync:{v:'3h ago', st:'ok'}, dstopped:{v:2, st:'ok'}, + cron:{v:'ok', st:'ok'}, libvirt:{v:'off', st:'off'}, + snapper:{v:42, st:'ok'}, stimer:{v:'active', st:'ok'}, + fw:{v:'active', st:'ok'}, listen:{v:3, st:'ok'}, ts:{v:'4/4', st:'ok'}, + f2b:{v:'active', st:'ok'}, ntp:{v:'synced', st:'ok'}, dns:{v:'ok', st:'ok'}, +}; + +const CATS = [ + {id:'storage', name:'Storage & Filesystem', ids:['disk','cache','scrub','fstrim','smart','wear','deverr','unalloc','integ','deeptrim']}, + {id:'packages', name:'Packages & Security', ids:['orphans','pacnew','keyring','reboot']}, + {id:'systemd', name:'systemd & Boot', ids:['failed','timers','sysrun','taint']}, + {id:'logs', name:'Logs & Coredumps', ids:['cores','journald','applog','jerr','khw']}, + {id:'power', name:'Memory · Thermal · Power', ids:['temps','mem','unclean','zram','throttle','battery']}, + {id:'services', name:'Services & Backups', ids:['docker','rsync','dstopped','cron','libvirt']}, + {id:'snapshots',name:'Snapshots', ids:['snapper','stimer']}, + {id:'network', name:'Network & Posture', ids:['fw','listen','ts','f2b','ntp','dns']}, +]; + +let view = 'home'; +let armed = null; // id of armed destructive key +let doctorTimer = null; + +/* ================= helpers ================= */ +const $ = id => document.getElementById(id); +function counts(cat){ + let attn=0, ok=0, crit=false; + cat.ids.forEach(i=>{ const st=S[i].st; + if(st==='warn'){attn++} else if(st==='fail'){attn++;crit=true} else ok++; }); + return {attn, ok, crit}; +} +function lampCls(st){ return st==='ok'?'':st==='warn'?'gold':st==='fail'?'red':'off'; } +function catLamp(cat){ const c=counts(cat); return c.crit?'red':c.attn?'gold':''; } +function toast(msg, err){ + const t=document.createElement('div'); t.className='toastw'+(err?' err':''); t.textContent=msg; + $('toast').appendChild(t); setTimeout(()=>t.remove(), 2600); +} +function esc(id){ toast('escalates to the system-health-check workflow — not run from the console', true); } + +/* generic lever runner: busy → mutate → re-render */ +function fire(act){ + const a = ACTIONS[act]; + if(a.arm && armed!==act){ armed=act; render(); toast(a.armMsg||'press again to confirm', true); return; } + armed=null; + toast(a.busy||'running…'); + setTimeout(()=>{ a.run(); toast(a.doneMsg); render(); }, a.ms||800); +} + +const ACTIONS = { + clean_cache:{ busy:'paccache -r / -ruk0 …', ms:1000, doneMsg:'cache trim — reclaimed 8.4 GB', + run(){ S.cache={v:0.4, st:'ok'}; } }, + scrub:{ busy:'btrfs scrub started on / …', ms:1400, doneMsg:'scrub running — age resets on completion', + run(){ S.scrub={v:0, st:'ok'}; } }, + fstrim:{ busy:'systemctl enable --now fstrim.timer …', ms:900, doneMsg:'fstrim.timer enabled — weekly TRIM active', + run(){ S.fstrim={v:'ON', st:'ok'}; S.timers={v:'5/5', st:'ok'}; } }, + deeptrim:{ arm:true, armMsg:'keep only 1 version of every pkg — press DEEP TRIM again', busy:'paccache -rk1 …', ms:1100, + doneMsg:'deep trim — kept 1 version, reclaimed 3.1 GB more', run(){ S.deeptrim={v:'keep 1', st:'ok'}; if(S.cache.st==='ok') S.cache={v:0.2,st:'ok'}; } }, + orphans:{ arm:true, armMsg:'remove 13 orphaned packages — press again', busy:'pacman -Rns (13 pkgs) …', ms:1200, + doneMsg:'orphans removed — 13 pkgs', run(){ S.orphans={v:0, st:'ok'}; } }, + pacnew:{ busy:'diffing 2 .pacnew files …', ms:900, doneMsg:'.pacnew resolved — mirrorlist deleted, locale.gen merged', + run(){ S.pacnew={v:0, st:'ok'}; } }, + keyring:{ busy:'pacman -Sy archlinux-keyring …', ms:900, doneMsg:'keyring refreshed', + run(){ S.keyring={v:0, st:'ok'}; } }, + timers:{ busy:'systemctl enable --now (1 timer) …', ms:800, doneMsg:'all 5 maintenance timers firing', + run(){ S.timers={v:'5/5', st:'ok'}; S.fstrim.st==='fail' && (S.fstrim={v:'ON',st:'ok'}); } }, + cores:{ busy:'coredumpctl clean (keep 3d) …', ms:800, doneMsg:'coredumps cleared — 18 removed', + run(){ S.cores={v:0, st:'ok'}; } }, + journald:{ busy:'journalctl --vacuum-size=300M …', ms:800, doneMsg:'journal vacuumed — reclaimed 0.9 GB', + run(){ S.journald={v:0.3, st:'ok'}; } }, + applog:{ busy:'log-cleanup …', ms:600, doneMsg:'app logs — nothing older than 7d', run(){} }, + docker:{ arm:true, armMsg:'docker prune tier 1+2 — press again', busy:'docker container/image prune …', ms:1200, + doneMsg:'docker pruned — reclaimed 2.8 GB', run(){ S.docker={v:0.2, st:'ok'}; } }, + snapper:{ arm:true, armMsg:'prune stale manual snapshots — press again', busy:'snapper cleanup …', ms:900, + doneMsg:'snapper — retention applied', run(){ S.snapper={v:31, st:'ok'}; } }, +}; + +/* ================= view renderers ================= */ +function lever(act, label, cls){ + const armedNow = armed===act; + return `<button class="key sm ${cls||''} ${armedNow?'armed':''}" onclick="event.stopPropagation();fire('${act}')">${armedNow?label+'?':label}</button>`; +} +function wkey(label){ return `<button class="key sm" onclick="event.stopPropagation();esc()">${label||'WORKFLOW'}</button>`; } +function lrow(id, name, extra){ + const m=S[id]; + return `<div class="lrow"><span class="lamp ${lampCls(m.st)}"></span><span class="who">${name}</span> + <span class="what">${extra||''}${m.v}</span></div>`; +} + +function renderHome(){ + const stations = CATS.map(c=>{ + const k=counts(c); + const st = k.attn ? `<span class="att ${k.crit?'crit':''}">${k.attn} attention</span> · <span class="okc">${k.ok} ok</span>` + : `<span class="okc">all clear · ${k.ok} ✓</span>`; + return `<div class="plate station ${k.crit?'crit':k.attn?'attn':'clear'}" onclick="go('cat:${c.id}')"> + <div class="top"><span class="lamp ${catLamp(c)}"></span><span class="nm">${c.name}</span><span class="chev">▸</span></div> + <div class="st">${st}</div></div>`; + }).join(''); + return ` + <div class="updbar"> + <span class="q">Updates</span> + <div class="rd"><span class="n">47</span><span class="k">pending</span></div> + <div class="rd"><span class="n cve">3</span><span class="k">CVE</span></div> + <div class="rd"><span class="n">5</span><span class="k">AUR stale</span></div> + <span class="msg"><span class="lamp red" style="width:8px;height:8px"></span> Security advisories present — updates run through the workflow, never the doctor.</span> + <span class="spacer"></span> + <button class="key red" onclick="esc()">RUN WORKFLOW</button> + </div> + <div class="ovhead"><span class="engrave">Overview<span class="cnt">· ${CATS.length} systems</span></span></div> + <div class="overview">${stations}</div>`; +} + +function heroBar(id, cap, unit, pct, opts){ + const m=S[id]; const o=opts||{}; + return `<div class="inst s3"> + ${o.lever||''} + <div class="cap"><span class="lamp ${lampCls(m.st)}"></span> ${cap}</div> + <div class="val"><span class="readout">${m.v}<small> ${unit||''}</small></span></div> + <div class="bar ${m.st==='warn'?'warn':m.st==='ok'?'okc':''}">${o.tick?'<span class="tk" style="left:'+o.tick+'%"></span>':''}<span style="width:${pct}%"></span></div> + <div class="sub">${o.sub||''}</div></div>`; +} +function heroCount(id, cap, opts){ + const m=S[id]; const o=opts||{}; + return `<div class="inst s3"> + ${o.lever||''} + <div class="cap"><span class="lamp ${lampCls(m.st)}"></span> ${cap}</div> + <div class="val"><span class="readout" ${m.st==='fail'?'style="color:var(--fail)"':m.st==='warn'&&o.tint?'style="color:var(--amber)"':''}>${m.v}${o.unit?'<small>'+o.unit+'</small>':''}</span></div> + <div class="sub">${o.sub||''}</div></div>`; +} +function heroRing(id, cap, pct, color, small, opts){ + const m=S[id]; const o=opts||{}; + return `<div class="inst s3"> + ${o.lever||''} + <div class="cap"><span class="lamp ${lampCls(m.st)}"></span> ${cap}</div> + <div style="display:flex;justify-content:center;margin-top:auto"> + <span class="ring" style="--p:${pct};--rc:${color}"><b>${m.v}</b><small>${small}</small></span></div> + <div class="sub" style="text-align:center">${o.sub||''}</div></div>`; +} + +const CATVIEW = { + storage(){ return { + heroes: + heroBar('disk','Disk usage','%',S.disk.v,{sub:'root · btrfs · thr 80 / 90'})+ + heroBar('cache','Package cache','GB',Math.min(100,S.cache.v/10*100),{lever:`<span class="lever">${lever('clean_cache','CLEAN')}</span>`,tick:100,sub:'Auto · threshold ~10 GB'})+ + heroRing('scrub','btrfs scrub',Math.min(100,S.scrub.v/55*100),S.scrub.st==='ok'?'var(--pass)':'var(--amber)','days',{lever:`<span class="lever">${lever('scrub','SCRUB')}</span>`,sub:S.scrub.st==='ok'?'scrub fresh':'past 30d cadence'})+ + heroCount('fstrim','fstrim.timer',{lever:`<span class="lever">${lever('fstrim','ENABLE',S.fstrim.st==='fail'?'red':'')}</span>`,sub:'Confirm · weekly SSD TRIM'}), + strip: + lrow('smart','SMART')+lrow('wear','SMART wear')+lrow('deverr','device errors')+ + lrow('unalloc','btrfs unalloc')+lrow('integ','pacman integrity')+ + lrow('deeptrim','deep-trim', lever('deeptrim','DEEP TRIM')+' ') + };}, + packages(){ return { + heroes: + heroCount('orphans','Orphans',{lever:`<span class="lever">${lever('orphans','REMOVE')}</span>`,sub:'Confirm · unowned pkgs',tint:1})+ + heroCount('pacnew','.pacnew files',{lever:`<span class="lever">${lever('pacnew','REVIEW')}</span>`,sub:'Confirm · config merges',tint:1}), + strip: + lrow('keyring','keyring age (d)', lever('keyring','REFRESH')+' ')+ + lrow('reboot','reboot required') + };}, + systemd(){ return { + heroes: + heroCount('failed','Failed units',{lever:`<span class="lever">${wkey()}</span>`,sub:'Workflow · investigate why',tint:1})+ + heroCount('timers','Maint timers',{lever:`<span class="lever">${lever('timers','ENABLE')}</span>`,sub:'Confirm · paccache/scrub/fstrim/…',tint:1}), + strip: lrow('sysrun','is-system-running')+lrow('taint','kernel taint') + };}, + logs(){ return { + heroes: + heroCount('cores','Coredumps 7d',{lever:`<span class="lever">${lever('cores','CLEAR')}</span>`,sub:'Auto · keeps last 3 days',tint:1}), + strip: + lrow('journald','journald size (GB)', lever('journald','VACUUM')+' ')+ + lrow('applog','app-log cleanup', lever('applog','RUN')+' ')+ + lrow('jerr','journal errors')+lrow('khw','kernel/hw events') + };}, + power(){ return { + heroes: + `<div class="inst s5"><div class="cap"><span class="lamp"></span> Temperatures</div> + <div class="gaugepair"> + <div class="g"><span class="ring" style="--p:61;--rc:var(--pass)"><b>61°</b></span><span class="lb">CPU</span></div> + <div class="g"><span class="ring" style="--p:54;--rc:var(--pass)"><b>54°</b></span><span class="lb">NVMe</span></div> + </div></div>`+ + `<div class="inst s4"><div class="cap"><span class="lamp"></span> Memory free</div> + <div class="val"><span class="readout">104<small> GB</small></span></div> + <div class="bar okc"><span style="width:83%"></span></div><div class="sub">of 125 GB · 0 OOM</div></div>`+ + `<div class="inst s3"><span class="lever">${wkey()}</span><div class="cap"><span class="lamp gold"></span> Unclean boot</div> + <div class="val"><span class="readout" style="color:var(--amber)">75<small>%</small></span></div> + <div class="bar warn"><span style="width:75%"></span></div><div class="sub">Workflow · investigate</div></div>`, + strip: lrow('zram','swap / zram')+lrow('throttle','thermal throttle')+lrow('battery','battery') + };}, + services(){ return { + heroes: + heroCount('docker','Docker reclaim',{lever:`<span class="lever">${lever('docker','PRUNE')}</span>`,unit:' GB',sub:'Confirm · tiers 1+2',tint:1}), + strip: + lrow('rsync','rsyncshot backup')+lrow('dstopped','docker stopped')+ + lrow('cron','cron')+lrow('libvirt','libvirt VMs') + };}, + snapshots(){ return { + heroes:'', + strip: + lrow('snapper','snapper count', lever('snapper','PRUNE')+' ')+ + lrow('stimer','snapshot timer') + };}, + network(){ return { + heroes:'', + strip: + lrow('fw','firewall')+lrow('listen','unexpected listeners')+lrow('ts','tailscale')+ + lrow('f2b','fail2ban')+lrow('ntp','NTP sync')+lrow('dns','DNS / NM') + };}, +}; + +function renderCat(cid){ + const idx = CATS.findIndex(c=>c.id===cid); + const cat = CATS[idx]; + const k = counts(cat); + const prev = CATS[(idx+CATS.length-1)%CATS.length].id; + const next = CATS[(idx+1)%CATS.length].id; + const body = CATVIEW[cid](); + const cnt = k.attn ? `<span class="att ${k.crit?'crit':''}">${k.attn} attention</span> · <span class="okc">${k.ok} ok</span>` + : `<span class="okc">all clear · ${k.ok} ✓</span>`; + return ` + <div class="crumb"><a onclick="go('home')">overview</a> ▸ ${cat.name.toLowerCase()}</div> + <div class="plate catplate"> + <div class="cathead"> + <span class="lamp ${catLamp(cat)}"></span> + <span class="nm">${cat.name}</span> + <span class="cnt">${cnt}</span> + <span class="navs"> + <button class="key sm" onclick="go('cat:${prev}')">◂ PREV</button> + <button class="key sm" onclick="go('home')">HOME</button> + <button class="key sm" onclick="go('cat:${next}')">NEXT ▸</button> + </span> + </div> + <div class="catbody"> + ${body.heroes?`<div class="heroes">${body.heroes}</div>`:''} + <div class="strip">${body.strip}</div> + </div> + </div>`; +} + +/* ---- doctor (clean up) ---- */ +let docSteps = []; +function renderDoctor(){ + const rows = docSteps.map(s=>{ + const lamp = s.st==='run'?'busy':s.st==='done'?'':'off'; + return `<div class="ostep ${s.st==='wait'?'queued':''}"><span class="lamp ${lamp}"></span> + <span><b>${s.name}</b> <span class="ev">${s.ev}</span></span></div>`; + }).join(''); + const running = docSteps.some(s=>s.st!=='done'); + return ` + <div class="crumb"><a onclick="go('home')">overview</a> ▸ doctor · clean up</div> + <div class="plate owell"> + <div class="oh"><span>MAINT · <span class="l">ratio</span> · CLEANING ${running?'<span class="lamp busy"></span>':'<span class="lamp"></span>'}</span> + <span style="color:var(--dim);font-size:11px">${docSteps.length} Auto actions</span></div> + ${rows} + <div class="ofoot"><button class="key ${running?'done':''}" onclick="go('home')">DONE</button></div> + </div>`; +} +function startDoctor(){ + clearTimeout(doctorTimer); + docSteps = [ + {name:'cache trim', act:'clean_cache', st:'wait', ev:'queued', + res:()=> S.cache.st==='warn' ? (S.cache={v:0.4,st:'ok'}, 'done · reclaimed 8.4 GB') : 'done · nothing to reclaim'}, + {name:'journal vacuum',act:'journald', st:'wait', ev:'queued', + res:()=> S.journald.v>0.5 ? (S.journald={v:0.3,st:'ok'}, 'done · reclaimed 0.9 GB') : 'done · already tight'}, + {name:'coredump clear',act:'cores', st:'wait', ev:'queued', + res:()=> S.cores.v>0 ? (()=>{const n=S.cores.v; S.cores={v:0,st:'ok'}; return 'done · '+n+' cleared';})() : 'done · none to clear'}, + {name:'app-log cleanup',act:'applog', st:'wait', ev:'queued', + res:()=> 'done · nothing older than 7d'}, + ]; + let i = 0; + function step(){ + if(i>0){ docSteps[i-1].st='done'; docSteps[i-1].ev=docSteps[i-1].res(); } + if(i<docSteps.length){ docSteps[i].st='run'; docSteps[i].ev='running…'; i++; + if(view==='doctor') $('view').innerHTML=renderDoctor(); + doctorTimer=setTimeout(step, 1000); + } else { + if(view==='doctor') $('view').innerHTML=renderDoctor(); + toast('clean up complete'); + } + } + step(); +} + +/* ---- review & fix ---- */ +const REVIEW = [ + {act:'fstrim', who:'fstrim.timer', why:'weekly SSD TRIM disabled', id:'fstrim', okv:()=>S.fstrim.st==='ok'}, + {act:'scrub', who:'btrfs scrub', why:'34d since last scrub (30d cadence)', id:'scrub', okv:()=>S.scrub.st==='ok'}, + {act:'orphans', who:'orphans', why:'13 unowned packages', id:'orphans', okv:()=>S.orphans.st==='ok'}, + {act:'pacnew', who:'.pacnew files', why:'2 configs awaiting merge', id:'pacnew', okv:()=>S.pacnew.st==='ok'}, + {act:'timers', who:'maint timers', why:'1 of 5 not firing', id:'timers', okv:()=>S.timers.st==='ok'}, + {act:'keyring', who:'keyring', why:'refresh before next update', id:'keyring', okv:()=>S.keyring.st==='ok'}, + {act:'docker', who:'docker reclaim', why:'3 GB reclaimable (tiers 1+2)', id:'docker', okv:()=>S.docker.st==='ok'}, + {act:'snapper', who:'snapper', why:'retention within budget — optional', id:'snapper', okv:()=>S.snapper.v<42}, + {act:'deeptrim',who:'deep-trim', why:'keep 1 version — frees most, less downgrade headroom', id:'deeptrim', okv:()=>S.deeptrim.v==='keep 1'}, +]; +function renderReview(){ + const rows = REVIEW.map(r=>{ + const done = r.okv(); + return `<div class="rvrow"> + <span class="lamp ${done?'':lampCls(S[r.id].st)}"></span> + <span class="who">${r.who}</span><span class="why">${r.why}</span> + <span class="keys">${done?'<span class="res">done ✓</span>':lever(r.act, ACTIONS[r.act].arm?'FIX':'FIX')}</span> + </div>`; + }).join(''); + return ` + <div class="crumb"><a onclick="go('home')">overview</a> ▸ doctor · review & fix</div> + <div class="plate owell"> + <div class="oh"><span>MAINT · <span class="l">ratio</span> · REVIEW & FIX</span> + <span style="color:var(--dim);font-size:11px">${REVIEW.filter(r=>!r.okv()).length} Confirm items</span></div> + ${rows} + <div class="rvnote">Destructive fixes (orphan removal, deep-trim, prunes) arm on first press and fire on the + second. Workflow items — failed units, unclean boots, updates, CVEs — are deliberately absent here: they + escalate to the health-check workflow.</div> + <div class="ofoot"><button class="key" onclick="go('home')">DONE</button></div> + </div>`; +} + +/* ================= router ================= */ +function go(v){ + armed = null; + view = v; + if(v==='doctor') startDoctor(); + render(); + window.scrollTo({top:0, behavior:'smooth'}); +} +function render(){ + $('homekey').classList.toggle('on', view==='home'); + const el = $('view'); + if(view==='home') el.innerHTML = renderHome(); + else if(view==='doctor') el.innerHTML = renderDoctor(); + else if(view==='review') el.innerHTML = renderReview(); + else if(view.startsWith('cat:')) el.innerHTML = renderCat(view.slice(4)); +} +render(); +</script> +</body> +</html> diff --git a/docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html b/docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html new file mode 100644 index 0000000..95ec4c9 --- /dev/null +++ b/docs/prototypes/2026-07-07-maint-console-E5-selector-subpanel.html @@ -0,0 +1,1696 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>MAINT · ratio — E5 · Selector + dense subpanel</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --amber:#dab53d; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);line-height:1.45; + padding:1.6rem 1.2rem 4rem; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground); + display:flex;justify-content:center;align-items:flex-start} + +.capsule{width:960px;max-width:100%;background:var(--panel);color:var(--silver); + border:1.6px solid var(--gold);border-radius:16px;padding:17px 19px; + box-shadow:0 18px 50px rgba(0,0,0,.55);position:relative} + +/* ---- primitives ---- */ +.lamp{width:10px;height:10px;border-radius:50%;background:var(--pass); + box-shadow:0 0 6px 1px rgba(116,147,47,.55);display:inline-block;flex:none} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +.lamp.red{background:var(--fail);box-shadow:0 0 7px 1px rgba(203,107,77,.6)} +.lamp.off{background:var(--wash);box-shadow:none} +.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite; + box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +@keyframes pulse{50%{opacity:.25}} + +.key{font:inherit;font-size:11.5px;letter-spacing:.06em;color:var(--silver);cursor:pointer; + background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a; + border-radius:8px;padding:6px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4); + white-space:nowrap} +.key:hover{color:var(--gold);border-color:var(--gold)} +.key:active{transform:translateY(1px)} +.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} +.key.armed{background:rgba(203,107,77,.15);border-color:var(--fail);color:var(--fail)} +.key.dis{opacity:.3;cursor:not-allowed} +.key.dis:hover{color:var(--silver);border-color:#33302b} +.key.sm{font-size:10px;padding:3px 8px;border-radius:7px} + +.badge{font-size:.6rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px;white-space:nowrap} +.badge.red{background:var(--fail);color:var(--cream)} +.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)} +.badge.pass{background:var(--pass);color:var(--cream)} + +.bar{width:100%;height:11px;background:#0d0f10;border:1px solid #231f18;border-radius:6px;overflow:hidden;position:relative} +.bar>span{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,#8a7524,var(--gold));border-radius:6px;transition:width .5s ease} +.bar.warn>span{background:linear-gradient(90deg,#a35a3f,var(--fail))} +.bar.okc>span{background:linear-gradient(90deg,#5c7626,var(--pass))} +.bar .tk{position:absolute;top:-2px;bottom:-2px;width:2px;background:var(--gold-hi)} + +.ring{width:54px;height:54px;border-radius:50%; + background:conic-gradient(var(--rc,var(--gold)) calc(var(--p)*1%),var(--wash) 0); + display:grid;place-items:center;position:relative;flex:none} +.ring::before{content:"";position:absolute;inset:6px;border-radius:50%;background:var(--well)} +.ring b{position:relative;color:var(--cream);font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1} +.ring small{position:relative;color:var(--dim);font-size:8px} + +.readout{color:var(--cream);font-size:21px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:.03em;line-height:1} +.readout small{color:var(--dim);font-size:11px;font-weight:400} + +.engrave{color:var(--steel);font-size:.64rem;letter-spacing:.26em;text-transform:uppercase; + display:flex;align-items:center;gap:10px} +.engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.engrave .cnt{color:var(--dim);letter-spacing:.08em;text-transform:none;font-size:.68rem} + +.lrow{display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:7px;background:#141210;font-size:12px} +.lrow .who{color:var(--silver)} +.lrow .what{margin-left:auto;color:var(--dim);font-size:10.5px;display:flex;align-items:center;gap:7px} + +.ladder{display:inline-flex;gap:3px;align-items:flex-end;height:16px} +.ladder i{width:5px;background:var(--wash);border-radius:1px} +.ladder i:nth-child(1){height:5px}.ladder i:nth-child(2){height:9px} +.ladder i:nth-child(3){height:12px}.ladder i:nth-child(4){height:16px} +.ladder i.on{background:var(--pass)} +.ladder.bad i.on{background:var(--fail)} + +/* faceplate */ +.face{display:flex;align-items:center;gap:11px;padding:11px 13px;margin-bottom:11px; + background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320;border-radius:11px; + box-shadow:inset 0 1px 0 rgba(255,255,255,.04)} +.face .word{color:var(--cream);font-size:15.5px;font-weight:700;letter-spacing:.22em} +.face .spacer{flex:1} +.face .unit{color:var(--steel);font-size:.72rem;letter-spacing:.1em} +.face .xbtn{width:24px;height:24px;border-radius:50%;border:1px solid #33302b;background:transparent; + color:var(--dim);cursor:pointer;font-size:12px;line-height:1;display:grid;place-items:center;font-family:var(--mono)} +.face .xbtn:hover{color:var(--fail);border-color:var(--fail)} + +.ctlrow{display:flex;align-items:center;gap:8px;margin-bottom:11px;padding:0 2px} +.ctlrow .lbl{color:var(--steel);font-size:.6rem;letter-spacing:.22em;text-transform:uppercase;margin-right:3px} +.ctlrow .spacer{flex:1} + +.updbar{display:flex;align-items:center;gap:13px;padding:7px 12px;margin-bottom:12px; + border:1px solid var(--wash);border-radius:9px;background:linear-gradient(180deg,#141310,#0a0c0d);font-size:11.5px} +.updbar .q{color:var(--dim);font-size:.58rem;letter-spacing:.2em;text-transform:uppercase; + border:1px solid var(--wash);border-radius:5px;padding:1px 6px} +.updbar.ok{border-color:var(--pass)} .updbar.ok .q{color:var(--pass);border-color:var(--pass)} +.updbar.warn{border-color:var(--amber)} .updbar.warn .q{color:var(--amber);border-color:var(--amber)} +.updbar.crit{border-color:var(--fail)} .updbar.crit .q{color:var(--fail);border-color:var(--fail)} +.updbar b{color:var(--cream);font-variant-numeric:tabular-nums} +.updbar .cve{color:var(--fail);font-weight:700} +.updbar .spacer{flex:1} +.updbar .dimtx{color:var(--dim);font-size:10.5px} + +/* selector */ +.selector{display:grid;grid-template-columns:repeat(4,1fr);gap:7px;margin-bottom:13px} +.selbtn{display:flex;align-items:center;gap:8px;padding:8px 10px;cursor:pointer;border-radius:9px; + background:linear-gradient(180deg,#191715,#131110);border:1px solid #262320;border-left:3px solid var(--wash); + transition:border-color .1s} +.selbtn:hover{border-color:#3a352c} +.selbtn .nm{color:var(--silver);font-size:11.5px;font-weight:700;letter-spacing:.02em} +.selbtn .ct{margin-left:auto;font-size:10px;color:var(--dim);font-variant-numeric:tabular-nums;white-space:nowrap} +.selbtn .ct .a{color:var(--amber)} .selbtn .ct .a.crit{color:var(--fail)} .selbtn .ct .k{color:var(--pass)} +.selbtn.attn{border-left-color:var(--amber)} +.selbtn.crit{border-left-color:var(--fail)} +.selbtn.clear{border-left-color:var(--pass)} +.selbtn.loading{border-left-color:var(--amber)} +.selbtn.sel{background:linear-gradient(180deg,#4a5768,var(--slate));border-color:var(--gold);border-left-width:3px} +.selbtn.sel .nm{color:var(--gold)} +.selbtn.sel .ct, .selbtn.sel .ct .a, .selbtn.sel .ct .k{color:var(--cream)} + +/* subpanel */ +.sub{background:var(--well);border:1px solid #201d17;border-radius:11px;padding:13px 14px;min-height:330px} +.subhead{display:flex;align-items:center;gap:10px;margin-bottom:12px} +.subhead .nm{color:var(--gold);font-weight:700;letter-spacing:.16em;text-transform:uppercase;font-size:12.5px} +.subhead .cnt{color:var(--dim);font-size:11px;margin-left:auto;display:flex;gap:9px;align-items:center} +.subhead .cnt .att{color:var(--amber)} .subhead .cnt .att.crit{color:var(--fail)} .subhead .cnt .okc{color:var(--pass)} + +.wgrid{display:grid;grid-template-columns:repeat(4,1fr);gap:9px;margin-bottom:10px} +.cell{background:#100f0e;border:1px solid #1f1c18;border-radius:9px;padding:9px 10px; + display:flex;flex-direction:column;gap:7px;position:relative;min-height:76px} +.cell .cap{color:var(--steel);font-size:.58rem;letter-spacing:.14em;text-transform:uppercase; + display:flex;align-items:center;gap:6px;padding-right:56px} +.cell .lever{position:absolute;top:7px;right:7px} +.cell .val{display:flex;align-items:baseline;gap:7px;margin-top:auto} +.cell .sub2{color:var(--dim);font-size:10px} +.cell .center{display:flex;justify-content:center;margin-top:auto} +.gpair{display:flex;gap:14px;align-items:center;justify-content:center;margin-top:auto} +.gpair .g{display:flex;flex-direction:column;align-items:center;gap:2px} +.gpair .g .lb{color:var(--steel);font-size:8.5px;letter-spacing:.1em} + +.striph{margin:9px 0 7px} +.strip{display:grid;grid-template-columns:repeat(3,1fr);gap:5px 9px} +.strip.two{grid-template-columns:repeat(2,1fr)} + +/* segmented selector (gallery widget 06) */ +.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden;width:max-content} +.seg button{font:inherit;font-size:10px;color:var(--silver);background:#191715;border:0; + border-right:1px solid #33302b;padding:5px 9px;cursor:pointer;letter-spacing:.06em} +.seg button:last-child{border-right:0} +.seg button:hover{color:var(--gold)} +.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700} + +/* evidence column headers */ +.evh{color:var(--steel);font-size:.58rem;letter-spacing:.14em;text-transform:uppercase;margin-bottom:4px} +.colh{display:flex;align-items:center;gap:8px;min-width:0} +.colh .engrave{flex:1;min-width:0} + +/* ===== rotary band selector (amplifier input-selector idiom) ===== */ +.deck{display:flex;align-items:center;gap:22px;margin:13px 4px 11px;padding:9px 14px; + background:linear-gradient(180deg,#161412,#100f0e);border:1px solid #201d17;border-radius:10px} +.knob{width:46px;height:46px;border-radius:50%;position:relative;cursor:pointer;flex:none; + background:radial-gradient(circle at 40% 35%,#2a2622,#141210);border:1px solid #3a352c; + box-shadow:inset 0 2px 3px rgba(255,255,255,.05),0 3px 6px rgba(0,0,0,.5)} +.knob:hover{border-color:var(--gold)} +.knob .ind{position:absolute;left:50%;top:4px;width:2px;height:14px;background:var(--gold-hi); + margin-left:-1px;transform-origin:50% 19px;border-radius:1px; + box-shadow:0 0 5px rgba(255,215,95,.6);transition:transform .25s cubic-bezier(.3,1.3,.5,1)} +.bands{display:flex;gap:26px;align-items:flex-end} +.band{display:flex;flex-direction:column;align-items:center;gap:4px;cursor:pointer;user-select:none} +.band .lamp{width:7px;height:7px} +.band .bl{color:var(--steel);font-size:.6rem;letter-spacing:.18em;text-transform:uppercase;white-space:nowrap} +.band:hover .bl{color:var(--silver)} +.band.on .bl{color:var(--cream)} +.band .ul{height:2px;width:100%;border-radius:1px;background:transparent;transition:background .15s} +.band.on .ul{background:var(--gold);box-shadow:0 0 6px rgba(218,181,61,.5)} + +/* journal digest rows — capped at ~3.5 rows; the half row signals scrollability */ +/* rows are fixed 30px so the 3.5-row cap is exact: 3×30 + 3×4 gap + 15 half = 117 */ +.jlist{display:flex;flex-direction:column;gap:4px;max-height:117px;overflow-y:auto;padding-right:4px} +.jlist .jrow{height:30px;min-height:30px;padding:2px 8px;flex:none} + +/* dark scrollbars, consistent with the panel family (slate thumb on dark track) */ +.capsule ::-webkit-scrollbar{width:8px;height:8px} +.capsule ::-webkit-scrollbar-track{background:#0a0c0d;border-radius:4px} +.capsule ::-webkit-scrollbar-thumb{background:#2c2f32;border-radius:4px} +.capsule ::-webkit-scrollbar-thumb:hover{background:#54677d} +.capsule *{scrollbar-width:thin;scrollbar-color:#2c2f32 #0a0c0d} +.jrow{gap:8px} +.jrow .jmsg{color:var(--dim);font-size:10.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0} +.jrow .what{margin-left:0;flex:none} +.jrow.known .who{color:var(--dim)} + +.skel{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px; + min-height:260px;color:var(--dim);font-size:11px;letter-spacing:.24em;text-transform:uppercase} + +/* doctor / review */ +.ostep{display:flex;gap:9px;align-items:flex-start;padding:4px 0;font-size:12.5px} +.ostep .lamp{margin-top:3px;width:8px;height:8px} +.ostep b{color:var(--cream);font-weight:700} +.ostep .ev{color:var(--steel);font-size:11px;display:block} +.ostep .ev.evfail{color:var(--fail)} +.ostep.queued b{color:var(--dim)} +.ofoot{margin-top:12px;display:flex;justify-content:flex-end;gap:8px} +/* review list: fixed 32px rows + 5px gap (37 stride); cap = 8.5 rows so the + panel holds its shape and the half row cues the scroll */ +.rvlist{max-height:312px;overflow-y:auto;padding-right:4px} +.rvrow{display:flex;align-items:center;gap:10px;padding:2px 10px;border-radius:8px;background:#141210; + font-size:12px;margin-bottom:5px;height:32px;min-height:32px} +.rvrow .who{color:var(--cream);font-weight:700} +.rvrow .why{color:var(--dim);font-size:10.5px} +.rvrow .why.errtx{color:var(--fail)} +.rvrow .keys{margin-left:auto;display:flex;gap:7px;align-items:center} +.rvrow .res{color:var(--pass);font-size:11px} +.rvnote{color:var(--dim);font-size:10.5px;margin:9px 2px 0;line-height:1.5} + +.errline{color:var(--fail);font-size:10px;line-height:1.35;margin-top:3px} + +/* results wall (output-well idiom from the net/bt panels) */ +.rwall{margin-top:12px;background:var(--well);border:1px solid #201d17;border-radius:11px;padding:10px 14px 12px} +.rwall .rhead{display:flex;align-items:center;gap:10px;margin-bottom:4px} +.rwall .rhead .keys{margin-left:auto;display:flex;gap:6px} +/* rows fixed 30px so the 3.5-entry cap is exact: 3.5 × 30 = 105 */ +.rwall .rbody{max-height:105px;overflow-y:auto;padding-right:4px} +.rwall .rstep{display:flex;gap:9px;align-items:center;height:30px;min-height:30px;font-size:12px} +.rwall .rstep .tm{color:var(--dim);font-size:10px;font-variant-numeric:tabular-nums;flex:none; + display:flex;flex-direction:column;line-height:1.2} +.rwall .rstep .tm .dt{font-size:8.5px;color:#5d636a;letter-spacing:.04em} +.rwall .rstep .lamp{width:8px;height:8px} +.rwall .rstep .rtxt{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.rwall .rstep b{color:var(--cream);font-weight:700} +.rwall .rstep .ev{color:var(--steel);font-size:11px} +.rwall .rstep .ev.evfail{color:var(--fail)} + +#toast{position:absolute;bottom:16px;right:18px;display:flex;flex-direction:column;gap:8px;z-index:9; + align-items:flex-end;pointer-events:none;max-width:70%} +.toastw{font-size:11.5px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 11px; + box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .18s ease} +.toastw.err{background:#5e3a2e} +@keyframes tin{from{opacity:0;transform:translateY(6px)}} + +#closed{display:none;color:var(--dim);font-size:12px;border:1px dashed var(--wash); + border-radius:10px;padding:14px 20px;cursor:pointer} +#closed:hover{color:var(--gold);border-color:var(--gold)} + +/* page-level prototype chrome (NOT part of the panel) */ +.pagectl{max-width:960px;margin:0 0 12px;display:flex;align-items:center;gap:8px; + color:var(--dim);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase} +.footnote{color:var(--dim);font-size:10.5px;margin-top:14px;line-height:1.55;max-width:960px} +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +</style> +</head> +<body> +<div> + +<!-- ===== prototype controls (page level, not panel UI) ===== --> +<div class="pagectl"> + prototype controls — + <button class="key sm" id="k-fail" onclick="toggleFail()" title="make every action fail so the error UX is visible">SIM FAIL</button> + <button class="key sm" id="k-bad" onclick="toggleBad()" title="load the degraded-machine snapshot: every metric in its warn/fail state">SIM BAD DAY</button> + <button class="key sm" id="k-zfs" onclick="toggleZfs()" title="show the velox/ZFS storage capability set instead of btrfs">SIM ZFS</button> + <button class="key sm" onclick="reloadPanel()" title="replay the open-and-hydrate sequence">SIM RELOAD</button> +</div> + +<div class="capsule" id="capsule"> + + <!-- faceplate --> + <div class="face"> + <span class="lamp" id="facelamp"></span> + <span class="word">MAINTENANCE</span> + <span class="badge red" id="cvebadge">3 CVE</span> + <span class="badge ghost" id="attbadge">9 ATTN</span> + <span class="badge red" id="errbadge" style="display:none">0 FAILED</span> + <span class="spacer"></span> + <span class="unit">MNT·01</span> + <button class="xbtn" title="Close (Esc)" onclick="closePanel()">✕</button> + </div> + + <!-- doctor control row --> + <div class="ctlrow"> + <span class="lbl">Doctor</span> + <button class="key" id="k-clean" onclick="go('doctor')">CLEAN UP</button> + <button class="key" id="k-review" onclick="go('review')">REVIEW & FIX</button> + <span class="spacer"></span> + <span class="lbl" style="margin-right:0" id="lastscan">last scan 07:41 · 44 checks · 0.9s</span> + </div> + + <!-- updates quarantine --> + <div class="updbar" id="updbar"> + <span class="q">Updates</span> + <span><b id="upd-p">47</b> pending</span> + <span class="cve" id="upd-c">3 CVE</span> + <span><b id="upd-a">5</b> AUR stale</span> + <span><b id="upd-f">0</b> firmware</span> + <span class="dimtx">mesa · linux-lts · systemd — guard-checked before any apply</span> + <span class="spacer"></span> + <span id="upd-rb"></span> + <button class="key sm" id="k-update" onclick="fire('sysupdate')">UPDATE</button> + <button class="key sm" id="k-topgrade" onclick="fire('topgrade')">TOPGRADE</button> + </div> + + <!-- persistent selector --> + <div class="selector" id="selector"></div> + + <!-- swapped subpanel --> + <div class="sub" id="sub"></div> + + <!-- results wall: appears on first action; toggle / copy / close --> + <div class="rwall" id="rwall" style="display:none"> + <div class="rhead"> + <span class="engrave" style="flex:1">Results<span class="cnt" id="rcount">· 0</span></span> + <span class="keys"> + <button class="key sm" id="rtoggle" onclick="toggleWall()">HIDE</button> + <button class="key sm" onclick="copyWall()">COPY</button> + </span> + </div> + <div class="rbody" id="rbody"></div> + </div> + + <div id="toast"></div> +</div> + +<div id="closed" onclick="openPanel()">MNT·01 closed — click to reopen (waybar: click the maint tag)</div> + +<div class="footnote"> + Prototype notes (not part of the panel): selector stays put — the gold/slate tile is the selection; the + subpanel below swaps. ✕ or Esc closes (chip reopens; reopening replays the hydrate sequence). CLEAN UP + streams into the subpanel and mutates real state; REVIEW & FIX lists every Confirm lever, destructive + ones arm-then-fire. Metrics without a determinate lever are read-only telemetry (agent/workflow assistance + is vLater). SIM FAIL makes every action fail (red lamp, error line, RETRY key, FAILED badge); SIM BAD DAY + loads a degraded-machine snapshot so every metric shows its warn/fail face, doctor or not; SIM RELOAD + replays the open: tiles pulse amber per tier (local reads land first, process probes next, package scans + last) and settle individually — cached values would fill the board instantly in the real build, with lamps + confirming freshness as probes return. +</div> +</div> + +<script> +/* ================= state snapshots ================= */ +function GOOD(){ return { + cache:{v:8.8, st:'warn'}, scrub:{v:34, st:'warn'}, fstrim:{v:'OFF', st:'fail'}, + disk:{v:69, st:'ok'}, smart:{v:'PASSED', st:'ok'}, wear:{v:0, st:'ok'}, + deverr:{v:'0 / 0', st:'ok'}, unalloc:{v:'118', st:'ok'}, integ:{v:'clean', st:'ok'}, + deeptrim:{v:'keep 3', st:'ok'}, smartlast:'passed 2026-07-01', devrows:[], + dtop:[['~/videos','620 GB'],['~/music','240 GB'],['~/pictures','180 GB'],['/var/lib/docker','38 GB'],['~/.cache','22 GB']], + orphans:{v:13, st:'warn'}, pacnew:{v:2, st:'warn'}, keyring:{v:12, st:'ok'}, reboot:{v:'NO', st:'ok'}, + orphlist:[['rust','1.2 GB'],['electron28','210 MB'],['python-build','84 MB'],['go-tools','61 MB'], + ['libplacebo','44 MB'],['ocaml','38 MB'],['ruby-rake','21 MB'],['libfoo','12 MB'],['perl-clone','9 MB'], + ['xcb-util-old','7 MB'],['libdazzle','6 MB'],['gtest','5 MB'],['orc-old','4 MB']], + pacnews:[{f:'/etc/pacman.d/mirrorlist.pacnew', safe:true, why:'reflector-managed'}, + {f:'/etc/locale.gen.pacnew', safe:false, why:'needs merge'}], + cves:[['openssh','CVE-2026-2892','high'],['mesa','CVE-2026-1104','medium'],['libxml2','CVE-2026-0517','low']], + aurnames:'zoom · spotify · ttf-berkeley-mono +2', fwnames:'', + failed:{v:1, st:'warn'}, timers:{v:'4/5', st:'warn'}, sysrun:{v:'running', st:'ok'}, taint:{v:0, st:'ok'}, + funits:[{u:'systemd-oomd', since:'09:12', info:'exit 217'}], + cores:{v:18, st:'warn'}, journald:{v:1.2, st:'ok'}, applog:{v:'ok', st:'ok'}, + jerr:{v:12, st:'ok'}, khw:{v:'clean', st:'ok'}, + coregroups:[['chrome',12,'13:02'],['hyprland',3,'06-07'],['hypridle',2,'11:40'],['dunst',1,'10:05']], + khwev:[], + jgroups:[ + {id:'bluetoothd', msg:'Hands-Free Voice gateway connect failed', n:26, first:'07:02', last:'13:58', unit:'bluetooth.service'}, + {id:'pixman', msg:'_pixman_log_error: BadDrawable', n:140, first:'07:02', last:'13:59', unit:null}, + {id:'xkbcomp', msg:'Errors from xkbcomp are not fatal', n:8, first:'07:02', last:'12:10', unit:null}, + {id:'insync', msg:'QSocketNotifier: invalid socket', n:6, first:'08:15', last:'13:20', unit:null}, + {id:'gammastep', msg:'Wayland connection experienced a fatal error', n:3, first:'07:02', last:'07:03', unit:'gammastep.service'}, + {id:'hypridle', msg:'Failed to inhibit idle', n:2, first:'09:41', last:'11:12', unit:'hypridle.service'}, + {id:'dunst', msg:'gdk_pixbuf assertion failure', n:1, first:'10:05', last:'10:05', unit:'dunst.service'}, + ], + temps:{v:'61/54', st:'ok'}, mem:{v:104, st:'ok'}, unclean:{v:75, st:'warn'}, + zram:{v:'16 GiB', st:'ok'}, throttle:{v:'NO', st:'ok'}, battery:{v:'n/a', st:'off'}, + cpumode:{v:'power', st:'ok'}, batt:{v:94, st:'ok'}, blimit:{v:'none', st:'warn'}, + memtop:[['chrome','4.2 GB'],['emacs','1.9 GB'],['insync','0.9 GB'],['waybar','0.3 GB'],['mpd','0.2 GB']], + boots:[['07-07 07:01','clean'],['07-06 08:12','unclean'],['07-05 09:45','unclean'],['07-04 07:58','clean'],['07-03 08:30','unclean']], + pevents:[], + docker:{v:3, st:'warn'}, rsync:{v:'3h', st:'ok'}, dstopped:{v:2, st:'ok'}, + cron:{v:'ok', st:'ok'}, libvirt:{v:'off', st:'off'}, + ddf:[['images','14.2 GB','1.8 GB'],['containers','0.4 GB','0.1 GB'],['volumes','6.8 GB','0.3 GB'],['build cache','0.8 GB','0.8 GB']], + ctrs:[{name:'winvm', when:'2d ago', code:0},{name:'deepsat-pg', when:'6h ago', code:0}], + snapper:{v:42, st:'ok'}, stimer:{v:'active', st:'ok'}, + snaptypes:{timeline:31, single:8, prepost:3, oldsingle:'2026-06-12'}, + oldest:{v:8, st:'ok'}, retention:{v:'H6 D7 W2 M2 Y0', st:'ok'}, + fw:{v:'active', st:'ok'}, listen:{v:0, st:'ok'}, ts:{v:'4/4', st:'ok'}, tsdown:[], + lsn:[ + {proc:'sshd', port:22, bind:'0.0.0.0', unit:'sshd'}, + {proc:'mpd', port:6600, bind:'127.0.0.1', unit:'mpd'}, + {proc:'tailscaled', port:41641, bind:'0.0.0.0', unit:'tailscaled'}, + ], + f2b:{v:0, st:'ok'}, ntp:{v:'synced', st:'ok'}, dns:{v:'ok', st:'ok'}, + tgage:{v:9, st:'warn'}, + upd:{p:47, c:3, a:5, f:0}, + zcap:{v:62, st:'ok'}, zfrag:{v:31, st:'ok'}, ztrim:{v:'OFF', st:'fail'}, + zhealth:{v:'ONLINE', st:'ok'}, zerr:{v:'0 / 0 / 0', st:'ok'}, +};} +function BAD(){ return { + cache:{v:17, st:'warn'}, scrub:{v:74, st:'fail'}, fstrim:{v:'OFF', st:'fail'}, + disk:{v:92, st:'fail'}, smart:{v:'FAILING', st:'fail'}, wear:{v:91, st:'warn'}, + deverr:{v:'14 / 2', st:'fail'}, unalloc:{v:'9', st:'warn'}, integ:{v:'3 modified', st:'warn'}, + deeptrim:{v:'never run', st:'warn'}, smartlast:'failed 2026-07-06 — reallocated sectors', + devrows:[['nvme0n1','12 read · 2 write · 3 corruption'],['nvme1n1','2 read · 0 write · 1 corruption']], + dtop:[['~/videos','1.4 TB'],['/var/lib/docker','160 GB'],['~/music','240 GB'],['~/.cache','96 GB'],['~/downloads','88 GB']], + orphans:{v:51, st:'warn'}, pacnew:{v:7, st:'warn'}, keyring:{v:94, st:'warn'}, reboot:{v:'YES', st:'warn'}, + orphlist:[['rust','1.2 GB'],['cuda-11','2.8 GB'],['electron28','210 MB'],['electron25','195 MB'], + ['python-build','84 MB'],['go-tools','61 MB'],['libplacebo','44 MB'],['ocaml','38 MB'], + ['texlive-extra','1.1 GB'],['ruby-rake','21 MB'],['libfoo','12 MB'],['perl-clone','9 MB']], + pacnews:[{f:'/etc/pacman.d/mirrorlist.pacnew', safe:true, why:'reflector-managed'}, + {f:'/etc/sudoers.pacnew', safe:false, why:'needs merge — review carefully'}, + {f:'/etc/ssh/sshd_config.pacnew', safe:false, why:'needs merge'}, + {f:'/etc/locale.gen.pacnew', safe:false, why:'needs merge'}], + cves:[['openssh','CVE-2026-2892','high'],['sudo','CVE-2026-3011','high'],['mesa','CVE-2026-1104','medium'], + ['curl','CVE-2026-2440','medium'],['libxml2','CVE-2026-0517','low']], + aurnames:'zoom · spotify · ttf-berkeley-mono · paru-git +8', fwnames:'UEFI 3.07→3.09 · NVMe fw', + failed:{v:4, st:'fail'}, timers:{v:'2/5', st:'fail'}, sysrun:{v:'degraded', st:'fail'}, taint:{v:'W', st:'warn'}, + funits:[{u:'systemd-oomd', since:'09:12', info:'exit 217'}, + {u:'fail2ban', since:'07:44', info:'exit 1 — bad jail'}, + {u:'cronie', since:'07:02', info:'exit 203'}, + {u:'smartd', since:'07:02', info:'exit 1'}], + cores:{v:112, st:'fail'}, journald:{v:3.8, st:'warn'}, applog:{v:'stale', st:'warn'}, + jerr:{v:214, st:'warn'}, khw:{v:'MCE + I/O', st:'fail'}, + coregroups:[['chrome',61,'13:41'],['hyprland',23,'12:15'],['electron',17,'11:03'],['dunst',11,'13:10']], + khwev:[['12:44','MCE: CPU 3 bank 5 — corrected'],['11:02','nvme0n1: I/O error, sector 88121'],['10:58','thermal: package throttle 91°C']], + jgroups:[ + {id:'bluetoothd', msg:'Hands-Free Voice gateway connect failed', n:31, first:'07:02', last:'13:58', unit:'bluetooth.service'}, + {id:'pixman', msg:'_pixman_log_error: BadDrawable', n:180, first:'07:02', last:'13:59', unit:null}, + {id:'xkbcomp', msg:'Errors from xkbcomp are not fatal', n:9, first:'07:02', last:'12:10', unit:null}, + {id:'systemd-oomd', msg:'Failed to acquire memory pressure info', n:88, first:'07:05', last:'13:55', unit:'systemd-oomd.service'}, + {id:'wpa_supplicant', msg:'bgscan simple: Failed to enable signal monitoring', n:61, first:'07:02', last:'13:59', unit:'wpa_supplicant.service'}, + {id:'hyprland', msg:'GLES2 framebuffer incomplete', n:31, first:'08:11', last:'13:40', unit:null}, + {id:'rsyncshot', msg:'rsync error: partial transfer (code 23)', n:23, first:'02:30', last:'13:30', unit:'rsyncshot.service'}, + {id:'dunst', msg:'gdk_pixbuf assertion failure', n:11, first:'07:20', last:'13:10', unit:'dunst.service'}, + ], + temps:{v:'91/78', st:'fail'}, mem:{v:6, st:'warn'}, unclean:{v:82, st:'warn'}, + zram:{v:'none', st:'warn'}, throttle:{v:'YES', st:'fail'}, battery:{v:'n/a', st:'off'}, + cpumode:{v:'perf', st:'ok'}, batt:{v:71, st:'warn'}, blimit:{v:'none', st:'warn'}, + memtop:[['chrome','38.4 GB'],['java (deepsat)','22.1 GB'],['emacs','2.1 GB'],['Hyprland','1.9 GB'],['insync','1.4 GB'],['dockerd','1.1 GB']], + boots:[['07-07 07:05','unclean'],['07-06 21:12','unclean'],['07-06 08:12','unclean'],['07-05 09:45','clean'],['07-04 07:58','unclean']], + pevents:[['13:41','OOM killed chrome (38 GB)'],['12:20','thermal throttle 91°C · 340 s'],['09:15','OOM killed java (22 GB)']], + docker:{v:22, st:'warn'}, rsync:{v:'52h', st:'fail'}, dstopped:{v:5, st:'warn'}, + cron:{v:'down', st:'fail'}, libvirt:{v:'off', st:'off'}, + ddf:[['images','38.1 GB','18.2 GB'],['containers','1.2 GB','0.4 GB'],['volumes','9.8 GB','2.1 GB'],['build cache','1.3 GB','1.3 GB']], + ctrs:[{name:'winvm', when:'2d ago', code:0},{name:'deepsat-pg', when:'6h ago', code:0}, + {name:'jellyfin', when:'2h ago', code:137},{name:'pihole', when:'9h ago', code:1}, + {name:'registry', when:'3d ago', code:0}], + snapper:{v:214, st:'warn'}, stimer:{v:'inactive', st:'fail'}, + snaptypes:{timeline:62, single:140, prepost:12, oldsingle:'2026-01-03'}, + oldest:{v:194, st:'fail'}, retention:{v:'H6 D7 W2 M10 Y2', st:'warn'}, + fw:{v:'inactive', st:'fail'}, listen:{v:9, st:'warn'}, ts:{v:'1/4', st:'fail'}, + tsdown:['truenas','worker','cjennings'], + lsn:[ + {proc:'sshd', port:22, bind:'0.0.0.0', unit:'sshd'}, + {proc:'mpd', port:6600, bind:'127.0.0.1', unit:'mpd'}, + {proc:'tailscaled', port:41641, bind:'0.0.0.0', unit:'tailscaled'}, + {proc:'python', port:8000, bind:'0.0.0.0', unit:null}, + {proc:'ncat', port:4444, bind:'0.0.0.0', unit:null}, + {proc:'smbd', port:445, bind:'0.0.0.0', unit:'smb'}, + {proc:'node', port:3000, bind:'127.0.0.1', unit:null}, + {proc:'postgres', port:5432, bind:'127.0.0.1', unit:'postgresql'}, + {proc:'cupsd', port:631, bind:'127.0.0.1', unit:'cups'}, + ], + f2b:{v:'down', st:'fail'}, ntp:{v:'unsynced', st:'warn'}, dns:{v:'FAIL', st:'fail'}, + tgage:{v:34, st:'fail'}, + upd:{p:214, c:9, a:12, f:2}, + zcap:{v:91, st:'fail'}, zfrag:{v:68, st:'warn'}, ztrim:{v:'OFF', st:'fail'}, + zhealth:{v:'DEGRADED', st:'fail'}, zerr:{v:'3 / 0 / 1', st:'fail'}, +};} +let S = GOOD(); + +const CATS = [ + {id:'storage', name:'STORAGE', ids:['disk','cache','scrub','fstrim','smart','wear','deverr','unalloc','integ','deeptrim']}, + {id:'packages', name:'PACKAGES', ids:['orphans','pacnew','keyring','reboot','tgage']}, + {id:'systemd', name:'SYSTEMD', ids:['failed','timers','sysrun','taint']}, + {id:'logs', name:'LOGS', ids:['cores','journald','applog','jerr','khw']}, + {id:'power', name:'MEM·PWR', ids:['temps','mem','unclean','zram','throttle','battery']}, + {id:'services', name:'SERVICES', ids:['docker','rsync','dstopped','cron','libvirt']}, + {id:'snapshots',name:'SNAPSHOTS', ids:['snapper','stimer','oldest','retention']}, + {id:'network', name:'NETWORK', ids:['fw','listen','ts','f2b','ntp','dns']}, +]; +/* diagnostic (no lever here) set — drives the faceplate verdict lamp */ +const DIAG = ['disk','smart','wear','deverr','unalloc','integ','sysrun','taint','jerr','khw', + 'temps','mem','zram','throttle','battery','listen','dstopped','libvirt','oldest','retention', + 'failed','unclean','rsync','dns','zcap','zfrag','zhealth','zerr','batt']; + +let view = 'cat:storage'; +let armed = null; +let doctorTimer = null; +let failMode = false; +let badMode = false; +const ERR = {}; +const loaded = new Set(); // categories whose probes have landed +let loadTimers = []; + +/* ================= helpers ================= */ +const $ = id => document.getElementById(id); +const BTRFS_IDS = ['disk','cache','scrub','fstrim','smart','wear','deverr','unalloc','integ','deeptrim']; +const ZFS_IDS = ['zcap','cache','scrub','ztrim','smart','wear','zhealth','zerr','zfrag','integ','deeptrim']; +const PWR_RATIO = ['temps','mem','unclean','zram','throttle','battery','cpumode']; +const PWR_VELOX = ['temps','mem','unclean','zram','throttle','batt','blimit','cpumode']; +function catIds(cat){ + if(cat.id==='storage') return zfsMode?ZFS_IDS:BTRFS_IDS; + if(cat.id==='power') return zfsMode?PWR_VELOX:PWR_RATIO; + return cat.ids; +} +/* fixable/watch split per category — sets expectations before a single cell is read */ +function fixwatch(cid){ + switch(cid){ + case 'storage': return zfsMode?[7,4]:[8,2]; + case 'packages': return [5,0]; + case 'systemd': return [2,2]; + case 'logs': return [4,1]; + case 'power': return zfsMode?[3,5]:[2,5]; + case 'services': return [4,1]; + case 'snapshots':return [3,1]; + case 'network': return [6,0]; + } +} +function counts(cat){ + let attn=0, ok=0, crit=false; + catIds(cat).forEach(i=>{ const st=S[i].st; + if(st==='warn'){attn++} else if(st==='fail'){attn++;crit=true} else ok++; }); + return {attn, ok, crit}; +} +function lampCls(st){ return st==='ok'?'':st==='warn'?'gold':st==='fail'?'red':'off'; } +function toast(msg, err){ + const t=document.createElement('div'); t.className='toastw'+(err?' err':''); t.textContent=msg; + $('toast').appendChild(t); setTimeout(()=>t.remove(), 2600); +} + +/* ---- prototype toggles ---- */ +function toggleFail(){ + failMode = !failMode; + const k=$('k-fail'); k.classList.toggle('red', failMode); + k.textContent = failMode ? 'SIM FAIL ON' : 'SIM FAIL'; + toast(failMode ? 'failure simulation ON — every action will fail' : 'failure simulation off', failMode); +} +function toggleBad(){ + badMode = !badMode; + const k=$('k-bad'); k.classList.toggle('red', badMode); + k.textContent = badMode ? 'BAD DAY ON' : 'SIM BAD DAY'; + S = badMode ? BAD() : GOOD(); + Object.keys(ERR).forEach(k2=>delete ERR[k2]); + render(); + toast(badMode ? 'degraded snapshot loaded — every metric at its warn/fail face' : 'healthy snapshot restored', badMode); +} +/* ---- journal noise list: shipped defaults + user marks (two layers) ---- */ +const NOISE = [ + {id:'bluetoothd', pat:'Hands-Free Voice gateway connect failed', date:'2026-02-27', shipped:true, disabled:false}, + {id:'pixman', pat:'_pixman_log_error: BadDrawable', date:'2026-02-27', shipped:true, disabled:false}, + {id:'xkbcomp', pat:'Errors from xkbcomp are not fatal', date:'2026-03-24', shipped:true, disabled:false}, +]; +const nkey = g => g.id+'|'+(g.pat||g.msg); +function noiseFor(g){ return NOISE.find(n=>!n.disabled && n.id===g.id && n.pat===g.msg); } +function jSignal(){ return S.jgroups.filter(g=>!noiseFor(g)).sort((a,b)=>b.n-a.n); } +function jKnown(){ return S.jgroups.filter(g=>noiseFor(g)); } +function recalcJerr(){ + const sum = jSignal().reduce((s,g)=>s+g.n,0); + S.jerr = {v:sum, st: sum===0?'ok': sum<=25?'ok':'warn'}; +} +function markKnown(key){ + const g = S.jgroups.find(x=>nkey(x)===key); if(!g) return; + if(armed!=='mark:'+key){ armed='mark:'+key; render(); + toast('mark known: '+g.id+' · "'+g.msg.slice(0,40)+'…" — press again', true); return; } + armed=null; + NOISE.push({id:g.id, pat:g.msg, date:today(), shipped:false, disabled:false}); + resAdd('mark known', g.id+' · '+g.msg.slice(0,50)+' ('+g.n+' this boot)', 'done'); + render(); +} +function unmarkKnown(key){ + const g = S.jgroups.find(x=>nkey(x)===key); if(!g) return; + const n = noiseFor(g); if(!n) return; + if(n.shipped) n.disabled = true; + else NOISE.splice(NOISE.indexOf(n),1); + resAdd('unmark', g.id+' — back in signal'+(n.shipped?' (shipped default disabled)':''), 'done'); + render(); +} +function clearMarks(){ + if(armed!=='clearmarks'){ armed='clearmarks'; render(); + toast('clear all user marks + re-enable shipped defaults — press again', true); return; } + armed=null; + const removed = NOISE.filter(n=>!n.shipped).length; + for(let i=NOISE.length-1;i>=0;i--){ if(!NOISE[i].shipped) NOISE.splice(i,1); else NOISE[i].disabled=false; } + resAdd('clear marks', removed+' user mark(s) removed · shipped defaults restored', 'done'); + render(); +} + +/* ---- listeners: expected-list curation + guarded per-socket remedies ---- */ +const LEXPECT = [ + {proc:'sshd', port:22, date:'2026-02-27', shipped:true, disabled:false}, + {proc:'mpd', port:6600, date:'2026-02-27', shipped:true, disabled:false}, + {proc:'tailscaled', port:41641, date:'2026-02-27', shipped:true, disabled:false}, +]; +const lkey = r => r.proc+'|'+r.port; +const isPub = b => b==='0.0.0.0'||b==='::'; +function lexpFor(r){ return LEXPECT.find(p=>!p.disabled && p.proc===r.proc && p.port===r.port); } +function lsnSignal(){ return S.lsn.filter(r=>!lexpFor(r)); } +function recalcListen(){ + const sig = lsnSignal(); + const pub = sig.filter(r=>isPub(r.bind)).length; + S.listen = {v:sig.length, st: sig.length===0?'ok': pub?'fail':'warn'}; +} +function markExpected(key){ + const r = S.lsn.find(x=>lkey(x)===key); if(!r) return; + if(armed!=='lexp:'+key){ armed='lexp:'+key; render(); + toast('mark expected: '+r.proc+' :'+r.port+' — press again', true); return; } + armed=null; + LEXPECT.push({proc:r.proc, port:r.port, date:today(), shipped:false, disabled:false}); + resAdd('mark expected', r.proc+' :'+r.port+' ('+r.bind+')', 'done'); + render(); +} +function unmarkExpected(key){ + const p = LEXPECT.find(x=>!x.disabled && x.proc+'|'+x.port===key); if(!p) return; + if(p.shipped) p.disabled = true; else LEXPECT.splice(LEXPECT.indexOf(p),1); + resAdd('unmark expected', p.proc+' :'+p.port+' — back in signal'+(p.shipped?' (shipped default disabled)':''), 'done'); + render(); +} +function clearLsn(){ + if(armed!=='clearlsn'){ armed='clearlsn'; render(); + toast('clear user expected-marks + re-enable shipped — press again', true); return; } + armed=null; + const removed = LEXPECT.filter(p=>!p.shipped).length; + for(let i=LEXPECT.length-1;i>=0;i--){ if(!LEXPECT[i].shipped) LEXPECT.splice(i,1); else LEXPECT[i].disabled=false; } + resAdd('clear expected', removed+' user mark(s) removed · shipped defaults restored', 'done'); + render(); +} +function lsnFix(key){ + const r = S.lsn.find(x=>lkey(x)===key); if(!r) return; + const verb = r.unit ? 'systemctl stop '+r.unit : 'SIGTERM '+r.proc; + if(armed!=='lsnfix:'+key){ armed='lsnfix:'+key; render(); + toast((r.unit?'STOP ':'KILL ')+r.proc+' :'+r.port+' — '+verb+' · press again', true); return; } + armed=null; + const e = resAdd(r.unit?'stop unit':'kill listener', verb+' …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail', r.unit?'systemctl: unit busy — stop refused':r.proc+' respawned — supervised? check its unit'); } + else { S.lsn.splice(S.lsn.indexOf(r),1); + resDone(e,'done', r.proc+' stopped — port '+r.port+' closed'); } + render(); + }, 800); +} + +/* ---- rotary sub-section selector state ---- */ +const SUBSEC = {packages:'orphans', logs:'signal', services:'containers'}; +const DECKDEFS = {}; +function setSubsec(cat,id){ SUBSEC[cat]=id; armed=null; render(); } +function cycleSubsec(cat){ + const defs = DECKDEFS[cat]; if(!defs||!defs.length) return; + const i = defs.findIndex(d=>d.id===SUBSEC[cat]); + setSubsec(cat, defs[(i+1)%defs.length].id); +} +function deck(cat, defs){ + DECKDEFS[cat] = defs; + const cur = SUBSEC[cat]; + const i = Math.max(0, defs.findIndex(d=>d.id===cur)); + const ang = defs.length>1 ? -50 + i*(100/(defs.length-1)) : 0; + const bands = defs.map(d=>`<span class="band ${d.id===cur?'on':''}" onclick="setSubsec('${cat}','${d.id}')"> + <span class="lamp ${d.lamp||''}"></span> + <span class="bl">${d.label}${d.count!==undefined&&d.count!==''?' · '+d.count:''}</span> + <span class="ul"></span></span>`).join(''); + return `<div class="deck"> + <span class="knob" onclick="cycleSubsec('${cat}')" title="cycle sections"> + <span class="ind" style="transform:rotate(${ang}deg)"></span></span> + <span class="bands">${bands}</span></div>`; +} + +/* ---- orphans: KEEP curation (the rust lesson) + per-package REMOVE ---- */ +const OKEEP = []; // user layer: packages marked intentional +const okept = name => OKEEP.some(k=>k.name===name); +function orphKeep(name){ + if(okept(name)) return; + OKEEP.push({name, date:today()}); + S.orphans.v = Math.max(0, S.orphans.v-1); + if(S.orphans.v===0) S.orphans.st='ok'; + resAdd('keep', name+' marked intentional — excluded from removal', 'done'); + render(); +} +function orphUnkeep(name){ + const k = OKEEP.find(x=>x.name===name); if(!k) return; + OKEEP.splice(OKEEP.indexOf(k),1); + S.orphans.v++; S.orphans.st='warn'; + resAdd('unkeep', name+' — back in the orphan set', 'done'); + render(); +} +function orphRemove(name){ + const row = S.orphlist.find(r=>r[0]===name); if(!row) return; + if(armed!=='orem:'+name){ armed='orem:'+name; render(); + toast('pacman -Rns '+name+' ('+row[1]+') — press again', true); return; } + armed=null; + const e = resAdd('orphan remove', 'pacman -Rns '+name+' …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail','pacman: breaking dependency — '+name+' required by another package'); } + else { S.orphlist.splice(S.orphlist.indexOf(row),1); + S.orphans.v = Math.max(0, S.orphans.v-1); + if(S.orphans.v===0) S.orphans.st='ok'; + resDone(e,'done',name+' removed — '+row[1]+' freed'); } + render(); + }, 900); +} + +/* ---- pacnew: per-file DELETE (safe) / MERGE (terminal delegation) ---- */ +function pacnewAct(file){ + const p = S.pacnews.find(x=>x.f===file); if(!p) return; + const e = resAdd(p.safe?'pacnew delete':'pacnew merge', + p.safe ? 'rm '+file+' — '+p.why+' …' : 'opening diff in terminal — '+file+' …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail', p.safe?'rm: permission denied':'diff tool not found'); } + else { S.pacnews.splice(S.pacnews.indexOf(p),1); + S.pacnew.v = Math.max(0, S.pacnew.v-1); + if(S.pacnew.v===0) S.pacnew.st='ok'; + resDone(e,'done', p.safe ? file+' deleted — current config is authoritative' + : file+' merged in terminal · pacnew deleted'); } + render(); + }, 700); +} + +/* ---- failed units: per-unit roster remedies ---- */ +function funitAct(u, reset){ + const r = S.funits.find(x=>x.u===u); if(!r) return; + const e = resAdd(reset?'reset-failed':'unit restart', + (reset?'systemctl reset-failed ':'systemctl restart ')+u+' …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail', u+': start request repeated too quickly'); } + else { S.funits.splice(S.funits.indexOf(r),1); + S.failed.v = Math.max(0, S.failed.v-1); + if(S.failed.v===0){ S.failed.st='ok'; if(S.sysrun.st!=='ok') S.sysrun={v:'running', st:'ok'}; } + resDone(e,'done', u+(reset?' cleared from failed list':' restarted — running')); } + render(); + }, 800); +} + +/* ---- containers: expected-list curation + per-container START ---- */ +const CEXPECT = [ + {name:'winvm', date:'2026-02-27', shipped:true, disabled:false}, + {name:'deepsat-pg', date:'2026-06-20', shipped:false, disabled:false}, +]; +const cexp = name => CEXPECT.some(p=>!p.disabled && p.name===name); +function recalcCtrs(){ + const unexp = S.ctrs.filter(c=>!cexp(c.name)).length; + S.dstopped = {v:S.ctrs.length, st: unexp?'warn':'ok'}; +} +function ctrStartOne(name){ + const c = S.ctrs.find(x=>x.name===name); if(!c) return; + const e = resAdd('container start', 'docker start '+name+' …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail', name+' exits immediately (code 1) — see docker logs'); } + else { S.ctrs.splice(S.ctrs.indexOf(c),1); + resDone(e,'done', name+' running'); } + render(); + }, 800); +} +function ctrMark(name){ + if(armed!=='cexp:'+name){ armed='cexp:'+name; render(); + toast('mark expected: '+name+' stays stopped by design — press again', true); return; } + armed=null; + CEXPECT.push({name, date:today(), shipped:false, disabled:false}); + resAdd('mark expected', name+' — expected-stopped (on-demand)', 'done'); + render(); +} +function ctrUnmark(name){ + const p = CEXPECT.find(x=>!x.disabled && x.name===name); if(!p) return; + if(p.shipped) p.disabled=true; else CEXPECT.splice(CEXPECT.indexOf(p),1); + resAdd('unmark expected', name+' — back in signal', 'done'); + render(); +} + +/* ---- snapshots: stale manual (single) snapshot delete ---- */ +function snapDelStale(){ + if(armed!=='snapstale'){ armed='snapstale'; render(); + toast('delete '+(S.snaptypes.single-2)+' stale manual snapshots (keep newest 2) — press again', true); return; } + armed=null; + const n = S.snaptypes.single-2; + const e = resAdd('stale snapshots', 'snapper delete ('+n+' singles) …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail','snapper: config busy — snapshot in progress'); } + else { S.snaptypes.single=2; S.snaptypes.oldsingle='recent'; + S.snapper.v = S.snaptypes.timeline+S.snaptypes.single+S.snaptypes.prepost; + if(S.snapper.v<100) S.snapper.st='ok'; + resDone(e,'done', n+' manual snapshots deleted — space freed on next cleanup'); } + render(); + }, 900); +} + +/* kill process: arm-to-fire SIGTERM on a top-memory item; session-critical names protected */ +const PROTECTED = ['systemd','Hyprland','maintenance-panel']; +function killProc(name){ + const row = S.memtop.find(r=>r[0]===name); if(!row) return; + if(armed!=='kill:'+name){ armed='kill:'+name; render(); + toast('KILL '+name+' ('+row[1]+') — SIGTERM · press again', true); return; } + armed=null; + const e = resAdd('kill process', 'SIGTERM '+name+' ('+row[1]+') …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail',name+' ignored SIGTERM — state D (uninterruptible IO)'); } + else { + S.memtop.splice(S.memtop.indexOf(row),1); + const gb = parseFloat(row[1])||0; + S.mem.v = Math.min(125, Math.round((S.mem.v+gb)*10)/10); + if(S.mem.v>16 && S.mem.st!=='ok') S.mem={v:S.mem.v, st:'ok'}; + resDone(e,'done',name+' exited cleanly · '+row[1]+' freed'); + } + render(); + }, 700); +} + +/* cpu mode: free selector — set the active EPP mode to anything */ +const MODE_LABELS = {perf:'performance', bal:'balance_performance', power:'power'}; +function setMode(m){ + if(S.cpumode.v===m) return; + const e = resAdd('cpu mode', 'writing EPP hint: '+MODE_LABELS[m]+' …'); + setTimeout(()=>{ + if(failMode){ resDone(e,'fail','sysfs: energy_performance_preference write refused'); } + else { S.cpumode.v=m; resDone(e,'done','EPP → '+MODE_LABELS[m]+' (all cores)'); } + render(); + }, 500); +} + +let zfsMode = false; +function toggleZfs(){ + zfsMode = !zfsMode; + const k=$('k-zfs'); k.classList.toggle('on', zfsMode); + k.textContent = zfsMode ? 'ZFS ON' : 'SIM ZFS'; + Object.keys(ERR).forEach(k2=>{ if(ACT2CAT[k2]==='storage') delete ERR[k2]; }); + render(); + toast(zfsMode ? 'storage shows the velox/ZFS capability set' : 'storage shows the ratio/btrfs capability set'); +} +function reloadPanel(){ openPanel(); } + +/* ---- actions ---- */ +/* ---- results wall ---- */ +const RES = []; +let wallHidden = false; +const ANAMES = { clean_cache:'cache trim', scrub:'btrfs scrub', fstrim:'fstrim enable', + deeptrim:'deep trim', orphans:'orphan removal', pacnew:'pacnew merge', keyring:'keyring refresh', + timers:'timer enable', cores:'coredump clear', journald:'journal vacuum', applog:'app-log cleanup', + docker:'docker prune', snapper:'snapper cleanup', unit_restart:'unit restart', + f2b_fix:'fail2ban restart', cron_fix:'cronie start', ntp_fix:'chrony restart', + ts_fix:'tailscaled restart', stimer_fix:'snapshot timers', zram_fix:'zram enable', + netdoc:'net doctor', balance:'btrfs balance', reinstall:'pkg reinstall', rsyncnow:'backup run', + smarttest:'smart self-test', retention:'retention repair', reboot:'reboot', + sysupdate:'system update', topgrade:'topgrade', ztrim_fix:'zpool autotrim', + openjournal:'open journal', bat_limit:'charge limit', + ctr_start:'container start', fw_fix:'firewall enable' }; +function now(){ return new Date().toTimeString().slice(0,8); } +function today(){ const d=new Date(); + return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); } +function resAdd(name, ev, st){ + const e = {d:today(), t:now(), name, ev, st:st||'run'}; + RES.push(e); renderWall(); return e; +} +function resDone(e, st, ev){ e.st=st; e.ev=ev; e.d=today(); e.t=now(); renderWall(); } +function renderWall(){ + const w = $('rwall'); + if(!RES.length){ w.style.display='none'; return; } + w.style.display='block'; + $('rcount').textContent = '· '+RES.length; + $('rtoggle').textContent = wallHidden ? 'SHOW' : 'HIDE'; + const b = $('rbody'); + b.style.display = wallHidden ? 'none' : 'block'; + b.innerHTML = RES.map(e=>{ + const lamp = e.st==='run'?'busy':e.st==='fail'?'red':''; + return `<div class="rstep"><span class="tm"><span class="dt">${e.d}</span>${e.t}</span><span class="lamp ${lamp}"></span> + <span class="rtxt" title="${e.name} — ${e.ev}"><b>${e.name}</b> <span class="ev ${e.st==='fail'?'evfail':''}">${e.ev}</span></span></div>`; + }).join(''); + b.scrollTop = b.scrollHeight; +} +function toggleWall(){ wallHidden=!wallHidden; renderWall(); } +function copyWall(){ + const txt = RES.map(e=>`${e.d} ${e.t} ${e.st.toUpperCase().padEnd(4)} ${e.name} — ${e.ev}`).join('\n'); + (navigator.clipboard ? navigator.clipboard.writeText(txt) : Promise.reject()) + .then(()=>toast('copied '+RES.length+' result lines')) + .catch(()=>toast('clipboard unavailable', true)); +} + +function fire(act){ + const a = ACTIONS[act]; + // guard-armed updates: first press runs the guard check and arms; second press overrides + if(a.guardArm && armed!==act){ + armed = act; + const e = resAdd(ANAMES[act]||act, a.busy||'checking guard…'); + setTimeout(()=>{ + resDone(e,'fail','guard: mesa in update set — press again to run anyway, or apply from a TTY'); + render(); + }, 600); + render(); + return; + } + if(a.arm && armed!==act && !ERR[act]){ armed=act; render(); toast(a.armMsg||'press again to confirm', true); return; } + const overridden = a.guardArm && armed===act; + armed=null; + const e = resAdd(ANAMES[act]||act, (overridden && a.busy2) ? a.busy2 : (a.busy||'running…')); + setTimeout(()=>{ + if(failMode){ ERR[act]=a.err||'exited 1'; resDone(e,'fail',ERR[act]); } + else { delete ERR[act]; a.run(); resDone(e,'done',a.doneMsg); + if(a.guardArm && S.reboot.v==='YES') + resAdd('reboot offer','kernel/mesa updated — REBOOT key armed on the updates strip','done'); + } + render(); + }, a.ms||800); +} +const ACTIONS = { + clean_cache:{ busy:'paccache -r / -ruk0 …', ms:1000, doneMsg:'cache trim — reclaimed 8.4 GB', + err:'paccache: /var/cache/pacman/pkg — permission denied (polkit dismissed)', + run(){ S.cache={v:0.4, st:'ok'}; } }, + scrub:{ busy:'btrfs scrub started on / …', ms:1400, doneMsg:'scrub running — age resets on completion', + err:'btrfs: scrub already running on / — see dmesg', + run(){ S.scrub={v:0, st:'ok'}; } }, + fstrim:{ busy:'systemctl enable --now fstrim.timer …', ms:900, doneMsg:'fstrim.timer enabled — weekly TRIM active', + err:'systemctl: unit fstrim.timer is masked', + run(){ S.fstrim={v:'ON', st:'ok'}; if(S.timers.st==='warn') S.timers={v:'5/5', st:'ok'}; } }, + deeptrim:{ arm:true, armMsg:'keep only 1 version of every pkg — press again', busy:'paccache -rk1 …', ms:1100, + doneMsg:'deep trim — kept 1 version, reclaimed 3.1 GB more', + err:'paccache -rk1: exited 1 — cache dir busy (pacman lock held)', + run(){ S.deeptrim={v:'keep 1', st:'ok'}; if(S.cache.st==='ok') S.cache={v:0.2,st:'ok'}; } }, + orphans:{ arm:true, armMsg:'remove all unkept orphans — press again', busy:'pacman -Rns (unkept set) …', ms:1200, + doneMsg:'orphans removed — kept packages skipped', + err:'pacman -Rns: breaking dependency — rust required by cargo-audit', + run(){ S.orphlist = S.orphlist.filter(r=>okept(r[0])); S.orphans={v:0, st:'ok'}; } }, + pacnew:{ busy:'diffing .pacnew files …', ms:900, doneMsg:'.pacnew resolved — mirrorlist deleted, locale.gen merged', + err:'merge aborted — locale.gen.pacnew conflicts, needs manual merge', + run(){ S.pacnew={v:0, st:'ok'}; } }, + keyring:{ busy:'pacman -Sy archlinux-keyring …', ms:900, doneMsg:'keyring refreshed', + err:'pacman -Sy: mirror timeout — no network?', + run(){ S.keyring={v:0, st:'ok'}; } }, + timers:{ busy:'systemctl enable --now …', ms:800, doneMsg:'all 5 maintenance timers firing', + err:'systemctl: failed to enable — unit file not found', + run(){ S.timers={v:'5/5', st:'ok'}; if(S.fstrim.st==='fail') S.fstrim={v:'ON',st:'ok'}; } }, + cores:{ busy:'coredumpctl clean (keep 3d) …', ms:800, doneMsg:'coredumps cleared', + err:'coredumpctl: permission denied — /var/lib/systemd/coredump', + run(){ S.cores={v:0, st:'ok'}; S.coregroups=[]; } }, + journald:{ busy:'journalctl --vacuum-size=300M …', ms:800, doneMsg:'journal vacuumed — reclaimed 0.9 GB', + err:'journalctl: vacuum failed — rotation in progress, retry', + run(){ S.journald={v:0.3, st:'ok'}; } }, + applog:{ busy:'log-cleanup …', ms:600, doneMsg:'app logs — nothing older than 7d', + err:'log-cleanup: exited 1 — ~/.local/var/log not writable', + run(){ S.applog={v:'ok', st:'ok'}; } }, + docker:{ arm:true, armMsg:'docker prune tier 1+2 — press again', busy:'docker container/image prune …', ms:1200, + doneMsg:'docker pruned — reclaimed 2.8 GB', + err:'docker: cannot connect to daemon — is docker.service running?', + run(){ S.docker={v:0.2, st:'ok'}; S.ddf = S.ddf.map(r=>[r[0], r[1], '0 GB']); } }, + snapper:{ arm:true, armMsg:'prune stale manual snapshots — press again', busy:'snapper cleanup …', ms:900, + doneMsg:'snapper — retention applied', + err:'snapper: config busy — snapshot in progress', + run(){ S.snapper={v:Math.min(S.snapper.v,31), st:'ok'}; } }, + /* --- group A: service restarts / enables --- */ + unit_restart:{ busy:'systemctl restart (all failed units) …', ms:900, doneMsg:'units restarted — running', + err:'systemd-oomd: start request repeated too quickly', + run(){ S.funits=[]; S.failed={v:0, st:'ok'}; if(S.sysrun.st!=='ok') S.sysrun={v:'running', st:'ok'}; } }, + f2b_fix:{ busy:'systemctl restart fail2ban …', ms:800, doneMsg:'fail2ban running — sshd jail active', + err:'fail2ban: failed to start — bad jail config', + run(){ S.f2b={v:0, st:'ok'}; } }, + cron_fix:{ busy:'systemctl start cronie …', ms:700, doneMsg:'cronie running — entries live', + err:'cronie: unit masked', + run(){ S.cron={v:'ok', st:'ok'}; } }, + ntp_fix:{ busy:'systemctl restart chronyd + makestep …', ms:800, doneMsg:'clock stepped — synchronized', + err:'chronyd: no reachable sources', + run(){ S.ntp={v:'synced', st:'ok'}; } }, + ts_fix:{ busy:'systemctl restart tailscaled …', ms:900, doneMsg:'tailscaled up — peers reconnecting', + err:'tailscaled: bind: address already in use', + run(){ S.ts={v:'4/4', st:'ok'}; S.tsdown=[]; } }, + stimer_fix:{ busy:'systemctl enable --now snapper timers …', ms:700, doneMsg:'snapshot timers firing', + err:'systemctl: unit not found', + run(){ S.stimer={v:'active', st:'ok'}; } }, + zram_fix:{ busy:'re-applying zram-generator config …', ms:800, doneMsg:'zram active — 16 GiB zstd', + err:'zram-generator: module not loaded', + run(){ S.zram={v:'16 GiB', st:'ok'}; } }, + netdoc:{ busy:'opening NET·01 …', ms:400, doneMsg:'delegated to the net panel doctor (NET·01)', + err:'net panel not running', + run(){} }, + /* --- group B: deterministic maintenance ops --- */ + balance:{ arm:true, armMsg:'btrfs balance is IO-heavy and slow — press again', busy:'btrfs balance -dusage=50 …', ms:1500, + doneMsg:'balance complete — unallocated recovered', + err:'balance cancelled — ENOSPC during relocation', + run(){ S.unalloc={v:'118', st:'ok'}; } }, + reinstall:{ busy:'pacman -S (owning packages) …', ms:1100, doneMsg:'modified files restored — integrity clean', + err:'pacman: failed to commit transaction', + run(){ S.integ={v:'clean', st:'ok'}; } }, + rsyncnow:{ busy:'rsyncshot manual run …', ms:1400, doneMsg:'backup complete — synced to truenas', + err:'rsync exit 23 — partial transfer (permissions)', + run(){ S.rsync={v:'now', st:'ok'}; } }, + smarttest:{ busy:'smartctl -t short (both NVMe) …', ms:900, doneMsg:'short self-test started — results in ~2 min', + err:'smartctl: device busy', + run(){ S.smartlast='passed just now (short test)'; } }, + retention:{ busy:'writing sane retention (M2 Y0) + cleanup …', ms:1000, doneMsg:'retention repaired — old chain pruned', + err:'snapperd: config locked', + run(){ S.retention={v:'H6 D7 W2 M2 Y0', st:'ok'}; S.oldest={v:8, st:'ok'}; S.snapper={v:Math.min(S.snapper.v,42), st:'ok'}; } }, + /* --- group D: disruptive but determinate --- */ + reboot:{ arm:true, armMsg:'reboot the machine — press again', busy:'rebooting … (simulated)', ms:1200, + doneMsg:'rebooted — new kernel running, taint cleared', + err:'logind: inhibitor lock held (backup in progress)', + run(){ S.reboot={v:'NO', st:'ok'}; S.taint={v:0, st:'ok'}; if(S.sysrun.st!=='ok') S.sysrun={v:'running', st:'ok'}; } }, + ctr_start:{ busy:'docker start (all unexpected exited) …', ms:1000, + doneMsg:'containers started — running again', + err:'docker: container exits immediately (code 1) — see docker logs', + run(){ S.ctrs = S.ctrs.filter(c=>cexp(c.name)); } }, + fw_fix:{ busy:'ufw enable …', ms:800, + doneMsg:'firewall active — mosh rule loaded', + err:'ufw: iptables lock held — retry', + run(){ S.fw={v:'active', st:'ok'}; } }, + bat_limit:{ busy:'writing charge_control_end_threshold=80 …', ms:700, + doneMsg:'charge limit set — 80% (battery longevity)', + err:'sysfs: permission denied', + run(){ S.blimit={v:'80%', st:'ok'}; } }, + openjournal:{ busy:'spawning terminal: journalctl -p err -b …', ms:400, + doneMsg:'opened in terminal — journalctl -p err -b', + err:'no terminal available', + run(){} }, + /* --- zfs (velox capability set) --- */ + ztrim_fix:{ busy:'zpool set autotrim=on + zpool trim …', ms:1000, + doneMsg:'autotrim enabled — initial trim running', + err:'zpool: pool is busy (resilver in progress)', + run(){ S.ztrim={v:'ON', st:'ok'}; } }, + /* --- updates (Confirm; guard arms instead of blocking — user decides) --- */ + sysupdate:{ guardArm:true, busy:'checking update set against the live-update guard …', + busy2:'pacman -Syu … (guard overridden)', ms:1400, + doneMsg:'system updated — 47 packages · reboot recommended', + err:'pacman: failed to commit transaction (conflicting files)', + run(){ S.upd.p=0; S.upd.c=0; S.reboot={v:'YES', st:'warn'}; } }, + topgrade:{ guardArm:true, busy:'guard check, then topgrade (--disable git) …', + busy2:'topgrade full run … (guard overridden)', ms:1800, + doneMsg:'topgrade complete — system + ecosystems · reboot recommended', + err:'topgrade: system step failed — pacman lock held', + run(){ S.upd.p=0; S.upd.c=0; S.upd.a=0; S.tgage={v:0, st:'ok'}; S.reboot={v:'YES', st:'warn'}; } }, +}; + +/* ================= widget builders ================= */ +function lever(act, label, cls){ + const a = armed===act; + if(ERR[act]) return `<button class="key sm red" onclick="event.stopPropagation();fire('${act}')">RETRY</button>`; + return `<button class="key sm ${cls||''} ${a?'armed':''}" onclick="event.stopPropagation();fire('${act}')">${a?label+'?':label}</button>`; +} +function errline(act){ return act && ERR[act] ? `<div class="errline">✗ ${ERR[act]}</div>` : ''; } +function lampFor(id, act){ return (act && ERR[act]) ? 'red' : lampCls(S[id].st); } +function lrow(id, name, extra, valOverride, act){ + return `<div class="lrow"><span class="lamp ${lampFor(id,act)}"></span><span class="who">${name}</span> + <span class="what">${extra||''}${(act&&ERR[act])?'✗ failed':(valOverride!==undefined?valOverride:S[id].v)}</span></div>`; +} +function barCls(st){ return st==='ok'?'okc':'warn'; } +function cellBar(id,cap,unit,pct,o){ o=o||{}; const m=S[id]; + return `<div class="cell">${o.lever?`<span class="lever">${o.lever}</span>`:''} + <div class="cap"><span class="lamp ${lampFor(id,o.act)}"></span> ${cap}</div> + <div class="val"><span class="readout">${m.v}<small> ${unit||''}</small></span></div> + <div class="bar ${barCls(m.st)}">${o.tick?`<span class="tk" style="left:${o.tick}%"></span>`:''}<span style="width:${Math.min(100,pct)}%"></span></div> + <div class="sub2">${o.sub||''}</div>${errline(o.act)}</div>`; } +function cellCount(id,cap,o){ o=o||{}; const m=S[id]; + const col = m.st==='fail'?'style="color:var(--fail)"':(m.st==='warn'?'style="color:var(--amber)"':''); + return `<div class="cell">${o.lever?`<span class="lever">${o.lever}</span>`:''} + <div class="cap"><span class="lamp ${lampFor(id,o.act)}"></span> ${cap}</div> + <div class="val"><span class="readout" ${col}>${m.v}${o.unit?`<small>${o.unit}</small>`:''}</span>${o.badge||''}</div> + <div class="sub2">${o.sub||''}</div>${errline(o.act)}</div>`; } +function cellRing(id,cap,pct,small,o){ o=o||{}; const m=S[id]; + const rc = (o.act&&ERR[o.act])?'var(--fail)':m.st==='ok'?'var(--pass)':m.st==='fail'?'var(--fail)':'var(--amber)'; + return `<div class="cell">${o.lever?`<span class="lever">${o.lever}</span>`:''} + <div class="cap"><span class="lamp ${lampFor(id,o.act)}"></span> ${cap}</div> + <div class="center"><span class="ring" style="--p:${Math.min(100,pct)};--rc:${rc}"><b>${m.v}</b><small>${small}</small></span></div> + <div class="sub2" style="text-align:center">${o.sub||''}</div>${errline(o.act)}</div>`; } +function cellBadge(id,cap,o){ o=o||{}; const m=S[id]; + const cls = m.st==='ok'?'pass':m.st==='fail'?'red':m.st==='warn'?'':'ghost'; + return `<div class="cell">${o.lever?`<span class="lever">${o.lever}</span>`:''} + <div class="cap"><span class="lamp ${lampFor(id,o.act)}"></span> ${cap}</div> + <div class="val"><span class="badge ${cls}">${String(m.v).toUpperCase()}</span></div> + <div class="sub2">${o.sub||''}</div>${errline(o.act)}</div>`; } + +/* dynamic rosters */ +function timerRoster(){ + const st = S.timers.st; + const rows = [ + ['paccache.timer','weekly', true], + ['btrfs-scrub@-.timer','monthly', st!=='fail'], + ['fstrim.timer','weekly', S.fstrim.st==='ok'], + ['reflector.timer','weekly', st!=='fail'], + ['snapper-cleanup.timer','daily', true], + ]; + return rows.map(([n,c,ok])=>`<div class="lrow"><span class="lamp ${ok?'':'red'}"></span> + <span class="who">${n}</span><span class="what">${ok?c+' · ok':'disabled'}</span></div>`).join(''); +} +function backupRoster(){ + if(S.rsync.st==='ok') return ` + <div class="lrow"><span class="lamp"></span><span class="who">hourly</span><span class="what">3h ago · ok</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">daily 02:30</span><span class="what">11h ago · ok</span></div>`; + return ` + <div class="lrow"><span class="lamp red"></span><span class="who">hourly</span><span class="what">52h ago · stale</span></div> + <div class="lrow"><span class="lamp red"></span><span class="who">daily 02:30</span><span class="what">rsync exit 23 · failed</span></div>`; +} +function peerRoster(){ + const down = S.tsdown||[]; + return ['truenas','velox','worker','cjennings'].map(p=>{ + const d = down.includes(p); + return `<div class="lrow"><span class="lamp ${d?'red':''}"></span><span class="who">${p}</span> + <span class="what">${d?'offline':'online'}</span></div>`;}).join(''); +} + +/* ================= subpanel templates ================= */ +const SUB = { +storage(){ + if(zfsMode) return ` + <div class="wgrid"> + ${cellBar('zcap','zpool capacity','%',S.zcap.v,{lever:`<button class="key sm" onclick="event.stopPropagation();go('reclaim')">RECLAIM</button>`,sub:'CoW slows > 80 · best-fit > 90'})} + ${cellBar('cache','Pkg cache','GB',S.cache.v/10*100,{lever:lever('clean_cache','CLEAN'),act:'clean_cache',tick:100,sub:'Auto · thr ~10 GB'})} + ${cellRing('scrub','zpool scrub',S.scrub.v/60*100,'days',{lever:lever('scrub','SCRUB'),act:'scrub',sub:S.scrub.st==='ok'?'fresh':'warn 35d · crit 60d'})} + ${cellCount('ztrim','zpool autotrim',{lever:S.ztrim.st!=='ok'?lever('ztrim_fix','ENABLE',S.ztrim.st==='fail'?'red':''):'',act:'ztrim_fix',sub:'the fstrim counterpart on ZFS'})} + </div> + <div class="wgrid"> + ${cellBadge('smart','SMART',{lever:lever('smarttest','SELF-TEST'),act:'smarttest',sub:'NVMe'})} + ${cellRing('wear','NVMe wear',Math.max(2,S.wear.v),'%',{sub:S.wear.st==='ok'?'0%':'high wear — plan replacement'})} + ${cellBadge('zhealth','Pool health',{sub:'zpool status -x'})} + ${cellCount('zerr','Pool errors',{sub:'read / write / cksum — no reset lever; investigate'})} + </div> + <div class="engrave striph">filesystem<span class="cnt">· 3</span></div> + <div class="strip"> + ${lrow('zfrag','fragmentation', '', S.zfrag.v+'% — no defrag exists; keep < 80% full')} + ${lrow('integ','pacman file integrity', S.integ.st!=='ok'?lever('reinstall','REINSTALL')+' ':'', undefined, 'reinstall')} + ${lrow('deeptrim','deep-trim (keep 1)', lever('deeptrim','DEEP TRIM')+' ', undefined, 'deeptrim')} + </div>`; + return ` + <div class="wgrid"> + ${cellBar('disk','Disk usage','%',S.disk.v,{lever:`<button class="key sm" onclick="event.stopPropagation();go('reclaim')">RECLAIM</button>`,sub:'root · btrfs · thr 80/90'})} + ${cellBar('cache','Pkg cache','GB',S.cache.v/10*100,{lever:lever('clean_cache','CLEAN'),act:'clean_cache',tick:100,sub:'Auto · thr ~10 GB'})} + ${cellRing('scrub','btrfs scrub',S.scrub.v/55*100,'days',{lever:lever('scrub','SCRUB'),act:'scrub',sub:S.scrub.st==='ok'?'fresh':'past 30d cadence'})} + ${cellCount('fstrim','fstrim.timer',{lever:lever('fstrim','ENABLE',S.fstrim.st==='fail'?'red':''),act:'fstrim',sub:'Confirm · weekly TRIM'})} + </div> + <div class="wgrid"> + ${cellBadge('smart','SMART',{lever:lever('smarttest','SELF-TEST'),act:'smarttest',sub:'last test: '+S.smartlast})} + ${cellRing('wear','NVMe wear',Math.max(2,S.wear.v),'%',{sub:S.wear.st==='ok'?'0% both drives':'high wear — plan replacement'})} + ${cellCount('deverr','Device errors',{sub:S.devrows.length?'per-device below · reset stays manual':'read / write · RAID1 · watch only'})} + ${cellCount('unalloc','btrfs unallocated',{lever:S.unalloc.st!=='ok'?lever('balance','BALANCE'):'',act:'balance',unit:' GiB',sub:'chunk headroom — raw space not yet carved into chunks'})} + </div> + ${S.devrows.length?` + <div class="engrave striph">device errors — which drive<span class="cnt">· cross-check SMART</span></div> + <div class="jlist">${S.devrows.map(r=>`<div class="lrow jrow"><span class="lamp red"></span> + <span class="who"><b>${r[0]}</b></span><span class="what">${r[1]}</span></div>`).join('')}</div>`:''} + <div class="engrave striph">disk — top consumers<span class="cnt">· evidence only</span></div> + <div class="jlist">${S.dtop.map(r=>`<div class="lrow jrow"><span class="who">${r[0]}</span> + <span class="what">${r[1]}</span></div>`).join('')}</div> + <div class="engrave striph">filesystem<span class="cnt">· 2</span></div> + <div class="strip two"> + ${lrow('integ','pacman file integrity', S.integ.st!=='ok'?lever('reinstall','REINSTALL')+' ':'', undefined, 'reinstall')} + ${lrow('deeptrim','deep-trim (keep 1)', lever('deeptrim','DEEP TRIM')+' ', undefined, 'deeptrim')} + </div>`; }, + +packages(){ + const kept = OKEEP.map(k=>k.name); + const orows = S.orphlist.filter(r=>!kept.includes(r[0])).map(r=>{ + const a = armed==='orem:'+r[0]; + return `<div class="lrow jrow"><span class="lamp gold"></span> + <span class="who"><b>${r[0]}</b></span><span class="jmsg">${r[1]}</span> + <span class="what"> + <button class="key sm" onclick="event.stopPropagation();orphKeep('${r[0]}')">KEEP</button> + <button class="key sm ${a?'armed':''}" onclick="event.stopPropagation();orphRemove('${r[0]}')">${a?'REMOVE?':'REMOVE'}</button> + </span></div>`; + }).join(''); + const krows = OKEEP.map(k=>`<div class="lrow jrow known"><span class="lamp off"></span> + <span class="who">${k.name}</span><span class="what">kept ${k.date} + <button class="key sm" onclick="event.stopPropagation();orphUnkeep('${k.name}')">UNKEEP</button></span></div>`).join(''); + const prows = S.pacnews.map(p=>`<div class="lrow jrow"><span class="lamp ${p.safe?'':'gold'}"></span> + <span class="who"><b>${p.f.split('/').pop()}</b></span><span class="jmsg">${p.f} · ${p.why}</span> + <span class="what"><button class="key sm" onclick="event.stopPropagation();pacnewAct('${p.f}')">${p.safe?'DELETE':'MERGE'}</button></span></div>`).join(''); + const crows = S.upd.c ? S.cves.map(c=>`<div class="lrow jrow"> + <span class="lamp ${c[2]==='high'?'red':c[2]==='medium'?'gold':''}"></span> + <span class="who"><b>${c[0]}</b></span><span class="jmsg">${c[1]}</span> + <span class="what" style="color:${c[2]==='high'?'var(--fail)':'var(--dim)'}">${c[2]} · fix: UPDATE</span></div>`).join('') : ''; + return ` + <div class="wgrid"> + ${cellCount('orphans','Orphans',{lever:lever('orphans','REMOVE ALL'),act:'orphans',sub:'digest below · kept pkgs skipped'})} + ${cellCount('pacnew','.pacnew',{lever:lever('pacnew','REVIEW'),act:'pacnew',sub:'per-file below'})} + ${cellRing('keyring','Keyring age',S.keyring.v/30*100,'days',{lever:lever('keyring','REFRESH'),act:'keyring',sub:'refresh < 30d'})} + ${cellBadge('reboot','Reboot req.',{lever:S.reboot.v==='YES'?lever('reboot','REBOOT','red'):'',act:'reboot',sub:'kernel == modules'})} + </div> + <div class="wgrid"> + ${cellRing('tgage','Topgrade age',S.tgage.v/14*100,'days',{lever:lever('topgrade','TOPGRADE'),act:'topgrade',sub:'ecosystems · guard-aware'})} + </div> + ${deck('packages',[ + {id:'orphans', label:'ORPHANS', count:S.orphans.v, lamp:S.orphans.v?'gold':''}, + {id:'pacnew', label:'PACNEW', count:S.pacnews.length, lamp:S.pacnews.length?'gold':''}, + {id:'advis', label:'ADVISORIES', count:S.upd.c, lamp:S.upd.c?'red':''}, + ])} + ${SUBSEC.packages==='orphans'?` + <div class="engrave striph">orphans — top by size<span class="cnt">· KEEP = intentional (the rust lesson)</span></div> + <div class="jlist">${orows || '<div class="rvnote">no unkept orphans.</div>'}</div> + ${OKEEP.length?`<div class="evh" style="margin-top:8px">kept · ${OKEEP.length}</div><div class="jlist">${krows}</div>`:''}`:''} + ${SUBSEC.packages==='pacnew'?` + <div class="engrave striph">pacnew files<span class="cnt">· safe-delete vs needs-merge</span></div> + <div class="jlist">${prows || '<div class="rvnote">none pending ✓</div>'}</div>`:''} + ${SUBSEC.packages==='advis'?` + <div class="engrave striph">security advisories<span class="cnt">· arch-audit</span></div> + <div class="jlist">${crows || '<div class="rvnote">clean ✓</div>'}</div>`:''} + <div class="engrave striph">update surface<span class="cnt">· informational</span></div> + <div class="strip"> + <div class="lrow"><span class="lamp ${S.upd.c?'red':''}"></span><span class="who">arch-audit</span><span class="what">${S.upd.c?S.upd.c+' CVE — named above':'clean'}</span></div> + <div class="lrow"><span class="lamp gold"></span><span class="who">pending updates</span><span class="what">${S.upd.p}</span></div> + <div class="lrow"><span class="lamp ${S.upd.a>8?'gold':''}"></span><span class="who">AUR / foreign</span><span class="what">${S.upd.a?S.upd.a+' — '+S.aurnames:'fresh'}</span></div> + ${S.upd.f?`<div class="lrow"><span class="lamp gold"></span><span class="who">firmware</span><span class="what">${S.upd.f} — ${S.fwnames}</span></div>`:''} + </div>`; }, + +systemd(){ + const frows = S.funits.map(f=>`<div class="lrow jrow"><span class="lamp red"></span> + <span class="who"><b>${f.u}</b></span><span class="jmsg">since ${f.since} · ${f.info} · journalctl -u ${f.u}</span> + <span class="what"> + <button class="key sm" onclick="event.stopPropagation();funitAct('${f.u}')">RESTART</button> + <button class="key sm" onclick="event.stopPropagation();funitAct('${f.u}',true)">RESET</button> + </span></div>`).join(''); + return ` + <div class="wgrid"> + ${cellCount('failed','Failed units',{lever:S.failed.v>1?lever('unit_restart','RESTART ALL'):'',act:'unit_restart',sub:S.failed.v?'roster below · per-unit keys':'none failed'})} + ${cellCount('timers','Maint timers',{lever:lever('timers','ENABLE'),act:'timers',sub:'Confirm · enable missing'})} + ${cellBadge('sysrun','is-system-running',{sub:S.sysrun.st==='ok'?'whole-system verdict · watch':'degraded — '+S.failed.v+' failed unit'+(S.failed.v===1?'':'s')+' below'})} + ${cellCount('taint','Kernel taint',{sub:S.taint.st==='ok'?'0 = untainted · watch':'W = kernel warned once — clears on reboot'})} + </div> + ${S.funits.length?`<div class="engrave striph">failed units<span class="cnt">· ${S.funits.length}</span></div> + <div class="jlist">${frows}</div>`:''} + <div class="engrave striph">timer roster<span class="cnt">· 5</span></div> + <div class="strip">${timerRoster()}</div>`; }, + +logs(){ + const sig = jSignal(), kn = jKnown(); + const sigRows = sig.slice(0,10).map(g=>{ + const key = nkey(g); + const armedNow = armed==='mark:'+key; + return `<div class="lrow jrow"><span class="lamp gold"></span> + <span class="who"><b>${g.id}</b></span><span class="jmsg">${g.msg}</span> + <span class="what">×${g.n} · ${g.first}–${g.last}${g.unit?' · journalctl -u '+g.unit.replace('.service',''):''} + <button class="key sm ${armedNow?'armed':''}" onclick="event.stopPropagation();markKnown('${key}')">${armedNow?'MARK?':'MARK KNOWN'}</button></span></div>`; + }).join(''); + const knRows = kn.map(g=>{ + const key = nkey(g); const n = noiseFor(g); + return `<div class="lrow jrow known"><span class="lamp off"></span> + <span class="who">${g.id}</span><span class="jmsg">${g.msg}</span> + <span class="what">×${g.n} · ${n.shipped?'shipped default':'marked '+n.date} + <button class="key sm" onclick="event.stopPropagation();unmarkKnown('${key}')">UNMARK</button></span></div>`; + }).join(''); + const cgrows = S.coregroups.map(g=>`<div class="lrow jrow"><span class="lamp gold"></span> + <span class="who"><b>${g[0]}</b></span><span class="jmsg">coredumpctl info ${g[0]}</span> + <span class="what">×${g[1]} · last ${g[2]}</span></div>`).join(''); + const khrows = S.khwev.map(e=>`<div class="lrow jrow"><span class="who" style="color:var(--dim)">${e[0]}</span> + <span class="jmsg" style="color:var(--fail)">${e[1]}</span></div>`).join(''); + return ` + <div class="wgrid"> + ${cellCount('cores','Coredumps 7d',{lever:lever('cores','CLEAR'),act:'cores',sub:S.coregroups.length?'by binary below · keep 3d':'Auto · keep 3d'})} + ${cellBar('journald','journald','GB',S.journald.v/2*100,{lever:lever('journald','VACUUM'),act:'journald',sub:'Auto · cap 2 GB'})} + ${cellCount('jerr','Journal errors',{sub:'this boot · noise-filtered'})} + ${cellBadge('khw','Kernel / HW',{sub:S.khw.st==='ok'?'MCE · USB · I/O · thermal — watch':'events below — hardware, watch only'})} + </div> + ${deck('logs',[ + {id:'signal', label:'SIGNAL', count:S.jerr.v, lamp:S.jerr.st==='ok'?(S.jerr.v?'gold':''):'gold'}, + {id:'noise', label:'KNOWN NOISE', count:kn.length, lamp:'off'}, + {id:'cores', label:'COREDUMPS', count:S.cores.v, lamp:lampCls(S.cores.st)}, + {id:'hw', label:'KERNEL/HW', count:S.khwev.length||'', lamp:lampCls(S.khw.st)}, + ])} + ${SUBSEC.logs==='signal'?` + <div class="striph" style="display:flex;align-items:center;gap:10px"> + <span class="engrave">journal — signal<span class="cnt">· ${sig.length} group${sig.length===1?'':'s'} · ${S.jerr.v} entries</span></span> + ${lever('openjournal','OPEN JOURNAL')} + </div> + <div class="jlist">${sigRows || '<div class="rvnote">no unexplained errors this boot ✓</div>'}</div>`:''} + ${SUBSEC.logs==='noise'?` + <div class="striph" style="display:flex;align-items:center;gap:10px"> + <span class="engrave">known noise<span class="cnt">· ${kn.length} pattern${kn.length===1?'':'s'}</span></span> + <button class="key sm ${armed==='clearmarks'?'armed':''}" onclick="event.stopPropagation();clearMarks()">${armed==='clearmarks'?'CLEAR?':'CLEAR MARKS'}</button> + </div> + <div class="jlist">${knRows || '<div class="rvnote">no known-noise patterns match this boot.</div>'}</div>`:''} + ${SUBSEC.logs==='cores'?` + <div class="engrave striph">coredumps — by binary<span class="cnt">· ${S.cores.v} total · coredumpctl info</span></div> + <div class="jlist">${cgrows || '<div class="rvnote">none ✓</div>'}</div> + <div class="evh" style="margin-top:8px">rotation</div> + <div class="jlist">${lrow('applog','app-log cleanup', lever('applog','RUN')+' ', S.applog.v==='ok'?'7d rotation':'stale >7d', 'applog')}</div>`:''} + ${SUBSEC.logs==='hw'?` + <div class="engrave striph">kernel / hw events<span class="cnt">· hardware — watch only</span></div> + <div class="jlist">${khrows || '<div class="rvnote">clean — no MCE, I/O, USB, or thermal events ✓</div>'}</div>`:''}`; }, + +power(){ + const [tc,tg] = String(S.temps.v).split('/'); + const trc = S.temps.st==='ok'?'var(--pass)':S.temps.st==='fail'?'var(--fail)':'var(--amber)'; + const seg = ['perf','bal','power'].map(m=> + `<button class="${S.cpumode.v===m?'on':''}" onclick="event.stopPropagation();setMode('${m}')">${m.toUpperCase()}</button>`).join(''); + const modeCell = ` + <div class="cell"><div class="cap"><span class="lamp ${lampCls(S.cpumode.st)}"></span> CPU mode</div> + <div class="val"><span class="seg">${seg}</span></div> + <div class="sub2">EPP hint · ${zfsMode?'intel_pstate':'amd-pstate'} active</div></div>`; + const veloxCells = zfsMode ? ` + ${cellRing('batt','Battery health',S.batt.v,'%',{sub:S.batt.st==='ok'?'of design · 187 cycles · hardware — watch only':'degraded · plan replacement — watch only'})} + ${cellCount('blimit','Charge limit',{lever:S.blimit.st!=='ok'?lever('bat_limit','SET 80%'):'',act:'bat_limit',sub:'longevity cap · sysfs threshold'})}` : ''; + const evRows = (rows,kind) => rows.length + ? rows.map(r=>{ + if(kind==='boot') return `<div class="lrow jrow"><span class="lamp ${r[1]==='clean'?'':'red'}"></span><span class="who">${r[0]}</span><span class="what">${r[1]}</span></div>`; + if(kind==='ev') return `<div class="lrow jrow"><span class="who" style="color:var(--dim)">${r[0]}</span><span class="jmsg" style="color:var(--fail)">${r[1]}</span></div>`; + // top-memory rows: KILL (arm-to-fire) — disabled on session-critical names + const prot = PROTECTED.includes(r[0]); + const armedNow = armed==='kill:'+r[0]; + const key = prot + ? `<button class="key sm dis" title="session-critical — protected">KILL</button>` + : `<button class="key sm ${armedNow?'armed':''}" onclick="event.stopPropagation();killProc('${r[0]}')">${armedNow?'KILL?':'KILL'}</button>`; + return `<div class="lrow jrow"><span class="who">${r[0]}</span><span class="what">${r[1]} ${key}</span></div>`; + }).join('') + : `<div class="lrow jrow"><span class="what" style="margin-left:0">none this boot ✓</span></div>`; + return ` + <div class="wgrid"> + <div class="cell"><div class="cap"><span class="lamp ${lampCls(S.temps.st)}"></span> Temperatures</div> + <div class="gpair"> + <div class="g"><span class="ring" style="--p:${tc};--rc:${trc}"><b>${tc}°</b></span><span class="lb">CPU</span></div> + <div class="g"><span class="ring" style="--p:${tg};--rc:${trc}"><b>${tg}°</b></span><span class="lb">NVME</span></div> + </div> + <div class="sub2" style="text-align:center">hardware — watch only</div></div> + ${cellBar('mem','Memory free','GB',S.mem.v/125*100,{sub:'of 125 GB · evidence below'})} + ${cellBar('unclean','Unclean boots','%',S.unclean.v,{sub:'evidence below — watch only'})} + ${cellCount('zram','Swap / zram',{lever:S.zram.st!=='ok'?lever('zram_fix','ENABLE'):'',act:'zram_fix',sub:S.zram.st==='ok'?'zstd · active':'no swap configured'})} + </div> + <div class="wgrid"> + ${modeCell} + ${veloxCells} + ${!zfsMode?`<div class="cell"><div class="cap"><span class="lamp off"></span> Battery</div> + <div class="val"><span class="readout" style="color:var(--dim)">n/a</span></div> + <div class="sub2">desktop — no battery</div></div>`:''} + ${cellBadge('throttle','Thermal throttle',{sub:S.throttle.st==='ok'?'no events · hardware — watch only':'active — cooling issue · watch only'})} + </div> + <div class="strip striph" style="align-items:center"> + <span class="engrave">top memory</span> + <span class="engrave" style="grid-column:span 2">evidence — watch only</span> + </div> + <div class="strip"> + <div><div class="evh">kill: sigterm · arm-to-fire</div><div class="jlist">${evRows(S.memtop,'mem')}</div></div> + <div><div class="evh">recent boots</div><div class="jlist">${evRows(S.boots,'boot')}</div></div> + <div><div class="evh">throttle / oom</div><div class="jlist">${evRows(S.pevents,'ev')}</div></div> + </div>`; }, + +services(){ + const unexp = S.ctrs.filter(c=>!cexp(c.name)); + const expd = S.ctrs.filter(c=>cexp(c.name)); + const urows = unexp.map(c=>{ + const aM = armed==='cexp:'+c.name; + return `<div class="lrow jrow"><span class="lamp gold"></span> + <span class="who"><b>${c.name}</b></span><span class="jmsg">exited ${c.when} · code ${c.code}${c.code?' · docker logs '+c.name:''}</span> + <span class="what"> + <button class="key sm ${aM?'armed':''}" onclick="event.stopPropagation();ctrMark('${c.name}')">${aM?'MARK?':'MARK EXPECTED'}</button> + <button class="key sm" onclick="event.stopPropagation();ctrStartOne('${c.name}')">START</button> + </span></div>`; + }).join(''); + const erows = expd.map(c=>{ + const p = CEXPECT.find(x=>!x.disabled&&x.name===c.name)||{}; + return `<div class="lrow jrow known"><span class="lamp off"></span> + <span class="who">${c.name}</span><span class="jmsg">exited ${c.when}</span> + <span class="what">${p.shipped?'shipped default':'marked '+(p.date||'')} + <button class="key sm" onclick="event.stopPropagation();ctrUnmark('${c.name}')">UNMARK</button></span></div>`; + }).join(''); + const dfrows = S.ddf.map(r=>`<div class="lrow jrow"><span class="who">${r[0]}</span> + <span class="what">${r[1]} · reclaimable ${r[2]}</span></div>`).join(''); + const cronRows = S.cron.st==='ok' ? ` + <div class="lrow"><span class="lamp"></span><span class="who">log-cleanup (12:00)</span><span class="what">present</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">rsyncshot hourly</span><span class="what">present</span></div> + <div class="lrow"><span class="lamp"></span><span class="who">rsyncshot daily 02:30</span><span class="what">present</span></div>` : ` + <div class="lrow"><span class="lamp red"></span><span class="who">log-cleanup (12:00)</span><span class="what">not running — cronie down</span></div> + <div class="lrow"><span class="lamp red"></span><span class="who">rsyncshot hourly</span><span class="what">not running — cronie down</span></div> + <div class="lrow"><span class="lamp red"></span><span class="who">rsyncshot daily 02:30</span><span class="what">not running — cronie down</span></div>`; + return ` + <div class="wgrid"> + ${cellCount('docker','Docker reclaim',{lever:lever('docker','PRUNE'),act:'docker',unit:' GB',sub:'breakdown below · tiers 1+2'})} + ${cellCount('rsync','Last backup',{lever:lever('rsyncnow','RUN NOW'),act:'rsyncnow',unit:' ago',sub:'rsyncshot → truenas'})} + ${cellCount('dstopped','Stopped containers',{lever:unexp.length>1?lever('ctr_start','START ALL'):'',act:'ctr_start',sub:unexp.length?unexp.length+' unexpected · '+expd.length+' expected':'all expected'})} + ${cellBadge('cron','cron',{lever:S.cron.st!=='ok'?lever('cron_fix','START'):'',act:'cron_fix',sub:'entry roster below'})} + </div> + ${deck('services',[ + {id:'containers', label:'CONTAINERS', count:unexp.length?unexp.length+'!':expd.length, lamp:unexp.length?'gold':''}, + {id:'docker', label:'DOCKER DISK', count:S.docker.v+' GB', lamp:lampCls(S.docker.st)}, + {id:'cron', label:'CRON & BACKUPS', count:'', lamp:(S.cron.st!=='ok'||S.rsync.st!=='ok')?'red':''}, + ])} + ${SUBSEC.services==='containers'?` + ${unexp.length?`<div class="engrave striph">containers — signal<span class="cnt">· ${unexp.length} unexpected</span></div> + <div class="jlist">${urows}</div>`:''} + <div class="engrave striph">containers — expected<span class="cnt">· ${expd.length}</span></div> + <div class="jlist">${erows || '<div class="rvnote">no expected-stopped containers present.</div>'}</div>`:''} + ${SUBSEC.services==='docker'?` + <div class="engrave striph">docker disk<span class="cnt">· system df · reclaimable by type</span></div> + <div class="jlist">${dfrows}</div> + <div class="evh" style="margin-top:8px">virt</div> + <div class="jlist">${lrow('libvirt','libvirt VMs','', 'shut off · expected')}</div>`:''} + ${SUBSEC.services==='cron'?` + <div class="engrave striph">cron entries — expected<span class="cnt">· 3</span></div> + <div class="jlist">${cronRows}</div> + <div class="evh" style="margin-top:8px">backups</div> + <div class="jlist">${backupRoster()}</div>`:''}`; }, + +snapshots(){ + const t = S.snaptypes; + const staleN = Math.max(0, t.single-2); + const aS = armed==='snapstale'; + return ` + <div class="wgrid"> + ${cellCount('snapper','Snapper count',{lever:lever('snapper','PRUNE'),act:'snapper',sub:S.snapper.st==='ok'?'by type below':'pile-up — see singles below'})} + ${cellBadge('stimer','Auto-timer',{lever:S.stimer.st!=='ok'?lever('stimer_fix','ENABLE'):'',act:'stimer_fix',sub:'timeline + cleanup'})} + ${cellCount('oldest','Oldest',{unit:' days',sub:S.oldest.st==='ok'?'within budget':'chain hoards space'})} + <div class="cell">${S.retention.st!=='ok'?`<span class="lever">${lever('retention','FIX')}</span>`:''} + <div class="cap"><span class="lamp ${lampFor('retention','retention')}"></span> Retention</div> + <div class="val" style="font-size:12px;color:${S.retention.st==='ok'?'var(--cream)':'var(--amber)'}">${S.retention.v}</div> + <div class="sub2">${S.retention.st==='ok'?'monthly=2 (the /home lesson)':'monthly=10 — silently hoards space'}</div>${errline('retention')}</div> + </div> + <div class="engrave striph">by type<span class="cnt">· singles escape timeline cleanup</span></div> + <div class="jlist"> + <div class="lrow jrow"><span class="lamp"></span><span class="who">timeline</span><span class="what">${t.timeline} · auto-pruned</span></div> + <div class="lrow jrow"><span class="lamp ${staleN>3?'gold':''}"></span><span class="who"><b>single (manual)</b></span> + <span class="jmsg">oldest ${t.oldsingle}</span> + <span class="what">${t.single}${staleN>3?` <button class="key sm ${aS?'armed':''}" onclick="event.stopPropagation();snapDelStale()">${aS?'DELETE?':'DELETE STALE'}</button>`:''}</span></div> + <div class="lrow jrow"><span class="lamp"></span><span class="who">pre / post</span><span class="what">${t.prepost} · paired with pacman ops</span></div> + </div>`; }, + +network(){ + const n = parseInt(S.ts.v)||0; + const bars = [1,2,3,4].map(i=>`<i class="${i<=n?'on':''}"></i>`).join(''); + const sig = lsnSignal(); + const exp = LEXPECT.filter(p=>!p.disabled); + const pubN = sig.filter(r=>isPub(r.bind)).length; + const sigRows = sig.map(r=>{ + const key = lkey(r); + const aFix = armed==='lsnfix:'+key, aMark = armed==='lexp:'+key; + const bindTag = isPub(r.bind) + ? `<span class="badge red" style="font-size:.55rem">PUBLIC</span>` + : `<span style="color:var(--dim);font-size:10px">loopback</span>`; + return `<div class="lrow jrow"><span class="lamp ${isPub(r.bind)?'red':'gold'}"></span> + <span class="who"><b>${r.proc}</b></span> + <span class="jmsg">:${r.port} · ${r.bind}${r.unit?' · '+r.unit+'.service':''}</span> + <span class="what">${bindTag} + <button class="key sm ${aMark?'armed':''}" onclick="event.stopPropagation();markExpected('${key}')">${aMark?'MARK?':'MARK EXPECTED'}</button> + <button class="key sm ${aFix?'armed':''}" onclick="event.stopPropagation();lsnFix('${key}')">${aFix?(r.unit?'STOP?':'KILL?'):(r.unit?'STOP':'KILL')}</button> + </span></div>`; + }).join(''); + const expRows = exp.map(p=>{ + const key = p.proc+'|'+p.port; + return `<div class="lrow jrow known"><span class="lamp off"></span> + <span class="who">${p.proc}</span><span class="jmsg">:${p.port}</span> + <span class="what">${p.shipped?'shipped default':'marked '+p.date} + <button class="key sm" onclick="event.stopPropagation();unmarkExpected('${key}')">UNMARK</button></span></div>`; + }).join(''); + const fwNote = S.fw.st!=='ok' && pubN + ? ` <span style="color:var(--fail);letter-spacing:0;text-transform:none">· ufw down — ${pubN} public bind${pubN===1?'':'s'} exposed</span>` : ''; + return ` + <div class="wgrid"> + ${cellBadge('fw','Firewall',{lever:S.fw.st!=='ok'?lever('fw_fix','ENABLE',S.fw.st==='fail'?'red':''):'',act:'fw_fix',sub:'ufw · mosh rule'})} + ${cellCount('listen','Unexpected listeners',{sub:S.listen.st==='ok'?'all matched · '+exp.length+' expected':S.listen.v+' unmatched · '+pubN+' public-bind'})} + <div class="cell">${S.ts.st!=='ok'?`<span class="lever">${lever('ts_fix','RESTART')}</span>`:''} + <div class="cap"><span class="lamp ${lampFor('ts','ts_fix')}"></span> Tailscale</div> + <div class="val"><span class="ladder ${S.ts.st==='fail'?'bad':''}">${bars}</span> + <span class="readout" style="font-size:15px">${S.ts.v}</span></div> + <div class="sub2">peers online</div>${errline('ts_fix')}</div> + ${cellCount('f2b','fail2ban',{lever:S.f2b.st!=='ok'?lever('f2b_fix','RESTART'):'',act:'f2b_fix',sub:S.f2b.st==='ok'?'sshd jail · '+S.f2b.v+' bans 7d':'service down'})} + </div> + <div class="striph" style="display:flex;align-items:center;gap:10px"> + <span class="engrave">listeners — signal<span class="cnt">· ${sig.length} unexpected</span>${fwNote}</span> + </div> + <div class="jlist">${sigRows || '<div class="rvnote">every listener matches the expected list.</div>'}</div> + <div class="striph" style="display:flex;align-items:center;gap:10px"> + <span class="engrave">expected<span class="cnt">· ${exp.length} pattern${exp.length===1?'':'s'}</span></span> + <button class="key sm ${armed==='clearlsn'?'armed':''}" onclick="event.stopPropagation();clearLsn()">${armed==='clearlsn'?'CLEAR?':'CLEAR MARKS'}</button> + </div> + <div class="jlist">${expRows}</div> + <div class="engrave striph">peers<span class="cnt">· 4</span></div> + <div class="strip">${peerRoster()}</div> + <div class="engrave striph">resolution<span class="cnt">· 2</span></div> + <div class="strip two"> + ${lrow('ntp','NTP (chrony)', S.ntp.st!=='ok'?lever('ntp_fix','RESTART')+' ':'', S.ntp.st==='ok'?'synced · +2 ms':undefined, 'ntp_fix')} + ${lrow('dns','DNS / NetworkManager', S.dns.st!=='ok'?lever('netdoc','NET DOCTOR')+' ':'', undefined, 'netdoc')} + </div>`; }, +}; + +/* ---- doctor: streams into the results wall; the metrics subpanel stays put ---- */ +let doctorRunning = false; +function startDoctor(mode){ + if(doctorRunning){ toast('doctor already running'); return; } + const title = mode==='reclaim' ? 'reclaim space' : 'clean up'; + const steps = [ + {name:'cache trim', act:'clean_cache', + res:()=> S.cache.st==='warn' ? (S.cache={v:0.4,st:'ok'}, 'reclaimed 8.4 GB') : 'nothing to reclaim'}, + {name:'journal vacuum', act:'journald', + res:()=> S.journald.v>0.5 ? (S.journald={v:0.3,st:'ok'}, 'reclaimed 0.9 GB') : 'already tight'}, + {name:'coredump clear', act:'cores', + res:()=> S.cores.v>0 ? (()=>{const n=S.cores.v; S.cores={v:0,st:'ok'}; return n+' cleared';})() : 'none to clear'}, + {name:'app-log cleanup', act:'applog', + res:()=> (S.applog={v:'ok',st:'ok'}, 'nothing older than 7d')}, + ]; + if(mode==='reclaim'){ + steps.push( + {name:'docker prune (tier 1)', act:'docker', + res:()=> S.docker.st==='warn' ? (S.docker={v:0.2,st:'ok'}, 'reclaimed 2.8 GB') : 'nothing to prune'}, + {name:'snapper cleanup', act:'snapper', + res:()=> (S.snapper={v:Math.min(S.snapper.v,31),st:'ok'}, 'retention applied')}, + {name:'disk re-read', act:'applog', + res:()=> { S.disk={v:Math.max(60,S.disk.v-11), st:S.disk.v-11>90?'fail':S.disk.v-11>80?'warn':'ok'}; return 'now '+S.disk.v+'%'; }}, + ); + } + doctorRunning = true; + resAdd('doctor · '+title, 'started · '+steps.length+' actions', 'done'); + render(); + let i=0, entry=null, failed=0; + function step(){ + if(entry){ + const p = steps[i-1]; + if(failMode){ ERR[p.act]=ACTIONS[p.act].err; failed++; resDone(entry,'fail',ERR[p.act]); } + else { delete ERR[p.act]; resDone(entry,'done',p.res()); } + render(); + } + if(i<steps.length){ + entry = resAdd(steps[i].name, 'running…'); i++; + doctorTimer = setTimeout(step, 1000); + } else { + doctorRunning = false; + resAdd('doctor · '+title, + `${steps.length-failed} done${failed?' · '+failed+' failed':''}`, + failed?'fail':'done'); + render(); + } + } + step(); +} + +/* ---- review & fix ---- */ +const REVIEW = [ + {act:'fstrim', who:'fstrim.timer', why:'weekly SSD TRIM disabled', id:'fstrim', ok:()=>zfsMode||S.fstrim.st==='ok'}, + {act:'ztrim_fix', who:'zpool autotrim', why:'TRIM disabled on the pool', id:'ztrim', ok:()=>!zfsMode||S.ztrim.st==='ok'}, + {act:'scrub', who:'scrub', why:'past the scrub cadence', id:'scrub', ok:()=>S.scrub.st==='ok'}, + {act:'orphans', who:'orphans', why:'unowned packages present', id:'orphans', ok:()=>S.orphans.st==='ok'}, + {act:'pacnew', who:'.pacnew files', why:'configs awaiting merge', id:'pacnew', ok:()=>S.pacnew.st==='ok'}, + {act:'timers', who:'maint timers', why:'timers not firing', id:'timers', ok:()=>S.timers.st==='ok'}, + {act:'keyring', who:'keyring', why:'refresh before next update', id:'keyring', ok:()=>S.keyring.st==='ok'}, + {act:'docker', who:'docker reclaim', why:'space reclaimable (tiers 1+2)', id:'docker', ok:()=>S.docker.st==='ok'}, + {act:'snapper', who:'snapper', why:'retention pass', id:'snapper', ok:()=>S.snapper.st==='ok'&&S.snapper.v<42}, + {act:'deeptrim',who:'deep-trim', why:'keep 1 version — max reclaim, less downgrade headroom', id:'deeptrim', ok:()=>S.deeptrim.v==='keep 1'}, + {act:'unit_restart', who:'failed units', why:'restart the failed unit(s)', id:'failed', ok:()=>S.failed.st==='ok'}, + {act:'tgage', who:'topgrade', why:'ecosystem updates stale', id:'tgage', ok:()=>S.tgage.st==='ok', useAct:'topgrade'}, + {act:'f2b_fix', who:'fail2ban', why:'service down', id:'f2b', ok:()=>S.f2b.st==='ok'}, + {act:'cron_fix',who:'cronie', why:'service down', id:'cron', ok:()=>S.cron.st==='ok'}, + {act:'ntp_fix', who:'NTP (chrony)', why:'clock unsynchronized', id:'ntp', ok:()=>S.ntp.st==='ok'}, + {act:'ts_fix', who:'tailscaled', why:'peers unreachable', id:'ts', ok:()=>S.ts.st==='ok'}, + {act:'stimer_fix', who:'snapshot timer', why:'auto-snapshots not firing', id:'stimer', ok:()=>S.stimer.st==='ok'}, + {act:'zram_fix',who:'zram', why:'no swap configured', id:'zram', ok:()=>S.zram.st==='ok'}, + {act:'balance', who:'btrfs balance', why:'unallocated headroom low', id:'unalloc', ok:()=>zfsMode||S.unalloc.st==='ok'}, + {act:'reinstall', who:'file integrity', why:'modified package files', id:'integ', ok:()=>S.integ.st==='ok'}, + {act:'retention', who:'snapshot retention', why:'monthly limit hoards space', id:'retention', ok:()=>S.retention.st==='ok'}, + {act:'rsyncnow', who:'backup', why:'stale or failed — run now', id:'rsync', ok:()=>S.rsync.st==='ok'}, + {act:'reboot', who:'reboot', why:'running kernel != installed', id:'reboot', ok:()=>S.reboot.v!=='YES'}, + {act:'bat_limit', who:'charge limit', why:'no charge cap — battery longevity', id:'blimit', ok:()=>!zfsMode||S.blimit.st==='ok'}, + {act:'ctr_start', who:'stopped containers', why:'unexpected exited containers', id:'dstopped', ok:()=>S.dstopped.st==='ok'}, + {act:'fw_fix', who:'firewall', why:'ufw inactive — host exposed', id:'fw', ok:()=>S.fw.st==='ok'}, +]; +function renderReview(){ + const items = REVIEW.filter(r=>!r.ok()||ERR[r.useAct||r.act]); + const rows = items.map(r=>{ + const act = r.useAct||r.act; + const done = r.ok(); + const failed = !!ERR[act]; + return `<div class="rvrow"> + <span class="lamp ${failed?'red':done?'':lampCls(S[r.id].st)}"></span> + <span class="who">${r.who}</span> + <span class="why ${failed?'errtx':''}">${failed?'✗ '+ERR[act]:r.why}</span> + <span class="keys">${done?'<span class="res">done ✓</span>':lever(act,'FIX')}</span></div>`; + }).join(''); + return ` + <div class="subhead"><span class="lamp gold"></span><span class="nm">Doctor · Review & fix</span> + <span class="cnt">${items.length} Confirm items</span></div> + <div class="rvlist">${rows||'<div class="rvnote">nothing needs review — all Confirm items clear.</div>'}</div> + <div class="rvnote">Destructive fixes arm on first press, fire on the second. Metrics without a determinate + lever — failed units, unclean boots, updates, CVEs — stay on their subpanels as read-only telemetry.</div> + <div class="ofoot"><button class="key" onclick="go('cat:storage')">DONE</button></div>`; +} + +/* ================= loading / hydration ================= */ +/* tiers mirror real probe cost: t0 = local file/daemon reads, t1 = process probes + (smartctl, docker df, snapper, du), t2 = package scans (cached in real build) */ +const TIERS = [ + {ms:220, cats:['systemd','power','network','snapshots']}, + {ms:650, cats:['storage','logs','services']}, + {ms:1150, cats:['packages']}, +]; +function startLoad(){ + loadTimers.forEach(clearTimeout); loadTimers=[]; + loaded.clear(); + $('lastscan').textContent = 'scanning…'; + render(); + TIERS.forEach(t=>{ + loadTimers.push(setTimeout(()=>{ + t.cats.forEach(c=>loaded.add(c)); + if(loaded.size===CATS.length) $('lastscan').textContent='last scan just now · 44 checks · 1.2s'; + render(); + }, t.ms)); + }); +} + +/* ================= render ================= */ +const ACT2CAT = { clean_cache:'storage', scrub:'storage', fstrim:'storage', deeptrim:'storage', + balance:'storage', reinstall:'storage', smarttest:'storage', ztrim_fix:'storage', + orphans:'packages', pacnew:'packages', keyring:'packages', reboot:'packages', topgrade:'packages', + timers:'systemd', unit_restart:'systemd', + cores:'logs', journald:'logs', applog:'logs', openjournal:'logs', + zram_fix:'power', bat_limit:'power', + docker:'services', cron_fix:'services', rsyncnow:'services', ctr_start:'services', fw_fix:'network', + snapper:'snapshots', stimer_fix:'snapshots', retention:'snapshots', + f2b_fix:'network', ntp_fix:'network', ts_fix:'network', netdoc:'network' }; +function catErr(cid){ return Object.keys(ERR).some(a=>ACT2CAT[a]===cid); } + +function renderSelector(){ + $('selector').innerHTML = CATS.map(c=>{ + const sel = view==='cat:'+c.id; + if(!loaded.has(c.id)){ + return `<div class="selbtn loading ${sel?'sel':''}" onclick="go('cat:${c.id}')"> + <span class="lamp busy"></span><span class="nm">${c.name}</span><span class="ct">···</span></div>`; + } + const k = counts(c); + const err = catErr(c.id); + const ct = err ? `<span class="a crit">✗ fail</span> · <span class="k">${k.ok}✓</span>` + : k.attn ? `<span class="a ${k.crit?'crit':''}">${k.attn}!</span> · <span class="k">${k.ok}✓</span>` + : `<span class="k">${k.ok} ✓</span>`; + return `<div class="selbtn ${(k.crit||err)?'crit':k.attn?'attn':'clear'} ${sel?'sel':''}" onclick="go('cat:${c.id}')"> + <span class="lamp ${(k.crit||err)?'red':k.attn?'gold':''}"></span> + <span class="nm">${c.name}</span><span class="ct">${ct}</span></div>`; + }).join(''); +} +function renderSub(){ + const el = $('sub'); + if(view==='review'){ el.innerHTML = renderReview(); return; } + const cid = view.slice(4); + if(!loaded.has(cid)){ + const cat = CATS.find(c=>c.id===cid); + el.innerHTML = `<div class="subhead"><span class="lamp busy"></span><span class="nm">${cat.name}</span> + <span class="cnt">gathering…</span></div> + <div class="skel"><span class="lamp busy" style="width:14px;height:14px"></span>probing</div>`; + return; + } + const cat = CATS.find(c=>c.id===cid); + const k = counts(cat); + const [nfix,nwatch] = fixwatch(cid); + const cnt = (k.attn ? `<span class="att ${k.crit?'crit':''}">${k.attn} attention</span> · <span class="okc">${k.ok} ok</span>` + : `<span class="okc">all clear · ${k.ok} ✓</span>`) + + ` · ${nfix} fixable · ${nwatch} watch`; + el.innerHTML = `<div class="subhead"> + <span class="lamp ${k.crit?'red':k.attn?'gold':''}"></span> + <span class="nm">${cat.name}</span><span class="cnt">${cnt}</span></div>` + SUB[cid](); +} +function render(){ + recalcJerr(); recalcListen(); recalcCtrs(); + renderSelector(); renderSub(); + // faceplate verdict = worst diagnostic state + let worst='ok'; + DIAG.forEach(d=>{ const st=S[d].st; + if(st==='fail') worst='fail'; else if(st==='warn'&&worst!=='fail') worst='warn'; }); + const fl=$('facelamp'); + fl.className = 'lamp ' + (loaded.size<CATS.length?'busy':worst==='fail'?'red':worst==='warn'?'gold':''); + // badges + let attn=0; CATS.forEach(c=>{ if(loaded.has(c.id)){ attn+=counts(c).attn; } }); + $('attbadge').textContent = loaded.size<CATS.length ? 'SCANNING' : (attn ? attn+' ATTN' : 'ALL CLEAR'); + const errN = Object.keys(ERR).length; + $('errbadge').style.display = errN ? '' : 'none'; + $('errbadge').textContent = errN + ' FAILED'; + // updates strip: green when nothing, amber for a few, red for CVEs / a lot + const u = S.upd; + const ucls = (u.c>0 || u.p>100) ? 'crit' : (u.p>0 || u.a>0 || u.f>0) ? 'warn' : 'ok'; + $('updbar').className = 'updbar ' + ucls; + // CVE badge: red and visible only when there are CVEs to show + $('cvebadge').style.display = S.upd.c ? '' : 'none'; + $('cvebadge').textContent = S.upd.c + ' CVE'; + $('upd-p').textContent = S.upd.p; $('upd-c').textContent = S.upd.c + ' CVE'; + $('upd-c').classList.toggle('cve', S.upd.c>0); + $('upd-c').style.color = S.upd.c ? '' : 'var(--dim)'; + $('upd-a').textContent = S.upd.a; $('upd-f').textContent = S.upd.f; + $('k-clean').classList.toggle('on', doctorRunning); + $('k-review').classList.toggle('on', view==='review'); + // guard-armed update keys: red + '?' while awaiting the override press + $('k-update').classList.toggle('armed', armed==='sysupdate'); + $('k-update').textContent = armed==='sysupdate' ? 'UPDATE?' : 'UPDATE'; + $('k-topgrade').classList.toggle('armed', armed==='topgrade'); + $('k-topgrade').textContent = armed==='topgrade' ? 'TOPGRADE?' : 'TOPGRADE'; + // reboot offer key appears on the updates strip once an update lands + $('upd-rb').innerHTML = S.reboot.v==='YES' ? lever('reboot','REBOOT','red') : ''; +} +function go(v){ + armed=null; + if(v==='doctor'||v==='reclaim'){ startDoctor(v==='reclaim'?'reclaim':undefined); return; } + view=v; render(); +} +function closePanel(){ $('capsule').style.display='none'; $('closed').style.display='block'; } +function openPanel(){ $('capsule').style.display='block'; $('closed').style.display='none'; startLoad(); } +document.addEventListener('keydown', e=>{ if(e.key==='Escape') closePanel(); }); +startLoad(); + +/* live refresh sim: the thermal/memory group re-reads every 3 s while its + subpanel is visible (panel-open gated, like the audio panel's meters) */ +setInterval(()=>{ + if($('capsule').style.display==='none') return; + if(view!=='cat:power' || !loaded.has('power')) return; + const j = (v,lo,hi)=>Math.max(lo,Math.min(hi, v + (Math.random()<0.5?-1:1))); + let [tc,tg] = String(S.temps.v).split('/').map(Number); + if(badMode){ tc=j(tc,88,94); tg=j(tg,74,80); } else { tc=j(tc,57,65); tg=j(tg,50,58); } + S.temps.v = tc+'/'+tg; + S.mem.v = Math.round((S.mem.v + (Math.random()-0.5)*0.6)*10)/10; + render(); +}, 3000); +</script> +</body> +</html> diff --git a/docs/prototypes/README.org b/docs/prototypes/README.org new file mode 100644 index 0000000..9df85cb --- /dev/null +++ b/docs/prototypes/README.org @@ -0,0 +1,21 @@ +#+TITLE: Panel & Waybar Design Prototypes +#+AUTHOR: Craig Jennings + +Self-contained HTML/CSS design prototypes for the instrument-console panel +family and the waybar redesign. Each opens standalone in a browser (no external +assets). These are the normative visual references the specs in [[file:../specs/][docs/specs/]] +point at. + +* Prototypes + +- [[file:2026-07-03-instrument-console-panels-prototype.html][2026-07-03-instrument-console-panels-prototype.html]] — the net + bluetooth + pair; the approved faceplate design that shipped. Normative reference for + [[file:../specs/2026-07-03-instrument-console-panels-spec.org][the instrument-console spec]]. +- [[file:2026-07-03-net-panel-rescan-prototype.html][2026-07-03-net-panel-rescan-prototype.html]] — the manual rescan/scan ⟳ + affordance for the NETWORKS/NEARBY headers (busy-style throbber + list fade). +- [[file:2026-07-03-sound-panel-prototype.html][2026-07-03-sound-panel-prototype.html]] — the audio/pulsemixer console; layout + reference for [[file:../specs/2026-07-03-audio-panel-spec.org][the audio-panel spec]]. +- [[file:2026-07-03-panel-widget-gallery-prototype.html][2026-07-03-panel-widget-gallery-prototype.html]] — the shared instrument-console + widget kit (lamps, engraved sections, console keys, needle gauges). +- [[file:2026-07-03-waybar-redesign-prototype.html][2026-07-03-waybar-redesign-prototype.html]] — three directions for sprucing up + waybar in the dupre instrument-console aesthetic (future work). |
