diff options
| -rw-r--r-- | docs/prototypes/2026-07-02-timer-panel-prototype-1.html | 693 | ||||
| -rw-r--r-- | docs/prototypes/2026-07-02-timer-panel-prototype-2.html | 553 | ||||
| -rw-r--r-- | docs/prototypes/2026-07-02-timer-panel-prototype-3.html | 556 | ||||
| -rw-r--r-- | docs/specs/2026-07-02-timer-panel-spec.org | 173 | ||||
| -rw-r--r-- | todo.org | 90 |
5 files changed, 1997 insertions, 68 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/specs/2026-07-02-timer-panel-spec.org b/docs/specs/2026-07-02-timer-panel-spec.org index baa1da1..275bb2c 100644 --- a/docs/specs/2026-07-02-timer-panel-spec.org +++ b/docs/specs/2026-07-02-timer-panel-spec.org @@ -4,34 +4,42 @@ #+TODO: TODO | DONE #+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED -* DOING Timer GTK Panel +* IMPLEMENTED Timer GTK Panel :PROPERTIES: :ID: 25ed5321-f035-42b3-b115-69364d775f41 :END: - 2026-07-04 Sat @ 12:36:56 -0500 — retrofitted by spec-sort; status set to DRAFT (evidence-based, human-confirmed) -* DOING Status +* IMPLEMENTED Status :PROPERTIES: :ID: 1770af2e-b093-4024-a512-ae4324a2869f :END: +- [2026-07-05 Sun] IMPLEMENTED — redesign built and shipped to dotfiles in a no-approvals speedrun (5 commits =c7ac193=..=5a863b5=): the wtimer engine (timer repeat, recurring alarms with snooze/ringing/dismiss, =@half=/=@hour=/=+dur= alarm parse, the rebuilt configurable pomodoro cycle, bar-tooltip parity), the PanelModel view-data rebuild (=row_view=, ringing-first sort, per-type create options as flags, locked presets + half-past + named pomodoro cycles), the GTK hero-on-top panel (Cairo progress ring + stopwatch sweep dial, per-type create strips, one transport row, close ✕/Esc), and the bar tooltip parity. wtimer + timer suites 231 green, full =make test= green. Live GTK render is the manual checklist (todo.org). Stopwatch run-save deferred to vNext. +- [2026-07-05 Sun] DOING — UI/UX redesign decided through a prototype process (research → brainstorm → several directions → iterate to final; see Prototype iterations below). The shipped v1 panel stands, but this rewrite supersedes its layout and adds functionality: a hero-on-top + rack layout, a live waybar-module preview at parity with =wtimer render=, per-type create-strip features (timer auto-repeat; alarm recurring weekdays + snooze + a ringing state; a configurable pomodoro cycle with work/rest short+long and a long-break interval; a stopwatch analog sweep dial + last-lap badge), presets (renamed from "chips") whose shipped defaults are locked and whose load flashes the fields, and a header close button. Stopwatch run-save is cut to a vNext. Rebuild pending — folds this decided design into the shipped =timer/= package. +- [2026-07-05 Sun] IMPLEMENTED — v1 built and shipped to dotfiles in a no-approvals speedrun (4 commits 1f4f270..78d3cbb): wtimer watch/lap/save; a new timer/ package with a GTK-free PanelModel (62 tests) + the GTK instrument-console panel; bar integration (custom/timer opens the panel, the fuzzel creation flow retired, Hyprland float rule added). This is the base the 2026-07-05 redesign iterates on. - [2026-07-05 Sun] DOING — Craig directed the build (no-approvals speedrun). Folded in the cj input from the sibling waybar-timer-module spec (GTK app styled like the panels; a queue/output-wall auto-sorted by fire time; stopwatch lap/stop + saveable runs; notify integration; 5/25-min configurable+deletable defaults; up to 10 timers; widget-gallery elements) — see Build scope below. Bypassed the READY spec-review step at Craig's direction; the four decisions were already resolved. - [2026-07-04 Sat] DRAFT — all four decisions resolved by Craig (standalone; retire fuzzel once the panel lands; timer chips gain 10m/30m/2h; wtimer watch mode over polling). Decision-complete; ready for a spec-review to flip it READY before build. - [2026-07-02 Thu] DRAFT — initial spec from Craig's roam capture "give the timer a gtk UI/UX like the network panel. spec this out." +** Prototype iterations +The redesign ran through the UI/UX prototype process (see the =ui-prototyping= rule proposed to rulesets, 2026-07-05). Full working HTML prototypes over one shared engine, in the dupre instrument-console aesthetic; each iteration links here, newest last, so the design history is walkable. +- [[file:../prototypes/2026-07-02-timer-panel-prototype-1.html][prototype-1]] — three initial directions over one shared engine: rack unit (faithful vertical list), transport deck (hero + track list), channel-strip board (vertical faders). Predates the formalized five-direction count. +- [[file:../prototypes/2026-07-02-timer-panel-prototype-2.html][prototype-2]] — chose the rack direction; flipped to hero-on-top → create strip → list; made pomodoro a configurable cycle; locked the shipped presets (default cycle undeletable); dropped the stopwatch/pomodoro value entry. +- [[file:../prototypes/2026-07-02-timer-panel-prototype-3.html][prototype-3]] — FINAL. Live waybar preview; hero donut moved right with one full-width button row; stopwatch sweep dial + ghost lap badge; alarm recurring days + snooze + ringing state; timer repeat; half-past alarm preset; presets flash on load; header close (Esc / bar-click reopen); verbatim tooltip labels; stopwatch save deferred. + * Metadata -| Field | Value | -|--------+---------------------------------------------------| -| Status | draft | -|--------+---------------------------------------------------| -| Owner | Craig Jennings | -|--------+---------------------------------------------------| -| Repo | dotfiles | -|--------+---------------------------------------------------| -| Kin | net panel (architecture donor), wtimer (backing), | -| | desktop-settings panel spec (sibling) | -|--------+---------------------------------------------------| +| Field | Value | +|--------+-----------------------------------------------------------------------------------------| +| Status | implemented | +|--------+-----------------------------------------------------------------------------------------| +| Owner | Craig Jennings | +|--------+-----------------------------------------------------------------------------------------| +| Repo | dotfiles | +|--------+-----------------------------------------------------------------------------------------| +| Kin | net panel (architecture donor), wtimer (backing), desktop-settings panel spec (sibling) | +|--------+-----------------------------------------------------------------------------------------| * Problem @@ -73,16 +81,41 @@ surface. - Dupre WIP palette CSS shared with the net panel (same factoring the desktop-settings spec calls for — one palette asset, three panels). -** Layout sketch - -- Header row: running-item count + a Clear All button (maps to cancel-all). -- Item list: one row per item — type glyph, label, live countdown / clock - time / phase+cycle for pomodoro, pause and cancel buttons, click-to-promote. -- Create strip: four type buttons (the wtimer glyphs), preset chips per type - (timer 5m / 10m / 15m / 25m / 30m / 60m / 2h; alarm +30m / top-of-hour / - 07:00; pomodoro default cycle; stopwatch none — decision C), a freeform - entry validated with wtimer's own parsers, an optional label field. -- Empty state: the create strip alone, centered. +** Layout sketch (decided in prototype-3) + +Top-to-bottom, one column: + +- Header: brand + live item count + Clear All + a flat circular close ✕ + (tooltip "Close (Esc)"), matching the net/bt/audio panels. Esc closes; + clicking the bar's timer module reopens it (mirrors =on-click: timer-panel=). +- Hero (the primary / bar-slot item): the info block (type badge, any feature + badges, pomodoro cycle dots, label, big countdown, subline) on the left with + the progress donut on the right, and all its controls in one full-width, + left-justified button row beneath. Countdown types show a filling progress + ring; a stopwatch shows an analog sweep dial (a gold second-hand, one + revolution per minute) with its last lap as a bordered ghost badge beside the + count — no fake progress ring for a count-up. The ‹ › keys cycle the primary + through the whole queue, wrapping at either end. +- Create strip: the four type buttons, then a per-type body — presets (renamed + from "chips") + a freeform entry validated by wtimer's parsers + an optional + label, plus per-type extras (see Build scope). Loading a preset flashes the + target fields rather than toasting. Shipped presets are locked (no delete); + only presets you add carry a ×. +- Queue list: the rest of the items (everything but the hero), soonest-fire + first, one rack row each — lamp, glyph, label, subline, countdown, and inline + pause / promote / cancel (two-stage arm). Stopwatches are promotable to the + hero like any other item. With a single item the list reads "Only one item is + queued. Add more above." Empty state: hero shows "No timers running", create + strip below. + +** Waybar module parity + +A live preview above the panel renders exactly what =wtimer render= emits for +the bar: =<large glyph> <countdown>= plus =+N= for the other items, recoloured +by state (urgent < 60 s terracotta, paused dim, pomodoro-work gold, +pomodoro-break sage, idle silver), with the full per-item hover tooltip. Tooltip +lines show each item's label verbatim — no phase word appended. The panel and +the bar stay one source of truth via the wtimer watch subscription. ** What happens to the fuzzel flow @@ -92,25 +125,26 @@ click-driven bar path and the keybind/fuzzel path. Until the panel ships the fuzzel flow stays (it's styled and tested); phase 4 removes it after the panel proves out. -* Build scope (consolidated 2026-07-05 — the four decisions plus Craig's cj input) +* Build scope (decided design — folds the prototype-3 redesign into the shipped =timer/= package) -The panel is a new =timer/= dotfiles package mirroring =net/= and =audio/= (src-layout, GTK4 + gtk4-layer-shell, humble-object PanelModel, instrument-console faceplate aesthetic — machined plate, engraved section labels, status lamps, console keys). wtimer stays the state engine; the panel is a view over it. +The panel is the existing =timer/= dotfiles package (src-layout, GTK4 + gtk4-layer-shell, humble-object PanelModel, instrument-console faceplate). wtimer stays the state engine; the panel is a view over it. This rebuild reshapes the layout (see Layout sketch) and adds the per-type functionality below. UI idioms draw from the widget gallery (=docs/prototypes/2026-07-03-panel-widget-gallery-prototype.html=); the reference build is prototype-3. -Create + queue: -- A configure strip (top): pick timer / alarm / stopwatch / pomodoro, set the value (preset chips per decision C + a freeform entry validated by wtimer's parsers, optional label). A =+= adds the configured item to the queue. -- The queue is an output-wall-style list (the instrument-console output well), *auto-sorted by soonest fire time* (the item that notifies next is on top). One row per item: type glyph, label, live countdown / clock time / pomodoro phase+cycle, pause/resume, cancel, click-to-promote (bar glyph slot). -- Up to 10 timers; the two starting timer presets default to 5 min and 25 min, and the preset set is configurable and deletable. +Queue + primary: +- Up to 10 items, auto-sorted by soonest fire time (four buckets: active countdown < paused countdown < active stopwatch < paused stopwatch). The soonest-firing is the hero/primary (the bar glyph slot). Promote via a row's promote key or by cycling ‹ ›; cycling and promotion include stopwatches and wrap around the whole list. +- The hero shows the primary big; the list shows the rest. Clear All cancels everything. -Stopwatch: -- A running stopwatch row has a Lap button and a Stop button. Lap records the elapsed time at the press; unlimited laps; a lap can optionally be named (non-interruptive — naming never blocks further laps). On stop, the full run (splits + optional names) can be saved to review later. Save target: an org file (default =~/org/stopwatch-runs.org=, override via a config key) — one heading per run with a table of laps. +Types + create strip: +- *Timer*: preset durations 5m / 25m / 10m / 15m / 30m / 60m / 2h (5m and 25m first), freeform entry (wtimer parser), optional label, and a *repeat* toggle — a repeating timer re-arms itself on fire instead of clearing. +- *Alarm*: presets +30m / top-of-hour / *half-past* (next X:30) / 07:00, freeform HH:MM, optional label, a *recurring weekday* selector (S M T W T F S, with weekdays / daily shortcuts) and a *snooze* duration. An alarm fires into a *ringing* state rather than silently vanishing: the hero/row shows SNOOZE (re-arm by the snooze minutes) and DISMISS (a recurring alarm re-arms to its next matching day; a one-shot clears). +- *Stopwatch*: no value entry — counts up from zero. Lap (unlimited) and Stop. The hero shows an analog sweep dial and the last lap as a ghost badge beside the count. *Run-save is deferred to a vNext* (cut from v1's org-save plan — see the status history). +- *Pomodoro*: a configurable cycle — Work and Rest each with a short and a long duration, a "long break every N cycles" interval, and an auto-advance toggle. Every Nth ("deep") cycle uses the long work + long rest together. Cycle progress shows as dots in the hero and row. With auto-advance off, each phase end waits for a Start press. Preset cycles (Classic 25/5/15, Deep 50/10/30, Sprint 15/3/10) load the fields. +- *Presets*: shipped defaults are locked (undeletable — the pomodoro default cycle can't be removed); presets you add carry a × and are deletable. Loading any preset flashes the target fields (no toast). Live updates + notifications: -- A new =wtimer watch= subcommand emits state on every change (state-file watch → JSON lines on stdout); the panel subscribes for push updates instead of polling. The bar may adopt it later. -- Notifications for alarms and timers go through the =notify= script (wtimer already fires notify on completion; keep that path the single notification owner). - -UI elements: draw from the panel widget gallery prototype (=docs/prototypes/2026-07-03-panel-widget-gallery-prototype.html= in the archsetup repo) for the console keys, lamps, output-well rows, and chips, matching the shipped net/bt/audio look. +- A =wtimer watch= subcommand emits state on every change (state-file watch → JSON lines on stdout); the panel subscribes for push updates instead of polling (decision D). Notifications for alarms and timers go through the =notify= script — wtimer stays the single notification owner. -Retire the old timer: the bar's =custom/timer= on-click drives =wtimer new= (the fuzzel chain). Rewire the bar module's on-click to open this panel, and retire the =wtimer new= fuzzel creation flow (decision B). Keep =wtimer render= as the bar indicator and the wtimer engine as the state source. +Bar + window: +- =custom/timer= left-click opens the panel; =wtimer render= stays the bar indicator (glyph + countdown + =+N=, state-coloured, verbatim tooltip labels). A header close ✕ and Esc close the panel; clicking the bar module reopens it. The =wtimer new= fuzzel creation flow is retired (decision B). * Decisions (Craig) @@ -130,15 +164,58 @@ Resolved (Craig, 2026-07-04): timer chips are 5m / 10m / 15m / 25m / 30m / 60m / CLOSED: [2026-07-04 Sat] Resolved (Craig, 2026-07-04): a wtimer watch/subscribe mode, not 1s polling. This grows wtimer with a new watch capability that the panel (and potentially the bar) subscribes to for live state, rather than reusing the poll cadence — cleaner at the cost of a wtimer addition. Fold the watch mode into the phase 1 CLI-backing seam. -* Implementation phases - -1. PanelModel presenter + CLI-backing seam (TDD, GTK-free, 100% like the net - PanelModel), plus the wtimer watch/subscribe mode (decision D) the presenter - subscribes to for live state. -2. Blueprint UI: item list + create strip, wired to the presenter; palette - css factored to the shared asset. -3. Bar integration: timer module left-click opens the panel (replacing the - fuzzel menu binding there); the panel and bar both track state via the - wtimer watch subscription. -4. AT-SPI smoke + manual-testing checklist; retire the fuzzel flow (decision B) - after the panel proves out over a week of real use. +The decisions below were resolved live through the prototype iteration (2026-07-05), each seen working in a prototype before being written down. + +** DONE Layout: hero on top, then create strip, then the queue list +CLOSED: [2026-07-05 Sun] +Resolved: the primary item rides a hero at the top (info left, donut right, all controls in one full-width button row), the create strip sits under it, the rest of the queue below. Chosen over the transport-deck and channel-strip directions in prototype-1. + +** DONE Stopwatch hero visual: analog sweep dial, not a progress ring +CLOSED: [2026-07-05 Sun] +Resolved: a count-up stopwatch shows a gold second-hand sweeping once per minute, with its last lap as a bordered ghost badge beside the count — not a fake progress ring (a stopwatch has no target to be a fraction of). + +** DONE Alarm: recurring weekdays + snooze + a ringing state; add a half-past preset +CLOSED: [2026-07-05 Sun] +Resolved: alarms carry a weekday-repeat selector and a snooze duration, and fire into a ringing state with SNOOZE / DISMISS rather than vanishing. A half-past preset joins top-of-hour (fires at the next X:30). Drawn from Alarm Clock Xtreme / Alarmy. + +** DONE Timer: auto-repeat toggle +CLOSED: [2026-07-05 Sun] +Resolved: a timer can repeat — it re-arms itself on fire instead of clearing. Drawn from MultiTimer / Multi Timer. + +** DONE Pomodoro: a fully configurable cycle +CLOSED: [2026-07-05 Sun] +Resolved: Work and Rest each get a short and a long duration, plus a long-break-every-N interval and an auto-advance toggle; every Nth deep cycle uses the long work + long rest; progress shows as cycle dots. The default cycle preset is locked (undeletable). Drawn from Pomofocus / the classic technique. + +** DONE Presets (formerly "chips"): lock defaults, flash on load +CLOSED: [2026-07-05 Sun] +Resolved: rename "chips" to "presets"; shipped defaults are locked (no delete), presets you add are deletable; loading a preset flashes the target fields instead of firing a toast. + +** DONE Stopwatch run-save: deferred to a vNext +CLOSED: [2026-07-05 Sun] +Resolved: v1's "save the run's splits to an org file on stop" is cut from this build. Stop just stops. Revisit in a vNext if the need is real. + +* Implementation phases (redesign rebuild) + +Folding prototype-3 into the shipped =timer/= package. TDD throughout — GTK-free +logic first, GUI last — reviewing between phases. Each phase is a dotfiles commit +under the archsetup-owns-dotfiles rule. + +1. wtimer engine: alarm recurring-days + snooze + a ringing state, timer repeat, + the configurable pomodoro cycle (work/rest short+long, long-break interval, + auto-advance, deep cycle), half-past parsing, and the watch/subscribe mode + (decision D). Extend wtimer's own suite per addition. +2. PanelModel: the four-bucket soonest-fire sort, promote/cycle wrap (stopwatches + included), per-type create validation + presets (locked defaults, custom + delete, flash-on-load), and the row/hero view data (sweep fraction, cycle + dots, last lap, feature badges, ringing controls). GTK-free, unit-tested like + the net PanelModel. +3. GTK GUI: the hero (progress ring / sweep dial, one full-width button row, lap + badge), the per-type create strip (timer repeat toggle; alarm weekday selector + + snooze; pomodoro config grid; presets that flash), the header close ✕, + Esc-to-close, and bar-click reopen. +4. Bar parity: =wtimer render= tooltip labels verbatim, state classes confirmed; + panel and bar track one state via the watch subscription. +5. AT-SPI smoke + a manual-testing checklist (todo.org). Retire the =wtimer new= + fuzzel flow (decision B) after the panel proves out. + +Deferred to a vNext: stopwatch run-save (an org log of splits). @@ -198,13 +198,14 @@ From the 2026-07-04 roam capture. The waybar collapse mechanism (click the trian :END: Follow-up from the 2026-07-04 net-panel hardening speedrun (Craig's cj question on the no-WiFi item). The shipped no-wifi-hardware verdict covers "no adapter at all." This tier covers "adapter present but the driver is wedged": read-only health signals — =ip link= (device present but no-carrier / down), =dmesg= / =journalctl -k= for firmware-load failures, =rfkill= for a hard block, =modinfo= / =lsmod= for the driver module — classified before a generic reset. Remedy actions: a privileged =modprobe -r <mod> && modprobe <mod>= reload of the wifi driver, and a firmware-package pointer when the failure is a missing/failed firmware load. Dotfiles net-package work (handled per the archsetup-owns-dotfiles rule). Design pass first to decide whether it's worth a repair tier vs a needs-user-action pointer. -** TODO [#B] Timer GTK panel :feature:waybar: -:PROPERTIES: -:LAST_REVIEWED: 2026-07-02 -:END: -Initial spec written 2026-07-02: [[file:docs/specs/2026-07-02-timer-panel-spec.org]] (DRAFT — four decisions await Craig's review before build; net-panel Blueprint/GTK4 stack, wtimer stays the state owner). +** DONE [#B] Timer GTK panel :feature:waybar: +CLOSED: [2026-07-05 Sun] +Built and shipped to dotfiles 2026-07-05 in a no-approvals speedrun (4 commits =1f4f270=..=78d3cbb=): wtimer gained watch/lap/save; a new =timer/= package holds a GTK-free PanelModel (62 tests) and the GTK instrument-console panel; the bar's =custom/timer= now opens the panel and the fuzzel creation flow retired. Spec: [[file:docs/specs/2026-07-02-timer-panel-spec.org]] (IMPLEMENTED). Code-complete; live GTK verification filed under Manual testing and validation below. + +From Craig's roam capture 2026-07-02: give the timer a GTK UI/UX like the network panel. Scope expanded via a later cj comment (queue/output-wall auto-sorted by fire time, stopwatch lap/stop + saveable runs, 5/25 configurable defaults, up to 10 timers, widget-gallery elements) — folded into the spec's Build scope and shipped. -From Craig's roam capture 2026-07-02: give the timer a GTK UI/UX like the network panel. +*** 2026-07-05 Sun @ 07:20:20 -0500 Redesign shipped — hero-on-top rebuild +The UI/UX redesign (decided through the prototype process, final = [[file:docs/prototypes/2026-07-02-timer-panel-prototype-3.html]]) built and shipped to dotfiles in a no-approvals speedrun, 5 commits =c7ac193=..=5a863b5=: Phase 1 wtimer engine (timer repeat; recurring alarms with snooze/ringing/dismiss; =@half=/=@hour=/=+dur= alarm parse; the rebuilt configurable pomodoro cycle — work/rest short+long, long-every-N, auto vs awaiting); Phase 2 PanelModel view-data (=row_view=, ringing-first sort, per-type create options as wtimer flags, locked presets + half-past + named pomodoro cycles); Phase 3 GTK hero-on-top panel (Cairo progress ring + stopwatch analog sweep dial, per-type create strips, one transport row, close ✕/Esc); Phase 4 bar-tooltip parity. wtimer + timer suites 231 green, full =make test= green. Spec re-flipped DOING → IMPLEMENTED. Stopwatch run-save deferred to vNext. Live GTK render is the refreshed manual checklist below. ** TODO [#B] Desktop-settings dropdown panel :waybar: :PROPERTIES: @@ -609,6 +610,67 @@ Parse yay errors and provide specific, actionable fixes instead of generic error Enhance existing indicators to show what's happening in real-time ** TODO Manual testing and validation +*** Timer redesign: apply the package + wtimer, clear stale timers (precondition) +What we're verifying: the redesigned =timer/= package + =wtimer= are live. Only a =git pull= is needed — the package .py files and =wtimer= are already stowed, and this build added no new launcher files, so no restow or reboot. Clear any pre-existing timers first so a stale-shape state file (an old pomodoro item lacking the new =cfg=) can't crash =wtimer render=. +#+begin_src sh :results output +cd ~/.dotfiles && git pull +wtimer cancel-all +#+end_src +Expected: the pull succeeds and the bar timer glyph goes idle (no active items). +*** Panel opens hero-on-top; close via the ✕, Esc, and the bar +What we're verifying: the redesigned layout renders (header, hero, CONFIGURE, queue) and every close path works. +- Click the timer module on the bar. +Expected: the panel opens floating top-right — a header faceplate, a HERO block for the primary item, the CONFIGURE strip, then a QUEUE well below. +- Click the ✕ in the header; reopen; press Esc; reopen; click the bar module again. +Expected: each of ✕, Esc, and the bar click closes the panel. +*** Timer with repeat +What we're verifying: the REPEAT toggle re-arms a timer on fire instead of dropping it. +- Pick TIMER, type =10s= (fast to observe), toggle REPEAT on, click +. +Expected: the timer appears carrying a "repeat" badge and a "timer · repeats" sub-line. +- Let it fire. +Expected: it notifies and immediately re-arms to a fresh full countdown rather than vanishing. +*** Alarm: recurring day, snooze, ringing, dismiss +What we're verifying: a recurring alarm rings (not fire-and-vanish), snooze re-arms, and dismiss re-arms a recurring one. +- Pick ALARM, type a clock time about a minute ahead (HH:MM), select today's weekday in the day picker, set SNOOZE to 1, click +. +Expected: the row shows the fire clock time and a weekday badge; the hero donut is a countdown ring to the fire time. +- Let it reach the fire time. +Expected: the hero shows RING and the bar glyph goes urgent (RING); SNOOZE and DISMISS buttons appear on the hero. +- Click SNOOZE. +Expected: ringing stops and the alarm re-arms one minute out. +- Let it ring again, click DISMISS. +Expected: ringing stops; because it is recurring it re-arms to its next matching day (a one-shot alarm would be removed instead). +*** Pomodoro: configurable cycle, auto vs await, cycle dots +What we're verifying: the config grid drives the cycle and the AUTO toggle switches between auto-advance and awaiting a start. +- Pick POMODORO, tap a named cycle (e.g. Deep) to fill the grid, set WORK S to 1 (min) for a fast run, toggle AUTO off, click + ADD CYCLE. +Expected: a pomodoro starts in work; the hero shows cycle dots and "work · cycle 1/N". +- Let the work phase fire with AUTO off. +Expected: it enters the rest phase awaiting a start — the hero shows "ready · start break" and a START BREAK button, and it does not count down until pressed. +- Press START BREAK. +Expected: the rest phase counts down; the glyph/color shifts to the break (sage) state. +*** Stopwatch: sweep dial, lap badge, promote, stop +What we're verifying: the analog sweep dial animates, the last-lap ghost badge shows, promote works, and STOP just stops (run-save deferred). +- Pick STOPWATCH, click +. If it isn't the hero, promote it (the ▲ on its queue row). +Expected: the hero donut is an analog second-hand dial sweeping once per minute (not a percentage ring); the elapsed value counts up. +- Hit LAP a couple of times (name one via the popover). +Expected: a "LAP m:ss" ghost badge appears beside the elapsed value and the sub-line shows the lap count. +- Hit STOP. +Expected: the stopwatch is removed. No =~/org/stopwatch-runs.org= entry is written — run-save is deferred to a later version. +*** Queue sort, promote/cycle, 10-item cap +- Add a 25m timer, a 5m timer, and an alarm a few minutes out. +Expected: the queue orders soonest-first; a ringing alarm jumps above everything. +- Use ‹ / › on the hero (or ▲ on a queue row) to change the primary. +Expected: the hero (bar-slot) item changes accordingly and the bar glyph follows. +- Queue items until 10 exist. +Expected: + disables with a "queue is full (10/10)" reason; running countdowns advance live (the =wtimer watch= subscription). +*** Presets: locked defaults, half-past, custom add/delete +- On TIMER, note the default chips carry no × (locked); add a custom preset via + preset, then delete it with its ×. +Expected: locked defaults can't be deleted; a custom preset adds and deletes cleanly. +- On ALARM, tap the half-past chip, click +. +Expected: an alarm is created at the next :30. +*** Bar tooltip parity +What we're verifying: the bar tooltip mirrors the redesign verbatim. +- With a pomodoro, a paused timer, and a ringing alarm active, hover the bar module. +Expected: each item lists on its own line — the pomodoro as "label cycle/iv countdown" (no phase word), the paused one with a "(paused)" suffix, the ringing one as "RING (ringing)". *** Audio panel: apply the new shims + configs (precondition for the tests below) What we're verifying: the new =audio=/=audio-panel=/=waybar-audio= bin shims and the hyprland.conf + waybar config edits are live. New files need a restow (a plain =git pull= doesn't symlink them). Quit Hyprland or run from a TTY — restowing live Hyprland writes a stub hyprland.conf. #+begin_src sh :results output @@ -654,20 +716,8 @@ Expected: it reads as a sibling of the net and bt panels; nothing overflows the - Right-click the waybar sound module. Expected: the panel opens (left-click still mutes, scroll still changes volume). -*** Timer dialog: Escape cancels at every step -What we're verifying: Escape in the real fuzzel dialogs aborts the whole flow (the fix rides fuzzel's abort exit code; the unit tests fake it, this is the live confirmation). -- Left-click the timer module on the bar, pick "timer", then hit Escape at the Duration prompt. -Expected: no Label prompt appears and no timer is created (bar count unchanged). -- Left-click again and hit Escape at the very first Timer-type menu, and once more at the Label prompt after entering a duration. -Expected: each Escape ends the flow with nothing created. - -*** Timer alarm: 12-hour time accepted, bad input notifies -What we're verifying: the alarm prompt takes 12h shapes live and errors are audible instead of silent. -- Left-click the timer module, pick "alarm", enter 2:30pm (or any 12h form), Enter through the label. -Expected: an alarm appears on the bar showing 14:30 as its fire time. -- Repeat with garbage like 99:99. -Expected: a fail notification names the bad input; nothing is created. -- Cancel the test alarm (right-click the module, pick it). +*** SUPERSEDED — Timer fuzzel dialogs (Escape-cancel, 12h alarm entry) +These two tests exercised the old fuzzel creation dialogs, retired when the bar's =custom/timer= on-click became =timer-panel=. The panel redesign checklist above covers the same ground: alarm entry (12h shapes, bad-input reject) is now the panel's ALARM freeform + inline validation, and the whole-flow abort is the panel's Esc/✕/bar close. Nothing to run here; kept as a pointer so the intent isn't lost. *** Speed test streams in the panel What we're verifying: the panel's speed test fills in as phases complete instead of dumping everything at the end (the CLI path is live-verified; this is the GTK rendering). |
