From 998423e15c8f908656599e5f2454f321b33ed510 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Tue, 7 Apr 2026 10:24:37 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(plan):=20complete=20sticky=20actions?= =?UTF-8?q?=20=E2=80=94=20pin=20toolstrip=20+=20badges=20on=20scroll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sticky actions setting previously only pinned the action button cluster on the right side of the plan card. The annotation toolstrip (selection mode + editor mode toggles) and the left-side badges (repo / branch / plan-diff) scrolled away, forcing users to scroll back to the top of long plans to switch modes or jump into diff view. Adds a ghost sticky header lane that pins the toolstrip and badges on the same horizontal plane as the already-sticky action buttons once the user scrolls past the top of the document. Top-of-document rendering is unchanged. - Extract the badge cluster into a reusable `DocBadges` component with `column` / `row` layouts so the card's absolute cluster and the ghost bar share the same markup. - Add a `compact` prop to `AnnotationToolstrip` that disables hover expansion (only the active button shows its label) and hides the help link, for use inside the ghost bar. - Introduce `StickyHeaderLane`: a zero-height sticky wrapper with an absolute-positioned bar that fades + slides in via IntersectionObserver on a sentinel. Uses a 72px top rootMargin so the bar only materializes after the real toolstrip has scrolled off. - Hidden in plan-diff, archive, and linked-doc modes, and gated by the existing `stickyActionsEnabled` preference. For provenance purposes, this commit was AI assisted. --- packages/editor/App.tsx | 23 +++ .../ui/components/AnnotationToolstrip.tsx | 36 +++- packages/ui/components/DocBadges.tsx | 162 ++++++++++++++++++ packages/ui/components/StickyHeaderLane.tsx | 130 ++++++++++++++ packages/ui/components/Viewer.tsx | 85 ++------- 5 files changed, 355 insertions(+), 81 deletions(-) create mode 100644 packages/ui/components/DocBadges.tsx create mode 100644 packages/ui/components/StickyHeaderLane.tsx 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..106d0fbd 100644 --- a/packages/ui/components/AnnotationToolstrip.tsx +++ b/packages/ui/components/AnnotationToolstrip.tsx @@ -8,6 +8,12 @@ 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; } export const AnnotationToolstrip: React.FC = ({ @@ -16,6 +22,7 @@ export const AnnotationToolstrip: React.FC = ({ mode, onModeChange, taterMode, + compact = false, }) => { const [showHelp, setShowHelp] = useState(false); const [helpTab, setHelpTab] = useState<'selection' | 'plannotator'>('selection'); @@ -28,7 +35,7 @@ export const AnnotationToolstrip: React.FC = ({ return ( <> -
+
{/* Input method group */}
= ({ label="Select" color="primary" mounted={mounted} + compact={compact} icon={ @@ -53,6 +61,7 @@ export const AnnotationToolstrip: React.FC = ({ label="Pinpoint" color="primary" mounted={mounted} + compact={compact} icon={ @@ -74,6 +83,7 @@ export const AnnotationToolstrip: React.FC = ({ label="Markup" color="secondary" mounted={mounted} + compact={compact} icon={ @@ -86,6 +96,7 @@ export const AnnotationToolstrip: React.FC = ({ label="Comment" color="accent" mounted={mounted} + compact={compact} icon={ @@ -98,6 +109,7 @@ export const AnnotationToolstrip: React.FC = ({ label="Redline" color="destructive" mounted={mounted} + compact={compact} icon={ @@ -110,6 +122,7 @@ export const AnnotationToolstrip: React.FC = ({ label="Label" color="warning" mounted={mounted} + compact={compact} icon={ @@ -119,12 +132,14 @@ export const AnnotationToolstrip: React.FC = ({
{/* Help */} - + {!compact && ( + + )}
{/* Help Video Dialog */} @@ -241,7 +256,8 @@ const ToolstripButton: React.FC<{ label: string; color: ButtonColor; mounted: boolean; -}> = ({ active, onClick, icon, label, color, mounted }) => { + compact?: boolean; +}> = ({ active, onClick, icon, label, color, mounted, compact = false }) => { const [hovered, setHovered] = useState(false); const [labelWidth, setLabelWidth] = useState(0); const measureRef = useRef(null); @@ -255,7 +271,9 @@ const ToolstripButton: React.FC<{ } }, [label]); - const expanded = active || hovered || isTouchDevice; + // In compact mode, only the active button expands to show its label — no + // hover expansion, no touch-device always-on expansion. + const expanded = 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..d41637d0 --- /dev/null +++ b/packages/ui/components/StickyHeaderLane.tsx @@ -0,0 +1,130 @@ +/** + * StickyHeaderLane — compact "ghost" header that pins at the top of
+ * when 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: fades + slides in at top: 12px, sitting on the same horizontal + * lane as the already-sticky action buttons inside the Viewer card. Contains + * a compact + a row-layout . + * + * 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); + + // 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. */} +