From eed59d4ef2f2ffbfb7899844ae5b0499e8264c80 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 20:31:36 +0100 Subject: [PATCH 01/17] docs: v1.1 M navigation motion plan Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-30-v11-motion.md | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-30-v11-motion.md diff --git a/docs/superpowers/plans/2026-10-30-v11-motion.md b/docs/superpowers/plans/2026-10-30-v11-motion.md new file mode 100644 index 0000000..968068c --- /dev/null +++ b/docs/superpowers/plans/2026-10-30-v11-motion.md @@ -0,0 +1,209 @@ +# v1.1 M — Navigation Motion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** answer the user feedback "the navigation between courses, pages and steps could need some light animations and not switch as hard" with light, purposeful motion, without moving the header or sidebar and without slowing anything down. + +**Architecture:** +- **Motion tokens** in `@stc/ui` `tokens.css`: durations (`--t-exit` 120 ms, `--t-fast` 140 ms, `--t-enter` 180 ms, `--t-fold` 200 ms, `--t-bar` 220 ms), one ease-out curve, a 5 px rise, and the shared keyframes. They use only `opacity` and the `translate` property. One `prefers-reduced-motion: reduce` block turns off every animation, transition and view-transition pseudo-element. +- **Motion helpers** in `@stc/ui` `motion.ts`: + - `reducedMotion()`; + - `viewTransition(update)`, which runs `update` inside `document.startViewTransition` + `flushSync` when that is supported and motion is allowed, and runs it directly otherwise; + - `useHeldValue()`, which keeps the previous value on screen while the next one loads, then swaps inside a view transition. +- **Scroll keeper** in `@stc/ui` `scroll.ts`. It saves the scroll position per history entry and applies it on route change: top for PUSH/REPLACE, the saved position for POP. A page that keeps old content on screen can defer this until it swaps. +- **Shell (`apps/web`):** + - When a navigation changes the matched route pattern (library → course, course → lesson, lesson → account…), the router's `navigate` is wrapped to add React Router's `viewTransition` + `flushSync` options. + - The View Transition CSS keeps the header (`stc-top`) and course sidebar (`lr-side`) as their own, non-animated groups. The old page dissolves (120 ms) over the live new page. + - Browsers without View Transitions get the CSS fallback: the new content's own enter animation. +- **Lesson → lesson** has the same route pattern, so it gets no router transition. `LessonPage` holds the current lesson until the next one has loaded, then swaps inside a view transition and only then applies the scroll. The sidebar highlight moves immediately on click. +- **Content enter:** lesson body, course overview, notes, library and account content fade in with a 5 px rise (180 ms) when they mount. The sidebar and dock are not re-animated. +- **Smaller pieces:** + - sidebar module lists fade/rise in when the user opens them; + - `
` folds animate height with `interpolate-size` + `::details-content` where supported, and open instantly elsewhere; + - the deck viewer double-buffers its canvas and cross-fades to the new slide with a 4 px shift in the direction of travel; + - progress fills transition their width; + - the toast fades/rises in and fades out; + - the course menu and the mobile scrim fade in. + +**Tech Stack:** +- the View Transitions API through React Router 6.30's `viewTransition` navigation option; +- CSS keyframes and transitions; +- no new dependencies. + +**Spec:** `docs/superpowers/plans/2026-10-30-v1-1.md` (Global Constraints, package M). Visual reference: `docs/design/mockups/platform-v1.html`. + +## Global Constraints + +- Everything in `CLAUDE.md` "Project rules" applies. +- Motion: + - durations 120–220 ms, ease-out, no bounce (cubic-bezier y values within [0, 1]), no scale-pop, no parallax; + - only `opacity` and `translate` of at most 8 px in keyframes; + - `prefers-reduced-motion: reduce` turns it all off, in CSS and in JS (no `startViewTransition` call); + - motion never delays input or blocks navigation: `::view-transition { pointer-events: none }`, swaps happen only once data is there, and every update callback is synchronous. +- No new colours or fonts. No new JS dependency. +- `bun test`, `bun run typecheck` and the Postgres run (`STC_TEST_PG_URL=postgres://postgres:stc@127.0.0.1:55433/postgres bun test`) stay green. +- Dev server for manual checks: port 3491, `STC_DATA_DIR=/tmp/stc-v11-motion`, `STC_DATABASE_URL=sqlite:///tmp/stc-v11-motion/stc.db`. Never touch port 3480. + +## File map + +- `packages/ui/src/tokens.css`: motion tokens, keyframes, view-transition rules, reduced-motion block, progress fill transition. +- `packages/ui/src/motion.ts` (new): `reducedMotion`, `viewTransition`, `useHeldValue`. +- `packages/ui/src/scroll.ts` (new): `createScrollKeeper`, `routeScroll` (the window-bound instance). +- `packages/ui/src/index.ts`: exports. +- `packages/ui/test/motion-css.test.ts` (new): static motion lint over every stylesheet. +- `packages/ui/test/motion.test.ts`, `packages/ui/test/scroll.test.ts` (new). +- `apps/web/src/nav.ts`: `changesPage(routes, fromPath, to)`. +- `apps/web/src/main.tsx`: navigate wrapper, scroll keeper wiring. +- `apps/web/src/nav.test.ts`: `changesPage` cases. +- `apps/web/src/motion.e2e.test.ts` (new): headless Chrome against the real server in a subprocess. +- `packages/feature-learner/src/web/styles.ts`: enter animations, module list, folds, slides, toast, menu, scrim. +- `packages/feature-learner/src/web/course/pages.tsx`: held lesson swap, deferred scroll. +- `packages/feature-learner/src/web/shell/CourseFrame.tsx`: module list enter only on user toggle. +- `packages/feature-learner/src/web/lesson/DeckViewer.tsx`: double-buffered canvas. +- `packages/feature-learner/src/web/api.ts`, `shell/HeaderExtras.tsx`: toast leave phase. +- `scripts/motion-frames.ts` (new): captures mid-transition frames from a running server (manual evidence). + +--- + +### Task 1: Motion tokens, keyframes and the reduced-motion switch + +**Files:** `packages/ui/src/tokens.css`, `packages/ui/test/motion-css.test.ts` + +- [ ] Write `motion-css.test.ts`. It reads `tokens.css` and the learner stylesheet string (`learnerCss`) and checks: + - (a) the tokens `--t-exit`, `--t-fast`, `--t-enter`, `--t-fold`, `--t-bar` exist and are within 120–220 ms; + - (b) every duration used in an `animation`/`transition` declaration resolves (through the tokens) to 0 or 120–220 ms, except the pre-existing off-canvas drawer, which stays at 160 ms; + - (c) every `@keyframes` block animates only `opacity` and `translate`, with each translate component ≤ 8 px; + - (d) every `cubic-bezier()` has y values within [0, 1]; + - (e) a `prefers-reduced-motion: reduce` block in `tokens.css` sets `animation: none` and `transition: none` on `*` and on `::view-transition-*` pseudo-elements; + - (f) `::view-transition` has `pointer-events: none`. +- [ ] Run it and see it fail (no tokens yet). +- [ ] Add the tokens (keep `--fast` as an alias of `--t-fast`), the keyframes (`stc-enter`, `stc-fade-in`, `stc-fade-out`, `stc-drop`, `stc-hold-out`, `stc-slide-next`, `stc-slide-prev`), the view-transition rules (header and sidebar groups static, old root dissolving over the new, `mix-blend-mode: normal`), the `.stc-bar > i` width transition and the reduced-motion block. +- [ ] Run the test until it passes; run `bun run typecheck`. Commit `feat(ui): motion tokens, keyframes and reduced-motion switch`. + +### Task 2: Motion helpers + +**Files:** `packages/ui/src/motion.ts`, `packages/ui/src/index.ts`, `packages/ui/test/motion.test.ts` + +- [ ] Tests, using a stub `document`/`matchMedia` on `globalThis`: + - `reducedMotion()` follows `matchMedia('(prefers-reduced-motion: reduce)')` and is false without `window`; + - `viewTransition(fn)` calls `startViewTransition` once and runs `fn` inside it when supported; + - it runs `fn` directly when reduced motion is on, when `startViewTransition` is missing, or when the document is hidden; + - `holdDecision(prev, next, keyOf, holdable)`, the pure core of `useHeldValue`, returns `show-next` / `hold` / `swap` / `replace` correctly. +- [ ] See them fail. Implement them. See them pass. Commit `feat(ui): view-transition helper and held-value hook`. + +### Task 3: Scroll keeper + +**Files:** `packages/ui/src/scroll.ts`, `packages/ui/test/scroll.test.ts` + +- [ ] Tests with a fake environment: + - PUSH scrolls to 0; + - POP restores the saved position of that key; + - POP without a saved position goes to 0; + - a hash scrolls its target into view; + - positions are saved for the current key on `onScroll`; + - `defer()` before `routeChanged` postpones the scroll until `settle()`, and keeps saving to the old key meanwhile; + - a restore past the current page height retries until the page is tall enough (budget 1 s); + - a newer `routeChanged` cancels a pending retry; + - positions persist to `sessionStorage`. +- [ ] See them fail. Implement them. See them pass. Commit `feat(ui): scroll keeper with deferred settle`. + +### Task 4: Route-change transitions in the shell, plus the e2e harness + +**Files:** `apps/web/src/nav.ts`, `apps/web/src/nav.test.ts`, `apps/web/src/main.tsx`, `apps/web/src/motion.e2e.test.ts`, `packages/feature-learner/src/web/styles.ts` + +- [ ] `nav.test.ts`: `changesPage(routes, "/", "/c/sample")` is true; `/c/sample` → `/c/sample/m1-intro` is true; `/c/sample/m1-intro` → `/c/sample/m1-code` is false; a query-only change is false; an unknown path is true. See it fail; implement it with `matchRoutes`. +- [ ] `motion.e2e.test.ts` (skipped without Chrome): + - start `apps/server/src/main.ts` in a subprocess (production bundle, temp data dir, fixtures, a free fixed port so the CSRF origin matches); + - open `/` in headless Chrome, with a probe that counts `document.startViewTransition` calls and records `animationstart` names; + - test "library → course": clicking the sample course runs one view transition and the course article plays `stc-enter`. +- [ ] See it fail. Wire `main.tsx`: + - wrap `router.navigate` (number targets and same-pattern targets are untouched; motion is skipped when reduced); + - install the scroll keeper (`routeChanged` in a layout effect keyed on `location.key`); + - add the `view-transition-name`s; + - add the learner enter rules (`.lr-lesson`, `.lr-main > article`, `.lr-lib`, account content). +- [ ] See it pass. Commit `feat(web): view transitions on route changes; content enter animation`. + +### Task 5: Lesson → lesson without a hard switch + +**Files:** `packages/feature-learner/src/web/course/pages.tsx`, `apps/web/src/motion.e2e.test.ts` + +- [ ] e2e, on `/c/sample/m1-intro`: + - scroll down, then click Next in the dock; + - the URL changes at once and the sidebar's `aria-current` moves at once; + - the old lesson title stays until the new lesson renders (it is never blank); + - one view transition runs; + - `.lr-lesson` plays `stc-enter`; + - the sidebar and header rects are identical before and after; + - `scrollY` is 0 after the swap; + - `history.back()` returns to the intro at the saved scroll position. +- [ ] See it fail. Implement: + - `useHeldValue` in `LessonPage`, holding only within the same course; + - `routeScroll.defer()` while held and `settle()` after the swap (and on error); + - remove the old `window.scrollTo(0, 0)`; + - the dock's "Mark done" is disabled while the shown lesson is stale. +- [ ] See it pass, and also the existing learner tests. Commit `feat(learner): hold the lesson until the next one loads, swap with a view transition`. + +### Task 6: Sidebar modules and folds + +**Files:** `packages/feature-learner/src/web/shell/CourseFrame.tsx`, `packages/feature-learner/src/web/styles.ts`, e2e + +- [ ] e2e: + - opening a collapsed module plays `stc-enter` on its list; + - lists are not animated on page load or lesson change; + - opening the transcript fold runs a `height` transition on `::details-content`; + - closing it does too; + - the fold content is reachable at once (`open` is set synchronously). +- [ ] See it fail. Implement: + - `data-enter` on lists opened by the user; + - the `@supports selector(::details-content)` fold rules with `interpolate-size: allow-keywords`, the height and `content-visibility allow-discrete` transitions, and an opacity fade. +- [ ] See it pass. Commit `feat(learner): module lists and folds open with motion`. + +### Task 7: Deck slide changes + +**Files:** `packages/feature-learner/src/web/lesson/DeckViewer.tsx`, `styles.ts`, e2e + +- [ ] e2e on `/c/sample/m1-video` (the fixture deck): + - Next slide flips the front canvas; + - the new front plays `stc-slide-next`; + - the old one plays `stc-hold-out`; + - the stage is never blank: one canvas is fully opaque at every sampled frame; + - the arrow keys still work. +- [ ] See it fail. Implement: + - two stacked canvases; + - render into the back canvas, then flip `data-front` and `data-dir`; + - a resize redraws the front without animation; + - only the front has `role="img"`. +- [ ] See it pass. Commit `feat(learner): cross-fade deck slides`. + +### Task 8: Progress fills, toast, menu and scrim + +**Files:** `packages/feature-learner/src/web/api.ts`, `shell/HeaderExtras.tsx`, `styles.ts`, e2e + +- [ ] e2e (signed in through the API from the page): + - Mark done runs a `width` transition on the sidebar progress fills; + - the toast (choose Reading on an exercise) plays `stc-enter` and, when it leaves, `stc-fade-out` before it is removed; + - opening the course switcher plays `stc-drop` on `.lr-menu`; + - Escape still closes the menu immediately. +- [ ] See it fail. Implement: + - a toast store of `{ text, leaving }`, removed after `--t-exit`; + - the CSS for the menu, scrim and toast. +- [ ] See it pass. Commit `feat(learner): progress, toast and menu motion`. + +### Task 9: Reduced motion turns it all off + +**Files:** e2e + +- [ ] e2e with `Emulation.setEmulatedMedia({ features: [{ name: "prefers-reduced-motion", value: "reduce" }] })`. It repeats the flows from Tasks 4–8: route change, lesson Next, module open, fold, slide, menu, toast. For each it checks `startViewTransition` calls = 0, `animationstart`/`transitionstart` events = 0 and `document.getAnimations().length` = 0. The behaviour is still correct: navigation, held swap, scroll and slide flip. +- [ ] Fix anything that still moves. Commit `test(web): reduced motion disables all motion`. + +### Task 10: Evidence and full verification + +**Files:** `scripts/motion-frames.ts` + +- [ ] `scripts/motion-frames.ts ` drives Chrome over CDP: + - slows animations with `Animation.setPlaybackRate`; + - captures before/mid/after PNGs for library → course, course → lesson, lesson Next, module open, fold open, slide change, toast and menu; + - captures one reduced-motion frame per flow. +- [ ] Run it against the dev server on port 3491 (with `STC_DATA_DIR=/tmp/stc-v11-motion`). Look at every frame. +- [ ] Run `bun test`, `bun run typecheck`, and the PG run. Check that `bun.lock` and the `package.json` dependencies are unchanged. +- [ ] Write the report to `/home/user/STCourses/.superpowers/sdd/2026-10-30-stcourses/v11-motion-report.md`. Commit `chore: motion frame capture script`. From 17b6ae382875f04371b423a23c8287ad8829a65c Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 20:32:21 +0100 Subject: [PATCH 02/17] feat(ui): motion tokens, keyframes and reduced-motion switch Co-Authored-By: Claude Opus 5.5 --- packages/feature-learner/src/web/styles.ts | 2 +- packages/ui/src/tokens.css | 39 ++++++++- packages/ui/test/motion-css.test.ts | 99 ++++++++++++++++++++++ 3 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 packages/ui/test/motion-css.test.ts diff --git a/packages/feature-learner/src/web/styles.ts b/packages/feature-learner/src/web/styles.ts index 8f96818..82df7ce 100644 --- a/packages/feature-learner/src/web/styles.ts +++ b/packages/feature-learner/src/web/styles.ts @@ -39,7 +39,7 @@ export const learnerCss = String.raw` /* ---------- course shell ---------- */ .stc-main:has(> .lr-shell) { padding: 0; } .lr-shell { display: flex; align-items: flex-start; } -.lr-side { position: sticky; top: var(--top); flex: none; width: var(--sidew); height: calc(100vh - var(--top)); overflow-y: auto; background: var(--side); border-right: 1px solid var(--line); overscroll-behavior: contain; } +.lr-side { view-transition-name: lr-side; position: sticky; top: var(--top); flex: none; width: var(--sidew); height: calc(100vh - var(--top)); overflow-y: auto; background: var(--side); border-right: 1px solid var(--line); overscroll-behavior: contain; } .lr-scrim { display: none; } .lr-main { flex: 1; min-width: 0; padding: 44px 40px 0; } .lr-side-head { display: flex; gap: 12px; padding: 18px 16px 16px; border-bottom: 1px solid var(--line); } diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index 4afc6f7..398b585 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -6,7 +6,10 @@ --sans: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; --serif: "Literata", Georgia, "Times New Roman", serif; --mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; - --top: 52px; --sidew: 296px; --fast: 140ms; + --top: 52px; --sidew: 296px; + /* motion (v1.1): light, ease-out, opacity and short rises only; prefers-reduced-motion turns it all off */ + --t-exit: 120ms; --t-fast: 140ms; --t-enter: 180ms; --t-fold: 200ms; --t-bar: 220ms; --fast: 140ms; + --ease-out: cubic-bezier(.2, .7, .3, 1); --rise: 5px; color-scheme: dark; } *, *::before, *::after { box-sizing: border-box; } @@ -23,7 +26,7 @@ code, pre { font-family: var(--mono); } .work { font-family: var(--sans); font-size: 15.5px; line-height: 1.6; color: var(--text); max-width: 760px; } /* shell */ -.stc-top { position: sticky; top: 0; z-index: 30; height: var(--top); display: flex; align-items: center; gap: 14px; padding: 0 16px; background: var(--ground); border-bottom: 1px solid var(--line); } +.stc-top { view-transition-name: stc-top; position: sticky; top: 0; z-index: 30; height: var(--top); display: flex; align-items: center; gap: 14px; padding: 0 16px; background: var(--ground); border-bottom: 1px solid var(--line); } /* quiet pill nav, as the mockup's segmented control: muted text, the current area raised in accent */ .stc-nav { display: flex; align-items: center; gap: 2px; } .stc-nav a { display: inline-flex; align-items: center; height: 28px; padding: 0 12px; border-radius: 16px; color: var(--muted); text-decoration: none; font-size: 13px; font-weight: 500; transition: background 120ms ease-out, color 120ms ease-out; } @@ -52,7 +55,7 @@ code, pre { font-family: var(--mono); } /* progress */ .stc-bar { display: block; flex: 1; height: 4px; border-radius: 2px; background: var(--line); overflow: hidden; } -.stc-bar > i { display: block; height: 100%; background: var(--accent); border-radius: 2px; } +.stc-bar > i { display: block; height: 100%; background: var(--accent); border-radius: 2px; transition: width var(--t-bar) var(--ease-out); } .stc-bar--dim > i { background: var(--accent-dim); } /* floating action bar */ @@ -65,3 +68,33 @@ code, pre { font-family: var(--mono); } .stc-cover { flex: none; position: relative; width: var(--cw, 44px); aspect-ratio: 3 / 4; border-radius: 6px; background: var(--cbg, var(--raised)); color: var(--cfg, var(--accent)); border: 1px solid color-mix(in srgb, var(--cfg, var(--accent)) 18%, transparent); overflow: hidden; font: 600 calc(var(--cw, 44px) * .34) / 1 var(--sans); letter-spacing: -.03em; display: flex; align-items: flex-end; padding: calc(var(--cw, 44px) * .12); } .stc-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; opacity: 0; transition: opacity 120ms ease-out; } .stc-cover img[data-shown] { opacity: 1; } + +/* ---------- motion ---------- */ +@keyframes stc-enter { from { opacity: 0; translate: 0 var(--rise); } } +@keyframes stc-fade-in { from { opacity: 0; } } +@keyframes stc-fade-out { to { opacity: 0; } } +@keyframes stc-drop { from { opacity: 0; translate: 0 -4px; } } +/* the outgoing slide stays fully visible under the incoming one, then goes */ +@keyframes stc-hold-out { from { opacity: 1; } 99% { opacity: 1; } to { opacity: 0; } } +@keyframes stc-slide-next { from { opacity: 0; translate: 4px 0; } } +@keyframes stc-slide-prev { from { opacity: 0; translate: -4px 0; } } +.stc-enter { animation: stc-enter var(--t-enter) var(--ease-out) both; } + +/* View transitions (route changes, lesson swaps). The old page dissolves over the live new one, whose content + plays its own enter animation; the header and the course sidebar are their own groups and do not move. + The overlay never takes pointer input, so a click during a transition reaches the new page. */ +::view-transition { pointer-events: none; } +::view-transition-old(root), ::view-transition-new(root) { mix-blend-mode: normal; } +::view-transition-old(root) { z-index: 1; animation: stc-fade-out var(--t-exit) var(--ease-out) both; } +::view-transition-new(root) { animation: none; } +::view-transition-group(stc-top), ::view-transition-old(stc-top), ::view-transition-new(stc-top), +::view-transition-group(lr-side), ::view-transition-old(lr-side), ::view-transition-new(lr-side) { animation: none; mix-blend-mode: normal; } +::view-transition-old(stc-top), ::view-transition-old(lr-side) { opacity: 0; } +/* the sidebar fades in or out only when the page gains or loses it (library to course and back) */ +::view-transition-new(lr-side):only-child { animation: stc-fade-in var(--t-enter) var(--ease-out) both; } +::view-transition-old(lr-side):only-child { opacity: 1; animation: stc-fade-out var(--t-exit) var(--ease-out) both; } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } + ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; } +} diff --git a/packages/ui/test/motion-css.test.ts b/packages/ui/test/motion-css.test.ts new file mode 100644 index 0000000..8da11d1 --- /dev/null +++ b/packages/ui/test/motion-css.test.ts @@ -0,0 +1,99 @@ +// Static motion lint (v1.1 package M): every animation and transition in the shared and learner stylesheets is +// light (120–220 ms, ease-out without overshoot), animates only opacity and short translates, and is switched off +// by prefers-reduced-motion. +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { learnerCss } from "../../feature-learner/src/web/styles"; + +const tokensCss = readFileSync(join(import.meta.dir, "../src/tokens.css"), "utf8"); +const sheets: [string, string][] = [["tokens.css", tokensCss], ["learnerCss", learnerCss]]; +const strip = (css: string) => css.replace(/\/\*[\s\S]*?\*\//g, ""); + +/** Duration tokens, in ms. */ +function tokens(): Record { + const out: Record = {}; + for (const m of strip(tokensCss).matchAll(/(--t-[\w-]+|--fast)\s*:\s*([\d.]+)ms/g)) out[m[1]!] = Number(m[2]); + return out; +} + +test("the motion duration tokens exist and are 120–220 ms", () => { + const t = tokens(); + for (const name of ["--t-exit", "--t-fast", "--t-enter", "--t-fold", "--t-bar"]) { + expect([name, t[name] !== undefined]).toEqual([name, true]); + expect(t[name]!).toBeGreaterThanOrEqual(120); + expect(t[name]!).toBeLessThanOrEqual(220); + } +}); + +test("every animation and transition duration is 0 or 120–220 ms", () => { + const t = tokens(); + const bad: string[] = []; + for (const [name, css] of sheets) { + for (const m of strip(css).matchAll(/(?:^|[;{\s])(animation|transition)(?:-duration)?\s*:\s*([^;}]+)/g)) { + const value = m[2]!; + if (/^\s*none/.test(value)) continue; + const durations: number[] = []; + for (const d of value.matchAll(/var\((--[\w-]+)\)|(? 220)) bad.push(`${name}: ${m[0].trim()}`); + } + } + expect(bad).toEqual([]); +}); + +test("keyframes animate only opacity and short translates", () => { + const bad: string[] = []; + let count = 0; + for (const [name, css] of sheets) { + for (const k of strip(css).matchAll(/@keyframes\s+([\w-]+)\s*\{((?:[^{}]*\{[^}]*\})*)\s*\}/g)) { + count++; + for (const d of k[2]!.matchAll(/([\w-]+)\s*:\s*([^;}]+)/g)) { + const [prop, value] = [d[1]!, d[2]!.trim()]; + if (prop === "opacity") continue; + if (prop === "translate") { + const px = [...value.matchAll(/(-?[\d.]+)px/g)].map((x) => Math.abs(Number(x[1]))); + if (px.every((n) => n <= 8) && !/%|em|vw|vh/.test(value)) continue; + } + bad.push(`${name} @keyframes ${k[1]}: ${prop}: ${value}`); + } + } + } + expect(count).toBeGreaterThan(0); + expect(bad).toEqual([]); +}); + +test("easing curves do not overshoot (no bounce)", () => { + const bad: string[] = []; + for (const [name, css] of sheets) { + for (const m of css.matchAll(/cubic-bezier\(([^)]*)\)/g)) { + const [, y1, , y2] = m[1]!.split(",").map(Number); + if (!(y1! >= 0 && y1! <= 1 && y2! >= 0 && y2! <= 1)) bad.push(`${name}: ${m[0]}`); + } + } + expect(bad).toEqual([]); +}); + +test("prefers-reduced-motion turns off every animation, transition and view transition", () => { + const css = strip(tokensCss); + const at = css.indexOf("@media (prefers-reduced-motion: reduce)"); + expect(at).toBeGreaterThan(-1); + const block = css.slice(at, css.indexOf("\n}", at)); + expect(block).toMatch(/\*,\s*\*::before,\s*\*::after/); + expect(block).toContain("animation: none !important"); + expect(block).toContain("transition: none !important"); + expect(block).toMatch(/::view-transition-group\(\*\)/); + expect(block).toMatch(/::view-transition-old\(\*\)/); + expect(block).toMatch(/::view-transition-new\(\*\)/); +}); + +test("view transitions never take pointer input and keep the header and sidebar still", () => { + const css = strip(tokensCss); + expect(css).toMatch(/::view-transition\s*\{[^}]*pointer-events:\s*none/); + expect(css).toMatch(/\.stc-top\s*\{[^}]*view-transition-name:\s*stc-top/); + expect(strip(learnerCss)).toMatch(/\.lr-side\s*\{[^}]*view-transition-name:\s*lr-side/); + expect(css).toMatch(/::view-transition-group\(stc-top\)[^{]*\{[^}]*animation:\s*none/); +}); From b1d17956f4ce4966cd7268747a07a4e90bd85508 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 20:32:57 +0100 Subject: [PATCH 03/17] feat(ui): view-transition helper and held-value hook Co-Authored-By: Claude Opus 5.5 --- packages/ui/src/index.ts | 1 + packages/ui/src/motion.ts | 55 ++++++++++++++++++++++++++++++++ packages/ui/test/motion.test.ts | 56 +++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 packages/ui/src/motion.ts create mode 100644 packages/ui/test/motion.test.ts diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index f884c61..8cd51c8 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,2 +1,3 @@ export { AppShell } from "./AppShell"; export { Pill, SegmentedToggle, ProgressBar, FloatingActionBar, CourseCover, coverFits, monogram } from "./primitives"; +export { reducedMotion, viewTransition, holdDecision, useHeldValue, type HoldDecision } from "./motion"; diff --git a/packages/ui/src/motion.ts b/packages/ui/src/motion.ts new file mode 100644 index 0000000..e8bae8f --- /dev/null +++ b/packages/ui/src/motion.ts @@ -0,0 +1,55 @@ +import { useEffect, useLayoutEffect, useState } from "react"; +import { flushSync } from "react-dom"; + +/** Whether the user asked for reduced motion (false outside a browser). */ +export function reducedMotion(): boolean { + return typeof window !== "undefined" && typeof window.matchMedia === "function" + && window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +type VtDocument = { startViewTransition?: (update: () => void) => unknown; visibilityState?: string }; + +/** + * Runs a React state update inside a view transition when the browser supports one and motion is allowed, and + * directly otherwise. The update is synchronous (flushSync), so the new state is captured at once and nothing + * waits on it. The look of the transition is in tokens.css. + */ +export function viewTransition(update: () => void): void { + const doc = (typeof document === "undefined" ? null : document) as VtDocument | null; + if (!doc || typeof doc.startViewTransition !== "function" || doc.visibilityState === "hidden" || reducedMotion()) { + update(); + return; + } + doc.startViewTransition(() => flushSync(update)); +} + +export type HoldDecision = "show-next" | "hold" | "swap" | "replace"; + +/** + * What `useHeldValue` does with the value on screen (`shown`) when the wanted one is `next` (null while loading): + * show it, keep the old one while loading ("hold"), swap with a transition (a different key) or replace it + * plainly (the same key reloaded, or an old value that may not be held, such as another course's lesson). + */ +export function holdDecision(shown: T | null, next: T | null, keyOf: (v: T) => string, holdable: (prev: T) => boolean = () => true): HoldDecision { + if (shown === null || shown === next) return "show-next"; + if (!holdable(shown)) return "replace"; + if (next === null) return "hold"; + return keyOf(shown) === keyOf(next) ? "replace" : "swap"; +} + +const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; + +/** + * Keeps the previous value on screen while the next one loads, then swaps to it inside a view transition, so a + * page never blanks between two steps. `stale` is true while the old value is shown. + */ +export function useHeldValue(next: T | null, keyOf: (v: T) => string, holdable?: (prev: T) => boolean): { value: T | null; stale: boolean } { + const [shown, setShown] = useState(next); + const decision = holdDecision(shown, next, keyOf, holdable); + useIsoLayoutEffect(() => { + if (decision === "swap") viewTransition(() => setShown(next)); + else if (shown !== next && decision !== "hold") setShown(next); + }, [next, decision]); + const value = decision === "hold" || decision === "swap" ? shown : next; + return { value, stale: value !== next }; +} diff --git a/packages/ui/test/motion.test.ts b/packages/ui/test/motion.test.ts new file mode 100644 index 0000000..a28bdd3 --- /dev/null +++ b/packages/ui/test/motion.test.ts @@ -0,0 +1,56 @@ +import { afterEach, expect, test } from "bun:test"; +import { holdDecision, reducedMotion, viewTransition } from "../src/motion"; + +const g = globalThis as Record; +afterEach(() => { delete g.document; delete g.window; }); + +function env({ reduce = false, vt = true, hidden = false } = {}) { + const calls: string[] = []; + g.window = { matchMedia: (q: string) => ({ matches: q.includes("prefers-reduced-motion: reduce") && reduce }) }; + g.document = { + visibilityState: hidden ? "hidden" : "visible", + ...(vt ? { startViewTransition: (cb: () => void) => { calls.push("vt"); cb(); return { finished: Promise.resolve() }; } } : {}), + }; + return calls; +} + +test("reducedMotion follows the media query and is false without a window", () => { + expect(reducedMotion()).toBe(false); + env({ reduce: true }); + expect(reducedMotion()).toBe(true); + env({ reduce: false }); + expect(reducedMotion()).toBe(false); +}); + +test("viewTransition runs the update inside startViewTransition when it can", () => { + const calls = env(); + viewTransition(() => calls.push("update")); + expect(calls).toEqual(["vt", "update"]); +}); + +test("viewTransition runs the update directly with reduced motion, without support, or in a hidden tab", () => { + for (const o of [{ reduce: true }, { vt: false }, { hidden: true }]) { + const calls = env(o); + viewTransition(() => calls.push("update")); + expect([o, calls]).toEqual([o, ["update"]]); + } + delete g.document; delete g.window; + const plain: string[] = []; + viewTransition(() => plain.push("update")); + expect(plain).toEqual(["update"]); +}); + +test("holdDecision: show the first value, hold while loading, swap to a new key, replace the same key", () => { + type L = { id: string; course: string }; + const key = (l: L) => l.id; + const sameCourse = (c: string) => (l: L) => l.course === c; + const a = { id: "a", course: "x" }, b = { id: "b", course: "x" }, a2 = { id: "a", course: "x" }; + expect(holdDecision(null, null, key)).toBe("show-next"); + expect(holdDecision(null, a, key)).toBe("show-next"); + expect(holdDecision(a, a, key)).toBe("show-next"); + expect(holdDecision(a, null, key, sameCourse("x"))).toBe("hold"); + expect(holdDecision(a, null, key, sameCourse("y"))).toBe("replace"); + expect(holdDecision(a, b, key)).toBe("swap"); + expect(holdDecision(a, a2, key)).toBe("replace"); + expect(holdDecision(a, b, key, sameCourse("y"))).toBe("replace"); +}); From 635fd66d0461649efacafb0f3d71e9e8064da042 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 20:33:44 +0100 Subject: [PATCH 04/17] feat(ui): scroll keeper with deferred settle Co-Authored-By: Claude Opus 5.5 --- packages/ui/src/index.ts | 1 + packages/ui/src/scroll.ts | 126 ++++++++++++++++++++++++++++++++ packages/ui/test/scroll.test.ts | 114 +++++++++++++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 packages/ui/src/scroll.ts create mode 100644 packages/ui/test/scroll.test.ts diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 8cd51c8..12597e5 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,3 +1,4 @@ export { AppShell } from "./AppShell"; export { Pill, SegmentedToggle, ProgressBar, FloatingActionBar, CourseCover, coverFits, monogram } from "./primitives"; export { reducedMotion, viewTransition, holdDecision, useHeldValue, type HoldDecision } from "./motion"; +export { createScrollKeeper, routeScroll, installRouteScroll, type ScrollEnv, type ScrollKeeper } from "./scroll"; diff --git a/packages/ui/src/scroll.ts b/packages/ui/src/scroll.ts new file mode 100644 index 0000000..4a310e2 --- /dev/null +++ b/packages/ui/src/scroll.ts @@ -0,0 +1,126 @@ +/** What the scroll keeper needs from the browser (a fake in tests). */ +export interface ScrollEnv { + getY(): number; + scrollTo(y: number): void; + maxY(): number; + /** Scrolls the element with this id into view; false when there is none. */ + scrollToId(id: string): boolean; + /** Runs `cb` on the next frame. */ + later(cb: () => void): void; + now(): number; + load(): string | null; + save(s: string): void; +} + +export interface ScrollKeeper { + /** Records the position for the history entry on screen. */ + onScroll(): void; + /** The shell calls this on every location change: top for PUSH/REPLACE, the saved position for POP. */ + routeChanged(key: string, pop: boolean, hash?: string): void; + /** A page that keeps the previous content on screen while the next loads calls this before the shell's routeChanged. */ + defer(): void; + /** ...and this once the new content is in place; it applies the deferred route scroll. No-op otherwise. */ + settle(): void; +} + +interface Route { key: string; pop: boolean; hash?: string } + +const KEEP = 60; +const RETRY_MS = 1000; + +/** Scroll positions per history entry, restored on back/forward even when the page renders its content late. */ +export function createScrollKeeper(env: ScrollEnv): ScrollKeeper { + const saved = new Map(); + try { + const raw = JSON.parse(env.load() ?? "[]") as unknown; + if (Array.isArray(raw)) for (const [k, y] of raw) if (typeof k === "string" && typeof y === "number") saved.set(k, y); + } catch { /* unreadable: start empty */ } + let current: string | null = null; + let deferNext = false; + let pending: Route | null = null; + let run = 0; + let restoring = false; + let persistQueued = false; + + const persist = () => { + if (persistQueued) return; + persistQueued = true; + env.later(() => { + persistQueued = false; + try { env.save(JSON.stringify([...saved].slice(-KEEP))); } catch { /* storage full or blocked */ } + }); + }; + + const apply = (r: Route) => { + current = r.key; + const id = ++run; + restoring = false; + if (r.hash && r.hash.length > 1) { + let target = r.hash.slice(1); + try { target = decodeURIComponent(target); } catch { /* keep raw */ } + if (env.scrollToId(target)) return; + } + const y = r.pop ? saved.get(r.key) ?? 0 : 0; + const start = env.now(); + const step = () => { + if (id !== run) return; + env.scrollTo(Math.min(y, env.maxY())); + restoring = env.maxY() < y && env.now() - start < RETRY_MS; + if (restoring) env.later(step); + }; + step(); + }; + + return { + onScroll() { + if (current === null || restoring) return; + saved.delete(current); + saved.set(current, env.getY()); + persist(); + }, + routeChanged(key, pop, hash) { + if (deferNext) { + deferNext = false; + run++; // a restore still retrying for the previous entry stops + restoring = false; + pending = { key, pop, hash }; + return; + } + pending = null; + apply({ key, pop, hash }); + }, + defer() { deferNext = true; }, + settle() { + if (!pending) return; + const r = pending; + pending = null; + apply(r); + }, + }; +} + +let instance: ScrollKeeper | null = null; + +/** The window-bound keeper shared by the shell and the pages. */ +export function routeScroll(): ScrollKeeper { + instance ??= createScrollKeeper({ + getY: () => window.scrollY, + scrollTo: (y) => window.scrollTo(0, y), + maxY: () => Math.max(0, document.documentElement.scrollHeight - window.innerHeight), + scrollToId: (id) => { const el = document.getElementById(id); el?.scrollIntoView({ block: "start" }); return !!el; }, + later: (cb) => { requestAnimationFrame(() => cb()); }, + now: () => performance.now(), + load: () => { try { return sessionStorage.getItem("stc.scroll"); } catch { return null; } }, + save: (s) => { try { sessionStorage.setItem("stc.scroll", s); } catch { /* private mode */ } }, + }); + return instance; +} + +/** Takes scroll restoration over from the browser and records positions. Returns a cleanup function. */ +export function installRouteScroll(): () => void { + const keeper = routeScroll(); + try { history.scrollRestoration = "manual"; } catch { /* unsupported */ } + const onScroll = () => keeper.onScroll(); + window.addEventListener("scroll", onScroll, { passive: true }); + return () => window.removeEventListener("scroll", onScroll); +} diff --git a/packages/ui/test/scroll.test.ts b/packages/ui/test/scroll.test.ts new file mode 100644 index 0000000..a8c94f6 --- /dev/null +++ b/packages/ui/test/scroll.test.ts @@ -0,0 +1,114 @@ +import { expect, test } from "bun:test"; +import { createScrollKeeper } from "../src/scroll"; + +function fakeEnv(opts: { max?: number; stored?: string | null; ids?: string[] } = {}) { + const e = { + y: 0, max: opts.max ?? 10_000, t: 0, stored: opts.stored ?? null as string | null, queue: [] as (() => void)[], intoView: [] as string[], + getY: () => e.y, + scrollTo: (y: number) => { e.y = Math.min(y, e.max); }, + maxY: () => e.max, + scrollToId: (id: string) => { if (!(opts.ids ?? []).includes(id)) return false; e.intoView.push(id); return true; }, + later: (cb: () => void) => { e.queue.push(cb); }, + now: () => e.t, + load: () => e.stored, + save: (s: string) => { e.stored = s; }, + flush() { const q = e.queue.splice(0); q.forEach((f) => f()); }, + }; + return e; +} + +test("PUSH goes to the top, POP restores the saved position of that entry", () => { + const env = fakeEnv(); + const k = createScrollKeeper(env); + k.routeChanged("a", false); + env.y = 640; k.onScroll(); + k.routeChanged("b", false); + expect(env.y).toBe(0); + env.y = 120; k.onScroll(); + k.routeChanged("a", true); + expect(env.y).toBe(640); + k.routeChanged("b", true); + expect(env.y).toBe(120); + k.routeChanged("c", true); // never seen: top + expect(env.y).toBe(0); +}); + +test("a hash scrolls its target into view", () => { + const env = fakeEnv({ ids: ["part 2"] }); + const k = createScrollKeeper(env); + env.y = 300; + k.routeChanged("a", false, "#part%202"); + expect(env.intoView).toEqual(["part 2"]); + k.routeChanged("b", false, "#missing"); + expect(env.y).toBe(0); +}); + +test("defer() keeps the old entry until settle(), which then applies the route scroll", () => { + const env = fakeEnv(); + const k = createScrollKeeper(env); + k.routeChanged("a", false); + env.y = 500; k.onScroll(); + k.defer(); + k.routeChanged("b", false); + expect(env.y).toBe(500); // the old content is still on screen + env.y = 520; k.onScroll(); // scrolling the old content still belongs to "a" + k.settle(); + expect(env.y).toBe(0); + k.routeChanged("a", true); + expect(env.y).toBe(520); + k.settle(); // nothing pending: no-op + expect(env.y).toBe(520); +}); + +test("a restore beyond the current page height retries until the page is tall enough", () => { + const env = fakeEnv(); + const k = createScrollKeeper(env); + k.routeChanged("a", false); + env.y = 2000; k.onScroll(); + k.routeChanged("b", false); + env.max = 300; // the old page is still loading + k.routeChanged("a", true); + expect(env.y).toBe(300); + env.flush(); + expect(env.y).toBe(300); + env.max = 5000; // content arrived + env.flush(); + expect(env.y).toBe(2000); + expect(env.queue.length).toBe(0); +}); + +test("the retry gives up after a second, and a newer route change cancels it", () => { + const env = fakeEnv(); + const k = createScrollKeeper(env); + k.routeChanged("a", false); + env.y = 2000; k.onScroll(); + k.routeChanged("b", false); + env.max = 300; + k.routeChanged("a", true); + k.onScroll(); // our own clamped scroll must not overwrite the saved 2000 + env.t = 1200; env.flush(); + expect(env.queue.length).toBe(0); + env.max = 5000; + k.routeChanged("a", true); + expect(env.y).toBe(2000); + + env.max = 300; + k.routeChanged("c", false); + k.routeChanged("a", true); + k.routeChanged("d", false); // cancels the restore of "a" + env.max = 5000; env.flush(); + expect(env.y).toBe(0); +}); + +test("positions survive a reload through storage", () => { + const env = fakeEnv(); + const k = createScrollKeeper(env); + k.routeChanged("a", false); + env.y = 777; k.onScroll(); env.flush(); + const env2 = fakeEnv({ stored: env.stored }); + createScrollKeeper(env2).routeChanged("a", true); + expect(env2.y).toBe(777); + const env3 = fakeEnv({ stored: "{not json" }); + createScrollKeeper(env3).routeChanged("a", true); + expect(env3.y).toBe(0); +}); From d607938630ee3de8fb1703a6fe0faa0f8b27fea0 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 20:36:45 +0100 Subject: [PATCH 05/17] feat(web): view transitions on route changes; content enter animation Co-Authored-By: Claude Opus 5.5 --- apps/web/src/main.tsx | 30 +++++- apps/web/src/motion.e2e.test.ts | 108 +++++++++++++++++++++ apps/web/src/nav.test.ts | 13 +++ apps/web/src/nav.ts | 13 +++ packages/feature-learner/src/web/styles.ts | 5 + 5 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/motion.e2e.test.ts diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 12bec53..684ef00 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,10 +1,10 @@ import { createRoot } from "react-dom/client"; -import { lazy, Suspense, useEffect, useState } from "react"; -import { createBrowserRouter, Link, NavLink, Outlet, RouterProvider, useLocation, type RouteObject } from "react-router-dom"; +import { lazy, Suspense, useEffect, useLayoutEffect, useState } from "react"; +import { createBrowserRouter, createPath, Link, NavLink, Outlet, RouterProvider, useLocation, useNavigationType, type RouteObject } from "react-router-dom"; import type { AuthUser } from "@stc/contracts"; -import { AppShell } from "@stc/ui"; +import { AppShell, installRouteScroll, reducedMotion, routeScroll } from "@stc/ui"; import { webFeatures } from "./features"; -import { visibleNav } from "./nav"; +import { changesPage, visibleNav } from "./nav"; function Home() { return

