docs: plano de implementacao do notebook story
This commit is contained in:
1 parent
7bbdfd9daf
commit
370cc34e16
1 file changed
+830
@@ -0,0 +1,830 @@
|
|||||||
|
# NotebookStory Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Substituir o celular 3D e o filmstrip horizontal por um único palco de notebook que abre, navega por quatro projetos com cursor/tabs e fecha antes de liberar o scroll para `About`.
|
||||||
|
|
||||||
|
**Architecture:** `NotebookStory` será o único orquestrador do scroll-story. `NotebookStage3D` carregará um GLB CC0 local e controlará somente base/tampa/câmera; `NotebookBrowser` será uma superfície DOM 3D acessível via `Html transform`, com screenshots locais e tabs reais. Um conjunto pequeno de funções puras definirá o frame da timeline para que o scrub, o rewind e os testes compartilhem o mesmo contrato.
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js App Router, React 19, TypeScript, Tailwind CSS 4, React Three Fiber, Three.js, Drei, GSAP e ScrollTrigger já instalados. Nenhuma dependência nova.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-08-30-notebook-story-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- O bloco de história terá um único dispositivo e um único Canvas WebGL; a hero atual não ganha outro renderer por causa desta mudança.
|
||||||
|
- A tela não usará iframe nem dependerá de rede para renderizar a narrativa.
|
||||||
|
- Screenshots locais de `public/assets/prints` são a fonte visual; deployments só aparecem no CTA explícito.
|
||||||
|
- O scroll é a fonte de verdade para play/rewind; não introduzir Lenis nem outro listener global de scroll.
|
||||||
|
- WebGL apenas em desktop com hover disponível; mobile, pointer coarse e reduced motion usam fallback DOM/2D.
|
||||||
|
- `prefers-reduced-motion` não inicializa GSAP, ScrollTrigger ou WebGL.
|
||||||
|
- Todas as tabs, painéis e CTAs precisam ser controles DOM acessíveis; o cursor é decorativo.
|
||||||
|
- Projetos exibidos vêm do `PortfolioProject[]` já filtrado pelo snapshot de saúde.
|
||||||
|
- O GLB deve ser local, CC0, otimizado e creditado em `CREDITS.md`.
|
||||||
|
- Não tocar no `globals.css` não rastreado do usuário nem misturar alterações anteriores de `data/project-health.json` e `lib/portfolio-data.ts` nos commits da feature.
|
||||||
|
- Testes novos devem falhar antes da implementação correspondente.
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
- Create: `public/models/laptop-kenney.glb` — modelo local CC0 validado.
|
||||||
|
- Modify: `CREDITS.md` — fonte, autor, licença e modificações do notebook.
|
||||||
|
- Create: `app/components/story/notebookProjects.ts` — ordenação e mapeamento puro de projetos para screenshots.
|
||||||
|
- Test: `app/components/story/notebookProjects.test.ts` — filtragem, ordem e ausência de asset.
|
||||||
|
- Create: `app/components/story/notebookTimeline.ts` — contrato puro `progress -> frame`.
|
||||||
|
- Test: `app/components/story/notebookTimeline.test.ts` — limites, fases, tabs, cursor e rewind.
|
||||||
|
- Create: `app/components/story/NotebookBrowser.tsx` — browser chrome, tablist, painel, cursor e CTA.
|
||||||
|
- Test: `app/components/story/NotebookBrowser.test.tsx` — ARIA, teclado, click e links.
|
||||||
|
- Create: `app/components/story/NotebookFallback.tsx` — mockup sem WebGL para mobile/reduced motion/falha.
|
||||||
|
- Test: `app/components/story/NotebookFallback.test.tsx` — conteúdo e acessibilidade do fallback.
|
||||||
|
- Create: `app/components/story/NotebookStage3D.tsx` — GLB, rig, câmera, luzes, `Html transform` e pause.
|
||||||
|
- Test: `app/components/cinematic/CinematicHome.test.tsx` — composição do novo palco e ordem com `About`.
|
||||||
|
- Create: `app/components/story/NotebookStory.tsx` — runway, ScrollTrigger, seleção de projeto e composição.
|
||||||
|
- Test: `app/components/story/NotebookStory.test.tsx` — modo reduzido, elegibilidade 3D e integração de tabs.
|
||||||
|
- Modify: `app/components/cinematic/CinematicHome.tsx` — usar `NotebookStory` e remover `PrintReel`/`ScrollStory`.
|
||||||
|
- Modify: `app/globals.css` — somente tokens/classes novas do notebook, sem tocar no `globals.css` raiz do usuário.
|
||||||
|
- Delete after migration: `app/components/story/PrintReel.tsx`, `app/components/story/ScrollStory.tsx`, `app/components/story/StoryStage3D.tsx`, `app/components/story/phoneScreenTexture.ts` e testes órfãos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Validar e versionar o modelo CC0
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `public/models/laptop-kenney.glb`
|
||||||
|
- Modify: `CREDITS.md`
|
||||||
|
- Test/verification: script temporário local para ler o JSON interno do GLB; não versionar o script.
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces a local asset with separate logical parts for `base`, `lid`, `screen` and `keyboard`.
|
||||||
|
- Produces the asset provenance used by `NotebookStage3D`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Download the candidate asset locally**
|
||||||
|
|
||||||
|
Use the verified Poly Pizza source page and its static GLB URL from the page metadata. Save the file as `public/models/laptop-kenney.glb`; do not load it remotely from the app.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest `
|
||||||
|
-Uri 'https://static.poly.pizza/8190e659-7079-442f-9ed9-083f67b1746b.glb' `
|
||||||
|
-OutFile 'D:\Documents\Github\portifolio-evandrodevbr\public\models\laptop-kenney.glb'
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Inspect the GLB node and material names before code**
|
||||||
|
|
||||||
|
Read the GLB header and JSON chunk with a one-off Node script. Record the actual node names, parent relationships, bounding boxes and material names in the implementation notes. The script must fail if there is no separate lid-like node.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const buffer = fs.readFileSync('public/models/laptop-kenney.glb');
|
||||||
|
if (buffer.toString('ascii', 0, 4) !== 'glTF') throw new Error('invalid GLB');
|
||||||
|
const jsonLength = buffer.readUInt32LE(12);
|
||||||
|
const json = JSON.parse(buffer.toString('utf8', 20, 20 + jsonLength));
|
||||||
|
console.log(json.nodes.map(({ name, mesh, children }) => ({ name, mesh, children })));
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Confirm the hinge can be controlled**
|
||||||
|
|
||||||
|
The chosen mapping must identify one lid node whose local pivot is near the hinge. If the candidate is a single rigid mesh or the pivot cannot be corrected offline, stop using it and select another CC0 model before continuing; never animate the complete laptop as a fake lid.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add provenance and modification record**
|
||||||
|
|
||||||
|
Append a `3D model — “Laptop”` section to `CREDITS.md` with author Kenney, source Poly Pizza URL, CC0 1.0 URL, local path, and the exact modifications: local copy, material overrides, pivot calibration and screen overlay.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify the asset is not accidentally ignored or oversized**
|
||||||
|
|
||||||
|
Run the repository’s file/status check and inspect the asset size. The GLB must be local, tracked, and below the 2 MB transfer budget from the spec. Do not stage `.next`, logs, diagnostic scripts or the user’s `globals.css`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit the asset slice**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add public/models/laptop-kenney.glb CREDITS.md
|
||||||
|
git commit -m "feat: add cc0 laptop asset for notebook story"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected result: the model is locally loadable, its hinge is known, and provenance is committed before component code depends on it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Define the pure project and timeline contracts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/components/story/notebookProjects.ts`
|
||||||
|
- Test: `app/components/story/notebookProjects.test.ts`
|
||||||
|
- Create: `app/components/story/notebookTimeline.ts`
|
||||||
|
- Test: `app/components/story/notebookTimeline.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
`notebookProjects.ts` produces:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type NotebookProject = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
demoUrl: string;
|
||||||
|
technologies: string[];
|
||||||
|
screenshotSrc: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function selectNotebookProjects(
|
||||||
|
projects: PortfolioProject[],
|
||||||
|
): NotebookProject[];
|
||||||
|
```
|
||||||
|
|
||||||
|
`notebookTimeline.ts` produces:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type NotebookPhase =
|
||||||
|
| "approach"
|
||||||
|
| "open"
|
||||||
|
| "boot"
|
||||||
|
| "cursor-in"
|
||||||
|
| "browse"
|
||||||
|
| "hold"
|
||||||
|
| "close";
|
||||||
|
|
||||||
|
export type NotebookFrame = {
|
||||||
|
phase: NotebookPhase;
|
||||||
|
lidAngle: number;
|
||||||
|
deviceOpacity: number;
|
||||||
|
screenOpacity: number;
|
||||||
|
activeTabIndex: number;
|
||||||
|
cursorVisible: boolean;
|
||||||
|
cursorX: number;
|
||||||
|
cursorY: number;
|
||||||
|
cursorClicking: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getNotebookFrame(progress: number, tabCount: number): NotebookFrame;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing project-map tests**
|
||||||
|
|
||||||
|
Declare one fixture with the exact fields consumed by the selector:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const projectsFixture = [
|
||||||
|
{
|
||||||
|
id: "nutriscan",
|
||||||
|
title: "NutriScan",
|
||||||
|
subtitle: "Análise Nutricional Inteligente",
|
||||||
|
demoUrl: "https://nutriscan.evandro.dev.br",
|
||||||
|
technologies: ["Next.js", "TypeScript"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sc-plus",
|
||||||
|
title: "SCPLUS",
|
||||||
|
subtitle: "Landing Page Institucional",
|
||||||
|
demoUrl: "https://scplus.evandro.dev.br",
|
||||||
|
technologies: ["Next.js", "React"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sev-modaintima",
|
||||||
|
title: "SEV Moda Íntima",
|
||||||
|
subtitle: "Catálogo Digital & E-commerce",
|
||||||
|
demoUrl: "https://sevmodaintima.com.br",
|
||||||
|
technologies: ["Next.js", "Prisma"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "as-servicos",
|
||||||
|
title: "AS Serviços",
|
||||||
|
subtitle: "Presença digital SSR",
|
||||||
|
demoUrl: "https://asservicos.com",
|
||||||
|
technologies: ["Next.js", "SSR"],
|
||||||
|
},
|
||||||
|
] as unknown as PortfolioProject[];
|
||||||
|
```
|
||||||
|
|
||||||
|
Cover one behavior per test:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('returns the four ordered projects that have a mapped screenshot and demo URL', () => {
|
||||||
|
const result = selectNotebookProjects(projectsFixture);
|
||||||
|
expect(result.map((project) => project.id)).toEqual([
|
||||||
|
'nutriscan',
|
||||||
|
'sc-plus',
|
||||||
|
'sev-modaintima',
|
||||||
|
'as-servicos',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits a live project when its screenshot mapping is missing', () => {
|
||||||
|
const result = selectNotebookProjects([
|
||||||
|
...projectsFixture,
|
||||||
|
{ ...projectsFixture[0], id: 'unknown-live-project' },
|
||||||
|
]);
|
||||||
|
expect(result.some((project) => project.id === 'unknown-live-project')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits projects without an approved demo URL', () => {
|
||||||
|
const result = selectNotebookProjects([
|
||||||
|
...projectsFixture,
|
||||||
|
{ ...projectsFixture[0], id: 'twins-lab', demoUrl: null },
|
||||||
|
]);
|
||||||
|
expect(result.some((project) => project.id === 'twins-lab')).toBe(false);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run project-map tests and verify the expected red failure**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec jest app/components/story/notebookProjects.test.ts --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the module and selector do not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the minimal static mapping**
|
||||||
|
|
||||||
|
Use a single ordered map, with no network access:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const SCREENSHOTS = {
|
||||||
|
nutriscan: "/assets/prints/print-nutriscan.png",
|
||||||
|
"sc-plus": "/assets/prints/print-scplus.png",
|
||||||
|
"sev-modaintima": "/assets/prints/print-sev.png",
|
||||||
|
"as-servicos": "/assets/prints/print-asservicos.png",
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
Iterate the fixed order, find the matching project, require a non-empty `demoUrl`, and return the normalized object. Do not mutate the input array.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run project-map tests and verify green**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec jest app/components/story/notebookProjects.test.ts --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all project-map tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Write failing timeline tests at every boundary**
|
||||||
|
|
||||||
|
Cover clamping, phase transitions, four tabs, cursor visibility/click pulse, and reverse determinism:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('clamps progress below zero and starts with a closed device', () => {
|
||||||
|
expect(getNotebookFrame(-1, 4)).toMatchObject({
|
||||||
|
phase: 'approach',
|
||||||
|
lidAngle: 0,
|
||||||
|
activeTabIndex: -1,
|
||||||
|
cursorVisible: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the lid before the browser starts', () => {
|
||||||
|
const frame = getNotebookFrame(0.22, 4);
|
||||||
|
expect(frame.phase).toBe('open');
|
||||||
|
expect(frame.lidAngle).toBeGreaterThan(0);
|
||||||
|
expect(frame.screenOpacity).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects each tab only inside the browse window', () => {
|
||||||
|
expect(getNotebookFrame(0.43, 4).activeTabIndex).toBe(-1);
|
||||||
|
expect(getNotebookFrame(0.48, 4).activeTabIndex).toBe(0);
|
||||||
|
expect(getNotebookFrame(0.57, 4).activeTabIndex).toBe(1);
|
||||||
|
expect(getNotebookFrame(0.67, 4).activeTabIndex).toBe(2);
|
||||||
|
expect(getNotebookFrame(0.76, 4).activeTabIndex).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the device after the final hold', () => {
|
||||||
|
const frame = getNotebookFrame(0.96, 4);
|
||||||
|
expect(frame.phase).toBe('close');
|
||||||
|
expect(frame.lidAngle).toBeLessThan(20);
|
||||||
|
expect(frame.cursorVisible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic when rewound to the same progress', () => {
|
||||||
|
expect(getNotebookFrame(0.61, 4)).toEqual(getNotebookFrame(0.61, 4));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run timeline tests and verify the expected red failure**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec jest app/components/story/notebookTimeline.test.ts --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the timeline module does not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Implement the pure frame function**
|
||||||
|
|
||||||
|
Use the exact spec windows: approach `0.00–0.10`, open `0.10–0.25`, boot `0.25–0.34`, cursor-in `0.34–0.44`, browse `0.44–0.78`, hold `0.78–0.87`, close `0.87–1.00`. Clamp progress and tab count. The active index must be `-1` outside `browse`/`hold` only when no tab is visible; during `hold`, keep the last tab active. Cursor coordinates are normalized `[0, 1]` and use a fixed path per tab; no time-based side effects.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run both pure-contract test files and verify green**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec jest app/components/story/notebookProjects.test.ts app/components/story/notebookTimeline.test.ts --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit the pure contract slice**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/components/story/notebookProjects.ts app/components/story/notebookProjects.test.ts app/components/story/notebookTimeline.ts app/components/story/notebookTimeline.test.ts
|
||||||
|
git commit -m "feat: define notebook project and timeline contracts"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Build the accessible browser surface
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/components/story/NotebookBrowser.tsx`
|
||||||
|
- Test: `app/components/story/NotebookBrowser.test.tsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type NotebookBrowserProps = {
|
||||||
|
projects: NotebookProject[];
|
||||||
|
activeIndex: number;
|
||||||
|
frame: Pick<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:
|
||||||
|
|
||||||
|
```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(/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**
|
||||||
|
|
||||||
|
```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. The test mock must expose the hook used by production:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
jest.mock("@react-three/drei", () => ({
|
||||||
|
...jest.requireActual("@react-three/drei"),
|
||||||
|
useGLTF: jest.fn(() => ({ scene: new THREE.Group() })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedUseGLTF = jest.mocked(useGLTF);
|
||||||
|
|
||||||
|
function mockUseGLTFFailure() {
|
||||||
|
mockedUseGLTF.mockImplementationOnce(() => {
|
||||||
|
throw new Error("GLB failed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run stage tests and verify the expected red failure**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec jest app/components/story/NotebookStage3D.test.tsx --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the GLB rig**
|
||||||
|
|
||||||
|
Implementation requirements:
|
||||||
|
|
||||||
|
- `useGLTF('/models/laptop-kenney.glb')` only; no remote URL.
|
||||||
|
- Clone the scene and apply materials from the notebook color map.
|
||||||
|
- Resolve the validated base/lid/screen/keyboard nodes from Task 1.
|
||||||
|
- Attach the browser surface to a calibrated screen anchor on the lid using Drei `Html transform`.
|
||||||
|
- Apply `getNotebookFrame(progress.current, projects.length)` to lid angle, screen opacity, device opacity and cursor props.
|
||||||
|
- Keep base stable; only lid articulates.
|
||||||
|
- Use `Canvas` with `dpr={[1, 1.5]}`, transparent background, conservative lights and no particle field.
|
||||||
|
- Set `frameloop="never"` when `isVisible` is false; render continuously only while the stage is visible.
|
||||||
|
- Use `Suspense` with a procedural notebook while the GLB loads.
|
||||||
|
- Wrap the Canvas in an outer error boundary; if GLB loading fails, return `NotebookFallback` as DOM outside the Canvas so tabs and CTA remain usable.
|
||||||
|
- Dispose cloned materials/geometries on unmount where the loader clone owns them.
|
||||||
|
- Do not reuse the old phone turntable or `phoneScreenTexture`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run stage tests and verify green**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec jest app/components/story/NotebookStage3D.test.tsx --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run TypeScript and a focused build check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit the 3D stage slice**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/components/story/NotebookStage3D.tsx app/components/story/NotebookStage3D.test.tsx
|
||||||
|
git commit -m "feat: add notebook three-dimensional stage"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Orchestrate scroll, tabs and normal continuation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/components/story/NotebookStory.tsx`
|
||||||
|
- Test: `app/components/story/NotebookStory.test.tsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export type NotebookStoryProps = {
|
||||||
|
projects: PortfolioProject[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function NotebookStory(props: NotebookStoryProps): JSX.Element;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing orchestrator tests**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
it('renders the static fallback when reduced motion is enabled', () => {
|
||||||
|
mockMatchMedia({
|
||||||
|
'(prefers-reduced-motion: reduce)': true,
|
||||||
|
'(pointer: coarse)': false,
|
||||||
|
});
|
||||||
|
render(<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.
|
||||||
Reference in new issue
Block a user