diff options
| author | Adam Soltys <[email protected]> | 2026-09-23 13:00:36 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-23 13:00:36 -0700 |
| commit | c6070c1891f4712b23397746f6aab1e2f070c3f8 (patch) | |
| tree | dba36d85fa42bcc5f47012dd00c0614140b8f650 | |
| parent | 2fbfe122ae0e7d59a6b1df08cccd091010bc94ec (diff) | |
| download | coinosv3-c6070c1891f4712b23397746f6aab1e2f070c3f8.tar.gz coinosv3-c6070c1891f4712b23397746f6aab1e2f070c3f8.zip | |
Keep chat headers and composers pinned without page scrolling
| -rw-r--r-- | src/style.css | 27 | ||||
| -rw-r--r-- | tools/chat-layout-test.js | 54 |
2 files changed, 68 insertions, 13 deletions
diff --git a/src/style.css b/src/style.css index 2a0a4ba..fc1f384 100644 --- a/src/style.css +++ b/src/style.css @@ -403,21 +403,28 @@ hr.divider { margin: 16px 0; } .sel-ico svg { display: block; } /* Messages — Concord community chat. */ -/* The composer must never be below the fold. The card is sized to the - viewport and the message log is the only part that flexes, so opening the - invite or member panel shrinks the scroll area instead of pushing the input - off the bottom of the screen. The subtracted space is what sits around the - card: #app's padding plus the brand header and its gap. */ +/* An open conversation fills the window. Its header and composer stay put; + only the message log scrolls. Let flex layout account for the brand header + and padding instead of subtracting a fixed estimate from the viewport. */ +html:has(#app .chat-card), body:has(#app .chat-card) { overflow: hidden; } +#app:has(.chat-card) { + height: 100vh; + height: var(--chat-viewport-height, 100dvh); + min-height: 0; +} +#app:has(.chat-card) > .footer { display: none; } +#app > .col:has(> .chat-card) { flex: 1; min-height: 0; } +#app > .col:has(> .chat-card) > :not(.chat-card) { flex-shrink: 0; } .chat-card { padding: 0; overflow: hidden; display: flex; flex-direction: column; - height: calc(100vh - 144px); max-height: calc(100vh - 144px); - height: calc(100dvh - 144px); max-height: calc(100dvh - 144px); - min-height: 320px; + flex: 1; min-height: 0; } +.chat-card > :not(.chat-log) { flex-shrink: 0; } +.chat-card > :is(.chat-invite, .chat-members) { flex-shrink: 1; min-height: 0; } .chat-head { padding: 14px 16px 10px; border-bottom: 1px solid var(--line); flex: 0 0 auto; } .chat-title { font-weight: 650; } .chat-log { - flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 12px 14px; + flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior-y: contain; padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; background: var(--surface2); } .chat-row { display: flex; gap: 9px; align-items: flex-start; } @@ -1106,7 +1113,7 @@ body::after { vertical room the footer needs by tightening the frame, not the content. */ @media (max-height: 620px) { #app { padding-top: 12px; padding-bottom: 12px; } - .card { padding: 16px; } + .card:not(.chat-card) { padding: 16px; } .profile-banner { margin: -16px -16px 0; } .footer { padding-top: 14px; padding-bottom: 0; } .tabs { margin: 10px 0; } diff --git a/tools/chat-layout-test.js b/tools/chat-layout-test.js index d8a3687..34c8dc0 100644 --- a/tools/chat-layout-test.js +++ b/tools/chat-layout-test.js @@ -35,7 +35,7 @@ const composerFits = () => page.evaluate(() => { }); try { - for (const vp of [{ width: 1280, height: 800, name: 'laptop' }, { width: 390, height: 844, name: 'phone' }, { width: 1024, height: 600, name: 'short laptop' }]) { + for (const vp of [{ width: 1280, height: 800, name: 'laptop' }, { width: 390, height: 844, name: 'phone' }, { width: 1024, height: 600, name: 'short laptop' }, { width: 390, height: 400, name: 'phone with keyboard' }, { width: 844, height: 390, name: 'landscape phone' }]) { console.log(`\n[${vp.name} ${vp.width}x${vp.height}]`); await page.setViewport({ width: vp.width, height: vp.height }); if (vp.name === 'laptop') { @@ -83,14 +83,40 @@ try { r = await composerFits(); check('...and with the member list open as well', r.fits, `bottom ${r.bottom} of ${r.vh}`); - // NB not asserting the page never scrolls: the footer legitimately sits - // below the fold. What matters is that the composer doesn't. + const frame = await page.evaluate(() => { + const footer = document.querySelector('.footer'); + const head = document.querySelector('.chat-head').getBoundingClientRect(); + return { + footerHidden: !footer || getComputedStyle(footer).display === 'none', + pageFits: document.documentElement.scrollHeight <= innerHeight, + headerFits: head.top >= 0 && head.bottom <= innerHeight, + }; + }); + check('footer hidden, header visible, and no page scrolling', frame.footerHidden && frame.pageFits && frame.headerFits, JSON.stringify(frame)); // put it back for the next viewport await page.evaluate(() => { const e = [...document.querySelectorAll('.chat-head .col')].find((n) => n.className.includes('clickable')); if (e) e.click(); }); await sleep(300); await page.evaluate(() => { const b = [...document.querySelectorAll('.chat-head button')].find((e) => /add person/i.test(e.textContent)); if (b) b.click(); }); await sleep(300); + + const pinned = await page.evaluate(() => { + const log = document.querySelector('.chat-log'); + const head = document.querySelector('.chat-head'); + const compose = document.querySelector('.chat-compose'); + const headTop = head.getBoundingClientRect().top; + const composeTop = compose.getBoundingClientRect().top; + const backlog = document.createElement('div'); + backlog.style.cssText = 'height:3000px;flex-shrink:0'; + log.append(backlog); + log.scrollTop = log.scrollHeight; + const result = log.scrollTop > 0 && head.getBoundingClientRect().top === headTop + && compose.getBoundingClientRect().top === composeTop + && document.documentElement.scrollHeight <= innerHeight; + backlog.remove(); + return result; + }); + check('a long message history scrolls with the name and composer pinned', pinned); } await page.setViewport({ width: 1280, height: 800 }); await sleep(500); @@ -134,6 +160,28 @@ try { check('and the naming field puts itself away', !two.field); const r2 = await composerFits(); check('composer still on screen', r2.fits, `bottom ${r2.bottom} of ${r2.vh}`); + + console.log('\n[direct messages]'); + await page.click('.chat-back'); + await sleep(500); + check('footer returns when the conversation closes', await page.$eval('.footer', (el) => getComputedStyle(el).display !== 'none')); + await click('button', 'New message'); + await setInput('input[placeholder*="npub" i]', '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'); + await click('button', 'Open'); + await page.waitForSelector('.chat-compose'); + await page.setViewport({ width: 390, height: 844 }); + await sleep(500); + const dm = await composerFits(); + check('DM composer fits and footer is hidden', dm.fits && await page.$eval('.footer', (el) => getComputedStyle(el).display === 'none')); + const keyboard = await page.evaluate(() => { + Object.defineProperty(window.visualViewport, 'height', { configurable: true, value: 400 }); + window.visualViewport.dispatchEvent(new Event('resize')); + const bottom = document.querySelector('.chat-compose').getBoundingClientRect().bottom; + delete window.visualViewport.height; + window.visualViewport.dispatchEvent(new Event('resize')); + return bottom <= 400; + }); + check('DM composer follows a keyboard that only resizes the visual viewport', keyboard); } catch (e) { check('run completed', false, e.message); } finally { |
