aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-23 13:00:36 -0700
committerAdam Soltys <[email protected]>2026-09-23 13:00:36 -0700
commitc6070c1891f4712b23397746f6aab1e2f070c3f8 (patch)
treedba36d85fa42bcc5f47012dd00c0614140b8f650
parent2fbfe122ae0e7d59a6b1df08cccd091010bc94ec (diff)
downloadcoinosv3-c6070c1891f4712b23397746f6aab1e2f070c3f8.tar.gz
coinosv3-c6070c1891f4712b23397746f6aab1e2f070c3f8.zip
Keep chat headers and composers pinned without page scrolling
-rw-r--r--src/style.css27
-rw-r--r--tools/chat-layout-test.js54
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 {