# 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`, seus testes órfãos e `public/models/phone-quaternius.glb`. --- ### 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 validated mapping (`Laptop_Base`, `Laptop_Lid`), parent relationships, bounding boxes and material names in `NotebookStage3D.tsx` and `CREDITS.md`. 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: "SC Plus Aquicultura", 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. Import `userEvent` in the test file before using `userEvent.setup()`: ```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(/SC Plus Aquicultura/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. Keep the `useGLTF` mock persistent for the failure case because React error boundaries may retry rendering: ```tsx jest.mock("@react-three/drei", () => ({ Html: ({ children }: { children: React.ReactNode }) =>
{children}
, useGLTF: jest.fn(() => ({ scene: new THREE.Group() })), })); const mockedUseGLTF = jest.mocked(useGLTF); beforeEach(() => { mockedUseGLTF.mockImplementation(() => ({ scene: new THREE.Group() })); }); it("returns usable content when GLB loading fails", () => { mockedUseGLTF.mockImplementation(() => { throw new Error("GLB failed"); }); // render and assert the DOM fallback, not the Canvas internals }); ``` - [ ] **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, resolve the validated `Laptop_Base` and `Laptop_Lid` nodes, and apply materials from the notebook color map. - 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 function canUseNotebook3D(view: Pick): boolean; export function getNotebookSeekProgress(index: number, tabCount: number): number; 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.