@plannotator/ui 0.35.2 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/HANDOFF.md CHANGED
@@ -190,12 +190,13 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r
190
190
  | `utils/parser` (`parseMarkdownToBlocks`, `exportAnnotations`) | Pure — no backend. |
191
191
  | `components/BlockRenderer` + the block components it renders (`TableBlock`, `HtmlBlock`, `Callout`, `MermaidBlock`, `MathBlock`, …) | Pure rendering. |
192
192
  | `components/InlineMarkdown` | Code-file hover previews route through the `docPreviewFetcher` seam. Wiki-link rendering takes the sync `resolveLinkedDoc` prop (live labels + deleted-doc treatment; see "Wiki-link seams (0.27.0)"). |
193
- | `components/Viewer` | The full annotatable document. Required props: `markdown` and `taterMode` (pass `false`). **Pass `disableCodePathValidation` unless you implement `/api/doc/exists`** — code-path validation is a prop-level opt-out, not a `configure` seam. |
193
+ | `components/Viewer` | The full annotatable document. Required props: `markdown` and `taterMode` (pass `false`). **Pass `disableCodePathValidation` unless you implement `/api/doc/exists`** — code-path validation is a prop-level opt-out, not a `configure` seam. `annotationHeader={{ onInputMethodChange, onModeChange, hideQuickLabel? }}` opts into one Viewer-owned, in-flow header containing the compact annotation controls and existing document actions. It reserves its measured responsive height, preserves all document badges, and follows `stickyActions` as one unit; omit it for the legacy action bar. Compact mode contains no help link. `hideQuickLabel` still requires the host to clamp restored mode state away from `'quickLabel'`. A host-owned scroll element must be supplied through `ScrollViewportProvider` (`hooks/useScrollViewport`) so stuck chrome and anchor clearance use the real scroller. |
194
194
  | `components/MarkdownEditor` | Theme-bridging wrapper over `@plannotator/markdown-editor`. Takes CM6 extensions via the `extensions` prop (captured ONCE per `documentId` — see "Wiki-link seams (0.27.0)") and re-exports `wikiLinks`, `embedPicker`, `embedSlashItem`, `planEmbedInsert`, and their public types. |
195
195
  | `components/MarkdownDiff` | Theme-bridging wrapper over `@plannotator/markdown-editor`'s frozen two-revision diff. Same shim pattern as `components/MarkdownEditor` (ThemeProvider bridge, `extensions` passthrough, grid card chrome); never editable. See "Frozen markdown diff (0.28.0)". |
196
196
  | `components/CommentPopover` | Anchor capture + comment entry. Ask-AI UI renders only if you pass `onAskAI`. |
197
197
  | `components/AnnotationPanel` | Renders from your annotation state; no fetches of its own. |
198
198
  | `components/AnnotationToolstrip` | The annotation mode toolstrip (Select / Pinpoint / Markup / Comment / Redline / Label). **Pass `showHelpLink={false}` in a host** — the default help modal embeds Plannotator's own YouTube walkthroughs. `hideQuickLabel` omits only the Label button (`StickyHeaderLane` forwards it, so the pinned scroll header matches); it hides the control, it does **not** clamp the mode — keep host mode state out of `'quickLabel'` (including preferences restored through `utils/editorMode`, which accepts it from storage) or text selection silently opens the quick-label picker with no visible cause. `hideInputMethodSwitch` likewise omits the pinpoint/drag switch. *(Blessed in 0.35.0.)* |
199
+ | `components/StickyHeaderLane` | The backward-compatible standalone ghost lane used by Plannotator beside Viewer's legacy action bar. Defaults remain hidden/inert at rest and visible only while stuck, including the incumbent hidden chrome during its fade. Its `visibility="always"` mode remains a zero-height overlay and therefore requires host-owned clearance. New hosts that need a visible in-flow header should use `Viewer.annotationHeader` instead; it owns both clusters and their clearance. **The `visibility="always"` / `sticky={false}` pair is soft-deprecated as of 0.37.0**: it shipped in 0.36.0, its one intended consumer moved to `Viewer.annotationHeader` before adopting it, and it has no known consumers. It is retained for compatibility and still tested, but do not build new integrations on it. `sticky={false}` uses normal-flow positioning, creates no intersection observer, and must be paired with `visibility="always"`. Wide active-label, tight icon-only, and narrow stacked fallbacks remain measurement-driven, and `hideQuickLabel` still forwards to the compact toolstrip. |
199
200
  | `components/ThemeProvider` | Color-mode context. |
