Files
STCourses/docs/superpowers/plans/2026-10-30-v11-motion.md
T
2026-10-30 20:31:36 +01:00

14 KiB
Raw Blame History

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;
    • <details> 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-names;
    • 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 <baseUrl> <outDir> 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.