// Invariants that keep the chat from shifting on mobile, iOS in particular. // // Every assertion here corresponds to something that was actually wrong: // // - `env(safe-area-inset-*)` was already used by the composer but resolved to 0, // because the viewport meta lacked `viewport-fit=cover`. // - A first attempt pinned the shell with position:fixed and chased // visualViewport.offsetTop from JS. That is the wrong instrument: a fixed box // is laid out against the layout viewport, which iOS never shrinks for the // keyboard, so it must be chased forever — and it felt nailed down rather // than laid out. The shell is sized from the visual viewport instead. // - `--sb-vh` was rewritten on every `visualViewport` *scroll* event, so the // whole layout resized under the finger while the iOS URL bar collapsed. // - Children re-asserted a full viewport height, adding the header back. // - The message list is `flex: 1` in a column, which defaults to // `min-height: auto` — it refuses to shrink below its content and pushes the // composer off the bottom once the conversation is long enough. // // These are structural checks. The layout itself is measured against a real // connection in a real browser; see the notes in the pull request. import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const app = readFileSync(new URL('../src/app.jsx', import.meta.url), 'utf8'); // --------------------------------------------------------------------------- // safe areas // --------------------------------------------------------------------------- const meta = / \{ raf = 0; applyOffset\(\); \}\)/, 'a burst of scroll events must collapse into one style write per frame'); assert.match(block, /const zoomed = !!vv && vv\.scale > 1\.01/, 'the only thing that may switch the pan off is a pinch-zoomed page. It ' + 'must not be gated on the window being taller than the visual viewport: ' + 'that assumes iOS never shrinks the layout viewport, and the day it does ' + 'the guard turns the whole fix off without a word'); // Redundant writes cause a style recalculation on every event. assert.match(block, /h !== lastH/, 'skip no-op height writes'); assert.match(block, /Math\.round/, 'sub-pixel churn must be rounded away'); } // --------------------------------------------------------------------------- // layout preview // --------------------------------------------------------------------------- assert.match(app, /get\('preview'\) === 'chat'/, '?preview=chat must render the chat layout without a connection'); assert.match(app, /webrtcManager: null/, 'the preview must not be given a peer manager'); { const i = app.indexOf('if (previewMode) {'); assert.notEqual(i, -1, 'the preview branch must exist'); const branch = app.slice(i, app.indexOf('return React.createElement(\'div\', {\n className: showSidebar', i) + 1 || i + 6000); assert.ok(!/onSendMessage: handleSendMessage/.test(branch), 'the preview must not wire real send handlers'); } // --------------------------------------------------------------------------- // the connection toast must not sit on the header // // It is `fixed top-4 left-1/2` (src/pwa/pwa-manager.js), which inside the chat // lands on the header and covers the peer name. The offset is expressed in the // same two variables the header's own height is (--sb-bar-h for the content row, // --sb-safe-top for the status-bar strip an installed app draws under), so the // toast follows the bar instead of restating a number that only holds in a // browser tab. // --------------------------------------------------------------------------- { const css = readFileSync(new URL('../src/styles/components.css', import.meta.url), 'utf8'); const rule = css.match(/body\.sb-in-chat #pwa-connection-status\s*\{[^}]*\}/); assert.ok(rule, 'the online/offline toast must be offset inside the chat'); assert.match(rule[0], /top:\s*calc\([^)]*var\(--sb-bar-h/, 'the toast offset must track the header height, not a hard-coded 64px'); assert.match(rule[0], /var\(--sb-safe-top/, 'the toast must also clear the status-bar strip in an installed app'); } // --------------------------------------------------------------------------- // iOS focus zoom // --------------------------------------------------------------------------- assert.match(app, /textarea,input,select\{font-size:16px !important/, 'iOS zooms the page when a focused field is under 16px, which reflows ' + 'everything and looks like the layout jumping'); console.log('mobile-chat-layout: all assertions passed');