Files
backnotprop__plannotator/packages/ui/components/HtmlSurfaceControls.tsx
Michael Ramos c2950e709f fix: pre-release QA findings for 0.27.9 (#1405)
Fixes from the 0.27.9 pre-release review. Servers: an unreadable rendered-HTML root falls back to the startup snapshot on both runtimes with a once-per-process warning instead of hanging (Pi) or answering 500 (Bun); the version diff is recomputed against current bytes on reload and carried through the in-app Refresh instead of being dropped, with no history write on a GET. Client: a Refresh action on the compact touch shell; HtmlSurfaceControls renders Refresh independently of the eye; the dead HtmlSurfaceActions removed. Threading: one linear, cycle-safe reply resolution shared by the annotations panel, its sort, and the export (5,000-chain tests), PATCH ingest on both runtimes rejects self-references and cycles, nothing is ever dropped from feedback. WebMCP and viewer hygiene: bounded tombstone and request memories, per-instance minted ids, nudge id caps, waiter cleanup on unmount, a shared retry epoch for diagram blocks. Docs: HTML Refresh documented, the WebMCP design pointer fixed, marketing pages updated.

AI-assisted (Claude) under maintainer direction.
2026-08-27 15:23:28 -07:00

180 lines
8.5 KiB
TypeScript

/**
* Header controls for a raw-HTML or live-app annotation surface: the eye
* (show/hide the floating tools over the page), the optional refresh, and
* the pen (Annotate/Interact toggle). Presentation only; every state lives
* in the host. Each control renders only when its handler is passed, so a
* read-only document can show the eye without a pen.
*
* The markup, data attributes (`data-html-tools-toggle`, `data-html-refresh`,
* `data-html-annotate-toggle`), aria state and the pixel-stable pen border
* are the exact ones Plannotator's header shipped with; hosts get the same
* control, and `labels` overrides the strings without touching the DOM.
*/
/** String overrides. Every key optional; defaults are Plannotator's strings. */
export interface HtmlSurfaceControlLabels {
/** Pen title while Annotate is armed. */
annotateTitle?: string;
/** Pen title while in Interact mode. */
interactTitle?: string;
/** Pen aria-label while armed. Default: none (the title carries the name). */
annotateLabel?: string;
/** Pen aria-label while in Interact mode. Default: none. */
interactLabel?: string;
/** Eye title and screen-reader text while the tools are visible. */
hideTools?: string;
/** Eye title and screen-reader text while the tools are hidden. */
showTools?: string;
/** Refresh visible text while idle. */
refresh?: string;
/** Refresh visible text while a refresh is in flight. */
refreshing?: string;
/** Refresh title and aria-label while idle. */
refreshTitle?: string;
/** Refresh title and aria-label while a refresh is in flight. */
refreshingTitle?: string;
}
export const DEFAULT_HTML_SURFACE_CONTROL_LABELS: Required<
Omit<HtmlSurfaceControlLabels, 'annotateLabel' | 'interactLabel'>
> = {
annotateTitle: 'Annotate mode: click an element or select text to comment. Esc to interact',
interactTitle: 'Interact mode: clicks reach the page (text selection still comments). Click to annotate',
hideTools: 'Hide tools',
showTools: 'Show tools',
refresh: 'Refresh',
refreshing: 'Refreshing',
refreshTitle: 'Refresh document',
refreshingTitle: 'Refreshing document',
};
export interface HtmlSurfaceControlsProps {
/** Whether Annotate is armed (pen pressed). */
armed: boolean;
/** Flip Annotate/Interact. The pen renders only when provided. */
onToggleArmed?: () => void;
/** Whether the floating tools over the page are hidden (eye-off). */
toolsHidden?: boolean;
/** Flip the tools. The eye renders only when provided. */
onToggleTools?: () => void;
/** Whether a refresh is offered for this document. The refresh renders
* whenever this is true and `onRefresh` is passed, with or without the eye. */
canRefresh?: boolean;
onRefresh?: () => void;
isRefreshing?: boolean;
/** Compact touch shells put these actions in a menu instead: render nothing. */
compact?: boolean;
labels?: HtmlSurfaceControlLabels;
}
export function HtmlSurfaceControls({
armed,
onToggleArmed,
toolsHidden = false,
onToggleTools,
canRefresh = false,
onRefresh,
isRefreshing = false,
compact = false,
labels,
}: HtmlSurfaceControlsProps) {
if (compact) return null;
const text = { ...DEFAULT_HTML_SURFACE_CONTROL_LABELS, ...labels };
const penLabel = armed ? labels?.annotateLabel : labels?.interactLabel;
const showRefresh = canRefresh && !!onRefresh;
return (
<>
{/* The refresh and the eye share one group, left of the pen. Each
renders on its own terms: the refresh whenever it is offered
(canRefresh + onRefresh), the eye whenever onToggleTools is passed,
so a host without the tools toggle still gets its refresh.
Show/hide tools: removes ALL floating chrome (sidebar tongue tabs +
the comment/attachments cluster) from the DOM, leaving nothing over
the page. This button is the only way back, so it never hides
itself. Eye = tools visible, eye-off = hidden. */}
{(showRefresh || onToggleTools) && (
<div className="ml-1 flex items-center gap-0.5">
{showRefresh && (
<button
type="button"
data-html-refresh
// aria-disabled rather than disabled: a disabled control drops
// keyboard focus to body when activated. useHtmlRefresh already
// dedups in-flight requests, so an extra click is harmless.
onClick={isRefreshing ? undefined : onRefresh}
aria-disabled={isRefreshing}
className="flex items-center gap-1.5 rounded px-1.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground aria-disabled:cursor-wait aria-disabled:opacity-70"
title={isRefreshing ? text.refreshingTitle : text.refreshTitle}
aria-label={isRefreshing ? text.refreshingTitle : text.refreshTitle}
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`h-3.5 w-3.5 ${isRefreshing ? 'animate-spin motion-reduce:animate-none' : ''}`}
>
<path d="M20 11a8.1 8.1 0 0 0-15.5-2M4 4v5h5" />
<path d="M4 13a8.1 8.1 0 0 0 15.5 2M20 20v-5h-5" />
</svg>
<span className="hidden sm:inline">{isRefreshing ? text.refreshing : text.refresh}</span>
</button>
)}
{onToggleTools && (
<button
type="button"
data-html-tools-toggle
onClick={onToggleTools}
aria-pressed={toolsHidden}
className="cursor-pointer rounded-md border border-transparent p-1.5 text-xs font-medium text-muted-foreground transition-all hover:bg-muted hover:text-foreground"
title={toolsHidden ? text.showTools : text.hideTools}
>
{toolsHidden ? (
<svg aria-hidden="true" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
) : (
<svg aria-hidden="true" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
)}
<span className="sr-only">{toolsHidden ? text.showTools : text.hideTools}</span>
</button>
)}
</div>
)}
{/* Interact/Annotate toggle. A PEN icon (deliberately not a speech
bubble: an annotations-panel bubble beside it must stay
distinguishable at a glance, and so must AI sparkles). Always the
same icon: armed shows the accent color plus a visible border;
unarmed is muted with a TRANSPARENT border of the same width, so
the button's box is pixel-identical in both states. */}
{onToggleArmed && (
<button
type="button"
data-html-annotate-toggle
onClick={onToggleArmed}
aria-pressed={armed}
className={`p-1.5 rounded-md border text-xs font-medium transition-all cursor-pointer ${
armed
? 'border-primary/60 bg-primary/15 text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
title={armed ? text.annotateTitle : text.interactTitle}
{...(penLabel !== undefined ? { 'aria-label': penLabel } : {})}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.862 4.487zm0 0L19.5 7.125" />
</svg>
</button>
)}
</>
);
}