200
201
  | `theme-modes` (`THEME_MODES`, `Mode`) | The supported Light/Dark/System catalog and mode type. `Mode` also remains exported from `components/ThemeProvider` for compatibility with existing consumers. |
201
202
  | `components/ImageThumbnail` / `getImageSrc` | Routes through `imageSrcResolver`. |
@@ -675,8 +676,8 @@ Additive only, but required: `@plannotator/ui` 0.32.0 imports the new `@plannota
675
676
 
676
677
  ## Publishing & versioning
677
678
 
678
- - The current pair is `@plannotator/ui` `0.35.2` on `@plannotator/core` `0.25.1`. Core 0.25.1 publishes the `annotation-threads` subpath already used by `AnnotationPanel` and `utils/parser`; UI 0.35.2 pins that corrected core exactly. The UI behavior remains the `hideQuickLabel` seam introduced in 0.35.0.
679
- - Recent pairs, for the consumer's install matrix: ui 0.31.0 on core 0.24.0 (lockstep), ui 0.32.0 on core 0.25.0 (lockstep, `html-anchor`), ui 0.33.0 and ui 0.34.0 on core 0.25.0 (ui only), and ui 0.35.2 on core 0.25.1. Do not consume ui 0.35.0 externally because its published manifest contains `workspace:*`; do not consume ui 0.35.1 because its exact core 0.25.0 dependency lacks the `annotation-threads` export.
679
+ - The current pair is `@plannotator/ui` `0.37.0` on `@plannotator/core` `0.25.1`. Core 0.25.1 publishes the `annotation-threads` subpath already used by `AnnotationPanel` and `utils/parser`; UI pins that corrected core exactly. UI 0.37.0 adds the Viewer-owned document-header seam (a new public API, hence the minor bump; 0.36.1 was reserved for it but never published) while retaining the `hideQuickLabel` and `StickyHeaderLane` seams from the 0.35.x and 0.36.0 releases.
680
+ - Recent pairs, for the consumer's install matrix: ui 0.32.0 on core 0.25.0 (lockstep, `html-anchor`), ui 0.33.0 and ui 0.34.0 on core 0.25.0 (ui only), and ui 0.35.2, ui 0.36.0, and ui 0.37.0 on core 0.25.1 (0.36.1 was never published). Do not consume ui 0.35.0 externally because its published manifest contains `workspace:*`; do not consume ui 0.35.1 because its exact core 0.25.0 dependency lacks the `annotation-threads` export.
680
681
  - When both packages change, **publish `core` first**: ui 0.32.0 imports the `@plannotator/core/html-anchor` subpath, which no earlier published core (0.24.0 and before) has, just as ui 0.29.0 needed core 0.23.0 for `@plannotator/core/annotatable`. Bump core, update UI's exact core dependency to the same new version, and run `bun install` so `bun.lock` records the new workspace versions before packing either package.
681
682
  - The HTML annotation seams also changed the guides.show viewer **stylesheet** (five utility rules from `HtmlSurfaceControls`; the viewer JS is unchanged), so `packages/core/guide-viewer-manifest.ts` now pins a CSS hash that exists on guides.show only after the deploy workflow has published this build's `/v1/` assets. A guide exported from this build before that deploy would pin a stylesheet the host does not serve yet: **deploy guides.show before any release that ships this manifest.**
682
683
  - UI declares the already published core version exactly in its source manifest. Do not replace it with `workspace:*`: direct publication can preserve that protocol and make the package impossible to install outside this repository. Bun links the local core workspace whenever its version matches the exact dependency. Before publishing, run `bun run --cwd packages/ui smoke:package`; it checks the source and packed manifests, required tarball subpaths, local Bun linking, and a real pnpm install in an external temporary consumer. When both packages change, publish **`core` first, then `ui`**.
package/README.md CHANGED
@@ -137,6 +137,78 @@ The srcdoc then carries one classic `<script src>` in the exact place the inline
137
137
  - **`showHelpLink={false}`** for hosts: the default help modal embeds Plannotator's own video walkthroughs.
138
138
  - **`hideInputMethodSwitch`** omits the pinpoint/drag input-method switch.
139
139
 
140
+ #### Sticky header lane host props
141
+
142
+ `components/StickyHeaderLane` is the measured compact companion to `Viewer`'s
143
+ `[data-sticky-actions]` cluster. Its defaults preserve Plannotator's ghost-header
144
+ behavior: hidden and inert at rest, then visible with card chrome once stuck.
145
+
146
+ - **`visibility="always"`** keeps the existing measured left lane visible and
147
+ interactive at rest as well as while stuck. Resting lanes have no background,
148
+ border, backdrop, shadow, or new document padding; stuck lanes retain the
149
+ incumbent chrome. The lane remains zero-height and absolutely positioned, so
150
+ the host must reserve a clear header-height region; otherwise its visible
151
+ controls can cover and intercept interaction with document content below.
152
+ - **`sticky={false}`** uses non-sticky positioning, creates no intersection
153
+ observer, and scrolls away normally. Pass the same value to
154
+ `Viewer.stickyActions` so the left lane and right action cluster follow one
155
+ policy. By itself it leaves the default stuck-only lane permanently hidden;
156
+ combine it with `visibility="always"` for a visible non-sticky header. The
157
+ measured Viewer-actions width is still reserved because both clusters share
158
+ the lane at rest before they scroll away together.
159
+ - Wide, tight icon-only, and narrow stacked layouts continue to derive from the
160
+ wrapper width and the measured action-cluster width. `hideQuickLabel` is still
161
+ forwarded to the compact toolstrip.
162
+
163
+ #### Viewer-owned document header
164
+
165
+ Use `Viewer.annotationHeader` when the compact annotation controls must be
166
+ visible at rest. Viewer then owns one in-flow header containing those controls
167
+ on the left and its existing Global comment / Copy actions on the right:
168
+
169
+ ```tsx
170
+ <Viewer
171
+ mode={mode}
172
+ inputMethod={inputMethod}
173
+ stickyActions={stickyActions}
174
+ annotationHeader={{
175
+ onInputMethodChange: setInputMethod,
176
+ onModeChange: setMode,
177
+ hideQuickLabel: true,
178
+ }}
179
+ // ...the existing Viewer props
180
+ />
181
+ ```
182
+
183
+ The trailing action cluster keeps full-width labels unless you also pass
184
+ `actionsLabelMode` (`'full' | 'short' | 'icon'`); the header measures the real
185
+ cluster either way, so omitting it costs earlier stacking on narrow columns,
186
+ never breakage.
187
+
188
+ The header reserves its real responsive height before document content. It
189
+ keeps active labels in the wide layout, switches the compact toolstrip to
190
+ icons in the tight layout, and stacks the two clusters when narrow or wrapped.
191
+ All Viewer badge context moves into the same measured header. With
192
+ `stickyActions={true}` the complete header pins and gains the existing stuck
193
+ chrome; with `false` it remains in flow and scrolls away. The complete header
194
+ has `data-print-hide`, so it contributes no print layout.
195
+
196
+ As with Viewer's legacy sticky actions and anchor navigation, hosts with a
197
+ custom scroll element must wrap Viewer in `ScrollViewportProvider` from
198
+ `@plannotator/ui/hooks/useScrollViewport` and pass that actual scroll element.
199
+ Without the provider, CSS page stickiness can still apply, but Viewer cannot
200
+ observe the host scroller to add stuck chrome or calculate anchor clearance.
201
+
202
+ The config is intentionally typed rather than a React-node slot. Viewer reuses
203
+ its existing `mode`, `inputMethod`, and `taterMode`; the config supplies only
204
+ the state-change callbacks and optional `hideQuickLabel`. Compact toolstrips
205
+ never render the Plannotator help modal. Hiding Quick Label does not clamp the
206
+ mode, so hosts must still prevent stored `'quickLabel'` state from reaching
207
+ Viewer. Omit `annotationHeader` to preserve the legacy floating action bar
208
+ exactly. The standalone `StickyHeaderLane` remains supported for Plannotator's
209
+ hidden-at-rest ghost lane, but its always-visible mode is an overlay and is not
210
+ the in-flow host integration.
211
+
140
212
  ### WebMCP provider (`@plannotator/ui/webmcp`; 0.32.0)
141
213
 
142
214
  The engine that lets a browser-integrated agent (Chrome/Edge WebMCP, `document.modelContext`) call in-page tools on a document surface. Feature-detected once; a browser without the API sees no registration, no DOM, no network, no timers. Seam: `configurePlannotatorUI({ webmcp: { enabled, namePrefix } })`, default enabled with the `plannotator.` prefix; pass `enabled: false` to keep a host page tool-free, or your own prefix to namespace the tools beside your own. There is deliberately no confirmation seam: the catalog is read-and-comment only (no approve / submit / close tools), and the agent may only edit or remove comments stamped `source: "browser-agent"`.
@@ -172,7 +244,7 @@ npm install @plannotator/ui @plannotator/core
172
244
  - `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
