diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..4a9a4ee --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "quiztin-frontend", + "runtimeExecutable": "sh", + "runtimeArgs": ["-c", "cd frontend && npm run dev"], + "port": 5173 + } + ] +} diff --git a/context/foundation.md b/context/foundation.md index 5222089..781590c 100644 --- a/context/foundation.md +++ b/context/foundation.md @@ -122,7 +122,7 @@ The heart of the file. Numbered so other files cite `foundation.md §7 #N`. Rows | 22 | **Hosting: containerized (Docker) → Railway**, with **Railway-managed Postgres** *(this session)* | Simplest path for a solo dev on a student budget; Docker keeps it portable; Postgres is Railway-native | Azure (heavier); Fly/Render (Railway chosen); self-hosted SQL Server container (heavy) | | 23 | **Frontend language: TypeScript (strict)** *(spec 0001)* | The API boundary is where drift bites; types plus Zod catch it before a screen does | Plain JavaScript (no boundary safety) | | 24 | **Tailwind v4, bound to the design tokens by reference (`@theme inline`), Preflight not loaded** *(spec 0001)* | Makes `bg-primary` compile to `var(--primary)`, so the "semantic tokens only, no raw hex" rule is enforced by the compiler rather than by review; `design-system/tokens/base.css` is already the reset, so a second one would fight it | CSS Modules (rule survives on discipline alone); Tailwind with Preflight (two competing resets) | -| 25 | **React Router v7 data router (`createBrowserRouter`), loaders and actions unused; TanStack Query owns all server state** *(spec 0001)* | Nested routes and error boundaries without framework mode, whose Vite plugin and server rendering are the same machinery rejected in #5; one data layer, not two | Framework mode (adds a Node runtime to a .NET architecture); loaders plus Query (two caches, one truth) | +| 25 | **React Router v8 data router (`createBrowserRouter`), loaders and actions unused; TanStack Query owns all server state** *(spec 0001; the installed major is 8.2.x, confirmed and its memory router used for the spec 0003 prerender)* | Nested routes and error boundaries without framework mode, whose Vite plugin and server rendering are the same machinery rejected in #5; one data layer, not two | Framework mode (adds a Node runtime to a .NET architecture); loaders plus Query (two caches, one truth) | | 26 | **Session: short-lived access token held in memory, rotating refresh token in an `HttpOnly` cookie; AuthService gains `POST /api/auth/refresh` + `POST /api/auth/logout`, with reuse detection and a grace window** *(spec 0001)* | A token in `localStorage` is readable by any injected script, and an 8h token guarding student records is an unacceptable blast radius (`security.md`); the cookie is unreachable by script and rotation bounds replay | Token in `localStorage` (script-readable); server-held session (a second runtime to operate) | | 27 | **The frontend is same-origin with the API: Vite dev proxy now, the YARP gateway (#16) later. No CORS in any service** *(spec 0001)* | CORS in five services is five places to get it wrong, then a sixth at the gateway; same-origin means the app never learns the concept, and the gateway move is config, not a rewrite | CORS per service (port chaos, five configs) | | 28 | **Build only against endpoints that exist; no mock layer** *(spec 0001)* | A mock is a second source of truth that agrees with the backend right up until it doesn't, and the divergence surfaces late; scope follows the real API surface instead | Mocked endpoints to unblock screens (Facade-first, rejected against §0) | diff --git a/context/library-docs.md b/context/library-docs.md index 3d7c7b9..e4c7388 100644 --- a/context/library-docs.md +++ b/context/library-docs.md @@ -59,9 +59,9 @@ - **How used:** `@theme inline { --color-primary: var(--primary); … }` over the semantic aliases only. Import `tailwindcss/theme.css` + `tailwindcss/utilities.css`; **never bare `tailwindcss`**, which would pull in Preflight and fight `design-system/tokens/base.css`. - **Gotchas:** ⚠️ Preflight is also what sets `border-style: solid` globally. Without it every Tailwind `border` utility renders **invisible**. Restore it with a three-line `@layer base` rule. Declare `@layer theme, base, components, utilities;` first, or the design system reset outranks the utilities. -### React Router v7 — ✅ (new) -- **How used:** `createBrowserRouter` for routing only (nested routes, `errorElement`, lazy routes). **Loaders and actions stay unused** — TanStack Query owns server state (§7 #25). -- **Gotchas:** do not adopt *framework mode*; its Vite plugin and server rendering are what #5 rejected with Next.js. +### React Router v8 — ✅ (new) +- **How used:** `createBrowserRouter` for routing only (nested routes, `errorElement`, lazy routes). **Loaders and actions stay unused** — TanStack Query owns server state (§7 #25). The landing prerender (spec 0003) uses `createMemoryRouter` for the same route table in Node. +- **Gotchas:** the installed major is **8.2.x**; earlier docs said v7, a drift now reconciled (spec 0003) after the prerender was wired and verified against 8.2.x (`createBrowserRouter`, `createMemoryRouter`, `RouterProvider`, `renderToString`). Do not adopt *framework mode*; its Vite plugin and server rendering are what #5 rejected with Next.js. ### TanStack Query v5 — ✅ (new) - **How used:** all server state. Query keys centralized; never retry a 4xx. @@ -77,6 +77,16 @@ ### Vitest + Testing Library + vitest-axe — ✅ (new, test) - **How used:** component tests and an automated accessibility floor on the primitives. +### framer-motion — ✅ (new, spec 0003) +- **Why:** foundation §7 #21 gates new dependencies. The landing page (spec 0003, AC-10) needs real motion with a genuine reduced motion path, and framer-motion ships `useReducedMotion` plus declarative enter and scroll animation. Chosen over hand rolled CSS so the reduced motion branch is one hook, not media queries scattered across the stylesheet. +- **How used:** landing page only, inside `features/landing/`. A small kit (`features/landing/motion/`) wraps it: `useMotionReady` (in `useMotionReady.ts`) combines framer-motion's `useReducedMotion` with a mount gate, so the resting state is always the fully visible one; `Reveal` fades and lifts content the first time it scrolls into view; `AmbientFloat` drifts the decorative bubbles; the hero crossfades its copy and persona on toggle; the `Highlight` fill sweeps in. Every animation checks `useMotionReady` first. +- **Gotchas:** keep it out of the authenticated app bundle. The landing route is lazy loaded (AC-13) so framer-motion lands in its own chunk; never import it from a shared component. During the build time prerender (AC-11) `useMotionReady` is false (no window, not mounted), so the static markup carries no hidden or shifted state and a no JavaScript reader still sees every word. + +### Landing prerender (react-dom/server, no new package) — ✅ (spec 0003) +- **Why:** the landing page needs real HTML for search engines and social cards (spec 0003, AC-11), but the app is a client rendered SPA with no SSR (foundation §7 #5/#25). Prerendering only the `/` route at build time gives that HTML without adopting an SSR framework. +- **How used:** `renderToString` from `react-dom/server` (already present through react-dom, so no new package) renders the same app tree the client hydrates, with a memory router pinned to `/` (`src/prerender.tsx`, built by `vite build --ssr`). A postbuild script (`scripts/prerender.mjs`) injects that markup plus the SEO head (`features/landing/seo.ts`) into the built `index.html` and writes a separate `index.prerender.html`. The neutral `index.html` stays the SPA fallback. The gateway serves `index.prerender.html` only at exact `/` and the neutral bootstrap everywhere else (AC-14). `main.tsx` hydrates when `#root` has prerendered markup, else mounts fresh. +- **Gotchas:** the route table lives in `routes.tsx` (no `createBrowserRouter`), so the prerender can import it in Node without touching the History API. Keep every provider above the routes identical between `main.tsx` and `prerender.tsx` or `useId` and hydration drift. `vite-react-ssg` is not used; if this script path is ever abandoned, add it here first. + ## Approved dependencies Do not install anything outside this list without adding it here first (with a why + how-used). @@ -95,10 +105,11 @@ Do not install anything outside this list without adding it here first (with a w | xUnit, Moq, coverlet | Testing | ✅ | | React 19, Vite 8, TypeScript | Frontend SPA | ✅ (spec 0001) | | tailwindcss 4 + @tailwindcss/vite | Styling, bound to design tokens | ✅ (spec 0001) | -| react-router 7 | Routing (data router, no loaders) | ✅ (spec 0001) | +| react-router 8 | Routing (data router, no loaders); memory router for the spec 0003 prerender | ✅ (spec 0001, 8.2.x) | | @tanstack/react-query 5 | Server state | ✅ (spec 0001) | | react-hook-form + @hookform/resolvers + zod 4 | Forms and boundary validation | ✅ (spec 0001) | | @phosphor-icons/react 2 | Icons | ✅ (spec 0001) | +| framer-motion 12 | Landing page motion, reduced motion aware (landing only, code split) | ✅ (spec 0003) | | vitest, @testing-library/react, vitest-axe | Frontend tests + a11y floor | ✅ (spec 0001) | | eslint + typescript-eslint + eslint-plugin-jsx-a11y + prettier | Lint (a11y rules enforced) | ✅ (spec 0001) | | UI components | Authored in-repo from `design-system/` | ✅ no library (export ships no React source) | diff --git a/context/progress-log.md b/context/progress-log.md index c952d33..f37c549 100644 --- a/context/progress-log.md +++ b/context/progress-log.md @@ -20,6 +20,61 @@ Category one of: `feature` · `fix` · `refactor` · `chore` · `decision` · `d ## Entries +### [feat] Landing page — scroll motion and an animated FAQ accordion (spec 0003, AC-10) +- **Date:** 2026-07-14 +- **Area:** apps/frontend +- **What:** Added scroll motion and animated the FAQ, both still inside AC-10's reduced motion contract. + - **Scroll:** smooth in page scrolling for the nav anchors (CSS `scroll-behavior`, scoped with `:root:has(.qz-landing)` so it never changes how the rest of the app scrolls, plus `scroll-margin-top: 6rem` so a section lands below the sticky nav rather than under it); a **scroll progress bar** across the top of the viewport (`ScrollProgress` in the motion kit); and **parallax** drift on the hero's two background blobs (`Parallax`), which move in opposite directions for depth. + - **FAQ:** the accordion now glides open and closed with a rotating chevron. It stays a native `
`, so with no JavaScript the browser still opens it and every answer remains in the prerendered markup (AC-11). When motion is allowed React drives the open state so the panel can animate, holding the `open` attribute through the collapse so the browser does not hide the panel mid animation. + - Under reduced motion: anchors jump instead of gliding, the progress bar is not rendered at all, the blobs sit still, and the FAQ is the plain native element. + - 9 new tests (suite now 66). +- **Notes:** Two real findings. **framer-motion's window level `useScroll()` sits at zero here**: it measures the scrollable range once, and the landing arrives as its own lazy chunk (AC-13) so the page is still growing when it measures. The progress bar therefore tracks scroll itself (a `MotionValue` fed by a scroll listener, re measured on resize and by a `ResizeObserver`), which is proven in `ScrollProgress.test.tsx` (scaleX 0 → 0.5 → 0.75 → full). Second, **framer-motion's `useReducedMotion` caches the media query answer once per module**, so a single test file cannot exercise both motion modes; the motion allowed tests live in their own files (`ScrollProgress.test.tsx`, `Faq.motion.test.tsx`). Tooling note: the embedded browser pane reported `visibilityState: hidden` with a zero height viewport, which pauses `requestAnimationFrame`, so scroll driven motion could not be filmed there; it was verified by test instead, and the smooth scroll CSS and the parallax transform were confirmed live before the pane degraded. + +### [docs] Reconcile react-router version drift; advance spec 0003 status +- **Date:** 2026-07-13 +- **Area:** context / docs +- **What:** Reconciled the React Router version the context names with what is installed and now used. `package.json` pins `react-router ^8.2.0` and the whole app (including the new spec 0003 prerender) is wired and verified against 8.2.x, so updated **`foundation.md` §7 #25** (v7 to v8, noting the memory router used for the prerender) and **`library-docs.md`** (the React Router entry heading, the how used and gotcha lines, and the approved list row). Confirmed **`ui-rules.md` §5** already names the marketing landing hero as an allowed blueberry gradient surface (added during the content build). Advanced **spec 0003** status from Proposed to **In Progress**. +- **Notes:** Closes the react-router follow up the spec cross check raised. The spec advances to Accepted after an independent `/check verify` pass. The whole landing feature (spec 0003) is now built: content, personas, motion (AC-10), prerender plus SEO (AC-11), the gateway serving split (AC-14), code splitting (AC-13), and a 27 test suite, all green on `npm run test`, `npm run lint`, `tsc --noEmit`, and `npm run build`. + +### [test] Landing page — test suite (spec 0003, AC-2/AC-5/AC-10/AC-11/AC-12/AC-14/AC-15) +- **Date:** 2026-07-13 +- **Area:** apps/frontend +- **What:** Added 27 tests across the landing feature (Vitest, Testing Library, vitest-axe), taking the frontend suite to 57. Coverage: the hero toggle (default audience, content swap, keyboard arrows, the `aria-live` content change announcement, tab and tabpanel roles, no auto rotate, and the call to action role hints, AC-2/AC-4); the auth adaptive nav (signed out vs signed in, AC-5); the reduced motion path (`Reveal` and `AmbientFloat` render fully visible with no hidden or shifted start state, AC-10); the SEO builder (title, description, Open Graph, Twitter, canonical, valid product JSON-LD with escaping, AC-11); the prerender output (hero copy plus every section plus the head tags) and the neutral bootstrap staying landing free (AC-11/AC-14); and the whole page (section order AC-3, the free line, the persona disclosure AC-15, and an axe pass AC-12). Added jsdom stubs for `matchMedia` and `IntersectionObserver` to `src/test/setup.ts` and a `setReducedMotion` helper (`src/test/matchMedia.ts`). +- **Notes:** The landing tests run on the reduced motion path, which is deterministic and fully visible (jsdom has no IntersectionObserver driven reveal or rAF), so they assert the AC-10 guarantee directly; the animated path was verified in the browser. axe cannot check colour contrast in jsdom (no canvas), so contrast (AC-7) stays a browser check, done. All green: `npm run test` (57), `npm run lint`, `tsc --noEmit`, `npm run build`. + +### [feat] Landing page — code split the landing route (spec 0003, AC-13, WIP on `feat/landing-page`) +- **Date:** 2026-07-13 +- **Area:** apps/frontend +- **What:** Made the landing page its own chunk so the authenticated app never downloads it. Moved every non landing route into `routes.tsx` (which no longer imports the landing), and build the router in `main.tsx`: on the prerendered `/` it loads the landing chunk with a dynamic import, then hydrates with an eager element (so the tree matches the prerender and hydration stays clean); on any other route the landing is a lazy route, so its chunk loads only if the user goes to `/`. `prerender.tsx` keeps the landing eager (Node). Removed the now unused `router.tsx`. Because the landing CSS is now a separate chunk, the prerender script reads the Vite build manifest and links the landing CSS (and modulepreloads its JS) in the prerendered document, so `/` still paints fully styled. Satisfies **AC-13**. +- **Notes:** Verified over a real gateway run: the main entry chunk dropped from ~188 KB to ~141 KB gzip (framer-motion and the landing left it), the landing is a separate ~48 KB gzip chunk plus its own CSS. On `/` the landing chunk loads (needed to hydrate) and the page is styled with no flash; on `/register` the Performance API shows only the main entry chunk, no landing chunk, no framer-motion, no persona images. Enabled `build.manifest` in `vite.config.ts` for the asset lookup. Remaining: the test suite (AC-2, AC-5, AC-10, AC-11, AC-14, AC-12), then the context reconcile. + +### [feat] Landing page — prerender, SEO, and the gateway serving fix (spec 0003, AC-11 + AC-14, WIP on `feat/landing-page`) +- **Date:** 2026-07-13 +- **Area:** apps/frontend / gateway +- **What:** Prerendered the `/` route to static HTML at build time and taught the gateway to serve it only at exact `/`. + - **Frontend:** split the route table into `routes.tsx` (no `createBrowserRouter`, safe to import in Node) and a shared `AppRoot.tsx` (the one app tree, used by both the client and the prerender). `prerender.tsx` renders that tree with a memory router pinned to `/` via `renderToString` (react-dom/server, no new package); `features/landing/seo.ts` builds the head (title, meta description, Open Graph, Twitter, canonical, product JSON-LD); `scripts/prerender.mjs` injects both into the built `index.html` and writes a separate `index.prerender.html`. `main.tsx` now hydrates when `#root` has prerendered markup and mounts fresh otherwise. Build wires it in: `vite build` then `npm run prerender` (`vite build --ssr` then the script). + - **Gateway:** `src/Gateway/Program.cs` serves `index.prerender.html` only at exact `/` (an explicit endpoint that outranks the SPA fallback), keeps the neutral `index.html` as the catch all, and dropped `UseDefaultFiles` so `/` is not rewritten to the neutral doc. + - Satisfies **AC-11** and **AC-14**. +- **Notes:** Verified against a real gateway run over the built output: `GET /` returns the landing markup plus the SEO tags, `GET /register` and `GET /profile` return the neutral bootstrap with zero landing content, assets still serve. The prerendered page hydrates with no console errors and is interactive. Fixed a UX regression found during that check: the hero toggle used `AnimatePresence mode="wait"`, which serialized exit then enter and felt stuck for over a second; switched to a keyed fade (the block remounts and fades in), now snappy. Also simplified so a reduced motion or pre hydration render has no initial state (never hidden). Recorded the prerender approach in `library-docs.md`. Open follow ups: add the real Open Graph share image (the tag points at `/og-image.png`) and confirm the canonical base URL (currently `SITE_URL` env with a Railway default). + +### [feat] Landing page — motion layer (spec 0003, AC-10, WIP on `feat/landing-page`) +- **Date:** 2026-07-13 +- **Area:** apps/frontend +- **What:** Added the motion layer with **framer-motion** (12.x), all of it landing only. New kit under `features/landing/motion/`: `useMotionReady.ts` (combines framer-motion `useReducedMotion` with a mount gate, so the resting state is always the fully visible one and the prerender and reduced motion paths render plain content) and `motion.tsx` (`Reveal`, a scroll reveal that fades and lifts content the first time it enters view; `AmbientFloat`, the slow drift on the decorative bubbles). Wired into the hero (a crossfade of copy and persona on toggle, plus a polite `aria-live` line announcing the content change for **AC-2**), the `Highlight` (the coral fill now sweeps in, the highlighter draw on), and every content section (`HowItWorks`, `ValueSplit`, `AISpotlight`, `Faq`, `CtaBand`) via `Reveal`, with staggered reveals on the step and FAQ lists. Recorded framer-motion in `library-docs.md` (the frontend section plus the approved list). Satisfies **AC-10** (and completes the content change announcement for **AC-2**). +- **Notes:** The resting state is the visible state by construction, so a reduced motion visitor, the pre hydration paint, and the coming prerender all show fully visible content with no hidden or shifted state to recover from. Verified live at localhost:5173: the toggle crossfade, the section reveals settling to full opacity, and no layout break from the `.qz-mark` restructure. Reliability follow through: the hero toggle keydown moved onto the tabs (the roving focus pattern) so the tablist is not an interactive element (lint clean). framer-motion is still in the main bundle for now; the next slice (**AC-13**) code splits the landing route so it never reaches the authenticated app. Reduced motion is asserted rigorously in the coming test suite (mocked matchMedia). + +### [feat] Landing page — content build done (spec 0003, WIP on `feat/landing-page`) +- **Date:** 2026-07-13 +- **Area:** apps/frontend +- **What:** Built the marketing landing page (spec 0003) within the existing design system. Delivered: public routing (`/` → `LandingPage` for everyone; removed the authed index redirect in `router.tsx`); an in-repo SVG **decoration kit** (`features/landing/decoration/Decoration.tsx`: Blob, Bubble, Squiggle, Dots, WaveDivider, Highlight, all tokenised); the auth-adaptive `LandingNav`; the For students / For teachers **toggle hero** (ARIA tabs, keyboard, no auto-rotate) with the coral highlighter, a persona in a blob frame + coral/sand duotone, and the one allowed blueberry-gradient glow; **How it works** (3 step cards); the **value split** (For teachers / For students, alternating photo/text) using the office + tutoring scenes; the **AI feedback spotlight** as an honest "Example feedback" vignette (coral Q avatar, "to review" badge) built from tokens (AC-9, the real quiz/results screens are not built); the **FAQ** as a `
` accordion; the closing **CTA band** with the free line; and the **footer** with the persona disclosure (AC-15). Wired `RegisterPage` to read the `?role=` hint (AC-4). **5 AI-generated persona shots** (female + male student portraits, professor portrait, students-tutoring, professor-office; 3 distinct races) added under `features/landing/assets/personas/`, web-resized. Satisfies **AC-1, AC-2, AC-3, AC-4, AC-5, AC-6, AC-7, AC-8, AC-9, AC-12, AC-15**. Build green (tsc + vite), no console errors, verified live at localhost:5173 desktop + mobile. +- **Notes:** **WIP. Remaining (technical layers):** motion via framer-motion (**AC-10**), prerender + SEO + the gateway serving fix (**AC-11, AC-14**), code-split the landing route (**AC-13**), and the **test suite**. Open follow-ups: add framer-motion to `library-docs.md`; reconcile the react-router v7→v8 drift (`package.json` pins `^8.2.0`). Reliability fix: dropped a `backdrop-filter` blur on the nav that hung the headless preview on scroll. Tooling note: the in-app browser desyncs screenshots after scrolling; set the viewport height ≥ page height to capture the full page in one shot. + +### [docs] Spec 0003 — marketing landing page designed (via /architect) +- **Date:** 2026-07-13 +- **Area:** context / docs / apps/frontend +- **What:** Authored `docs/specs/0003-landing-page/` (index.md, rationale.md, verify.md), status Proposed — the design for a public marketing landing page at `/`, built entirely from the locked design system (dialed up a little). Decisions: one page at `/` for everyone (no auth redirect); a "For students / For teachers" segmented toggle hero (not an auto-rotating carousel); an in-repo SVG decoration kit (bubbles/blobs/squiggles/dividers/highlighter); AI-generated persona photos (female + male students + professor, plus tutoring + office scenes, three distinct races) in blob-cutout + duotone frames; unDraw illustrated people for process zones; on-brand UI vignettes standing in for the quiz/results/AI-feedback screens (NOT built yet — no real screenshots, no fabricated testimonials, personas disclosed as illustrative); framer-motion for motion (reduced-motion honored); and a build-time prerender via `react-dom/server` renderToStaticMarkup for SEO. 15 acceptance criteria. Reconciled **`ui-rules.md` §5** to allow the landing hero as a second blueberry-gradient surface (the decision required it — the golden rule). +- **Notes:** An independent-model cross check (a different model, read-only) caught a real **blocker** before sign-off: the gateway's `MapFallbackToFile("index.html")` would serve the landing markup for *every* route once `index.html` is prerendered → fixed by **AC-14** (serve the static `/` only at exact `/`, keep a neutral bootstrap fallback, hydrate not repaint). Also caught: `/` is already claimed by the authed index redirect, so mounting the landing there is a **reroute** (build task 1 removes the `Navigate to="/profile"` index); and `frontend/package.json` actually pins **react-router `^8.2.0`**, not the v7 the context names — a drift flagged for reconciliation before the prerender entry is wired. Spec Follow-ups: add framer-motion to `library-docs.md`; reconcile the react-router version; swap vignettes for real screenshots when those screens ship. No `docs/scope/` row (project is context-first by design); the build tasks live in the spec's `## Build plan`. Next: `/develop` builds it. Personas currently sit in the session scratchpad; they get committed into `frontend/` at build time. + ### [docs] Production platform Phase 8 — reconcile the context system (spec 0002 complete) - **Date:** 2026-07-13 - **Area:** context / docs diff --git a/context/ui-rules.md b/context/ui-rules.md index cbf25de..6b4949a 100644 --- a/context/ui-rules.md +++ b/context/ui-rules.md @@ -30,7 +30,7 @@ Fredoka (`--font-display`) for headings and brand moments; Nunito (`--font-body` ## §5 Per-surface rules - **Take-quiz:** flat, calm, low-anxiety. No gradients. Calm ever-present `ProgressBar`. No countdown-timer language. -- **Teacher dashboard:** the **one** place a blueberry gradient hero is allowed. +- **Teacher dashboard** and the **marketing landing hero** (spec 0003): the two places a blueberry gradient hero is allowed. Nowhere else. - **Results:** encouraging headline + calm score ring + counts framed as "to review" (`ResultSummary`). - **AI feedback:** the coral/apricot `AIFeedbackCard` — visually distinct and supportive, a helpful voice, **not a grade stamp**. diff --git a/docs/specs/0003-landing-page/index.md b/docs/specs/0003-landing-page/index.md new file mode 100644 index 0000000..e9c14f8 --- /dev/null +++ b/docs/specs/0003-landing-page/index.md @@ -0,0 +1,158 @@ +# 0003. Quiztin marketing landing page + +**Date**: 2026-07-13 +**Status**: In Progress + +## Summary + +Quiztin is deployed and works, but it has no public front door: a visitor lands straight on the sign in screen with no idea what Quiztin is or who it serves. This spec designs a public marketing landing page at `/`, built entirely from the locked design system (turned up a little, not a new look). It speaks to both audiences through one hero with a "For students" and "For teachers" toggle, carries a light bubble and blob motif on every section, and uses real personas, friendly illustrations, and on brand interface vignettes for warmth and proof. It ships prerendered to static HTML so search engines and social cards work, with motion that always yields to reduced motion. + +## Requirements + +**User stories**: +- As a visiting teacher, I want to see what Quiztin does and that it is credible and free, so that I decide to create my first quiz. +- As a visiting student, I want the page to feel welcoming and low pressure, so that joining a classroom feels easy, not like a test. +- As a returning user, I want the front door to recognise me, so that I can step back into the app quickly. +- As anyone sharing the link, I want a proper title, description, and share card, so that the link looks trustworthy. + +**Acceptance criteria** (the contract, each independently checkable): +- **AC-1**: A public landing page renders at `/` for every visitor, signed in or not, with no authentication required and no forced redirect. +- **AC-2**: The hero carries a "For students" and "For teachers" toggle (a segmented control, never an auto advancing carousel). Switching updates the headline, subcopy, primary call to action, and hero visual for that audience. The default tab is "For students". The control is fully keyboard operable, its own active state is announced to assistive tech, and the resulting hero content change is announced too (through an `aria-live` region or by moving focus), so a screen reader user knows the rest of the hero changed. +- **AC-3**: The page presents these sections in order: public nav, hero, how it works (three steps), value split (for teachers and for students), AI feedback spotlight, FAQ, a free for classrooms line, a closing call to action band, and a footer. +- **AC-4**: Primary calls to action route to the existing auth screens: "Get started" goes to `/register`, "Sign in" goes to `/signin`. The "For teachers" call to action hints the instructor role on the register screen. +- **AC-5**: The public nav adapts to auth state: a signed out visitor sees "Sign in" and "Get started"; a signed in visitor sees a link into their app (today `/profile`, the built entry, later a dashboard) instead of "Get started". +- **AC-6**: Every colour, type, radius, and shadow comes from the semantic design tokens (no raw hex, no off palette value). Headings use Fredoka, body uses Nunito. The one blueberry gradient moment appears at most once, in the hero, and this second allowed gradient surface is recorded in `ui-rules.md` §5 (updated from teacher dashboard only), so the spec does not license it against a context file that forbids it. +- **AC-7**: Every section carries the decoration motif (bubbles, blobs, squiggles, dots, wave dividers, highlighter marks) as on brand inline SVG. The busy background never drops any text pairing below WCAG AA contrast. +- **AC-8**: Persona photographs appear inside organic blob cutout frames with a coral or sand duotone treatment. Illustrated people (unDraw, recoloured to the palette) appear only in the process and decorative zones. The two people styles never mix inside the same block. +- **AC-9**: Product visuals are on brand interface vignettes composed from the real design tokens and primitives, because the quiz taking, results, and AI feedback screens are not built yet. The page shows no screenshot of a screen that does not exist, and no invented testimonial or attributed quote. +- **AC-10**: Motion is powered by framer-motion with a genuine reduced motion path (framer-motion's `useReducedMotion`). When `prefers-reduced-motion: reduce` is set, ambient motion and scroll reveals do not run, and all content is fully visible and usable without them. +- **AC-11**: The landing route is prerendered to static HTML at build time, so a crawler that runs no JavaScript still receives the hero copy and section text. The page ships a title, a meta description, Open Graph and Twitter card tags, a canonical URL, and product JSON-LD. +- **AC-12**: The page is responsive from a 360px width up: the hero toggle and every section reflow cleanly, tap targets are at least 44px, and the whole page is keyboard navigable with an always visible focus ring. +- **AC-13**: The landing route and its marketing only dependencies and large decorative or persona assets are code split, so they do not enlarge the authenticated app's initial download. +- **AC-14**: The prerendered landing markup is served only for an exact `/` request. Every other client route (`/signin`, `/register`, `/profile`, any deep link) still receives a neutral SPA bootstrap document, never the landing page's HTML, title, or meta. The gateway catch all fallback keeps serving that neutral document, not the landing markup. +- **AC-15**: The page discloses, in visible microcopy, that the persona photographs are illustrative and generated, so no visitor reads them as real named users. + +## Decision + +**Chosen option**: Option 2: an on brand landing page at `/`, composed from the design system, with a decoration kit, personas and vignettes, and a build time prerender. + +Build one public marketing page at the app root, entirely inside the locked Quiztin design system (dialed up a little, not reskinned), served by the same SPA and gateway as the rest of the app, and prerendered to static HTML for search and social. + +The prerender uses a small postbuild script calling `react-dom/server` for the single `/` route. react-dom is already a dependency, so this adds no new prerender package; the dedicated tool `vite-react-ssg` was not needed. As built, the script uses `renderToString` rather than `renderToStaticMarkup`, so the prerendered `/` hydrates its markup in place instead of repainting from empty (see Build status). Motion uses framer-motion, which the developer reaffirmed after the cross check surfaced a CSS only alternative; it brings first class reduced motion support (`useReducedMotion`). + +**Implementation skills**: `quiztin-design` (the Quiztin branded UI and asset skill, `.claude/skills/quiztin-design/`) governs the look, colour, type, and token use for every part of this page. + +## Feature design + +**Data model sketch**: +No new data model. The landing page holds no server state and persists nothing. All content (headlines, the three steps, value points, FAQ entries, footer links) is static in the frontend. The page reads the existing auth context only to choose the nav variant, and it reuses the existing auth screens for its calls to action. + +**State transitions**: +Two small pieces of client only UI state, no domain state machine: +- Hero audience: `students` (default) or `teachers`, switched by the toggle. +- Nav variant: `signed out` or `signed in`, derived from the existing auth context, not new state. + +**Routing and serving** (load bearing, corrected after the cross check): +- `/` today resolves to the authenticated shell: `router.tsx` mounts `RequireAuth` and `AppShell` as pathless layout routes whose index child does `Navigate to="/profile"`, so bare `/` already redirects an anonymous visitor to sign in. This build removes that index redirect and renders the public `LandingPage` at `/` outside `RequireAuth`, for everyone. The signed in nav links into the app at `/profile` (the only built authed screen today; it becomes the dashboard when one exists). +- Serving: the SPA is baked into the gateway `wwwroot` and served with a catch all fallback to a bootstrap `index.html` (`src/Gateway/Program.cs`, `MapFallbackToFile`). The prerendered landing markup must not overwrite that bootstrap `index.html`, or every non file route would fall through to the landing content and a no JavaScript crawler at `/register` would get the wrong page. Emit the static `/` HTML to a target the gateway serves only for an exact `/` match, and keep the neutral bootstrap `index.html` as the generic SPA fallback. The landing entry hydrates its prerendered markup rather than repainting from empty, so a JavaScript visitor sees no flash of blank or wrong content. + +**API surface**: +No new endpoints. The landing page issues no API calls of its own. Its calls to action navigate to `/register` and `/signin`, which use the existing `/api/auth` endpoints through screens that already exist (spec 0001). + +| Surface | Method | Key inputs | Key outputs | Auth | Key errors | +|---|---|---|---|---|---| +| (none new) | | | | public | | + +**Key invariants**: +- Tokens only: every value is a semantic alias, never a raw hex or off palette colour (the design system contract in `ui-tokens.md`). +- At most one blueberry gradient on the page, in the hero, and `ui-rules.md` §5 is updated to name the landing hero as an allowed gradient surface before build. +- Coral stays the sparing signature and the AI voice; it is not spread everywhere just because this is marketing (`ui-rules.md` §4). +- Decoration is always a background layer; it never lowers a text pairing below WCAG AA. +- Honesty: no screenshot of a screen that is not built, no invented testimonial, and the personas are visibly disclosed as illustrative. +- All motion yields to `prefers-reduced-motion`. +- The prerendered landing markup is served only at exact `/`; the SPA fallback stays neutral. + +**Security model**: +A public, unauthenticated, read only marketing surface. It exposes no user data and adds no new authorization. It reads the existing session state only to swap the nav label, issuing no new tokens. Signed in visitors are allowed to view it (the chosen routing). The prerendered HTML is fully public content and must contain no secret or private data. + +**Configuration required**: +No new runtime environment variables. The build gains framer-motion as a runtime dependency. The prerender uses `react-dom/server` `renderToString`, already present through react-dom, so it adds no new prerender package. A canonical base URL (the `SITE_URL` build env, defaulting to the Railway origin) and an Open Graph image are build config, not secrets. + +**Critical test scenarios**: +- Happy path: a visitor loads `/`, toggles to "For teachers", clicks "Get started", and reaches `/register` with the instructor role hinted. Verifies AC-1, AC-2, AC-4. +- Serving: the built `/register` route, fetched with JavaScript disabled, returns the neutral bootstrap document, not the landing page's HTML; a fetch of `/` returns the prerendered landing markup. Verifies AC-14. +- Reduced motion: with `prefers-reduced-motion: reduce`, no ambient or scroll motion runs and every section is fully visible and usable. Verifies AC-10. +- Prerender and SEO: the built HTML for `/` contains the hero copy, the meta description, and the Open Graph tags with JavaScript disabled. Verifies AC-11. +- Accessibility: a keyboard only visitor operates the nav, the hero toggle, and the calls to action with a visible focus ring; the toggle announces both its state and the content change; and an automated axe pass finds no violations. Verifies AC-2, AC-12. +- Auth adaptive nav: a signed in visitor sees a link into the app instead of "Get started". Verifies AC-5. +- Contrast: text over decorated backgrounds holds WCAG AA everywhere. Verifies AC-7. + +## Build plan + +No build approach is recorded in the context system for this feature, so this plan uses a thinnest usable page first ordering (a Skateboard and Facade blend), fitting for a static marketing surface: stand up a real, viewable page at `/` first, then thicken it section by section, then layer on the rich assets, motion, and SEO. + +1. Public shell and routing: add a `PublicLayout` (marketing nav and footer); remove the authenticated index redirect that currently sends bare `/` to `/profile` in `router.tsx`, and mount `LandingPage` at `/` outside `RequireAuth` for everyone; make the nav adapt to auth state (signed in links to `/profile`); wire the calls to action to `/register` and `/signin`. Satisfies AC-1, AC-4, AC-5. +2. Landing style layer and decoration kit: bind any dialed up accent values through the existing token theme, and build the in repo SVG decoration components (bubble, blob, squiggle, dot, wave divider, highlighter), all tokenised. Satisfies AC-6, AC-7. +3. Hero with the audience toggle: a keyboard operable segmented control (default "For students") that announces both its own state and the content change, audience specific copy, call to action, and visual, plus the single allowed gradient moment. Satisfies AC-2, AC-6. +4. Content sections in order: how it works (three steps joined by squiggle connectors), the value split (teacher cards and student cards), the AI feedback spotlight (coral), the FAQ, the free for classrooms line, the closing call to action band, and the footer. Satisfies AC-3. +5. Human and illustration assets: add the persona photographs as static assets and build the blob cutout plus duotone treatment, with the visible microcopy disclosing they are illustrative; add unDraw illustrations recoloured to the palette into the process zones; keep the two people styles in separate blocks. Satisfies AC-8, AC-15. +6. Product vignettes: compose on brand interface vignettes from the design tokens and primitives to stand in for the quiz taking, results, and AI feedback screens (not built yet); use real screenshots only for screens that exist. Satisfies AC-9. +7. Motion: with framer-motion, add ambient bubble drift, scroll reveal, toggle crossfade, and the highlighter draw on, every one gated behind a reduced motion check (framer-motion `useReducedMotion`). Satisfies AC-10. +8. SEO, prerender, and serving: prerender the `/` route to static HTML with a small postbuild script using `react-dom/server` (no new dependency; `vite-react-ssg` not needed); emit it to a target the gateway serves for an exact `/` only while the neutral bootstrap `index.html` stays the catch all fallback; hydrate rather than repaint; and ship the title, meta description, Open Graph and Twitter tags, canonical URL, and product JSON-LD. Satisfies AC-11, AC-14. +9. Responsive and accessibility pass: reflow from 360px, 44px targets, visible focus, and a clean axe run. Satisfies AC-12. +10. Performance: code split the landing route and its marketing only dependencies so the authenticated app's first load stays lean. Satisfies AC-13. +11. Tests: cover the toggle (state and content announcement), the auth adaptive nav, and call to action routing; a reduced motion test; an axe accessibility test; a serving test (a non `/` route does not get landing markup); and a prerender assertion on the built HTML. Satisfies AC-2, AC-5, AC-10, AC-11, AC-14, AC-12. + +## Build status + +Built on `feat/landing-page`. All 11 build tasks are done and all 15 acceptance criteria are implemented and checked (in the browser and by tests). Green on `npm run test` (57 tests, 27 for the landing), `npm run lint`, `tsc --noEmit`, and `npm run build`. Status is In Progress; it advances to Accepted after an independent `/check verify` pass. Per phase detail is in `context/progress-log.md`. + +**Key files as built**: +- Motion kit: `frontend/src/features/landing/motion/useMotionReady.ts` (the reduced motion plus mount gate) and `motion.tsx` (`Reveal`, `AmbientFloat`, `ScrollProgress`, `Parallax`); the highlighter draw on lives in `decoration/Decoration.tsx`. +- Scroll motion (AC-10): smooth in page scrolling for the nav anchors (CSS `scroll-behavior`, scoped to the landing with `:root:has(.qz-landing)` and with `scroll-margin-top` clearing the sticky nav), a scroll progress bar across the top of the viewport, and parallax drift on the hero's background blobs. All three are off under reduced motion (the bar is not rendered at all, and the anchors jump instead of gliding). +- The FAQ accordion animates open and closed, but stays a native `
`: with no JavaScript the browser still opens it and every answer is in the prerendered markup (AC-11). When motion is allowed React drives the open state so the panel can animate, holding the `open` attribute through the collapse so the browser does not hide the panel mid animation. +- Prerender: `frontend/src/routes.tsx` (the route table minus the landing, so Node can import it), `AppRoot.tsx` (the one app tree shared by the client and the prerender), `prerender.tsx` (renders `/` with a memory router), `features/landing/seo.ts` (the head builder), and `scripts/prerender.mjs` (the postbuild injector). `main.tsx` hydrates the prerendered `/` and mounts fresh elsewhere. +- Serving: `src/Gateway/Program.cs` serves the prerender at exact `/` through an explicit endpoint and keeps the neutral `index.html` as the catch all. + +**Deviations from the plan** (all deliberate, all verified): +- The prerender uses `renderToString`, not `renderToStaticMarkup`, so the prerendered `/` hydrates in place with no flash. The tree the prerender renders matches the tree the client hydrates (same providers, only the router differs: memory versus browser), which keeps `useId` and hydration aligned. +- The gateway serves the prerender through an explicit `MapGet("/")` endpoint that outranks the SPA fallback, and `UseDefaultFiles` was removed so `/` is not rewritten to the neutral document. A path rewrite was tried first but the auto inserted routing selected the fallback endpoint before the rewrite could run. +- Code splitting (AC-13) is a dynamic import of the landing in `main.tsx`: on the prerendered `/` it loads the landing chunk then hydrates with an eager element (so the tree still matches the prerender); on any other route the landing is a lazy route, so its chunk never loads there. Because the landing CSS is then its own chunk, the prerender script reads the Vite build manifest and links that CSS (and modulepreloads the JS) in the prerendered document, so `/` still paints fully styled. `build.manifest` is enabled in `vite.config.ts` for that lookup. +- The hero audience swap is a keyed fade rather than `AnimatePresence` with `mode="wait"`, which serialized exit then enter and felt stuck for over a second. + +## Consequences + +**Positive**: +- A real front door that speaks to both audiences at once, without picking one. +- Unmistakably on brand, because it is built from the same tokens and skill as the app. +- Genuine search and social presence for a client rendered SPA, through static prerendering. +- A reusable decoration kit and a persona and vignette asset set for future marketing surfaces. +- The prerender needs no new package (it reuses `react-dom/server`), so the only new runtime dependency is framer-motion. + +**Negative / tradeoffs**: +- The prerender and the gateway fallback interact in a way that will ship a bug if built naively: the static `/` markup and the neutral SPA bootstrap must be different targets, and the gateway must serve the static one only at exact `/`. This is now a named criterion (AC-14), not a hope. +- Mounting the landing at `/` is a reroute: the authenticated index redirect must move off `/`, touching `router.tsx`. +- The interface vignettes are not real screenshots, so they carry drift risk and must be swapped for real captures when the core loop screens ship. +- Signed in visitors see the marketing page at `/` (a small friction the developer accepted, in exchange for a simpler routing rule). +- More imagery means more page weight, offset by code splitting and lazy loading, not eliminated. +- The context said react-router v7 but `frontend/package.json` pins `^8.2.0`; the prerender entry wiring depended on the real version. Reconciled (foundation §7 #25 and `library-docs.md` now say v8), and the prerender is wired and verified against 8.2.x. + +**Neutral**: +- Introduces a public layout separate from the authenticated app shell. +- `ui-rules.md` §5 gains the landing hero as a second allowed gradient surface (a context edit this spec requires). +- unDraw is attribution free and the personas are ours to use, so no licensing debt, but the personas are representative, disclosed as generated, and never captioned as real users. +- The landing page becomes the first surface that must be kept in visual sync with the design system as it grows. + +## Follow-up + +- [x] Add framer-motion to `library-docs.md` approved dependencies with a why and how used (the list gates additions, foundation §7 #21). Done, and the prerender approach (no new package, `react-dom/server` `renderToString`) is recorded there too; `vite-react-ssg` was not needed. +- [x] Reconcile the React Router version drift: `frontend/package.json` pins react-router `^8.2.0`, but foundation §7 #25 and `library-docs.md` said v7. Done, the context now says v8 (8.2.x), reconciled after the prerender entry was wired and verified. +- [x] Update `ui-rules.md` §5 to name the marketing landing hero as an allowed blueberry gradient surface alongside the teacher dashboard. Done during the content build. +- [ ] Swap the interface vignettes for real screenshots once the quiz taking, results, and AI feedback screens are built. +- [ ] Add the real Open Graph share image (the tag points at `/og-image.png`, which does not exist yet) and confirm the canonical base URL (the build reads `SITE_URL`, defaulting to the Railway origin; set it to the final public origin). +- [ ] Consider self hosting the Fredoka and Nunito woff2 for the public page (the export flags CDN fonts; a public page benefits from self hosted fonts for speed and privacy). +- [ ] If the personas must recur with identical faces across the tutoring and office scenes, consider training reusable persona characters, since plain generation does not guarantee an exact match. + +## Rationale + +Reasoning, options, context, and references: see [rationale.md](rationale.md). Verification steps: see [verify.md](verify.md). diff --git a/docs/specs/0003-landing-page/rationale.md b/docs/specs/0003-landing-page/rationale.md new file mode 100644 index 0000000..9a557dc --- /dev/null +++ b/docs/specs/0003-landing-page/rationale.md @@ -0,0 +1,101 @@ +# 0003. Quiztin marketing landing page — rationale + +The decision record for [index.md](index.md): the why, the options, and what this rests on. `/develop` builds from `index.md` and can skip this file. + +## Context + +> ⚠️ Premise note: two things shape this design and are worth stating plainly. First, the core loop screens (quiz taking, results, AI feedback) are not built yet, so there are no real screenshots of them to show; a marketing page that implies otherwise would be dishonest. This spec resolves it by composing on brand interface vignettes from the real design tokens, and flags the swap to real captures as follow up. Second, this page adds one frontend dependency (framer-motion), which runs against the project's few and explicit dependencies ethos (foundation §7 #21); the developer chose it consciously, for richer motion. The prerender for SEO adds no package: as built it reuses `react-dom/server` `renderToString`, already present through react-dom. + +Quiztin is deployed and functional (spec 0002), but it has no public front door. A first time visitor reaches the sign in screen with no explanation of what Quiztin is, who it is for, or why it is trustworthy. + +The product serves two audiences doing opposite jobs: teachers author and publish quizzes inside classrooms, and students join and take them. A single hero that tries to address both at once shortchanges one. The page has to speak to each without burying the other. + +The brand and design system are locked, generated from the Claude Design export (`design-system/`, plus the ui trio and the `quiztin-design` skill). The look is warm, calm, rounded, and light only, with a strict token contract (semantic aliases, no raw hex) and firm rules (coral is sparing and is the AI voice, one blueberry gradient moment is allowed, all motion yields to reduced motion). A marketing page may be a little louder than the in app calm, but it must not fork the system. + +The app is a client rendered single page app served single origin by the YARP gateway, with server side rendering deliberately rejected (foundation §7 #5 and #25). That is right for the app, but it fights discoverability for a public marketing page, whose whole job is to be found and shared. + +Not deciding leaves the product with no way to explain or sell itself, and every visitor arriving cold at a login form. + +## Options considered + +### Option 1: Pure client rendered page, on brand, CSS only motion, no personas + +One more React route inside the SPA, styled from the tokens, animated with CSS, no new dependencies and no generated imagery. + +**Pros**: +- Zero new dependencies; nothing to add to the approved list. +- Fastest to ship, least build complexity. + +**Cons**: +- Poor search and social presence, because a client rendered page serves crawlers an empty shell. +- Less warmth and less proof without personas or richer motion, which is exactly what the developer asked for. + +### Option 2 (chosen): On brand landing page at `/`, decoration kit, personas and vignettes, framer-motion, build time prerender + +One public page at the root, composed from the design system, carrying the decoration kit on every section, real persona faces and friendly illustrations, on brand interface vignettes for product proof, framer-motion for motion, and a build time prerender for SEO. + +**Pros**: +- Real search and social presence through static prerendering, without adopting runtime server rendering. +- Rich, warm, and on brand, serving both audiences through the hero toggle. +- Produces reusable assets (the decoration kit, personas, vignettes) for later marketing. + +**Cons**: +- Two new frontend dependencies and a more complex build. +- The vignettes are not real screenshots and must be maintained until the real screens exist. + +### Option 3: A separate marketing site outside the app + +Build the landing page in a dedicated static site generator or a hosted page builder, deployed apart from the app. + +**Pros**: +- Best in class marketing and SEO tooling. +- Fully decoupled from the app's release cycle. + +**Cons**: +- A second toolchain and a second deployment to run and pay for. +- Forks the design system into a second place and risks brand drift. +- Overkill for a single page, and it fights the single origin gateway model the platform is built on. + +### Option 4: Extend the brand through Claude Design for a bespoke marketing look + +Use Claude Design to evolve a distinct marketing visual language for the page. + +**Pros**: +- Potentially more distinctive than reusing the app's system. + +**Cons**: +- Forks the locked design system for no strong reason, since the brand is already settled. +- More time and cost, and a second visual language to maintain. + +## Rationale + +Option 2 wins because it is the only one that satisfies the two forces in tension: a public page has to be found and shared (which the SPA's no server rendering stance blocks), and the brand must not fork (which a separate site or a Claude Design reskin would do). A build time prerender resolves the first without reopening the second: it is static generation of one public route at build, not runtime server rendering, so it respects foundation §7 #5 and #25 while still handing crawlers real HTML (basis: foundation §7 #5 and #25; static site generation practice). + +The prerender is done with a small postbuild script calling `react-dom/server`, which is already available through react-dom, so it adds no new package; the dedicated tool `vite-react-ssg` was not needed. As built the script uses `renderToString` (not `renderToStaticMarkup`), so the prerendered `/` hydrates in place rather than repainting: the script renders the same app tree the client hydrates, differing only in the router (a memory router pinned to `/` for the prerender, the browser router in the app), which keeps `useId` and hydration aligned. This settled the router question the design flagged: the app pins react-router `^8.2.0` (a drift from the v7 the context named, now reconciled), and 8.2.x supplies `createMemoryRouter`, `RouterProvider`, and `renderToString` as verified. The static markup is served only at exact `/`, through an explicit gateway endpoint that outranks the SPA fallback, with the neutral bootstrap `index.html` kept as the catch all, so the gateway does not hand the landing page's HTML to `/register` or any other deep link. + +The hero toggle is chosen over an auto advancing carousel deliberately. Rotating hero carousels are a known weak pattern: viewers miss the second slide, the moving call to action hurts conversion, and the motion works against a brand whose whole promise is calm and accessible. A user driven segmented control gives the same two vibes, lets the visitor choose, doubles as audience routing, and adds no motion, which fits the brand and the accessibility bar (basis: `ui-rules.md` §0 and §7). + +The motion approach is framer-motion. The cross check surfaced that the app has no animation library today and that CSS keyframes plus a small `IntersectionObserver` would cover the `ui-rules.md` §7 motion vocabulary with zero new dependency, so the developer weighed that and reaffirmed framer-motion for its richer, spring based choreography and its first class reduced motion support (`useReducedMotion`). The tradeoff is one new runtime dependency, recorded in `library-docs.md` (basis: foundation §7 #21; `ui-rules.md` §7). + +## Cross check + +An independent model read the drafted spec against the actual codebase and returned a needs rework verdict. Its findings were folded in before acceptance: the gateway fallback would have served the landing markup for every route (now AC-14, with the serving fix in Feature design); `/` was already claimed by the authenticated index redirect, so mounting the landing there is a reroute (now named in build task 1); the gradient rule cited `ui-rules.md` §5 without updating it (now a required reconciliation, AC-6 and Follow-up); the prerender tool was unverified against the real router, and a zero dependency `react-dom/server` path exists (the chosen approach, built with `renderToString` for clean hydration); and three smaller gaps (no verify step for section order, the toggle announcing only its own state, and undisclosed personas) became AC and verify additions (AC-3 check, AC-2 content announcement, AC-15). + +The interface vignettes over screenshots choice is forced by honesty: the quiz taking, results, and AI feedback screens do not exist yet (only the primitives and the auth and profile screens are built, per `ui-registry.md`), so a screenshot of them would be fabricated, and an invented testimonial would be worse. Composing vignettes from the real tokens keeps the page truthful and still on brand, and the swap to real captures is queued for when those screens ship. + +The personas are generated rather than stock for two reasons: continuity (the same three faces can recur across the hero, the how it works steps, and the spotlight, which stock cannot provide) and rights (free stock grants no model release, so using a real face as a representative Quiztin user edges into implied endorsement; a generated face does not). + +## References + +**Project sources** (verifiable, in this repo): +- `context/ui-rules.md` (§0 prime directive, §4 colour discipline, §5 per surface rules, §7 motion and reduced motion) and `context/ui-tokens.md` (the semantic token contract). +- `context/foundation.md` §7 #5 and #25 (no server rendering, no meta framework), #21 (few and explicit dependencies), #24 (styling bound to tokens). +- `context/ui-registry.md` (which components are built: the primitives, plus auth and profile; the quiz, results, and AI feedback components are still planned). +- Spec 0001 (the frontend foundation and the auth screens the calls to action reuse) and spec 0002 (the gateway and single origin serving model). +- The `quiztin-design` skill (`.claude/skills/quiztin-design/`), the branded UI and asset authority. + +**Practices & standards**: +- Static site generation (prerendering one public route at build) over runtime server rendering, to gain SEO without adopting a server rendering framework. +- A segmented audience control over an auto advancing hero carousel, for accessibility and conversion. +- Representative personas, never fabricated testimonials, for honest marketing. +- `prefers-reduced-motion` respected for all motion (WCAG). diff --git a/docs/specs/0003-landing-page/verify.md b/docs/specs/0003-landing-page/verify.md new file mode 100644 index 0000000..6040e5d --- /dev/null +++ b/docs/specs/0003-landing-page/verify.md @@ -0,0 +1,41 @@ +# 0003. Quiztin marketing landing page — verify + +How to prove each acceptance criterion once the page is built. Run the app locally (`docker compose up` for the backend, then `cd frontend && npm run dev`) unless a step says to check the production build. Acceptance criteria live in [index.md](index.md). + +## Rendering and routing +- **AC-1**: Load `/` logged out. The landing page renders, with no redirect to sign in. Load `/` with a valid session. The same landing page renders, with no forced redirect. +- **AC-5**: Logged out, the nav shows "Sign in" and "Get started". With a session, the nav shows a link into the app (`/profile` today) instead of "Get started". +- **AC-4**: Click "Get started". You reach `/register`. Click "Sign in". You reach `/signin`. The "For teachers" call to action lands on register with the instructor role preselected or hinted. + +## Hero toggle +- **AC-2**: The hero shows a "For students" and "For teachers" segmented control, defaulting to students. Switching updates the headline, subcopy, call to action, and visual. Operate it by keyboard (tab to it, arrow or enter to switch). The active option is exposed to assistive tech (a pressed state, or a radio group with a checked option), and the content change is announced too (an `aria-live` region updates, or focus moves into the changed hero). Confirm there is no automatic rotation. + +## Sections +- **AC-3**: The sections appear in this order, top to bottom: public nav, hero, how it works (three steps), value split (for teachers and for students), AI feedback spotlight, FAQ, free for classrooms line, closing call to action band, footer. None missing, none out of order. + +## Brand and tokens +- **AC-6**: Inspect computed styles. Colours, type, radius, and shadow all resolve from `--` token aliases, with no raw hex in the landing styles. Fredoka on headings, Nunito on body. Count blueberry gradients on the page: at most one, in the hero. Confirm `ui-rules.md` §5 now names the landing hero as an allowed gradient surface. +- **AC-7**: Every section shows the decoration motif. Run a contrast checker on text over decorated areas. All pairings meet WCAG AA. +- **AC-8**: Persona photos sit in blob cutout frames with a coral or sand duotone. unDraw illustrations appear only in the process and decorative zones. No single block mixes a photo person and an illustrated person. + +## Honesty +- **AC-9**: Product visuals are vignettes built from tokens and primitives, not screenshots of unbuilt screens. There is no invented testimonial or attributed quote anywhere on the page. +- **AC-15**: A visible line of microcopy discloses that the persona photographs are illustrative and generated (for example near the images or in the footer). A visitor cannot mistake them for real named users. + +## Motion +- **AC-10**: With motion allowed, the ambient drift, scroll reveal, toggle crossfade, and highlighter draw on all play. Set the OS or dev tools to `prefers-reduced-motion: reduce` and reload. None of that motion runs, and every section is fully visible and usable. The reduced motion path (content fully visible, no hidden or shifted start state) is also asserted in `src/features/landing/motion/motion.test.tsx`. +- **AC-10 (scroll)**: Click a nav anchor ("How it works", "Questions"): the page glides to it and the section lands below the sticky nav, not under it. A progress bar fills across the very top of the viewport as you scroll. The hero's background blobs drift against the scroll. Under `prefers-reduced-motion: reduce` the anchors jump instantly, the bar is not rendered, and the blobs sit still. The bar's tracking is asserted in `src/features/landing/motion/ScrollProgress.test.tsx`. +- **AC-10 (FAQ)**: Clicking a question glides the answer open and closed, with the chevron rotating. It is still a native `
`: with JavaScript disabled the browser opens and closes it, and every answer is present in the prerendered HTML. Covered by `src/features/landing/sections/Faq.test.tsx` (reduced motion, native) and `Faq.motion.test.tsx` (animated). + +## SEO, prerender, and serving +- **AC-11**: Build the frontend (`npm run build`). The prerendered file is `dist/index.prerender.html`; it contains the hero copy and section text with no JavaScript (open the file, or curl the served route). Confirm the ``, meta description, Open Graph and Twitter tags, canonical link, and product JSON-LD are present in it. The prerender output is also asserted in `src/prerender.test.tsx` and the head builder in `src/features/landing/seo.test.ts`. +- **AC-14**: The build emits two documents: `dist/index.prerender.html` (the landing, served only at exact `/`) and `dist/index.html` (the neutral bootstrap, the SPA catch all). The gateway (`src/Gateway/Program.cs`) serves the prerender through an explicit `MapGet("/")` endpoint and keeps `index.html` as `MapFallbackToFile`. Run the gateway over the built `wwwroot` and curl: `GET /` returns the landing markup plus the SEO tags; `GET /register` and `GET /profile` return the neutral bootstrap (generic title, no landing markup or Open Graph meta). No client route other than `/` shows landing content to a no JavaScript client. `src/prerender.test.tsx` asserts the neutral bootstrap stays landing free. + +## Responsive and accessibility +- **AC-12**: At a 360px width, the hero toggle and every section reflow cleanly, with no horizontal scroll. Tap targets are at least 44px. Tab through the whole page. Focus is visible on every control. Run axe (the project already uses vitest-axe). No violations. + +## Performance +- **AC-13**: Inspect the build output. The landing route and framer-motion are in their own chunk (`dist/assets/LandingPage-*.js`, plus `LandingPage-*.css`), not in the authenticated app's main entry chunk (`dist/assets/index-*.js`). Load a non `/` route (for example `/register`) and confirm, via the Network panel or `performance.getEntriesByType('resource')`, that only the main entry chunk loads: no `LandingPage` chunk, no framer-motion, no persona images. + +## Regression +- Build green (`npm run build`, which also runs the prerender, `npm run lint`, `tsc --noEmit`), and the tests pass (`npm run test`, 57 including 27 for the landing). The only backend touch is the gateway serving endpoint (`src/Gateway/Program.cs`); the services are untouched, and `dotnet build` plus `dotnet test` stay green. diff --git a/frontend/.gitignore b/frontend/.gitignore index d736c75..1426243 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,6 +1,7 @@ # Node / Vite node_modules/ dist/ +dist-ssr/ coverage/ *.local diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b19eaa0..1a8b323 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@hookform/resolvers": "^5.4.0", "@phosphor-icons/react": "^2.1.10", "@tanstack/react-query": "^5.101.2", + "framer-motion": "^12.42.2", "react": "^19.2.7", "react-dom": "^19.2.7", "react-hook-form": "^7.81.0", @@ -3380,6 +3381,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/framer-motion": { + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz", + "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.42.2", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4739,6 +4767,21 @@ "node": "*" } }, + "node_modules/motion-dom": { + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz", + "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5825,9 +5868,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", diff --git a/frontend/package.json b/frontend/package.json index a064219..2ed1e58 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc --noEmit && vite build", + "build": "tsc --noEmit && vite build && npm run prerender", + "prerender": "vite build --ssr src/prerender.tsx --outDir dist-ssr && node scripts/prerender.mjs", "preview": "vite preview", "lint": "eslint .", "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", @@ -16,6 +17,7 @@ "@hookform/resolvers": "^5.4.0", "@phosphor-icons/react": "^2.1.10", "@tanstack/react-query": "^5.101.2", + "framer-motion": "^12.42.2", "react": "^19.2.7", "react-dom": "^19.2.7", "react-hook-form": "^7.81.0", diff --git a/frontend/scripts/prerender.mjs b/frontend/scripts/prerender.mjs new file mode 100644 index 0000000..6994ad6 --- /dev/null +++ b/frontend/scripts/prerender.mjs @@ -0,0 +1,52 @@ +// Postbuild prerender (spec 0003, AC-11 and AC-14). Runs after the client build +// and the SSR build. It renders the landing route to static HTML and writes it as a +// SEPARATE file (index.prerender.html), leaving the neutral bootstrap (index.html) +// untouched so the gateway can serve the landing markup only at exact "/". +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(dir, '..'); // frontend/ +const dist = path.join(root, 'dist'); + +// The canonical origin for SEO tags. Overridable at build time; see the spec follow up. +const siteUrl = process.env.SITE_URL ?? 'https://quiztin.up.railway.app'; + +const { render } = await import(path.join(root, 'dist-ssr', 'prerender.js')); +const { html, head } = render(siteUrl); + +// The landing is its own chunk (AC-13), so its CSS is not in the main stylesheet the +// template already links. Link it here, up front, or the prerendered page paints +// unstyled until the chunk loads. Modulepreload the JS too, so it loads in parallel +// with the entry and hydration is quick. +const manifest = JSON.parse(readFileSync(path.join(dist, '.vite', 'manifest.json'), 'utf8')); +const landing = manifest['src/features/landing/LandingPage.tsx']; +const assetLinks = []; +for (const css of landing?.css ?? []) { + assetLinks.push(`<link rel="stylesheet" href="/${css}" />`); +} +if (landing?.file) { + assetLinks.push(`<link rel="modulepreload" href="/${landing.file}" />`); +} +const headBlock = assetLinks.length > 0 ? `${head}\n ${assetLinks.join('\n ')}` : head; + +const template = readFileSync(path.join(dist, 'index.html'), 'utf8'); + +if (!template.includes('<div id="root"></div>')) { + throw new Error('prerender: could not find <div id="root"></div> in dist/index.html'); +} +if (!template.includes('<title>Quiztin')) { + throw new Error('prerender: could not find the placeholder Quiztin in dist/index.html'); +} + +// Inject the prerendered markup into #root, and swap the neutral for the +// full landing head (its own <title>, description, Open Graph, Twitter, canonical, +// JSON-LD). The neutral index.html keeps its generic title, so no other route +// inherits this metadata. +let out = template.replace('<div id="root"></div>', `<div id="root">${html}</div>`); +out = out.replace('<title>Quiztin', headBlock); + +writeFileSync(path.join(dist, 'index.prerender.html'), out); +console.log(`prerender: wrote dist/index.prerender.html (${out.length} bytes, body ${html.length} bytes)`); +process.exit(0); diff --git a/frontend/src/AppRoot.tsx b/frontend/src/AppRoot.tsx new file mode 100644 index 0000000..4c98b78 --- /dev/null +++ b/frontend/src/AppRoot.tsx @@ -0,0 +1,23 @@ +import { StrictMode } from 'react'; +import { RouterProvider } from 'react-router'; +import type { createBrowserRouter } from 'react-router'; +import { Providers } from './app/Providers'; + +type AppRouter = ReturnType; + +/** + * The single app tree, shared by the client entry (main.tsx) and the build time + * prerender (prerender.tsx). Only the router differs: a browser router in the app, + * a memory router in the prerender. Keeping every wrapper above the routes identical + * keeps useId and the whole render tree aligned, so the prerendered `/` hydrates + * cleanly instead of repainting (spec 0003, AC-11/AC-14). + */ +export function AppRoot({ router }: { router: AppRouter }) { + return ( + + + + + + ); +} diff --git a/frontend/src/features/auth/RegisterPage.tsx b/frontend/src/features/auth/RegisterPage.tsx index 0587936..984822c 100644 --- a/frontend/src/features/auth/RegisterPage.tsx +++ b/frontend/src/features/auth/RegisterPage.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Link, Navigate, useLocation } from 'react-router'; +import { Link, Navigate, useLocation, useSearchParams } from 'react-router'; import { EnvelopeSimple, Lock } from '@phosphor-icons/react'; import { Button, Select, TextField } from '@/components/ui'; import { useAuth } from '@/lib/auth/useAuth'; @@ -16,16 +16,20 @@ const ROLE_OPTIONS = roleSchema.options.map((role) => ({ value: role, label: rol export function RegisterPage() { const { status, register: registerAccount } = useAuth(); const location = useLocation(); + const [searchParams] = useSearchParams(); const from = (location.state as { from?: string } | null)?.from ?? '/profile'; const [submitError, setSubmitError] = useState(null); + // A landing page CTA may hint the role via ?role=Student|Teacher; preselect it when valid. + const roleHint = roleSchema.safeParse(searchParams.get('role')); + const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(registerSchema), - defaultValues: { email: '', password: '' }, + defaultValues: { email: '', password: '', ...(roleHint.success ? { role: roleHint.data } : {}) }, }); if (status === 'authenticated') return ; diff --git a/frontend/src/features/landing/LandingPage.test.tsx b/frontend/src/features/landing/LandingPage.test.tsx new file mode 100644 index 0000000..ce43992 --- /dev/null +++ b/frontend/src/features/landing/LandingPage.test.tsx @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { axe } from 'vitest-axe'; +import { MemoryRouter } from 'react-router'; +import { LandingPage } from './LandingPage'; +import { AuthContext } from '@/lib/auth/useAuth'; +import { makeAuthValue } from '@/test/authValue'; +import { setReducedMotion } from '@/test/matchMedia'; + +function renderPage() { + return render( + + + + + , + ); +} + +describe('LandingPage', () => { + beforeEach(() => { + setReducedMotion(true); + }); + + it('presents the sections in the specified order (AC-3)', () => { + renderPage(); + const headings = screen.getAllByRole('heading').map((h) => h.textContent?.trim() ?? ''); + const expectedOrder = [ + 'Learning that', // hero + 'Three steps', // how it works + 'Less marking', // value: teachers + 'A quiz that is on your side', // value: students + 'Feedback that sounds like a person', // AI spotlight + 'Good questions', // FAQ + 'Ready to make quizzes feel calmer', // closing call to action + ]; + const indices = expectedOrder.map((marker) => headings.findIndex((h) => h.includes(marker))); + expect(indices.every((i) => i >= 0)).toBe(true); + expect(indices).toEqual([...indices].sort((a, b) => a - b)); + }); + + it('shows the free for classrooms line (AC-3)', () => { + renderPage(); + expect(screen.getByText('Free for your classroom')).toBeInTheDocument(); + }); + + it('discloses that the personas are illustrative (AC-15)', () => { + renderPage(); + expect(screen.getByText(/illustrative and were generated/i)).toBeInTheDocument(); + }); + + it('has no accessibility violations (AC-12)', async () => { + const { container } = renderPage(); + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/frontend/src/features/landing/LandingPage.tsx b/frontend/src/features/landing/LandingPage.tsx new file mode 100644 index 0000000..0da13ff --- /dev/null +++ b/frontend/src/features/landing/LandingPage.tsx @@ -0,0 +1,36 @@ +import { LandingNav } from './sections/LandingNav'; +import { Hero } from './sections/Hero'; +import { HowItWorks } from './sections/HowItWorks'; +import { ValueSplit } from './sections/ValueSplit'; +import { AISpotlight } from './sections/AISpotlight'; +import { Faq } from './sections/Faq'; +import { CtaBand } from './sections/CtaBand'; +import { LandingFooter } from './sections/LandingFooter'; +import { ScrollProgress } from './motion/motion'; +import './landing.css'; + +/** + * The public marketing landing page (spec 0003), rendered at `/` for everyone. + * Composed section by section; more sections (how it works, value split, AI + * feedback spotlight, FAQ, free line, closing call to action) land in later slices. + */ +export function LandingPage() { + return ( +
+ + + Skip to content + + +
+ + + + + + +
+ +
+ ); +} diff --git a/frontend/src/features/landing/assets/personas/professor-office.jpg b/frontend/src/features/landing/assets/personas/professor-office.jpg new file mode 100644 index 0000000..9879443 Binary files /dev/null and b/frontend/src/features/landing/assets/personas/professor-office.jpg differ diff --git a/frontend/src/features/landing/assets/personas/professor.jpg b/frontend/src/features/landing/assets/personas/professor.jpg new file mode 100644 index 0000000..8460fe4 Binary files /dev/null and b/frontend/src/features/landing/assets/personas/professor.jpg differ diff --git a/frontend/src/features/landing/assets/personas/student-female.jpg b/frontend/src/features/landing/assets/personas/student-female.jpg new file mode 100644 index 0000000..83189db Binary files /dev/null and b/frontend/src/features/landing/assets/personas/student-female.jpg differ diff --git a/frontend/src/features/landing/assets/personas/student-male.jpg b/frontend/src/features/landing/assets/personas/student-male.jpg new file mode 100644 index 0000000..dca973a Binary files /dev/null and b/frontend/src/features/landing/assets/personas/student-male.jpg differ diff --git a/frontend/src/features/landing/assets/personas/students-tutoring.jpg b/frontend/src/features/landing/assets/personas/students-tutoring.jpg new file mode 100644 index 0000000..1cb6517 Binary files /dev/null and b/frontend/src/features/landing/assets/personas/students-tutoring.jpg differ diff --git a/frontend/src/features/landing/content.ts b/frontend/src/features/landing/content.ts new file mode 100644 index 0000000..de81f22 --- /dev/null +++ b/frontend/src/features/landing/content.ts @@ -0,0 +1,218 @@ +/** + * All landing page copy in one place, so the words stay easy to review and edit. + * Voice follows ui-rules §1: sentence case, address the reader as "you", warm and + * encouraging, never exam or testing jargon, no emoji. + */ +import type { Role } from '@/lib/auth/session'; + +export type Audience = 'students' | 'teachers'; + +export interface HeroContent { + audience: Audience; + tabLabel: string; + eyebrow: string; + /** The headline, split so one run can carry the coral highlighter mark. */ + headlineLead: string; + headlineMark: string; + headlineTail: string; + subcopy: string; + primaryCta: { label: string; to: string }; + secondaryCta: { label: string; to: string }; + /** Persona image alt text (the image itself is imported in the Hero). */ + imageAlt: string; +} + +export const HERO: Record = { + students: { + audience: 'students', + tabLabel: 'For students', + eyebrow: 'For students', + headlineLead: 'Learning that feels like a ', + headlineMark: 'game', + headlineTail: ', not a test.', + subcopy: + 'Join your class with a code, answer at your own pace, and get friendly feedback on every question. No red ink, no pressure, just a calmer way to learn.', + primaryCta: { label: 'Join your class', to: '/register?role=Student' }, + secondaryCta: { label: 'See how it works', to: '#how-it-works' }, + imageAlt: 'A student smiling while working on a laptop in a library.', + }, + teachers: { + audience: 'teachers', + tabLabel: 'For teachers', + eyebrow: 'For teachers', + headlineLead: 'Write a quiz once. Let Quiztin do the ', + headlineMark: 'grading', + headlineTail: '.', + subcopy: + 'Build a quiz in minutes, publish it to your classroom, and see every answer scored the moment a student submits, with AI feedback that saves you hours of marking.', + primaryCta: { label: 'Create your first quiz', to: '/register?role=Teacher' }, + secondaryCta: { label: 'See how it works', to: '#how-it-works' }, + imageAlt: 'A professor smiling warmly in a library.', + }, +}; + +/** The role a hero CTA hints on the register screen, so the form can preselect it. */ +export const AUDIENCE_ROLE: Record = { + students: 'Student', + teachers: 'Teacher', +}; + +/* ---- How it works (the core loop, three steps) --------------------------- */ +export type StepKey = 'create' | 'take' | 'feedback'; + +export interface Step { + key: StepKey; + n: string; + title: string; + body: string; +} + +export const STEPS: Step[] = [ + { + key: 'create', + n: '01', + title: 'Teachers build a quiz', + body: 'Write your own questions, or start from an AI draft and make it yours, then publish to your classroom in a couple of clicks.', + }, + { + key: 'take', + n: '02', + title: 'Students take it, calmly', + body: 'They join with a short class code and answer at their own pace. A gentle progress bar, never a countdown.', + }, + { + key: 'feedback', + n: '03', + title: 'Everyone gets feedback', + body: 'Answers are scored the moment a student submits, with supportive AI feedback on every question, for both sides.', + }, +]; + +/* ---- Value split (one block per audience) -------------------------------- */ +export type ValueId = 'for-teachers' | 'for-students'; + +export interface ValueBlock { + id: ValueId; + audience: Audience; + eyebrow: string; + title: string; + body: string; + points: string[]; + cta: { label: string; to: string }; + imageAlt: string; +} + +export const VALUE: ValueBlock[] = [ + { + id: 'for-teachers', + audience: 'teachers', + eyebrow: 'For teachers', + title: 'Less marking, more teaching.', + body: 'Quiztin grades the second a student submits, and writes the specific, encouraging feedback you never have time for.', + points: [ + 'Build a quiz in minutes, or start from an AI draft and make it yours.', + 'Publish to a classroom, so only enrolled students can take it.', + 'Every submission scored instantly, with no red pen in sight.', + 'See who is thriving and who needs a hand, at a glance.', + ], + cta: { label: 'Create your first quiz', to: '/register?role=Teacher' }, + imageAlt: 'A professor smiling in her office in front of full bookshelves.', + }, + { + id: 'for-students', + audience: 'students', + eyebrow: 'For students', + title: 'A quiz that is on your side.', + body: 'Join with a code, take your time, and learn from feedback that points at the idea, not the grade.', + points: [ + 'Join a class with a short code. Nothing to install.', + 'Answer at your own pace, with a calm progress bar and no timer.', + 'Get friendly feedback on every question, right after you submit.', + 'Misses are framed as things to review, never as failures.', + ], + cta: { label: 'Join your class', to: '/register?role=Student' }, + imageAlt: 'Two students studying together over a laptop at a library table.', + }, +]; + +/* ---- AI feedback spotlight ---------------------------------------------- * + * The vignette below is an on brand mock of the AI feedback card, built from + * the design tokens (AC-9), because the real quiz and results screens are not + * built yet. It is illustrative of the experience, not a screenshot. */ +export const AI_SPOTLIGHT = { + eyebrow: 'AI feedback', + title: 'Feedback that sounds like a person, not a red pen.', + body: 'After every question, Quiztin explains the idea in a warm, encouraging voice. A miss becomes something to review, never a mark against you. And every quiz still scores on its own if the AI is ever away.', + vignette: { + question: 'Which layer of the OSI model routes packets between networks?', + theirAnswer: 'The transport layer', + status: 'to review', + feedback: + 'So close. The transport layer carries data end to end, but choosing a route between networks happens one layer down, at the network layer, where IP addresses do their work. Worth another look.', + }, +} as const; + +/* ---- FAQ ----------------------------------------------------------------- */ +export const FAQ: ReadonlyArray<{ q: string; a: string }> = [ + { + q: 'Is Quiztin free?', + a: 'Yes. Quiztin is free for classrooms. Create an account and start building quizzes right away.', + }, + { + q: 'How do students join a class?', + a: 'A teacher shares a short class code. A student creates an account, enters the code, and they are enrolled. Nothing to install.', + }, + { + q: 'Is the AI feedback real?', + a: 'Yes. Quiztin uses a language model to write feedback on each question, and every quiz still scores correctly on its own if the model is ever unavailable.', + }, + { + q: 'Do you send student data to the AI?', + a: 'No. Only the academic content of a question is ever sent for feedback, never a student name, email, or identity.', + }, + { + q: 'What kinds of questions can I ask?', + a: 'Multiple choice to start, with more types on the way. Write your own, or begin from an AI draft and edit it.', + }, +]; + +/* ---- Closing call to action --------------------------------------------- */ +export const CTA_BAND = { + freeLine: 'Free for your classroom', + title: 'Ready to make quizzes feel calmer?', + body: 'Create an account in under a minute. Build a quiz, invite your class, and let Quiztin handle the scoring and the feedback.', + primary: { label: 'Get started', to: '/register' }, + secondary: { label: 'Sign in', to: '/sign-in' }, +} as const; + +export const NAV_LINKS: ReadonlyArray<{ label: string; href: string }> = [ + { label: 'How it works', href: '#how-it-works' }, + { label: 'For teachers', href: '#for-teachers' }, + { label: 'For students', href: '#for-students' }, + { label: 'Questions', href: '#faq' }, +]; + +export const FOOTER = { + tagline: 'A calmer way to run classroom quizzes.', + /** Honest disclosure: the persona photographs are illustrative, not real users (AC-15). */ + personaDisclosure: + 'The people pictured on this page are illustrative and were generated for the design; they are not real Quiztin users.', + columns: [ + { + title: 'Product', + links: [ + { label: 'How it works', href: '#how-it-works' }, + { label: 'For teachers', href: '#for-teachers' }, + { label: 'For students', href: '#for-students' }, + { label: 'Questions', href: '#faq' }, + ], + }, + { + title: 'Get started', + links: [ + { label: 'Create an account', href: '/register' }, + { label: 'Sign in', href: '/sign-in' }, + ], + }, + ], +} as const; diff --git a/frontend/src/features/landing/decoration/Decoration.tsx b/frontend/src/features/landing/decoration/Decoration.tsx new file mode 100644 index 0000000..b352332 --- /dev/null +++ b/frontend/src/features/landing/decoration/Decoration.tsx @@ -0,0 +1,159 @@ +import { motion } from 'framer-motion'; +import { useId } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; +import { useMotionReady } from '../motion/useMotionReady'; + +/** + * The Quiztin decoration kit: soft, on brand SVG shapes that give every landing + * section its playful "bubbly" texture. Everything here is decorative, so every + * node is aria-hidden, and every colour is a semantic design token (no raw hex), + * kept light so it never competes with the text in front of it (AC-6, AC-7). + * + * Tones map to the palette's soft aliases so the shapes read as gentle tints. + */ +export type Tone = 'coral' | 'blueberry' | 'sand' | 'ai'; + +const TONE_FILL: Record = { + coral: 'var(--accent-soft)', + blueberry: 'var(--primary-soft)', + sand: 'var(--surface-sunken)', + ai: 'var(--ai-surface)', +}; + +const TONE_STROKE: Record = { + coral: 'var(--accent)', + blueberry: 'var(--primary)', + sand: 'var(--border-strong)', + ai: 'var(--ai-accent)', +}; + +interface ShapeProps { + className?: string; + style?: CSSProperties; + tone?: Tone; +} + +/** One soft organic blob. Three path variants keep clusters from looking stamped. */ +export function Blob({ className, style, tone = 'coral', variant = 0 }: ShapeProps & { variant?: 0 | 1 | 2 }) { + const paths = [ + 'M43.9-58.6C56 -50 63.4 -34.6 66.8 -18.7C70.2 -2.7 69.6 13.9 62.4 27.4C55.2 40.9 41.4 51.3 26 57.9C10.6 64.5 -6.5 67.2 -22.6 62.9C-38.7 58.6 -53.8 47.3 -61.6 32.5C-69.4 17.7 -69.9 -0.6 -64.4 -16.4C-58.9 -32.2 -47.4 -45.6 -33.6 -53.9C-19.8 -62.2 -3.7 -65.5 11.6 -64.4C26.9 -63.3 31.8 -67.1 43.9 -58.6Z', + 'M39.5-52.4C50.3 -44 57.4 -30.9 61.7 -16.6C66 -2.3 67.5 13.2 61.9 25.7C56.3 38.2 43.6 47.7 29.6 54.4C15.6 61.1 0.3 65 -15.8 63.1C-31.9 61.2 -48.8 53.5 -58.3 40.4C-67.8 27.3 -69.9 8.8 -66 -8C-62.1 -24.8 -52.2 -39.9 -39 -48.5C-25.8 -57.1 -9.3 -59.2 5.6 -60.6C20.5 -62 28.7 -60.8 39.5 -52.4Z', + 'M48.2-60.8C61.4 -52.3 69.9 -35.6 71.8 -18.7C73.7 -1.8 69 15.3 60.2 29.8C51.4 44.3 38.5 56.2 23.2 62.3C7.9 68.4 -9.8 68.7 -25.9 63C-42 57.3 -56.5 45.6 -63.7 30.6C-70.9 15.6 -70.8 -2.7 -64.9 -18.2C-59 -33.7 -47.3 -46.4 -33.6 -54.6C-19.9 -62.8 -4.2 -66.5 11.7 -66.4C27.6 -66.3 35 -69.3 48.2 -60.8Z', + ]; + return ( + + ); +} + +/** A glossy floating bubble: a soft fill with a light highlight, like a real bubble. */ +export function Bubble({ className, style, tone = 'blueberry', size = 48 }: ShapeProps & { size?: number }) { + const id = `bub${useId()}`; + return ( + + ); +} + +/** A hand drawn squiggle, for accents and step connectors. */ +export function Squiggle({ className, style, tone = 'coral' }: ShapeProps) { + return ( + + ); +} + +/** A small scatter of dots, the confetti of the kit. */ +export function Dots({ className, style, tone = 'blueberry' }: ShapeProps) { + const pts = [ + [6, 6], + [22, 14], + [10, 28], + [30, 32], + [4, 44], + [26, 50], + ]; + return ( + + ); +} + +/** A full width wavy divider that sits between sections. */ +export function WaveDivider({ + className, + fill = 'var(--color-bg)', + flip = false, +}: { + className?: string; + fill?: string; + flip?: boolean; +}) { + return ( + + ); +} + +/** + * A coral highlighter swipe behind a word, the "fun academic" note from the inspo. + * The colour lives in a fill layer behind the text. When motion is allowed the fill + * sweeps in from the left the first time it scrolls into view (the highlighter draw + * on, AC-10); at rest the fill is simply full, so the word is always highlighted. + */ +export function Highlight({ children, className }: { children: ReactNode; className?: string }) { + const on = useMotionReady(); + return ( + + {on ? ( + + ); +} diff --git a/frontend/src/features/landing/landing.css b/frontend/src/features/landing/landing.css new file mode 100644 index 0000000..88b3385 --- /dev/null +++ b/frontend/src/features/landing/landing.css @@ -0,0 +1,732 @@ +/* ========================================================================== + Quiztin marketing landing page (spec 0003). + Built on the design tokens only, no raw hex. Warm, calm, a little louder than + the in app surfaces. The one blueberry gradient the system allows lives in the + hero (ui-rules.md §5). All motion yields to prefers-reduced-motion. + ========================================================================== */ + +.qz-landing { + background: var(--color-bg); + color: var(--text-body); + overflow-x: clip; /* keep the decoration from widening the page */ +} + +/* ---- Scroll ------------------------------------------------------------- */ +/* Smooth in page scrolling for the nav anchors, scoped to while the landing page is + mounted so it never changes how the rest of the app scrolls. */ +:root:has(.qz-landing) { + scroll-behavior: smooth; +} +/* Anchors land below the sticky nav instead of under it. */ +#main-content, +#how-it-works, +#for-teachers, +#for-students, +#faq { + scroll-margin-top: 6rem; +} + +/* The scroll progress bar: fills across the very top as the page moves. Decorative, + so it is aria-hidden, and it is not rendered at all under reduced motion. */ +.qz-scroll-progress { + position: fixed; + inset: 0 0 auto 0; + height: 3px; + z-index: 60; /* above the sticky nav */ + background: var(--primary); + transform-origin: 0 50%; + will-change: transform; +} + +/* Skip link: hidden until focused, then a real visible control. */ +.qz-skip { + position: absolute; + left: var(--space-3); + top: var(--space-3); + z-index: 100; + transform: translateY(-150%); + background: var(--surface-card); + color: var(--text-strong); + border: 1px solid var(--border-strong); + border-radius: var(--radius-button); + padding: var(--space-2) var(--space-4); + font-family: var(--font-body); + font-weight: var(--weight-bold); + transition: transform var(--duration-fast) var(--ease-out); +} +.qz-skip:focus-visible { + transform: translateY(0); + outline: none; + box-shadow: var(--focus-ring-shadow); +} + +/* ---- Shared section chrome ---------------------------------------------- */ +.qz-section { + position: relative; + padding-block: clamp(var(--space-9), 8vw, var(--space-12)); +} +.qz-container { + max-width: var(--content-max); + margin-inline: auto; + padding-inline: var(--space-6); +} +.qz-eyebrow { + font-family: var(--font-body); + font-weight: var(--weight-extrabold, 800); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: var(--text-xs); + color: var(--accent-text); +} +.qz-section__title { + font-family: var(--font-display); + font-weight: 700; + font-size: clamp(1.8rem, 3.6vw, 2.6rem); + line-height: 1.1; + letter-spacing: -0.01em; + color: var(--text-strong); +} +.qz-section__lead { + margin-top: var(--space-3); + max-width: var(--reading-max); + font-size: var(--text-lg); + color: var(--text-muted); +} + +/* A coral highlighter swipe behind a word. The colour is a fill layer behind the + text so it can sweep in (the draw on); at rest the fill is full. */ +.qz-mark { + position: relative; + display: inline-block; + background: transparent; /* neutralise the browser's default mark styling */ + color: inherit; + padding: 0 0.14em; + white-space: nowrap; /* keep the marked word on one line so the single fill covers it */ +} +.qz-mark__fill { + position: absolute; + inset: 0; + z-index: 0; + background: var(--accent-soft); + border-radius: 0.24em; + transform-origin: left center; +} +.qz-mark__text { + position: relative; + z-index: 1; +} + +/* ---- Navigation --------------------------------------------------------- */ +.qz-nav { + position: sticky; + top: 0; + z-index: 50; + background: var(--color-bg); + border-bottom: 1px solid var(--border); +} +.qz-nav__inner { + max-width: var(--content-max); + margin-inline: auto; + padding: var(--space-3) var(--space-6); + display: flex; + align-items: center; + gap: var(--space-5); +} +.qz-wordmark { + font-family: var(--font-display); + font-weight: 600; + font-size: var(--text-xl); + color: var(--text-strong); + text-decoration: none; + white-space: nowrap; +} +.qz-wordmark__dot { + color: var(--brand-mark); +} +.qz-nav__links { + display: none; + gap: var(--space-5); + margin-inline-start: var(--space-4); +} +.qz-nav__link { + font-family: var(--font-body); + font-weight: var(--weight-semibold, 600); + color: var(--text-muted); + text-decoration: none; + border-radius: var(--radius-chip); + padding: var(--space-1) var(--space-2); + transition: color var(--duration-fast) var(--ease-out); +} +.qz-nav__link:hover { + color: var(--text-strong); +} +.qz-nav__link:focus-visible { + outline: none; + box-shadow: var(--focus-ring-shadow); +} +.qz-nav__actions { + margin-inline-start: auto; + display: flex; + align-items: center; + gap: var(--space-2); +} +@media (min-width: 860px) { + .qz-nav__links { + display: flex; + } +} + +/* ---- Hero --------------------------------------------------------------- */ +.qz-hero { + position: relative; + padding-block: clamp(var(--space-8), 7vw, var(--space-11)); + isolation: isolate; +} +.qz-hero__grid { + display: grid; + gap: clamp(var(--space-7), 5vw, var(--space-10)); + align-items: center; + grid-template-columns: 1fr; +} +.qz-hero__eyebrow { + display: inline-block; + margin-bottom: var(--space-3); +} +.qz-hero__title { + font-family: var(--font-display); + font-weight: 700; + font-size: clamp(2.3rem, 5.4vw, 3.7rem); + line-height: 1.04; + letter-spacing: -0.015em; + color: var(--text-strong); + text-wrap: balance; +} +.qz-hero__sub { + margin-top: var(--space-5); + max-width: 34rem; + font-size: var(--text-lg); + line-height: 1.6; + color: var(--text-muted); +} +.qz-hero__ctas { + margin-top: var(--space-7); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); +} + +/* Audience toggle: a soft segmented control (ARIA tabs). */ +.qz-toggle { + display: inline-flex; + gap: var(--space-1); + padding: var(--space-1); + margin-bottom: var(--space-6); + background: var(--surface-sunken); + border: 1px solid var(--border); + border-radius: var(--radius-pill); +} +.qz-toggle__btn { + appearance: none; + border: none; + cursor: pointer; + background: transparent; + border-radius: var(--radius-pill); + padding: var(--space-2) var(--space-4); + min-height: 2.5rem; + font-family: var(--font-display); + font-weight: 600; + font-size: var(--text-sm); + color: var(--text-muted); + transition: + color var(--duration-fast) var(--ease-out), + background-color var(--duration-fast) var(--ease-out), + box-shadow var(--duration-fast) var(--ease-out); +} +.qz-toggle__btn:hover { + color: var(--text-strong); +} +.qz-toggle__btn[aria-selected='true'] { + background: var(--surface-card); + color: var(--text-strong); + box-shadow: var(--shadow-sm); +} +.qz-toggle__btn:focus-visible { + outline: none; + box-shadow: var(--focus-ring-shadow); +} + +/* Hero visual: the one blueberry gradient glow behind a blob framed persona. */ +.qz-hero__visual { + position: relative; + display: grid; + place-items: center; + min-height: 20rem; +} +.qz-hero__glow { + position: absolute; + inset: 4% 6%; + z-index: -1; + border-radius: 46% 54% 52% 48% / 55% 45% 55% 45%; + background: linear-gradient(155deg, var(--primary), var(--primary-press)); + box-shadow: var(--shadow-primary); +} +.qz-persona-frame { + position: relative; + width: min(100%, 26rem); + aspect-ratio: 4 / 5; + border-radius: 47% 53% 44% 56% / 58% 44% 56% 42%; + overflow: hidden; + border: 6px solid var(--surface-card); + box-shadow: var(--shadow-lg); + background: var(--surface-sunken); +} +.qz-persona { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + filter: saturate(0.96) contrast(1.02); +} +/* The coral and sand duotone wash that pulls the photo into the brand. */ +.qz-persona-frame::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(165deg, transparent 52%, var(--accent-soft)); + mix-blend-mode: multiply; + opacity: 0.55; + pointer-events: none; +} + +/* Decoration layer: soft shapes behind the content, never in front of text. */ +.qz-deco { + position: absolute; + z-index: -2; + pointer-events: none; + opacity: 0.9; +} + +@media (min-width: 900px) { + .qz-hero__grid { + grid-template-columns: 1.05fr 0.95fr; + } +} + +/* ---- Footer ------------------------------------------------------------- */ +.qz-footer { + position: relative; + background: var(--color-bg-subtle); + border-top: 1px solid var(--border); + padding-block: clamp(var(--space-8), 6vw, var(--space-10)); +} +.qz-footer__grid { + display: grid; + gap: var(--space-8); + grid-template-columns: 1fr; +} +.qz-footer__brand-mark { + font-family: var(--font-display); + font-weight: 600; + font-size: var(--text-xl); + color: var(--text-strong); + text-decoration: none; +} +.qz-footer__tagline { + margin-top: var(--space-2); + max-width: 22rem; + color: var(--text-muted); +} +.qz-footer__cols { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-6); +} +.qz-footer__col-title { + font-family: var(--font-display); + font-weight: 600; + font-size: var(--text-sm); + color: var(--text-strong); + margin-bottom: var(--space-3); +} +.qz-footer__link { + display: block; + padding-block: var(--space-1); + color: var(--text-muted); + text-decoration: none; + border-radius: var(--radius-chip); +} +.qz-footer__link:hover { + color: var(--text-strong); +} +.qz-footer__link:focus-visible { + outline: none; + box-shadow: var(--focus-ring-shadow); +} +.qz-footer__fine { + margin-top: var(--space-8); + padding-top: var(--space-5); + border-top: 1px solid var(--border); + font-size: var(--text-sm); + color: var(--text-subtle); + display: grid; + gap: var(--space-2); +} +@media (min-width: 760px) { + .qz-footer__grid { + grid-template-columns: 1.4fr 1fr; + align-items: start; + } +} + +/* ---- Section head ------------------------------------------------------- */ +.qz-section__head { + max-width: 46rem; + margin-bottom: clamp(var(--space-7), 4vw, var(--space-9)); +} +.qz-section__head .qz-eyebrow { + display: inline-block; + margin-bottom: var(--space-2); +} + +/* ---- How it works ------------------------------------------------------- */ +.qz-how { + background: var(--color-bg-subtle); + border-block: 1px solid var(--border); +} +.qz-steps { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: var(--space-6); + grid-template-columns: 1fr; +} +.qz-step { + position: relative; + background: var(--surface-card); + border: 1px solid var(--border); + border-radius: var(--radius-card); + box-shadow: var(--shadow-sm); + padding: var(--space-7); +} +.qz-step__n { + position: absolute; + top: var(--space-5); + right: var(--space-6); + font-family: var(--font-display); + font-weight: 700; + font-size: var(--text-2xl); + color: var(--primary-soft); +} +.qz-step__badge { + display: inline-grid; + place-items: center; + width: 3.25rem; + height: 3.25rem; + border-radius: var(--radius-tile); + background: var(--accent-soft); + color: var(--accent-text); + margin-bottom: var(--space-4); +} +.qz-step__badge svg { + width: 1.6rem; + height: 1.6rem; +} +.qz-step__title { + font-family: var(--font-display); + font-weight: 600; + font-size: var(--text-xl); + color: var(--text-strong); + margin-bottom: var(--space-2); +} +.qz-step__body { + color: var(--text-muted); + line-height: 1.6; +} +@media (min-width: 820px) { + .qz-steps { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +/* ---- Value split -------------------------------------------------------- */ +.qz-value__grid { + display: grid; + gap: clamp(var(--space-7), 5vw, var(--space-10)); + align-items: center; + grid-template-columns: 1fr; +} +.qz-value__points { + list-style: none; + margin: var(--space-6) 0 var(--space-7); + padding: 0; + display: grid; + gap: var(--space-3); +} +.qz-value__point { + display: flex; + gap: var(--space-3); + align-items: flex-start; + color: var(--text-body); + line-height: 1.55; +} +.qz-value__check { + flex: none; + display: inline-grid; + place-items: center; + width: 1.6rem; + height: 1.6rem; + margin-top: 0.15rem; + border-radius: var(--radius-pill); + background: var(--success-soft); + color: var(--success-text); +} +.qz-value__check svg { + width: 0.95rem; + height: 0.95rem; +} +.qz-value__media { + position: relative; +} +.qz-photo-frame { + position: relative; + border-radius: 42% 58% 54% 46% / 55% 48% 52% 45%; + overflow: hidden; + border: 6px solid var(--surface-card); + box-shadow: var(--shadow-lg); + aspect-ratio: 4 / 3; +} +.qz-photo { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} +.qz-photo-frame::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(160deg, transparent 55%, var(--accent-soft)); + mix-blend-mode: multiply; + opacity: 0.4; + pointer-events: none; +} +@media (min-width: 880px) { + .qz-value__grid { + grid-template-columns: 1fr 1fr; + } + .qz-value--image-first .qz-value__text { + order: 2; + } + .qz-value--image-first .qz-value__media { + order: 1; + } +} + +/* ---- AI feedback spotlight ---------------------------------------------- */ +.qz-ai__grid { + display: grid; + gap: clamp(var(--space-7), 5vw, var(--space-10)); + align-items: center; + grid-template-columns: 1fr; +} +.qz-ai__demo { + position: relative; +} +.qz-fb { + position: relative; + margin: 0; + background: var(--ai-surface); + border: 1px solid var(--ai-border); + border-radius: var(--radius-card); + box-shadow: var(--shadow-md); + padding: var(--space-6); +} +.qz-fb__tag { + display: inline-block; + font-family: var(--font-body); + font-weight: var(--weight-bold); + font-size: var(--text-2xs); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ai-accent); + margin-bottom: var(--space-3); +} +.qz-fb__q { + font-family: var(--font-display); + font-weight: 600; + font-size: var(--text-lg); + color: var(--text-strong); + margin-bottom: var(--space-4); +} +.qz-fb__their { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + color: var(--text-muted); + margin-bottom: var(--space-4); +} +.qz-fb__badge { + flex: none; + background: var(--answer-incorrect-bg); + color: var(--danger-text); + border: 1px solid var(--answer-incorrect-border); + border-radius: var(--radius-pill); + padding: 0.1rem var(--space-2); + font-weight: var(--weight-bold); + font-size: var(--text-2xs); +} +.qz-fb__msg { + display: flex; + gap: var(--space-3); + align-items: flex-start; + background: var(--surface-card); + border-radius: var(--radius-tile); + padding: var(--space-4); +} +.qz-fb__avatar { + flex: none; + display: grid; + place-items: center; + width: 2.25rem; + height: 2.25rem; + border-radius: var(--radius-pill); + background: var(--accent); + color: var(--text-on-accent); + font-family: var(--font-display); + font-weight: 700; +} +.qz-fb__text { + margin: 0; + color: var(--ai-text); + line-height: 1.55; + font-size: var(--text-sm); +} +@media (min-width: 880px) { + .qz-ai__grid { + grid-template-columns: 1fr 1fr; + } +} + +/* ---- FAQ ---------------------------------------------------------------- */ +.qz-faq__inner { + max-width: 48rem; +} +.qz-faq__list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: var(--space-3); +} +.qz-faq__item { + background: var(--surface-card); + border: 1px solid var(--border); + border-radius: var(--radius-tile); + padding: 0 var(--space-6); +} +.qz-faq__q { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + cursor: pointer; + list-style: none; + padding: var(--space-5) 0; + font-family: var(--font-display); + font-weight: 600; + font-size: var(--text-lg); + color: var(--text-strong); +} +.qz-faq__q::-webkit-details-marker { + display: none; +} +.qz-faq__q:focus-visible { + outline: none; + box-shadow: var(--focus-ring-shadow); + border-radius: var(--radius-chip); +} +.qz-faq__chevron { + flex: none; + display: inline-flex; + color: var(--text-muted); + transition: transform var(--duration-fast) var(--ease-out); +} +.qz-faq__item[open] .qz-faq__chevron { + transform: rotate(180deg); +} +/* The animated panel. It is clipped so the height animation reads as a collapse. + With motion off this element is not rendered and the native details does the work. */ +.qz-faq__panel { + overflow: hidden; +} +.qz-faq__a { + margin: 0; + padding-bottom: var(--space-5); + color: var(--text-muted); + line-height: 1.6; + max-width: var(--reading-max); +} + +/* ---- Closing CTA -------------------------------------------------------- */ +.qz-cta__panel { + position: relative; + overflow: hidden; + isolation: isolate; + text-align: center; + background: var(--surface-card); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); + padding: clamp(var(--space-8), 6vw, var(--space-11)) var(--space-6); +} +.qz-cta__free { + display: inline-block; + background: var(--accent-soft); + color: var(--accent-text); + border-radius: var(--radius-pill); + padding: var(--space-1) var(--space-4); + font-family: var(--font-body); + font-weight: var(--weight-bold); + font-size: var(--text-sm); + margin-bottom: var(--space-5); +} +.qz-cta__title { + font-family: var(--font-display); + font-weight: 700; + font-size: clamp(1.9rem, 4vw, 2.8rem); + line-height: 1.08; + color: var(--text-strong); + max-width: 20ch; + margin-inline: auto; +} +.qz-cta__body { + margin: var(--space-4) auto 0; + max-width: 40rem; + font-size: var(--text-lg); + color: var(--text-muted); +} +.qz-cta__actions { + margin-top: var(--space-7); + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + justify-content: center; +} + +/* ---- Reduced motion ----------------------------------------------------- */ +@media (prefers-reduced-motion: reduce) { + /* Jump to anchors instead of gliding to them. */ + :root:has(.qz-landing) { + scroll-behavior: auto; + } + .qz-landing *, + .qz-landing *::before, + .qz-landing *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/frontend/src/features/landing/motion/ScrollProgress.test.tsx b/frontend/src/features/landing/motion/ScrollProgress.test.tsx new file mode 100644 index 0000000..e9ca126 --- /dev/null +++ b/frontend/src/features/landing/motion/ScrollProgress.test.tsx @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, fireEvent, waitFor } from '@testing-library/react'; +import { ScrollProgress } from './motion'; +import { setReducedMotion } from '@/test/matchMedia'; + +// The motion ALLOWED path. It lives in its own file because framer-motion's +// useReducedMotion caches the media query answer the first time it runs, so a file +// can only exercise one motion mode. The reduced motion path is in motion.test.tsx. +// +// The bar reads the live document geometry on every scroll rather than measuring the +// scrollable range once, because the landing arrives as its own chunk and the page +// keeps growing as images and fonts land. + +/** Give the jsdom document a scrollable geometry the progress bar can read. */ +function setScrollGeometry(scrollTop: number, scrollHeight: number, clientHeight: number) { + const doc = document.documentElement; + Object.defineProperty(doc, 'scrollHeight', { value: scrollHeight, configurable: true }); + Object.defineProperty(doc, 'clientHeight', { value: clientHeight, configurable: true }); + Object.defineProperty(doc, 'scrollTop', { value: scrollTop, writable: true, configurable: true }); +} + +describe('ScrollProgress with motion allowed', () => { + beforeEach(() => { + setReducedMotion(false); + }); + + it('renders an aria-hidden bar that is empty at the top of the page', async () => { + setScrollGeometry(0, 5000, 1000); + const { container } = render(); + + const bar = container.querySelector('.qz-scroll-progress') as HTMLElement; + expect(bar).toBeInTheDocument(); + expect(bar).toHaveAttribute('aria-hidden', 'true'); + await waitFor(() => { + expect(bar.style.transform).toContain('scaleX(0)'); + }); + }); + + it('fills as the page scrolls', async () => { + setScrollGeometry(0, 5000, 1000); + const { container } = render(); + const bar = container.querySelector('.qz-scroll-progress') as HTMLElement; + + // Halfway down the 4000px of scrollable range. + setScrollGeometry(2000, 5000, 1000); + fireEvent.scroll(window); + await waitFor(() => { + expect(bar.style.transform).toContain('scaleX(0.5)'); + }); + + // Three quarters of the way. + setScrollGeometry(3000, 5000, 1000); + fireEvent.scroll(window); + await waitFor(() => { + expect(bar.style.transform).toContain('scaleX(0.75)'); + }); + + // All the way to the bottom. A full scaleX(1) is the identity transform, which + // is written out as "none", so a full bar reads as no transform at all. + setScrollGeometry(4000, 5000, 1000); + fireEvent.scroll(window); + await waitFor(() => { + expect(bar.style.transform).toBe('none'); + }); + }); +}); diff --git a/frontend/src/features/landing/motion/motion.test.tsx b/frontend/src/features/landing/motion/motion.test.tsx new file mode 100644 index 0000000..e95f13e --- /dev/null +++ b/frontend/src/features/landing/motion/motion.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { Reveal, AmbientFloat, ScrollProgress } from './motion'; +import { setReducedMotion } from '@/test/matchMedia'; + +// AC-10: when the visitor asks for reduced motion, nothing animates and all content +// is fully visible and usable. Reveal and AmbientFloat must render their children in +// a plain, visible resting state, with no hidden (opacity 0) or shifted start state. +// +// This file covers the reduced motion path only. framer-motion's useReducedMotion +// caches the media query answer the first time it runs, so a single file cannot test +// both paths; the motion allowed path lives in ScrollProgress.test.tsx. +describe('motion kit under reduced motion (AC-10)', () => { + beforeEach(() => { + setReducedMotion(true); + }); + + it('Reveal shows its children with no hidden start state', () => { + render( + +

Feedback that sounds like a person.

+
, + ); + const content = screen.getByTestId('revealed'); + expect(content).toBeInTheDocument(); + // The wrapper is a plain element, not a motion element parked at opacity 0. + const wrapper = content.parentElement as HTMLElement; + expect(wrapper.style.opacity).not.toBe('0'); + expect(wrapper.style.transform).toBe(''); + }); + + it('Reveal renders the requested element (a list item stays a real li)', () => { + render( +
    + + Step one + +
, + ); + expect(screen.getByRole('listitem')).toHaveTextContent('Step one'); + }); + + it('AmbientFloat still renders its decorative child, without drifting', () => { + render( + + deco + , + ); + const bubble = screen.getByTestId('bubble'); + expect(bubble).toBeInTheDocument(); + expect((bubble.parentElement as HTMLElement).style.transform).toBe(''); + }); + + it('the scroll progress bar is not rendered at all', () => { + const { container } = render(); + expect(container.querySelector('.qz-scroll-progress')).toBeNull(); + }); +}); diff --git a/frontend/src/features/landing/motion/motion.tsx b/frontend/src/features/landing/motion/motion.tsx new file mode 100644 index 0000000..28dca31 --- /dev/null +++ b/frontend/src/features/landing/motion/motion.tsx @@ -0,0 +1,200 @@ +import { motion, useMotionValue, useScroll, useTransform } from 'framer-motion'; +import { useEffect, useRef } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; +import { useMotionReady } from './useMotionReady'; + +/** + * The landing page motion kit (spec 0003, AC-10). Every animation on the page runs + * through here, and every piece obeys one rule: the resting state is the fully + * visible, static state. So the prerendered HTML (AC-11), a visitor who asked for + * reduced motion, and the split second before hydration all render content that is + * already there, with no hidden or shifted state to recover from. The mount and + * reduced motion gate lives in useMotionReady. + */ + +// The elements Reveal knows how to animate. A small fixed map keeps this type safe +// under noUncheckedIndexedAccess, and lets a list item reveal as a real
  • . +const REVEAL_TAGS = { + div: motion.div, + li: motion.li, + section: motion.section, +} as const; +type RevealTag = keyof typeof REVEAL_TAGS; + +interface RevealProps { + children: ReactNode; + className?: string; + style?: CSSProperties; + /** Which element to render (defaults to a div). */ + as?: RevealTag; + /** Stagger delay in seconds, e.g. index * 0.08 down a list. */ + delay?: number; +} + +/** + * Fades and lifts its children in the first time they scroll into view. At rest it + * renders a plain, fully visible element, so the prerender, a reduced motion + * visitor, and the pre hydration paint show the content with no hidden state. The + * animated version swaps in through the layout effect, before paint, so there is no + * flash of content appearing and disappearing. + */ +export function Reveal({ children, className, style, as = 'div', delay = 0 }: RevealProps) { + const on = useMotionReady(); + + if (!on) { + const Tag = as; + return ( + + {children} + + ); + } + + const MotionTag = REVEAL_TAGS[as]; + return ( + + {children} + + ); +} + +interface AmbientFloatProps { + children: ReactNode; + className?: string; + style?: CSSProperties; + /** Drift distance in px (the peak of the bob). */ + range?: number; + /** Seconds for one full up and back cycle. */ + duration?: number; + delay?: number; +} + +/** + * Wraps a decorative shape in a slow, endless vertical drift, so the bubbles feel + * alive. Decorative only (aria-hidden). It drifts only when motion is allowed; + * otherwise it is a still wrapper in the exact same position. + */ +export function AmbientFloat({ children, className, style, range = 10, duration = 6, delay = 0 }: AmbientFloatProps) { + const on = useMotionReady(); + + if (!on) { + return ( + + ); + } + + return ( + + ); +} + +/** + * A thin bar across the top of the viewport that fills as the visitor scrolls the + * page. Decorative progress feedback, so it is aria-hidden and simply absent when + * the visitor asked for reduced motion. + */ +export function ScrollProgress() { + const on = useMotionReady(); + if (!on) return null; + return ; +} + +// Split out so the scroll hooks only ever run when motion is on (hooks cannot be +// called conditionally, but a component can be rendered conditionally). +function ScrollProgressBar() { + const progress = useMotionValue(0); + + // The page's scrollable height is not settled when this mounts: the landing arrives + // as its own chunk and images and fonts land after. framer-motion's window level + // useScroll measures the range once and would sit at zero, so track it ourselves and + // re measure whenever the document actually changes size. + useEffect(() => { + const doc = document.documentElement; + let range = 0; + + const update = () => { + progress.set(range > 0 ? Math.min(1, Math.max(0, doc.scrollTop / range)) : 0); + }; + const measure = () => { + range = doc.scrollHeight - doc.clientHeight; + update(); + }; + + measure(); + window.addEventListener('scroll', update, { passive: true }); + window.addEventListener('resize', measure); + const observer = new ResizeObserver(measure); + observer.observe(document.body); + + return () => { + window.removeEventListener('scroll', update); + window.removeEventListener('resize', measure); + observer.disconnect(); + }; + }, [progress]); + + return