From 22a1b75db23fbd4fb8090ca23da38ee8049be5a1 Mon Sep 17 00:00:00 2001 From: Logan Lindquist Land Date: Tue, 15 Sep 2026 12:17:44 -0500 Subject: [PATCH] Improve mobile chat responsiveness --- app/Views/chat/svelteView.php | 2 +- app/Views/chat/vueView.php | 2 +- docs/mobile.md | 58 +++++++++++++++++++ e2e/support/chat-scenarios.ts | 65 +++++++++++++++++++++- src/css/chat.scss | 20 +++++++ src/svelte/App.svelte | 101 +++++++++++++++++++++++++++++++++- src/svelte/main.js | 5 +- src/vue/App.vue | 101 +++++++++++++++++++++++++++++++++- 8 files changed, 348 insertions(+), 6 deletions(-) create mode 100644 docs/mobile.md diff --git a/app/Views/chat/svelteView.php b/app/Views/chat/svelteView.php index 1c6f40e..99b4d79 100644 --- a/app/Views/chat/svelteView.php +++ b/app/Views/chat/svelteView.php @@ -2,7 +2,7 @@ CodeIgniter Chat - Svelte Edition - + diff --git a/app/Views/chat/vueView.php b/app/Views/chat/vueView.php index 42e754e..8ee2849 100644 --- a/app/Views/chat/vueView.php +++ b/app/Views/chat/vueView.php @@ -2,7 +2,7 @@ CodeIgniter Chat - Vue.js Edition - + diff --git a/docs/mobile.md b/docs/mobile.md new file mode 100644 index 0000000..d8ca37b --- /dev/null +++ b/docs/mobile.md @@ -0,0 +1,58 @@ +# Mobile behavior + +The Vue (`/chat/vue`) and Svelte (`/chat/svelte`) chat interfaces are designed +for narrow touch devices down to a 320px viewport. + +## Viewport and safe areas + +Both pages use `width=device-width, initial-scale=1, viewport-fit=cover`. The +viewport remains zoomable; do not add `user-scalable=no` or cap the maximum +scale. + +Shared CSS exposes the four `env(safe-area-inset-*)` values as +`--chat-safe-area-*` custom properties. On narrow screens, the header and +content respect the side and top insets, while the sticky message composer adds +the bottom inset to its padding. Browsers without display cutouts resolve these +values to `0px`. + +The chat container uses `100dvh` with a `100vh` fallback. The composer sticks to +the bottom of the dynamic viewport, and focusing the message field scrolls the +complete composer into view. This keeps the input and actions reachable when a +mobile browser reduces its visual viewport for the on-screen keyboard. + +## Touch and narrow-screen layout + +- Visible buttons, form controls, and the logout control have a minimum target + size of 44 by 44 CSS pixels. +- Buttons and the logout control provide a visible pressed state and use + `touch-action: manipulation`. +- Header and form actions wrap instead of forcing horizontal scrolling. +- Messages, usernames, Markdown code, and other long unbroken content wrap + within the message card. Code blocks remain horizontally scrollable as a + fallback. +- The scrollable message log contains a keyboard-accessible skip link to the + composer. + +## Verification + +The shared Playwright chat scenario temporarily uses a 320 by 568 viewport for +both Vue and Svelte. It verifies: + +- zoom-safe viewport metadata; +- no horizontal overflow on the document, body, or chat container; +- long username and message wrapping; +- 44 by 44 CSS-pixel minimum touch targets; +- a focused sticky composer that remains inside the dynamic viewport; and +- serious or critical automated accessibility violations via axe-core. + +Run the browser suite after building production assets: + +```bash +corepack pnpm@10.34.5 build +corepack pnpm@10.34.5 e2e +``` + +For release checks, run Lighthouse in its mobile mode against both authenticated +pages. Accessibility and best-practices scores must each be at least 95. Record +the tested commit, browser/Lighthouse version, and scores in the release or pull +request notes. diff --git a/e2e/support/chat-scenarios.ts b/e2e/support/chat-scenarios.ts index dca8045..6b76dda 100644 --- a/e2e/support/chat-scenarios.ts +++ b/e2e/support/chat-scenarios.ts @@ -42,6 +42,7 @@ export function chatScenario(scenario: ChatScenario): void { await expect(page.locator(scenario.messageInput)).toBeVisible(); if (isModernChat) { + await assertModernChatMobile(page, scenario.messageInput); await assertModernChatAccessibility(page, scenario.messageInput); await assertModernChatTyping(page, scenario.messageInput); await assertModernChatSearch(page); @@ -69,6 +70,62 @@ export function chatScenario(scenario: ChatScenario): void { }); } +async function assertModernChatMobile(page: Page, messageInput: string): Promise { + const originalViewport = page.viewportSize() ?? { width: 1280, height: 720 }; + await page.setViewportSize({ width: 320, height: 568 }); + + const viewport = page.locator('meta[name="viewport"]'); + await expect(viewport).toHaveAttribute('content', /viewport-fit=cover/); + await expect(viewport).not.toHaveAttribute('content', /user-scalable=no|maximum-scale=1/); + + const widthMetrics = await page.evaluate(() => ({ + viewport: window.innerWidth, + document: document.documentElement.scrollWidth, + body: document.body.scrollWidth, + chat: document.querySelector('.chat-container')?.scrollWidth ?? 0, + offenders: [...document.querySelectorAll('*')] + .filter((element) => element.getBoundingClientRect().right > window.innerWidth + 1) + .slice(0, 10) + .map((element) => ({ + tag: element.tagName, + id: element.id, + className: typeof element.className === 'string' ? element.className : '', + right: Math.round(element.getBoundingClientRect().right), + scrollWidth: element.scrollWidth, + })), + })); + expect(widthMetrics.document, JSON.stringify(widthMetrics.offenders)).toBeLessThanOrEqual(widthMetrics.viewport); + expect(widthMetrics.body).toBeLessThanOrEqual(widthMetrics.viewport); + expect(widthMetrics.chat).toBeLessThanOrEqual(widthMetrics.viewport); + + const longMessage = page.locator('.message-content').first(); + await expect(longMessage).toContainText('mobile-overflow-check'); + const messageWidths = await longMessage.evaluate((element) => ({ + client: element.clientWidth, + scroll: element.scrollWidth, + })); + expect(messageWidths.scroll).toBeLessThanOrEqual(messageWidths.client); + + const targets = page.locator('button:visible, input:visible, textarea:visible, a.logout-btn:visible'); + for (let index = 0; index < (await targets.count()); index += 1) { + const target = targets.nth(index); + const box = await target.boundingBox(); + expect(box, `Expected a box for interactive target ${index}`).not.toBeNull(); + expect(box?.width, `Interactive target ${index} is narrower than 44px`).toBeGreaterThanOrEqual(44); + expect(box?.height, `Interactive target ${index} is shorter than 44px`).toBeGreaterThanOrEqual(44); + } + + const input = page.locator(messageInput); + await input.focus(); + const composer = page.locator('.message-form-container'); + await expect(composer).toHaveCSS('position', 'sticky'); + const composerBox = await composer.boundingBox(); + expect(composerBox, 'Expected the focused mobile composer to be visible').not.toBeNull(); + expect((composerBox?.y ?? 0) + (composerBox?.height ?? 0)).toBeLessThanOrEqual(569); + + await page.setViewportSize(originalViewport); +} + async function assertModernChatTyping(page: Page, messageInput: string): Promise { await page.evaluate(() => { window.__chatTypingRequests = []; @@ -293,7 +350,13 @@ async function installModernChatWebSocket(page: Page): Promise { response = { action: 'messages', data: { - messages: [], + messages: [ + { + user: 'mobile-overflow-check-user-with-a-very-long-name', + msg: `mobile-overflow-check-${'x'.repeat(460)}`, + timestamp: Math.floor(Date.now() / 1000) - 60, + }, + ], pagination: { hasNext: false }, }, }; diff --git a/src/css/chat.scss b/src/css/chat.scss index 37cab2c..20a8ad7 100644 --- a/src/css/chat.scss +++ b/src/css/chat.scss @@ -11,6 +11,24 @@ $border-color: #ddd; $logout-color: #a52834; $border-radius: 4px; +:root { + --chat-safe-area-top: env(safe-area-inset-top, 0px); + --chat-safe-area-right: env(safe-area-inset-right, 0px); + --chat-safe-area-bottom: env(safe-area-inset-bottom, 0px); + --chat-safe-area-left: env(safe-area-inset-left, 0px); +} + +html, +body { + min-width: 0; + max-width: 100%; +} + +body { + margin: 0; + overflow-x: hidden; +} + .skip-link { position: fixed; top: 0.5rem; @@ -69,6 +87,8 @@ $border-radius: 4px; #wrapper { margin: auto; width: 438px; + max-width: 100%; + box-sizing: border-box; } // Error styles diff --git a/src/svelte/App.svelte b/src/svelte/App.svelte index 8e9bd9f..ff1f532 100644 --- a/src/svelte/App.svelte +++ b/src/svelte/App.svelte @@ -770,6 +770,11 @@ startTyping(); } + function ensureComposerVisible(event) { + const composer = event.currentTarget.closest('.message-form-container'); + requestAnimationFrame(() => composer?.scrollIntoView({ block: 'end' })); + } + function startTyping() { if (!webSocketConnected) return; @@ -1223,6 +1228,7 @@ aria-relevant="additions text" tabindex="-1" > + {#each messages as msg, index (index)}
@@ -1285,6 +1291,7 @@ maxlength="500" aria-invalid={Boolean(error)} aria-describedby={error ? 'formatting-help message-error' : 'formatting-help'} + onfocus={ensureComposerVisible} oninput={handleTypingInput} onblur={stopTyping} onkeydown={handleKeydown} @@ -1364,6 +1371,25 @@ box-sizing: border-box; } + :global(button), + :global(input), + :global(textarea), + .logout-btn { + min-height: 44px; + } + + :global(button), + .logout-btn { + min-width: 44px; + touch-action: manipulation; + } + + :global(button:active:not(:disabled)), + .logout-btn:active { + filter: brightness(0.85); + transform: translateY(1px); + } + /* Chat container - the main wrapper */ .chat-container { max-width: 600px; @@ -1393,6 +1419,8 @@ display: flex; justify-content: space-between; align-items: center; + gap: 8px; + min-width: 0; } .welcome-text { @@ -1400,6 +1428,9 @@ } .logout-btn { + display: inline-flex; + align-items: center; + justify-content: center; color: white; text-decoration: none; font-size: 14px; @@ -1497,6 +1528,7 @@ .message-container { position: relative; min-height: 300px; + min-width: 0; } /* Messages list */ @@ -1513,6 +1545,7 @@ /* Individual message item */ .message-item { + min-width: 0; margin-bottom: 15px; padding: 10px; background-color: white; @@ -1528,23 +1561,30 @@ .message-header { display: flex; justify-content: space-between; + gap: 8px; margin-bottom: 5px; font-size: 14px; } .username { + min-width: 0; + overflow-wrap: anywhere; font-weight: bold; color: $primary-color; } .timestamp { + flex: 0 0 auto; color: $light-text-color; font-size: 12px; } .message-content { + min-width: 0; + max-width: 100%; line-height: 1.5; word-break: break-word; + overflow-wrap: anywhere; :global(a) { color: $primary-color; @@ -1556,6 +1596,9 @@ } :global(code) { + max-width: 100%; + white-space: pre-wrap; + overflow-wrap: anywhere; background-color: $background-color; padding: 2px 4px; border-radius: 3px; @@ -1564,12 +1607,20 @@ } :global(blockquote) { + max-width: 100%; border-left: 3px solid $border-color; margin: 5px 0; padding-left: 10px; color: $secondary-color; } + :global(pre) { + max-width: 100%; + overflow-x: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + :global(mark) { padding: 0 2px; border-radius: 2px; @@ -1716,6 +1767,7 @@ font-family: inherit; font-size: 14px; resize: vertical; + overflow-wrap: anywhere; &:focus { border-color: $primary-color; @@ -1819,12 +1871,42 @@ box-shadow: none; margin: 0; height: 100vh; + height: 100dvh; + width: 100%; + max-width: 100%; + min-height: 0; display: flex; flex-direction: column; + overflow-x: hidden; + overflow-y: auto; + scroll-padding-bottom: calc(120px + var(--chat-safe-area-bottom)); + } + + .chat-header { + padding-top: calc(15px + var(--chat-safe-area-top)); + padding-right: calc(15px + var(--chat-safe-area-right)); + padding-left: calc(15px + var(--chat-safe-area-left)); + } + + .user-info { + flex-wrap: wrap; + } + + .welcome-text { + flex: 1 1 100%; + } + + .search-panel, + .messages, + .load-more-container, + .typing-indicator { + padding-right: calc(15px + var(--chat-safe-area-right)); + padding-left: calc(15px + var(--chat-safe-area-left)); } .message-container { - flex: 1; + flex: 1 0 180px; + min-height: 180px; overflow: hidden; } @@ -1833,9 +1915,26 @@ } .form-actions { + flex-wrap: wrap; + button { + flex: 1 1 120px; padding: 12px 15px; } } + + .message-form-container { + position: sticky; + bottom: 0; + z-index: 2; + padding-right: calc(15px + var(--chat-safe-area-right)); + padding-bottom: calc(15px + var(--chat-safe-area-bottom)); + padding-left: calc(15px + var(--chat-safe-area-left)); + background-color: white; + } + + #message-input { + scroll-margin-bottom: calc(120px + var(--chat-safe-area-bottom)); + } } diff --git a/src/svelte/main.js b/src/svelte/main.js index 2c75af1..44c9341 100644 --- a/src/svelte/main.js +++ b/src/svelte/main.js @@ -71,9 +71,12 @@ setupPwa(config.userId); * We pass our configuration as props, making them available * inside the App component via $props(). */ +const target = document.getElementById('app'); +target.replaceChildren(); + const app = mount(App, { // The DOM element where the app will be rendered - target: document.getElementById('app'), + target, // Props passed to the root component // These are accessed in App.svelte using $props() diff --git a/src/vue/App.vue b/src/vue/App.vue index 6531f10..ecd687e 100644 --- a/src/vue/App.vue +++ b/src/vue/App.vue @@ -102,6 +102,7 @@ aria-relevant="additions text" tabindex="-1" > +
composer?.scrollIntoView({ block: 'end' })); + }, + startTyping() { if (!this.webSocketConnected) return; @@ -1146,6 +1153,25 @@ $transition-speed: 0.2s; box-sizing: border-box; } +button, +input, +textarea, +.logout-btn { + min-height: 44px; +} + +button, +.logout-btn { + min-width: 44px; + touch-action: manipulation; +} + +button:active:not(:disabled), +.logout-btn:active { + filter: brightness(0.85); + transform: translateY(1px); +} + // Chat container .chat-container { max-width: 600px; @@ -1175,6 +1201,8 @@ $transition-speed: 0.2s; display: flex; justify-content: space-between; align-items: center; + gap: 8px; + min-width: 0; } .welcome-text { @@ -1182,6 +1210,9 @@ $transition-speed: 0.2s; } .chat-header .logout-btn { + display: inline-flex; + align-items: center; + justify-content: center; color: white; text-decoration: none; font-size: 14px; @@ -1279,6 +1310,7 @@ $transition-speed: 0.2s; .message-container { position: relative; min-height: 300px; + min-width: 0; } // Messages @@ -1294,6 +1326,7 @@ $transition-speed: 0.2s; } .message-item { + min-width: 0; margin-bottom: 15px; padding: 10px; background-color: white; @@ -1309,23 +1342,30 @@ $transition-speed: 0.2s; .message-header { display: flex; justify-content: space-between; + gap: 8px; margin-bottom: 5px; font-size: 14px; } .username { + min-width: 0; + overflow-wrap: anywhere; font-weight: bold; color: $primary-color; } .timestamp { + flex: 0 0 auto; color: $light-text-color; font-size: 12px; } .message-content { + min-width: 0; + max-width: 100%; line-height: 1.5; word-break: break-word; + overflow-wrap: anywhere; a { color: $primary-color; @@ -1337,6 +1377,9 @@ $transition-speed: 0.2s; } code { + max-width: 100%; + white-space: pre-wrap; + overflow-wrap: anywhere; background-color: $background-color; padding: 2px 4px; border-radius: 3px; @@ -1345,12 +1388,20 @@ $transition-speed: 0.2s; } blockquote { + max-width: 100%; border-left: 3px solid $border-color; margin: 5px 0; padding-left: 10px; color: $secondary-color; } + pre { + max-width: 100%; + overflow-x: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + mark { padding: 0 2px; border-radius: 2px; @@ -1496,6 +1547,7 @@ $transition-speed: 0.2s; font-family: inherit; font-size: 14px; resize: vertical; + overflow-wrap: anywhere; &:focus { border-color: $primary-color; @@ -1596,12 +1648,42 @@ $transition-speed: 0.2s; box-shadow: none; margin: 0; height: 100vh; + height: 100dvh; + width: 100%; + max-width: 100%; + min-height: 0; display: flex; flex-direction: column; + overflow-x: hidden; + overflow-y: auto; + scroll-padding-bottom: calc(120px + var(--chat-safe-area-bottom)); + } + + .chat-header { + padding-top: calc(15px + var(--chat-safe-area-top)); + padding-right: calc(15px + var(--chat-safe-area-right)); + padding-left: calc(15px + var(--chat-safe-area-left)); + } + + .user-info { + flex-wrap: wrap; + } + + .welcome-text { + flex: 1 1 100%; + } + + .search-panel, + .messages, + .load-more-container, + .typing-indicator { + padding-right: calc(15px + var(--chat-safe-area-right)); + padding-left: calc(15px + var(--chat-safe-area-left)); } .message-container { - flex: 1; + flex: 1 0 180px; + min-height: 180px; overflow: hidden; } @@ -1610,9 +1692,26 @@ $transition-speed: 0.2s; } .form-actions { + flex-wrap: wrap; + button { + flex: 1 1 120px; padding: 12px 15px; } } + + .message-form-container { + position: sticky; + bottom: 0; + z-index: 2; + padding-right: calc(15px + var(--chat-safe-area-right)); + padding-bottom: calc(15px + var(--chat-safe-area-bottom)); + padding-left: calc(15px + var(--chat-safe-area-left)); + background-color: white; + } + + #message-input { + scroll-margin-bottom: calc(120px + var(--chat-safe-area-bottom)); + } }