Skip to content

Commit 1e8a60b

Browse files
authored
feat(plan): complete sticky actions — pin toolstrip + badges on scroll (#510)
* feat(plan): complete sticky actions — pin toolstrip + badges on scroll 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. * fix(plan): mobile sticky lane, keyboard a11y, action button overlap Iteration on the sticky header lane addressing review feedback and in-browser testing across viewport sizes: - Mobile (<640px): the bar now drops to its own full-width row at top: 52px, directly below the action buttons row. Two stacked horizontal lanes — no horizontal collision possible. The toolstrip switches to icon-only on mobile so the diff badges fit alongside. - sm-md (640-1023px): bar shares the top: 12px lane with action buttons, capped at `max-w-[calc(100%-340px)]` to clear the short- label action button cluster (Attachments + Comment + Copy ≈ 320px). - lg+ (1024px+): same shared-lane behavior, cap raised to 400px to clear the full-label cluster. - Bar is `inline-flex` (content-width), not flex (full bounding-box width), so the chrome wraps tightly to the toolstrip + badges and doesn't extend visually past where it needs to. - Add `inert` to the bar when not stuck — removes the hidden subtree from the tab order entirely so keyboard users don't land on invisible duplicate controls before reaching the real toolstrip. - Add `iconOnly` prop to AnnotationToolstrip; passed via JS-driven matchMedia('(max-width: 639px)') in StickyHeaderLane. Active button expansion is preserved on sm+ so users still see the current mode label there. - Bump action button label breakpoint md → lg in Viewer so "Comment" and "Copy" stay short labels until 1024px, giving the action button cluster more breathing room on tablet/laptop widths. For provenance purposes, this commit was AI assisted.
1 parent 91f57ad commit 1e8a60b

5 files changed

Lines changed: 427 additions & 83 deletions

File tree

packages/editor/App.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Annotation, Block, EditorMode, type InputMethod, type ImageAttachment }
1010
import { ThemeProvider } from '@plannotator/ui/components/ThemeProvider';
1111
import { ModeToggle } from '@plannotator/ui/components/ModeToggle';
1212
import { AnnotationToolstrip } from '@plannotator/ui/components/AnnotationToolstrip';
13+
import { StickyHeaderLane } from '@plannotator/ui/components/StickyHeaderLane';
1314
import { TaterSpriteRunning } from '@plannotator/ui/components/TaterSpriteRunning';
1415
import { TaterSpritePullup } from '@plannotator/ui/components/TaterSpritePullup';
1516
import { Settings } from '@plannotator/ui/components/Settings';
@@ -1597,6 +1598,28 @@ const App: React.FC = () => {
15971598
showCancel
15981599
/>
15991600
<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">
1601+
{/* Sticky header lane — ghost bar that pins the toolstrip +
1602+
badges at top: 12px once the user scrolls. Invisible at top
1603+
of doc; original toolstrip/badges remain the source of
1604+
truth there. Hidden in plan diff, archive, linked-doc mode,
1605+
or when sticky actions are disabled. */}
1606+
{!isPlanDiffActive && !archive.archiveMode && !linkedDocHook.isActive && uiPrefs.stickyActionsEnabled && (
1607+
<StickyHeaderLane
1608+
inputMethod={inputMethod}
1609+
onInputMethodChange={handleInputMethodChange}
1610+
mode={editorMode}
1611+
onModeChange={handleEditorModeChange}
1612+
taterMode={taterMode}
1613+
repoInfo={repoInfo}
1614+
planDiffStats={planDiff.diffStats}
1615+
isPlanDiffActive={isPlanDiffActive}
1616+
hasPreviousVersion={planDiff.hasPreviousVersion}
1617+
onPlanDiffToggle={() => setIsPlanDiffActive(!isPlanDiffActive)}
1618+
archiveInfo={archive.currentInfo}
1619+
maxWidth={planMaxWidth}
1620+
/>
1621+
)}
1622+
16001623
{/* Annotation Toolstrip (hidden during plan diff and archive mode) */}
16011624
{!isPlanDiffActive && !archive.archiveMode && (
16021625
<div data-print-hide className="w-full mb-3 md:mb-4 flex items-center justify-start" style={{ maxWidth: planMaxWidth }}>

packages/ui/components/AnnotationToolstrip.tsx

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ interface AnnotationToolstripProps {
88
mode: EditorMode;
99
onModeChange: (mode: EditorMode) => void;
1010
taterMode?: boolean;
11+
/**
12+
* Compact mode: used inside the sticky header lane. Buttons only expand for
13+
* the active mode (no hover expansion), gap is tightened, and the help link
14+
* is hidden.
15+
*/
16+
compact?: boolean;
17+
/**
18+
* Icon-only mode: no button ever expands to show a label, even the active
19+
* one. Used in the sticky header lane on mobile so the toolstrip stays
20+
* narrow and leaves room for the diff badges.
21+
*/
22+
iconOnly?: boolean;
1123
}
1224

1325
export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
@@ -16,6 +28,8 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
1628
mode,
1729
onModeChange,
1830
taterMode,
31+
compact = false,
32+
iconOnly = false,
1933
}) => {
2034
const [showHelp, setShowHelp] = useState(false);
2135
const [helpTab, setHelpTab] = useState<'selection' | 'plannotator'>('selection');
@@ -28,7 +42,7 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
2842

2943
return (
3044
<>
31-
<div className="flex items-center gap-1.5 flex-wrap">
45+
<div className={`flex items-center flex-wrap ${compact ? 'gap-1' : 'gap-1.5'}`}>
3246
{/* Input method group */}
3347
<div className="inline-flex items-center gap-0.5 bg-muted/50 rounded-lg p-0.5 border border-border/30">
3448
<ToolstripButton
@@ -37,6 +51,8 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
3751
label="Select"
3852
color="primary"
3953
mounted={mounted}
54+
compact={compact}
55+
iconOnly={iconOnly}
4056
icon={
4157
<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">
4258
<path d="M12 20h-1a2 2 0 0 1-2-2 2 2 0 0 1-2 2H6"/>
@@ -53,6 +69,8 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
5369
label="Pinpoint"
5470
color="primary"
5571
mounted={mounted}
72+
compact={compact}
73+
iconOnly={iconOnly}
5674
icon={
5775
<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">
5876
<circle cx="12" cy="12" r="10" />
@@ -74,6 +92,8 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
7492
label="Markup"
7593
color="secondary"
7694
mounted={mounted}
95+
compact={compact}
96+
iconOnly={iconOnly}
7797
icon={
7898
<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">
7999
<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" />
@@ -86,6 +106,8 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
86106
label="Comment"
87107
color="accent"
88108
mounted={mounted}
109+
compact={compact}
110+
iconOnly={iconOnly}
89111
icon={
90112
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
91113
<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" />
@@ -98,6 +120,8 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
98120
label="Redline"
99121
color="destructive"
100122
mounted={mounted}
123+
compact={compact}
124+
iconOnly={iconOnly}
101125
icon={
102126
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
103127
<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" />
@@ -110,6 +134,8 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
110134
label="Label"
111135
color="warning"
112136
mounted={mounted}
137+
compact={compact}
138+
iconOnly={iconOnly}
113139
icon={
114140
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
115141
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
@@ -119,12 +145,14 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
119145
</div>
120146

121147
{/* Help */}
122-
<button
123-
onClick={() => setShowHelp(true)}
124-
className="ml-2 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors hidden sm:block"
125-
>
126-
how does this work?
127-
</button>
148+
{!compact && (
149+
<button
150+
onClick={() => setShowHelp(true)}
151+
className="ml-2 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors hidden sm:block"
152+
>
153+
how does this work?
154+
</button>
155+
)}
128156
</div>
129157

130158
{/* Help Video Dialog */}
@@ -241,7 +269,9 @@ const ToolstripButton: React.FC<{
241269
label: string;
242270
color: ButtonColor;
243271
mounted: boolean;
244-
}> = ({ active, onClick, icon, label, color, mounted }) => {
272+
compact?: boolean;
273+
iconOnly?: boolean;
274+
}> = ({ active, onClick, icon, label, color, mounted, compact = false, iconOnly = false }) => {
245275
const [hovered, setHovered] = useState(false);
246276
const [labelWidth, setLabelWidth] = useState(0);
247277
const measureRef = useRef<HTMLSpanElement>(null);
@@ -255,7 +285,14 @@ const ToolstripButton: React.FC<{
255285
}
256286
}, [label]);
257287

258-
const expanded = active || hovered || isTouchDevice;
288+
// iconOnly: never expand (mobile sticky lane).
289+
// compact: only active expands (sm+ sticky lane — shows current mode).
290+
// default: active or hovered expands (top-of-doc full toolstrip).
291+
const expanded = iconOnly
292+
? false
293+
: compact
294+
? active
295+
: (active || hovered || isTouchDevice);
259296
const expandedWidth = H_PAD + ICON_INNER + GAP + labelWidth + H_PAD;
260297
const currentWidth = expanded ? expandedWidth : ICON_SIZE;
261298

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* DocBadges — repo / branch / plan-diff / demo / archive / linked-doc badge cluster.
3+
*
4+
* Extracted from Viewer.tsx so the same markup can render in two places:
5+
* - layout="column": original location at the top-left of the plan card (absolute)
6+
* - layout="row": inside the sticky header lane when the user scrolls
7+
*
8+
* In row layout, the demo badge and linked-doc breadcrumb are dropped — the
9+
* sticky lane hides entirely in linked-doc mode, and the demo badge is purely
10+
* decorative top-of-doc context.
11+
*/
12+
13+
import React from 'react';
14+
import { PlanDiffBadge } from './plan-diff/PlanDiffBadge';
15+
import type { PlanDiffStats } from '../utils/planDiffEngine';
16+
17+
export interface DocBadgesProps {
18+
layout: 'column' | 'row';
19+
repoInfo?: { display: string; branch?: string } | null;
20+
planDiffStats?: PlanDiffStats | null;
21+
isPlanDiffActive?: boolean;
22+
hasPreviousVersion?: boolean;
23+
onPlanDiffToggle?: () => void;
24+
showDemoBadge?: boolean;
25+
archiveInfo?: { status: 'approved' | 'denied' | 'unknown'; timestamp: string; title: string } | null;
26+
linkedDocInfo?: { filepath: string; onBack: () => void; label?: string; backLabel?: string } | null;
27+
}
28+
29+
export const DocBadges: React.FC<DocBadgesProps> = ({
30+
layout,
31+
repoInfo,
32+
planDiffStats,
33+
isPlanDiffActive,
34+
hasPreviousVersion,
35+
onPlanDiffToggle,
36+
showDemoBadge,
37+
archiveInfo,
38+
linkedDocInfo,
39+
}) => {
40+
const anything =
41+
repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo;
42+
if (!anything) return null;
43+
44+
const isRow = layout === 'row';
45+
46+
// Row layout: single horizontal line. Column layout: stacked rows.
47+
const outerClass = isRow
48+
? 'flex flex-row items-center gap-1.5 text-[9px] text-muted-foreground/70 font-mono'
49+
: 'flex flex-col items-start gap-1 text-[9px] text-muted-foreground/50 font-mono';
50+
51+
return (
52+
<div className={outerClass}>
53+
{repoInfo && !linkedDocInfo && (
54+
<div className="flex items-center gap-1.5">
55+
<span
56+
className="px-1.5 py-0.5 bg-muted/50 rounded truncate max-w-[140px]"
57+
title={repoInfo.display}
58+
>
59+
{repoInfo.display}
60+
</span>
61+
{repoInfo.branch && (
62+
<span
63+
className="px-1.5 py-0.5 bg-muted/30 rounded max-w-[120px] flex items-center gap-1 overflow-hidden"
64+
title={repoInfo.branch}
65+
>
66+
<svg className="w-2.5 h-2.5 flex-shrink-0" viewBox="0 0 16 16" fill="currentColor">
67+
<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" />
68+
</svg>
69+
<span className="truncate">{repoInfo.branch}</span>
70+
</span>
71+
)}
72+
</div>
73+
)}
74+
75+
{onPlanDiffToggle && !linkedDocInfo && (
76+
<PlanDiffBadge
77+
stats={planDiffStats ?? null}
78+
isActive={isPlanDiffActive ?? false}
79+
onToggle={onPlanDiffToggle}
80+
hasPreviousVersion={hasPreviousVersion ?? false}
81+
/>
82+
)}
83+
84+
{/* Demo badge: only in column (top-of-doc) layout */}
85+
{!isRow && showDemoBadge && !linkedDocInfo && (
86+
<span className="px-1.5 py-0.5 rounded text-[9px] font-mono bg-amber-500/15 text-amber-600 dark:text-amber-400">
87+
Demo
88+
</span>
89+
)}
90+
91+
{archiveInfo && !linkedDocInfo && (
92+
<div className="flex items-center gap-1.5">
93+
<span
94+
className={`px-1.5 py-0.5 rounded ${
95+
archiveInfo.status === 'approved'
96+
? 'bg-green-500/15 text-green-600 dark:text-green-400'
97+
: archiveInfo.status === 'denied'
98+
? 'bg-red-500/15 text-red-600 dark:text-red-400'
99+
: 'bg-muted/50 text-muted-foreground'
100+
}`}
101+
>
102+
{archiveInfo.status === 'approved'
103+
? 'Approved'
104+
: archiveInfo.status === 'denied'
105+
? 'Denied'
106+
: 'Unknown'}
107+
</span>
108+
{archiveInfo.timestamp && (
109+
<span
110+
className="px-1.5 py-0.5 bg-muted/50 rounded"
111+
title={archiveInfo.timestamp}
112+
>
113+
{new Date(archiveInfo.timestamp).toLocaleDateString(undefined, {
114+
month: 'short',
115+
day: 'numeric',
116+
year: 'numeric',
117+
})}{' '}
118+
{new Date(archiveInfo.timestamp).toLocaleTimeString(undefined, {
119+
hour: 'numeric',
120+
minute: '2-digit',
121+
})}
122+
</span>
123+
)}
124+
</div>
125+
)}
126+
127+
{/* Linked-doc breadcrumb: only in column layout (sticky lane is hidden in linked-doc mode) */}
128+
{!isRow && linkedDocInfo && (
129+
<div className="flex items-center gap-1.5">
130+
<button
131+
onClick={linkedDocInfo.onBack}
132+
className="px-1.5 py-0.5 bg-primary/10 text-primary rounded hover:bg-primary/20 transition-colors flex items-center gap-1"
133+
>
134+
<svg
135+
className="w-2.5 h-2.5"
136+
fill="none"
137+
viewBox="0 0 24 24"
138+
stroke="currentColor"
139+
strokeWidth={2.5}
140+
>
141+
<path
142+
strokeLinecap="round"
143+
strokeLinejoin="round"
144+
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
145+
/>
146+
</svg>
147+
{linkedDocInfo.backLabel || 'plan'}
148+
</button>
149+
<span className="px-1.5 py-0.5 bg-primary/10 text-primary/80 rounded">
150+
{linkedDocInfo.label || 'Linked File'}
151+
</span>
152+
<span
153+
className="px-1.5 py-0.5 bg-muted/50 text-muted-foreground rounded truncate max-w-[200px]"
154+
title={linkedDocInfo.filepath}
155+
>
156+
{linkedDocInfo.filepath.split('/').pop()}
157+
</span>
158+
</div>
159+
)}
160+
</div>
161+
);
162+
};

0 commit comments

Comments
 (0)