diff --git a/eslint.config.mjs b/eslint.config.mjs index ac7f0c9..fda5a49 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -102,6 +102,13 @@ const gameGlobals = { // Input keys: "writable", + // Result-screen scrolling (issue #120) — declared in state.js, driven from + // renderer.js (which sets the bound it measured) and game-loop.js (reset). + resultScrollY: "writable", resultScrollMaxY: "writable", + RESULT_SCROLL_KEY_STEP: "readonly", + resultScreenScrollable: "readonly", scrollResultScreen: "readonly", + resetResultScroll: "readonly", _lastGameState: "writable", + // Mutable game state (state.js) gameState: "writable", diveMode: "writable", depth: "writable", maxDepth: "writable", diff --git a/src/constants.js b/src/constants.js index a7d957c..95453b1 100644 --- a/src/constants.js +++ b/src/constants.js @@ -584,6 +584,7 @@ const STRINGS = { diveComplete: 'DIVE COMPLETE', avgDepthLbl: 'Avg Depth', gasUsed: 'used', + barLeft: 'bar left', safetySkipped: '\u26A0 SAFETY STOP SKIPPED', safetyExpl: [ 'A safety stop helps off-gas dissolved nitrogen and reduces', @@ -819,6 +820,7 @@ const STRINGS = { diveComplete: 'TAUCHGANG BEENDET', avgDepthLbl: 'Durchschn. Tiefe', gasUsed: 'verbraucht', + barLeft: 'bar übrig', safetySkipped: '\u26A0 SICHERHEITSSTOPP AUSGELASSEN', safetyExpl: [ 'Ein Sicherheitsstopp hilft, gel\u00F6sten Stickstoff abzuatmen und', diff --git a/src/game-loop.js b/src/game-loop.js index 715b380..e58c602 100644 --- a/src/game-loop.js +++ b/src/game-loop.js @@ -1139,6 +1139,28 @@ function gameLoop(timestamp) { ctx.clearRect(0, 0, cssWidth, cssHeight); + // Issue #120: reset the result-screen scroll on entry, so a second dive + // never opens mid-page. Detected as a transition rather than reset at each + // `gameState = 'post-dive'` / `'gameover'` assignment — there are 13 of + // those and a 14th would silently miss it. + if (gameState !== _lastGameState) { + if (gameState === 'post-dive' || gameState === 'gameover') resetResultScroll(); + // Issue #120: the four in-dive HUD chips are shown/hidden inside + // updateDiving(), which stops being called the moment the dive ends — + // so whatever was on screen at that instant stayed on screen. Ending a + // wreck or cave dive left the rule-of-thirds chip latched on, sitting + // over the post-dive header. Clear them on any exit from 'diving' + // rather than at each of the 13 places that end a dive. + if (_lastGameState === 'diving' && gameState !== 'diving') { + var _chipIds = ['hud-horizontal-speed', 'hud-current', 'hud-thirds', 'hud-backway']; + for (var _ci = 0; _ci < _chipIds.length; _ci++) { + var _chip = document.getElementById(_chipIds[_ci]); + if (_chip) _chip.style.display = 'none'; + } + } + _lastGameState = gameState; + } + switch (gameState) { case 'gas-setup': updateGasSetup(); @@ -1926,6 +1948,9 @@ window.gameAPI = { get cssHeight() { return cssHeight; }, set cssHeight(v) { cssHeight = v; }, get gameOverReason() { return gameOverReason; }, + // Issue #120 test hook: result-screen layout is asserted per reason, and + // each reason produces a different amount of text. + set gameOverReason(v) { gameOverReason = v; }, get tissues() { return tissues; }, set tissues(v) { tissues = v; }, get tissuesHe() { return tissuesHe; }, @@ -1971,6 +1996,9 @@ window.gameAPI = { get ndlDroppedBelow5() { return ndlDroppedBelow5; }, calculateSafetyStopDuration: calculateSafetyStopDuration, get showHelp() { return showHelp; }, + // Issue #120 test hook: the help overlay layers over a result screen, and + // the two must not fight over the same wheel/touch events. + set showHelp(v) { showHelp = !!v; }, get showAdvanced() { return isAdvanced(); }, set showAdvanced(v) { switchMode(v ? 'tec' : 'rec'); }, get diveMode() { return diveMode; }, @@ -2015,6 +2043,14 @@ window.gameAPI = { calculatePO2: calculatePO2, calculateMOD: calculateMOD, calculateTTS: calculateTTS, + // Issue #120: result-screen scroll offset and its measured bound, so a test + // can assert that content drawn below the fold is actually reachable. + get resultScrollY() { return resultScrollY; }, + set resultScrollY(v) { resultScrollY = v; }, + get resultScrollMaxY() { return resultScrollMaxY; }, + // Translation lookup, so layout assertions can enumerate the strings that + // are actually drawn rather than restating them. + S: S, bestGasForDepth: bestGasForDepth, get DIVER_SCREEN_X_FRACTION() { return DIVER_SCREEN_X_FRACTION; }, get SAFETY_STOP_ACTIVE_MIN_D() { return SAFETY_STOP_ACTIVE_MIN_D; }, diff --git a/src/renderer.js b/src/renderer.js index 61aebb7..bc94be8 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -9265,13 +9265,102 @@ function drawInstructorOverlay() { cx.textAlign = 'left'; } +// ============================================================ +// ISSUE #120 — RESULT-SCREEN SCROLL FRAME +// +// drawPostDive() and drawGameOver() lay out in absolute pixels, so on a small +// viewport they draw well past the bottom edge — and the page cannot scroll, +// because style.css sets `html, body { overflow: hidden }`. Content below the +// fold was simply unreachable. +// +// These two helpers wrap a draw pass: the backdrop is painted unscrolled, the +// body is drawn inside a translate, and the final `y` reported back becomes +// the scroll bound for the next frame. Measuring the frame we just drew (as +// opposed to a separate measure pass) keeps one source of layout truth; the +// bound is right from the second frame on, and the first frame is drawn at +// offset 0 anyway. +// +// Content reserves BOTTOM_GUTTER at the end so the fixed touch CTA +// (#touch-postdive-btn / #touch-gameover-btn, anchored bottom: 6%/12% in +// style.css) never sits on top of the last line of text. +// ============================================================ +var RESULT_BOTTOM_GUTTER = 96; + +function beginResultScroll(cx) { + cx.save(); + cx.translate(0, -resultScrollY); +} + +// `contentBottomY` is the layout cursor after the last element was drawn. +function endResultScroll(cx, contentBottomY, W, H) { + cx.restore(); + var overflow = Math.max(0, (contentBottomY + RESULT_BOTTOM_GUTTER) - H); + resultScrollMaxY = overflow; + if (resultScrollY > overflow) resultScrollY = overflow; + // The CTA is a fixed DOM button (style.css anchors it bottom: 6%/12%), so + // scrolling body text passes underneath it. Reserving space at the end of + // the content is not enough on its own — text still crosses the button on + // the way past. A scrim gives it a surface to disappear behind instead of + // colliding with the label. + if (isTouchDevice) drawResultFooterScrim(cx, W, H); + if (overflow > 0) drawResultScrollIndicator(cx, W, H, overflow); +} + +function drawResultFooterScrim(cx, W, H) { + var band = Math.min(RESULT_BOTTOM_GUTTER, H * 0.28); + var g = cx.createLinearGradient(0, H - band, 0, H); + g.addColorStop(0, 'rgba(6,20,26,0)'); + g.addColorStop(0.45, 'rgba(6,20,26,0.88)'); + g.addColorStop(1, 'rgba(6,20,26,0.97)'); + cx.save(); + cx.fillStyle = g; + cx.fillRect(0, H - band, W, band); + cx.restore(); +} + +// A slim track on the right plus a "more below" cue, so the affordance is +// visible without a scrollbar the platform would otherwise draw for us. +function drawResultScrollIndicator(cx, W, H, overflow) { + var trackX = W - 7; + var trackTop = 12; + var trackH = H - 24; + var frac = H / (H + overflow); + var thumbH = Math.max(28, trackH * frac); + var thumbY = trackTop + (trackH - thumbH) * (resultScrollY / overflow); + + cx.save(); + cx.fillStyle = 'rgba(150,180,200,0.13)'; + cx.beginPath(); + cx.roundRect(trackX, trackTop, 4, trackH, 2); + cx.fill(); + cx.fillStyle = 'rgba(120,220,255,0.5)'; + cx.beginPath(); + cx.roundRect(trackX, thumbY, 4, thumbH, 2); + cx.fill(); + + // Chevron only while there is still something below. The fade behind it is + // drawn by drawResultFooterScrim() so the two do not stack. + if (resultScrollY < overflow - 1) { + cx.strokeStyle = 'rgba(160,220,240,0.75)'; + cx.lineWidth = 2; + cx.beginPath(); + cx.moveTo(W / 2 - 8, H - 20); + cx.lineTo(W / 2, H - 13); + cx.lineTo(W / 2 + 8, H - 20); + cx.stroke(); + } + cx.restore(); +} + function drawPostDive() { var cx = ctx; var W = cssWidth; var H = cssHeight; var DCF = "'Barlow Semi Condensed', monospace"; + // Backdrop is painted before the scroll translate so it stays put. gsBackdrop(cx, W, H); + beginResultScroll(cx); var centerX = W / 2; var y = H * 0.07; @@ -9280,10 +9369,12 @@ function drawPostDive() { cx.font = 'bold 12px monospace'; cx.fillStyle = '#34e6ff'; cx.fillText('DIVE LOG', centerX, y); - y += 30; + // 38px type with an alphabetic baseline reaches ~30px above it, so a 30px + // step put "DIVE COMPLETE" back through the kicker. Clear the ascender. + y += 38; cx.font = 'bold 38px ' + DCF; cx.fillStyle = hudColor('ok'); - cx.fillText(S('diveComplete'), centerX, y); + drawFittedText(cx, S('diveComplete'), centerX, y, W - 24); y += 30; // Stats card: Dive Time / Max / Avg @@ -9307,13 +9398,18 @@ function drawPostDive() { cx.lineTo(cardX + cardW * sc / 3, y + cardH - 16); cx.stroke(); } + // Fit to the cell. cardW is min(560, W-80), so at 320 px wide each of + // the three cells is only 80 px — "1840:00" at 30px Barlow is wider + // than that, and the three values ran into each other. The maxWidth + // argument condenses instead of overlapping. + var cellInnerW = cardW / 3 - 10; cx.textAlign = 'center'; cx.font = '11px monospace'; cx.fillStyle = '#8694a1'; - cx.fillText(String(statCells[sc][0]).toUpperCase(), sccx, y + 31); + cx.fillText(String(statCells[sc][0]).toUpperCase(), sccx, y + 31, cellInnerW); cx.font = 'bold 30px ' + DCF; cx.fillStyle = '#eaf2ff'; - cx.fillText(statCells[sc][1], sccx, y + 63); + cx.fillText(statCells[sc][1], sccx, y + 63, cellInnerW); } y += cardH + 20; @@ -9397,15 +9493,15 @@ function drawPostDive() { var o2Used = (ccrState.o2CylPressureStart - ccrState.o2CylPressure) * ccrState.o2CylVolume; var dilUsed = (ccrState.dilCylPressureStart - ccrState.dilCylPressure) * ccrState.dilCylVolume; var scrubUsed = ccrState.scrubberTotal - ccrState.scrubberRemaining; - cx.fillText(S('ccrO2Cyl') + ': ' + o2Used.toFixed(0) + 'L ' + S('gasUsed') + ' / ' + ccrState.o2CylPressure.toFixed(0) + ' bar left', centerX, y); + drawFittedText(cx, S('ccrO2Cyl') + ': ' + o2Used.toFixed(0) + 'L ' + S('gasUsed') + ' / ' + ccrState.o2CylPressure.toFixed(0) + ' ' + S('barLeft'), centerX, y, W - 32); y += 24; - cx.fillText(S('ccrDilCyl') + ': ' + dilUsed.toFixed(0) + 'L ' + S('gasUsed') + ' / ' + ccrState.dilCylPressure.toFixed(0) + ' bar left', centerX, y); + drawFittedText(cx, S('ccrDilCyl') + ': ' + dilUsed.toFixed(0) + 'L ' + S('gasUsed') + ' / ' + ccrState.dilCylPressure.toFixed(0) + ' ' + S('barLeft'), centerX, y, W - 32); y += 24; - cx.fillText(S('ccrScrubber') + ': ' + scrubUsed.toFixed(0) + ' min ' + S('gasUsed'), centerX, y); + drawFittedText(cx, S('ccrScrubber') + ': ' + scrubUsed.toFixed(0) + ' min ' + S('gasUsed'), centerX, y, W - 32); y += 24; if (ccrState.onBailout) { cx.fillStyle = hudColor('caution'); - cx.fillText(S('ccrBailout'), centerX, y); + drawFittedText(cx, S('ccrBailout'), centerX, y, W - 32); cx.fillStyle = '#a8b6cc'; y += 24; } @@ -9413,7 +9509,7 @@ function drawPostDive() { for (var ti = 0; ti < tankCount; ti++) { var tk = tanks[ti]; var used = tk.totalGas - tk.gasRemaining; - cx.fillText('Tank ' + (ti + 1) + ' (' + tk.label + '): ' + used.toFixed(0) + 'L ' + S('gasUsed') + ' / ' + tk.totalGas + 'L', centerX, y); + drawFittedText(cx, 'Tank ' + (ti + 1) + ' (' + tk.label + '): ' + used.toFixed(0) + 'L ' + S('gasUsed') + ' / ' + tk.totalGas + 'L', centerX, y, W - 32); y += 24; } } @@ -9423,14 +9519,15 @@ function drawPostDive() { if (safetyStopNeeded && !safetyStopComplete) { cx.font = 'bold 16px monospace'; cx.fillStyle = hudColor('caution'); - cx.fillText(S('safetySkipped'), centerX, y); + drawFittedText(cx, S('safetySkipped'), centerX, y, W - 24); y += 22; cx.font = '12px monospace'; cx.fillStyle = '#8694a1'; - var safetyLines = S('safetyExpl'); - for (var si = 0; si < safetyLines.length; si++) { - cx.fillText(safetyLines[si], centerX, y); y += 16; - } + // S('safetyExpl') is authored as fixed-length lines wrapped for a + // desktop width — they measure 383-416 px and bled off both edges of a + // 320 px screen. Re-wrap to the card instead of trusting the authored + // line breaks. + y = drawWrappedText(cx, S('safetyExpl').join(' '), centerX, y, cardW, 16); y += 24; } @@ -9445,7 +9542,7 @@ function drawPostDive() { // Tissue loading bar graph — N2 + He cx.font = 'bold 14px monospace'; cx.fillStyle = '#8694a1'; - cx.fillText(S('tissueLoading'), centerX, y); + drawFittedText(cx, S('tissueLoading'), centerX, y, W - 24); y += 20; var startX = centerX - totalBarW / 2; @@ -9521,9 +9618,11 @@ function drawPostDive() { cx.lineWidth = 1; cx.stroke(); cx.fillStyle = '#7df0b0'; - cx.fillText(pTxt, centerX, y + 4); + drawFittedText(cx, pTxt, centerX, y + 4, W - 32); + y += 14; } + endResultScroll(cx, y, W, H); cx.textAlign = 'left'; } @@ -9534,6 +9633,35 @@ function drawPostDive() { // GAME OVER SCREEN // ============================================================ +// Issue #120: the result screens lay out in absolute pixels, so any string +// wider than the canvas ran off both edges with no way to reach it — scrolling +// recovers height, not width. Single-line headings and stat lines cannot be +// wrapped without breaking the layout around them, so shrink to fit instead. +// +// Shrinks the current font down to 60% (never below 9px), then hands whatever +// is still too wide to the canvas `maxWidth` squeeze, so the string can never +// overhang however long a translation turns out to be. The caller's font is +// restored, so this is a drop-in for `cx.fillText(t, x, y)`. +// +// Worst case measured at 320px: "PULMONARY BAROTRAUMA — PNEUMOTHORAX" spanned +// x=-61.7…381.7 against a 320px canvas. +function drawFittedText(cx, text, x, y, maxWidth) { + var str = String(text); + var originalFont = cx.font; + var parts = /^(.*?)(\d+(?:\.\d+)?)px(.*)$/.exec(originalFont); + if (parts && cx.measureText(str).width > maxWidth) { + var px = parseFloat(parts[2]); + var floor = Math.max(9, px * 0.6); + while (px > floor) { + px -= 1; + cx.font = parts[1] + px + 'px' + parts[3]; + if (cx.measureText(str).width <= maxWidth) break; + } + } + cx.fillText(str, x, y, maxWidth); + cx.font = originalFont; +} + function drawWrappedText(cx, text, x, y, maxWidth, lineHeight, measureOnly) { var words = text.split(' '); var line = ''; @@ -9571,6 +9699,9 @@ function drawGameOver() { rg.addColorStop(1, 'rgba(200,50,50,0)'); cx.fillStyle = rg; cx.fillRect(0, 0, W, H); + // Backdrop first, then scroll the body (issue #120). Every gameOverReason + // overflows 320x568 — narcosis by 438 px — and none of it was reachable. + beginResultScroll(cx); var margin = 40; var maxTextW = Math.min(700, W - margin * 2); @@ -9583,16 +9714,18 @@ function drawGameOver() { cx.font = 'bold 12px monospace'; cx.fillStyle = '#8694a1'; cx.fillText('— DIVE TERMINATED —', centerX, y); - y += 30; + // 46px type reaches ~37px above its baseline, so a 30px step drew "GAME + // OVER" straight through the kicker above it (125x9 px of overlap). + y += 46; cx.font = 'bold 46px ' + DCF; cx.fillStyle = hudColor('danger'); - cx.fillText(S('gameOver'), centerX, y); + drawFittedText(cx, S('gameOver'), centerX, y, W - 24); y += 40; // Failure reason cx.font = 'bold 24px ' + DCF; cx.fillStyle = '#ffb060'; - cx.fillText(S('gameOverReasons')[gameOverReason] || gameOverReason, centerX, y); + drawFittedText(cx, S('gameOverReasons')[gameOverReason] || gameOverReason, centerX, y, W - 24); y += 35; cx.textAlign = 'left'; @@ -9666,7 +9799,7 @@ function drawGameOver() { cx.font = '14px monospace'; cx.fillStyle = '#8694a1'; cx.textAlign = 'center'; - cx.fillText(S('diveTimeLbl') + ': ' + formatTime(diveTime) + ' ' + S('maxDepthLbl') + ': ' + maxDepth.toFixed(1) + 'm', centerX, y); + drawFittedText(cx, S('diveTimeLbl') + ': ' + formatTime(diveTime) + ' ' + S('maxDepthLbl') + ': ' + maxDepth.toFixed(1) + 'm', centerX, y, W - 32); y += 35; if (!isTouchDevice) { @@ -9682,9 +9815,11 @@ function drawGameOver() { cx.lineWidth = 1; cx.stroke(); cx.fillStyle = '#ff9a9a'; - cx.fillText(gTxt, centerX, y + 4); + drawFittedText(cx, gTxt, centerX, y + 4, W - 32); + y += 14; } + endResultScroll(cx, y, W, H); cx.textAlign = 'left'; } diff --git a/src/state.js b/src/state.js index 8a53c9a..a77d0d1 100644 --- a/src/state.js +++ b/src/state.js @@ -61,6 +61,93 @@ function resize() { window.addEventListener('resize', resize); resize(); +// SECTION: Result-screen scrolling +// SEARCH TERMS: resultScrollY, resultScrollMaxY, issue #120 + +// ============================================================ +// RESULT-SCREEN SCROLLING (issue #120) +// +// The post-dive and game-over screens are painted onto the canvas, and +// `html, body { overflow: hidden }` (style.css) means the page itself never +// scrolls. drawPostDive()/drawGameOver() lay out in absolute pixels with no +// clamp, so on a phone the content simply runs off the bottom and is +// unreachable — the whole tissue-loading chart starts below the fold at +// 320x568, and every game-over reason overflows. +// +// The setup and help overlays solve this by being HTML with overflow-y:auto. +// These two are canvas, so they carry their own scroll offset instead: the +// renderer translates by -resultScrollY and reports how tall the content +// actually was, which is what bounds the offset. +// ============================================================ + +// Current scroll offset in CSS px. 0 = top. +var resultScrollY = 0; +// Furthest the content can scroll, in CSS px. Set by the renderer once it has +// measured the frame it just drew; 0 means everything fits and scrolling is +// inert (no wheel capture, no indicator). +var resultScrollMaxY = 0; + +// Wheel/drag steps are in CSS px; the key step is a comfortable "page" nudge. +var RESULT_SCROLL_KEY_STEP = 60; + +function resultScreenScrollable() { + // `showHelp` matters because the help overlay is a real scrollable DOM + // element layered over the canvas, and these handlers sit on `window`. + // Without this guard a wheel over the open overlay was preventDefault()ed + // for the canvas underneath it: the overlay (scrollHeight 2268 in a 568px + // viewport) stayed pinned at scrollTop 0 while the hidden result screen + // scrolled instead. Same for the gas-info overlay. + if (showHelp || showGasInfo) return false; + return (gameState === 'post-dive' || gameState === 'gameover') && resultScrollMaxY > 0; +} + +// Events that originate inside an HTML overlay belong to that overlay's own +// scrolling, never to the canvas behind it. The state guard above covers the +// overlays that exist today; this covers any that get added later. +function _eventInsideHtmlOverlay(e) { + var t = e.target; + return !!(t && t.closest && t.closest('#html-help-overlay, #html-gas-setup')); +} + +function scrollResultScreen(deltaPx) { + if (!resultScreenScrollable()) return; + resultScrollY = Math.max(0, Math.min(resultScrollMaxY, resultScrollY + deltaPx)); +} + +// Previous frame's gameState, so the loop can spot entry into a result screen. +var _lastGameState = null; + +// Reset on entry so a second dive never opens mid-page. +function resetResultScroll() { + resultScrollY = 0; + resultScrollMaxY = 0; +} + +window.addEventListener('wheel', e => { + if (!resultScreenScrollable() || _eventInsideHtmlOverlay(e)) return; + e.preventDefault(); + scrollResultScreen(e.deltaY); +}, { passive: false }); + +// Touch drag. Tracked separately from touch.js's button bindings: those live on +// #touch-ui elements, this is a drag anywhere over the canvas. +var _resultTouchY = null; +window.addEventListener('touchstart', e => { + if (!resultScreenScrollable() || _eventInsideHtmlOverlay(e)) return; + if (e.target && e.target.closest && e.target.closest('.t-btn')) return; + _resultTouchY = e.touches[0] ? e.touches[0].clientY : null; +}, { passive: true }); +window.addEventListener('touchmove', e => { + if (_resultTouchY === null || !resultScreenScrollable()) return; + if (_eventInsideHtmlOverlay(e)) { _resultTouchY = null; return; } + var y = e.touches[0] ? e.touches[0].clientY : null; + if (y === null) return; + e.preventDefault(); + scrollResultScreen(_resultTouchY - y); + _resultTouchY = y; +}, { passive: false }); +window.addEventListener('touchend', () => { _resultTouchY = null; }, { passive: true }); + // SECTION: Input / keyboard state // SEARCH TERMS: keys, keydown, keyup, addEventListener @@ -107,6 +194,22 @@ window.addEventListener('keydown', e => { if ((e.key === 'l' || e.key === 'L') && gameState === 'diving') { instructorMode = !instructorMode; } + // Issue #120: keyboard scrolling for the canvas result screens. Guarded on + // resultScreenScrollable() so these keys keep their normal meaning + // everywhere else, and stay inert when the content already fits. + if (resultScreenScrollable()) { + var step = 0; + if (e.key === 'ArrowDown') step = RESULT_SCROLL_KEY_STEP; + else if (e.key === 'ArrowUp') step = -RESULT_SCROLL_KEY_STEP; + else if (e.key === 'PageDown') step = cssHeight * 0.8; + else if (e.key === 'PageUp') step = -cssHeight * 0.8; + else if (e.key === 'Home') step = -resultScrollMaxY; + else if (e.key === 'End') step = resultScrollMaxY; + if (step !== 0) { + e.preventDefault(); + scrollResultScreen(step); + } + } }); window.addEventListener('keyup', e => { // Issue #9: if Shift is released before the letter (e.g. Shift+G), the diff --git a/src/style.css b/src/style.css index f3c7b1c..fdc0ade 100644 --- a/src/style.css +++ b/src/style.css @@ -261,7 +261,9 @@ canvas { display: block; } #html-help-overlay .help-section-title { font-size: 12px; font-weight: bold; margin-bottom: 4px; } #html-help-overlay .help-section-text { font-size: 11px; color: #bbb; line-height: 1.5; } #html-help-overlay .help-close-btn { - display: block; margin: 20px auto; padding: 12px 40px; + /* Issue #121: 12px padding + 14px monospace came to 43px — one pixel under + the minimum, which is exactly the kind of thing only measurement finds. */ + display: block; margin: 20px auto; padding: 12px 40px; min-height: 44px; background: rgba(255,255,255,0.12); color: #888; border: 1px solid rgba(255,255,255,0.25); border-radius: 10px; font-family: monospace; font-weight: bold; font-size: 14px; cursor: pointer; touch-action: manipulation; @@ -521,14 +523,18 @@ canvas { display: block; } background: rgba(255,255,255,0.038); border: 1px solid rgba(255,255,255,0.09); border-radius: 14px; - padding: 3px; - gap: 3px; + padding: 4px; + /* Issue #121: 3px between two 41px targets left almost no dead space, so a + slightly-off tap landed on the neighbouring mode. 6px still measured under + the 8px target once flex rounding took a pixel off one gap. */ + gap: 10px; margin: 0 0 14px !important; } #html-gas-setup .gs-modes:has([data-mode]) .gs-btn { border-radius: 11px; padding: 10px 0; - min-height: 40px; + /* WCAG 2.5.5 / iOS HIG minimum. Was 40px. */ + min-height: 44px; border-color: transparent; background: transparent; letter-spacing: 0.08em; @@ -537,16 +543,23 @@ canvas { display: block; } /* ── Gas Setup: layout density ─────────────────────────────────── */ #html-gas-setup .gs-card { padding: 11px 14px; margin-bottom: 10px; } -#html-gas-setup .gs-presets { gap: 6px; margin-bottom: 14px; } +/* Issue #121: gas/site preset chips measured 38-39px tall with 5-7px gaps. */ +#html-gas-setup .gs-presets { gap: 8px; margin-bottom: 14px; } #html-gas-setup .gs-presets .gs-btn { - padding: 9px 0; font-size: 12px; border-radius: 10px; letter-spacing: 0.02em; + padding: 12px 0; min-height: 44px; font-size: 12px; border-radius: 10px; + letter-spacing: 0.02em; } #html-gas-setup .gs-mod { padding: 8px 14px; margin: 0 0 12px; font-size: 12px; } #html-gas-setup .gs-title { margin-bottom: 14px; } @media (max-width: 480px) { .t-tank-btns .t-btn { width: 44px; height: 44px; font-size: 12px; } - #html-gas-setup .gs-btn { min-width: 40px; min-height: 38px; padding: 4px 10px; font-size: 14px; } + /* Issue #121: this used to set min-height: 38px, overriding the 44px base + rule specifically on the phones where hitting a target is hardest. Narrow + the button (horizontal room is the real constraint at 320px) but keep the + full touch height. The setup panel already scrolls (overflow-y: auto), so + the extra height costs reachability nothing. */ + #html-gas-setup .gs-btn { min-width: 44px; min-height: 44px; padding: 4px 8px; font-size: 14px; } #html-gas-setup .gs-btn.gs-accent { min-width: 160px; min-height: 44px; font-size: 16px; } #html-gas-setup .gs-value { font-size: 26px; } #html-gas-setup .gs-title { font-size: 20px; } diff --git a/tests/result-screen.spec.js b/tests/result-screen.spec.js new file mode 100644 index 0000000..4d55e10 --- /dev/null +++ b/tests/result-screen.spec.js @@ -0,0 +1,287 @@ +// ============================================================ +// FILE: tests/result-screen.spec.js +// PURPOSE: Regression cover for issues #120 and #121 — the result screens +// (post-dive / game-over) and mobile touch-target geometry. +// +// These defects all shipped past lint, typecheck, unit and parity because the +// content is painted onto the canvas: nothing in the DOM shows a heading that +// runs off the edge, or a chart drawn 300px below the fold. The only way to +// see them is to measure what the 2D context is actually asked to draw, so +// this spec wraps fillText/strokeText and asserts on the real geometry. +// ============================================================ + +const { test, expect } = require('@playwright/test'); + +// The tightest viewport the project targets. Everything that overflows, +// overflows here first. +const SMALL_PHONE = { + viewport: { width: 320, height: 568 }, + hasTouch: true, + isMobile: true, + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 ' + + '(KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1', +}; + +// Records the bounding box of every text run drawn between __inkStart and +// __inkStop. Font size is read from the `px` token rather than parseFloat, +// which would return the numeric weight in "500 20px Barlow". +const INK_RECORDER = () => { + const proto = CanvasRenderingContext2D.prototype; + window.__ink = null; + const sizeOf = font => { + const m = /(\d+(?:\.\d+)?)px/.exec(String(font || '')); + return m ? parseFloat(m[1]) : 12; + }; + const record = function (ctx, text, x, y, maxWidth) { + const sink = window.__ink; + if (!sink) return; + let measured = { width: 0 }; + try { measured = ctx.measureText(String(text)); } catch { /* ignore */ } + const size = sizeOf(ctx.font); + const width = maxWidth == null ? measured.width : Math.min(measured.width, maxWidth); + const align = ctx.textAlign; + const left = align === 'center' ? x - width / 2 + : align === 'right' || align === 'end' ? x - width + : x; + if (!isFinite(left) || !isFinite(y)) return; + sink.push({ text: String(text), left, right: left + width, top: y - size * 0.8, bottom: y + size * 0.2 }); + }; + const originalFill = proto.fillText; + const originalStroke = proto.strokeText; + proto.fillText = function (t, x, y, w) { record(this, t, x, y, w); return originalFill.call(this, t, x, y, w); }; + proto.strokeText = function (t, x, y, w) { record(this, t, x, y, w); return originalStroke.call(this, t, x, y, w); }; + window.__inkStart = () => { window.__ink = []; }; + window.__inkStop = () => { const sink = window.__ink; window.__ink = null; return sink; }; +}; + +async function bootGame(page) { + const consoleErrors = []; + page.on('console', msg => { if (msg.type() === 'error') consoleErrors.push(msg.text()); }); + page.on('pageerror', err => consoleErrors.push(err.message)); + page.on('dialog', d => d.dismiss().catch(() => {})); + await page.addInitScript(INK_RECORDER); + await page.goto('/src/diving-simulator.html'); + await page.waitForFunction(() => !!window.gameAPI, { timeout: 15000 }); + return consoleErrors; +} + +async function reachDiving(page) { + await page.evaluate(() => window.gameAPI.startDiveAction()); + await page.waitForFunction(() => window.gameAPI.gameState === 'surface', { timeout: 5000 }); + await page.keyboard.down('s'); + await page.waitForFunction(() => window.gameAPI.gameState === 'diving', { timeout: 5000 }); + await page.keyboard.up('s'); +} + +/** Draw one frame of `state` and return every text run's geometry. */ +async function captureResultScreen(page, state, reason) { + return page.evaluate(async ({ state, reason }) => { + window.gameAPI.maxDepth = 38.4; + if (reason) window.gameAPI.gameOverReason = reason; + window.gameAPI.gameState = state; + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); + window.__inkStart(); + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); + return { + runs: window.__inkStop(), + width: window.innerWidth, + height: window.innerHeight, + scrollMax: window.gameAPI.resultScrollMaxY, + }; + }, { state, reason }); +} + +test.describe('issue #120: result screens fit and can be reached', () => { + test.use(SMALL_PHONE); + + test('no result text runs off either edge, for any game-over reason', async ({ page }) => { + const errors = await bootGame(page); + await reachDiving(page); + + const reasons = await page.evaluate(() => Object.keys(window.gameAPI.S('gameOverReasons'))); + expect(reasons.length).toBeGreaterThan(0); + + const clipped = []; + for (const reason of reasons) { + const { runs, width } = await captureResultScreen(page, 'gameover', reason); + for (const run of runs) { + if (run.left < -0.5 || run.right > width + 0.5) { + clipped.push(`${reason}: "${run.text.slice(0, 40)}" spans ${run.left.toFixed(1)}…${run.right.toFixed(1)} in ${width}px`); + } + } + } + expect(clipped, clipped.join('\n')).toEqual([]); + expect(errors).toEqual([]); + }); + + test('no post-dive text runs off either edge, in any language or dive mode', async ({ page }) => { + // The matrix has to cover modes as well as languages: the CCR cylinder + // lines are the longest strings the screen can draw and only exist in CCR, + // so a Rec-only loop leaves them untested however many languages it walks. + const errors = await bootGame(page); + + const clipped = []; + const modesSeen = []; + for (const mode of ['rec', 'tec', 'ccr']) { + for (const lang of ['en', 'de']) { + await page.evaluate( + ({ l, m }) => { window.gameAPI.currentLang = l; window.gameAPI.diveMode = m; }, + { l: lang, m: mode } + ); + await reachDiving(page); + const { runs, width } = await captureResultScreen(page, 'post-dive'); + + // Guard the matrix itself. If switching mode silently failed, every + // iteration would draw the same Rec screen and the loop would prove + // nothing — which is exactly how the CCR lines went untested before. + const drawn = runs.map(r => r.text).join(' '); + modesSeen.push({ mode, lang, ccrLinesDrawn: /Cylinder|Flasche|Scrubber/.test(drawn) }); + + for (const run of runs) { + if (run.left < -0.5 || run.right > width + 0.5) { + clipped.push(`${mode}/${lang}: "${run.text.slice(0, 40)}" spans ${run.left.toFixed(1)}…${run.right.toFixed(1)} in ${width}px`); + } + } + await page.reload(); + await page.waitForFunction(() => !!window.gameAPI, { timeout: 15000 }); + } + } + + const ccrRuns = modesSeen.filter(m => m.mode === 'ccr'); + expect(ccrRuns).toHaveLength(2); + for (const run of ccrRuns) { + expect(run.ccrLinesDrawn, `${run.mode}/${run.lang} must actually draw the CCR cylinder lines`).toBe(true); + } + + expect(clipped, clipped.join('\n')).toEqual([]); + expect(errors).toEqual([]); + }); + + test('content taller than the viewport is reachable by scrolling', async ({ page }) => { + // Reachability has to be measured against the content, not against the + // renderer's own declared scroll range. Asserting only that scrolling + // reaches `resultScrollMaxY` is circular: pinning that value to 1 leaves + // every line below the fold unreachable and still satisfies it. + await bootGame(page); + await reachDiving(page); + + const { runs, height, scrollMax } = await captureResultScreen(page, 'post-dive'); + + // Text is drawn inside a translate(0, -resultScrollY), so recorded y values + // are content-space. Screen position is `content y - resultScrollY`. + const lowestContentY = Math.max(...runs.map(r => r.bottom)); + // The post-dive screen genuinely overflows a 568px phone; if it ever stops + // doing so, this is the signal to revisit the rest of the test. + expect(lowestContentY).toBeGreaterThan(height); + + const overflow = lowestContentY - height; + expect( + scrollMax, + `declared scroll range ${scrollMax.toFixed(1)} must cover the ${overflow.toFixed(1)}px the content actually overflows` + ).toBeGreaterThanOrEqual(overflow); + + await page.mouse.move(160, 300); + await page.mouse.wheel(0, 10000); + await page.waitForTimeout(250); + + // Re-measure at the bottom of the scroll and check where the last line + // actually lands, using the offset the renderer really applied. + const atBottom = await page.evaluate(async () => { + window.__inkStart(); + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); + return { + runs: window.__inkStop(), + offset: window.gameAPI.resultScrollY, + height: window.innerHeight, + }; + }); + const lowestOnScreen = Math.max(...atBottom.runs.map(r => r.bottom)) - atBottom.offset; + expect( + lowestOnScreen, + `last line sits ${lowestOnScreen.toFixed(1)}px down a ${atBottom.height}px viewport after scrolling to the bottom` + ).toBeLessThanOrEqual(atBottom.height); + expect(lowestOnScreen, 'and should not have been scrolled off the top').toBeGreaterThan(0); + }); + + test('the help overlay keeps its own scrolling while a result screen is open', async ({ page }) => { + // Regression: the wheel/touch handlers live on `window`, so events bubbling + // out of the HTML help overlay were preventDefault()ed on the canvas's + // behalf — the overlay stayed pinned at scrollTop 0 while the result screen + // hidden behind it scrolled instead. + await bootGame(page); + await reachDiving(page); + await captureResultScreen(page, 'post-dive'); + + await page.mouse.move(160, 300); + await page.mouse.wheel(0, 300); + await page.waitForTimeout(150); + const resultBefore = await page.evaluate(() => window.gameAPI.resultScrollY); + expect(resultBefore, 'result screen should scroll when no overlay is open').toBeGreaterThan(0); + + await page.evaluate(() => { window.gameAPI.showHelp = true; }); + await page.waitForFunction( + () => getComputedStyle(document.getElementById('html-help-overlay')).display !== 'none', + { timeout: 5000 } + ); + + const overlay = page.locator('#html-help-overlay'); + expect(await overlay.evaluate(el => el.scrollHeight)).toBeGreaterThan(600); + + await page.mouse.move(160, 300); + await page.mouse.wheel(0, 400); + await page.waitForTimeout(250); + + expect(await overlay.evaluate(el => el.scrollTop), 'overlay must scroll').toBeGreaterThan(0); + expect( + await page.evaluate(() => window.gameAPI.resultScrollY), + 'result screen behind the overlay must not move' + ).toBe(resultBefore); + }); +}); + +test.describe('issue #121: mobile touch targets', () => { + test.use(SMALL_PHONE); + + test('every setup control meets 44px and is at least 8px from its neighbours', async ({ page }) => { + await bootGame(page); + + const geometry = await page.evaluate(() => { + const visible = Array.from(document.querySelectorAll('button, .gs-btn')) + .filter(el => { + const s = getComputedStyle(el); + return s.display !== 'none' && s.visibility !== 'hidden' && el.offsetParent !== null; + }); + const boxes = visible.map(el => { + const r = el.getBoundingClientRect(); + return { label: (el.textContent || '').trim().slice(0, 16) || el.id, x: r.x, y: r.y, w: r.width, h: r.height }; + }).filter(b => b.w > 0 && b.h > 0); + + const undersized = boxes + .filter(b => b.w < 44 || b.h < 44) + .map(b => `${b.label} is ${Math.round(b.w)}x${Math.round(b.h)}`); + + const tight = []; + for (let i = 0; i < boxes.length; i += 1) { + for (let j = i + 1; j < boxes.length; j += 1) { + const a = boxes[i], b = boxes[j]; + const gapX = Math.max(a.x, b.x) - Math.min(a.x + a.w, b.x + b.w); + const gapY = Math.max(a.y, b.y) - Math.min(a.y + a.h, b.y + b.h); + // Only neighbours along one axis are "adjacent"; diagonal pairs are + // separated by the other axis and cannot be mistapped for each other. + const overlapX = gapX < 0, overlapY = gapY < 0; + if (overlapX === overlapY) continue; + const gap = overlapX ? gapY : gapX; + if (gap >= 0 && gap < 8) { + tight.push(`${a.label} <-> ${b.label} = ${gap.toFixed(1)}px`); + } + } + } + return { undersized, tight, total: boxes.length }; + }); + + expect(geometry.total).toBeGreaterThan(10); + expect(geometry.undersized, geometry.undersized.join('\n')).toEqual([]); + expect(geometry.tight, geometry.tight.join('\n')).toEqual([]); + }); +});