Skip to content

Commit b08b154

Browse files
lifeartclaude
andcommitted
Improve onboarding flow and user guidance
Onboarding: - Add sound/microphone permission notice (orange callout) - Add detection range and optimal conditions tips (blue box) - Expand step descriptions (mic permission, stay still during calibration) - Add Help (?) button in header to re-show onboarding anytime Post-onboarding guidance: - Add dynamic init hint that updates through the user journey (init -> calibrate -> scan, with error-specific messages) - Disabled buttons now explain why in tooltips - Descriptive status labels (idle -> "click Init Audio to begin") Error handling: - Specific messages for mic permission denied and no audio device - Error hint directs to Diagnostics log Information gaps filled: - Heatmap hint explains axes and color meaning - Profile hint explains peaks and hover interaction - Geometry hint explains layout and target trails - Calibration section explains quality scores and conditions - Direction axis hint explains detection range and surface types - Keyboard shortcuts listed near viz pane with ? button reference - Device preset shows (detected) on initial auto-detection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1525cde commit b08b154

7 files changed

Lines changed: 191 additions & 59 deletions

File tree

index.html

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@
2222
</head>
2323
<body>
2424
<header>
25-
<h1>Echoscope — Browser Sonar</h1>
25+
<div style="display:flex;justify-content:space-between;align-items:center;">
26+
<h1>Echoscope — Browser Sonar</h1>
27+
<button id="btnHelp" type="button" class="help-btn" title="Show quick-start guide and keyboard shortcuts.">?</button>
28+
</div>
2629
</header>
2730

2831
<!-- Device answer QR — top-level so it's immediately visible on phone -->
@@ -36,11 +39,11 @@ <h1>Echoscope — Browser Sonar</h1>
3639

3740
<section class="card controls-pane">
3841
<div class="row actions-sticky" style="justify-content:space-between">
39-
<button id="btnInit" title="Initialize Web Audio, request microphone access, and prepare the app for ping/scan operations.">Init Audio <kbd>I</kbd></button>
40-
<button id="btnPing" disabled title="Run a single measurement at the current steering angle and update readouts.">Ping <kbd>P</kbd></button>
41-
<button id="btnScan" disabled title="Run a full angular sweep using current scan settings and build the heatmap.">Scan <kbd>S</kbd></button>
42-
<button id="btnStop" disabled title="Stop the active scan loop immediately.">Stop <kbd>Esc</kbd></button>
43-
<button id="btnCalibrate" disabled title="Run direct-path calibration to improve timing lock and range accuracy.">Calibrate <kbd>C</kbd></button>
42+
<button id="btnInit" title="Start here: initialize Web Audio and request microphone permission. Required before any other action.">Init Audio <kbd>I</kbd></button>
43+
<button id="btnPing" disabled title="Requires Init Audio first. Runs a single measurement at the current steering angle.">Ping <kbd>P</kbd></button>
44+
<button id="btnScan" disabled title="Requires Init Audio first. Runs a full angular sweep and builds the heatmap.">Scan <kbd>S</kbd></button>
45+
<button id="btnStop" disabled title="No scan is running. Active only during a scan sweep.">Stop <kbd>Esc</kbd></button>
46+
<button id="btnCalibrate" disabled title="Requires Init Audio first. Measures speaker-to-mic timing for accurate range.">Calibrate <kbd>C</kbd></button>
4447
</div>
4548

4649
<div id="levelMeterWrap" style="display:none" class="level-meter-wrap">
@@ -50,8 +53,11 @@ <h1>Echoscope — Browser Sonar</h1>
5053
<span id="stereoIndicator" class="small indicator-ok" style="display:none;"></span>
5154
</div>
5255

56+
<p id="initHint" class="hint" style="color:#8dd0ff;font-weight:600;">
57+
&#x2192; Start by clicking <b>Init Audio</b> above to enable microphone access and the audio engine.
58+
</p>
5359
<p class="hint warn">
54-
Run on <b>https</b> (or localhost). Keep volume low to avoid feedback. On iPhone, tap buttons to start/resume audio.
60+
Run on <b>https</b> (or localhost). Keep volume moderate to avoid feedback. On iPhone, tap buttons to start/resume audio.
5561
</p>
5662

5763
<details id="quickSetup" class="section-group" open>
@@ -625,12 +631,12 @@ <h1>Echoscope — Browser Sonar</h1>
625631
</label>
626632
</div>
627633

