diff --git a/apps/web/__tests__/unit/homepage-demo-accessibility.test.ts b/apps/web/__tests__/unit/homepage-demo-accessibility.test.ts
new file mode 100644
index 00000000000..4fb1294ba00
--- /dev/null
+++ b/apps/web/__tests__/unit/homepage-demo-accessibility.test.ts
@@ -0,0 +1,34 @@
+import { JSDOM } from "jsdom";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+import { DesktopDemo } from "@/components/pages/HomeTwo/demo/DesktopDemo";
+
+describe("homepage demo keyboard controls", () => {
+ it("keeps hidden controls inert until the visitor starts the tour", () => {
+ const dom = new JSDOM(renderToStaticMarkup(createElement(DesktopDemo)));
+ try {
+ const buttons = [...dom.window.document.querySelectorAll("button")];
+ for (const label of [
+ "Jump to Instant Mode",
+ "Jump to Studio Mode",
+ "Jump to The Editor",
+ "Skip demo",
+ "Restart demo",
+ "Start recording",
+ "Stop recording",
+ "Export the recording",
+ ]) {
+ const button = buttons.find(
+ (candidate) =>
+ (candidate.getAttribute("aria-label") ??
+ candidate.textContent?.trim()) === label,
+ );
+ expect(button, label).toBeDefined();
+ expect(button?.closest("[inert]"), label).not.toBeNull();
+ }
+ } finally {
+ dom.window.close();
+ }
+ });
+});
diff --git a/apps/web/__tests__/unit/homepage-seo.test.ts b/apps/web/__tests__/unit/homepage-seo.test.ts
new file mode 100644
index 00000000000..aeaadf3cc99
--- /dev/null
+++ b/apps/web/__tests__/unit/homepage-seo.test.ts
@@ -0,0 +1,84 @@
+import { readFileSync } from "node:fs";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+import robots from "@/app/robots";
+import { homePageMetadata } from "@/components/pages/HomeTwo/metadata";
+import { HomeTwoSchema } from "@/components/pages/HomeTwo/Schema";
+import { homepageSchema, homepageSeo } from "@/components/pages/HomeTwo/seo";
+import { PRICING } from "@/data/pricing";
+
+describe("homepage SEO", () => {
+ it("uses the public root as the crawlable canonical", async () => {
+ expect(homePageMetadata.alternates?.canonical).toBe("https://cap.so/");
+ expect(homePageMetadata.robots).toMatchObject({
+ index: true,
+ follow: true,
+ });
+ const policy = await robots();
+ const rules = Array.isArray(policy.rules) ? policy.rules : [policy.rules];
+ for (const rule of rules) {
+ if (rule.userAgent !== "*") continue;
+ const disallowed = Array.isArray(rule.disallow)
+ ? rule.disallow
+ : [rule.disallow];
+ expect(disallowed).not.toContain("/");
+ expect(disallowed).not.toContain("/home");
+ }
+ });
+
+ it("connects the website, page, software, and publisher without invented ratings", () => {
+ const graph = homepageSchema["@graph"];
+ const ids = new Set(graph.map((entity) => entity["@id"]));
+ expect(ids.size).toBe(graph.length);
+ const visit = (value: unknown) => {
+ if (Array.isArray(value)) {
+ value.forEach(visit);
+ } else if (value && typeof value === "object") {
+ if ("@id" in value) expect(ids.has(String(value["@id"]))).toBe(true);
+ Object.values(value).forEach(visit);
+ }
+ };
+ visit(graph);
+ const serialized = JSON.stringify(homepageSchema);
+ expect(serialized).not.toContain("aggregateRating");
+ expect(serialized).not.toContain("reviewRating");
+ expect(serialized).not.toContain("FAQPage");
+ expect(serialized).not.toContain("priceValidUntil");
+ });
+
+ it("uses the displayed plan prices and supported desktop platforms", () => {
+ const software = homepageSchema["@graph"].find(
+ (entity) => entity["@type"] === "SoftwareApplication",
+ );
+ expect(software?.operatingSystem).toEqual(["macOS", "Windows", "Linux"]);
+ expect(software?.offers).toMatchObject([
+ { name: "Cap Free", price: 0 },
+ { name: "Desktop License", price: PRICING.commercial.lifetime },
+ { name: "Cap Pro", price: PRICING.pro.monthly },
+ ]);
+ });
+
+ it("describes the actual logo dimensions", () => {
+ const logo = homepageSchema["@graph"].find(
+ (entity) => entity["@type"] === "Organization",
+ )?.logo;
+ const image = readFileSync(
+ new URL("../../public/cap-logo.png", import.meta.url),
+ );
+ expect(logo).toMatchObject({
+ width: image.readUInt32BE(16),
+ height: image.readUInt32BE(20),
+ });
+ });
+
+ it("renders valid JSON-LD with the same description as the search metadata", () => {
+ const html = renderToStaticMarkup(createElement(HomeTwoSchema));
+ const json = html.match(
+ /
+);
diff --git a/apps/web/components/pages/HomeTwo/Testimonials.tsx b/apps/web/components/pages/HomeTwo/Testimonials.tsx
new file mode 100644
index 00000000000..2cf5e091af7
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/Testimonials.tsx
@@ -0,0 +1,84 @@
+import { classNames } from "@cap/utils/helpers";
+import Image from "next/image";
+import Link from "next/link";
+import { testimonials } from "@/data/testimonials";
+import { Eyebrow } from "./Eyebrow";
+import { BODY_TEXT, BTN_SECONDARY, H_SECTION, MODE_THEME } from "./theme";
+
+const PICKS = [
+ "Olivia",
+ "CJ",
+ "evening kid",
+ "Roger Mattos",
+ "Greg_Ld",
+ "Rohith Gilla",
+ "Hrushi",
+ "Bilal Budhani",
+ "diana",
+];
+
+const QUOTES = PICKS.map((name) =>
+ testimonials.find((item) => item.name === name),
+).filter((item): item is (typeof testimonials)[number] => Boolean(item));
+
+export const Testimonials = () => (
+
+
+
+
Testimonials
+
+ Loved by builders, trusted by teams
+
+
+ Join the thousands who made Cap their daily driver for showing work
+ instead of writing about it.
+
+
+
+
+
+
+
+ Read more testimonials
+
+
+
+
+);
diff --git a/apps/web/components/pages/HomeTwo/Workflow.tsx b/apps/web/components/pages/HomeTwo/Workflow.tsx
new file mode 100644
index 00000000000..716f9227560
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/Workflow.tsx
@@ -0,0 +1,173 @@
+import { classNames } from "@cap/utils/helpers";
+import Link from "next/link";
+import type { ComponentType } from "react";
+import {
+ InstantIcon,
+ ScreenshotIcon,
+ StudioIcon,
+} from "@/components/pages/HomePage/modeIcons";
+import { Eyebrow } from "./Eyebrow";
+import {
+ BAND,
+ BODY_TEXT,
+ BTN_PRIMARY,
+ CARD_BG,
+ grainBg,
+ H_SECTION,
+ MODE_THEME,
+ MONO,
+ type ModeKey,
+ type ModeTheme,
+} from "./theme";
+
+type Mode = {
+ key: ModeKey;
+ name: string;
+ promise: string;
+ steps: string[];
+ bestFor: string;
+ Icon: ComponentType<{ className?: string }>;
+};
+
+const MODES: Mode[] = [
+ {
+ key: "instant",
+ name: "Instant Mode",
+ promise: "From recording to shared in one step",
+ steps: [
+ "Press record. Cap uploads while you talk, so there is nothing to wait for.",
+ "Press stop. The share link is already on your clipboard.",
+ "Paste it anywhere. Viewers watch in the browser, no account needed.",
+ ],
+ bestFor: "Bug reports, quick answers, async standups",
+ Icon: InstantIcon,
+ },
+ {
+ key: "studio",
+ name: "Studio Mode",
+ promise: "Full quality, polished before anyone sees it",
+ steps: [
+ "Record locally in 4K. Screen, camera, and mic each get their own track.",
+ "The editor opens when you stop: backgrounds, auto zoom, cursor effects, captions.",
+ "Export in 4K, or share it as a Cap link like everything else.",
+ ],
+ bestFor: "Product demos, tutorials, launch videos",
+ Icon: StudioIcon,
+ },
+ {
+ key: "screenshot",
+ name: "Screenshot Mode",
+ promise: "Stills that look designed",
+ steps: [
+ "Hit the hotkey and grab any window or area.",
+ "Beautify with one click: background, padding, shadow.",
+ "It's on your clipboard, ready to paste or share as a link.",
+ ],
+ bestFor: "Docs, pull requests, social posts",
+ Icon: ScreenshotIcon,
+ },
+];
+
+const StepRow = ({
+ index,
+ text,
+ theme,
+}: {
+ index: number;
+ text: string;
+ theme: ModeTheme;
+}) => (
+
+
+ {index}
+
+
+ {text}
+
+
+);
+
+export const Workflow = () => (
+ // biome-ignore lint/correctness/useUniqueElementIds: anchor target for the demo's "Learn more"
+
+
+
+
Cap has 3 modes
+
+ One app for every workflow
+
+
+ Record and share with Instant, edit your videos with Studio, or
+ capture and customize images with Screenshot.
+
+
+ Download Cap free
+
+
+
+
+
+ {MODES.map((mode) => {
+ const theme = MODE_THEME[mode.key];
+ return (
+
+
+
+
+
+
+ {mode.name}
+
+
+
+
+ {mode.promise}
+
+
+
+
+
+ {mode.steps.map((text, i) => (
+
+ ))}
+
+
+
+
+ Best for
+
+
+ {mode.bestFor}
+
+
+
+
+
+ );
+ })}
+
+
+
+
+);
diff --git a/apps/web/components/pages/HomeTwo/cursors.tsx b/apps/web/components/pages/HomeTwo/cursors.tsx
new file mode 100644
index 00000000000..6772f35948b
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/cursors.tsx
@@ -0,0 +1,51 @@
+/**
+ * The two pointers Cap Desktop actually ships, traced 1:1 from
+ * `packages/ui-solid/icons/cursor-macos.svg` and `cursor-windows.svg`.
+ *
+ * The only change is the shadow: the source files carry it as an SVG filter
+ * with a hard-coded id, which would collide wherever two cursors render on
+ * the same page, so it is applied in CSS at the use site instead
+ * (`drop-shadow(...)`, matching the 1.5px blur at 60% black).
+ */
+
+type CursorProps = { className?: string };
+
+export const MacCursor = ({ className }: CursorProps) => (
+
+
+
+
+);
+
+export const WindowsCursor = ({ className }: CursorProps) => (
+
+
+
+);
+
+export const CURSOR_RATIO = { macos: 17 / 24, windows: 18 / 25 } as const;
+
+export const PlatformCursor = ({
+ platform,
+ className,
+}: CursorProps & { platform: "macos" | "windows" }) =>
+ platform === "windows" ? (
+
+ ) : (
+
+ );
diff --git a/apps/web/components/pages/HomeTwo/demo/CapEditorWindow.tsx b/apps/web/components/pages/HomeTwo/demo/CapEditorWindow.tsx
new file mode 100644
index 00000000000..51f231fd585
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/demo/CapEditorWindow.tsx
@@ -0,0 +1,831 @@
+"use client";
+
+import { classNames } from "@cap/utils/helpers";
+import Image from "next/image";
+import type { RefObject } from "react";
+import {
+ CapAudioOn,
+ CapCamera,
+ CapClapperboard,
+ CapCrop,
+ CapCursor,
+ CapImage,
+ CapLayoutIcon,
+ CapMessageBubble,
+ CapNext,
+ CapPause,
+ CapPlay,
+ CapPresets,
+ CapPrev,
+ CapRedo,
+ CapScissors,
+ CapTrash,
+ CapUndo,
+ CapUpload,
+ LucideAppWindowMac,
+ LucideBuilding2,
+ LucideChevronDown,
+ LucideClock,
+ LucideFolder,
+ LucideKeyboard,
+ LucidePlus,
+ LucideSearch,
+ LucideZoomIn,
+ LucideZoomOut,
+} from "./capIcons";
+import { TrafficLights, WindowsCaptionControls } from "./chrome";
+import { useVideoAttrs, VIDEO_POSTERS } from "./media";
+import { useIsWindowsDemo } from "./platform";
+
+const C = {
+ gray1: "#fcfcfc",
+ gray2: "#f9f9f9",
+ gray3: "#f0f0f0",
+ gray4: "#e8e8e8",
+ gray5: "#e0e0e0",
+ gray6: "#d9d9d9",
+ gray9: "#8d8d8d",
+ gray10: "#838383",
+ gray11: "#646464",
+ gray12: "#202020",
+ blue9: "#0090ff",
+ trackClip: "#3f8ae0",
+ trackZoom: "#4a4f5c",
+};
+
+const WALLPAPERS = [
+ "sf",
+ "nyc",
+ "miami",
+ "monaco",
+ "london",
+ "rome",
+ "santorini",
+].map((city) => ({
+ id: city,
+ thumb: `/backgrounds/thumbs/${city}.webp`,
+ full: `/backgrounds/${city}.webp`,
+}));
+
+const WALLPAPER_THEMES = [
+ "macOS",
+ "Dark",
+ "Blue",
+ "Cities",
+ "Purple",
+ "Orange",
+];
+
+const EditorButton = ({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) => (
+
+ {children}
+
+);
+
+const Field = ({
+ icon,
+ label,
+ children,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ children?: React.ReactNode;
+}) => (
+
+
+
+ {icon}
+
+
+ {label}
+
+
+ {children}
+
+);
+
+const SliderRow = ({ fill, anchor }: { fill: number; anchor?: string }) => (
+
+);
+
+const Tab = ({
+ icon,
+ selected,
+ disabled,
+}: {
+ icon: React.ReactNode;
+ selected?: boolean;
+ disabled?: boolean;
+}) => (
+
+ {selected ? (
+
+ ) : null}
+
+ {icon}
+
+
+);
+
+const WAVE_HEIGHTS = Array.from({ length: 90 }, (_, i) => {
+ const a = Math.sin(i * 0.55) * 0.5 + 0.5;
+ const b = Math.sin(i * 1.7 + 2) * 0.5 + 0.5;
+ return 0.15 + 0.75 * (0.4 * a + 0.6 * b);
+});
+
+export type EditorUi = {
+ visible: boolean;
+ bgIndex: number;
+ playing: boolean;
+ padding?: number;
+ radius?: number;
+ zoomSegments?: boolean;
+};
+
+export const CapEditorWindow = ({
+ ui,
+ width,
+ height,
+ videoRef,
+ camVideoRef,
+ playheadRef,
+ timeRef,
+ canvasRef,
+ canvasChildren,
+ onSwatch,
+ onExport,
+ onTogglePlay,
+}: {
+ ui: EditorUi;
+ width: number;
+ height: number;
+ videoRef: RefObject;
+ camVideoRef: RefObject;
+ playheadRef: RefObject;
+ timeRef: RefObject;
+ canvasRef?: RefObject;
+ canvasChildren?: React.ReactNode;
+ onSwatch: (index: number) => void;
+ onExport: () => void;
+ onTogglePlay: () => void;
+}) => {
+ const isWindows = useIsWindowsDemo();
+ const screenVideo = useVideoAttrs(VIDEO_POSTERS.screen, ui.visible);
+ const cameraVideo = useVideoAttrs(VIDEO_POSTERS.webcam, ui.visible);
+ const scale = width / 1275;
+ const wallpaper = WALLPAPERS[ui.bgIndex] ?? WALLPAPERS[0];
+ const padding = ui.padding ?? 0.35;
+ const radius = ui.radius ?? 0.5;
+
+ return (
+
+
+
+
+
+ {isWindows ? null : }
+
+
+
+
+
+
+
+ Dashboard walkthrough
+ .cap
+
+
+
+
+
+
+ Presets
+
+
+
+
+ Cap
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Clips
+
+
+
+ Export
+
+ {isWindows ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ Auto
+
+
+
+
+ Crop
+
+
+
+ Frame
+
+
+
+
+
+ Preview quality
+
+
+ Full
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {canvasChildren}
+
+
+
+
+
+
+
+ 0:00.00
+ / 0:32.00
+
+
+
+
+
+ {ui.playing ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } selected />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
}
+ label="Background Image"
+ >
+
+
+ {["Desktop", "Wallpaper", "Image"].map((label) => (
+
+ ))}
+
+
+ {["Color", "Gradient", "None"].map((label) => (
+
+ ))}
+
+
+
+
+
+ {WALLPAPER_THEMES.map((label) => (
+
+ {label}
+
+ ))}
+
+
+ {WALLPAPERS.map((item, i) => (
+ onSwatch(i)}
+ className="aspect-square cursor-pointer overflow-hidden rounded-lg transition-[box-shadow,transform] duration-150 hover:scale-[1.06]"
+ style={{
+ boxShadow:
+ ui.bgIndex === i
+ ? "0 0 0 2px #e5e7eb, 0 0 0 4px #6b7280"
+ : undefined,
+ }}
+ >
+
+
+ ))}
+
+
+
+
+
+
}
+ label="Padding"
+ >
+
+
+
}
+ label="Rounded Corners"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Add track
+
+
+
+
+ {[0, 1, 2, 3, 4, 5, 6].map((s) => (
+
+ {`0:${String(s * 5).padStart(2, "0")}`}
+
+
+ ))}
+
+
+
+
+
+
+ Video
+
+
+
+
+ Clip
+
+
+ 0:32
+ 1x
+
+
+
+ {WAVE_HEIGHTS.map((h, i) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ Zoom
+
+
+ {ui.zoomSegments ? (
+ [
+ { left: "9%", width: "22%" },
+ { left: "44%", width: "26%" },
+ ].map((segment) => (
+
+
+ Auto
+
+ ))
+ ) : (
+
+ Click to generate zoom segments
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const SourceTile = ({
+ label,
+ selected,
+}: {
+ label: string;
+ selected?: boolean;
+}) => (
+
+
+ {label}
+
+);
diff --git a/apps/web/components/pages/HomeTwo/demo/CapRecorderWindow.tsx b/apps/web/components/pages/HomeTwo/demo/CapRecorderWindow.tsx
new file mode 100644
index 00000000000..22655f11ba1
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/demo/CapRecorderWindow.tsx
@@ -0,0 +1,485 @@
+"use client";
+
+import { classNames } from "@cap/utils/helpers";
+import { useState } from "react";
+import {
+ CapCamera,
+ CapChevronDown,
+ CapFilmCut,
+ CapInfo,
+ CapInstant,
+ CapLogoFull,
+ CapMicrophone,
+ CapScreenshot,
+ LucideAppWindowMac,
+ LucideBell,
+ LucideCircleHelp,
+ LucideImage,
+ LucideMaximize2,
+ LucideScanText,
+ LucideSettings,
+ LucideSquarePlay,
+ LucideVideo,
+ MdiMonitor,
+ MsScreenshotFrame,
+ PhMonitorBold,
+} from "./capIcons";
+import { TrafficLights, WindowsCaptionControls } from "./chrome";
+import { useIsWindowsDemo } from "./platform";
+
+export type RecorderMode = "instant" | "studio";
+
+export type RecorderUi = {
+ visible: boolean;
+ mode: RecorderMode;
+ displaySelected: boolean;
+ cameraOn: boolean;
+};
+
+const C = {
+ gray1: "#fcfcfc",
+ gray2: "#f9f9f9",
+ gray3: "#f0f0f0",
+ gray4: "#e8e8e8",
+ gray5: "#e0e0e0",
+ gray6: "#d9d9d9",
+ gray7: "#cecece",
+ gray8: "#bbbbbb",
+ gray10: "#838383",
+ gray11: "#646464",
+ gray12: "#202020",
+ blue3: "#e6f4fe",
+ blue8: "#5eb1ef",
+ blue9: "#0090ff",
+ blue10: "#0588f0",
+ blue11: "#0d74ce",
+};
+
+type HoverMode = RecorderMode | "screenshot";
+
+const MODE_HOVERCARDS: Record<
+ HoverMode,
+ { label: string; description: string }
+> = {
+ instant: {
+ label: "Instant mode",
+ description:
+ "Uploads while you record and gives you a link to share when you stop.",
+ },
+ studio: {
+ label: "Studio mode",
+ description:
+ "Saves recordings on your computer and opens the editor when you stop.",
+ },
+ screenshot: {
+ label: "Screenshot mode",
+ description:
+ "Capture a window or area, adjust the background, and copy the image to your clipboard.",
+ },
+};
+
+const HeaderIconButton = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+
+const InfoPill = ({ on }: { on: boolean }) => (
+
+ {on ? "On" : "Off"}
+
+);
+
+const DeviceRow = ({
+ icon,
+ label,
+ on,
+ showSettings,
+ anchor,
+ ariaLabel,
+ onClick,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ on: boolean;
+ showSettings?: boolean;
+ anchor?: string;
+ ariaLabel: string;
+ onClick: () => void;
+}) => (
+
+
+ {icon}
+
+
+ {label}
+
+
+ {showSettings ? (
+
+
+
+ ) : null}
+
+
+
+);
+
+const TargetTile = ({
+ icon,
+ name,
+ selected,
+ withDropdown,
+ anchor,
+ onClick,
+}: {
+ icon: React.ReactNode;
+ name: string;
+ selected?: boolean;
+ withDropdown?: boolean;
+ anchor?: string;
+ onClick: () => void;
+}) => (
+
+
+
+ {icon}
+
+
+ {name}
+
+
+ {withDropdown ? (
+
+
+
+ ) : null}
+
+);
+
+const ModeButton = ({
+ selected,
+ hovered,
+ anchor,
+ label,
+ onClick,
+ onHover,
+ children,
+}: {
+ selected: boolean;
+ hovered: boolean;
+ anchor: string;
+ label: string;
+ onClick: () => void;
+ onHover: (over: boolean) => void;
+ children: React.ReactNode;
+}) => (
+ onHover(true)}
+ onMouseLeave={() => onHover(false)}
+ className="relative flex size-7 cursor-pointer items-center justify-center rounded-full transition-all duration-200"
+ style={{
+ background: selected || hovered ? C.gray7 : C.gray3,
+ boxShadow: selected
+ ? `0 0 0 1px ${C.gray1}, 0 0 0 3px #3b82f6`
+ : undefined,
+ }}
+ >
+ {children}
+
+);
+
+export const CapRecorderWindow = ({
+ ui,
+ onMode,
+ onSelectDisplay,
+ onToggleCamera,
+ onMiss,
+}: {
+ ui: RecorderUi;
+ onMode: (mode: RecorderMode) => void;
+ onSelectDisplay: () => void;
+ onToggleCamera: () => void;
+ onMiss: () => void;
+}) => {
+ const isWindows = useIsWindowsDemo();
+ const [hoverMode, setHoverMode] = useState(null);
+ const hovercard = hoverMode ? MODE_HOVERCARDS[hoverMode] : null;
+
+ const hover = (mode: HoverMode) => (over: boolean) =>
+ setHoverMode((prev) => (over ? mode : prev === mode ? null : prev));
+
+ return (
+
+
+
+ {isWindows ? null : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isWindows ?
: null}
+
+
+
+
+
+
+
+ Personal
+
+
+
+
+
+
+
+
onMode("instant")}
+ onHover={hover("instant")}
+ >
+
+
+
onMode("studio")}
+ onHover={hover("studio")}
+ >
+
+
+
+
+
+
+
+
+
+
+ {hovercard?.label ?? ""}
+
+
+ {hovercard?.description ?? ""}
+
+
+
+
+ Quality settings
+
+
+
+
+
+
+
+
+ }
+ name="Display"
+ selected={ui.displaySelected}
+ withDropdown
+ anchor="target-display"
+ onClick={onSelectDisplay}
+ />
+ }
+ name="Window"
+ withDropdown
+ onClick={onMiss}
+ />
+
+
+ }
+ name="Area"
+ onClick={onMiss}
+ />
+ }
+ name="Camera Only"
+ onClick={onMiss}
+ />
+
+
+
+
+ }
+ label={ui.cameraOn ? "MacBook Pro Camera" : "No Camera"}
+ on={ui.cameraOn}
+ showSettings={ui.cameraOn}
+ anchor="row-camera"
+ ariaLabel={ui.cameraOn ? "Turn camera off" : "Turn camera on"}
+ onClick={onToggleCamera}
+ />
+ }
+ label={isWindows ? "Microphone Array" : "MacBook Pro Microphone"}
+ on
+ showSettings
+ anchor="row-mic"
+ ariaLabel="Microphone"
+ onClick={onMiss}
+ />
+ }
+ label="Record System Audio"
+ on
+ ariaLabel="System audio"
+ onClick={onMiss}
+ />
+
+
+
+
+ );
+};
diff --git a/apps/web/components/pages/HomeTwo/demo/CapShareWindow.tsx b/apps/web/components/pages/HomeTwo/demo/CapShareWindow.tsx
new file mode 100644
index 00000000000..5d17c242550
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/demo/CapShareWindow.tsx
@@ -0,0 +1,239 @@
+"use client";
+
+import { classNames } from "@cap/utils/helpers";
+import type { RefObject } from "react";
+import { useState } from "react";
+import { CapLogoMark, CapPlay } from "./capIcons";
+import { TrafficLights, WindowsCaptionControls } from "./chrome";
+import { useVideoAttrs, VIDEO_POSTERS } from "./media";
+import { useIsWindowsDemo } from "./platform";
+
+export const CapShareWindow = ({
+ visible,
+ width,
+ height,
+ commentVisible,
+ videoRef,
+ title = "Dashboard walkthrough",
+ url = "cap.link/dashboard-walkthrough",
+ duration = "0:32",
+}: {
+ visible: boolean;
+ width: number;
+ height: number;
+ commentVisible: boolean;
+ videoRef: RefObject;
+ title?: string;
+ url?: string;
+ duration?: string;
+}) => {
+ const isWindows = useIsWindowsDemo();
+ const screenVideo = useVideoAttrs(VIDEO_POSTERS.screen, visible);
+ const [reacted, setReacted] = useState>({});
+
+ return (
+
+
+
+ {isWindows ? null :
}
+
+
+ {isWindows ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ {title}
+
+
+ Richie · just now · {duration}
+
+
+
+ Share
+
+
+
+
+
+
+
+
+
+
+
+ 0:11 / {duration}
+
+
+
+
+
+
+ {["👍", "🔥", "❤️"].map((emoji) => (
+
+ setReacted((prev) => ({ ...prev, [emoji]: !prev[emoji] }))
+ }
+ className={classNames(
+ "flex h-7 cursor-pointer items-center gap-1 rounded-full border px-2.5 text-[12px] transition-colors duration-150",
+ reacted[emoji]
+ ? "border-[#5eb1ef] bg-[#e6f4fe]"
+ : "border-[rgba(0,0,0,0.08)] hover:bg-[rgba(17,17,17,0.04)]",
+ )}
+ style={{ color: "rgba(17,17,17,0.7)" }}
+ >
+ {emoji}
+
+ {(emoji === "🔥" ? 2 : 1) + (reacted[emoji] ? 1 : 0)}
+
+
+ ))}
+
+
+ 3 views
+
+
+
+
+
+ S
+
+
+
+ Sofia
+
+ at 0:12
+
+
+
+ Perfect — shipping this today 🔥
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/components/pages/HomeTwo/demo/CapSurfaces.tsx b/apps/web/components/pages/HomeTwo/demo/CapSurfaces.tsx
new file mode 100644
index 00000000000..85576b285f3
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/demo/CapSurfaces.tsx
@@ -0,0 +1,447 @@
+"use client";
+
+import { classNames } from "@cap/utils/helpers";
+import type { RefObject } from "react";
+import type { RecorderMode } from "./CapRecorderWindow";
+import {
+ CapCamera,
+ CapCaretDown,
+ CapFilmCut,
+ CapGear,
+ CapInfo,
+ CapInstant,
+ CapLogoMark,
+ CapMicrophone,
+ CapMoreVertical,
+ CapPauseCircle,
+ CapPlay,
+ CapRestart,
+ CapSettingsGear,
+ CapStopCircle,
+ CapTrash,
+ CapX,
+} from "./capIcons";
+import { useVideoAttrs, VIDEO_POSTERS } from "./media";
+import { OS_FONT, useIsWindowsDemo } from "./platform";
+
+const ToolButton = ({
+ label,
+ onClick,
+ children,
+}: {
+ label: string;
+ onClick: () => void;
+ children: React.ReactNode;
+}) => (
+
+ {children}
+
+);
+
+export const RecordingToolbar = ({
+ visible,
+ paused,
+ timerRef,
+ onStop,
+ onTogglePause,
+ onRestart,
+ onMiss,
+}: {
+ visible: boolean;
+ paused: boolean;
+ timerRef: RefObject;
+ onStop: () => void;
+ onTogglePause: () => void;
+ onRestart: () => void;
+ onMiss: () => void;
+}) => (
+
+
+
+
+
+
+ 0:00
+
+
+
+
+
+
+
+
+
+
+
+ {paused ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export const CameraWindow = ({
+ visible,
+ videoRef,
+}: {
+ visible: boolean;
+ videoRef: RefObject;
+}) => {
+ const videoAttrs = useVideoAttrs(VIDEO_POSTERS.webcam, visible);
+ return (
+
+ );
+};
+
+const GLASS: React.CSSProperties = {
+ background: "rgba(252,252,252,0.82)",
+ border: "1px solid rgba(32,32,32,0.1)",
+ boxShadow:
+ "0 20px 25px -5px rgba(0,0,0,0.2), 0 8px 10px -6px rgba(0,0,0,0.2)",
+};
+
+const OverlayDeviceRow = ({
+ icon,
+ label,
+ on,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ on: boolean;
+}) => (
+
+
+ {icon}
+
+
+ {label}
+
+
+ {on ? "On" : "Off"}
+
+
+);
+
+export const TargetOverlayPanel = ({
+ visible,
+ mode,
+ cameraOn,
+ onStart,
+ onClose,
+}: {
+ visible: boolean;
+ mode: RecorderMode;
+ cameraOn: boolean;
+ onStart: () => void;
+ onClose: () => void;
+}) => {
+ const isWindows = useIsWindowsDemo();
+ const modeLabel = mode === "instant" ? "Instant" : "Studio";
+ return (
+
+
+
+
+
+
+
+
+
+
+ {mode === "instant" ? (
+
+ ) : (
+
+ )}
+
+
+ Start Recording
+
+
+ {modeLabel} Mode
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ label={
+ cameraOn
+ ? isWindows
+ ? "Integrated Webcam"
+ : "MacBook Pro Camera"
+ : "No Camera"
+ }
+ on={cameraOn}
+ />
+ }
+ label={isWindows ? "Microphone Array" : "MacBook Pro Microphone"}
+ on
+ />
+
+
+
+
+
+
+
+ What is
+ {modeLabel} Mode ?
+
+
+
+ );
+};
+
+export const LinkNotification = ({
+ visible,
+ onOpen,
+}: {
+ visible: boolean;
+ onOpen: () => void;
+}) => {
+ const isWindows = useIsWindowsDemo();
+ return (
+
+ {isWindows ? (
+
+
+
+
+ Cap
+
+
+
+ now
+
+
+
+ Link copied
+
+
+ The share link is on your clipboard. Click to open it.
+
+
+ ) : (
+
+
+
+
+ Link Copied
+
+
+ Link copied to clipboard
+
+
+
+ now
+
+
+ )}
+
+ );
+};
diff --git a/apps/web/components/pages/HomeTwo/demo/DesktopDemo.tsx b/apps/web/components/pages/HomeTwo/demo/DesktopDemo.tsx
new file mode 100644
index 00000000000..afbe89cf5ed
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/demo/DesktopDemo.tsx
@@ -0,0 +1,1516 @@
+"use client";
+
+import { classNames } from "@cap/utils/helpers";
+import { useDetectPlatform } from "hooks/useDetectPlatform";
+import { MousePointerClick, RotateCcw } from "lucide-react";
+import Image from "next/image";
+import Link from "next/link";
+import {
+ memo,
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useReducer,
+ useRef,
+ useState,
+} from "react";
+import { PlatformCursor } from "../cursors";
+import {
+ BTN_PRIMARY,
+ BTN_SECONDARY,
+ CARD_BG,
+ grainBg,
+ MODE_THEME,
+ type ModeTheme,
+} from "../theme";
+import { useInView, usePageVisible } from "../visibility";
+import { CapEditorWindow, type EditorUi } from "./CapEditorWindow";
+import {
+ CapRecorderWindow,
+ type RecorderMode,
+ type RecorderUi,
+} from "./CapRecorderWindow";
+import { CapShareWindow } from "./CapShareWindow";
+import {
+ CameraWindow,
+ LinkNotification,
+ RecordingToolbar,
+ TargetOverlayPanel,
+} from "./CapSurfaces";
+import { CapClapperboard, CapFilmCut, CapInstant } from "./capIcons";
+import { ContentWindow, DesktopFiles, Dock, MenuBar } from "./MacDesktop";
+import { type DemoPlatform, DemoPlatformProvider } from "./platform";
+import { WinDesktopFiles, WinTaskbar } from "./WindowsShell";
+
+const MemoizedEditorWindow = memo(CapEditorWindow);
+const MemoizedShareWindow = memo(CapShareWindow);
+const MemoizedContentWindow = memo(ContentWindow);
+
+const STAGE_W = 1360;
+
+const LAPTOP = {
+ w: 1470,
+ h: 920,
+ bodyX: 33,
+ bodyW: 1404,
+ bodyH: 894,
+ screenX: 55,
+ screenY: 22,
+ screenW: 1360,
+ screenH: 850,
+ baseY: 894,
+ baseH: 26,
+};
+
+const POS = {
+ content: { left: 80, top: 58, width: 760, height: 560 },
+ recorder: { left: 906, top: 96 },
+ camera: { left: 1040, top: 474 },
+ toolbar: { left: (STAGE_W - 296) / 2, top: 646 },
+ overlay: { left: (STAGE_W - 416) / 2, top: 388 },
+ notification: { left: STAGE_W - 344 - 18, top: 14 },
+ share: { left: (STAGE_W - 720) / 2, top: 40, width: 720, height: 622 },
+ editor: { left: (STAGE_W - 1080) / 2, top: 14, width: 1080, height: 678 },
+};
+
+/**
+ * Windows toasts rise from the bottom right, above the taskbar, where macOS
+ * banners drop in at the top right. Layer space, so the 28px the windows
+ * layer is already offset by is taken out.
+ */
+const WIN_NOTIFICATION_TOP = LAPTOP.screenH - 28 - 48 - 16 - 112;
+
+const IDLE_RECORDER = { left: (STAGE_W - 330) / 2, top: 185 };
+
+const clamp = (v: number, lo: number, hi: number) =>
+ Math.min(hi, Math.max(lo, v));
+
+const formatClock = (totalSeconds: number) => {
+ const s = Math.max(0, Math.floor(totalSeconds));
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
+};
+
+type Stage =
+ | "recorder"
+ | "overlay"
+ | "recording"
+ | "shared"
+ | "editor"
+ | "done";
+
+type DemoState = {
+ step: number;
+ stage: Stage;
+ mode: RecorderMode;
+ displaySelected: boolean;
+ cameraOn: boolean;
+ notification: boolean;
+ shareVisible: boolean;
+ comment: boolean;
+ bgIndex: number;
+ paused: boolean;
+ editorPlaying: boolean;
+
+ r1Started: boolean;
+ r1Stopped: boolean;
+ shareSeen: boolean;
+ r2Started: boolean;
+ r2Stopped: boolean;
+ swatched: boolean;
+ interacted: boolean;
+
+ nudge: number;
+};
+
+type Action =
+ | { type: "mode"; mode: RecorderMode }
+ | { type: "display" }
+ | { type: "closeOverlay" }
+ | { type: "start" }
+ | { type: "stop" }
+ | { type: "togglePause" }
+ | { type: "openShare" }
+ | { type: "commentIn" }
+ | { type: "continue" }
+ | { type: "toggleCamera" }
+ | { type: "swatch"; index: number }
+ | { type: "export" }
+ | { type: "toggleEditorPlay" }
+ | { type: "jump"; phase: number }
+ | { type: "skip" }
+ | { type: "replay" }
+ | { type: "miss" };
+
+const TOTAL_STEPS = 13;
+
+const STEP_DONE: ((s: DemoState) => boolean)[] = [
+ (s) => s.mode === "instant",
+ (s) => s.displaySelected,
+ (s) => s.r1Started,
+ (s) => s.r1Stopped,
+ (s) => s.shareVisible,
+ (s) => s.shareSeen,
+ (s) => s.mode === "studio",
+ (s) => s.cameraOn,
+ (s) => s.displaySelected,
+ (s) => s.r2Started,
+ (s) => s.r2Stopped,
+ (s) => s.swatched,
+ () => false, // export jumps straight to done
+];
+
+const advance = (s: DemoState): DemoState => {
+ let step = s.step;
+ while (step < TOTAL_STEPS && STEP_DONE[step]?.(s)) step++;
+ return step === s.step ? s : { ...s, step };
+};
+
+const INITIAL: DemoState = {
+ step: 0,
+ stage: "recorder",
+ mode: "studio",
+ displaySelected: false,
+ cameraOn: false,
+ notification: false,
+ shareVisible: false,
+ comment: false,
+ bgIndex: 0,
+ paused: false,
+ editorPlaying: false,
+ r1Started: false,
+ r1Stopped: false,
+ shareSeen: false,
+ r2Started: false,
+ r2Stopped: false,
+ swatched: false,
+ interacted: false,
+ nudge: 0,
+};
+
+const PHASE_STATES: DemoState[] = [
+ INITIAL,
+ {
+ ...INITIAL,
+ step: 6,
+ mode: "instant",
+ r1Started: true,
+ r1Stopped: true,
+ shareSeen: true,
+ interacted: true,
+ },
+ {
+ ...INITIAL,
+ step: 11,
+ stage: "editor",
+ mode: "studio",
+ cameraOn: true,
+ r1Started: true,
+ r1Stopped: true,
+ shareSeen: true,
+ r2Started: true,
+ r2Stopped: true,
+ editorPlaying: true,
+ interacted: true,
+ },
+];
+
+const miss = (s: DemoState): DemoState => ({
+ ...s,
+ nudge: s.nudge + 1,
+ interacted: true,
+});
+
+const reducer = (s: DemoState, a: Action): DemoState => {
+ switch (a.type) {
+ case "mode": {
+ if (s.stage !== "recorder" && s.stage !== "overlay") return miss(s);
+ if (a.mode === s.mode) return s;
+
+ const allowed = a.mode === "instant" ? s.step < 6 : s.step >= 6;
+ if (!allowed) return miss(s);
+ return advance({ ...s, mode: a.mode, interacted: true });
+ }
+ case "display": {
+ if (s.stage === "overlay") return s;
+ if (s.stage !== "recorder") return miss(s);
+ return advance({
+ ...s,
+ displaySelected: true,
+ stage: "overlay",
+ interacted: true,
+ });
+ }
+ case "closeOverlay": {
+ if (s.stage !== "overlay") return s;
+ return {
+ ...s,
+ stage: "recorder",
+ displaySelected: false,
+ step: Math.min(s.step, s.r1Stopped ? 8 : 1),
+ interacted: true,
+ };
+ }
+ case "start": {
+ if (s.stage !== "overlay") return s;
+ if (s.step !== 2 && s.step !== 9) return miss(s);
+ const second = s.r1Stopped;
+ return advance({
+ ...s,
+ stage: "recording",
+ paused: false,
+ r1Started: s.r1Started || !second,
+ r2Started: s.r2Started || second,
+ interacted: true,
+ });
+ }
+ case "stop": {
+ if (s.stage !== "recording") return s;
+ if (s.mode === "instant") {
+ return advance({
+ ...s,
+ stage: "shared",
+ notification: true,
+ r1Stopped: true,
+ displaySelected: false,
+ paused: false,
+ interacted: true,
+ });
+ }
+ return advance({
+ ...s,
+ stage: "editor",
+ r2Stopped: true,
+ displaySelected: false,
+ paused: false,
+ editorPlaying: true,
+ interacted: true,
+ });
+ }
+ case "togglePause":
+ return s.stage === "recording"
+ ? { ...s, paused: !s.paused, interacted: true }
+ : s;
+ case "openShare":
+ if (!s.notification) return s;
+ return advance({
+ ...s,
+ shareVisible: true,
+ notification: false,
+ interacted: true,
+ });
+ case "commentIn":
+ return { ...s, comment: true };
+ case "continue":
+ return advance({
+ ...s,
+ shareSeen: true,
+ shareVisible: false,
+ comment: false,
+ stage: "recorder",
+ interacted: true,
+ });
+ case "toggleCamera": {
+ if (s.stage !== "recorder" && s.stage !== "overlay") return miss(s);
+ return advance({ ...s, cameraOn: !s.cameraOn, interacted: true });
+ }
+ case "swatch":
+ if (s.stage !== "editor") return s;
+ return advance({
+ ...s,
+ bgIndex: a.index,
+ swatched: true,
+ interacted: true,
+ });
+ case "export":
+ if (s.stage !== "editor") return s;
+ return {
+ ...s,
+ stage: "done",
+ step: TOTAL_STEPS,
+ swatched: true,
+ interacted: true,
+ };
+ case "toggleEditorPlay":
+ return s.stage === "editor" || s.stage === "done"
+ ? { ...s, editorPlaying: !s.editorPlaying, interacted: true }
+ : s;
+ case "jump":
+ return PHASE_STATES[a.phase] ?? INITIAL;
+ case "skip":
+ return {
+ ...(PHASE_STATES[2] ?? INITIAL),
+ stage: "done",
+ step: TOTAL_STEPS,
+ swatched: true,
+ };
+ case "replay":
+ return INITIAL;
+ case "miss":
+ return miss(s);
+ }
+};
+
+type TourStep = {
+ text: string;
+
+ anchor?: string;
+
+ pad?: number;
+
+ dim?: boolean;
+
+ bx: number;
+ by: number;
+
+ continueLabel?: string;
+};
+
+const TOUR: TourStep[] = [
+ {
+ text: "Select Instant Mode to record and share a video.",
+ anchor: "mode-instant",
+ bx: 640,
+ by: 420,
+ },
+ {
+ text: "Click Display to record the whole screen.",
+ anchor: "target-display",
+ bx: 620,
+ by: 480,
+ },
+ { text: "Click Start Recording.", anchor: "overlay-start", bx: 360, by: 645 },
+ {
+ text: "Cap uploads while you record. Click Stop to finish.",
+ anchor: "toolbar-stop",
+ dim: false,
+ bx: 400,
+ by: 550,
+ },
+ {
+ text: "Cap copies a share link when you stop. Click the notification to open the video.",
+ anchor: "notification",
+ bx: 800,
+ by: 170,
+ },
+ {
+ text: "Viewers can watch, comment, and react in their browser.",
+ anchor: "share-window",
+ pad: 6,
+ bx: 1180,
+ by: 400,
+ continueLabel: "Try Studio Mode",
+ },
+ {
+ text: "Select Studio Mode to edit a recording before sharing it.",
+ anchor: "mode-studio",
+ bx: 640,
+ by: 420,
+ },
+ {
+ text: "Turn on the camera to record yourself alongside your screen.",
+ anchor: "row-camera",
+ bx: 600,
+ by: 645,
+ },
+ {
+ text: "Click Display to select your screen.",
+ anchor: "target-display",
+ bx: 620,
+ by: 480,
+ },
+ {
+ text: "Studio saves the recording on your computer. Click Start Recording.",
+ anchor: "overlay-start",
+ bx: 360,
+ by: 645,
+ },
+ {
+ text: "Click Stop to open your recording in the editor.",
+ anchor: "toolbar-stop",
+ dim: false,
+ bx: 400,
+ by: 550,
+ },
+ {
+ text: "Choose a wallpaper to change the video's background.",
+ anchor: "editor-swatches",
+ bx: 620,
+ by: 620,
+ },
+ {
+ text: "Click Export to finish the demo. In Cap, you can save a video file or share a link.",
+ anchor: "editor-export",
+ bx: 860,
+ by: 230,
+ },
+];
+
+type InfoSpot = {
+ key: string;
+ anchor: string;
+ title: string;
+ text: string;
+
+ fx: number;
+ fy: number;
+ side: "above" | "below" | "left" | "right";
+ when: (s: DemoState) => boolean;
+};
+
+const onDesktop = (s: DemoState) =>
+ s.stage === "recorder" || s.stage === "overlay";
+
+const INFO_SPOTS: InfoSpot[] = [
+ {
+ key: "modes",
+ anchor: "mode-info",
+ title: "Recording modes",
+ text: "Use Instant for quick video sharing, Studio for editing recordings, or Screenshot for still images.",
+ fx: 0.5,
+ fy: 0.5,
+ side: "below",
+ when: onDesktop,
+ },
+ {
+ key: "mic",
+ anchor: "row-mic",
+ title: "Audio tracks",
+ text: "Studio saves your microphone and system audio separately so you can adjust their volume in the editor.",
+ fx: 1,
+ fy: 0,
+ side: "below",
+ when: onDesktop,
+ },
+ {
+ key: "camera",
+ anchor: "camera-window",
+ title: "Camera preview",
+ text: "See your camera while recording. Studio saves it separately so you can adjust its size and position later.",
+ fx: 0.85,
+ fy: 0.1,
+ side: "left",
+ when: (s) => s.cameraOn && (onDesktop(s) || s.stage === "recording"),
+ },
+ {
+ key: "tools",
+ anchor: "toolbar-tools",
+ title: "Recording controls",
+ text: "Pause and resume from the toolbar. Try the pause button.",
+ fx: 1,
+ fy: 0,
+ side: "above",
+ when: (s) => s.stage === "recording",
+ },
+ {
+ key: "reactions",
+ anchor: "share-reactions",
+ title: "Comments and reactions",
+ text: "Viewers can leave feedback at a specific point in the video. Click an emoji to add a reaction.",
+ fx: 1,
+ fy: 0.2,
+ side: "above",
+ when: (s) => s.shareVisible,
+ },
+ {
+ key: "tracks",
+ anchor: "editor-timeline",
+ title: "Separate tracks",
+ text: "Studio records your screen, camera, and audio separately, so you can edit them after recording.",
+ fx: 0.5,
+ fy: 0.06,
+ side: "below",
+ when: (s) => s.stage === "editor",
+ },
+];
+
+type Caption = {
+ key: string;
+ theme: ModeTheme;
+ chip: string;
+ Icon: React.ComponentType<{
+ className?: string;
+ style?: React.CSSProperties;
+ }>;
+};
+
+const CAPTIONS: [Caption, Caption, Caption] = [
+ {
+ key: "instant",
+ theme: MODE_THEME.instant,
+ chip: "Instant Mode",
+ Icon: CapInstant,
+ },
+ {
+ key: "studio",
+ theme: MODE_THEME.studio,
+ chip: "Studio Mode",
+ Icon: CapFilmCut,
+ },
+ {
+ key: "editor",
+ theme: MODE_THEME.share,
+ chip: "The Editor",
+ Icon: CapClapperboard,
+ },
+];
+
+const phaseOf = (step: number) => (step >= 11 ? 2 : step >= 6 ? 1 : 0);
+
+const PHASE_TICKS = [6 / TOTAL_STEPS, 11 / TOTAL_STEPS];
+
+type Mark = { x: number; y: number; w: number; h: number };
+
+export const DesktopDemo = ({
+ startRequested = false,
+}: {
+ startRequested?: boolean;
+}) => {
+ const frameBoxRef = useRef(null);
+ const inView = useInView(frameBoxRef, "0px");
+ const pageVisible = usePageVisible();
+ const active = inView && pageVisible;
+ const screenRef = useRef(null);
+ const timerRef = useRef(null);
+ const contentScrollRef = useRef(null);
+ const editorTimeRef = useRef(null);
+ const playheadRef = useRef(null);
+ const cameraVideoRef = useRef(null);
+ const shareVideoRef = useRef(null);
+ const editorVideoRef = useRef(null);
+ const editorCamRef = useRef(null);
+ const typedRef = useRef(null);
+ const clockRef = useRef({ accum: 0, last: 0 });
+ const playOffsetRef = useRef(0);
+ const marksSigRef = useRef("");
+
+ /** Null until the first fit: the laptop stays invisible so it can't paint
+ at a guessed size and visibly re-scale once measured. */
+ const [scale, setScale] = useState(null);
+ const [state, dispatch] = useReducer(reducer, INITIAL);
+ const [marks, setMarks] = useState>({});
+ const [openSpot, setOpenSpot] = useState(null);
+
+ const [idle, setIdle] = useState(true);
+
+ const { platform } = useDetectPlatform();
+ const [osOverride, setOsOverride] = useState(null);
+ const demoPlatform: DemoPlatform =
+ osOverride ?? (platform === "windows" ? "windows" : "macos");
+ const isWindows = demoPlatform === "windows";
+ const switchOs = useCallback(
+ () => setOsOverride(isWindows ? "macos" : "windows"),
+ [isWindows],
+ );
+
+ const startDemo = useCallback(() => setIdle(false), []);
+ useEffect(() => {
+ if (startRequested) setIdle(false);
+ }, [startRequested]);
+
+ useEffect(() => {
+ const onStart = () => setIdle(false);
+ window.addEventListener("ht-demo-start", onStart);
+ return () => window.removeEventListener("ht-demo-start", onStart);
+ }, []);
+
+ const send = useCallback((action: Action) => {
+ setOpenSpot(null);
+ dispatch(action);
+ }, []);
+ const chooseWallpaper = useCallback(
+ (index: number) => send({ type: "swatch", index }),
+ [send],
+ );
+ const exportRecording = useCallback(() => send({ type: "export" }), [send]);
+ const toggleEditorPlayback = useCallback(
+ () => send({ type: "toggleEditorPlay" }),
+ [send],
+ );
+
+ useLayoutEffect(() => {
+ const box = frameBoxRef.current;
+ if (!box) return;
+ const fitLaptop = () => {
+ const r = box.getBoundingClientRect();
+
+ setScale(
+ Math.min(
+ 0.82,
+ 0.95 * Math.min(r.width / LAPTOP.w, r.height / LAPTOP.h),
+ ),
+ );
+ };
+ fitLaptop();
+ const ro = new ResizeObserver(fitLaptop);
+ ro.observe(box);
+ return () => ro.disconnect();
+ }, []);
+
+ /* Resolve the anchors the current state cares about (the objective + the
+ visible info spots) into screen-space rects. Re-measured a few times
+ after every state change so entrance transitions settle. */
+ const stepDef = state.step < TOTAL_STEPS ? TOUR[state.step] : undefined;
+ const visibleSpots = useMemo(
+ () => INFO_SPOTS.filter((spot) => spot.when(state)),
+ [state],
+ );
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: scale/idle/demoPlatform re-trigger measurement after the laptop refits, the recorder slides home, or the shell swaps
+ useEffect(() => {
+ const wanted = [
+ ...(stepDef?.anchor ? [stepDef.anchor] : []),
+ ...visibleSpots.map((spot) => spot.anchor),
+ ];
+ const measure = () => {
+ const screen = screenRef.current;
+ if (!screen) return;
+ const rect = screen.getBoundingClientRect();
+ if (rect.width < 10) return;
+ const s = rect.width / LAPTOP.screenW;
+ const next: Record = {};
+ for (const anchor of wanted) {
+ const el = screen.querySelector(
+ `[data-demo-anchor="${anchor}"]`,
+ );
+ if (!el) continue;
+ const r = el.getBoundingClientRect();
+ next[anchor] = {
+ x: (r.left - rect.left) / s,
+ y: (r.top - rect.top) / s,
+ w: r.width / s,
+ h: r.height / s,
+ };
+ }
+ const sig = JSON.stringify(next);
+ if (sig !== marksSigRef.current) {
+ marksSigRef.current = sig;
+ setMarks(next);
+ }
+ };
+ measure();
+ if (idle) return;
+ const t1 = setTimeout(measure, 300);
+ const t2 = setTimeout(measure, 700);
+ return () => {
+ clearTimeout(t1);
+ clearTimeout(t2);
+ };
+ }, [stepDef, visibleSpots, scale, idle, demoPlatform]);
+
+ /* Press record and it is recording: no 3-2-1. Every fresh take starts from
+ a zeroed clock with the recorded window scrolled back to the top. Keyed
+ on the stage alone, so pause and resume never reset it. */
+ useEffect(() => {
+ if (state.stage !== "recording") return;
+ clockRef.current = { accum: 0, last: 0 };
+ if (timerRef.current) timerRef.current.textContent = "0:00";
+ if (contentScrollRef.current)
+ contentScrollRef.current.style.transform = "translate3d(0, 0, 0)";
+ }, [state.stage]);
+
+ useEffect(() => {
+ if (state.stage !== "recording" || state.paused) return;
+ clockRef.current.last = performance.now();
+ return () => {
+ const c = clockRef.current;
+ c.accum += performance.now() - c.last;
+ c.last = 0;
+ };
+ }, [state.stage, state.paused]);
+
+ useEffect(() => {
+ if (state.stage !== "recording" || !active) return;
+ const paint = () => {
+ const c = clockRef.current;
+ const elapsed = c.accum + (state.paused ? 0 : performance.now() - c.last);
+ const secs = elapsed / 1000;
+ const text = formatClock(secs);
+ if (timerRef.current && timerRef.current.textContent !== text)
+ timerRef.current.textContent = text;
+ if (contentScrollRef.current)
+ contentScrollRef.current.style.transform = `translate3d(0, ${-Math.min(
+ 240,
+ secs * 14,
+ )}px, 0)`;
+ };
+ paint();
+ if (state.paused) return;
+ const id = setInterval(paint, 200);
+ return () => clearInterval(id);
+ }, [state.stage, state.paused, active]);
+
+ const restartClock = useCallback(() => {
+ clockRef.current = { accum: 0, last: performance.now() };
+ if (timerRef.current) timerRef.current.textContent = "0:00";
+ }, []);
+
+ useEffect(() => {
+ if (!state.shareVisible) return;
+ const id = setTimeout(() => dispatch({ type: "commentIn" }), 1400);
+ return () => clearTimeout(id);
+ }, [state.shareVisible]);
+
+ useEffect(() => {
+ const playing =
+ (state.stage === "editor" || state.stage === "done") &&
+ state.editorPlaying &&
+ active;
+ if (!playing) return;
+ let raf = 0;
+ const t0 = performance.now();
+ const offset = playOffsetRef.current;
+ const loop = (ts: number) => {
+ const frac = (((ts - t0) / 1000 + offset) % 20) / 20;
+ if (playheadRef.current)
+ playheadRef.current.style.transform = `translate3d(${frac * 720}px, 0, 0)`;
+ if (editorTimeRef.current) {
+ const secs = frac * 19;
+ const frames = Math.floor((secs % 1) * 30);
+ const text = `${formatClock(secs)}.${String(frames).padStart(2, "0")}`;
+ if (editorTimeRef.current.textContent !== text)
+ editorTimeRef.current.textContent = text;
+ }
+ raf = requestAnimationFrame(loop);
+ };
+ raf = requestAnimationFrame(loop);
+ return () => {
+ playOffsetRef.current = ((performance.now() - t0) / 1000 + offset) % 20;
+ cancelAnimationFrame(raf);
+ };
+ }, [state.stage, state.editorPlaying, active]);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: a nudge remounts the keyed card, so the typed node must refill
+ useEffect(() => {
+ if (idle) return;
+ const text = TOUR[state.step]?.text;
+ const typed = typedRef.current;
+ if (!text || !typed) return;
+ let i = 0;
+ typed.textContent = "▍";
+ const id = setInterval(() => {
+ i += 2;
+ if (i >= text.length) {
+ typed.textContent = text;
+ clearInterval(id);
+ } else {
+ typed.textContent = `${text.slice(0, i)}▍`;
+ }
+ }, 16);
+ return () => clearInterval(id);
+ }, [state.step, state.nudge, idle]);
+
+ const cameraWindowVisible =
+ state.cameraOn && (onDesktop(state) || state.stage === "recording");
+ const editorVisible = state.stage === "editor" || state.stage === "done";
+ const editorUi = useMemo(
+ () => ({
+ visible: editorVisible,
+ bgIndex: state.bgIndex,
+ playing: state.editorPlaying,
+ }),
+ [editorVisible, state.bgIndex, state.editorPlaying],
+ );
+ useEffect(() => {
+ const sync = (video: HTMLVideoElement | null, shouldPlay: boolean) => {
+ if (!video) return;
+ if (shouldPlay && active) {
+ if (video.paused) video.play().catch(() => {});
+ } else if (!video.paused) {
+ video.pause();
+ }
+ };
+ sync(cameraVideoRef.current, cameraWindowVisible);
+ sync(shareVideoRef.current, state.shareVisible);
+ sync(editorVideoRef.current, editorUi.visible && editorUi.playing);
+ sync(editorCamRef.current, editorUi.visible && editorUi.playing);
+ }, [
+ cameraWindowVisible,
+ state.shareVisible,
+ editorUi.visible,
+ editorUi.playing,
+ active,
+ ]);
+
+ const recorderUi: RecorderUi = {
+ visible: onDesktop(state),
+ mode: state.mode,
+ displaySelected: state.displaySelected,
+ cameraOn: state.cameraOn,
+ };
+ const overlayVisible = state.stage === "overlay";
+ const toolbarVisible = state.stage === "recording";
+ const phase = phaseOf(state.step);
+ const caption = CAPTIONS[phase];
+
+ const objectiveMark = stepDef?.anchor ? marks[stepDef.anchor] : undefined;
+ const pad = stepDef?.pad ?? 10;
+ const dimmed = Boolean(
+ !idle &&
+ objectiveMark &&
+ stepDef &&
+ stepDef.dim !== false &&
+ state.stage !== "done",
+ );
+ /* The notification step is the one place the two shells disagree on where
+ the action happens, so its bubble follows the toast to the bottom. */
+ const notificationStep = stepDef?.anchor === "notification";
+ const bubbleX = isWindows && notificationStep ? 700 : (stepDef?.bx ?? 0);
+ const bubbleY = isWindows && notificationStep ? 620 : (stepDef?.by ?? 0);
+
+ const beaconVisible = Boolean(
+ !idle &&
+ objectiveMark &&
+ stepDef &&
+ !stepDef.continueLabel &&
+ state.stage !== "done",
+ );
+
+ const fitScale = scale ?? 0.6;
+ const laptopStyle = useMemo(
+ () => ({ width: LAPTOP.w * fitScale, height: LAPTOP.h * fitScale }),
+ [fitScale],
+ );
+ const laptopInnerStyle = useMemo(
+ () => ({
+ width: LAPTOP.w,
+ height: LAPTOP.h,
+ transform: `scale(${fitScale})`,
+ transformOrigin: "top left",
+ fontFamily:
+ "var(--font-ht-geist), 'Geist Sans', -apple-system, system-ui, sans-serif",
+ }),
+ [fitScale],
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isWindows ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ send({ type: "mode", mode })}
+ onSelectDisplay={() => send({ type: "display" })}
+ onToggleCamera={() => send({ type: "toggleCamera" })}
+ onMiss={() => send({ type: "miss" })}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ send({ type: "start" })}
+ onClose={() => send({ type: "closeOverlay" })}
+ />
+
+
+
+ send({ type: "stop" })}
+ onTogglePause={() => send({ type: "togglePause" })}
+ onRestart={restartClock}
+ onMiss={() => send({ type: "miss" })}
+ />
+
+
+
+ send({ type: "openShare" })}
+ />
+
+
+
+
+
+ {beaconVisible && objectiveMark ? (
+
+ ) : null}
+
+ {idle
+ ? null
+ : visibleSpots.map((spot) => {
+ const mark = marks[spot.anchor];
+ if (!mark) return null;
+ const x = mark.x + mark.w * spot.fx;
+ const y = mark.y + mark.h * spot.fy;
+ const open = openSpot === spot.key;
+ return (
+
+
+ setOpenSpot((prev) =>
+ prev === spot.key ? null : spot.key,
+ )
+ }
+ className={classNames(
+ "absolute -left-[11px] -top-[11px] flex size-[22px] cursor-pointer items-center justify-center rounded-full text-[12px] font-semibold italic shadow-[0_2px_10px_rgba(17,17,17,0.35)] transition-transform duration-150 hover:scale-110",
+ open
+ ? "bg-[#111111] text-white"
+ : "bg-white text-[#111111]",
+ )}
+ style={{
+ border: "1.5px solid rgba(17,17,17,0.35)",
+ fontFamily: "Georgia, serif",
+ }}
+ >
+ i
+
+ {open ? (
+
+
+ {spot.title}
+
+
+ {spot.text}
+
+
+ ) : null}
+
+ );
+ })}
+
+ {!idle && stepDef && state.stage !== "done" ? (
+
+
+
0
+ ? "animate-[ht-caption-in_300ms_ease-out,ht-demo-wiggle_400ms_ease-out]"
+ : "animate-[ht-caption-in_300ms_ease-out]",
+ )}
+ style={{
+ ...grainBg(CARD_BG),
+ fontFamily:
+ "var(--font-ht-sans), ui-sans-serif, system-ui, sans-serif",
+ }}
+ >
+
+
+ {caption.chip}
+
+
+
+ {state.step + 1}
+
+
+
+ {stepDef.text}
+
+ {stepDef.text}
+
+ {stepDef.continueLabel ? (
+
send({ type: "continue" })}
+ className="mt-3 flex h-9 cursor-pointer items-center gap-1.5 rounded-full bg-[#111111] px-4 text-[14px] font-medium text-white transition-colors duration-150 hover:bg-[#2b2b2b]"
+ >
+ {stepDef.continueLabel}
+ →
+
+ ) : null}
+
+
+
+
+
+ ) : null}
+
+
+
+
send({ type: "replay" })}
+ className={classNames(
+ "absolute bottom-4 left-4 z-[45] flex h-[44px] cursor-pointer items-center gap-2 rounded-full bg-black/55 px-5 text-[17px] font-medium text-white backdrop-blur-md transition-[opacity,background-color] duration-300 hover:bg-black/75 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70",
+ idle ? "pointer-events-none opacity-0" : "opacity-100",
+ )}
+ >
+
+ Restart demo
+
+
+ {/* Idle hover overlay: dim the screen and offer the two ways
+ in. It stops short of the OS bar so the Apple glyph (or
+ Start) can still switch shells before the tour begins. */}
+ {idle ? (
+
+
+
+
+
+ Start interactive demo
+
+ {
+ event.stopPropagation();
+ document
+ .getElementById("workflow")
+ ?.scrollIntoView({ behavior: "smooth" });
+ }}
+ className="pointer-events-auto flex h-[56px] cursor-pointer items-center gap-2 rounded-full border border-white/40 bg-black/30 px-9 text-[20px] font-medium text-white backdrop-blur-md transition-colors duration-200 hover:bg-black/50 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-white/70"
+ >
+ Learn more
+ ↓
+
+
+
+ ) : null}
+
+ {state.stage === "done" ? (
+
+
+
+
+ Demo complete
+
+
+ Try Cap for yourself
+
+
+ Download Cap for macOS or Windows to record, edit, and
+ share your screen.
+
+
+
+ Download Cap free
+
+ send({ type: "replay" })}
+ className={classNames(
+ BTN_SECONDARY,
+ "cursor-pointer",
+ )}
+ >
+ Replay the demo
+
+
+
+
+ ) : null}
+
+ {isWindows ? null : (
+
+ )}
+
+
+
+
+
+
+
+
+
+ {CAPTIONS.map((c, i) => (
+ send({ type: "jump", phase: i })}
+ aria-label={`Jump to ${c.chip}`}
+ aria-current={i === phase ? "true" : undefined}
+ className={classNames(
+ "flex h-9 cursor-pointer items-center gap-1.5 rounded-full px-3 text-[13px] font-medium transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#111111]",
+ i === phase
+ ? "text-[#111111]"
+ : "text-[rgba(17,17,17,0.45)] hover:bg-[#EDF1F6] hover:text-[#111111]",
+ )}
+ style={i === phase ? { background: c.theme.pill } : undefined}
+ >
+
+ {c.chip}
+
+ ))}
+
+
+
+ {PHASE_TICKS.map((t) => (
+
+ ))}
+
+
+ {Math.min(state.step + 1, TOTAL_STEPS)} / {TOTAL_STEPS}
+
+
send({ type: "skip" })}
+ className="flex h-8 cursor-pointer items-center gap-1 rounded-full px-2.5 text-[13px] font-medium text-[rgba(17,17,17,0.45)] transition-colors duration-200 hover:bg-[#EDF1F6] hover:text-[#111111] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#111111]"
+ >
+ Skip demo
+
+
+
+
+ );
+};
diff --git a/apps/web/components/pages/HomeTwo/demo/MacDesktop.tsx b/apps/web/components/pages/HomeTwo/demo/MacDesktop.tsx
new file mode 100644
index 00000000000..d8aef112420
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/demo/MacDesktop.tsx
@@ -0,0 +1,572 @@
+"use client";
+
+import { classNames } from "@cap/utils/helpers";
+import type { RefObject } from "react";
+import { AppleGlyph } from "../glyphs";
+import { CapLogoMark } from "./capIcons";
+import {
+ OsSwitchButton,
+ TrafficLights,
+ WindowsCaptionControls,
+} from "./chrome";
+import { useIsWindowsDemo } from "./platform";
+
+const MenuGlyph = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+export const MenuBar = ({
+ recording,
+ onSwitchOs,
+}: {
+ recording: boolean;
+ onSwitchOs: () => void;
+}) => (
+
+
+
+
+
Cap
+ {["File", "Edit", "View", "Window", "Help"].map((item) => (
+
+ {item}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Thu 20 Aug 9:41 AM
+
+
+);
+
+const FolderIcon = () => (
+
+
+
+
+
+);
+
+export const CapFileIcon = () => (
+
+
+
+
+);
+
+export const ImageFileIcon = () => (
+
+
+
+
+);
+
+const DesktopFile = ({
+ icon,
+ label,
+}: {
+ icon: React.ReactNode;
+ label: string;
+}) => (
+
+ {icon}
+
+ {label}
+
+
+);
+
+export const DesktopFiles = () => (
+
+ } label="Recordings" />
+ } label="team-update.cap" />
+ } label="Q3 launch.png" />
+
+);
+
+const FinderIcon = () => (
+
+
+
+
+
+
+
+);
+
+const SafariIcon = () => (
+
+
+
+
+
+
+ {/* biome-ignore lint/correctness/useUniqueElementIds: single-instance decorative svg defs */}
+
+
+
+
+
+
+);
+
+const MessagesIcon = () => (
+
+
+
+
+ {/* biome-ignore lint/correctness/useUniqueElementIds: single-instance decorative svg defs */}
+
+
+
+
+
+
+);
+
+const NotesIcon = () => (
+
+
+
+
+
+
+);
+
+const TrashIcon = () => (
+
+
+
+
+
+ {/* biome-ignore lint/correctness/useUniqueElementIds: single-instance decorative svg defs */}
+
+
+
+
+
+
+);
+
+const DockApp = ({
+ children,
+ running,
+}: {
+ children: React.ReactNode;
+ running?: boolean;
+}) => (
+
+
+ {children}
+
+
+
+);
+
+export const Dock = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+const SkeletonBar = ({
+ w,
+ tone = "rgba(17,17,17,0.08)",
+ h = 8,
+}: {
+ w: number | string;
+ tone?: string;
+ h?: number;
+}) => (
+
+);
+
+export const ContentWindow = ({
+ width,
+ height,
+ scrollRef,
+}: {
+ width: number;
+ height: number;
+ scrollRef: RefObject;
+}) => {
+ const isWindows = useIsWindowsDemo();
+ return (
+
+
+ {isWindows ? null :
}
+
+
+
+
+
+
+ acme.com/dashboard
+
+
+ {isWindows ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {[0.14, 0.08, 0.08, 0.08].map((o, i) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ {["#E4F0FB", "#EFE9FB", "#E5F3EC"].map((bg) => (
+
+
+
+
+ ))}
+
+
+
+
+
+ {[
+ 0.35, 0.5, 0.42, 0.62, 0.55, 0.74, 0.66, 0.88, 0.79, 0.95,
+ 0.85, 1,
+ ].map((h, i) => (
+
+ ))}
+
+
+
+
+ {[0, 1, 2, 3, 4].map((row) => (
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/components/pages/HomeTwo/demo/WindowsShell.tsx b/apps/web/components/pages/HomeTwo/demo/WindowsShell.tsx
new file mode 100644
index 00000000000..5de75e37162
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/demo/WindowsShell.tsx
@@ -0,0 +1,270 @@
+"use client";
+
+import { classNames } from "@cap/utils/helpers";
+import { CapLogoMark } from "./capIcons";
+import { OsSwitchButton } from "./chrome";
+import { CapFileIcon, ImageFileIcon } from "./MacDesktop";
+import { OS_FONT } from "./platform";
+
+const WIN_FONT = OS_FONT.windows;
+
+const StartGlyph = () => (
+
+ {[
+ [2, 2],
+ [13, 2],
+ [2, 13],
+ [13, 13],
+ ].map(([x, y]) => (
+
+ ))}
+
+);
+
+const SearchGlyph = () => (
+
+
+
+
+);
+
+const ExplorerGlyph = () => (
+
+
+
+
+);
+
+const EdgeGlyph = () => (
+
+
+
+
+ {/* biome-ignore lint/correctness/useUniqueElementIds: single-instance decorative svg defs */}
+
+
+
+
+
+
+
+);
+
+const TrayChevron = () => (
+
+
+
+);
+
+const TrayWifi = () => (
+
+
+
+);
+
+const TraySpeaker = () => (
+
+
+
+
+);
+
+const TrayBattery = () => (
+
+
+
+
+
+);
+
+const TaskbarApp = ({
+ children,
+ running,
+ label,
+}: {
+ children: React.ReactNode;
+ running?: boolean;
+ label: string;
+}) => (
+
+ {children}
+ {label}
+
+
+);
+
+export const WinTaskbar = ({
+ recording,
+ onSwitchOs,
+}: {
+ recording: boolean;
+ onSwitchOs: () => void;
+}) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ REC
+
+
+
+
+
+
+ 9:41 AM
+ 20/08/2026
+
+
+
+);
+
+const WinFolderIcon = () => (
+
+
+
+
+
+);
+
+const WinDesktopFile = ({
+ icon,
+ label,
+}: {
+ icon: React.ReactNode;
+ label: string;
+}) => (
+
+ {icon}
+
+ {label}
+
+
+);
+
+export const WinDesktopFiles = () => (
+ // Kept on the right so the recorded window still owns the left half; a
+ // Windows desktop lets icons live anywhere.
+
+ } label="Recordings" />
+ } label="team-update.cap" />
+ } label="Q3 launch.png" />
+
+);
diff --git a/apps/web/components/pages/HomeTwo/demo/capIcons.tsx b/apps/web/components/pages/HomeTwo/demo/capIcons.tsx
new file mode 100644
index 00000000000..14949a96a79
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/demo/capIcons.tsx
@@ -0,0 +1,930 @@
+/**
+ * Exact SVG copies of the icons the Cap desktop app renders, lifted from
+ * packages/ui-solid/icons/*.svg and the icon sets the app imports
+ * (lucide, mdi, ph, material-symbols). Fill colours that ship as white/black
+ * in the source files are swapped to currentColor so CSS drives them, which
+ * is also what the app itself does via its `invert` utility.
+ */
+
+type IconProps = { className?: string; style?: React.CSSProperties };
+
+export const CapLogoFull = ({ className, style }: IconProps) => (
+
+
+
+
+
+
+
+
+);
+
+export const CapLogoMark = ({ className, style }: IconProps) => (
+
+
+
+
+
+
+
+);
+
+export const CapInstant = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapFilmCut = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapScreenshot = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapCamera = ({ className, style }: IconProps) => (
+
+
+
+
+);
+
+export const CapMicrophone = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapChevronDown = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapCaretDown = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapX = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapInfo = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapStopCircle = ({ className, style }: IconProps) => (
+
+
+
+
+);
+
+export const CapPauseCircle = ({ className, style }: IconProps) => (
+
+
+
+
+
+);
+
+export const CapRestart = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapTrash = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapSettingsGear = ({ className, style }: IconProps) => (
+
+
+
+
+);
+
+export const CapGear = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapMoreVertical = ({ className, style }: IconProps) => (
+
+
+
+
+
+);
+
+export const CapClapperboard = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapCrop = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapImage = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapAudioOn = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapMessageBubble = ({ className, style }: IconProps) => (
+
+
+
+
+);
+
+export const CapCursor = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapUndo = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapRedo = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapPresets = ({ className, style }: IconProps) => (
+
+
+
+
+
+
+);
+
+export const CapScissors = ({ className, style }: IconProps) => (
+
+
+
+
+);
+
+export const CapUpload = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapLayoutIcon = ({ className, style }: IconProps) => (
+
+
+
+
+);
+
+export const CapPrev = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapNext = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapPlay = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const CapPause = ({ className, style }: IconProps) => (
+
+
+
+);
+
+/** ph:monitor-bold — the system audio row icon. */
+export const PhMonitorBold = ({ className, style }: IconProps) => (
+
+
+
+);
+
+/** mdi:monitor — the Display target icon. */
+export const MdiMonitor = ({ className, style }: IconProps) => (
+
+
+
+);
+
+/** lucide:app-window-mac — the Window target icon. */
+export const LucideAppWindowMac = ({ className, style }: IconProps) => (
+
+
+
+
+
+
+);
+
+/** material-symbols:screenshot-frame-2-rounded — the Area target icon. */
+export const MsScreenshotFrame = ({ className, style }: IconProps) => (
+
+
+
+);
+
+const Lucide = ({
+ className,
+ style,
+ children,
+}: IconProps & { children: React.ReactNode }) => (
+
+ {children}
+
+);
+
+export const LucideVideo = ({ className, style }: IconProps) => (
+
+
+
+
+);
+
+export const LucideSettings = ({ className, style }: IconProps) => (
+
+
+
+
+);
+
+export const LucideImage = ({ className, style }: IconProps) => (
+
+
+
+
+
+);
+
+export const LucideSquarePlay = ({ className, style }: IconProps) => (
+
+
+
+
+);
+
+export const LucideScanText = ({ className, style }: IconProps) => (
+
+
+
+
+
+
+
+
+
+);
+
+export const LucideCircleHelp = ({ className, style }: IconProps) => (
+
+
+
+
+
+);
+
+export const LucideMaximize2 = ({ className, style }: IconProps) => (
+
+
+
+
+
+
+);
+
+export const LucideBell = ({ className, style }: IconProps) => (
+
+
+
+
+);
+
+export const LucideEyeOff = ({ className, style }: IconProps) => (
+
+
+
+
+
+
+);
+
+export const LucideFolder = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const LucideBuilding2 = ({ className, style }: IconProps) => (
+
+
+
+
+
+
+
+
+
+);
+
+export const LucideKeyboard = ({ className, style }: IconProps) => (
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export const LucideChevronDown = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const LucideSearch = ({ className, style }: IconProps) => (
+
+
+
+
+);
+
+export const LucideZoomIn = ({ className, style }: IconProps) => (
+
+
+
+
+
+
+);
+
+export const LucideZoomOut = ({ className, style }: IconProps) => (
+
+
+
+
+
+);
+
+export const LucideClock = ({ className, style }: IconProps) => (
+
+
+
+
+);
+
+export const LucidePlus = ({ className, style }: IconProps) => (
+
+
+
+
+);
+
+export const LucideLoader = ({ className, style }: IconProps) => (
+
+
+
+);
+
+export const MacCursor = ({ className, style }: IconProps) => (
+
+
+
+
+
+
+ {/* biome-ignore lint/correctness/useUniqueElementIds: single-instance svg filter */}
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/apps/web/components/pages/HomeTwo/demo/chrome.tsx b/apps/web/components/pages/HomeTwo/demo/chrome.tsx
new file mode 100644
index 00000000000..f9b01a1b310
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/demo/chrome.tsx
@@ -0,0 +1,144 @@
+"use client";
+
+import { classNames } from "@cap/utils/helpers";
+
+export const TrafficLights = ({
+ size = 12,
+ minimize = true,
+ className,
+}: {
+ size?: number;
+ minimize?: boolean;
+ className?: string;
+}) => (
+
+ {["#FF5F57", ...(minimize ? ["#FEBC2E"] : []), "#28C840"].map((color) => (
+
+ ))}
+
+);
+
+const MinimizeGlyph = () => (
+
+
+
+);
+
+const MaximizeGlyph = () => (
+
+
+
+);
+
+const CloseGlyph = () => (
+
+
+
+);
+
+export const WindowsCaptionControls = ({
+ light,
+ className,
+}: {
+ light?: boolean;
+ className?: string;
+}) => {
+ const cell =
+ "flex w-[46px] shrink-0 items-center justify-center self-stretch transition-colors duration-150";
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export const OsSwitchButton = ({
+ children,
+ label,
+ tooltip,
+ side = "below",
+ align = "center",
+ className,
+ onClick,
+}: {
+ children: React.ReactNode;
+ label: string;
+ tooltip: string;
+ side?: "below" | "above";
+ /** "left" keeps the bubble on screen when the button hugs the corner. */
+ align?: "center" | "left";
+ className?: string;
+ onClick: () => void;
+}) => (
+ {
+ event.stopPropagation();
+ onClick();
+ }}
+ className={classNames(
+ "group relative flex cursor-pointer items-center justify-center transition-colors duration-150",
+ className,
+ )}
+ >
+ {children}
+
+ {tooltip}
+
+
+);
diff --git a/apps/web/components/pages/HomeTwo/demo/media.tsx b/apps/web/components/pages/HomeTwo/demo/media.tsx
new file mode 100644
index 00000000000..c488eb53297
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/demo/media.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import { createContext, useContext } from "react";
+
+const SceneMediaContext = createContext<{ still: boolean }>({ still: false });
+
+export const SceneMediaProvider = SceneMediaContext.Provider;
+
+export const useSceneMedia = () => useContext(SceneMediaContext);
+
+export const VIDEO_POSTERS = {
+ webcam: "/videos/home-two/webcam-poster.jpg",
+ screen: "/illustrations/homepage-animation-poster.jpg",
+} as const;
+
+export const useVideoAttrs = (poster: string, visible = true) => {
+ const { still } = useSceneMedia();
+ return { poster, preload: still || !visible ? "none" : "metadata" } as const;
+};
diff --git a/apps/web/components/pages/HomeTwo/demo/platform.tsx b/apps/web/components/pages/HomeTwo/demo/platform.tsx
new file mode 100644
index 00000000000..9e12661fffc
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/demo/platform.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import { createContext, useContext } from "react";
+
+/**
+ * Which OS the demo is dressed as. Everything except the Cap windows'
+ * chrome is shared, so a single context beats threading a prop through the
+ * whole window tree.
+ *
+ * Windows visitors get the Windows shell; every other platform (macOS,
+ * Linux, and the first paint before detection resolves) gets macOS, the same
+ * default the download button uses.
+ */
+export type DemoPlatform = "macos" | "windows";
+
+const DemoPlatformContext = createContext("macos");
+
+export const DemoPlatformProvider = DemoPlatformContext.Provider;
+
+export const useDemoPlatform = () => useContext(DemoPlatformContext);
+
+export const useIsWindowsDemo = () => useDemoPlatform() === "windows";
+
+export const OS_FONT = {
+ macos:
+ "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif",
+ windows:
+ "'Segoe UI Variable Text', 'Segoe UI', 'Segoe UI Web (West European)', system-ui, sans-serif",
+} as const;
diff --git a/apps/web/components/pages/HomeTwo/fonts.ts b/apps/web/components/pages/HomeTwo/fonts.ts
new file mode 100644
index 00000000000..7ca3b75d704
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/fonts.ts
@@ -0,0 +1,35 @@
+import {
+ DM_Mono,
+ Geist,
+ Instrument_Sans,
+ Source_Serif_4,
+} from "next/font/google";
+
+export const htSans = Instrument_Sans({
+ subsets: ["latin"],
+ weight: ["400", "500"],
+ display: "swap",
+ variable: "--font-ht-sans",
+});
+
+export const htSerif = Source_Serif_4({
+ subsets: ["latin"],
+ weight: ["300", "400"],
+ style: ["normal"],
+ display: "swap",
+ variable: "--font-ht-serif",
+});
+
+export const htGeist = Geist({
+ subsets: ["latin"],
+ weight: ["400", "500", "600"],
+ display: "swap",
+ variable: "--font-ht-geist",
+});
+
+export const htMono = DM_Mono({
+ subsets: ["latin"],
+ weight: ["400"],
+ display: "swap",
+ variable: "--font-ht-mono",
+});
diff --git a/apps/web/components/pages/HomeTwo/glyphs.tsx b/apps/web/components/pages/HomeTwo/glyphs.tsx
new file mode 100644
index 00000000000..294ae509dfd
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/glyphs.tsx
@@ -0,0 +1,22 @@
+import Image from "next/image";
+
+export const AppleGlyph = ({ className }: { className?: string }) => (
+
+
+
+);
+
+export const WindowsLogo = ({ size = 16 }: { size?: number }) => (
+
+);
diff --git a/apps/web/components/pages/HomeTwo/index.tsx b/apps/web/components/pages/HomeTwo/index.tsx
new file mode 100644
index 00000000000..77f95ffc5bb
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/index.tsx
@@ -0,0 +1,57 @@
+import { Agents } from "@/components/pages/HomeTwo/Agents";
+import { DeepDives } from "@/components/pages/HomeTwo/DeepDives";
+import { Faq } from "@/components/pages/HomeTwo/Faq";
+import { Features } from "@/components/pages/HomeTwo/Features";
+import { FinalCta } from "@/components/pages/HomeTwo/FinalCta";
+import { htMono, htSans, htSerif } from "@/components/pages/HomeTwo/fonts";
+import { Hero } from "@/components/pages/HomeTwo/Hero";
+import { LoomBridge } from "@/components/pages/HomeTwo/LoomBridge";
+import { ModeWalkthrough } from "@/components/pages/HomeTwo/ModeWalkthrough";
+import { Ownership } from "@/components/pages/HomeTwo/Ownership";
+import { Platforms } from "@/components/pages/HomeTwo/Platforms";
+import { Pricing } from "@/components/pages/HomeTwo/Pricing";
+import { Testimonials } from "@/components/pages/HomeTwo/Testimonials";
+import { BAND, CREAM, grainBg, SHELL } from "@/components/pages/HomeTwo/theme";
+import { Workflow } from "@/components/pages/HomeTwo/Workflow";
+import { HomeTwoSchema } from "./Schema";
+
+const CARD_RADIUS = "rounded-[24px]";
+
+export function HomeTwoPage() {
+ // No overflow clipping anywhere on the card: it would trap the walkthrough's
+ // sticky card. The corners are cut on the card and on the band that paints
+ // over them instead.
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/components/pages/HomeTwo/metadata.ts b/apps/web/components/pages/HomeTwo/metadata.ts
new file mode 100644
index 00000000000..7386e863374
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/metadata.ts
@@ -0,0 +1,25 @@
+import type { Metadata } from "next";
+import { buildMarketingMetadata } from "@/lib/og/url";
+import { homepageSeo } from "./seo";
+
+export const homePageMetadata: Metadata = {
+ ...buildMarketingMetadata({
+ title: homepageSeo.title,
+ description: homepageSeo.description,
+ path: homepageSeo.url,
+ ogTitle: "Record. Edit. Share.",
+ ogDescription:
+ "The open source screen recorder for Mac, Windows, and Linux.",
+ }),
+ robots: {
+ index: true,
+ follow: true,
+ googleBot: {
+ index: true,
+ follow: true,
+ "max-image-preview": "large",
+ "max-snippet": -1,
+ "max-video-preview": -1,
+ },
+ },
+};
diff --git a/apps/web/components/pages/HomeTwo/scenes/AgentScene.tsx b/apps/web/components/pages/HomeTwo/scenes/AgentScene.tsx
new file mode 100644
index 00000000000..fb102347c05
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/scenes/AgentScene.tsx
@@ -0,0 +1,458 @@
+"use client";
+
+import { classNames } from "@cap/utils/helpers";
+import { Check } from "lucide-react";
+import { useRef } from "react";
+import { CapShareWindow } from "../demo/CapShareWindow";
+import { RecordingToolbar } from "../demo/CapSurfaces";
+import { CapLogoMark } from "../demo/capIcons";
+import { ContentWindow } from "../demo/MacDesktop";
+import { MONO } from "../theme";
+import { SCENE_META } from "./catalog";
+import {
+ clockText,
+ easeInOut,
+ Fit,
+ noop,
+ type SceneModule,
+ type SceneProps,
+ STAGE,
+ Stage,
+ span,
+ typed,
+ useCursor,
+ useSceneClock,
+ useSceneState,
+ useVideo,
+ type Way,
+} from "./engine";
+
+const CHAPTERS = SCENE_META.agent.chapters;
+
+const PROMPT =
+ "Record a 20 second repro of the checkout bug and send me the link";
+const PLAN =
+ "I'll pick the screen, record it in Instant Mode, upload it, and read back the summary.";
+const DONE = "Done. The repro is live with a transcript and summary.";
+const SUMMARY = "Summary: the cart total resets after a promo code is applied.";
+
+const PROMPT_START = 250;
+const PROMPT_CPS = 36;
+const PLAN_AT = 2300;
+const TOOL1 = { start: 2700, done: 3400 };
+const RECORD = { start: 3900, end: 8900 };
+const TOOL2 = { start: 3600, done: 9000 };
+const TOOL3 = { start: 9400, done: 10600 };
+const TOOL4 = { start: 10900, done: 12000 };
+const FINAL_AT = 12200;
+const LINK_AT = 12500;
+const SHARE_AT = 10650;
+const COMMENT_AT = 13800;
+const RECORD_SECONDS = 20;
+
+type ToolStatus = "idle" | "running" | "done";
+
+const toolStatus = (
+ t: number,
+ tool: { start: number; done: number },
+): ToolStatus => (t < tool.start ? "idle" : t < tool.done ? "running" : "done");
+
+const uiAt = (t: number) => ({
+ plan: t >= PLAN_AT,
+ tool1: toolStatus(t, TOOL1),
+ tool2: toolStatus(t, TOOL2),
+ tool3: toolStatus(t, TOOL3),
+ tool4: toolStatus(t, TOOL4),
+ recording: t >= RECORD.start && t < RECORD.end,
+ final: t >= FINAL_AT,
+ link: t >= LINK_AT,
+ toolbar: t >= RECORD.start - 200 && t < RECORD.end + 100,
+ share: t >= SHARE_AT,
+ comment: t >= COMMENT_AT,
+});
+
+const recordSeconds = (t: number) =>
+ Math.min(
+ RECORD_SECONDS,
+ Math.floor(span(t, RECORD.start, RECORD.end) * RECORD_SECONDS),
+ );
+
+const POS = {
+ content: { left: 60, top: 40, width: 560, height: 380 },
+ toolbar: { left: (680 - 296) / 2, top: 428 },
+ share: { left: 80, top: 24, width: 520, height: 440 },
+};
+
+const PATH: Way[] = [
+ { t: 0, x: 620, y: 440 },
+ { t: 17000, x: 620, y: 440 },
+];
+
+const AGENT_CSS = `
+ @keyframes ht-agent-spin {
+ to { transform: rotate(360deg); }
+ }
+ @keyframes ht-agent-blink {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0; }
+ }
+ @keyframes ht-agent-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.35; }
+ }
+`;
+
+const Status = ({ status }: { status: ToolStatus }) => (
+
+
+
+
+
+
+
+);
+
+const Tokens = ({ text }: { text: string }) => {
+ const parts = text.split(" ");
+ const seen = new Map();
+ return parts.map((token, i) => {
+ const n = seen.get(token) ?? 0;
+ seen.set(token, n + 1);
+ return (
+
+ {token}
+ {i < parts.length - 1 ? " " : ""}
+
+ );
+ });
+};
+
+const Tool = ({
+ status,
+ command,
+ via = "$",
+ children,
+}: {
+ status: ToolStatus;
+ command: string;
+ via?: string;
+ children?: React.ReactNode;
+}) => (
+
+);
+
+const Json = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+const Key = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+const Str = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+const Num = ({ children }: { children: React.ReactNode }) => (
+ {children}
+);
+
+const Message = ({
+ show,
+ children,
+}: {
+ show: boolean;
+ children: React.ReactNode;
+}) => (
+
+);
+
+export const AgentScene = (props: SceneProps) => {
+ const promptRef = useRef(null);
+ const clockRef = useRef(null);
+ const timerRef = useRef(null);
+ const layerRef = useRef(null);
+ const scrollRef = useRef(null);
+ const shareVideoRef = useRef(null);
+ const [ui, setUi] = useSceneState(uiAt(0));
+ const cursor = useCursor(layerRef);
+ useVideo(props.playing && ui.share, shareVideoRef);
+
+ useSceneClock({
+ ...props,
+ chapters: CHAPTERS,
+ tick: (t, seek) => {
+ setUi(uiAt(t));
+ if (promptRef.current) {
+ promptRef.current.textContent = typed(
+ PROMPT,
+ t,
+ PROMPT_START,
+ PROMPT_CPS,
+ );
+ }
+ const seconds = recordSeconds(t);
+ if (clockRef.current) {
+ clockRef.current.textContent =
+ t < RECORD.start
+ ? "Starting"
+ : t < RECORD.end
+ ? `Recording ${clockText(seconds * 1000)}`
+ : `Recorded ${clockText(RECORD_SECONDS * 1000)}`;
+ }
+ if (timerRef.current) {
+ timerRef.current.textContent = clockText(seconds * 1000);
+ }
+ if (scrollRef.current) {
+ scrollRef.current.style.transform = `translateY(${
+ -120 * easeInOut(span(t, RECORD.start + 800, RECORD.end - 600))
+ }px)`;
+ }
+ cursor.tick(PATH, t, seek);
+ },
+ });
+
+ return (
+
+
+
+
+
+
+
+
+ agent session · ~/checkout
+
+
+
+
+
+
+
+
+ {PLAN}
+
+
+
+
+
+
+ {"{"} "screens" : [{"{"} "id" : 1
+ , "name" : "Built-in Retina Display" {"}"}]{" "}
+ {"}"}
+
+
+
+
+
+ {"{"}
+ "type" :"started"
+ {"}"}
+
+
+
+
+
+
+
+
+ {"{"}
+ "type" :"stopped" ,
+ "recordingMetaExists" :true
+ {"}"}
+
+
+
+
+
+ {"{"} "url" : "https://cap.so/s/x7f2k9" {" "}
+ {"}"}
+
+
+
+ {SUMMARY}
+
+
+
+
+
+ {DONE}
+
+
+
+
+
+
+
+ Checkout bug repro
+
+
+ cap.so/s/x7f2k9 · 0:20 · AI summary ready
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {cursor.Cursor}
+
+
+
+
+ );
+};
+
+export const AGENT: SceneModule = {
+ Scene: AgentScene,
+ chapters: CHAPTERS,
+ poster: SCENE_META.agent.poster,
+};
diff --git a/apps/web/components/pages/HomeTwo/scenes/AiScene.tsx b/apps/web/components/pages/HomeTwo/scenes/AiScene.tsx
new file mode 100644
index 00000000000..207ee4c5aee
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/scenes/AiScene.tsx
@@ -0,0 +1,594 @@
+"use client";
+
+import { classNames } from "@cap/utils/helpers";
+import { Search, Sparkles } from "lucide-react";
+import { type RefObject, useRef } from "react";
+import { CapLogoMark, CapPlay } from "../demo/capIcons";
+import { TrafficLights } from "../demo/chrome";
+import { useVideoAttrs, VIDEO_POSTERS } from "../demo/media";
+import { SCENE_META } from "./catalog";
+import {
+ restartAnimation,
+ type SceneModule,
+ type SceneProps,
+ Stage,
+ typed,
+ useCursor,
+ useSceneClock,
+ useSceneState,
+ useVideo,
+ type Way,
+} from "./engine";
+
+const CHAPTERS = SCENE_META.share.chapters;
+
+const WINDOW = { left: 30, top: 22, width: 620, height: 438 };
+const VIDEO_SECONDS = 32;
+const SPEED = 2.5;
+
+const TITLE = "Dashboard walkthrough";
+const QUERY = "live data";
+const SUMMARY =
+ "A tour of the new analytics dashboard: the live data cards, the revenue chart, and the team table, plus what ships next.";
+
+const TRANSCRIPT = [
+ { at: 0, t: "0:00", text: "So this is the new dashboard we're shipping." },
+ { at: 4, t: "0:04", text: "Every card here pulls live data, watch this." },
+ { at: 9, t: "0:09", text: "The chart updates as orders come in." },
+ { at: 14, t: "0:14", text: "Down here is the team table, sortable now." },
+ { at: 19, t: "0:19", text: "Next week we add filters and exports." },
+ { at: 25, t: "0:25", text: "And that's really all there is to it." },
+];
+
+const CHAPTER_LIST = [
+ { at: 0, t: "0:00", label: "What we shipped" },
+ { at: 11, t: "0:11", label: "The new flow, end to end" },
+ { at: 24, t: "0:24", label: "What's next" },
+];
+
+const PATH: Way[] = [
+ { t: 0, x: 520, y: 330 },
+ { t: 1200, at: "share-search" },
+ { t: 1300, at: "share-search", click: true },
+ { t: 2800, x: 610, y: 400 },
+ { t: 7000, x: 610, y: 400 },
+ { t: 12100, at: "chapter-1" },
+ { t: 12200, at: "chapter-1", click: true },
+ { t: 13000, at: "reaction-fire" },
+ { t: 14000, at: "reaction-fire" },
+ { t: 14150, at: "reaction-fire", click: true },
+ { t: 15000, x: 560, y: 440 },
+ { t: 20500, x: 560, y: 440 },
+];
+
+const SEEK_AT = 12200;
+const SEEK_TO = CHAPTER_LIST[1]?.at ?? 11;
+const QUERY_START = 1400;
+const TITLE_START = 7200;
+const SUMMARY_START = 8300;
+const CHAPTER_REVEAL = [10500, 10850, 11200];
+const FIRE_AT = 14150;
+const SUMMARY_TAB = 7000;
+const COMMENTS_TAB = 14000;
+
+const videoSecondsAt = (t: number) => {
+ const raw = (t / 1000) * SPEED;
+ const offset = t >= SEEK_AT ? SEEK_TO - (SEEK_AT / 1000) * SPEED : 0;
+ return (((raw + offset) % VIDEO_SECONDS) + VIDEO_SECONDS) % VIDEO_SECONDS;
+};
+
+const uiAt = (t: number) => {
+ const seconds = videoSecondsAt(t);
+ const queryDone = t >= QUERY_START + (QUERY.length / 12) * 1000 + 300;
+ return {
+ tab:
+ t < SUMMARY_TAB
+ ? "transcript"
+ : t < COMMENTS_TAB
+ ? "summary"
+ : "comments",
+ activeLine: TRANSCRIPT.reduce(
+ (acc, line, i) => (seconds >= line.at ? i : acc),
+ 0,
+ ),
+ matched: t < SUMMARY_TAB && queryDone,
+ chaptersShown: CHAPTER_REVEAL.filter((at) => t >= at).length,
+ activeChapter: CHAPTER_LIST.reduce(
+ (acc, chapter, i) => (seconds >= chapter.at ? i : acc),
+ 0,
+ ),
+ fire: t >= FIRE_AT,
+ comment: t >= 14500,
+ reply: t >= 16000,
+ };
+};
+
+const Tab = ({ label, active }: { label: string; active: boolean }) => (
+
+ {label}
+
+
+);
+
+const Avatar = ({
+ initial,
+ gradient,
+}: {
+ initial: string;
+ gradient: string;
+}) => (
+
+ {initial}
+
+);
+
+const Comment = ({
+ show,
+ name,
+ stamp,
+ text,
+ initial,
+ gradient,
+ indent,
+}: {
+ show: boolean;
+ name: string;
+ stamp: string;
+ text: string;
+ initial: string;
+ gradient: string;
+ indent?: boolean;
+}) => (
+
+
+
+
+ {name}
+
+ {stamp}
+
+
+
+ {text}
+
+
+
+);
+
+const SharePage = ({
+ ui,
+ videoRef,
+ progressRef,
+ timeRef,
+ titleRef,
+ queryRef,
+ summaryRef,
+ popRef,
+}: {
+ ui: ReturnType;
+ videoRef: RefObject;
+ progressRef: RefObject;
+ timeRef: RefObject;
+ titleRef: RefObject;
+ queryRef: RefObject;
+ summaryRef: RefObject;
+ popRef: RefObject;
+}) => {
+ const screenVideo = useVideoAttrs(VIDEO_POSTERS.screen);
+ return (
+
+
+
+
+
+ cap.link/dashboard-walkthrough
+
+
+
+
+
+
+
+
+
+
+
+ {TITLE}
+
+
+
+ Richie · just now · 0:32
+
+
+
+ Share
+
+
+
+
+
+
+
+
+ {ui.chaptersShown > 0
+ ? CHAPTER_LIST.map((chapter, i) => (
+ = ui.chaptersShown ? 0 : 1,
+ }}
+ />
+ ))
+ : null}
+
+
+
+
+ 0:00 / 0:32
+
+
+
+
+
+ {[
+ { emoji: "👍", count: 1, anchor: "reaction-up", lit: false },
+ {
+ emoji: "🔥",
+ count: ui.fire ? 3 : 2,
+ anchor: "reaction-fire",
+ lit: ui.fire,
+ },
+ { emoji: "❤️", count: 1, anchor: "reaction-heart", lit: false },
+ ].map((reaction) => (
+
+ {reaction.emoji}
+
+ {reaction.count}
+
+ {reaction.anchor === "reaction-fire" ? (
+
+ 🔥
+
+ ) : null}
+
+ ))}
+
+
+ 3 views
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {ui.tab === "transcript" && ui.matched
+ ? ""
+ : "Search this recording"}
+
+
+
+
+ {TRANSCRIPT.map((line, i) => {
+ const active = i === ui.activeLine;
+ const hit = ui.matched && i === 1;
+ const dim = ui.matched && !hit;
+ return (
+
+
+ {line.t}
+
+
+ {line.text}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+ Summary
+
+
+
+ Cap AI
+
+
+
+
+
+
+
+
+ Chapters
+
+
+ {CHAPTER_LIST.map((chapter, i) => (
+
0
+ ? "#f0f0f0"
+ : "transparent",
+ }}
+ >
+
+ {chapter.t}
+
+
+ {chapter.label}
+
+
+ ))}
+
+
+
+
+
+
+
+
+ Leave a comment
+
+
+
+
+
+
+ );
+};
+
+export const AiScene = (props: SceneProps) => {
+ const layerRef = useRef(null);
+ const videoRef = useRef(null);
+ const progressRef = useRef(null);
+ const timeRef = useRef(null);
+ const titleRef = useRef(null);
+ const queryRef = useRef(null);
+ const summaryRef = useRef(null);
+ const popRef = useRef(null);
+ const popAtRef = useRef(-1);
+ const [ui, setUi] = useSceneState(uiAt(0));
+ const cursor = useCursor(layerRef);
+ useVideo(props.playing, videoRef);
+
+ useSceneClock({
+ ...props,
+ chapters: CHAPTERS,
+ tick: (t, seek) => {
+ setUi(uiAt(t));
+ const seconds = videoSecondsAt(t);
+ if (progressRef.current) {
+ progressRef.current.style.width = `${(seconds / VIDEO_SECONDS) * 100}%`;
+ }
+ if (timeRef.current) {
+ timeRef.current.textContent = `0:${String(Math.floor(seconds)).padStart(
+ 2,
+ "0",
+ )} / 0:32`;
+ }
+ if (queryRef.current) {
+ queryRef.current.textContent =
+ t >= QUERY_START && t < SUMMARY_TAB
+ ? typed(QUERY, t, QUERY_START, 12)
+ : "";
+ }
+ if (titleRef.current) {
+ titleRef.current.textContent =
+ t >= SUMMARY_TAB && t < COMMENTS_TAB
+ ? typed(TITLE, t, TITLE_START, 20)
+ : TITLE;
+ }
+ if (summaryRef.current) {
+ summaryRef.current.textContent =
+ t >= COMMENTS_TAB ? SUMMARY : typed(SUMMARY, t, SUMMARY_START, 58);
+ }
+ if (seek) popAtRef.current = t - 1;
+ if (popAtRef.current < FIRE_AT && t >= FIRE_AT) {
+ restartAnimation(popRef.current, "ht-scene-pop 700ms ease-out");
+ }
+ popAtRef.current = t;
+ cursor.tick(PATH, t, seek);
+ },
+ });
+
+ return (
+
+
+
+
+ {cursor.Cursor}
+
+ );
+};
+
+export const AI: SceneModule = {
+ Scene: AiScene,
+ chapters: CHAPTERS,
+ poster: SCENE_META.share.poster,
+};
diff --git a/apps/web/components/pages/HomeTwo/scenes/InstantScene.tsx b/apps/web/components/pages/HomeTwo/scenes/InstantScene.tsx
new file mode 100644
index 00000000000..67a77919536
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/scenes/InstantScene.tsx
@@ -0,0 +1,250 @@
+"use client";
+
+import { classNames } from "@cap/utils/helpers";
+import { useRef } from "react";
+import { CapShareWindow } from "../demo/CapShareWindow";
+import { LinkNotification, RecordingToolbar } from "../demo/CapSurfaces";
+import { CapLogoMark } from "../demo/capIcons";
+import { ContentWindow } from "../demo/MacDesktop";
+import { SCENE_META } from "./catalog";
+import {
+ clockText,
+ easeInOut,
+ noop,
+ Reveal,
+ type SceneModule,
+ type SceneProps,
+ Stage,
+ span,
+ useCursor,
+ useSceneClock,
+ useSceneState,
+ useVideo,
+ type Way,
+} from "./engine";
+
+const CHAPTERS = SCENE_META.instant.chapters;
+
+const POS = {
+ content: { left: 24, top: 22, width: 430, height: 330 },
+ toolbar: { left: 192, top: 424 },
+ notification: { left: 322, top: 10 },
+ share: { left: 100, top: 12, width: 480, height: 464 },
+ popover: { left: 264, top: 108, width: 300 },
+};
+
+const PATH: Way[] = [
+ { t: 0, x: 250, y: 200 },
+ { t: 2200, x: 330, y: 250 },
+ { t: 3900, x: 390, y: 180 },
+ { t: 5800, at: "toolbar-stop" },
+ { t: 5900, at: "toolbar-stop", click: true },
+ { t: 6100, at: "toolbar-stop" },
+ { t: 7300, at: "notification" },
+ { t: 7400, at: "notification", click: true },
+ { t: 8800, x: 620, y: 400 },
+ { t: 10300, x: 620, y: 400 },
+ { t: 11400, at: "share-button" },
+ { t: 11600, at: "share-button" },
+ { t: 11700, at: "share-button", click: true },
+ { t: 12900, at: "share-password" },
+ { t: 13000, at: "share-password", click: true },
+ { t: 14200, at: "share-public" },
+ { t: 14300, at: "share-public", click: true },
+ { t: 15400, at: "share-copy" },
+ { t: 15500, at: "share-copy", click: true },
+ { t: 16400, at: "share-copy", dx: 40, dy: 30 },
+ { t: 17600, at: "share-copy", dx: 40, dy: 30 },
+];
+
+const RECORD_START = 200;
+const RECORD_STOP = 5950;
+
+const uiAt = (t: number) => ({
+ toolbar: t >= 100 && t < RECORD_STOP,
+ recording: t >= RECORD_START && t < RECORD_STOP,
+ notification: t >= 6150 && t < 8200,
+ share: t >= 7450,
+ comment: t >= 9500,
+ popover: t >= 11750,
+ password: t >= 13000,
+ publicLink: t < 14300,
+ copied: t >= 15500,
+});
+
+const Switch = ({ on }: { on: boolean }) => (
+
+
+
+);
+
+const SharePopover = ({
+ show,
+ password,
+ publicLink,
+ copied,
+}: {
+ show: boolean;
+ password: boolean;
+ publicLink: boolean;
+ copied: boolean;
+}) => (
+
+
+
+
+
+ cap.link/dashboard-walkthrough
+
+
+ {copied ? "Copied" : "Copy"}
+
+
+
+
+ Password protection
+
+
+
+
+
+
+
+
+ {publicLink ? "Anyone with the link" : "Only your team"}
+
+
+
+
+
+
+
+
+);
+
+export const InstantScene = (props: SceneProps) => {
+ const layerRef = useRef(null);
+ const scrollRef = useRef(null);
+ const timerRef = useRef(null);
+ const shareVideoRef = useRef(null);
+ const [ui, setUi] = useSceneState(uiAt(0));
+ const cursor = useCursor(layerRef);
+ useVideo(props.playing && ui.share, shareVideoRef);
+
+ useSceneClock({
+ ...props,
+ chapters: CHAPTERS,
+ tick: (t, seek) => {
+ setUi(uiAt(t));
+ if (timerRef.current) {
+ timerRef.current.textContent =
+ t >= RECORD_START ? clockText(t - RECORD_START) : "0:00";
+ }
+ if (scrollRef.current) {
+ scrollRef.current.style.transform = `translateY(${
+ -110 * easeInOut(span(t, 1300, 4200))
+ }px)`;
+ }
+ cursor.tick(PATH, t, seek);
+ },
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {cursor.Cursor}
+
+ );
+};
+
+export const INSTANT: SceneModule = {
+ Scene: InstantScene,
+ chapters: CHAPTERS,
+ poster: SCENE_META.instant.poster,
+};
diff --git a/apps/web/components/pages/HomeTwo/scenes/ScreenshotScene.tsx b/apps/web/components/pages/HomeTwo/scenes/ScreenshotScene.tsx
new file mode 100644
index 00000000000..ee8784f2798
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/scenes/ScreenshotScene.tsx
@@ -0,0 +1,310 @@
+"use client";
+
+import { classNames } from "@cap/utils/helpers";
+import { Check, MoveUpRight, Square, Type } from "lucide-react";
+import { useRef } from "react";
+import { CapLogoMark } from "../demo/capIcons";
+import { ContentWindow } from "../demo/MacDesktop";
+import { SCENE_META } from "./catalog";
+import {
+ easeOut,
+ lerp,
+ Reveal,
+ type SceneModule,
+ type SceneProps,
+ Stage,
+ span,
+ useCursor,
+ useSceneClock,
+ useSceneState,
+ type Way,
+} from "./engine";
+
+const CHAPTERS = SCENE_META.screenshot.chapters;
+
+const WALLPAPER = "/backgrounds/london.webp";
+
+const POS = {
+ content: { left: 120, top: 44, width: 440, height: 330 },
+ result: { left: 130, top: 60, width: 420, height: 300 },
+ notification: { left: 322, top: 10 },
+ toolbar: { left: 130 + (420 - 196) / 2, top: 374 },
+};
+
+const MARQUEE = {
+ x0: 112,
+ y0: 36,
+ x1: 568,
+ y1: 382,
+};
+
+const DRAG = { start: 300, end: 1600 };
+const ARROW = { from: { x: 300, y: 150 }, to: { x: 410, y: 215 } };
+const DRAW = { start: 7100, end: 8100 };
+
+const PATH: Way[] = [
+ { t: 0, x: MARQUEE.x0, y: MARQUEE.y0 },
+ { t: DRAG.start, x: MARQUEE.x0, y: MARQUEE.y0 },
+ { t: DRAG.end, x: MARQUEE.x1, y: MARQUEE.y1 },
+ { t: 1700, x: MARQUEE.x1, y: MARQUEE.y1 },
+ { t: 2800, x: 610, y: 430 },
+ { t: 4600, x: 610, y: 430 },
+ { t: 5700, at: "annotate-arrow" },
+ { t: 6000, at: "annotate-arrow" },
+ { t: 6200, at: "annotate-arrow", click: true },
+ { t: 7000, x: ARROW.from.x, y: ARROW.from.y },
+ { t: DRAW.start, x: ARROW.from.x, y: ARROW.from.y },
+ { t: DRAW.end, x: ARROW.to.x, y: ARROW.to.y },
+ { t: 8300, x: ARROW.to.x, y: ARROW.to.y },
+ { t: 9200, at: "annotate-done" },
+ { t: 9300, at: "annotate-done", click: true },
+ { t: 10200, x: 600, y: 440 },
+ { t: 12000, x: 600, y: 440 },
+];
+
+const uiAt = (t: number) => ({
+ marquee: t >= DRAG.start && t < 1750,
+ flash: t >= 1700 && t < 2050,
+ result: t >= 2050,
+ captured: t >= 2900 && t < 6000,
+ toolbar: t >= 6050,
+ arrowTool: t >= 6200,
+ drawing: t >= DRAW.start,
+ copied: t >= 9400,
+});
+
+const Notification = ({ show, title }: { show: boolean; title: string }) => (
+
+
+
+
+
+ {title}
+
+
+ Copied to clipboard
+
+
+
+ now
+
+
+
+);
+
+export const ScreenshotScene = (props: SceneProps) => {
+ const layerRef = useRef(null);
+ const scrollRef = useRef(null);
+ const resultScrollRef = useRef(null);
+ const marqueeRef = useRef(null);
+ const sizeRef = useRef(null);
+ const arrowRef = useRef(null);
+ const [ui, setUi] = useSceneState(uiAt(0));
+ const cursor = useCursor(layerRef);
+
+ useSceneClock({
+ ...props,
+ chapters: CHAPTERS,
+ tick: (t, seek) => {
+ setUi(uiAt(t));
+ const drag = easeOut(span(t, DRAG.start, DRAG.end));
+ const width = lerp(0, MARQUEE.x1 - MARQUEE.x0, drag);
+ const height = lerp(0, MARQUEE.y1 - MARQUEE.y0, drag);
+ if (marqueeRef.current) {
+ marqueeRef.current.style.width = `${width}px`;
+ marqueeRef.current.style.height = `${height}px`;
+ }
+ if (sizeRef.current) {
+ sizeRef.current.textContent = `${Math.round(width)} × ${Math.round(
+ height,
+ )}`;
+ }
+ if (arrowRef.current) {
+ const draw = span(t, DRAW.start, DRAW.end);
+ const x = lerp(ARROW.from.x, ARROW.to.x, draw);
+ const y = lerp(ARROW.from.y, ARROW.to.y, draw);
+ const line = arrowRef.current.querySelector("line");
+ const head = arrowRef.current.querySelector("polygon");
+ line?.setAttribute("x2", String(x));
+ line?.setAttribute("y2", String(y));
+ const angle =
+ (Math.atan2(y - ARROW.from.y, x - ARROW.from.x) * 180) / Math.PI;
+ head?.setAttribute(
+ "transform",
+ `translate(${x} ${y}) rotate(${angle})`,
+ );
+ arrowRef.current.style.opacity = t >= DRAW.start ? "1" : "0";
+ }
+ cursor.tick(PATH, t, seek);
+ },
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {[
+ { Icon: MoveUpRight, anchor: "annotate-arrow" },
+ { Icon: Square, anchor: "annotate-square" },
+ { Icon: Type, anchor: "annotate-text" },
+ ].map(({ Icon, anchor }, i) => (
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {cursor.Cursor}
+
+ );
+};
+
+export const SCREENSHOT: SceneModule = {
+ Scene: ScreenshotScene,
+ chapters: CHAPTERS,
+ poster: SCENE_META.screenshot.poster,
+};
diff --git a/apps/web/components/pages/HomeTwo/scenes/StudioScene.tsx b/apps/web/components/pages/HomeTwo/scenes/StudioScene.tsx
new file mode 100644
index 00000000000..c2aecb9c5d3
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/scenes/StudioScene.tsx
@@ -0,0 +1,303 @@
+"use client";
+
+import { useRef } from "react";
+import { CapEditorWindow } from "../demo/CapEditorWindow";
+import { CapRecorderWindow } from "../demo/CapRecorderWindow";
+import {
+ CameraWindow,
+ RecordingToolbar,
+ TargetOverlayPanel,
+} from "../demo/CapSurfaces";
+import { CapCursor } from "../demo/capIcons";
+import { ContentWindow } from "../demo/MacDesktop";
+import { SCENE_META } from "./catalog";
+import {
+ clockText,
+ easeInOut,
+ lerp,
+ noop,
+ quantize,
+ restartAnimation,
+ type SceneModule,
+ type SceneProps,
+ Stage,
+ span,
+ useCursor,
+ useSceneClock,
+ useSceneState,
+ useVideo,
+ type Way,
+} from "./engine";
+
+const CHAPTERS = SCENE_META.studio.chapters;
+
+const EDITOR = { left: 30, top: 44, width: 620, height: 389 };
+const EDITOR_SCALE = EDITOR.width / 1275;
+const SLIDER_PX = 384 * EDITOR_SCALE;
+
+const POS = {
+ content: { left: 250, top: 26, width: 400, height: 300 },
+ recorder: { left: 36, top: 50 },
+ camera: { left: 420, top: 236 },
+ overlay: { left: 132, top: 130 },
+ toolbar: { left: 192, top: 428 },
+};
+
+const PATH: Way[] = [
+ { t: 0, x: 330, y: 300 },
+ { t: 1200, at: "row-camera" },
+ { t: 1300, at: "row-camera", click: true },
+ { t: 2900, at: "target-display" },
+ { t: 3000, at: "target-display", click: true },
+ { t: 4600, at: "overlay-start" },
+ { t: 4700, at: "overlay-start", click: true },
+ { t: 5800, x: 590, y: 170 },
+ { t: 6400, x: 590, y: 170 },
+ { t: 7200, at: "swatch-2" },
+ { t: 7300, at: "swatch-2", click: true },
+ { t: 8300, at: "editor-padding" },
+ { t: 8400, at: "editor-padding" },
+ { t: 9400, at: "editor-padding", dx: SLIDER_PX * 0.4 },
+ { t: 10100, at: "editor-radius" },
+ { t: 10200, at: "editor-radius" },
+ { t: 11000, at: "editor-radius", dx: SLIDER_PX * 0.45 },
+ { t: 12300, at: "editor-zoom-generate" },
+ { t: 13400, at: "editor-zoom-generate" },
+ { t: 13500, at: "editor-zoom-generate", click: true },
+ { t: 14300, x: 210, y: 452 },
+ { t: 20000, x: 210, y: 452 },
+];
+
+const RECORD_START = 5000;
+const STUDIO_END = 6400;
+const EDITOR_OPEN = 6500;
+const PLAYBACK_LOOP = 9000;
+const ZOOM_GENERATE = 13500;
+const ZOOM_IN = { start: 13800, end: 15200 };
+const ZOOM_OUT = { start: 17800, end: 19000 };
+const CANVAS_CLICK = 15500;
+
+const uiAt = (t: number) => ({
+ recorder: t < 4750,
+ cameraOn: t >= 1300,
+ camera: t >= 1400 && t < STUDIO_END,
+ display: t >= 3000,
+ overlay: t >= 3200 && t < 4700,
+ toolbar: t >= RECORD_START && t < STUDIO_END,
+ recording: t >= RECORD_START && t < STUDIO_END,
+ content: t < STUDIO_END,
+ editor: t >= EDITOR_OPEN,
+ bgIndex: t >= 7300 ? 2 : 0,
+ padding: quantize(0.35 + 0.4 * span(t, 8400, 9400)),
+ radius: quantize(0.5 + 0.45 * span(t, 10200, 11000)),
+ zoomSegments: t >= ZOOM_GENERATE,
+});
+
+const zoomAt = (t: number) =>
+ 1 +
+ 0.75 * easeInOut(span(t, ZOOM_IN.start, ZOOM_IN.end)) -
+ 0.75 * easeInOut(span(t, ZOOM_OUT.start, ZOOM_OUT.end));
+
+export const StudioScene = (props: SceneProps) => {
+ const layerRef = useRef(null);
+ const scrollRef = useRef(null);
+ const timerRef = useRef(null);
+ const cameraRef = useRef(null);
+ const editorVideoRef = useRef(null);
+ const editorCamRef = useRef(null);
+ const playheadRef = useRef(null);
+ const timeRef = useRef(null);
+ const canvasRef = useRef(null);
+ const canvasCursorRef = useRef(null);
+ const canvasRingRef = useRef(null);
+ const canvasClickRef = useRef(-1);
+ const [ui, setUi] = useSceneState(uiAt(0));
+ const cursor = useCursor(layerRef);
+
+ useVideo(props.playing && ui.camera, cameraRef);
+ useVideo(props.playing && ui.editor, editorVideoRef);
+ useVideo(props.playing && ui.editor, editorCamRef);
+
+ useSceneClock({
+ ...props,
+ chapters: CHAPTERS,
+ tick: (t, seek) => {
+ setUi(uiAt(t));
+ if (timerRef.current) {
+ timerRef.current.textContent =
+ t >= RECORD_START ? clockText(t - RECORD_START) : "0:00";
+ }
+ if (scrollRef.current) {
+ scrollRef.current.style.transform = `translateY(${
+ -70 * easeInOut(span(t, 5300, 6300))
+ }px)`;
+ }
+ if (t >= EDITOR_OPEN) {
+ const f = ((t - EDITOR_OPEN) % PLAYBACK_LOOP) / PLAYBACK_LOOP;
+ if (playheadRef.current) {
+ playheadRef.current.style.left = `${128 + f * 0.78 * 1131}px`;
+ }
+ if (timeRef.current) {
+ const seconds = f * 32;
+ timeRef.current.textContent = `0:${String(
+ Math.floor(seconds),
+ ).padStart(2, "0")}.${String(
+ Math.floor((seconds % 1) * 100),
+ ).padStart(2, "0")}`;
+ }
+ }
+ if (canvasRef.current) {
+ canvasRef.current.style.transform = `scale(${zoomAt(t)})`;
+ }
+ if (canvasCursorRef.current) {
+ const glide = easeInOut(span(t, ZOOM_IN.start, ZOOM_IN.end));
+ const settle = easeInOut(span(t, 15800, 17000));
+ canvasCursorRef.current.style.left = `${lerp(
+ lerp(30, 61, glide),
+ 52,
+ settle,
+ )}%`;
+ canvasCursorRef.current.style.top = `${lerp(
+ lerp(40, 45.5, glide),
+ 58,
+ settle,
+ )}%`;
+ }
+ if (seek) canvasClickRef.current = t - 1;
+ if (canvasClickRef.current < CANVAS_CLICK && t >= CANVAS_CLICK) {
+ restartAnimation(
+ canvasRingRef.current,
+ "ht-scene-ripple 620ms ease-out",
+ );
+ }
+ canvasClickRef.current = t;
+ cursor.tick(PATH, t, seek);
+ },
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ onSwatch={noop}
+ onExport={noop}
+ onTogglePlay={noop}
+ />
+
+ {cursor.Cursor}
+
+ );
+};
+
+export const STUDIO: SceneModule = {
+ Scene: StudioScene,
+ chapters: CHAPTERS,
+ poster: SCENE_META.studio.poster,
+};
diff --git a/apps/web/components/pages/HomeTwo/scenes/catalog.ts b/apps/web/components/pages/HomeTwo/scenes/catalog.ts
new file mode 100644
index 00000000000..057609a93fc
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/scenes/catalog.ts
@@ -0,0 +1,35 @@
+export type Chapter = { start: number; end: number; pose: number };
+
+const chapterList = (
+ ...spans: [duration: number, pose: number][]
+): Chapter[] => {
+ let cursor = 0;
+ return spans.map(([duration, pose]) => {
+ const start = cursor;
+ cursor += duration;
+ return { start, end: cursor, pose: start + pose };
+ });
+};
+
+export const SCENE_META = {
+ instant: {
+ chapters: chapterList([6100, 4200], [5500, 4700], [6000, 5400]),
+ poster: 10800,
+ },
+ studio: {
+ chapters: chapterList([6400, 5800], [7000, 6300], [6600, 2200]),
+ poster: 11800,
+ },
+ screenshot: {
+ chapters: chapterList([6000, 4200], [6000, 4400]),
+ poster: 4200,
+ },
+ share: {
+ chapters: chapterList([7000, 6000], [7000, 6500], [6500, 5500]),
+ poster: 13500,
+ },
+ agent: {
+ chapters: chapterList([3600, 3000], [7000, 6800], [6400, 4200]),
+ poster: 13800,
+ },
+};
diff --git a/apps/web/components/pages/HomeTwo/scenes/engine.tsx b/apps/web/components/pages/HomeTwo/scenes/engine.tsx
new file mode 100644
index 00000000000..a246816883b
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/scenes/engine.tsx
@@ -0,0 +1,511 @@
+"use client";
+
+import { classNames } from "@cap/utils/helpers";
+import Image from "next/image";
+import {
+ type ComponentType,
+ type ReactNode,
+ type RefObject,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
+import { MacCursor } from "../cursors";
+import { MenuBar } from "../demo/MacDesktop";
+import { SceneMediaProvider, useSceneMedia } from "../demo/media";
+import { htGeist } from "../fonts";
+import { usePageVisible } from "../visibility";
+import type { Chapter } from "./catalog";
+
+export const STAGE = { w: 680, h: 510, bar: 28 } as const;
+export const LAYER = { w: STAGE.w, h: STAGE.h - STAGE.bar } as const;
+
+export type { Chapter } from "./catalog";
+
+export type SceneProps = {
+ chapter: number;
+ playing: boolean;
+ onChapterEnd?: () => void;
+ progressRef?: RefObject;
+ staticT?: number;
+};
+
+export type SceneModule = {
+ Scene: ComponentType;
+ chapters: Chapter[];
+ poster: number;
+};
+
+export const noop = () => {};
+
+export const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
+
+export const easeInOut = (f: number) =>
+ f < 0.5 ? 4 * f * f * f : 1 - (-2 * f + 2) ** 3 / 2;
+
+export const easeOut = (f: number) => 1 - (1 - f) ** 3;
+
+export const span = (t: number, from: number, to: number) =>
+ clamp01((t - from) / (to - from));
+
+export const lerp = (a: number, b: number, f: number) => a + (b - a) * f;
+
+export const clockText = (ms: number) => {
+ const s = Math.max(0, Math.floor(ms / 1000));
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
+};
+
+export const typed = (text: string, t: number, from: number, cps = 28) =>
+ text.slice(0, Math.max(0, Math.floor(((t - from) / 1000) * cps)));
+
+export const useSceneState = (initial: S) => {
+ const [state, setState] = useState(initial);
+ const keyRef = useRef("");
+ const set = useRef((next: S) => {
+ const key = JSON.stringify(next);
+ if (key === keyRef.current) return;
+ keyRef.current = key;
+ setState(next);
+ }).current;
+ return [state, set] as const;
+};
+
+export const useVideo = (
+ playing: boolean,
+ ref: RefObject,
+) => {
+ const { still } = useSceneMedia();
+ const pageVisible = usePageVisible();
+ useEffect(() => {
+ const video = ref.current;
+ if (!video || still) return;
+ if (playing && pageVisible) {
+ video.play().catch(noop);
+ return;
+ }
+ video.pause();
+ const nudge = () => {
+ if (video.currentTime === 0) video.currentTime = 0.1;
+ };
+ if (video.readyState >= 1) nudge();
+ else video.addEventListener("loadedmetadata", nudge, { once: true });
+ return () => video.removeEventListener("loadedmetadata", nudge);
+ }, [playing, ref, still, pageVisible]);
+};
+
+export const LazyMount = ({
+ w,
+ h,
+ rootMargin = "800px 0px",
+ grow,
+ className,
+ children,
+}: {
+ w: number;
+ h: number;
+ rootMargin?: string;
+ grow?: boolean;
+ className?: string;
+ children: ReactNode;
+}) => {
+ const boxRef = useRef(null);
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ const el = boxRef.current;
+ if (!el || mounted) return;
+ const io = new IntersectionObserver(
+ ([entry]) => {
+ if (entry?.isIntersecting) setMounted(true);
+ },
+ { rootMargin },
+ );
+ io.observe(el);
+ return () => io.disconnect();
+ }, [mounted, rootMargin]);
+
+ return (
+
+ {mounted ? children : null}
+
+ );
+};
+
+export { useInView, useReducedMotion } from "../visibility";
+
+type Tick = (t: number, seek: boolean) => void;
+
+export const useSceneClock = ({
+ chapters,
+ chapter,
+ playing,
+ staticT,
+ onChapterEnd,
+ progressRef,
+ tick,
+}: SceneProps & { chapters: Chapter[]; tick: Tick }) => {
+ const pageVisible = usePageVisible();
+ const tRef = useRef(staticT ?? chapters[chapter]?.start ?? 0);
+ const startedRef = useRef(false);
+ const tickRef = useRef(tick);
+ tickRef.current = tick;
+ const endRef = useRef(onChapterEnd);
+ endRef.current = onChapterEnd;
+
+ useEffect(() => {
+ if (staticT === undefined) return;
+ tRef.current = staticT;
+ tickRef.current(staticT, true);
+ const settle = setTimeout(() => tickRef.current(staticT, true), 700);
+ return () => clearTimeout(settle);
+ }, [staticT]);
+
+ useEffect(() => {
+ if (staticT !== undefined) return;
+ const current = chapters[chapter];
+ if (!current) return;
+ tRef.current = startedRef.current ? current.start : current.pose;
+ tickRef.current(tRef.current, true);
+ }, [chapter, chapters, staticT]);
+
+ useEffect(() => {
+ if (staticT !== undefined || !playing || !pageVisible) return;
+ const current = chapters[chapter];
+ if (!current) return;
+ if (!startedRef.current) {
+ startedRef.current = true;
+ tRef.current = current.start;
+ tickRef.current(current.start, true);
+ }
+ let raf = 0;
+ let last = performance.now();
+ const paint = (fraction: number) => {
+ const bar = progressRef?.current;
+ if (bar) bar.style.transform = `scaleX(${fraction})`;
+ };
+ const frame = (now: number) => {
+ const dt = Math.min(48, now - last);
+ last = now;
+ const t = tRef.current + dt;
+ if (t >= current.end) {
+ tRef.current = current.end;
+ tickRef.current(current.end, false);
+ paint(1);
+ endRef.current?.();
+ return;
+ }
+ tRef.current = t;
+ tickRef.current(t, false);
+ paint((t - current.start) / (current.end - current.start));
+ raf = requestAnimationFrame(frame);
+ };
+ raf = requestAnimationFrame(frame);
+ return () => cancelAnimationFrame(raf);
+ }, [playing, chapter, chapters, staticT, progressRef, pageVisible]);
+};
+
+export const restartAnimation = (el: HTMLElement | null, animation: string) => {
+ if (!el) return;
+ el.style.animation = "none";
+ void el.offsetWidth;
+ el.style.animation = animation;
+};
+
+export const quantize = (value: number, steps = 20) =>
+ Math.round(value * steps) / steps;
+
+export type Way = {
+ t: number;
+ x?: number;
+ y?: number;
+ at?: string;
+ dx?: number;
+ dy?: number;
+ click?: boolean;
+};
+
+type Point = { x: number; y: number };
+
+const anchorCenter = (root: HTMLElement, name: string): Point | null => {
+ const el = root.querySelector(
+ `[data-demo-anchor="${name}"], [data-scene-anchor="${name}"]`,
+ );
+ if (!el) return null;
+ const rootRect = root.getBoundingClientRect();
+ const scale = rootRect.width / LAYER.w || 1;
+ const rect = el.getBoundingClientRect();
+ return {
+ x: (rect.left - rootRect.left + rect.width / 2) / scale,
+ y: (rect.top - rootRect.top + rect.height / 2) / scale,
+ };
+};
+
+export const useCursor = (root: RefObject) => {
+ const elRef = useRef(null);
+ const ringRef = useRef(null);
+ const prevRef = useRef(-1);
+ const cache = useRef(new Map());
+
+ const resolve = (way: Way, settle = false): Point => {
+ if (way.at) {
+ let hit = cache.current.get(way.at);
+ if ((!hit || (settle && !hit.settled)) && root.current) {
+ const measured = anchorCenter(root.current, way.at);
+ if (measured) {
+ hit = { point: measured, settled: settle };
+ cache.current.set(way.at, hit);
+ }
+ }
+ if (hit) {
+ return {
+ x: hit.point.x + (way.dx ?? 0),
+ y: hit.point.y + (way.dy ?? 0),
+ };
+ }
+ }
+ return { x: way.x ?? 0, y: way.y ?? 0 };
+ };
+
+ const positionAt = (path: Way[], t: number): Point => {
+ const first = path[0];
+ if (!first) return { x: 0, y: 0 };
+ if (t <= first.t) return resolve(first);
+ for (let i = 1; i < path.length; i++) {
+ const a = path[i - 1];
+ const b = path[i];
+ if (!a || !b) break;
+ if (t <= b.t) {
+ const progress = span(t, a.t, b.t);
+ const from = resolve(a);
+ const to = resolve(b, progress > 0.5);
+ const f = easeInOut(progress);
+ return { x: lerp(from.x, to.x, f), y: lerp(from.y, to.y, f) };
+ }
+ }
+ return resolve(path[path.length - 1] ?? first, true);
+ };
+
+ const tick = (path: Way[], t: number, seek: boolean) => {
+ if (seek) {
+ prevRef.current = t - 1;
+ cache.current.clear();
+ }
+ const pos = positionAt(path, t);
+ const el = elRef.current;
+ if (el) el.style.transform = `translate3d(${pos.x}px, ${pos.y}px, 0)`;
+ const clicked =
+ !seek && path.some((w) => w.click && w.t > prevRef.current && w.t <= t);
+ if (clicked)
+ restartAnimation(ringRef.current, "ht-scene-ripple 520ms ease-out");
+ prevRef.current = t;
+ };
+
+ const Cursor = (
+
+
+
+
+
+
+ );
+
+ return { tick, Cursor };
+};
+
+export const SCENE_CSS = `
+ @keyframes ht-scene-ripple {
+ 0% { transform: scale(0.35); opacity: 0.9; }
+ 100% { transform: scale(1.6); opacity: 0; }
+ }
+ @keyframes ht-demo-mic {
+ 0% { transform: translateX(-70%); }
+ 15% { transform: translateX(-42%); }
+ 30% { transform: translateX(-60%); }
+ 45% { transform: translateX(-30%); }
+ 60% { transform: translateX(-55%); }
+ 75% { transform: translateX(-38%); }
+ 100% { transform: translateX(-70%); }
+ }
+ .ht-demo-mic-meter { animation: ht-demo-mic 1.6s ease-in-out infinite; }
+ @keyframes ht-scene-pop {
+ 0% { transform: translateY(6px) scale(0.6); opacity: 0; }
+ 60% { transform: translateY(-10px) scale(1.15); opacity: 1; }
+ 100% { transform: translateY(-26px) scale(1); opacity: 0; }
+ }
+`;
+
+export const Scaled = ({
+ w,
+ h,
+ className,
+ style,
+ inert: isInert,
+ grow,
+ children,
+}: {
+ w: number;
+ h: number;
+ className?: string;
+ style?: React.CSSProperties;
+ inert?: boolean;
+ grow?: boolean;
+ children: ReactNode;
+}) => {
+ const boxRef = useRef(null);
+ const [scale, setScale] = useState(null);
+
+ useLayoutEffect(() => {
+ const box = boxRef.current;
+ if (!box) return;
+ const measure = () =>
+ setScale(grow ? box.clientWidth / w : Math.min(1, box.clientWidth / w));
+ measure();
+ const ro = new ResizeObserver(measure);
+ ro.observe(box);
+ return () => ro.disconnect();
+ }, [w, grow]);
+
+ const s = scale ?? 1;
+ return (
+
+ );
+};
+
+export const Fit = ({
+ w,
+ h,
+ className,
+ still,
+ grow,
+ children,
+}: {
+ w: number;
+ h: number;
+ className?: string;
+ still?: boolean;
+ grow?: boolean;
+ children: ReactNode;
+}) => (
+
+
+ {children}
+
+
+);
+
+export const Stage = ({
+ wallpaper,
+ recording,
+ layerRef,
+ children,
+}: {
+ wallpaper: string;
+ recording?: boolean;
+ layerRef?: RefObject;
+ children: ReactNode;
+}) => (
+
+);
+
+export const Reveal = ({
+ show,
+ from = "translateY(8px) scale(0.98)",
+ className,
+ style,
+ children,
+}: {
+ show: boolean;
+ from?: string;
+ className?: string;
+ style?: React.CSSProperties;
+ children: ReactNode;
+}) => (
+
+ {children}
+
+);
diff --git a/apps/web/components/pages/HomeTwo/scenes/index.ts b/apps/web/components/pages/HomeTwo/scenes/index.ts
new file mode 100644
index 00000000000..b319c2a1f80
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/scenes/index.ts
@@ -0,0 +1,39 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import { createElement } from "react";
+import type { ModeKey } from "../theme";
+import { SCENE_META } from "./catalog";
+import type { SceneModule } from "./engine";
+
+export const SCENES: Record = {
+ instant: {
+ ...SCENE_META.instant,
+ Scene: dynamic(() => import("./InstantScene").then((m) => m.InstantScene)),
+ },
+ studio: {
+ ...SCENE_META.studio,
+ Scene: dynamic(() => import("./StudioScene").then((m) => m.StudioScene)),
+ },
+ screenshot: {
+ ...SCENE_META.screenshot,
+ Scene: dynamic(() =>
+ import("./ScreenshotScene").then((m) => m.ScreenshotScene),
+ ),
+ },
+ share: {
+ ...SCENE_META.share,
+ Scene: dynamic(() => import("./AiScene").then((m) => m.AiScene)),
+ },
+};
+
+export const AGENT: SceneModule = {
+ ...SCENE_META.agent,
+ Scene: dynamic(() => import("./AgentScene").then((m) => m.AgentScene), {
+ loading: () =>
+ createElement("div", { style: { aspectRatio: "1200 / 520" } }),
+ }),
+};
+
+export type { SceneModule, SceneProps } from "./engine";
+export { Fit, STAGE } from "./engine";
diff --git a/apps/web/components/pages/HomeTwo/seo.ts b/apps/web/components/pages/HomeTwo/seo.ts
new file mode 100644
index 00000000000..9c5399114cb
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/seo.ts
@@ -0,0 +1,102 @@
+import { PRICING } from "@/data/pricing";
+
+export const homepageSeo = {
+ url: "https://cap.so/",
+ title: "Cap — Free Screen Recorder & Open Source Loom Alternative",
+ description:
+ "Cap is the free, open source screen recorder for Mac, Windows, and Linux. Record, edit, take screenshots, and share videos with a link.",
+} as const;
+
+export const homepageSchema = {
+ "@context": "https://schema.org",
+ "@graph": [
+ {
+ "@type": "Organization",
+ "@id": "https://cap.so/#organization",
+ name: "Cap",
+ url: homepageSeo.url,
+ logo: {
+ "@type": "ImageObject",
+ url: "https://cap.so/cap-logo.png",
+ width: 1459,
+ height: 480,
+ },
+ sameAs: [
+ "https://github.com/CapSoftware/Cap",
+ "https://x.com/cap",
+ "https://www.linkedin.com/company/caprecorder/",
+ ],
+ contactPoint: {
+ "@type": "ContactPoint",
+ email: "hello@cap.so",
+ contactType: "customer support",
+ },
+ },
+ {
+ "@type": "WebSite",
+ "@id": "https://cap.so/#website",
+ name: "Cap",
+ url: homepageSeo.url,
+ publisher: { "@id": "https://cap.so/#organization" },
+ inLanguage: "en",
+ },
+ {
+ "@type": "WebPage",
+ "@id": "https://cap.so/#webpage",
+ url: homepageSeo.url,
+ name: homepageSeo.title,
+ description: homepageSeo.description,
+ isPartOf: { "@id": "https://cap.so/#website" },
+ mainEntity: { "@id": "https://cap.so/#software" },
+ inLanguage: "en",
+ },
+ {
+ "@type": "SoftwareApplication",
+ "@id": "https://cap.so/#software",
+ name: "Cap",
+ url: homepageSeo.url,
+ description: homepageSeo.description,
+ applicationCategory: "MultimediaApplication",
+ operatingSystem: ["macOS", "Windows", "Linux"],
+ downloadUrl: "https://cap.so/download",
+ publisher: { "@id": "https://cap.so/#organization" },
+ mainEntityOfPage: { "@id": "https://cap.so/#webpage" },
+ featureList: [
+ "Screen, webcam, and audio recording",
+ "Instant video sharing",
+ "Local recording with a built-in video editor",
+ "Screenshot capture and annotation",
+ "Custom backgrounds and automatic zoom",
+ "Google Drive and S3 storage integrations",
+ "Open source and self-hostable",
+ ],
+ offers: [
+ {
+ "@type": "Offer",
+ name: "Cap Free",
+ price: 0,
+ priceCurrency: "USD",
+ url: "https://cap.so/download",
+ description: "Free local recording and editing for personal use.",
+ },
+ {
+ "@type": "Offer",
+ name: "Desktop License",
+ price: PRICING.commercial.lifetime,
+ priceCurrency: "USD",
+ url: "https://cap.so/pricing",
+ description: "One-time desktop license for commercial use.",
+ },
+ {
+ "@type": "Offer",
+ name: "Cap Pro",
+ price: PRICING.pro.monthly,
+ priceCurrency: "USD",
+ url: "https://cap.so/pricing",
+ description:
+ "Per user, billed monthly. Annual billing is also available.",
+ },
+ ],
+ },
+ ],
+};
diff --git a/apps/web/components/pages/HomeTwo/studio/StudioFeatures.tsx b/apps/web/components/pages/HomeTwo/studio/StudioFeatures.tsx
new file mode 100644
index 00000000000..91b48d8a687
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/studio/StudioFeatures.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import { classNames } from "@cap/utils/helpers";
+import { useRef } from "react";
+import { Eyebrow } from "../Eyebrow";
+import { Fit, LazyMount } from "../scenes/engine";
+import {
+ BAND,
+ BODY_TEXT,
+ grainBg,
+ H_SECTION,
+ MODE_THEME,
+ meshStyle,
+} from "../theme";
+import { useInView, useReducedMotion } from "../visibility";
+import { CARDS_A, CARDS_B } from "./catalog";
+import { CANVAS, type StudioCard } from "./shared";
+
+const ORDER = [
+ "mask",
+ "text",
+ "scenes",
+ "zoom",
+ "captions",
+ "three-d",
+ "canvas",
+ "grades",
+ "clips",
+];
+const SPAN: Record = { mask: 2, scenes: 2, clips: 2 };
+const ALL = [...CARDS_A, ...CARDS_B];
+const CARDS = ORDER.flatMap((key) => {
+ const card = ALL.find((item) => item.key === key);
+ return card ? [{ ...card, span: SPAN[key] ?? 1 }] : [];
+});
+
+const Card = ({ card }: { card: StudioCard }) => {
+ const ref = useRef(null);
+ const inView = useInView(ref, "-5% 0px -5% 0px");
+ const reduced = useReducedMotion();
+ return (
+
+
+
+
+
+
+
+
+
+
+ {card.title}
+
+
+ {card.body}
+
+
+
+ );
+};
+
+export const StudioFeatures = () => (
+
+
+
+
+ Studio Mode · The editor
+
+
+ Polish it before anyone sees it
+
+
+ Studio Mode opens straight into an editor made for screen recordings.
+ Blur what is private, switch scenes between screen and camera, add
+ text and captions, grade the color, and export in 4K or as a link.
+
+
+
+
+ {CARDS.map((card) => (
+
+ ))}
+
+
+
+);
diff --git a/apps/web/components/pages/HomeTwo/studio/cardsA.tsx b/apps/web/components/pages/HomeTwo/studio/cardsA.tsx
new file mode 100644
index 00000000000..a6571e326fb
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/studio/cardsA.tsx
@@ -0,0 +1,795 @@
+"use client";
+
+import { classNames } from "@cap/utils/helpers";
+import { useRef } from "react";
+import { ContentWindow } from "../demo/MacDesktop";
+import { useVideoAttrs, VIDEO_POSTERS } from "../demo/media";
+import {
+ easeInOut,
+ easeOut,
+ lerp,
+ restartAnimation,
+ SCENE_CSS,
+ span,
+ typed,
+ useCursor,
+ useSceneState,
+ useVideo,
+ type Way,
+} from "../scenes/engine";
+import {
+ CameraBubble,
+ CanvasStage,
+ Chip,
+ RECORDED,
+ RecordedWindow,
+ useLoop,
+} from "./shared";
+
+const MASK_DURATION = 8200;
+const MASK = { x: RECORDED.left + 72, y: RECORDED.top + 112, w: 268, h: 52 };
+const MASK_DRAG = { start: 600, end: 1700 };
+const MASK_BLUR = 1900;
+const MASK_PIXELATE = 3600;
+const MASK_HIGHLIGHT = 5400;
+const MASK_CONTROL = { x: MASK.x, y: MASK.y + MASK.h + 14 };
+const MASK_MODES = ["Blur", "Pixelate", "Highlight"] as const;
+const SEGMENT_W = 74;
+
+const MASK_PATH: Way[] = [
+ { t: 0, x: MASK.x - 36, y: MASK.y - 26 },
+ { t: MASK_DRAG.start, x: MASK.x, y: MASK.y },
+ { t: MASK_DRAG.end, x: MASK.x + MASK.w, y: MASK.y + MASK.h },
+ { t: MASK_BLUR, x: MASK.x + MASK.w, y: MASK.y + MASK.h },
+ { t: 3400, x: MASK_CONTROL.x + SEGMENT_W * 1.5, y: MASK_CONTROL.y + 14 },
+ {
+ t: MASK_PIXELATE,
+ x: MASK_CONTROL.x + SEGMENT_W * 1.5,
+ y: MASK_CONTROL.y + 14,
+ click: true,
+ },
+ { t: 5200, x: MASK_CONTROL.x + SEGMENT_W * 2.5, y: MASK_CONTROL.y + 14 },
+ {
+ t: MASK_HIGHLIGHT,
+ x: MASK_CONTROL.x + SEGMENT_W * 2.5,
+ y: MASK_CONTROL.y + 14,
+ click: true,
+ },
+ { t: 6600, x: MASK.x + MASK.w + 60, y: MASK.y + MASK.h + 90 },
+ { t: MASK_DURATION, x: MASK.x + MASK.w + 60, y: MASK.y + MASK.h + 90 },
+];
+
+const PIXELS = Array.from({ length: 22 * 4 }, (_, i) => {
+ const seed = Math.sin(i * 12.9898 + 4.1414) * 43758.5453;
+ const v = seed - Math.floor(seed);
+ const l = 62 + Math.floor(v * 30);
+ return `hsl(216 22% ${l}%)`;
+});
+
+const maskUiAt = (t: number) => ({
+ drawing: t >= MASK_DRAG.start && t < MASK_BLUR,
+ mode:
+ t >= MASK_HIGHLIGHT
+ ? "Highlight"
+ : t >= MASK_PIXELATE
+ ? "Pixelate"
+ : t >= MASK_BLUR
+ ? "Blur"
+ : null,
+});
+
+export const BlurVisual = ({ playing }: { playing: boolean }) => {
+ const rootRef = useRef(null);
+ const boxRef = useRef(null);
+ const [ui, setUi] = useSceneState(maskUiAt(0));
+ const cursor = useCursor(rootRef);
+
+ useLoop({
+ duration: MASK_DURATION,
+ playing,
+ pose: 4400,
+ tick: (t, seek) => {
+ setUi(maskUiAt(t));
+ const drag = easeOut(span(t, MASK_DRAG.start, MASK_DRAG.end));
+ if (boxRef.current) {
+ boxRef.current.style.width = `${lerp(0, MASK.w, drag)}px`;
+ boxRef.current.style.height = `${lerp(0, MASK.h, drag)}px`;
+ boxRef.current.style.opacity = t >= MASK_DRAG.start ? "1" : "0";
+ }
+ cursor.tick(MASK_PATH, t, seek);
+ },
+ });
+
+ const applied = ui.mode !== null;
+ const highlight = ui.mode === "Highlight";
+
+ return (
+
+
+
+
+
+
+
+
+ {PIXELS.map((color, i) => (
+
+ ))}
+
+
+
+ {[
+ { left: MASK.x - 5, top: MASK.y - 5 },
+ { left: MASK.x + MASK.w - 5, top: MASK.y - 5 },
+ { left: MASK.x - 5, top: MASK.y + MASK.h - 5 },
+ { left: MASK.x + MASK.w - 5, top: MASK.y + MASK.h - 5 },
+ ].map((handle) => (
+
+ ))}
+
+
+ {MASK_MODES.map((mode) => (
+
+ {mode}
+
+ ))}
+
+ {cursor.Cursor}
+
+
+ );
+};
+
+const TRACK = { top: 328, height: 36, left: 96, right: 584 } as const;
+
+const TrackStrip = ({
+ label,
+ children,
+ playhead,
+}: {
+ label: string;
+ children: React.ReactNode;
+ playhead: React.RefObject;
+}) => (
+
+);
+
+const CameraPane = ({
+ playing,
+ style,
+ className,
+}: {
+ playing: boolean;
+ style?: React.CSSProperties;
+ className?: string;
+}) => {
+ const ref = useRef(null);
+ const attrs = useVideoAttrs(VIDEO_POSTERS.webcam);
+ useVideo(playing, ref);
+ return (
+
+
+
+ );
+};
+
+const SCENES_DURATION = 12000;
+const SCENE_MODES = [
+ { key: "default", label: "Default" },
+ { key: "cameraOnly", label: "Camera only" },
+ { key: "splitScreen", label: "Split screen" },
+ { key: "floating", label: "Floating" },
+] as const;
+type SceneKey = (typeof SCENE_MODES)[number]["key"];
+const SCENE_SLOT = SCENES_DURATION / SCENE_MODES.length;
+const SCENE_SCREEN = { w: 360, h: 250 } as const;
+
+const SCENE_LAYOUT: Record<
+ SceneKey,
+ {
+ screen: {
+ left: number;
+ top: number;
+ w: number;
+ h: number;
+ shift: number;
+ scale: number;
+ radius: number;
+ opacity: number;
+ };
+ camera: {
+ left: number;
+ top: number;
+ w: number;
+ h: number;
+ radius: number;
+ opacity: number;
+ };
+ }
+> = {
+ default: {
+ screen: {
+ left: 120,
+ top: 30,
+ w: 360,
+ h: 250,
+ shift: 0,
+ scale: 1,
+ radius: 10,
+ opacity: 1,
+ },
+ camera: { left: 28, top: 222, w: 88, h: 88, radius: 44, opacity: 1 },
+ },
+ cameraOnly: {
+ screen: {
+ left: 120,
+ top: 30,
+ w: 360,
+ h: 250,
+ shift: 0,
+ scale: 1,
+ radius: 10,
+ opacity: 0,
+ },
+ camera: { left: 120, top: 30, w: 360, h: 250, radius: 12, opacity: 1 },
+ },
+ splitScreen: {
+ screen: {
+ left: 24,
+ top: 30,
+ w: 272,
+ h: 250,
+ shift: -6,
+ scale: 1,
+ radius: 12,
+ opacity: 1,
+ },
+ camera: { left: 304, top: 30, w: 272, h: 250, radius: 12, opacity: 1 },
+ },
+ floating: {
+ screen: {
+ left: 52,
+ top: 40,
+ w: 320,
+ h: 222,
+ shift: 0,
+ scale: 320 / 360,
+ radius: 14,
+ opacity: 1,
+ },
+ camera: { left: 396, top: 84, w: 160, h: 160, radius: 18, opacity: 1 },
+ },
+};
+
+const SCENE_EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
+const sceneTransition = `left 480ms ${SCENE_EASE}, top 480ms ${SCENE_EASE}, width 480ms ${SCENE_EASE}, height 480ms ${SCENE_EASE}, border-radius 480ms ${SCENE_EASE}, opacity 320ms ease, transform 480ms ${SCENE_EASE}`;
+
+const sceneAt = (t: number): SceneKey =>
+ SCENE_MODES[Math.min(SCENE_MODES.length - 1, Math.floor(t / SCENE_SLOT))]
+ ?.key ?? "default";
+
+export const ScenesVisual = ({ playing }: { playing: boolean }) => {
+ const playheadRef = useRef(null);
+ const [scene, setScene] = useSceneState("default");
+ const scrollRef = useRef(null);
+
+ useLoop({
+ duration: SCENES_DURATION,
+ playing,
+ pose: 7000,
+ tick: (t) => {
+ setScene(sceneAt(t));
+ if (playheadRef.current) {
+ playheadRef.current.style.left = `${(t / SCENES_DURATION) * 100}%`;
+ }
+ },
+ });
+
+ const layout = SCENE_LAYOUT[scene];
+
+ return (
+
+
+
+
+ {SCENE_MODES.map((mode, i) => (
+
+ {mode.label}
+
+ ))}
+
+
+ );
+};
+
+const TEXT_DURATION = 10000;
+const LOWER = { enter: 400, exit: 2900 };
+const STAT = { enter: 3700, exit: 6100 };
+const TYPE = { enter: 6600, exit: 9400 };
+const TYPEWRITER_TEXT = "Try the new flow →";
+
+const textPresetAt = (t: number) =>
+ t >= TYPE.enter - 100 && t < TYPE.exit + 300
+ ? "Typewriter · Typewriter"
+ : t >= STAT.enter - 100 && t < STAT.exit + 300
+ ? "Big Stat · Pop"
+ : t >= LOWER.enter - 100 && t < LOWER.exit + 300
+ ? "Lower Third · Slide up"
+ : null;
+
+export const TextVisual = ({ playing }: { playing: boolean }) => {
+ const lowerRef = useRef(null);
+ const statRef = useRef(null);
+ const typeRef = useRef(null);
+ const typeTextRef = useRef(null);
+ const [preset, setPreset] = useSceneState(null);
+ const lastPreset = useRef("Lower Third · Slide up");
+ if (preset) lastPreset.current = preset;
+
+ useLoop({
+ duration: TEXT_DURATION,
+ playing,
+ pose: 4800,
+ tick: (t) => {
+ setPreset(textPresetAt(t));
+ if (lowerRef.current) {
+ const inF = easeOut(span(t, LOWER.enter, LOWER.enter + 480));
+ const outF = easeOut(span(t, LOWER.exit, LOWER.exit + 360));
+ lowerRef.current.style.opacity = `${inF * (1 - outF)}`;
+ lowerRef.current.style.transform = `translateY(${lerp(26, 0, inF) + outF * 18}px)`;
+ }
+ if (statRef.current) {
+ const inF = span(t, STAT.enter, STAT.enter + 520);
+ const overshoot = 1 + Math.sin(inF * Math.PI) * 0.12;
+ const outF = easeOut(span(t, STAT.exit, STAT.exit + 320));
+ statRef.current.style.opacity = `${easeOut(inF) * (1 - outF)}`;
+ statRef.current.style.transform = `translateX(-50%) scale(${lerp(0.6, 1, easeOut(inF)) * overshoot})`;
+ }
+ if (typeRef.current && typeTextRef.current) {
+ const outF = easeOut(span(t, TYPE.exit, TYPE.exit + 300));
+ typeRef.current.style.opacity = `${t >= TYPE.enter ? 1 - outF : 0}`;
+ typeTextRef.current.textContent =
+ t >= TYPE.enter
+ ? typed(TYPEWRITER_TEXT, t, TYPE.enter + 200, 22)
+ : "";
+ }
+ },
+ });
+
+ return (
+
+
+
+
+
+
+
+
+ Sofia Chen
+
+
+ Head of Product
+
+
+
+
+
+
+ 3.2×
+
+
+ faster onboarding
+
+
+
+
+
+
+
+
+
+ {preset ?? lastPreset.current}
+
+
+ );
+};
+
+const ZOOM_DURATION = 8000;
+const ZOOM_FOCUS = { x: 420, y: 104 };
+const ZOOM_CLICK = 1500;
+const ZOOM_IN = { start: 1600, end: 2600 };
+const ZOOM_OUT = { start: 5200, end: 6200 };
+const ZOOM_PATH: Way[] = [
+ { t: 0, x: 300, y: 230 },
+ { t: 1400, x: ZOOM_FOCUS.x, y: ZOOM_FOCUS.y },
+ { t: ZOOM_CLICK, x: ZOOM_FOCUS.x, y: ZOOM_FOCUS.y, click: true },
+ { t: 3200, x: ZOOM_FOCUS.x, y: ZOOM_FOCUS.y },
+ { t: 4600, x: ZOOM_FOCUS.x + 26, y: ZOOM_FOCUS.y + 34 },
+ { t: ZOOM_DURATION, x: ZOOM_FOCUS.x + 26, y: ZOOM_FOCUS.y + 34 },
+];
+
+const zoomScaleAt = (t: number) =>
+ 1 +
+ easeInOut(span(t, ZOOM_IN.start, ZOOM_IN.end)) -
+ easeInOut(span(t, ZOOM_OUT.start, ZOOM_OUT.end));
+
+export const ZoomVisual = ({ playing }: { playing: boolean }) => {
+ const rootRef = useRef(null);
+ const zoomRef = useRef(null);
+ const ringRef = useRef(null);
+ const playheadRef = useRef(null);
+ const clickRef = useRef(-1);
+ const [ui, setUi] = useSceneState({ segment: false });
+ const cursor = useCursor(rootRef);
+
+ useLoop({
+ duration: ZOOM_DURATION,
+ playing,
+ pose: 3600,
+ tick: (t, seek) => {
+ setUi({ segment: t >= ZOOM_CLICK });
+ if (zoomRef.current) {
+ zoomRef.current.style.transform = `scale(${zoomScaleAt(t)})`;
+ }
+ if (playheadRef.current) {
+ playheadRef.current.style.left = `${(t / ZOOM_DURATION) * 100}%`;
+ }
+ if (seek) clickRef.current = t - 1;
+ if (clickRef.current < ZOOM_CLICK && t >= ZOOM_CLICK) {
+ restartAnimation(ringRef.current, "ht-scene-ripple 700ms ease-out");
+ }
+ clickRef.current = t;
+ cursor.tick(ZOOM_PATH, t, seek);
+ },
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ 2×
+
+
+ {cursor.Cursor}
+
+
+ );
+};
+
+const CAPTIONS_DURATION = 9000;
+const CAPTION_LINES = [
+ {
+ start: 600,
+ words: "So this is the new dashboard we’re shipping".split(" "),
+ },
+ { start: 4400, words: "Every card here pulls live data".split(" ") },
+];
+const WORD_MS = 380;
+const CAPTION_CLOCK_BASE = 4;
+const BURN_TOGGLE = { x: 548, y: 30 };
+const BURN_AT = 7600;
+const CAPTION_PATH: Way[] = [
+ { t: 0, x: 470, y: 250 },
+ { t: 6600, x: 470, y: 250 },
+ { t: 7400, x: BURN_TOGGLE.x, y: BURN_TOGGLE.y },
+ { t: BURN_AT, x: BURN_TOGGLE.x, y: BURN_TOGGLE.y, click: true },
+ { t: 8400, x: BURN_TOGGLE.x - 60, y: BURN_TOGGLE.y + 70 },
+ { t: CAPTIONS_DURATION, x: BURN_TOGGLE.x - 60, y: BURN_TOGGLE.y + 70 },
+];
+
+const captionAt = (t: number) => {
+ let line = 0;
+ for (let i = 0; i < CAPTION_LINES.length; i++) {
+ if (t >= (CAPTION_LINES[i]?.start ?? 0)) line = i;
+ }
+ const current = CAPTION_LINES[line];
+ const elapsed = t - (current?.start ?? 0);
+ const active = Math.min(
+ (current?.words.length ?? 1) - 1,
+ Math.floor(Math.max(0, elapsed) / WORD_MS),
+ );
+ return {
+ line,
+ active,
+ shown: t >= (CAPTION_LINES[0]?.start ?? 0),
+ burn: t >= BURN_AT,
+ };
+};
+
+export const CaptionsVisual = ({ playing }: { playing: boolean }) => {
+ const rootRef = useRef(null);
+ const clockRef = useRef(null);
+ const [ui, setUi] = useSceneState(captionAt(0));
+ const cursor = useCursor(rootRef);
+
+ useLoop({
+ duration: CAPTIONS_DURATION,
+ playing,
+ pose: 2200,
+ tick: (t, seek) => {
+ setUi(captionAt(t));
+ if (clockRef.current) {
+ const seconds =
+ CAPTION_CLOCK_BASE + Math.floor(Math.max(0, t - 600) / 1000);
+ clockRef.current.textContent = `0:${String(seconds).padStart(2, "0")}`;
+ }
+ cursor.tick(CAPTION_PATH, t, seek);
+ },
+ });
+
+ const line = CAPTION_LINES[ui.line] ?? CAPTION_LINES[0];
+
+ return (
+
+
+
+
+
+
+
+
+ English · Whisper
+
+
+
+
+ Burn in
+
+
+
+
+
+
+ {line?.words.map((word, i) => (
+
+ {word}
+
+ ))}
+
+
+ 0:04
+
+ {cursor.Cursor}
+
+
+ );
+};
diff --git a/apps/web/components/pages/HomeTwo/studio/cardsB.tsx b/apps/web/components/pages/HomeTwo/studio/cardsB.tsx
new file mode 100644
index 00000000000..9a9a2a3b219
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/studio/cardsB.tsx
@@ -0,0 +1,843 @@
+"use client";
+
+import { classNames } from "@cap/utils/helpers";
+import Image from "next/image";
+import { useRef } from "react";
+import {
+ easeInOut,
+ lerp,
+ span,
+ useCursor,
+ useSceneState,
+ type Way,
+} from "../scenes/engine";
+import {
+ CANVAS,
+ CameraBubble,
+ CanvasStage,
+ Chip,
+ RECORDED,
+ RecordedWindow,
+ useLoop,
+} from "./shared";
+
+const THREE_D_DURATION = 9000;
+
+const threeDPoseAt = (t: number) => {
+ if (t < 3000) {
+ const f = easeInOut(span(t, 0, 3000));
+ return {
+ ry: lerp(-14, 14, f),
+ rx: 0,
+ tx: lerp(-28, 28, f),
+ s: 1,
+ label: "Glide across",
+ };
+ }
+ if (t < 6000) {
+ const f = easeInOut(span(t, 3000, 6000));
+ return {
+ ry: lerp(14, 0, f),
+ rx: lerp(0, 10, f),
+ tx: lerp(28, 0, f),
+ s: lerp(1, 0.86, f),
+ label: "Pull back",
+ };
+ }
+ if (t < 7500) {
+ const f = easeInOut(span(t, 6000, 7500));
+ return {
+ ry: lerp(0, -24, f),
+ rx: lerp(10, 6, f),
+ tx: lerp(0, -10, f),
+ s: lerp(0.86, 1, f),
+ label: "Tilt away",
+ };
+ }
+ const f = easeInOut(span(t, 7500, THREE_D_DURATION));
+ return {
+ ry: lerp(-24, -14, f),
+ rx: lerp(6, 0, f),
+ tx: lerp(-10, -28, f),
+ s: 1,
+ label: "Tilt away",
+ };
+};
+
+export const ThreeDVisual = ({ playing }: { playing: boolean }) => {
+ const groupRef = useRef(null);
+ const depthRef = useRef(null);
+ const vignetteRef = useRef(null);
+ const floorRef = useRef(null);
+ const [ui, setUi] = useSceneState({ label: "Glide across" });
+
+ useLoop({
+ duration: THREE_D_DURATION,
+ playing,
+ pose: 6900,
+ tick: (t) => {
+ const pose = threeDPoseAt(t);
+ setUi({ label: pose.label });
+ const tilt = Math.min(1, Math.abs(pose.ry) / 24);
+ if (groupRef.current) {
+ groupRef.current.style.transform = `translateX(${pose.tx}px) scale(${pose.s}) rotateY(${pose.ry}deg) rotateX(${pose.rx}deg)`;
+ }
+ if (depthRef.current) {
+ depthRef.current.style.opacity = `${tilt * 0.55}`;
+ depthRef.current.style.background =
+ pose.ry > 0
+ ? "linear-gradient(to right, rgba(9,12,20,0) 35%, rgba(9,12,20,0.75))"
+ : "linear-gradient(to left, rgba(9,12,20,0) 35%, rgba(9,12,20,0.75))";
+ }
+ if (vignetteRef.current) {
+ vignetteRef.current.style.opacity = `${0.18 + tilt * 0.4}`;
+ }
+ if (floorRef.current) {
+ floorRef.current.style.transform = `translateX(${pose.tx * 1.15}px) scaleX(${0.7 + pose.s * 0.4}) scaleY(${pose.s})`;
+ floorRef.current.style.opacity = `${0.35 + tilt * 0.3}`;
+ }
+ },
+ });
+
+ return (
+
+
+
+
+
+
+
+ 3D camera
+
+ {ui.label}
+
+
+ );
+};
+
+const CANVAS_DURATION = 10000;
+const WALLS = [
+ "/backgrounds/monaco.webp",
+ "/backgrounds/santorini.webp",
+ "/backgrounds/nyc.webp",
+];
+type FrameKind = "none" | "browser" | "macbook";
+
+const canvasUiAt = (t: number) => ({
+ wall: t < 2500 ? 0 : t < 5000 ? 1 : 2,
+ frame: (t < 5000
+ ? "none"
+ : t < 7600
+ ? "browser"
+ : t < 9500
+ ? "macbook"
+ : "none") as FrameKind,
+});
+
+const FRAME_LABEL: Record = {
+ none: "Frame · None",
+ browser: "Frame · Browser",
+ macbook: "Frame · MacBook",
+};
+
+const canvasScaleAt = (t: number) => {
+ const grow = easeInOut(span(t, 2500, 3500));
+ const back = easeInOut(span(t, 9500, CANVAS_DURATION));
+ const s = lerp(lerp(1, 0.9, grow), 1, back);
+ const r = lerp(lerp(10, 20, grow), 10, back);
+ return { s, r };
+};
+
+export const CanvasVisual = ({ playing }: { playing: boolean }) => {
+ const groupRef = useRef(null);
+ const clipRef = useRef(null);
+ const [ui, setUi] = useSceneState(canvasUiAt(0));
+
+ useLoop({
+ duration: CANVAS_DURATION,
+ playing,
+ pose: 6400,
+ tick: (t) => {
+ setUi(canvasUiAt(t));
+ const { s, r } = canvasScaleAt(t);
+ if (groupRef.current) groupRef.current.style.transform = `scale(${s})`;
+ if (clipRef.current) clipRef.current.style.borderRadius = `${r}px`;
+ },
+ });
+
+ const browser = ui.frame === "browser";
+ const macbook = ui.frame === "macbook";
+
+ return (
+
+ {WALLS.slice(1).map((wall, i) => (
+ = i + 1 ? 1 : 0 }}
+ />
+ ))}
+
+
+
+
+
+ {["#FF5F57", "#FEBC2E", "#28C840"].map((color) => (
+
+ ))}
+
+
+ Dashboard
+
+
+ acme.com/dashboard
+
+
+
+
+
+
+ {FRAME_LABEL[ui.frame]}
+
+
+ );
+};
+
+type Grade = {
+ name: string;
+ filter: string;
+ tint: string;
+ sliders: [number, number, number];
+};
+
+const NONE_GRADE: Grade = {
+ name: "None",
+ filter: "none",
+ tint: "transparent",
+ sliders: [0.5, 0.5, 0.5],
+};
+
+const GRADES: Grade[] = [
+ {
+ name: "Cinematic",
+ filter: "contrast(1.15) saturate(0.82) sepia(0.12)",
+ tint: "rgba(24,52,92,0.18)",
+ sliders: [0.45, 0.68, 0.42],
+ },
+ {
+ name: "Noir",
+ filter: "grayscale(1) contrast(1.25) brightness(0.96)",
+ tint: "rgba(0,0,0,0.08)",
+ sliders: [0.48, 0.78, 0.02],
+ },
+ {
+ name: "Vintage",
+ filter: "sepia(0.45) contrast(0.95) brightness(1.04) saturate(0.9)",
+ tint: "rgba(255,196,120,0.16)",
+ sliders: [0.56, 0.42, 0.44],
+ },
+ {
+ name: "Frost",
+ filter: "saturate(0.75) brightness(1.06) hue-rotate(-8deg)",
+ tint: "rgba(168,208,255,0.24)",
+ sliders: [0.6, 0.46, 0.36],
+ },
+ {
+ name: "Golden",
+ filter: "sepia(0.3) saturate(1.2) brightness(1.03)",
+ tint: "rgba(255,186,74,0.2)",
+ sliders: [0.55, 0.52, 0.66],
+ },
+ {
+ name: "Midnight",
+ filter: "brightness(0.82) contrast(1.15) saturate(0.9) hue-rotate(12deg)",
+ tint: "rgba(22,30,86,0.3)",
+ sliders: [0.3, 0.66, 0.45],
+ },
+ {
+ name: "Vivid",
+ filter: "saturate(1.55) contrast(1.08)",
+ tint: "transparent",
+ sliders: [0.52, 0.6, 0.88],
+ },
+ {
+ name: "Dreamy",
+ filter: "brightness(1.08) contrast(0.88) saturate(1.15)",
+ tint: "rgba(255,190,230,0.18)",
+ sliders: [0.66, 0.36, 0.62],
+ },
+];
+
+const GRADES_DURATION = 9600;
+const SWATCH = { w: 62, gap: 6, x: 16, y: CANVAS.h - 16 - 26 };
+const GRADE_STOPS: { name: string; at: number }[] = [
+ { name: "Cinematic", at: 1000 },
+ { name: "Noir", at: 2400 },
+ { name: "Golden", at: 3800 },
+ { name: "Midnight", at: 5200 },
+ { name: "Vivid", at: 6600 },
+ { name: "Dreamy", at: 8000 },
+];
+const SLIDER_LABELS = ["Exposure", "Contrast", "Saturation"];
+
+const swatchCenter = (name: string) => {
+ const index = GRADES.findIndex((grade) => grade.name === name);
+ return {
+ x: SWATCH.x + index * (SWATCH.w + SWATCH.gap) + SWATCH.w / 2,
+ y: SWATCH.y + 13,
+ };
+};
+
+const GRADE_PATH: Way[] = [
+ { t: 0, x: 320, y: 190 },
+ ...GRADE_STOPS.flatMap((stop) => {
+ const point = swatchCenter(stop.name);
+ return [
+ { t: stop.at - 160, ...point },
+ { t: stop.at, ...point, click: true },
+ ];
+ }),
+ { t: GRADES_DURATION - 600, x: 420, y: 210 },
+ { t: GRADES_DURATION, x: 420, y: 210 },
+];
+
+const gradeAt = (t: number) => {
+ let current = NONE_GRADE.name;
+ for (const stop of GRADE_STOPS) if (t >= stop.at) current = stop.name;
+ return current;
+};
+
+export const GradesVisual = ({ playing }: { playing: boolean }) => {
+ const rootRef = useRef(null);
+ const [ui, setUi] = useSceneState({ grade: NONE_GRADE.name });
+ const cursor = useCursor(rootRef);
+
+ useLoop({
+ duration: GRADES_DURATION,
+ playing,
+ pose: 4600,
+ tick: (t, seek) => {
+ setUi({ grade: gradeAt(t) });
+ cursor.tick(GRADE_PATH, t, seek);
+ },
+ });
+
+ const grade = GRADES.find((item) => item.name === ui.grade) ?? NONE_GRADE;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {SLIDER_LABELS.map((label, i) => (
+
+
+ {label}
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ {GRADES.map((item) => (
+
+ {item.name}
+
+ ))}
+
+ {cursor.Cursor}
+
+
+ );
+};
+
+const CLIPS_DURATION = 10000;
+const PANEL = { x: 16, y: 206, w: 568, h: 153 };
+const GUTTER = 68;
+const LANE_X = PANEL.x + 16 + GUTTER + 8;
+const LANE_W = PANEL.w - 32 - GUTTER - 8;
+const TRACK_Y = PANEL.y + 16 + 22 + 8;
+const TRACK_H = 40;
+const CLIP_A = { start: 232, trimmed: 196 };
+const CLIP_B = { start: 190, fast: 95 };
+const TRIM = { start: 2200, end: 3400 };
+const SPEED_AT = 3900;
+const FADE_AT = 5200;
+const AUDIO_AT = 6400;
+
+const WAVES = Array.from({ length: 48 }, (_, i) => {
+ const a = Math.sin(i * 0.7) * 0.5 + 0.5;
+ const b = Math.sin(i * 1.9 + 1) * 0.5 + 0.5;
+ return { key: `w${i}`, h: 0.25 + 0.7 * (0.5 * a + 0.5 * b) };
+});
+
+const clipsUiAt = (t: number) => ({
+ speed: t >= SPEED_AT,
+ fade: t >= FADE_AT,
+ audio: t >= AUDIO_AT,
+});
+
+const clipWidthsAt = (t: number) => {
+ const trim = easeInOut(span(t, TRIM.start, TRIM.end));
+ const a = lerp(CLIP_A.start, CLIP_A.trimmed, trim);
+ const b = t >= SPEED_AT ? CLIP_B.fast : CLIP_B.start;
+ return { a, b };
+};
+
+const CLIPS_PATH: Way[] = [
+ { t: 0, x: 420, y: 120 },
+ { t: 1900, x: LANE_X + CLIP_A.start + 2, y: TRACK_Y + TRACK_H / 2 },
+ { t: TRIM.start, x: LANE_X + CLIP_A.start + 2, y: TRACK_Y + TRACK_H / 2 },
+ { t: TRIM.end, x: LANE_X + CLIP_A.trimmed + 2, y: TRACK_Y + TRACK_H / 2 },
+ {
+ t: SPEED_AT - 150,
+ x: LANE_X + CLIP_A.trimmed + 6 + CLIP_B.start / 2,
+ y: TRACK_Y + TRACK_H / 2,
+ },
+ {
+ t: SPEED_AT,
+ x: LANE_X + CLIP_A.trimmed + 6 + CLIP_B.start / 2,
+ y: TRACK_Y + TRACK_H / 2,
+ click: true,
+ },
+ { t: FADE_AT - 150, x: LANE_X + CLIP_A.trimmed + 3, y: TRACK_Y - 6 },
+ { t: FADE_AT, x: LANE_X + CLIP_A.trimmed + 3, y: TRACK_Y - 6, click: true },
+ { t: AUDIO_AT + 400, x: 470, y: 150 },
+ { t: CLIPS_DURATION, x: 470, y: 150 },
+];
+
+export const ClipsVisual = ({ playing }: { playing: boolean }) => {
+ const rootRef = useRef(null);
+ const clipARef = useRef(null);
+ const clipBRef = useRef(null);
+ const seamRef = useRef(null);
+ const playheadRef = useRef(null);
+ const [ui, setUi] = useSceneState(clipsUiAt(0));
+ const cursor = useCursor(rootRef);
+
+ useLoop({
+ duration: CLIPS_DURATION,
+ playing,
+ pose: 7200,
+ tick: (t, seek) => {
+ setUi(clipsUiAt(t));
+ const { a, b } = clipWidthsAt(t);
+ if (clipARef.current) clipARef.current.style.width = `${a}px`;
+ if (clipBRef.current) {
+ clipBRef.current.style.left = `${a + 6}px`;
+ clipBRef.current.style.width = `${b}px`;
+ }
+ if (seamRef.current) seamRef.current.style.left = `${a - 6}px`;
+ const frac = (t % 3200) / 3200;
+ const fast = t >= SPEED_AT;
+ const split = fast ? 0.66 : a / (a + b);
+ const head =
+ frac < split
+ ? lerp(0, a, frac / split)
+ : lerp(a + 6, a + 6 + b, (frac - split) / (1 - split));
+ if (playheadRef.current) {
+ playheadRef.current.style.transform = `translateX(${head}px)`;
+ }
+ cursor.tick(CLIPS_PATH, t, seek);
+ },
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+ {["0:00", "0:05", "0:10", "0:15", "0:20"].map((label, i) => (
+
+ {label}
+
+
+ ))}
+
+
+
+
+
+ Video
+
+
+
+
+ Clip 1
+
+
+ {WAVES.slice(0, 30).map((wave) => (
+
+ ))}
+
+
+
+
+ Clip 2
+
+
+ 2×
+
+
+ {WAVES.slice(18, 40).map((wave) => (
+
+ ))}
+
+
+
+
+ Crossfade
+
+
+
+
+
+
+
+
+
+
+
+ Audio
+
+
+
+ Lofi 03
+
+
+ {WAVES.map((wave) => (
+
+ ))}
+
+
+
+
+ {cursor.Cursor}
+
+
+ );
+};
diff --git a/apps/web/components/pages/HomeTwo/studio/catalog.ts b/apps/web/components/pages/HomeTwo/studio/catalog.ts
new file mode 100644
index 00000000000..ef5d8234eba
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/studio/catalog.ts
@@ -0,0 +1,84 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import type { StudioCard } from "./shared";
+
+export const CARDS_A: StudioCard[] = [
+ {
+ key: "mask",
+ title: "Blur what is private",
+ body: "Drop a mask over a password, an email, or a face and choose blur or pixelate. Or flip it to a highlight that dims everything else.",
+ span: 2,
+ Visual: dynamic(() =>
+ import("./cardsA").then((module) => module.BlurVisual),
+ ),
+ },
+ {
+ key: "scenes",
+ title: "Scenes for screen and camera",
+ body: "Switch any stretch of the timeline to camera only, hide camera, split screen, or floating cards, each with its own transition.",
+ span: 2,
+ Visual: dynamic(() =>
+ import("./cardsA").then((module) => module.ScenesVisual),
+ ),
+ },
+ {
+ key: "text",
+ title: "Text that animates",
+ body: "Titles, lower thirds, big stats, and typewriter callouts as stackable tracks, with fade, slide, pop, or typewriter in and out.",
+ Visual: dynamic(() =>
+ import("./cardsA").then((module) => module.TextVisual),
+ ),
+ },
+ {
+ key: "zoom",
+ title: "Automatic zoom",
+ body: "Generate zooms from your recorded clicks, or draw your own from 1x to 4.5x with a fixed focal point.",
+ Visual: dynamic(() =>
+ import("./cardsA").then((module) => module.ZoomVisual),
+ ),
+ },
+ {
+ key: "captions",
+ title: "Captions, generated locally",
+ body: "Transcribe on your machine in 19 languages, fix the words, style them, and burn them in with the active word highlighted.",
+ Visual: dynamic(() =>
+ import("./cardsA").then((module) => module.CaptionsVisual),
+ ),
+ },
+];
+
+export const CARDS_B: StudioCard[] = [
+ {
+ key: "three-d",
+ title: "3D camera moves",
+ body: "Tilt the frame into perspective and glide, sweep, or pull back across it with focus blur.",
+ Visual: dynamic(() =>
+ import("./cardsB").then((module) => module.ThreeDVisual),
+ ),
+ },
+ {
+ key: "canvas",
+ title: "Any canvas, one recording",
+ body: "Wallpapers, gradients, or your own image, then padding, corners, shadows, and a macOS, Windows, browser, or MacBook frame.",
+ Visual: dynamic(() =>
+ import("./cardsB").then((module) => module.CanvasVisual),
+ ),
+ },
+ {
+ key: "grades",
+ title: "Color grades",
+ body: "Cinematic, Noir, Vintage, Frost, Golden, Midnight, Vivid, or Dreamy, then dial exposure, contrast, and vignette.",
+ Visual: dynamic(() =>
+ import("./cardsB").then((module) => module.GradesVisual),
+ ),
+ },
+ {
+ key: "clips",
+ title: "Clips, speed, and music",
+ body: "Trim, split, reorder, speed up to 8x, crossfade between clips, and lay a track from the built in library underneath.",
+ Visual: dynamic(() =>
+ import("./cardsB").then((module) => module.ClipsVisual),
+ ),
+ },
+];
diff --git a/apps/web/components/pages/HomeTwo/studio/shared.tsx b/apps/web/components/pages/HomeTwo/studio/shared.tsx
new file mode 100644
index 00000000000..ed0f0fa8cac
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/studio/shared.tsx
@@ -0,0 +1,200 @@
+"use client";
+
+import { classNames } from "@cap/utils/helpers";
+import Image from "next/image";
+import {
+ type ComponentType,
+ type CSSProperties,
+ type ReactNode,
+ useEffect,
+ useRef,
+} from "react";
+import { ContentWindow } from "../demo/MacDesktop";
+import { useVideoAttrs, VIDEO_POSTERS } from "../demo/media";
+import { useVideo } from "../scenes/engine";
+import { useReducedMotion } from "../visibility";
+
+export type StudioCard = {
+ key: string;
+ title: string;
+ body: string;
+ span?: 1 | 2;
+ Visual: ComponentType<{ playing: boolean }>;
+};
+
+export const CANVAS = { w: 600, h: 375 } as const;
+
+export const useLoop = ({
+ duration,
+ playing,
+ pose,
+ tick,
+}: {
+ duration: number;
+ playing: boolean;
+ pose: number;
+ tick: (t: number, seek: boolean) => void;
+}) => {
+ const tRef = useRef(0);
+ const tickRef = useRef(tick);
+ tickRef.current = tick;
+ const reduced = useReducedMotion();
+
+ useEffect(() => {
+ if (reduced) {
+ tickRef.current(pose, true);
+ return;
+ }
+ if (!playing) return;
+ let raf = 0;
+ let last = performance.now();
+ let seek = true;
+ const frame = (now: number) => {
+ const dt = Math.min(48, now - last);
+ last = now;
+ const next = tRef.current + dt;
+ if (next >= duration) seek = true;
+ tRef.current = next % duration;
+ tickRef.current(tRef.current, seek);
+ seek = false;
+ raf = requestAnimationFrame(frame);
+ };
+ raf = requestAnimationFrame(frame);
+ return () => cancelAnimationFrame(raf);
+ }, [playing, reduced, duration, pose]);
+};
+
+export const CanvasStage = ({
+ wallpaper = "/backgrounds/monaco.webp",
+ className,
+ style,
+ children,
+}: {
+ wallpaper?: string;
+ className?: string;
+ style?: CSSProperties;
+ children: ReactNode;
+}) => (
+
+
+ {children}
+
+);
+
+export const RECORDED = {
+ left: 120,
+ top: 62,
+ width: 360,
+ height: 250,
+} as const;
+
+export const RecordedWindow = ({
+ left = RECORDED.left,
+ top = RECORDED.top,
+ width = RECORDED.width,
+ height = RECORDED.height,
+ className,
+ style,
+}: {
+ left?: number;
+ top?: number;
+ width?: number;
+ height?: number;
+ className?: string;
+ style?: CSSProperties;
+}) => {
+ const scrollRef = useRef(null);
+ return (
+
+
+
+ );
+};
+
+export const CameraBubble = ({
+ playing,
+ size = 96,
+ left = 24,
+ top = CANVAS.h - 24 - 96,
+ className,
+ style,
+}: {
+ playing: boolean;
+ size?: number;
+ left?: number;
+ top?: number;
+ className?: string;
+ style?: CSSProperties;
+}) => {
+ const ref = useRef(null);
+ const attrs = useVideoAttrs(VIDEO_POSTERS.webcam);
+ useVideo(playing, ref);
+ return (
+
+
+
+ );
+};
+
+export const Chip = ({
+ children,
+ className,
+ style,
+}: {
+ children: ReactNode;
+ className?: string;
+ style?: CSSProperties;
+}) => (
+
+ {children}
+
+);
diff --git a/apps/web/components/pages/HomeTwo/theme.ts b/apps/web/components/pages/HomeTwo/theme.ts
new file mode 100644
index 00000000000..deb5f42f240
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/theme.ts
@@ -0,0 +1,147 @@
+import type { CSSProperties } from "react";
+
+export type ModeKey = "instant" | "studio" | "screenshot" | "share";
+
+export const INK = "#111111";
+
+export const CREAM = "#F8FAFC";
+
+export const SHELL = "#FFFFFF";
+
+export const BAND = "#EDF1F6";
+
+export const BODY_COLOR = "rgba(17,17,17,0.78)";
+
+export const MUTED = "rgba(17,17,17,0.5)";
+
+export const HAIRLINE = "#E1E7EE";
+
+export const CARD_BG = CREAM;
+
+export const GRAIN = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.1'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E")`;
+
+export const grainBg = (color: string): CSSProperties => ({
+ backgroundColor: color,
+ backgroundImage: GRAIN,
+ backgroundSize: "200px 200px",
+});
+
+export type ModeTheme = {
+ /** Radial layers of the mesh (exactly four, see meshStyle's size list). */
+ image: string;
+
+ base: string;
+
+ pill: string;
+
+ chip: string;
+
+ glyph: string;
+
+ panel: string;
+
+ bars: string;
+
+ accent: string;
+};
+
+export const meshStyle = (t: ModeTheme): CSSProperties => ({
+ backgroundColor: t.base,
+ backgroundImage: `${GRAIN}, ${t.image}`,
+ backgroundSize: "200px 200px, auto, auto, auto, auto",
+});
+
+export const MODE_THEME: Record = {
+ instant: {
+ image: [
+ "radial-gradient(92% 95% at 6% 6%, #8FC1F7 0%, rgba(143,193,247,0) 66%)",
+ "radial-gradient(70% 78% at 96% 2%, #CDEBF4 0%, rgba(205,235,244,0) 60%)",
+ "radial-gradient(82% 76% at 92% 96%, #DDD7F8 0%, rgba(221,215,248,0) 62%)",
+ "radial-gradient(92% 88% at 24% 100%, #AFD6F8 0%, rgba(175,214,248,0) 70%)",
+ ].join(","),
+ base: "#E4F0FB",
+ pill: "#BFDCFC",
+ chip: "#E1EFFE",
+ glyph: "#3D77C2",
+ panel: "rgba(143,193,247,0.18)",
+ bars: "rgba(111,168,232,0.32)",
+ accent: "#8DBCF0",
+ },
+ studio: {
+ image: [
+ "radial-gradient(80% 88% at 92% 8%, #B9A5F2 0%, rgba(185,165,242,0) 60%)",
+ "radial-gradient(72% 80% at 4% 4%, #C3DCF8 0%, rgba(195,220,248,0) 60%)",
+ "radial-gradient(84% 80% at 6% 98%, #F6D9EC 0%, rgba(246,217,236,0) 62%)",
+ "radial-gradient(88% 72% at 68% 100%, #D5EDE6 0%, rgba(213,237,230,0) 66%)",
+ ].join(","),
+ base: "#EFE9FB",
+ pill: "#DACBF9",
+ chip: "#EFE7FD",
+ glyph: "#7B5FD0",
+ panel: "rgba(185,165,242,0.18)",
+ bars: "rgba(167,139,240,0.30)",
+ accent: "#BCA7F4",
+ },
+ screenshot: {
+ image: [
+ "radial-gradient(80% 88% at 8% 92%, #8FDCBB 0%, rgba(143,220,187,0) 60%)",
+ "radial-gradient(72% 78% at 88% 4%, #E0D8F7 0%, rgba(224,216,247,0) 60%)",
+ "radial-gradient(80% 80% at 96% 92%, #F8E7C8 0%, rgba(248,231,200,0) 64%)",
+ "radial-gradient(88% 80% at 28% 2%, #C8EEDD 0%, rgba(200,238,221,0) 66%)",
+ ].join(","),
+ base: "#E5F3EC",
+ pill: "#BFEDD8",
+ chip: "#E2F6EC",
+ glyph: "#3F9974",
+ panel: "rgba(143,220,187,0.20)",
+ bars: "rgba(111,203,164,0.32)",
+ accent: "#93D9BC",
+ },
+ share: {
+ image: [
+ "radial-gradient(80% 88% at 92% 88%, #F5BE85 0%, rgba(245,190,133,0) 60%)",
+ "radial-gradient(70% 74% at 4% 8%, #D6EDE1 0%, rgba(214,237,225,0) 60%)",
+ "radial-gradient(80% 80% at 2% 94%, #F9D4C6 0%, rgba(249,212,198,0) 64%)",
+ "radial-gradient(84% 74% at 58% 0%, #FBEAD0 0%, rgba(251,234,208,0) 68%)",
+ ].join(","),
+ base: "#F9F0E2",
+ pill: "#FFD9AC",
+ chip: "#FCEEDB",
+ glyph: "#B07430",
+ panel: "rgba(245,190,133,0.20)",
+ bars: "rgba(240,168,96,0.32)",
+ accent: "#F5C08A",
+ },
+};
+
+export const SANS =
+ "[font-family:var(--font-ht-sans),ui-sans-serif,system-ui,sans-serif]";
+
+export const SERIF_BODY =
+ "[font-family:var(--font-ht-serif),Georgia,serif] font-light";
+
+export const MONO = "[font-family:var(--font-ht-mono),ui-monospace,monospace]";
+
+export const EYEBROW = `${MONO} text-[12px] font-normal uppercase leading-none tracking-[0.05em]`;
+
+export const H_HERO = `${SANS} font-normal leading-[0.98] tracking-[-0.03em] text-[#111111]`;
+
+export const H_SECTION = `${SANS} font-normal leading-[1.0] tracking-[-0.03em] text-[#111111]`;
+
+export const H_CARD = `${SANS} font-normal leading-[1.04] tracking-[-0.03em] text-[#111111]`;
+
+export const BODY_TEXT = `${SERIF_BODY} tracking-[-0.01em]`;
+
+export const BTN_PRIMARY = [
+ "group relative inline-flex h-[48px] items-center justify-center overflow-hidden rounded-[12px] px-6",
+ "text-[16px] font-medium text-[#111111] [text-shadow:0_1px_0_rgba(255,255,255,0.45)]",
+ "[background:linear-gradient(180deg,#F5FAFE_0%,#E3EFFB_52%,#CFE2F6_100%)]",
+ "shadow-[inset_0_0_0_1px_rgba(63,127,205,0.65),inset_0_2px_3px_-1px_rgba(255,255,255,0.8),inset_0_-1px_1px_rgba(61,119,194,0.2),0_1px_2px_rgba(61,119,194,0.2),0_10px_24px_-10px_rgba(120,178,240,0.55)]",
+ "after:pointer-events-none after:absolute after:inset-x-[3px] after:top-[3px] after:h-[44%] after:rounded-t-[9px] after:bg-gradient-to-b after:from-white/35 after:to-transparent",
+ "transition-[filter,transform,box-shadow] duration-200 hover:brightness-[1.03] hover:shadow-[inset_0_0_0_1px_rgba(52,116,196,0.78),inset_0_2px_3px_-1px_rgba(255,255,255,0.85),inset_0_-1px_1px_rgba(61,119,194,0.2),0_2px_4px_rgba(61,119,194,0.22),0_14px_30px_-10px_rgba(120,178,240,0.65)]",
+ "active:translate-y-px active:brightness-[0.98]",
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#55A0EA] focus-visible:ring-offset-2 focus-visible:ring-offset-[#F8FAFC]",
+].join(" ");
+
+export const BTN_SECONDARY =
+ "inline-flex h-[48px] items-center justify-center rounded-[10px] border border-[#D3DCE6] bg-white px-6 text-[16px] font-normal text-[#111111] transition-colors duration-200 hover:bg-[#EDF1F6] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#111111] focus-visible:ring-offset-2 focus-visible:ring-offset-[#F8FAFC]";
diff --git a/apps/web/components/pages/HomeTwo/visibility.ts b/apps/web/components/pages/HomeTwo/visibility.ts
new file mode 100644
index 00000000000..38391d0d1c8
--- /dev/null
+++ b/apps/web/components/pages/HomeTwo/visibility.ts
@@ -0,0 +1,49 @@
+"use client";
+
+import {
+ type RefObject,
+ useEffect,
+ useState,
+ useSyncExternalStore,
+} from "react";
+
+const subscribeToVisibility = (notify: () => void) => {
+ document.addEventListener("visibilitychange", notify);
+ return () => document.removeEventListener("visibilitychange", notify);
+};
+
+const pageVisible = () => document.visibilityState !== "hidden";
+const initiallyVisible = () => true;
+
+export const usePageVisible = () =>
+ useSyncExternalStore(subscribeToVisibility, pageVisible, initiallyVisible);
+
+export const useInView = (
+ ref: RefObject,
+ margin = "-10% 0px -10% 0px",
+) => {
+ const [inView, setInView] = useState(false);
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const io = new IntersectionObserver(
+ ([entry]) => setInView(Boolean(entry?.isIntersecting)),
+ { rootMargin: margin },
+ );
+ io.observe(el);
+ return () => io.disconnect();
+ }, [ref, margin]);
+ return inView;
+};
+
+export const useReducedMotion = () => {
+ const [reduced, setReduced] = useState(false);
+ useEffect(() => {
+ const query = window.matchMedia("(prefers-reduced-motion: reduce)");
+ const sync = () => setReduced(query.matches);
+ sync();
+ query.addEventListener("change", sync);
+ return () => query.removeEventListener("change", sync);
+ }, []);
+ return reduced;
+};
diff --git a/apps/web/components/ui/MobileMenu.tsx b/apps/web/components/ui/MobileMenu.tsx
index 933baef2adb..2fb195ecf92 100644
--- a/apps/web/components/ui/MobileMenu.tsx
+++ b/apps/web/components/ui/MobileMenu.tsx
@@ -1,7 +1,8 @@
"use client";
-import { Button, Logo } from "@cap/ui";
-import { classNames } from "@cap/utils";
+import { Button } from "@cap/ui/button";
+import { Logo } from "@cap/ui/logo";
+import { classNames } from "@cap/utils/helpers";
import { Menu, X } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -203,6 +204,7 @@ const MobileMenu = ({ stars }: MobileMenuProps) => {
onClick={() => setOpen(false)}
>
diff --git a/apps/web/data/homepage-copy.ts b/apps/web/data/homepage-copy.ts
index 921b3f6cf67..8b199734e45 100644
--- a/apps/web/data/homepage-copy.ts
+++ b/apps/web/data/homepage-copy.ts
@@ -371,7 +371,7 @@ export const homepageCopy: HomePageCopy = {
{
question: "How long can I record for on the free version?",
answer:
- "You can record for 5 minutes on the free version. After that, you'll need to upgrade to a paid plan.",
+ "Studio Mode lets you record locally without a time limit. Free cloud shareable links are limited to 5 minutes each.",
},
{
question: "How does Cap AI work?",
@@ -396,7 +396,7 @@ export const homepageCopy: HomePageCopy = {
{
question: "Which platforms do you support?",
answer:
- "Native desktop apps for macOS (Apple Silicon & Intel) and Windows. View your shareable links from anywhere.",
+ "Native desktop apps for macOS (Apple Silicon & Intel), Windows, and Linux, plus a Chrome extension. View your shareable links from anywhere.",
},
{
question: "Can I use Cap for commercial purposes?",
@@ -406,7 +406,7 @@ export const homepageCopy: HomePageCopy = {
{
question: "Is my data secure?",
answer:
- "Security is core to Cap. Cap is SOC 2 Type II, ISO 27001, and HIPAA compliant, and as an open source project, our code is fully auditable and transparent — you can see exactly how your data is handled. End-to-end encryption for cloud storage, option to use your own infrastructure, and community-driven security reviews keep your content safe.",
+ "Security is core to Cap. Cap is SOC 2 Type II, ISO 27001, and HIPAA compliant, and as an open source project, our code is fully auditable and transparent — you can see exactly how your data is handled. You can connect your own storage or self-host Cap for more control over your recordings.",
},
{
question: "What about SOC 2, ISO 27001, GDPR, and HIPAA compliance?",
diff --git a/apps/web/public/backgrounds/liverpool.webp b/apps/web/public/backgrounds/liverpool.webp
new file mode 100644
index 00000000000..f36b5392e07
Binary files /dev/null and b/apps/web/public/backgrounds/liverpool.webp differ
diff --git a/apps/web/public/backgrounds/london.webp b/apps/web/public/backgrounds/london.webp
new file mode 100644
index 00000000000..2aaca4f7736
Binary files /dev/null and b/apps/web/public/backgrounds/london.webp differ
diff --git a/apps/web/public/backgrounds/miami.webp b/apps/web/public/backgrounds/miami.webp
new file mode 100644
index 00000000000..4ddceace49d
Binary files /dev/null and b/apps/web/public/backgrounds/miami.webp differ
diff --git a/apps/web/public/backgrounds/monaco.webp b/apps/web/public/backgrounds/monaco.webp
new file mode 100644
index 00000000000..a6b28cac45b
Binary files /dev/null and b/apps/web/public/backgrounds/monaco.webp differ
diff --git a/apps/web/public/backgrounds/nyc.webp b/apps/web/public/backgrounds/nyc.webp
new file mode 100644
index 00000000000..bb47e3b191e
Binary files /dev/null and b/apps/web/public/backgrounds/nyc.webp differ
diff --git a/apps/web/public/backgrounds/rome.webp b/apps/web/public/backgrounds/rome.webp
new file mode 100644
index 00000000000..e5c7f31a251
Binary files /dev/null and b/apps/web/public/backgrounds/rome.webp differ
diff --git a/apps/web/public/backgrounds/santorini.webp b/apps/web/public/backgrounds/santorini.webp
new file mode 100644
index 00000000000..f65af11e51f
Binary files /dev/null and b/apps/web/public/backgrounds/santorini.webp differ
diff --git a/apps/web/public/backgrounds/sf.webp b/apps/web/public/backgrounds/sf.webp
new file mode 100644
index 00000000000..deb5d122a4a
Binary files /dev/null and b/apps/web/public/backgrounds/sf.webp differ
diff --git a/apps/web/public/backgrounds/thumbs/liverpool.webp b/apps/web/public/backgrounds/thumbs/liverpool.webp
new file mode 100644
index 00000000000..819d1f788f8
Binary files /dev/null and b/apps/web/public/backgrounds/thumbs/liverpool.webp differ
diff --git a/apps/web/public/backgrounds/thumbs/london.webp b/apps/web/public/backgrounds/thumbs/london.webp
new file mode 100644
index 00000000000..5d9d7ffc771
Binary files /dev/null and b/apps/web/public/backgrounds/thumbs/london.webp differ
diff --git a/apps/web/public/backgrounds/thumbs/miami.webp b/apps/web/public/backgrounds/thumbs/miami.webp
new file mode 100644
index 00000000000..0537348fdca
Binary files /dev/null and b/apps/web/public/backgrounds/thumbs/miami.webp differ
diff --git a/apps/web/public/backgrounds/thumbs/monaco.webp b/apps/web/public/backgrounds/thumbs/monaco.webp
new file mode 100644
index 00000000000..fe02d6e0493
Binary files /dev/null and b/apps/web/public/backgrounds/thumbs/monaco.webp differ
diff --git a/apps/web/public/backgrounds/thumbs/nyc.webp b/apps/web/public/backgrounds/thumbs/nyc.webp
new file mode 100644
index 00000000000..d0fc57e4d15
Binary files /dev/null and b/apps/web/public/backgrounds/thumbs/nyc.webp differ
diff --git a/apps/web/public/backgrounds/thumbs/rome.webp b/apps/web/public/backgrounds/thumbs/rome.webp
new file mode 100644
index 00000000000..1c58ebdc8bb
Binary files /dev/null and b/apps/web/public/backgrounds/thumbs/rome.webp differ
diff --git a/apps/web/public/backgrounds/thumbs/santorini.webp b/apps/web/public/backgrounds/thumbs/santorini.webp
new file mode 100644
index 00000000000..8f18f5f8dd3
Binary files /dev/null and b/apps/web/public/backgrounds/thumbs/santorini.webp differ
diff --git a/apps/web/public/backgrounds/thumbs/sf.webp b/apps/web/public/backgrounds/thumbs/sf.webp
new file mode 100644
index 00000000000..1ecf5b0b248
Binary files /dev/null and b/apps/web/public/backgrounds/thumbs/sf.webp differ
diff --git a/apps/web/public/illustrations/homepage-animation-poster.jpg b/apps/web/public/illustrations/homepage-animation-poster.jpg
new file mode 100644
index 00000000000..1dfa45f8ff8
Binary files /dev/null and b/apps/web/public/illustrations/homepage-animation-poster.jpg differ
diff --git a/apps/web/public/videos/home-two/webcam-poster.jpg b/apps/web/public/videos/home-two/webcam-poster.jpg
new file mode 100644
index 00000000000..4b6aa3b7961
Binary files /dev/null and b/apps/web/public/videos/home-two/webcam-poster.jpg differ
diff --git a/apps/web/public/videos/home-two/webcam.mp4 b/apps/web/public/videos/home-two/webcam.mp4
new file mode 100644
index 00000000000..d56d9d38432
Binary files /dev/null and b/apps/web/public/videos/home-two/webcam.mp4 differ
diff --git a/packages/ui/package.json b/packages/ui/package.json
index b76a0f4b1e8..041bbc22d33 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -5,6 +5,9 @@
"types": "./src/index.tsx",
"exports": {
".": "./src/index.tsx",
+ "./logo": "./src/components/icons/Logo.tsx",
+ "./button": "./src/components/Button.tsx",
+ "./navigation-menu": "./src/components/NavigationMenu.tsx",
"./tailwind": "./tailwind.config.js",
"./postcss": "./postcss.config.js",
"./style": "./style/styles.css"
diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx
index 099e9bd32d9..25bd3a72b29 100644
--- a/packages/ui/src/components/Button.tsx
+++ b/packages/ui/src/components/Button.tsx
@@ -1,4 +1,4 @@
-import { classNames } from "@cap/utils";
+import { classNames } from "@cap/utils/helpers";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
diff --git a/packages/ui/src/components/NavigationMenu.tsx b/packages/ui/src/components/NavigationMenu.tsx
index eea3baf65dd..590839cfe38 100644
--- a/packages/ui/src/components/NavigationMenu.tsx
+++ b/packages/ui/src/components/NavigationMenu.tsx
@@ -1,4 +1,4 @@
-import { classNames } from "@cap/utils";
+import { classNames } from "@cap/utils/helpers";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDown } from "lucide-react";
diff --git a/packages/ui/src/components/icons/Logo.tsx b/packages/ui/src/components/icons/Logo.tsx
index 2235462ba79..82a7dcf62d4 100644
--- a/packages/ui/src/components/icons/Logo.tsx
+++ b/packages/ui/src/components/icons/Logo.tsx
@@ -4,6 +4,7 @@ export const Logo = ({
showBeta,
white,
hideLogoName,
+ squaredMark,
viewBoxDimensions = "0 0 120 40",
style,
}: {
@@ -12,6 +13,7 @@ export const Logo = ({
showBeta?: boolean;
white?: boolean;
hideLogoName?: boolean;
+ squaredMark?: boolean;
style?: React.CSSProperties;
viewBoxDimensions?: `${string} ${string} ${string} ${string}`;
}) => {
@@ -23,26 +25,31 @@ export const Logo = ({
preserveAspectRatio="xMidYMid meet"
fill="none"
style={style}
+ role="img"
aria-label="Cap Logo"
className={className}
>
- {/* */}
- {/* */}
+ {squaredMark && (
+ <>
+
+
+ >
+ )}