31 KiB
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/printssã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-motionnã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.cssnão rastreado do usuário nem misturar alterações anteriores dedata/project-health.jsonelib/portfolio-data.tsnos 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 puroprogress -> 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 transforme pause. - Test:
app/components/cinematic/CinematicHome.test.tsx— composição do novo palco e ordem comAbout. - 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— usarNotebookStorye removerPrintReel/ScrollStory. - Modify:
app/globals.css— somente tokens/classes novas do notebook, sem tocar noglobals.cssraiz 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.tse testes órfãos.
Task 1: Validar e versionar o modelo CC0
Files:
- Create:
public/models/laptop-kenney.glb - Modify:
CREDITS.md - Test/verification: script temporário local para ler o JSON interno do GLB; não versionar o script.
Interfaces:
-
Produces a local asset with separate logical parts for
base,lid,screenandkeyboard. -
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 node names, parent relationships, bounding boxes and material names in the implementation notes. The script must fail if there is no separate lid-like node.
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: "SCPLUS",
subtitle: "Landing Page Institucional",
demoUrl: "https://scplus.evandro.dev.br",
technologies: ["Next.js", "React"],
},
{
id: "sev-modaintima",
title: "SEV Moda Íntima",
subtitle: "Catálogo Digital & E-commerce",
demoUrl: "https://sevmodaintima.com.br",
technologies: ["Next.js", "Prisma"],
},
{
id: "as-servicos",
title: "AS Serviços",
subtitle: "Presença digital SSR",
demoUrl: "https://asservicos.com",
technologies: ["Next.js", "SSR"],
},
] as unknown as PortfolioProject[];
Cover one behavior per test:
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:
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(/SCPLUS/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"androle="tabpanel". -
Set
aria-selected,aria-controls,aria-labelledby,tabIndexand stable IDs from project IDs. -
Arrow keys are bounded to the first/last tab;
HomeandEndjump 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-hiddenelement withpointer-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. The test mock must expose the hook used by production:
jest.mock("@react-three/drei", () => ({
...jest.requireActual("@react-three/drei"),
useGLTF: jest.fn(() => ({ scene: new THREE.Group() })),
}));
const mockedUseGLTF = jest.mocked(useGLTF);
function mockUseGLTFFailure() {
mockedUseGLTF.mockImplementationOnce(() => {
throw new Error("GLB failed");
});
}
- Step 2: Run stage tests and verify the expected red failure
pnpm exec jest app/components/story/NotebookStage3D.test.tsx --runInBand
- Step 3: Implement the GLB rig
Implementation requirements:
-
useGLTF('/models/laptop-kenney.glb')only; no remote URL. -
Clone the scene and apply materials from the notebook color map.
-
Resolve the validated base/lid/screen/keyboard nodes from Task 1.
-
Attach the browser surface to a calibrated screen anchor on the lid using Drei
Html transform. -
Apply
getNotebookFrame(progress.current, projects.length)to lid angle, screen opacity, device opacity and cursor props. -
Keep base stable; only lid articulates.
-
Use
Canvaswithdpr={[1, 1.5]}, transparent background, conservative lights and no particle field. -
Set
frameloop="never"whenisVisibleis false; render continuously only while the stage is visible. -
Use
Suspensewith a procedural notebook while the GLB loads. -
Wrap the Canvas in an outer error boundary; if GLB loading fails, return
NotebookFallbackas 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 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.tsxcoverage withNotebookStory.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:
- Capture console and page errors before navigation.
- Confirm the notebook is closed at the section start.
- Scroll through
approach,open,boot, cursor entry and each tab. - Confirm browser chrome stays fixed while screenshot content changes.
- Scroll upward and confirm the exact visual sequence rewinds.
- Click a tab and verify smooth seek to its timeline window.
- Use keyboard arrows,
Home,End,EnterandSpace. - Finish the sequence and confirm
Aboutfollows without a stuck pin. - Verify there is no horizontal overflow.
- 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;
-
Aboutfollows in normal document flow. -
Step 6: Measure the runtime performance budget
Use DevTools performance/runtime evidence:
-
one Canvas maximum inside
NotebookStory; -
no deployment
fetchfrom 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.