From 29b1edfbd94b1ad17e7998763ff13bf41ea1a576 Mon Sep 17 00:00:00 2001 From: Niklas Gorman Date: Sun, 23 Aug 2026 13:17:30 +0200 Subject: [PATCH 1/4] fix: make result screens scrollable and raise mobile touch targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #120. Closes #121. The post-dive and game-over screens are painted onto the canvas, and `html, body { overflow: hidden }` means the page never scrolls. Both draw functions accumulate `y` in fixed pixels with no clamp, so on a phone the content simply ran off the bottom and was unreachable: the entire tissue compartment chart started below the fold at 320x568, and all seven game-over reasons overflowed — narcosis by 438 px. 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 the height it just laid out, which becomes the bound; input comes from wheel, touch drag and arrow/PageUp/PageDown/Home/End, all inert when the content already fits. Four layout bugs found alongside it, each of which needed its own fix: - The stats card divides min(560, W-80) into three cells, so at 320 px each cell is 80 px while the values render at 30 px — "1840:00" overlapped its neighbour. Now passed through fillText's maxWidth so it condenses instead. - "DIVE COMPLETE" (38px) and "GAME OVER" (46px) were stepped 30 px below their kickers, less than their own ascent, so both drew through the line above. - S('safetyExpl') is authored as fixed-length lines wrapped for desktop; they measure 383-416 px and bled off both edges of a 320 px screen. Re-wrapped to the available width instead of trusting the authored breaks. - The four in-dive HUD chips are shown/hidden inside updateDiving(), which stops being called the instant the dive ends, so whatever was visible then stayed visible. Ending a wreck or cave dive left the rule-of-thirds chip latched over the post-dive header. Cleared on exit from 'diving'. Scroll reset and chip clearing both hook the state transition rather than the 13 individual places that end a dive, so a 14th cannot miss them. For #121, every gas-setup control was under the 44 px WCAG 2.5.5 / iOS HIG minimum — 71 undersized targets across the measured screens, now zero. The direct cause was a `max-width: 480px` rule setting `min-height: 38px`, which overrode the 44 px base specifically on the phones where hitting a target is hardest. Spacing was tightened up too: 3 px between two mode pills left almost no dead zone between them. Verified with the instrumentation that found both issues: canvas text captured in screen space across the whole scroll range, and getBoundingClientRect over every visible control. Unreachable text 48 -> 0, horizontal clipping 5 -> 0, text-over-text 2 -> 0, undersized targets 71 -> 0, no horizontal overflow introduced at 320 px. Co-Authored-By: Claude Opus 5 --- eslint.config.mjs | 7 +++ src/game-loop.js | 22 +++++++++ src/renderer.js | 122 +++++++++++++++++++++++++++++++++++++++++++--- src/state.js | 87 +++++++++++++++++++++++++++++++++ src/style.css | 26 +++++++--- 5 files changed, 249 insertions(+), 15 deletions(-) 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/game-loop.js b/src/game-loop.js index 715b380..999ea63 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(); diff --git a/src/renderer.js b/src/renderer.js index 61aebb7..9e6eda0 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,7 +9369,9 @@ 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); @@ -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; @@ -9427,10 +9523,11 @@ function drawPostDive() { 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; } @@ -9522,8 +9619,10 @@ function drawPostDive() { cx.stroke(); cx.fillStyle = '#7df0b0'; cx.fillText(pTxt, centerX, y + 4); + y += 14; } + endResultScroll(cx, y, W, H); cx.textAlign = 'left'; } @@ -9571,6 +9670,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,7 +9685,9 @@ 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); @@ -9683,8 +9787,10 @@ function drawGameOver() { cx.stroke(); cx.fillStyle = '#ff9a9a'; cx.fillText(gTxt, centerX, y + 4); + y += 14; } + endResultScroll(cx, y, W, H); cx.textAlign = 'left'; } diff --git a/src/state.js b/src/state.js index 8a53c9a..dac1277 100644 --- a/src/state.js +++ b/src/state.js @@ -61,6 +61,77 @@ 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() { + return (gameState === 'post-dive' || gameState === 'gameover') && resultScrollMaxY > 0; +} + +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()) 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()) 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; + 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 +178,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..c5bc8da 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,17 @@ 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. */ + gap: 6px; 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 +542,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; } From 9cd4977b5764214b45b8f19cd46a876ce56e2a61 Mon Sep 17 00:00:00 2001 From: Niklas Gorman Date: Sun, 23 Aug 2026 16:35:08 +0200 Subject: [PATCH 2/4] fix: fit result text to the canvas, stop capturing overlay scroll, widen mode gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on #125, all reproduced before changing anything. [P1] Scrolling recovered height but not width, and several single-line strings were still drawn past both edges at 320px: three game-over reasons (worst "PULMONARY BAROTRAUMA — PNEUMOTHORAX" at x=-61.7…381.7), the German post-dive title, and the CCR cylinder lines in both languages. These are headings and stat lines that cannot wrap without breaking the layout around them, so drawFittedText() shrinks the font to fit and hands anything still too wide to the canvas maxWidth squeeze. Applied to every centred dynamic string on both result screens rather than only the three that clip today, since the next translation would surface the rest. Replacing the CCR lines also fixed a hardcoded ' bar left' that stayed English in German output; it is now S('barLeft'). [P2] 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 (scrollHeight 2268 in a 568px viewport) stayed pinned at scrollTop 0 while the result screen hidden behind it scrolled instead. resultScreenScrollable() now returns false while showHelp or showGasInfo is set, which also covers the keyboard path, and the handlers ignore events originating inside an HTML overlay so a future one is covered without a new state flag. [P3] Rec/Tec/CCR reached 44px tall but stayed 6px apart, under the 8px target in #121 — and flex rounding took one gap to 5px. Now 10px, measuring 10/10. The earlier PR description was wrong to attribute the remaining tight pairs solely to the D-pad: the setup screen now reports 19 controls, 0 under 44px and 0 pairs under 8px apart. Adds tests/result-screen.spec.js, which is what was missing when these three shipped. It wraps fillText/strokeText and asserts on real geometry, because none of this is visible to the DOM. Each test fails on its own defect and no other: reverting the fitted draws reports the exact spans above, dropping the overlay guards fails "overlay must scroll", and restoring the 6px gap reports both mode-button pairs. New gameAPI hooks for those assertions: gameOverReason and showHelp setters, resultScrollY/resultScrollMaxY, and S. Co-Authored-By: Claude Opus 5 --- src/constants.js | 2 + src/game-loop.js | 14 +++ src/renderer.js | 55 +++++++-- src/state.js | 20 ++- src/style.css | 5 +- tests/result-screen.spec.js | 236 ++++++++++++++++++++++++++++++++++++ 6 files changed, 315 insertions(+), 17 deletions(-) create mode 100644 tests/result-screen.spec.js 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 999ea63..e58c602 100644 --- a/src/game-loop.js +++ b/src/game-loop.js @@ -1948,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; }, @@ -1993,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; }, @@ -2037,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 9e6eda0..bc94be8 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -9374,7 +9374,7 @@ function drawPostDive() { 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 @@ -9493,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; } @@ -9509,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; } } @@ -9519,7 +9519,7 @@ 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'; @@ -9542,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; @@ -9618,7 +9618,7 @@ 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; } @@ -9633,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 = ''; @@ -9690,13 +9719,13 @@ function drawGameOver() { 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'; @@ -9770,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) { @@ -9786,7 +9815,7 @@ 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; } diff --git a/src/state.js b/src/state.js index dac1277..a77d0d1 100644 --- a/src/state.js +++ b/src/state.js @@ -91,9 +91,24 @@ var resultScrollMaxY = 0; 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)); @@ -109,7 +124,7 @@ function resetResultScroll() { } window.addEventListener('wheel', e => { - if (!resultScreenScrollable()) return; + if (!resultScreenScrollable() || _eventInsideHtmlOverlay(e)) return; e.preventDefault(); scrollResultScreen(e.deltaY); }, { passive: false }); @@ -118,12 +133,13 @@ window.addEventListener('wheel', e => { // #touch-ui elements, this is a drag anywhere over the canvas. var _resultTouchY = null; window.addEventListener('touchstart', e => { - if (!resultScreenScrollable()) return; + 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(); diff --git a/src/style.css b/src/style.css index c5bc8da..fdc0ade 100644 --- a/src/style.css +++ b/src/style.css @@ -525,8 +525,9 @@ canvas { display: block; } border-radius: 14px; padding: 4px; /* Issue #121: 3px between two 41px targets left almost no dead space, so a - slightly-off tap landed on the neighbouring mode. */ - gap: 6px; + 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 { diff --git a/tests/result-screen.spec.js b/tests/result-screen.spec.js new file mode 100644 index 0000000..e21c7c4 --- /dev/null +++ b/tests/result-screen.spec.js @@ -0,0 +1,236 @@ +// ============================================================ +// 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 either language', async ({ page }) => { + const errors = await bootGame(page); + + const clipped = []; + for (const lang of ['en', 'de']) { + await page.evaluate(l => { window.gameAPI.currentLang = l; }, lang); + await reachDiving(page); + const { runs, width } = await captureResultScreen(page, 'post-dive'); + for (const run of runs) { + if (run.left < -0.5 || run.right > width + 0.5) { + clipped.push(`${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 }); + } + expect(clipped, clipped.join('\n')).toEqual([]); + expect(errors).toEqual([]); + }); + + test('content taller than the viewport is reachable by scrolling', async ({ page }) => { + await bootGame(page); + await reachDiving(page); + + const { runs, height, scrollMax } = await captureResultScreen(page, 'post-dive'); + const lowest = Math.max(...runs.map(r => r.bottom)); + // The post-dive screen genuinely overflows a 568px phone; if it ever stops + // doing so this assertion is the signal to revisit the rest of the test. + expect(lowest).toBeGreaterThan(height); + expect(scrollMax, 'overflowing content must expose a scroll range').toBeGreaterThan(0); + + await page.mouse.move(160, 300); + await page.mouse.wheel(0, 10000); + await page.waitForTimeout(200); + const scrolled = await page.evaluate(() => window.gameAPI.resultScrollY); + expect(scrolled).toBeCloseTo(scrollMax, 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([]); + }); +}); From 24d5e971ac77196e60b0bfaff76c9e979f68f59f Mon Sep 17 00:00:00 2001 From: Niklas Gorman Date: Sun, 23 Aug 2026 16:52:19 +0200 Subject: [PATCH 3/4] test: make the result-screen assertions independent of what they test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both weaknesses were confirmed by reintroducing the defect and watching the suite stay green. The scroll test asserted only that scrolling reached `resultScrollMaxY`, which is the renderer's own declared range — so it was checking that value against itself. Pinning `resultScrollMaxY` to 1 left every line below the fold unreachable and all five tests still passed. It now measures the content: the declared range must cover the overflow the drawn text actually has, and after scrolling to the bottom the last line must land inside the viewport, computed from the offset the renderer really applied. The clipping test walked both languages but never left Rec mode, so the CCR cylinder lines — the longest strings the screen can draw, and two of the five that were clipping — were never rendered. Un-fitting them left the test green. The matrix is now Rec/Tec/CCR x en/de, and it asserts that the CCR iterations actually drew the cylinder lines, so a silent failure to switch mode fails rather than quietly proving nothing. All five defect classes now trip their own test and no other: un-fitting the headings reports the exact spans, dropping the overlay guards fails "overlay must scroll", restoring the 6px gap reports both mode pairs, pinning the scroll range fails reachability, and un-fitting the CCR lines fails the matrix. Co-Authored-By: Claude Opus 5 --- tests/result-screen.spec.js | Bin 10247 -> 12660 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/result-screen.spec.js b/tests/result-screen.spec.js index e21c7c42fb5074c85fa7b92e050cba63d0921660..3be52caac523a7ba8945efacb7cd1821d99c376c 100644 GIT binary patch delta 2606 zcmZuzO>Y}T80G?$h*H|7X$y+_N`!h{oDD@nNQqq32KphT6w)FTAyCHav3KZt$DNst z9i_%dAi)u_NSu&3^^C+1KtkfyKfo1+lNfOietVstbru=Dfwftjo z>5MOoyCsSaC~MJxCe8A%#fw`bAfZQbUkv2c4{ybc^KA~?rojeHLPy~s&aHbiaeCd? zLX%nC<_4#G;mqr0>&(sRY#HF*D1_;H5G%Trvoh<&bgM^pq+F5$Gf)7oaG+=tgQVUtuoyqlP!(~)Kk39O5J z`sOi^yRm@E%15W7^6{xF<+0PJUgWrwwUq-Nh>}j{dIO~-c_f!TBq5*d`h6_60mdyJujozv@ z0X&LA8bCE=A=d}Ibh(Nu5WdIwb_hg^c2^E~wQh-@xvdEe3e*h(3z31tnLOsFKCrpV zbzPH@Glgt!Jny@nER;W=mgPHVKVgU2mLf?-M`g-E4Z&kNFdZpuVKBU+FBGH1Dnla< z>Sz%pHUGhfMJyxPZ=`8h(j$0?OfuL_m({9aQwFumn66qYjpK2cdgE}ZfDQRASUik{ zx?m-{M%p5CPyj;Q7UP{=mT#g=QXu#`4OO0VEFWc0WCiR^)8)#E>S%t%JS5bSS*7{j z8ydq^2pV41KnZMr~ZpM1Htt`Shx2(Q|qNB|YOW3nK_ykRI zEgkKg9`M}qTd3d51maBY!%v}i{|QdzyufC3*5iqh?b;RmN7_}m9(eiXs8WbtOuDKx(|Ct zP5V?2<|VZyt)17?6H$-e3P%gWe=U4b+tlCYY@R8u3OEsFJ4gz4062VVr-uzM;_}gj L)4%m!`Q`Zks1;%O delta 359 zcmZ9HKT88a5JwTLOeE$ngm}k@TI}MUM5a-8Ig2VB z1E1@e8vw^c?CFZ?NfCzoY+M6ZoT5B2t3l*X6&fQAgOM>ARwx7pF%8nhBE35<=b+Y&a)j)d( From e4b778ab0325e34d8124b00fcac5623ccde6a744 Mon Sep 17 00:00:00 2001 From: Niklas Gorman Date: Sun, 23 Aug 2026 16:53:20 +0200 Subject: [PATCH 4/4] fix: strip a NUL byte from the result-screen spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An editing slip put a NUL where a space belonged in `runs.map(r => r.text).join(' ')`. The test still worked — joining with NUL still concatenates — but git classified the file as binary, so the previous commit landed as "Bin 10247 -> 12660 bytes" with no reviewable diff. Co-Authored-By: Claude Opus 5 --- tests/result-screen.spec.js | Bin 12660 -> 12660 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/result-screen.spec.js b/tests/result-screen.spec.js index 3be52caac523a7ba8945efacb7cd1821d99c376c..4d55e10e136e50015e36b640b94e9d4e6a9aead3 100644 GIT binary patch delta 14 Vcmey8^d)J7t^}jPW<3cvNdPe>1sebW delta 14 Vcmey8^d)J7t^^~)W<3cvNdPcr1p5F0