From 370cc34e16ee3d3364b36ed9267e22522ecda6ca Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Sun, 30 Aug 2026 23:24:48 -0300 Subject: [PATCH] docs: plano de implementacao do notebook story --- .../plans/2026-08-30-notebook-story.md | 830 ++++++++++++++++++ 1 file changed, 830 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-30-notebook-story.md diff --git a/docs/superpowers/plans/2026-08-30-notebook-story.md b/docs/superpowers/plans/2026-08-30-notebook-story.md new file mode 100644 index 0000000..180cd0d --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-notebook-story.md @@ -0,0 +1,830 @@ +# NotebookStory Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Substituir o celular 3D e o filmstrip horizontal por um único palco de notebook que abre, navega por quatro projetos com cursor/tabs e fecha antes de liberar o scroll para `About`. + +**Architecture:** `NotebookStory` será o único orquestrador do scroll-story. `NotebookStage3D` carregará um GLB CC0 local e controlará somente base/tampa/câmera; `NotebookBrowser` será uma superfície DOM 3D acessível via `Html transform`, com screenshots locais e tabs reais. Um conjunto pequeno de funções puras definirá o frame da timeline para que o scrub, o rewind e os testes compartilhem o mesmo contrato. + +**Tech Stack:** Next.js App Router, React 19, TypeScript, Tailwind CSS 4, React Three Fiber, Three.js, Drei, GSAP e ScrollTrigger já instalados. Nenhuma dependência nova. + +**Spec:** `docs/superpowers/specs/2026-08-30-notebook-story-design.md` + +## Global Constraints + +- O bloco de história terá um único dispositivo e um único Canvas WebGL; a hero atual não ganha outro renderer por causa desta mudança. +- A tela não usará iframe nem dependerá de rede para renderizar a narrativa. +- Screenshots locais de `public/assets/prints` são a fonte visual; deployments só aparecem no CTA explícito. +- O scroll é a fonte de verdade para play/rewind; não introduzir Lenis nem outro listener global de scroll. +- WebGL apenas em desktop com hover disponível; mobile, pointer coarse e reduced motion usam fallback DOM/2D. +- `prefers-reduced-motion` não inicializa GSAP, ScrollTrigger ou WebGL. +- Todas as tabs, painéis e CTAs precisam ser controles DOM acessíveis; o cursor é decorativo. +- Projetos exibidos vêm do `PortfolioProject[]` já filtrado pelo snapshot de saúde. +- O GLB deve ser local, CC0, otimizado e creditado em `CREDITS.md`. +- Não tocar no `globals.css` não rastreado do usuário nem misturar alterações anteriores de `data/project-health.json` e `lib/portfolio-data.ts` nos commits da feature. +- Testes novos devem falhar antes da implementação correspondente. + +## File Map + +- Create: `public/models/laptop-kenney.glb` — modelo local CC0 validado. +- Modify: `CREDITS.md` — fonte, autor, licença e modificações do notebook. +- Create: `app/components/story/notebookProjects.ts` — ordenação e mapeamento puro de projetos para screenshots. +- Test: `app/components/story/notebookProjects.test.ts` — filtragem, ordem e ausência de asset. +- Create: `app/components/story/notebookTimeline.ts` — contrato puro `progress -> frame`. +- Test: `app/components/story/notebookTimeline.test.ts` — limites, fases, tabs, cursor e rewind. +- Create: `app/components/story/NotebookBrowser.tsx` — browser chrome, tablist, painel, cursor e CTA. +- Test: `app/components/story/NotebookBrowser.test.tsx` — ARIA, teclado, click e links. +- Create: `app/components/story/NotebookFallback.tsx` — mockup sem WebGL para mobile/reduced motion/falha. +- Test: `app/components/story/NotebookFallback.test.tsx` — conteúdo e acessibilidade do fallback. +- Create: `app/components/story/NotebookStage3D.tsx` — GLB, rig, câmera, luzes, `Html transform` e pause. +- Test: `app/components/cinematic/CinematicHome.test.tsx` — composição do novo palco e ordem com `About`. +- Create: `app/components/story/NotebookStory.tsx` — runway, ScrollTrigger, seleção de projeto e composição. +- Test: `app/components/story/NotebookStory.test.tsx` — modo reduzido, elegibilidade 3D e integração de tabs. +- Modify: `app/components/cinematic/CinematicHome.tsx` — usar `NotebookStory` e remover `PrintReel`/`ScrollStory`. +- Modify: `app/globals.css` — somente tokens/classes novas do notebook, sem tocar no `globals.css` raiz do usuário. +- Delete after migration: `app/components/story/PrintReel.tsx`, `app/components/story/ScrollStory.tsx`, `app/components/story/StoryStage3D.tsx`, `app/components/story/phoneScreenTexture.ts` e testes órfãos. + +--- + +### Task 1: Validar e versionar o modelo CC0 + +**Files:** +- Create: `public/models/laptop-kenney.glb` +- Modify: `CREDITS.md` +- Test/verification: script temporário local para ler o JSON interno do GLB; não versionar o script. + +**Interfaces:** +- Produces a local asset with separate logical parts for `base`, `lid`, `screen` and `keyboard`. +- Produces the asset provenance used by `NotebookStage3D`. + +- [ ] **Step 1: Download the candidate asset locally** + +Use the verified Poly Pizza source page and its static GLB URL from the page metadata. Save the file as `public/models/laptop-kenney.glb`; do not load it remotely from the app. + +```powershell +Invoke-WebRequest ` + -Uri 'https://static.poly.pizza/8190e659-7079-442f-9ed9-083f67b1746b.glb' ` + -OutFile 'D:\Documents\Github\portifolio-evandrodevbr\public\models\laptop-kenney.glb' +``` + +- [ ] **Step 2: Inspect the GLB node and material names before code** + +Read the GLB header and JSON chunk with a one-off Node script. Record the actual node names, parent relationships, bounding boxes and material names in the implementation notes. The script must fail if there is no separate lid-like node. + +```js +const fs = require('node:fs'); +const buffer = fs.readFileSync('public/models/laptop-kenney.glb'); +if (buffer.toString('ascii', 0, 4) !== 'glTF') throw new Error('invalid GLB'); +const jsonLength = buffer.readUInt32LE(12); +const json = JSON.parse(buffer.toString('utf8', 20, 20 + jsonLength)); +console.log(json.nodes.map(({ name, mesh, children }) => ({ name, mesh, children }))); +``` + +- [ ] **Step 3: Confirm the hinge can be controlled** + +The chosen mapping must identify one lid node whose local pivot is near the hinge. If the candidate is a single rigid mesh or the pivot cannot be corrected offline, stop using it and select another CC0 model before continuing; never animate the complete laptop as a fake lid. + +- [ ] **Step 4: Add provenance and modification record** + +Append a `3D model — “Laptop”` section to `CREDITS.md` with author Kenney, source Poly Pizza URL, CC0 1.0 URL, local path, and the exact modifications: local copy, material overrides, pivot calibration and screen overlay. + +- [ ] **Step 5: Verify the asset is not accidentally ignored or oversized** + +Run the repository’s file/status check and inspect the asset size. The GLB must be local, tracked, and below the 2 MB transfer budget from the spec. Do not stage `.next`, logs, diagnostic scripts or the user’s `globals.css`. + +- [ ] **Step 6: Commit the asset slice** + +```bash +git add public/models/laptop-kenney.glb CREDITS.md +git commit -m "feat: add cc0 laptop asset for notebook story" +``` + +Expected result: the model is locally loadable, its hinge is known, and provenance is committed before component code depends on it. + +--- + +### Task 2: Define the pure project and timeline contracts + +**Files:** +- Create: `app/components/story/notebookProjects.ts` +- Test: `app/components/story/notebookProjects.test.ts` +- Create: `app/components/story/notebookTimeline.ts` +- Test: `app/components/story/notebookTimeline.test.ts` + +**Interfaces:** + +`notebookProjects.ts` produces: + +```ts +export type NotebookProject = { + id: string; + title: string; + subtitle: string; + demoUrl: string; + technologies: string[]; + screenshotSrc: string; +}; + +export function selectNotebookProjects( + projects: PortfolioProject[], +): NotebookProject[]; +``` + +`notebookTimeline.ts` produces: + +```ts +export type NotebookPhase = + | "approach" + | "open" + | "boot" + | "cursor-in" + | "browse" + | "hold" + | "close"; + +export type NotebookFrame = { + phase: NotebookPhase; + lidAngle: number; + deviceOpacity: number; + screenOpacity: number; + activeTabIndex: number; + cursorVisible: boolean; + cursorX: number; + cursorY: number; + cursorClicking: boolean; +}; + +export function getNotebookFrame(progress: number, tabCount: number): NotebookFrame; +``` + +- [ ] **Step 1: Write failing project-map tests** + +Declare one fixture with the exact fields consumed by the selector: + +```ts +const projectsFixture = [ + { + id: "nutriscan", + title: "NutriScan", + subtitle: "Análise Nutricional Inteligente", + demoUrl: "https://nutriscan.evandro.dev.br", + technologies: ["Next.js", "TypeScript"], + }, + { + id: "sc-plus", + title: "SCPLUS", + subtitle: "Landing Page Institucional", + demoUrl: "https://scplus.evandro.dev.br", + technologies: ["Next.js", "React"], + }, + { + id: "sev-modaintima", + title: "SEV Moda Íntima", + subtitle: "Catálogo Digital & E-commerce", + demoUrl: "https://sevmodaintima.com.br", + technologies: ["Next.js", "Prisma"], + }, + { + id: "as-servicos", + title: "AS Serviços", + subtitle: "Presença digital SSR", + demoUrl: "https://asservicos.com", + technologies: ["Next.js", "SSR"], + }, +] as unknown as PortfolioProject[]; +``` + +Cover one behavior per test: + +```ts +it('returns the four ordered projects that have a mapped screenshot and demo URL', () => { + const result = selectNotebookProjects(projectsFixture); + expect(result.map((project) => project.id)).toEqual([ + 'nutriscan', + 'sc-plus', + 'sev-modaintima', + 'as-servicos', + ]); +}); + +it('omits a live project when its screenshot mapping is missing', () => { + const result = selectNotebookProjects([ + ...projectsFixture, + { ...projectsFixture[0], id: 'unknown-live-project' }, + ]); + expect(result.some((project) => project.id === 'unknown-live-project')).toBe(false); +}); + +it('omits projects without an approved demo URL', () => { + const result = selectNotebookProjects([ + ...projectsFixture, + { ...projectsFixture[0], id: 'twins-lab', demoUrl: null }, + ]); + expect(result.some((project) => project.id === 'twins-lab')).toBe(false); +}); +``` + +- [ ] **Step 2: Run project-map tests and verify the expected red failure** + +```bash +pnpm exec jest app/components/story/notebookProjects.test.ts --runInBand +``` + +Expected: FAIL because the module and selector do not exist yet. + +- [ ] **Step 3: Implement the minimal static mapping** + +Use a single ordered map, with no network access: + +```ts +const SCREENSHOTS = { + nutriscan: "/assets/prints/print-nutriscan.png", + "sc-plus": "/assets/prints/print-scplus.png", + "sev-modaintima": "/assets/prints/print-sev.png", + "as-servicos": "/assets/prints/print-asservicos.png", +} as const; +``` + +Iterate the fixed order, find the matching project, require a non-empty `demoUrl`, and return the normalized object. Do not mutate the input array. + +- [ ] **Step 4: Run project-map tests and verify green** + +```bash +pnpm exec jest app/components/story/notebookProjects.test.ts --runInBand +``` + +Expected: all project-map tests pass. + +- [ ] **Step 5: Write failing timeline tests at every boundary** + +Cover clamping, phase transitions, four tabs, cursor visibility/click pulse, and reverse determinism: + +```ts +it('clamps progress below zero and starts with a closed device', () => { + expect(getNotebookFrame(-1, 4)).toMatchObject({ + phase: 'approach', + lidAngle: 0, + activeTabIndex: -1, + cursorVisible: false, + }); +}); + +it('opens the lid before the browser starts', () => { + const frame = getNotebookFrame(0.22, 4); + expect(frame.phase).toBe('open'); + expect(frame.lidAngle).toBeGreaterThan(0); + expect(frame.screenOpacity).toBe(0); +}); + +it('selects each tab only inside the browse window', () => { + expect(getNotebookFrame(0.43, 4).activeTabIndex).toBe(-1); + expect(getNotebookFrame(0.48, 4).activeTabIndex).toBe(0); + expect(getNotebookFrame(0.57, 4).activeTabIndex).toBe(1); + expect(getNotebookFrame(0.67, 4).activeTabIndex).toBe(2); + expect(getNotebookFrame(0.76, 4).activeTabIndex).toBe(3); +}); + +it('closes the device after the final hold', () => { + const frame = getNotebookFrame(0.96, 4); + expect(frame.phase).toBe('close'); + expect(frame.lidAngle).toBeLessThan(20); + expect(frame.cursorVisible).toBe(false); +}); + +it('is deterministic when rewound to the same progress', () => { + expect(getNotebookFrame(0.61, 4)).toEqual(getNotebookFrame(0.61, 4)); +}); +``` + +- [ ] **Step 6: Run timeline tests and verify the expected red failure** + +```bash +pnpm exec jest app/components/story/notebookTimeline.test.ts --runInBand +``` + +Expected: FAIL because the timeline module does not exist yet. + +- [ ] **Step 7: Implement the pure frame function** + +Use the exact spec windows: approach `0.00–0.10`, open `0.10–0.25`, boot `0.25–0.34`, cursor-in `0.34–0.44`, browse `0.44–0.78`, hold `0.78–0.87`, close `0.87–1.00`. Clamp progress and tab count. The active index must be `-1` outside `browse`/`hold` only when no tab is visible; during `hold`, keep the last tab active. Cursor coordinates are normalized `[0, 1]` and use a fixed path per tab; no time-based side effects. + +- [ ] **Step 8: Run both pure-contract test files and verify green** + +```bash +pnpm exec jest app/components/story/notebookProjects.test.ts app/components/story/notebookTimeline.test.ts --runInBand +``` + +- [ ] **Step 9: Commit the pure contract slice** + +```bash +git add app/components/story/notebookProjects.ts app/components/story/notebookProjects.test.ts app/components/story/notebookTimeline.ts app/components/story/notebookTimeline.test.ts +git commit -m "feat: define notebook project and timeline contracts" +``` + +--- + +### Task 3: Build the accessible browser surface + +**Files:** +- Create: `app/components/story/NotebookBrowser.tsx` +- Test: `app/components/story/NotebookBrowser.test.tsx` + +**Interfaces:** + +```ts +export type NotebookBrowserProps = { + projects: NotebookProject[]; + activeIndex: number; + frame: Pick; + onSelectTab: (index: number) => void; +}; + +export default function NotebookBrowser(props: NotebookBrowserProps): JSX.Element; +``` + +- [ ] **Step 1: Write failing semantic and interaction tests** + +Test observable behavior, not implementation details: + +```tsx +it('renders the active project as an accessible tabpanel', () => { + render(); + expect(screen.getByRole('tablist', { name: /projetos em produção/i })).toBeInTheDocument(); + expect(screen.getAllByRole('tab')[1]).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tabpanel')).toHaveAccessibleName(/SCPLUS/i); +}); + +it('moves between tabs with arrow keys and Home/End', async () => { + const onSelectTab = jest.fn(); + render(); + const tabs = screen.getAllByRole('tab'); + await userEvent.click(tabs[1]); + await userEvent.keyboard('{ArrowRight}'); + expect(onSelectTab).toHaveBeenLastCalledWith(2); + await userEvent.keyboard('{Home}'); + expect(onSelectTab).toHaveBeenLastCalledWith(0); + await userEvent.keyboard('{End}'); + expect(onSelectTab).toHaveBeenLastCalledWith(props.projects.length - 1); +}); + +it('exposes a real deployment link with safe new-tab attributes', () => { + render(); + const link = screen.getByRole('link', { name: /abrir projeto/i }); + expect(link).toHaveAttribute('href', props.projects[0].demoUrl); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); +}); +``` + +Also test click callback, `aria-controls`/`aria-labelledby`, visible focusable buttons, and that no `iframe` exists. + +- [ ] **Step 2: Run browser-surface tests and verify the expected red failure** + +```bash +pnpm exec jest app/components/story/NotebookBrowser.test.tsx --runInBand +``` + +Expected: FAIL because the component does not exist yet. + +- [ ] **Step 3: Implement the browser surface** + +Rules: + +- Use a real `role="tablist"`, `role="tab"` and `role="tabpanel"`. +- Set `aria-selected`, `aria-controls`, `aria-labelledby`, `tabIndex` and stable IDs from project IDs. +- Arrow keys are bounded to the first/last tab; `Home` and `End` jump directly. +- Render only the selected screenshot in the active panel; use a local `` with stable aspect ratio. +- Render browser controls and project tabs in DOM, not on Canvas. +- Put the animated cursor in an `aria-hidden` element with `pointer-events: none`. +- Keep the CTA as a normal anchor; do not make the simulated screenshot itself clickable. +- Use the existing color/font tokens and state rules from the spec. + +- [ ] **Step 4: Run browser-surface tests and verify green** + +```bash +pnpm exec jest app/components/story/NotebookBrowser.test.tsx --runInBand +``` + +- [ ] **Step 5: Commit the browser surface slice** + +```bash +git add app/components/story/NotebookBrowser.tsx app/components/story/NotebookBrowser.test.tsx +git commit -m "feat: add accessible notebook browser surface" +``` + +--- + +### Task 4: Add the non-WebGL fallback first + +**Files:** +- Create: `app/components/story/NotebookFallback.tsx` +- Test: `app/components/story/NotebookFallback.test.tsx` +- Modify: `app/globals.css` + +**Interfaces:** + +```ts +export type NotebookFallbackProps = { + projects: NotebookProject[]; + initialIndex?: number; +}; + +export default function NotebookFallback(props: NotebookFallbackProps): JSX.Element; +``` + +- [ ] **Step 1: Write failing fallback tests** + +```tsx +it('keeps project content and controls in normal document flow', () => { + render(); + expect(screen.getByRole('tablist')).toBeInTheDocument(); + expect(screen.getByRole('tabpanel')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /abrir projeto/i })).toBeInTheDocument(); +}); + +it('allows manual tab changes without animation', async () => { + render(); + await userEvent.click(screen.getAllByRole('tab')[2]); + expect(screen.getByRole('tabpanel')).toHaveAccessibleName(/SEV Moda Íntima/i); +}); +``` + +- [ ] **Step 2: Run fallback tests and verify the expected red failure** + +```bash +pnpm exec jest app/components/story/NotebookFallback.test.tsx --runInBand +``` + +- [ ] **Step 3: Implement the fallback** + +Use the same `NotebookBrowser` contract in a static wrapper, with no `useEffect`, GSAP, ScrollTrigger, Canvas or pointer animation. On mobile it shows one browser surface and wrapped tabs in normal flow. The fallback must work with JavaScript disabled as a server-rendered semantic structure. + +- [ ] **Step 4: Add only notebook-specific CSS tokens and responsive rules** + +Modify `app/globals.css` with classes for: + +- `.notebook-story`, `.notebook-story-sticky`, `.notebook-story-grid`; +- `.notebook-browser`, `.notebook-tab`, `.notebook-panel`, `.notebook-cursor`; +- desktop two-column layout, tablet single-column layout and mobile wrapped tablist; +- `@media (prefers-reduced-motion: reduce)` static flow; +- no horizontal overflow at `320px`; +- focus, hover, active and disabled states from the spec. + +Do not modify the untracked root `globals.css`. + +- [ ] **Step 5: Run fallback tests and type-check the slice** + +```bash +pnpm exec jest app/components/story/NotebookFallback.test.tsx app/components/story/NotebookBrowser.test.tsx --runInBand +pnpm exec tsc --noEmit +``` + +- [ ] **Step 6: Commit the fallback slice** + +```bash +git add app/components/story/NotebookFallback.tsx app/components/story/NotebookFallback.test.tsx app/globals.css +git commit -m "feat: add notebook accessibility fallback" +``` + +--- + +### Task 5: Build the notebook 3D stage + +**Files:** +- Create: `app/components/story/NotebookStage3D.tsx` +- Test: `app/components/story/NotebookStage3D.test.tsx` + +**Interfaces:** + +```ts +export type NotebookStage3DProps = { + progress: { current: number }; + projects: NotebookProject[]; + activeIndex: number; + onSelectTab: (index: number) => void; + isVisible: boolean; +}; + +export default function NotebookStage3D(props: NotebookStage3DProps): JSX.Element; +``` + +- [ ] **Step 1: Write failing stage boundary tests** + +Mock only the R3F/Drei renderer boundary; assert the observable fallback contract. The test mock must expose the hook used by production: + +```tsx +jest.mock("@react-three/drei", () => ({ + ...jest.requireActual("@react-three/drei"), + useGLTF: jest.fn(() => ({ scene: new THREE.Group() })), +})); + +const mockedUseGLTF = jest.mocked(useGLTF); + +function mockUseGLTFFailure() { + mockedUseGLTF.mockImplementationOnce(() => { + throw new Error("GLB failed"); + }); +} +``` + +- [ ] **Step 2: Run stage tests and verify the expected red failure** + +```bash +pnpm exec jest app/components/story/NotebookStage3D.test.tsx --runInBand +``` + +- [ ] **Step 3: Implement the GLB rig** + +Implementation requirements: + +- `useGLTF('/models/laptop-kenney.glb')` only; no remote URL. +- Clone the scene and apply materials from the notebook color map. +- Resolve the validated base/lid/screen/keyboard nodes from Task 1. +- Attach the browser surface to a calibrated screen anchor on the lid using Drei `Html transform`. +- Apply `getNotebookFrame(progress.current, projects.length)` to lid angle, screen opacity, device opacity and cursor props. +- Keep base stable; only lid articulates. +- Use `Canvas` with `dpr={[1, 1.5]}`, transparent background, conservative lights and no particle field. +- Set `frameloop="never"` when `isVisible` is false; render continuously only while the stage is visible. +- Use `Suspense` with a procedural notebook while the GLB loads. +- Wrap the Canvas in an outer error boundary; if GLB loading fails, return `NotebookFallback` as DOM outside the Canvas so tabs and CTA remain usable. +- Dispose cloned materials/geometries on unmount where the loader clone owns them. +- Do not reuse the old phone turntable or `phoneScreenTexture`. + +- [ ] **Step 4: Run stage tests and verify green** + +```bash +pnpm exec jest app/components/story/NotebookStage3D.test.tsx --runInBand +``` + +- [ ] **Step 5: Run TypeScript and a focused build check** + +```bash +pnpm exec tsc --noEmit +``` + +- [ ] **Step 6: Commit the 3D stage slice** + +```bash +git add app/components/story/NotebookStage3D.tsx app/components/story/NotebookStage3D.test.tsx +git commit -m "feat: add notebook three-dimensional stage" +``` + +--- + +### Task 6: Orchestrate scroll, tabs and normal continuation + +**Files:** +- Create: `app/components/story/NotebookStory.tsx` +- Test: `app/components/story/NotebookStory.test.tsx` + +**Interfaces:** + +```tsx +export type NotebookStoryProps = { + projects: PortfolioProject[]; +}; + +export default function NotebookStory(props: NotebookStoryProps): JSX.Element; +``` + +- [ ] **Step 1: Write failing orchestrator tests** + +```tsx +it('renders the static fallback when reduced motion is enabled', () => { + mockMatchMedia({ + '(prefers-reduced-motion: reduce)': true, + '(pointer: coarse)': false, + }); + render(); + expect(screen.getByTestId('notebook-fallback')).toBeInTheDocument(); + expect(screen.queryByTestId('notebook-stage')).not.toBeInTheDocument(); +}); + +it('does not initialize the 3D stage on coarse pointers', () => { + mockMatchMedia({ + '(prefers-reduced-motion: reduce)': false, + '(pointer: coarse)': true, + }); + render(); + expect(screen.getByTestId('notebook-fallback')).toBeInTheDocument(); +}); + +it('passes only approved mapped projects into the browser', () => { + render(); + expect(screen.getByRole('tab', { name: /NutriScan/i })).toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: /Twins Lab/i })).not.toBeInTheDocument(); +}); +``` + +Also cover the scroll-to-tab helper with a pure mocked section rectangle: target progress `0`, middle and last tab map to positions inside the runway; no global listener is created. + +- [ ] **Step 2: Run orchestrator tests and verify the expected red failure** + +```bash +pnpm exec jest app/components/story/NotebookStory.test.tsx --runInBand +``` + +- [ ] **Step 3: Implement capability detection and fallback selection** + +On mount, read `prefers-reduced-motion`, `(pointer: coarse)`, `(hover: hover)` and `window.innerWidth`. Enable the 3D/sticky mode only for desktop hover-capable devices at `>= 1025px`. All other modes render the static fallback without importing the 3D stage. + +- [ ] **Step 4: Implement the single ScrollTrigger timeline** + +Use the existing pattern: + +```ts +const timeline = gsap.timeline({ + defaults: { ease: 'none' }, + scrollTrigger: { + trigger: section, + start: 'top top', + end: 'bottom bottom', + scrub: 0.85, + invalidateOnRefresh: true, + }, +}); +``` + +Set a `progressRef` in `onUpdate`, derive the active tab only when its integer index changes, and update the contextual metadata without writing text on every frame. Use `gsap.context().revert()` and disconnect the visibility observer on cleanup. + +- [ ] **Step 5: Implement the runway and continuation** + +Use a `520vh` runway in 3D mode and a `100dvh` sticky stage. The next DOM child after the section is `About`; when progress reaches `1`, the device is closed and the sticky releases naturally. Do not put a horizontal track or an additional pin after the notebook. + +- [ ] **Step 6: Implement tab seek without a second scroll engine** + +When a tab is clicked, calculate the target scroll from the section top and runway distance, then call native `window.scrollTo({ behavior: 'smooth' })`. Do not call `ScrollTrigger` from a per-frame handler or add Lenis. + +- [ ] **Step 7: Run orchestrator tests and verify green** + +```bash +pnpm exec jest app/components/story/NotebookStory.test.tsx app/components/story/NotebookFallback.test.tsx app/components/story/NotebookBrowser.test.tsx --runInBand +pnpm exec tsc --noEmit +``` + +- [ ] **Step 8: Commit the orchestrator slice** + +```bash +git add app/components/story/NotebookStory.tsx app/components/story/NotebookStory.test.tsx +git commit -m "feat: orchestrate notebook scroll story" +``` + +--- + +### Task 7: Replace the old stages and update the home composition + +**Files:** +- Modify: `app/components/cinematic/CinematicHome.tsx` +- Delete after reference migration: `app/components/story/PrintReel.tsx`, `app/components/story/ScrollStory.tsx`, `app/components/story/StoryStage3D.tsx`, `app/components/story/phoneScreenTexture.ts` +- Modify/rename tests: replace `ScrollStory.test.tsx` coverage with `NotebookStory.test.tsx`; remove tests with no remaining consumer. + +**Interfaces:** + +`CinematicHome` changes from separate story/filmstrip stages to: + +```tsx + +``` + +- [ ] **Step 1: Write the integration assertion before changing composition** + +Add an assertion to the home composition test or a focused render test that the home renders `NotebookStory` once, does not render `PrintReel`, and keeps `About` after it in the DOM order. + +- [ ] **Step 2: Run the integration test and verify the expected red failure** + +```bash +pnpm exec jest app/components/cinematic/CinematicHome.test.tsx --runInBand +``` + +Expected: FAIL because the current composition still renders `ScrollStory` and `PrintReel`. + +- [ ] **Step 3: Update `CinematicHome.tsx`** + +Remove the old imports and render: + +```tsx + + + + +``` + +Do not change unrelated dynamic section imports or the existing `ReelDirector`. + +- [ ] **Step 4: Search all old consumers before deletion** + +Use repository search to confirm there are no imports/references to the old components outside their own tests. Migrate any remaining direct reference before deleting; do not leave aliases or dead re-exports. + +- [ ] **Step 5: Delete the obsolete story implementation** + +Delete only after the search is clean. The new `NotebookStory` owns the replacement behavior; no old horizontal or phone code remains. + +- [ ] **Step 6: Run the full existing suite and verify green** + +```bash +pnpm exec jest --runInBand +``` + +Expected: all existing tests plus notebook tests pass. Existing jsdom Canvas warnings may remain in unrelated hero tests; they must not become test failures. + +- [ ] **Step 7: Commit the composition cutover** + +```bash +git add app/components/cinematic/CinematicHome.tsx app/components/story +git commit -m "refactor: replace phone and filmstrip with notebook story" +``` + +--- + +### Task 8: Run full verification and real-browser acceptance checks + +**Files:** +- No new source files. +- Modify only files that fail an acceptance check; do not broaden scope. + +- [ ] **Step 1: Run static quality checks** + +```bash +pnpm exec jest --runInBand +pnpm exec tsc --noEmit +pnpm exec biome check app/components/story app/components/cinematic/CinematicHome.tsx app/globals.css +pnpm run build +``` + +Expected: tests pass, TypeScript exits cleanly, Biome reports no errors in touched files, and the production build completes. + +- [ ] **Step 2: Start one clean development server** + +Stop only verified duplicate Next processes if present, remove stale `.next` output, and start one `pnpm run dev` instance. Do not run multiple browser navigation scripts against the same dev process. + +- [ ] **Step 3: Verify the desktop runtime in Chromium** + +At a desktop viewport with hover support: + +1. Capture console and page errors before navigation. +2. Confirm the notebook is closed at the section start. +3. Scroll through `approach`, `open`, `boot`, cursor entry and each tab. +4. Confirm browser chrome stays fixed while screenshot content changes. +5. Scroll upward and confirm the exact visual sequence rewinds. +6. Click a tab and verify smooth seek to its timeline window. +7. Use keyboard arrows, `Home`, `End`, `Enter` and `Space`. +8. Finish the sequence and confirm `About` follows without a stuck pin. +9. Verify there is no horizontal overflow. +10. Verify only an explicit CTA click opens a new tab. + +- [ ] **Step 4: Verify mobile and coarse-pointer fallback** + +At `320px` and `390px` widths with touch emulation: + +- no WebGL Canvas is created for `NotebookStory`; +- notebook/browser mockup is in normal flow; +- tabs wrap without horizontal overflow; +- active screenshot, metadata and CTA remain visible; +- keyboard/focus semantics remain intact where applicable. + +- [ ] **Step 5: Verify reduced motion** + +Enable `prefers-reduced-motion: reduce` and reload: + +- no GSAP/ScrollTrigger setup for the notebook; +- no WebGL Canvas for the notebook; +- no animated cursor; +- static open browser surface with manual tabs; +- `About` follows in normal document flow. + +- [ ] **Step 6: Measure the runtime performance budget** + +Use DevTools performance/runtime evidence: + +- one Canvas maximum inside `NotebookStory`; +- no deployment `fetch` from the page request or notebook component; +- GLB transfer below 2 MB; +- current and next screenshot loaded locally; +- stage render paused when outside viewport; +- desktop scrub maintains at least 50 FPS on the available mid-range profile. + +- [ ] **Step 7: Review the final diff and workspace scope** + +```bash +git diff --check +git status --short +git log -8 --oneline +``` + +The feature commits must contain only the model/credits, pure contracts/tests, browser/fallback/stage/orchestrator, home cutover and required CSS. Preserve the user’s untracked `globals.css` and the prior health-snapshot changes unless the user explicitly asks for them in the same commit. + +- [ ] **Step 8: Commit any final acceptance fix separately** + +If a real-browser acceptance check finds a concrete defect, add a focused regression test first, fix it, rerun the affected checks, then commit with a message describing the defect. Do not mark this task complete from a visual assumption alone. + +## Final Definition of Done + +- [ ] GLB source, license, node mapping and local file are verified. +- [ ] Every pure timeline/project test was observed failing before implementation and passes after. +- [ ] Browser tabs and CTA are accessible DOM controls. +- [ ] Desktop opening, browsing, closing and rewind are verified in a real browser. +- [ ] Mobile, coarse pointer, reduced motion and GLB failure fallbacks are verified. +- [ ] `PrintReel`, phone stage and phone texture have no remaining consumers. +- [ ] Full Jest suite, TypeScript, Biome and production build pass. +- [ ] No horizontal overflow or stuck sticky stage remains. +- [ ] Final diff contains no unrelated user file changes.