628-
<p class="hint">Use <b>Horizontal (L/R)</b> for left/right and <b>Vertical (T/B)</b> when your speaker axis is rotated 90 degrees.</p>
634+
<p class="hint">Use <b>Horizontal (L/R)</b> for left/right and <b>Vertical (T/B)</b> when your speaker axis is rotated 90&deg;. Detection works best on hard, flat surfaces (walls, furniture) within 0.3&#x2013;5 m. Soft materials (curtains, pillows) absorb sound and produce weak echoes.</p>
629635

630636
<hr/>
631637

632638
<!-- Calibration -->
633-
<p class="hint">Calibration measures speaker-to-mic distances to lock direct-path timing and improve range accuracy.</p>
639+
<p class="hint">Calibration measures speaker-to-mic distances to lock direct-path timing and improve range accuracy. Keep the room quiet and don't move the device during calibration. A quality score above 0.5 is good; above 0.8 is excellent.</p>
634640
<div class="row" style="justify-content:space-between">
635641
<label class="checkbox-label">
636642
<input id="useCalib" type="checkbox" checked title="Use calibration data to constrain direct-path search and reduce ambiguity.">
@@ -769,7 +775,7 @@ <h1>Echoscope — Browser Sonar</h1>
769775
<details id="heatmapDetails" open style="margin-top:10px">
770776
<summary>Angle x Range heatmap</summary>
771777
<p class="hint" style="margin:4px 0 8px">
772-
Main scan view: bright spots show stronger reflections by angle and range.
778+
Main scan view. Horizontal axis = steering angle (degrees), vertical axis = distance (meters). Brighter colors = stronger echo reflections. Look for bright spots to locate objects.
773779
</p>
774780
<canvas id="heatmap" width="1000" height="520" role="img" aria-label="Angle versus range heatmap showing echo strength"></canvas>
775781
<div class="row" style="margin-top:6px;display:flex;gap:12px;align-items:center;flex-wrap:wrap">
@@ -792,7 +798,7 @@ <h1>Echoscope — Browser Sonar</h1>
792798
<details id="profileDetails" style="margin-top:10px">
793799
<summary>Range profile</summary>
794800
<p class="hint" style="margin:4px 0 8px">
795-
Correlation vs range for the latest ping/scan row.
801+
Correlation strength vs distance (meters) for the latest ping. Peaks indicate detected objects; taller peaks = stronger reflections. Hover to read exact range values.
796802
</p>
797803
<canvas id="profile" width="1000" height="260" role="img" aria-label="Correlation profile showing echo strength vs range"></canvas>
798804
</details>
@@ -811,7 +817,7 @@ <h1>Echoscope — Browser Sonar</h1>
811817
<details id="geometryDetails" style="margin-top:10px">
812818
<summary>Geometry view</summary>
813819
<p class="hint" style="margin:4px 0 8px">
814-
Device layout and target position estimate in top view.
820+
Top-down view showing speaker positions (L/R), microphone, and estimated target locations. Tracked targets show trails indicating movement history.
815821
</p>
816822
<canvas id="geometry" width="1000" height="280" role="img" aria-label="Geometry view showing speaker and microphone positions"></canvas>
817823
<div class="row" style="margin-top:8px">
@@ -826,6 +832,7 @@ <h1>Echoscope — Browser Sonar</h1>
826832
</details>
827833

828834
<p class="hint">If audio goes silent after switching apps/locking screen: tap <b>Init Audio</b> to resume.</p>
835+
<p class="hint">Keyboard shortcuts: <kbd>I</kbd> Init Audio, <kbd>P</kbd> / <kbd>Space</kbd> Ping, <kbd>S</kbd> Scan, <kbd>Esc</kbd> Stop, <kbd>C</kbd> Calibrate. Click <b>?</b> in the header for the full guide.</p>
829836
</section>
830837

