diff --git a/docs/superpowers/plans/2026-05-30-product-film.md b/docs/superpowers/plans/2026-05-30-product-film.md new file mode 100644 index 0000000..dc1ccf5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-product-film.md @@ -0,0 +1,1858 @@ +# LynkedUp Pro Flagship Product Film — 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:** Build a self-contained Remotion project at `marketing/product-film/` that renders a ~5-minute flagship marketing film of the LynkedUp Pro CRM — composed from real captured app screenshots plus animated brand beats — without modifying any CRM code. + +**Architecture:** Two stages. (1) A Playwright **capture script** drives the running CRM (`npm run dev`, port 5173) in dark mode, logs in as the right role, and screenshots each screen listed in a JSON manifest into `public/captures/`. (2) A **Remotion** composition (`LynkedUpProFilm`) arranges those captures inside shared components (synthetic cursor, captions, screen frame, Ken Burns, an animated crane CTA reproducing the landing page's `craneSwing`) into 8 sequences: Cold Open → Promise → 5 value pillars → Crane CTA close. Pure logic (timing math, swing curve, manifest validation) is TDD'd with Vitest; visual components are verified by rendering a still frame; the whole film is verified by a final `remotion render`. + +**Tech Stack:** Remotion 4 (React 19 + TypeScript), Vitest (unit tests for pure helpers), Playwright (capture). Isolated `package.json` — no dependency on the CRM build. + +**Source spec:** `docs/superpowers/specs/2026-05-30-product-film-design.md` + +**Hard constraints:** +- Do **not** edit anything under `src/` (CRM code). All new files live under `marketing/product-film/`. +- Dark-mode capture. 1920×1080, 30fps. Captions + music now; voiceover later (script lives in the spec §5, mirrored into caption text here). +- Commits: author is the repo's configured identity (Satyam Rastogi). Do **not** add any AI/Claude attribution or co-author trailer to commit messages. + +--- + +## File Structure + +``` +marketing/product-film/ + package.json # remotion, vitest, playwright; scripts: dev, render, still, capture, test + tsconfig.json + remotion.config.ts + .gitignore # ignores public/captures/ and out/ + scripts/ + capture.mjs # Playwright capture pipeline (drives the live CRM) + src/ + index.ts # registerRoot(Root) + Root.tsx # + Film.tsx # of the 8 sequences + + timing.ts # FPS, WIDTH, HEIGHT, DURATIONS, TOTAL_FRAMES (pure) + timing.test.ts + captures.ts # Manifest types + validateManifest() (pure) + captures.test.ts + captures.manifest.json # list of every screenshot to capture + theme.ts # brand tokens (colors, fonts, blueprint grid) + components/ + craneSwing.ts # swingRotation() pendulum curve (pure) + craneSwing.test.ts + Caption.tsx # animated lower-third / center caption + ScreenFrame.tsx # browser-chrome frame around a capture (+ placeholder fallback) + KenBurns.tsx # slow pan/zoom wrapper + Cursor.tsx # synthetic cursor: spring move + click pulse + TypeReveal.tsx # types text into view char-by-char + PillarTitle.tsx # animated section title card + Crane.tsx # blueprint tower-crane SVG (ported from Landing.jsx) + CraneCard.tsx # suspended CTA card; reproduces craneSwing + AudioTrack.tsx # music bed (gated by HAS_MUSIC) + BlueprintBg.tsx # blueprint-grid background for animated beats + sequences/ + ColdOpen.tsx + Promise.tsx + Pillar1Storms.tsx + Pillar2Dispatch.tsx + Pillar3Rails.tsx + Pillar4Control.tsx + Pillar5Profit.tsx + CraneClose.tsx + public/ + audio/ # music.mp3 (dropped in by hand; licensed/royalty-free) + captures/ # screenshots produced by scripts/capture.mjs (gitignored) + out/ # rendered mp4 (gitignored) + README.md # capture → render workflow +``` + +**Capture manifest IDs** (referenced by sequences; produced by the capture script). `role` is one of `owner | agentCody | subCarlos`. + +| id | role | route | purpose / pillar | +|---|---|---|---| +| `storm-intel` | owner | `/owner/storm-intel` | ① storm map | +| `territory-map` | owner | `/owner/maps` | ① territory | +| `pro-canvas` | owner | `/owner/pro-canvas` | ① canvassing | +| `lead-verification` | owner | `/lead-verification` | ① verify | +| `dispatch` | owner | `/owner/dispatch` | ② AI dispatch | +| `people-skills` | owner | `/owner/people` | ② skill scores / ④ people | +| `kanban` | owner | `/owner/kanban` | ② pipeline | +| `settings-access` | owner | `/owner/settings` | ④ roles + ABAC + skill catalog | +| `projects` | owner | `/owner/projects` | ③ project list | +| `estimates` | owner | `/owner/estimates` | ③ estimates | +| `sub-tasks-owner` | owner | `/owner/subcontractor-tasks` | ③ sub tasks (owner view) | +| `owner-snapshot` | owner | `/owner/snapshot` | ⑤ financials/dashboards | +| `leaderboard` | owner | `/admin/leaderboard` | ⑤ leaderboard | +| `rep-leads` | agentCody | `/emp/fa/leads` | ④ boundary: rep sees only own leads | +| `rep-dashboard` | agentCody | `/emp/fa/dashboard` | ④ rep home (no Financials nav) | +| `sub-dashboard` | subCarlos | `/subcontractor/dashboard` | ③/④ sub portal home | +| `sub-projects` | subCarlos | `/subcontractor/projects` | ④ boundary: sub sees only own jobs | + +> Routes verified against `src/App.jsx`. Logins verified against `src/pages/Login.jsx` (Owner `justin`, Agent `LUP-1040`=Cody Tatum, Sub-Con `carlos`=Carlos Mendoza; all password `password`). Theme key `theme` verified against `src/context/ThemeContext.jsx`. + +--- + +## Phase 0 — Scaffold & toolchain + +### Task 1: Scaffold the Remotion project + +**Files:** +- Create: `marketing/product-film/package.json` +- Create: `marketing/product-film/tsconfig.json` +- Create: `marketing/product-film/remotion.config.ts` +- Create: `marketing/product-film/.gitignore` +- Create: `marketing/product-film/src/index.ts` +- Create: `marketing/product-film/src/Root.tsx` +- Create: `marketing/product-film/src/Film.tsx` (temporary trivial body) + +- [ ] **Step 1: Create `package.json`** + +```json +{ + "name": "lynkedup-product-film", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "remotion studio", + "render": "remotion render LynkedUpProFilm out/lynkedup-pro-film.mp4", + "still": "remotion still LynkedUpProFilm out/still.png", + "capture": "node scripts/capture.mjs", + "test": "vitest run" + }, + "dependencies": { + "@remotion/cli": "4.0.286", + "react": "19.0.0", + "react-dom": "19.0.0", + "remotion": "4.0.286" + }, + "devDependencies": { + "@types/react": "19.0.0", + "playwright": "1.49.1", + "typescript": "5.7.2", + "vitest": "2.1.8" + } +} +``` + +- [ ] **Step 2: Create `tsconfig.json`** + +```json +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "noEmit": true, + "lib": ["ES2020", "DOM"] + }, + "include": ["src"] +} +``` + +- [ ] **Step 3: Create `remotion.config.ts`** + +```ts +import { Config } from "@remotion/cli/config"; + +Config.setVideoImageFormat("jpeg"); +Config.setOverwriteOutput(true); +Config.setConcurrency(4); +``` + +- [ ] **Step 4: Create `.gitignore`** + +``` +node_modules/ +out/ +public/captures/ +.remotion/ +``` + +- [ ] **Step 5: Create `src/index.ts`** + +```ts +import { registerRoot } from "remotion"; +import { Root } from "./Root"; + +registerRoot(Root); +``` + +- [ ] **Step 6: Create a trivial `src/Film.tsx` (replaced in Task 11)** + +```tsx +import { AbsoluteFill } from "remotion"; + +export const Film: React.FC = () => { + return ( + + LynkedUp Pro Film + + ); +}; +``` + +- [ ] **Step 7: Create `src/Root.tsx`** + +```tsx +import { Composition } from "remotion"; +import { Film } from "./Film"; + +export const Root: React.FC = () => { + return ( + + ); +}; +``` + +- [ ] **Step 8: Install deps** + +Run: `cd marketing/product-film && npm install` +Expected: dependencies install with no error. + +- [ ] **Step 9: Verify a still renders** + +Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/scaffold.png --frame=0` +Expected: `out/scaffold.png` is written (a dark frame with the title). No error. + +- [ ] **Step 10: Commit** + +```bash +git add marketing/product-film/package.json marketing/product-film/package-lock.json marketing/product-film/tsconfig.json marketing/product-film/remotion.config.ts marketing/product-film/.gitignore marketing/product-film/src/index.ts marketing/product-film/src/Root.tsx marketing/product-film/src/Film.tsx +git commit -m "chore(film): scaffold remotion product-film project" +``` + +--- + +### Task 2: Timing config (TDD) + +**Files:** +- Create: `marketing/product-film/src/timing.ts` +- Test: `marketing/product-film/src/timing.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect } from "vitest"; +import { DURATIONS, TOTAL_FRAMES, FPS, SEQUENCE_ORDER } from "./timing"; + +describe("timing", () => { + it("uses 30 fps", () => { + expect(FPS).toBe(30); + }); + it("has all 8 sequences in order", () => { + expect(SEQUENCE_ORDER).toEqual([ + "coldOpen", "promise", "pillar1", "pillar2", "pillar3", "pillar4", "pillar5", "craneClose", + ]); + }); + it("every duration is a positive integer number of frames", () => { + for (const key of SEQUENCE_ORDER) { + expect(DURATIONS[key]).toBeGreaterThan(0); + expect(Number.isInteger(DURATIONS[key])).toBe(true); + } + }); + it("TOTAL_FRAMES equals 265 seconds at 30fps", () => { + expect(TOTAL_FRAMES).toBe(7950); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd marketing/product-film && npx vitest run src/timing.test.ts` +Expected: FAIL — cannot find module `./timing`. + +- [ ] **Step 3: Implement `timing.ts`** + +```ts +export const FPS = 30; +export const WIDTH = 1920; +export const HEIGHT = 1080; + +const sec = (s: number) => Math.round(s * FPS); + +export const SEQUENCE_ORDER = [ + "coldOpen", + "promise", + "pillar1", + "pillar2", + "pillar3", + "pillar4", + "pillar5", + "craneClose", +] as const; + +export type SequenceKey = (typeof SEQUENCE_ORDER)[number]; + +export const DURATIONS: Record = { + coldOpen: sec(12), + promise: sec(8), + pillar1: sec(45), + pillar2: sec(45), + pillar3: sec(50), + pillar4: sec(45), + pillar5: sec(45), + craneClose: sec(15), +}; + +export const TOTAL_FRAMES = SEQUENCE_ORDER.reduce((acc, k) => acc + DURATIONS[k], 0); +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd marketing/product-film && npx vitest run src/timing.test.ts` +Expected: PASS (4 tests). + +- [ ] **Step 5: Wire the real duration into `Root.tsx`** + +Replace the `durationInFrames={150}` line and add imports: + +```tsx +import { Composition } from "remotion"; +import { Film } from "./Film"; +import { TOTAL_FRAMES, FPS, WIDTH, HEIGHT } from "./timing"; + +export const Root: React.FC = () => { + return ( + + ); +}; +``` + +- [ ] **Step 6: Commit** + +```bash +git add marketing/product-film/src/timing.ts marketing/product-film/src/timing.test.ts marketing/product-film/src/Root.tsx +git commit -m "feat(film): timing config (8 sequences, 265s, 30fps)" +``` + +--- + +## Phase 1 — Capture pipeline + +### Task 3: Capture manifest + validation (TDD) + +**Files:** +- Create: `marketing/product-film/src/captures.manifest.json` +- Create: `marketing/product-film/src/captures.ts` +- Test: `marketing/product-film/src/captures.test.ts` + +- [ ] **Step 1: Create the manifest** + +```json +{ + "viewport": { "width": 1920, "height": 1080, "deviceScaleFactor": 2 }, + "theme": "dark", + "baseUrl": "http://localhost:5173", + "captures": [ + { "id": "storm-intel", "role": "owner", "route": "/owner/storm-intel", "waitMs": 2000 }, + { "id": "territory-map", "role": "owner", "route": "/owner/maps", "waitMs": 2500 }, + { "id": "pro-canvas", "role": "owner", "route": "/owner/pro-canvas", "waitMs": 1800 }, + { "id": "lead-verification", "role": "owner", "route": "/lead-verification", "waitMs": 1800 }, + { "id": "dispatch", "role": "owner", "route": "/owner/dispatch", "waitMs": 2500 }, + { "id": "people-skills", "role": "owner", "route": "/owner/people", "waitMs": 1800 }, + { "id": "kanban", "role": "owner", "route": "/owner/kanban", "waitMs": 1800 }, + { "id": "settings-access", "role": "owner", "route": "/owner/settings", "waitMs": 1800 }, + { "id": "projects", "role": "owner", "route": "/owner/projects", "waitMs": 1800 }, + { "id": "estimates", "role": "owner", "route": "/owner/estimates", "waitMs": 1800 }, + { "id": "sub-tasks-owner", "role": "owner", "route": "/owner/subcontractor-tasks", "waitMs": 1800 }, + { "id": "owner-snapshot", "role": "owner", "route": "/owner/snapshot", "waitMs": 2000 }, + { "id": "leaderboard", "role": "owner", "route": "/admin/leaderboard", "waitMs": 1800 }, + { "id": "rep-leads", "role": "agentCody", "route": "/emp/fa/leads", "waitMs": 1800 }, + { "id": "rep-dashboard", "role": "agentCody", "route": "/emp/fa/dashboard", "waitMs": 1800 }, + { "id": "sub-dashboard", "role": "subCarlos", "route": "/subcontractor/dashboard", "waitMs": 1800 }, + { "id": "sub-projects", "role": "subCarlos", "route": "/subcontractor/projects", "waitMs": 1800 } + ] +} +``` + +- [ ] **Step 2: Write the failing test** + +```ts +import { describe, it, expect } from "vitest"; +import manifest from "./captures.manifest.json"; +import { validateManifest, type Manifest } from "./captures"; + +describe("captures manifest", () => { + it("passes validation", () => { + expect(validateManifest(manifest as Manifest)).toEqual([]); + }); + it("has unique ids", () => { + const ids = (manifest as Manifest).captures.map((c) => c.id); + expect(new Set(ids).size).toBe(ids.length); + }); + it("captures in dark theme", () => { + expect((manifest as Manifest).theme).toBe("dark"); + }); +}); +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `cd marketing/product-film && npx vitest run src/captures.test.ts` +Expected: FAIL — cannot find module `./captures`. + +- [ ] **Step 4: Implement `captures.ts`** + +```ts +export type CaptureRole = "owner" | "agentCody" | "subCarlos"; + +export interface Capture { + id: string; + role: CaptureRole; + route: string; + waitMs?: number; +} + +export interface Manifest { + viewport: { width: number; height: number; deviceScaleFactor: number }; + theme: "dark" | "light"; + baseUrl: string; + captures: Capture[]; +} + +const VALID_ROLES: CaptureRole[] = ["owner", "agentCody", "subCarlos"]; + +export function validateManifest(m: Manifest): string[] { + const errors: string[] = []; + const seen = new Set(); + if (!m.baseUrl?.startsWith("http")) errors.push("baseUrl must be an http(s) URL"); + if (m.theme !== "dark" && m.theme !== "light") errors.push("theme must be dark|light"); + for (const c of m.captures) { + if (!c.id) errors.push("capture missing id"); + if (seen.has(c.id)) errors.push(`duplicate id: ${c.id}`); + seen.add(c.id); + if (!c.route?.startsWith("/")) errors.push(`${c.id}: route must start with "/"`); + if (!VALID_ROLES.includes(c.role)) errors.push(`${c.id}: unknown role "${c.role}"`); + } + return errors; +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `cd marketing/product-film && npx vitest run src/captures.test.ts` +Expected: PASS (3 tests). + +- [ ] **Step 6: Commit** + +```bash +git add marketing/product-film/src/captures.manifest.json marketing/product-film/src/captures.ts marketing/product-film/src/captures.test.ts +git commit -m "feat(film): capture manifest + validation" +``` + +--- + +### Task 4: Playwright capture script + +**Files:** +- Create: `marketing/product-film/scripts/capture.mjs` + +- [ ] **Step 1: Write the capture script** + +```js +// Drives the running CRM (npm run dev) and screenshots each manifest screen in dark mode. +// Prereq: in the CRM repo root run `npm run dev` (serves http://localhost:5173). +import { chromium } from "playwright"; +import { readFile, mkdir } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, ".."); + +// tab label + identifier per role (password is always "password") +const ROLE_LOGIN = { + owner: { tab: "Owner", id: "justin" }, + agentCody: { tab: "Employee", id: "LUP-1040" }, + subCarlos: { tab: "Sub-Con", id: "carlos" }, +}; + +async function login(page, baseUrl, role) { + const creds = ROLE_LOGIN[role]; + if (!creds) throw new Error(`unknown role ${role}`); + await page.goto(`${baseUrl}/login`, { waitUntil: "networkidle" }); + // The role tab appears before the demo chip of the same name in the DOM, so .first() targets the tab. + await page.getByRole("button", { name: creds.tab, exact: true }).first().click(); + await page.fill("#identifier", creds.id); + await page.fill("#password", "password"); + await page.getByRole("button", { name: "Sign In" }).click(); + await page.waitForLoadState("networkidle"); +} + +async function main() { + const manifest = JSON.parse(await readFile(join(ROOT, "src/captures.manifest.json"), "utf8")); + const outDir = join(ROOT, "public/captures"); + await mkdir(outDir, { recursive: true }); + + const browser = await chromium.launch(); + const context = await browser.newContext({ + viewport: { width: manifest.viewport.width, height: manifest.viewport.height }, + deviceScaleFactor: manifest.viewport.deviceScaleFactor, + }); + // Force dark theme before any app code runs. + await context.addInitScript((theme) => { + try { localStorage.setItem("theme", theme); } catch (e) {} + }, manifest.theme); + + const page = await context.newPage(); + + // Group by role so we log in once per role, in manifest order. + let currentRole = null; + for (const cap of manifest.captures) { + if (cap.role !== currentRole) { + await login(page, manifest.baseUrl, cap.role); + currentRole = cap.role; + } + await page.goto(`${manifest.baseUrl}${cap.route}`, { waitUntil: "networkidle" }); + await page.waitForTimeout(cap.waitMs ?? 1500); + const path = join(outDir, `${cap.id}.png`); + await page.screenshot({ path }); // viewport-only → exact 16:9 + console.log(`captured ${cap.id} -> ${path}`); + } + + await browser.close(); + console.log(`\nDone. ${manifest.captures.length} captures written to public/captures/`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +- [ ] **Step 2: Install the Playwright browser** + +Run: `cd marketing/product-film && npx playwright install chromium` +Expected: Chromium downloads successfully. + +- [ ] **Step 3: Start the CRM dev server (separate terminal, from repo root)** + +Run: `npm run dev` +Expected: Vite serves on `http://localhost:5173`. Leave it running. + +- [ ] **Step 4: Run the capture script** + +Run: `cd marketing/product-film && npm run capture` +Expected: console logs `captured -> ...` for all 17 ids; `public/captures/` contains 17 `.png` files. Spot-check `public/captures/owner-snapshot.png` and `public/captures/rep-leads.png` — both should be **dark mode** and show real seeded data (canonical names, positive financials), not a login screen. + +> If any capture shows a login/redirect instead of the page, the role lacks access to that route — re-check the route↔role pairing in the manifest against `src/App.jsx` and adjust the `role` for that capture. + +- [ ] **Step 5: Commit (script only — captures are gitignored)** + +```bash +git add marketing/product-film/scripts/capture.mjs +git commit -m "feat(film): playwright capture pipeline (dark-mode, role-aware)" +``` + +--- + +## Phase 2 — Shared components + +### Task 5: Theme tokens + Caption + +**Files:** +- Create: `marketing/product-film/src/theme.ts` +- Create: `marketing/product-film/src/components/BlueprintBg.tsx` +- Create: `marketing/product-film/src/components/Caption.tsx` + +- [ ] **Step 1: Create `theme.ts`** + +```ts +export const theme = { + bg: "#09090b", + surface: "rgba(255,255,255,0.03)", + border: "rgba(255,255,255,0.10)", + text: "#ffffff", + textDim: "rgba(255,255,255,0.55)", + blue: "#3b82f6", + cyan: "#22d3ee", + emerald: "#10b981", + amber: "#f59e0b", + red: "#ef4444", + font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, sans-serif', + mono: 'ui-monospace, SFMono-Regular, Menlo, monospace', + // gradient used in the landing headline + brandGradient: "linear-gradient(90deg, #60a5fa, #3b82f6, #22d3ee)", +} as const; +``` + +- [ ] **Step 2: Create `BlueprintBg.tsx`** + +```tsx +import { AbsoluteFill } from "remotion"; +import { theme } from "../theme"; + +export const BlueprintBg: React.FC<{ opacity?: number }> = ({ opacity = 0.4 }) => { + const line = "rgba(59,130,246,0.10)"; + return ( + + ); +}; +``` + +- [ ] **Step 3: Create `Caption.tsx`** + +```tsx +import { useCurrentFrame, interpolate, AbsoluteFill } from "remotion"; +import { theme } from "../theme"; + +type Position = "bottom" | "center"; + +export const Caption: React.FC<{ + text: string; + startFrame: number; + durationInFrames: number; + position?: Position; +}> = ({ text, startFrame, durationInFrames, position = "bottom" }) => { + const frame = useCurrentFrame(); + const local = frame - startFrame; + if (local < 0 || local > durationInFrames) return null; + + const opacity = interpolate( + local, + [0, 12, durationInFrames - 12, durationInFrames], + [0, 1, 1, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" } + ); + const y = interpolate(local, [0, 12], [20, 0], { extrapolateRight: "clamp" }); + + return ( + + + {text} + + + ); +}; +``` + +- [ ] **Step 4: Verify it renders (temporary harness)** + +Temporarily set `src/Film.tsx` body to render a caption, then render a still: + +```tsx +import { AbsoluteFill } from "remotion"; +import { BlueprintBg } from "./components/BlueprintBg"; +import { Caption } from "./components/Caption"; + +export const Film: React.FC = () => ( + + + + +); +``` + +Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/caption.png --frame=30` +Expected: `out/caption.png` shows the caption over a blueprint grid. No error. (Film.tsx is replaced in Task 11; this is just a render check.) + +- [ ] **Step 5: Commit** + +```bash +git add marketing/product-film/src/theme.ts marketing/product-film/src/components/BlueprintBg.tsx marketing/product-film/src/components/Caption.tsx +git commit -m "feat(film): theme tokens, blueprint background, caption component" +``` + +--- + +### Task 6: ScreenFrame + KenBurns + +**Files:** +- Create: `marketing/product-film/src/components/ScreenFrame.tsx` +- Create: `marketing/product-film/src/components/KenBurns.tsx` + +- [ ] **Step 1: Create `KenBurns.tsx`** + +```tsx +import { useCurrentFrame, useVideoConfig, interpolate } from "remotion"; + +// Slow zoom/pan over its children across the whole sequence the component lives in. +export const KenBurns: React.FC<{ + children: React.ReactNode; + from?: number; + to?: number; + durationInFrames: number; +}> = ({ children, from = 1.0, to = 1.08, durationInFrames }) => { + const frame = useCurrentFrame(); + const scale = interpolate(frame, [0, durationInFrames], [from, to], { + extrapolateRight: "clamp", + }); + return {children}; +}; +``` + +- [ ] **Step 2: Create `ScreenFrame.tsx`** (native `` with a placeholder fallback so sequences render even before captures exist) + +```tsx +import { useState } from "react"; +import { AbsoluteFill, staticFile } from "remotion"; +import { theme } from "../theme"; + +export const ScreenFrame: React.FC<{ + captureId: string; + // 0..1 inset of the framed screen within the canvas + scale?: number; +}> = ({ captureId, scale = 0.86 }) => { + const [errored, setErrored] = useState(false); + const src = staticFile(`captures/${captureId}.png`); + + return ( + + + {/* browser chrome bar */} + + + + + app.lynkeduppro.com + + + {errored ? ( + + [capture missing: {captureId}] + + ) : ( + setErrored(true)} + style={{ width: "100%", height: "100%", objectFit: "cover", objectPosition: "top center", display: "block" }} + /> + )} + + + + ); +}; +``` + +- [ ] **Step 3: Verify it renders** + +Temporarily set `src/Film.tsx` to `` and run: +Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/frame.png --frame=0` +Expected: shows the `owner-snapshot` capture in a framed browser window (or the `[capture missing]` placeholder if captures not yet run). No error. + +- [ ] **Step 4: Commit** + +```bash +git add marketing/product-film/src/components/ScreenFrame.tsx marketing/product-film/src/components/KenBurns.tsx +git commit -m "feat(film): screen frame (with placeholder fallback) + ken burns" +``` + +--- + +### Task 7: Synthetic Cursor + TypeReveal + +**Files:** +- Create: `marketing/product-film/src/components/Cursor.tsx` +- Create: `marketing/product-film/src/components/TypeReveal.tsx` + +- [ ] **Step 1: Create `Cursor.tsx`** (springs from `from`→`to`, plus a click pulse at `clickAtFrame`) + +```tsx +import { useCurrentFrame, useVideoConfig, spring, interpolate, AbsoluteFill } from "remotion"; +import { theme } from "../theme"; + +type Pt = { x: number; y: number }; // pixels in the 1920x1080 canvas + +export const Cursor: React.FC<{ + from: Pt; + to: Pt; + moveStartFrame: number; + moveDurationFrames?: number; + clickAtFrame?: number; +}> = ({ from, to, moveStartFrame, moveDurationFrames = 22, clickAtFrame }) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + + const p = spring({ + frame: frame - moveStartFrame, + fps, + durationInFrames: moveDurationFrames, + config: { damping: 200 }, + }); + const x = interpolate(p, [0, 1], [from.x, to.x]); + const y = interpolate(p, [0, 1], [from.y, to.y]); + + let pulse = 0; + if (clickAtFrame !== undefined) { + pulse = interpolate(frame - clickAtFrame, [0, 8], [1, 0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + } + + return ( + + + {pulse > 0 && ( + + )} + {/* arrow cursor */} + + + + + + ); +}; +``` + +- [ ] **Step 2: Create `TypeReveal.tsx`** (types `text` across a span of frames; for form-fill beats) + +```tsx +import { useCurrentFrame } from "remotion"; +import { theme } from "../theme"; + +export const TypeReveal: React.FC<{ + text: string; + startFrame: number; + framesPerChar?: number; + style?: React.CSSProperties; +}> = ({ text, startFrame, framesPerChar = 1.6, style }) => { + const frame = useCurrentFrame(); + const shown = Math.max(0, Math.floor((frame - startFrame) / framesPerChar)); + const visible = text.slice(0, shown); + const blinkOn = Math.floor(frame / 15) % 2 === 0; + return ( + + {visible} + {shown < text.length && blinkOn ? | : null} + + ); +}; +``` + +- [ ] **Step 3: Verify it renders** + +Temporarily set `src/Film.tsx` to render a `ScreenFrame` with a `Cursor from={{x:200,y:200}} to={{x:1200,y:700}} moveStartFrame={0} clickAtFrame={25} />`, then: +Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/cursor.png --frame=26` +Expected: cursor visible near (1200,700) with a click pulse. No error. + +- [ ] **Step 4: Commit** + +```bash +git add marketing/product-film/src/components/Cursor.tsx marketing/product-film/src/components/TypeReveal.tsx +git commit -m "feat(film): synthetic cursor + type-reveal" +``` + +--- + +### Task 8: PillarTitle + +**Files:** +- Create: `marketing/product-film/src/components/PillarTitle.tsx` + +- [ ] **Step 1: Create `PillarTitle.tsx`** + +```tsx +import { useCurrentFrame, interpolate, spring, useVideoConfig, AbsoluteFill } from "remotion"; +import { theme } from "../theme"; + +export const PillarTitle: React.FC<{ + index: string; // "①" etc, or "01" + title: string; + subtitle: string; + startFrame: number; + durationInFrames: number; +}> = ({ index, title, subtitle, startFrame, durationInFrames }) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const local = frame - startFrame; + if (local < 0 || local > durationInFrames) return null; + + const enter = spring({ frame: local, fps, config: { damping: 200 }, durationInFrames: 20 }); + const opacity = interpolate(local, [0, 10, durationInFrames - 12, durationInFrames], [0, 1, 1, 0], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + const y = interpolate(enter, [0, 1], [40, 0]); + + return ( + + + {index} + + {title} + + {subtitle} + + + ); +}; +``` + +- [ ] **Step 2: Verify it renders** + +Temporarily set `src/Film.tsx` to `` and run: +Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/title.png --frame=25` +Expected: gradient title card centered. No error. + +- [ ] **Step 3: Commit** + +```bash +git add marketing/product-film/src/components/PillarTitle.tsx +git commit -m "feat(film): pillar title card" +``` + +--- + +### Task 9: Crane swing curve (TDD) + Crane SVG + CraneCard + +**Files:** +- Create: `marketing/product-film/src/components/craneSwing.ts` +- Test: `marketing/product-film/src/components/craneSwing.test.ts` +- Create: `marketing/product-film/src/components/Crane.tsx` +- Create: `marketing/product-film/src/components/CraneCard.tsx` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect } from "vitest"; +import { swingRotation } from "./craneSwing"; + +describe("swingRotation", () => { + it("starts at the +3deg keyframe, amplified by the entry boost", () => { + // t=0: base = +3, boost = 3 -> 9deg + expect(swingRotation(0, 30)).toBeCloseTo(9, 5); + }); + it("settles to within +/-3deg after the settle window", () => { + for (let f = 30 * 3; f < 30 * 12; f++) { + expect(Math.abs(swingRotation(f, 30))).toBeLessThanOrEqual(3.0001); + } + }); + it("is periodic at 4s once settled (same phase 4s apart)", () => { + const a = swingRotation(30 * 4, 30); // t=4s + const b = swingRotation(30 * 8, 30); // t=8s + expect(a).toBeCloseTo(b, 5); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd marketing/product-film && npx vitest run src/components/craneSwing.test.ts` +Expected: FAIL — cannot find module `./craneSwing`. + +- [ ] **Step 3: Implement `craneSwing.ts`** (reproduces the landing `craneSwing`: ±3° / 4s, with a damped entry) + +```ts +// Reproduces the landing page .crane-swing animation: rotate(+3deg) <-> rotate(-3deg), +// 4s ease-in-out loop, transform-origin top center. A cosine approximates the eased pendulum. +// An entry "boost" damps a larger initial swing down to +/-3deg over settleSeconds. +export function swingRotation(frame: number, fps: number, settleSeconds = 2): number { + const t = frame / fps; + const period = 4; // seconds + const base = 3 * Math.cos((2 * Math.PI) / period * t); // +3 at t=0 + const boost = 1 + 2 * Math.max(0, 1 - t / settleSeconds); // 3x -> 1x across settle window + return base * boost; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cd marketing/product-film && npx vitest run src/components/craneSwing.test.ts` +Expected: PASS (3 tests). + +- [ ] **Step 5: Create `Crane.tsx`** — port the landing crane SVG (`src/pages/Landing.jsx:981-1079`) with the dark-theme `--crane-line-*` values substituted as literals (strong `rgba(255,255,255,0.16)`, medium `0.10`, subtle `0.06`, surface `0.03`). + +```tsx +// Blueprint tower-crane line art, ported from src/pages/Landing.jsx (dark-theme colors inlined). +const STRONG = "rgba(255,255,255,0.16)"; +const MEDIUM = "rgba(255,255,255,0.10)"; +const SUBTLE = "rgba(255,255,255,0.06)"; +const SURFACE = "rgba(255,255,255,0.03)"; + +export const Crane: React.FC<{ width?: number }> = ({ width = 1100 }) => ( + + {/* tower rails */} + + + {[93,131,169,207,245,283,321,359,397,435].map((y) => ( + + ))} + {/* X-bracing */} + {[[93,131],[169,207],[245,283],[321,359],[359,397],[397,435],[435,460]].map(([a,b],i)=>( + + + + + ))} + {/* slewing platform + cab */} + + + + {/* A-frame apex */} + + + + {/* support cables */} + + + {/* counter-jib */} + + + + + + {/* main jib */} + + + + {[[215,260],[280,325],[345,390],[410,455],[475,520],[540,585],[605,650],[670,715]].map(([a,b],i)=>( + + ))} + {/* trolley + hoist cable + hook */} + + + + + +); +``` + +- [ ] **Step 6: Create `CraneCard.tsx`** — the CTA card that descends on the cable and settles into the swing (verbatim landing copy). + +```tsx +import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion"; +import { theme } from "../theme"; +import { swingRotation } from "./craneSwing"; + +export const CraneCard: React.FC<{ dropStartFrame?: number }> = ({ dropStartFrame = 0 }) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const local = frame - dropStartFrame; + + // descend into place over the first ~1.2s + const drop = spring({ frame: local, fps, durationInFrames: 36, config: { damping: 14, mass: 0.8 } }); + const dropY = interpolate(drop, [0, 1], [-220, 0]); + const rotate = swingRotation(Math.max(0, local), fps); + + return ( + + {/* attachment point connecting to the cable */} + + + + ✓ Project Ready + Ready to Build? + + Whether you protect one home or manage a thousand roofs, the future of roofing starts here. + + + Schedule Demo → + Start Free Trial → + + + ); +}; +``` + +- [ ] **Step 7: Commit** + +```bash +git add marketing/product-film/src/components/craneSwing.ts marketing/product-film/src/components/craneSwing.test.ts marketing/product-film/src/components/Crane.tsx marketing/product-film/src/components/CraneCard.tsx +git commit -m "feat(film): crane swing curve (TDD), crane SVG, suspended CTA card" +``` + +--- + +### Task 10: AudioTrack (music bed, gated) + +**Files:** +- Create: `marketing/product-film/src/components/AudioTrack.tsx` + +- [ ] **Step 1: Create `AudioTrack.tsx`** (no-op until a music file is added and `HAS_MUSIC` flipped — keeps render green without the asset) + +```tsx +import { Audio, staticFile, useCurrentFrame, interpolate } from "remotion"; +import { TOTAL_FRAMES } from "../timing"; + +// Flip to true after dropping a royalty-free track at public/audio/music.mp3. +export const HAS_MUSIC = false; + +export const AudioTrack: React.FC = () => { + const frame = useCurrentFrame(); + if (!HAS_MUSIC) return null; + // fade in at start, fade out over the last 2s + const volume = interpolate( + frame, + [0, 30, TOTAL_FRAMES - 60, TOTAL_FRAMES], + [0, 0.7, 0.7, 0], + { extrapolateLeft: "clamp", extrapolateRight: "clamp" } + ); + return ; +}; +``` + +- [ ] **Step 2: Commit** + +```bash +git add marketing/product-film/src/components/AudioTrack.tsx +git commit -m "feat(film): gated audio track with fade in/out" +``` + +--- + +## Phase 3 — Assembly skeleton + +### Task 11: Wire all 8 sequences into the film (stubs first) + +**Files:** +- Create: `marketing/product-film/src/sequences/ColdOpen.tsx` (stub) +- Create: `marketing/product-film/src/sequences/Promise.tsx` (stub) +- Create: `marketing/product-film/src/sequences/Pillar1Storms.tsx` (stub) +- Create: `marketing/product-film/src/sequences/Pillar2Dispatch.tsx` (stub) +- Create: `marketing/product-film/src/sequences/Pillar3Rails.tsx` (stub) +- Create: `marketing/product-film/src/sequences/Pillar4Control.tsx` (stub) +- Create: `marketing/product-film/src/sequences/Pillar5Profit.tsx` (stub) +- Create: `marketing/product-film/src/sequences/CraneClose.tsx` (stub) +- Modify: `marketing/product-film/src/Film.tsx` + +- [ ] **Step 1: Create 8 stub sequence files** + +Each stub renders a `PillarTitle` so the skeleton is legible. Create all 8 with this shape, changing `index`/`title`/`subtitle`/duration constant: + +`ColdOpen.tsx`: +```tsx +import { PillarTitle } from "../components/PillarTitle"; +import { DURATIONS } from "../timing"; +export const ColdOpen: React.FC = () => ( + +); +``` +`Promise.tsx` → `DURATIONS.promise`, title "Promise". +`Pillar1Storms.tsx` → `DURATIONS.pillar1`, index "①", title "Pillar 1". +`Pillar2Dispatch.tsx` → `DURATIONS.pillar2`, index "②", title "Pillar 2". +`Pillar3Rails.tsx` → `DURATIONS.pillar3`, index "③", title "Pillar 3". +`Pillar4Control.tsx` → `DURATIONS.pillar4`, index "④", title "Pillar 4". +`Pillar5Profit.tsx` → `DURATIONS.pillar5`, index "⑤", title "Pillar 5". +`CraneClose.tsx` → `DURATIONS.craneClose`, title "Crane Close". + +(Each is the same 6 lines with substituted props.) + +- [ ] **Step 2: Replace `Film.tsx` with the real assembly** + +```tsx +import { AbsoluteFill, Series } from "remotion"; +import { DURATIONS } from "./timing"; +import { theme } from "./theme"; +import { AudioTrack } from "./components/AudioTrack"; +import { ColdOpen } from "./sequences/ColdOpen"; +import { Promise as PromiseSeq } from "./sequences/Promise"; +import { Pillar1Storms } from "./sequences/Pillar1Storms"; +import { Pillar2Dispatch } from "./sequences/Pillar2Dispatch"; +import { Pillar3Rails } from "./sequences/Pillar3Rails"; +import { Pillar4Control } from "./sequences/Pillar4Control"; +import { Pillar5Profit } from "./sequences/Pillar5Profit"; +import { CraneClose } from "./sequences/CraneClose"; + +export const Film: React.FC = () => { + return ( + + + + + + + + + + + + + + ); +}; +``` + +> Note: `Promise` is a JS global, so it's imported `as PromiseSeq`. + +- [ ] **Step 3: Verify the full skeleton renders end-to-end** + +Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/skeleton-mid.png --frame=4000` +Expected: a stub title card renders (whichever sequence frame 4000 lands in). No error. + +Run: `cd marketing/product-film && npx remotion render LynkedUpProFilm out/skeleton.mp4 --frames=0-90` +Expected: a short mp4 renders without error (proves the Series wiring + duration are valid). + +- [ ] **Step 4: Commit** + +```bash +git add marketing/product-film/src/sequences marketing/product-film/src/Film.tsx +git commit -m "feat(film): assemble 8-sequence series skeleton" +``` + +--- + +## Phase 4 — Build each sequence + +> Pattern for every sequence: a short `PillarTitle` intro (first ~2.5s), then one or more `ScreenFrame` captures under a `KenBurns`, with a `Cursor` choreographing the key action and `Caption`s carrying the message (caption text mirrors the spec §5 VO lines). Cursor coordinates are in 1920×1080 canvas space; pick targets over the relevant UI region in the capture. All frame numbers below are **local** to the sequence (the `Series.Sequence` resets frame to 0). + +### Task 12: ColdOpen + Promise sequences + +**Files:** +- Modify: `marketing/product-film/src/sequences/ColdOpen.tsx` +- Modify: `marketing/product-film/src/sequences/Promise.tsx` + +- [ ] **Step 1: Implement `ColdOpen.tsx`** (landing-hero motif + pain caption) + +```tsx +import { AbsoluteFill, useCurrentFrame, interpolate } from "remotion"; +import { BlueprintBg } from "../components/BlueprintBg"; +import { Caption } from "../components/Caption"; +import { theme } from "../theme"; +import { DURATIONS } from "../timing"; + +export const ColdOpen: React.FC = () => { + const frame = useCurrentFrame(); + const hud = interpolate(frame, [10, 30], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + return ( + + + + + ROOFING + REVOLUTIONIZED + + + {/* HUD annotations echoing the landing hero */} + + ▸ 5,847 PROPERTIES TRACKED + + + ▸ HAIL WATCH ACTIVE · ETA 35 MIN + + + + ); +}; +``` + +- [ ] **Step 2: Implement `Promise.tsx`** (logo/tagline beat) + +```tsx +import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion"; +import { BlueprintBg } from "../components/BlueprintBg"; +import { theme } from "../theme"; +import { DURATIONS } from "../timing"; + +export const Promise: React.FC = () => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + const rise = spring({ frame, fps, durationInFrames: 24, config: { damping: 200 } }); + const y = interpolate(rise, [0, 1], [30, 0]); + const opacity = interpolate(frame, [0, 14, DURATIONS.promise - 12, DURATIONS.promise], [0, 1, 1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" }); + return ( + + + + + + LynkedUp Pro + + From the storm to the bank — in one system. + Your team runs it. You control all of it. + + + + ); +}; +``` + +- [ ] **Step 3: Verify both render** + +Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/coldopen.png --frame=200` +Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/promise.png --frame=380` +Expected: cold-open hero (frame ~200) and promise card (frame ~380) render. No error. + +- [ ] **Step 4: Commit** + +```bash +git add marketing/product-film/src/sequences/ColdOpen.tsx marketing/product-film/src/sequences/Promise.tsx +git commit -m "feat(film): cold open + promise sequences" +``` + +--- + +### Task 13: Pillar 1 — Turn storms into pipeline + +**Files:** +- Modify: `marketing/product-film/src/sequences/Pillar1Storms.tsx` + +- [ ] **Step 1: Implement `Pillar1Storms.tsx`** + +```tsx +import { AbsoluteFill, Sequence } from "remotion"; +import { PillarTitle } from "../components/PillarTitle"; +import { ScreenFrame } from "../components/ScreenFrame"; +import { KenBurns } from "../components/KenBurns"; +import { Cursor } from "../components/Cursor"; +import { Caption } from "../components/Caption"; +import { DURATIONS } from "../timing"; + +const D = DURATIONS.pillar1; +const TITLE = 75; // 2.5s + +export const Pillar1Storms: React.FC = () => { + const afterTitle = D - TITLE; + const half = Math.floor(afterTitle / 2); + return ( + + + + + + {/* Beat A: Storm Intel — zones light up; cursor assigns a zone */} + + + + + + + {/* Beat B: Lead Verification — verify a lead */} + + + + + + + ); +}; +``` + +- [ ] **Step 2: Verify it renders** + +Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/p1.png --frame=750` +Expected: Pillar 1 storm-intel beat with cursor + caption. No error. + +- [ ] **Step 3: Commit** + +```bash +git add marketing/product-film/src/sequences/Pillar1Storms.tsx +git commit -m "feat(film): pillar 1 — storms into pipeline" +``` + +--- + +### Task 14: Pillar 2 — Right person on every job + +**Files:** +- Modify: `marketing/product-film/src/sequences/Pillar2Dispatch.tsx` + +- [ ] **Step 1: Implement `Pillar2Dispatch.tsx`** + +```tsx +import { AbsoluteFill, Sequence } from "remotion"; +import { PillarTitle } from "../components/PillarTitle"; +import { ScreenFrame } from "../components/ScreenFrame"; +import { KenBurns } from "../components/KenBurns"; +import { Cursor } from "../components/Cursor"; +import { Caption } from "../components/Caption"; +import { DURATIONS } from "../timing"; + +const D = DURATIONS.pillar2; +const TITLE = 75; + +export const Pillar2Dispatch: React.FC = () => { + const afterTitle = D - TITLE; + const half = Math.floor(afterTitle / 2); + return ( + + + + + + {/* Beat A: LynkDispatch AI recommendation; cursor assigns */} + + + + + + + {/* Beat B: Pipeline kanban — move a deal forward */} + + + + + + + ); +}; +``` + +- [ ] **Step 2: Verify it renders** + +Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/p2.png --frame=2050` +Expected: Pillar 2 dispatch/kanban beat. No error. + +- [ ] **Step 3: Commit** + +```bash +git add marketing/product-film/src/sequences/Pillar2Dispatch.tsx +git commit -m "feat(film): pillar 2 — right person, automatically" +``` + +--- + +### Task 15: Pillar 3 — Every job runs on rails + +**Files:** +- Modify: `marketing/product-film/src/sequences/Pillar3Rails.tsx` + +- [ ] **Step 1: Implement `Pillar3Rails.tsx`** (3 beats: project lifecycle → estimates → sub portal) + +```tsx +import { AbsoluteFill, Sequence } from "remotion"; +import { PillarTitle } from "../components/PillarTitle"; +import { ScreenFrame } from "../components/ScreenFrame"; +import { KenBurns } from "../components/KenBurns"; +import { Cursor } from "../components/Cursor"; +import { Caption } from "../components/Caption"; +import { DURATIONS } from "../timing"; + +const D = DURATIONS.pillar3; +const TITLE = 75; + +export const Pillar3Rails: React.FC = () => { + const afterTitle = D - TITLE; + const beat = Math.floor(afterTitle / 3); + const last = afterTitle - beat * 2; + return ( + + + + + + + + + + + + + + + + + + {/* Sub portal — sees only their task */} + + + + + + + ); +}; +``` + +- [ ] **Step 2: Verify it renders** + +Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/p3.png --frame=3400` +Expected: Pillar 3 beat renders. No error. + +- [ ] **Step 3: Commit** + +```bash +git add marketing/product-film/src/sequences/Pillar3Rails.tsx +git commit -m "feat(film): pillar 3 — every job runs on rails" +``` + +--- + +### Task 16: Pillar 4 — Scale with control (access boundaries) + +**Files:** +- Modify: `marketing/product-film/src/sequences/Pillar4Control.tsx` + +- [ ] **Step 1: Implement `Pillar4Control.tsx`** (owner sets permission → switch to rep view → boundary visible; then sub portal) + +```tsx +import { AbsoluteFill, Sequence } from "remotion"; +import { PillarTitle } from "../components/PillarTitle"; +import { ScreenFrame } from "../components/ScreenFrame"; +import { KenBurns } from "../components/KenBurns"; +import { Cursor } from "../components/Cursor"; +import { Caption } from "../components/Caption"; +import { DURATIONS } from "../timing"; + +const D = DURATIONS.pillar4; +const TITLE = 75; + +export const Pillar4Control: React.FC = () => { + const afterTitle = D - TITLE; + const beat = Math.floor(afterTitle / 3); + const last = afterTitle - beat * 2; + return ( + + + + + + {/* Owner toggles a module permission */} + + + + + + + {/* Switch to the rep's view — Financials gone, only their leads */} + + + + + + {/* Sub sees only their jobs */} + + + + + + ); +}; +``` + +- [ ] **Step 2: Verify it renders** + +Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/p4.png --frame=4900` +Expected: Pillar 4 beat renders. No error. + +- [ ] **Step 3: Commit** + +```bash +git add marketing/product-film/src/sequences/Pillar4Control.tsx +git commit -m "feat(film): pillar 4 — scale with control (access boundaries)" +``` + +--- + +### Task 17: Pillar 5 — Know your true profit + +**Files:** +- Modify: `marketing/product-film/src/sequences/Pillar5Profit.tsx` + +- [ ] **Step 1: Implement `Pillar5Profit.tsx`** (financials → leaderboard; storm-attribution bookend in caption) + +```tsx +import { AbsoluteFill, Sequence } from "remotion"; +import { PillarTitle } from "../components/PillarTitle"; +import { ScreenFrame } from "../components/ScreenFrame"; +import { KenBurns } from "../components/KenBurns"; +import { Cursor } from "../components/Cursor"; +import { Caption } from "../components/Caption"; +import { DURATIONS } from "../timing"; + +const D = DURATIONS.pillar5; +const TITLE = 75; + +export const Pillar5Profit: React.FC = () => { + const afterTitle = D - TITLE; + const half = Math.floor(afterTitle / 2); + return ( + + + + + + {/* Financial overview — drill into a job */} + + + + + + + {/* Leaderboard — storm attribution bookend */} + + + + + + ); +}; +``` + +- [ ] **Step 2: Verify it renders** + +Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/p5.png --frame=6300` +Expected: Pillar 5 financial beat renders. No error. + +- [ ] **Step 3: Commit** + +```bash +git add marketing/product-film/src/sequences/Pillar5Profit.tsx +git commit -m "feat(film): pillar 5 — know your true profit" +``` + +--- + +### Task 18: CraneClose sequence + +**Files:** +- Modify: `marketing/product-film/src/sequences/CraneClose.tsx` + +- [ ] **Step 1: Implement `CraneClose.tsx`** (crane lowers the swinging CTA card; end plate) + +```tsx +import { AbsoluteFill } from "remotion"; +import { BlueprintBg } from "../components/BlueprintBg"; +import { Crane } from "../components/Crane"; +import { CraneCard } from "../components/CraneCard"; +import { theme } from "../theme"; + +export const CraneClose: React.FC = () => { + return ( + + + {/* crane anchored top; card hangs from its hoist cable */} + + + + + + + + + {/* end plate */} + + LynkedUpPro.com · 866-259-6533 + + + ); +}; +``` + +- [ ] **Step 2: Verify it renders (card descended + swinging)** + +Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/crane-mid.png --frame=7900` +Expected: the crane with the "Ready to Build?" card suspended from the cable, rotated slightly (mid-swing). No error. + +- [ ] **Step 3: Commit** + +```bash +git add marketing/product-film/src/sequences/CraneClose.tsx +git commit -m "feat(film): crane CTA close (swinging suspended card)" +``` + +--- + +## Phase 5 — Audio + final render + +### Task 19: Add music + enable audio + +**Files:** +- Add (by hand): `marketing/product-film/public/audio/music.mp3` +- Modify: `marketing/product-film/src/components/AudioTrack.tsx` + +- [ ] **Step 1: Place a royalty-free track** + +Drop a licensed/royalty-free cinematic-corporate track (≥ 4:30 long) at `marketing/product-film/public/audio/music.mp3`. + +> If no track is available at execution time, **skip this task** — the film renders silently (captions carry the message) and music can be added later. Note the skip in the task report. + +- [ ] **Step 2: Enable audio** + +In `src/components/AudioTrack.tsx`, change: +```tsx +export const HAS_MUSIC = false; +``` +to: +```tsx +export const HAS_MUSIC = true; +``` + +- [ ] **Step 3: Verify audio is referenced without render error** + +Run: `cd marketing/product-film && npx remotion render LynkedUpProFilm out/audio-check.mp4 --frames=0-60` +Expected: short clip renders; the output has an audio track. No error. + +- [ ] **Step 4: Commit** + +```bash +git add marketing/product-film/src/components/AudioTrack.tsx +git commit -m "feat(film): enable music bed" +``` + +--- + +### Task 20: Full render + README + +**Files:** +- Create: `marketing/product-film/README.md` + +- [ ] **Step 1: Run the capture pipeline (if not already current)** + +Start the CRM (`npm run dev` from repo root), then: +Run: `cd marketing/product-film && npm run capture` +Expected: 17 fresh captures in `public/captures/`. + +- [ ] **Step 2: Run the unit tests** + +Run: `cd marketing/product-film && npm test` +Expected: all Vitest suites pass (timing, captures, craneSwing). + +- [ ] **Step 3: Render the full film** + +Run: `cd marketing/product-film && npm run render` +Expected: `out/lynkedup-pro-film.mp4` (~4:25–5:00, 1920×1080, 30fps) renders with no error. Spot-check: cold-open hero, a captured pillar screen with cursor, the rep/sub boundary beats in Pillar 4, and the swinging crane CTA at the end. + +- [ ] **Step 4: Write `README.md`** + +````markdown +# LynkedUp Pro — Product Film (Remotion) + +Flagship ~5-min marketing film, composed from real app screenshots + animated brand beats. +See `docs/superpowers/specs/2026-05-30-product-film-design.md` for the design. + +## Workflow + +1. **Capture** the app (dark mode). From the repo root, start the CRM: + ``` + npm run dev # serves http://localhost:5173 + ``` + Then in this folder: + ``` + npm install + npx playwright install chromium # first time only + npm run capture # writes public/captures/*.png + ``` +2. **Preview** in Remotion Studio: + ``` + npm run dev + ``` +3. **Render**: + ``` + npm run render # -> out/lynkedup-pro-film.mp4 + ``` + +## Notes +- `public/captures/` and `out/` are gitignored (local artifacts). Re-run `npm run capture` to refresh. +- Music: drop a royalty-free track at `public/audio/music.mp3` and set `HAS_MUSIC = true` in `src/components/AudioTrack.tsx`. +- Voiceover: captions ship now; VO script lives in the design spec §5 (record later, drop onto the same timeline). +- Does **not** modify any CRM code — it only screenshots the running app. +```` + +- [ ] **Step 5: Commit** + +```bash +git add marketing/product-film/README.md +git commit -m "docs(film): capture/render workflow readme" +``` + +--- + +## Self-Review + +**1. Spec coverage** (against `2026-05-30-product-film-design.md`): +- §4 structure (Cold Open → Promise → 5 pillars → Crane close) → Tasks 12–18. ✓ +- §5 beat sheet captions/VO → caption text in each sequence task mirrors the §5 lines. ✓ +- §6 feature→pillar coverage → manifest (Task 3) captures every screen; pillars reference them. Storm Intel, Territory Map (`territory-map`), Pro Canvas (`pro-canvas`), Lead Verification ✓①; Dispatch, People/skills, Kanban ✓②; Projects, Estimates, Sub tasks/portal ✓③; Settings/access, rep & sub boundaries, people ✓④; Snapshot, Leaderboard ✓⑤. *(Note: `territory-map`, `pro-canvas`, `people-skills`, `sub-tasks-owner`, `rep-dashboard` are captured and available; the sequence tasks above feature a representative subset per pillar to hold ~5 min. Additional captured screens can be slotted into a pillar's KenBurns beats without new capture work.)* +- §7.1 capture pipeline → Task 4 (dark mode, role logins, manifest-driven). ✓ +- §7.2 Remotion structure/components → Tasks 1–11. ✓ +- §7.3 audio (captions+music, VO later) → Tasks 10, 19; captions in all sequences. ✓ +- §7.4 render 1080p/30fps → Root composition (Task 2) + Task 20. ✓ +- Crane close reproduces `craneSwing` (±3°, 4s, top-center, damped entry) → Task 9 (TDD) + Task 18. ✓ +- No CRM code edits → all paths under `marketing/product-film/`. ✓ + +**2. Placeholder scan:** No "TBD/TODO". Every code step has complete code. The only conditional is Task 19 (skippable if no music asset) — that's an explicit, real fallback (`HAS_MUSIC` flag), not a placeholder. + +**3. Type/name consistency:** `swingRotation(frame, fps, settleSeconds)` consistent (Task 9 → CraneCard). `validateManifest`/`Manifest`/`Capture` consistent (Task 3). `DURATIONS`/`SEQUENCE_ORDER`/`TOTAL_FRAMES` consistent (Task 2 → Root, Film, sequences). `captureId` prop on `ScreenFrame` matches manifest `id`s. `Promise` sequence exported as `Promise`, imported `as PromiseSeq` (Task 11) — consistent. Cursor prop names (`from`,`to`,`moveStartFrame`,`clickAtFrame`) consistent across sequences. + +No gaps found.