diff --git a/.github/scripts/ci-shell.test.mjs b/.github/scripts/ci-shell.test.mjs new file mode 100644 index 00000000..0311d4b8 --- /dev/null +++ b/.github/scripts/ci-shell.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const workflow = readFileSync(new URL("../workflows/ci.yml", import.meta.url), "utf8"); + +test("cross-platform build and test jobs retain fail-fast bash", () => { + for (const name of ["build", "test"]) { + const job = workflow.match(new RegExp(`^ ${name}:\\n([\\s\\S]*?)(?=^ [\\w-]+:|$(?![\\s\\S]))`, "m"))?.[1]; + assert.ok(job, `missing ${name} job`); + assert.match(job, /^ defaults:\n run:\n shell: bash$/m); + const steps = job.split(/^ steps:\n/m)[1]; + assert.ok(steps, `missing ${name} steps`); + assert.doesNotMatch(steps, /^ shell:/m, "steps must not override the fail-fast shell"); + } +}); + +// GitHub's documented shell:bash invocation, including Git for Windows. +// Run this on every matrix OS, not just a simulated Windows platform value. +function run(script) { + const result = spawnSync("bash", ["--noprofile", "--norc", "-eo", "pipefail", "-c", script], { + encoding: "utf8", + timeout: 10_000, + }); + assert.ifError(result.error); + return result; +} + +test("a failed native command cannot be hidden by a later successful command", () => { + const result = run('node -e "process.exit(23)"\nnode -e "console.log(\'must-not-run\')"'); + assert.equal(result.status, 23); + assert.doesNotMatch(result.stdout, /must-not-run/); +}); + +test("a failed pipeline command cannot be hidden by its successful consumer", () => { + const result = run('node -e "process.exit(23)" | node -e "process.exit(0)"\nnode -e "console.log(\'must-not-run\')"'); + assert.equal(result.status, 23); + assert.doesNotMatch(result.stdout, /must-not-run/); +}); + +test("successful command sequences still complete normally", () => { + const result = run('node -e "console.log(\'first\')"\nnode -e "console.log(\'second\')"'); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /first\s+second/); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5675ae1f..58cf2018 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,10 @@ jobs: build: name: Build (${{ matrix.os }}) + # Explicit bash stops on any failed native command, also on Windows. + defaults: + run: + shell: bash strategy: fail-fast: false matrix: @@ -138,6 +142,9 @@ jobs: test: name: Test (${{ matrix.os }}) + defaults: + run: + shell: bash strategy: fail-fast: false matrix: @@ -152,6 +159,8 @@ jobs: cache-dependency-path: | backend/package-lock.json frontend/package-lock.json + - name: Verify native-command failure propagation + run: node --test .github/scripts/ci-shell.test.mjs - name: Backend — install & test working-directory: backend run: | diff --git a/backend/src/services/execution/backends/localDocker.test.ts b/backend/src/services/execution/backends/localDocker.test.ts index f0917c6b..c1d889d5 100644 --- a/backend/src/services/execution/backends/localDocker.test.ts +++ b/backend/src/services/execution/backends/localDocker.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, type MockInstance } from "vitest"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -113,18 +113,36 @@ describe("ensureNoSymlinkInPath", () => { it("preserves existing bind paths and applies the requested mode only to directories it creates", async () => { tmp = await fs.mkdtemp(path.join(os.tmpdir(), "runner-mode-")); + let chmod: MockInstance | undefined; try { await fs.chmod(tmp, 0o700); await fs.mkdir(path.join(tmp, "a"), { mode: 0o711 }); + const rootMode = (await fs.stat(tmp)).mode; + const parentMode = (await fs.stat(path.join(tmp, "a"))).mode; + // Pass through to the real filesystem while checking the no-rechmod + // contract on every OS, including Windows without POSIX mode bits. + chmod = vi.spyOn(fs, "chmod"); const target = path.join(tmp, "a", "b"); await ensureNoSymlinkInPath(tmp, target, 0o707); // Existing bind-mount paths may be reported as UID 0 by Docker Desktop // and reject chmod from the UID-1100 backend. Re-validating their type // without mutating their mode keeps repeated snapshots idempotent. - expect((await fs.stat(tmp)).mode & 0o777).toBe(0o700); - expect((await fs.stat(path.join(tmp, "a"))).mode & 0o777).toBe(0o711); - expect((await fs.stat(target)).mode & 0o777).toBe(0o707); + expect((await fs.stat(tmp)).mode).toBe(rootMode); + expect((await fs.stat(path.join(tmp, "a"))).mode).toBe(parentMode); + expect((await fs.stat(target)).isDirectory()).toBe(true); + expect(chmod).toHaveBeenCalledExactlyOnceWith(target, 0o707); + // Node on Windows supports write permission, not owner/group/other + // POSIX modes. Keep exact mode enforcement on Linux and macOS. + if (process.platform !== "win32") { + expect(rootMode & 0o777).toBe(0o700); + expect(parentMode & 0o777).toBe(0o711); + expect((await fs.stat(target)).mode & 0o777).toBe(0o707); + } + chmod.mockClear(); + await ensureNoSymlinkInPath(tmp, target, 0o707); + expect(chmod).not.toHaveBeenCalled(); } finally { + chmod?.mockRestore(); await fs.rm(tmp, { recursive: true, force: true }); } }); diff --git a/docs/MARKETING_PARTICLE_STUDY.md b/docs/MARKETING_PARTICLE_STUDY.md new file mode 100644 index 00000000..00c9cd62 --- /dev/null +++ b/docs/MARKETING_PARTICLE_STUDY.md @@ -0,0 +1,226 @@ +# CodeTutor homepage: glyph-field redesign + +Status: design approved by the user on 2026-09-07; production integration and +local validation complete. PR review, CI, merge and production verification remain. + +PR: [#50](https://github.com/msrivas-7/CodeTutor-AI/pull/50). Initial E2E run had +three flaky jobs (onboarding fixture 500, share-button stability, Escape timing); +only the failed jobs were rerun once and the rerun passed. Review identified +UX-196 below. The user approved fixing the pre-existing Windows assertion and +CI failure masking in this PR. All platforms retain real-filesystem checks for +no mutation of existing paths and chmod only on newly created directories; +Linux/macOS retain exact POSIX mode assertions. Build/test jobs use explicit +fail-fast Bash, with native-command and pipeline negative controls on each OS. +Local backend suite (1424 passed, 29 existing skips), typecheck, baseline and +governance checks, four shell regressions and actionlint pass. Hosted Windows +job 101894481739 confirms all 31 localDocker tests pass and deliberate command/ +pipeline failures propagate correctly; CI/E2E/security on 8ba0252 are green and +Codex returned no new findings. The user then requested the responsive refinement +below, which needs fresh final-head review and CI before merge. + +Experiment harness: `1692c52e-76b0-4fa4-ae20-a334eb578724`. +Branch-bound release harness: `79523664-9004-44ba-97b6-6ad58d64c4e2`. +Evidence: `.agent-harness/browser-evidence/1692c52e-76b0-4fa4-ae20-a334eb578724/`. + +## Scope and release gates + +Redesign the public homepage only. Preserve the headline, acquisition and returning +learner destinations, account options, footer links and public metadata. +Editor, lessons, welcome, auth, admin, backend, AI behavior and quotas stay unchanged. +Both the lightweight public router and the already-loaded full app use the same +homepage. The local `?motionStudy=1` link still works but no longer gates the design. + +The user approved taking the design through thorough validation, a published PR, +Codex review and resolution of actionable threads, green CI, merge, deployment, +then actual-browser production verification. Earlier local-only restrictions are +superseded by that explicit authorization. A green unit suite alone is insufficient. + +## Approved experience + +- One continuous original code-glyph field on a near-black page. Ice-blue, amber, + violet and white light come from the particles, not a blue background wash. +- A large centered bracket hero disperses into the margins while text is read. + The field regroups centrally between sections; no assembled side illustrations. +- Numerous tiny fragments, readable glyphs and rare large highlights. Size, symbol + and palette use independent seeded channels; largest sprites are not all pluses. +- Persistent faint atmosphere also drifts and responds to the pointer. +- Swept mouse paths impart momentum. Circular gestures add subtle swirl, followed + by gentle recovery; drag/arrow rotation is separate and bounded. +- Manual Read / Ask / Check demonstration follows one average-score mistake, + useful tutor hint, correction and learner explanation. It is explicitly labeled + illustrative, not a live AI response or execution. +- The header contains only brand and sign-in/dashboard navigation. Local testing + controls, density selector, theme toggle and free/card/time fine print are gone. +- The user chose system Reduce Motion handling instead of a pause button after + comparing Astra. Only system Reduce Motion selects static original glyphs; + phones retain animation, with smaller sprites inside compact sculptures. + The public composition stays dark under either system color preference. + +The hero is deliberately more cinematic than the baseline. On a 960×863 viewport, +the primary CTA follows the artwork below the first screen; there is no scroll +lock, forced delay or interaction gate. This approved hierarchy change must be +called out in the PR. It is not a measured conversion improvement. + +## Reference research + +[OpenAI GPT-6 Astra](https://openai.com/index/gpt-6-astra/) was inspected through +actual Codex in-app scrolling from hero to footer, representative demo tabs, +pointer sweeps/circles, drag/arrow controls and reduced-motion emulation. + +| Observation | Transferable principle | +| --- | --- | +| Luminous 6, cursor and later blossom, rather than a shape for every paragraph | Give important chapters meaningful visual markers | +| Sculptures disperse around reading content; faint particles remain | Coordinate one field around attention, not separate decorative widgets | +| Wide demos alternate with narrower explanation and manual tabs | Keep evidence legible and pacing under visitor control | +| Pointer movement and whole-form rotation behave differently | Separate local momentum from sculpture rotation | +| Footnotes/footer become quieter | Continuity need not mean identical intensity everywhere | + +Loaded public code identifies Next.js, Three.js, React Three Fiber, custom GLSL, +shape samples, pointer-motion/coasting state and renderer quality limits. It also +contains postprocessing integration; presence does not prove every option runs. +Public build artifacts inspected through browser DevTools: + +- [Astra geometry/shader](https://openai.com/_next/static/immutable/chunks/1rfzm7jt1igp4.js) +- [Configuration/React integration](https://openai.com/_next/static/immutable/chunks/1kdmy-p0oag4x.js) +- [Three renderer](https://openai.com/_next/static/immutable/chunks/1a43l2lhrwu30.js) +- [Postprocessing integration](https://openai.com/_next/static/immutable/chunks/2_rkuko8hans4.js) + +These are version-specific evidence, not stable APIs. One direct HTTP request met +a protection challenge; research used already-loaded public scripts, without +bypass. No reference code, shapes or assets are copied into this implementation. + +### Reduced motion: verified + +Astra exposes replay and drag/arrow rotation, but no particle pause toggle. With +`prefers-reduced-motion: reduce` emulated and the page reloaded, its hero remains +visible but static. Screenshots across pointer sweeps were byte-identical. The +loaded component gates continuous scene rendering and scroll effects on that +preference and resets pointer state. Clearing it restores changing frames. +Screenshot: `astra-reduced-motion.png`. This does not audit every embedded video +or establish whole-site accessibility compliance. + +## Architecture and alternatives + +| Choice | Rationale | +| --- | --- | +| Direct Three.js Points and original glyph atlas | One scene/draw call, not a DOM element per glyph | +| DOM-derived scroll keyframes | Reversible native scrolling; no scroll hijacking | +| 420 foreground + 630–2520 ambient glyphs, capped DPR 1.5 | CSS viewport area increases atmosphere above 1280×900; fixed capped pool/draw range avoids renderer recreation on resize | +| Four morph targets | Brackets, book, conversation and check reinforce programming/learning | +| Per-glyph velocity and damped return | Cursor gestures feel carried rather than radially repelled | +| Static SVG fallback, lazy renderer | Text and acquisition remain available before graphics load or after failure | +| Manual demo tabs | No timed content replacement while someone reads | +| Shared production homepage in both routers | Direct visits and returns from the editor cannot show different designs | + +CSS/Framer Motion remain suited to ordinary DOM effects, but not a separate node +for every particle. React Three Fiber adds no needed capability for this single +scene; raw WebGL/WebGPU adds engine responsibility without demonstrated benefit. +No bloom/postprocessing is needed. Three's scene/math core is packaged separately +from WebGL renderer code, behind the lazy renderer boundary; budgets remain intact. + +Rejected through user/browser feedback: disconnected side sculptures, dust-only +particles, correlated large-plus symbols, radial repulsion, and local testing +chrome. The current curvature refinement adds up to 40% carry only to curved +strokes; straight sweeps retain the preceding response. Deterministic tests cover +clockwise/counterclockwise direction, release, distant particles and long frames. + +References: [Three batching](https://threejs.org/manual/en/optimize-lots-of-objects.html), +[resource cleanup](https://threejs.org/manual/en/cleanup.html), +[rendering on demand](https://threejs.org/manual/en/rendering-on-demand.html), +[WCAG pause/stop/hide](https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html), +[animation from interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html). +The user-approved preference-based approach has no independent on-page stop +mechanism. Matching Astra does not itself prove WCAG conformance. + +## Verification ledger + +Actual-browser observations use rebuilt Docker frontend images, not Playwright +as a substitute. CI-oriented automation supplements those interactions. + +| Check | Evidence / state | +| --- | --- | +| Reference and baseline | Full reference scroll, targeted gestures and reduced motion; original hero/header/footer compared before activation | +| Centered continuity | Production-route and optimized-build hero, forward/reverse scroll and walkthrough verified; UX-195 tall-screen initial dispersion corrected and rechecked at 1440×1320 | +| Keyboard | Corrected DOM order: header → artwork → CTA; arrows and Tab exit work; Ask → Tab/Space → Check works | +| Responsive | 390×844 and 320×740 have no document overflow and all stages work; code fits at 320; 768×900 restores one canvas | +| Theme/reduced motion | Light system preference preserves intended dark composition; reduce removes canvas/rotation and keeps SVGs; clearing it restores one ready canvas | +| Graphics recovery | Optimized build: blocked graphics download leaves Read/Ask/Check usable, Reload recovers; context loss leaves demo usable, Retry restores rendering and focuses headline; automatic restoration also exercised locally | +| Public controls | No testing controls or pause; footer destinations restored; actual Continue → dashboard → sign-out → Privacy → home → anonymous first lesson → skip intro/welcome reaches enabled editor controls | +| Automated | Full frontend suite: 569 tests; typecheck/build; 16 homepage + zero-state first-journey E2E checks passed with retries disabled | +| Performance | Lighthouse homepage performance 0.98, LCP 2104 ms, CLS 0, TBT 0; why-not page 0.99/LCP 1955 ms. Local optimized desktop sample: 60 frame intervals, median 16.7 ms, p95 16.8 ms, max 17.5 ms at 1280×720/DPR 2 (renderer cap 1.5) | +| PR/release | PR #50 published; initial CI and failed-job E2E rerun green. UX-196 and approved Windows CI correction below; final-head CI/review, production deployment and browser confirmation remain pending. | + +Review images, captured from the optimized local build without changing page content: +[hero](images/marketing-redesign/hero.png) and +[walkthrough](images/marketing-redesign/walkthrough.png). +Harness evidence additionally includes `optimized-mobile-320.png`, +`optimized-reduced-motion.png`, `final-desktop-check.png` and +`astra-reduced-motion.png`. Earlier `public-preview-*` images show superseded UI. +The harness audit binds this evidence to the staged code fingerprint; the PR +identifies its commit. These are local-build images, not production proof. + +The first production integration hit the unchanged 120 KB per-chunk gzip budget: +graphics chunk 131,439 bytes; all JS 632,053 bytes, below the 700 KB total budget. +Splitting the actual Three package core from its WebGL renderer corrected the +packaging without raising limits. After responsive refinement, all-JS gzip is 634,016 bytes; largest chunk +90,640 bytes; CSS 19,657 bytes; HTML 1,195 bytes. Both graphics chunks remain lazy, +and their successful load/render was verified in the optimized build. +Development-server timings are not production payload benchmarks; the brief +desktop frame sample is not GPU time, sustained load or low-end-device proof. + +### Finding added during final validation + +- **UX-195 — tall viewport disperses the hero before scrolling:** closed locally. + A negative scroll anchor placed the initial sculpture in its departure phase. + Clamp shape anchors to reachable scroll positions; regression covers 720, + 1000 and 1320px viewport heights. Actual 1440×1320 browser replay confirms a + complete initial bracket form; production confirmation remains required. +- **UX-196 — focused artwork disappears during motion interruption:** reviewer + finding, reproduced locally (active element became BODY). The media-query and + renderer-status transitions now hand focus to the stable headline before + removing the artwork control, but leave focus elsewhere untouched. Regression + coverage includes reduced motion, compact resize, context loss and the next + Tab to the first-lesson action. Actual in-app browser checks passed all three + interruption paths, Retry recovery, and preserving Check-stage focus when + changing motion preferences. Full frontend 569 tests, build/typecheck, budgets + and all 17 marketing E2E checks passed with retries disabled. Release harness + `e9ca7c5e-8ee8-4b33-8f45-57c63eb587c5` holds browser evidence; production + confirmation remains required. +- **UX-197 — compact chapter sculpture becomes an overbright cluster:** found + during the newly enabled phone-motion browser pass. Foreground sprite sizes + now scale with assembled sculpture size; scattered/ambient glyph sizes and + normal desktop shapes remain unchanged. The small book silhouette is distinct + in the actual in-app browser after the correction. + +### User-approved responsive refinement + +Phones no longer disable motion by width. Resize retains the scene and focus; +Reduce Motion still removes it and safely transfers artwork focus to the headline. +Ambient counts are 630 at normal/phone sizes, 1134 at 1920×1080, 2016 at 2560×1440, +and capped at 2520 at 3840×2160 and above. These use CSS area, not physical DPR. +Only active particles are updated/drawn; the fixed 420-particle contours keep +their identity. All 570 frontend tests and 34 complete Chromium/WebKit marketing +checks pass without retries. macOS WebKit link traversal uses Apple's documented +Option-Tab; its default plain Tab skips links, not an application focus defect. + +Actual in-app checks cover phone/desktop resize, chapter clarity, walkthrough +state, focus, light system preference, reduced motion and graphics recovery. +At 3840×2160 CSS pixels, DOM bounds confirmed centered content/no overflow and +an 85-frame local sample measured median 16.7 ms, p95 17.6 ms (DPR 1). This is +not physical-phone, sustained GPU or battery evidence. The in-app screenshot +capture cropped/tiled the emulated 4K surface; use the valid 1920px capture for +visual evidence, not those diagnostic captures. Native touch-swipe injection is +unsupported in this in-app tool: live scrolling was inspected with its scroll +control, supplemented by touch-context tap/layout checks in Chromium/WebKit. + +## Completion checklist + +- [x] Reference behavior and public implementation researched. +- [x] Original glyph design iterated with the user. +- [x] Explicit design and conditional release approval recorded. +- [x] Substantial local browser evidence and deterministic checks. +- [x] Final production-route browser pass, screenshots and performance checks. +- [x] Full relevant automated checks and asset budgets green. +- [x] Independent reader review, clean initial diff, harness and commit. +- [ ] Published PR, clear Codex review, actionable threads resolved, green CI. +- [ ] Merge, successful deployment, deployed-site browser verification. diff --git a/docs/images/marketing-redesign/hero.png b/docs/images/marketing-redesign/hero.png new file mode 100644 index 00000000..b2617c7a Binary files /dev/null and b/docs/images/marketing-redesign/hero.png differ diff --git a/docs/images/marketing-redesign/walkthrough.png b/docs/images/marketing-redesign/walkthrough.png new file mode 100644 index 00000000..0eec99f9 Binary files /dev/null and b/docs/images/marketing-redesign/walkthrough.png differ diff --git a/e2e/specs/marketing.spec.ts b/e2e/specs/marketing.spec.ts index 8cc03fd7..72cf41c3 100644 --- a/e2e/specs/marketing.spec.ts +++ b/e2e/specs/marketing.spec.ts @@ -1,8 +1,8 @@ -// Phase 22C: marketing page (`/`) e2e. Exercises: +// Public glyph homepage (`/`) e2e. Preserves Phase 22C acquisition contracts: // - anonymous visitor lands on / and sees the hero claim + nav // - the primary CTA starts the no-signup lesson; signup is secondary // - "Sign in" anchor leads to /login -// - "How it works" anchor smooth-scrolls to the Section 2 content +// - the walkthrough anchor reaches reversible Read / Ask / Check content // - logged-in users hitting / are NOT redirected; they see the // marketing page with a "Dashboard" affordance and a CTA that // points at /start with "Continue learning" copy @@ -36,12 +36,11 @@ test.describe("marketing page (Phase 22C) — anonymous", () => { await expect(hero).toBeVisible({ timeout: 5_000 }); await expect(hero).toHaveText(HERO_CLAIM); - // Match-cut panel renders a JetBrains Mono code line — the typewriter - // is mid-animation when the assertion runs, so we assert on the string - // literal "Maya" we know lands within the first ~250ms (line 1 of - // the new Python TypeError beat). - const monoLine = page.getByText(/Maya/, { exact: false }); - await expect(monoLine.first()).toBeVisible({ timeout: 5_000 }); + // The linked walkthrough is authored HTML, never a simulated live AI call. + await expect( + page.getByText("Illustrative walkthrough · not live AI"), + ).toBeVisible(); + await expect(page.getByText("average.py", { exact: true })).toBeVisible(); // Primary CTA (in-hero). Two CTAs on the page (hero + repeat) — // both share the label, so .first() is fine. @@ -49,10 +48,9 @@ test.describe("marketing page (Phase 22C) — anonymous", () => { await expect(heroCta.first()).toBeVisible(); const heroHref = await heroCta.first().getAttribute("href"); expect(heroHref).toMatch(/\/try\/lesson\/python-fundamentals\/hello-world/); - await expect(page.getByRole("link", { name: /create a free account/i })).toHaveAttribute( - "href", - /\/signup/, - ); + await expect( + page.getByRole("link", { name: /create an account/i }), + ).toHaveAttribute("href", /\/signup/); // Top-right Sign in anchor (in the marketing nav). const signIn = page.getByRole("link", { name: /^sign in$/i }).first(); @@ -60,48 +58,80 @@ test.describe("marketing page (Phase 22C) — anonymous", () => { await expect(signIn).toHaveAttribute("href", /\/login/); }); - test("clicking the primary CTA navigates directly to the no-signup lesson", async ({ page }) => { + test("clicking the primary CTA navigates directly to the no-signup lesson", async ({ + page, + }) => { await page.goto("/"); - const cta = page.getByRole("link", { name: /try your first lesson/i }).first(); + const cta = page + .getByRole("link", { name: /try your first lesson/i }) + .first(); await cta.click(); await expect(page).toHaveURL( /\/try\/lesson\/python-fundamentals\/hello-world$/, ); }); - test("primary action is visible without scrolling on a common laptop screen", async ({ browser }) => { - const context = await browser.newContext({ viewport: { width: 1280, height: 720 } }); + test("cinematic introduction never gates the primary action or traps scrolling", async ({ + browser, + }) => { + const context = await browser.newContext({ + viewport: { width: 1280, height: 720 }, + }); const page = await context.newPage(); await page.goto("/"); - const cta = page.getByRole("link", { name: /try your first lesson/i }).first(); + const cta = page + .getByRole("link", { name: /try your first lesson/i }) + .first(); await expect(cta).toBeVisible(); - const box = await cta.boundingBox(); - expect(box).not.toBeNull(); - expect(box!.y + box!.height).toBeLessThanOrEqual(720); + // Approved redesign puts the artwork before the headline/CTA. Verify + // access instead of retaining the superseded above-the-fold composition. expect(await page.evaluate(() => window.scrollY)).toBe(0); + await cta.scrollIntoViewIfNeeded(); + await expect(cta).toBeInViewport(); + await cta.click(); + await expect(page).toHaveURL( + /\/try\/lesson\/python-fundamentals\/hello-world$/, + ); await context.close(); }); - test("the three How-it-works beats render below the hero", async ({ + test("the linked Read Ask Check walkthrough is reversible and useful", async ({ page, }) => { await page.goto("/"); - // Force the section into viewport for assertion. JSDOM-style hash - // anchors are flakey under Lenis smooth-scroll; we scroll the - // element into view directly instead. - await page.locator("#how-it-works").scrollIntoViewIfNeeded(); - - await expect(page.getByRole("heading", { name: "Read." })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Ask." })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Check." })).toBeVisible(); + await page.getByRole("link", { name: /See how learning happens/ }).click(); + await expect(page).toHaveURL(/#study-demo$/); + const stages = page.getByRole("group", { name: "Walkthrough stages" }); + await stages.getByRole("button", { name: /Ask/ }).click(); + await expect(page.getByText(/Trace total after each pass/)).toBeVisible(); + await stages.getByRole("button", { name: /Check/ }).click(); + await expect(page.locator(".study-output samp")).toHaveText("8.0"); + await expect( + page.getByText(/Assignment kept only the last score/), + ).toBeVisible(); + await stages.getByRole("button", { name: /Read/ }).click(); + await expect(page.locator(".study-output samp")).toHaveText( + "3.3333333333333335", + ); + await expect(stages.getByRole("button", { name: /Read/ })).toHaveAttribute( + "aria-pressed", + "true", + ); }); - test("essential How-it-works copy is never animation-gated at zero opacity", async ({ page }) => { + test("essential How-it-works copy is never animation-gated at zero opacity", async ({ + page, + }) => { await page.goto("/"); - const section = page.locator("#how-it-works"); - const hiddenEssential = await section.locator("h3, p").evaluateAll((nodes) => - nodes.filter((node) => Number.parseFloat(getComputedStyle(node).opacity) === 0).length, - ); + const section = page.locator("#study-demo"); + const hiddenEssential = await section + .locator("h3, p") + .evaluateAll( + (nodes) => + nodes.filter( + (node) => Number.parseFloat(getComputedStyle(node).opacity) === 0, + ).length, + ); expect(hiddenEssential).toBe(0); }); @@ -143,6 +173,97 @@ test.describe("marketing page (Phase 22C) — anonymous", () => { }); test.describe("marketing page (Phase 22C) — reduced motion", () => { + for (const interruption of ["reduced motion", "context loss"] as const) { + test(`hands focused artwork to the headline on ${interruption}`, async ({ + page, + browserName, + }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.goto("/"); + const artwork = page.getByRole("button", { + name: /gently rotate the code glyphs/i, + }); + await expect(artwork).toBeVisible(); + await artwork.focus(); + await expect(artwork).toBeFocused(); + if (interruption === "reduced motion") { + await page.emulateMedia({ reducedMotion: "reduce" }); + } else { + const lost = await page + .locator(".motion-study-canvas canvas") + .evaluate((canvas) => { + const extension = (canvas as HTMLCanvasElement) + .getContext("webgl2") + ?.getExtension("WEBGL_lose_context"); + extension?.loseContext(); + return !!extension; + }); + expect(lost).toBe(true); + } + await expect(artwork).toHaveCount(0); + await expect(page.getByRole("heading", { level: 1 })).toBeFocused(); + // macOS WebKit defaults to skipping links with plain Tab; Option-Tab + // is Apple's documented full-navigation shortcut (Safari cpsh003). + await page.keyboard.press( + browserName === "webkit" && process.platform === "darwin" ? "Alt+Tab" : "Tab", + ); + await expect( + page.getByRole("link", { name: /try your first lesson/i }).first(), + ).toBeFocused(); + }); + } + + test("responds to a live reduced-motion change without losing demo state", async ({ + page, + }) => { + await page.goto("/"); + await page + .getByRole("group", { name: "Walkthrough stages" }) + .getByRole("button", { name: /Check/ }) + .click(); + const check = page.getByRole("button", { name: "03 Check" }); + await check.focus(); + await page.emulateMedia({ reducedMotion: "reduce" }); + await expect(page.locator(".motion-study-canvas canvas")).toHaveCount(0); + await expect(page.locator("[data-field-interaction]")).toHaveCount(0); + await expect(page.locator(".study-output samp")).toHaveText("8.0"); + await expect(check).toBeFocused(); + await expect( + page.getByRole("button", { name: /pause animation/i }), + ).toHaveCount(0); + await page.emulateMedia({ reducedMotion: "no-preference" }); + await expect(page.locator(".study-output samp")).toHaveText("8.0"); + await expect( + page.getByRole("link", { name: /try your first lesson/i }).first(), + ).toHaveAttribute("href", /\/try\/lesson\//); + }); + + test("graphics module failure leaves content usable and reload recovers", async ({ + page, + }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.route("**/ParticleField*", (route) => route.abort()); + await page.goto("/"); + await expect(page.getByText(/Motion could not load/)).toBeVisible(); + await page + .getByRole("group", { name: "Walkthrough stages" }) + .getByRole("button", { name: /Ask/ }) + .click(); + await expect(page.getByText(/Trace total after each pass/)).toBeVisible(); + await page.unroute("**/ParticleField*"); + await page.getByRole("button", { name: "Reload page" }).click(); + await expect(page.getByRole("heading", { level: 1 })).toHaveText( + HERO_CLAIM, + ); + await expect(page.getByText(/Motion could not load/)).toHaveCount(0); + await expect( + page + .getByRole("group", { name: "Walkthrough stages" }) + .getByRole("button", { name: /Read/ }), + ).toHaveAttribute("aria-pressed", "true"); + }); + test("renders the hero in its final state statically", async ({ page }) => { // Force the prefers-reduced-motion media query BEFORE navigation so // the very first render of MatchCutHero sees `reduce === true` and @@ -157,15 +278,12 @@ test.describe("marketing page (Phase 22C) — reduced motion", () => { await expect(hero).toBeVisible({ timeout: 3_000 }); await expect(hero).toHaveText(HERO_CLAIM); - // The match-cut panel skips its scheduled beats and renders the - // final state directly — code visible AND tutor question visible - // from first paint, no waiting on the ~8.4s play-through. - await expect(page.getByText(/Maya/).first()).toBeVisible({ - timeout: 3_000, - }); + await expect(page.locator(".motion-study-canvas canvas")).toHaveCount(0); + await expect(page.locator("[data-field-interaction]")).toHaveCount(0); + await expect(page.locator(".study-still")).toHaveCount(3); await expect( - page.getByText(/Why does this fail when points is 100\?/), - ).toBeVisible({ timeout: 3_000 }); + page.getByRole("heading", { name: "Find the average score" }), + ).toBeVisible(); // CTA still functions. const cta = page @@ -176,7 +294,9 @@ test.describe("marketing page (Phase 22C) — reduced motion", () => { expect(box?.height ?? 0).toBeGreaterThanOrEqual(44); }); - test("360px viewport has no essential horizontal overflow", async ({ browser }) => { + test("360px viewport has no essential horizontal overflow", async ({ + browser, + }) => { const context = await browser.newContext({ viewport: { width: 360, height: 800 }, isMobile: true, @@ -198,7 +318,52 @@ test.describe("marketing page (Phase 22C) — mobile viewport", () => { // iPhone 13 portrait dimensions. Setting just the viewport (rather // than `...devices["iPhone 13"]`) sidesteps Playwright's "can't // change defaultBrowserType inside describe" constraint. - test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); + test.use({ + viewport: { width: 390, height: 844 }, + isMobile: true, + hasTouch: true, + }); + + test("keeps animation across phone sizes and stops only for reduced motion", async ({ + page, + }) => { + await page.emulateMedia({ reducedMotion: "no-preference" }); + await page.goto("/"); + const canvas = page.locator(".motion-study-canvas canvas"); + const artwork = page.getByRole("button", { + name: /gently rotate the code glyphs/i, + }); + await expect(artwork).toBeVisible(); + await expect(canvas).toHaveCount(1); + await artwork.focus(); + for (const viewport of [ + { width: 844, height: 390 }, + { width: 320, height: 740 }, + { width: 390, height: 844 }, + ]) { + await page.setViewportSize(viewport); + await expect(artwork).toBeFocused(); + await expect(canvas).toHaveCount(1); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth + 1, + ), + ).toBe(true); + } + // The artwork must not capture vertical touch scrolling. + await expect(artwork).toHaveCSS("touch-action", "pan-y"); + await page.getByRole("button", { name: "03 Check" }).tap(); + await expect(page.locator(".study-output samp")).toHaveText("8.0"); + await page.emulateMedia({ reducedMotion: "reduce" }); + await expect(canvas).toHaveCount(0); + await expect(artwork).toHaveCount(0); + await expect(page.locator(".study-still").first()).toBeVisible(); + await expect(page.locator(".study-output samp")).toHaveText("8.0"); + await page.emulateMedia({ reducedMotion: "no-preference" }); + await expect(canvas).toHaveCount(1); + await expect(artwork).toBeVisible(); + await expect(page.locator(".study-output samp")).toHaveText("8.0"); + }); test("renders without horizontal overflow at iPhone 13 width", async ({ page, @@ -242,19 +407,16 @@ authedTest.describe("marketing page (Phase 22C) — authed nav swap", () => { }, ); - authedTest( - "clicking Dashboard navigates to /start", - async ({ page }) => { - await page.goto("/"); - // The URL assertion below owns navigation readiness. Avoid making the - // click also wait for every scheduled navigation because the public-app - // auth handoff can replace the active React subtree during that wait. - await page.getByRole("link", { name: /^dashboard/i }).click({ - noWaitAfter: true, - }); - await authedExpect(page).toHaveURL(/\/start$/, { timeout: 5_000 }); - }, - ); + authedTest("clicking Dashboard navigates to /start", async ({ page }) => { + await page.goto("/"); + // The URL assertion below owns navigation readiness. Avoid making the + // click also wait for every scheduled navigation because the public-app + // auth handoff can replace the active React subtree during that wait. + await page.getByRole("link", { name: /^dashboard/i }).click({ + noWaitAfter: true, + }); + await authedExpect(page).toHaveURL(/\/start$/, { timeout: 5_000 }); + }); authedTest( "primary CTA reads 'Continue learning' and points at /start when authed", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 73810d19..df035dba 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ "react-markdown": "10.1.0", "react-router-dom": "^7.18.2", "remark-gfm": "4.0.1", + "three": "0.185.1", "zustand": "^4.5.4" }, "devDependencies": { @@ -31,6 +32,7 @@ "@types/node": "^25.6.0", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/three": "0.185.4", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.19", "chrome-launcher": "1.2.1", @@ -341,6 +343,13 @@ "node": ">=6.9.0" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -2448,6 +2457,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2650,6 +2666,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/tedious": { "version": "4.0.14", "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", @@ -2660,12 +2683,41 @@ "@types/node": "*" } }, + "node_modules/@types/three": { + "version": "0.185.4", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.4.tgz", + "integrity": "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/three/node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -5172,6 +5224,13 @@ "node": ">= 8" } }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -7227,6 +7286,12 @@ "dev": true, "license": "MIT" }, + "node_modules/three": { + "version": "0.185.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", + "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==", + "license": "MIT" + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1c364980..cfc4215b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ "react-markdown": "10.1.0", "react-router-dom": "^7.18.2", "remark-gfm": "4.0.1", + "three": "0.185.1", "zustand": "^4.5.4" }, "devDependencies": { @@ -40,6 +41,7 @@ "@types/node": "^25.6.0", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/three": "0.185.4", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.19", "chrome-launcher": "1.2.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 928d89cb..135d5ca7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,7 +10,7 @@ import { RequireAdmin } from "./auth/RequireAdmin"; import { HydrationGate } from "./auth/HydrationGate"; import { WelcomeBackOverlay } from "./features/firstRun/WelcomeBackOverlay"; import { ReplayReturnFocus } from "./auth/ReplayReturnFocus"; -const MarketingPage = lazy(() => import("./pages/MarketingPage")); +const MarketingPage = lazy(() => import("./features/marketing/study/MarketingHomepage")); const WhyNotChatGPTPage = lazy(() => import("./pages/WhyNotChatGPTPage")); const StartPage = lazy(() => import("./pages/StartPage")); const EditorPage = lazy(() => import("./pages/EditorPage")); diff --git a/frontend/src/PublicApp.tsx b/frontend/src/PublicApp.tsx index ddcfc5a2..e4ce8d97 100644 --- a/frontend/src/PublicApp.tsx +++ b/frontend/src/PublicApp.tsx @@ -1,10 +1,11 @@ -import { lazy, Suspense, useEffect, useState } from "react"; +import { lazy, Suspense } from "react"; import { Route, Routes } from "react-router-dom"; import type { ReactNode } from "react"; -import CompactMarketingPage from "./pages/CompactMarketingPage"; import WhyNotChatGPTPage from "./pages/WhyNotChatGPTPage"; -const MarketingPage = lazy(() => import("./pages/MarketingPage")); +const MarketingHomepage = lazy( + () => import("./features/marketing/study/MarketingHomepage"), +); const TrustPage = lazy(() => import("./pages/TrustPage")); const FullApp = lazy(async () => { const [appModule, { initAuth }] = await Promise.all([ @@ -31,22 +32,6 @@ function PublicSurface({ children }: { children: ReactNode }) { return
{children}
; } -function MarketingEntry() { - const [compact, setCompact] = useState(() => - window.matchMedia("(max-width: 640px)").matches, - ); - - useEffect(() => { - const query = window.matchMedia("(max-width: 640px)"); - const update = () => setCompact(query.matches); - update(); - query.addEventListener("change", update); - return () => query.removeEventListener("change", update); - }, []); - - return compact ? : ; -} - /** * Lightweight route shell for acquisition and trust surfaces. * @@ -59,11 +44,46 @@ export default function PublicApp() { return ( }> - } /> - } /> - } /> - } /> - } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> } /> diff --git a/frontend/src/features/marketing/study/MarketingHomepage.tsx b/frontend/src/features/marketing/study/MarketingHomepage.tsx new file mode 100644 index 00000000..30abbb3b --- /dev/null +++ b/frontend/src/features/marketing/study/MarketingHomepage.tsx @@ -0,0 +1,402 @@ +import { + Component, + lazy, + Suspense, + useCallback, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { Link } from "react-router-dom"; +import { Wordmark } from "../../../components/Wordmark"; +import { FIRST_LESSON_CONTRACT } from "../../../productContract"; +import { pickHeroCopy } from "../heroCopy"; +import { useMarketingAuth } from "../useMarketingAuth"; +import { + createShape, + particleIdentity, + particleSeed, + type Shape, +} from "./geometry"; +import "./study.css"; + +const ParticleField = lazy(() => import("./ParticleField")); +class MotionBoundary extends Component< + { children: ReactNode }, + { failed: boolean } +> { + state = { failed: false }; + static getDerivedStateFromError() { + return { failed: true }; + } + render() { + return this.state.failed ? ( +

+ Motion could not load. The walkthrough is still available.{" "} + +

+ ) : ( + this.props.children + ); + } +} +const stages = [ + { + name: "Read", + shape: "read", + title: "Start with something you can reason about.", + description: + "Read a short lesson. Try a prediction. Then run real code and see where your understanding meets the result.", + heading: "Find the average score", + prompt: + "These scores are 6, 8, and 10. What average would you expect? Read the loop before you run it.", + output: "3.3333333333333335", + note: "The result is surprising. That is a useful place to start.", + }, + { + name: "Ask", + shape: "ask", + title: "A useful question changes what you notice.", + description: + "Your tutor helps you inspect your own work. A specific hint gives you a next step without taking the thinking away.", + heading: "Look at what changes", + prompt: + "Trace total after each pass through the loop. Does total = score add to the previous total, or replace it? What would you need to keep?", + output: "After each pass: 6 → 8 → 10", + note: "A concrete trace. A question you can answer. The next step is yours.", + }, + { + name: "Check", + shape: "check", + title: "Code that works. Understanding that stays.", + description: + "Check your work, then explain the idea in your own words. Finishing means more than getting a green result.", + heading: "Explain your change", + prompt: + "Why does adding to total keep all three scores, while assigning score to total does not?", + output: "8.0", + note: "The learner changed the accumulation. The tutor did not supply the solution.", + }, +] as const; + +function StillArt({ shape }: { shape: Shape }) { + const points = createShape(shape, 90); + const glyphs = ["{", "}", "<", ">", "[", "]", ";", "+"]; + return ( + + ); +} + +export default function MarketingHomepage() { + const headline = useRef(null); + const artworkControl = useRef(null); + const handOffArtworkFocus = useCallback(() => { + if (artworkControl.current === document.activeElement) { + headline.current?.focus(); + } + }, []); + const [root, setRoot] = useState(null); + const [staticMode, setStaticMode] = useState( + () => matchMedia("(prefers-reduced-motion: reduce)").matches, + ); + const [status, setStatus] = useState<"loading" | "ready" | "unavailable">( + "loading", + ); + const handleStatus = useCallback( + (next: "loading" | "ready" | "unavailable") => { + // Move focus while the control still exists, never after DOM removal. + if (next !== "ready") handOffArtworkFocus(); + setStatus(next); + }, + [handOffArtworkFocus], + ); + const [attempt, setAttempt] = useState(0); + const [stage, setStage] = useState(0); + const { isLoggedIn } = useMarketingAuth(); + const copy = pickHeroCopy(); + const current = stages[stage]!; + const animate = !staticMode; + const destination = isLoggedIn ? "/start" : FIRST_LESSON_CONTRACT.route; + const cta = isLoggedIn ? "Continue learning" : "Try your first lesson"; + useEffect(() => { + const query = matchMedia("(prefers-reduced-motion: reduce)"); + const update = () => { + if (query.matches) handOffArtworkFocus(); + setStaticMode(query.matches); + }; + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, [handOffArtworkFocus]); + return ( +
+ + Skip to the product walkthrough + + {root && animate && ( + + + + + + )} +
+ + + + +
+ {animate && status === "unavailable" && ( +

+ Motion is unavailable. You can still explore the walkthrough.{" "} + +

+ )} + +
+
+ + {animate && status === "ready" && ( +
+
+

A coding tutor. Not a shortcut.

+

+ {copy.claim} +

+

+ {copy.subhead}. Read, experiment, and ask better questions. Build an + understanding that belongs to you. +

+ + {cta} + + + See how learning happens + +
+
+ +
+
+
+

01 — The learning loop

+

+ From “why?” +
+ to “I see it.” +

+
+
+ +
+
+
+
+ {stages.map((item, i) => ( + + ))} + + Illustrative walkthrough · not live AI + +
+
+
+
+ average.py + Python +
+
+                
+                  
+                    # Find the average of three scores
+                  
+                  {"\n"}scores = [6, 8, 10
+                  ]{"\n"}total = 0
+                  {"\n\n"}
+                  for score{" "}
+                  in scores:{"\n"}
+                  
+                    {"    "}
+                    total {stage === 2 ? "+=" : "="} score
+                  
+                  {"\n\n"}average = total /{" "}
+                  len(scores){"\n"}
+                  print(average)
+                
+              
+
+ + {stage === 1 ? "Trace the loop" : "Example output"} + + {current.output} +
+
+
+

+ {stage === 0 ? "Your lesson" : "Your tutor"} +

+

{current.heading}

+ {stage === 1 && ( +

+ “I expected 8. Why am I getting 3.33?” +

+ )} +

{current.prompt}

+ {stage === 2 && ( +

+ “Assignment kept only the last score. Adding each score keeps + the running total: 24 divided by 3 is 8.” +

+ )} +

{current.note}

+
+
+
+

{current.title}

+

{current.description}

+
+
+
+ +
+
+ +
+
+

02 — Your next line

+

+ Less copying. +
+ More understanding. +

+

+ A lesson, an editor, and a tutor to help you think it through. Start + with one small program. +

+ + {cta} + +
+
+ +
+ ); +} diff --git a/frontend/src/features/marketing/study/ParticleField.tsx b/frontend/src/features/marketing/study/ParticleField.tsx new file mode 100644 index 00000000..5cb4f5f1 --- /dev/null +++ b/frontend/src/features/marketing/study/ParticleField.tsx @@ -0,0 +1,620 @@ +import { useEffect, useRef } from "react"; +import * as THREE from "three"; +import { + createScatter, + createShape, + particleIdentity, + particleSeed, + smoothProgress, + shapeScrollAnchor, + ambientParticleCount, + MAX_AMBIENT_MULTIPLIER, + type Shape, +} from "./geometry"; +import { + advanceGlyphMotion, + curveCarry, + type PointerStroke, +} from "./interaction"; + +interface Props { + root: HTMLElement; + light: boolean; + count: number; + onStatus: (status: "loading" | "ready" | "unavailable") => void; +} + +// Original shader: no reference-site implementation or art is copied. +const vertexShader = ` + attribute vec3 readShape; + attribute vec3 askShape; + attribute vec3 checkShape; + attribute vec3 scatter; + attribute float seed; + attribute vec2 identity; + attribute float background; + attribute vec2 displacement; + uniform vec4 weights; + uniform float spread; + uniform vec2 viewport; + uniform float clock; + uniform float scale; + uniform float dpr; + uniform vec2 center; + uniform vec2 pointer; + uniform vec2 tilt; + uniform float pointerActive; + uniform float scrollOffset; + uniform vec2 atmosphereShift; + varying float brightness; + varying float glyphIndex; + varying float spectrum; + varying float ambientGlyph; + void main() { + vec3 p = position * weights.x + readShape * weights.y + askShape * weights.z + checkShape * weights.w; + float yaw = sin(clock * .16) * .035 + tilt.x; + float pitch = tilt.y; + p = vec3(p.x*cos(yaw)+p.z*sin(yaw), p.y, -p.x*sin(yaw)+p.z*cos(yaw)); + p = vec3(p.x, p.y*cos(pitch)-p.z*sin(pitch), p.y*sin(pitch)+p.z*cos(pitch)); + p.xy *= scale; + p.xy += center; + vec2 dispersed = scatter.xy * viewport * .5; + dispersed += vec2(sin(clock*.13+seed*23.0)*8.0,cos(clock*.10+seed*19.0)*6.0); + dispersed += atmosphereShift*(.3+seed*.7); + p.xy = mix(p.xy, dispersed, spread); + if (background > .5) { + // A persistent distant field never participates in shape assembly. + // Wrap beyond the viewport; only a small fraction of page scroll becomes parallax. + p.x = scatter.x * viewport.x * .5 + sin(clock*.16+seed*20.0)*10.0 + atmosphereShift.x*(.4+seed*.6); + p.y = mod(scatter.y*viewport.y*.5 + scrollOffset*(.025+seed*.035) + + cos(clock*.12+seed*15.0)*7.0 + atmosphereShift.y*(.4+seed*.6) + + viewport.y*.6, viewport.y*1.2)-viewport.y*.6; + p.z = -.4; + } + p.xy += displacement; + p.z *= scale; + brightness = .4 + .6 * seed; + glyphIndex = floor(identity.x * 7.999); + spectrum = identity.y; + ambientGlyph = background; + gl_Position = projectionMatrix * modelViewMatrix * vec4(p,1.0); + // A long-tailed size distribution, not a field of equal-size text icons: + // tiny distant fragments, readable mid-size glyphs, rare luminous anchors. + float foregroundSize = 4.0 + pow(seed,2.0)*13.0 + pow(seed,24.0)*28.0; + // Compact sculptures need smaller glyphs, not overlapping desktop sprites. + // Dispersed particles retain their established varied sizes. + foregroundSize *= mix(clamp(scale / 160.0, .35, 1.0), 1.0, spread); + float backgroundSize = 2.0 + pow(seed,2.0)*5.0 + pow(seed,24.0)*7.0; + gl_PointSize = mix(foregroundSize,backgroundSize,background) * dpr; + } +`; +const fragmentShader = ` + uniform vec3 tint; + uniform float opacity; + uniform sampler2D glyphAtlas; + uniform float highlight; + varying float brightness; + varying float glyphIndex; + varying float spectrum; + varying float ambientGlyph; + void main() { + vec2 uv = vec2((glyphIndex + gl_PointCoord.x) / 8.0, 1.0-gl_PointCoord.y); + float alpha = texture2D(glyphAtlas, uv).a * brightness * mix(opacity,.42,ambientGlyph); + if(alpha<.01) discard; + // Stable spectral identity: predominantly ice, with warm and violet accents. + // The page stays neutral; the glyphs carry the color and light. + vec3 spectral = spectrum < .52 ? vec3(.46,.77,1.0) + : spectrum < .74 ? vec3(1.0,.62,.34) + : spectrum < .89 ? vec3(.72,.59,1.0) : vec3(.88,.97,1.0); + vec3 color = mix(tint, spectral, highlight); + gl_FragColor = vec4(mix(color, vec3(1.0), pow(brightness,10.0)*highlight*.7),alpha); + } +`; + +export default function ParticleField({ root, light, count, onStatus }: Props) { + const host = useRef(null); + useEffect(() => { + const element = host.current; + if (!element) return; + onStatus("loading"); + let renderer: THREE.WebGLRenderer; + try { + renderer = new THREE.WebGLRenderer({ + alpha: true, + antialias: false, + powerPreference: "low-power", + }); + } catch { + onStatus("unavailable"); + return; + } + element.appendChild(renderer.domElement); + const scene = new THREE.Scene(); + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 2000); + camera.position.z = 1000; + const geometry = new THREE.BufferGeometry(); + // Allocate a bounded pool once. Resizing changes only the draw range, + // preserving the renderer, particle identities and keyboard focus. + const total = count + Math.round(count * 1.5 * MAX_AMBIENT_MULTIPLIER); + let activeTotal = count; + const shapes = Object.fromEntries( + (["code", "read", "ask", "check"] as Shape[]).map((s) => { + const positions = new Float32Array(total * 3); + positions.set(createShape(s, count)); + return [s, positions]; + }), + ) as Record; + geometry.setAttribute( + "position", + new THREE.BufferAttribute(shapes.code, 3), + ); + geometry.setAttribute( + "readShape", + new THREE.BufferAttribute(shapes.read, 3), + ); + geometry.setAttribute("askShape", new THREE.BufferAttribute(shapes.ask, 3)); + geometry.setAttribute( + "checkShape", + new THREE.BufferAttribute(shapes.check, 3), + ); + const scattered = new Float32Array(total * 3); + scattered.set(createScatter(count)); + for (let i = count; i < total; i++) { + scattered[i * 3] = particleSeed(i * 3) * 2 - 1; + scattered[i * 3 + 1] = particleSeed(i * 3 + 1) * 2.4 - 1.2; + } + geometry.setAttribute("scatter", new THREE.BufferAttribute(scattered, 3)); + geometry.setAttribute( + "seed", + new THREE.BufferAttribute( + Float32Array.from({ length: total }, (_, i) => particleSeed(i)), + 1, + ), + ); + geometry.setAttribute( + "identity", + new THREE.BufferAttribute( + Float32Array.from( + Array.from({ length: total }, (_, i) => particleIdentity(i)).flat(), + ), + 2, + ), + ); + geometry.setAttribute( + "background", + new THREE.BufferAttribute( + Float32Array.from({ length: total }, (_, i) => (i >= count ? 1 : 0)), + 1, + ), + ); + const offsets = new Float32Array(total * 2); + const velocities = new Float32Array(total * 2); + const displacement = new THREE.BufferAttribute(offsets, 2).setUsage( + THREE.DynamicDrawUsage, + ); + geometry.setAttribute("displacement", displacement); + // One original code-symbol atlas, not hundreds of animated DOM text nodes. + const atlasCanvas = document.createElement("canvas"); + atlasCanvas.width = 512; + atlasCanvas.height = 64; + const context = atlasCanvas.getContext("2d"); + if (!context) { + renderer.dispose(); + renderer.domElement.remove(); + geometry.dispose(); + onStatus("unavailable"); + return; + } + context.font = "500 44px monospace"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillStyle = "#ffffff"; + context.shadowColor = "#ffffff"; + context.shadowBlur = light ? 0 : 7; + ["{", "}", "<", ">", "[", "]", ";", "+"].forEach((glyph, i) => + context.fillText(glyph, i * 64 + 32, 33), + ); + const atlas = new THREE.CanvasTexture(atlasCanvas); + atlas.minFilter = THREE.LinearFilter; + atlas.magFilter = THREE.LinearFilter; + atlas.generateMipmaps = false; + const uniforms = { + atmosphereShift: { value: new THREE.Vector2() }, + scrollOffset: { value: 0 }, + tilt: { value: new THREE.Vector2() }, + glyphAtlas: { value: atlas }, + highlight: { value: light ? 0 : 1 }, + weights: { value: new THREE.Vector4(1, 0, 0, 0) }, + spread: { value: 1 }, + viewport: { value: new THREE.Vector2() }, + clock: { value: 0 }, + scale: { value: 180 }, + dpr: { value: 1 }, + center: { value: new THREE.Vector2() }, + pointer: { value: new THREE.Vector2(-10000, -10000) }, + pointerActive: { value: 0 }, + tint: { value: new THREE.Color(light ? "#086b91" : "#82d6fa") }, + opacity: { value: 1 }, + }; + const material = new THREE.ShaderMaterial({ + uniforms, + vertexShader, + fragmentShader, + transparent: true, + depthWrite: false, + depthTest: false, + blending: light ? THREE.NormalBlending : THREE.AdditiveBlending, + }); + const points = new THREE.Points(geometry, material); + points.frustumCulled = false; + scene.add(points); + let width = innerWidth, + height = innerHeight, + frame = 0, + last = 0, + elapsed = 0, + disposed = false, + ready = false, + contextLost = false; + let pointerTarget = 0; + const pointerPosition = new THREE.Vector2(-10000, -10000); + const strokes: PointerStroke[] = []; + let previousPointer: { x: number; y: number; time: number } | null = null; + let previousStroke: PointerStroke | null = null; + const tiltTarget = new THREE.Vector2(); + let drag: { + id: number; + x: number; + y: number; + element: HTMLElement; + } | null = null; + let anchors: HTMLElement[] = []; + const resize = () => { + width = innerWidth; + height = innerHeight; + activeTotal = count + ambientParticleCount(width, height, count); + geometry.setDrawRange(0, activeTotal); + renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5)); + renderer.setSize(width, height); + uniforms.viewport.value.set(width, height); + uniforms.dpr.value = renderer.getPixelRatio(); + camera.left = -width / 2; + camera.right = width / 2; + camera.top = height / 2; + camera.bottom = -height / 2; + camera.updateProjectionMatrix(); + anchors = Array.from( + root.querySelectorAll("[data-particle-shape]"), + ); + }; + const move = (event: PointerEvent) => { + pointerPosition.set( + event.clientX - width / 2, + height / 2 - event.clientY, + ); + const target = event.target instanceof Element ? event.target : null; + const control = target?.closest("a,button,input,select,pre"); + pointerTarget = + event.pointerType === "mouse" && + (!control || control.hasAttribute("data-field-interaction")) + ? 1 + : 0; + if (pointerTarget) { + const next = { + x: pointerPosition.x, + y: pointerPosition.y, + time: event.timeStamp, + }; + if (previousPointer && next.time - previousPointer.time < 160) { + const stroke: PointerStroke = { + x0: previousPointer.x, + y0: previousPointer.y, + x1: next.x, + y1: next.y, + }; + stroke.curve = curveCarry(previousStroke, stroke); + strokes.push(stroke); + previousStroke = stroke; + if (strokes.length > 64) strokes.shift(); + } else previousStroke = null; + previousPointer = next; + } else { + previousPointer = null; + previousStroke = null; + } + if (drag && drag.id === event.pointerId) { + tiltTarget.set( + Math.max(-0.16, Math.min(0.16, (event.clientX - drag.x) * 0.001)), + Math.max(-0.12, Math.min(0.12, (event.clientY - drag.y) * 0.001)), + ); + } + }; + const leave = () => { + pointerTarget = 0; + previousPointer = null; + previousStroke = null; + tiltTarget.set(0, 0); + }; + const down = (event: PointerEvent) => { + const target = + event.target instanceof Element + ? event.target.closest("[data-field-interaction]") + : null; + if (!target || event.pointerType !== "mouse" || event.button !== 0) + return; + drag = { + id: event.pointerId, + x: event.clientX, + y: event.clientY, + element: target, + }; + target.setPointerCapture(event.pointerId); + }; + const up = (event: PointerEvent) => { + if (drag?.id === event.pointerId) { + if (drag.element.hasPointerCapture(event.pointerId)) + drag.element.releasePointerCapture(event.pointerId); + drag = null; + tiltTarget.set(0, 0); + } + }; + const key = (event: KeyboardEvent) => { + if ( + !(event.target instanceof Element) || + !event.target.hasAttribute("data-field-interaction") || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey + ) + return; + const steps: Record = { + ArrowLeft: [-0.035, 0], + ArrowRight: [0.035, 0], + ArrowUp: [0, -0.035], + ArrowDown: [0, 0.035], + }; + const step = steps[event.key]; + if (!step) return; + event.preventDefault(); + tiltTarget.set( + Math.max(-0.16, Math.min(0.16, tiltTarget.x + step[0])), + Math.max(-0.12, Math.min(0.12, tiltTarget.y + step[1])), + ); + }; + const render = (now: number) => { + if (disposed || contextLost || document.hidden) return; + const dt = Math.min((now - last) / 1000 || 0, 0.05); + last = now; + elapsed += dt; + uniforms.clock.value = elapsed; + uniforms.scrollOffset.value = scrollY; + const states = anchors.map((anchor) => { + const rect = anchor.getBoundingClientRect(); + return { + shape: (anchor.dataset.particleShape || "code") as Shape, + at: shapeScrollAnchor(rect.top + scrollY + rect.height / 2, height), + x: rect.left + rect.width / 2 - width / 2, + y: height / 2 - rect.top - rect.height / 2, + size: Math.min(rect.width, rect.height) * 0.42, + spread: 0, + }; + }); + const [hero, demo, closing] = states; + const demoSurface = root + .querySelector(".study-demo-surface") + ?.getBoundingClientRect(); + const heroCopy = root + .querySelector(".study-hero-copy") + ?.getBoundingClientRect(); + if (hero && demo && closing && demoSurface && heroCopy) { + // Continuous keyframes, not nearest-anchor selection. Between chapters the + // same particles remain visible in a quiet edge field around the content. + // Every sculpture is central. The field retreats to the margins only + // while content is read, then gathers in the next open central interval. + const heroClearAt = Math.max( + hero.at + 1, + heroCopy.top + scrollY - height * 0.55, + ); + const clearAt = Math.max( + demo.at + 1, + demoSurface.top + scrollY - height * 0.38, + ); + const keys = [ + hero, + { ...hero, at: heroClearAt, spread: 1 }, + { + ...demo, + at: Math.max(heroClearAt + 1, demo.at - height * 0.75), + spread: 1, + }, + { ...demo, at: demo.at - height * 0.12 }, + demo, + { ...demo, at: Math.min(clearAt - 1, demo.at + height * 0.27) }, + { ...demo, at: clearAt, spread: 1 }, + { + ...closing, + at: Math.max(clearAt + 1, closing.at - height * 0.75), + spread: 1, + }, + closing, + ]; + keys.sort((a, b) => a.at - b.at); + let a = keys[0]!, + b = a; + for (let i = 1; i < keys.length; i++) { + b = keys[i]!; + if (scrollY <= b.at) break; + a = b; + } + const p = + a === b + ? 0 + : smoothProgress((scrollY - a.at) / Math.max(1, b.at - a.at)); + const blend = 1 - Math.exp(-dt * 3); + const weight = (shape: Shape) => + (a.shape === shape ? 1 - p : 0) + (b.shape === shape ? p : 0); + const desired = new THREE.Vector4( + weight("code"), + weight("read"), + weight("ask"), + weight("check"), + ); + uniforms.weights.value.lerp(desired, blend); + const spread = a.spread + (b.spread - a.spread) * p; + uniforms.spread.value += + (Math.max(spread, 1 - smoothProgress(elapsed / 1.5)) - + uniforms.spread.value) * + blend; + uniforms.opacity.value = + (1 - uniforms.spread.value) * 0.94 + + uniforms.spread.value * (light ? 0.5 : 0.6); + uniforms.center.value.lerp( + new THREE.Vector2(a.x + (b.x - a.x) * p, a.y + (b.y - a.y) * p), + blend, + ); + uniforms.scale.value += + (a.size + (b.size - a.size) * p - uniforms.scale.value) * blend; + } + uniforms.pointerActive.value += + (pointerTarget - uniforms.pointerActive.value) * Math.min(1, dt * 8); + uniforms.pointer.value.lerp(pointerPosition, 1 - Math.exp(-dt * 6)); + uniforms.tilt.value.lerp(tiltTarget, 1 - Math.exp(-dt * 2.2)); + uniforms.atmosphereShift.value.lerp( + new THREE.Vector2( + (pointerPosition.x / width) * 14 * pointerTarget, + (pointerPosition.y / height) * 14 * pointerTarget, + ), + 1 - Math.exp(-dt * 1.4), + ); + // Stateful, damped return: moving the cursor away does not reset a glyph + // to its home position on the next frame. The same spring handles release. + const weights = uniforms.weights.value; + const scale = uniforms.scale.value, + spread = uniforms.spread.value; + const yaw = Math.sin(elapsed * 0.16) * 0.035 + uniforms.tilt.value.x; + const pitch = uniforms.tilt.value.y; + for (let i = 0; i < activeTotal; i++) { + const j = i * 3, + seed = particleSeed(i); + let x = + shapes.code[j]! * weights.x + + shapes.read[j]! * weights.y + + shapes.ask[j]! * weights.z + + shapes.check[j]! * weights.w; + let y = + shapes.code[j + 1]! * weights.x + + shapes.read[j + 1]! * weights.y + + shapes.ask[j + 1]! * weights.z + + shapes.check[j + 1]! * weights.w; + const z = + shapes.code[j + 2]! * weights.x + + shapes.read[j + 2]! * weights.y + + shapes.ask[j + 2]! * weights.z + + shapes.check[j + 2]! * weights.w; + const rotatedZ = -x * Math.sin(yaw) + z * Math.cos(yaw); + x = x * Math.cos(yaw) + z * Math.sin(yaw); + y = y * Math.cos(pitch) - rotatedZ * Math.sin(pitch); + x = + (x * scale + uniforms.center.value.x) * (1 - spread) + + (scattered[j]! * width * 0.5 + + Math.sin(elapsed * 0.13 + seed * 23) * 8 + + uniforms.atmosphereShift.value.x * (0.3 + seed * 0.7)) * + spread; + y = + (y * scale + uniforms.center.value.y) * (1 - spread) + + (scattered[j + 1]! * height * 0.5 + + Math.cos(elapsed * 0.1 + seed * 19) * 6 + + uniforms.atmosphereShift.value.y * (0.3 + seed * 0.7)) * + spread; + if (i >= count) { + x = + scattered[j]! * width * 0.5 + + Math.sin(elapsed * 0.16 + seed * 20) * 10 + + uniforms.atmosphereShift.value.x * (0.4 + seed * 0.6); + const raw = + scattered[j + 1]! * height * 0.5 + + scrollY * (0.025 + seed * 0.035) + + Math.cos(elapsed * 0.12 + seed * 15) * 7 + + uniforms.atmosphereShift.value.y * (0.4 + seed * 0.6) + + height * 0.6; + y = + (((raw % (height * 1.2)) + height * 1.2) % (height * 1.2)) - + height * 0.6; + } + advanceGlyphMotion( + offsets, + velocities, + i, + x, + y, + strokes, + dt, + i >= count, + ); + } + strokes.length = 0; + displacement.needsUpdate = true; + renderer.render(scene, camera); + if (!ready) { + ready = true; + onStatus("ready"); + } + frame = requestAnimationFrame(render); + }; + const visibility = () => { + cancelAnimationFrame(frame); + last = performance.now(); + if (!document.hidden && !contextLost && !disposed) + frame = requestAnimationFrame(render); + }; + const lost = (event: Event) => { + event.preventDefault(); + cancelAnimationFrame(frame); + contextLost = true; + ready = false; + onStatus("unavailable"); + }; + const restored = () => { + if (disposed) return; + contextLost = false; + onStatus("loading"); + resize(); + visibility(); + }; + resize(); + frame = requestAnimationFrame(render); + window.addEventListener("resize", resize); + root.addEventListener("pointermove", move, { passive: true }); + root.addEventListener("pointerleave", leave); + root.addEventListener("pointerdown", down); + root.addEventListener("pointerup", up); + root.addEventListener("pointercancel", up); + root.addEventListener("keydown", key); + root.addEventListener("focusout", leave); + document.addEventListener("visibilitychange", visibility); + renderer.domElement.addEventListener("webglcontextlost", lost); + renderer.domElement.addEventListener("webglcontextrestored", restored); + return () => { + disposed = true; + cancelAnimationFrame(frame); + window.removeEventListener("resize", resize); + root.removeEventListener("pointermove", move); + root.removeEventListener("pointerleave", leave); + root.removeEventListener("pointerdown", down); + root.removeEventListener("pointerup", up); + root.removeEventListener("pointercancel", up); + root.removeEventListener("keydown", key); + root.removeEventListener("focusout", leave); + document.removeEventListener("visibilitychange", visibility); + renderer.domElement.removeEventListener("webglcontextlost", lost); + renderer.domElement.removeEventListener("webglcontextrestored", restored); + geometry.dispose(); + material.dispose(); + atlas.dispose(); + renderer.dispose(); + renderer.domElement.remove(); + }; + }, [root, light, count, onStatus]); + return