diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index f927b25a..2a1191b3 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -10,6 +10,7 @@ import { Annotation, Block, EditorMode, type InputMethod, type ImageAttachment } import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider'; import { ModeToggle } from '@plannotator/ui/components/ModeToggle'; import { AnnotationToolstrip } from '@plannotator/ui/components/AnnotationToolstrip'; +import { StickyHeaderLane } from '@plannotator/ui/components/StickyHeaderLane'; import { TaterSpriteRunning } from '@plannotator/ui/components/TaterSpriteRunning'; import { TaterSpritePullup } from '@plannotator/ui/components/TaterSpritePullup'; import { Settings } from '@plannotator/ui/components/Settings'; @@ -1583,6 +1584,28 @@ const App: React.FC = () => { showCancel />
+ {/* Sticky header lane — ghost bar that pins the toolstrip + + badges at top: 12px once the user scrolls. Invisible at top + of doc; original toolstrip/badges remain the source of + truth there. Hidden in plan diff, archive, linked-doc mode, + or when sticky actions are disabled. */} + {!isPlanDiffActive && !archive.archiveMode && !linkedDocHook.isActive && uiPrefs.stickyActionsEnabled && ( + setIsPlanDiffActive(!isPlanDiffActive)} + archiveInfo={archive.currentInfo} + maxWidth={planMaxWidth} + /> + )} + {/* Annotation Toolstrip (hidden during plan diff and archive mode) */} {!isPlanDiffActive && !archive.archiveMode && (
diff --git a/packages/ui/components/AnnotationToolstrip.tsx b/packages/ui/components/AnnotationToolstrip.tsx index e94018be..bebd4e00 100644 --- a/packages/ui/components/AnnotationToolstrip.tsx +++ b/packages/ui/components/AnnotationToolstrip.tsx @@ -8,6 +8,18 @@ interface AnnotationToolstripProps { mode: EditorMode; onModeChange: (mode: EditorMode) => void; taterMode?: boolean; + /** + * Compact mode: used inside the sticky header lane. Buttons only expand for + * the active mode (no hover expansion), gap is tightened, and the help link + * is hidden. + */ + compact?: boolean; + /** + * Icon-only mode: no button ever expands to show a label, even the active + * one. Used in the sticky header lane on mobile so the toolstrip stays + * narrow and leaves room for the diff badges. + */ + iconOnly?: boolean; } export const AnnotationToolstrip: React.FC = ({ @@ -16,6 +28,8 @@ export const AnnotationToolstrip: React.FC = ({ mode, onModeChange, taterMode, + compact = false, + iconOnly = false, }) => { const [showHelp, setShowHelp] = useState(false); const [helpTab, setHelpTab] = useState<'selection' | 'plannotator'>('selection'); @@ -28,7 +42,7 @@ export const AnnotationToolstrip: React.FC = ({ return ( <> -
+
{/* Input method group */}
= ({ label="Select" color="primary" mounted={mounted} + compact={compact} + iconOnly={iconOnly} icon={ @@ -53,6 +69,8 @@ export const AnnotationToolstrip: React.FC = ({ label="Pinpoint" color="primary" mounted={mounted} + compact={compact} + iconOnly={iconOnly} icon={ @@ -74,6 +92,8 @@ export const AnnotationToolstrip: React.FC = ({ label="Markup" color="secondary" mounted={mounted} + compact={compact} + iconOnly={iconOnly} icon={ @@ -86,6 +106,8 @@ export const AnnotationToolstrip: React.FC = ({ label="Comment" color="accent" mounted={mounted} + compact={compact} + iconOnly={iconOnly} icon={ @@ -98,6 +120,8 @@ export const AnnotationToolstrip: React.FC = ({ label="Redline" color="destructive" mounted={mounted} + compact={compact} + iconOnly={iconOnly} icon={ @@ -110,6 +134,8 @@ export const AnnotationToolstrip: React.FC = ({ label="Label" color="warning" mounted={mounted} + compact={compact} + iconOnly={iconOnly} icon={ @@ -119,12 +145,14 @@ export const AnnotationToolstrip: React.FC = ({
{/* Help */} - + {!compact && ( + + )}
{/* Help Video Dialog */} @@ -241,7 +269,9 @@ const ToolstripButton: React.FC<{ label: string; color: ButtonColor; mounted: boolean; -}> = ({ active, onClick, icon, label, color, mounted }) => { + compact?: boolean; + iconOnly?: boolean; +}> = ({ active, onClick, icon, label, color, mounted, compact = false, iconOnly = false }) => { const [hovered, setHovered] = useState(false); const [labelWidth, setLabelWidth] = useState(0); const measureRef = useRef(null); @@ -255,7 +285,14 @@ const ToolstripButton: React.FC<{ } }, [label]); - const expanded = active || hovered || isTouchDevice; + // iconOnly: never expand (mobile sticky lane). + // compact: only active expands (sm+ sticky lane — shows current mode). + // default: active or hovered expands (top-of-doc full toolstrip). + const expanded = iconOnly + ? false + : compact + ? active + : (active || hovered || isTouchDevice); const expandedWidth = H_PAD + ICON_INNER + GAP + labelWidth + H_PAD; const currentWidth = expanded ? expandedWidth : ICON_SIZE; diff --git a/packages/ui/components/DocBadges.tsx b/packages/ui/components/DocBadges.tsx new file mode 100644 index 00000000..c7243a9a --- /dev/null +++ b/packages/ui/components/DocBadges.tsx @@ -0,0 +1,162 @@ +/** + * DocBadges — repo / branch / plan-diff / demo / archive / linked-doc badge cluster. + * + * Extracted from Viewer.tsx so the same markup can render in two places: + * - layout="column": original location at the top-left of the plan card (absolute) + * - layout="row": inside the sticky header lane when the user scrolls + * + * In row layout, the demo badge and linked-doc breadcrumb are dropped — the + * sticky lane hides entirely in linked-doc mode, and the demo badge is purely + * decorative top-of-doc context. + */ + +import React from 'react'; +import { PlanDiffBadge } from './plan-diff/PlanDiffBadge'; +import type { PlanDiffStats } from '../utils/planDiffEngine'; + +export interface DocBadgesProps { + layout: 'column' | 'row'; + repoInfo?: { display: string; branch?: string } | null; + planDiffStats?: PlanDiffStats | null; + isPlanDiffActive?: boolean; + hasPreviousVersion?: boolean; + onPlanDiffToggle?: () => void; + showDemoBadge?: boolean; + archiveInfo?: { status: 'approved' | 'denied' | 'unknown'; timestamp: string; title: string } | null; + linkedDocInfo?: { filepath: string; onBack: () => void; label?: string; backLabel?: string } | null; +} + +export const DocBadges: React.FC = ({ + layout, + repoInfo, + planDiffStats, + isPlanDiffActive, + hasPreviousVersion, + onPlanDiffToggle, + showDemoBadge, + archiveInfo, + linkedDocInfo, +}) => { + const anything = + repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo; + if (!anything) return null; + + const isRow = layout === 'row'; + + // Row layout: single horizontal line. Column layout: stacked rows. + const outerClass = isRow + ? 'flex flex-row items-center gap-1.5 text-[9px] text-muted-foreground/70 font-mono' + : 'flex flex-col items-start gap-1 text-[9px] text-muted-foreground/50 font-mono'; + + return ( +
+ {repoInfo && !linkedDocInfo && ( +
+ + {repoInfo.display} + + {repoInfo.branch && ( + + + + + {repoInfo.branch} + + )} +
+ )} + + {onPlanDiffToggle && !linkedDocInfo && ( + + )} + + {/* Demo badge: only in column (top-of-doc) layout */} + {!isRow && showDemoBadge && !linkedDocInfo && ( + + Demo + + )} + + {archiveInfo && !linkedDocInfo && ( +
+ + {archiveInfo.status === 'approved' + ? 'Approved' + : archiveInfo.status === 'denied' + ? 'Denied' + : 'Unknown'} + + {archiveInfo.timestamp && ( + + {new Date(archiveInfo.timestamp).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + })}{' '} + {new Date(archiveInfo.timestamp).toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + })} + + )} +
+ )} + + {/* Linked-doc breadcrumb: only in column layout (sticky lane is hidden in linked-doc mode) */} + {!isRow && linkedDocInfo && ( +
+ + + {linkedDocInfo.label || 'Linked File'} + + + {linkedDocInfo.filepath.split('/').pop()} + +
+ )} +
+ ); +}; diff --git a/packages/ui/components/StickyHeaderLane.tsx b/packages/ui/components/StickyHeaderLane.tsx new file mode 100644 index 00000000..90e78508 --- /dev/null +++ b/packages/ui/components/StickyHeaderLane.tsx @@ -0,0 +1,181 @@ +/** + * StickyHeaderLane — compact "ghost" header that pins as the user scrolls + * past the AnnotationToolstrip. + * + * At rest (top of doc): invisible, non-interactive. The original toolstrip + * and badge cluster on the card remain the visible source of truth. + * + * Scrolled, sm+ (640px): fades + slides in at top: 12px, sitting on the + * same horizontal lane as the already-sticky action buttons inside the + * Viewer card. Single horizontal header. + * + * Scrolled, mobile (<640px): pins at top: 52px on its OWN full-width row, + * directly below the action buttons row. Toolstrip switches to icon-only + * (no labels) so the diff badges have room. + * + * Composes + . + * No state is duplicated — all props are passed through from App.tsx. + */ + +import React, { useEffect, useRef, useState } from 'react'; +import { AnnotationToolstrip } from './AnnotationToolstrip'; +import { DocBadges } from './DocBadges'; +import type { EditorMode, InputMethod } from '../types'; +import type { PlanDiffStats } from '../utils/planDiffEngine'; + +interface StickyHeaderLaneProps { + // Toolstrip state + inputMethod: InputMethod; + onInputMethodChange: (method: InputMethod) => void; + mode: EditorMode; + onModeChange: (mode: EditorMode) => void; + taterMode?: boolean; + + // Badge state + repoInfo?: { display: string; branch?: string } | null; + planDiffStats?: PlanDiffStats | null; + isPlanDiffActive?: boolean; + hasPreviousVersion?: boolean; + onPlanDiffToggle?: () => void; + archiveInfo?: { status: 'approved' | 'denied' | 'unknown'; timestamp: string; title: string } | null; + + // Layout + maxWidth?: number; +} + +export const StickyHeaderLane: React.FC = ({ + inputMethod, + onInputMethodChange, + mode, + onModeChange, + taterMode, + repoInfo, + planDiffStats, + isPlanDiffActive, + hasPreviousVersion, + onPlanDiffToggle, + archiveInfo, + maxWidth, +}) => { + const sentinelRef = useRef(null); + const [isStuck, setIsStuck] = useState(false); + const [isMobile, setIsMobile] = useState(false); + + // Match the `sm` Tailwind breakpoint (640px). Below it the bar lives on + // its own row and switches the toolstrip to icon-only so the diff + // badges have room. + useEffect(() => { + const mq = window.matchMedia('(max-width: 639px)'); + const handler = () => setIsMobile(mq.matches); + handler(); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); + + // IntersectionObserver-on-sentinel pattern (mirrors Viewer.tsx:257-267). + // Sentinel sits inline at the natural position the bar would occupy; when + // it leaves the viewport, the bar pins. The positive top rootMargin grows + // the effective viewport upward so the sentinel is considered "visible" + // for an extra ~72px of scroll — delaying the bar's appearance until the + // real toolstrip has actually scrolled past the top of
. Without + // this, the sentinel (which sits at the top of the column, above the + // toolstrip) fires the moment scrolling begins and the bar doubles up + // with the still-visible toolstrip. + useEffect(() => { + if (!sentinelRef.current) return; + const scrollContainer = document.querySelector('main'); + const observer = new IntersectionObserver( + ([entry]) => setIsStuck(!entry.isIntersecting), + { root: scrollContainer, rootMargin: '72px 0px 0px 0px', threshold: 0 } + ); + observer.observe(sentinelRef.current); + return () => observer.disconnect(); + }, []); + + return ( + <> + {/* Sentinel — zero-size, rendered in normal flow at the top of
. + When it scrolls out of the viewport, the sticky bar fades in. */} +