Files
portifolio-evandrodevbr/docs/superpowers/plans/2026-08-30-notebook-story.md
T

836 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<NotebookFrame, "cursorVisible" | "cursorX" | "cursorY" | "cursorClicking">;
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(<NotebookBrowser {...props} activeIndex={1} />);
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(<NotebookBrowser {...props} onSelectTab={onSelectTab} />);
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(<NotebookBrowser {...props} activeIndex={0} />);
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 `<img>` 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(<NotebookFallback projects={projectsFixture} />);
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(<NotebookFallback projects={projectsFixture} />);
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 }) => <div>{children}</div>,
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<Window, "innerWidth" | "matchMedia">): 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(<NotebookStory projects={projectsFixture} />);
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(<NotebookStory projects={projectsFixture} />);
expect(screen.getByTestId('notebook-fallback')).toBeInTheDocument();
});
it('passes only approved mapped projects into the browser', () => {
render(<NotebookStory projects={projectsFixture} />);
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
<NotebookStory projects={projects} />
```
- [ ] **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
<NotebookStory projects={projects} />
<CinematicSection effect="parallax-depth">
<About />
</CinematicSection>
```
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.