Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/Views/chat/svelteView.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="en">
<head>
<title>CodeIgniter Chat - Svelte Edition</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#3f6398">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png">
Expand Down
2 changes: 1 addition & 1 deletion app/Views/chat/vueView.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="en">
<head>
<title>CodeIgniter Chat - Vue.js Edition</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#3f6398">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png">
Expand Down
58 changes: 58 additions & 0 deletions docs/mobile.md
Original file line number Diff line number Diff line change
@@ -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.
65 changes: 64 additions & 1 deletion e2e/support/chat-scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -69,6 +70,62 @@ export function chatScenario(scenario: ChatScenario): void {
});
}

async function assertModernChatMobile(page: Page, messageInput: string): Promise<void> {
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<void> {
await page.evaluate(() => {
window.__chatTypingRequests = [];
Expand Down Expand Up @@ -293,7 +350,13 @@ async function installModernChatWebSocket(page: Page): Promise<void> {
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 },
},
};
Expand Down
20 changes: 20 additions & 0 deletions src/css/chat.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -69,6 +87,8 @@ $border-radius: 4px;
#wrapper {
margin: auto;
width: 438px;
max-width: 100%;
box-sizing: border-box;
}

// Error styles
Expand Down
Loading
Loading