831838
</div>
@@ -836,12 +843,14 @@ <h1>Echoscope — Browser Sonar</h1>
836843
<div class="onboarding-dialog">
837844
<h2>Welcome to Echoscope</h2>
838845
<p class="onboarding-subtitle">Browser-based active sonar echolocation. Transmit acoustic signals through your speakers, capture echoes with your microphone, and visualize your surroundings in real time.</p>
846+
<p class="onboarding-notice">&#x1f50a; This app emits audible sound through your speakers and requires <b>microphone permission</b> to capture echoes. Use headphones-free and keep volume moderate.</p>
839847
<ol class="onboarding-steps">
840848
<li><b>Select your device</b> &#x2014; pick your laptop or tablet from the <em>Device preset</em> dropdown so speaker/mic geometry is correct.</li>
841-
<li><b>Init Audio</b> &#x2014; click the button (or press <kbd>I</kbd>) to grant microphone access and start the audio engine.</li>
842-
<li><b>Calibrate</b> &#x2014; click <em>Calibrate</em> (or press <kbd>C</kbd>) to measure speaker-to-mic timing in your room.</li>
843-
<li><b>Scan</b> &#x2014; click <em>Scan</em> (or press <kbd>S</kbd>) to start echolocation and build a heatmap.</li>
849+
<li><b>Init Audio</b> &#x2014; click the button (or press <kbd>I</kbd>) to grant microphone access and start the audio engine. Your browser will ask for mic permission.</li>
850+
<li><b>Calibrate</b> &#x2014; click <em>Calibrate</em> (or press <kbd>C</kbd>) to measure speaker-to-mic timing in your room. Stay still and keep the room quiet.</li>
851+
<li><b>Scan</b> &#x2014; click <em>Scan</em> (or press <kbd>S</kbd>) to start echolocation and build a heatmap of nearby objects.</li>
844852
</ol>
853+
<p class="onboarding-tip">Typical detection range is <b>0.3 &#x2013; 5 m</b> indoors. Hard, flat surfaces (walls, furniture, monitors) reflect best. A quiet room with minimal background noise gives the best results.</p>
845854
<div class="onboarding-actions">
846855
<button id="onboardingDismiss" type="button">Get Started</button>
847856
<a id="onboardingNeverShow" href="#" class="onboarding-skip">Don't show again</a>

src/styles/main.css

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,52 @@ kbd { padding:1px 5px; font-size:10px; color:#9e9e9e; background:#1a1a1a; border
177177
text-decoration: underline;
178178
}
179179

