Skip to content
Open
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
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Before opening a pull request:
- Add regression coverage for changes to durable jobs, permissions, cancellation, or API behavior.
- Run the repository's formatter, lint, typecheck, tests, and production build.
- Exercise changed UI behavior and check narrow-screen layouts and keyboard access.
- Browser layout regressions use Playwright's bundled Chromium, or installed Chrome when the bundled executable is missing. If neither is available, run `npx playwright install chromium --only-shell` before testing. Tests launch a fresh temporary browser profile.
- Update setup instructions if configuration or dependencies change.
- Keep credentials, local databases, generated test reports, and internal planning notes out of commits.

Expand Down
11 changes: 3 additions & 8 deletions src/client/PageDocument.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { api } from './api';
import { usePageAutosave } from './editor/use-page-autosave';
import { inspectMarkdown } from './editor/markdown';
import { DocumentMenu } from './editor/DocumentMenu';
import { PageTitleField } from './editor/PageTitleField';
import { PageConversation } from './PageConversation';
const RichEditor = lazy(() => import('./editor/RichEditor'));
export function PageDocument({
Expand Down Expand Up @@ -260,15 +261,9 @@ export function PageDocument({
<button onClick={() => setMove(false)}>Done</button>
</div>
)}
<input
className="document-title"
aria-label="Page title"
placeholder="Untitled page"
maxLength={160}
<PageTitleField
value={draft.title}
onChange={(event) =>
controller.edit({ title: event.target.value })
}
onChange={(title) => controller.edit({ title })}
/>
{sourceMode ? (
<>
Expand Down
5 changes: 5 additions & 0 deletions src/client/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,11 @@
margin-bottom: 24px;
outline: none;
border-radius: 0;
display: block;
resize: none;
overflow: hidden;
font-family: inherit;
overflow-wrap: anywhere;
}
.document-title::placeholder {
color: #747c70;
Expand Down
45 changes: 45 additions & 0 deletions src/client/editor/PageTitleField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useLayoutEffect, useRef } from 'react';

export function singleLineTitle(value: string): string {

Check failure on line 3 in src/client/editor/PageTitleField.tsx

View workflow job for this annotation

GitHub Actions / check

✂️ Knip / Unused exports

singleLineTitle in src/client/editor/PageTitleField.tsx
return value.replace(/[\r\n]+/g, ' ');
}

export function PageTitleField({
value,
onChange,
}: {
value: string;
onChange: (title: string) => void;
}) {
const ref = useRef<HTMLTextAreaElement>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const fitToContent = () => {
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
};
fitToContent();
// Text wraps differently when the field's width changes (viewport or
// panel resize), so the fitted height must be recalculated then too,
// not only when the value changes.
const observer = new ResizeObserver(fitToContent);
observer.observe(el);
return () => observer.disconnect();
}, [value]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Recalculate title height when available width changes. This effect depends only on value. Resizing the actual field from 1440px to 375px leaves clientHeight at 208px while scrollHeight grows to 250px; overflow:hidden clips the last line until the user edits the title. Recalculate on element width changes, covering viewport and panel resizing, and add a browser regression for this case.

return (
<textarea
ref={ref}
className="document-title"
aria-label="Page title"
placeholder="Untitled page"
maxLength={160}
rows={1}
value={value}
onChange={(event) => onChange(singleLineTitle(event.target.value))}
onKeyDown={(event) => {
if (event.key === 'Enter') event.preventDefault();
}}
/>
);
}
128 changes: 128 additions & 0 deletions tests/page-title-field.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Browser regression: the fitted title height must follow width changes.
// Uses esbuild to bundle the real component and Playwright Chromium for real
// layout, since SSR or jsdom cannot reproduce textarea wrapping/clipping.
import { buildSync } from 'esbuild';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { chromium } from 'playwright';
import { expect, it, vi } from 'vitest';

const LONG_TITLE =
'AI Coworkers: OpenAI Dots vs Meta Muse vs OpenDots compared in depth';

async function launchTitleBrowser() {
try {
return await chromium.launch();
} catch (error) {
if (
!(error instanceof Error) ||
!error.message.includes("Executable doesn't exist at")
) {
throw error;
}
// Hosted CI images include Chrome even when Playwright's cache is empty.
// Playwright still creates a fresh temporary profile for this launch.
return chromium.launch({ channel: 'chrome' });
}
}

it('falls back to Chrome when the bundled Chromium executable is missing', async () => {
const browser = {} as Awaited<ReturnType<typeof chromium.launch>>;
const launch = vi
.spyOn(chromium, 'launch')
.mockRejectedValueOnce(
new Error("Executable doesn't exist at /cache/chromium"),
)
.mockResolvedValueOnce(browser);
try {
expect(await launchTitleBrowser()).toBe(browser);
expect(launch.mock.calls).toEqual([[], [{ channel: 'chrome' }]]);
} finally {
launch.mockRestore();
}
});

it('does not hide other browser launch failures with a fallback', async () => {
const launch = vi
.spyOn(chromium, 'launch')
.mockRejectedValue(new Error('browser sandbox failed'));
try {
await expect(launchTitleBrowser()).rejects.toThrow(
'browser sandbox failed',
);
expect(launch).toHaveBeenCalledTimes(1);
} finally {
launch.mockRestore();
}
});

function buildHarness(): string {
const dir = mkdtempSync(join(tmpdir(), 'title-field-'));
const entry = join(dir, 'entry.tsx');
writeFileSync(
entry,
`import { createRoot } from 'react-dom/client';
import { PageTitleField } from '${join(process.cwd(), 'src/client/editor/PageTitleField')}';
createRoot(document.getElementById('root')!).render(
<div style={{ width: '100%' }}>
<PageTitleField value=${JSON.stringify(LONG_TITLE)} onChange={() => {}} />
</div>,
);
`,
);
const bundle = join(dir, 'bundle.js');
buildSync({
entryPoints: [entry],
outfile: bundle,
bundle: true,
format: 'iife',
jsx: 'automatic',
logLevel: 'silent',
nodePaths: [join(process.cwd(), 'node_modules')],
});
const css = readFileSync(
join(process.cwd(), 'src/client/editor.css'),
'utf8',
);
const titleRule =
css.match(/\.document-title\s*\{[^}]*\}/)?.[0] ??
(() => {
throw new Error('document-title rule not found');
})();
return `<!doctype html><html><head><style>
body { margin: 0; font-family: sans-serif; }
${titleRule}
</style></head><body><div id="root"></div><script>${readFileSync(bundle, 'utf8')}</script></body></html>`;
}

it('recalculates the fitted title height when the field width shrinks', async () => {
const browser = await launchTitleBrowser();
try {
const page = await browser.newPage({
viewport: { width: 1440, height: 900 },
});
await page.setContent(buildHarness());
const field = page.locator('.document-title');
await field.waitFor();
const before = await field.evaluate((el) => ({
client: el.clientHeight,
scroll: el.scrollHeight,
}));
expect(before.scroll).toBeLessThanOrEqual(before.client);

await page.setViewportSize({ width: 375, height: 900 });
// Give the observer a couple of frames to deliver the resize.
await page.waitForTimeout(200);
const after = await field.evaluate((el) => ({
client: el.clientHeight,
scroll: el.scrollHeight,
}));
// Narrower field wraps the title onto more lines, so the fitted height
// must grow; if it stays stale, overflow:hidden clips the last line.
expect(after.scroll).toBeGreaterThan(before.scroll);
expect(after.client).toBeGreaterThanOrEqual(after.scroll);
} finally {
await browser.close();
}
}, 60_000);
25 changes: 25 additions & 0 deletions tests/page-title-field.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, it } from 'vitest';
import {
PageTitleField,
singleLineTitle,
} from '../src/client/editor/PageTitleField';

it('renders the page title as a wrapping textarea so long titles are not clipped', () => {
const html = renderToStaticMarkup(
<PageTitleField
value="AI Coworkers: OpenAI Dots vs Meta Muse vs OpenDots"
onChange={() => {}}
/>,
);
expect(html).toContain('<textarea');
expect(html).toContain('aria-label="Page title"');
expect(html).toContain('rows="1"');
expect(html).toContain('maxLength="160"');
expect(html).toContain('AI Coworkers: OpenAI Dots vs Meta Muse vs OpenDots');
});

it('keeps the title on a single logical line when text with line breaks is pasted', () => {
expect(singleLineTitle('First\nSecond\r\nThird')).toBe('First Second Third');
expect(singleLineTitle('Plain title')).toBe('Plain title');
});
Loading