Files

32 KiB
Raw Permalink Blame History

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.

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.

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
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:

export type NotebookProject = {
  id: string;
  title: string;
  subtitle: string;
  demoUrl: string;
  technologies: string[];
  screenshotSrc: string;
};

export function selectNotebookProjects(
  projects: PortfolioProject[],
): NotebookProject[];

notebookTimeline.ts produces:

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:

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:

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
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:

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
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:

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
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
pnpm exec jest app/components/story/notebookProjects.test.ts app/components/story/notebookTimeline.test.ts --runInBand
  • Step 9: Commit the pure contract slice
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:

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():

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
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

pnpm exec jest app/components/story/NotebookBrowser.test.tsx --runInBand
  • Step 5: Commit the browser surface slice
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:

export type NotebookFallbackProps = {
  projects: NotebookProject[];
  initialIndex?: number;
};

export default function NotebookFallback(props: NotebookFallbackProps): JSX.Element;
  • Step 1: Write failing fallback tests
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
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
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
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:

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:

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
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

pnpm exec jest app/components/story/NotebookStage3D.test.tsx --runInBand
  • Step 5: Run TypeScript and a focused build check
pnpm exec tsc --noEmit
  • Step 6: Commit the 3D stage slice
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:

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
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
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:

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
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
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:

<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
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:

<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
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
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

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

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.