173
245
  - `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on an exact published `@plannotator/core` version. Published.
174
246
  - `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
175
- - Currently `@plannotator/ui` 0.35.2 depends exactly on `@plannotator/core` 0.25.1. `core` is bumped only when something under `packages/core` changes, so `ui` can advance alone. Keep the published core version exact in `packages/ui/package.json`; do not use a `workspace:` protocol there, because a directly published manifest must remain installable outside this monorepo. Bun still links the matching local workspace during development. When both packages change, publish `core` first, then build and publish the UI tarball. See HANDOFF.md "Publishing & versioning" for the verification command.
247
+ - Currently `@plannotator/ui` 0.37.0 depends exactly on `@plannotator/core` 0.25.1. `core` is bumped only when something under `packages/core` changes, so `ui` can advance alone. Keep the published core version exact in `packages/ui/package.json`; do not use a `workspace:` protocol there, because a directly published manifest must remain installable outside this monorepo. Bun still links the matching local workspace during development. When both packages change, publish `core` first, then build and publish the UI tarball. See HANDOFF.md "Publishing & versioning" for the verification command.
176
248
 
177
249
  ## The one rule
178
250
 
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Extracted from Viewer.tsx so the same markup can render in two places:
5
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
6
+ * - layout="row": compact context inside the legacy sticky header lane
7
+ * - layout="header": complete context inside Viewer's in-flow shared header
7
8
  *
