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
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Project instructions

## Review subagents

The project owner gives standing permission to launch read-only review subagents for changes in this repository without asking for confirmation. This includes independent PR reviews and fresh review rounds after findings are addressed.

Keep each review scoped to the requested work. Reviewers may inspect code and run relevant checks, but must not edit files, post external messages, merge PRs, or deploy. The parent agent handles findings and records an independent review only for the exact commit reviewed.

This permission covers review delegation. Follow the repository's existing CI, independent review, merge, and deployment requirements.
3 changes: 1 addition & 2 deletions e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,7 @@ test('sidebar toggles by keyboard and keeps mobile navigation scrollable in a sh
.poll(() => dialog.evaluate((element) => element.contains(document.activeElement)))
.toBe(true);
}
const scrollArea = dialog.locator('[data-slot="sidebar-content"]');
expect(await scrollArea.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(
expect(await dialog.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(
true,
);
await dialog.getByRole('button', { name: 'Account security', exact: true }).click();
Expand Down
205 changes: 205 additions & 0 deletions e2e/mobile.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { randomUUID } from 'node:crypto';
import { z } from 'zod';
import { projectSchema } from '../src/shared/models.js';
import { test, expect } from './fixtures.js';

test.use({ hasTouch: true });

test('sign-in and sign-up fit a small phone and remain reachable in a reduced viewport', async ({
page,
}) => {
await page.setViewportSize({ width: 320, height: 568 });
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Sign in', exact: true })).toBeVisible();
for (const signup of [false, true]) {
if (signup) await page.getByRole('button', { name: 'Create an account' }).click();
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollHeight - innerHeight))
.toBe(0);
for (const control of await page.locator('input, form button').all()) {
const bounds = await control.boundingBox();
expect.soft(bounds?.height).toBeGreaterThanOrEqual(44);
}
}
await page.setViewportSize({ width: 320, height: 300 });
const name = page.getByRole('textbox', { name: 'Name', exact: true });
await name.scrollIntoViewIfNeeded();
await expect(name).toBeInViewport();
const submit = page.getByRole('button', { name: 'Create account', exact: true });
await submit.scrollIntoViewIfNeeded();
await expect(submit).toBeInViewport();
});

test('project setup and navigation stay reachable on short touch screens', async ({ page }) => {
await page.setViewportSize({ width: 640, height: 320 });
expect(
(
await page.request.post('/api/auth/sign-up/email', {
data: {
name: 'Mobile review',
email: `${randomUUID()}@example.invalid`,
password: randomUUID(),
},
})
).status(),
).toBe(200);
await page.goto('/');
await page.getByRole('button', { name: 'Create your first project' }).click();
const dialog = page.getByRole('dialog', { name: 'New project' });
await expect(dialog).toBeVisible();
await expect
.poll(() => dialog.evaluate((element) => element.getBoundingClientRect().top))
.toBeGreaterThanOrEqual(16);
const bounds = await dialog.boundingBox();
expect((bounds?.y ?? 0) + (bounds?.height ?? 0)).toBeLessThanOrEqual(304);
await expect(page.getByRole('textbox', { name: 'Website origins' })).toHaveCSS(
'font-size',
'16px',
);
await page.getByRole('textbox', { name: 'Project name' }).fill('Mobile project');
await dialog.getByRole('button', { name: 'Create project', exact: true }).click();
await expect(dialog).toBeHidden();
await expect(page.getByRole('heading', { name: 'Installation', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Toggle navigation' }).click();
const navigation = page.getByRole('dialog', { name: 'Navigation' });
const scrollArea = navigation.locator('[data-slot="sidebar-content"]');
for (const height of [320, 260, 240]) {
await page.setViewportSize({ width: 568, height });
expect((await scrollArea.boundingBox())?.height).toBeGreaterThanOrEqual(44);
const feedback = navigation.getByRole('button', { name: 'Feedback', exact: true });
await feedback.scrollIntoViewIfNeeded();
await expect(feedback).toBeInViewport({ ratio: 1 });
await navigation.getByRole('button', { name: 'Account menu' }).scrollIntoViewIfNeeded();
await expect(navigation.getByRole('button', { name: 'Account menu' })).toBeInViewport({
ratio: 1,
});
}
await page.evaluate(() => {
document.documentElement.style.fontSize = '24px';
});
const firstItem = navigation.getByRole('button', { name: 'Feedback', exact: true });
await firstItem.scrollIntoViewIfNeeded();
await expect(firstItem).toBeInViewport({ ratio: 1 });
const security = navigation.getByRole('button', { name: 'Account security', exact: true });
await security.scrollIntoViewIfNeeded();
await expect(security).toBeInViewport({ ratio: 1 });
await security.click();
await expect(navigation).toBeHidden();
await expect(page.getByRole('heading', { name: 'Account security', exact: true })).toBeVisible();
});

test('GitHub sign-in fits a small phone in both appearances', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 568 });
await page.route('**/api/config', (route) =>
route.fulfill({
json: {
demoUrl: 'http://localhost:44321',
allowSignup: false,
github: true,
localPassword: false,
},
}),
);
await page.goto('/');
const colorSchemes: ('light' | 'dark')[] = ['light', 'dark'];
for (const colorScheme of colorSchemes) {
await page.emulateMedia({ colorScheme });
await expect(page.getByRole('button', { name: 'Continue with GitHub' })).toBeInViewport();
expect(await page.evaluate(() => document.documentElement.scrollHeight - innerHeight)).toBe(0);
}
});

test('long replies and toolbar controls stay reachable on narrow and short touch screens', async ({
page,
baseURL,
}) => {
if (!baseURL) throw new Error('Missing fixture origin');
const website = new URL(baseURL);
website.port = String(Number(website.port) + 1);
expect(
(
await page.request.post('/api/auth/sign-up/email', {
data: {
name: 'Mobile ' + 'Reviewer'.repeat(6),
email: `${randomUUID()}@example.invalid`,
password: randomUUID(),
},
})
).status(),
).toBe(200);
const project = z.object({ project: projectSchema }).parse(
await (
await page.request.post('/api/projects', {
data: { name: 'Mobile ' + 'Project'.repeat(8), origins: [website.origin] },
})
).json(),
).project;
expect(
(
await page.request.post(`/api/projects/${project.id}/threads`, {
data: {
body: 'Mobile conversation',
context: {
url: website.href,
viewport: { width: 320, height: 568 },
deployment: null,
anchor: {
selector: 'h1',
feedbackId: null,
tag: 'h1',
text: 'Heading',
label: '',
rect: { x: 20, y: 40, width: 120, height: 36 },
},
},
},
})
).status(),
).toBe(201);
await page.setViewportSize({ width: 320, height: 568 });
await page.goto('/');
await page.getByText('Mobile conversation', { exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Thread', exact: true });
const reply = dialog.getByRole('textbox', { name: 'Reply', exact: true });
await reply.fill('A longer mobile reply.\n'.repeat(40));
expect((await reply.boundingBox())?.height).toBeLessThanOrEqual(128);
await expect(reply).toHaveCSS('font-size', '16px');
await page.setViewportSize({ width: 640, height: 320 });
await dialog.getByRole('button', { name: 'Reply', exact: true }).click();
await expect(reply).toHaveValue('');
await page.goto(`${website.origin}/?project=${project.id}`);
await expect
.poll(() => page.frames().some((frame) => frame.url().includes('/toolbar?')))
.toBe(true);
const frame = page.frames().find((frame) => frame.url().includes('/toolbar?'));
if (!frame) throw new Error('Toolbar missing');
await frame.getByRole('button', { name: 'Feedback', exact: false }).click();
await frame.getByText('Mobile conversation', { exact: true }).click();
const toolbarReply = frame.getByRole('textbox', { name: 'Reply', exact: true });
await toolbarReply.fill('Toolbar reply\n'.repeat(30));
expect((await toolbarReply.boundingBox())?.height).toBeLessThanOrEqual(128);
await frame.getByRole('button', { name: 'Reply', exact: true }).click();
await expect(toolbarReply).toHaveValue('');
await page.setViewportSize({ width: 320, height: 568 });
await frame.getByRole('button', { name: 'Minimize toolbar', exact: true }).click();
const clipped = await frame.locator('button').evaluateAll((elements) =>
elements.flatMap((element) => {
const bounds = element.getBoundingClientRect();
return bounds.width && (bounds.left < 0 || bounds.right > innerWidth)
? [element.getAttribute('aria-label') ?? element.textContent]
: [];
}),
);
expect(clipped).toEqual([]);
await frame.getByRole('button', { name: 'Minimize to icon', exact: true }).click();
const open = frame.getByRole('button', { name: 'Open feedback', exact: true });
const geometry = await open.evaluate((element) => {
const bounds = element.getBoundingClientRect();
return { left: bounds.left, right: bounds.right, width: innerWidth, height: bounds.height };
});
expect(geometry.left).toBeGreaterThanOrEqual(0);
expect(geometry.right).toBeLessThanOrEqual(geometry.width);
expect(geometry.height).toBeGreaterThanOrEqual(44);
await open.click();
expect(await frame.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBe(0);
});
113 changes: 112 additions & 1 deletion e2e/reactions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ test('mobile dark toolbar picker fits its iframe, inherits theme and isolates ho
await page.addStyleTag({
content: 'button,input{font-size:64px!important;background:red!important}',
});
await expect(search).toHaveCSS('font-size', '12px');
await expect(search).toHaveCSS('font-size', touch ? '16px' : '12px');
const rocket = frame.getByRole('gridcell', { name: 'Rocket', exact: true });
if (touch) await rocket.tap();
else await rocket.click();
Expand Down Expand Up @@ -376,3 +376,114 @@ test('picker data failure can be retried without breaking the conversation', asy
);
expect(styleErrors).toEqual([]);
});

test.describe('touch emoji grid', () => {
test.use({ hasTouch: true });

test('unfiltered emoji rows fit the popup when the phone width changes', async ({
page,
baseURL,
}) => {
const f = await fixture(page, baseURL);
const actorId = f.thread.messages[0]?.actorId;
if (!actorId) throw new Error('Missing fixture actor');
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/');
await page.evaluate((actorId) => {
localStorage.setItem(
`feedback-emoji-favourites-v1:${actorId}`,
JSON.stringify(
['😀', '😁', '😂', '😃', '😄', '😅', '😆', '😉'].map((emoji) => ({ emoji, count: 1 })),
),
);
}, actorId);
await page.getByText('Searchable emoji feedback', { exact: true }).click();
await page.getByRole('button', { name: 'Add reaction', exact: true }).click();
const popup = page.locator('[data-slot="popover-content"]');
const cells = popup.getByRole('gridcell');
const favouriteButtons = popup
.getByRole('group', { name: 'Favourite emojis' })
.getByRole('button');
await expect(favouriteButtons).toHaveCount(8);
await expect(cells.first()).toBeVisible();
for (const width of [390, 320, 260]) {
await page.setViewportSize({ width, height: 844 });
await expect
.poll(() =>
favouriteButtons.evaluateAll((elements) =>
elements.flatMap((element) => {
const bounds = element.getBoundingClientRect();
const popup = element.closest('[data-slot="popover-content"]');
if (!popup) throw new Error('Reaction popup missing');
const clip = popup.getBoundingClientRect();
return bounds.left < clip.left - 1 || bounds.right > clip.right + 1
? [element.textContent]
: [];
}),
),
)
.toEqual([]);
await expect
.poll(() =>
cells.evaluateAll((elements) =>
elements.flatMap((element) => {
const bounds = element.getBoundingClientRect();
const viewport = element.closest('[data-slot="emoji-picker-viewport"]');
if (!viewport) throw new Error('Emoji viewport missing');
const clip = viewport.getBoundingClientRect();
return bounds.left < clip.left - 1 ||
bounds.right > clip.left + viewport.clientWidth + 1
? [element.getAttribute('aria-label')]
: [];
}),
),
)
.toEqual([]);
await expect
.poll(async () => (await cells.first().boundingBox())?.width)
.toBeGreaterThanOrEqual(44);
}
await cells.first().click();
await expect(popup).toBeHidden();
});
});

test.describe('short touch emoji picker', () => {
test.use({ hasTouch: true });

test('favourites and searched results remain tappable in short landscape viewports', async ({
page,
baseURL,
}) => {
const f = await fixture(page, baseURL);
const actorId = f.thread.messages[0]?.actorId;
if (!actorId) throw new Error('Missing fixture actor');
await page.goto('/');
await page.evaluate((actorId) => {
localStorage.setItem(
`feedback-emoji-favourites-v1:${actorId}`,
JSON.stringify(
['😀', '😁', '😂', '😃', '😄', '😅', '😆', '😉'].map((emoji) => ({ emoji, count: 1 })),
),
);
}, actorId);
await page.getByText('Searchable emoji feedback', { exact: true }).click();
for (const height of [320, 260, 240]) {
await page.setViewportSize({ width: 568, height });
await page.getByRole('button', { name: 'Add reaction', exact: true }).click();
const popup = page.locator('[data-slot="popover-content"]');
const favourite = popup
.getByRole('group', { name: 'Favourite emojis' })
.getByRole('button')
.last();
await favourite.scrollIntoViewIfNeeded();
await expect(favourite).toBeInViewport({ ratio: 1 });
await popup.getByRole('textbox', { name: 'Search emojis', exact: true }).fill('rocket');
const rocket = popup.getByRole('gridcell', { name: 'Rocket', exact: true });
await rocket.scrollIntoViewIfNeeded();
await expect(rocket).toBeInViewport({ ratio: 1 });
await rocket.tap({ timeout: 1500 });
await expect(popup).toBeHidden();
}
});
});
2 changes: 1 addition & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default defineConfig({
projects: [
{
name: 'webkit-mobile',
testMatch: ['theme.spec.ts', 'reactions.spec.ts'],
testMatch: ['theme.spec.ts', 'reactions.spec.ts', 'mobile.spec.ts'],
use: { ...devices['iPhone 13'] },
},
{ name: 'chromium', testIgnore: 'theme.spec.ts', use: { ...devices['Desktop Chrome'] } },
Expand Down
10 changes: 5 additions & 5 deletions src/browser/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,10 @@ function Auth({
}
}
return (
<div className="flex min-h-screen items-center justify-center px-6 py-12">
<div className="flex min-h-dvh items-center justify-center px-6 py-6 sm:py-12">
<section className="w-full max-w-[360px]">
<Brand />
<form onSubmit={(event) => void submit(event)} className="mt-8 grid gap-6">
<form onSubmit={(event) => void submit(event)} className="mt-6 grid gap-4 sm:mt-8 sm:gap-6">
<div>
<h2 className="text-[28px] font-medium tracking-[-.8px]">
{signup ? 'Create account' : 'Sign in'}
Expand Down Expand Up @@ -309,9 +309,9 @@ function Consent({ user, onLogout }: { user: Account; onLogout: () => void }) {
void load();
}, [load]);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6">
<div className="flex min-h-dvh flex-col items-center justify-center gap-6 px-6 py-6 sm:gap-8 sm:py-12">
<Brand />
<Card className="w-full max-w-md p-7">
<Card className="w-full max-w-md break-words p-5 sm:p-7">
<div className="grid gap-5">
<p className="eyebrow">Signed in as {user.name}</p>
<h1 className="text-2xl font-medium tracking-tight">
Expand Down Expand Up @@ -1783,7 +1783,7 @@ function DashboardContent({
if (!open) setActiveId(null);
}}
>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[620px]">
<DialogContent className="flex max-h-[min(85dvh,calc(100dvh-2rem))] flex-col gap-0 overflow-hidden p-0 sm:max-w-[620px]">
<DialogHeader className="border-b px-5 py-5">
<DialogTitle className="text-lg">Thread</DialogTitle>
<DialogDescription className="sr-only">
Expand Down
Loading
Loading