Skip to content

Commit 2c93f3a

Browse files
jeremymanningclaude
andcommitted
Fix tutorial modal positioning in mobile landscape mode
Extract positionMobileLandscape() function for reuse and add deferred repositioning (350ms, 600ms) to catch panel CSS transitions. Set proper maxHeight based on header height to maximize content visibility. Add boxSizing, overflowY auto, and width locking on drag to prevent reflow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0880bd2 commit 2c93f3a

1 file changed

Lines changed: 72 additions & 66 deletions

File tree

src/ui/tutorial.js

Lines changed: 72 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1114,83 +1114,85 @@ function renderOverlay(highlightSelector, title, message, showNextBtn, isFinish,
11141114
border: '1px solid var(--color-border, rgba(226,232,240,0.8))',
11151115
fontFamily: 'system-ui, -apple-system, sans-serif',
11161116
lineHeight: '1.5',
1117+
boxSizing: 'border-box',
1118+
overflowY: 'auto',
1119+
maxHeight: 'calc(100vh - 60px)',
11171120
opacity: '0',
11181121
transform: prefersReducedMotion() ? 'none' : 'translateY(8px)',
11191122
transition: prefersReducedMotion() ? 'none' : 'opacity 300ms var(--ease-emphasized-decel, ease), transform 300ms var(--ease-emphasized-decel, ease), left 300ms var(--ease-emphasized-decel, ease), top 300ms var(--ease-emphasized-decel, ease)',
11201123
cursor: 'default',
11211124
});
11221125

