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

+
+ +
+ {QUOTES.map((quote) => ( + +

+ {quote.content} +

+ + + + + {quote.name} + + + {quote.handle} + + + +
+ ))} +
+ +
+ + 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 + + + {isWindows ? ( + + ) : null} +
    +
    + +
    +
    +
    +
    + + + Auto + + + + + Crop + + + + Frame + + +
    +
    + + Preview quality + + + Full + + +
    +
    + +
    +
    +
    +
    +
    +
    +
    + {canvasChildren} +
    +
    +
    + +
    +
    + + 0:00.00 + / 0:32.00 + +
    +
    + + + +
    +
    + + + +
    +
    +
    +
    + +
    + +
    +
    + +
    + + +
    +
    + +
    +
    + } selected /> + } /> + } /> + } /> + } /> + } /> +
    + +
    + } + label="Background Image" + > +
    +
    + {["Desktop", "Wallpaper", "Image"].map((label) => ( + + ))} +
    +
    + {["Color", "Gradient", "None"].map((label) => ( + + ))} +
    +
    +
    +
    +
    + {WALLPAPER_THEMES.map((label) => ( + + {label} + + ))} +
    +
    + {WALLPAPERS.map((item, i) => ( + + ))} +
    +
    + +
    + + } + 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; +}) => ( + +); + +const TargetTile = ({ + icon, + name, + selected, + withDropdown, + anchor, + onClick, +}: { + icon: React.ReactNode; + name: string; + selected?: boolean; + withDropdown?: boolean; + anchor?: string; + onClick: () => void; +}) => ( + +); + +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; +}) => ( + +); + +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 : } +
    + + +
    +
    + + + {url} + +
    + {isWindows ? ( + + ) : ( + + )} +
    + +
    +
    + +
    +

    + {title} +

    +

    + Richie · just now · {duration} +

    +
    + + Share + +
    + +
    +
    + +
    +
    + {["👍", "🔥", "❤️"].map((emoji) => ( + + ))} + + + 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; +}) => ( + +); + +export const RecordingToolbar = ({ + visible, + paused, + timerRef, + onStop, + onTogglePause, + onRestart, + onMiss, +}: { + visible: boolean; + paused: boolean; + timerRef: RefObject; + onStop: () => void; + onTogglePause: () => void; + onRestart: () => void; + onMiss: () => void; +}) => ( +
    +
    +
    + + +
    +
    + + + + +
    + + {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 ( +
    +
    +
    +
    + + + + + + + +
    +
    + +
    +
    + } + 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 ? ( + + ) : ( + + )} +
    + ); +}; 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 ( + +
    + + +
    +