diff --git a/__tests__/fixtures/resume-import/synthetic.md b/__tests__/fixtures/resume-import/synthetic.md new file mode 100644 index 0000000..b0c3b0a --- /dev/null +++ b/__tests__/fixtures/resume-import/synthetic.md @@ -0,0 +1,23 @@ +# Ada Example + +ada@example.invalid + +## Experience + +### Example Company + +- Built accessible TypeScript screens. + +## Projects + +### Example Reader + +- Released an accessible reading application. + +## Education + +BSc, Example University. + +## Skills + +TypeScript, React. diff --git a/__tests__/html-document.test.ts b/__tests__/html-document.test.ts new file mode 100644 index 0000000..42b9fb8 --- /dev/null +++ b/__tests__/html-document.test.ts @@ -0,0 +1,26 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { ensureLeadingHtmlDoctype } from '../html-document.mjs'; + +const encoder = new TextEncoder(); +const decode = (body: Uint8Array) => new TextDecoder().decode(body); + +describe('anonymous cached HTML standards mode', () => { + it('moves the doctype ahead of Next inlineCss polyfills without losing script or content', () => { + const script = ''; + const document = '
Résumé'; + const result = decode(ensureLeadingHtmlDoctype(encoder.encode(script + document))); + expect(result).toBe(`${script}${document.slice(15)}`); + expect(result.match(//g)).toHaveLength(1); + }); + it('preserves normal documents and does not invent a doctype or move arbitrary content', () => { + for (const html of [ + 'Normal', + 'No declaration', + 'Other prefix
', + ]) { + const original = encoder.encode(html); + expect(ensureLeadingHtmlDoctype(original)).toBe(original); + } + }); +}); diff --git a/docs/architecture/decisions/0016-public-document-rendering.md b/docs/architecture/decisions/0016-public-document-rendering.md new file mode 100644 index 0000000..9eee6d9 --- /dev/null +++ b/docs/architecture/decisions/0016-public-document-rendering.md @@ -0,0 +1,51 @@ +--- +title: Public document rendering +--- + +# ADR-0016: Public document rendering + +Date: 2026-10-13 +Status: Accepted for the source candidate; no production release + +## Context + +Pricing and the tools catalog have server-rendered text, but their initial +document also carried the complete application stylesheet twice: in the HTML +and in Next's Flight payload. Guest navigation imported the authenticated +dropdown and sidebar primitives even when there was no signed-in account. +These costs compete with the public text rendering path tracked in +[issue #39](https://github.com/Significant-Hobbies/rolepatch/issues/39). + +## Decision + +Use Next's documented external-stylesheet mode. Keep the complete stylesheets +render-blocking, and retain the existing critical-CSS build step for +prerendered files. Import workspace-only styles from the already lazy workspace +interior. Generate eager utilities from catalog and shared navigation sources; +the complete application utility set loads on other routes and with the +authenticated menu. Their rules and the public content remain unchanged. + +Load the signed-in menu through `next/dynamic` after a session supplies a user. +Keep the existing guest sign-in path and all authenticated menu actions. A +disabled account placeholder reserves space while the account module loads; +it exposes neither settings nor sign-out until those actions are ready. + +Retain Next's native async script scheduling, inline Flight data and Worker +cache/auth boundaries. There are no timers or interaction-based hydration gates. + +## Consequences + +The public document avoids duplicated inline CSS and guest-only visits avoid +account UI code. External CSS adds a request waterfall, so production-build +Lighthouse comparisons must verify the trade-off. Menu, tool and builder +readiness need interaction assertions, not just screenshots or static HTML. +Responsive geometry and workspace theme portals must remain consistent. + +## Alternatives considered + +- Global `inlineCss`: Next documents its initial Flight duplication; keeping + the full stylesheet inline preserves the measured document overhead. +- Deferring every route or removing hydration: rejected because workspace, + account and mutation flows need the existing Next runtime. +- Native `defer`: did not consistently meet the gate and makes hydration wait + for stylesheets; keep the framework's original scheduling. diff --git a/docs/operations/evidence/resume-first-performance-2026-10-13.json b/docs/operations/evidence/resume-first-performance-2026-10-13.json new file mode 100644 index 0000000..998ba86 --- /dev/null +++ b/docs/operations/evidence/resume-first-performance-2026-10-13.json @@ -0,0 +1,112 @@ +{ + "date": "2026-10-13", + "repository": "Significant-Hobbies/rolepatch", + "issues": [ + 32, + 39 + ], + "pull_request": 34, + "verified_remote_main": "eb355d08d5df1a9bec92ef8f77511cb216b10912", + "unchanged_remote_pr_head": "ecb8c423aba3722c71a6c63855df6099ddc1bdeb", + "measured_code_sha": "edb6fce51e51190f40e58f8573ad4f55f7d153ed", + "history": "Current main merged into retained PR34 branch; both original author commits remain ancestors. No rebases or force pushes.", + "runtime": "wrangler dev --local --env cache; full cf:build; anonymous public routes only", + "workload": "Lighthouse 12.8.2 mobile defaults; simulated throttling; cold browser storage each run; sequential 3 runs per route; external HTTPS blocked to prevent outbound provider/analytics traffic", + "baseline_note": "Retained production build at verified main; source branch changed during baseline but built Worker artifacts remained main.", + "main": { + "/": { + "median_lcp_ms": 1600.5108, + "median_performance": 100, + "best_practices": [ + 100, + 100, + 100 + ] + }, + "/pricing": { + "median_lcp_ms": 3637.28025, + "median_performance": 89.0, + "best_practices": [ + 100, + 100, + 100 + ] + }, + "/tools": { + "median_lcp_ms": 4421.040149999999, + "median_performance": 83.0, + "best_practices": [ + 100, + 100, + 100 + ] + } + }, + "candidate": { + "/": { + "median_lcp_ms": 1740.6149, + "median_performance": 100, + "best_practices": [ + 100, + 100, + 100 + ] + }, + "/pricing": { + "median_lcp_ms": 2662.948400000001, + "median_performance": 95.0, + "best_practices": [ + 100, + 100, + 100 + ] + }, + "/tools": { + "median_lcp_ms": 2920.2155999999995, + "median_performance": 95.0, + "best_practices": [ + 100, + 100, + 100 + ] + } + }, + "checks": { + "quality": "passed: Biome, types, coverage, docs, extension types, unused, complexity, duplication, cycles, dependencies, suppressions, hygiene", + "unit_passed": 762, + "unit_skipped": 1, + "focused_browser_passed": 24, + "responsive_widths": [ + 390, + 768, + 1440 + ], + "responsive_routes": [ + "/", + "/pricing", + "/tools", + "/resume-builder", + "/jobs" + ], + "production_build": "complete cf:build passed" + }, + "lcp_gate_ms": 2500, + "lcp_gate_met": false, + "best_practices_preserved": true, + "publication": { + "status": "blocked", + "git_push": "HTTP 403: Permission denied to configured account", + "github_connector": "HTTP 403: Resource not accessible by integration creating a tree", + "remote_ci": "No new run possible because code could not be pushed; old PR checks still fail. Local required checks pass.", + "pr_metadata": "HTTP 403: Resource not accessible by integration updating PR body; PR metadata remains unchanged." + }, + "open_gates": [ + "Any unmet local LCP medians", + "GitHub write access, actual branch push, and fresh remote CI", + "Human/editorial/taste acceptance", + "Real-device acceptance", + "Production acceptance, including reported Cloudflare 401 noise", + "PDF/DOCX import end-to-end AI structuring and real OAuth #12 were excluded" + ], + "boundaries": "Local synthetic guest checks only. No credentials inspected or changed; no owner resumes/research/prepurge read; no live providers, paid services, deploys, releases, merges, production configuration or remote migrations. No skipped or synthetic checks claimed as live acceptance." +} diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts index 46a9898..1aeac51 100644 --- a/e2e/navigation.spec.ts +++ b/e2e/navigation.spec.ts @@ -4,15 +4,24 @@ test.describe('public pages load', () => { test('landing page renders', async ({ page }) => { await page.goto('/'); await expect(page).toHaveTitle(/RolePatch|Resume/i); + await page + .getByRole('link', { name: /Tailor my resume/i }) + .first() + .click(); + await expect(page).toHaveURL(/resume-builder/); + await expect(page.getByRole('button', { name: 'Import Resume', exact: true })).toBeEnabled(); }); test('dashboard is reachable in guest mode', async ({ page }) => { await page.goto('/dashboard'); await expect(page.getByRole('button', { name: /create|new resume/i })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Import Resume', exact: true })).toBeEnabled(); + await expect(page.getByRole('button', { name: '+ Add Job', exact: true })).toBeDisabled(); }); test('pricing page renders', async ({ page }) => { await page.goto('/pricing'); + expect(await page.evaluate(() => document.compatMode)).toBe('CSS1Compat'); await expect(page.getByText(/Starter|Pro|Bulk/i).first()).toBeVisible(); }); @@ -25,6 +34,7 @@ test.describe('public pages load', () => { test('tools index links to diff and keywords', async ({ page }) => { await page.goto('/tools'); + expect(await page.evaluate(() => document.compatMode)).toBe('CSS1Compat'); await expect(page.getByRole('link', { name: /diff/i })).toBeVisible(); await expect(page.getByRole('link', { name: /keyword/i })).toBeVisible(); }); diff --git a/e2e/public-performance.spec.ts b/e2e/public-performance.spec.ts new file mode 100644 index 0000000..c923294 --- /dev/null +++ b/e2e/public-performance.spec.ts @@ -0,0 +1,93 @@ +import { expect, test } from '@playwright/test'; + +test('account menu becomes usable after a synthetic session loads its account-only code', async ({ + page, +}) => { + await page.emulateMedia({ colorScheme: 'light' }); + await page.addInitScript(() => { + localStorage.setItem('rolepatch-workspace-theme', 'light'); + }); + const synthetic = { + user: { + id: 'public-performance-fixture', + name: 'Sample Reader', + email: 'sample@example.org', + emailVerified: false, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + session: { + id: 'fixture-session', + token: 'synthetic-local-only', + userId: 'public-performance-fixture', + expiresAt: '2030-01-01T00:00:00.000Z', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + }; + await page.route('**/api/auth/get-session**', (route) => route.fulfill({ json: synthetic })); + for (const path of ['/pricing', '/resume-builder']) { + await page.goto(path); + if (path === '/resume-builder' && (page.viewportSize()?.width ?? 0) < 768) { + await page.getByRole('button', { name: 'Toggle Sidebar', exact: true }).click(); + } + if (path === '/resume-builder') { + await expect(page.locator('[data-sidebar="sidebar"]')).toHaveCSS( + 'background-color', + 'oklch(0.985 0 0)' + ); + } + const account = page.getByRole('button', { name: 'Account menu', exact: true }); + await expect(account).toBeEnabled(); + await account.click(); + await expect(page.getByRole('menu')).toHaveCSS('background-color', 'oklch(1 0 0)'); + await expect(page.getByRole('menuitem', { name: 'Account settings' })).toHaveAttribute( + 'href', + '/settings' + ); + await expect(page.getByRole('menuitem', { name: 'Sign out' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: /pricing|buy|billing/i })).toHaveCount(0); + await page.keyboard.press('Escape'); + } +}); + +for (const route of ['/pricing', '/tools']) { + test(`${route} hydrates its guest controls without losing server content`, async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(route); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toBeEnabled(); + await page.getByRole('button', { name: 'Open menu' }).click(); + await expect(page.getByRole('button', { name: 'Close menu' })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + await page.getByRole('button', { name: 'Close menu' }).click(); + await expect(page.getByRole('button', { name: 'Open menu' })).toHaveAttribute( + 'aria-expanded', + 'false' + ); + expect(errors).toEqual([]); + }); +} + +test('tools keeps all cards and loads the selected tool on navigation', async ({ page }) => { + const prefetched: string[] = []; + page.on('request', (request) => { + if ( + request.headers()['next-router-prefetch'] && + new URL(request.url()).pathname.startsWith('/tools/') + ) + prefetched.push(request.url()); + }); + await page.goto('/tools'); + await expect(page.getByRole('link', { name: /Use tool/ })).toHaveCount(6); + await page.waitForLoadState('networkidle'); + expect(prefetched).toEqual([]); + await page.getByRole('link', { name: /ATS Keyword Checker/ }).click(); + await expect(page).toHaveURL(/\/tools\/keywords$/); + await expect(page.locator('textarea').first()).toBeVisible(); + await expect(page.locator('textarea').nth(1)).toBeVisible(); +}); diff --git a/e2e/resume-first.spec.ts b/e2e/resume-first.spec.ts new file mode 100644 index 0000000..3259e6f --- /dev/null +++ b/e2e/resume-first.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test'; +import { resolve } from 'node:path'; + +test('guest import unlocks Add Job only after a ready master survives reload', async ({ page }) => { + await page.goto('/jobs'); + await expect(page.getByRole('button', { name: '+ Add Job', exact: true })).toBeDisabled(); + await page.getByRole('button', { name: '+ New Resume', exact: true }).click(); + await page.getByRole('button', { name: 'Save draft', exact: true }).click(); + await expect(page).toHaveURL(/resume-builder\?resume=/); + await page.goto('/jobs'); + await expect(page.getByRole('button', { name: '+ Add Job', exact: true })).toBeDisabled(); + await page.goto('/resume-builder'); + await expect(page.getByRole('button', { name: 'Import Resume', exact: true })).toBeEnabled(); + await page + .locator('input[type=file]') + .setInputFiles(resolve('__tests__/fixtures/resume-import/synthetic.md')); + await expect(page.getByText('Master resume · ready for a job', { exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByText('Master resume · ready for a job', { exact: true })).toBeVisible(); + await page.goto('/jobs'); + await expect(page.getByRole('button', { name: '+ Add Job', exact: true })).toBeEnabled(); + await page.getByRole('button', { name: '+ Add Job', exact: true }).click(); + await expect(page.getByRole('combobox')).toContainText('synthetic'); +}); + +test('public and workspace routes fit established responsive layouts', async ({ + page, +}, testInfo) => { + for (const route of ['/', '/pricing', '/tools', '/resume-builder', '/jobs']) { + await page.goto(route); + await expect(page.locator('main')).toBeVisible(); + if (route === '/resume-builder') + await expect(page.getByRole('button', { name: 'Import Resume', exact: true })).toBeEnabled(); + for (const width of [390, 768, 1440]) { + await page.setViewportSize({ width, height: 900 }); + await expect + .poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)) + .toBe(true); + await page.screenshot({ + path: testInfo.outputPath(`${route.slice(1) || 'home'}-${width}.png`), + fullPage: true, + }); + } + } +}); diff --git a/html-document.mjs b/html-document.mjs new file mode 100644 index 0000000..ed2a0ce --- /dev/null +++ b/html-document.mjs @@ -0,0 +1,18 @@ +/** Preserve Next's early polyfill while keeping its doctype first (standards mode). */ +export function ensureLeadingHtmlDoctype(body) { + const bytes = body instanceof Uint8Array ? body : new Uint8Array(body); + const prefix = new TextDecoder().decode(bytes.subarray(0, 1024)); + const doctype = //i.exec(prefix); + if (!doctype || doctype.index === 0) return bytes; + const before = prefix.slice(0, doctype.index); + // Only repair the observed Next inlineCss ordering; leave other documents alone. + if (!/^(?:\s* @@ -71,6 +71,8 @@ content.page.jsonLd = [jsonLd, { --font-geist-mono: ui-monospace, 'SFMono-Regular', Menlo, monospace; } body { margin: 0; } + /* Paint the complete hero immediately instead of animating blur/position. */ + main .hero-in { animation: none; }