From ee325f381c4abc4647cc21fe7129d4a92b3562e8 Mon Sep 17 00:00:00 2001 From: msrivas-7 Date: Tue, 8 Sep 2026 18:05:58 -0700 Subject: [PATCH 01/14] Unify public surfaces with the living code-glyph theme --- docs/DESIGN_SYSTEM.md | 111 +++ docs/DEVELOPMENT.md | 29 +- docs/PUBLIC_BRAND_CONTINUITY_DESIGN.md | 354 ++++++++ docs/PUBLIC_THEME_CONTINUITY.md | 178 ++++ e2e/specs/marketing.spec.ts | 19 + e2e/specs/public-theme.spec.ts | 769 ++++++++++++++++++ e2e/specs/share-reveal.spec.ts | 87 ++ frontend/index.html | 75 +- frontend/scripts/discoverySite.test.ts | 31 +- frontend/scripts/discoverySite.ts | 64 +- frontend/scripts/vitePluginDesignTokens.ts | 10 + frontend/scripts/vitePluginDiscovery.ts | 43 +- frontend/src/App.tsx | 17 +- frontend/src/PublicApp.tsx | 46 +- frontend/src/auth/AuthShell.tsx | 115 +-- frontend/src/auth/PasswordSignupForm.tsx | 2 +- frontend/src/design-system/tokens.test.ts | 35 + frontend/src/design-system/tokens.ts | 102 +++ .../marketing/public/AuthFieldStill.tsx | 63 ++ .../marketing/public/DiscoveryMotion.tsx | 40 + .../marketing/public/PublicMotionWorld.tsx | 68 ++ .../features/marketing/public/PublicPage.tsx | 84 ++ .../marketing/public/PublicThemeSync.tsx | 103 +++ .../marketing/public/RouteLoading.tsx | 73 ++ .../marketing/public/authComposition.test.ts | 60 ++ .../marketing/public/authComposition.ts | 41 + .../marketing/public/discovery-theme.css | 63 ++ .../features/marketing/public/public-page.css | 358 ++++++++ .../marketing/public/publicTheme.test.ts | 103 +++ .../src/features/marketing/public/theme.css | 96 +++ .../src/features/marketing/public/world.css | 18 + .../marketing/study/MarketingHomepage.tsx | 60 +- .../marketing/study/ParticleField.tsx | 119 ++- .../src/features/marketing/study/study.css | 106 +-- .../share/components/CodeTypewriter.tsx | 77 +- .../share/components/codeRevealTiming.test.ts | 48 ++ .../share/components/codeRevealTiming.ts | 25 + .../src/features/share/pages/SharePage.tsx | 168 ++-- .../src/hooks/useReducedMotionPreference.ts | 18 + frontend/src/index.css | 3 +- frontend/src/main.tsx | 12 +- frontend/src/pages/LoginPage.tsx | 6 +- frontend/src/pages/NotFoundPage.tsx | 43 +- frontend/src/pages/ResetPasswordPage.tsx | 4 +- frontend/src/pages/TrustPage.tsx | 294 ++++--- frontend/src/pages/WhyNotChatGPTPage.tsx | 137 ++-- frontend/staticwebapp.config.json | 6 + frontend/vite.config.ts | 2 + 48 files changed, 3679 insertions(+), 706 deletions(-) create mode 100644 docs/DESIGN_SYSTEM.md create mode 100644 docs/PUBLIC_BRAND_CONTINUITY_DESIGN.md create mode 100644 docs/PUBLIC_THEME_CONTINUITY.md create mode 100644 e2e/specs/public-theme.spec.ts create mode 100644 e2e/specs/share-reveal.spec.ts create mode 100644 frontend/scripts/vitePluginDesignTokens.ts create mode 100644 frontend/src/design-system/tokens.test.ts create mode 100644 frontend/src/design-system/tokens.ts create mode 100644 frontend/src/features/marketing/public/AuthFieldStill.tsx create mode 100644 frontend/src/features/marketing/public/DiscoveryMotion.tsx create mode 100644 frontend/src/features/marketing/public/PublicMotionWorld.tsx create mode 100644 frontend/src/features/marketing/public/PublicPage.tsx create mode 100644 frontend/src/features/marketing/public/PublicThemeSync.tsx create mode 100644 frontend/src/features/marketing/public/RouteLoading.tsx create mode 100644 frontend/src/features/marketing/public/authComposition.test.ts create mode 100644 frontend/src/features/marketing/public/authComposition.ts create mode 100644 frontend/src/features/marketing/public/discovery-theme.css create mode 100644 frontend/src/features/marketing/public/public-page.css create mode 100644 frontend/src/features/marketing/public/publicTheme.test.ts create mode 100644 frontend/src/features/marketing/public/theme.css create mode 100644 frontend/src/features/marketing/public/world.css create mode 100644 frontend/src/features/share/components/codeRevealTiming.test.ts create mode 100644 frontend/src/features/share/components/codeRevealTiming.ts create mode 100644 frontend/src/hooks/useReducedMotionPreference.ts 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..0063c12b 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -426,7 +426,7 @@ 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 @@ -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..ceed2d7d --- /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. +- [ ] Final source/deterministic/browser/harness gates on exact intended phase diff. +- [ ] 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..7a0aaa28 --- /dev/null +++ b/docs/PUBLIC_THEME_CONTINUITY.md @@ -0,0 +1,178 @@ +# Public theme continuity + +**September 8: local implementation approved; release gates in progress.** Branch +`dev/public-theme-continuity`, based on homepage release `690c767`. +Nothing from this branch is committed, published or deployed. + +**27 of 27 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 + +- **Latest product source:** 632 frontend tests, production build/typecheck and + unchanged asset budgets pass. **34 marketing + 62 public-theme/share-reveal + checks pass in Chromium/WebKit, zero retries.** These supplement real browsing. + The combined 96-case run passed again after unlock (2.0 minutes), along with + all 632 tests, the production build and asset budgets. + 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. | + +## 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`. | +| `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. | + +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. +- [ ] 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. +- [ ] Publish separate PR with before→after journey notes. 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. +Auth/access rules are unchanged. 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/public-theme.spec.ts b/e2e/specs/public-theme.spec.ts new file mode 100644 index 00000000..aca671ed --- /dev/null +++ b/e2e/specs/public-theme.spec.ts @@ -0,0 +1,769 @@ +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 release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + await page.route(/\/(?:src\/App\.tsx|assets\/App-[^/]+\.js)(?:\?|$)/, async route => { + await held; + 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(); + release(); + 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 { + release(); + await page.unrouteAll({ behavior: "wait" }); + } +}); + +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\/App-[^/]+\.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\/App\.tsx|assets\/App-[^/]+\.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[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + 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([]); +}); + +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-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/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( + /