Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1583,6 +1584,28 @@ const App: React.FC = () => {
showCancel
/>
<div className="min-h-full flex flex-col items-center px-2 py-3 md:px-10 md:py-8 xl:px-16 relative z-10">
{/* 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 && (
<StickyHeaderLane
inputMethod={inputMethod}
onInputMethodChange={handleInputMethodChange}
mode={editorMode}
onModeChange={handleEditorModeChange}
taterMode={taterMode}
repoInfo={repoInfo}
planDiffStats={planDiff.diffStats}
isPlanDiffActive={isPlanDiffActive}
hasPreviousVersion={planDiff.hasPreviousVersion}
onPlanDiffToggle={() => setIsPlanDiffActive(!isPlanDiffActive)}
archiveInfo={archive.currentInfo}
maxWidth={planMaxWidth}
/>
)}

{/* Annotation Toolstrip (hidden during plan diff and archive mode) */}
{!isPlanDiffActive && !archive.archiveMode && (
<div data-print-hide className="w-full mb-3 md:mb-4 flex items-center justify-start" style={{ maxWidth: planMaxWidth }}>
Expand Down
55 changes: 46 additions & 9 deletions packages/ui/components/AnnotationToolstrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnnotationToolstripProps> = ({
Expand All @@ -16,6 +28,8 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
mode,
onModeChange,
taterMode,
compact = false,
iconOnly = false,
}) => {
const [showHelp, setShowHelp] = useState(false);
const [helpTab, setHelpTab] = useState<'selection' | 'plannotator'>('selection');
Expand All @@ -28,7 +42,7 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({

return (
<>
<div className="flex items-center gap-1.5 flex-wrap">
<div className={`flex items-center flex-wrap ${compact ? 'gap-1' : 'gap-1.5'}`}>
{/* Input method group */}
<div className="inline-flex items-center gap-0.5 bg-muted/50 rounded-lg p-0.5 border border-border/30">
<ToolstripButton
Expand All @@ -37,6 +51,8 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
label="Select"
color="primary"
mounted={mounted}
compact={compact}
iconOnly={iconOnly}
icon={
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20h-1a2 2 0 0 1-2-2 2 2 0 0 1-2 2H6"/>
Expand All @@ -53,6 +69,8 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
label="Pinpoint"
color="primary"
mounted={mounted}
compact={compact}
iconOnly={iconOnly}
icon={
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
Expand All @@ -74,6 +92,8 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
label="Markup"
color="secondary"
mounted={mounted}
compact={compact}
iconOnly={iconOnly}
icon={
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
Expand All @@ -86,6 +106,8 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
label="Comment"
color="accent"
mounted={mounted}
compact={compact}
iconOnly={iconOnly}
icon={
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
Expand All @@ -98,6 +120,8 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
label="Redline"
color="destructive"
mounted={mounted}
compact={compact}
iconOnly={iconOnly}
icon={
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9.75L14.25 12m0 0l2.25 2.25M14.25 12l2.25-2.25M14.25 12L12 14.25m-2.58 4.92l-6.375-6.375a1.125 1.125 0 010-1.59L9.42 4.83c.211-.211.498-.33.796-.33H19.5a2.25 2.25 0 012.25 2.25v10.5a2.25 2.25 0 01-2.25 2.25h-9.284c-.298 0-.585-.119-.796-.33z" />
Expand All @@ -110,6 +134,8 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
label="Label"
color="warning"
mounted={mounted}
compact={compact}
iconOnly={iconOnly}
icon={
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
Expand All @@ -119,12 +145,14 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
</div>

{/* Help */}
<button
onClick={() => setShowHelp(true)}
className="ml-2 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors hidden sm:block"
>
how does this work?
</button>
{!compact && (
<button
onClick={() => setShowHelp(true)}
className="ml-2 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors hidden sm:block"
>
how does this work?
</button>
)}
</div>

{/* Help Video Dialog */}
Expand Down Expand Up @@ -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<HTMLSpanElement>(null);
Expand All @@ -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;

Expand Down
162 changes: 162 additions & 0 deletions packages/ui/components/DocBadges.tsx
Original file line number Diff line number Diff line change
@@ -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<DocBadgesProps> = ({
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 (
<div className={outerClass}>
{repoInfo && !linkedDocInfo && (
<div className="flex items-center gap-1.5">
<span
className="px-1.5 py-0.5 bg-muted/50 rounded truncate max-w-[140px]"
title={repoInfo.display}
>
{repoInfo.display}
</span>
{repoInfo.branch && (
<span
className="px-1.5 py-0.5 bg-muted/30 rounded max-w-[120px] flex items-center gap-1 overflow-hidden"
title={repoInfo.branch}
>
<svg className="w-2.5 h-2.5 flex-shrink-0" viewBox="0 0 16 16" fill="currentColor">
<path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
</svg>
<span className="truncate">{repoInfo.branch}</span>
</span>
)}
</div>
)}

{onPlanDiffToggle && !linkedDocInfo && (
<PlanDiffBadge
stats={planDiffStats ?? null}
isActive={isPlanDiffActive ?? false}
onToggle={onPlanDiffToggle}
hasPreviousVersion={hasPreviousVersion ?? false}
/>
)}

{/* Demo badge: only in column (top-of-doc) layout */}
{!isRow && showDemoBadge && !linkedDocInfo && (
<span className="px-1.5 py-0.5 rounded text-[9px] font-mono bg-amber-500/15 text-amber-600 dark:text-amber-400">
Demo
</span>
)}

{archiveInfo && !linkedDocInfo && (
<div className="flex items-center gap-1.5">
<span
className={`px-1.5 py-0.5 rounded ${
archiveInfo.status === 'approved'
? 'bg-green-500/15 text-green-600 dark:text-green-400'
: archiveInfo.status === 'denied'
? 'bg-red-500/15 text-red-600 dark:text-red-400'
: 'bg-muted/50 text-muted-foreground'
}`}
>
{archiveInfo.status === 'approved'
? 'Approved'
: archiveInfo.status === 'denied'
? 'Denied'
: 'Unknown'}
</span>
{archiveInfo.timestamp && (
<span
className="px-1.5 py-0.5 bg-muted/50 rounded"
title={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',
})}
</span>
)}
</div>
)}

{/* Linked-doc breadcrumb: only in column layout (sticky lane is hidden in linked-doc mode) */}
{!isRow && linkedDocInfo && (
<div className="flex items-center gap-1.5">
<button
onClick={linkedDocInfo.onBack}
className="px-1.5 py-0.5 bg-primary/10 text-primary rounded hover:bg-primary/20 transition-colors flex items-center gap-1"
>
<svg
className="w-2.5 h-2.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
/>
</svg>
{linkedDocInfo.backLabel || 'plan'}
</button>
<span className="px-1.5 py-0.5 bg-primary/10 text-primary/80 rounded">
{linkedDocInfo.label || 'Linked File'}
</span>
<span
className="px-1.5 py-0.5 bg-muted/50 text-muted-foreground rounded truncate max-w-[200px]"
title={linkedDocInfo.filepath}
>
{linkedDocInfo.filepath.split('/').pop()}
</span>
</div>
)}
</div>
);
};
Loading