180+
.onboarding-notice {
181+
font-size: 12px;
182+
color: #ffbf80;
183+
line-height: 1.45;
184+
margin: 0 0 12px;
185+
padding: 8px 10px;
186+
background: rgba(255, 191, 128, 0.08);
187+
border: 1px solid rgba(255, 191, 128, 0.18);
188+
border-radius: 8px;
189+
}
190+
191+
.onboarding-tip {
192+
font-size: 12px;
193+
color: #8dd0ff;
194+
line-height: 1.45;
195+
margin: 0 0 16px;
196+
padding: 8px 10px;
197+
background: rgba(141, 208, 255, 0.06);
198+
border: 1px solid rgba(141, 208, 255, 0.15);
199+
border-radius: 8px;
200+
}
201+
202+
.help-btn {
203+
background: transparent;
204+
border: 1px solid #444;
205+
color: #bdbdbd;
206+
border-radius: 50%;
207+
width: 28px;
208+
height: 28px;
209+
padding: 0;
210+
font-size: 14px;
211+
font-weight: 700;
212+
line-height: 1;
213+
cursor: pointer;
214+
flex-shrink: 0;
215+
display: flex;
216+
align-items: center;
217+
justify-content: center;
218+
}
219+
220+
.help-btn:hover {
221+
color: #eaeaea;
222+
border-color: #888;
223+
background: rgba(255, 255, 255, 0.05);
224+
}
225+
180226
/* ---- Status error ---- */
181227
.status-error { color: #f44336; font-weight: bold; }
182228

src/ui/app.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,11 @@ export function initApp(): void {
124124
await refreshDeviceInfo();
125125
const audioState = store.get().audio;
126126
log(`[ok] audio initialized: sr=${audioState.actualSampleRate} Hz, capture=${audioState.captureMethod}, channels=${audioState.channelCount}`);
127+
// Hide the init hint and show next-step guidance
128+
const initHintEl = el('initHint');
129+
if (initHintEl) {
130+
initHintEl.textContent = '\u2192 Audio ready. Next: click Calibrate for best accuracy, or Scan to start immediately.';
131+
}
127132
const stereoEl = el('stereoIndicator');
128133
if (stereoEl) {
129134
if (audioState.channelCount >= 2) {
@@ -141,7 +146,19 @@ export function initApp(): void {
141146
} catch (e: any) {
142147
if (btn) { btn.disabled = false; btn.textContent = 'Init Audio [I]'; }
143148
setStatus('error');
144-
log('[err] init failed: ' + (e?.message || e));
149+
const msg = e?.message || String(e);
150+
if (/permission|denied|not allowed/i.test(msg)) {
151+
log('[err] Microphone permission denied. Please allow mic access in your browser settings and try again.');
152+
} else if (/not found|no device/i.test(msg)) {
153+
log('[err] No audio input device found. Connect a microphone and try again.');
154+
} else {
155+
log('[err] init failed: ' + msg);
156+
}
157+
const initHintEl = el('initHint');
158+
if (initHintEl) {
159+
initHintEl.style.color = '#ffbf80';
160+
initHintEl.textContent = '\u2192 Init failed. Check the Diagnostics log below for details, then retry Init Audio.';
161+
}
145162
}
146163
});
147164

@@ -198,6 +215,13 @@ export function initApp(): void {
198215
drawCalibSanityPlot(calib.sanity.curveL, calib.sanity.peakIndexL, calib.sanity.curveR, calib.sanity.peakIndexR, calib.sanity.earlyMs);
199216
}
200217
drawGeometry(store.get().config.minRange, store.get().config.maxRange);
218+
// Update hint after successful calibration
219+
const calibHintEl = el('initHint');
220+
if (calibHintEl) {
221+
const q = store.get().calibration?.quality ?? 0;
222+
calibHintEl.style.color = q > 0.5 ? '#4caf50' : '#ffbf80';
223+
calibHintEl.textContent = `\u2713 Calibrated (quality ${q.toFixed(2)}). Click Scan to start echolocation.`;
224+
}
201225
} catch (e: any) {
202226
setStatus('error');
203227
log('[err] calibrate failed: ' + (e?.message || e));

src/ui/controls.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -375,9 +375,35 @@ export function setButtonStates(audioReady: boolean, scanning: boolean): void {
375375
const btnCalibrate = el('btnCalibrate') as HTMLButtonElement | null;
376376
const btnRefreshDevices = el('btnRefreshDevices') as HTMLButtonElement | null;
377377

378-
if (btnPing) btnPing.disabled = !audioReady || scanning;
379-
if (btnScan) btnScan.disabled = !audioReady || scanning;
380-
if (btnStop) btnStop.disabled = !audioReady || !scanning;
381-
if (btnCalibrate) btnCalibrate.disabled = !audioReady || scanning;
378+
if (btnPing) {
379+
btnPing.disabled = !audioReady || scanning;
380+
btnPing.title = !audioReady
381+
? 'Requires Init Audio first. Runs a single measurement at the current steering angle.'
382+
: scanning
383+
? 'Busy: a scan is in progress. Stop it first.'
384+
: 'Run a single measurement at the current steering angle and update readouts.';
385+
}
386+
if (btnScan) {
387+
btnScan.disabled = !audioReady || scanning;
388+
btnScan.title = !audioReady
389+
? 'Requires Init Audio first. Runs a full angular sweep and builds the heatmap.'
390+
: scanning
391+
? 'A scan is already running.'
392+
: 'Run a full angular sweep using current scan settings and build the heatmap.';
393+
}
394+
if (btnStop) {
395+
btnStop.disabled = !audioReady || !scanning;
396+
btnStop.title = !scanning
397+
? 'No scan is running. Active only during a scan sweep.'
398+
: 'Stop the active scan loop immediately.';
399+
}
400+
if (btnCalibrate) {
401+
btnCalibrate.disabled = !audioReady || scanning;
402+
btnCalibrate.title = !audioReady
403+
? 'Requires Init Audio first. Measures speaker-to-mic timing for accurate range.'
404+
: scanning
405+
? 'Busy: a scan is in progress. Stop it first.'
406+
: 'Run direct-path calibration to improve timing lock and range accuracy.';
407+
}
382408
if (btnRefreshDevices) btnRefreshDevices.disabled = !audioReady;
383409
}

src/ui/device-presets.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,16 +78,16 @@ export function applyDevicePreset(key: string, silent = false): void {
7878
}
7979
});
8080

81+
// Show "(detected)" hint on the selected preset option
82+
const option = devicePresetEl?.querySelector(`option[value="${key}"]`);
83+
if (option && !option.textContent?.includes('(detected)')) {
84+
option.textContent += ' (detected)';
85+
}
86+
8187
if (!silent) {
8288
const dStr = preset.d !== null ? `d=${preset.d}m` : 'manual';
8389
const micStr = preset.mic.x !== null ? `mic\u2248(${preset.mic.x}, ${preset.mic.y})m` : 'manual';
8490
log(`[preset] ${preset.name}: ${dStr}, ${micStr}`);
85-
86-
// Show "(detected)" hint on auto-detected preset option
87-
const option = devicePresetEl?.querySelector(`option[value="${key}"]`);
88-
if (option && !option.textContent?.includes('(detected)')) {
89-
option.textContent += ' (detected)';
90-
}
9191
}
9292