8
9
  * In row layout, the demo badge and linked-doc breadcrumb are dropped —
9
10
  * everything else is decorative top-of-doc context except the plan-diff
@@ -29,7 +30,7 @@ export interface LinkedDocBadgeInfo {
29
30
  }
30
31
 
31
32
  export interface DocBadgesProps {
32
- layout: 'column' | 'row';
33
+ layout: 'column' | 'row' | 'header';
33
34
  repoInfo?: { display: string; branch?: string } | null;
34
35
  planDiffStats?: PlanDiffStats | null;
35
36
  isPlanDiffActive?: boolean;
@@ -68,6 +69,7 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
68
69
  openInAppPath,
69
70
  }) => {
70
71
  const isRow = layout === 'row';
72
+ const isHorizontal = layout !== 'column';
71
73
  const canOpenInApp =
72
74
  !!openInAppPath && !/^https?:\/\//i.test(openInAppPath);
73
75
  const openInButton = canOpenInApp ? (
@@ -85,15 +87,16 @@ export const DocBadges: React.FC<DocBadgesProps> = ({
85
87
  if (!anything) return null;
86
88
 
87
89
  // Row layout: single horizontal line. Column layout: stacked rows.
88
- const outerClass = isRow
89
- ? 'flex flex-row items-center gap-1.5 text-[9px] text-muted-foreground/70 font-mono'
90
+ const outerClass = isHorizontal
91
+ ? `flex flex-row items-center gap-1.5 text-[9px] text-muted-foreground/70 font-mono ${layout === 'header' ? 'flex-wrap' : ''}`
90
92
  : 'flex flex-col items-start gap-1 text-[9px] text-muted-foreground/50 font-mono';
91
93
 
92
94
  return (
93
95
  <div className={outerClass}>
94
- {/* Row layout (sticky lane) omits repo/branch to keep the bar compact —
96
+ {/* Legacy row layout omits repo/branch to keep the ghost bar compact —
95
97
  they'd otherwise push the container wide enough to visually extend
96
- under the action buttons. Plan-diff badge still renders below. */}
98
+ under the action buttons. The Viewer-owned header measures and wraps
99
+ the complete badge set, so its `header` layout keeps this context. */}
97
100
  {repoInfo && !linkedDocInfo && !isRow && (
98
101
  <div className="flex items-center gap-1.5">
99
102
  <span
@@ -1,11 +1,12 @@
1
1
  /**
2
- * StickyHeaderLane — compact "ghost" header that pins as the user scrolls
2
+ * StickyHeaderLane — compact "ghost" header that can pin as the user scrolls
3
3
  * past the AnnotationToolstrip.
4
4
  *
5
- * At rest (top of doc): invisible, non-interactive. The original toolstrip
6
- * and badge cluster on the card remain the visible source of truth.
5
+ * By default, the lane is invisible and non-interactive at rest, leaving the
6
+ * original toolstrip and badge cluster as the visible source of truth. Hosts
7
+ * can keep this measured lane visible at rest or let it scroll normally.
7
8
  *
8
- * Layout is driven by two ResizeObserver measurements — the sticky
9
+ * Layout is driven by two ResizeObserver measurements — the lane
9
10
  * wrapper's actual width AND the Viewer action button cluster's actual
10
11
  * width — so the bar fits exactly into the space between its left edge
11
12
  * and the buttons, with no fixed pixel reserves.
@@ -33,32 +34,20 @@ import {
33
34
  } from '../hooks/useScrollViewport';
34
35
  import type { EditorMode, InputMethod } from '../types';
35
36
  import type { PlanDiffStats } from '../utils/planDiffEngine';
37
+ import {
38
+ resolveCompactHeaderGeometry,
39
+ snapCompactHeaderWidth,
40
+ } from './compactHeaderLayout';
36
41
 
37
- // Snap a measured pixel width to a 16px grid. ResizeObserver fires every
38
- // frame during a drag; without quantization the sticky bar would
39
- // re-render on every pixel. Hoisted to module scope so the effects
40
- // (which use [] deps) can't accidentally close over a stale instance.
41
- // Floor (not round) so wrapper undershoots and actions overshoots — both
42
- // errors push toward a more cautious layout, avoiding a one-bucket overlap
43
- // flash right at the 300/460 thresholds during a slow drag.
44
- const snap = (n: number) => Math.floor(n / 16) * 16;
45
-
46
- // Layout geometry — static tuning constants, hoisted alongside `snap`.
47
- // LEFT_OFFSET: matches the bar's `md:left-5` (20px).
48
- // GAP: minimum breathing room between the bar's right edge and the
49
- // action button cluster's left edge when they share a lane.
50
- // Two-stage shared-lane shrinkage, mirroring the right side:
51
- // WIDE_BAR_WIDTH: full toolstrip (active labels Pinpoint/Markup ~300px)
52
- // + badges (~140px) on a single line.
53
- // MIN_BAR_WIDTH: icon-only toolstrip (~140px) + badges (~140px).
54
- // Below MIN, even the icon-only bar can't fit beside the (likely also
55
- // icon-only) action cluster — stack as the final fallback.
42
+ // Matches the bar's `md:left-5` inset. The remaining shared geometry lives
43
+ // in compactHeaderLayout so Viewer-owned and standalone lanes cannot drift.
56
44
  const LEFT_OFFSET = 20;
57
- const GAP = 16;
58
- const WIDE_BAR_WIDTH = 460;
59
- const MIN_BAR_WIDTH = 300;
60
45
 
61
- interface StickyHeaderLaneProps {
46
+ /** Controls when the compact header lane is visible and interactive. */
47
+ export type StickyHeaderLaneVisibility = 'stuck' | 'always';
48
+
49
+ /** Props for the measured compact annotation and document header lane. */
50
+ export interface StickyHeaderLaneProps {
62
51
  // Toolstrip state
63
52
  inputMethod: InputMethod;
64
53
  onInputMethodChange: (method: InputMethod) => void;
@@ -68,6 +57,21 @@ interface StickyHeaderLaneProps {
68
57
  /** Omit the Quick Label tool in the compact toolstrip (mirrors AnnotationToolstripProps.hideQuickLabel). */
69
58
  hideQuickLabel?: boolean;
70
59
 
60
+ /**
61
+ * Show the lane only after it sticks, or keep it visible at rest too.
62
+ * Defaults to `'stuck'`, preserving the incumbent ghost-header behavior.
63
+ * Hosts using `'always'` must reserve a clear header-height region because
64
+ * the lane remains zero-height and absolutely positioned over its sibling.
65
+ */
66
+ visibility?: StickyHeaderLaneVisibility;
67
+ /**
68
+ * Keep the lane pinned while its scroll viewport moves. Pass the same value
69
+ * to `Viewer.stickyActions` so both measured header lanes share one policy.
70
+ * Defaults to true. Pair false with `visibility="always"`; the default
71
+ * stuck-only visibility cannot become visible when stickiness is disabled.
72
+ */
73
+ sticky?: boolean;
74
+
71
75
  // Badge state
72
76
  repoInfo?: { display: string; branch?: string } | null;
73
77
  planDiffStats?: PlanDiffStats | null;
@@ -91,6 +95,7 @@ interface StickyHeaderLaneProps {
91
95
  remountToken?: string;
92
96
  }
93
97
 
98
+ /** Render the shared, measured header lane beside the Viewer's action cluster. */
94
99
  export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
95
100
  inputMethod,
96
101
  onInputMethodChange,
@@ -98,6 +103,8 @@ export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
98
103
  onModeChange,
99
104
  taterMode,
100
105
  hideQuickLabel,
106
+ visibility = 'stuck',
107
+ sticky = true,
101
108
  repoInfo,
102
109
  planDiffStats,
103
110
  isPlanDiffActive,
@@ -115,28 +122,26 @@ export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
115
122
  const [wrapperWidth, setWrapperWidth] = useState(0);
116
123
  const [actionsWidth, setActionsWidth] = useState(0);
117
124
  const scrollViewport = useScrollViewport();
125
+ const laneIsStuck = sticky && isStuck;
126
+ const isVisible = visibility === 'always' || laneIsStuck;
127
+ // Preserve the incumbent ghost lane exactly: its chrome remains mounted
128
+ // while the whole hidden bar fades out. Only the new always-visible mode
129
+ // removes chrome at rest for the supported chrome-free presentation.
130
+ const showChrome = visibility === 'always' ? laneIsStuck : true;
118
131
 
119
- // Space available for the bar in the shared lane = wrapper width, minus
120
- // the bar's left offset, minus the action buttons' measured width, minus
121
- // the breathing gap.
122
- const availableForBar = wrapperWidth - LEFT_OFFSET - actionsWidth - GAP;
123
-
124
- // Narrow = not enough room in the shared lane for even the icon-only
125
- // bar. Falls back to a stacked row below the action buttons.
126
- // actionsWidth=0 before measurement is treated as "don't know yet"
127
- // (not narrow), so we don't flash the wrong layout on first paint.
128
- const measured = wrapperWidth > 0 && actionsWidth > 0;
129
- const isNarrow = measured && availableForBar < MIN_BAR_WIDTH;
130
- // Tight = shared lane still fits, but only if the toolstrip drops its
131
- // active labels and goes icon-only. Lets us stay horizontally aligned
132
- // for an extra ~160px of width before stacking.
133
- const isToolstripIconOnly =
134
- measured && !isNarrow && availableForBar < WIDE_BAR_WIDTH;
132
+ const headerGeometry = resolveCompactHeaderGeometry({
133
+ containerWidth: wrapperWidth,
134
+ trailingWidth: actionsWidth,
135
+ leadingInset: LEFT_OFFSET,
136
+ });
137
+ const availableForBar = headerGeometry.availableForLeading;
138
+ const isNarrow = headerGeometry.layout === 'narrow';
139
+ const isToolstripIconOnly = headerGeometry.layout === 'tight';
135
140
 
136
141
  useEffect(() => {
137
142
  if (!wrapperRef.current) return;
138
143
  const ro = new ResizeObserver(([entry]) => {
139
- const next = snap(entry.contentRect.width);
144
+ const next = snapCompactHeaderWidth(entry.contentRect.width);
140
145
  setWrapperWidth((prev) => (prev === next ? prev : next));
141
146
  });
142
147
  ro.observe(wrapperRef.current);
@@ -159,7 +164,7 @@ export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
159
164
  const el = document.querySelector<HTMLElement>('[data-sticky-actions]');
160
165
  if (!el) return;
161
166
  const ro = new ResizeObserver(([entry]) => {
162
- const next = snap(entry.contentRect.width);
167
+ const next = snapCompactHeaderWidth(entry.contentRect.width);
163
168
  setActionsWidth((prev) => (prev === next ? prev : next));
164
169
  });
165
170
  ro.observe(el);
@@ -175,6 +180,10 @@ export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
175
180
  // doubles up with the still-visible toolstrip. Root is the OverlayScrollArea
176
181
  // viewport from context, NOT <main> (which doesn't actually scroll).
177
182
  useEffect(() => {
183
+ if (!sticky) {
184
+ setIsStuck(false);
185
+ return;
186
+ }
178
187
  if (!sentinelRef.current || !scrollViewport) return;
179
188
  const observer = new IntersectionObserver(
180
189
  ([entry]) => setIsStuck(!entry.isIntersecting),
@@ -186,17 +195,18 @@ export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
186
195
  );
187
196
  observer.observe(sentinelRef.current);
188
197
  return () => observer.disconnect();
189
- }, [scrollViewport]);
198
+ }, [scrollViewport, sticky]);
190
199
 
191
200
  return (
192
201
  <>
193
- {/* Sentinel — zero-size, rendered in normal flow at the top of the
194
- column. When it scrolls out of the OverlayScrollArea viewport,
195
- the sticky bar fades in. */}
196
- <div ref={sentinelRef} aria-hidden="true" className="h-0 w-0" />
202
+ {/* Sentinel — present only for sticky positioning. It sits at the top of
203
+ the column and activates the stuck state after scrolling out of the
204
+ OverlayScrollArea viewport. */}
205
+ {sticky && <div ref={sentinelRef} aria-hidden="true" className="h-0 w-0" />}
197
206
 
198
- {/* Sticky wrapper — zero-height so it never pushes content down. The
199
- visible bar is positioned absolutely relative to this wrapper.
207
+ {/* Zero-height wrapper — sticky by default, relative when sticky is
208
+ disabled so the absolutely positioned lane scrolls in normal flow.
209
+ It never pushes document content down.
200
210
  The Viewer's outer wrapper uses z-50, so the sticky lane must
201
211
  sit above that to paint over the card.
202
212
 
@@ -209,8 +219,8 @@ export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
209
219
  <div
210
220
  ref={wrapperRef}
211
221
  data-sticky-header-lane="true"
212
- className={`sticky z-[60] w-full self-center pointer-events-none ${
213
- isNarrow ? 'top-[52px] md:top-[60px]' : 'top-3'
222
+ className={`${sticky ? 'sticky' : 'relative'} z-[60] w-full self-center pointer-events-none ${
223
+ sticky ? (isNarrow ? 'top-[52px] md:top-[60px]' : 'top-3') : ''
214
224
  }`}
215
225
  style={maxWidth == null ? { height: 0 } : { maxWidth, height: 0 }}
216
226
  >
@@ -227,11 +237,13 @@ export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
227
237
  is the final safety net so any overflow clips inside the chrome
228
238
  rather than leaking out.
229
239
 
230
- `inert` removes the bar from the tab order when not stuck. */}
240
+ `inert` removes the bar from the tab order whenever it is hidden. */}
231
241
  <div
232
- inert={!isStuck || undefined}
233
- className={`absolute left-3 md:left-5 top-0 inline-flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0 overflow-hidden rounded-lg py-1 md:py-1.5 bg-card/95 backdrop-blur-sm shadow-sm border border-border/30 motion-reduce:transform-none ${
234
- isStuck
242
+ inert={!isVisible || undefined}
243
+ className={`absolute left-3 md:left-5 top-0 inline-flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0 overflow-hidden rounded-lg py-1 md:py-1.5 ${
244
+ showChrome ? 'bg-card/95 backdrop-blur-sm shadow-sm border border-border/30' : ''
245
+ } motion-reduce:transform-none ${
246
+ isVisible
235
247
  ? 'opacity-100 translate-y-0 pointer-events-auto'
236
248
  : 'opacity-0 -translate-y-1 pointer-events-none'
237
249
  }`}