14 KiB
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/uitokens.css: durations (--t-exit120 ms,--t-fast140 ms,--t-enter180 ms,--t-fold200 ms,--t-bar220 ms), one ease-out curve, a 5 px rise, and the shared keyframes. They use onlyopacityand thetranslateproperty. Oneprefers-reduced-motion: reduceblock turns off every animation, transition and view-transition pseudo-element. - Motion helpers in
@stc/uimotion.ts:reducedMotion();viewTransition(update), which runsupdateinsidedocument.startViewTransition+flushSyncwhen 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/uiscroll.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
navigateis wrapped to add React Router'sviewTransition+flushSyncoptions. - 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.
- When a navigation changes the matched route pattern (library → course, course → lesson, lesson → account…), the router's
- Lesson → lesson has the same route pattern, so it gets no router transition.
LessonPageholds 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 withinterpolate-size+::details-contentwhere 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
viewTransitionnavigation 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
opacityandtranslateof at most 8 px in keyframes; prefers-reduced-motion: reduceturns it all off, in CSS and in JS (nostartViewTransitioncall);- 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 typecheckand 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:changesPagecases.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 readstokens.cssand the learner stylesheet string (learnerCss) and checks:- (a) the tokens
--t-exit,--t-fast,--t-enter,--t-fold,--t-barexist and are within 120–220 ms; - (b) every duration used in an
animation/transitiondeclaration resolves (through the tokens) to 0 or 120–220 ms, except the pre-existing off-canvas drawer, which stays at 160 ms; - (c) every
@keyframesblock animates onlyopacityandtranslate, with each translate component ≤ 8 px; - (d) every
cubic-bezier()has y values within [0, 1]; - (e) a
prefers-reduced-motion: reduceblock intokens.csssetsanimation: noneandtransition: noneon*and on::view-transition-*pseudo-elements; - (f)
::view-transitionhaspointer-events: none.
- (a) the tokens
- Run it and see it fail (no tokens yet).
- Add the tokens (keep
--fastas 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 > iwidth transition and the reduced-motion block. - Run the test until it passes; run
bun run typecheck. Commitfeat(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/matchMediaonglobalThis:reducedMotion()followsmatchMedia('(prefers-reduced-motion: reduce)')and is false withoutwindow;viewTransition(fn)callsstartViewTransitiononce and runsfninside it when supported;- it runs
fndirectly when reduced motion is on, whenstartViewTransitionis missing, or when the document is hidden; holdDecision(prev, next, keyOf, holdable), the pure core ofuseHeldValue, returnsshow-next/hold/swap/replacecorrectly.
- 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()beforerouteChangedpostpones the scroll untilsettle(), 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
routeChangedcancels 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-introis true;/c/sample/m1-intro→/c/sample/m1-codeis false; a query-only change is false; an unknown path is true. See it fail; implement it withmatchRoutes.motion.e2e.test.ts(skipped without Chrome):- start
apps/server/src/main.tsin 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 countsdocument.startViewTransitioncalls and recordsanimationstartnames; - test "library → course": clicking the sample course runs one view transition and the course article plays
stc-enter.
- start
- 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 (
routeChangedin a layout effect keyed onlocation.key); - add the
view-transition-names; - add the learner enter rules (
.lr-lesson,.lr-main > article,.lr-lib, account content).
- wrap
- 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-currentmoves at once; - the old lesson title stays until the new lesson renders (it is never blank);
- one view transition runs;
.lr-lessonplaysstc-enter;- the sidebar and header rects are identical before and after;
scrollYis 0 after the swap;history.back()returns to the intro at the saved scroll position.
- See it fail. Implement:
useHeldValueinLessonPage, holding only within the same course;routeScroll.defer()while held andsettle()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-enteron its list; - lists are not animated on page load or lesson change;
- opening the transcript fold runs a
heighttransition on::details-content; - closing it does too;
- the fold content is reachable at once (
openis set synchronously).
- opening a collapsed module plays
- See it fail. Implement:
data-enteron lists opened by the user;- the
@supports selector(::details-content)fold rules withinterpolate-size: allow-keywords, the height andcontent-visibility allow-discretetransitions, 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-frontanddata-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
widthtransition on the sidebar progress fills; - the toast (choose Reading on an exercise) plays
stc-enterand, when it leaves,stc-fade-outbefore it is removed; - opening the course switcher plays
stc-dropon.lr-menu; - Escape still closes the menu immediately.
- Mark done runs a
- See it fail. Implement:
- a toast store of
{ text, leaving }, removed after--t-exit; - the CSS for the menu, scrim and toast.
- a toast store of
- 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 checksstartViewTransitioncalls = 0,animationstart/transitionstartevents = 0 anddocument.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.
- slows animations with
- 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 thatbun.lockand thepackage.jsondependencies are unchanged. - Write the report to
/home/user/STCourses/.superpowers/sdd/2026-10-30-stcourses/v11-motion-report.md. Commitchore: motion frame capture script.