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. */} {failed ? (
Watch on X
) : ( )}
); } interface DemoSwitchProps { readonly active: TerminalToolsDemo['id']; readonly onChange: (id: TerminalToolsDemo['id']) => void; } function DemoSwitch({ active, onChange }: DemoSwitchProps) { const tabRefs = useRef>({}); const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; event.preventDefault(); const index = TERMINAL_TOOLS_DEMOS.findIndex((demo) => demo.id === active); const step = event.key === 'ArrowRight' ? 1 : -1; const next = TERMINAL_TOOLS_DEMOS[ (index + step + TERMINAL_TOOLS_DEMOS.length) % TERMINAL_TOOLS_DEMOS.length ]; onChange(next.id); tabRefs.current[next.id]?.focus(); }; return (
{TERMINAL_TOOLS_DEMOS.map((demo) => { const selected = demo.id === active; return ( ); })}
); } export function TerminalToolsAnnouncementDialog({ isOpen, onDismiss, reducedMotion, }: TerminalToolsAnnouncementDialogProps) { const dismissRef = useRef(null); const previousFocusRef = useRef(null); const onDismissRef = useRef(onDismiss); const [activeDemoId, setActiveDemoId] = useState('full'); const [motionPreference] = useState(() => reducedMotion ?? prefersReducedMotion()); const reduced = reducedMotion ?? motionPreference; const activeDemo = TERMINAL_TOOLS_DEMOS.find((demo) => demo.id === activeDemoId) ?? TERMINAL_TOOLS_DEMOS[0]; useEffect(() => { onDismissRef.current = onDismiss; }, [onDismiss]); useEffect(() => { if (!isOpen) return; previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; dismissRef.current?.focus(); const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onDismissRef.current(); return; } // Both apps submit their decision on Mod+Enter from a window-level // handler. This listener is registered on the capture phase, so // swallowing the chord here is what stops a keystroke aimed at the // announcement from approving a plan or posting a review behind it. if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); event.stopPropagation(); return; } if (event.key !== 'Tab') return; const dialog = document.querySelector('[data-terminal-tools-announcement-dialog]'); const focusable = Array.from( dialog?.querySelectorAll('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])') ?? [], ).filter((element) => element.getAttribute('tabindex') !== '-1'); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }; document.addEventListener('keydown', handleKeyDown, true); return () => { document.removeEventListener('keydown', handleKeyDown, true); previousFocusRef.current?.focus(); previousFocusRef.current = null; }; }, [isOpen]); if (!isOpen) return null; return createPortal(
{ if (event.target === event.currentTarget) onDismissRef.current(); }} >
{/* Keyed so a switch resets playback and load-failure state with the footage. */}

Plannotator TUI and Herdr Annotate

Annotate Markdown and terminal text, then send the notes to your agent.

}> Star plannotator-tui }> Star herdr-annotate }> Watch on X
, document.body, ); }