Skip to content

Commit 9eb326a

Browse files
jeremymanningclaude
andcommitted
Fix mobile UX: scrollable quiz, video panel, dropdown, and fetch dedup
Mobile bottom sheet layout for quiz and video panels with mutual exclusion. Domain dropdown uses fixed positioning to escape overflow clipping. Suggest button toggles video panel on mobile. Added in-flight request deduplication to domain loader — prevents 50+ duplicate fetches when loadQuestionsForDomain('all') races with background pre-loads, which caused switchDomain to hang indefinitely. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0a25409 commit 9eb326a

5 files changed

Lines changed: 138 additions & 38 deletions

File tree

index.html

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,11 @@
684684
.custom-select-arrow { font-size: 0.9rem; }
685685
/* Hide custom tooltips on touch devices */
686686
.ui-tooltip { display: none !important; }
687+
688+
/* ── Quiz panel: collapse mode buttons to free space for questions ── */
689+
.modes-wrapper { display: none !important; }
690+
691+
/* ── Quiz panel: bottom sheet ── */
687692
#quiz-panel {
688693
position: absolute;
689694
top: auto; bottom: 0; left: 0; right: 0;
@@ -694,12 +699,22 @@
694699
z-index: 20;
695700
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), padding 0.3s cubic-bezier(0.4, 0, 0.2, 1);
696701
flex-shrink: 0;
702+
display: flex;
703+
flex-direction: column;
697704
}
698705
#quiz-panel.open {
699706
width: 100% !important; height: 60vh;
700707
padding: 1rem 1.25rem;
708+
overflow: hidden; /* Panel itself doesn't scroll; .quiz-content does */
709+
}
710+
#quiz-panel.open .quiz-content {
711+
flex: 1;
701712
overflow-y: auto;
713+
-webkit-overflow-scrolling: touch;
714+
min-height: 0; /* Allow flex child to shrink below content size */
702715
}
716+
717+
/* ── Quiz toggle: centered bottom tab ── */
703718
.quiz-toggle-btn {
704719
top: auto;
705720
bottom: 0;
@@ -710,12 +725,52 @@
710725
border-radius: 8px 8px 0 0;
711726
border: 1px solid var(--color-border);
712727
border-bottom: none;
728+
background: var(--color-primary);
729+
color: #fff;
730+
box-shadow: 0 -2px 8px rgba(0,0,0,0.2);
713731
}
714-
/* Arrow points UP when closed (pull up to open), DOWN when open (pull down to close) */
732+
/* Arrow points UP when closed (pull up to open) */
715733
.quiz-toggle-btn i { transform: rotate(90deg); }
716-
.quiz-toggle-btn.panel-open { bottom: 60vh; right: 50%; }
717-
.quiz-toggle-btn.panel-open i { transform: rotate(90deg); }
734+
/* Arrow points DOWN when open (pull down to close) */
735+
.quiz-toggle-btn.panel-open { bottom: 60vh; right: 50%; background: var(--color-surface); color: var(--color-text-muted); }
736+
.quiz-toggle-btn.panel-open i { transform: rotate(-90deg); }
737+
738+
/* ── Video panel: bottom sheet on mobile (not hidden) ── */
739+
#video-panel {
740+
display: flex !important; /* Override video-panel.js display:none */
741+
position: absolute;
742+
top: auto; bottom: 0; left: 0; right: 0;
743+
width: 100% !important; height: 0;
744+
min-width: 0;
745+
transform: none;
746+
border-radius: 16px 16px 0 0;
747+
box-shadow: 0 -4px 20px rgba(0,0,0,0.3);
748+
z-index: 21; /* Above quiz panel */
749+
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), padding 0.3s cubic-bezier(0.4, 0, 0.2, 1);
750+
flex-direction: column;
751+
overflow: hidden;
752+
}
753+
#video-panel.open {
754+
width: 100% !important; height: 55vh;
755+
padding: 1rem 1.25rem;
756+
overflow-y: auto;
757+
-webkit-overflow-scrolling: touch;
758+
}
759+
.video-toggle-btn { display: none !important; } /* Hide side tab; use header button instead */
760+
761+
/* ── Domain dropdown: use fixed positioning so it's not clipped by overflow:hidden ── */
718762
.domain-selector { flex-grow: 1; max-width: 160px; }
763+
.custom-select-options {
764+
position: fixed !important;
765+
top: var(--header-height) !important;
766+
bottom: auto !important;
767+
left: 0 !important;
768+
right: 0 !important;
769+
max-height: 50vh;
770+
min-width: unset;
771+
border-radius: 0 0 12px 12px;
772+
}
773+
719774
#minimap-container { display: none; }
720775
.resize-handle { display: none; }
721776
button, a, .quiz-option {

src/app.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,11 @@ async function boot() {
207207
if (suggestBtn) {
208208
suggestBtn.addEventListener('click', () => {
209209
if (!globalEstimator) return;
210+
// On mobile (<=480px), toggle the video discovery panel instead of the modal
211+
if (window.innerWidth <= 480) {
212+
toggleVideoPanel();
213+
return;
214+
}
210215
const { data, promise } = videoLoader.getVideos();
211216
if (data) {
212217
openVideoModal(data);
@@ -833,6 +838,13 @@ function toggleQuizPanel(show) {
833838
if (show === undefined) show = !quizPanel.classList.contains('open');
834839

835840
if (show) {
841+
// On mobile, close the video panel to avoid overlapping bottom sheets
842+
if (window.innerWidth <= 480) {
843+
const videoEl = document.getElementById('video-panel');
844+
if (videoEl && videoEl.classList.contains('open')) {
845+
toggleVideoPanel(false);
846+
}
847+
}
836848
quizPanel.classList.add('open');
837849
if (toggleBtn) {
838850
toggleBtn.classList.add('panel-open');
@@ -857,6 +869,10 @@ function toggleVideoPanel(show) {
857869
if (show === undefined) show = !panel.classList.contains('open');
858870

859871
if (show) {
872+
// On mobile, close the quiz panel to avoid overlapping bottom sheets
873+
if (window.innerWidth <= 480) {
874+
toggleQuizPanel(false);
875+
}
860876
panel.classList.add('open');
861877
if (toggleBtn) {
862878
toggleBtn.classList.add('panel-open');

src/domain/loader.js

Lines changed: 47 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
/** Async domain data loading with progress callbacks. */
1+
/** Async domain data loading with progress callbacks and request deduplication. */
22

33
import { $domainCache } from '../state/store.js';
44
import { getDescendants } from './registry.js';
55

66
const PROGRESS_THROTTLE_MS = 100;
77

8+
/** In-flight request deduplication — prevents duplicate fetches for the same domain. */
9+
const inflight = new Map();
10+
811
/**
912
* Load a domain bundle, with caching and streaming progress.
13+
* Concurrent calls for the same domainId share a single fetch request.
1014
* @param {string} domainId
1115
* @param {{ onProgress?, onComplete?, onError? }} [callbacks={}]
1216
* @param {string} [basePath] - Defaults to import.meta.env.BASE_URL || '/mapper/'
@@ -24,36 +28,54 @@ export async function load(domainId, callbacks = {}, basePath) {
2428
return cached;
2529
}
2630

27-
try {
28-
const url = `${base}data/domains/${domainId}.json`;
29-
const res = await fetch(url);
30-
if (!res.ok) {
31-
throw new Error(`Failed to fetch domain ${domainId}: ${res.status} ${res.statusText}`);
32-
}
33-
34-
let bundle;
35-
const contentLength = res.headers.get('Content-Length');
36-
const total = contentLength ? parseInt(contentLength, 10) : 0;
37-
38-
if (total > 0 && res.body) {
39-
bundle = await readWithProgress(res.body, total, onProgress);
40-
} else {
41-
onProgress?.({ loaded: 0, total: 0, percent: 0 });
42-
bundle = await res.json();
43-
onProgress?.({ loaded: 1, total: 1, percent: 100 });
44-
}
31+
// Deduplicate: if a fetch is already in-flight for this domain, await it
32+
if (inflight.has(domainId)) {
33+
const bundle = await inflight.get(domainId);
34+
onProgress?.({ loaded: 1, total: 1, percent: 100 });
35+
onComplete?.(bundle);
36+
return bundle;
37+
}
4538

46-
// Cache the result
47-
const next = new Map($domainCache.get());
48-
next.set(domainId, bundle);
49-
$domainCache.set(next);
39+
const promise = _fetchAndCache(domainId, base, onProgress);
40+
inflight.set(domainId, promise);
5041

42+
try {
43+
const bundle = await promise;
5144
onComplete?.(bundle);
5245
return bundle;
5346
} catch (err) {
5447
onError?.(err);
5548
throw err;
49+
} finally {
50+
inflight.delete(domainId);
51+
}
52+
}
53+
54+
async function _fetchAndCache(domainId, base, onProgress) {
55+
const url = `${base}data/domains/${domainId}.json`;
56+
const res = await fetch(url);
57+
if (!res.ok) {
58+
throw new Error(`Failed to fetch domain ${domainId}: ${res.status} ${res.statusText}`);
59+
}
60+
61+
let bundle;
62+
const contentLength = res.headers.get('Content-Length');
63+
const total = contentLength ? parseInt(contentLength, 10) : 0;
64+
65+
if (total > 0 && res.body) {
66+
bundle = await readWithProgress(res.body, total, onProgress);
67+
} else {
68+
onProgress?.({ loaded: 0, total: 0, percent: 0 });
69+
bundle = await res.json();
70+
onProgress?.({ loaded: 1, total: 1, percent: 100 });
5671
}
72+
73+
// Cache the result
74+
const next = new Map($domainCache.get());
75+
next.set(domainId, bundle);
76+
$domainCache.set(next);
77+
78+
return bundle;
5779
}
5880

5981
/** @param {ReadableStream} body */
@@ -103,7 +125,8 @@ async function readWithProgress(body, total, onProgress) {
103125
export async function loadQuestionsForDomain(domainId, basePath) {
104126
const idsToLoad = [domainId, ...getDescendants(domainId)];
105127

106-
// Load all bundles in parallel (cached ones resolve instantly)
128+
// Load all bundles in parallel (cached ones resolve instantly,
129+
// in-flight ones share the existing fetch via dedup)
107130
const bundles = await Promise.all(
108131
idsToLoad.map(id => load(id, {}, basePath).catch(() => null))
109132
);

src/ui/video-panel.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ const PANEL_CSS = `
398398
#video-panel.open { width: 50%; }
399399
}
400400
@media (max-width: 480px) {
401-
#video-panel { display: none; }
401+
/* Mobile: video panel is a bottom sheet, controlled by index.html styles */
402402
.video-toggle-btn { display: none; }
403403
}
404404
`;

tests/visual/responsive.spec.js

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,30 +33,36 @@ test.describe('Responsive Layout (SC-008)', () => {
3333
});
3434

3535
test('touch tap on answer button works on mobile (T062)', async ({ browser }) => {
36+
test.setTimeout(60000);
3637
const context = await browser.newContext({
3738
viewport: { width: 375, height: 667 },
3839
hasTouch: true,
3940
});
4041
const page = await context.newPage();
4142
await page.goto('/');
42-
// Click start button to enter the map, then select physics from header
43-
const startBtn = page.locator('#landing-start-btn');
43+
44+
// Click start to enter map (triggers switchDomain('all'))
4445
await page.waitForSelector('#landing-start-btn[data-ready]', { timeout: 15000 });
45-
await startBtn.tap();
46-
await page.waitForSelector('#quiz-panel:not([hidden])', { timeout: 15000 });
46+
await page.locator('#landing-start-btn').click();
47+
48+
// Wait for question to load (switchDomain loads all bundles via deduped fetches)
49+
const questionEl = page.locator('.quiz-question');
50+
await expect(questionEl).not.toBeEmpty({ timeout: 40000 });
4751

48-
const trigger = page.locator('.domain-selector .custom-select-trigger');
49-
await trigger.tap();
50-
await page.locator('.domain-selector .custom-select-option[data-value="physics"]').tap();
52+
// Ensure quiz panel bottom sheet is open on mobile
53+
const quizPanel = page.locator('#quiz-panel');
54+
const isOpen = await quizPanel.evaluate(el => el.classList.contains('open'));
55+
if (!isOpen) {
56+
await page.locator('#quiz-toggle').click();
57+
await expect(quizPanel).toHaveClass(/open/, { timeout: 3000 });
58+
}
5159

52-
await page.waitForSelector('.quiz-option', { timeout: 15000 });
5360
const btn = page.locator('.quiz-option').first();
5461
await btn.waitFor({ state: 'visible' });
55-
// Use click() instead of tap() — tap() doesn't reliably fire click handlers
5662
await btn.click();
5763

5864
const feedback = page.locator('.quiz-feedback');
59-
await expect(feedback).not.toBeEmpty({ timeout: 3000 });
65+
await expect(feedback).not.toBeEmpty({ timeout: 5000 });
6066
await context.close();
6167
});
6268
});

0 commit comments

Comments
 (0)