Easy Mode: big-control page for non-technical users

easy.html: giant start/stop, plain-word sliders (how fast / how big / how
loud), friendly sound names (Ocean/Beep/Click/Knock), big color dots,
duration chips, live preview strip of the patient view, same safety notice.
Auto-connects to the relay when served over http with a persistent random
room id — patient link is one big copy button, no Detect IP step.
Advanced panel untouched, linked both ways; server opens easy.html first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-07 14:46:12 +10:00
parent 32dc6c4561
commit dadda8765d
4 changed files with 290 additions and 10 deletions

View File

@ -23,20 +23,28 @@ fancy lightbar, this is the backup plan.
## Quick start
**One computer, two screens (easiest — nothing to run):**
1. Open `index.html` in a browser. This is the therapist panel.
2. Click **Open Patient View**, drag that window to the patient-facing
**Easy Mode — start here.** `easy.html` is a big-buttons control page for
non-technical users: one start button, plain-word sliders (how fast, how big,
how loud), friendly sound names, a live preview of what the patient sees.
The full control panel (`index.html`) is one tap away under "Advanced
controls".
**One computer, two screens (nothing to run):**
1. Open `easy.html` in a browser.
2. Tap **Open patient screen**, drag that window to the patient-facing
display, make it fullscreen.
3. One click on the patient view unlocks audio (browser rule).
**Two devices on the same Wi-Fi:**
1. Double-click `start-relay.command` (Mac) or `start-relay.bat` (Windows),
or run `./start-relay.sh` (Linux). Needs only Python 3 — no pip, no
installs, no internet. It serves the app and the session relay in one
process and opens the therapist page for you.
2. Click **Detect Local IP** — the patient join link is auto-copied.
3. Open that link on the patient device; one tap unlocks audio.
4. Allow the firewall prompt (private networks) if one appears.
installs, no internet. It serves the app and the relay in one process and
opens Easy Mode for you.
2. Easy Mode connects to the relay automatically — tap **Copy link for
patient's phone / tablet** and open it on the patient device; one tap
there unlocks audio. (The advanced panel's Detect Local IP flow still
exists too.)
3. Allow the firewall prompt (private networks) if one appears.
## The therapist panel

269
easy.html Normal file
View File

@ -0,0 +1,269 @@
<!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>
<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('ws://' + location.hostname + ':8787') }catch(_){ return }
ws.onopen = function(){
net.ws = ws;
try{ ws.send(JSON.stringify({type:'join', sessionId: net.sid, role:'admin'})) }catch(_){ }
document.getElementById('copyLinkBig').hidden = false;
};
ws.onclose = function(){ net.ws = null; setTimeout(connect, 3000) };
ws.onerror = function(){ try{ ws.close() }catch(_){ } };
}
connect();
})();
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 = location.origin + '/patient.html#' + net.sid;
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>

View File

@ -46,7 +46,10 @@
<span class="btn-icon">👁️</span>
Open Patient View
</button>
<button class="btn-secondary" onclick="location.href='easy.html'">
<span class="btn-icon">🌞</span>
Easy Mode
</button>
</div>
</div>
</header>

View File

@ -233,7 +233,7 @@ def main():
threading.Thread(target=wsd.serve_forever, daemon=True).start()
if '--no-browser' not in sys.argv:
try:
webbrowser.open('http://%s:%d/' % (ip, HTTP_PORT))
webbrowser.open('http://%s:%d/easy.html' % (ip, HTTP_PORT))
except Exception:
pass
try: