import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Pause, Play } from 'lucide-react';
/**
* One-time announcement for Plannotator's two terminal clients, Plannotator TUI
* and Herdr Annotate. Video first: the real demo footage fills the top of the
* panel edge to edge, and the text below it is one headline and one sentence.
* Same shell as the other first-run announcements (portal, z-[100], hand-rolled
* Escape + Tab wrap + focus restore), with one difference: it asks the user to
* decide nothing, so Escape, the backdrop and the single "Got it" button all do
* the same thing.
*
* LAST in each app's first-run dialog chain. The Apps gate rendering through
* terminalToolsAnnouncementCanShow so the chain dialogs never stack; see that
* function for why last rather than first.
*
* The footage is hosted on plannotator.ai like GuideIntroDialog's hero image
* (the two demos together are ~23MB, far too much to inline the way the Edit
* Mode recording is), so the dialog has to look right without it: the poster
* stays up while the video buffers, and if the media cannot load at all the
* frame keeps its place and offers the X post instead.
*/
const TUI_REPO = 'https://github.com/plannotator/plannotator-tui';
const HERDR_REPO = 'https://github.com/plannotator/herdr-annotate';
/** Where the marketing deploy publishes `apps/marketing/public/assets/`. */
const MEDIA_BASE_URL = 'https://plannotator.ai/assets';
export interface TerminalToolsDemo {
readonly id: 'full' | 'lite';
/** Segment label. Herdr Annotate's own naming for its two install targets. */
readonly label: string;
readonly mp4: string;
readonly webm: string;
readonly poster: string;
/** The X post the footage was cut from. */
readonly watchUrl: string;
readonly description: string;
}
/** 1280x806 and 1280x808: one aspect ratio, so switching never reflows the panel. */
const DEMO_ASPECT = 1280 / 806;
/**
* Herdr Annotate's own colors, from its repo badges (assets/install-full.svg
* and siblings): badge purple, lavender text, periwinkle accent. They belong to
* another product, so they live as local variables on this dialog and never
* enter theme.css. The badge purple is the one that still reads as a purple
* block over the dark footage; the darker hero ground (#10101f-#2c2536) would
* vanish into the frame.
*/
const HERDR_BRAND_VARS = {
'--announce-brand': '#312b52',
'--announce-brand-text': '#c9c6f1',
'--announce-brand-accent': '#B9C0FF',
} as React.CSSProperties;
export const TERMINAL_TOOLS_DEMOS: readonly TerminalToolsDemo[] = [
{
id: 'full',
label: 'Full',
mp4: `${MEDIA_BASE_URL}/tui-herdr-full-demo.mp4`,
webm: `${MEDIA_BASE_URL}/tui-herdr-full-demo.webm`,
poster: `${MEDIA_BASE_URL}/tui-herdr-full-poster.jpg`,
watchUrl: 'https://x.com/plannotator/status/2093419561077154287',
description:
'Herdr Annotate reviewing a Markdown file with Plannotator TUI: a file tree, a selected block with its comment, and the feedback sent to the agent.',
},
{
id: 'lite',
label: 'Lite',
mp4: `${MEDIA_BASE_URL}/tui-herdr-lite-demo.mp4`,
webm: `${MEDIA_BASE_URL}/tui-herdr-lite-demo.webm`,
poster: `${MEDIA_BASE_URL}/tui-herdr-lite-poster.jpg`,
watchUrl: 'https://x.com/plannotator/status/2092757422322627008',
description:
'Herdr Annotate Lite: terminal text selected in an agent session, a comment written in a popover, and the notes sent back to the agent.',
},
];
interface TerminalToolsAnnouncementDialogProps {
readonly isOpen: boolean;
/** Marks the announcement seen and closes it. Also wired to Escape and the backdrop. */
readonly onDismiss: () => void;
/**
* Test seam. Defaults to the media query; forcing it lets a test assert the
* reduced-motion branch without a real `matchMedia`.
*/
readonly reducedMotion?: boolean;
}
export function prefersReducedMotion(): boolean {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function GitHubMark({ className }: { readonly className?: string }) {
return (
);
}
export function XMark({ className }: { readonly className?: string }) {
return (
);
}
function OutboundAction({
href,
children,
icon,
}: {
readonly href: string;
readonly children: React.ReactNode;
readonly icon: React.ReactNode;
}) {
return (
{icon}
{children}
);
}
interface DemoPlayerProps {
readonly demo: TerminalToolsDemo;
readonly reducedMotion: boolean;
}
/**
* The footage. Muted, looping, inline; autoplays unless the reader asked for
* reduced motion, in which case the poster waits behind a play button. One
* toggle serves both: large and centered while paused, tucked into a corner
* while playing, so a paused frame is never mistaken for a broken one.
*/
function DemoPlayer({ demo, reducedMotion }: DemoPlayerProps) {
const videoRef = useRef(null);
// Optimistic: autoplay is expected, so the corner control is the initial
// shape and the centered Play only appears once the browser has proven it
// will not start (canplaythrough with the element still paused).
const [playing, setPlaying] = useState(!reducedMotion);
const [failed, setFailed] = useState(false);
const toggle = useCallback(() => {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
// Autoplay policies can reject play(); the button simply stays put.
void video.play().catch(() => {});
} else {
video.pause();
}
}, []);
return (
{/* A corner block on the frame, flush to the panel's top-left edge (the
panel's own radius clips its outer corner) the way a "new" corner tag
sits on a product card. The posters keep only a sidebar label and a
tab marker under this corner, so nothing that matters is covered.
Roughly 200x48 over the desktop video, 118x32 at phone width. */}
New ยท Watch:
{failed ? (