diff options
| -rw-r--r-- | archive/task-archive.org | 48 | ||||
| -rw-r--r-- | docs/prototypes/panel-widget-gallery.html | 96 | ||||
| -rw-r--r-- | docs/prototypes/widgets.js | 256 | ||||
| -rw-r--r-- | todo.org | 147 | ||||
| -rw-r--r-- | working/retro-stereo-widgets/references/2026-07-12-oasis-fader-bank.png | bin | 0 -> 213597 bytes | |||
| -rw-r--r-- | working/retro-stereo-widgets/references/2026-07-13-swr-cross-needle-meter.jpg | bin | 0 -> 17271 bytes | |||
| -rw-r--r-- | working/retro-stereo-widgets/references/2026-07-13-weather-station-comfort-meter.webp | bin | 0 -> 26004 bytes |
7 files changed, 393 insertions, 154 deletions
diff --git a/archive/task-archive.org b/archive/task-archive.org index 1372d4f..f1a6938 100644 --- a/archive/task-archive.org +++ b/archive/task-archive.org @@ -1382,3 +1382,51 @@ CLOSED: [2026-07-03 Fri] Went past the spec to a full build in a no-approvals speedrun. Spec is now IMPLEMENTED ([[file:docs/specs/2026-07-03-audio-panel-spec.org]], all 5 Decisions resolved). The panel shipped in the dotfiles repo (branch panel-bugfixing, commits 65e5bb0..9601420): pactl engine, GTK-free presenter, GTK instrument-console panel (OUTPUTS/INPUTS device rows with faders + per-device mute, LIVE/MUTED/PUSH·TALK mic keys, twin VU gauges, master quick-mute), Hyprland-bind push-to-talk, bar indicator, and the bar/keybind wiring (Super+A → panel, XF86AudioMute → master quick-mute). 102 unit tests + a passing AT-SPI smoke on velox. Live-eyeball validation filed under Manual testing and validation. Apply steps + follow-ups handed to the dotfiles project inbox. Original ask (roam inbox, 2026-07-02): net/bt-panel kin — change default output/input, volume for both, push-to-talk mic mode for meetings, master quick-mute, bar sound-glyph state. Related bindings: Super+M audio-cycle ring, Super+Shift+A mic-toggle. Prototype: =docs/prototypes/2026-07-03-sound-panel-prototype.html=. +** DONE [#B] Panels moveable + resizable by drag :feature:waybar:network:bluetooth: +CLOSED: [2026-07-04 Sat] +Resolved by the 2026-07-03 instrument-console rebuild (dotfiles e993c3f): both net + bt panels switched from anchored gtk4-layer-shell overlays to normal floating windows (set_decorated(False), positioned by the net.cjennings.netpanel window rule), so Hyprland moves them on drag and resizes on corner-drag natively. That was exactly the "switch to a normal floating window" approach the design note flagged as the required decision. + +Both the net and bluetooth instrument-console panels should be repositionable and resizable at runtime: click-drag to move the panel anywhere on screen, drag the corners to resize. Raised from roam capture 2026-07-03. + +Design note: the panels are gtk4-layer-shell overlays anchored TOP+RIGHT with fixed margins — layer-shell surfaces are compositor-positioned, so free drag-move/resize needs either dynamic margin updates on pointer motion or a switch to a normal floating window (Hyprland moves/resizes those natively). Approach decision required before build. +** CANCELLED [#B] Net panel wider initial width :waybar:network:quick: +CLOSED: [2026-07-04 Sat] +Superseded by the 2026-07-03 instrument-console rebuild (dotfiles e993c3f): the panel is now a floating, user-resizable window (set_default_size(420, 560)), no longer a right-anchored layer-shell surface. The task's mechanic ("keep the right edge fixed, extend the left border leftward") assumed the old anchored surface, which no longer exists — the width is now drag-adjustable. Cancelled per the 2026-07-04 audit (Craig's call to close rather than re-file a "bump the 420px default" task). + +Start the network panel a bit wider — keep the right edge fixed (it's right-anchored), extend the left border leftward. Raised from roam capture 2026-07-03. +** DONE [#B] Net panel doctor results can't display :bug:waybar:network: +CLOSED: [2026-07-04 Sat] +Resolved by the 2026-07-03 instrument-console rebuild (dotfiles e993c3f): the panel gained a streaming output well (gui.py) with a "Copy results" button (via wl-copy) and a dismiss control that collapses the well back to the panel's pre-open height (_shrink_to_compact asks Hyprland to resize back). Doctor/speed-test output streams into it as appended lines — matching the task's ask for a tall results box, copy button, and collapse-back. This capture (filed 2026-07-03 morning) predates the same-day 22:06 redesign that addressed it. + +The doctor diagnostic output is unreadable — the results well is too constrained to show the multi-line result. It should open a results box tall enough for several lines with a copy-results button; closing it via an X in the box's upper-right collapses the space back to what it occupied before. Raised from roam capture 2026-07-03. +** 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. + +*** 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. +** CANCELLED [#B] Test each modernization thoroughly before replacing +CLOSED: [2026-07-04 Sat] +Retired in the 2026-07-04 audit (Craig's call): a standing-judgment umbrella with no completion criterion. The fleet is Hyprland-only now, and per-change test discipline is already carried by the actual work (TDD + the VM harness), so this adds nothing to track. Original intent: ensure new tools integrate with the Hyprland environment and don't break workflow (archsetup still supports DWM/X11 but no current machine uses it). +** DONE [#C] Window focus lost when unhiding stashed windows :bug:hyprland: +CLOSED: [2026-07-04 Sat] +Verified fixed live on ratio 2026-07-04 (Craig at the machine). Stash (Super+O) → restore (Super+Shift+O) left the restored window focused, and Super+J/K (layout-navigate) cycled focus normally afterward — both original symptoms gone. Resolved by two fixes that postdated the filing: dotfiles 5619342 (raise window on focus nav, stop float focus-follow, 2026-06-28) and 09815f3 (cycle focus by address so j/k works in monocle, 2026-06-29). Both confirmed present in ratio's HEAD and in the live layout-navigate script at test time. + +From the roam inbox: hiding a window (e.g. the org-capture popup) then unhiding it should leave the unhidden window focused, but another window typically takes focus. Also =ctrl+j/k= (layout-navigate) can't reach the unhidden window afterward — it should always reach any visible window except the waybar. Involves stash-restore + layout-navigate; needs interactive reproduction with Craig. (Note: the actual bind is Super+J/K, not ctrl+j/k as the capture said.) +** DONE [#C] Instrument-console panel bugs (net/bt/audio) :bug:dotfiles: +CLOSED: [2026-07-05 Sun] +Batch from the roam inbox (2026-07-05). Panel code lives in =~/.dotfiles= (net/, bluetooth/, audio/). All eight shipped 2026-07-05, each verified live and covered by the panel test suites. +*** 2026-07-05 Sun @ 17:49:51 -0400 Titled the panel windows Network/Bluetooth/Audio (was python3) +The GTK app set no window title, so it fell back to the process name. dotfiles 2d03451. +*** 2026-07-05 Sun @ 17:49:51 -0400 Faceplate word is the subsystem name, not live state (net/bt/audio) +NETWORKING / BLUETOOTH / AUDIO; state now reads off the lamp colour + badges. Bluetooth's DOCTOR feedback (CHECKING/FIXING) moved to the status line and output well. dotfiles 5c58833. +*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel flags a signed-out Proton CLI as needs-login +The proton probe reads =protonvpn info= first, since =protonvpn status= says Disconnected either way. The panel already blocks a needs-login row with a sign-in hint. dotfiles 2671472. +*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel sorts live tunnels to the top +Stable within each backend group. dotfiles 2671472. +*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel refuses a second full-tunnel VPN while one is active +tailscale is a mesh overlay, not a full tunnel, so it never conflicts. dotfiles 307a0fe. +*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel dedupes Proton's own wireguard row +The proton CLI's =ProtonVPN <server>= NM profile no longer shows alongside the proton backend row. dotfiles dbc9ee8. diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html index 30bf086..9740c46 100644 --- a/docs/prototypes/panel-widget-gallery.html +++ b/docs/prototypes/panel-widget-gallery.html @@ -105,8 +105,27 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} /* validation lamp: off = not done, amber = in progress, green = done; click cycles, state persists */ .vlamp{margin-left:auto;flex:0 0 auto;width:10px;height:10px;border-radius:50%;cursor:pointer; background:#221f1b;border:1px solid #33302b} -.vlamp[data-v="amber"]{background:var(--gold);border-color:#7d5c16;box-shadow:0 0 6px 1px rgba(var(--glow-lo),.6)} -.vlamp[data-v="green"]{background:var(--pass);border-color:#4a5c22;box-shadow:0 0 6px 1px rgba(116,147,47,.6)} +.vlamp[data-v="amber"],.vtally .vdot[data-v="amber"],#vaudit .vdot[data-v="amber"]{background:var(--gold);border-color:#7d5c16;box-shadow:0 0 6px 1px rgba(var(--glow-lo),.6)} +.vlamp[data-v="green"],.vtally .vdot[data-v="green"],#vaudit .vdot[data-v="green"]{background:var(--pass);border-color:#4a5c22;box-shadow:0 0 6px 1px rgba(116,147,47,.6)} +/* audit stepper pill: docks bottom-right while stepping through one state's cards */ +#vaudit{position:fixed;right:18px;bottom:16px;z-index:60;display:flex;align-items:center;gap:7px; + padding:8px 13px;border-radius:20px;background:var(--raise);border:1px solid var(--wash); + color:var(--cream);font-size:.78rem;letter-spacing:.05em;cursor:pointer;user-select:none; + box-shadow:0 4px 14px rgba(0,0,0,.5)} +#vaudit .vdot{flex:0 0 auto;width:8px;height:8px;border-radius:50%;background:#221f1b;border:1px solid #33302b} +#vaudit .vx{margin-left:6px;color:var(--steel);padding:0 2px} +#vaudit .vx:hover{color:var(--fail)} +/* validation tally in the index: .vdot mirrors the lamp look but must NOT match + .vlamp — the counter counts .vlamp nodes */ +.vtally{margin-top:2px} +.vtally .vrow{display:flex;align-items:center;gap:7px;color:var(--dim);font-size:.75rem; + letter-spacing:.04em;padding:2.5px 0} +.vtally .vrow[data-v]{cursor:pointer} +.vtally .vrow[data-v]:hover{color:var(--gold-hi)} +.vtally .vdot{flex:0 0 auto;width:8px;height:8px;border-radius:50%; + background:#221f1b;border:1px solid #33302b} +.vtally .vn{margin-left:auto;color:var(--cream);font-variant-numeric:tabular-nums} +.vtally .vtot{border-top:1px solid var(--wash);margin-top:3px;padding-top:4.5px;color:var(--steel)} .stagew{background:var(--well);border:1px solid #201d17;border-radius:9px;padding:14px 12px; min-height:78px;display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap} .wrd{color:var(--gold-hi);font-size:.8rem;letter-spacing:.06em;font-variant-numeric:tabular-nums; @@ -176,6 +195,8 @@ h2::after{content:"";height:1px;background:var(--wash);flex:1} <a href="#sec-meters">Meters & gauges</a> <a href="#sec-indicators">Indicators & readouts</a> <a href="#sec-palette">Palette</a> + <div class="tt" style="margin-top:11px">Validation</div> + <div class="vtally" id="vtally"></div> </nav> </div> </header> @@ -366,7 +387,7 @@ const INFO={ limits:'Stepping is sequential — jumping to a far position takes several clicks.', origin:'Rotary switches on radios and instruments.',difficulty:'Intuitive.', prefer:'The selection should read like hardware, not a menu.'}, -'25':{input:'Drag to slide the cursor. Drag-only — needs a key idiom for Emacs.', +'25':{input:'Click a numeral, a mark, or between marks for the units; focused, arrows step one unit. Click + keys.', solves:'Reading a value against a long calibrated scale.', use:'Specialty. Shines where the scale itself carries meaning.', limits:'Scale literacy required; poor for quick setting.', @@ -1018,6 +1039,46 @@ const INFO={ const INFO_FIELDS=[['input','input'],['solves','solves'],['use','use'],['limits','limits'], ['origin','origin'],['difficulty','difficulty'],['prefer','prefer when'],['period','period']]; const VLAMP_TITLES={off:'validation: not done',amber:'validation: in progress',green:'validation: done'}; +/* index tally: recounts every card lamp; setV calls it on every state change. + Clicking a row jumps to the next card in that state (cycles), so a count + that disagrees with a visual scan can be audited card by card. */ +const VJUMP={green:0,amber:0,off:0}; +function updateVTally(){ + const el=$('vtally'); if(!el)return; + const lamps=[...document.querySelectorAll('.vlamp')]; + const n={off:0,amber:0,green:0}; + lamps.forEach(l=>{n[l.dataset.v]=(n[l.dataset.v]||0)+1;}); + el.innerHTML= + `<div class="vrow" data-v="green"><span class="vdot" data-v="green"></span>done<span class="vn">${n.green}</span></div>`+ + `<div class="vrow" data-v="amber"><span class="vdot" data-v="amber"></span>in progress<span class="vn">${n.amber}</span></div>`+ + `<div class="vrow" data-v="off"><span class="vdot"></span>not done<span class="vn">${n.off}</span></div>`+ + `<div class="vrow vtot">total<span class="vn">${lamps.length}</span></div>`; + el.querySelectorAll('.vrow[data-v]').forEach(row=>row.addEventListener('click',()=>{ + VJUMP[row.dataset.v]=0; auditNext(row.dataset.v); + })); +} +/* audit stepper: a row click jumps to the first card in that state and docks a + floating pill; clicking the pill advances through the rest without scrolling + back to the index. The card list is re-read on every step, so lamp changes + mid-audit are picked up. Esc or the x dismisses. */ +const VAUDIT_LBL={green:'done',amber:'in progress',off:'not done'}; +function auditNext(v){ + const cards=[...document.querySelectorAll('.vlamp')].filter(l=>l.dataset.v===v) + .map(l=>l.closest('.card')); + if(!cards.length){auditStop();return;} + const i=VJUMP[v]%cards.length; VJUMP[v]++; + location.hash='';location.hash=cards[i].id; /* re-fire :target ring on repeat visits */ + let pill=$('vaudit'); + if(!pill){pill=document.createElement('div');pill.id='vaudit';document.body.appendChild(pill); + pill.addEventListener('click',e=>{ + if(e.target.classList.contains('vx')){auditStop();return;} + auditNext(pill.dataset.v);});} + pill.dataset.v=v; + pill.innerHTML=`<span class="vdot" data-v="${v==='off'?'':v}"></span>`+ + `${VAUDIT_LBL[v]} ${i+1}/${cards.length} · next ▸<span class="vx" title="stop auditing">✕</span>`; +} +function auditStop(){const p=$('vaudit');if(p)p.remove();} +addEventListener('keydown',e=>{if(e.key==='Escape')auditStop();}); /* card(host, no, name, htmlOrBuild, note) — the declarative card record. htmlOrBuild: a legacy stage-HTML string, or a builder function (stage, rd) => handle that instantiates a GW.* widget into the stage; rd(txt) writes the card readout. */ @@ -1028,7 +1089,7 @@ function card(host, no, name, html, note){ `<div class="stagew">${isBuild?'':html}</div><div class="wrd" id="rd-${no}">—</div>`+ `<div class="opts"></div><div class="wnote">${note}</div>`; const lamp=c.querySelector('.vlamp'), VKEY='gv-'+no, VSTATES=['off','amber','green']; - const setV=v=>{lamp.dataset.v=v;lamp.title=VLAMP_TITLES[v];}; + const setV=v=>{lamp.dataset.v=v;lamp.title=VLAMP_TITLES[v];updateVTally();}; setV(localStorage.getItem(VKEY)||'off'); lamp.addEventListener('click',e=>{e.stopPropagation(); const v=VSTATES[(VSTATES.indexOf(lamp.dataset.v)+1)%3]; @@ -1070,7 +1131,7 @@ card(C,'05','Rotary knob', '<b>dial in a value.</b> Volume/gain the analog way. Drag up/down to turn; readout shows the level.'); card(C,'06','Segmented selector', (st,rd)=>GW.segmented(st,{onChange:(i,t)=>rd(t)}), - '<b>pick one of a few.</b> Timer type, layout mode. Click a segment; readout names the choice.'); + '<b>pick one of a few.</b> Timer type, layout mode. Click a segment; readout names the choice. The lit segment ships in amber, green, or red — the accent chips below switch it.'); card(C,'07','Chip toggle', (st,rd)=>GW.chipToggle(st,{on:true,onChange:(v,t)=>rd(t)}), '<b>inline binary.</b> A soft toggle inside a line of text. Click to flip; gold when on.'); @@ -1085,7 +1146,7 @@ card(C,'24','Rotary selector', '<b>pick one of N by position.</b> Printed detents, the pointer names the value. Click to turn; readout shows it.'); card(C,'25','Slide-rule dial', (st,rd)=>GW.slideRule(st,{onChange:(v,t)=>rd(t)}), - '<b>value on a printed scale.</b> A lit pointer glides a warm-backlit strip. Click a mark to jump, or focus it and press ←/→ (↑/↓); readout shows it.'); + '<b>value on a printed scale.</b> A lit pointer glides a warm-backlit strip. Printed numerals are the majors; the units between them carry minor ticks. Click any of them to jump, or focus and press ←/→ (↑/↓) to step a unit; readout shows it. Four faces — warm backlit, chrome, 80s black glass, marantz blue — on the chips below.'); card(C,'N01','Rocker power switch', (st,rd)=>GW.rocker(st,{on:true,onChange:(v,t)=>rd(t)}), '<b>hard on / off, lit legend.</b> A master power paddle — the pressed half glows. Click to rock.'); @@ -1127,7 +1188,7 @@ card(C,'R04','Bakelite fluted knob', '<b>the skirted console knob.</b> Scalloped bakelite skirt, glossy dome, amber index over a printed 0-10 scale. Drag up/down to turn. After a vintage console mixer knob.'); card(C,'R05','Filter slider bank', (st,rd)=>GW.filterBank(st,{onChange:(v,t)=>rd(t)}), - '<b>a wall of band faders.</b> One slider per band, arrow-head caps on black tracks, dB rail at the side. Drag any cap; readout names band and level. After a variable multi-band filter.'); + '<b>a wall of band faders.</b> Twelve bands on a screwed faceplate, dB rails both sides. Drag any cap; readout names band and level. Skins split three independent axes — panel (silver hi-fi after the Pioneer SG-9500 / studio black after the Technics SH-8065), cap shape (tall block fader after the Zaxcom Oasis / short ribbed / chrome T), and cap color (black white-index / red / green / blue / amber stripes / chrome / cream). The chips below mix them freely.'); card(C,'R06','Chicken-head selector', (st,rd)=>GW.chickenHead(st,{index:2,onChange:(i,t)=>rd(t)}), '<b>the pointer-lever switch.</b> The tapered bakelite lever IS the indicator — it aims at the engraved position. Click to step through. After a modulator mode switch.'); @@ -1523,24 +1584,33 @@ BOOST.forEach(no=>{const rd=document.getElementById('rd-'+no); el.innerHTML=el.innerHTML.replace(/\b(R\d{2}|N\d{2})\b/g, m=>ids.has(m)?`<a class="xref" href="#card-${m}">${m}</a>`:m);});})(); -/* slide-toggle style chips: demo rig for GW.slideToggle's constructor opts — - the named styles live in GW.slideToggle.STYLES; chips drive setStyle on the live instance */ -(function(){const h=$('card-01')?.gw;if(!h)return; +/* style chips: shared demo rig for builders with constructor style opts — + reads a builder's STYLES table and drives setStyle on the live card instance */ +function styleChips(no,STYLES,AXES){ + const h=$('card-'+no)?.gw; if(!h)return; const cardEl=h.el.closest('.card'); const row=document.createElement('div'); row.className='famchips'; - const AXES=[['on','on','amber'],['off','off','dark'],['off text','offText','white'],['thumb','thumb','light']]; for(const [label,axis,def] of AXES){ const g=document.createElement('span'); g.className='fgroup'; const lab=document.createElement('span'); lab.className='lab'; lab.textContent=label; g.appendChild(lab); const chips=[]; - for(const [name,o] of Object.entries(GW.slideToggle.STYLES[axis])){ + for(const [name,o] of Object.entries(STYLES[axis])){ const b=document.createElement('span'); b.className='fc'+(name===def?' on':''); b.style.background=o.dot; b.title=name; chips.push(b); b.addEventListener('click',()=>{h.setStyle(axis,name); chips.forEach(x=>x.classList.toggle('on',x===b));}); g.appendChild(b);} row.appendChild(g);} - cardEl.querySelector('.opts').appendChild(row);})(); + cardEl.querySelector('.opts').appendChild(row); +} +styleChips('01',GW.slideToggle.STYLES,[['on','on','amber'],['off','off','dark'],['off text','offText','white'],['thumb','thumb','light']]); +styleChips('R05',GW.filterBank.STYLES,[['panel','panel','silver'],['shape','caps','block'],['color','capColor','black']]); +styleChips('06',GW.segmented.STYLES,[['accent','accent','amber']]); +styleChips('25',GW.slideRule.STYLES,[['face','skin','warm']]); + +/* final tally pass: setV fires per card during build, but each card is still + detached at that moment, so the running counts lag by one — recount now */ +updateVTally(); </script> </body> </html> diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index c01972e..c12e5b7 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -234,17 +234,33 @@ GW.knob = function (host, opts = {}) { return { el: k, get: () => val, set }; }; -/* 06 segmented selector — pick one of a few */ +/* 06 segmented selector — pick one of a few. opts.accent picks the lit-segment + color from GW.segmented.STYLES (amber / green / red); defaults match the + stylesheet fallbacks. setStyle(axis, name) restyles a live instance. */ GW.segmented = function (host, opts = {}) { const onChange = opts.onChange || noop; const items = opts.items || ['TIMER', 'ALARM', 'POMO']; const seg = document.createElement('div'); seg.className = 'seg'; host.appendChild(seg); + const setStyle = (axis, name) => { + const o = (GW.segmented.STYLES[axis] || {})[name]; + if (!o) return; + for (const [k, v] of Object.entries(o.vars)) seg.style.setProperty(k, v); + }; + setStyle('accent', opts.accent || 'amber'); const btns = items.map(t => { const b = document.createElement('button'); b.textContent = t; seg.appendChild(b); return b; }); let idx; const set = i => { idx = i; btns.forEach((b, j) => b.classList.toggle('on', j === i)); onChange(i, items[i]); }; btns.forEach((b, i) => b.addEventListener('click', () => set(i))); set(opts.active || 0); - return { el: seg, get: () => idx, set }; + return { el: seg, get: () => idx, set, setStyle }; +}; +/* named styles per axis: dot = swatch color for pickers, vars = --seg-* overrides */ +GW.segmented.STYLES = { + accent: { + amber: { dot: 'var(--gold-hi)', vars: { '--seg-on-bg': 'linear-gradient(180deg,var(--amber-grad-top),var(--gold))', '--seg-on-ink': 'var(--panel)' } }, + green: { dot: '#8fb944', vars: { '--seg-on-bg': 'linear-gradient(180deg,#a9c95f,var(--pass))', '--seg-on-ink': 'var(--panel)' } }, + red: { dot: 'var(--fail)', vars: { '--seg-on-bg': 'linear-gradient(180deg,#d98a6f,var(--fail))', '--seg-on-ink': 'var(--panel)' } }, + }, }; /* 07 chip toggle — inline binary inside a line of text */ @@ -322,37 +338,89 @@ GW.rotarySelector = function (host, opts = {}) { return { el: rs, get: () => values[idx], set }; }; -/* 25 slide-rule dial — lit pointer on a printed scale; click a mark or arrow-key */ +/* 25 slide-rule dial — lit pointer on a printed scale. The printed numerals + are the majors; the integer units between them get minor ticks and are + selectable too. Click a numeral, a mark, or between marks; ←/→ (↑/↓) step + one unit. set() takes a stop index; get() returns the value. opts.skin picks + a face from GW.slideRule.STYLES (warm backlit / chrome / 80s black glass / marantz blue); + setStyle(axis, name) restyles a live instance. */ GW.slideRule = function (host, opts = {}) { const onChange = opts.onChange || noop; - const values = opts.values || [4, 6, 8, 10, 12]; + const majors = opts.values || [4, 6, 8, 10, 12]; const fmt = opts.fmt || (v => 'pos ' + v); const X = [12, 51, 90, 129, 168]; + /* expand to unit stops: every integer between adjacent numeric majors */ + const stops = []; + majors.forEach((v, i) => { + stops.push({ v, x: X[i], major: true }); + const b = majors[i + 1]; + if (typeof v === 'number' && typeof b === 'number') + for (let u = v + 1; u < b; u++) + stops.push({ v: u, x: X[i] + (X[i + 1] - X[i]) * (u - v) / (b - v), major: false }); + }); const t = document.createElement('span'); t.className = 'tuner'; t.tabIndex = 0; t.setAttribute('role', 'slider'); t.setAttribute('aria-label', 'slide-rule value'); - values.forEach((v, i) => { - t.insertAdjacentHTML('beforeend', `<span class="tick" style="left:${X[i]}px"></span><span class="mk" style="left:${X[i]}px">${v}</span>`); + const setStyle = (axis, name) => { + const o = (GW.slideRule.STYLES[axis] || {})[name]; + if (!o) return; + for (const [k, v] of Object.entries(o.vars)) t.style.setProperty(k, v); + }; + setStyle('skin', opts.skin || 'warm'); + stops.forEach(s => { + t.insertAdjacentHTML('beforeend', s.major + ? `<span class="tick" style="left:${s.x}px"></span><span class="mk" style="left:${s.x}px">${s.v}</span>` + : `<span class="tick mn" style="left:${s.x}px"></span>`); }); const ndl = document.createElement('span'); ndl.className = 'ndl'; t.appendChild(ndl); host.appendChild(t); let idx; const set = i => { - idx = Math.max(0, Math.min(values.length - 1, i)); - ndl.style.left = X[idx] + 'px'; - t.querySelectorAll('.mk').forEach((m, j) => m.classList.toggle('on', j === idx)); - onChange(values[idx], fmt(values[idx])); + idx = Math.max(0, Math.min(stops.length - 1, i)); + ndl.style.left = stops[idx].x + 'px'; + t.querySelectorAll('.mk').forEach(m => m.classList.toggle('on', +m.textContent === stops[idx].v)); + onChange(stops[idx].v, fmt(stops[idx].v)); }; t.addEventListener('click', e => { - const r = t.getBoundingClientRect(), x = e.clientX - r.left; - let best = 0, bd = 1e9; X.slice(0, values.length).forEach((tx, j) => { const d = Math.abs(tx - x); if (d < bd) { bd = d; best = j; } }); + const r = t.getBoundingClientRect(); + /* rect is visual px but stop coords are layout px — divide the CSS zoom out */ + const x = (e.clientX - r.left) * (t.offsetWidth / r.width); + let best = 0, bd = 1e9; stops.forEach((s, j) => { const d = Math.abs(s.x - x); if (d < bd) { bd = d; best = j; } }); set(best); t.focus(); }); t.addEventListener('keydown', e => { if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); set(idx - 1); } else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); set(idx + 1); } }); - set(opts.index !== undefined ? opts.index : 2); - return { el: t, get: () => values[idx], set }; + const initV = opts.value !== undefined ? opts.value : majors[opts.index !== undefined ? opts.index : 2]; + const init = stops.findIndex(s => s.v === initV); + set(init < 0 ? 0 : init); + return { el: t, get: () => stops[idx].v, set, setStyle }; +}; +/* named faces: dot = swatch color for pickers, vars = --tn-* overrides. + warm restates the stylesheet fallbacks so switching back is exact. */ +GW.slideRule.STYLES = { + skin: { + warm: { dot: 'var(--gold-hi)', vars: { + '--tn-bg': 'linear-gradient(180deg,#191510,#0b0908)', '--tn-brd': '#2a251c', + '--tn-glow': 'rgba(var(--glow-lo),.12)', '--tn-tick': 'var(--steel)', '--tn-ink': 'var(--steel)', + '--tn-on': 'var(--gold-hi)', '--tn-onglow': 'rgba(var(--glow-hi),.6)', + '--tn-ndl': 'var(--fail)', '--tn-ndlglow': 'rgba(203,107,77,.85)' } }, + chrome: { dot: '#dfe6f2', vars: { + '--tn-bg': 'linear-gradient(180deg,#e9e9e5,#bcbcb6)', '--tn-brd': '#8e8e88', + '--tn-glow': 'rgba(255,255,255,.28)', '--tn-tick': '#3c3a34', '--tn-ink': '#3c3a34', + '--tn-on': '#14110e', '--tn-onglow': 'none', + '--tn-ndl': 'var(--fail)', '--tn-ndlglow': 'rgba(203,107,77,.45)' } }, + black: { dot: '#1c1a18', vars: { + '--tn-bg': 'linear-gradient(180deg,#17181a,#08090a)', '--tn-brd': '#000', + '--tn-glow': 'rgba(255,255,255,.05)', '--tn-tick': '#c8cac8', '--tn-ink': '#c8cac8', + '--tn-on': '#f2f4f2', '--tn-onglow': 'rgba(242,244,242,.5)', + '--tn-ndl': '#ff3b28', '--tn-ndlglow': 'rgba(255,59,40,.9)' } }, + blue: { dot: '#8fb4d8', vars: { + '--tn-bg': 'linear-gradient(180deg,#0c1520,#070c14)', '--tn-brd': '#1c2c40', + '--tn-glow': 'rgba(120,170,230,.16)', '--tn-tick': '#8fb4d8', '--tn-ink': '#8fb4d8', + '--tn-on': '#cfe4ff', '--tn-onglow': 'rgba(150,200,255,.7)', + '--tn-ndl': '#e8f2ff', '--tn-ndlglow': 'rgba(180,220,255,.9)' } }, + }, }; /* N01 rocker power switch — hard on/off, lit legend */ @@ -676,41 +744,129 @@ GW.flutedKnob = function (host, opts = {}) { return { el: s, get: () => val, set }; }; -/* R05 filter slider bank — one fader per band, teal arrow caps on black tracks */ +/* R05 filter slider bank — a dense fader wall on a real faceplate. Period skins + as constructor opts backed by GW.filterBank.STYLES, three independent axes: + panel (silver hi-fi aluminum / studio black), caps — the cap SHAPE + (chrome T / short ribbed / tall block fader), and capColor — the cap + FINISH (black white-index / color stripes / chrome / cream). opts.style + picks a native trio (silver+chrome+chrome, studio+ribbed+red); + opts.panel / opts.caps / opts.capColor override an axis; setStyle(axis, name) + restyles live (values kept). After the Pioneer SG-9500, the Technics + SH-8065, and the Zaxcom Oasis block faders. */ GW.filterBank = function (host, opts = {}) { const onChange = opts.onChange || noop; - const freqs = opts.freqs || [78, 136, 235, 406, 701, 1210, 2090, 3620]; - const vals = (opts.values || [18, 30, 42, 25, 35, 55, 20, 48]).slice(); - const fmtHz = f => f < 1000 ? f : (f / 1000) + 'k'; - const s = stageSvg(host, 'rsvg', 190, 96); - const y0 = 14, y1 = 84, x0 = 30, dx = 19.5; - for (const [lbl, y] of [['0', y0], ['20', y0 + (y1 - y0) / 3], ['40', y0 + 2 * (y1 - y0) / 3], ['60', y1]]) - svgEl(s, 'text', { x: 14, y: y + 2, 'text-anchor': 'end', 'font-size': 6, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = lbl; - svgEl(s, 'text', { x: 14, y: 94, 'text-anchor': 'end', 'font-size': 5.5, 'font-family': 'var(--mono)', fill: 'var(--dim)' }).textContent = 'dB'; + const freqs = opts.freqs || [78, 113, 163, 235, 339, 487, 701, 1010, 1450, 2090, 3010, 4340]; + const vals = (opts.values || [18, 30, 42, 25, 35, 55, 20, 48, 38, 26, 44, 32]).slice(0, freqs.length); + while (vals.length < freqs.length) vals.push(30); + const fmtHz = f => f < 1000 ? f : (f / 1000).toFixed(1).replace(/\.0$/, '') + 'k'; + const ST = GW.filterBank.STYLES; + const native = { + silver: { caps: 'chrome', capColor: 'chrome' }, + studio: { caps: 'ribbed', capColor: 'red' }, + }; + const nat = native[opts.style] || {}; + const cur = { + panel: ST.panel[opts.panel] ? opts.panel : (ST.panel[opts.style] ? opts.style : 'silver'), + caps: ST.caps[opts.caps] ? opts.caps : (nat.caps || 'block'), + capColor: ST.capColor[opts.capColor] ? opts.capColor : (nat.capColor || 'black'), + }; + gradDef('fbPlateSilver', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d8d8d4'], ['.5', '#c6c6c1'], ['1', '#adada7']]); + gradDef('fbPlateStudio', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#221f1c'], ['1', '#131110']]); + gradDef('sfChrome', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f0efec'], ['.45', '#c4c1b9'], ['1', '#75726a']]); + gradDef('fbCapBlack', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#2a2724'], ['1', '#171412']]); + gradDef('fbCapCream', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#f2ecd8'], ['1', '#cfc8ae']]); + const s = stageSvg(host, 'rsvg', 250, 112); + const y0 = 20, y1 = 92, x0 = 36, dx = (214 - x0) / (freqs.length - 1); + const yOf = db => y0 + db / 60 * (y1 - y0); const caps = []; + const place = i => caps[i].setAttribute('transform', `translate(0,${yOf(vals[i])})`); + const drawCap = (g, kind, x) => { + const c = ST.capColor[cur.capColor]; + if (kind === 'chrome') { + svgEl(g, 'rect', { x: x - 6, y: -3.25, width: 12, height: 6.5, rx: .9, fill: c.body, stroke: c.edge, 'stroke-width': .6 }); + /* bare metal needs no painted stripe — chrome-on-chrome stays lineless */ + if (cur.capColor !== 'chrome') + svgEl(g, 'line', { x1: x - 6, y1: 0, x2: x + 6, y2: 0, stroke: c.index, 'stroke-width': 1 }); + svgEl(g, 'line', { x1: x - 6, y1: 2, x2: x + 6, y2: 2, stroke: c.rib, 'stroke-width': .5 }); + } else if (kind === 'ribbed') { + svgEl(g, 'rect', { x: x - 5, y: -4, width: 10, height: 8, rx: 1, fill: c.body, stroke: c.edge, 'stroke-width': .6 }); + for (const dy of [-2.4, 2.4]) + svgEl(g, 'line', { x1: x - 4, y1: dy, x2: x + 4, y2: dy, stroke: c.rib, 'stroke-width': .6 }); + svgEl(g, 'line', { x1: x - 5, y1: 0, x2: x + 5, y2: 0, stroke: c.index, 'stroke-width': 1.1 }); + } else { /* block — tall ribbed fader after the Zaxcom Oasis; fine rib + groups above and below a mid-cap index line that marks the value */ + svgEl(g, 'rect', { x: x - 4.5, y: -8, width: 9, height: 16, rx: 1.1, fill: c.body, stroke: c.edge, 'stroke-width': .6 }); + for (const dy of [-6.4, -4.8, -3.2, -1.6, 1.6, 3.2, 4.8, 6.4]) + svgEl(g, 'line', { x1: x - 3.7, y1: dy, x2: x + 3.7, y2: dy, stroke: c.rib, 'stroke-width': .6 }); + svgEl(g, 'line', { x1: x - 4.5, y1: 0, x2: x + 4.5, y2: 0, stroke: c.index, 'stroke-width': 1.2 }); + } + }; const set = (i, db) => { - db = Math.max(0, Math.min(60, db)); vals[i] = db; - caps[i].setAttribute('transform', `translate(0,${14 + db / 60 * 70})`); + db = Math.max(0, Math.min(60, db)); vals[i] = db; place(i); onChange({ band: i, hz: freqs[i], db }, `${fmtHz(freqs[i])} Hz · −${Math.round(db)} dB`); }; - freqs.forEach((f, i) => { - const x = x0 + i * dx; - svgEl(s, 'text', { x, y: 8, 'text-anchor': 'middle', 'font-size': 5.2, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = fmtHz(f); - svgEl(s, 'line', { x1: x, y1: y0, x2: x, y2: y1, stroke: '#0a0908', 'stroke-width': 3.4, 'stroke-linecap': 'round' }); - svgEl(s, 'line', { x1: x - .7, y1: y0, x2: x - .7, y2: y1, stroke: 'rgba(255,255,255,.05)', 'stroke-width': .7 }); - const cap = svgEl(s, 'g', {}); - svgEl(cap, 'polygon', { points: `${x - 8.5},${-4.5} ${x - 2.5},0 ${x - 8.5},${4.5}`, fill: 'var(--vfd)', stroke: '#123028', 'stroke-width': .6 }); - svgEl(cap, 'polygon', { points: `${x + 8.5},${-4.5} ${x + 2.5},0 ${x + 8.5},${4.5}`, fill: 'var(--vfd)', stroke: '#123028', 'stroke-width': .6 }); - svgEl(cap, 'circle', { cx: x, cy: 0, r: 1.5, fill: '#123028' }); - caps.push(cap); - /* paint the initial position without announcing it */ - cap.setAttribute('transform', `translate(0,${14 + vals[i] / 60 * 70})`); - const hit = svgEl(s, 'rect', { x: x - 9.5, y: y0 - 6, width: 19, height: y1 - y0 + 12, fill: 'transparent' }); - hit.style.cursor = 'ns-resize'; - dragY(hit, pct => set(i, (100 - pct) / 100 * 60)); - }); + const render = () => { + const st = ST.panel[cur.panel]; + s.replaceChildren(); caps.length = 0; + svgEl(s, 'rect', { x: 1.5, y: 1.5, width: 247, height: 109, rx: 5, fill: st.plate, stroke: st.edge, 'stroke-width': 1 }); + svgEl(s, 'rect', { x: 3, y: 3, width: 244, height: 106, rx: 4, fill: 'none', stroke: st.bevel, 'stroke-width': .7 }); + [[9, 9], [241, 9], [9, 103], [241, 103]].forEach(([sx, sy], k) => { + svgEl(s, 'circle', { cx: sx, cy: sy, r: 2.7, fill: st.screw, stroke: 'rgba(0,0,0,.55)', 'stroke-width': .6 }); + svgEl(s, 'line', { x1: sx - 1.9, y1: sy, x2: sx + 1.9, y2: sy, stroke: 'rgba(0,0,0,.6)', 'stroke-width': .7, transform: `rotate(${25 + k * 40},${sx},${sy})` }); + }); + for (let db = 0; db <= 60; db += 10) { + const y = yOf(db), major = db % 20 === 0, len = major ? 4 : 2.5; + svgEl(s, 'line', { x1: 24 - len, y1: y, x2: 24, y2: y, stroke: st.dim, 'stroke-width': major ? .9 : .6 }); + svgEl(s, 'line', { x1: 226, y1: y, x2: 226 + len, y2: y, stroke: st.dim, 'stroke-width': major ? .9 : .6 }); + if (major) { + svgEl(s, 'text', { x: 17, y: y + 2, 'text-anchor': 'end', 'font-size': 5.2, 'font-family': 'var(--mono)', fill: st.ink }).textContent = db; + svgEl(s, 'text', { x: 233, y: y + 2, 'text-anchor': 'start', 'font-size': 5.2, 'font-family': 'var(--mono)', fill: st.ink }).textContent = db; + } + } + svgEl(s, 'text', { x: 20, y: 100, 'text-anchor': 'end', 'font-size': 4.6, 'font-family': 'var(--mono)', fill: st.dim }).textContent = 'dB'; + freqs.forEach((f, i) => { + const x = x0 + i * dx; + svgEl(s, 'text', { x, y: 13, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)', fill: st.ink }).textContent = fmtHz(f); + svgEl(s, 'line', { x1: x, y1: y0, x2: x, y2: y1, stroke: st.track, 'stroke-width': st.trackW, 'stroke-linecap': 'round' }); + if (st.trackHi) svgEl(s, 'line', { x1: x - st.trackW / 2 - .35, y1: y0, x2: x - st.trackW / 2 - .35, y2: y1, stroke: st.trackHi, 'stroke-width': .7 }); + const cap = svgEl(s, 'g', {}); + drawCap(cap, cur.caps, x); + caps.push(cap); place(i); + const hit = svgEl(s, 'rect', { x: x - dx / 2, y: y0 - 6, width: dx, height: y1 - y0 + 12, fill: 'transparent' }); + hit.style.cursor = 'ns-resize'; + dragY(hit, pct => set(i, (100 - pct) / 100 * 60)); + }); + }; + const setStyle = (axis, name) => { + if (!ST[axis] || !ST[axis][name]) return; + cur[axis] = name; render(); + }; + render(); onChange(null, 'drag a band'); - return { el: s, get: () => vals.slice(), set }; + return { el: s, get: () => vals.slice(), set, setStyle }; +}; +/* named styles per axis: dot = swatch color for pickers. caps is the cap + shape; capColor is the cap finish (body + rib + index line), applicable to + any shape. */ +GW.filterBank.STYLES = { + panel: { + silver: { dot: '#c9c9c5', plate: 'url(#fbPlateSilver)', edge: '#8e8e88', bevel: 'rgba(255,255,255,.4)', ink: '#33332f', dim: '#5c5c56', screw: '#9c9c96', track: '#121210', trackW: 2.0, trackHi: null }, + studio: { dot: '#1c1a18', plate: 'url(#fbPlateStudio)', edge: '#000', bevel: 'rgba(255,255,255,.06)', ink: '#b9b4a6', dim: '#6f6a5e', screw: '#3c3832', track: '#050505', trackW: 2.2, trackHi: 'rgba(255,255,255,.05)' }, + }, + caps: { + block: { dot: '#2c2824' }, + ribbed: { dot: '#6f6a5e' }, + chrome: { dot: '#dfe6f2' }, + }, + capColor: { + black: { dot: '#211e1b', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: '#f2f4f2', edge: '#000' }, + red: { dot: 'var(--sevred)', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: 'var(--sevred)', edge: '#000' }, + green: { dot: 'var(--sevgrn)', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: 'var(--sevgrn)', edge: '#000' }, + blue: { dot: '#4f9fe0', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: '#4f9fe0', edge: '#000' }, + amber: { dot: 'var(--gold-hi)', body: 'url(#fbCapBlack)', rib: 'rgba(255,255,255,.16)', index: 'var(--gold-hi)', edge: '#000' }, + chrome: { dot: '#dfe6f2', body: 'url(#sfChrome)', rib: 'rgba(0,0,0,.28)', index: '#14110e', edge: '#3c3a34' }, + cream: { dot: '#efe9d4', body: 'url(#fbCapCream)', rib: 'rgba(0,0,0,.18)', index: '#3a3128', edge: '#8a8474' }, + }, }; /* R06 chicken-head selector — tapered lever aims at the position */ @@ -3953,7 +4109,7 @@ const GW_CSS = ` .seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden} .seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b;padding:7px 11px;cursor:pointer} .seg button:last-child{border-right:0} -.seg button.on{background:linear-gradient(180deg,var(--amber-grad-top),var(--gold));color:var(--panel);font-weight:700} +.seg button.on{background:var(--seg-on-bg,linear-gradient(180deg,var(--amber-grad-top),var(--gold)));color:var(--seg-on-ink,var(--panel));font-weight:700} /* engraved section label */ .engrave{width:180px;color:var(--steel);font-size:.62rem;letter-spacing:.3em;text-transform:uppercase;cursor:pointer; @@ -3983,14 +4139,16 @@ const GW_CSS = ` /* slide-rule tuner dial */ .tuner{width:180px;height:46px;position:relative;border-radius:6px;overflow:hidden;cursor:pointer; - background:linear-gradient(180deg,#191510,#0b0908);border:1px solid #2a251c; - box-shadow:inset 0 0 20px rgba(var(--glow-lo),.12),inset 0 1px 0 rgba(255,255,255,.03)} -.tuner .tick{position:absolute;top:6px;width:1px;height:11px;background:var(--steel);transform:translateX(-50%)} -.tuner .mk{position:absolute;bottom:8px;transform:translateX(-50%);color:var(--steel);font-size:10px} -.tuner .mk.on{color:var(--gold-hi);text-shadow:0 0 6px rgba(var(--glow-hi),.6)} + background:var(--tn-bg,linear-gradient(180deg,#191510,#0b0908));border:1px solid var(--tn-brd,#2a251c); + box-shadow:inset 0 0 20px var(--tn-glow,rgba(var(--glow-lo),.12)),inset 0 1px 0 rgba(255,255,255,.03)} +.tuner .tick{position:absolute;top:6px;width:1px;height:11px;background:var(--tn-tick,var(--steel));transform:translateX(-50%)} +.tuner .tick.mn{height:6px;opacity:.5} +.tuner .mk{position:absolute;bottom:8px;transform:translateX(-50%);color:var(--tn-ink,var(--steel));font-size:10px} +.tuner .mk.on{color:var(--tn-on,var(--gold-hi));text-shadow:0 0 6px var(--tn-onglow,rgba(var(--glow-hi),.6))} +.tuner:focus{outline:1px solid rgba(var(--glow-lo),.5);outline-offset:2px} .tuner:focus-visible{outline:2px solid var(--gold);outline-offset:2px} .tuner .ndl{position:absolute;top:3px;bottom:3px;width:2px;margin-left:-1px;border-radius:1px; - background:var(--fail);box-shadow:0 0 7px rgba(203,107,77,.85);transition:left .25s} + background:var(--tn-ndl,var(--fail));box-shadow:0 0 7px var(--tn-ndlglow,rgba(203,107,77,.85));transition:left .25s} /* nixie tube */ .nixie{display:inline-flex;gap:5px;cursor:pointer} @@ -22,6 +22,9 @@ The vocabulary is open — topic tags are coined as needed — so these are conv - *Topic / area* (open): the subsystem a task touches — e.g. =:hyprland:= =:waybar:= =:mpd:= =:music:= =:network:= =:tooling:= =:llm:= =:eask:= =:pocketbook:= =:cmail:=. Coin a new one when it aids filtering. * Archsetup Open Work ** DOING [#B] Widget gallery upgrades :feature:design: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-13 +:END: Usability + documentation pass over the [[file:docs/prototypes/panel-widget-gallery.html][panel widget gallery]], orthogonal to the component-generation spec work, so it runs on the =gallery-upgrades= branch (squash merge to main after Craig's UI confirmation + tweaks). Items 1-4 run as a no-approvals speedrun (Craig authorized 2026-07-12); item 5 is a joint brainstorm after the merge. *** 2026-07-12 Sun @ 12:59:48 -0500 Added the card size toggle (1x/2x/3x, default 3x) Masthead size chips apply CSS zoom per grid; 2x/3x drop the 1320px wrap cap so wide monitors get the room. CDP-verified: 84 cards, no exceptions, fader drag and toggle click both track at 3x (drag helpers are rect-ratio based, so zoom is transparent to them). @@ -66,6 +69,7 @@ Brainstorm WITH Craig (after the squash merge, not solo): break the components d ** DOING [#B] Retro widget catalogue :feature:design: :PROPERTIES: :SPEC_ID: 3ac0d42c-db1a-4d21-bce4-e63785fef0ba +:LAST_REVIEWED: 2026-07-13 :END: The panel widget gallery ([[file:docs/prototypes/panel-widget-gallery.html][docs/prototypes/panel-widget-gallery.html]]) grows into a retro-instrument component catalogue: reference photos of period hardware → gallery cards (the visual + behavioral spec) → reusable components for three targets (emacs svg.el, web/React, waybar). Tokens single-sourced in [[file:docs/prototypes/tokens.json][tokens.json]] (gen_tokens.py emits web/waybar/elisp); svg.el proof widget shipped (gallery-widget.el, needle gauge). Reference photos live in [[file:working/retro-stereo-widgets/][working/retro-stereo-widgets/]]. Collection converged at R56 (109 cards, all behaviorally verified; probes in [[file:tests/gallery-probes/][tests/gallery-probes/]]). @@ -88,21 +92,15 @@ Restyle the audio panel's GTK CSS onto =tokens-waybar.css= + the banked composit After ~5 hand ports, weigh widget-level codegen with evidence (mechanical duplication vs judgment per port). Recorded as a dated decision in the spec; go spawns its own spec. *** TODO Flip the spec to IMPLEMENTED When the phases above close: status heading keyword → =IMPLEMENTED=, dated history line with the reason, Metadata =Status= mirror. Three lines, one file. -** DONE [#C] Net panel: Enterprise error never dismisses :bug:dotfiles:network: -CLOSED: [2026-07-12 Sun] -Fixed in dotfiles =a157bed=. Root cause: error toasts are sticky by design (so background refreshes can't wipe an unread error), but the enterprise join hint's flow posts no follow-up status and row clicks post none either, so nothing ever replaced it. Fix: a window-wide capture-phase click gesture dismisses a sticky toast on the user's next interaction; policy in =viewmodel.toast_action_plan= (unit-tested), timed toasts and background clears unchanged. Panel smoke run confirms launch/doctor/close with the gesture installed. Pointer-level dismiss is a manual-testing child (AT-SPI can't drive pointer gestures). Repro screenshot: =~/pictures/screenshots/2026-07-10_195911.png=. -** TODO [#C] Notification/capture popup grows too large :bug:hyprland: -A popup (Craig calls it the notification/capture popup) gets too large; it needs a max on its scale so it never exceeds a threshold — his guess is roughly X by 120 (from roam inbox, 2026-07-11). Confirm which popup and window rule. Minor severity × most users, frequently = P3 = [#C]. -** DONE [#C] Net diagnostics leak connection names + SSIDs into copyable report and --json :bug:dotfiles:network:solo: -CLOSED: [2026-07-12 Sun] -Resolved in dotfiles =df1543a=: the =redact_ssid= toggle now scrubs saved profile names, active SSIDs, envelope-carried names, and =.nmconnection= keyfile basenames from the copyable report and the diag/doctor =--json= envelopes (one systemic pass in =redact.py=; MAC/IP scrub applies to those envelopes too). On-screen output and functional envelopes (status/list) unchanged. 15 new tests; live-verified on ratio (toggle on removes the active connection name from =diagnose --json=, default unchanged). -The net doctor's copyable report (=report.py=, =scrub_text=) scrubs only MAC/IP, and =net diag/doctor --json= (=cli.py=) dumps the raw dict with no redaction. SSID redaction lives only in the event log (=redact_event=, gated on =redact_ssid=, default off). So a connection name (usually the SSID) appears in the clear in the link-step evidence and in every =--json= consumer — the copyable report is exactly the text a user pastes into a bug report. Secrets (PSK/password/token/portal URL) are already stripped, so this is names, not credentials: Minor severity, graded on severity alone per the privacy carve-out. - -Split out of the 2026-07-11 net-doctor-expansion spec review: that spec's new rival-manager/keyfile-perms verdicts keep parity with this pre-existing behavior rather than half-solve it. Fix shape: extend redaction to cover the connection name + keyfile basename across the copyable report and =--json= (one systemic pass, not per-verdict), with a redaction test. Engine-wide, so it wants one coherent change rather than being bolted onto the expansion work. - -** TODO [#B] Net doctor expansion v1 — build the READY spec :feature:dotfiles:network: +** TODO [#C] Org-capture float popup grows too large :bug:hyprland:quick:solo: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-13 +:END: +The org-capture float (confirmed by Craig, 2026-07-13) gets too large; it needs a max on its scale so it never exceeds a threshold — his guess is roughly X by 120 (from roam inbox, 2026-07-11). Fix shape: a maxsize windowrule on the org-capture float rule in the dotfiles hyprland config (archsetup owns dotfiles end to end). Pre-flight question: the exact cap values. Minor severity × most users, frequently = P3 = [#C]. +** TODO [#B] Net doctor expansion v1 — VM live verification :feature:dotfiles:network: :PROPERTIES: :SPEC_ID: ce29b103-ed9d-4f56-bf8c-9ed8fe680ff3 +:LAST_REVIEWED: 2026-07-13 :END: Build the [[file:docs/specs/2026-07-11-net-doctor-expansion-spec.org][net doctor expansion]] (IMPLEMENTED). Adds the control-plane cluster (rival-manager / nm-masked / keyfile-perms) and a sharper auth verdict to the shipped net doctor (=~/.dotfiles/net/=). Archsetup owns the dotfiles work end to end — edit, test, commit, and push in =~/.dotfiles=, then drop an inbox note. All build phases shipped and fake-verified; the one open piece is the VM live verification below. *** 2026-07-11 Sat @ 02:47:47 -0500 Built the read-only control-plane probe @@ -120,21 +118,6 @@ On dotfiles main (=12e3e76=, pushed). =gather_context= derives an auth cause on *** 2026-07-12 Sun @ 09:14:00 -0500 Flipped the net spec to IMPLEMENTED and logged the vNext items Spec status heading now IMPLEMENTED (dated history line + Status mirror); all four phase headings DONE. vNext items (flaky/drops cluster, DoT/DNSSEC verdict, profile hygiene) logged as the "Net doctor vNext" task. The privileged-fix live halves remain with the VM live-verification sub-task and the manual-testing checklist — findings there come back as bugs. -** DONE [#B] Bt doctor expansion v1 — build the READY spec :feature:dotfiles:bluetooth: -CLOSED: [2026-07-12 Sun] -:PROPERTIES: -:SPEC_ID: 3d4d61c4-e5df-44e9-b8e0-40b31452c3f7 -:END: -Build the [[file:docs/specs/2026-07-11-bt-doctor-expansion-spec.org][bt doctor expansion]] (IMPLEMENTED). Adds a dmesg firmware-hint probe (names the missing blob on a no-adapter fault) and a boot-enablement probe (catches an adapter disabled at boot) to the shipped bt doctor (=~/.dotfiles/bluetooth/=). Archsetup owns the dotfiles work end to end. All phases shipped and fake-verified (d19fdca, f05a9b4, d7d859f); the live reboot-persistence half is on the manual-testing checklist. -*** 2026-07-11 Sat @ 03:06:32 -0500 Built the two read-only probes -New module =bluetooth/src/bt/probes.py= plus =doctor.py= wiring, on dotfiles main (=d19fdca=, pushed). Two reads the diagnose chain never did: =firmware_hint()= scans the current boot's kernel log for per-vendor firmware-load failures (Intel ibt-*.sfi, MediaTek BT_RAM_CODE, Realtek rtl_bt, Broadcom .hcd, Qualcomm QCA), returning the named blob via a bounded =cmd.run(journalctl -k)= that reuses the =doctor.py:84= precedent; =boot_enablement()= reads three boot-persistence signals (bluez AutoEnable from main.conf [Policy], =systemctl is-enabled bluetooth=, whether TLP lists bluetooth in =DEVICES_TO_DISABLE_ON_STARTUP=). =diagnose()= gates the firmware read to the no-adapter branch and the boot read to the soft-blocked/powered-off branch, so a healthy run reads neither; the raw signals ride a new =probes= key that =doctor()= carries into =--json=. Detection only: no verdict, formatter, or repair change (that's Phase 1). Every read degrades to None on an unreadable tool/file, so a probe that can't see never invents a fault. AutoEnable absent/unset reads None, not false, matching bluez's compiled default of true, so only an explicit =AutoEnable=false= is the fault. New env roots for tests (=BT_MAIN_CONF=, =BT_TLP_CONF=, defaulting to absent temp paths in the Sandbox base so no test reads real /etc); =fake-journalctl= branches on =-k=, =fake-systemctl= answers =is-enabled bluetooth=. 125 bt tests, full =make test= green; =/review-code= approved (no Critical/Important; one Minor noting the firmware read also covers the btctl-unavailable branch, harmless). Inbox note sent to dotfiles. -*** 2026-07-11 Sat @ 03:16:59 -0500 Built the firmware-hint Guide verdict -On dotfiles main (=f05a9b4=, pushed). The no-adapter step now names the blob: a new =_no_adapter_step= consults =probes.firmware_hint()= on a genuine no-adapter fault and, on a per-vendor signature match, sets =evidence= to "no Bluetooth adapter found — <Vendor> firmware <blob> failed to load" and =next_action= to "update linux-firmware and reboot", tagged with a new =code="no-adapter-firmware"= so a =--json= consumer can branch without string-matching. A clean log keeps the generic hardware/driver verdict. A Guide, not a repair: the step carries no =repair= action, so =--fix= never touches it, and it needs no privilege model (so Phase 1 lands independently of the shared cross-panel model). A missing =bluetoothctl= (=BtctlError=) short-circuits before the firmware read, so the verdict fires only on a real no-adapter fault, not a broken install — this also tightened Phase 0 (which read the log on both None branches) to the genuine no-adapter case. =_mk= gained a uniform =code= key (default None) added to every diagnose step, mirroring the existing =repair= key; no test asserts an exact step key-set, verified. =format_doctor_human= already renders =evidence=/=next_action=, so no formatter change. 133 bt tests (+8), full =make test= green; =/review-code= clean. Inbox note sent to dotfiles. -*** 2026-07-11 Sat @ 06:48:21 -0500 Built the persistent-power verdict + fix -On dotfiles main (=d7d859f=, pushed). =_powered_step= consumes the bt Phase 0 boot-enablement probe: AutoEnable explicitly false, service disabled at boot, or TLP listing bluetooth → =powered-off-persistent= (code + evidence naming the cause) carrying the =persist-power= repair; absent/unset config reads as auto-enable-on (bluez default) → the plain =power-on=, so healthy machines can't false-positive. The =persist-power= repair fixes only the causes set (AutoEnable=true, =systemctl enable bluetooth=, drop bluetooth from the TLP list), powers the adapter on now, and verifies each cause cleared. Config edits are pure idempotent text transforms in a new =bootconf= module (comments preserved), staged and installed via a fixed-destination =cp= verb so the write needs root but the mutation is unit-testable. =priv.py= gained three narrow verbs (=enable-bluetooth=, =write-main-conf=, =write-tlp-conf=). The repair is Privileged and resolves through =panelkit= before running: can't-elevate degrades to the guide. The bt shim gained =panelkit= on its path. 287 bt tests + 65 make-test suites green, review-code Approve, voice. Inbox note sent. Live half (real reboot persistence) is the VM/manual checklist. -*** 2026-07-12 Sun @ 09:14:00 -0500 Flipped the bt spec to IMPLEMENTED and logged the vNext items -Spec status heading now IMPLEMENTED (dated history line + Status mirror); all four phase headings DONE. vNext items (stale-bond signature, connection-parameter hints, bt-audio-profile expansion) logged as the "Bt doctor vNext" task. The live half (real reboot persistence) remains on the manual-testing checklist — findings there come back as bugs. - ** TODO [#D] Net doctor vNext :feature:dotfiles:network: Deferred from the [[file:docs/specs/2026-07-11-net-doctor-expansion-spec.org][net doctor expansion spec]] (IMPLEMENTED, v1 shipped): event-log correlation for the flaky/drops cluster (powersave, roaming stalls, USB autosuspend, no-reconnect-after-resume, firmware crashloop drop signatures — needs history a one-shot probe can't see); a DoT/DNSSEC-specific verdict distinguishing "the venue resolver mangles DNSSEC" from the generic DNS-not-resolving; per-profile autoconnect/duplicate-profile hygiene. @@ -174,21 +157,10 @@ Scope note (Craig, 2026-07-07): realtime lamp *behavior* only. The maintenance c Addendum (Craig, 2026-07-07): DO backport the 3.5-entry height convention — every panel's output well caps at 3.5 visible entries, the half-visible entry being the scroll cue, with the dark slate-on-black scrollbar. Layout stays compact per above; only the height cap + scroll affordance carries over. -** DONE [#B] One copy + close control pair on every output wall :feature:dotfiles:solo: -CLOSED: [2026-07-12 Sun] -Resolved in dotfiles =dccd744=: every wall carries the o-copy/o-clear overlay pair. bluetooth gained the copy key (transcript via the new =viewmodel.step_copy_line=, CLI-shaped); maint traded its header COPY key for the overlay pair, kept HIDE, and its ✕ clears the session log via =PanelModel.wall_clear=; the four hand-rolled wl-copy calls collapsed into =panelkit.clipboard.copy_text= (PANELKIT_WLCOPY test seam), moving maint off the GTK clipboard so its copies survive the panel closing too. 15 new tests (8 clipboard, 4 step_copy_line, 3 wall_clear); full suite 66 green; all four panel smokes run live off-workspace — maint + audio fully pass, net + bt fail only the pre-existing state-word startup race. - -Converge all four instrument-console output walls on the net panel's well controls: a copy glyph and a ✕ close, as an overlay at the top right, hidden until content lands. Craig's call, 2026-07-10, while reviewing the audio doctor's wall: "network panels as standard across all others, make it consistent." - -Where they stand today, no two alike. net has copy + ✕ (=net/src/net/gui.py=, the =o-copy= / =o-clear= overlay). bluetooth has ✕ but no copy. maint has COPY + HIDE as keys in a header row, and no ✕. audio just gained copy + ✕ (dotfiles =bd33440=). - -Work: give bluetooth a copy key, give maint the overlay pair, and lift the four hand-rolled =_copy_output= implementations into one shared helper rather than a fifth copy. maint keeps HIDE alongside close, because its wall is a persistent session action log you collapse and keep, where net's, bt's, and audio's are per-run results you dismiss. - -Copy text is per panel but one rule: it pastes as that panel's CLI prints, so the paste lines up with the terminal a user is already looking at. audio's =viewmodel.wall_copy_text()= is the worked example. - -Consistent with the 2026-07-07 scope note on the sibling task above: net's compact glyph overlay is what standardizes, not maint's wide COPY-key header row. - ** DOING [#B] Run-time privilege model, standard across every panel doctor :feature:dotfiles: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-13 +:END: The audio input/output doctor is gaining a run-time privilege model (see [[file:docs/specs/2026-07-10-audio-doctor-input-side-spec.org][docs/specs/2026-07-10-audio-doctor-input-side-spec.org]], decision "The doctor may use sudo, resolved by context at run time"). Craig's call, 2026-07-10: make it a standard, "revise the other panels to be consistent with these changes." The model: a doctor resolves its privilege at run time from three signals — passwordless sudo available (=sudo -n true=, which never hangs), a tty to prompt at, and whether it is the GUI panel. Four remedy classes: Auto (user-scope, reversible), Privileged (needs sudo — runs where passwordless, prompts on a CLI tty, degrades to Guide in a GUI with neither), Reboot-tail (run the applicable part, then instruct the reboot), and Guide (physical/BIOS/wait-for-upstream, nothing to run). Safety floor: every Privileged and Reboot-tail remedy defaults to Confirm or Arm tier, never silent Auto, because passwordless sudo is not consequence-free. @@ -491,7 +463,7 @@ Consider: document as post-install step or create a sync script. ** TODO [#B] Test + CI infrastructure :test: :PROPERTIES: -:LAST_REVIEWED: 2026-06-28 +:LAST_REVIEWED: 2026-07-13 :END: Umbrella for the test-harness and CI-automation buildout. Consolidated from the 2026-06-28 task audit: these were scattered top-level tasks circling one effort, re-homed as children so the work reads as a unit. Each child ships independently and keeps the priority it carried before. No CI runner exists yet, so the CI/CD-pipeline child gates several of the others. @@ -1383,7 +1355,10 @@ Rewrote the bare =if $var= boolean conditionals (=show_status_only=, =fresh_inst *** 2026-05-26 Tue @ 15:27:09 -0500 eval task moot — the line-434 eval is gone, the survivor is deliberate Verified: the only =eval= left in =archsetup= is line 578 in =retry_install=, and it's intentional and documented — it captures =$?= directly from =eval "$cmd"= to dodge the if-compound-swallows-exit-code trap. Replacing it with an array would reintroduce that bug. The line-434 eval this task pointed at no longer exists. Nothing to change. -** TODO [#B] The audio doctor never checks the microphone :bug:audio:solo: +** TODO [#B] The audio doctor never checks the microphone :bug:audio: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-13 +:END: The classifier is output-only. =diag.probe_semantic= already collects =default_source= and =default_source_present=, and =classify.py= reads neither: the word "source" appears once in the whole module, in the graph row that counts them. So a muted mic, a default source naming an unplugged device, or a mic at zero volume all classify as =healthy=, and the verdict prints "the default output is present and audible" while the input side goes unexamined. Found 2026-07-10 while asking whether the doctor would have caught Chrome losing the mic. It would not have. Not a scope decision. The spec's Non-Goals never say the doctor is output-only, and its Summary calls it a doctor for "a broken sound stack". @@ -1409,54 +1384,6 @@ The maintenance console's coredump metric flagged telega-server on ratio (8 core * Archsetup Resolved -** DONE [#B] Panels moveable + resizable by drag :feature:waybar:network:bluetooth: -CLOSED: [2026-07-04 Sat] -Resolved by the 2026-07-03 instrument-console rebuild (dotfiles e993c3f): both net + bt panels switched from anchored gtk4-layer-shell overlays to normal floating windows (set_decorated(False), positioned by the net.cjennings.netpanel window rule), so Hyprland moves them on drag and resizes on corner-drag natively. That was exactly the "switch to a normal floating window" approach the design note flagged as the required decision. - -Both the net and bluetooth instrument-console panels should be repositionable and resizable at runtime: click-drag to move the panel anywhere on screen, drag the corners to resize. Raised from roam capture 2026-07-03. - -Design note: the panels are gtk4-layer-shell overlays anchored TOP+RIGHT with fixed margins — layer-shell surfaces are compositor-positioned, so free drag-move/resize needs either dynamic margin updates on pointer motion or a switch to a normal floating window (Hyprland moves/resizes those natively). Approach decision required before build. -** CANCELLED [#B] Net panel wider initial width :waybar:network:quick: -CLOSED: [2026-07-04 Sat] -Superseded by the 2026-07-03 instrument-console rebuild (dotfiles e993c3f): the panel is now a floating, user-resizable window (set_default_size(420, 560)), no longer a right-anchored layer-shell surface. The task's mechanic ("keep the right edge fixed, extend the left border leftward") assumed the old anchored surface, which no longer exists — the width is now drag-adjustable. Cancelled per the 2026-07-04 audit (Craig's call to close rather than re-file a "bump the 420px default" task). - -Start the network panel a bit wider — keep the right edge fixed (it's right-anchored), extend the left border leftward. Raised from roam capture 2026-07-03. -** DONE [#B] Net panel doctor results can't display :bug:waybar:network: -CLOSED: [2026-07-04 Sat] -Resolved by the 2026-07-03 instrument-console rebuild (dotfiles e993c3f): the panel gained a streaming output well (gui.py) with a "Copy results" button (via wl-copy) and a dismiss control that collapses the well back to the panel's pre-open height (_shrink_to_compact asks Hyprland to resize back). Doctor/speed-test output streams into it as appended lines — matching the task's ask for a tall results box, copy button, and collapse-back. This capture (filed 2026-07-03 morning) predates the same-day 22:06 redesign that addressed it. - -The doctor diagnostic output is unreadable — the results well is too constrained to show the multi-line result. It should open a results box tall enough for several lines with a copy-results button; closing it via an X in the box's upper-right collapses the space back to what it occupied before. Raised from roam capture 2026-07-03. -** 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. - -*** 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. -** CANCELLED [#B] Test each modernization thoroughly before replacing -CLOSED: [2026-07-04 Sat] -Retired in the 2026-07-04 audit (Craig's call): a standing-judgment umbrella with no completion criterion. The fleet is Hyprland-only now, and per-change test discipline is already carried by the actual work (TDD + the VM harness), so this adds nothing to track. Original intent: ensure new tools integrate with the Hyprland environment and don't break workflow (archsetup still supports DWM/X11 but no current machine uses it). -** DONE [#C] Window focus lost when unhiding stashed windows :bug:hyprland: -CLOSED: [2026-07-04 Sat] -Verified fixed live on ratio 2026-07-04 (Craig at the machine). Stash (Super+O) → restore (Super+Shift+O) left the restored window focused, and Super+J/K (layout-navigate) cycled focus normally afterward — both original symptoms gone. Resolved by two fixes that postdated the filing: dotfiles 5619342 (raise window on focus nav, stop float focus-follow, 2026-06-28) and 09815f3 (cycle focus by address so j/k works in monocle, 2026-06-29). Both confirmed present in ratio's HEAD and in the live layout-navigate script at test time. - -From the roam inbox: hiding a window (e.g. the org-capture popup) then unhiding it should leave the unhidden window focused, but another window typically takes focus. Also =ctrl+j/k= (layout-navigate) can't reach the unhidden window afterward — it should always reach any visible window except the waybar. Involves stash-restore + layout-navigate; needs interactive reproduction with Craig. (Note: the actual bind is Super+J/K, not ctrl+j/k as the capture said.) -** DONE [#C] Instrument-console panel bugs (net/bt/audio) :bug:dotfiles: -CLOSED: [2026-07-05 Sun] -Batch from the roam inbox (2026-07-05). Panel code lives in =~/.dotfiles= (net/, bluetooth/, audio/). All eight shipped 2026-07-05, each verified live and covered by the panel test suites. -*** 2026-07-05 Sun @ 17:49:51 -0400 Titled the panel windows Network/Bluetooth/Audio (was python3) -The GTK app set no window title, so it fell back to the process name. dotfiles 2d03451. -*** 2026-07-05 Sun @ 17:49:51 -0400 Faceplate word is the subsystem name, not live state (net/bt/audio) -NETWORKING / BLUETOOTH / AUDIO; state now reads off the lamp colour + badges. Bluetooth's DOCTOR feedback (CHECKING/FIXING) moved to the status line and output well. dotfiles 5c58833. -*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel flags a signed-out Proton CLI as needs-login -The proton probe reads =protonvpn info= first, since =protonvpn status= says Disconnected either way. The panel already blocks a needs-login row with a sign-in hint. dotfiles 2671472. -*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel sorts live tunnels to the top -Stable within each backend group. dotfiles 2671472. -*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel refuses a second full-tunnel VPN while one is active -tailscale is a mesh overlay, not a full tunnel, so it never conflicts. dotfiles 307a0fe. -*** 2026-07-05 Sun @ 17:49:51 -0400 Net panel dedupes Proton's own wireguard row -The proton CLI's =ProtonVPN <server>= NM profile no longer shows alongside the proton backend row. dotfiles dbc9ee8. ** DONE [#B] Maintenance console build :feature: CLOSED: [2026-07-08 Wed] :PROPERTIES: @@ -1595,3 +1522,39 @@ Live verification on ratio replaced the planned VM run for remedies 1, 4 and 5, Left open, not a v1 gap: mpv played silently while the doctor read healthy. The stack was genuinely fine, and per-application stream routing is an explicit spec Non-Goal. Worth a task only if it recurs. Second sighting, 2026-07-10: Chrome stopped recognizing the microphone while the stack was healthy (Shure MV7+ default, unmuted, 82%, PTT off). Restarting Chrome fixed it; nothing on the machine was touched. Both sightings share a shape the doctor cannot see: the graph is fine and one client cannot use it. A third sighting turns this into a design question, namely whether the doctor should say "the stack is fine, the fault is in the application" rather than a bare healthy. +** DONE [#C] Net panel: Enterprise error never dismisses :bug:dotfiles:network: +CLOSED: [2026-07-12 Sun] +Fixed in dotfiles =a157bed=. Root cause: error toasts are sticky by design (so background refreshes can't wipe an unread error), but the enterprise join hint's flow posts no follow-up status and row clicks post none either, so nothing ever replaced it. Fix: a window-wide capture-phase click gesture dismisses a sticky toast on the user's next interaction; policy in =viewmodel.toast_action_plan= (unit-tested), timed toasts and background clears unchanged. Panel smoke run confirms launch/doctor/close with the gesture installed. Pointer-level dismiss is a manual-testing child (AT-SPI can't drive pointer gestures). Repro screenshot: =~/pictures/screenshots/2026-07-10_195911.png=. +** DONE [#C] Net diagnostics leak connection names + SSIDs into copyable report and --json :bug:dotfiles:network:solo: +CLOSED: [2026-07-12 Sun] +Resolved in dotfiles =df1543a=: the =redact_ssid= toggle now scrubs saved profile names, active SSIDs, envelope-carried names, and =.nmconnection= keyfile basenames from the copyable report and the diag/doctor =--json= envelopes (one systemic pass in =redact.py=; MAC/IP scrub applies to those envelopes too). On-screen output and functional envelopes (status/list) unchanged. 15 new tests; live-verified on ratio (toggle on removes the active connection name from =diagnose --json=, default unchanged). +The net doctor's copyable report (=report.py=, =scrub_text=) scrubs only MAC/IP, and =net diag/doctor --json= (=cli.py=) dumps the raw dict with no redaction. SSID redaction lives only in the event log (=redact_event=, gated on =redact_ssid=, default off). So a connection name (usually the SSID) appears in the clear in the link-step evidence and in every =--json= consumer — the copyable report is exactly the text a user pastes into a bug report. Secrets (PSK/password/token/portal URL) are already stripped, so this is names, not credentials: Minor severity, graded on severity alone per the privacy carve-out. + +Split out of the 2026-07-11 net-doctor-expansion spec review: that spec's new rival-manager/keyfile-perms verdicts keep parity with this pre-existing behavior rather than half-solve it. Fix shape: extend redaction to cover the connection name + keyfile basename across the copyable report and =--json= (one systemic pass, not per-verdict), with a redaction test. Engine-wide, so it wants one coherent change rather than being bolted onto the expansion work. +** DONE [#B] Bt doctor expansion v1 — build the READY spec :feature:dotfiles:bluetooth: +CLOSED: [2026-07-12 Sun] +:PROPERTIES: +:SPEC_ID: 3d4d61c4-e5df-44e9-b8e0-40b31452c3f7 +:END: +Build the [[file:docs/specs/2026-07-11-bt-doctor-expansion-spec.org][bt doctor expansion]] (IMPLEMENTED). Adds a dmesg firmware-hint probe (names the missing blob on a no-adapter fault) and a boot-enablement probe (catches an adapter disabled at boot) to the shipped bt doctor (=~/.dotfiles/bluetooth/=). Archsetup owns the dotfiles work end to end. All phases shipped and fake-verified (d19fdca, f05a9b4, d7d859f); the live reboot-persistence half is on the manual-testing checklist. +*** 2026-07-11 Sat @ 03:06:32 -0500 Built the two read-only probes +New module =bluetooth/src/bt/probes.py= plus =doctor.py= wiring, on dotfiles main (=d19fdca=, pushed). Two reads the diagnose chain never did: =firmware_hint()= scans the current boot's kernel log for per-vendor firmware-load failures (Intel ibt-*.sfi, MediaTek BT_RAM_CODE, Realtek rtl_bt, Broadcom .hcd, Qualcomm QCA), returning the named blob via a bounded =cmd.run(journalctl -k)= that reuses the =doctor.py:84= precedent; =boot_enablement()= reads three boot-persistence signals (bluez AutoEnable from main.conf [Policy], =systemctl is-enabled bluetooth=, whether TLP lists bluetooth in =DEVICES_TO_DISABLE_ON_STARTUP=). =diagnose()= gates the firmware read to the no-adapter branch and the boot read to the soft-blocked/powered-off branch, so a healthy run reads neither; the raw signals ride a new =probes= key that =doctor()= carries into =--json=. Detection only: no verdict, formatter, or repair change (that's Phase 1). Every read degrades to None on an unreadable tool/file, so a probe that can't see never invents a fault. AutoEnable absent/unset reads None, not false, matching bluez's compiled default of true, so only an explicit =AutoEnable=false= is the fault. New env roots for tests (=BT_MAIN_CONF=, =BT_TLP_CONF=, defaulting to absent temp paths in the Sandbox base so no test reads real /etc); =fake-journalctl= branches on =-k=, =fake-systemctl= answers =is-enabled bluetooth=. 125 bt tests, full =make test= green; =/review-code= approved (no Critical/Important; one Minor noting the firmware read also covers the btctl-unavailable branch, harmless). Inbox note sent to dotfiles. +*** 2026-07-11 Sat @ 03:16:59 -0500 Built the firmware-hint Guide verdict +On dotfiles main (=f05a9b4=, pushed). The no-adapter step now names the blob: a new =_no_adapter_step= consults =probes.firmware_hint()= on a genuine no-adapter fault and, on a per-vendor signature match, sets =evidence= to "no Bluetooth adapter found — <Vendor> firmware <blob> failed to load" and =next_action= to "update linux-firmware and reboot", tagged with a new =code="no-adapter-firmware"= so a =--json= consumer can branch without string-matching. A clean log keeps the generic hardware/driver verdict. A Guide, not a repair: the step carries no =repair= action, so =--fix= never touches it, and it needs no privilege model (so Phase 1 lands independently of the shared cross-panel model). A missing =bluetoothctl= (=BtctlError=) short-circuits before the firmware read, so the verdict fires only on a real no-adapter fault, not a broken install — this also tightened Phase 0 (which read the log on both None branches) to the genuine no-adapter case. =_mk= gained a uniform =code= key (default None) added to every diagnose step, mirroring the existing =repair= key; no test asserts an exact step key-set, verified. =format_doctor_human= already renders =evidence=/=next_action=, so no formatter change. 133 bt tests (+8), full =make test= green; =/review-code= clean. Inbox note sent to dotfiles. +*** 2026-07-11 Sat @ 06:48:21 -0500 Built the persistent-power verdict + fix +On dotfiles main (=d7d859f=, pushed). =_powered_step= consumes the bt Phase 0 boot-enablement probe: AutoEnable explicitly false, service disabled at boot, or TLP listing bluetooth → =powered-off-persistent= (code + evidence naming the cause) carrying the =persist-power= repair; absent/unset config reads as auto-enable-on (bluez default) → the plain =power-on=, so healthy machines can't false-positive. The =persist-power= repair fixes only the causes set (AutoEnable=true, =systemctl enable bluetooth=, drop bluetooth from the TLP list), powers the adapter on now, and verifies each cause cleared. Config edits are pure idempotent text transforms in a new =bootconf= module (comments preserved), staged and installed via a fixed-destination =cp= verb so the write needs root but the mutation is unit-testable. =priv.py= gained three narrow verbs (=enable-bluetooth=, =write-main-conf=, =write-tlp-conf=). The repair is Privileged and resolves through =panelkit= before running: can't-elevate degrades to the guide. The bt shim gained =panelkit= on its path. 287 bt tests + 65 make-test suites green, review-code Approve, voice. Inbox note sent. Live half (real reboot persistence) is the VM/manual checklist. +*** 2026-07-12 Sun @ 09:14:00 -0500 Flipped the bt spec to IMPLEMENTED and logged the vNext items +Spec status heading now IMPLEMENTED (dated history line + Status mirror); all four phase headings DONE. vNext items (stale-bond signature, connection-parameter hints, bt-audio-profile expansion) logged as the "Bt doctor vNext" task. The live half (real reboot persistence) remains on the manual-testing checklist — findings there come back as bugs. +** DONE [#B] One copy + close control pair on every output wall :feature:dotfiles:solo: +CLOSED: [2026-07-12 Sun] +Resolved in dotfiles =dccd744=: every wall carries the o-copy/o-clear overlay pair. bluetooth gained the copy key (transcript via the new =viewmodel.step_copy_line=, CLI-shaped); maint traded its header COPY key for the overlay pair, kept HIDE, and its ✕ clears the session log via =PanelModel.wall_clear=; the four hand-rolled wl-copy calls collapsed into =panelkit.clipboard.copy_text= (PANELKIT_WLCOPY test seam), moving maint off the GTK clipboard so its copies survive the panel closing too. 15 new tests (8 clipboard, 4 step_copy_line, 3 wall_clear); full suite 66 green; all four panel smokes run live off-workspace — maint + audio fully pass, net + bt fail only the pre-existing state-word startup race. + +Converge all four instrument-console output walls on the net panel's well controls: a copy glyph and a ✕ close, as an overlay at the top right, hidden until content lands. Craig's call, 2026-07-10, while reviewing the audio doctor's wall: "network panels as standard across all others, make it consistent." + +Where they stand today, no two alike. net has copy + ✕ (=net/src/net/gui.py=, the =o-copy= / =o-clear= overlay). bluetooth has ✕ but no copy. maint has COPY + HIDE as keys in a header row, and no ✕. audio just gained copy + ✕ (dotfiles =bd33440=). + +Work: give bluetooth a copy key, give maint the overlay pair, and lift the four hand-rolled =_copy_output= implementations into one shared helper rather than a fifth copy. maint keeps HIDE alongside close, because its wall is a persistent session action log you collapse and keep, where net's, bt's, and audio's are per-run results you dismiss. + +Copy text is per panel but one rule: it pastes as that panel's CLI prints, so the paste lines up with the terminal a user is already looking at. audio's =viewmodel.wall_copy_text()= is the worked example. + +Consistent with the 2026-07-07 scope note on the sibling task above: net's compact glyph overlay is what standardizes, not maint's wide COPY-key header row. diff --git a/working/retro-stereo-widgets/references/2026-07-12-oasis-fader-bank.png b/working/retro-stereo-widgets/references/2026-07-12-oasis-fader-bank.png Binary files differnew file mode 100644 index 0000000..cb0174f --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-12-oasis-fader-bank.png diff --git a/working/retro-stereo-widgets/references/2026-07-13-swr-cross-needle-meter.jpg b/working/retro-stereo-widgets/references/2026-07-13-swr-cross-needle-meter.jpg Binary files differnew file mode 100644 index 0000000..c97a9a9 --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-13-swr-cross-needle-meter.jpg diff --git a/working/retro-stereo-widgets/references/2026-07-13-weather-station-comfort-meter.webp b/working/retro-stereo-widgets/references/2026-07-13-weather-station-comfort-meter.webp Binary files differnew file mode 100644 index 0000000..0a9705a --- /dev/null +++ b/working/retro-stereo-widgets/references/2026-07-13-weather-station-comfort-meter.webp |