1123-
if (mobile) {
1124-
const isLandscape = window.matchMedia('(orientation: landscape)').matches;
1125-
if (isLandscape) {
1126-
// Landscape: position modal under header, avoiding any open panel
1127-
const headerH = document.getElementById('app-header')?.offsetHeight || 48;
1128-
const topPos = (headerH + 8) + 'px';
1129-
1130-
// Detect which panels are open and compute available space
1131-
const quizPanel = document.getElementById('quiz-panel');
1132-
const videoPanel = document.getElementById('video-panel');
1133-
const quizOpen = quizPanel?.classList.contains('open');
1134-
const videoOpen = videoPanel?.classList.contains('open');
1135-
const toggleWidth = 36; // drawer toggle tab extends past panel edge
1136-
1137-
// Determine best side: prefer positionHint, then place away from open panels
1138-
const hintRight = positionHint === 'right' || positionHint === 'video-final';
1139-
const hintLeft = positionHint === 'left' || positionHint === 'quiz-final';
1140-
let placeLeft = hintLeft;
1141-
let placeRight = hintRight;
1142-
if (!placeLeft && !placeRight) {
1143-
// No hint: place opposite the open panel, or left by default
1144-
placeLeft = !videoOpen;
1145-
placeRight = videoOpen && !quizOpen;
1146-
}
1126+
// Mobile landscape positioning — extracted so it can be re-run after panel transitions
1127+
function positionMobileLandscape(m) {
1128+
const headerH = document.getElementById('app-header')?.offsetHeight || 48;
1129+
const topPos = (headerH + 8) + 'px';
1130+
m.style.maxHeight = `calc(100vh - ${headerH + 12}px)`;
1131+
const quizPanel = document.getElementById('quiz-panel');
1132+
const videoPanel = document.getElementById('video-panel');
1133+
const quizOpen = quizPanel?.classList.contains('open');
1134+
const videoOpen = videoPanel?.classList.contains('open');
1135+
const toggleWidth = 36;
1136+
1137+
const hintRight = positionHint === 'right' || positionHint === 'video-final';
1138+
const hintLeft = positionHint === 'left' || positionHint === 'quiz-final';
1139+
let placeLeft = hintLeft;
1140+
let placeRight = hintRight;
1141+
if (!placeLeft && !placeRight) {
1142+
placeLeft = !videoOpen;
1143+
placeRight = videoOpen && !quizOpen;
1144+
}
11471145

1148-
// Calculate available width on each side (accounting for panel + toggle)
1149-
let availLeft = window.innerWidth;
1150-
let availRight = window.innerWidth;
1151-
if (quizOpen && quizPanel) {
1152-
// Quiz panel is on the RIGHT — constrains left space and right space
1153-
const qpRect = quizPanel.getBoundingClientRect();
1154-
availLeft = Math.min(availLeft, qpRect.left - toggleWidth);
1155-
availRight = Math.min(availRight, window.innerWidth - qpRect.left);
1156-
}
1157-
if (videoOpen && videoPanel) {
1158-
// Video panel is on the LEFT — constrains right space and left space
1159-
const vpRect = videoPanel.getBoundingClientRect();
1160-
availRight = Math.min(availRight, window.innerWidth - vpRect.right - toggleWidth);
1161-
availLeft = Math.min(availLeft, vpRect.right);
1162-
}
1146+
let availLeft = window.innerWidth;
1147+
let availRight = window.innerWidth;
1148+
if (quizOpen && quizPanel) {
1149+
const qpRect = quizPanel.getBoundingClientRect();
1150+
availLeft = Math.min(availLeft, qpRect.left - toggleWidth);
1151+
availRight = Math.min(availRight, window.innerWidth - qpRect.left);
1152+
}
1153+
if (videoOpen && videoPanel) {
1154+
const vpRect = videoPanel.getBoundingClientRect();
1155+
availRight = Math.min(availRight, window.innerWidth - vpRect.right - toggleWidth);
1156+
availLeft = Math.min(availLeft, vpRect.right);
1157+
}
11631158

1164-
// If preferred side has no room, flip; if neither side has room, overlay on top
1165-
const minUsable = 160;
1166-
if (placeLeft && availLeft < minUsable && availRight > availLeft) {
1167-
placeLeft = false; placeRight = true;
1168-
} else if (placeRight && availRight < minUsable && availLeft > availRight) {
1169-
placeRight = false; placeLeft = true;
1170-
}
1159+
const minUsable = 160;
1160+
if (placeLeft && availLeft < minUsable && availRight > availLeft) {
1161+
placeLeft = false; placeRight = true;
1162+
} else if (placeRight && availRight < minUsable && availLeft > availRight) {
1163+
placeRight = false; placeLeft = true;
1164+
}
11711165

1172-
const chosenAvail = placeLeft ? availLeft : availRight;
1173-
if (chosenAvail < minUsable) {
1174-
// Both sides blocked (both panels open) — center overlay on top
1175-
Object.assign(modal.style, {
1176-
maxWidth: `${MODAL_MAX_WIDTH}px`, borderRadius: '12px',
1177-
top: topPos, left: '50%', right: 'auto',
1178-
transform: 'translateX(-50%)',
1179-
});
1166+
const chosenAvail = placeLeft ? availLeft : availRight;
1167+
if (chosenAvail < minUsable) {
1168+
Object.assign(m.style, {
1169+
width: `${MODAL_MAX_WIDTH}px`, maxWidth: '90vw', borderRadius: '12px',
1170+
top: topPos, left: '50%', right: 'auto',
1171+
transform: 'translateX(-50%)',
1172+
});
1173+
} else {
1174+
const usableW = Math.max(Math.min(MODAL_MAX_WIDTH, chosenAvail - 24), minUsable);
1175+
Object.assign(m.style, {
1176+
width: usableW + 'px', maxWidth: usableW + 'px', borderRadius: '12px',
1177+
top: topPos,
1178+
});
1179+
if (placeRight) {
1180+
m.style.right = '12px';
1181+
m.style.left = 'auto';
11801182
} else {
1181-
const maxW = Math.min(MODAL_MAX_WIDTH, chosenAvail - 24);
1182-
Object.assign(modal.style, {
1183-
maxWidth: Math.max(maxW, minUsable) + 'px', borderRadius: '12px',
1184-
top: topPos,
1185-
});
1186-
if (placeRight) {
1187-
modal.style.right = '12px';
1188-
modal.style.left = 'auto';
1189-
} else {
1190-
modal.style.left = '12px';
1191-
modal.style.right = 'auto';
1192-
}
1183+
m.style.left = '12px';
1184+
m.style.right = 'auto';
11931185
}
1186+
}
1187+
}
1188+
1189+
if (mobile) {
1190+
const isLandscape = window.matchMedia('(orientation: landscape)').matches;
1191+
if (isLandscape) {
1192+
positionMobileLandscape(modal);
1193+
// Defer reposition to catch panel CSS transitions
1194+
setTimeout(() => positionMobileLandscape(modal), 350);
1195+
setTimeout(() => positionMobileLandscape(modal), 600);
11941196
} else {
11951197
// Portrait: bottom sheet or top bar
11961198
const highlightInBottom = highlightEl && highlightEl.getBoundingClientRect().bottom > window.innerHeight * 0.6;
@@ -1353,6 +1355,8 @@ function makeDraggable(modal) {
13531355
if (e.target.closest('button, a')) return; // don't drag on buttons
13541356
e.preventDefault();
13551357
const rect = modal.getBoundingClientRect();
1358+
// Lock width so text doesn't reflow when right/bottom are cleared
1359+
modal.style.width = rect.width + 'px';
13561360
_dragState = { startX: e.clientX, startY: e.clientY, origLeft: rect.left, origTop: rect.top };
13571361
modal.style.transition = 'none';
13581362
handle.style.cursor = 'grabbing';
@@ -1382,6 +1386,8 @@ function makeDraggable(modal) {
13821386
if (e.target.closest('button, a')) return;
13831387
const touch = e.touches[0];
13841388
const rect = modal.getBoundingClientRect();
1389+
// Lock width so text doesn't reflow when right/bottom are cleared
1390+
modal.style.width = rect.width + 'px';
13851391
_dragState = { startX: touch.clientX, startY: touch.clientY, origLeft: rect.left, origTop: rect.top };
13861392
modal.style.transition = 'none';
13871393

0 commit comments

Comments
 (0)