docs: v1.1 M navigation motion plan
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fd5c72afc2
commit
eed59d4ef2
@@ -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;
|
||||
- `<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:[email protected]: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 <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`.
|
||||
Reference in New Issue
Block a user