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*]*>[\s\S]*?<\/script>)+\s*$/i.test(before)) return bytes; + const encoder = new TextEncoder(); + const offset = encoder.encode(before).length; + const declaration = encoder.encode(doctype[0]); + const result = new Uint8Array(bytes.length); + result.set(declaration); + result.set(bytes.subarray(0, offset), declaration.length); + result.set(bytes.subarray(offset + declaration.length), offset + declaration.length); + return result; +} diff --git a/landing-astro/scripts/inline-critical-css.mjs b/landing-astro/scripts/inline-critical-css.mjs index 1d9985f..a2e5842 100644 --- a/landing-astro/scripts/inline-critical-css.mjs +++ b/landing-astro/scripts/inline-critical-css.mjs @@ -19,7 +19,6 @@ export async function runAstroInlineCss() { [...source.matchAll(styles)] .map((match) => match[1]) .join('\n') - .replaceAll('font-display:swap', 'font-display:optional') ), }).code.toString(); // Restrict first paint to the header and complete hero at every breakpoint. @@ -178,8 +177,8 @@ export async function runAstroInlineCss() { if (!hasMobileCtaRule) throw new Error('Critical CSS is missing the mobile CTA stacking rule'); // Register initial fonts only once. Repeating @font-face in a late stylesheet - // creates new FontFace objects with fresh display timelines: even `optional` - // can then select an already-cached Geist and rewrap the painted hero. + // creates new FontFace objects with fresh display timelines and can rewrap + // the painted hero. Keep swap and the metric fallback in the initial sheet. const deferred = transform({ filename: 'home.css', code: Buffer.from(css), diff --git a/landing-astro/src/components/DeferredScripts.astro b/landing-astro/src/components/DeferredScripts.astro index 885cc2f..e027ee6 100644 --- a/landing-astro/src/components/DeferredScripts.astro +++ b/landing-astro/src/components/DeferredScripts.astro @@ -7,7 +7,7 @@ if (window.appHealth) return; const link = event.target.closest?.('[data-app-health-event],a[href]'); const name = link?.getAttribute('data-app-health-event') || { - '/dashboard': 'workspace_opened', + '/resume-builder': 'workspace_opened', '/pricing': 'pricing_opened', '/tools': 'free_tools_opened', }[link?.getAttribute('href')]; diff --git a/landing-astro/src/components/SiteHeader.astro b/landing-astro/src/components/SiteHeader.astro index de27244..8a7f56c 100644 --- a/landing-astro/src/components/SiteHeader.astro +++ b/landing-astro/src/components/SiteHeader.astro @@ -25,6 +25,6 @@ const { active = 'home', precision = false } = Astro.props; Pricing {!precision && FAQ} - Tailor my resume + Tailor my resume diff --git a/landing-astro/src/content/home.json b/landing-astro/src/content/home.json index 4e74fc3..ef66c6f 100644 --- a/landing-astro/src/content/home.json +++ b/landing-astro/src/content/home.json @@ -52,7 +52,7 @@ "header": { "primary": { "label": "tailor my resume", - "href": "/dashboard" + "href": "/resume-builder" } }, "hero": { @@ -61,7 +61,7 @@ "lede": "Bring your resume and a specific job. RolePatch connects the requirements to your experience, then puts every change in your hands.", "primary": { "label": "tailor my resume", - "href": "/dashboard" + "href": "/resume-builder" }, "secondary": { "label": "see the patch", @@ -88,7 +88,7 @@ "lede": "The work, scope and result already exist in the source. Only the summary is generated. Achievement wording, complete projects and education stay preserved.", "primary": { "label": "review your own resume", - "href": "/dashboard" + "href": "/resume-builder" }, "note": "Kubernetes is requested by the job but absent from this resume. It is left out of this illustrative draft." } @@ -166,7 +166,7 @@ "title": "bring your experience. *make the connection.*", "primary": { "label": "tailor my resume", - "href": "/dashboard" + "href": "/resume-builder" }, "note": "Your resume. One job. A draft you can review." } @@ -185,7 +185,7 @@ "links": [ { "label": "dashboard", - "href": "/dashboard" + "href": "/resume-builder" }, { "label": "browse jobs", diff --git a/landing-astro/src/lib/product.ts b/landing-astro/src/lib/product.ts index d392e77..acd67c3 100644 --- a/landing-astro/src/lib/product.ts +++ b/landing-astro/src/lib/product.ts @@ -100,7 +100,7 @@ export const REDIRECT_AUTHENTICATED_SCRIPT = ` c.indexOf('better-auth.session_token') !== -1 || c.indexOf('__Secure-better-auth.session_token') !== -1 ) { - location.replace('/dashboard'); + location.replace('/resume-builder'); } })(); `; diff --git a/landing-astro/src/pages/faq.astro b/landing-astro/src/pages/faq.astro index 2360fff..962d09f 100644 --- a/landing-astro/src/pages/faq.astro +++ b/landing-astro/src/pages/faq.astro @@ -49,7 +49,7 @@ const faqJsonLd = {

Bring the job. Keep the claims yours.

Start with a guest workspace, then decide whether account-backed persistence belongs in your search.

- Open RolePatch + Open RolePatch diff --git a/landing-astro/src/pages/index.astro b/landing-astro/src/pages/index.astro index d313f6e..ab2f4cb 100644 --- a/landing-astro/src/pages/index.astro +++ b/landing-astro/src/pages/index.astro @@ -50,7 +50,7 @@ content.page.jsonLd = [jsonLd, { data-vitals-routes="changelog,docs,faq" > @@ -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; }