diff --git a/.github/e2e-shard-capacity.json b/.github/e2e-shard-capacity.json index a280a3b9..f694a539 100644 --- a/.github/e2e-shard-capacity.json +++ b/.github/e2e-shard-capacity.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "selectedShards": 16, + "selectedShards": 12, "workersPerShard": 2, "retriesDuringBenchmark": 0, "benchmark": { @@ -10,24 +10,46 @@ "url": "https://github.com/msrivas-7/CodeTutor-AI/actions/runs/33385421742", "headSha": "ced40c1b465cfeddba37c6299e4668a38d235139", "topologies": [16, 20] + }, + { + "runId": 34688798759, + "url": "https://github.com/msrivas-7/CodeTutor-AI/actions/runs/34688798759", + "headSha": "144335888eb1129dfd8e4bc6f0fbc47644308af8", + "topologies": [16, 20] } ], - "method": "After the account moved to GitHub Pro, 16 and 20 shards ran sequentially on the same stable commit with two workers per shard and no retries. All jobs started without an account queue and every shard passed; 16 had both the faster retry-free test critical path and the faster topology completion.", - "totalTests": 439, + "method": "On the exact PR head, 16 and 20 Chromium shards ran sequentially against one frozen 484-test inventory with two workers per shard and no retries. All 16-shard jobs passed in isolation. Six of 20-shard jobs failed after the shared development database reached its 200-client connection ceiling. The normal E2E workflow then proved that 16 Chromium shards plus four concurrently running support stacks reproduces the same ceiling, so the operational workflow reserves those four stacks and selects 12 Chromium shards.", + "totalTests": 484, "topologies": [ - { "shards": 16, "modeledTestCriticalPathSeconds": 160, "topologyReadySeconds": 379, "reliable": true }, - { "shards": 20, "modeledTestCriticalPathSeconds": 198, "topologyReadySeconds": 416, "reliable": true } + { + "shards": 16, + "modeledTestCriticalPathSeconds": 160, + "topologyReadySeconds": 289, + "reliable": true + }, + { + "shards": 20, + "modeledTestCriticalPathSeconds": 151, + "topologyReadySeconds": 287, + "reliable": false + } ], - "selectedModeledTestCriticalPathSeconds": 160, - "selectedTopologyReadySeconds": 379, - "selectedAverageTestsPerShard": 27.4, - "measuredAt": "2026-08-31T11:20:21Z" + "bestReliableIsolatedModeledTestCriticalPathSeconds": 160, + "bestReliableIsolatedTopologyReadySeconds": 289, + "selectedAverageTestsPerShard": 40.3, + "measuredAt": "2026-09-12T10:44:41Z" }, "rebenchmark": { - "atOrBelowTests": 411, - "atOrAboveTests": 467, + "atOrBelowTests": 443, + "atOrAboveTests": 525, "rule": "Rebenchmark when the suite changes by one selected shard's measured average workload." }, + "operationalTopology": { + "maximumReliableConcurrentStacks": 16, + "evidenceRunId": 34690166145, + "url": "https://github.com/msrivas-7/CodeTutor-AI/actions/runs/34690166145", + "method": "The normal exact-head workflow launched 16 blocking Chromium shards and four support stacks together. Backend logs recorded EMAXCONN at the shared 200-client database ceiling and unrelated browser lanes flaked. The capacity guard now derives every Compose-backed job and matrix cardinality from the workflow itself, then fails if their total exceeds the proven reliable 16-stack boundary." + }, "selectionPolicy": { "minimumAbsoluteGainSeconds": 20, "minimumRelativeGain": 0.05, @@ -48,9 +70,24 @@ "selected": true }, "workerCandidates": [ - { "workers": 2, "testCriticalPathSeconds": 190, "reliable": true, "selected": true }, - { "workers": 3, "testCriticalPathSeconds": 160, "reliable": false, "selected": false }, - { "workers": 4, "testCriticalPathSeconds": null, "reliable": false, "selected": false } + { + "workers": 2, + "testCriticalPathSeconds": 190, + "reliable": true, + "selected": true + }, + { + "workers": 3, + "testCriticalPathSeconds": 160, + "reliable": false, + "selected": false + }, + { + "workers": 4, + "testCriticalPathSeconds": null, + "reliable": false, + "selected": false + } ], "maximumChromiumShards": 20, "method": "Three images prepared in parallel, then local-build and digest-pinned 16-shard stages ran sequentially on the same commit with zero retries. Higher worker counts were eligible only when every shard passed." diff --git a/.github/scripts/e2e-shard-benchmark.test.mjs b/.github/scripts/e2e-shard-benchmark.test.mjs index 543f32a5..3447096e 100644 --- a/.github/scripts/e2e-shard-benchmark.test.mjs +++ b/.github/scripts/e2e-shard-benchmark.test.mjs @@ -102,6 +102,21 @@ test("workflow benchmarks the complete measured range under the account ceiling" assert.match(topologyWorkflow, /--shard=\$\{\{ matrix\.shard }}\/\$\{\{ inputs\.total }}/); }); +test("capacity candidates share one frozen history and use the normal duration planner", () => { + assert.match(benchmarkWorkflow, /for total in 16 20; do/); + assert.match(benchmarkWorkflow, /node \.github\/scripts\/e2e-duration-plan\.mjs/); + assert.match(benchmarkWorkflow, /--history e2e\/shard-capacity-plans\/history\.json/); + assert.match(benchmarkWorkflow, /needs: plan/); + assert.match(benchmarkWorkflow, /needs: \[plan, benchmark-sixteen\]/); + assert.match(benchmarkWorkflow, /needs\.plan\.result == 'success'/); + assert.equal((benchmarkWorkflow.match(/duration_plan_artifact: shard-capacity-plans/g) ?? []).length, 2); + assert.match(topologyWorkflow, /if: inputs\.duration_plan_artifact != ''/); + assert.match(topologyWorkflow, /test -s "\$plan"/); + assert.match(topologyWorkflow, /--test-list="\$plan" --workers=\$\{\{ inputs\.workers }} --retries=0/); + // Older worker/image experiments retain their explicit native-shard control. + assert.match(topologyWorkflow, /else\n\s+npx playwright test --project=chromium --shard=/); +}); + test("does not treat preceding-experiment wait time as test work", () => { const result = compareShardTopologies({ benchmarkRun, diff --git a/.github/scripts/e2e-shard-capacity.mjs b/.github/scripts/e2e-shard-capacity.mjs index 05c59c1d..c53a2c05 100644 --- a/.github/scripts/e2e-shard-capacity.mjs +++ b/.github/scripts/e2e-shard-capacity.mjs @@ -22,12 +22,58 @@ export function deriveRebenchmarkBounds(totalTests, selectedShards) { }; } -export function evaluateShardCapacity({ record, totalTests, activeShards }) { +export function deriveDatabaseStackFanout(workflowSource) { + if (typeof workflowSource !== "string" || workflowSource.trim() === "") { + throw new Error("workflow source must be a non-empty string"); + } + + const jobs = [...workflowSource.matchAll( + /^ ([A-Za-z0-9_-]+):\s*\n([\s\S]*?)(?=^ [A-Za-z0-9_-]+:\s*\n|(?![\s\S]))/gm, + )] + .filter(([, , body]) => /\bdocker compose[^\n]*\bup\b/.test(body)) + .map(([, name, body]) => { + const matrixBlock = body.match( + /^ matrix:\s*\n((?: [^\n]+\n?)*)/m, + )?.[1]; + if (!matrixBlock) return { name, instances: 1 }; + + const dimensions = [...matrixBlock.matchAll( + /^ ([A-Za-z0-9_-]+):\s*\[([^\]]+)]\s*$/gm, + )]; + if (dimensions.length === 0) { + throw new Error( + `database-backed job ${name} must use inline matrix lists so fan-out can be verified`, + ); + } + const instances = dimensions.reduce((product, [, dimension, values]) => { + const count = values.split(",").map((value) => value.trim()).filter(Boolean).length; + if (count === 0) { + throw new Error(`database-backed job ${name} has an empty ${dimension} matrix`); + } + return product * count; + }, 1); + return { name, instances }; + }); + + if (jobs.length === 0) { + throw new Error("workflow has no database-backed docker compose jobs"); + } + return { + jobs, + totalConcurrentStacks: jobs.reduce((sum, job) => sum + job.instances, 0), + }; +} + +export function evaluateShardCapacity({ record, totalTests, activeShards, workflowSource }) { if (record?.schemaVersion !== 1) { throw new Error("capacity record schemaVersion must be 1"); } requirePositiveInteger(record.selectedShards, "selectedShards"); requirePositiveInteger(record?.benchmark?.totalTests, "benchmark.totalTests"); + requirePositiveInteger( + record?.operationalTopology?.maximumReliableConcurrentStacks, + "operationalTopology.maximumReliableConcurrentStacks", + ); requirePositiveInteger(totalTests, "totalTests"); requirePositiveInteger(activeShards, "activeShards"); @@ -37,6 +83,22 @@ export function evaluateShardCapacity({ record, totalTests, activeShards }) { ); } + const fanout = deriveDatabaseStackFanout(workflowSource); + const blockingJob = fanout.jobs.find(({ name }) => name === "e2e"); + if (!blockingJob || blockingJob.instances !== activeShards) { + throw new Error( + "workflow e2e matrix must match the active blocking Chromium shard count", + ); + } + if ( + fanout.totalConcurrentStacks + > record.operationalTopology.maximumReliableConcurrentStacks + ) { + throw new Error( + `operational workflow requests ${fanout.totalConcurrentStacks} concurrent stacks but the measured reliable limit is ${record.operationalTopology.maximumReliableConcurrentStacks}`, + ); + } + const expectedBounds = deriveRebenchmarkBounds( record.benchmark.totalTests, record.selectedShards, @@ -65,6 +127,7 @@ export function evaluateShardCapacity({ record, totalTests, activeShards }) { benchmarkTests: record.benchmark.totalTests, allowedMinimum: expectedBounds.atOrBelowTests + 1, allowedMaximum: expectedBounds.atOrAboveTests - 1, + totalConcurrentStacks: fanout.totalConcurrentStacks, ...expectedBounds, }; } @@ -75,7 +138,7 @@ function parseArgs(argv) { const key = argv[index]; const value = argv[index + 1]; if (!key?.startsWith("--") || value === undefined) { - throw new Error("expected --record, --total-tests, and --active-shards arguments"); + throw new Error("expected --record, --workflow, --total-tests, and --active-shards arguments"); } args[key.slice(2)] = value; } @@ -84,15 +147,20 @@ function parseArgs(argv) { async function main() { const args = parseArgs(process.argv.slice(2)); - if (!args.record || !args["total-tests"] || !args["active-shards"]) { - throw new Error("expected --record, --total-tests, and --active-shards arguments"); + if (!args.record || !args.workflow || !args["total-tests"] || !args["active-shards"]) { + throw new Error("expected --record, --workflow, --total-tests, and --active-shards arguments"); } - const record = JSON.parse(await readFile(args.record, "utf8")); + const [recordText, workflowSource] = await Promise.all([ + readFile(args.record, "utf8"), + readFile(args.workflow, "utf8"), + ]); + const record = JSON.parse(recordText); const result = evaluateShardCapacity({ record, totalTests: Number(args["total-tests"]), activeShards: Number(args["active-shards"]), + workflowSource, }); if (!result.eligible) { @@ -103,7 +171,7 @@ async function main() { } console.log( - `Playwright shard capacity is current: ${result.totalTests} tests, ${result.selectedShards} shards, rebenchmark outside ${result.allowedMinimum}-${result.allowedMaximum} tests.`, + `Playwright shard capacity is current: ${result.totalTests} tests, ${result.selectedShards} Chromium shards, ${result.totalConcurrentStacks} total database stacks, rebenchmark outside ${result.allowedMinimum}-${result.allowedMaximum} tests.`, ); } diff --git a/.github/scripts/e2e-shard-capacity.test.mjs b/.github/scripts/e2e-shard-capacity.test.mjs index 4d8791a1..baf67ac9 100644 --- a/.github/scripts/e2e-shard-capacity.test.mjs +++ b/.github/scripts/e2e-shard-capacity.test.mjs @@ -3,44 +3,84 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import { + deriveDatabaseStackFanout, deriveRebenchmarkBounds, evaluateShardCapacity, } from "./e2e-shard-capacity.mjs"; -const record = JSON.parse(readFileSync(new URL("../e2e-shard-capacity.json", import.meta.url), "utf8")); -const workflow = readFileSync(new URL("../workflows/e2e.yml", import.meta.url), "utf8"); +const record = JSON.parse( + readFileSync(new URL("../e2e-shard-capacity.json", import.meta.url), "utf8"), +); +const workflow = readFileSync( + new URL("../workflows/e2e.yml", import.meta.url), + "utf8", +); test("derives a one-shard-workload rebenchmark band", () => { - assert.deepEqual(deriveRebenchmarkBounds(439, 16), { - atOrBelowTests: 411, - atOrAboveTests: 467, + assert.deepEqual(deriveRebenchmarkBounds(484, 12), { + atOrBelowTests: 443, + atOrAboveTests: 525, }); }); test("tracked decision preserves the clean controlled benchmark evidence", () => { assert.deepEqual( - record.benchmark.runs.map(({ runId, headSha, topologies }) => ({ runId, headSha, topologies })), + record.benchmark.runs.map(({ runId, headSha, topologies }) => ({ + runId, + headSha, + topologies, + })), [ { runId: 33385421742, headSha: "ced40c1b465cfeddba37c6299e4668a38d235139", topologies: [16, 20], }, + { + runId: 34688798759, + headSha: "144335888eb1129dfd8e4bc6f0fbc47644308af8", + topologies: [16, 20], + }, ], ); assert.deepEqual( - record.benchmark.topologies.map(({ shards, modeledTestCriticalPathSeconds, topologyReadySeconds, reliable }) => ({ - shards, - modeledTestCriticalPathSeconds, - topologyReadySeconds, - reliable, - })), + record.benchmark.topologies.map( + ({ + shards, + modeledTestCriticalPathSeconds, + topologyReadySeconds, + reliable, + }) => ({ + shards, + modeledTestCriticalPathSeconds, + topologyReadySeconds, + reliable, + }), + ), [ - { shards: 16, modeledTestCriticalPathSeconds: 160, topologyReadySeconds: 379, reliable: true }, - { shards: 20, modeledTestCriticalPathSeconds: 198, topologyReadySeconds: 416, reliable: true }, + { + shards: 16, + modeledTestCriticalPathSeconds: 160, + topologyReadySeconds: 289, + reliable: true, + }, + { + shards: 20, + modeledTestCriticalPathSeconds: 151, + topologyReadySeconds: 287, + reliable: false, + }, ], ); - assert.equal(record.benchmark.selectedModeledTestCriticalPathSeconds, 160); + assert.equal(record.benchmark.totalTests, 484); + assert.equal(record.benchmark.bestReliableIsolatedModeledTestCriticalPathSeconds, 160); + assert.equal(record.benchmark.selectedAverageTestsPerShard, 40.3); + assert.deepEqual(record.operationalTopology, { + maximumReliableConcurrentStacks: 16, + evidenceRunId: 34690166145, + url: "https://github.com/msrivas-7/CodeTutor-AI/actions/runs/34690166145", + method: record.operationalTopology.method, + }); assert.deepEqual(record.runtimeOptimization.imageReuse, { localBuildEndToEndSeconds: 369, prebuiltEndToEndSecondsIncludingPreparation: 338, @@ -62,27 +102,92 @@ test("tracked decision preserves the clean controlled benchmark evidence", () => assert.equal(record.runtimeOptimization.maximumChromiumShards, 20); }); -test("blocking workflow uses the selected matrix and derives its denominator", () => { - const exhaustiveJob = workflow.match(/\n e2e:\n([\s\S]+?)\n cross-browser-core:/)?.[1] ?? ""; - const shardMatrix = exhaustiveJob.match(/matrix:\n\s+shard: \[([^\]]+)]/)?.[1] +test("blocking workflow uses the selected matrix and complete planned inventory", () => { + const exhaustiveJob = + workflow.match(/\n e2e:\n([\s\S]+?)\n cross-browser-core:/)?.[1] ?? ""; + const shardMatrix = exhaustiveJob + .match(/matrix:\n\s+shard: \[([^\]]+)]/)?.[1] .split(",") .map((value) => Number(value.trim())); - assert.deepEqual(shardMatrix, Array.from({ length: record.selectedShards }, (_, index) => index + 1)); - assert.match(exhaustiveJob, /--active-shards "\$\{\{ strategy\.job-total }}/); - assert.match(workflow, /--output e2e\/duration-plan\/full[\s\S]+--shards 16/); - assert.match(exhaustiveJob, /--test-list=duration-plan-artifact\/full\/shard-\$\{\{ matrix\.shard }}\.txt/); + assert.deepEqual( + shardMatrix, + Array.from({ length: record.selectedShards }, (_, index) => index + 1), + ); + assert.match(workflow, /--output e2e\/duration-plan\/full[\s\S]+--shards 12/); + assert.match( + exhaustiveJob, + /--test-list=duration-plan-artifact\/full\/shard-\$\{\{ matrix\.shard }}\.txt/, + ); assert.match(exhaustiveJob, /name: Upload test-duration evidence/); + assert.doesNotMatch(exhaustiveJob, /e2e-shard-capacity\.mjs/); }); test("advisory critical coverage is split across two isolated duration-balanced jobs", () => { - assert.match(workflow, /--output e2e\/duration-plan\/critical[\s\S]+--shards 2[\s\S]+--tag lane:critical/); + assert.match( + workflow, + /--output e2e\/duration-plan\/critical[\s\S]+--shards 2[\s\S]+--tag lane:critical/, + ); assert.match(workflow, /critical-shadow:[\s\S]+matrix:\n\s+shard: \[1, 2]/); assert.doesNotMatch(workflow, /critical-shadow-summary:/); - assert.match(workflow, /shadow-evidence:[\s\S]+needs: \[duration-plan, critical-shadow, e2e, cross-browser-core][\s\S]+files\.length!==2/); + assert.match( + workflow, + /shadow-evidence:[\s\S]+needs: \[duration-plan, critical-shadow, e2e, cross-browser-core][\s\S]+files\.length!==2/, + ); +}); + +test("derives every concurrent database stack from the workflow", () => { + assert.deepEqual(deriveDatabaseStackFanout(workflow), { + jobs: [ + { name: "critical-shadow", instances: 2 }, + { name: "e2e", instances: 12 }, + { name: "cross-browser-core", instances: 2 }, + ], + totalConcurrentStacks: 16, + }); +}); + +test("capacity gate runs before any database-backed job can launch", () => { + const planningJob = + workflow.match(/\n duration-plan:\n([\s\S]+?)\n prepare-backend:/)?.[1] ?? + ""; + assert.match(planningJob, /name: Enforce measured database fan-out/); + assert.match(planningJob, /--workflow \.github\/workflows\/e2e\.yml/); + assert.match(planningJob, /--active-shards 12/); + assert.match( + planningJob, + /Build coverage-complete duration plan[\s\S]+Enforce measured database fan-out[\s\S]+Upload duration plan/, + ); + for (const job of ["critical-shadow", "e2e", "cross-browser-core"]) { + assert.match(workflow, new RegExp(`\\n ${job}:[\\s\\S]+?needs: \\[[^\\]]*duration-plan`)); + } +}); + +test("recognizes docker compose flags before the up command", () => { + const workflowWithComposeFlags = workflow.replace( + "docker compose up -d --no-build backend frontend", + "docker compose --project-name isolated up -d --no-build backend frontend", + ); + assert.deepEqual( + deriveDatabaseStackFanout(workflowWithComposeFlags), + deriveDatabaseStackFanout(workflow), + ); +}); + +test("fails closed when a database-backed matrix cannot be counted", () => { + const dynamicMatrix = workflow.replace( + "browser: [firefox, webkit]", + "browser: ${{ fromJSON(needs.plan.outputs.browsers) }}", + ); + assert.throws( + () => deriveDatabaseStackFanout(dynamicMatrix), + /cross-browser-core must use inline matrix lists/, + ); }); test("duration planning receives the authenticated fixture environment required for discovery", () => { - const planningJob = workflow.match(/\n duration-plan:\n([\s\S]+?)\n prepare-backend:/)?.[1] ?? ""; + const planningJob = + workflow.match(/\n duration-plan:\n([\s\S]+?)\n prepare-backend:/)?.[1] ?? + ""; for (const variable of [ "SUPABASE_URL", "SUPABASE_ANON_KEY", @@ -92,37 +197,93 @@ test("duration planning receives the authenticated fixture environment required "DATABASE_URL", "BYOK_ENCRYPTION_KEY", ]) { - assert.match(planningJob, new RegExp(`${variable}: \\$\\{\\{ secrets\\.${variable} }}`)); + assert.match( + planningJob, + new RegExp(`${variable}: \\$\\{\\{ secrets\\.${variable} }}`), + ); } }); test("accepts the measured inventory and normal growth", () => { - assert.equal(evaluateShardCapacity({ record, totalTests: 439, activeShards: 16 }).eligible, true); - assert.equal(evaluateShardCapacity({ record, totalTests: 466, activeShards: 16 }).eligible, true); - assert.equal(evaluateShardCapacity({ record, totalTests: 412, activeShards: 16 }).eligible, true); + assert.equal( + evaluateShardCapacity({ record, totalTests: 484, activeShards: 12, workflowSource: workflow }) + .eligible, + true, + ); + assert.equal( + evaluateShardCapacity({ record, totalTests: 524, activeShards: 12, workflowSource: workflow }) + .eligible, + true, + ); + assert.equal( + evaluateShardCapacity({ record, totalTests: 444, activeShards: 12, workflowSource: workflow }) + .eligible, + true, + ); }); test("requires a new benchmark at either capacity boundary", () => { - const upper = evaluateShardCapacity({ record, totalTests: 467, activeShards: 16 }); - const lower = evaluateShardCapacity({ record, totalTests: 411, activeShards: 16 }); - assert.deepEqual({ eligible: upper.eligible, direction: upper.direction }, { eligible: false, direction: "upper" }); - assert.deepEqual({ eligible: lower.eligible, direction: lower.direction }, { eligible: false, direction: "lower" }); + const upper = evaluateShardCapacity({ + record, + totalTests: 525, + activeShards: 12, + workflowSource: workflow, + }); + const lower = evaluateShardCapacity({ + record, + totalTests: 443, + activeShards: 12, + workflowSource: workflow, + }); + assert.deepEqual( + { eligible: upper.eligible, direction: upper.direction }, + { eligible: false, direction: "upper" }, + ); + assert.deepEqual( + { eligible: lower.eligible, direction: lower.direction }, + { eligible: false, direction: "lower" }, + ); }); test("fails closed when workflow topology drifts from the measured record", () => { assert.throws( - () => evaluateShardCapacity({ record, totalTests: 437, activeShards: 10 }), + () => evaluateShardCapacity({ + record, + totalTests: 437, + activeShards: 10, + workflowSource: workflow, + }), /active workflow has 10 shards/, ); }); -test("fails closed when recorded boundaries are stale or hand-edited", () => { +test("fails closed when the complete workflow exceeds measured database fan-out", () => { assert.throws( () => evaluateShardCapacity({ - record: { ...record, rebenchmark: { atOrBelowTests: 1, atOrAboveTests: 999 } }, - totalTests: 439, + record: { ...record, selectedShards: 16 }, + totalTests: 484, activeShards: 16, + workflowSource: workflow.replace( + "shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]", + "shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]", + ), }), - /capacity record bounds must be 411\/467/, + /requests 20 concurrent stacks.*reliable limit is 16/, + ); +}); + +test("fails closed when recorded boundaries are stale or hand-edited", () => { + assert.throws( + () => + evaluateShardCapacity({ + record: { + ...record, + rebenchmark: { atOrBelowTests: 1, atOrAboveTests: 999 }, + }, + totalTests: 484, + activeShards: 12, + workflowSource: workflow, + }), + /capacity record bounds must be 443\/525/, ); }); diff --git a/.github/workflows/e2e-shard-benchmark.yml b/.github/workflows/e2e-shard-benchmark.yml index 1a6e3578..6a00c163 100644 --- a/.github/workflows/e2e-shard-benchmark.yml +++ b/.github/workflows/e2e-shard-benchmark.yml @@ -26,23 +26,77 @@ permissions: pull-requests: read jobs: + plan: + name: Freeze duration-balanced benchmark plans + if: github.event.label.name == 'ci-shard-benchmark' + runs-on: ubuntu-latest + env: + SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: e2e/package-lock.json + - name: Restore one timing baseline for both candidates + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: .e2e-duration + key: e2e-duration-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + e2e-duration-${{ runner.os }}- + - name: Install e2e deps + working-directory: e2e + run: npm ci + - name: Plan every test with the normal CI algorithm + run: | + set -euo pipefail + mkdir -p e2e/shard-capacity-plans .e2e-duration + if [ ! -s .e2e-duration/history.json ]; then + cp .github/e2e-duration-seed.json .e2e-duration/history.json + fi + cp .e2e-duration/history.json e2e/shard-capacity-plans/history.json + cd e2e + npx playwright test --list --project=chromium --reporter=json > shard-capacity-plans/inventory.json + cd .. + for total in 16 20; do + node .github/scripts/e2e-duration-plan.mjs \ + --inventory e2e/shard-capacity-plans/inventory.json \ + --history e2e/shard-capacity-plans/history.json \ + --output "e2e/shard-capacity-plans/$total" \ + --shards "$total" + done + - name: Preserve frozen plans and timing provenance + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: shard-capacity-plans + path: e2e/shard-capacity-plans + retention-days: 90 + if-no-files-found: error + benchmark-sixteen: if: github.event.label.name == 'ci-shard-benchmark' + needs: plan uses: ./.github/workflows/e2e-shard-topology.yml with: total: 16 shards: '[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16]' max_parallel: 16 + duration_plan_artifact: shard-capacity-plans secrets: inherit benchmark-twenty: - if: always() && github.event.label.name == 'ci-shard-benchmark' - needs: benchmark-sixteen + if: always() && github.event.label.name == 'ci-shard-benchmark' && needs.plan.result == 'success' + needs: [plan, benchmark-sixteen] uses: ./.github/workflows/e2e-shard-topology.yml with: total: 20 shards: '[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]' max_parallel: 20 + duration_plan_artifact: shard-capacity-plans secrets: inherit compare: diff --git a/.github/workflows/e2e-shard-topology.yml b/.github/workflows/e2e-shard-topology.yml index 76aa9983..4cc68045 100644 --- a/.github/workflows/e2e-shard-topology.yml +++ b/.github/workflows/e2e-shard-topology.yml @@ -28,6 +28,11 @@ on: required: false default: 2 type: number + duration_plan_artifact: + description: Optional frozen duration-balanced plans for all candidate counts + required: false + default: '' + type: string backend_image_ref: description: Optional immutable backend image reference required: false @@ -89,6 +94,7 @@ jobs: BACKEND_IMAGE: ${{ inputs.backend_image_ref }} RUNNER_IMAGE: ${{ inputs.runner_image_ref }} FRONTEND_IMAGE: ${{ inputs.frontend_image_ref }} + DURATION_PLAN_ARTIFACT: ${{ inputs.duration_plan_artifact }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -119,6 +125,12 @@ jobs: - name: Install e2e deps working-directory: e2e run: npm ci + - name: Download frozen duration-balanced benchmark plans + if: inputs.duration_plan_artifact != '' + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: ${{ inputs.duration_plan_artifact }} + path: e2e/duration-plan - name: Cache Playwright browser uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: @@ -131,7 +143,15 @@ jobs: run: npx playwright install --with-deps chromium - name: Run identical full suite without retries working-directory: e2e - run: npx playwright test --shard=${{ matrix.shard }}/${{ inputs.total }} --workers=${{ inputs.workers }} --retries=0 + run: | + set -euo pipefail + if [ -n "$DURATION_PLAN_ARTIFACT" ]; then + plan="duration-plan/${{ inputs.total }}/shard-${{ matrix.shard }}.txt" + test -s "$plan" + npx playwright test --project=chromium --test-list="$plan" --workers=${{ inputs.workers }} --retries=0 + else + npx playwright test --project=chromium --shard=${{ matrix.shard }}/${{ inputs.total }} --workers=${{ inputs.workers }} --retries=0 + fi - name: Dump docker-compose logs on failure if: failure() run: docker compose logs --no-color --tail=300 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3ba9666a..e096ea1c 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -103,13 +103,23 @@ jobs: --inventory e2e/duration-plan/inventory.json \ --history .e2e-duration/history.json \ --output e2e/duration-plan/full \ - --shards 16 + --shards 12 node .github/scripts/e2e-duration-plan.mjs \ --inventory e2e/duration-plan/inventory.json \ --history .e2e-duration/history.json \ --output e2e/duration-plan/critical \ --shards 2 \ --tag lane:critical + - name: Enforce measured database fan-out + run: | + set -euo pipefail + total_tests=$(node -e 'const fs=require("fs"); const value=JSON.parse(fs.readFileSync("e2e/duration-plan/full/manifest.json","utf8")); process.stdout.write(String(value.testCount))') + test -n "$total_tests" + node .github/scripts/e2e-shard-capacity.mjs \ + --record .github/e2e-shard-capacity.json \ + --workflow .github/workflows/e2e.yml \ + --total-tests "$total_tests" \ + --active-shards 12 - name: Validate critical metadata and frozen regression coverage id: contract continue-on-error: true @@ -397,19 +407,20 @@ jobs: needs: [duration-plan, prepare-backend, prepare-runner, prepare-frontend] runs-on: ubuntu-latest # Sharding splits the exhaustive suite across isolated runners. The - # capacity record owns the measured decision and the shard-1 guard below - # forces a new benchmark after one shard-equivalent of suite growth or - # shrinkage. Runs 33377525950 and 33381912250 selected 16×2 workers: - # 178s of modeled retry-free test work versus 233s for 14, 241s for 17, - # and 170s for 20. The eight-second 20-shard gain missed the required - # 20-second / 5% adoption threshold and consumed the account ceiling. + # capacity record owns the measured decision; the duration-planning gate + # verifies this complete workflow before any database-backed job launches + # and forces a new benchmark after one shard-equivalent of suite growth or + # shrinkage. The isolated capacity benchmark proved 16 stacks reliable, + # but this workflow also runs Firefox, WebKit, and two critical-shadow + # stacks concurrently. Twelve Chromium shards reserve those four slots and + # keep the complete database-backed fan-out at the proven 16-stack limit. strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] + shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] # Bumped from 15 → 25 in Phase 22C: full e2e sweep grew past the 15-min # ceiling once the marketing specs landed. Sharding (Phase 27-v2.2) - # each shard now runs ~1/16 of the suite, so 25 stays generous. + # each shard now runs ~1/12 of the suite, so 25 stays generous. timeout-minutes: 25 # Phase 18d: cloud-only Supabase model. These feed both docker compose # (which reads process env for ${VAR} interpolation in docker-compose.yml) @@ -501,18 +512,6 @@ jobs: name: e2e-duration-plan path: e2e/duration-plan-artifact - - name: Enforce measured shard capacity - if: matrix.shard == 1 - working-directory: e2e - run: | - set -euo pipefail - total_tests=$(node -e 'const fs=require("fs"); const value=JSON.parse(fs.readFileSync("duration-plan-artifact/full/manifest.json","utf8")); process.stdout.write(String(value.testCount))') - test -n "$total_tests" - node ../.github/scripts/e2e-shard-capacity.mjs \ - --record ../.github/e2e-shard-capacity.json \ - --total-tests "$total_tests" \ - --active-shards "${{ strategy.job-total }}" - - name: Cache Playwright browsers uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md new file mode 100644 index 00000000..eff6cdcd --- /dev/null +++ b/docs/DESIGN_SYSTEM.md @@ -0,0 +1,111 @@ +# CodeTutor design system + +The homepage anchors the public brand. Shared decisions must have one owner; +page-specific composition must not become a second theme. This guide describes +the local implementation, not evidence that its latest visual changes are released. +See [the public continuity ledger](PUBLIC_THEME_CONTINUITY.md) for verification. + +## Architecture and ownership + +| Layer | Source of truth | Responsibility | +| --- | --- | --- | +| Public values | `frontend/src/design-system/tokens.ts` | Palette, named surface/text roles, shared header/control sizing, reading protection and field arrival duration | +| First paint | `frontend/scripts/vitePluginDesignTokens.ts` | Inline the same token output into Vite HTML before the bootstrap; no extra request or runtime theme generator | +| Static documents | `frontend/scripts/discoverySite.ts` | Inline the same tokens and derive browser chrome from the same canvas value; works without JavaScript | +| Shared public materials/components | `frontend/src/features/marketing/public/theme.css`, `frontend/src/features/marketing/public/PublicPage.tsx`, `frontend/src/auth/AuthShell.tsx` | Navigation geometry, display-copy treatment and reusable page/form boundaries | +| Public family composition | `frontend/src/features/marketing/public/public-page.css`, `frontend/src/features/marketing/public/discovery-theme.css`, `frontend/src/features/marketing/study/study.css` | Reading widths, editorial layouts and responsive arrangements; consume roles instead of copying values | +| Shared motion | `frontend/src/features/marketing/public/PublicMotionWorld.tsx`, `frontend/src/features/marketing/study/ParticleField.tsx`, adjacent scene/geometry modules | Retained renderer and existing motion physics; page descriptors supply composition, not another engine | +| Workspace themes | `frontend/src/index.css`, `frontend/tailwind.config.js` | Existing light/dark semantic colors and typography; unchanged by the public palette migration | + +The dependency direction is **values → purpose-based roles → materials/components +→ page composition**. `--study-*` names remain compatibility aliases, not another +palette. Tailwind's space-separated RGB channel variables and ordinary CSS colors +are derived from the same public values. Existing light-study overrides are +centralized too; this does not introduce a new user-facing theme control. + +This is an incremental system, not a claim that every legacy style is migrated. +Workspace roles already centralize theme colors. Bespoke typography, spacing, +shadows, syntax colors, graphics material constants and component motion still +have existing owners. Migrate those by a coherent, browser-verified slice when +needed; do not copy them into a second registry or silently restyle the workspace. + +Public pages own their `.public-surface` typography boundary, not the initial +entry router: direct loading, auth-to-home navigation and history must render +the same fonts. Public auth dividers and recovery instructions share the scoped +supporting-copy recipe; workspace signup retains its own existing styling. +Browser share comments consume the readable faint text role. The image export's +fixed palette remains a separate output contract, not a second web theme. + +## Surface rules + +- **World:** the shared near-black canvas and living glyph field. +- **Layout:** transparent spacing/alignment wrappers. A layout box is not a card. +- **Display copy:** large, brief headings can use `brand-display-copy`; letterform + shadows and a local elliptical fade avoid painting a hard rectangle. Currently applied only to + the walkthrough heading as a local visual experiment. Moving readability needs + actual-browser acceptance before broader adoption. +- **Reading:** dense prose uses a solid canvas core with the shared + `--brand-reading-shadow` edge. Do not put animated glyphs through paragraphs. +- **Objects:** code, tutor content, controls, tables and actionable cards remain + stable surfaces. Do not make everything transparent or apply glass everywhere. + Public filled actions use `public-action`; `public-action--accent` swaps its + resting and hover fills while keeping the paired foreground. Do not combine + page-level link-hover colors with independent workspace button utilities. + +The walkthrough keeps its code/tutor body and controls opaque. Its outer layout +and explanatory interval are open; actual explanation text keeps local backing. +No content, demo stages, geometry, navigation or particle physics change with this +material experiment. Focus outlines must remain outside protective paint layers. + +Mobile story pacing belongs to `study.css`: artwork reserves bounded stable- +viewport space so the scroll-driven renderer can form, linger and disperse before +reading clusters. Chapter/closing frames share `--study-phone-art-space`; the +opening has its own larger interval. Do not shrink these to decorative icons or +compensate by slowing pointer physics or intercepting native scroll. Validate +short/tall phones, reverse scrolling, reduced motion and physical swipe feel. + +## How to make a change + +1. Read the approved brand direction and start the harness. Material redesigns + need Mehul's approval; token centralization is not permission to redesign. +2. If a decision is shared, change its existing token or component. Add a token + only for a real reusable role, not every arbitrary number. Distinguish surfaces + such as `field` and `object` even if they later share a value. +3. Components consume CSS roles such as `var(--study-ink)` or Tailwind `text-ink`. + Do not import the token generator into React or copy raw palette values into + route files. The build owns serialization and first-paint injection. +4. Do not change a role's meaning between themes. Keep text, control, focus, + disabled, hover, error and loading states coherent. Component behavior and + authorization remain outside the theme layer. +5. Run token/public-theme unit tests and build/asset budgets. The public E2E + contract tests mutate tokens to prove real SPA and static consumers inherit + them, check first paint with the app blocked, and protect workspace isolation. +6. Rebuild local services; inspect affected routes plus adjacent consumers in the + actual browser. Include motion, reduced motion, keyboard/focus, long/error + content, narrow/large displays and reload. Automated checks support, but never + replace, this gate. Update the finding ledger with exact evidence and limits. + +Do not create a catch-all configurable component, another CSS framework, or a +global selector that changes every button/dialog. Reuse existing components when +semantics and behavior match; extract a primitive only when duplication warrants it. +For a future workspace migration, preserve values first, verify dark/light states, +then propose any actual visual change separately. + +## Research and tradeoffs + +[IBM Carbon's theming model](https://carbondesignsystem.com/elements/themes/overview/) +uses stable purpose-based token names whose values vary by theme, including color, +spacing and typography. That is the basis for role ownership here, not a proposal +to adopt Carbon's visual style or component package. + +[Adobe Spectrum's design tokens](https://spectrum.adobe.com/page/design-tokens/) +also separate reusable values and their semantic use. We apply that separation +without adding an unused catalog of hundreds of tokens. + +[DTCG 2025.10](https://www.designtokens.org/tr/2025.10/format/) standardizes a token +interchange format, including types and aliases; it is a Community Group report, +not a W3C Recommendation or a prescribed application architecture. Our small +TypeScript source is **not claimed to be DTCG-format JSON**. It fits the current +single web-codebase build. If design-tool/native exports become real requirements, +migrate the canonical source to DTCG with generated consumers rather than maintain +two editable copies. No new dependency or token service is needed now. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 93b3c666..0b0f799f 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -426,14 +426,14 @@ Use the existing abstraction before creating another version of the same behavio | [`backend/src/services/ai/canonicalTutorContext.ts`](../backend/src/services/ai/canonicalTutorContext.ts) | Server-authoritative guided tutor context | | [`backend/src/db/aiReservations.ts`](../backend/src/db/aiReservations.ts) | Atomic platform AI admission and settlement | -Frontend color and surface styling uses semantic Tailwind tokens (`bg`, `panel`, `elevated`, `ink`, `muted`, `border`, `accent`, `success`, `warn`, `danger`, `violet`) backed by CSS variables in [`frontend/src/index.css`](../frontend/src/index.css). Do not introduce raw palette shades into product components; semantic tokens preserve contrast across light and dark themes. +Follow the [design system](DESIGN_SYSTEM.md) for ownership and change rules. Public brand values live in [`frontend/src/design-system/tokens.ts`](../frontend/src/design-system/tokens.ts); both SPA first paint and generated static documents consume that source. Workspace styling retains semantic Tailwind tokens (`bg`, `panel`, `elevated`, `ink`, `muted`, `border`, `accent`, `success`, `warn`, `danger`, `violet`) backed by [`frontend/src/index.css`](../frontend/src/index.css). Do not introduce raw palette shades into product components; semantic tokens preserve contrast across light and dark themes. ## CI and release gates The repository's workflow files are the source of truth: - [CI](../.github/workflows/ci.yml) runs release-contract checks, harness doctor, secret scanning, cross-platform builds/tests, content and solution gates, share-function tests, performance budgets, and shell/PowerShell validation. -- [E2E](../.github/workflows/e2e.yml) runs exhaustive Chromium in sixteen measured shards with two workers each, an advisory metadata-owned critical shadow, and focused Firefox/WebKit journeys. The tracked [capacity record](../.github/e2e-shard-capacity.json) forces a controlled rebenchmark after one shard-equivalent of suite growth or shrinkage; it never authorizes selecting tests away. The benchmark evaluates the practical range through the account-concurrency ceiling and adopts more shards only when every shard passes and the modeled retry-free Playwright critical path improves by at least 20 seconds and 5%. Queue-inclusive wall time and Docker/setup overhead remain reported operational evidence, but one hosted-runner startup outlier does not choose the topology. +- [E2E](../.github/workflows/e2e.yml) runs exhaustive Chromium in twelve measured shards with two workers each, an advisory two-shard critical shadow, and focused Firefox/WebKit journeys. These sixteen concurrent database-backed stacks are the proven reliable limit; the tracked [capacity record](../.github/e2e-shard-capacity.json) reserves the four support stacks and fails closed if the workflow exceeds that limit. It also forces a controlled rebenchmark after one Chromium-shard equivalent of suite growth or shrinkage and never authorizes selecting tests away. - [Security](../.github/workflows/security.yml) runs isolated execution and abuse scenarios on relevant pull requests, on schedule, and as a release-callable gate. - [Production release](../.github/workflows/release.yml) builds immutable candidate artifacts, invokes the validation workflows against those artifacts, verifies migration state, then promotes the VM and Static Web App surfaces. @@ -473,6 +473,33 @@ must use a reviewed forward compensating migration. | Node behavior differs inside a harness composite | Use a non-login shell, check `node --version`, and keep package `--cwd` explicit. | | ACI does not activate locally | Expected unless the flag and complete Azure target configuration are present; the factory falls back to local-only mode. | +## Local phone access + +- [ ] Add authenticated device pairing/revocation and encrypted transport to the + machine-local development gateway. Reserved-IP filtering is not device auth. + Keep databases, Docker controls and unrelated internal services private unless + separately approved. This is a local developer-tooling task, not production auth. +- Owner-approved preview access uses a machine-local gateway restricted to + the phone's reserved LAN source IP; app authentication remains unchanged. A DHCP + reservation is not cryptographic device authentication. Do not widen the rule to + the subnet, publish it to the internet, or commit machine addresses/configuration. + Phone verification remains required; a successful request from the Mac is not proof + of successful phone access. +- On the owner's Mac, `~/.local/bin/phone-dev list` shows registered projects; + `phone-dev add NAME PORT [TARGET_PORT]` registers a development website/API once, + and `phone-dev remove NAME` revokes its forwarding. Use the full executable path + if it is not on your PATH. Only registered loopback targets are exposed, not every + listening port. New projects should bind to loopback; an independently wildcard- + bound server is not protected by this gateway. +- The per-user `local.development.phone-access` LaunchAgent starts at login, + restarts on exit, and reloads registrations/retries network binding automatically. + Installation, configuration, tests and recovery instructions live outside the repo + at `~/Library/Application Support/Phone Dev Access/README.md`. Do not revive the + old temporary `.agent-harness/local-phone-preview.mjs` alongside this service. + The Mac must be awake and the project (including any SSH tunnel) running. This + service does not start projects or change sleep settings. HTTPS-only browser + features and OAuth may still require explicit development origin configuration. + ## Manual QA entry points - Product: [http://localhost:5173](http://localhost:5173) diff --git a/docs/PUBLIC_BRAND_CONTINUITY_DESIGN.md b/docs/PUBLIC_BRAND_CONTINUITY_DESIGN.md new file mode 100644 index 00000000..186886e1 --- /dev/null +++ b/docs/PUBLIC_BRAND_CONTINUITY_DESIGN.md @@ -0,0 +1,354 @@ +# Public brand continuity — design reset + +Status: **phone-reviewed auth direction approved for rollout; local implementation in progress**. +Approval covers the described direction, not acceptance of an unseen rendered result. +The prior local treatment is rejected; its functional fixes and tests remain useful, +but are not evidence that its visual design is acceptable. + +### Latest moving-prototype feedback + +Mehul has now reviewed login/signup on his phone: they look better, but navbar +sizes and other elements jump between routes. He explicitly requested correcting +those seams and extending this direction to the remaining public pages, including +privacy. Implementation and supporting checks may proceed while the Mac is locked; +final actual-browser verification waits for unlock. This advances the rollout +approval, not the release/quality gates or approval of unrelated design changes. + +Earlier feedback rejected the first motion treatment (historical prototype): +public pages must feel like different parts of one living body, not a static glyph +shape using matching colors. Reduced-motion emulation was briefly active during +recovery-page QA and has been cleared; that test state does not explain away the +design feedback. In that prototype's normal motion, the auth scene fixed its +assembly and scale, with only the shared small yaw and pointer displacement. +It did not deliver the planned arrival, redistribution or route persistence; +the current connected-world implementation is described below. + +Keep the improved surface treatment and centered forms. Next evaluate connected +field behavior, not additional standalone sculptures: material carries momentum +between public SPA routes and redistributes into the available space around each +task. Avoid a permanently outlined bracket frame, arbitrary timed shape carousel, +or moving form controls. Preserve the homepage's established pacing and physics; +prove the effect through homepage → login → signup/recovery and back, as a moving +journey, before accepting this as the shared brand contract. + +**Current local experiment:** `PublicMotionWorld` now owns the renderer above +route Suspense boundaries; pages register presentation-only scene descriptors. +The auth experiment uses the living-clearing alternative: seeded glyphs flow +through a broad stream on the same retained clock, instead of holding a stretched +code contour. Target interpolation shares the CPU pointer projection and shader +geometry. The phone review authorizes extension; it is not a released design. +The original homepage chapter solver and pointer spring remain the reference. + +**Latest local surface refinement (UX-212):** Mehul requested improving the +opaque “From why to I see it” area and centralizing design decisions. The local +study uses letterform shadows plus a small elliptical fade for that brief display +heading, not rectangular backing. Dense explanation copy keeps local protection; +the actual demo controls/code/tutor body remain solid. Desktop/phone supporting +captures prompted the added fade after the unprotected phone heading looked busy. +Mehul explicitly approved this current design after viewing the local study-demo +page on his phone on September 8. This does not authorize making dense prose +transparent or substitute for final actual-browser readability/recovery checks. +The [design system](DESIGN_SYSTEM.md) now owns shared token and material conventions. + +## Decision in plain language + +The homepage is the brand. Moving to another public page should feel like entering +another part of the same living environment, not leaving it for a dark form template. +Keep its near-black canvas, luminous code-symbol material, depth, spectral variation, +inertial pointer response, deliberate pacing, typography and action language. +Change the composition to support the page's task—not the material or physics. + +**Recommended first study: the surface-and-field relationship on the homepage +and centered login together.** Remove unnecessary background-blocking wrappers, +not every useful surface. Preserve the homepage's story and motion. Then evaluate +login's proposed open code contours within that same material system—not a fixed +bracket ornament. Mehul approves the actual moving desktop and phone experience +before it becomes the shared design contract. + +## What failed, and what the evidence proves + +| Evidence | Consequence | +| --- | --- | +| `AuthShell` chooses `ambient`; `ParticleField` forces `spread=1`, `opacity=.35` | The foreground never gathers into a meaningful composition. | +| Auth CSS multiplies canvas opacity by `.5` | Foreground maximum alpha is `.175` before texture/brightness attenuation. | +| Desktop hides a 500px central strip; phone hides everything except 20px edges | Little visible space remains for interaction. | +| Public foreground count is 240 versus homepage 420; `createScatter` puts foreground at extreme edges | The visual weight and distribution differ before any masking. More particles alone cannot fix this. | +| Page components own their renderer; changing route replaces those components | Clock, spring displacement and canvas are discarded. Sharing component code is not continuous motion. | +| Actual in-app homepage → Sign in showed a full-screen skeleton before the new scene | Correct background color alone does not preserve the experience. | + +Source anchors: `frontend/src/features/marketing/study/ParticleField.tsx:419`, +`frontend/src/features/marketing/study/geometry.ts:130`, +`frontend/src/features/marketing/public/public-page.css:147`, +`frontend/src/features/marketing/public/PublicPage.tsx:98`, +`frontend/src/auth/AuthShell.tsx:17`, and `frontend/src/PublicApp.tsx:47`. +Diagnosis refers to the uncommitted `dev/public-theme-continuity` worktree based +on `690c767`; line anchors may move during the design reset. +Actual browser comparison: 1159×863 desktop, approved homepage versus rejected login. +Machine-local evidence: +`.agent-harness/browser-evidence/5c7d75ed-22d3-41db-bfc3-857daf5b8b6f/login-rejected-treatment-desktop.png`. +That gitignored evidence is not portable with a clone; capture fresh proof when +reviewing the prototype and attach appropriate public-safe evidence to its PR. +Numeric causes are source-derived; motion quality still needs moving-browser proof. + +## Surface review: what should cover the field? + +Mehul's follow-up reopens **surface treatment** on the homepage as well as public +pages for review. It does not approve changing the homepage's layout, narrative, +typography, glyph identity, pointer physics or scroll choreography. The three +returning reviewers (product, motion/UX and frontend feasibility) agree that the +previous proposal did not distinguish protective surfaces precisely enough. + +| Current surface / source | Proposed treatment | Reason | +| --- | --- | --- | +| Homepage hero, chapter and closing copy; footer (`study.css`, `.study-solid`) | Transparent layout; localized protection for actual text/link clusters | Whole wrappers hide field in otherwise empty space. | +| Homepage artwork caption and navigation backing | Protect caption text and navigation controls; evaluate removing excess full-width backing | Avoid a visible strip cutting through the shared world; maintain legible navigation. | +| Homepage walkthrough (`.study-demo-surface`) | Keep a stable, explicit product surface | Code, output and tutor conversation should read as one usable product demonstration. | +| Login/signup/reset intro, form wrapper and full-height canvas mask (`public-page.css`) | Remove broad masks; centered content with local text/control protection | The environment should surround the form, not survive only in edge slivers. Inputs stay solid. | +| Legal/support/comparison headings and broad reading wrappers | Transparent section layout; paragraph/heading-cluster protection with open section intervals | Keep sustained reading safe without turning the whole page into an opaque slab. | +| Discovery hero/main shells (`discovery-theme.css`) | Remove broad shell backing; preserve authored grouping | Expose real space between sections and cards without adding artificial whitespace. | +| Discovery course cards, method articles, notes, code and tables | Retain useful local grouping; distinguish clickable cards from noninteractive articles | Shared `.course-card` styling does not mean every item is interactive. Do not add false hover affordances. | +| Share outer artifact/recovery wrappers (`SharePage.tsx`) | Remove redundant outer backing where local protection suffices; retain actual code/artifact panel | Avoid nested slabs. Preserve protection throughout the existing opacity/scale reveal, not just at rest. | +| Errors, loading and footer states | Same localized material rules; status and actions remain immediately readable | No separate visual language for exceptional states. | + +### Material contract + +1. **World:** one near-black base and a connected, living glyph field. +2. **Layout:** ordinary alignment/spacing wrappers are transparent, not cards. +3. **Reading:** small protected text/control clusters have an opaque core and a + short, feathered outer transition where needed. Do not fade directly under + letters, create per-line stripes, or replace visible slabs with equally large + invisible rectangular holes. +4. **Objects:** inputs, buttons, active states, real code/output, tables and the + demo retain stable surfaces. Clickable collections keep recognizable boundaries + and keyboard focus. Surface reduction must not remove information hierarchy. + +Compose particles in connected available space **first**; protection is a safety +net for motion near content, not a substitute for composition. Do not maximize +transparency as a metric. At dense text, small screens, zoom or an open keyboard, +readability wins; do not force extra scrolling merely to exhibit more particles. + +**Rejected defaults:** blanket transparency (moving glyphs behind text), frosted +glass everywhere (the same rectangles with moving blur), giant central exclusion +zones (the rejected edge-only field), and a new particle collision/obstacle solver. +Subtle local backing is the first prototype, not an assertion that its appearance +has already passed review. + +### Evidence and acceptance + +In-app inspection at 1280×720 confirmed opaque homepage copy/caption wrappers and +the purposeful walkthrough surface; the actual **02 Ask** demo was exercised. +Comparison and discovery were also inspected, including navigation to the real +course-card collection. These are current-state observations, not validation of +the proposed materials. Source review covers the remaining wrappers listed above. +The homepage hero-copy box measured 640×470 and its caption backing 700×17 in this +viewport: paint hides the complete rectangles, not only their text. Shared base +color can conceal the rectangle's outline without restoring the field behind it. + +The first approval comparison must show the unchanged homepage beside the local +proposal, plus centered login: idle, scroll, pointer circles across edges and soft +release. Check visible continuity **and** text contrast; no rectangular cutouts, +halos, shimmer, clipped focus or glyphs leaking through code. Include phone, +200%/400% zoom, wrapped/long text, expanded errors, keyboard, reduced motion, +slow graphics and failure fallback. Verify the share reveal separately before +propagating its treatment. Screenshots alone cannot establish moving readability. + +## Shared design rules + +- One visual world, one maintained renderer/interaction model, no new animation + engine, paid service, AI-generated asset or reference-site code copied into the repo. +- Recognizable foreground glyphs plus persistent distant particles. Preserve size, + depth and color variation; do not reduce the whole scene to faint edge dust. +- Protect actual text, controls, focus outlines and expanded errors using the + material contract above. Measured composition clearances must not become broad + invisible holes or a full-height center strip. +- Do not solve every page with a floating illustration or identical loop. A shape + must explain the composition or the learning context, not merely fill space. +- Forms, reading and navigation work immediately. Motion never gates entry, fakes + progress, moves a control away from the pointer or reacts to credentials. +- Touch scrolling and text selection stay native. No scroll-jacking, trapped drag + gestures, forced introductory delays or required decorative interaction. +- Existing public copy, claims, concessions, CTAs, auth handlers, return targets, + authorization, lesson content, metadata and internal workspaces remain unchanged. + +## Login study: options and recommendation + +| Option | Composition | Decision | +| --- | --- | --- | +| Open code contours | Loose `< >` strands belong to one surrounding field; the form is its center | Recommended study: strongest connection to the existing homepage motif. | +| Living clearing | A distributed field flows around a form-sized clear region, without a stable contour | Fallback if contours feel decorative or overbearing; must not repeat the rejected dust treatment. | +| Separate chapter moments | Above/below-form shapes respond to scrolling | Do not lead with this: desktop login should not gain unnecessary scrolling or another hero section. | + +The recommended study must **not** look like two side illustrations or a rigid +box enclosing a form. Use the homepage's code topology as loose connected material: +wide surrounding space, visible upper/lower connections, dimensional depth and +responsive local movement. The form stays centered at its current usable width. +If the available viewport cannot support that composition, recompose it—do not +crop it into slivers or shrink glyphs into illegibility. + +### Behavior storyboard + +1. **Homepage → login:** retain the living background while content changes. Glyphs + settle toward the login composition; no empty-canvas flash or full-screen scene + replacement. The form does not wait for the particles to finish. +2. **Direct load:** correct canvas and an intentional static starting composition + appear before the graphics download. First animated frame joins that composition, + avoiding an unrelated scatter → sudden shape jump. Graphics failure retains it. +3. **Idle:** preserve the homepage's gentle dimensional motion and distant field. + Do not introduce an automatic 20-second shape carousel or periodic pulse just to + prove something is animated. Judge presence at actual viewing size. +4. **Pointer:** curved strokes carry nearby glyphs into a visible swirl; release + retains momentum and returns softly. The surrounding field—not a tiny invisible + hit area—is responsive. Entering a form stops new forces, not existing momentum. +5. **Typing, validation, submission:** inputs remain stable and legible. No password + influence on the scene, validation checkmark sculpture, red-particle error storm, + focus-triggered global dimming, or login-success choreography added to the flow. +6. **Signup/reset navigation:** preserve material, clock and motion; update scene + clearances when content grows. History, return targets and focus retain their + intended behavior. Background persistence must not preserve credentials across + routes or defeat the forms' existing cleanup. +7. **Phone/virtual keyboard:** use available upper/lower and surrounding space with + readable glyph sizes. Keep native scrolling and inputs visible; keyboard resize + must not restart/recenter the scene violently or cause horizontal overflow. +8. **Reduced motion:** equally intentional static composition, not a blank page. + Preserve the approved preference behavior. A new pause button is **not approved**. + Review the applicable motion-accessibility requirements separately; never claim + reduced-motion handling alone proves all WCAG motion criteria are satisfied. + +## Other page families, after login approval + +| Family | Purpose and composition | Must remain primary | +| --- | --- | --- | +| Signup/reset/callback | Same welcoming environment; composition follows changing form/recovery bounds | Current authentication, errors and recovery actions | +| Privacy/terms | Field continues around headings and section intervals; meaningful reading contours can gather between sections | Legal text, anchor navigation and sustained readability | +| Support | Same environment and recognizable orientation; no service-status-like animation | Existing help and contact paths | +| Why not ChatGPT | Continuous material accompanies argument and section transitions; no extra product demo | Balanced comparison and existing concessions | +| Catalog/course/lesson discovery | Shared glyph material connects overview, course structure and authored reading | Complete static content, code/tables, canonical metadata and links | +| Public share | Field supports the real learner artifact; coordinate with its existing reveal rather than layering competing shows | Actual code, accomplishment and share controls | +| 404/unavailable | World remains present; composition feels settled and recoverable, never broken or punitive | Clear error meaning and immediate navigation | + +The latest phone review authorizes extending the same system to these families. +Validate representative reading, discovery and share pages before publication; +any different layout model or new motion language still requires approval. + +## Engineering strategy and constraints + +**App-rendered routes:** use one lazy, presentation-only motion host above the +changing PublicApp/FullApp and content-Suspense boundary. Pages register a scene +descriptor (composition targets, content-cluster bounds and readiness), +not their own WebGL renderer. Keep GPU resources, seeded identities, elapsed time +and pointer springs stable; blend updated targets without recreating the engine. +Resolve and release route anchors on navigation, not just window resize. Late route +registration must not overwrite a newer scene. Unregister on workspace entry and +dispose/pause resources without decorating or changing internal pages. + +Extract the existing homepage scene solver as the unchanged story descriptor. +Maintain its choreography, demo controls, density rules and pointer response as the +golden reference. Only the separately approved surface treatment may change; +renderer extraction is not permission for a broader homepage redesign. React's lifetime model explains why +the host must survive route replacement: [preserving state](https://react.dev/learn/preserving-and-resetting-state). + +**Paint and protection:** make ownership explicit: base → field → transparent route +layout → local protective backing → text/controls/focus. Hoisting the canvas behind +today's opaque route roots would hide it. Use ordinary CSS backing/pseudo-elements +first; they follow wrapping, errors and transforms without per-frame layout reads. +They must not intercept input or obscure focus. Keep share code protected during +its existing `.55` opacity entry and parent scaling, using independent backing if +necessary. A stationary final-state mask is not proof for an animated artifact. + +Keep placement, visibility and interaction physics separate. Cache scene bounds +on registration, resize, font readiness and content resize; never read DOM geometry +per particle/frame. Do not add obstacle forces or shader-only position avoidance: +CPU pointer projection and shader placement must remain consistent. Only if the +CSS prototype fails, evaluate visibility-only shader fading after final projection, +accounting for full glyph/glow size. That is a fallback experiment with extra +scroll/DPR/zoom risk, not a second simultaneous implementation. + +**Static discovery:** its normal links perform document navigation. Preserve that +architecture and no-JavaScript content; reuse scene definitions and a deterministic +first-paint composition with matched arrival. Do not promise cross-document GPU +state continuity, hijack links, or convert discovery to an SPA for decoration. + +**Budget and recovery:** retain bounded particle pools, area-based density, DPR cap +1.5, hidden-tab suspension, lazy graphics and context-loss recovery. Measure frame +times during pointer bursts and navigation before raising counts. Preserve current +production asset budgets and public-entry avoidance of eager editor/admin downloads. +No exact count or timing is a design success criterion by itself. + +## Review decisions and open questions + +- Motion/UX proposed open contours, a living clearing and chapter moments; product + agreed the world must surround the task, not compete with it. Frontend review + judged reuse feasible; persistent-host extraction, first-paint matching and + keyboard recomposition are prototype risks, not demonstrated results. +- Do **not** adopt a periodic 20-second loop suggested during review; preserve the + approved interaction language first. Do **not** introduce the suggested pause + control without Mehul's approval. +- Product review correctly separates perceptual continuity from literal GPU-state + persistence. Persist the SPA host where useful; use coherent new-document arrival + for static discovery. Do not expand routing scope to chase an illusion. +- Generic skill-generated font/palette/horizontal-scroll recommendations were + rejected: the approved homepage already supplies the brand. Skills inform the + design-review and accessibility checklist, not a replacement aesthetic. +- Motion accessibility requires explicit review: W3C distinguishes + [automatic movement](https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html) + from [interaction-triggered animation](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html). + Do not cite another site's missing control as compliance evidence. Resolve any + needed user-facing policy change before implementation/shipping; no silent + motion restriction or new control. + +**D1 — motion policy (release design approved; conformance not established):** this prototype +retains the existing automatic, indefinite low-speed drift and intentional pointer/ +keyboard response; it adds no periodic shape carousel, delay, or new motion control. +Reduced motion remains static. W3C 2.2.2 covers automatic motion over five seconds +alongside other content, so absence of a pause control is not evidence of compliance. +The owner approved retaining the current design after the phone preview was +restored and the pending motion decision was surfaced. No new control or motion +restriction is introduced. This is product approval, not an accessibility ruling +or permission to make an unsupported WCAG claim. + +September 8 standards follow-up: the [normative definition of mechanism](https://www.w3.org/TR/WCAG22/#dfn-mechanism) +allows platform/user-agent mechanisms, so a visible in-page pause button is not +inherently required. However, W3C's [C39 technique](https://www.w3.org/WAI/WCAG22/Techniques/css/C39) +is expressly sufficient for interaction-triggered motion (2.3.3), not a blanket +determination for automatic animation (2.2.2). The working group's exact question +about OS Reduce Motion and 2.2.2 remains [open in issue 4319](https://github.com/w3c/wcag/issues/4319). +An open discussion is evidence of uncertainty, not a ruling that this implementation +passes or fails. Our live preference-change checks prove that the artwork stops +and content remains usable; they do not settle that interpretation. Further generic +searching is unlikely to resolve this gate. Preserve the approved no-new-button +design; obtain an explicit release disposition acknowledging the limitation, or +approval for a different mechanism. Do not change the Mac's system preferences +or substitute another site's design as conformance evidence. + +For subsequent proposed behaviors, the implementing agent records whether each proposed +behavior is automatic or deliberately activated, its duration, preference response +and applicable accessibility criterion. Review this before implementing the motion +study. Mehul approves any resulting visible control or changed motion policy before +it is applied. The current no-new-pause-button direction is not permission to claim +unproven compliance; an unresolved conflict must be surfaced, not silently worked +around. This decision belongs here rather than becoming a fabricated confirmed bug. + +## Approval and delivery gates + +- [x] Mehul approves the revised material contract and study direction ("Approved"). +- [x] D1 motion-policy classification reviewed for the local prototype; no policy change. +- [x] Owner approved current release design; preserve D1 limitation without claiming conformance. +- [x] Local direction approved for extension after Mehul's phone login/signup review: wrapper reduction, + protected text/controls and solid demo, with unchanged homepage story/physics. + Navigation seam correction is explicitly required before readiness. +- [x] Complete moving login study and persistent-host work; desktop/phone evidence is in the delivery ledger. +- [x] Mehul reviewed login/signup in his phone browser and authorized extension; + final moving-browser regression verification remains a separate gate. +- [x] Extend the agreed composition to signup/reset/callback; test real input, + keyboard/focus, expanded errors, interruption/recovery, browser history, + reduced motion, graphics failure and phone widths. Owner phone acceptance + supplements emulation; this is not exhaustive device/virtual-keyboard proof. +- [x] Verify the authorized reading/discovery/share extension in the actual local browser. +- [x] Complete scoped public-route and adjacent-workspace local checks; production verification remains separate. +- [x] Final source/deterministic/browser/harness gates passed for implementation `ee325f3`. +- [ ] Separate PR: brief before-to-after journeys, green CI **and** clean Codex + review on final head, then authorized merge, deployment and production proof. + +Functional checks are necessary but cannot override a failed visual review. Keep +the existing [delivery checklist](PUBLIC_THEME_CONTINUITY.md) as the single finding +ledger; this document owns the proposed design reset, not duplicated fix status. diff --git a/docs/PUBLIC_THEME_CONTINUITY.md b/docs/PUBLIC_THEME_CONTINUITY.md new file mode 100644 index 00000000..c058475c --- /dev/null +++ b/docs/PUBLIC_THEME_CONTINUITY.md @@ -0,0 +1,235 @@ +# Public theme continuity + +**September 12: design approved; implementation and local browser verification complete.** +[PR #51](https://github.com/msrivas-7/CodeTutor-AI/pull/51), branch +`dev/public-theme-continuity`, implementation `ee325f3`, based on homepage release +`690c767`. Not merged or production-deployed. + +**31 of 31 findings are locally verified; owner approved the phone experience.** “Local” +never means merged or production-verified. This checklist is the status source; +[the design plan](PUBLIC_BRAND_CONTINUITY_DESIGN.md) records decisions and +[the design system](DESIGN_SYSTEM.md) owns shared tokens/components. + +## Approved scope + +Continue the homepage's near-black, living code-glyph world across centered auth, +legal/support, comparison, generated discovery, shares, loading and public errors. +One renderer; open composition around protected text; solid forms/code/controls; +aligned navigation. No detached auth sculpture, static replacement theme, broad +opaque slabs, new pause button or mobile motion disable. OS Reduce Motion gets +a static composition; graphics failure must not block tasks. + +Mehul approved phone login/signup for extension, the homepage demo materials, +a four-to-five-second share reveal cap, and the phone spacing refinement. Further +material design changes require approval. Auth/access rules, legal meaning, +authored lessons, metadata and signed-in workspaces stay intact. The anonymous +editor remains a workspace, without marketing decoration. + +## Current verification + +- **UX-225 locally closed:** two CI runs missed the completion dialog's first + Escape. A regression reproduced the unhandled DOM-commit-boundary event before + passive effects installed keyboard ownership. The shared modal now establishes + keyboard, focus and background blocking before paint; focused regression and + actual-browser completion/recovery checks pass. Fresh final-head review and CI + remain required. The historical 96-case result below predates this repair. +- **UX-226 locally closed:** direct login, signup, recovery, callback, public + share and anonymous-lesson entries now paint through the lightweight public + route tree. The full route tree retains those routes for workspace-originated + navigation. Real-browser blocking of the full App module still produced a + working login; pre-app paint, protected redirect, share recovery, anonymous + handoff, 404, desktop, 390px and reduced-motion checks pass. The complete + 58-case Chromium/WebKit public-theme matrix passes without retries. +- **UX-227/228 locally closed:** final-head review found that a quick homepage + transition could retain the five-second deferred-auth timer, and that the + public bootstrap did not mirror React Router's case/trailing-slash matching. + Auth-dependent navigation now cancels the delay immediately, while a shared + normalized pathname classifies both exact and prefixed public routes. Actual + browser homepage → login/trial and direct `/Signup/` checks pass; the focused + 25-case bootstrap contract and frontend typecheck pass. +- **UX-229 locally closed:** the lightweight anonymous lesson omitted the full app's + global shortcut owner, so `?` and Cmd/Ctrl+K stopped working after the + cinematic. The anonymous product route now mounts that same owner without + loading it on acquisition/trust pages. In the rebuilt local in-app browser, + `?` opened and Escape closed the shortcuts dialog, Cmd+K focused the Tutor, + typed question marks stayed in the composer, and Privacy remained shortcut-free. + The retry-disabled Chromium regression and 39 focused frontend contracts pass. +- **PR gate:** Full cross-platform CI is green. Capacity run `34688798759` + proved 16 isolated database stacks reliable and 20 unreliable. Exact-head E2E + run `34690166145` then exposed that the normal workflow also added Firefox, + WebKit and two critical stacks to its 16 Chromium shards, recreating the same + 20-stack overload. The workflow now reserves those four support stacks and + runs 12 exhaustive Chromium shards, keeping all coverage inside the proven + 16-stack limit; exact-head E2E confirmation is pending. + A final review found that encoded public paths selected the full application + tree; the shared normalizer and exact browser contract now match the guarded + pre-paint decoder. Exact-head review/E2E, merge, deployment and focused + production browser verification remain pending. +- **Hosted preview:** actual browser catalog → Python Intermediate → Mini ORM + capstone, narrow-screen lists/code and invalid discovery → catalog recovery + pass. Invalid paths return HTTP 404. This is preview evidence, not production. +- **Latest product source:** 663 frontend tests, production build/typecheck and + unchanged asset budgets pass. The final 98-case public/marketing/share matrix + passed 97 cases directly; the one local Supabase-outage case passed on its exact + zero-retry rerun after the stack recovered. These supplement real browsing. + September 8 source snapshot: 44 changed/untracked frontend and E2E files; + SHA-256 `2e8c21a6458aa40316121c86fd9642d4ae9a7a6a49eae8e8af3c4aab1f7a745a` + over sorted path-NUL/content-NUL pairs. This identifies the reviewed local + source, not a commit or the harness's final staged fingerprint. +- **Actual desktop journey:** homepage → Privacy → Terms → Support → comparison + → catalog/course → Mini ORM capstone → Hello World trial → Back. Reading, + focus, scroll restoration and public/workspace theme separation checked. +- **Actual phone-width journey:** auth modes/recovery/callback, legal reading, + support keyboard focus, comparison → trial → Back, catalog, valid/invalid share, + blocked lookup → keyboard Retry → recovered share. Dense paragraphs, lists, + code and controls inspected while the glyph field moved. +- **Cold paint/history:** held app JavaScript leaves a near-black login; reload + recovers. Fresh auth/legal Back/Forward and static discovery/home/trial returns + retain theme tokens. A documented dev account successfully signed in; saved + Light survives public Privacy → Start → reload, without changing preferences + or progress. One earlier anomalous history entry remains unexplained (UX-199). +- **Motion/resilience:** actual short/fast/reversed scrolling, Read/Ask/Check, + keyboard artwork input, live Reduce Motion changes and blocked-renderer recovery + checked. Scoped checks also cover 320px, tablet, 4K, no-JavaScript discovery, + route-loading focus and long-share reveal. +- **Scope preservation:** legal text/section-title AST comparison against base + passes. No backend, migration, authored-course or auth-handler changes. + Final boundary review additionally confirms entry-document metadata is unchanged + and login/reset/signup function bodies differ only in CSS classes (AST-backed + comparison recorded in the parent harness). Shared route/loading/world code + was inspected separately; this is not a production-hosting claim. + Static production hosting still requires deployed verification. +- **Resumed final browser pass:** access returned after manual unlock. Signup's + Privacy link opens a readable separate tab and leaves signup intact; the test + tab was closed. Held-script signup first paint stays near-black. Fresh + Back/Forward retains tokens/bootstrap and restores a measured Privacy reading + position of 863px. Phone Terms remains readable during motion and live Reduce + Motion; Support keyboard focus and blocked graphics → usable homepage Ask → + Reload recovery pass. Viewport/network/media overrides were restored. + +No email or personal messaging app was opened. Browser wheel/viewport emulation +does not prove physical iPhone swipe momentum, virtual keyboard or feel. + +## Finding checklist + +Checked means the bounded finding has local browser evidence, not whole-release acceptance. + +| Status | Finding | Change / remaining work | +| ----------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [x] Local | UX-198 | Center auth; remove purposeless split-layout sculpture. | +| [x] Local | UX-199 | Initial document/loading/settled colors aligned. Fresh cold loads, saved-Light and resumed Back/Forward journeys pass. The earlier missing-token/bootstrap history anomaly has not reproduced on the final source; retain it as an unexplained historical observation, not a claimed root-cause fix or browser defect. Recheck deployed cold load/history. | +| [x] Local | UX-200 | Main-content focus appears on the heading, unobscured by child surfaces. | +| [x] Local | UX-201 | Repair trust dividers and protect contact-link readability. | +| [x] Local | UX-202 | New public navigation starts at destination top; explicit anchors and Back retain their own behavior. | +| [x] Local | UX-203 | Router selects trust content; `/privacy/` no longer becomes Support. | +| [x] Local | UX-204 | Malformed shares show unavailable; actual lookup failures retain working Retry. | +| [x] Local | UX-205 | Themed static discovery 404 with real error status and no-JavaScript recovery; production host unverified. | +| [x] Local | UX-206 | Living world, readable materials and persistent SPA renderer implemented. Connected desktop/phone family reading/navigation, auth and share recovery, workspace isolation, and resumed graphics interruption/recovery pass. Final harness phase/production gates remain separate. | +| [x] Local | UX-207 | Disabled auth controls use opaque state colors instead of letting glyphs bleed through opacity. | +| [x] Local | UX-208 | Bound auth foreground at 4K; preserve full-screen ambient density. | +| [x] Local | UX-209 | Restore homepage graphics-download notice and Reload recovery after renderer extraction. | +| [x] Local | UX-210 | Align header/wordmark/action geometry; auth heading no longer recenters with form height. | +| [x] Local | UX-211 | Live Reduce Motion settles share code/count/timeline without replaying on restoration. | +| [x] Local | UX-212 | Open homepage explanation area; keep demo functional surfaces solid. Owner approved. | +| [x] Local | UX-213 | Support action retains readable normal/hover/focus colors; no mail app used. | +| [x] Local | UX-214 | Lazy public loading retains escape navigation and focus through nested fallbacks. | +| [x] Local | UX-215 | Long lesson inline code wraps without phone-wide document overflow. | +| [x] Local | UX-216 | Omit empty concept panels; retain populated ones. | +| [x] Local | UX-217 | Mixed text/code objective chips wrap as one text flow. | +| [x] Local | UX-218 | Direct `/#study-demo` arrives after lazy loading; user interruption cancels pending handoff. | +| [x] Local | UX-219 | Homepage loading handoff retains skip/home/main focus without unwanted page movement. | +| [x] Local | UX-220 | Mobile homepage typography agrees across direct entry, auth return, reload and Back. | +| [x] Local | UX-221 | Public share comments use readable faint-text role (at least 4.5:1); image-export palette unchanged. | +| [x] Local | UX-222 | Auth supporting copy shares 14px/21px recipe; workspace signup unchanged. Intercepted recovery responses prove presentation, not delivery. | +| [x] Local | UX-223 | Long reveal ends within five seconds of typing start and reserves line/footer space; short cadence retained. Network loading/later celebration excluded. | +| [x] Local + owner | UX-224 | More phone formation space: hero departure interval at 390×844 increases from about 1px to 351px. Local adversarial scroll/recovery checks pass; after restored phone access Mehul confirmed it works and approved the experience. | +| [x] Closed | UX-225 | Completion dialog now owns Escape at first mount and closes safely before checkout gates; the browser evidence now confirms the first-commit close path, with modal-level regression checked and happy-path recovery preserved. | +| [x] Closed | UX-226 | Direct auth, recovery, callback, share and anonymous-lesson entries use the lightweight public bootstrap while retaining the same routes in the full app for later SPA navigation. Rebuilt real-browser and retry-disabled Chromium/WebKit proof pass. | +| [x] Closed | UX-227 | Auth-dependent navigation from a deferred public page cancels the five-second hydration delay immediately; homepage → login/trial are ready without the stale waiting state. | +| [x] Closed | UX-228 | Public bootstrap classification now matches React Router for case, trailing slashes and guarded percent decoding across exact, share and anonymous-lesson routes. | + +## Evidence map + +Screenshots and detailed chronological audits are machine-local, not included in +a fresh clone. Root: `.agent-harness/browser-evidence/`. + +| Session directory | Evidence | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `5c7d75ed-22d3-41db-bfc3-857daf5b8b6f/` | Parent audits; `UX199-*`, `UX204-*`, `UX205-*`, `UX206-final-*`, `UX210-final-*`, `UX212-*`, `UX214-final-*`, `UX215-*`–`UX219-*`. | +| Same parent, `connected-*.png` | Final desktop family/long-lesson journey and auth recovery. | +| Same parent, `dev-account-*.png` | Dev-account sign-in and saved-Light/public-dark/workspace-Light boundaries. | +| Same parent, `final-*.png` | Latest cold login, phone legal/support/comparison/trial, share failure/retry, callback/reset and static-history checks. | +| Same parent, `resumed-*.png` | Post-unlock signup privacy-tab, held-script first paint, history/863px reading restoration, phone Terms/preferences/Support focus and graphics recovery. | +| Parent finding audits | UX-199: `79a1d45e-a40e-458c-a867-cb5f07479852`; UX-206: `2a8377ee-db89-4806-9a3f-bb86f86c3ecf`. | +| Final local phase | `b293581c-f3e4-4a7f-a724-50f5daf20165`; staging-only fingerprint refresh `459bb440-e310-4631-8bea-f4d73cd38a85`. Parent harness finished and pre-commit live-browser gate passed. | +| `693b16bc-7909-40fc-b51d-b531f67fe84d/` | UX-213 action states. | +| `e2880cbd-d263-4e58-abb7-5cf88b8809d6/` | UX-220–222 typography/contrast/supporting-copy repairs. | +| `dd56a6ef-9a6c-4f6c-b414-2092b1522ef6/` | UX-223 reveal/interruption/recovery. Failed audit retained, incident resolved; passing audit `b56570ef-8eeb-48e6-9d83-d9b79bc31021`. | +| `e61236d8-3b68-4c1c-84a7-f704c6045827/` | UX-224 formation/dwell, reversal, keyboard, preferences and graphics recovery. Finding audit `cc2ea60a-4a6a-4502-ae3e-4b3de4e43d7a`. | +| `b3564266-122c-439e-92c9-2dd55e3c4e3e/` | Independent design review of 27 primary-agent captures and source; reviewers did not run separate browser sessions. | +| `825e4a74-2653-4b04-ae59-269a8bc6f90f/` | Final-head UX-225/226 browser replay: direct auth without App, pre-app paint, protected redirect, phone/reduced-motion recovery, share-to-anonymous handoff, completion Escape and focus restoration. | +| `a6c213ed-476c-4201-a428-ebe6b7ea02d4/` | UX-227/228 review follow-up: immediate homepage-to-auth/product hydration and normalized direct public entry. | + +Historical prototype captures do not establish acceptance of later edits. +Named final checks supersede them only for their stated scope. The anomalous +history capture is `UX199-restored-document-missing-theme.png`; do not discard it. +UX-220–224 finding evidence is also indexed in the parent harness session, with +original audit IDs, timestamps and fingerprints preserved in its notes. This is +evidence consolidation, not a new browser execution or physical-phone acceptance. + +## Design review disposition + +Product/brand, motion/UX and design-system reviewers agreed on one recognizable +family; keep the direction. Confirmed typography, share contrast and auth hierarchy +inconsistencies became UX-220–222. Heavy-text browsing found no through-letter +glyph collision; protect reading locally rather than broadly dimming the world. + +Optional, **not approved for this phase**: shorten desktop hero to expose CTA +earlier; link Lessons to the course anchor; reconcile method/demo wording; soften +comparison copy; quiet peripheral glow. Transparent pager/secondary-control +consistency merits inspection, not redesign based on an assumed defect. + +## Remaining release gates + +- [x] After manual unlock, inspect signup's new privacy tab, restore viewport and + finish the connected browser pass and UX-199/206 disposition. Retain relevant + keyboard, loading/error/recovery, moving readability, responsive, preference + and adjacent-workspace coverage. +- [x] Get physical-phone feedback for UX-224 through the registered local preview. + The temporary gateway stopped while localhost remained healthy. It has been + replaced with the owner-requested persistent, reserved-phone project gateway; + automatic process restart, allowed/denied peers, registration/removal and local + browser rendering pass. The service remains running on recheck. See + [local phone access](DEVELOPMENT.md#local-phone-access). This is not a production + deployment. Mehul subsequently confirmed: “Yeah it worked and I like it approved + from me.” This closes owner phone acceptance, not production verification. +- [x] Retain the approved [D1 motion policy](PUBLIC_BRAND_CONTINUITY_DESIGN.md#review-decisions-and-open-questions): + current design approved after the pending decisions were surfaced; OS Reduce + Motion works and no pause button is added. Accessibility conformance remains + unproven and must not be advertised as established by product approval. +- [x] Record final finding/whole-phase evidence; inspect the full diff, stage only + this phase, run final deterministic checks, doctor and harness finish on the + exact intended phase. Any subsequent product-source change invalidates the + affected evidence and requires revalidation. +- [x] Publish separate PR with before→after journey notes: PR #51. +- [ ] Require **green CI AND + clean Codex review** on final head: reviewer has completed its review with no + outstanding actionable findings, not merely no pending comment request. + Answer/resolve actionable threads and obtain a fresh review after fixes. +- [ ] Merge after those gates, verify deployment SHA and changed/adjacent + production browser journeys, then complete the goal. + +PR journey notes: independent page themes → shared living brand; old scroll +retained → new destination at top; stalled loading without escape → retained +navigation/focus; direct walkthrough at top → correct delayed anchor; malformed +share “connection failure” → unavailable; long reveal hides lines/moves footer → +capped stable reveal; phone shapes rush away → longer native formation space. +Direct logged-out entries waited on the authenticated application route tree → +auth, recovery, callback, share and anonymous-lesson entries load through the +lightweight public route tree while protected-entry navigation remains intact. +Auth/access rules are unchanged. + +Separate follow-up approved: automatically choose the shard count from trusted +runtime history, measured setup cost and safe concurrency bounds. Preserve the +full suite, stable fallback and a minimum meaningful gain; validate predictions +against real runs before enabling. This is not part of PR #51. diff --git a/e2e/README.md b/e2e/README.md index 6b7a964b..ef27e4cf 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -61,17 +61,17 @@ npm run test:real ## Fixtures -| Fixture | Purpose | -| --- | --- | -| `fixtures/boot.ts` | globalSetup; asserts frontend + backend reachable | -| `fixtures/profiles.ts` | `loadProfile(page, id)` + `seedApiKey(page)` + `clearAppStorage(page)` | -| `fixtures/monaco.ts` | `waitForMonacoReady` / `setMonacoValue` / `getMonacoValue` (uses `window.monaco` global) | -| `fixtures/aiMocks.ts` | SSE scenario frames for `/api/ai/ask/stream` — matches production `data: {...}\n\n` wire format | -| `fixtures/harnessResults.ts` | Canned `TestReport` payloads for `/api/execute/tests` | -| `fixtures/testMetadata.ts` | Required risk/owner/browser/device/quarantine metadata for the advisory critical lane | -| `fixtures/seeds/*.json` | Serialized `__dev__` profile localStorage seeds | -| `utils/selectors.ts` | Centralized Playwright locators (role + aria-label first) | -| `utils/assertions.ts` | Domain-level expects (`expectLessonComplete`, `expectStdoutContains`, …) | +| Fixture | Purpose | +| ---------------------------- | ----------------------------------------------------------------------------------------------- | +| `fixtures/boot.ts` | globalSetup; asserts frontend + backend reachable | +| `fixtures/profiles.ts` | `loadProfile(page, id)` + `seedApiKey(page)` + `clearAppStorage(page)` | +| `fixtures/monaco.ts` | `waitForMonacoReady` / `setMonacoValue` / `getMonacoValue` (uses `window.monaco` global) | +| `fixtures/aiMocks.ts` | SSE scenario frames for `/api/ai/ask/stream` — matches production `data: {...}\n\n` wire format | +| `fixtures/harnessResults.ts` | Canned `TestReport` payloads for `/api/execute/tests` | +| `fixtures/testMetadata.ts` | Required risk/owner/browser/device/quarantine metadata for the advisory critical lane | +| `fixtures/seeds/*.json` | Serialized `__dev__` profile localStorage seeds | +| `utils/selectors.ts` | Centralized Playwright locators (role + aria-label first) | +| `utils/assertions.ts` | Domain-level expects (`expectLessonComplete`, `expectStdoutContains`, …) | ## Conventions @@ -106,12 +106,16 @@ npm run test:real See `.github/workflows/e2e.yml`. The current PR model is: -- sixteen blocking Chromium shards for all 439 tests, selected by a same-commit, - zero-retry capacity benchmark with no regression-coverage reduction; +- twelve blocking Chromium shards for all 484 tests, plus four concurrent + Firefox, WebKit and critical support stacks, with no coverage reduction; - blocking Firefox and WebKit focused journeys; - one advisory, zero-retry Chromium critical lane (currently 41 tests in 15 files); - CI retries retain diagnostic traces, but `failOnFlakyTests` makes a flaky result fail its shard so a targeted rerun cannot erase the original signal; +- disposable-user provisioning retries only the Supabase SDK's explicit + `AuthRetryableFetchError`, whether thrown or returned in the SDK response, + with a four-attempt exponential equal-jitter bound; ordinary auth errors and + every browser assertion still fail immediately; - each lane, shard, attempt, and benchmark stage receives a stable synthetic address from the reserved `2001:db8::/32` range through the Vite proxy, so the real per-IP abuse controls are tested without unrelated jobs sharing one @@ -120,20 +124,20 @@ See `.github/workflows/e2e.yml`. The current PR model is: `e2e/shadow/regression-corpus.json` freezes the initial P0/P1 catch corpus. `e2e/shadow/migration-pilots.json` records the three lower-layer pilots and the -browser boundary retained for each. The earlier shard benchmark measured four, -six, and eight shards on commit `c6aa5f0`; at the then-smaller suite size, six -was fastest at 316 seconds versus 340 for eight and 495 for four. The suite has -since grown to 439 Chromium tests, so the capacity benchmark compared 16 and 20 -shards sequentially on the same stable GitHub Pro commit and without retries. Run -[`33385421742`](https://github.com/msrivas-7/CodeTutor-AI/actions/runs/33385421742) -selected sixteen shards: its retry-free test critical path was 160 seconds and -its topology completed in 379 seconds, versus 198 and 416 seconds for 20 -shards. Every shard passed and all 439 tests remained blocking. The benchmark -reports end-to-end completion, slowest test time, shard imbalance, aggregate -runner time, setup overhead, and tests per shard. A larger topology is -recommended only when every shard passes and it improves completion by at least -20 seconds and 5%; this avoids buying more runner/setup overhead for a noisy or -negligible gain. +browser boundary retained for each. The latest capacity run +[`34688798759`](https://github.com/msrivas-7/CodeTutor-AI/actions/runs/34688798759) +compared 16 and 20 Chromium shards sequentially on the exact 484-test PR head +with two workers per shard and no retries. All 16 shards passed in isolation; +20 failed after the shared development database reached its 200-client +connection ceiling. Exact-head normal run `34690166145` showed that 16 Chromium +shards plus Firefox, WebKit and two critical support stacks also reaches 20 +database stacks and reproduces that failure. The operational workflow +therefore uses 12 Chromium shards and reserves four support slots, keeping the +full run at the proven 16-stack limit. All 484 tests remain blocking. The +benchmark reports end-to-end completion, slowest test time, shard imbalance, +aggregate runner time, setup overhead, and tests per shard. A larger topology +is recommended only when every shard passes and it improves completion by at +least 20 seconds and 5%. After the account moved to GitHub Pro, the controlled capacity pass narrowed to a fresh same-commit comparison of the 16-shard incumbent and 20 shards. The @@ -157,13 +161,16 @@ workers on the reused images. Each stage is sequential, retry-free, and must be fully green. Image reuse is adopted only from a material end-to-end gain; worker count is selected independently from the Playwright test critical path. -`.github/e2e-shard-capacity.json` records the measured decision. Shard 1 counts -the live Chromium inventory and fails closed when it reaches 467 tests or falls -to 411, one measured shard-workload from the 439-test baseline. Re-run the -benchmark and update the record at that point instead of guessing a new shard -count or selecting tests away. +`.github/e2e-shard-capacity.json` records the measured decision. The +duration-planning gate counts the live Chromium inventory and derives every +database-backed job's matrix cardinality from the workflow before any of those +jobs can launch. It fails closed if the complete fan-out exceeds the measured +16-stack limit, or if the suite reaches 525 tests or falls to 443—one selected +shard-workload from the 484-test baseline. Re-run the benchmark and update the +record at that point instead of guessing a new shard count or selecting tests +away. -The blocking 16-shard lane uses a duration-aware plan rather than Playwright's +The blocking 12-shard lane uses a duration-aware plan rather than Playwright's test-count-only partition. `.github/e2e-duration-seed.json` is the cold-start baseline from a clean 439-test run. Before each workflow, the planner enumerates the current Chromium inventory and assigns the longest predicted test to the diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts index 11a0e1d1..be198021 100644 --- a/e2e/fixtures/auth.ts +++ b/e2e/fixtures/auth.ts @@ -23,6 +23,7 @@ import { buildWorkerTestEmail, listAllUsers, } from "./testIdentity"; +import { withAuthProvisioningRetry } from "./authRetry"; const BACKEND_URL = process.env.E2E_API_URL ?? "http://localhost:4000"; const APP_ORIGIN = process.env.E2E_APP_ORIGIN ?? "http://localhost:5173"; @@ -77,18 +78,25 @@ async function createOrReuseUser(email: string): Promise { // design — this endpoint is idempotent on the email key as of GoTrue // 2.x). We still rely on email_confirm:true so the session we issue // downstream lands with email_verified = true. - const { data, error } = await admin.auth.admin.createUser({ - email, - password: PASSWORD, - email_confirm: true, - }); + const { data, error } = await withAuthProvisioningRetry( + "create test user", + () => + admin.auth.admin.createUser({ + email, + password: PASSWORD, + email_confirm: true, + }), + ); if (error) { // Supabase returns 422 with "User already registered" when the email // exists. We can still sign in, so treat this as a non-fatal reuse. const isDuplicate = /already registered|already exists/i.test(error.message); if (!isDuplicate) throw error; // Look the user up so we can track the id for teardown. - const users = await listAllUsers(admin); + const users = await withAuthProvisioningRetry( + "find existing test user", + () => listAllUsers(admin), + ); const existing = users.find((u) => u.email === email); if (!existing) throw error; return existing.id; @@ -114,10 +122,14 @@ async function freshSession(email: string): Promise { }, }, }); - const { data, error } = await anon.auth.signInWithPassword({ - email, - password: PASSWORD, - }); + const { data, error } = await withAuthProvisioningRetry( + "sign in test user", + () => + anon.auth.signInWithPassword({ + email, + password: PASSWORD, + }), + ); if (error) throw error; if (!data.session) throw new Error("signIn returned no session"); return data.session; @@ -237,9 +249,13 @@ export async function getAdminWorkerUser(workerIndex: number): Promise { const userId = await createOrReuseUser(email); - const { error: metadataError } = await admin.auth.admin.updateUserById(userId, { - app_metadata: { role: "admin" }, - }); + const { error: metadataError } = await withAuthProvisioningRetry( + "grant test admin metadata", + () => + admin.auth.admin.updateUserById(userId, { + app_metadata: { role: "admin" }, + }), + ); if (metadataError) throw metadataError; const { error: roleError } = await admin.from("user_roles").upsert({ user_id: userId, diff --git a/e2e/fixtures/authRetry.ts b/e2e/fixtures/authRetry.ts new file mode 100644 index 00000000..f9f5220e --- /dev/null +++ b/e2e/fixtures/authRetry.ts @@ -0,0 +1,109 @@ +export type AuthProvisioningRetryOptions = { + maxAttempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; + sleep?: (delayMs: number) => Promise; + random?: () => number; + onRetry?: (event: { + operation: string; + attempt: number; + maxAttempts: number; + delayMs: number; + status: number | undefined; + }) => void; +}; + +const DEFAULT_MAX_ATTEMPTS = 4; +const DEFAULT_BASE_DELAY_MS = 250; +const DEFAULT_MAX_DELAY_MS = 2_000; + +function sleep(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} + +type RetryableAuthProvisioningError = { + name: "AuthRetryableFetchError"; + status?: unknown; +}; + +export function isRetryableAuthProvisioningError( + error: unknown, +): error is RetryableAuthProvisioningError { + return ( + typeof error === "object" && + error !== null && + "name" in error && + error.name === "AuthRetryableFetchError" + ); +} + +function returnedRetryableAuthProvisioningError( + result: unknown, +): RetryableAuthProvisioningError | undefined { + if (typeof result !== "object" || result === null || !("error" in result)) { + return undefined; + } + return isRetryableAuthProvisioningError(result.error) + ? result.error + : undefined; +} + +/** + * Absorb only Supabase Auth failures that the SDK explicitly classifies as + * retryable. Playwright retries remain a signal for product/test flakes; this + * narrow boundary prevents a transient provisioning request from rerunning an + * otherwise-complete browser journey. + */ +export async function withAuthProvisioningRetry( + operation: string, + run: () => Promise, + options: AuthProvisioningRetryOptions = {}, +): Promise { + const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; + const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS; + const maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS; + const wait = options.sleep ?? sleep; + const random = options.random ?? Math.random; + const onRetry = + options.onRetry ?? + ((event) => { + console.warn( + `[auth fixture] ${event.operation} received a retryable Auth response` + + ` (status=${event.status ?? "network"}); retrying ${event.attempt + 1}/${event.maxAttempts}` + + ` after ${event.delayMs}ms`, + ); + }); + + if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1) { + throw new Error("Auth provisioning maxAttempts must be a positive integer"); + } + if (baseDelayMs < 0 || maxDelayMs < baseDelayMs) { + throw new Error("Auth provisioning retry delays are invalid"); + } + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const result = await run(); + const returnedError = returnedRetryableAuthProvisioningError(result); + if (returnedError) throw returnedError; + return result; + } catch (error) { + if (!isRetryableAuthProvisioningError(error) || attempt === maxAttempts) { + throw error; + } + + const ceiling = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1)); + // Equal jitter prevents all hosted workers from retrying in lockstep + // while retaining a meaningful lower bound between attempts. + const delayMs = Math.round(ceiling / 2 + random() * (ceiling / 2)); + const status = + "status" in error && typeof error.status === "number" + ? error.status + : undefined; + onRetry({ operation, attempt, maxAttempts, delayMs, status }); + await wait(delayMs); + } + } + + throw new Error("Auth provisioning retry loop exited unexpectedly"); +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 7d0deaba..29debf1c 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -69,7 +69,7 @@ export default defineConfig({ // "Waiting for session". Two preserves useful parallelism without turning // infrastructure capacity into false product failures. // - // CI parallelism comes from sharding (16 matrix shards × 2 workers = 32 + // CI parallelism comes from sharding (12 matrix shards × 2 workers = 24 // effective workers across separate ubuntu-latest runners) — see // .github/workflows/e2e.yml. The measured topology and rebenchmark band live // in .github/e2e-shard-capacity.json. Larger diff --git a/e2e/specs/anon-celebration-dismiss-lock.spec.ts b/e2e/specs/anon-celebration-dismiss-lock.spec.ts index 701916b6..d12bd5d4 100644 --- a/e2e/specs/anon-celebration-dismiss-lock.spec.ts +++ b/e2e/specs/anon-celebration-dismiss-lock.spec.ts @@ -52,6 +52,30 @@ test.describe("Phase A-Q — celebration dismissal and continuation", () => { await expect(page.getByRole("button", { name: /check/i }).first()).toBeEnabled(); }); + test("Escape is owned from the first committed completion dialog", async ({ page }) => { + await page.goto(PATH); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await page.getByRole("button", { name: /run/i }).first().click(); + await expect(page.getByText(/Hello, Maya!/).last()).toBeVisible(); + await page.evaluate(() => { + // Exercise the DOM-commit boundary without a timing sleep or a poll that + // could let passive effects catch up before the one-shot key arrives. + const observer = new MutationObserver(() => { + if (!document.querySelector('[role="dialog"][aria-labelledby="lesson-complete-title"]')) return; + observer.disconnect(); + const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }); + window.dispatchEvent(event); + document.documentElement.dataset.earlyDialogEscape = String(event.defaultPrevented); + }); + observer.observe(document.body, { childList: true, subtree: true }); + }); + await page.getByRole("button", { name: /check/i }).first().click(); + await expect(page.locator("html")).toHaveAttribute("data-early-dialog-escape", "true"); + await expect(page.getByRole("dialog", { name: /lesson complete/i })).toHaveCount(0); + await expect(page.getByText(/Lesson 2 is queued up/i)).toHaveCount(0); + await expect(page.getByRole("button", { name: /check/i }).first()).toBeEnabled(); + }); + test("Keep practicing has a distinct outcome from Next Lesson", async ({ page }) => { const celebration = await openCelebration(page); await celebration.getByRole("button", { name: /keep practicing/i }).click(); diff --git a/e2e/specs/anon-lesson.spec.ts b/e2e/specs/anon-lesson.spec.ts index 81aa3d0c..c09d0d1a 100644 --- a/e2e/specs/anon-lesson.spec.ts +++ b/e2e/specs/anon-lesson.spec.ts @@ -81,6 +81,30 @@ test.describe("anonymous lesson 1 (Phase 27 §3a)", () => { await expect(page.getByLabel(/ask the tutor/i)).toBeVisible(); }); + test("anonymous lesson retains product shortcuts after the cinematic", async ({ page }) => { + await page.addInitScript(() => { + window.sessionStorage.setItem("codetutor.anonChoreographyDone", "1"); + }); + await page.goto(ALLOWED_PATH); + const composer = page.getByLabel(/ask the tutor/i); + await expect(composer).toBeVisible(); + + await page.keyboard.press("?"); + const dialog = page.getByRole("dialog", { name: /keyboard shortcuts/i }); + await expect(dialog).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + + await page.getByRole("main").focus(); + await page.keyboard.press("Control+K"); + await expect(composer).toBeFocused(); + + await composer.fill("Does ? stay in the question?"); + await page.keyboard.press("?"); + await expect(dialog).toHaveCount(0); + await expect(composer).toHaveValue("Does ? stay in the question??"); + }); + test("anonymous tutor preserves the same Socratic first-turn proof flow", async ({ page }) => { await page.addInitScript(() => { window.sessionStorage.setItem("codetutor.anonChoreographyDone", "1"); diff --git a/e2e/specs/auth-retry-contract.spec.ts b/e2e/specs/auth-retry-contract.spec.ts new file mode 100644 index 00000000..a194e447 --- /dev/null +++ b/e2e/specs/auth-retry-contract.spec.ts @@ -0,0 +1,141 @@ +import { test, expect } from "@playwright/test"; +import { + isRetryableAuthProvisioningError, + withAuthProvisioningRetry, +} from "../fixtures/authRetry"; + +test.describe("E2E auth provisioning retry contract", () => { + test("retries the SDK's explicit retryable failure with bounded equal jitter", async () => { + const delays: number[] = []; + let attempts = 0; + + const value = await withAuthProvisioningRetry( + "create user", + async () => { + attempts += 1; + if (attempts < 3) { + throw { name: "AuthRetryableFetchError", status: 503 }; + } + return "ready"; + }, + { + baseDelayMs: 200, + random: () => 0, + sleep: async (delayMs) => void delays.push(delayMs), + onRetry: () => undefined, + }, + ); + + expect(value).toBe("ready"); + expect(attempts).toBe(3); + expect(delays).toEqual([100, 200]); + }); + + test("does not retry ordinary auth or assertion failures", async () => { + const failure = new Error("invalid fixture input"); + let attempts = 0; + + await expect( + withAuthProvisioningRetry( + "create user", + async () => { + attempts += 1; + throw failure; + }, + { + sleep: async () => undefined, + onRetry: () => undefined, + }, + ), + ).rejects.toBe(failure); + + expect(attempts).toBe(1); + expect(isRetryableAuthProvisioningError(failure)).toBe(false); + }); + + test("retries a retryable error returned in a Supabase SDK response", async () => { + const failure = { name: "AuthRetryableFetchError" as const, status: 503 }; + let attempts = 0; + + const value = await withAuthProvisioningRetry( + "create user", + async () => { + attempts += 1; + return attempts === 1 + ? { data: null, error: failure } + : { data: { id: "user-1" }, error: null }; + }, + { + sleep: async () => undefined, + onRetry: () => undefined, + }, + ); + + expect(attempts).toBe(2); + expect(value).toEqual({ data: { id: "user-1" }, error: null }); + }); + + test("returns ordinary Supabase SDK errors for the caller to handle", async () => { + const failure = { name: "AuthApiError", status: 422 }; + let attempts = 0; + + const value = await withAuthProvisioningRetry( + "create user", + async () => { + attempts += 1; + return { data: null, error: failure }; + }, + { + sleep: async () => undefined, + onRetry: () => undefined, + }, + ); + + expect(attempts).toBe(1); + expect(value.error).toBe(failure); + }); + + test("preserves the final retryable failure after the fixed attempt bound", async () => { + const failure = { name: "AuthRetryableFetchError", status: 0 }; + let attempts = 0; + + await expect( + withAuthProvisioningRetry( + "sign in", + async () => { + attempts += 1; + throw failure; + }, + { + maxAttempts: 3, + sleep: async () => undefined, + onRetry: () => undefined, + }, + ), + ).rejects.toBe(failure); + + expect(attempts).toBe(3); + }); + + test("preserves a returned retryable failure after the fixed attempt bound", async () => { + const failure = { name: "AuthRetryableFetchError" as const, status: 503 }; + let attempts = 0; + + await expect( + withAuthProvisioningRetry( + "update user", + async () => { + attempts += 1; + return { data: null, error: failure }; + }, + { + maxAttempts: 3, + sleep: async () => undefined, + onRetry: () => undefined, + }, + ), + ).rejects.toBe(failure); + + expect(attempts).toBe(3); + }); +}); diff --git a/e2e/specs/marketing.spec.ts b/e2e/specs/marketing.spec.ts index 72cf41c3..b1ae57cf 100644 --- a/e2e/specs/marketing.spec.ts +++ b/e2e/specs/marketing.spec.ts @@ -338,7 +338,9 @@ test.describe("marketing page (Phase 22C) — mobile viewport", () => { await artwork.focus(); for (const viewport of [ { width: 844, height: 390 }, + { width: 320, height: 568 }, { width: 320, height: 740 }, + { width: 430, height: 932 }, { width: 390, height: 844 }, ]) { await page.setViewportSize(viewport); @@ -349,6 +351,23 @@ test.describe("marketing page (Phase 22C) — mobile viewport", () => { () => document.documentElement.scrollWidth <= innerWidth + 1, ), ).toBe(true); + if (viewport.width <= 640) { + // UX-224: the old 210px hero collapsed the renderer's departure + // interval to 1px. Protect usable scroll space, not a CSS constant. + const pacing = await page.evaluate(() => { + const hero = document.querySelector(".study-hero-art")!.getBoundingClientRect(); + const copy = document.querySelector(".study-hero-copy")!.getBoundingClientRect(); + const assembledAt = Math.max(0, hero.top + scrollY + hero.height / 2 - innerHeight / 2); + const clearAt = Math.max(assembledAt + 1, copy.top + scrollY - innerHeight * 0.55); + return { + departure: (clearAt - assembledAt) / innerHeight, + chapters: [...document.querySelectorAll(".study-chapter-art, .study-closing-art")] + .map(element => element.getBoundingClientRect().height / innerHeight), + }; + }); + expect(pacing.departure).toBeGreaterThan(0.3); + expect(pacing.chapters.every(height => height >= 0.6)).toBe(true); + } } // The artwork must not capture vertical touch scrolling. await expect(artwork).toHaveCSS("touch-action", "pan-y"); diff --git a/e2e/specs/phase-aq-cross-browser.spec.ts b/e2e/specs/phase-aq-cross-browser.spec.ts index 540fa00f..9f55155b 100644 --- a/e2e/specs/phase-aq-cross-browser.spec.ts +++ b/e2e/specs/phase-aq-cross-browser.spec.ts @@ -275,26 +275,24 @@ test.describe( await expect(continuation).toHaveCount(0); }); - test("a native viewport reflow consumes Escape before the product modal", async ({ page }) => { + test("Escape closes the product modal through a native viewport reflow", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 720 }); await seedFirstRun(page, true); await openLesson(page); - await page.getByRole("button", { name: /sign up to save/i }).click(); + const returnFocus = page.getByRole("button", { name: /sign up to save/i }); + await returnFocus.click(); const continuation = page.getByRole("dialog", { name: /sign up to save/i }); await expect(continuation).toBeVisible(); - await page.evaluate(() => { - window.dispatchEvent(new KeyboardEvent("keydown", { - key: "Escape", - bubbles: true, - })); - }); - await page.setViewportSize({ width: 900, height: 720 }); - await page.waitForTimeout(180); - await expect(continuation).toBeVisible(); - - await page.keyboard.press("Escape"); + // Safari can apply this same physical Escape to its native fullscreen + // transition. The product modal must still close and restore its trigger + // after the resulting viewport reflow. + await Promise.all([ + page.keyboard.press("Escape"), + page.setViewportSize({ width: 900, height: 720 }), + ]); await expect(continuation).toHaveCount(0); + await expect(returnFocus).toBeFocused(); }); }, ); diff --git a/e2e/specs/phase-aq-visual-quality.spec.ts b/e2e/specs/phase-aq-visual-quality.spec.ts index 5d574388..8fd87d34 100644 --- a/e2e/specs/phase-aq-visual-quality.spec.ts +++ b/e2e/specs/phase-aq-visual-quality.spec.ts @@ -44,72 +44,61 @@ test.describe("Phase A-Q — visual viewport matrix", () => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto("/login"); - const field = page.getByTestId("ambient-glyph-field"); - const glyphs = field.locator("[data-floating-glyph]"); + await expect(page.getByRole("heading", { name: "Sign in", exact: true })).toBeVisible(); + await expect(page.locator(".public-page")).toHaveAttribute("data-motion", "ready"); + const field = page.locator(".public-motion-world > .motion-study-canvas"); + const canvas = field.locator("canvas"); await expect(field).toBeVisible(); - await expect(glyphs).toHaveCount(7); - await expect(field).toHaveClass(/ambient-glyph-field--center-safe/); - - const sample = () => - glyphs.evaluateAll((elements) => - elements.map((element) => { - const style = getComputedStyle(element); - return `${style.transform}|${style.opacity}`; - }), - ); + await expect(canvas).toHaveCount(1); + await page.evaluate(() => document.fonts.ready); + + // The approved public world is rendered, not seven independent DOM + // glyphs. Compare actual frames so a mounted but frozen canvas fails. + const sample = () => canvas.screenshot({ caret: "hide" }); const initialMotion = await sample(); await expect - .poll(async () => JSON.stringify(await sample()), { timeout: 5_000 }) - .not.toBe(JSON.stringify(initialMotion)); + .poll(async () => (await sample()).equals(initialMotion), { timeout: 5_000 }) + .toBe(false); - const contentLayer = page - .locator("div.relative.z-10") - .filter({ has: page.getByRole("heading", { name: "Sign in", exact: true }) }) - .first(); + const contentLayer = page.locator(".public-page"); await expect(contentLayer).toBeVisible(); expect(await field.evaluate((element) => getComputedStyle(element).zIndex)).toBe("0"); - expect(await contentLayer.evaluate((element) => getComputedStyle(element).zIndex)).toBe( - "10", - ); + // The page is an isolated stacking context painted after the fixed world; + // it need not invent a positive z-index to protect its content. + await expect(contentLayer).toHaveCSS("isolation", "isolate"); + await expect(contentLayer).toHaveCSS("position", "relative"); + expect(await field.evaluate(element => Boolean( + element.compareDocumentPosition(document.querySelector(".public-page")!) & Node.DOCUMENT_POSITION_FOLLOWING, + ))).toBe(true); expect(await field.evaluate((element) => getComputedStyle(element).pointerEvents)).toBe( "none", ); + await page.getByLabel("Email", { exact: true }).click(); + await expect(page.getByLabel("Email", { exact: true })).toBeFocused(); - await page.evaluate(() => { - document.documentElement.dataset.theme = "light"; - document.documentElement.style.colorScheme = "light"; - }); + // Public pages intentionally retain their approved dark brand even when + // the OS is light; changing system preference must not stop the field. + await page.emulateMedia({ colorScheme: "light", reducedMotion: "no-preference" }); + await expect(page.locator("html")).toHaveAttribute("data-public-theme"); await expect(field).toBeVisible(); - expect(await field.evaluate((element) => getComputedStyle(element).mixBlendMode)).toBe( - "multiply", - ); - const lightGlyphColor = await glyphs.first().evaluate( - (element) => getComputedStyle(element).color, - ); - expect(lightGlyphColor).toMatch(/rgba?\(2, 132, 199(?:, 0\.55)?\)/); - const centerMask = await field.evaluate((element) => { - const style = getComputedStyle(element); - return style.maskImage || style.webkitMaskImage; - }); - expect(centerMask).toMatch(/transparent|rgba\(0, 0, 0, 0\)/); const lightMotion = await sample(); await expect - .poll(async () => JSON.stringify(await sample()), { timeout: 5_000 }) - .not.toBe(JSON.stringify(lightMotion)); + .poll(async () => (await sample()).equals(lightMotion), { timeout: 5_000 }) + .toBe(false); await page.emulateMedia({ colorScheme: "dark", reducedMotion: "reduce" }); - await page.reload(); - await expect(field).toBeVisible(); - await expect - .poll( - async () => - (await sample()).every((value) => value === "none|0.35"), - { timeout: 5_000 }, - ) - .toBe(true); - const stillStart = await sample(); + await expect(canvas).toHaveCount(0); + const still = page.locator(".public-auth-still"); + await expect(still).toBeVisible(); + // A focused input caret is unrelated motion over the still artwork. + await page.getByRole("heading", { name: "Sign in", exact: true }).click(); + const stillStart = await still.screenshot({ caret: "hide" }); await page.waitForTimeout(400); - expect(await sample()).toEqual(stillStart); + expect((await still.screenshot({ caret: "hide" })).equals(stillStart), "reduced-motion artwork remains still").toBe(true); + await page.reload(); + await expect(page.getByRole("heading", { name: "Sign in", exact: true })).toBeVisible(); + await expect(canvas).toHaveCount(0); + await expect(still).toBeVisible(); }); test("phone auth and recovery controls keep a 44px interaction floor", async ({ diff --git a/e2e/specs/public-theme.spec.ts b/e2e/specs/public-theme.spec.ts new file mode 100644 index 00000000..96900478 --- /dev/null +++ b/e2e/specs/public-theme.spec.ts @@ -0,0 +1,796 @@ +import { expect, test } from "@playwright/test"; + +test("public mobile typography is independent of the document entry shell", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + for (const path of ["/", "/privacy", "/why-not-chatgpt"]) { + const title = path === "/" ? "AI that builds you, not the code" + : path === "/privacy" ? "Privacy, in plain language." : "Why not just use ChatGPT?"; + const heading = page.getByRole("heading", { level: 1, name: title, exact: true }); + const typography = async () => { + await expect(heading).toBeVisible(); + await page.evaluate(() => document.fonts.ready); + return page.locator("h1, h2.font-display").evaluateAll(elements => elements.map(element => { + const style = getComputedStyle(element); + return { text: element.textContent, font: style.fontFamily, weight: style.fontWeight, + width: element.getBoundingClientRect().width, height: element.getBoundingClientRect().height }; + })); + }; + await page.goto(path); + const baseline = await typography(); + // A fresh auth document selects FullApp, not the acquisition entry shell. + await page.goto("/login"); + await expect(page.getByRole("heading", { name: "Sign in", exact: true })).toBeVisible(); + if (path === "/") await page.getByRole("link", { name: "CodeTutor AI home" }).click(); + else if (path === "/privacy") await page.getByRole("link", { name: "Privacy", exact: true }).click(); + else { + await page.getByRole("link", { name: "CodeTutor AI home" }).click(); + await page.getByRole("link", { name: "Why not ChatGPT?", exact: true }).click(); + } + await expect(page).toHaveURL(new RegExp(`${path === "/" ? "/" : path}$`)); + expect(await typography()).toEqual(baseline); + await page.reload(); + expect(await typography()).toEqual(baseline); + await page.getByRole("link", { name: path === "/" ? "Privacy" : "CodeTutor AI home", exact: true }).click(); + await page.goBack(); + expect(await typography()).toEqual(baseline); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); + } + await page.goto("/try/lesson/python-fundamentals/hello-world"); + await expect(page.locator(".public-surface")).toHaveCount(0); +}); + +test("public auth supporting copy uses one readable role", async ({ page }) => { + await page.setViewportSize({ width: 320, height: 844 }); + for (const route of ["/login", "/signup"]) { + await page.goto(route); + const divider = page.getByText(/or sign (?:in|up) with email/); + await expect(divider).toBeVisible(); + await expect(divider).toHaveCSS("font-size", "14px"); + } + // Controlled transport response: exercise the real form's recovery state + // without sending mail or changing an account. + await page.route("**/auth/v1/recover*", route => route.fulfill({ json: {} })); + await page.goto("/reset-password"); + await page.getByLabel("Email", { exact: true }).fill("typography-review@example.com"); + await page.getByRole("button", { name: "Send reset link" }).click(); + const detail = page.getByText("The link expires in an hour.", { exact: true }); + await expect(detail).toBeVisible(); + await expect(detail).toHaveCSS("font-size", "14px"); + await expect(detail).toHaveCSS("line-height", "21px"); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320); +}); + +test("public share comments retain readable contrast in normal and reduced motion", async ({ page }) => { + const comment = "# Read the result before changing the code."; + await page.route("**/api/shares/aaaaaaaaaaaa", route => route.fulfill({ json: { + shareToken: "aaaaaaaaaaaa", courseId: "python-fundamentals", lessonId: "hello-world", + lessonTitle: "Hello, World!", lessonOrder: 1, courseTitle: "Python Fundamentals", + courseTotalLessons: 12, mastery: "strong", timeSpentMs: 60000, attemptCount: 1, + codeSnippet: `print("Hello!")\n${comment}`, displayName: null, ogImageUrl: null, + ogStoryImageUrl: null, viewCount: 1, createdAt: "2026-09-01T00:00:00Z", + } })); + for (const width of [390, 1440]) { + await page.setViewportSize({ width, height: 900 }); + for (const reducedMotion of ["no-preference", "reduce"] as const) { + await page.emulateMedia({ reducedMotion }); + await page.goto("/s/aaaaaaaaaaaa"); + const text = page.getByText(comment, { exact: true }); + await expect(text).toBeVisible(); + const contrast = await text.evaluate(element => { + let ancestor: Element | null = element; + let background = "rgba(0, 0, 0, 0)"; + while (ancestor && background === "rgba(0, 0, 0, 0)") { + background = getComputedStyle(ancestor).backgroundColor; + ancestor = ancestor.parentElement; + } + const lum = (color: string) => { + const channels = color.match(/[\d.]+/g)!.slice(0, 3).map(Number).map(value => { + const c = value / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; + }; + const a = lum(getComputedStyle(element).color), b = lum(background); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + }); + expect(contrast).toBeGreaterThanOrEqual(4.5); + } + } +}); + +test("support action keeps readable contrast and stable geometry through pointer and keyboard states", async ({ page }) => { + await page.goto("/support"); + const action = page.getByRole("link", { name: /^Email / }); + await expect(action).toBeVisible(); + await expect(action).toHaveAttribute("href", /^mailto:.*\?subject=CodeTutor%20support$/); + const contrast = () => action.evaluate(element => { + const style = getComputedStyle(element); + const luminance = (color: string) => { + const channels = color.match(/[\d.]+/g)!.slice(0, 3).map(Number).map(channel => { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; + }; + const foreground = luminance(style.color); + const background = luminance(style.backgroundColor); + return (Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05); + }); + for (const reducedMotion of ["reduce", "no-preference"] as const) { + await page.emulateMedia({ reducedMotion }); + for (const width of [320, 1440]) { + await page.setViewportSize({ width, height: 900 }); + await action.scrollIntoViewIfNeeded(); + await page.mouse.move(0, 0); + await expect.poll(contrast).toBeGreaterThanOrEqual(4.5); + const initial = await action.boundingBox(); + for (let repeat = 0; repeat < 2; repeat++) { + await action.hover(); + // Assert the foreground too: a transition must not briefly satisfy a + // contrast poll before the broken final hover color takes effect. + await expect(action).toHaveCSS("color", "rgb(5, 7, 9)"); + await expect.poll(contrast).toBeGreaterThanOrEqual(4.5); + expect(await action.boundingBox()).toEqual(initial); + await page.mouse.move(0, 0); + } + await action.focus(); + await expect(action).toBeFocused(); + await expect(action).toHaveCSS("outline-style", "solid"); + await expect(action).toHaveCSS("outline-width", "2px"); + await expect.poll(contrast).toBeGreaterThanOrEqual(4.5); + await page.keyboard.press("Tab"); + await expect(action).not.toBeFocused(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width); + } + } +}); + +test("public token edits reach SPA and static surfaces without recoloring the workspace", async ({ page }) => { + for (const route of ["/", "/login", "/privacy", "/learn-to-code/"]) { + await page.goto(route); + await expect(page.getByRole("heading", { level: 1 }).first()).toBeVisible(); + await expect(page.locator("#design-system-tokens")).toHaveCount(1); + const header = page.locator(".brand-header"); + await expect(header).toHaveCSS("min-height", "88px"); + // Verify inheritance through the real consumers, not just string presence. + await page.evaluate(() => { + document.documentElement.style.setProperty("--brand-header-height", "96px"); + document.documentElement.style.setProperty("--brand-text", "rgb(220, 230, 240)"); + }); + await expect(header).toHaveCSS("min-height", "96px"); + await expect(header.getByRole("link", { name: "CodeTutor AI home" })).toHaveCSS("color", "rgb(220, 230, 240)"); + await page.reload(); + await expect(header).toHaveCSS("min-height", "88px"); + } + await page.goto("/try/lesson/python-fundamentals/hello-world"); + await expect(page.locator("html")).not.toHaveAttribute("data-public-theme"); + expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--color-bg").trim())).not.toBe("5 7 9"); +}); + +test("walkthrough opens surrounding space but keeps all demo states protected", async ({ page }) => { + for (const reducedMotion of ["reduce", "no-preference"] as const) { + await page.emulateMedia({ reducedMotion }); + for (const width of [320, 1440]) { + await page.setViewportSize({ width, height: 1000 }); + await page.goto("/#study-demo"); + await expect(page.locator("#study-demo-title")).toBeVisible(); + await expect(page.locator("#study-demo-title")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); + await expect(page.locator(".study-demo-surface")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); + for (const stage of ["Ask", "Check", "Read", "Ask"]) { + const button = page.getByRole("button", { name: new RegExp(stage) }); + await button.focus(); + await page.keyboard.press("Enter"); + await expect(button).toHaveAttribute("aria-pressed", "true"); + await expect(button).toBeFocused(); + await expect(page.locator(".study-demo-body")).toHaveCSS("background-color", "rgb(5, 7, 9)"); + await expect(page.locator(".study-demo-explanation > p")).toHaveCSS("background-color", "rgb(5, 7, 9)"); + await expect(page.locator(".study-code pre")).toHaveAttribute("aria-label", `Code example, ${stage} stage`); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width); + } + } + } +}); + +for (const checkpoint of ["during typing", "after reveal"] as const) { +test(`a live reduced-motion change settles the entire public share ${checkpoint}`, async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + const code = 'print("' + 'learning '.repeat(checkpoint === "during typing" ? 60 : 1) + '")'; + await page.route('**/api/shares/aaaaaaaaaaaa', route => route.fulfill({ json: { + shareToken: 'aaaaaaaaaaaa', courseId: 'python-fundamentals', lessonId: 'hello-world', + lessonTitle: 'Hello, World!', lessonOrder: 1, courseTitle: 'Python Fundamentals', + courseTotalLessons: 12, mastery: 'strong', timeSpentMs: 60000, attemptCount: 1, + codeSnippet: code, displayName: null, ogImageUrl: null, ogStoryImageUrl: null, + viewCount: 25, createdAt: '2026-09-01T00:00:00Z', + }})); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await page.goto('/s/aaaaaaaaaaaa'); + await expect(page.getByRole('heading', { name: 'Hello, World!' })).toBeVisible(); + if (checkpoint === "during typing") { + await expect(page.locator('.public-share-artifact')).not.toContainText(code); + } else { + await expect(page.getByText(code, { exact: true })).toBeVisible(); + await expect(page.getByText('25 readers', { exact: true })).toBeVisible(); + } + await page.emulateMedia({ reducedMotion: 'reduce' }); + await expect(page.locator('.motion-study-canvas canvas')).toHaveCount(0); + await expect(page.getByText(code, { exact: true })).toBeVisible({ timeout: 1500 }); + await expect(page.getByText('25 readers', { exact: true })).toBeVisible(); + const cta = page.getByRole('link', { name: /Try this lesson/ }); + await expect(cta.locator('..')).toHaveCSS('opacity', '1'); + await expect(cta.locator('..')).toHaveCSS('transform', 'none'); + await cta.hover(); + await expect(cta).toHaveCSS('transform', 'none'); + await cta.focus(); + await expect(cta).toBeFocused(); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await expect(page.locator('.public-page')).toHaveAttribute('data-motion', 'ready'); + await expect(page.getByText(code, { exact: true })).toBeVisible(); + await expect(page.getByText('25 readers', { exact: true })).toBeVisible(); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await expect(page.locator('.motion-study-canvas canvas')).toHaveCount(0); + await expect(page.getByText(code, { exact: true })).toBeVisible(); + await expect(cta).toBeFocused(); + expect(errors).toEqual([]); +}); +} + +test("public navigation keeps its geometry across page families and auth steps", async ({ page }) => { + for (const width of [320, 390, 768, 1440]) { + await page.setViewportSize({ width, height: 900 }); + let baseline: { height: number; logoX: number; logoY: number } | undefined; + let authHeadingY: number | undefined; + for (const route of ["/", "/login", "/signup", "/reset-password", "/privacy", "/terms", "/support", "/why-not-chatgpt", "/learn-to-code/"]) { + await page.goto(route); + // The shared loading shell has its own accessible heading. Measure only + // after the actual destination replaces it, not during that handoff. + await expect(page.getByRole("heading", { level: 1 }).filter({ hasNotText: "Loading page" }).first()).toBeVisible(); + await page.evaluate(() => document.fonts.ready); + const header = page.locator(".study-nav, .public-header, .site-nav"); + const box = (await header.boundingBox())!; + const logo = (await header.getByRole("link", { name: "CodeTutor AI home" }).boundingBox())!; + const geometry = { height: box.height, logoX: logo.x, logoY: logo.y + logo.height / 2 }; + baseline ??= geometry; + for (const key of ["height", "logoX", "logoY"] as const) { + expect(Math.abs(geometry[key] - baseline[key]), `${route} ${width}px ${key}`).toBeLessThan(1); + } + expect(await page.evaluate(() => document.documentElement.scrollWidth), route).toBeLessThanOrEqual(width); + if (["/login", "/signup", "/reset-password"].includes(route)) { + const heading = (await page.getByRole("heading", { level: 1 }).boundingBox())!; + authHeadingY ??= heading.y; + expect(Math.abs(heading.y - authHeadingY), `${route} auth heading jumps`).toBeLessThan(1); + } + } + } +}); + +test("one public field survives the homepage and auth journey without keeping form state", async ({page}) => { + await page.emulateMedia({reducedMotion: "no-preference"}); + await page.goto("/"); + await expect(page.locator(".study-ready")).toBeVisible(); + const original = await page.locator(".motion-study-canvas canvas").elementHandle(); + expect(original).not.toBeNull(); + const sameField = async () => { + await expect(page.locator(".motion-study-canvas canvas")).toHaveCount(1); + expect(await original!.evaluate(node => node.isConnected && node === document.querySelector(".motion-study-canvas canvas"))).toBe(true); + }; + await page.getByRole("navigation", {name: "Main navigation"}).getByRole("link", {name: "Sign in"}).click(); + await expect(page.getByRole("heading", {name: "Sign in", exact:true})).toBeVisible(); + await sameField(); + await page.getByLabel("Email", {exact:true}).fill("invalid"); + await page.getByRole("link", {name:"Create one"}).click(); + await expect(page).toHaveURL(/\/signup/); + await expect(page.getByLabel("Email", {exact:true})).toHaveValue(""); + await sameField(); + await page.getByRole("link", {name:"Sign in", exact:true}).click(); + await page.getByRole("link", {name:"Forgot password?"}).click(); + await expect(page.getByRole("heading", {name:"Reset your password"})).toBeVisible(); + await sameField(); + await page.getByRole("link", {name:"CodeTutor AI home"}).click(); + await expect(page.locator(".study-ready")).toBeVisible(); + await sameField(); + await page.getByRole("link", {name:"Try your first lesson"}).first().click(); + await expect(page).toHaveURL(/\/try\/lesson\//); + await expect(page.locator(".motion-study-canvas")).toHaveCount(0); +}); + +test("reading routes retain the field and protect text without an opaque page slab", async ({ page }, testInfo) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.goto("/login"); + await expect(page.getByRole('heading', { name: 'Sign in', exact: true })).toBeVisible(); + await expect(page.locator('.public-page')).toHaveAttribute('data-motion', 'ready'); + const original = await page.locator('.motion-study-canvas canvas').elementHandle(); + for (const name of ['Privacy', 'Terms', 'Support']) { + await page.getByRole('navigation', { name: 'Trust and support' }).getByRole('link', { name, exact: true }).click(); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + expect(await original!.evaluate(node => node === document.querySelector('.motion-study-canvas canvas'))).toBe(true); + await expect(page.locator('.public-reading-body')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + await expect(page.locator('.public-reading-body section p').first()).toHaveCSS('background-color', 'rgb(5, 7, 9)'); + await expect(page.locator('.public-content')).toBeFocused(); + } + for (const width of [1440, 390]) { + await page.setViewportSize({ width, height: 900 }); + await page.getByRole('navigation', { name: 'Trust and support' }).getByRole('link', { name: 'Privacy', exact: true }).click(); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + await page.evaluate(() => document.fonts.ready); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width); + await page.screenshot({ path: testInfo.outputPath(`reading-${width}.png`) }); + } + await page.emulateMedia({ reducedMotion: 'reduce' }); + await expect(page.locator('.motion-study-canvas canvas')).toHaveCount(0); + await expect(page.locator('.public-flow-still')).toBeVisible(); + await page.getByRole('heading', { name: 'How code and AI requests are used' }).scrollIntoViewIfNeeded(); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await expect(page.locator('.public-page')).toHaveAttribute('data-motion', 'ready'); + await expect(page.getByRole('heading', { name: 'How code and AI requests are used' })).toBeInViewport(); + await page.goto('/learn-to-code/'); + await expect(page.locator('body')).toHaveAttribute('data-motion', 'ready'); + await expect(page.locator('.motion-study-canvas canvas')).toHaveCount(1); + for (const width of [390, 1440]) { + await page.setViewportSize({ width, height: 900 }); + for (const id of ['method-title', 'courses-title']) { + const heading = page.locator(`#${id}`); + await expect(heading).toHaveCSS('background-color', 'rgb(5, 7, 9)'); + await expect(heading.locator('..')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + if (width === 1440) { + expect((await heading.boundingBox())!.width).toBeLessThan((await heading.locator('..').boundingBox())!.width); + } + } + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width); + } + await page.emulateMedia({ reducedMotion: 'reduce' }); + await expect(page.locator('.public-flow-still')).toBeVisible(); + await expect(page.locator('.motion-study-canvas canvas')).toHaveCount(0); +}); + +test("cold public loading retains the header without adding it to workspace loading", async ({ page }) => { + let releasePublic!: () => void; + let releaseWorkspace!: () => void; + const heldPublic = new Promise(resolve => { releasePublic = resolve; }); + const heldWorkspace = new Promise(resolve => { releaseWorkspace = resolve; }); + await page.route(/\/(?:src\/pages\/LoginPage\.tsx|assets\/LoginPage-[^/]+\.js)(?:\?|$)/, async route => { + await heldPublic; + await route.continue(); + }); + await page.route(/\/(?:src\/App\.tsx|assets\/App-[^/]+\.js)(?:\?|$)/, async route => { + await heldWorkspace; + await route.continue(); + }); + try { + await page.setViewportSize({ width: 320, height: 844 }); + await page.goto("/login", { waitUntil: "domcontentloaded" }); + await expect(page.locator(".public-route-loading")).toBeVisible(); + await expect(page.getByRole("status")).toContainText("Loading"); + const header = page.locator(".brand-header"); + const initial = await header.boundingBox(); + await expect(page.getByRole("link", { name: "Back to CodeTutor" })).toBeVisible(); + await expect(page.locator("html")).toHaveAttribute("data-public-theme", ""); + await page.goto("/editor", { waitUntil: "domcontentloaded" }); + await expect(page.locator(".route-loading")).toBeVisible(); + await expect(page.locator(".brand-header")).toHaveCount(0); + await expect(page.locator("html")).not.toHaveAttribute("data-public-theme"); + await page.goto("/login", { waitUntil: "domcontentloaded" }); + await expect(page.locator(".public-route-loading")).toBeVisible(); + await page.getByRole("link", { name: "Back to CodeTutor" }).focus(); + releasePublic(); + await expect(page.getByRole("heading", { name: "Sign in", exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "Back to CodeTutor" })).toBeFocused(); + expect(await header.boundingBox()).toEqual(initial); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320); + } finally { + releasePublic(); + releaseWorkspace(); + await page.unrouteAll({ behavior: "wait" }); + } +}); + +test("direct auth entry does not wait for the authenticated application bundle", async ({ page }) => { + let fullAppRequests = 0; + await page.route(/\/(?:src\/App\.tsx|assets\/App-[^/]+\.js)(?:\?|$)/, route => { + fullAppRequests += 1; + return route.abort(); + }); + + await page.goto("/login"); + await expect(page.getByRole("heading", { name: "Sign in", exact: true })).toBeVisible(); + await expect(page.locator("html")).toHaveAttribute("data-public-theme", ""); + expect(fullAppRequests).toBe(0); +}); + +test("nested auth loading can be left through its header and completed without stale navigation", async ({ page }) => { + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + await page.route(/\/(?:src\/pages\/LoginPage\.tsx|assets\/LoginPage-[^/]+\.js)(?:\?|$)/, async route => { + await held; + await route.continue(); + }); + try { + await page.goto("/login", { waitUntil: "domcontentloaded" }); + await expect(page.locator(".public-route-loading")).toBeVisible(); + const header = page.locator(".brand-header"); + const initial = await header.boundingBox(); + await page.getByRole("link", { name: "Back to CodeTutor" }).click(); + await expect(page.locator(".study-ready")).toBeVisible(); + release(); + await page.getByRole("navigation", { name: "Main navigation" }).getByRole("link", { name: "Sign in" }).click(); + await expect(page.getByRole("heading", { name: "Sign in", exact: true })).toBeVisible(); + expect(await header.boundingBox()).toEqual(initial); + } finally { + release(); + await page.unrouteAll({ behavior: "wait" }); + } +}); + +test("a canceled slow auth load keeps public navigation and cannot replace the restored homepage field", async ({page}) => { + let release!: () => void; + let intercepted = false; + const held = new Promise(resolve => { release = resolve; }); + await page.route(/\/(?:src\/pages\/LoginPage\.tsx|assets\/LoginPage-[^/]+\.js)(?:\?|$)/, async route => { + intercepted = true; + await held; + await route.continue(); + }); + try { + await page.emulateMedia({reducedMotion:"no-preference"}); + await page.goto("/"); + await expect(page.locator(".study-ready")).toBeVisible(); + const original = await page.locator(".motion-study-canvas canvas").elementHandle(); + await page.getByRole("navigation", {name:"Main navigation"}).getByRole("link", {name:"Sign in"}).click(); + // React may retain the outgoing page during a transition instead of showing + // Suspense's fallback. Assert the held request, not that presentation choice. + await expect.poll(() => intercepted).toBe(true); + await expect(page).toHaveURL(/\/login$/); + await expect(page.locator(".public-auth")).toHaveCount(0); + await expect(page.locator(".brand-header:visible")).toHaveCount(1); + await expect(page.getByRole("link", { name: "CodeTutor AI home" })).toBeVisible(); + expect(await original!.evaluate(node => node.isConnected)).toBe(true); + await page.goBack(); + release(); + await expect(page.locator(".study-ready")).toBeVisible(); + await expect(page.locator(".public-auth")).toHaveCount(0); + expect(await original!.evaluate(node => node === document.querySelector(".motion-study-canvas canvas"))).toBe(true); + await page.getByRole("navigation", {name:"Main navigation"}).getByRole("link", {name:"Sign in"}).click(); + await expect(page.getByRole("heading", {name:"Sign in",exact:true})).toBeVisible(); + expect(await original!.evaluate(node => node === document.querySelector(".motion-study-canvas canvas"))).toBe(true); + } finally { + release(); + await page.unrouteAll({behavior:"wait"}); + } +}); + +test("malformed public shares are unavailable links, not retryable connection failures", async ({ page }) => { + let lookups = 0; + page.on("request", request => { if (request.url().includes("/api/shares/")) lookups += 1; }); + for (const token of ["not-a-real-share", "short", "012345678901", "mine"]) { + await page.goto(`/s/${token}`); + const heading = page.getByRole("heading", { name: "Share not found", exact: true }); + await expect(heading).toBeVisible(); + await expect(heading).toBeFocused(); + await expect(page.getByRole("button", { name: "Try again" })).toHaveCount(0); + } + expect(lookups).toBe(0); + await page.route("**/api/shares/aaaaaaaaaaaa", route => route.fulfill({ status: 503, json: { error: "unavailable" } })); + await page.goto("/s/aaaaaaaaaaaa"); + await expect(page.getByRole("button", { name: "Try again" })).toBeVisible(); +}); + +test("missing discovery documents remain themed real 404s without JavaScript", async ({ browser }) => { + const context = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 320, height: 800 } }); + try { + const page = await context.newPage(); + for (const path of ["/learn-to-code/missing-course/", "/lessons/python-fundamentals/missing-lesson/", "/lessons/malformed/path/extra/"]) { + const response = await page.goto(path); + expect(response?.status()).toBe(404); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("This page isn't here."); + await expect(page.locator("body")).toHaveCSS("background-color", "rgb(5, 7, 9)"); + await expect(page.locator('meta[name="robots"]')).toHaveAttribute("content", "noindex,follow"); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320); + } + await page.getByRole("link", { name: "Browse public lessons" }).click(); + await expect(page).toHaveURL(/\/learn-to-code\/$/); + await expect(page.getByRole("heading", { level: 1 })).toContainText("Built to teach"); + } finally { await context.close(); } +}); + +test("static discovery is readable without JavaScript and motion stays optional", async ({ browser, page }) => { + const staticContext = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 390, height: 844 } }); + try { + const staticPage = await staticContext.newPage(); + await staticPage.goto("/lessons/python-intermediate/file-io/"); + await expect(staticPage.getByRole("heading", { level: 1 })).toContainText("File"); + await expect(staticPage.locator("article table")).toBeVisible(); + await expect(staticPage.locator("body")).toHaveCSS("background-color", "rgb(5, 7, 9)"); + await expect(staticPage.locator("canvas")).toHaveCount(0); + } finally { await staticContext.close(); } + const errors: string[] = []; + page.on("pageerror", error => errors.push(error.message)); + await page.goto("/learn-to-code/"); + await expect(page.locator("body")).toHaveAttribute("data-motion", "ready"); + await expect(page.locator("canvas")).toHaveCount(1); + for (const width of [1440, 390, 320]) { + await page.setViewportSize({ width, height: 900 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width); + } + await page.emulateMedia({ reducedMotion: "reduce" }); + await expect(page.locator("canvas")).toHaveCount(0); + await page.emulateMedia({ reducedMotion: "no-preference" }); + await expect(page.locator("body")).toHaveAttribute("data-motion", "ready"); + await expect(page.locator("canvas")).toHaveCount(1); + await expect(page.locator("#root")).toHaveCount(0); + expect(errors).toEqual([]); +}); + +test("a cold homepage fragment waits for content without replaying on later interaction", async ({ page, browserName }) => { + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + await page.route("**/MarketingHomepage.tsx*", async route => { await held; await route.continue(); }); + try { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/#study-demo", { waitUntil: "domcontentloaded" }); + await expect(page.getByRole("heading", { name: "Loading page", exact: true })).toBeAttached(); + } finally { release(); } + await expect(page.locator("#study-demo-title")).toBeVisible(); + const anchorError = () => page.locator("#study-demo").evaluate(el => + Math.abs(el.getBoundingClientRect().top - parseFloat(getComputedStyle(el).scrollMarginTop)), + ); + await expect.poll(anchorError).toBeLessThan(2); + const nextControl = browserName === "webkit" && process.platform === "darwin" ? "Alt+Tab" : "Tab"; + await page.keyboard.press(nextControl); + await expect(page.getByRole("button", { name: "01 Read", exact: true })).toBeFocused(); + await page.keyboard.press(nextControl); + await expect(page.getByRole("button", { name: "02 Ask", exact: true })).toBeFocused(); + await page.keyboard.press("Space"); + const offset = await page.evaluate(() => scrollY); + await page.emulateMedia({ reducedMotion: "reduce" }); + await expect(page.getByRole("button", { name: "02 Ask", exact: true })).toBeFocused(); + await expect(page.getByRole("button", { name: "02 Ask", exact: true })).toHaveAttribute("aria-pressed", "true"); + expect(await page.evaluate(() => scrollY)).toBe(offset); + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.reload(); + await expect.poll(anchorError).toBeLessThan(2); + await page.goto("/support"); + await page.goto("/#%E0%A4%A"); + await expect(page.locator("#study-title")).toBeVisible(); + expect(await page.evaluate(() => scrollY)).toBe(0); +}); + +test("a pending homepage fragment yields to visitor intent and later history", async ({ page, browserName }) => { + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + await page.route("**/MarketingHomepage.tsx*", async route => { await held; await route.continue(); }); + try { + await page.goto("/#study-demo", { waitUntil: "domcontentloaded" }); + await expect(page.getByRole("heading", { name: "Loading page", exact: true })).toBeAttached(); + await page.keyboard.press(browserName === "webkit" && process.platform === "darwin" ? "Alt+Tab" : "Tab"); + } finally { release(); } + await expect(page.locator("#study-title")).toBeVisible(); + await page.evaluate(() => document.fonts.ready); + expect(await page.evaluate(() => scrollY)).toBe(0); + await expect(page.getByRole("link", { name: "Skip to the product walkthrough", exact: true })).toBeFocused(); + await page.getByRole("link", { name: "See how learning happens", exact: true }).click(); + await expect.poll(() => page.locator("#study-demo").evaluate(el => + Math.abs(el.getBoundingClientRect().top - parseFloat(getComputedStyle(el).scrollMarginTop)), + )).toBeLessThan(2); + // Observe only programmatic replay; native Back may restore the fragment + // rather than the footer's offset, and remains owned by the browser. + await page.evaluate(() => { + const original = Element.prototype.scrollIntoView; + Element.prototype.scrollIntoView = function (...args) { + this.setAttribute("data-test-programmatic-scroll", "true"); + return original.apply(this, args); + }; + }); + await page.getByRole("navigation", { name: "Footer", exact: true }).getByRole("link", { name: "Sign in", exact: true }).click(); + await expect(page).toHaveURL(/\/login$/); + await page.goBack(); + await expect(page.locator("#study-title")).toBeAttached(); + await expect(page.locator("#study-demo")).not.toHaveAttribute("data-test-programmatic-scroll"); +}); + +for (const destination of ["main", "home"] as const) { + test(`homepage loading hands ${destination} focus to its final equivalent`, async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 600 }); + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + await page.route("**/MarketingHomepage.tsx*", async route => { await held; await route.continue(); }); + try { + await page.goto("/#study-demo", { waitUntil: "domcontentloaded" }); + await expect(page.getByRole("heading", { name: "Loading page", exact: true })).toBeAttached(); + if (destination === "main") { + await page.getByRole("link", { name: "Skip to content", exact: true }).focus(); + await page.keyboard.press("Enter"); + await expect(page.locator("#public-content")).toBeFocused(); + } else { + await page.keyboard.press("Tab"); // Cancel the pending automatic fragment. + await page.getByRole("link", { name: "Back to CodeTutor", exact: true }).focus(); + } + } finally { release(); } + const target = destination === "main" + ? page.locator("#study-title") + : page.getByRole("link", { name: "CodeTutor AI home", exact: true }); + await expect(target).toBeFocused(); + if (destination === "main") { + await expect(target).toBeInViewport(); + } else { + expect(await page.evaluate(() => scrollY)).toBe(0); + } + }); +} + +test("long discovery code stays within narrow reading widths without empty concept panels", async ({ page }) => { + await page.goto("/lessons/python-intermediate/capstone-mini-orm/"); + await expect(page.getByRole("heading", { level: 1 })).toContainText("Mini In-Memory ORM"); + await page.evaluate(() => document.fonts.ready); + for (const width of [320, 390, 1440]) { + await page.setViewportSize({ width, height: 844 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width); + await expect(page.locator("article code").filter({ hasText: 'User.objects.filter(role="admin")' })).toContainText('.order_by("-age").first()'); + expect(await page.locator(".hero .chip").evaluateAll(chips => chips.every(chip => + chip.getBoundingClientRect().width <= chip.parentElement!.getBoundingClientRect().width + 1, + ))).toBe(true); + } + await expect(page.locator(".side .note").filter({ hasText: "Concepts in this lesson" })).toHaveCount(0); + await expect(page.locator(".side").getByRole("link", { name: "Start with lesson 1 — required first →" })).toBeVisible(); +}); + +test("comparison keeps readable columns, navigation and the anonymous trial", async ({ + page, +}) => { + await page.goto("/why-not-chatgpt"); + for (const width of [1440, 390, 320]) { + await page.setViewportSize({ width, height: 900 }); + await expect(page.locator(".public-comparison-row")).toHaveCount(4); + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBeLessThanOrEqual(width); + const columns = page + .locator(".public-comparison-pair") + .first() + .locator(":scope > div"); + const first = (await columns.nth(0).boundingBox())!; + const second = (await columns.nth(1).boundingBox())!; + if (width > 760) expect(second.x).toBeGreaterThan(first.x + first.width); + else expect(second.y).toBeGreaterThanOrEqual(first.y + first.height); + } + await page.emulateMedia({ reducedMotion: "reduce" }); + await expect(page.locator("canvas")).toHaveCount(0); + await expect( + page.getByRole("heading", { name: "When ChatGPT is the better tool" }), + ).toBeAttached(); + await expect( + page + .getByRole("navigation", { name: "Product links" }) + .getByRole("link", { name: "Lessons", exact: true }), + ).toHaveAttribute("href", "/learn-to-code/"); + await page + .getByRole("link", { + name: "Judge for yourself — try lesson 1, no signup →", + }) + .click(); + await expect(page).toHaveURL( + /\/try\/lesson\/python-fundamentals\/hello-world$/, + ); + await expect(page.locator(".public-page")).toHaveCount(0); + await expect(page.locator("html")).not.toHaveAttribute("data-public-theme"); +}); + +test("trust route aliases keep their content and malformed anchors cannot crash it", async ({ + page, +}) => { + const errors: string[] = []; + const fullAppRequests: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("request", (request) => { + if (new URL(request.url()).pathname === "/src/App.tsx") { + fullAppRequests.push(request.url()); + } + }); + for (const path of [ + "/privacy/", + "/Privacy", + "/%70rivacy", + "/privacy#%E0%A4%A", + ]) { + await page.goto(path); + await expect( + page.getByRole("heading", { name: "How code and AI requests are used" }), + ).toBeAttached(); + await expect(page.locator("html")).toHaveAttribute("data-public-theme", ""); + await expect(page).toHaveTitle(/Privacy/); + } + expect(errors).toEqual([]); + expect(fullAppRequests).toEqual([]); +}); + +for (const [path, heading] of [ + ["/login", "Sign in"], + ["/this-route-does-not-exist", "This page isn't here."], +] as const) { + test(`public document ${path} paints correctly before the application can load`, async ({ + page, + }) => { + const appScript = /\/(?:src\/main\.tsx|assets\/index-[^/]+\.js)(?:\?|$)/; + await page.route(appScript, (route) => route.abort()); + await page.goto(path, { waitUntil: "domcontentloaded" }); + await expect(page.locator("#root")).toBeEmpty(); + await expect(page.locator("html")).toHaveAttribute("data-public-theme", ""); + await expect(page.locator("body")).toHaveCSS( + "background-color", + "rgb(5, 7, 9)", + ); + await page.unroute(appScript); + await page.reload(); + await expect( + page.getByRole("heading", { name: heading, exact: true }), + ).toBeVisible(); + await expect(page.locator("body")).toHaveCSS( + "background-color", + "rgb(5, 7, 9)", + ); + }); +} + +test("auth remains centered and usable through motion and mode changes", async ({ + page, +}) => { + await page.goto("/login"); + await expect( + page.getByRole("heading", { name: "Sign in", exact: true }), + ).toBeVisible(); + await expect(page.locator(".public-glyph")).toHaveCount(0); + await expect(page.locator('.public-auth-still svg')).toHaveCount(1); + await expect(page.locator('.public-auth-form')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + await expect(page.getByRole('button', { name: 'Sign in', exact: true })).toHaveCSS('opacity', '1'); + for (const width of [1440, 390, 320]) { + await page.setViewportSize({ width, height: 900 }); + const box = await page.locator(".public-auth-form").boundingBox(); + expect(box).not.toBeNull(); + expect(Math.abs(box!.x + box!.width / 2 - width / 2)).toBeLessThan(2); + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBeLessThanOrEqual(width); + } + await page.getByLabel("Email", { exact: true }).fill("invalid"); + await expect(page.getByText("Enter a valid email address.")).toBeVisible(); + await page.emulateMedia({ reducedMotion: "reduce", colorScheme: "light" }); + await expect(page.locator(".public-page")).toHaveAttribute( + "data-motion", + "static", + ); + await expect(page.locator("canvas")).toHaveCount(0); + await expect(page.locator('.public-auth-still')).toBeVisible(); + await expect(page.getByLabel("Email", { exact: true })).toHaveValue( + "invalid", + ); + await page + .getByRole("button", { name: "Prefer not to use a password?" }) + .click(); + await expect( + page.getByRole("button", { name: "Send magic link" }), + ).toBeDisabled(); + await page.getByRole("button", { name: "Use a password instead" }).click(); + await expect(page.getByLabel("Password", { exact: true })).toBeVisible(); + await expect(page.getByLabel("Email", { exact: true })).toHaveValue( + "invalid", + ); +}); + +test("public footer navigation starts at the heading and explicit anchors still focus", async ({ + page, +}) => { + await page.goto("/privacy"); + await page + .getByRole("navigation", { name: "Trust and support" }) + .getByRole("link", { name: "Support", exact: true }) + .click(); + await expect( + page.getByRole("heading", { name: "Let's get you unstuck.", exact: true }), + ).toBeInViewport(); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0); + await expect(page.getByRole("main")).toBeFocused(); + await page.goto("/privacy#ai"); + await expect(page.locator("#ai")).toBeFocused(); + await expect( + page.getByRole("heading", { name: "How code and AI requests are used" }), + ).toBeInViewport(); +}); diff --git a/e2e/specs/share-mobile.spec.ts b/e2e/specs/share-mobile.spec.ts index c391e1dd..5c58a1a3 100644 --- a/e2e/specs/share-mobile.spec.ts +++ b/e2e/specs/share-mobile.spec.ts @@ -97,7 +97,7 @@ test.describe("Phase 22E: SharePage at iPhone 13 portrait", () => { await page.goto(`/s/${shareToken}`); // Lesson title — the H1, gradient sweep settles within ~2.5s. - const title = page.getByRole("heading", { level: 1 }); + const title = page.getByRole("heading", { level: 1, name: "Hello, World!", exact: true }); await expect(title).toBeVisible({ timeout: 10_000 }); // Wait for the typewriter to finish typing — `print(greet("Mehul"))` @@ -134,7 +134,7 @@ test.describe("Phase 22E: SharePage at iPhone 13 portrait", () => { await page.goto(`/s/${shareToken}`); // Wait for content to render so visibility checks aren't racing // the lazy chrome. - await expect(page.getByRole("heading", { level: 1 })).toBeVisible({ + await expect(page.getByRole("heading", { level: 1, name: "Hello, World!", exact: true })).toBeVisible({ timeout: 10_000, }); @@ -170,7 +170,7 @@ test.describe("Phase 22E: SharePage reduced-motion at iPhone 13", () => { // dynamic-chunk time are not animation time; once the heading renders, // reduced motion must expose the full code in the same settled state // rather than starting the typewriter timeline. - await expect(page.getByRole("heading", { level: 1 })).toBeVisible({ + await expect(page.getByRole("heading", { level: 1, name: "Hello, World!", exact: true })).toBeVisible({ timeout: 10_000, }); await expect( diff --git a/e2e/specs/share-reveal.spec.ts b/e2e/specs/share-reveal.spec.ts new file mode 100644 index 00000000..3ca07e10 --- /dev/null +++ b/e2e/specs/share-reveal.spec.ts @@ -0,0 +1,87 @@ +import { expect, test } from "@playwright/test"; + +const longCode = Array.from({ length: 9 }, (_, i) => + `# Step ${i + 1}: ${"Read, predict, run, and compare. ".repeat(4)}`, +).concat('print("Finished learning")').join("\n"); + +test("short, empty and truncated shares preserve their content boundaries", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + let snippet = 'print("Hi!")'; + await page.route("**/api/shares/bbbbbbbbbbbb", route => route.fulfill({ json: { + shareToken: "bbbbbbbbbbbb", courseId: "python-fundamentals", lessonId: "hello-world", + lessonTitle: "Hello, World!", lessonOrder: 1, courseTitle: "Python Fundamentals", + courseTotalLessons: 12, mastery: "strong", timeSpentMs: 60000, attemptCount: 1, + codeSnippet: snippet, displayName: null, ogImageUrl: null, ogStoryImageUrl: null, + viewCount: 1, createdAt: "2026-09-01T00:00:00Z", + } })); + const code = page.locator(".public-share-artifact .overflow-x-auto > div"); + for (const source of ['print("Hi!")', "", Array.from({ length: 12 }, (_, i) => `# line ${i + 1}`).join("\n")]) { + snippet = source; + await page.goto("/s/bbbbbbbbbbbb"); + await expect(code).toBeVisible(); + await expect(code.locator(".animate-pulse")).toHaveCount(0, { timeout: 4000 }); + if (!source) await expect(code).toHaveText(""); + else if (source.startsWith("print")) await expect(code).toHaveText(source); + else { + await expect(code).toContainText("# line 10"); + await expect(code).not.toContainText("# line 11"); + await expect(code.getByText("…", { exact: true })).toBeVisible(); + const height = await code.evaluate(element => element.clientHeight); + await page.reload(); + await expect(code.locator(".animate-pulse")).toHaveCount(1); + await expect(code.getByText("…", { exact: true })).toBeHidden(); + expect(await code.evaluate(element => element.clientHeight)).toBe(height); + await page.emulateMedia({ reducedMotion: "reduce" }); + await expect(code.getByText("…", { exact: true })).toBeVisible(); + expect(await code.evaluate(element => element.clientHeight)).toBe(height); + } + } +}); + +for (const width of [390, 1440]) { + test(`long shares reserve their footprint and finish typing within five seconds at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.route("**/api/shares/aaaaaaaaaaaa", route => route.fulfill({ json: { + shareToken: "aaaaaaaaaaaa", courseId: "python-fundamentals", lessonId: "hello-world", + lessonTitle: "Hello, World!", lessonOrder: 1, courseTitle: "Python Fundamentals", + courseTotalLessons: 12, mastery: "strong", timeSpentMs: 60000, attemptCount: 1, + codeSnippet: longCode, displayName: null, ogImageUrl: null, ogStoryImageUrl: null, + viewCount: 1, createdAt: "2026-09-01T00:00:00Z", + } })); + await page.goto("/s/aaaaaaaaaaaa"); + const code = page.locator(".public-share-artifact .overflow-x-auto > div"); + await expect(code).toBeVisible(); + await page.evaluate(() => document.fonts.ready); + const cursor = code.locator(".animate-pulse"); + await expect(cursor).toHaveCount(1); + // Layout coordinates ignore the existing celebratory scale transform. + const footprint = () => code.evaluate(element => ({ + height: (element as HTMLElement).offsetHeight, + panelHeight: (element.parentElement!.parentElement as HTMLElement).offsetHeight, + footerTop: (element.closest(".public-share-artifact")!.querySelector(".border-t") as HTMLElement).offsetTop, + })); + const initial = await footprint(); + // Fine-grained observation: the default one-second late polling interval + // can miss completion near this deliberately tight five-second deadline. + await expect.poll(() => cursor.count(), { timeout: 5500, intervals: [50] }).toBe(0); + await expect(code).toContainText('"Finished learning"'); + expect(await footprint()).toEqual(initial); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width); + // Replay must start again with the same reserved footprint. + await page.reload(); + await expect(cursor).toHaveCount(1); + expect(await footprint()).toEqual(initial); + await page.emulateMedia({ reducedMotion: "reduce" }); + await expect(cursor).toHaveCount(0); + await expect(code).toContainText('"Finished learning"'); + await page.emulateMedia({ reducedMotion: "no-preference" }); + await expect(cursor).toHaveCount(0); + expect(await footprint()).toEqual(initial); + const cta = page.getByRole("link", { name: /Try this lesson/ }); + await cta.focus(); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/\/try\/lesson\/python-fundamentals\/hello-world/); + await expect(page.locator("html")).not.toHaveAttribute("data-public-theme"); + }); +} diff --git a/frontend/index.html b/frontend/index.html index 16e3ef82..b828f733 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,8 +3,61 @@ - + + + + + AI that builds you, not the code · CodeTutor AI - + - + @@ -34,8 +93,14 @@ name="twitter:description" content="An AI coding tutor for beginners. Walks you to the answer with hints and questions, never gives it away. Python, JavaScript, and a 9-language editor." /> - - + + "); + expect(html.indexOf('id="design-system-tokens"')).toBeLessThan(html.indexOf('id="public-theme-bootstrap"')); + expect(html).toContain(`isPublic ? "${publicCanvasColor}"`); + }); +}); diff --git a/frontend/src/design-system/tokens.ts b/frontend/src/design-system/tokens.ts new file mode 100644 index 00000000..dad48360 --- /dev/null +++ b/frontend/src/design-system/tokens.ts @@ -0,0 +1,102 @@ +/** Public brand decisions. CSS, first paint and static documents derive from + * this source; components consume roles, never a second copy of the palette. + * Workspace dark/light roles remain in index.css until separately migrated. */ +const dark = { + canvas: [5, 7, 9], + text: [236, 239, 241], + muted: [160, 168, 177], + faint: [140, 150, 160], + line: [37, 42, 48], + object: [16, 19, 22], + field: [12, 15, 18], + elevated: [19, 23, 27], + border: [49, 56, 64], + accent: [160, 217, 237], + code: [16, 25, 31], + tableHeader: [23, 28, 33], +} as const; + +const light = { + ...dark, + canvas: [248, 250, 252], + text: [15, 23, 42], + muted: [71, 85, 105], + line: [203, 213, 225], + object: [255, 255, 255], + accent: [8, 107, 145], +} as const; + +export const publicBrand = { + dark, + light, + headerHeight: "88px", + targetMin: "44px", + controlHeight: "48px", + controlRadius: "10px", + pillRadius: "24px", + readingFeather: "12px", + readingSpread: "8px", + fontUi: "Inter, system-ui, sans-serif", + fieldArrival: "800ms", +} as const; + +const hex = (rgb: readonly number[]) => + `#${rgb.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; + +export const publicCanvasColor = hex(publicBrand.dark.canvas); + +function paletteCss(palette: typeof dark | typeof light) { + return Object.entries(palette).map(([role, rgb]) => + `--brand-${role}-rgb: ${rgb.join(" ")};\n--brand-${role}: ${hex(rgb)};`, + ).join("\n"); +} + +/** Inline before first paint: no theme-fetch race or runtime token generator. */ +export function designTokenCss() { + return `:root { +${paletteCss(publicBrand.dark)} +--brand-header-height: ${publicBrand.headerHeight}; +--brand-target-min: ${publicBrand.targetMin}; +--brand-control-height: ${publicBrand.controlHeight}; +--brand-control-radius: ${publicBrand.controlRadius}; +--brand-pill-radius: ${publicBrand.pillRadius}; +--brand-reading-feather: ${publicBrand.readingFeather}; +--brand-reading-spread: ${publicBrand.readingSpread}; +--brand-font-ui: ${publicBrand.fontUi}; +--brand-field-arrival: ${publicBrand.fieldArrival}; +} +.motion-study.study-light { ${paletteCss(publicBrand.light)} } +.motion-study, .public-theme { +--color-bg: var(--brand-canvas-rgb); +--color-ink: var(--brand-text-rgb); +--color-muted: var(--brand-muted-rgb); +--study-bg: var(--brand-canvas); +--study-ink: var(--brand-text); +--study-muted: var(--brand-muted); +--study-line: var(--brand-line); +--study-panel: var(--brand-object); +--study-accent: var(--brand-accent); +--brand-reading-shadow: 0 0 var(--brand-reading-feather) var(--brand-reading-spread) var(--study-bg); +--brand-display-shadow: 0 1px 3px var(--study-bg), 0 0 12px var(--study-bg); +--brand-display-backing: radial-gradient(ellipse at center, var(--study-bg) 45%, transparent 75%); +} +.public-page { +--color-panel: var(--brand-field-rgb); +--color-elevated: var(--brand-elevated-rgb); +--color-border: var(--brand-border-rgb); +--color-border-soft: var(--brand-line-rgb); +--color-faint: var(--brand-faint-rgb); +--color-accent: var(--brand-accent-rgb); +--color-accent-ink: var(--brand-accent-rgb); +--color-success: 52 211 153; +--color-warn: 251 191 36; +--color-warn-ink: 251 191 36; +--color-danger: 248 113 113; +}`; +} + +export function renderDesignTokensHtml(html: string) { + return html + .replace("", ``) + .replaceAll("__PUBLIC_CANVAS_COLOR__", publicCanvasColor); +} diff --git a/frontend/src/features/marketing/public/AuthFieldStill.tsx b/frontend/src/features/marketing/public/AuthFieldStill.tsx new file mode 100644 index 00000000..09672810 --- /dev/null +++ b/frontend/src/features/marketing/public/AuthFieldStill.tsx @@ -0,0 +1,63 @@ +import { useLayoutEffect, useState } from "react"; +import { authContour, readingBounds, type AuthBounds } from "./authComposition"; +import { particleIdentity, particleSeed } from "../study/geometry"; + +const glyphs = ["{", "}", "<", ">", "[", "]", ";", "+"]; + +/** Geometry fallback exists before WebGL and survives reduced motion or failure. */ +export function AuthFieldStill({ root, reading = false }: { root: HTMLElement; reading?: boolean }) { + const [layout, setLayout] = useState<{ + width: number; + height: number; + content: AuthBounds; + } | null>(null); + useLayoutEffect(() => { + const content = root.querySelector(".public-content, main"); + if (!content) return; + let alive = true; + const measure = () => { + if (!alive) return; + const page = root.getBoundingClientRect(); + const rect = content.getBoundingClientRect(); + setLayout(reading ? { + width: innerWidth, height: innerHeight, + content: readingBounds(innerWidth, innerHeight, rect.width), + } : { + width: page.width, + height: page.height, + content: { left: rect.left - page.left, top: rect.top - page.top, + width: rect.width, height: rect.height }, + }); + }; + const observer = new ResizeObserver(measure); + observer.observe(root); + observer.observe(content); + window.addEventListener("resize", measure); + void document.fonts.ready.then(measure); + measure(); + return () => { + alive = false; + observer.disconnect(); + window.removeEventListener("resize", measure); + }; + }, [root, reading]); + if (!layout) return null; + const points = authContour(160, layout.width, layout.content); + const cx = layout.content.left + layout.content.width / 2; + const cy = layout.content.top + layout.content.height / 2; + return ( + + ); +} diff --git a/frontend/src/features/marketing/public/DiscoveryMotion.tsx b/frontend/src/features/marketing/public/DiscoveryMotion.tsx new file mode 100644 index 00000000..10c4c49c --- /dev/null +++ b/frontend/src/features/marketing/public/DiscoveryMotion.tsx @@ -0,0 +1,40 @@ +import { Component, lazy, Suspense, useEffect, useState, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { AuthFieldStill } from "./AuthFieldStill"; + +const ParticleField = lazy(() => import("../study/ParticleField")); + +// The authored document is not hydrated or owned by React. A failed download +// or renderer can only remove decoration, never lesson content or navigation. +class DecorationBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { + state = { failed: false }; + static getDerivedStateFromError() { return { failed: true }; } + render() { return this.state.failed ? null : this.props.children; } +} + +function DiscoveryMotion() { + const [reduced, setReduced] = useState(() => matchMedia("(prefers-reduced-motion: reduce)").matches); + useEffect(() => { + const media = matchMedia("(prefers-reduced-motion: reduce)"); + const update = () => setReduced(media.matches); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, []); + useEffect(() => { + if (reduced) document.body.dataset.motion = "static"; + }, [reduced]); + return <> + + {!reduced && ( + + + { document.body.dataset.motion = status; }} /> + + + )} + ; +} + +const mount = document.getElementById("discovery-motion"); +if (mount) createRoot(mount).render(); diff --git a/frontend/src/features/marketing/public/PublicMotionWorld.tsx b/frontend/src/features/marketing/public/PublicMotionWorld.tsx new file mode 100644 index 00000000..9f3c25d7 --- /dev/null +++ b/frontend/src/features/marketing/public/PublicMotionWorld.tsx @@ -0,0 +1,68 @@ +import { Component, createContext, lazy, Suspense, useCallback, useContext, + useEffect, useLayoutEffect, useMemo, useState, type ReactNode } from "react"; +import { useLocation } from "react-router-dom"; +import type { ParticleScene } from "../study/ParticleField"; +import "./world.css"; + +const ParticleField = lazy(() => import("../study/ParticleField")); +type Status = "loading" | "ready" | "unavailable"; +const World = createContext<{ + register: (scene: ParticleScene) => () => void; + status: Status; reduced: boolean; loadFailed: boolean; retry: () => void; +}>({ register: () => () => {}, status: "loading", reduced: false, loadFailed: false, retry: () => {} }); + +class GraphicsBoundary extends Component<{ + children: ReactNode; onFailure: () => void; +}, { failed: boolean }> { + state = { failed: false }; + static getDerivedStateFromError() { return { failed: true }; } + componentDidCatch() { this.props.onFailure(); } + render() { return this.state.failed ? null : this.props.children; } +} + +/** Only presentation lives here. Page components retain their own form state, + * guards, focus and cleanup; this host survives public route Suspense boundaries. */ +export function PublicMotionWorld({ children }: { children: ReactNode }) { + const { key } = useLocation(); + const [root, setRoot] = useState(null); + const [scene, setScene] = useState(null); + const [status, setStatus] = useState("loading"); + const [attempt, setAttempt] = useState(0); + const [loadFailed, setLoadFailed] = useState(false); + const [isPublic, setPublic] = useState(() => document.documentElement.hasAttribute("data-public-theme")); + const [reduced, setReduced] = useState(() => matchMedia("(prefers-reduced-motion: reduce)").matches); + useLayoutEffect(() => { + setPublic(document.documentElement.hasAttribute("data-public-theme")); + }, [key]); + useEffect(() => { + const query = matchMedia("(prefers-reduced-motion: reduce)"); + const update = () => { setStatus("loading"); setReduced(query.matches); }; + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); + const register = useCallback((next: ParticleScene) => { + setScene(next); + // An outgoing page cannot unregister a more recently mounted destination. + return () => setScene(current => current === next ? null : current); + }, []); + const retry = useCallback(() => { setStatus("loading"); setAttempt(n => n + 1); }, []); + const failed = useCallback(() => { setLoadFailed(true); setStatus("unavailable"); }, []); + const value = useMemo(() => ({register, status, reduced, loadFailed, retry}), [register, status, reduced, loadFailed, retry]); + return +
+ {root && isPublic && !reduced && + + + + } + {children} +
+
; +} + +export function usePublicMotionScene(root: HTMLElement | null, composition: ParticleScene["composition"]) { + const world = useContext(World); + const { register } = world; + useLayoutEffect(() => root ? register({root, composition}) : undefined, [root, composition, register]); + return world; +} diff --git a/frontend/src/features/marketing/public/PublicPage.tsx b/frontend/src/features/marketing/public/PublicPage.tsx new file mode 100644 index 00000000..6a97aad2 --- /dev/null +++ b/frontend/src/features/marketing/public/PublicPage.tsx @@ -0,0 +1,84 @@ +import { + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { Link, useLocation, useNavigationType } from "react-router-dom"; +import { Wordmark } from "../../../components/Wordmark"; +import { AuthFieldStill } from "./AuthFieldStill"; +import { usePublicMotionScene } from "./PublicMotionWorld"; +import "./public-page.css"; + +export function PublicPage({ + children, + className = "", + composition = "ambient", + focusOnNavigation = true, + headerAction, + footerLinks, + documentNavigation = false, +}: { + children: ReactNode; + className?: string; + composition?: "ambient" | "auth"; + focusOnNavigation?: boolean; + headerAction?: ReactNode; + footerLinks?: ReactNode; + documentNavigation?: boolean; +}) { + const main = useRef(null); + const { key, hash } = useLocation(); + const navigationType = useNavigationType(); + useLayoutEffect(() => { + if (focusOnNavigation && navigationType !== "POP" && !hash) { + main.current?.focus({ preventScroll: true }); + } + }, [key, hash, navigationType, focusOnNavigation]); + const [root, setRoot] = useState(null); + const {status, reduced} = usePublicMotionScene(root, composition); + // A loading fallback must be escapable even if the full app bundle never + // arrives. Its navigation uses ordinary documents, not that pending router. + const publicLink = (href: string, children: ReactNode, props = {}) => + documentNavigation ? {children} : {children}; + return ( +
+ {root && composition === "auth" && } + {root && composition === "ambient" && } + + Skip to content + +
+ {publicLink("/", , { "aria-label": "CodeTutor AI home" })} + {headerAction ?? ( + publicLink("/", <> + Back to CodeTutor + + + , { className: "brand-header-action public-back", "aria-label": "Back to CodeTutor" }) + )} +
+
+ {children} +
+
+ © {new Date().getFullYear()} Mehul Srivastava + {footerLinks} + +
+
+ ); +} diff --git a/frontend/src/features/marketing/public/PublicThemeSync.tsx b/frontend/src/features/marketing/public/PublicThemeSync.tsx new file mode 100644 index 00000000..1087faac --- /dev/null +++ b/frontend/src/features/marketing/public/PublicThemeSync.tsx @@ -0,0 +1,103 @@ +import { useLayoutEffect, useRef, useSyncExternalStore } from "react"; +import { useLocation, useNavigationType } from "react-router-dom"; + +function subscribePublicTheme(notify: () => void) { + window.addEventListener("codetutor:route-change", notify); + window.addEventListener("popstate", notify); + return () => { + window.removeEventListener("codetutor:route-change", notify); + window.removeEventListener("popstate", notify); + }; +} + +/** Reuse the pre-paint classifier; don't maintain a second workspace route list. */ +export function useIsPublicTheme() { + return useSyncExternalStore( + subscribePublicTheme, + () => document.documentElement.hasAttribute("data-public-theme"), + () => false, + ); +} + +/** The inline pre-paint script owns the classifier, including on reload. */ +export function PublicThemeSync() { + const location = useLocation(); + const { pathname, hash, key } = location; + const navigationType = useNavigationType(); + const initialLocation = useRef(location); + const fragmentSettled = useRef(false); + useLayoutEffect(() => { + if (fragmentSettled.current) return; + const initial = initialLocation.current; + const navigation = performance.getEntriesByType("navigation")[0] as + | PerformanceNavigationTiming + | undefined; + if ( + initial.pathname !== "/" || !initial.hash || + location.key !== initial.key || location.pathname !== initial.pathname || + location.search !== initial.search || location.hash !== initial.hash || + navigation?.type === "back_forward" + ) { + fragmentSettled.current = true; + return; + } + let id: string; + try { + id = decodeURIComponent(initial.hash.slice(1)); + } catch { + fragmentSettled.current = true; + return; + } + + // Native fragment scrolling can run before the lazy homepage exists. + // Wait for its actual content, not its graphics, and never replay a scroll + // after the visitor takes control or navigates elsewhere (including Back). + const events = ["pointerdown", "touchstart", "wheel", "keydown"] as const; + const cleanup = () => { + observer.disconnect(); + events.forEach(event => window.removeEventListener(event, cancel, true)); + }; + const cancel = () => { + fragmentSettled.current = true; + cleanup(); + }; + const restore = () => { + const homepage = document.querySelector('[data-marketing="glyph-homepage"]'); + if (!homepage) return; + cancel(); + const target = document.getElementById(id); + if (target && homepage.contains(target)) { + target.scrollIntoView({ behavior: "instant", block: "start" }); + // WebKit does not move its sequential keyboard starting point when a + // late fragment is only scrolled. Match the native anchor handoff. + // Focus the section's heading, not its full multi-screen layout box. + const focusTarget = target.matches("section") + ? target.querySelector("h1, h2") ?? target + : target; + if (!focusTarget.hasAttribute("tabindex")) focusTarget.tabIndex = -1; + focusTarget.focus({ preventScroll: true }); + } + }; + const observer = new MutationObserver(restore); + observer.observe(document.body, { childList: true, subtree: true }); + events.forEach(event => window.addEventListener(event, cancel, { capture: true, passive: true })); + restore(); + // StrictMode may re-arm an unfulfilled request; only fulfillment or actual + // visitor intent consumes it, not effect cleanup itself. + return cleanup; + }, [location]); + useLayoutEffect(() => { + window.dispatchEvent(new Event("codetutor:route-change")); + // New public destinations start with their heading, even when the link + // was in a long page's footer. Leave history restoration and explicit + // anchors to the browser/owning page; never move an internal workspace. + if ( + navigationType !== "POP" && + !hash && + document.documentElement.hasAttribute("data-public-theme") + ) { + window.scrollTo({ top: 0, left: 0, behavior: "instant" }); + } + }, [pathname, hash, key, navigationType]); + return null; +} diff --git a/frontend/src/features/marketing/public/RouteLoading.tsx b/frontend/src/features/marketing/public/RouteLoading.tsx new file mode 100644 index 00000000..c2519f34 --- /dev/null +++ b/frontend/src/features/marketing/public/RouteLoading.tsx @@ -0,0 +1,73 @@ +import { useLayoutEffect } from "react"; +import { useLocation } from "react-router-dom"; +import { PublicPage } from "./PublicPage"; +import { useIsPublicTheme } from "./PublicThemeSync"; + +/** Public route waits keep the same navigation and canvas as the destination. + * Workspace waits retain their existing presentation; no auth state is read. */ +export function RouteLoading({ fullHeight = false }: { fullHeight?: boolean }) { + const isPublic = useIsPublicTheme(); + const { pathname, search, hash } = useLocation(); + useLayoutEffect(() => { + if (!isPublic) return; + return () => { + const active = document.activeElement; + if (!(active instanceof HTMLElement) || !active.closest(".public-route-loading")) return; + const link = active instanceof HTMLAnchorElement ? active : null; + const mainFocused = active.id === "public-content"; + if (!link && !mainFocused) return; + // Suspense replaces its fallback in the same commit. Restore only an + // equivalent control on this destination, and never override new focus. + requestAnimationFrame(() => { + if (window.location.pathname + window.location.search + window.location.hash !== pathname + search + hash) return; + if (document.activeElement !== document.body) return; + // Nested lazy boundaries can replace one fallback with another before + // the destination is ready. Hand focus through that shell too; its own + // cleanup will transfer it when the final page arrives. + const page = Array.from(document.querySelectorAll('.public-page, [data-marketing="glyph-homepage"]')) + .find(candidate => candidate.getClientRects().length > 0); + let target = mainFocused + ? page?.querySelector("#public-content") + : Array.from(page?.querySelectorAll("a") ?? []).find(candidate => + candidate.href === link!.href && + candidate.getAttribute("aria-label") === link!.getAttribute("aria-label") && + candidate.textContent === link!.textContent, + ); + // The homepage deliberately has its own editorial layout, but its + // skip, home and main destinations are equivalents of this shell's. + if (!target && page?.matches('[data-marketing="glyph-homepage"]')) { + if (mainFocused) target = page.querySelector("#study-title"); + else if (link?.classList.contains("public-skip")) target = page.querySelector(".study-skip"); + else if (link?.origin === window.location.origin && link.pathname === "/" && !link.search && !link.hash) { + target = page.querySelector('[aria-label="CodeTutor AI home"]'); + } + } + // An activated skip requested readable content, not just offscreen + // focus below the homepage artwork. Equivalent header links stay put. + const revealHomepageMain = mainFocused && page?.matches('[data-marketing="glyph-homepage"]'); + target?.focus({ preventScroll: !revealHomepageMain }); + }); + }; + }, [isPublic, pathname, search, hash]); + if (!isPublic) { + return ( +
+ +
+ ); + } + const auth = /^\/(?:login|signup|reset-password|auth\/callback)\/?$/i.test(pathname); + return ( + +
+

Loading page

+

Loading…

+
+
+ ); +} diff --git a/frontend/src/features/marketing/public/authComposition.test.ts b/frontend/src/features/marketing/public/authComposition.test.ts new file mode 100644 index 00000000..84b6423e --- /dev/null +++ b/frontend/src/features/marketing/public/authComposition.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { authContour, readingBounds } from "./authComposition"; + +describe("public reading composition", () => { + it.each([[320, 740], [1440, 900], [3840, 2160]])("keeps the living field centered and bounded at %i x %i", (width, height) => { + const bounds = readingBounds(width, height, 760); + expect(bounds.left + bounds.width / 2).toBe(width / 2); + expect(bounds.top + bounds.height / 2).toBe(height / 2); + expect(bounds.width).toBeLessThanOrEqual(width - 40); + expect(bounds.height).toBeLessThanOrEqual(960); + const points = authContour(420, width, bounds, 40); + expect(points).not.toEqual(authContour(420, width, bounds, 45)); + expect(Array.from(points).every(Number.isFinite)).toBe(true); + }); +}); + +describe("centered auth composition", () => { + it.each([[390, 600], [1280, 620], [3840, 760], [320, 1200]])( + "keeps finite deterministic surrounding material at width %i and content height %i", + (width, height) => { + const bounds = { left: 20, top: 88, width: Math.min(420, width - 40), height }; + const points = authContour(420, width, bounds); + expect(points).toEqual(authContour(420, width, bounds)); + expect(points.length).toBe(1260); + expect(Array.from(points).every(Number.isFinite)).toBe(true); + const xs = Array.from(points).filter((_, i) => i % 3 === 0); + const ys = Array.from(points).filter((_, i) => i % 3 === 1); + expect(Math.min(...xs)).toBeLessThan(-Math.min(width * 0.3, 300)); + expect(Math.max(...xs)).toBeGreaterThan(Math.min(width * 0.3, 300)); + expect(Math.max(...xs.map(Math.abs))).toBeLessThan(width / 2); + expect(Math.min(...ys)).toBeLessThan(-height * 0.4); + expect(Math.max(...ys)).toBeGreaterThan(height * 0.4); + }, + ); + it("does not stretch a short form's contour across a 4K display", () => { + const bounds = { left: 0, top: 0, width: 420, height: 480 }; + expect(authContour(160, 3840, bounds)).toEqual(authContour(160, 1920, bounds)); + }); + it("adapts to expanded content without changing horizontal identity", () => { + const base = { left: 430, top: 120, width: 420, height: 550 }; + const a = authContour(160, 1280, base); + const b = authContour(160, 1280, { ...base, height: 850 }); + for (let i = 0; i < 160; i++) { + expect(a[i * 3]).toBe(b[i * 3]); + expect(a[i * 3 + 2]).toBe(b[i * 3 + 2]); + expect(Math.abs(b[i * 3 + 1]!)).toBeGreaterThanOrEqual(Math.abs(a[i * 3 + 1]!)); + } + }); + it("flows continuously without resetting identities or allocating a new pool", () => { + const bounds = {left:0, top:0, width:420, height:620}; + const a = authContour(420, 1280, bounds, 30); + const b = authContour(420, 1280, bounds, 30 + 1 / 60); + const later = authContour(420, 1280, bounds, 35); + expect(Math.max(...a.map((v,i) => Math.abs(v-b[i]!)))).toBeLessThan(3); + expect(Math.max(...a.map((v,i) => Math.abs(v-later[i]!)))).toBeGreaterThan(20); + const output = new Float32Array(420 * 3); + expect(authContour(420, 1280, bounds, 35, output)).toBe(output); + expect(output).toEqual(later); + }); +}); diff --git a/frontend/src/features/marketing/public/authComposition.ts b/frontend/src/features/marketing/public/authComposition.ts new file mode 100644 index 00000000..913fb12b --- /dev/null +++ b/frontend/src/features/marketing/public/authComposition.ts @@ -0,0 +1,41 @@ +import { particleSeed } from "../study/geometry"; + +export interface AuthBounds { + left: number; + top: number; + width: number; + height: number; +} + +/** Reading pages keep the field in the current viewport, not stretched around + * a multi-screen document. They share auth's material, clock and pointer physics. */ +export function readingBounds(width: number, height: number, contentWidth: number): AuthBounds { + const readingWidth = Math.max(1, Math.min(contentWidth, width - 40)); + const readingHeight = Math.max(1, Math.min(height * 0.82, 960)); + return { left: (width - readingWidth) / 2, top: (height - readingHeight) / 2, + width: readingWidth, height: readingHeight }; +} + +/** The homepage material opens into a living clearing, not a second sculpture. + * Each seeded glyph travels with the shared clock through a broad, uneven stream. + * No form values, input state, timers or per-page animation clocks are involved. + * The fallback is the same field at time zero. */ +export function authContour(count: number, viewportWidth: number, content: AuthBounds, seconds = 0, output?: Float32Array) { + const points = output ?? new Float32Array(count * 3); + // Foreground belongs to the centered task, not the monitor's outer edges. + // The distant field independently retains the homepage's area-based density. + const horizontal = Math.max(1, Math.min(viewportWidth * 0.48, content.width * 1.55)); + const vertical = Math.max(180, content.height * 0.76); + for (let i = 0; i < count; i++) { + const seed = particleSeed(i); + const angle = i * 2.399963229728653 + seconds * (0.025 + seed * 0.009); + const depth = 0.57 + particleSeed(i + 7919) * 0.38; + const x = Math.sin(angle); + const y = Math.cos(angle); + // Softly squared flow gives text room without tracing a rigid frame. + points[i * 3] = Math.sign(x) * Math.pow(Math.abs(x), 0.65) * horizontal * depth; + points[i * 3 + 1] = y * vertical * depth; + points[i * 3 + 2] = Math.sin(angle + seed * 6.28) * 120 * depth; + } + return points; +} diff --git a/frontend/src/features/marketing/public/discovery-theme.css b/frontend/src/features/marketing/public/discovery-theme.css new file mode 100644 index 00000000..a7362cf2 --- /dev/null +++ b/frontend/src/features/marketing/public/discovery-theme.css @@ -0,0 +1,63 @@ +/* Shared tokens are embedded ahead of this sheet by the static generator. */ +body.public-theme { + --bg: var(--study-bg); + --panel: var(--study-panel); + --ink: var(--study-ink); + --muted: var(--study-muted); + --faint: var(--brand-faint); + --line: var(--study-line); + --accent: var(--study-accent); + background: var(--bg); + isolation: isolate; +} +#discovery-motion { position: fixed; inset: 0; z-index: -1; pointer-events: none; } +.motion-study-canvas { position: fixed; inset: 0; width: 100%; height: 100%; opacity: 0; transition: opacity var(--brand-field-arrival) ease; } +body[data-motion="ready"] .motion-study-canvas { opacity: 1; } +.public-theme .shell { width: min(1120px, calc(100% - 80px)); } +.public-theme :is(h1,h2,h3) { font-weight: 500; } +.public-theme h1 { font-size: clamp(40px, 6vw, 76px); max-width: 18ch; text-wrap: balance; } +.public-theme .primary-link { background: var(--ink); color: var(--bg); text-align: center; justify-content: center; text-wrap: balance; } +.public-theme .primary-link:hover { background: var(--accent); } +.public-theme a:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; } +.public-theme :is(.hero,.footer) { border-color: var(--line); } +.public-theme :is(.hero > .shell, main > .shell) { background: transparent; } +.public-theme .hero > .shell > *, +.public-theme main > section.shell > :is(.eyebrow,h2), +.public-theme .prose > :is(h1,h2,h3,p,ul,ol,blockquote), +.public-theme .footer-row > *, +.public-theme .nav-links > .quiet-link { + background: var(--bg); + box-shadow: var(--brand-reading-shadow); +} +/* Protect section text, not the open interval around the course library. */ +.public-theme main > section.shell > :is(.eyebrow,h2) { + width: fit-content; + max-width: 100%; +} +.public-theme .hero { border: 0; } +.public-theme .recovery { min-height: 60vh; display: grid; align-items: center; } +.public-theme .hero > .shell { padding-block: 24px; } +.public-theme .content-grid { padding-inline: 24px; width: min(1168px, calc(100% - 32px)); } +.public-theme :is(.course-card,.lesson-card,.note,.prose pre,.prose table) { background: var(--panel); box-shadow: none; } +.public-theme .course-card { min-height: 260px; } +.public-theme :is(a.course-card,a.lesson-card):hover { border-color: var(--accent); } +.public-theme :is(article.course-card,article.lesson-card):hover { transform: none; border-color: var(--line); } +.public-theme :is(.prose p,.prose li,.prose th,.prose td) { color: var(--ink); } +.public-theme .prose code { color: var(--accent); background: var(--brand-code); border-color: var(--line); } +.public-theme .prose pre code { color: var(--ink); background: none; } +/* Long inline expressions wrap as prose; preformatted examples keep scrolling. */ +.public-theme .prose :not(pre) > code { overflow-wrap: anywhere; } +/* Markdown inside a chip is one text flow, not anonymous flex columns. */ +.public-theme .chip { display: block; max-width: 100%; overflow-wrap: anywhere; } +.public-theme .prose th { background: var(--brand-tableHeader); } +.public-theme .footer { background: transparent; } +.public-theme .footer a { display: inline-flex; align-items: center; min-height: 44px; } +.public-theme #main:focus { outline: none; } +.public-theme #main:focus-visible h1 { outline: 2px solid var(--accent); outline-offset: 8px; } +@media(max-width:760px) { + .public-theme .shell { width: calc(100% - 40px); } + .public-theme .hero { padding-block: 24px; } + .public-theme .content-grid { padding-inline: 12px; width: calc(100% - 16px); } + .public-theme h1 { font-size: clamp(40px, 10vw, 60px); } +} +@media(prefers-reduced-motion:reduce) { .motion-study-canvas { transition: none; } } diff --git a/frontend/src/features/marketing/public/public-page.css b/frontend/src/features/marketing/public/public-page.css new file mode 100644 index 00000000..e36bba59 --- /dev/null +++ b/frontend/src/features/marketing/public/public-page.css @@ -0,0 +1,358 @@ +@import "./theme.css"; +.public-page { + position: relative; + isolation: isolate; + min-height: 100svh; + display: flex; + flex-direction: column; + color-scheme: dark; + color: var(--study-ink); + background: var(--study-bg); + overflow: clip; +} +.public-footer { + position: relative; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 40px; + background: transparent; +} +.public-footer a { + display: inline-flex; + align-items: center; + min-height: 44px; + border-radius: 24px; +} +.public-footer { + border-top: 1px solid var(--study-line); + font-size: 12px; + color: var(--study-muted); +} +.public-footer nav { + display: flex; + flex-wrap: wrap; + gap: 8px 24px; +} +.public-page :is(a, button, input, textarea):focus-visible { + outline: 2px solid var(--study-accent); + outline-offset: 4px; +} +.public-page :is(a, button) { + -webkit-tap-highlight-color: transparent; +} +.public-page a:hover { + color: var(--study-accent); +} +.public-content { + width: min(1120px, calc(100% - 80px)); + margin: auto; + padding-block: 72px; + flex: 1; + outline: none; +} +.public-route-loading .public-content { + display: grid; + place-items: center; +} +.public-route-loading [role="status"] { + background: var(--study-bg); + box-shadow: var(--brand-reading-shadow); +} +.public-content:focus-visible h1 { + outline: 2px solid var(--study-accent); + outline-offset: 8px; + border-radius: 2px; +} +.public-page h1 { + font-family: "Fraunces", "Iowan Old Style", Georgia, serif; + font-size: clamp(40px, 5vw, 68px); + font-weight: 500; + line-height: 1.08; + letter-spacing: -0.035em; + text-wrap: balance; +} +.public-eyebrow { + font-family: ui-monospace, monospace; + font-size: 11px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--study-accent); +} +.public-skip { + position: absolute; + left: 16px; + top: 12px; + z-index: 10; + padding: 12px 18px; + transform: translateY(-180%); + background: var(--study-panel); + border-radius: 8px; +} +.public-skip:focus { + transform: none; +} +.public-auth .public-content { + width: min(420px, calc(100% - 40px)); + margin: 0 auto; + flex: none; + padding-block: 48px; +} +.public-auth .public-footer { margin-top: auto; } +.public-auth-intro { + text-align: center; +} +.public-auth-intro h1 { + font-size: clamp(36px, 5vw, 48px); +} +.public-auth-intro > p { + margin-top: 16px; + color: var(--study-muted); + font-size: 16px; + line-height: 1.65; + overflow-wrap: anywhere; +} +.public-auth-form { + position: relative; + min-width: 0; + padding: 24px 0; +} +/* Reading/control clusters, not the form's full bounding box, own protection. */ +.public-auth-intro > *, +.public-auth-form > :not(form), +.public-auth-form form > *, +.public-footer > span, +.public-footer nav > *, +.public-reading-header > div:first-child > *, +.public-reading-body section > h2, +.public-reading-body section > div > :is(p, ul, ol), +.public-reading-body > a, +.public-recovery-copy > :is(h1, p, .public-eyebrow), +.public-comparison-copy > :is(h1, p), +.public-comparison-copy section > :is(h2, p, ul), +.public-comparison-pair > div > *, +.public-comparison-copy > div:last-child > p { + background-color: var(--study-bg); + box-shadow: var(--brand-reading-shadow); +} +.public-auth :is(.public-header, .public-footer) { + background: transparent; +} +.public-auth-still { + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; + overflow: hidden; + color: var(--study-accent); +} +.public-auth-still svg { + width: 100%; + height: 100%; +} +.public-auth[data-motion="ready"] .public-auth-still { + visibility: hidden; +} +.public-auth-form input:not([type="checkbox"]):not([type="radio"]) { + font-size: 16px; + min-height: var(--brand-control-height); + border-radius: var(--brand-control-radius); +} +.public-auth-form label { + font-size: 14px; +} +/* Equivalent instructions share a role across auth modes. Scope the recipe + to the public form: the signup component also serves workspace dialogs. */ +.public-auth-form :is(.auth-email-divider, .auth-supporting-copy) { + font-size: 14px; + line-height: 1.5; +} +.public-auth-form :is(.text-danger, [role="alert"]) { + font-size: 14px; + line-height: 1.5; +} +.public-auth-form button[type="submit"] { + background: var(--study-ink); + color: var(--study-bg); + border-radius: var(--brand-pill-radius); + min-height: var(--brand-control-height); +} +.public-auth-form button[type="submit"]:disabled { + opacity: 1; + background: color-mix(in srgb, var(--study-ink) 38%, var(--study-bg)); + color: var(--study-bg); +} +.public-auth-footer { + border-top: 1px solid var(--study-line); + margin-top: 28px; + padding-top: 16px; + text-align: center; + color: var(--study-muted); + font-size: 14px; +} +.public-auth-footer :is(a, button) { + display: inline-flex; + align-items: center; + min-height: 44px; + padding-inline: 6px; + border-radius: 8px; +} +.public-reading-header { + display: grid; + grid-template-columns: minmax(0, 1fr); + max-width: 760px; + gap: 48px; + align-items: center; +} +.public-reading-header h1 { + margin-top: 18px; +} +.public-reading-header .public-intro { + margin-top: 24px; + color: var(--study-muted); + font-size: 18px; + line-height: 1.8; +} +.public-reading-body { + position: relative; + max-width: 760px; + padding: 40px 32px; + margin: 32px -32px 0; + background: transparent; +} +.public-reading-body h2 { + font-family: "Fraunces", Georgia, serif; + font-weight: 500; +} +.public-action { + --public-action-fill: var(--study-ink); + --public-action-hover-fill: var(--study-accent); + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 48px; + padding: 12px 24px; + border: 1px solid var(--public-action-fill); + border-radius: 32px; + background: var(--public-action-fill); + color: var(--study-bg); + font-size: 14px; + font-weight: 600; + line-height: 1.6; + text-align: center; + text-wrap: balance; + transition: background-color 160ms ease, border-color 160ms ease; +} +.public-action--accent { + --public-action-fill: var(--study-accent); + --public-action-hover-fill: var(--study-ink); +} +.public-page .public-action:hover { + color: var(--study-bg); + background: var(--public-action-hover-fill); + border-color: var(--public-action-hover-fill); +} +.public-recovery .public-content { + display: flex; + align-items: center; +} +.public-recovery-copy { + position: relative; + padding: 16px; + margin: -16px; + background: transparent; +} +.public-share .public-content { + max-width: 1000px; + min-width: 0; +} +.public-share-artifact { + min-width: 0; + padding: 24px; + margin: -24px; + background: var(--study-bg); + overflow-wrap: anywhere; +} +.public-share h1 { + font-size: clamp(36px, 5vw, 60px); +} +.share-recovery { + max-width: 760px; + margin-block: 48px; +} +.public-comparison .public-content { + max-width: 920px; +} +.public-comparison-copy { + padding: 0 24px; + margin-inline: -24px; + background: transparent; + line-height: 1.85; +} +.public-comparison-copy .public-intro { + max-width: 740px; + color: var(--study-muted); + font-size: 18px; +} +.public-comparison-copy h2 { + font-family: "Fraunces", Georgia, serif; + font-size: clamp(24px, 3vw, 30px); + line-height: 1.35; + font-weight: 500; +} +.public-comparison-rows { + margin-top: 64px; +} +.public-comparison-row { + padding-block: 32px; + border-top: 1px solid var(--study-line); +} +.public-comparison-pair { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 40px; + margin-top: 24px; +} +.public-comparison-concession { + margin-block: 32px; + padding-block: 32px; + border-block: 1px solid var(--study-line); +} +@media (max-width: 760px) { + .public-comparison-copy { + margin-inline: -12px; + padding-inline: 12px; + } + .public-comparison-pair { + grid-template-columns: 1fr; + gap: 24px; + } + .public-footer { + padding: 14px 20px; + } + .public-content { + width: calc(100% - 40px); + padding-block: 36px; + } + .public-auth .public-content { + padding-block: 36px; + } + .public-reading-header { + grid-template-columns: 1fr; + gap: 16px; + } + .public-reading-body { + margin: 16px -12px 0; + padding: 16px 12px; + } +} +@media (prefers-reduced-motion: reduce) { + .public-page *, + .public-page *::before, + .public-page *::after { + scroll-behavior: auto !important; + transition: none !important; + } +} diff --git a/frontend/src/features/marketing/public/publicBootstrap.test.ts b/frontend/src/features/marketing/public/publicBootstrap.test.ts new file mode 100644 index 00000000..30a2ea4f --- /dev/null +++ b/frontend/src/features/marketing/public/publicBootstrap.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + shouldDeferAuthHydration, + shouldUsePublicApp, +} from "./publicBootstrap"; + +describe("public route bootstrap", () => { + it.each([ + "/", + "/why-not-chatgpt", + "/privacy", + "/terms", + "/support", + "/login", + "/signup", + "/reset-password", + "/auth/callback", + "/s/qd99cvtcbwdn", + "/try/lesson/python-fundamentals/hello-world", + ])("uses the lightweight public route tree for direct entry %s", (path) => { + expect(shouldUsePublicApp(path)).toBe(true); + }); + + it.each([ + "/login/", + "/Signup", + "/AUTH/CALLBACK/", + "/%70rivacy", + "/%53/qd99cvtcbwdn", + "/%54RY/LESSON/python-fundamentals/hello-world/", + "/S/qd99cvtcbwdn/", + "/TRY/LESSON/python-fundamentals/hello-world/", + ])( + "matches React Router's casing and trailing-slash behavior for %s", + (path) => { + expect(shouldUsePublicApp(path)).toBe(true); + }, + ); + + it.each([ + "/editor", + "/start", + "/learn/course/python-fundamentals", + "/admin/project", + "/this-route-does-not-exist", + ])("keeps the full route tree for %s", (path) => { + expect(shouldUsePublicApp(path)).toBe(false); + }); + + it("leaves malformed percent escapes to the full router without throwing", () => { + expect(shouldUsePublicApp("/%E0%A4%A")).toBe(false); + expect(shouldDeferAuthHydration("/%E0%A4%A")).toBe(false); + }); + + it("defers auth only on acquisition and trust entries", () => { + for (const path of [ + "/", + "/why-not-chatgpt", + "/privacy", + "/terms", + "/support", + ]) { + expect(shouldDeferAuthHydration(path), path).toBe(true); + } + + for (const path of [ + "/login", + "/signup", + "/reset-password", + "/auth/callback", + "/s/qd99cvtcbwdn", + "/try/lesson/python-fundamentals/hello-world", + ]) { + expect(shouldDeferAuthHydration(path), path).toBe(false); + } + }); + + it.each(["/PRIVACY", "/terms/", "/Support/", "/%70rivacy"])( + "normalizes deferred auth path %s", + (path) => { + expect(shouldDeferAuthHydration(path)).toBe(true); + }, + ); +}); diff --git a/frontend/src/features/marketing/public/publicBootstrap.ts b/frontend/src/features/marketing/public/publicBootstrap.ts new file mode 100644 index 00000000..217ebac9 --- /dev/null +++ b/frontend/src/features/marketing/public/publicBootstrap.ts @@ -0,0 +1,54 @@ +const PUBLIC_APP_EXACT_PATHS = new Set([ + "/", + "/why-not-chatgpt", + "/privacy", + "/terms", + "/support", + "/login", + "/signup", + "/reset-password", + "/auth/callback", +]); + +const PUBLIC_APP_PREFIXES = ["/s/", "/try/lesson/"]; + +const DEFERRED_AUTH_PATHS = new Set([ + "/", + "/why-not-chatgpt", + "/privacy", + "/terms", + "/support", +]); + +function normalizePublicPathname(pathname: string): string { + let decodedPathname = pathname; + try { + decodedPathname = decodeURI(pathname); + } catch { + // Match the pre-paint classifier: malformed escapes stay router-owned. + } + const lowerPathname = decodedPathname.toLowerCase(); + if (lowerPathname === "/") return lowerPathname; + return lowerPathname.replace(/\/+$/, ""); +} + +/** + * Direct entries that can paint through the lightweight public route tree. + * + * The anonymous lesson deliberately uses the workspace visual theme, but it is + * still a logged-out direct entry and does not need the authenticated app shell. + * App.tsx retains the same public routes for SPA navigation after a workspace + * entry has already selected the full route tree. + */ +export function shouldUsePublicApp(pathname: string): boolean { + const normalizedPathname = normalizePublicPathname(pathname); + if (PUBLIC_APP_EXACT_PATHS.has(normalizedPathname)) return true; + return PUBLIC_APP_PREFIXES.some((prefix) => + normalizedPathname.startsWith(prefix), + ); +} + +/** Acquisition and trust pages can defer session hydration until entry intent. */ +export function shouldDeferAuthHydration(pathname: string): boolean { + return DEFERRED_AUTH_PATHS.has(normalizePublicPathname(pathname)); +} diff --git a/frontend/src/features/marketing/public/publicTheme.test.ts b/frontend/src/features/marketing/public/publicTheme.test.ts new file mode 100644 index 00000000..d058535d --- /dev/null +++ b/frontend/src/features/marketing/public/publicTheme.test.ts @@ -0,0 +1,103 @@ +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import { describe, expect, it } from "vitest"; +import { renderDesignTokensHtml } from "../../../design-system/tokens"; + +const html = renderDesignTokensHtml(readFileSync( + new URL("../../../../index.html", import.meta.url), + "utf8", +)); +const bootstrap = html.match( + /