aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes
diff options
context:
space:
mode:
Diffstat (limited to 'docs/prototypes')
-rw-r--r--docs/prototypes/panel-widget-gallery.html279
-rw-r--r--docs/prototypes/widgets.js334
2 files changed, 342 insertions, 271 deletions
diff --git a/docs/prototypes/panel-widget-gallery.html b/docs/prototypes/panel-widget-gallery.html
index e08db62..082c503 100644
--- a/docs/prototypes/panel-widget-gallery.html
+++ b/docs/prototypes/panel-widget-gallery.html
@@ -1686,28 +1686,28 @@ card(C,'R38','Dead-man button',
(st,rd)=>GW.deadMan(st,{onChange:(v,t)=>rd(t)}),
'<b>active only while held.</b> Press and hold — the ring arms, the lamp runs, the dwell clock counts. Let go anywhere and it drops to SAFE. The kit has toggles and momentaries; this is the only control whose state IS your grip.');
card(C,'R39','Rotary telephone dial',
- `<svg id="teldial" class="rsvg press" viewBox="0 0 130 130" width="130" height="130"></svg>`,
+ (st,rd)=>GW.telephoneDial(st,{onChange:(v,t)=>rd(t)}),
'<b>numeric entry embodied as time.</b> Click a hole: the finger wheel winds to the stop and spins back, and the digit lands in the number. Bigger digits take longer, exactly like the pulses did. After a Western Electric dial.');
card(C,'R40','Circuit breaker panel',
- `<svg id="brkpanel" class="rsvg press" viewBox="0 0 160 110" width="160" height="110"></svg>`,
+ (st,rd)=>GW.breakerPanel(st,{onChange:(v,t)=>rd(t)}),
'<b>the first control the system can throw.</b> Click a breaker to close or open it; hit TRIP and a fault pops one out to the tripped mid-position, amber collar showing. A tripped breaker must be pulled to reset before it closes again. After an aircraft breaker row.');
card(C,'R41','DSKY verb-noun panel',
- `<div class="dsky" id="dsky"></div>`,
+ (st,rd)=>GW.dsky(st,{onChange:(v,t)=>rd(t)}),
'<b>a command grammar, not a button per command.</b> VERB is the action, NOUN is the operand: press VERB 3 5 ENTR for the lamp test. Wrong grammar lights OPR ERR. Two keys plus digits reach every function — the Apollo answer to a panel with too few buttons.');
card(C,'R42','Cam-timer program drum',
- `<svg id="camtimer" class="rsvg press" viewBox="0 0 130 130" width="130" height="130"></svg>`,
+ (st,rd)=>GW.camTimer(st,{position:0,onChange:(v,t)=>rd(t)}),
'<b>a procedure as a rotating position.</b> The ring is the whole program — fill, wash, rinse, spin — and the pointer is where you are in it. Click to advance (or start from OFF); it then walks itself through the steps. After a washing-machine cam timer.');
card(C,'R48','Knife switch',
- `<svg id="knifesw" class="rsvg press" viewBox="0 0 130 110" width="130" height="110"></svg>`,
+ (st,rd)=>GW.knifeSwitch(st,{closed:true,onChange:(v,t)=>rd(t)}),
'<b>the visible disconnect.</b> Copper blades either sit in their jaws or hang open in the air — you can SEE the circuit is broken, which is the entire safety argument. Click the handle to throw it. After early power-station switchboards.');
card(C,'R49','Decade switch box',
- `<svg id="decbox" class="rsvg" viewBox="0 0 160 100" width="160" height="100"></svg>`,
+ (st,rd)=>GW.decadeBox(st,{digits:[3,5,0,0],onChange:(v,t)=>rd(t)}),
'<b>a number composed a digit at a time.</b> Four skirted knobs, one per decade — drag each to its digit and the total just IS their sum. The thumbwheel does one digit; this is how a lab built a precise value. After a General Radio decade resistance box.');
card(C,'R50','Two-hand safety control',
- `<svg id="twohand" class="rsvg press" viewBox="0 0 160 100" width="160" height="100"></svg>`,
+ (st,rd)=>GW.twoHandSafety(st,{onChange:(v,t)=>rd(t)}),
'<b>both hands, deliberately.</b> Click one palm button to arm the window, the other within half a second to cycle the press. Too slow — TIE-DOWN fault, start over. The hardware keeps hands out of the die; the grammar survives on screen. After OSHA press controls.');
card(C,'R51','Voice-loop keyset',
- `<div class="vloop" id="vloop"></div>`,
+ (st,rd)=>GW.voiceLoop(st,{onChange:(v,t)=>rd(t)}),
'<b>attention management, not selection.</b> Each loop is independent: click to monitor (green bar, flickering with activity), again to talk (amber, exclusive — one voice out), again to drop. Flight controllers ran a dozen of these at once. After a mission-control comm keyset.');
/* ============ METERS & GAUGES ============ */
@@ -2625,150 +2625,6 @@ function setDdcal(d,w){ddDate=d;ddDay=w;const HAND=135;
setRd('R36',lit+' / 64 lit');});}
setRd('R36',lit+' / 64 lit');})();
-/* R39 rotary telephone dial: click a hole; the wheel winds to the stop and returns */
-(function(){const s=$('teldial'),cx=65,cy=63;
- const DIGITS=['1','2','3','4','5','6','7','8','9','0'];
- const angOf=i=>(60-i*30+360)%360; // 1 at 60cw, counterclockwise to 0 at 150
- const STOP=105;
- const defs=svgEl(s,'defs',{});
- const mt=svgEl(defs,'radialGradient',{id:'tdWheel',cx:'42%',cy:'36%',r:'85%'});
- svgEl(mt,'stop',{offset:'0','stop-color':'#8f8a7e'});
- svgEl(mt,'stop',{offset:'1','stop-color':'#4a463e'});
- svgEl(s,'circle',{cx,cy,r:56,fill:'#14110e',stroke:'#060505','stroke-width':2});
- svgEl(s,'circle',{cx,cy,r:50,fill:'#1e1a16',stroke:'#0a0908','stroke-width':1});
- // rotating finger wheel: ring + hole openings (dark wells punched in the metal)
- const wheel=svgEl(s,'g',{});
- svgEl(wheel,'circle',{cx,cy,r:46,fill:'none',stroke:'url(#tdWheel)','stroke-width':17,opacity:.95});
- DIGITS.forEach((d,i)=>{const [x,y]=polar(cx,cy,38,angOf(i));
- svgEl(wheel,'circle',{cx:x,cy:y,r:7.2,fill:'#1e1a16',stroke:'#26221c','stroke-width':1.4});});
- // fixed digits ON TOP so they read through the holes (they stay put while the wheel spins)
- DIGITS.forEach((d,i)=>{const [x,y]=polar(cx,cy,38,angOf(i));
- svgEl(s,'text',{x,y:y+3,'text-anchor':'middle','font-size':8.5,'font-weight':700,
- 'font-family':'var(--mono)',fill:'var(--cream)'}).textContent=d;});
- svgEl(s,'circle',{cx,cy,r:16,fill:'url(#tdWheel)',stroke:'#26221c','stroke-width':1});
- // finger stop
- const [fx,fy]=polar(cx,cy,52,STOP);
- svgEl(s,'rect',{x:fx-2.5,y:fy-8,width:5,height:16,rx:2,fill:'#b8b2a4',stroke:'#3c382f','stroke-width':1,
- transform:`rotate(${STOP},${fx},${fy})`});
- let dialed='',spinning=false;
- const setW=a=>wheel.setAttribute('transform',`rotate(${a},${cx},${cy})`);
- const finish=d=>{dialed=(dialed+d).slice(-10);setRd('R39',dialed);spinning=false;};
- DIGITS.forEach((d,i)=>{const [x,y]=polar(cx,cy,38,angOf(i));
- const hit=svgEl(s,'circle',{cx:x,cy:y,r:8.5,fill:'transparent'});
- hit.style.cursor='pointer';
- hit.addEventListener('click',()=>{if(spinning)return;spinning=true;
- const R=(STOP-angOf(i)+360)%360;
- if(matchMedia('(prefers-reduced-motion: reduce)').matches){setW(R);setTimeout(()=>{setW(0);finish(d);},160);return;}
- const t0=performance.now(),Tf=R/300*1000,Tb=R/450*1000;
- const step=t=>{const el=t-t0;
- if(el<Tf){setW(R*el/Tf);requestAnimationFrame(step);}
- else if(el<Tf+80){setW(R);requestAnimationFrame(step);}
- else if(el<Tf+80+Tb){setW(R*(1-(el-Tf-80)/Tb));requestAnimationFrame(step);}
- else{setW(0);finish(d);}};
- requestAnimationFrame(step);});});
- setRd('R39','dial a number');})();
-
-/* R40 circuit breaker panel: on/off by click, TRIP pops one to the amber mid-state, reset is two-step */
-(function(){const s=$('brkpanel');
- const NAMES=['MAIN','PUMP','LAMP','AUX'];
- svgEl(s,'rect',{x:2,y:2,width:156,height:106,rx:8,fill:'#1c1916',stroke:'#060505','stroke-width':1.5});
- const brk=[];
- const draw=()=>{let closed=0,trip=null;
- brk.forEach(b=>{if(b.state==='on')closed++;if(b.state==='tripped')trip=b.name;
- b.lamp.setAttribute('fill',b.state==='on'?'var(--pass)':'#1e2a12');
- b.lamp.setAttribute('filter',b.state==='on'?'drop-shadow(0 0 3px rgba(116,147,47,.8))':'none');
- b.handle.setAttribute('y',b.state==='on'?40:b.state==='tripped'?57:74);
- b.collar.setAttribute('opacity',b.state==='tripped'?1:0);});
- setRd('R40',closed+'/4 closed'+(trip?' · '+trip+' TRIPPED':''));};
- NAMES.forEach((name,i)=>{const x=14+i*30;
- svgEl(s,'rect',{x,y:36,width:20,height:52,rx:4,fill:'#14110e',stroke:'#2c2820','stroke-width':1});
- const lamp=svgEl(s,'circle',{cx:x+10,cy:26,r:4,fill:'#1e2a12'});
- const collar=svgEl(s,'rect',{x:x+4,y:69,width:12,height:5,fill:'var(--amber-warn)',opacity:0});
- const handle=svgEl(s,'rect',{x:x+4,y:40,width:12,height:14,rx:2,fill:'#3a3631',stroke:'#060505','stroke-width':1});
- svgEl(s,'text',{x:x+10,y:98,'text-anchor':'middle','font-size':5.4,'letter-spacing':'.05em',
- 'font-family':'var(--mono)',fill:'var(--steel)'}).textContent=name;
- const hit=svgEl(s,'rect',{x,y:20,width:20,height:80,fill:'transparent'});
- hit.style.cursor='pointer';
- const b={name,state:i<3?'on':'off',lamp,collar,handle};
- hit.addEventListener('click',()=>{b.state=b.state==='on'?'off':b.state==='off'?'on':'off';draw();});
- brk.push(b);});
- const tb=svgEl(s,'g',{});
- svgEl(tb,'rect',{x:132,y:40,width:22,height:16,rx:4,fill:'var(--fail)',stroke:'#5c150c','stroke-width':1});
- svgEl(tb,'text',{x:143,y:50.5,'text-anchor':'middle','font-size':6,'font-weight':700,
- 'font-family':'var(--mono)',fill:'var(--cream)'}).textContent='TRIP';
- tb.style.cursor='pointer';
- tb.addEventListener('click',()=>{for(let i=brk.length-1;i>=0;i--)if(brk[i].state==='on'){brk[i].state='tripped';break;}draw();});
- draw();})();
-
-/* R41 DSKY: verb/noun command grammar with status lamps and a lamp-test verb */
-(function(){const host=$('dsky');
- const LAMPS=['UPLINK','TEMP','GIMBAL','PROG','RESTART','OPR ERR'];
- host.innerHTML=
- `<div class="lampcol">${LAMPS.map(l=>`<div class="sl" data-l="${l}">${l}</div>`).join('')}</div>`+
- `<div class="right"><div class="wins">`+
- `<div class="win" data-w="prog"><span class="wl">PROG</span><span class="wd"></span></div>`+
- `<div class="win" data-w="verb"><span class="wl">VERB</span><span class="wd"></span></div>`+
- `<div class="win" data-w="noun"><span class="wl">NOUN</span><span class="wd"></span></div>`+
- `</div><div class="pad"></div></div>`;
- const win=w=>host.querySelector(`[data-w="${w}"]`);
- const setWin=(w,txt)=>{win(w).querySelector('.wd').innerHTML=seg7(txt[0]||' ')+seg7(txt[1]||' ');};
- const lampEl=l=>host.querySelector(`[data-l="${l}"]`);
- let mode=null,vals={prog:' ',verb:' ',noun:' '},entry='';
- const KEYS=['VERB','NOUN','CLR','ENTR','RSET','7','8','9','4','5','6','1','2','3','0'];
- const pad=host.querySelector('.pad');
- const hot=()=>{host.querySelectorAll('.win').forEach(w=>w.classList.toggle('hot',w.dataset.w===mode));};
- const oprErr=()=>{lampEl('OPR ERR').classList.add('on');setTimeout(()=>lampEl('OPR ERR').classList.remove('on'),1200);setRd('R41','OPR ERR');};
- const commit=()=>{
- if(vals.verb.trim().length<2){oprErr();return;}
- const v=vals.verb,n=vals.noun.trim();
- if(v==='35'){LAMPS.forEach(l=>lampEl(l).classList.add('on'));
- setTimeout(()=>LAMPS.forEach(l=>lampEl(l).classList.remove('on')),1400);
- setRd('R41','V35 · lamp test');}
- else if(v==='16'&&n==='36'){vals.prog='16';setWin('prog',vals.prog);setRd('R41','V16 N36 · monitor clock');}
- else setRd('R41','V'+v+(n?' N'+n:'')+' ENTR');
- vals.verb=' ';vals.noun=' ';entry='';mode=null;hot();};
- KEYS.forEach(k=>{const b=document.createElement('button');b.className='key';b.textContent=k;
- b.addEventListener('click',()=>{
- if(k==='VERB'||k==='NOUN'){mode=k.toLowerCase();entry='';vals[mode]=' ';setWin(mode,' ');hot();}
- else if(k==='CLR'){if(mode){vals[mode]=' ';entry='';setWin(mode,' ');}}
- else if(k==='RSET'){LAMPS.forEach(l=>lampEl(l).classList.remove('on'));setRd('R41','RSET');}
- else if(k==='ENTR')commit();
- else if(/\d/.test(k)){if(!mode){oprErr();return;}
- entry=(entry+k).slice(0,2);vals[mode]=entry.padEnd(2,' ');setWin(mode,vals[mode]);}});
- pad.appendChild(b);});
- vals.prog='00';setWin('prog',vals.prog);setWin('verb',' ');setWin('noun',' ');
- setRd('R41','VERB ## ENTR');})();
-
-/* R42 cam-timer program drum: the program is a ring; the pointer self-advances through it */
-const CAM_STEPS=['OFF','FILL','WASH','WASH','WASH','RINSE','RINSE','DRAIN','SPIN','SPIN','FLUFF','COOL'];
-const CAM_COLS={OFF:'#3a3426',FILL:'#54677d',WASH:'#d29638',RINSE:'#46b89e',DRAIN:'#969385',SPIN:'#cb6b4d',FLUFF:'#c9b98a',COOL:'#7c99b0'};
-let camPos=0,camPtr=null;
-(function(){const s=$('camtimer'),cx=65,cy=65;
- svgEl(s,'circle',{cx,cy,r:60,fill:'#17140f',stroke:'#060505','stroke-width':2});
- CAM_STEPS.forEach((st,i)=>{const a0=i*30-90,a1=a0+30;
- const p0o=polar(cx,cy,52,a0+90),p1o=polar(cx,cy,52,a1+90),p0i=polar(cx,cy,38,a0+90),p1i=polar(cx,cy,38,a1+90);
- svgEl(s,'path',{d:`M ${p0i[0]} ${p0i[1]} L ${p0o[0]} ${p0o[1]} A 52 52 0 0 1 ${p1o[0]} ${p1o[1]} L ${p1i[0]} ${p1i[1]} A 38 38 0 0 0 ${p0i[0]} ${p0i[1]} Z`,
- fill:CAM_COLS[st],opacity:.75,stroke:'#0a0908','stroke-width':.8});
- const [tx,ty]=polar(cx,cy,45,i*30+15);
- svgEl(s,'text',{x:tx,y:ty+2,'text-anchor':'middle','font-size':4.4,'font-weight':700,
- 'font-family':'var(--mono)',fill:st==='OFF'?'var(--cream)':'#14110e'}).textContent=st==='OFF'?'OFF':st[0];});
- svgEl(s,'circle',{cx,cy,r:34,fill:'#211e1a',stroke:'#0a0908','stroke-width':1.5});
- CAM_STEPS.forEach((st,i)=>{if(i===0)return;const [lx,ly]=polar(cx,cy,27,i*30+15);
- svgEl(s,'text',{x:lx,y:ly+1.8,'text-anchor':'middle','font-size':4.2,
- 'font-family':'var(--mono)',fill:'var(--steel)'}).textContent=st;});
- svgEl(s,'circle',{cx,cy,r:17,fill:'#14110e',stroke:'#2c2820','stroke-width':1.5});
- camPtr=svgEl(s,'polygon',{points:`${cx-2.5},${cy} ${cx+2.5},${cy} ${cx},${cy-31}`,fill:'var(--gold-hi)',
- filter:'drop-shadow(0 0 3px rgba(var(--glow-lo),.7))'});
- svgEl(s,'circle',{cx,cy,r:5,fill:'#3a3631',stroke:'#060505','stroke-width':1});
- s.style.cursor='pointer';
- s.addEventListener('click',()=>setCam((camPos+1)%12));})();
-function setCam(i){camPos=i;
- camPtr.setAttribute('transform',`rotate(${i*30+15},65,65)`);
- setRd('R42',camPos===0?'OFF':'STEP '+camPos+' · '+CAM_STEPS[camPos]);}
-setCam(0);
-if(!matchMedia('(prefers-reduced-motion: reduce)').matches)
- setInterval(()=>{if(camPos>0){const n=(camPos+1)%12;setCam(n);if(n===0)setRd('R42','CYCLE DONE · OFF');}},2000);
-
/* R43 attitude indicator: sky/ground roll+shift behind a fixed miniature aircraft; 2D drag */
(function(){const s=$('attind'),cx=65,cy=65;
const defs=svgEl(s,'defs',{});
@@ -2920,125 +2776,6 @@ let dkPos=0,dkCarry=0;
setTimeout(()=>{state=target;busy=false;paint();},1500);});
paint();})();
-/* R48 knife switch (side view): the blade hinges at the left post and lands in the right jaw */
-(function(){const s=$('knifesw');
- const defs=svgEl(s,'defs',{});
- const cu=svgEl(defs,'linearGradient',{id:'ksCu',x1:0,y1:0,x2:0,y2:1});
- svgEl(cu,'stop',{offset:'0','stop-color':'#d09a5c'});
- svgEl(cu,'stop',{offset:'1','stop-color':'#8a5a2a'});
- const HX=32,HY=74; // hinge pivot
- svgEl(s,'rect',{x:6,y:56,width:118,height:44,rx:6,fill:'#22262a',stroke:'#0c0d0e','stroke-width':1.5});
- const lamp=svgEl(s,'circle',{cx:116,cy:22,r:4.5,fill:'#1e2a12'});
- svgEl(s,'text',{x:116,y:36,'text-anchor':'middle','font-size':4.8,'letter-spacing':'.08em',
- 'font-family':'var(--mono)',fill:'var(--steel)'}).textContent='LIVE';
- // hinge post (left) and jaw clip (right), both on the base
- svgEl(s,'rect',{x:HX-6,y:HY-6,width:12,height:16,rx:2,fill:'url(#ksCu)',stroke:'#5c3a18','stroke-width':.8});
- svgEl(s,'rect',{x:96,y:HY-9,width:5,height:18,rx:1.5,fill:'url(#ksCu)',stroke:'#5c3a18','stroke-width':.7});
- svgEl(s,'rect',{x:104,y:HY-9,width:5,height:18,rx:1.5,fill:'url(#ksCu)',stroke:'#5c3a18','stroke-width':.7});
- // blade + upright handle, hinged at (HX,HY)
- const blade=svgEl(s,'g',{});
- svgEl(blade,'rect',{x:HX,y:HY-3,width:71,height:6,rx:2.5,fill:'url(#ksCu)',stroke:'#5c3a18','stroke-width':.8});
- svgEl(blade,'rect',{x:88,y:HY-26,width:7,height:26,rx:3,fill:'#14110e',stroke:'#000','stroke-width':1});
- svgEl(blade,'circle',{cx:91.5,cy:HY-26,r:5,fill:'#14110e',stroke:'#000','stroke-width':1});
- svgEl(s,'circle',{cx:HX,cy:HY,r:3,fill:'#5c3a18'});
- blade.style.transformBox='view-box';blade.style.transformOrigin=`${HX}px ${HY}px`;
- blade.style.transition='transform .28s ease';
- let closed=true;
- const draw=()=>{blade.style.transform=closed?'rotate(0deg)':'rotate(-46deg)';
- lamp.setAttribute('fill',closed?'var(--pass)':'#1e2a12');
- lamp.setAttribute('filter',closed?'drop-shadow(0 0 4px rgba(116,147,47,.8))':'none');
- setRd('R48',closed?'CLOSED · live':'OPEN · visibly dead');};
- s.style.cursor='pointer';
- s.addEventListener('click',()=>{closed=!closed;draw();});
- draw();})();
-
-/* R49 decade box: four skirted knobs, one digit each; the value is their sum */
-(function(){const s=$('decbox');
- const MUL=[1000,100,10,1],LBL=['x1000','x100','x10','x1'];
- const digs=[3,5,0,0];
- svgEl(s,'rect',{x:2,y:2,width:156,height:96,rx:8,fill:'#17140f',stroke:'#060505','stroke-width':1.5});
- const wins=[],knobs=[];
- const total=()=>digs.reduce((a,d,i)=>a+d*MUL[i],0);
- const draw=()=>{wins.forEach((w,i)=>w.textContent=String(digs[i]));
- knobs.forEach((k,i)=>k.setAttribute('transform',`rotate(${digs[i]*36},${28+i*35},58)`));
- setRd('R49',total().toLocaleString('en-US')+' Ω');};
- MUL.forEach((m,i)=>{const cx=28+i*35,cy=58;
- svgEl(s,'rect',{x:cx-8,y:16,width:16,height:12,rx:2,fill:'#0a0806',stroke:'#2c2820','stroke-width':1});
- wins.push(svgEl(s,'text',{x:cx,y:25.5,'text-anchor':'middle','font-size':8,'font-weight':700,
- 'font-family':'var(--mono)',fill:'var(--cream)'}));
- svgEl(s,'circle',{cx,cy,r:15,fill:'#211e1a',stroke:'#0a0908','stroke-width':1.5}); // skirt
- for(let t=0;t<10;t++){const [x1,y1]=polar(cx,cy,14,t*36),[x2,y2]=polar(cx,cy,12,t*36);
- svgEl(s,'line',{x1,y1,x2,y2,stroke:'var(--steel)','stroke-width':.6,opacity:.7});}
- const k=svgEl(s,'g',{});
- svgEl(k,'circle',{cx,cy,r:10,fill:'#2c2824',stroke:'#060505','stroke-width':1});
- svgEl(k,'line',{x1:cx,y1:cy-9,x2:cx,y2:cy-4,stroke:'var(--gold-hi)','stroke-width':1.8});
- knobs.push(k);
- svgEl(s,'text',{x:cx,y:88,'text-anchor':'middle','font-size':5.4,'letter-spacing':'.06em',
- 'font-family':'var(--mono)',fill:'var(--steel)'}).textContent=LBL[i];
- const hit=svgEl(s,'circle',{cx,cy,r:16,fill:'transparent'});
- hit.style.cursor='ns-resize';
- dragDelta(hit,()=>digs[i],v=>{digs[i]=Math.round(Math.max(0,Math.min(9,v)));draw();},{min:0,max:9,sens:9/70});});
- draw();})();
-
-/* R50 two-hand safety: one palm button arms a 500ms window; the other completes the cycle */
-(function(){const s=$('twohand');
- svgEl(s,'rect',{x:2,y:2,width:156,height:96,rx:8,fill:'#1c1916',stroke:'#060505','stroke-width':1.5});
- const runLamp=svgEl(s,'circle',{cx:80,cy:24,r:5.5,fill:'#1e2a12'});
- svgEl(s,'text',{x:80,y:40,'text-anchor':'middle','font-size':5.4,'letter-spacing':'.12em',
- 'font-family':'var(--mono)',fill:'var(--steel)'}).textContent='CYCLE';
- const mkBtn=(cx,lbl)=>{const g=svgEl(s,'g',{});
- svgEl(g,'ellipse',{cx,cy:70,rx:19,ry:6,fill:'#0d0b09'});
- const cap=svgEl(g,'circle',{cx,cy:62,r:16,fill:'#8f2416',stroke:'#5c150c','stroke-width':1.5});
- svgEl(g,'ellipse',{cx:cx-5,cy:56,rx:6,ry:3.4,fill:'rgba(255,255,255,.22)'});
- svgEl(s,'text',{x:cx,y:92,'text-anchor':'middle','font-size':5.4,'letter-spacing':'.08em',
- 'font-family':'var(--mono)',fill:'var(--steel)'}).textContent=lbl;
- g.style.cursor='pointer';return {g,cap};};
- const L=mkBtn(32,'LEFT PALM'),R=mkBtn(128,'RIGHT PALM');
- let armed=null,armT=null,busy=false;
- const flash=(cap)=>{cap.setAttribute('fill','#e0523a');setTimeout(()=>cap.setAttribute('fill','#8f2416'),200);};
- const fault=()=>{armed=null;clearTimeout(armT);
- setRd('R50','TIE-DOWN FAULT · release and retry');};
- const press=side=>{if(busy)return;
- if(armed===null){armed=side;flash(side==='L'?L.cap:R.cap);
- setRd('R50',side+' armed · other hand within 0.5s');
- armT=setTimeout(fault,500);return;}
- if(armed===side){fault();return;} // same hand twice = tie-down
- clearTimeout(armT);armed=null;busy=true;
- flash(side==='L'?L.cap:R.cap);
- runLamp.setAttribute('fill','var(--pass)');
- runLamp.setAttribute('filter','drop-shadow(0 0 5px rgba(116,147,47,.8))');
- setRd('R50','CYCLE RUNNING');
- setTimeout(()=>{runLamp.setAttribute('fill','#1e2a12');runLamp.removeAttribute('filter');
- busy=false;setRd('R50','ready');},1500);};
- L.g.addEventListener('click',()=>press('L'));
- R.g.addEventListener('click',()=>press('R'));
- setRd('R50','ready');})();
-
-/* R51 voice-loop keyset: independent monitor states, exclusive talk, activity flicker */
-(function(){const host=$('vloop');
- const LOOPS=['FD','GNC','ECOM','SURG','A/G','NET1','NET2','PAO'];
- const keys=LOOPS.map((l,i)=>{const k=document.createElement('div');k.className='vk';
- k.innerHTML=l+'<span class="bar"></span>';k.dataset.state='0';host.appendChild(k);return k;});
- // a legible default: FD + A/G monitored, GNC talking
- keys[0].dataset.state='1';keys[0].classList.add('mon');
- keys[4].dataset.state='1';keys[4].classList.add('mon');
- keys[1].dataset.state='2';keys[1].classList.add('mon','tlk');
- const refresh=()=>{const mon=keys.filter(k=>k.dataset.state!=='0').length;
- const tlk=keys.findIndex(k=>k.dataset.state==='2');
- setRd('R51',mon===0?'all loops dropped':mon+' monitored'+(tlk>=0?' · talk '+LOOPS[tlk]:''));};
- keys.forEach((k,i)=>k.addEventListener('click',()=>{
- const st=k.dataset.state;
- if(st==='0'){k.dataset.state='1';k.classList.add('mon');}
- else if(st==='1'){keys.forEach(o=>{if(o.dataset.state==='2'){o.dataset.state='1';o.classList.remove('tlk');}});
- k.dataset.state='2';k.classList.add('tlk');}
- else{k.dataset.state='0';k.classList.remove('mon','tlk','act');}
- refresh();}));
- if(!matchMedia('(prefers-reduced-motion: reduce)').matches)
- setInterval(()=>{const t=performance.now()/1000;
- keys.forEach((k,i)=>{if(k.dataset.state==='0'){k.classList.remove('act');return;}
- k.classList.toggle('act',Math.sin(t*(1.1+i*0.37)+i*2.1)>0.55);});},300);
- refresh();})();
-
/* R52 blinkenlights: live register lamps driven by a pseudo-PC that folds in the switch register */
(function(){const s=$('blinken');
svgEl(s,'rect',{x:2,y:2,width:166,height:96,rx:8,fill:'#3a1420',stroke:'#060505','stroke-width':1.5});
diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js
index f213518..ed0feb4 100644
--- a/docs/prototypes/widgets.js
+++ b/docs/prototypes/widgets.js
@@ -1472,6 +1472,340 @@ GW.deadMan = function (host, opts = {}) {
return { el: s, get: () => t0 !== null };
};
+/* R39 rotary telephone dial — click a hole; the wheel winds to the stop and returns */
+GW.telephoneDial = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 63;
+ const DIGITS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'];
+ const angOf = i => (60 - i * 30 + 360) % 360; /* 1 at 60cw, counterclockwise to 0 at 150 */
+ const STOP = 105;
+ gradDef('tdWheel', 'radialGradient', { cx: '42%', cy: '36%', r: '85%' }, [['0', '#8f8a7e'], ['1', '#4a463e']]);
+ svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 });
+ svgEl(s, 'circle', { cx, cy, r: 50, fill: '#1e1a16', stroke: '#0a0908', 'stroke-width': 1 });
+ /* rotating finger wheel: ring + hole openings (dark wells punched in the metal) */
+ const wheel = svgEl(s, 'g', {});
+ svgEl(wheel, 'circle', { cx, cy, r: 46, fill: 'none', stroke: 'url(#tdWheel)', 'stroke-width': 17, opacity: .95 });
+ DIGITS.forEach((_, i) => {
+ const [x, y] = polar(cx, cy, 38, angOf(i));
+ svgEl(wheel, 'circle', { cx: x, cy: y, r: 7.2, fill: '#1e1a16', stroke: '#26221c', 'stroke-width': 1.4 });
+ });
+ /* fixed digits ON TOP so they read through the holes (they stay put while the wheel spins) */
+ DIGITS.forEach((d, i) => {
+ const [x, y] = polar(cx, cy, 38, angOf(i));
+ svgEl(s, 'text', { x, y: y + 3, 'text-anchor': 'middle', 'font-size': 8.5, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = d;
+ });
+ svgEl(s, 'circle', { cx, cy, r: 16, fill: 'url(#tdWheel)', stroke: '#26221c', 'stroke-width': 1 });
+ /* finger stop */
+ const [fx, fy] = polar(cx, cy, 52, STOP);
+ svgEl(s, 'rect', { x: fx - 2.5, y: fy - 8, width: 5, height: 16, rx: 2, fill: '#b8b2a4', stroke: '#3c382f', 'stroke-width': 1, transform: `rotate(${STOP},${fx},${fy})` });
+ let dialed = '', spinning = false;
+ const setW = a => wheel.setAttribute('transform', `rotate(${a},${cx},${cy})`);
+ const finish = d => { dialed = (dialed + d).slice(-10); onChange(dialed, dialed); spinning = false; };
+ DIGITS.forEach((d, i) => {
+ const [x, y] = polar(cx, cy, 38, angOf(i));
+ const hit = svgEl(s, 'circle', { cx: x, cy: y, r: 8.5, fill: 'transparent' });
+ hit.style.cursor = 'pointer';
+ hit.addEventListener('click', () => {
+ if (spinning) return; spinning = true;
+ const R = (STOP - angOf(i) + 360) % 360;
+ if (matchMedia('(prefers-reduced-motion: reduce)').matches) { setW(R); setTimeout(() => { setW(0); finish(d); }, 160); return; }
+ const t0 = performance.now(), Tf = R / 300 * 1000, Tb = R / 450 * 1000;
+ const step = t => {
+ const el = t - t0;
+ if (el < Tf) { setW(R * el / Tf); requestAnimationFrame(step); }
+ else if (el < Tf + 80) { setW(R); requestAnimationFrame(step); }
+ else if (el < Tf + 80 + Tb) { setW(R * (1 - (el - Tf - 80) / Tb)); requestAnimationFrame(step); }
+ else { setW(0); finish(d); }
+ };
+ requestAnimationFrame(step);
+ });
+ });
+ onChange('', 'dial a number');
+ return { el: s, get: () => dialed };
+};
+
+/* R40 circuit breaker panel — on/off by click, TRIP pops one to the amber mid-state, reset is two-step */
+GW.breakerPanel = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const NAMES = opts.names || ['MAIN', 'PUMP', 'LAMP', 'AUX'];
+ const s = stageSvg(host, 'rsvg press', 160, 110);
+ svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 106, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
+ const brk = [];
+ const draw = () => {
+ let closed = 0, trip = null;
+ brk.forEach(b => {
+ if (b.state === 'on') closed++; if (b.state === 'tripped') trip = b.name;
+ b.lamp.setAttribute('fill', b.state === 'on' ? 'var(--pass)' : '#1e2a12');
+ b.lamp.setAttribute('filter', b.state === 'on' ? 'drop-shadow(0 0 3px rgba(116,147,47,.8))' : 'none');
+ b.handle.setAttribute('y', b.state === 'on' ? 40 : b.state === 'tripped' ? 57 : 74);
+ b.collar.setAttribute('opacity', b.state === 'tripped' ? 1 : 0);
+ });
+ onChange(brk.map(b => b.state), closed + '/' + NAMES.length + ' closed' + (trip ? ' · ' + trip + ' TRIPPED' : ''));
+ };
+ NAMES.forEach((name, i) => {
+ const x = 14 + i * 30;
+ svgEl(s, 'rect', { x, y: 36, width: 20, height: 52, rx: 4, fill: '#14110e', stroke: '#2c2820', 'stroke-width': 1 });
+ const lamp = svgEl(s, 'circle', { cx: x + 10, cy: 26, r: 4, fill: '#1e2a12' });
+ const collar = svgEl(s, 'rect', { x: x + 4, y: 69, width: 12, height: 5, fill: 'var(--amber-warn)', opacity: 0 });
+ const handle = svgEl(s, 'rect', { x: x + 4, y: 40, width: 12, height: 14, rx: 2, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 });
+ svgEl(s, 'text', { x: x + 10, y: 98, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.05em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = name;
+ const hit = svgEl(s, 'rect', { x, y: 20, width: 20, height: 80, fill: 'transparent' });
+ hit.style.cursor = 'pointer';
+ const b = { name, state: i < 3 ? 'on' : 'off', lamp, collar, handle };
+ hit.addEventListener('click', () => { b.state = b.state === 'on' ? 'off' : b.state === 'off' ? 'on' : 'off'; draw(); });
+ brk.push(b);
+ });
+ const tb = svgEl(s, 'g', {});
+ svgEl(tb, 'rect', { x: 132, y: 40, width: 22, height: 16, rx: 4, fill: 'var(--fail)', stroke: '#5c150c', 'stroke-width': 1 });
+ svgEl(tb, 'text', { x: 143, y: 50.5, 'text-anchor': 'middle', 'font-size': 6, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }).textContent = 'TRIP';
+ tb.style.cursor = 'pointer';
+ tb.addEventListener('click', () => { for (let i = brk.length - 1; i >= 0; i--) if (brk[i].state === 'on') { brk[i].state = 'tripped'; break; } draw(); });
+ draw();
+ return { el: s, get: () => brk.map(b => b.state) };
+};
+
+/* R41 DSKY — verb/noun command grammar with status lamps and a lamp-test verb */
+GW.dsky = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const LAMPS = ['UPLINK', 'TEMP', 'GIMBAL', 'PROG', 'RESTART', 'OPR ERR'];
+ const el = document.createElement('div'); el.className = 'dsky'; host.appendChild(el);
+ el.innerHTML =
+ `<div class="lampcol">${LAMPS.map(l => `<div class="sl" data-l="${l}">${l}</div>`).join('')}</div>` +
+ `<div class="right"><div class="wins">` +
+ `<div class="win" data-w="prog"><span class="wl">PROG</span><span class="wd"></span></div>` +
+ `<div class="win" data-w="verb"><span class="wl">VERB</span><span class="wd"></span></div>` +
+ `<div class="win" data-w="noun"><span class="wl">NOUN</span><span class="wd"></span></div>` +
+ `</div><div class="pad"></div></div>`;
+ const win = w => el.querySelector(`[data-w="${w}"]`);
+ const setWin = (w, txt) => { win(w).querySelector('.wd').innerHTML = seg7(txt[0] || ' ') + seg7(txt[1] || ' '); };
+ const lampEl = l => el.querySelector(`[data-l="${l}"]`);
+ let mode = null, vals = { prog: ' ', verb: ' ', noun: ' ' }, entry = '';
+ const KEYS = ['VERB', 'NOUN', 'CLR', 'ENTR', 'RSET', '7', '8', '9', '4', '5', '6', '1', '2', '3', '0'];
+ const pad = el.querySelector('.pad');
+ const hot = () => { el.querySelectorAll('.win').forEach(w => w.classList.toggle('hot', w.dataset.w === mode)); };
+ const oprErr = () => { lampEl('OPR ERR').classList.add('on'); setTimeout(() => lampEl('OPR ERR').classList.remove('on'), 1200); onChange(vals, 'OPR ERR'); };
+ const commit = () => {
+ if (vals.verb.trim().length < 2) { oprErr(); return; }
+ const v = vals.verb, n = vals.noun.trim();
+ if (v === '35') {
+ LAMPS.forEach(l => lampEl(l).classList.add('on'));
+ setTimeout(() => LAMPS.forEach(l => lampEl(l).classList.remove('on')), 1400);
+ onChange(vals, 'V35 · lamp test');
+ }
+ else if (v === '16' && n === '36') { vals.prog = '16'; setWin('prog', vals.prog); onChange(vals, 'V16 N36 · monitor clock'); }
+ else onChange(vals, 'V' + v + (n ? ' N' + n : '') + ' ENTR');
+ vals.verb = ' '; vals.noun = ' '; entry = ''; mode = null; hot();
+ };
+ KEYS.forEach(k => {
+ const b = document.createElement('button'); b.className = 'key'; b.textContent = k;
+ b.addEventListener('click', () => {
+ if (k === 'VERB' || k === 'NOUN') { mode = k.toLowerCase(); entry = ''; vals[mode] = ' '; setWin(mode, ' '); hot(); }
+ else if (k === 'CLR') { if (mode) { vals[mode] = ' '; entry = ''; setWin(mode, ' '); } }
+ else if (k === 'RSET') { LAMPS.forEach(l => lampEl(l).classList.remove('on')); onChange(vals, 'RSET'); }
+ else if (k === 'ENTR') commit();
+ else if (/\d/.test(k)) {
+ if (!mode) { oprErr(); return; }
+ entry = (entry + k).slice(0, 2); vals[mode] = entry.padEnd(2, ' '); setWin(mode, vals[mode]);
+ }
+ });
+ pad.appendChild(b);
+ });
+ vals.prog = '00'; setWin('prog', vals.prog); setWin('verb', ' '); setWin('noun', ' ');
+ onChange(vals, 'VERB ## ENTR');
+ return { el, get: () => ({ ...vals }) };
+};
+
+/* R42 cam-timer program drum — the program is a ring; the pointer self-advances through it.
+ Runs its own reduced-motion-gated 2 s step interval once started. */
+GW.camTimer = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const STEPS = opts.steps || ['OFF', 'FILL', 'WASH', 'WASH', 'WASH', 'RINSE', 'RINSE', 'DRAIN', 'SPIN', 'SPIN', 'FLUFF', 'COOL'];
+ const COLS = opts.colors || { OFF: '#3a3426', FILL: '#54677d', WASH: '#d29638', RINSE: '#46b89e', DRAIN: '#969385', SPIN: '#cb6b4d', FLUFF: '#c9b98a', COOL: '#7c99b0' };
+ const s = stageSvg(host, 'rsvg press', 130, 130), cx = 65, cy = 65;
+ svgEl(s, 'circle', { cx, cy, r: 60, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 });
+ STEPS.forEach((st, i) => {
+ const a0 = i * 30 - 90, a1 = a0 + 30;
+ const p0o = polar(cx, cy, 52, a0 + 90), p1o = polar(cx, cy, 52, a1 + 90), p0i = polar(cx, cy, 38, a0 + 90), p1i = polar(cx, cy, 38, a1 + 90);
+ svgEl(s, 'path', { d: `M ${p0i[0]} ${p0i[1]} L ${p0o[0]} ${p0o[1]} A 52 52 0 0 1 ${p1o[0]} ${p1o[1]} L ${p1i[0]} ${p1i[1]} A 38 38 0 0 0 ${p0i[0]} ${p0i[1]} Z`, fill: COLS[st], opacity: .75, stroke: '#0a0908', 'stroke-width': .8 });
+ const [tx, ty] = polar(cx, cy, 45, i * 30 + 15);
+ svgEl(s, 'text', { x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.4, 'font-weight': 700, 'font-family': 'var(--mono)', fill: st === 'OFF' ? 'var(--cream)' : '#14110e' }).textContent = st === 'OFF' ? 'OFF' : st[0];
+ });
+ svgEl(s, 'circle', { cx, cy, r: 34, fill: '#211e1a', stroke: '#0a0908', 'stroke-width': 1.5 });
+ STEPS.forEach((st, i) => {
+ if (i === 0) return; const [lx, ly] = polar(cx, cy, 27, i * 30 + 15);
+ svgEl(s, 'text', { x: lx, y: ly + 1.8, 'text-anchor': 'middle', 'font-size': 4.2, 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = st;
+ });
+ svgEl(s, 'circle', { cx, cy, r: 17, fill: '#14110e', stroke: '#2c2820', 'stroke-width': 1.5 });
+ const ptr = svgEl(s, 'polygon', { points: `${cx - 2.5},${cy} ${cx + 2.5},${cy} ${cx},${cy - 31}`, fill: 'var(--gold-hi)', filter: 'drop-shadow(0 0 3px rgba(var(--glow-lo),.7))' });
+ svgEl(s, 'circle', { cx, cy, r: 5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 });
+ let pos;
+ const set = i => {
+ pos = i;
+ ptr.setAttribute('transform', `rotate(${i * 30 + 15},${cx},${cy})`);
+ onChange(pos, pos === 0 ? 'OFF' : 'STEP ' + pos + ' · ' + STEPS[pos]);
+ };
+ s.style.cursor = 'pointer';
+ s.addEventListener('click', () => set((pos + 1) % STEPS.length));
+ set(opts.position !== undefined ? opts.position : 0);
+ if (!matchMedia('(prefers-reduced-motion: reduce)').matches)
+ setInterval(() => { if (pos > 0) { const n = (pos + 1) % STEPS.length; set(n); if (n === 0) onChange(0, 'CYCLE DONE · OFF'); } }, 2000);
+ return { el: s, get: () => pos, set };
+};
+
+/* R48 knife switch (side view) — the blade hinges at the left post and lands in the right jaw */
+GW.knifeSwitch = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg press', 130, 110);
+ gradDef('ksCu', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d09a5c'], ['1', '#8a5a2a']]);
+ const HX = 32, HY = 74; /* hinge pivot */
+ svgEl(s, 'rect', { x: 6, y: 56, width: 118, height: 44, rx: 6, fill: '#22262a', stroke: '#0c0d0e', 'stroke-width': 1.5 });
+ const lamp = svgEl(s, 'circle', { cx: 116, cy: 22, r: 4.5, fill: '#1e2a12' });
+ svgEl(s, 'text', { x: 116, y: 36, 'text-anchor': 'middle', 'font-size': 4.8, 'letter-spacing': '.08em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'LIVE';
+ /* hinge post (left) and jaw clip (right), both on the base */
+ svgEl(s, 'rect', { x: HX - 6, y: HY - 6, width: 12, height: 16, rx: 2, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .8 });
+ svgEl(s, 'rect', { x: 96, y: HY - 9, width: 5, height: 18, rx: 1.5, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .7 });
+ svgEl(s, 'rect', { x: 104, y: HY - 9, width: 5, height: 18, rx: 1.5, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .7 });
+ /* blade + upright handle, hinged at (HX,HY) */
+ const blade = svgEl(s, 'g', {});
+ svgEl(blade, 'rect', { x: HX, y: HY - 3, width: 71, height: 6, rx: 2.5, fill: 'url(#ksCu)', stroke: '#5c3a18', 'stroke-width': .8 });
+ svgEl(blade, 'rect', { x: 88, y: HY - 26, width: 7, height: 26, rx: 3, fill: '#14110e', stroke: '#000', 'stroke-width': 1 });
+ svgEl(blade, 'circle', { cx: 91.5, cy: HY - 26, r: 5, fill: '#14110e', stroke: '#000', 'stroke-width': 1 });
+ svgEl(s, 'circle', { cx: HX, cy: HY, r: 3, fill: '#5c3a18' });
+ blade.style.transformBox = 'view-box'; blade.style.transformOrigin = `${HX}px ${HY}px`;
+ blade.style.transition = 'transform .28s ease';
+ let closed;
+ const set = v => {
+ closed = !!v;
+ blade.style.transform = closed ? 'rotate(0deg)' : 'rotate(-46deg)';
+ lamp.setAttribute('fill', closed ? 'var(--pass)' : '#1e2a12');
+ lamp.setAttribute('filter', closed ? 'drop-shadow(0 0 4px rgba(116,147,47,.8))' : 'none');
+ onChange(closed, closed ? 'CLOSED · live' : 'OPEN · visibly dead');
+ };
+ s.style.cursor = 'pointer';
+ s.addEventListener('click', () => set(!closed));
+ set(opts.closed !== undefined ? opts.closed : true);
+ return { el: s, get: () => closed, set };
+};
+
+/* R49 decade box — four skirted knobs, one digit each; the value is their sum */
+GW.decadeBox = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const MUL = [1000, 100, 10, 1], LBL = ['x1000', 'x100', 'x10', 'x1'];
+ const digs = (opts.digits || [3, 5, 0, 0]).slice();
+ const s = stageSvg(host, 'rsvg', 160, 100);
+ svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 96, rx: 8, fill: '#17140f', stroke: '#060505', 'stroke-width': 1.5 });
+ const wins = [], knobs = [];
+ const total = () => digs.reduce((a, d, i) => a + d * MUL[i], 0);
+ const draw = () => {
+ wins.forEach((w, i) => w.textContent = String(digs[i]));
+ knobs.forEach((k, i) => k.setAttribute('transform', `rotate(${digs[i] * 36},${28 + i * 35},58)`));
+ onChange(total(), total().toLocaleString('en-US') + ' Ω');
+ };
+ MUL.forEach((_, i) => {
+ const cx = 28 + i * 35, cy = 58;
+ svgEl(s, 'rect', { x: cx - 8, y: 16, width: 16, height: 12, rx: 2, fill: '#0a0806', stroke: '#2c2820', 'stroke-width': 1 });
+ wins.push(svgEl(s, 'text', { x: cx, y: 25.5, 'text-anchor': 'middle', 'font-size': 8, 'font-weight': 700, 'font-family': 'var(--mono)', fill: 'var(--cream)' }));
+ svgEl(s, 'circle', { cx, cy, r: 15, fill: '#211e1a', stroke: '#0a0908', 'stroke-width': 1.5 }); /* skirt */
+ for (let t = 0; t < 10; t++) {
+ const [x1, y1] = polar(cx, cy, 14, t * 36), [x2, y2] = polar(cx, cy, 12, t * 36);
+ svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--steel)', 'stroke-width': .6, opacity: .7 });
+ }
+ const k = svgEl(s, 'g', {});
+ svgEl(k, 'circle', { cx, cy, r: 10, fill: '#2c2824', stroke: '#060505', 'stroke-width': 1 });
+ svgEl(k, 'line', { x1: cx, y1: cy - 9, x2: cx, y2: cy - 4, stroke: 'var(--gold-hi)', 'stroke-width': 1.8 });
+ knobs.push(k);
+ svgEl(s, 'text', { x: cx, y: 88, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = LBL[i];
+ const hit = svgEl(s, 'circle', { cx, cy, r: 16, fill: 'transparent' });
+ hit.style.cursor = 'ns-resize';
+ dragDelta(hit, () => digs[i], v => { digs[i] = Math.round(Math.max(0, Math.min(9, v))); draw(); }, { min: 0, max: 9, sens: 9 / 70 });
+ });
+ draw();
+ return { el: s, get: () => total() };
+};
+
+/* R50 two-hand safety — one palm button arms a 500ms window; the other completes the cycle */
+GW.twoHandSafety = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const s = stageSvg(host, 'rsvg press', 160, 100);
+ svgEl(s, 'rect', { x: 2, y: 2, width: 156, height: 96, rx: 8, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 });
+ const runLamp = svgEl(s, 'circle', { cx: 80, cy: 24, r: 5.5, fill: '#1e2a12' });
+ svgEl(s, 'text', { x: 80, y: 40, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.12em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = 'CYCLE';
+ const mkBtn = (cx, lbl) => {
+ const g = svgEl(s, 'g', {});
+ svgEl(g, 'ellipse', { cx, cy: 70, rx: 19, ry: 6, fill: '#0d0b09' });
+ const cap = svgEl(g, 'circle', { cx, cy: 62, r: 16, fill: '#8f2416', stroke: '#5c150c', 'stroke-width': 1.5 });
+ svgEl(g, 'ellipse', { cx: cx - 5, cy: 56, rx: 6, ry: 3.4, fill: 'rgba(255,255,255,.22)' });
+ svgEl(s, 'text', { x: cx, y: 92, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em', 'font-family': 'var(--mono)', fill: 'var(--steel)' }).textContent = lbl;
+ g.style.cursor = 'pointer'; return { g, cap };
+ };
+ const L = mkBtn(32, 'LEFT PALM'), R = mkBtn(128, 'RIGHT PALM');
+ let armed = null, armT = null, busy = false;
+ const flash = cap => { cap.setAttribute('fill', '#e0523a'); setTimeout(() => cap.setAttribute('fill', '#8f2416'), 200); };
+ const fault = () => { armed = null; clearTimeout(armT); onChange('fault', 'TIE-DOWN FAULT · release and retry'); };
+ const press = side => {
+ if (busy) return;
+ if (armed === null) {
+ armed = side; flash(side === 'L' ? L.cap : R.cap);
+ onChange('armed', side + ' armed · other hand within 0.5s');
+ armT = setTimeout(fault, 500); return;
+ }
+ if (armed === side) { fault(); return; } /* same hand twice = tie-down */
+ clearTimeout(armT); armed = null; busy = true;
+ flash(side === 'L' ? L.cap : R.cap);
+ runLamp.setAttribute('fill', 'var(--pass)');
+ runLamp.setAttribute('filter', 'drop-shadow(0 0 5px rgba(116,147,47,.8))');
+ onChange('running', 'CYCLE RUNNING');
+ setTimeout(() => {
+ runLamp.setAttribute('fill', '#1e2a12'); runLamp.removeAttribute('filter');
+ busy = false; onChange('ready', 'ready');
+ }, 1500);
+ };
+ L.g.addEventListener('click', () => press('L'));
+ R.g.addEventListener('click', () => press('R'));
+ onChange('ready', 'ready');
+ return { el: s, press };
+};
+
+/* R51 voice-loop keyset — independent monitor states, exclusive talk, activity flicker */
+GW.voiceLoop = function (host, opts = {}) {
+ const onChange = opts.onChange || noop;
+ const LOOPS = opts.loops || ['FD', 'GNC', 'ECOM', 'SURG', 'A/G', 'NET1', 'NET2', 'PAO'];
+ const el = document.createElement('div'); el.className = 'vloop'; host.appendChild(el);
+ const keys = LOOPS.map(l => {
+ const k = document.createElement('div'); k.className = 'vk';
+ k.innerHTML = l + '<span class="bar"></span>'; k.dataset.state = '0'; el.appendChild(k); return k;
+ });
+ /* a legible default: FD + A/G monitored, GNC talking */
+ keys[0].dataset.state = '1'; keys[0].classList.add('mon');
+ keys[4].dataset.state = '1'; keys[4].classList.add('mon');
+ keys[1].dataset.state = '2'; keys[1].classList.add('mon', 'tlk');
+ const refresh = () => {
+ const mon = keys.filter(k => k.dataset.state !== '0').length;
+ const tlk = keys.findIndex(k => k.dataset.state === '2');
+ onChange(keys.map(k => k.dataset.state), mon === 0 ? 'all loops dropped' : mon + ' monitored' + (tlk >= 0 ? ' · talk ' + LOOPS[tlk] : ''));
+ };
+ keys.forEach(k => k.addEventListener('click', () => {
+ const st = k.dataset.state;
+ if (st === '0') { k.dataset.state = '1'; k.classList.add('mon'); }
+ else if (st === '1') {
+ keys.forEach(o => { if (o.dataset.state === '2') { o.dataset.state = '1'; o.classList.remove('tlk'); } });
+ k.dataset.state = '2'; k.classList.add('tlk');
+ }
+ else { k.dataset.state = '0'; k.classList.remove('mon', 'tlk', 'act'); }
+ refresh();
+ }));
+ if (!matchMedia('(prefers-reduced-motion: reduce)').matches)
+ setInterval(() => {
+ const t = performance.now() / 1000;
+ keys.forEach((k, i) => {
+ if (k.dataset.state === '0') { k.classList.remove('act'); return; }
+ k.classList.toggle('act', Math.sin(t * (1.1 + i * 0.37) + i * 2.1) > 0.55);
+ });
+ }, 300);
+ refresh();
+ return { el, get: () => keys.map(k => k.dataset.state) };
+};
+
/* ---- widget CSS: injected once, grows as builders move in ---- */
const GW_CSS = ``;
function ensureCss() {