backnforth/easy.html
type-two eed30a2ba5 Harden hosted relay with access codes + illustrated manual page
--tokens FILE gates session hosting: hosts present an access code, patients
join live sessions freely (no code), un-joined connections can't relay, and
joined members can't spoof state into other rooms. Easy Mode grows a gentle
access-code card (one-computer mode stays free/ungated); advanced panel gets
a token field; patient view shows a waiting note and auto-retries.
manual.html: picture-book how-to, images slot in under img/manual/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:14:03 +10:00

305 lines
15 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>backnforth — Easy Mode</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='%231f6feb'/%3E%3C/svg%3E">
<style>
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin: 0; background: #10141a; color: #e6edf3;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 520px; margin: 0 auto; padding: 16px 16px 48px; }
h1 { font-size: 22px; text-align: center; margin: 10px 0 2px; letter-spacing: 1px; }
.sub { text-align: center; opacity: .6; font-size: 14px; margin-bottom: 14px; }
#preview { display: block; width: 100%; height: 90px; border-radius: 12px; background: #000; }
.card { background: #1a2028; border-radius: 14px; padding: 14px 16px; margin-top: 14px; }
.card label.head { display: block; font-size: 17px; font-weight: 600; margin-bottom: 10px; }
.rowicons { display: flex; align-items: center; gap: 12px; font-size: 26px; }
.rowicons input { flex: 1; }
input[type=range] { -webkit-appearance: none; appearance: none; height: 14px; border-radius: 7px;
background: #2d3743; outline: none; margin: 14px 0; }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none;
width: 44px; height: 44px; border-radius: 50%; background: #1f6feb; border: 4px solid #fff;
cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,.5); }
input[type=range]::-moz-range-thumb { width: 44px; height: 44px; border-radius: 50%;
background: #1f6feb; border: 4px solid #fff; cursor: pointer; }
.word { text-align: center; font-size: 15px; opacity: .75; min-height: 20px; }
.chips { display: flex; flex-wrap: wrap; gap: 10px; }
.chip { flex: 1 1 28%; min-height: 56px; font-size: 17px; border: 2px solid #2d3743;
border-radius: 12px; background: #222a34; color: #e6edf3; cursor: pointer; }
.chip.on { border-color: #1f6feb; background: #1f6feb33; }
.dots { display: flex; gap: 14px; justify-content: center; }
.dot { width: 52px; height: 52px; border-radius: 50%; border: 3px solid #2d3743; cursor: pointer; }
.dot.on { border-color: #fff; box-shadow: 0 0 0 3px #1f6feb; }
#bigStart { width: 100%; min-height: 84px; font-size: 30px; font-weight: 700; border: none;
border-radius: 16px; background: #2ea043; color: #fff; cursor: pointer; margin-top: 16px; }
#bigStart.stop { background: #da3633; }
#bigTimer { text-align: center; font-size: 40px; font-variant-numeric: tabular-nums;
margin: 8px 0 0; opacity: .9; }
.bigbtn { width: 100%; min-height: 60px; font-size: 19px; border: 2px solid #2d3743;
border-radius: 14px; background: #222a34; color: #e6edf3; cursor: pointer; margin-top: 14px; }
.adv { display: block; text-align: center; margin-top: 22px; color: #7d8590; font-size: 15px; }
</style>
</head>
<body>
<div id="safetyNotice" style="display:none; position:fixed; inset:0; z-index:9999; background:rgba(0,0,0,0.8); overflow:auto;">
<div style="max-width:560px; margin:8vh auto; background:#1c2128; color:#e6edf3; border-radius:12px; padding:28px; line-height:1.55;">
<h2 style="margin-top:0">⚠️ Read before first use</h2>
<ul style="padding-left:20px">
<li style="margin-bottom:10px"><strong>Photosensitivity:</strong> moving and pulsing visuals can trigger seizures in people with photosensitive epilepsy. Ask about seizure history before use and stop immediately if the viewer feels unwell.</li>
<li style="margin-bottom:10px"><strong>Clinical use:</strong> bilateral stimulation can surface intense memories and emotions. This tool assists a trained clinician — it is not therapy by itself.</li>
</ul>
<button id="safetyAck" style="width:100%; padding:14px; border:none; border-radius:8px; background:#1f6feb; color:#fff; font-size:16px; cursor:pointer">I understand</button>
</div>
</div>
<main>
<h1>backnforth</h1>
<div class="sub">Set it up, press start, watch the patient — not the screen.</div>
<canvas id="preview" width="640" height="120"></canvas>
<button id="bigStart">▶ START</button>
<div id="bigTimer">00:00</div>
<div class="card">
<label class="head">How fast</label>
<div class="rowicons">🐢 <input type="range" id="ezSpeed" min="60" max="600" step="10"> 🐇</div>
<div class="word" id="speedWord"></div>
</div>
<div class="card">
<label class="head">How big</label>
<div class="rowicons"><span style="font-size:14px"></span> <input type="range" id="ezSize" min="12" max="90" step="2"> <span style="font-size:36px"></span></div>
</div>
<div class="card">
<label class="head">Color</label>
<div class="dots" id="ezColors">
<button class="dot" data-color="#00ff88" style="background:#00ff88"></button>
<button class="dot" data-color="#58a6ff" style="background:#58a6ff"></button>
<button class="dot" data-color="#ffd33d" style="background:#ffd33d"></button>
<button class="dot" data-color="#ff6b6b" style="background:#ff6b6b"></button>
<button class="dot" data-color="#ffffff" style="background:#ffffff"></button>
</div>
</div>
<div class="card">
<label class="head">Sound</label>
<div class="chips" id="ezSounds">
<button class="chip" data-sound="off">🔇 Off</button>
<button class="chip" data-sound="ocean">🌊 Ocean</button>
<button class="chip" data-sound="beep">🔔 Beep</button>
<button class="chip" data-sound="click">🥢 Click</button>
<button class="chip" data-sound="knock">🪵 Knock</button>
</div>
<div class="rowicons" style="margin-top:8px">🔈 <input type="range" id="ezVolume" min="0" max="100" step="5"> 🔊</div>
</div>
<div class="card">
<label class="head">How long</label>
<div class="chips" id="ezDuration">
<button class="chip" data-min="1">1 min</button>
<button class="chip" data-min="2">2 min</button>
<button class="chip" data-min="5">5 min</button>
<button class="chip on" data-min="0">No limit</button>
</div>
</div>
<button id="openPatientBig" class="bigbtn">🖥️ Open patient screen</button>
<button id="copyLinkBig" class="bigbtn" hidden>🔗 Copy link for patient's phone / tablet</button>
<div id="accessCard" class="card" hidden>
<label class="head">🔑 Access code</label>
<div style="font-size:14px; opacity:.8; margin-bottom:10px">
Linking a second device through this site needs a free access code
(that's how we keep the relay clean). No code? Everything still works
on one computer — just tap "Open patient screen" above.
</div>
<div style="display:flex; gap:10px">
<input id="accessInput" type="text" placeholder="paste your code"
style="flex:1; min-height:48px; font-size:17px; border-radius:10px; border:2px solid #2d3743; background:#222a34; color:#e6edf3; padding:0 12px">
<button id="accessSave" class="chip" style="flex:0 0 auto; padding:0 18px">Save</button>
</div>
</div>
<a class="adv" href="manual.html">📖 How it works (picture guide)</a>
<a class="adv" href="index.html">Advanced controls →</a>
</main>
<script src="js/bls.js"></script>
<script>
(function(){
// one-time safety notice (same ack flag as the advanced page)
var seen = false; try{ seen = localStorage.getItem('bls_safety_ack')==='1' }catch(_){ }
if(!seen){
var sn = document.getElementById('safetyNotice');
sn.style.display = 'block';
document.getElementById('safetyAck').addEventListener('click', function(){
try{ localStorage.setItem('bls_safety_ack','1') }catch(_){ }
sn.style.display = 'none';
});
}
var chan = BLS.createChannel('bls-session');
var state = BLS.loadState();
var preview = BLS.Renderer(document.getElementById('preview'));
var durationMin = 0;
var tick = null;
// optional relay: only when served over http(s); silently absent on file://
var net = { ws: null, sid: null };
(function(){
if(!/^https?:$/.test(location.protocol) || !window.WebSocket) return;
try{ net.sid = localStorage.getItem('bls_easy_sid') }catch(_){ }
if(!net.sid){
var a = new Uint32Array(2);
(window.crypto||{getRandomValues:function(x){ for(var i=0;i<x.length;i++){ x[i]=Math.floor(Math.random()*0xffffffff) } }}).getRandomValues(a);
net.sid = 'room-' + (a[0].toString(36)+a[1].toString(36)).slice(0,8);
try{ localStorage.setItem('bls_easy_sid', net.sid) }catch(_){ }
}
function connect(){
var ws;
try{ ws = new WebSocket(BLS.relayUrl()) }catch(_){ return }
ws.onopen = function(){
var token = null; try{ token = localStorage.getItem('bls_relay_token') }catch(_){ }
try{ ws.send(JSON.stringify({type:'join', sessionId: net.sid, role:'admin', token: token || undefined})) }catch(_){ }
};
ws.onmessage = function(ev){
try{
var msg = JSON.parse(ev.data);
if(msg.type === 'joined'){
net.ws = ws;
document.getElementById('copyLinkBig').hidden = false;
document.getElementById('accessCard').hidden = true;
} else if(msg.type === 'join_denied'){
document.getElementById('accessCard').hidden = false;
try{ ws.close() }catch(_){ }
}
}catch(_){ }
};
ws.onclose = function(){ net.ws = null; setTimeout(connect, 5000) };
ws.onerror = function(){ try{ ws.close() }catch(_){ } };
}
connect();
document.getElementById('accessSave').addEventListener('click', function(){
var v = document.getElementById('accessInput').value.trim();
if(!v) return;
try{ localStorage.setItem('bls_relay_token', v) }catch(_){ }
this.textContent = 'Connecting…';
var b = this;
setTimeout(function(){ b.textContent = 'Save' }, 6000);
});
})();
function apply(next){
state = BLS.mergeState(state, next, true);
chan.send(state);
if(net.ws && net.ws.readyState === 1){
try{ net.ws.send(JSON.stringify({type:'state', sessionId: net.sid, payload: state})) }catch(_){ }
}
preview.setState(state, false);
if(state.running && !state.paused){ preview.start() } else { preview.stop() }
render();
}
function speedWord(v){ return v < 160 ? 'Gentle' : v < 300 ? 'Steady' : v < 450 ? 'Quick' : 'Fast' }
var soundMap = {
off: { audioEnabled: false },
ocean: { audioEnabled: true, audioMode: 'programmatic', programPreset: 'pink', syncPan: true },
beep: { audioEnabled: true, audioMode: 'programmatic', programPreset: 'tone', toneFreq: 220, toneDurationMs: 90 },
click: { audioEnabled: true, audioMode: 'programmatic', programPreset: 'click' },
knock: { audioEnabled: true, audioMode: 'programmatic', programPreset: 'woodblock' }
};
function currentSound(){
if(!state.audioEnabled) return 'off';
if(state.programPreset === 'pink') return 'ocean';
if(state.programPreset === 'tone') return 'beep';
if(state.programPreset === 'click') return 'click';
return 'knock';
}
function render(){
var b = document.getElementById('bigStart');
b.textContent = state.running ? '⏹ STOP' : '▶ START';
b.className = state.running ? 'stop' : '';
document.getElementById('speedWord').textContent = speedWord(state.speed);
var snd = currentSound();
document.querySelectorAll('#ezSounds .chip').forEach(function(c){ c.classList.toggle('on', c.dataset.sound === snd) });
document.querySelectorAll('#ezColors .dot').forEach(function(d){ d.classList.toggle('on', d.dataset.color.toLowerCase() === String(state.shapeColor).toLowerCase()) });
}
function fmt(sec){ var m = Math.floor(sec/60), s = sec%60; return String(m).padStart(2,'0')+':'+String(s).padStart(2,'0') }
function startTick(){
if(tick) clearInterval(tick);
tick = setInterval(function(){
var st = BLS.loadState();
if(!st.running){ clearInterval(tick); tick = null; document.getElementById('bigTimer').textContent = '00:00'; render(); return }
var elapsed = Math.max(0, Math.floor((Date.now() - st.startedAt) / 1000));
var el = document.getElementById('bigTimer');
if(st.durationSec > 0){
var rem = Math.max(0, st.durationSec - elapsed);
el.textContent = fmt(rem);
if(rem <= 0){ apply({ running:false, startedAt:null }) }
} else {
el.textContent = fmt(elapsed);
}
}, 250);
}
document.getElementById('bigStart').addEventListener('click', function(){
if(state.running){
apply({ running:false, paused:false, startedAt:null });
} else {
apply({ running:true, paused:false, pausedMs:0, pausedAt:null,
startedAt: Date.now(), durationSec: durationMin * 60 });
startTick();
}
});
var ezSpeed = document.getElementById('ezSpeed');
var ezSize = document.getElementById('ezSize');
var ezVolume = document.getElementById('ezVolume');
ezSpeed.value = state.speed || 250;
ezSize.value = state.size || 28;
ezVolume.value = Math.round((state.volume || 0.6) * 100);
ezSpeed.addEventListener('input', function(){ apply({ speed: parseInt(ezSpeed.value, 10) }) });
ezSize.addEventListener('input', function(){ apply({ size: parseInt(ezSize.value, 10) }) });
ezVolume.addEventListener('input', function(){ apply({ volume: parseInt(ezVolume.value, 10) / 100 }) });
document.getElementById('ezColors').addEventListener('click', function(e){
var c = e.target && e.target.dataset && e.target.dataset.color;
if(c) apply({ shapeColor: c });
});
document.getElementById('ezSounds').addEventListener('click', function(e){
var s = e.target && e.target.dataset && e.target.dataset.sound;
if(s) apply(soundMap[s]);
});
document.getElementById('ezDuration').addEventListener('click', function(e){
var m = e.target && e.target.dataset && e.target.dataset.min;
if(m === undefined) return;
durationMin = parseInt(m, 10) || 0;
document.querySelectorAll('#ezDuration .chip').forEach(function(c){ c.classList.toggle('on', c === e.target) });
if(state.running){ apply({ durationSec: durationMin * 60, startedAt: Date.now() }) }
});
document.getElementById('openPatientBig').addEventListener('click', function(){
window.open('patient.html' + (net.sid ? '#' + net.sid : ''), 'bls-patient');
});
document.getElementById('copyLinkBig').addEventListener('click', function(){
var link = new URL('patient.html#' + net.sid, location.href).href;
var b = this;
try{ navigator.clipboard.writeText(link) }catch(_){ try{ prompt('Patient link:', link) }catch(__){ } }
b.textContent = '✅ Copied — open it on the patient device';
setTimeout(function(){ b.textContent = '🔗 Copy link for patient\'s phone / tablet' }, 2500);
});
preview.setState(state, false);
if(state.running){ preview.start(); startTick() }
render();
})();
</script>
</body>
</html>