9393
// Apply laptop mode scan settings if checkbox is checked

src/ui/onboarding.ts

Lines changed: 49 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,38 +4,7 @@ function el(id: string): HTMLElement | null {
44
return document.getElementById(id);
55
}
66

7-
/** Show onboarding overlay on first visit. No-op if already dismissed. */
8-
export function initOnboarding(): void {
9-
if (localStorage.getItem(STORAGE_KEY)) return;
10-
11-
const overlay = el('onboardingOverlay');
12-
if (!overlay) return;
13-
14-
overlay.classList.remove('hidden');
15-
16-
// Remember previously focused element to restore on dismiss
17-
const previouslyFocused = document.activeElement as HTMLElement | null;
18-
19-
const dismiss = () => {
20-
overlay.classList.add('hidden');
21-
localStorage.setItem(STORAGE_KEY, '1');
22-
// Return focus to previously focused element
23-
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
24-
previouslyFocused.focus();
25-
}
26-
};
27-
28-
// "Get Started" button
29-
const dismissBtn = el('onboardingDismiss');
30-
dismissBtn?.addEventListener('click', dismiss);
31-
32-
// "Don't show again" link — same effect
33-
const neverShowLink = el('onboardingNeverShow');
34-
neverShowLink?.addEventListener('click', (ev) => {
35-
ev.preventDefault();
36-
dismiss();
37-
});
38-
7+
function setupOverlayBehavior(overlay: HTMLElement, dismiss: () => void): void {
398
// Click outside the dialog box to dismiss
409
overlay.addEventListener('click', (ev) => {
4110
if (ev.target === overlay) dismiss();
@@ -70,6 +39,39 @@ export function initOnboarding(): void {
7039
}
7140
}
7241
});
42+
}
43+
44+
/** Show the onboarding overlay and wire dismiss behavior. */
45+
function showOverlay(): void {
46+
const overlay = el('onboardingOverlay');
47+
if (!overlay) return;
48+
49+
// Remember previously focused element to restore on dismiss
50+
const previouslyFocused = document.activeElement as HTMLElement | null;
51+
52+
overlay.classList.remove('hidden');
53+
54+
const dismiss = () => {
55+
overlay.classList.add('hidden');
56+
localStorage.setItem(STORAGE_KEY, '1');
57+
// Return focus to previously focused element
58+
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
59+
previouslyFocused.focus();
60+
}
61+
};
62+
63+
// "Get Started" button
64+
const dismissBtn = el('onboardingDismiss');
65+
dismissBtn?.addEventListener('click', dismiss);
66+
67+
// "Don't show again" link — same effect
68+
const neverShowLink = el('onboardingNeverShow');
69+
neverShowLink?.addEventListener('click', (ev) => {
70+
ev.preventDefault();
71+
dismiss();
72+
});
73+
74+
setupOverlayBehavior(overlay, dismiss);
7375

7476
// Move focus to the "Get Started" button when dialog opens
7577
if (dismissBtn) {
@@ -78,3 +80,18 @@ export function initOnboarding(): void {
7880
});
7981
}
8082
}
83+
84+
/** Show onboarding overlay on first visit. No-op if already dismissed. */
85+
export function initOnboarding(): void {
86+
// Wire up the Help (?) button so it always re-shows the overlay
87+
const helpBtn = el('btnHelp');
88+
if (helpBtn) {
89+
helpBtn.addEventListener('click', () => {
90+
showOverlay();
91+
});
92+
}
93+
94+
if (localStorage.getItem(STORAGE_KEY)) return;
95+
96+
showOverlay();
97+
}

src/ui/readouts.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,20 @@ function el(id: string): HTMLElement | null {
55
return document.getElementById(id);
66
}
77

8+
const STATUS_LABELS: Record<string, string> = {
9+
idle: 'idle — click Init Audio to begin',
10+
initializing: 'initializing audio...',
11+
ready: 'ready — audio active',
12+
pinging: 'pinging...',
13+
scanning: 'scanning...',
14+
calibrating: 'calibrating...',
15+
error: 'error — see Diagnostics log',
16+
};
17+
818
export function setStatus(msg: string): void {
919
const statusEl = el('status');
1020
if (statusEl) {
11-
statusEl.textContent = 'Status: ' + msg;
21+
statusEl.textContent = 'Status: ' + (STATUS_LABELS[msg] ?? msg);
1222
statusEl.classList.toggle('status-error', msg === 'error');
1323
}
1424
}

0 commit comments

Comments
 (0)