No features installed yet

; @@ -25,7 +25,18 @@ function useSessionUser(): AuthUser | null { return user; } +/** Scroll per history entry: top on a new page, the saved position on back/forward (a page may defer it). */ +function useRouteScroll() { + const loc = useLocation(); + const type = useNavigationType(); + useEffect(() => installRouteScroll(), []); + // A layout effect, so with a view transition the scroll is part of the captured new state. Pages' layout + // effects run before this one, so a page keeping its old content on screen can defer it first. + useLayoutEffect(() => { routeScroll().routeChanged(loc.key, type === "POP", loc.hash); }, [loc.key]); +} + function Layout() { + useRouteScroll(); // Links the user cannot use are hidden; the pages and APIs still enforce access themselves. const nav = visibleNav(webFeatures, useSessionUser()); const header = ( @@ -56,4 +67,15 @@ const router = createBrowserRouter([ { element: , children: [...featureRoutes, ...(featureRoutes.some((r) => r.path === "/") ? [] : [{ path: "*", element: }])] }, ]); +// Navigating to another kind of page (library, course, lesson, account, studio…) runs as a view transition: the old +// page dissolves while the new one's content rises in (styles in @stc/ui tokens.css). Steps within a page (lesson +// to lesson) are animated by the page itself. Reduced motion and browsers without the API navigate plainly. +const routePatterns = featureRoutes.map((r) => ({ path: r.path })); +const plainNavigate = router.navigate.bind(router); +router.navigate = ((to: Parameters[0], opts?: Parameters[1]) => { + const animate = typeof to !== "number" && to !== null && "startViewTransition" in document && !reducedMotion() + && changesPage(routePatterns, router.state.location.pathname, typeof to === "string" ? to : createPath(to)); + return plainNavigate(to as never, animate ? { ...opts, viewTransition: opts?.viewTransition ?? true, flushSync: opts?.flushSync ?? true } : opts); +}) as typeof router.navigate; + createRoot(document.getElementById("root")!).render(); diff --git a/apps/web/src/motion.e2e.test.ts b/apps/web/src/motion.e2e.test.ts new file mode 100644 index 0000000..8eafe6c --- /dev/null +++ b/apps/web/src/motion.e2e.test.ts @@ -0,0 +1,108 @@ +// Navigation motion (v1.1 package M), checked in headless Chrome against the real server (every feature, the +// production bundle) in a subprocess. A probe in the page counts document.startViewTransition calls and records +// animationstart / transitionstart events, so each test can say exactly what moved. Skipped without Chrome. +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { Chrome, chromePath } from "../../../packages/feature-learner/test/chrome"; + +const ROOT = resolve(import.meta.dir, "../../.."); +const run = chromePath ? describe : describe.skip; + +/** Injected before any page script: counts view transitions and records animation and transition starts. */ +const PROBE = ` + window.__m = { vt: 0, anims: [], trans: [] }; + const vt = Document.prototype.startViewTransition; + if (vt) Document.prototype.startViewTransition = function (...a) { window.__m.vt++; return vt.apply(this, a); }; + const who = (e) => { + const t = e.target; + const cls = t && t.getAttribute ? (t.getAttribute("class") || t.tagName.toLowerCase()) : "?"; + return cls + (e.pseudoElement || ""); + }; + document.addEventListener("animationstart", (e) => window.__m.anims.push(e.animationName + "@" + who(e)), true); + document.addEventListener("transitionstart", (e) => window.__m.trans.push(e.propertyName + "@" + who(e)), true); +`; + +let server: Bun.Subprocess, chrome: Chrome, base = "", dataDir = ""; + +async function freePort(): Promise { + const s = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("") }); + const p = s.port!; + await s.stop(true); + return p; +} + +export async function until(f: () => Promise, what: string, ms = 8000): Promise> { + const end = Date.now() + ms; + for (;;) { + const v = await f().catch(() => null); + if (v) return v as NonNullable; + if (Date.now() > end) throw new Error(`timed out waiting for ${what}`); + await Bun.sleep(25); + } +} + +const js = (s: string) => JSON.stringify(s); +const m = () => chrome.eval<{ vt: number; anims: string[]; trans: string[] }>("window.__m"); +const reset = () => chrome.eval("(window.__m.vt = 0, window.__m.anims = [], window.__m.trans = [], true)"); +const exists = (sel: string) => chrome.eval(`!!document.querySelector(${js(sel)})`); +const click = (sel: string) => chrome.eval(`document.querySelector(${js(sel)}).click(), true`); +const text = (sel: string) => chrome.eval(`document.querySelector(${js(sel)})?.textContent ?? null`); +const rect = (sel: string) => chrome.eval(`JSON.stringify(document.querySelector(${js(sel)}).getBoundingClientRect())`); + +/** Full page load of `path`; waits for `ready` to exist. */ +async function open(path: string, ready: string) { + await chrome.goto(`${base}${path}`, 50); + await until(() => chrome.eval(`location.pathname === ${js(path)} && !!document.querySelector(${js(ready)})`), `${path} ${ready}`); + // Let the first-render animations finish so they do not count for the step under test. + await Bun.sleep(400); + await reset(); +} + +async function setReducedMotion(on: boolean) { + await chrome.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-reduced-motion", value: on ? "reduce" : "no-preference" }] }); +} + +run("navigation motion (headless Chrome)", () => { + beforeAll(async () => { + const port = await freePort(); + dataDir = mkdtempSync(join(tmpdir(), "stc-motion-")); + base = `http://localhost:${port}`; + server = Bun.spawn([process.execPath, "apps/server/src/main.ts"], { + cwd: ROOT, stdout: "pipe", stderr: "pipe", + env: { + ...process.env, NODE_ENV: "production", STC_PORT: String(port), STC_SECRET: "motion-secret-0123456789abcdef0123456789", + STC_DATA_DIR: dataDir, STC_DATABASE_URL: `sqlite://${join(dataDir, "stc.db")}`, STC_COURSE_DIRS: join(ROOT, "fixtures/courses"), + STC_PUBLIC_URL: base, + }, + }); + await until(async () => (await fetch(`${base}/api/health`)).ok, "server", 30_000); + // The production bundle is built on the first request. + await until(async () => (await fetch(`${base}/`)).ok, "bundle", 30_000); + chrome = await Chrome.launch(); + await chrome.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false }); + await chrome.send("Page.addScriptToEvaluateOnNewDocument", { source: PROBE }); + await setReducedMotion(false); + }, 60_000); + + afterAll(async () => { + await chrome?.close(); + server?.kill(); + await server?.exited; + if (dataDir) rmSync(dataDir, { recursive: true, force: true }); + }); + + test("library to course: one view transition, the course content rises in", async () => { + await open("/", 'a[href="/c/sample"]'); + await click('a[href="/c/sample"]'); + // The URL changes at once: motion never holds navigation back. + expect(await chrome.eval("location.pathname")).toBe("/c/sample"); + await until(() => exists(".lr-main > article h1"), "course page"); + await Bun.sleep(300); + const r = await m(); + expect(r.vt).toBe(1); + expect(r.anims).toContain("stc-enter@col"); + expect(r.anims.some((a) => a.startsWith("stc-fade-out@") && a.includes("::view-transition-old(root)"))).toBe(true); + }, 30_000); +}); diff --git a/apps/web/src/nav.test.ts b/apps/web/src/nav.test.ts index 3e3a19f..5abf80d 100644 --- a/apps/web/src/nav.test.ts +++ b/apps/web/src/nav.test.ts @@ -19,3 +19,16 @@ test("features.ts imports only browser entries, by package name", () => { for (const s of specs) expect(s === "@stc/contracts" || /^@stc\/feature-[a-z]+\/web$/.test(s)).toBe(true); expect(specs.filter((s) => s.endsWith("/web")).length).toBe(4); }); + +test("changesPage: a view transition for a new kind of page, none between steps of the same page", async () => { + const { changesPage } = await import("./nav"); + const routes = webFeatures.flatMap((f) => f.routes.map((r) => ({ path: r.path }))); + expect(changesPage(routes, "/", "/c/sample")).toBe(true); + expect(changesPage(routes, "/c/sample", "/c/sample/m1-intro")).toBe(true); + expect(changesPage(routes, "/c/sample/m1-intro", "/c/sample/notes")).toBe(true); + expect(changesPage(routes, "/c/sample/m1-intro", "/c/sample/m1-code")).toBe(false); + expect(changesPage(routes, "/c/sample/m1-intro", "/c/other/x?t=3")).toBe(false); + expect(changesPage(routes, "/c/sample/m1-video", "/c/sample/m1-video?deck=d1&slide=2")).toBe(false); + expect(changesPage(routes, "/c/a%20b/x", "/c/a%20b/y")).toBe(false); + expect(changesPage(routes, "/c/sample", "/nowhere/at/all/deep")).toBe(true); +}); diff --git a/apps/web/src/nav.ts b/apps/web/src/nav.ts index 3364a07..39343fe 100644 --- a/apps/web/src/nav.ts +++ b/apps/web/src/nav.ts @@ -1,3 +1,4 @@ +import { matchRoutes } from "react-router-dom"; import type { AuthUser, Role, WebFeature, WebRoute } from "@stc/contracts"; /** Admin-area links default to the owner; header links default to everyone. `nav.roles` overrides both. */ @@ -13,3 +14,15 @@ export function visibleNav(features: WebFeature[], user: Pick }; return [...all.filter((r) => r.nav!.area === "header" && allowed(r)), ...all.filter((r) => r.nav!.area === "admin" && allowed(r))]; } + +/** + * Whether navigating from `fromPath` to `to` shows another kind of page (another route pattern), which gets a + * view transition. Steps within one page (lesson to lesson, a query change) do not: the page animates them itself. + */ +export function changesPage(routes: { path?: string }[], fromPath: string, to: string): boolean { + const pattern = (path: string) => matchRoutes(routes, path)?.at(-1)?.route.path ?? null; + let toPath: string; + try { toPath = new URL(to, `http://x${fromPath}`).pathname; } catch { return true; } + const a = pattern(fromPath), b = pattern(toPath); + return a === null || b === null || a !== b; +} diff --git a/packages/feature-learner/src/web/styles.ts b/packages/feature-learner/src/web/styles.ts index 82df7ce..da80a03 100644 --- a/packages/feature-learner/src/web/styles.ts +++ b/packages/feature-learner/src/web/styles.ts @@ -331,6 +331,11 @@ export const learnerCss = String.raw` .lr-toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 60; background: var(--raised); border: 1px solid var(--line2); border-radius: 18px; padding: 9px 16px; font-size: 13px; color: var(--text); } +/* ---------- motion ---------- + New content rises in when it arrives (a page, a lesson, the library); the header, sidebar and dock stay put. + With view transitions the old page dissolves over it (tokens.css); without them this is the whole effect. */ +.lr-main .lr-lesson, .lr-main > article, .stc-main > .lr > article, .lr-lib { animation: stc-enter var(--t-enter) var(--ease-out) backwards; } + /* ---------- responsive ---------- */ @media (max-width: 1100px) { .lr .ex { grid-template-columns: minmax(0, 1fr); } From 3988addaed3ea0be048607dede1cc12f251b7cf2 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 20:41:23 +0100 Subject: [PATCH 06/17] fix(learner): destroy decks through the pdf.js loading task pdf.js 6 documents have no destroy(); leaving a lesson whose deck had loaded threw in the effect cleanup and unmounted the app. Co-Authored-By: Claude Opus 5.5 --- .../feature-learner/src/web/lesson/DeckViewer.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/feature-learner/src/web/lesson/DeckViewer.tsx b/packages/feature-learner/src/web/lesson/DeckViewer.tsx index 11d367a..295554b 100644 --- a/packages/feature-learner/src/web/lesson/DeckViewer.tsx +++ b/packages/feature-learner/src/web/lesson/DeckViewer.tsx @@ -49,17 +49,21 @@ export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, titl const canvas = useRef(null); useEffect(() => { - let live = true, loaded: PdfDoc | null = null; + // pdf.js 6 documents have no destroy(); the loading task owns the document and the worker port. + let live = true, task: { destroy(): Promise } | null = null; setDoc(null); setError(null); setPages(0); loadPdfjs() - .then((pdfjs) => pdfjs.getDocument({ url: deck.url, withCredentials: true }).promise) + .then((pdfjs) => { + const t = pdfjs.getDocument({ url: deck.url, withCredentials: true }); + task = t; + return t.promise; + }) .then((d: PdfDoc) => { - loaded = d; - if (!live) { void d.destroy(); return; } + if (!live) return; setDoc(d); setPages(d.numPages); setPage((p) => Math.min(Math.max(1, p), d.numPages)); }) .catch((e: unknown) => live && setError(`This deck could not be opened. ${(e as Error).message ?? ""}`.trim())); - return () => { live = false; if (loaded) void loaded.destroy(); }; + return () => { live = false; void task?.destroy().catch(() => {}); }; }, [deck.url]); // Render the current page to fit the stage. From a953e351786d105762fe964de1fa1a22fb23b85b Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 20:42:15 +0100 Subject: [PATCH 07/17] feat(learner): hold the lesson until the next one loads, swap with a view transition The sidebar highlight moves at once; the lesson column keeps the current lesson until the next has loaded, then swaps inside a view transition, and the route scroll (top, or the saved position on back) is applied then. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/motion.e2e.test.ts | 49 ++++++++++++++++++- .../feature-learner/src/web/course/pages.tsx | 27 ++++++---- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/apps/web/src/motion.e2e.test.ts b/apps/web/src/motion.e2e.test.ts index 8eafe6c..c49151a 100644 --- a/apps/web/src/motion.e2e.test.ts +++ b/apps/web/src/motion.e2e.test.ts @@ -2,7 +2,7 @@ // production bundle) in a subprocess. A probe in the page counts document.startViewTransition calls and records // animationstart / transitionstart events, so each test can say exactly what moved. Skipped without Chrome. import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { appendFileSync, cpSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { Chrome, chromePath } from "../../../packages/feature-learner/test/chrome"; @@ -21,6 +21,14 @@ const PROBE = ` return cls + (e.pseudoElement || ""); }; document.addEventListener("animationstart", (e) => window.__m.anims.push(e.animationName + "@" + who(e)), true); + // window.__delay (ms) holds lesson requests back, to see what the page shows while the next lesson loads. + const f = window.fetch; + window.fetch = function (input, init) { + const u = String((input && input.url) || input); + const d = window.__delay || 0; + if (d && u.includes("/lessons/")) return new Promise((r) => setTimeout(r, d)).then(() => f.call(this, input, init)); + return f.call(this, input, init); + }; document.addEventListener("transitionstart", (e) => window.__m.trans.push(e.propertyName + "@" + who(e)), true); `; @@ -68,12 +76,17 @@ run("navigation motion (headless Chrome)", () => { beforeAll(async () => { const port = await freePort(); dataDir = mkdtempSync(join(tmpdir(), "stc-motion-")); + // The sample course, with lessons long enough to scroll. + const courses = join(dataDir, "courses"); + cpSync(join(ROOT, "fixtures/courses/sample"), join(courses, "sample"), { recursive: true }); + const filler = Array.from({ length: 40 }, (_, i) => `Paragraph ${i + 1}. ${"A line of reading text that wraps across the column. ".repeat(4)}`).join("\n\n"); + for (const f of ["01-intro.md", "03-code.md"]) appendFileSync(join(courses, "sample/lessons", f), `\n\n${filler}\n`); base = `http://localhost:${port}`; server = Bun.spawn([process.execPath, "apps/server/src/main.ts"], { cwd: ROOT, stdout: "pipe", stderr: "pipe", env: { ...process.env, NODE_ENV: "production", STC_PORT: String(port), STC_SECRET: "motion-secret-0123456789abcdef0123456789", - STC_DATA_DIR: dataDir, STC_DATABASE_URL: `sqlite://${join(dataDir, "stc.db")}`, STC_COURSE_DIRS: join(ROOT, "fixtures/courses"), + STC_DATA_DIR: dataDir, STC_DATABASE_URL: `sqlite://${join(dataDir, "stc.db")}`, STC_COURSE_DIRS: courses, STC_PUBLIC_URL: base, }, }); @@ -105,4 +118,36 @@ run("navigation motion (headless Chrome)", () => { expect(r.anims).toContain("stc-enter@col"); expect(r.anims.some((a) => a.startsWith("stc-fade-out@") && a.includes("::view-transition-old(root)"))).toBe(true); }, 30_000); + + test("lesson to lesson: the old lesson stays until the next is ready, then swaps; sidebar and header do not move", async () => { + await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title"); + const side = await rect(".lr-side"), top = await rect(".stc-top"); + await chrome.eval("window.scrollTo(0, 900)"); + await Bun.sleep(150); + expect(await chrome.eval("scrollY")).toBe(900); + await chrome.eval("window.__delay = 500"); + await reset(); + await click(".stc-dock .stc-pill--primary"); + expect(await chrome.eval("location.pathname")).toBe("/c/sample/m1-video"); + await Bun.sleep(150); + // The sidebar answers the click at once; the lesson column keeps the old lesson, where it was, until the next is in. + expect(await text('.lr-les[aria-current="page"]')).toContain("Watch the clip"); + expect(await text(".lr-main .lr-lesson h1.title")).toBe("Introduction"); + expect(await chrome.eval("scrollY")).toBe(900); + await until(async () => (await text(".lr-main .lr-lesson h1.title")) === "Watch the clip", "next lesson"); + await chrome.eval("window.__delay = 0"); + await Bun.sleep(300); + expect(await chrome.eval("scrollY")).toBe(0); + const r = await m(); + expect(r.vt).toBe(1); + expect(r.anims).toContain("stc-enter@lr lr-lesson"); + expect(await rect(".lr-side")).toBe(side); + expect(await rect(".stc-top")).toBe(top); + + // Back: the intro returns at the position it was left. + await chrome.eval("history.back()"); + await until(async () => (await text(".lr-main .lr-lesson h1.title")) === "Introduction", "previous lesson"); + await until(async () => (await chrome.eval("scrollY")) === 900, "restored scroll", 2000); + expect(await rect(".lr-side")).toBe(side); + }, 30_000); }); diff --git a/packages/feature-learner/src/web/course/pages.tsx b/packages/feature-learner/src/web/course/pages.tsx index 39eb4c9..9e2dbfb 100644 --- a/packages/feature-learner/src/web/course/pages.tsx +++ b/packages/feature-learner/src/web/course/pages.tsx @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useMemo, type CSSProperties } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, type CSSProperties } from "react"; import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; -import { CourseCover, FloatingActionBar, Pill, ProgressBar } from "@stc/ui"; +import { CourseCover, FloatingActionBar, Pill, ProgressBar, routeScroll, useHeldValue } from "@stc/ui"; import type { CoursePayload, LessonPayload, Note, ProgressState } from "../../shared/types"; import { api, ensureStyles, fmtMinutes, fmtTime, lessonHref, refreshLibrary, toast, useApi, useCourse, useLook, useMe, useTrack } from "../api"; import { Check, Next, Prev } from "../icons"; @@ -106,26 +106,34 @@ export function LessonPage() { const navigate = useNavigate(); const { course, error: courseError, reload } = useCourse(courseId); const lessonApi = useApi(`/api/courses/${encodeURIComponent(courseId)}/lessons/${encodeURIComponent(lessonId)}`); - const lesson = lessonApi.data; + const loaded = lessonApi.data && lessonApi.data.lesson.id === lessonId && lessonApi.data.courseId === courseId ? lessonApi.data : null; + // Lesson to lesson: the current lesson stays on screen while the next loads, then the two swap in a view + // transition, so the column never blanks. Only within one course; the sidebar highlight moves at once. + const held = useHeldValue(loaded, (l) => l.lesson.id, (l) => l.courseId === courseId && !lessonApi.error); + const lesson = held.value; const { user } = useMe(); const [look] = useLook(); const [track, setTrack] = useTrack(courseId, course?.tracks ?? lesson?.tracks ?? []); const q = useMemo(() => new URLSearchParams(loc.search), [loc.search]); const initialTime = q.has("t") ? Number(q.get("t")) : null; const initialSlide = q.has("slide") && q.has("deck") ? { deckId: q.get("deck")!, page: Number(q.get("slide")) } : null; - const progress = course?.progress[lessonId] ?? null; + const shownId = lesson?.lesson.id ?? lessonId; + const progress = course?.progress[shownId] ?? null; const signedIn = !!user; useEffect(() => { if (lesson) document.title = `${lesson.lesson.title.replace(/`/g, "")} – ${lesson.courseTitle}`; }, [lesson]); - useEffect(() => { window.scrollTo(0, 0); }, [lessonId]); + // While the old lesson is held, the shell's scroll for the new entry waits (this runs before the shell's + // layout effect) and is applied once the new lesson is on screen, inside the same transition. + useLayoutEffect(() => { if (held.stale) routeScroll().defer(); }, [loc.key]); + useLayoutEffect(() => { if (!held.stale || lessonApi.error) routeScroll().settle(); }, [lesson, held.stale, lessonApi.error]); const setState = useCallback(async (state: ProgressState, positionSeconds?: number) => { if (!signedIn) return; try { - await api(`/api/courses/${encodeURIComponent(courseId)}/progress/${encodeURIComponent(lessonId)}`, { method: "PUT", body: { state, positionSeconds } }); + await api(`/api/courses/${encodeURIComponent(courseId)}/progress/${encodeURIComponent(shownId)}`, { method: "PUT", body: { state, positionSeconds } }); if (state === "done" || progress?.state !== state) { await reload(); void refreshLibrary(); } } catch (e) { toast(`Progress was not saved: ${(e as Error).message}`); } - }, [courseId, lessonId, signedIn, progress?.state, reload]); + }, [courseId, shownId, signedIn, progress?.state, reload]); if (courseError && !course) return ; if (lessonApi.error && course) { @@ -135,7 +143,7 @@ export function LessonPage() { ); } - if (!course || !lesson || lesson.lesson.id !== lessonId) { + if (!course || !lesson) { return course ?
: null; } const isDone = progress?.state === "done"; @@ -144,6 +152,7 @@ export function LessonPage() { return ( { void reload(); void refreshLibrary(); }} initialTime={initialTime} initialSlide={initialSlide} @@ -153,7 +162,7 @@ export function LessonPage() { lesson.prev && navigate(lessonHref(courseId, lesson.prev.id))}> Previous lesson - void setState(isDone ? "not-started" : "done")}> {isDone ? "Done" : "Mark done"} From a83ac02a90c8d832e5d3a88f57e3122a1a6ed183 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 20:47:12 +0100 Subject: [PATCH 08/17] feat(learner): module lists and folds open with motion Co-Authored-By: Claude Opus 5.5 --- apps/web/src/motion.e2e.test.ts | 39 +++++++++++++++++++ .../src/web/shell/CourseFrame.tsx | 15 ++++--- packages/feature-learner/src/web/styles.ts | 8 ++++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/apps/web/src/motion.e2e.test.ts b/apps/web/src/motion.e2e.test.ts index c49151a..2560e90 100644 --- a/apps/web/src/motion.e2e.test.ts +++ b/apps/web/src/motion.e2e.test.ts @@ -59,6 +59,10 @@ const click = (sel: string) => chrome.eval(`document.querySelector(${js(sel)}).c const text = (sel: string) => chrome.eval(`document.querySelector(${js(sel)})?.textContent ?? null`); const rect = (sel: string) => chrome.eval(`JSON.stringify(document.querySelector(${js(sel)}).getBoundingClientRect())`); +/** The animations and transitions running now, as "name@class::pseudo" (some pseudo-elements send no events). */ +const running = () => chrome.eval(`document.getAnimations().map((a) => (a.transitionProperty || a.animationName) + "@" + (a.effect.target.getAttribute?.("class") || a.effect.target.tagName?.toLowerCase()) + (a.effect.pseudoElement || ""))`); +const afterFrame = "new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))"; + /** Full page load of `path`; waits for `ready` to exist. */ async function open(path: string, ready: string) { await chrome.goto(`${base}${path}`, 50); @@ -150,4 +154,39 @@ run("navigation motion (headless Chrome)", () => { await until(async () => (await chrome.eval("scrollY")) === 900, "restored scroll", 2000); expect(await rect(".lr-side")).toBe(side); }, 30_000); + + test("a module opened by the user rises in; lists do not animate on page load or lesson change", async () => { + await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title"); + await click('.lr-les[href="/c/sample/m1-code"]'); + await until(async () => (await text(".lr-main .lr-lesson h1.title")) === "Code along", "code lesson"); + await Bun.sleep(300); + expect((await m()).anims.filter((a) => a.includes("lr-lessons"))).toEqual([]); + await reset(); + const head = '[...document.querySelectorAll(".lr-mod-head")][1]'; + expect(await chrome.eval(`${head}.getAttribute("aria-expanded")`)).toBe("false"); + await chrome.eval(`${head}.click(), true`); + // Open at once: the list is there and focusable in the same task as the click. + expect(await chrome.eval(`${head}.parentElement.querySelectorAll(".lr-lessons a").length`)).toBe(2); + await Bun.sleep(300); + expect((await m()).anims).toContain("stc-enter@lr-lessons"); + }, 30_000); + + test("folds open and close by animating their height", async () => { + // Chrome animates ::details-content without listing it in getAnimations() or sending events, so sample its height. + const sample = (open: boolean) => chrome.eval(`(() => { + const d = document.querySelector("details.fold.transcript"); + d.querySelector("summary").click(); + if (d.open !== ${open}) return [-1]; + const out = [], t0 = performance.now(); + return new Promise((r) => { const f = () => { out.push(parseFloat(getComputedStyle(d, "::details-content").height)); if (performance.now() - t0 < 400) requestAnimationFrame(f); else r(out); }; f(); }); + })()`); + await open("/c/sample/m1-video", "details.fold.transcript"); + const opening = await sample(true); + const full = opening.at(-1)!; + expect(full).toBeGreaterThan(20); + expect(opening.some((h) => h > 1 && h < full - 1)).toBe(true); // passed through in-between heights + const closing = await sample(false); + expect(closing.some((h) => h > 1 && h < full - 1)).toBe(true); + expect(closing.at(-1)).toBe(0); + }, 30_000); }); diff --git a/packages/feature-learner/src/web/shell/CourseFrame.tsx b/packages/feature-learner/src/web/shell/CourseFrame.tsx index c3e472a..706e1cf 100644 --- a/packages/feature-learner/src/web/shell/CourseFrame.tsx +++ b/packages/feature-learner/src/web/shell/CourseFrame.tsx @@ -81,7 +81,12 @@ export function Outline({ course, currentLessonId }: { course: CoursePayload; cu .map((m) => ({ m, lessons: query ? m.lessons.filter((l) => fold(l.title).includes(query)) : m.lessons })) .filter(({ m, lessons }) => !query || lessons.length || fold(m.title).includes(query)), [course, query]); - const toggle = (id: string) => setOpenMods((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; }); + // Only a list the user opens rises in; lists open on page load or after a lesson change stay still. + const [opened, setOpened] = useState(null); + const toggle = (id: string) => { + if (!openMods.has(id)) setOpened(id); + setOpenMods((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; }); + }; const sourceLine = course.authors.length ? course.authors.join(", ") : course.tracks.length > 1 ? `Tracks: ${course.tracks.map((t) => t.label).join(", ")}` : course.pitch ?? ""; const errors = course.diagnostics.filter((d) => d.level === "error"); @@ -108,15 +113,15 @@ export function Outline({ course, currentLessonId }: { course: CoursePayload; cu
) : null} {filtered.map(({ m, lessons }) => ( - toggle(m.id)} /> + toggle(m.id)} /> ))} {query && filtered.length === 0 ?

No lessons match “{q.trim()}”.

: null} ); } -function ModuleBlock({ course, m, lessons, open, current, currentLessonId, onToggle }: { - course: CoursePayload; m: OutlineModule; lessons: OutlineLesson[]; open: boolean; current: boolean; currentLessonId: string | null; onToggle: () => void; +function ModuleBlock({ course, m, lessons, open, enter, current, currentLessonId, onToggle }: { + course: CoursePayload; m: OutlineModule; lessons: OutlineLesson[]; open: boolean; enter: boolean; current: boolean; currentLessonId: string | null; onToggle: () => void; }) { const d = m.lessons.filter((l) => course.progress[l.id]?.state === "done").length; const pct = m.lessons.length ? d / m.lessons.length : 0; @@ -131,7 +136,7 @@ function ModuleBlock({ course, m, lessons, open, current, currentLessonId, onTog {course.signedIn ? : } {open ? ( -
    +
      {lessons.map((l) => { const isDone = course.progress[l.id]?.state === "done"; const cls = ["lr-les", isDone && "done", m.locked && "locked"].filter(Boolean).join(" "); diff --git a/packages/feature-learner/src/web/styles.ts b/packages/feature-learner/src/web/styles.ts index da80a03..f9119ba 100644 --- a/packages/feature-learner/src/web/styles.ts +++ b/packages/feature-learner/src/web/styles.ts @@ -335,6 +335,14 @@ export const learnerCss = String.raw` New content rises in when it arrives (a page, a lesson, the library); the header, sidebar and dock stay put. With view transitions the old page dissolves over it (tokens.css); without them this is the whole effect. */ .lr-main .lr-lesson, .lr-main > article, .stc-main > .lr > article, .lr-lib { animation: stc-enter var(--t-enter) var(--ease-out) backwards; } +/* a module list the user opens */ +.lr-lessons[data-enter] { animation: stc-enter var(--t-enter) var(--ease-out) backwards; } +/* folds and hint tiers grow to their height where the browser can animate it, and open instantly elsewhere */ +@supports selector(::details-content) { + .lr details.fold { interpolate-size: allow-keywords; } + .lr details.fold::details-content { height: 0; opacity: 0; overflow: clip; transition: height var(--t-fold) var(--ease-out), opacity var(--t-fold) var(--ease-out), content-visibility var(--t-fold) allow-discrete; } + .lr details.fold[open]::details-content { height: auto; opacity: 1; } +} /* ---------- responsive ---------- */ @media (max-width: 1100px) { From d5e836cc79d80fd5c38e8366df235132b216ea50 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 20:50:54 +0100 Subject: [PATCH 09/17] feat(learner): cross-fade deck slides The deck viewer draws the next slide into a second canvas and fades it in over the current one, 4 px from the direction of travel; the stage never blanks while pdf.js draws. A newer draw into a canvas cancels the older one. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/motion.e2e.test.ts | 35 ++++++++ .../src/web/lesson/DeckViewer.tsx | 86 ++++++++++++++----- packages/feature-learner/src/web/styles.ts | 7 ++ 3 files changed, 105 insertions(+), 23 deletions(-) diff --git a/apps/web/src/motion.e2e.test.ts b/apps/web/src/motion.e2e.test.ts index 2560e90..4e37173 100644 --- a/apps/web/src/motion.e2e.test.ts +++ b/apps/web/src/motion.e2e.test.ts @@ -189,4 +189,39 @@ run("navigation motion (headless Chrome)", () => { expect(closing.some((h) => h > 1 && h < full - 1)).toBe(true); expect(closing.at(-1)).toBe(0); }, 30_000); + + test("slides cross-fade in the direction of travel and the stage never blanks", async () => { + await open("/c/sample/m1-video", ".stage canvas[data-front]"); + await until(() => chrome.eval(`document.querySelector(".deckbar .count").textContent.trim() === "1 / 3"`), "deck loaded"); + // Next slide; then sample every frame: the animations running and the most opaque canvas. + const frames = await chrome.eval<{ anims: string[]; maxOpacity: number }[]>(`(() => { + document.querySelector('.deckbar [aria-label="Next slide"]').click(); + const out = [], t0 = performance.now(); + return new Promise((r) => { const f = () => { + const cs = [...document.querySelectorAll(".stage canvas")]; + out.push({ anims: document.getAnimations().map((a) => a.animationName).filter(Boolean), maxOpacity: Math.max(...cs.map((c) => Number(getComputedStyle(c).opacity))) }); + if (performance.now() - t0 < 600) requestAnimationFrame(f); else r(out); + }; f(); }); + })()`); + const names = new Set(frames.flatMap((f) => f.anims)); + expect(names.has("stc-slide-next")).toBe(true); + expect(names.has("stc-hold-out")).toBe(true); + expect(frames.every((f) => f.maxOpacity === 1)).toBe(true); + expect(await text(".deckbar .count")).toBe("2 / 3"); + expect(await chrome.eval(`document.querySelectorAll('.stage canvas[role="img"]').length`)).toBe(1); + expect(await chrome.eval(`document.querySelector('.stage canvas[data-front]').getAttribute("aria-label")`)).toBe("Slide 2 of 3"); + + // The arrow keys still turn slides, backwards with the mirrored motion. + await reset(); + await chrome.send("Input.dispatchKeyEvent", { type: "keyDown", key: "ArrowLeft", code: "ArrowLeft", windowsVirtualKeyCode: 37 }); + await chrome.send("Input.dispatchKeyEvent", { type: "keyUp", key: "ArrowLeft", code: "ArrowLeft", windowsVirtualKeyCode: 37 }); + await until(async () => (await text(".deckbar .count")) === "1 / 3", "previous slide"); + await Bun.sleep(300); + expect((await m()).anims.some((a) => a.startsWith("stc-slide-prev@"))).toBe(true); + + // Two quick clicks: the second supersedes the first draw and the stage ends on the last slide. + await chrome.eval(`(() => { const b = document.querySelector('.deckbar [aria-label="Next slide"]'); b.click(); setTimeout(() => b.click(), 5); return true; })()`); + await until(() => chrome.eval(`document.querySelector(".stage canvas[data-front]").dataset.page === "3"`), "slide 3 drawn"); + expect(await text(".deckbar .count")).toBe("3 / 3"); + }, 30_000); }); diff --git a/packages/feature-learner/src/web/lesson/DeckViewer.tsx b/packages/feature-learner/src/web/lesson/DeckViewer.tsx index 295554b..a644837 100644 --- a/packages/feature-learner/src/web/lesson/DeckViewer.tsx +++ b/packages/feature-learner/src/web/lesson/DeckViewer.tsx @@ -16,20 +16,38 @@ function loadPdfjs() { return pdfjsPromise; } -/** Renders `page` of `doc` into `canvas` at `cssWidth` pixels wide (scaled for the device pixel ratio). */ -async function renderPage(doc: PdfDoc, page: number, canvas: HTMLCanvasElement, cssWidth: number, cssMaxHeight?: number) { - const p = await doc.getPage(page); - const base = p.getViewport({ scale: 1 }); - let scale = cssWidth / base.width; - if (cssMaxHeight && base.height * scale > cssMaxHeight) scale = cssMaxHeight / base.height; - const dpr = Math.min(window.devicePixelRatio || 1, 2); - const vp = p.getViewport({ scale: scale * dpr }); - canvas.width = Math.floor(vp.width); - canvas.height = Math.floor(vp.height); - canvas.style.width = `${Math.floor(vp.width / dpr)}px`; - canvas.style.height = `${Math.floor(vp.height / dpr)}px`; - const task = p.render({ canvasContext: canvas.getContext("2d")!, viewport: vp, canvas }); - await task.promise; +const jobs = new WeakMap }>(); +const superseded = () => new Error("superseded"); + +/** + * Renders `page` of `doc` into `canvas` at `cssWidth` pixels wide (scaled for the device pixel ratio). A newer + * render into the same canvas cancels this one (its promise rejects), so pdf.js never draws one canvas twice at once. + */ +function renderPage(doc: PdfDoc, page: number, canvas: HTMLCanvasElement, cssWidth: number, cssMaxHeight?: number): Promise { + const prev = jobs.get(canvas); + prev?.task?.cancel(); + const job: { task?: { cancel(): void }; done: Promise } = { done: Promise.resolve() }; + const run = (async () => { + await prev?.done; + if (jobs.get(canvas) !== job) throw superseded(); + const p = await doc.getPage(page); + if (jobs.get(canvas) !== job) throw superseded(); + const base = p.getViewport({ scale: 1 }); + let scale = cssWidth / base.width; + if (cssMaxHeight && base.height * scale > cssMaxHeight) scale = cssMaxHeight / base.height; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const vp = p.getViewport({ scale: scale * dpr }); + canvas.width = Math.floor(vp.width); + canvas.height = Math.floor(vp.height); + canvas.style.width = `${Math.floor(vp.width / dpr)}px`; + canvas.style.height = `${Math.floor(vp.height / dpr)}px`; + const task = p.render({ canvasContext: canvas.getContext("2d")!, viewport: vp, canvas }); + job.task = task; + await task.promise; + })(); + job.done = run.catch(() => {}); + jobs.set(canvas, job); + return run; } const isTyping = (t: EventTarget | null) => !!(t as Element | null)?.closest?.("input, textarea, select, video, audio, [contenteditable], [role='slider']"); @@ -46,12 +64,18 @@ export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, titl const [page, setPage] = useState(initialPage); const [error, setError] = useState(null); const stage = useRef(null); - const canvas = useRef(null); + // Two stacked canvases: the next slide is drawn into the hidden one, which then fades in over the current one + // (shifted 4 px from the direction of travel), so the stage never blanks while pdf.js draws. + const canvasA = useRef(null); + const canvasB = useRef(null); + const [view, setView] = useState<{ front: 0 | 1; page: number; dir: "next" | "prev" | null }>({ front: 0, page: 0, dir: null }); + const viewRef = useRef(view); + viewRef.current = view; useEffect(() => { // pdf.js 6 documents have no destroy(); the loading task owns the document and the worker port. let live = true, task: { destroy(): Promise } | null = null; - setDoc(null); setError(null); setPages(0); + setDoc(null); setError(null); setPages(0); setView({ front: 0, page: 0, dir: null }); loadPdfjs() .then((pdfjs) => { const t = pdfjs.getDocument({ url: deck.url, withCredentials: true }); @@ -66,16 +90,23 @@ export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, titl return () => { live = false; void task?.destroy().catch(() => {}); }; }, [deck.url]); - // Render the current page to fit the stage. + // Draw the current page to fit the stage: first into the front canvas, later pages into the back one, then flip. useEffect(() => { - if (!doc || !canvas.current || !stage.current) return; + if (!doc || !canvasA.current || !canvasB.current || !stage.current) return; let live = true; - const draw = () => { + const canvases = [canvasA.current, canvasB.current] as const; + const draw = (into: 0 | 1, then?: () => void) => { const r = stage.current!.getBoundingClientRect(); - if (live) renderPage(doc, page, canvas.current!, r.width, r.height).catch(() => {}); + renderPage(doc, page, canvases[into], r.width, r.height).then(() => { if (live) then?.(); }, () => {}); }; - draw(); - const ro = new ResizeObserver(() => draw()); + const cur = viewRef.current; + if (cur.page === 0 || cur.page === page) draw(cur.front, () => setView((v) => ({ ...v, page }))); + else { + const back = (1 - cur.front) as 0 | 1; + draw(back, () => setView({ front: back, page, dir: page > cur.page ? "next" : "prev" })); + } + let first = true; // ResizeObserver reports once on observe; that size is already drawn + const ro = new ResizeObserver(() => { if (first) { first = false; return; } draw(viewRef.current.front); }); ro.observe(stage.current); return () => { live = false; ro.disconnect(); }; }, [doc, page]); @@ -109,7 +140,16 @@ export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, titl
      {error ? {error} Open the PDF : null} {!error && !doc ? Loading slides… : null} -
      go(page - 1)}> diff --git a/packages/feature-learner/src/web/styles.ts b/packages/feature-learner/src/web/styles.ts index f9119ba..6eadd37 100644 --- a/packages/feature-learner/src/web/styles.ts +++ b/packages/feature-learner/src/web/styles.ts @@ -335,6 +335,13 @@ export const learnerCss = String.raw` New content rises in when it arrives (a page, a lesson, the library); the header, sidebar and dock stay put. With view transitions the old page dissolves over it (tokens.css); without them this is the whole effect. */ .lr-main .lr-lesson, .lr-main > article, .stc-main > .lr > article, .lr-lib { animation: stc-enter var(--t-enter) var(--ease-out) backwards; } +/* deck slides: the new slide fades in over the old one, 4 px from the side it comes from */ +.lr .stage canvas { grid-area: 1 / 1; } +.lr .stage canvas:not([data-front]) { opacity: 0; } +.lr .stage canvas[data-front] { position: relative; z-index: 1; } +.lr .stage canvas[data-front][data-dir="next"] { animation: stc-slide-next var(--t-enter) var(--ease-out) backwards; } +.lr .stage canvas[data-front][data-dir="prev"] { animation: stc-slide-prev var(--t-enter) var(--ease-out) backwards; } +.lr .stage canvas[data-leaving] { animation: stc-hold-out var(--t-enter) linear both; } /* a module list the user opens */ .lr-lessons[data-enter] { animation: stc-enter var(--t-enter) var(--ease-out) backwards; } /* folds and hint tiers grow to their height where the browser can animate it, and open instantly elsewhere */ From 370d381ff45e6700f9db7fcb64d3c16dca16b02f Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 20:53:34 +0100 Subject: [PATCH 10/17] feat(learner): progress, toast and menu motion Co-Authored-By: Claude Opus 5.5 --- apps/web/src/motion.e2e.test.ts | 25 +++++++++++++++++++ packages/feature-learner/src/web/api.ts | 17 ++++++++++--- .../src/web/shell/HeaderExtras.tsx | 2 +- packages/feature-learner/src/web/styles.ts | 5 ++++ 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/apps/web/src/motion.e2e.test.ts b/apps/web/src/motion.e2e.test.ts index 4e37173..ff37c10 100644 --- a/apps/web/src/motion.e2e.test.ts +++ b/apps/web/src/motion.e2e.test.ts @@ -224,4 +224,29 @@ run("navigation motion (headless Chrome)", () => { await until(() => chrome.eval(`document.querySelector(".stage canvas[data-front]").dataset.page === "3"`), "slide 3 drawn"); expect(await text(".deckbar .count")).toBe("3 / 3"); }, 30_000); + + test("progress fills, the toast and the course menu answer with motion; Escape still closes at once", async () => { + await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title"); + const status = await chrome.eval(`fetch("/api/auth/sign-up/email", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, + body: JSON.stringify({ email: "motion@example.org", name: "Motion", password: "correct horse battery" }) }).then((r) => r.status)`); + expect(status).toBe(200); + await open("/c/sample/m1-intro", ".stc-dock button[aria-pressed]:not([disabled])"); + await click(".stc-dock button[aria-pressed]"); + await until(async () => (await m()).trans.includes("width@i"), "progress fill transition"); + + // The toast rises in, then fades out before it is removed. + await open("/c/sample/m2-ex-add", ".stc-seg button"); + await chrome.eval(`[...document.querySelectorAll(".stc-seg button")].find((b) => b.textContent === "Reading").click(), true`); + await until(async () => (await m()).anims.includes("stc-enter@lr lr-toast"), "toast enter"); + await until(async () => (await m()).anims.includes("stc-fade-out@lr lr-toast is-leaving"), "toast leave", 5000); + await until(async () => !(await exists(".lr-toast")), "toast removed", 2000); + + // The course menu drops in; Escape closes it in the same task. + await reset(); + await click(".lr-switch-btn"); + await until(async () => (await m()).anims.includes("stc-drop@lr-menu"), "menu enter"); + await chrome.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); + await chrome.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); + expect(await exists(".lr-menu")).toBe(false); + }, 40_000); }); diff --git a/packages/feature-learner/src/web/api.ts b/packages/feature-learner/src/web/api.ts index 034b514..1b5dc13 100644 --- a/packages/feature-learner/src/web/api.ts +++ b/packages/feature-learner/src/web/api.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import type { AuthUser } from "@stc/contracts"; +import { reducedMotion } from "@stc/ui"; import type { CoursePayload, LibraryPayload } from "../shared/types"; import { learnerCss } from "./styles"; @@ -154,14 +155,22 @@ export function clearCourseCache(): void { courseCache.clear(); courseVersion.se /* ---------- toast ---------- */ -const toastStore = store(null); +export interface ToastState { text: string; leaving: boolean } +const toastStore = store(null); let toastTimer: ReturnType | undefined; +/** How long the toast takes to fade out (--t-exit). */ +const TOAST_EXIT_MS = 120; export function toast(msg: string): void { - toastStore.set(msg); + toastStore.set({ text: msg, leaving: false }); clearTimeout(toastTimer); - toastTimer = setTimeout(() => toastStore.set(null), 2600); + toastTimer = setTimeout(() => { + const t = toastStore.get(); + if (!t || reducedMotion()) { toastStore.set(null); return; } + toastStore.set({ ...t, leaving: true }); + toastTimer = setTimeout(() => toastStore.set(null), TOAST_EXIT_MS); + }, 2600); } -export function useToast(): string | null { return useStore(toastStore); } +export function useToast(): ToastState | null { return useStore(toastStore); } /* ---------- formatting ---------- */ diff --git a/packages/feature-learner/src/web/shell/HeaderExtras.tsx b/packages/feature-learner/src/web/shell/HeaderExtras.tsx index f0cdc9a..b90e7e6 100644 --- a/packages/feature-learner/src/web/shell/HeaderExtras.tsx +++ b/packages/feature-learner/src/web/shell/HeaderExtras.tsx @@ -37,7 +37,7 @@ export function HeaderExtras({ course, lookMode, outline }: {
      , host, )} - {msg ?
      {msg}
      : null} + {msg ?
      {msg.text}
      : null} ); } diff --git a/packages/feature-learner/src/web/styles.ts b/packages/feature-learner/src/web/styles.ts index 6eadd37..d1861ca 100644 --- a/packages/feature-learner/src/web/styles.ts +++ b/packages/feature-learner/src/web/styles.ts @@ -342,6 +342,11 @@ export const learnerCss = String.raw` .lr .stage canvas[data-front][data-dir="next"] { animation: stc-slide-next var(--t-enter) var(--ease-out) backwards; } .lr .stage canvas[data-front][data-dir="prev"] { animation: stc-slide-prev var(--t-enter) var(--ease-out) backwards; } .lr .stage canvas[data-leaving] { animation: stc-hold-out var(--t-enter) linear both; } +/* overlays answer the action that opened them; closing is instant except for the toast's short fade */ +.lr-menu { animation: stc-drop var(--t-fast) var(--ease-out) backwards; } +.lr-shell.side-open .lr-scrim { animation: stc-fade-in var(--t-fast) var(--ease-out) backwards; } +.lr-toast { animation: stc-enter var(--t-enter) var(--ease-out) backwards; } +.lr-toast.is-leaving { animation: stc-fade-out var(--t-exit) var(--ease-out) both; } /* a module list the user opens */ .lr-lessons[data-enter] { animation: stc-enter var(--t-enter) var(--ease-out) backwards; } /* folds and hint tiers grow to their height where the browser can animate it, and open instantly elsewhere */ From 6bb768130601e1caacf8fe151d71386c30e36954 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 20:55:32 +0100 Subject: [PATCH 11/17] test(web): reduced motion disables all motion Also turns off the fold height transition, which the universal selector does not reach (::details-content). Co-Authored-By: Claude Opus 5.5 --- apps/web/src/motion.e2e.test.ts | 70 +++++++++++++++++++++++++++++ packages/ui/src/tokens.css | 2 + packages/ui/test/motion-css.test.ts | 2 + 3 files changed, 74 insertions(+) diff --git a/apps/web/src/motion.e2e.test.ts b/apps/web/src/motion.e2e.test.ts index ff37c10..7c0ed4a 100644 --- a/apps/web/src/motion.e2e.test.ts +++ b/apps/web/src/motion.e2e.test.ts @@ -249,4 +249,74 @@ run("navigation motion (headless Chrome)", () => { await chrome.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); expect(await exists(".lr-menu")).toBe(false); }, 40_000); + + test("prefers-reduced-motion: no view transition, animation or transition anywhere, and everything still works", async () => { + await setReducedMotion(true); + const still = async (what: string) => { + await chrome.eval(afterFrame); + await Bun.sleep(250); + const r = await m(); + expect([what, r.vt, r.anims, r.trans, await running()]).toEqual([what, 0, [], [], []]); + await reset(); + }; + try { + // library to course + await open("/", 'a[href="/c/sample"]'); + await click('a[href="/c/sample"]'); + await until(() => exists(".lr-main > article h1"), "course page"); + await still("library to course"); + + // course to lesson, then Next: the old lesson is still held while the next loads, then swapped plainly + await click('.lr-les[href="/c/sample/m1-intro"]'); + await until(async () => (await text(".lr-main .lr-lesson h1.title")) === "Introduction", "intro"); + await still("course to lesson"); + await chrome.eval("window.scrollTo(0, 900)"); + await chrome.eval("window.__delay = 400"); + await click(".stc-dock .stc-pill--primary"); + await Bun.sleep(120); + expect(await text(".lr-main .lr-lesson h1.title")).toBe("Introduction"); + await until(async () => (await text(".lr-main .lr-lesson h1.title")) === "Watch the clip", "next lesson"); + await chrome.eval("window.__delay = 0"); + expect(await chrome.eval("scrollY")).toBe(0); + await still("lesson next"); + + // module open + await chrome.eval(`[...document.querySelectorAll(".lr-mod-head")][1].click(), true`); + await still("module open"); + + // fold: straight from closed to open + const heights = await chrome.eval(`(() => { + const d = document.querySelector("details.fold.transcript"); + d.querySelector("summary").click(); + const out = [], t0 = performance.now(); + return new Promise((r) => { const f = () => { out.push(parseFloat(getComputedStyle(d, "::details-content").height)); if (performance.now() - t0 < 300) requestAnimationFrame(f); else r(out); }; f(); }); + })()`); + expect(new Set(heights).size).toBe(1); + await still("fold"); + + // slide change: flips without animating + await until(() => chrome.eval(`document.querySelector(".stage canvas[data-front]")?.dataset.page === "1"`), "deck"); + await click('.deckbar [aria-label="Next slide"]'); + await until(() => chrome.eval(`document.querySelector(".stage canvas[data-front]").dataset.page === "2"`), "slide 2"); + await still("slide"); + + // mark done (progress fills), menu, toast + await click(".stc-dock button[aria-pressed]"); + await Bun.sleep(400); + await still("progress"); + await click(".lr-switch-btn"); + await until(() => exists(".lr-menu"), "menu"); + await still("menu"); + await chrome.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); + await chrome.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 }); + await open("/c/sample/m2-ex-add", ".stc-seg button"); + await chrome.eval(`[...document.querySelectorAll(".stc-seg button")].find((b) => b.textContent === "Reading").click(), true`); + await until(() => exists(".lr-toast"), "toast"); + await still("toast"); + await until(async () => !(await exists(".lr-toast")), "toast removed", 4000); + expect((await m()).anims).toEqual([]); + } finally { + await setReducedMotion(false); + } + }, 60_000); }); diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index 398b585..2ef20d3 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -97,4 +97,6 @@ code, pre { font-family: var(--mono); } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; } + /* separate rule: browsers without ::details-content drop only this one */ + *::details-content { transition: none !important; } } diff --git a/packages/ui/test/motion-css.test.ts b/packages/ui/test/motion-css.test.ts index 8da11d1..07eefc5 100644 --- a/packages/ui/test/motion-css.test.ts +++ b/packages/ui/test/motion-css.test.ts @@ -88,6 +88,8 @@ test("prefers-reduced-motion turns off every animation, transition and view tran expect(block).toMatch(/::view-transition-group\(\*\)/); expect(block).toMatch(/::view-transition-old\(\*\)/); expect(block).toMatch(/::view-transition-new\(\*\)/); + // `*` does not reach ::details-content (fold heights), so it has its own rule. + expect(block).toMatch(/\*::details-content\s*\{\s*transition:\s*none !important/); }); test("view transitions never take pointer input and keep the header and sidebar still", () => { From 57fe83f5de27ebcfdde6c9c73abd9910478d44bd Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 20:59:50 +0100 Subject: [PATCH 12/17] chore: motion frame capture script Co-Authored-By: Claude Opus 5.5 --- scripts/motion-frames.ts | 99 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 scripts/motion-frames.ts diff --git a/scripts/motion-frames.ts b/scripts/motion-frames.ts new file mode 100644 index 0000000..25b8783 --- /dev/null +++ b/scripts/motion-frames.ts @@ -0,0 +1,99 @@ +// Captures before / mid / after frames of each navigation motion from a running server, for visual review. +// Animations are slowed 10x through the DevTools Animation domain (view transitions included), so a screenshot +// taken a few hundred ms in lands mid-transition. A second pass with prefers-reduced-motion shows the same +// moment with no motion. Uses the sample course; sign-up creates a throwaway account on that server. +// +// bun scripts/motion-frames.ts http://localhost:3491 /tmp/frames +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { Chrome } from "../packages/feature-learner/test/chrome"; + +const [base = "http://localhost:3491", out = "motion-frames"] = process.argv.slice(2); +const SLOW = 0.1; +mkdirSync(out, { recursive: true }); +let dir = out; + +const chrome = await Chrome.launch(); +await chrome.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false }); +await chrome.send("Animation.enable"); + +const js = (s: string) => JSON.stringify(s); +async function until(cond: string, ms = 8000) { + const end = Date.now() + ms; + while (!(await chrome.eval(cond).catch(() => false))) { + if (Date.now() > end) throw new Error(`timed out: ${cond}`); + await Bun.sleep(30); + } +} +async function shot(name: string) { + const r = await chrome.send("Page.captureScreenshot", { format: "png" }); + writeFileSync(join(dir, `${name}.png`), Buffer.from(r.result.data, "base64")); +} +async function open(path: string, ready: string) { + await chrome.goto(`${base}${path}`, 100); + await until(`location.pathname === ${js(path)} && !!document.querySelector(${js(ready)})`); + await Bun.sleep(500); +} +/** Runs `action` (page JS), then captures frames at the given real-time offsets (ms). */ +async function frames(name: string, action: string, at: number[]) { + await shot(`${name}-0-before`); + const t0 = Date.now(); + await chrome.eval(`(${action}, true)`); + for (const [i, t] of at.entries()) { + await Bun.sleep(Math.max(0, t - (Date.now() - t0))); + await shot(`${name}-${i + 1}-${t}ms`); + } +} + +const flows: [string, () => Promise][] = [ + ["01-library-to-course", async () => { + await open("/", 'a[href="/c/sample"]'); + await frames("01-library-to-course", `document.querySelector('a[href="/c/sample"]').click()`, [300, 700, 2500]); + }], + ["02-course-to-lesson", async () => { + await open("/c/sample", ".lr-main > article h1"); + await frames("02-course-to-lesson", `document.querySelector('.lr-les[href="/c/sample/m1-code"]').click()`, [300, 700, 2500]); + }], + ["03-lesson-next", async () => { + await open("/c/sample/m1-code", ".lr-main .lr-lesson h1.title"); + await frames("03-lesson-next", `document.querySelector(".stc-dock .stc-pill--primary").click()`, [300, 700, 2500]); + }], + ["04-module-open", async () => { + await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title"); + await frames("04-module-open", `[...document.querySelectorAll(".lr-mod-head")][1].click()`, [500, 2500]); + }], + ["05-fold-open", async () => { + await open("/c/sample/m1-video", "details.fold.transcript"); + await chrome.eval(`document.querySelector("details.fold.transcript").scrollIntoView({ block: "center" })`); + // ::details-content transitions are not slowed by the Animation domain; sample real time instead. + await frames("05-fold-open", `document.querySelector("details.fold.transcript > summary").click()`, [80, 140, 1000]); + }], + ["06-slide-next", async () => { + await open("/c/sample/m1-video", '.stage canvas[data-page="1"]'); + await chrome.eval(`document.querySelector(".deck").scrollIntoView({ block: "center" })`); + await Bun.sleep(300); + await frames("06-slide-next", `document.querySelector('.deckbar [aria-label="Next slide"]').click()`, [600, 2500]); + }], + ["07-menu", async () => { + await open("/c/sample/m1-intro", ".lr-switch-btn"); + await frames("07-menu", `document.querySelector(".lr-switch-btn").click()`, [500, 2000]); + }], + ["08-toast", async () => { + await open("/c/sample/m2-ex-add", ".stc-seg button"); + await frames("08-toast", `[...document.querySelectorAll(".stc-seg button")].find((b) => b.textContent === "Reading").click()`, [500, 2000]); + }], +]; + +try { + await chrome.send("Animation.setPlaybackRate", { playbackRate: SLOW }); + for (const [, run] of flows) await run(); + // The same moments with reduced motion: the end state at once. + await chrome.send("Animation.setPlaybackRate", { playbackRate: 1 }); + await chrome.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-reduced-motion", value: "reduce" }] }); + dir = join(out, "reduced"); + mkdirSync(dir, { recursive: true }); + for (const [name, run] of flows) await run().catch((e) => console.error(name, e)); +} finally { + await chrome.close(); +} +console.log(`frames written to ${out}`); From 89ff72cc284318a54fade912db3186c2941fa691 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 21:24:43 +0100 Subject: [PATCH 13/17] fix(web): keep the sidebar through route transitions; end them within 120 ms Router updates now run in a React transition, so a route whose code is still loading keeps the current page up and the view transition captures the new page with its sidebar (it used to drop out and pop back on the first course-to-lesson visit). The root group's 250 ms UA morph is off: pointer input goes to the document while a transition runs, so it stays as short as the old page's 120 ms fade. The pointer-events rule had no effect. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/main.tsx | 7 +++++-- apps/web/src/motion.e2e.test.ts | 28 +++++++++++++++++++++++++++- packages/ui/src/tokens.css | 5 +++-- packages/ui/test/motion-css.test.ts | 5 +++-- 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 684ef00..eb78b13 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -70,12 +70,15 @@ const router = createBrowserRouter([ // Navigating to another kind of page (library, course, lesson, account, studio…) runs as a view transition: the old // page dissolves while the new one's content rises in (styles in @stc/ui tokens.css). Steps within a page (lesson // to lesson) are animated by the page itself. Reduced motion and browsers without the API navigate plainly. +// Router updates run in a React transition (v7_startTransition), so a route whose code is still loading keeps the +// current page up instead of falling back to nothing; the view transition captures the new page once it renders +// (with the course sidebar still in place). const routePatterns = featureRoutes.map((r) => ({ path: r.path })); const plainNavigate = router.navigate.bind(router); router.navigate = ((to: Parameters[0], opts?: Parameters[1]) => { const animate = typeof to !== "number" && to !== null && "startViewTransition" in document && !reducedMotion() && changesPage(routePatterns, router.state.location.pathname, typeof to === "string" ? to : createPath(to)); - return plainNavigate(to as never, animate ? { ...opts, viewTransition: opts?.viewTransition ?? true, flushSync: opts?.flushSync ?? true } : opts); + return plainNavigate(to as never, animate ? { ...opts, viewTransition: opts?.viewTransition ?? true } : opts); }) as typeof router.navigate; -createRoot(document.getElementById("root")!).render(); +createRoot(document.getElementById("root")!).render(); diff --git a/apps/web/src/motion.e2e.test.ts b/apps/web/src/motion.e2e.test.ts index 7c0ed4a..1a95c1a 100644 --- a/apps/web/src/motion.e2e.test.ts +++ b/apps/web/src/motion.e2e.test.ts @@ -44,7 +44,8 @@ async function freePort(): Promise { export async function until(f: () => Promise, what: string, ms = 8000): Promise> { const end = Date.now() + ms; for (;;) { - const v = await f().catch(() => null); + // A stalled DevTools call (a heavily loaded machine) counts as "not yet" instead of hanging the test. + const v = await Promise.race([f(), Bun.sleep(3000).then(() => null)]).catch(() => null); if (v) return v as NonNullable; if (Date.now() > end) throw new Error(`timed out waiting for ${what}`); await Bun.sleep(25); @@ -123,6 +124,31 @@ run("navigation motion (headless Chrome)", () => { expect(r.anims.some((a) => a.startsWith("stc-fade-out@") && a.includes("::view-transition-old(root)"))).toBe(true); }, 30_000); + test("course to lesson: the sidebar stays through the transition, which is over within 220 ms", async () => { + // A fresh page, so the lesson route's code is not loaded yet (the case where the sidebar used to drop out). + await open("/c/sample", ".lr-main > article h1"); + const side = await rect(".lr-side"); + await click('.lr-les[href="/c/sample/m1-intro"]'); + await until(() => exists(".lr-main .lr-lesson h1.title"), "lesson"); + await Bun.sleep(300); + const r = await m(); + expect(r.vt).toBe(1); + expect(r.anims.filter((a) => a.includes("(lr-side)") || a.includes("(stc-top)"))).toEqual([]); + expect(await rect(".lr-side")).toBe(side); + // Every view-transition animation ends by 220 ms (no 250 ms UA default left over). + await open("/c/sample", ".lr-main > article h1"); + const ends = await chrome.eval(`(() => { + document.querySelector('.lr-les[href="/c/sample/m1-code"]').click(); + const ends = [], t0 = performance.now(); + return new Promise((r) => { const f = () => { + for (const a of document.getAnimations()) if (a.effect.pseudoElement?.startsWith("::view-transition")) ends.push(a.effect.getComputedTiming().endTime); + if (performance.now() - t0 < 500) requestAnimationFrame(f); else r(ends); + }; f(); }); + })()`); + expect(ends.length).toBeGreaterThan(0); + expect(Math.max(...ends)).toBeLessThanOrEqual(220); + }, 30_000); + test("lesson to lesson: the old lesson stays until the next is ready, then swaps; sidebar and header do not move", async () => { await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title"); const side = await rect(".lr-side"), top = await rect(".stc-top"); diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index 2ef20d3..137ea82 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -82,8 +82,9 @@ code, pre { font-family: var(--mono); } /* View transitions (route changes, lesson swaps). The old page dissolves over the live new one, whose content plays its own enter animation; the header and the course sidebar are their own groups and do not move. - The overlay never takes pointer input, so a click during a transition reaches the new page. */ -::view-transition { pointer-events: none; } + While a transition runs the browser sends pointer input to the document, so it is kept to --t-exit: the root + group's default 250 ms morph is off (old and new root are both the viewport). Keyboard input is unaffected. */ +::view-transition-group(root) { animation: none; } ::view-transition-old(root), ::view-transition-new(root) { mix-blend-mode: normal; } ::view-transition-old(root) { z-index: 1; animation: stc-fade-out var(--t-exit) var(--ease-out) both; } ::view-transition-new(root) { animation: none; } diff --git a/packages/ui/test/motion-css.test.ts b/packages/ui/test/motion-css.test.ts index 07eefc5..387c3e3 100644 --- a/packages/ui/test/motion-css.test.ts +++ b/packages/ui/test/motion-css.test.ts @@ -92,9 +92,10 @@ test("prefers-reduced-motion turns off every animation, transition and view tran expect(block).toMatch(/\*::details-content\s*\{\s*transition:\s*none !important/); }); -test("view transitions never take pointer input and keep the header and sidebar still", () => { +test("view transitions are short and keep the header and sidebar still", () => { const css = strip(tokensCss); - expect(css).toMatch(/::view-transition\s*\{[^}]*pointer-events:\s*none/); + // the UA default (a 250 ms root morph) would outlast the 220 ms ceiling + expect(css).toMatch(/::view-transition-group\(root\)\s*\{\s*animation:\s*none/); expect(css).toMatch(/\.stc-top\s*\{[^}]*view-transition-name:\s*stc-top/); expect(strip(learnerCss)).toMatch(/\.lr-side\s*\{[^}]*view-transition-name:\s*lr-side/); expect(css).toMatch(/::view-transition-group\(stc-top\)[^{]*\{[^}]*animation:\s*none/); From 305c262ff1ee0eab29803cda12bc2d90efd03ca4 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 21:26:15 +0100 Subject: [PATCH 14/17] docs: record motion plan deviations Co-Authored-By: Claude Opus 5.5 --- docs/superpowers/plans/2026-10-30-v11-motion.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/superpowers/plans/2026-10-30-v11-motion.md b/docs/superpowers/plans/2026-10-30-v11-motion.md index 968068c..84808b0 100644 --- a/docs/superpowers/plans/2026-10-30-v11-motion.md +++ b/docs/superpowers/plans/2026-10-30-v11-motion.md @@ -207,3 +207,11 @@ - [ ] Run it against the dev server on port 3491 (with `STC_DATA_DIR=/tmp/stc-v11-motion`). Look at every frame. - [ ] Run `bun test`, `bun run typecheck`, and the PG run. Check that `bun.lock` and the `package.json` dependencies are unchanged. - [ ] Write the report to `/home/user/STCourses/.superpowers/sdd/2026-10-30-stcourses/v11-motion-report.md`. Commit `chore: motion frame capture script`. + +## Deviations found during execution + +- **`flushSync` on route changes became `v7_startTransition`.** With `flushSync`, the first visit to a lazily loaded route captured an empty page, so the sidebar dropped out and popped back in. Router updates now run in a React transition: the current page stays up until the new route has rendered, then the view transition captures it. +- **`::view-transition { pointer-events: none }` has no effect.** Chrome sends pointer input to the document element while a transition runs. Instead, each transition is kept to the old page's 120 ms fade, and the root group's default 250 ms morph is turned off. Keyboard input is unaffected. +- **`*` does not match `::details-content`.** The reduced-motion block has a separate `*::details-content` rule. +- **Chrome lists no `::details-content` animations in `getAnimations()` and sends no transition events for them.** The fold tests sample the computed height instead. +- **Pre-existing bug: pdf.js 6 documents have no `destroy()`.** Leaving a lesson whose deck had loaded crashed the app. Fixed by destroying the loading task instead. From b5e00af4f720f58f7b55d3cebcfdf8b9815d8e03 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 21:49:49 +0100 Subject: [PATCH 15/17] fix(web,learner): navigation swaps at once; content-only entrance, loading line, focus (R19) Drops document-level View Transitions and the lesson hold: they dropped clicks for 120-180 ms, froze the screen while route code loaded, and let the dock act on a stale lesson. Navigation now swaps immediately; only the content region enters (opacity and a 6 px rise, 160 ms); a 2 px loading line appears when code or data takes over 150 ms; the dock works from the outline and the URL, so a second Next advances again; focus moves to the new h1 and a polite live region announces it. Route chunks of a feature preload together so the sidebar never drops. Scroll restore stops on user input; pdf.js never starts a load after cleanup. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/main.tsx | 106 +++++++++++++----- apps/web/src/nav.test.ts | 12 -- apps/web/src/nav.ts | 12 -- .../plans/2026-10-30-v11-motion.md | 10 ++ .../feature-learner/src/web/course/pages.tsx | 70 ++++++------ .../src/web/lesson/DeckViewer.tsx | 3 +- .../src/web/library/LibraryPage.tsx | 4 +- packages/feature-learner/src/web/styles.ts | 11 +- .../feature-learner/test/web-shell.test.tsx | 8 +- packages/ui/src/index.ts | 4 +- packages/ui/src/motion.ts | 50 --------- packages/ui/src/primitives.tsx | 15 ++- packages/ui/src/scroll.ts | 42 +++---- packages/ui/src/tokens.css | 39 +++---- packages/ui/test/motion-css.test.ts | 27 +++-- packages/ui/test/motion.test.ts | 56 --------- packages/ui/test/motion.test.tsx | 18 +++ packages/ui/test/scroll.test.ts | 19 ++-- 18 files changed, 231 insertions(+), 275 deletions(-) delete mode 100644 packages/ui/test/motion.test.ts create mode 100644 packages/ui/test/motion.test.tsx diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index eb78b13..67c1fa0 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,10 +1,10 @@ import { createRoot } from "react-dom/client"; -import { lazy, Suspense, useEffect, useLayoutEffect, useState } from "react"; -import { createBrowserRouter, createPath, Link, NavLink, Outlet, RouterProvider, useLocation, useNavigationType, type RouteObject } from "react-router-dom"; +import { Suspense, use, useEffect, useLayoutEffect, useRef, useState, type ComponentType } from "react"; +import { createBrowserRouter, Link, NavLink, Outlet, RouterProvider, useLocation, useNavigationType, type RouteObject } from "react-router-dom"; import type { AuthUser } from "@stc/contracts"; -import { AppShell, installRouteScroll, reducedMotion, routeScroll } from "@stc/ui"; +import { AppShell, installRouteScroll, LoadingLine, routeScroll } from "@stc/ui"; import { webFeatures } from "./features"; -import { changesPage, visibleNav } from "./nav"; +import { visibleNav } from "./nav"; function Home() { return

      No features installed yet

      ; @@ -25,18 +25,50 @@ function useSessionUser(): AuthUser | null { return user; } -/** Scroll per history entry: top on a new page, the saved position on back/forward (a page may defer it). */ +/** Scroll per history entry: top on a new navigation, the saved position on back/forward. */ function useRouteScroll() { const loc = useLocation(); const type = useNavigationType(); useEffect(() => installRouteScroll(), []); - // A layout effect, so with a view transition the scroll is part of the captured new state. Pages' layout - // effects run before this one, so a page keeping its old content on screen can defer it first. useLayoutEffect(() => { routeScroll().routeChanged(loc.key, type === "POP", loc.hash); }, [loc.key]); } +/** + * After a navigation, focus moves to the new page's heading (its first h1, given tabindex -1) once it renders, and + * its text goes to a polite live region. The first page load keeps the browser's own focus. + */ +function useRouteFocus(): string { + const loc = useLocation(); + const [said, setSaid] = useState(""); + const last = useRef(null); + const first = useRef(true); + useEffect(() => { + const main = document.querySelector(".stc-main"); + if (!main) return; + const initial = first.current; + first.current = false; + const take = () => { + const h = main.querySelector("h1"); + if (!h || h === last.current) return false; + last.current = h; + if (initial) return true; + if (!h.hasAttribute("tabindex")) h.setAttribute("tabindex", "-1"); + h.focus({ preventScroll: true }); + setSaid(h.textContent ?? ""); + return true; + }; + if (take()) return; + const mo = new MutationObserver(() => { if (take()) mo.disconnect(); }); + mo.observe(main, { childList: true, subtree: true }); + const stop = setTimeout(() => mo.disconnect(), 10_000); + return () => { mo.disconnect(); clearTimeout(stop); }; + }, [loc.key]); + return said; +} + function Layout() { useRouteScroll(); + const said = useRouteFocus(); // Links the user cannot use are hidden; the pages and APIs still enforce access themselves. const nav = visibleNav(webFeatures, useSessionUser()); const header = ( @@ -50,35 +82,47 @@ function Layout() { ); return ( - - - + <> + + }> + +
      {said}
      + ); } -const featureRoutes: RouteObject[] = webFeatures.flatMap((f) => - f.routes.map((r) => { - const Element = lazy(r.element); - return { path: r.path, element: }; - }), -); + +/** + * A route component loaded on first use, like React.lazy, but preloadable: once one route of a feature has + * rendered, the feature's other routes load straight after. Moving between them (course page to lesson) then never + * suspends, so the course sidebar is not dropped for a frame on the first visit. + */ +function lazyRoute(load: () => Promise<{ default: ComponentType }>, siblings: (() => unknown)[]) { + let Loaded: ComponentType | null = null; + let pending: Promise | null = null; + const preload = () => (pending ??= load().then((m) => { Loaded = m.default; })); + function Route() { + if (!Loaded) use(preload()); + useEffect(() => { siblings.forEach((p) => void p()); }, []); + const C = Loaded!; + return ; + } + return { Route, preload }; +} + +const featureRoutes: RouteObject[] = webFeatures.flatMap((f) => { + const siblings: (() => unknown)[] = []; + return f.routes.map((r) => { + const { Route, preload } = lazyRoute(r.element, siblings); + siblings.push(preload); + return { path: r.path, element: }; + }); +}); const router = createBrowserRouter([ { element: , children: [...featureRoutes, ...(featureRoutes.some((r) => r.path === "/") ? [] : [{ path: "*", element: }])] }, ]); -// Navigating to another kind of page (library, course, lesson, account, studio…) runs as a view transition: the old -// page dissolves while the new one's content rises in (styles in @stc/ui tokens.css). Steps within a page (lesson -// to lesson) are animated by the page itself. Reduced motion and browsers without the API navigate plainly. -// Router updates run in a React transition (v7_startTransition), so a route whose code is still loading keeps the -// current page up instead of falling back to nothing; the view transition captures the new page once it renders -// (with the course sidebar still in place). -const routePatterns = featureRoutes.map((r) => ({ path: r.path })); -const plainNavigate = router.navigate.bind(router); -router.navigate = ((to: Parameters[0], opts?: Parameters[1]) => { - const animate = typeof to !== "number" && to !== null && "startViewTransition" in document && !reducedMotion() - && changesPage(routePatterns, router.state.location.pathname, typeof to === "string" ? to : createPath(to)); - return plainNavigate(to as never, animate ? { ...opts, viewTransition: opts?.viewTransition ?? true } : opts); -}) as typeof router.navigate; - -createRoot(document.getElementById("root")!).render(); +// Navigation swaps at once (no View Transitions, no holding the old page, ruling R19); the new content region +// plays its CSS entrance (@stc/ui tokens.css) and a loading line shows if code or data takes over 150 ms. +createRoot(document.getElementById("root")!).render(); diff --git a/apps/web/src/nav.test.ts b/apps/web/src/nav.test.ts index 5abf80d..d5d1a3f 100644 --- a/apps/web/src/nav.test.ts +++ b/apps/web/src/nav.test.ts @@ -20,15 +20,3 @@ test("features.ts imports only browser entries, by package name", () => { expect(specs.filter((s) => s.endsWith("/web")).length).toBe(4); }); -test("changesPage: a view transition for a new kind of page, none between steps of the same page", async () => { - const { changesPage } = await import("./nav"); - const routes = webFeatures.flatMap((f) => f.routes.map((r) => ({ path: r.path }))); - expect(changesPage(routes, "/", "/c/sample")).toBe(true); - expect(changesPage(routes, "/c/sample", "/c/sample/m1-intro")).toBe(true); - expect(changesPage(routes, "/c/sample/m1-intro", "/c/sample/notes")).toBe(true); - expect(changesPage(routes, "/c/sample/m1-intro", "/c/sample/m1-code")).toBe(false); - expect(changesPage(routes, "/c/sample/m1-intro", "/c/other/x?t=3")).toBe(false); - expect(changesPage(routes, "/c/sample/m1-video", "/c/sample/m1-video?deck=d1&slide=2")).toBe(false); - expect(changesPage(routes, "/c/a%20b/x", "/c/a%20b/y")).toBe(false); - expect(changesPage(routes, "/c/sample", "/nowhere/at/all/deep")).toBe(true); -}); diff --git a/apps/web/src/nav.ts b/apps/web/src/nav.ts index 39343fe..6577a98 100644 --- a/apps/web/src/nav.ts +++ b/apps/web/src/nav.ts @@ -1,4 +1,3 @@ -import { matchRoutes } from "react-router-dom"; import type { AuthUser, Role, WebFeature, WebRoute } from "@stc/contracts"; /** Admin-area links default to the owner; header links default to everyone. `nav.roles` overrides both. */ @@ -15,14 +14,3 @@ export function visibleNav(features: WebFeature[], user: Pick return [...all.filter((r) => r.nav!.area === "header" && allowed(r)), ...all.filter((r) => r.nav!.area === "admin" && allowed(r))]; } -/** - * Whether navigating from `fromPath` to `to` shows another kind of page (another route pattern), which gets a - * view transition. Steps within one page (lesson to lesson, a query change) do not: the page animates them itself. - */ -export function changesPage(routes: { path?: string }[], fromPath: string, to: string): boolean { - const pattern = (path: string) => matchRoutes(routes, path)?.at(-1)?.route.path ?? null; - let toPath: string; - try { toPath = new URL(to, `http://x${fromPath}`).pathname; } catch { return true; } - const a = pattern(fromPath), b = pattern(toPath); - return a === null || b === null || a !== b; -} diff --git a/docs/superpowers/plans/2026-10-30-v11-motion.md b/docs/superpowers/plans/2026-10-30-v11-motion.md index 84808b0..e0d93ad 100644 --- a/docs/superpowers/plans/2026-10-30-v11-motion.md +++ b/docs/superpowers/plans/2026-10-30-v11-motion.md @@ -215,3 +215,13 @@ - **`*` does not match `::details-content`.** The reduced-motion block has a separate `*::details-content` rule. - **Chrome lists no `::details-content` animations in `getAnimations()` and sends no transition events for them.** The fold tests sample the computed height instead. - **Pre-existing bug: pdf.js 6 documents have no `destroy()`.** Leaving a lesson whose deck had loaded crashed the app. Fixed by destroying the loading task instead. + +## Fix round 1 (ruling R19) + +- Document-level View Transitions are gone, along with the lesson hold. Navigation now swaps at once, and only the content region enters: `stc-page`, opacity plus a 6 px rise in 160 ms. +- When route code or data takes longer than 150 ms, a 2 px accent loading line (`LoadingLine`) shows at the top of the content region. +- The dock's previous/next come from the course outline and the URL, so the dock works while the lesson loads. +- After a navigation, focus moves to the new page's `h1`, and its text goes to a polite live region. +- Route components are preloadable: once a feature's route renders, its sibling routes load, so the sidebar is never dropped on a first visit. +- The scroll keeper loses defer/settle and gains `userInput()`. +- The e2e tests are deterministic: they use a fetch gate and condition waits, and finish animations through `getAnimations()`. diff --git a/packages/feature-learner/src/web/course/pages.tsx b/packages/feature-learner/src/web/course/pages.tsx index 9e2dbfb..9950738 100644 --- a/packages/feature-learner/src/web/course/pages.tsx +++ b/packages/feature-learner/src/web/course/pages.tsx @@ -1,7 +1,7 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, type CSSProperties } from "react"; +import { useCallback, useEffect, useMemo, type CSSProperties } from "react"; import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; -import { CourseCover, FloatingActionBar, Pill, ProgressBar, routeScroll, useHeldValue } from "@stc/ui"; -import type { CoursePayload, LessonPayload, Note, ProgressState } from "../../shared/types"; +import { CourseCover, FloatingActionBar, LoadingLine, Pill, ProgressBar } from "@stc/ui"; +import type { CoursePayload, LessonPayload, Note, OutlineLesson, ProgressState } from "../../shared/types"; import { api, ensureStyles, fmtMinutes, fmtTime, lessonHref, refreshLibrary, toast, useApi, useCourse, useLook, useMe, useTrack } from "../api"; import { Check, Next, Prev } from "../icons"; import { CourseFrame, courseDone, moduleNumber } from "../shell/CourseFrame"; @@ -52,7 +52,7 @@ export function CoursePage() { const [look] = useLook(); useEffect(() => { if (course) document.title = `${course.title} – STCourses`; }, [course]); if (error && !course) return ; - if (!course) return null; + if (!course) return ; const { done, total } = courseDone(course); const next = nextLesson(course); const started = Object.keys(course.progress).length > 0; @@ -100,74 +100,76 @@ export function CoursePage() { /* ---------- /c/:courseId/:lessonId ---------- */ +/** The lessons before and after `lessonId` in course order (the same flat order the lesson API uses). */ +export function lessonNeighbours(c: CoursePayload, lessonId: string): { current: OutlineLesson | null; prev: OutlineLesson | null; next: OutlineLesson | null } { + const flat = c.modules.flatMap((m) => m.lessons); + const i = flat.findIndex((l) => l.id === lessonId); + return i < 0 ? { current: null, prev: null, next: null } : { current: flat[i]!, prev: flat[i - 1] ?? null, next: flat[i + 1] ?? null }; +} + export function LessonPage() { const { courseId = "", lessonId = "" } = useParams(); const loc = useLocation(); const navigate = useNavigate(); const { course, error: courseError, reload } = useCourse(courseId); const lessonApi = useApi(`/api/courses/${encodeURIComponent(courseId)}/lessons/${encodeURIComponent(lessonId)}`); - const loaded = lessonApi.data && lessonApi.data.lesson.id === lessonId && lessonApi.data.courseId === courseId ? lessonApi.data : null; - // Lesson to lesson: the current lesson stays on screen while the next loads, then the two swap in a view - // transition, so the column never blanks. Only within one course; the sidebar highlight moves at once. - const held = useHeldValue(loaded, (l) => l.lesson.id, (l) => l.courseId === courseId && !lessonApi.error); - const lesson = held.value; + // Only the lesson the URL names is ever shown: while it loads the column is empty (a loading line after 150 ms), + // never the previous lesson. + const lesson = lessonApi.data && lessonApi.data.lesson.id === lessonId && lessonApi.data.courseId === courseId ? lessonApi.data : null; const { user } = useMe(); const [look] = useLook(); const [track, setTrack] = useTrack(courseId, course?.tracks ?? lesson?.tracks ?? []); const q = useMemo(() => new URLSearchParams(loc.search), [loc.search]); const initialTime = q.has("t") ? Number(q.get("t")) : null; const initialSlide = q.has("slide") && q.has("deck") ? { deckId: q.get("deck")!, page: Number(q.get("slide")) } : null; - const shownId = lesson?.lesson.id ?? lessonId; - const progress = course?.progress[shownId] ?? null; + const progress = course?.progress[lessonId] ?? null; const signedIn = !!user; useEffect(() => { if (lesson) document.title = `${lesson.lesson.title.replace(/`/g, "")} – ${lesson.courseTitle}`; }, [lesson]); - // While the old lesson is held, the shell's scroll for the new entry waits (this runs before the shell's - // layout effect) and is applied once the new lesson is on screen, inside the same transition. - useLayoutEffect(() => { if (held.stale) routeScroll().defer(); }, [loc.key]); - useLayoutEffect(() => { if (!held.stale || lessonApi.error) routeScroll().settle(); }, [lesson, held.stale, lessonApi.error]); const setState = useCallback(async (state: ProgressState, positionSeconds?: number) => { if (!signedIn) return; try { - await api(`/api/courses/${encodeURIComponent(courseId)}/progress/${encodeURIComponent(shownId)}`, { method: "PUT", body: { state, positionSeconds } }); + await api(`/api/courses/${encodeURIComponent(courseId)}/progress/${encodeURIComponent(lessonId)}`, { method: "PUT", body: { state, positionSeconds } }); if (state === "done" || progress?.state !== state) { await reload(); void refreshLibrary(); } } catch (e) { toast(`Progress was not saved: ${(e as Error).message}`); } - }, [courseId, shownId, signedIn, progress?.state, reload]); + }, [courseId, lessonId, signedIn, progress?.state, reload]); if (courseError && !course) return ; - if (lessonApi.error && course) { + if (!course) return ; + if (lessonApi.error) { return (

      Lesson not found

      This course has no lesson “{lessonId}”.

      ); } - if (!course || !lesson) { - return course ?
      : null; - } + // The dock works from the course outline and the URL, so it is there (and right) while the lesson loads: + // a second Next goes on from the lesson the URL names. + const { current, prev, next } = lessonNeighbours(course, lessonId); const isDone = progress?.state === "done"; - const isExercise = lesson.lesson.type === "exercise"; - const n = moduleNumber(course, lesson.module.index); + const isExercise = (lesson?.lesson.type ?? current?.type) === "exercise"; return ( - { void reload(); void refreshLibrary(); }} - initialTime={initialTime} initialSlide={initialSlide} - moduleLabel={`${/^(section|part|chapter)\b/i.test(lesson.module.title) ? "" : "Module "}${n}`.trim()} - /> + {lesson ? ( + { void reload(); void refreshLibrary(); }} + initialTime={initialTime} initialSlide={initialSlide} + moduleLabel={`${/^(section|part|chapter)\b/i.test(lesson.module.title) ? "" : "Module "}${moduleNumber(course, lesson.module.index)}`.trim()} + /> + ) :
      } - lesson.prev && navigate(lessonHref(courseId, lesson.prev.id))}> + prev && navigate(lessonHref(courseId, prev.id))}> Previous lesson - void setState(isDone ? "not-started" : "done")}> {isDone ? "Done" : "Mark done"} @@ -185,7 +187,7 @@ export function NotesPage() { const notesApi = useApi(user ? `/api/courses/${encodeURIComponent(courseId)}/notes` : null); useEffect(() => { if (course) document.title = `Your notes – ${course.title}`; }, [course]); if (error && !course) return ; - if (!course) return null; + if (!course) return ; const titles = new Map(course.modules.flatMap((m) => m.lessons.map((l) => [l.id, { title: l.title, module: m.title }] as const))); const order = new Map(course.modules.flatMap((m) => m.lessons).map((l, i) => [l.id, i])); const notes = notesApi.data ?? []; diff --git a/packages/feature-learner/src/web/lesson/DeckViewer.tsx b/packages/feature-learner/src/web/lesson/DeckViewer.tsx index a644837..e697fe9 100644 --- a/packages/feature-learner/src/web/lesson/DeckViewer.tsx +++ b/packages/feature-learner/src/web/lesson/DeckViewer.tsx @@ -78,6 +78,7 @@ export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, titl setDoc(null); setError(null); setPages(0); setView({ front: 0, page: 0, dir: null }); loadPdfjs() .then((pdfjs) => { + if (!live) throw new Error("unmounted"); // never start a load (and its worker) after cleanup const t = pdfjs.getDocument({ url: deck.url, withCredentials: true }); task = t; return t.promise; @@ -146,7 +147,7 @@ export function DeckViewer({ deck, notes, initialPage = 1, keyboard = true, titl
      ); } + +/** + * A slim accent line at the top of the content region (the nearest positioned ancestor), shown only when loading + * takes longer than `delay` ms, so fast loads show nothing at all. + */ +export function LoadingLine({ delay = 150 }: { delay?: number }) { + const [shown, setShown] = useState(false); + useEffect(() => { + const t = setTimeout(() => setShown(true), delay); + return () => clearTimeout(t); + }, [delay]); + return shown ?