@plannotator/ui 0.53.1 → 0.55.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.
Files changed (149) hide show
  1. package/HANDOFF.md +274 -14
  2. package/README.md +114 -11
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +142 -24
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/ApproveDropdown.tsx +7 -9
  9. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  10. package/components/AttachmentsButton.tsx +1 -1
  11. package/components/BlockRenderer.tsx +22 -5
  12. package/components/CodeFilePopout.tsx +139 -4
  13. package/components/CodeSnippetPreview.tsx +174 -0
  14. package/components/CommentPopover.tsx +44 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DeferredMarkdownContentsRail.tsx +38 -0
  17. package/components/DiagramBlock.tsx +17 -12
  18. package/components/DiffFileTree.tsx +7 -6
  19. package/components/DocumentContents.tsx +74 -0
  20. package/components/EditorAnnotationCard.tsx +1 -1
  21. package/components/ExportModal.tsx +77 -7
  22. package/components/HostTitle.tsx +0 -4
  23. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  24. package/components/ImageAnnotator/index.tsx +24 -4
  25. package/components/ImageLightbox.tsx +722 -126
  26. package/components/ImagePeek.tsx +38 -4
  27. package/components/ImageRegions.tsx +369 -0
  28. package/components/ImageThumbnail.tsx +1 -1
  29. package/components/ImageZoomStage.tsx +796 -0
  30. package/components/InboxAnnouncementDialog.tsx +202 -0
  31. package/components/InlineMarkdown.tsx +167 -174
  32. package/components/KeyboardShortcuts.tsx +23 -0
  33. package/components/LazyBlockBoundary.tsx +100 -0
  34. package/components/ListMarker.tsx +3 -3
  35. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  36. package/components/MarkdownContentsRail.tsx +777 -0
  37. package/components/MenuVersionSection.tsx +31 -9
  38. package/components/PlanDecisionMenuExtras.tsx +129 -0
  39. package/components/PlanHeaderMenu.tsx +15 -1
  40. package/components/ProviderIcons.tsx +8 -0
  41. package/components/RefHoverCard.tsx +913 -0
  42. package/components/SendFailureToast.tsx +126 -0
  43. package/components/Settings.tsx +617 -126
  44. package/components/TableOfContents.tsx +20 -6
  45. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  46. package/components/ThemeProvider.tsx +14 -1
  47. package/components/ThemeTab.tsx +16 -24
  48. package/components/Tooltip.tsx +49 -18
  49. package/components/Viewer.tsx +288 -43
  50. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  51. package/components/ai/NewChatButton.tsx +74 -0
  52. package/components/blocks/CodeBlock.tsx +1 -1
  53. package/components/blocks/HtmlBlock.tsx +7 -1
  54. package/components/blocks/QuestionBlock.tsx +532 -62
  55. package/components/diagram/DiagramComposer.tsx +5 -1
  56. package/components/diagram/DiagramPending.tsx +12 -2
  57. package/components/diagram/DiagramViewer.tsx +19 -3
  58. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  59. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  60. package/components/html-embed/index.ts +3 -0
  61. package/components/html-embed/languages.ts +15 -0
  62. package/components/html-embed/loadGate.ts +107 -0
  63. package/components/html-viewer/HtmlViewer.tsx +82 -6
  64. package/components/html-viewer/bridge-script.asset.js +173 -1
  65. package/components/html-viewer/bridge-script.ts +173 -1
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +276 -0
  68. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  69. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  70. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  71. package/components/sidebar/FileBrowser.tsx +16 -2
  72. package/components/sidebar/SidebarTabs.tsx +3 -0
  73. package/config/index.ts +3 -0
  74. package/config/reviewView.ts +41 -1
  75. package/config/settings.ts +155 -4
  76. package/configure.ts +28 -0
  77. package/contents-rail.css +337 -0
  78. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  79. package/hooks/useAIChat.ts +72 -25
  80. package/hooks/useAnnotationDraft.ts +71 -6
  81. package/hooks/useAnnotationHighlighter.ts +57 -3
  82. package/hooks/useCodeAnnotationDraft.ts +113 -17
  83. package/hooks/useCookieHostSetting.ts +96 -0
  84. package/hooks/useDocumentFont.ts +17 -0
  85. package/hooks/useDocumentTextSize.ts +20 -0
  86. package/hooks/useFileBrowser.ts +10 -1
  87. package/hooks/useHeadingScrollSpy.ts +77 -0
  88. package/hooks/useHoverPreview.ts +94 -0
  89. package/hooks/useLinkedDoc.ts +226 -13
  90. package/hooks/usePackageChangesSetting.ts +90 -0
  91. package/hooks/usePlanLookDialog.ts +67 -0
  92. package/hooks/usePrefersReducedMotion.ts +23 -0
  93. package/hooks/useSharing.ts +11 -7
  94. package/hooks/useSwipeStep.ts +597 -0
  95. package/hooks/useThemePaletteHalves.ts +37 -0
  96. package/hooks/useUpdateCheck.ts +22 -4
  97. package/hooks/useUpdateToast.ts +1 -1
  98. package/package.json +26 -23
  99. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  100. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  101. package/shortcuts/core.ts +63 -4
  102. package/shortcuts/index.ts +1 -0
  103. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  104. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  105. package/shortcuts/runtime.ts +4 -3
  106. package/styles.css +1 -1
  107. package/theme.css +179 -1
  108. package/themes/plannotator-graphite.css +66 -0
  109. package/types.ts +26 -9
  110. package/utils/agentSwitch.ts +41 -20
  111. package/utils/agentToolSetting.ts +1 -1
  112. package/utils/aiChatFormat.ts +23 -7
  113. package/utils/annotationHelpers.ts +32 -4
  114. package/utils/annotationOwnsHighlight.ts +4 -2
  115. package/utils/asciidocParser.ts +570 -0
  116. package/utils/autoUpdateNotice.ts +24 -0
  117. package/utils/belowTitleSlot.ts +174 -0
  118. package/utils/blockParse.ts +45 -0
  119. package/utils/codeLineScroll.ts +100 -0
  120. package/utils/commentSubmitKey.ts +48 -0
  121. package/utils/cssFontFamily.ts +65 -0
  122. package/utils/decisionSpec.ts +190 -2
  123. package/utils/defaultNotesApp.ts +1 -1
  124. package/utils/diffFonts.ts +4 -1
  125. package/utils/documentFont.ts +68 -0
  126. package/utils/documentTextSize.ts +54 -0
  127. package/utils/fontDetect.ts +63 -0
  128. package/utils/headingScrollSpy.ts +92 -0
  129. package/utils/inboxAnnouncement.ts +78 -0
  130. package/utils/lookAndFeelAnnouncement.ts +82 -5
  131. package/utils/markdownExtensions.ts +2 -2
  132. package/utils/mermaid.ts +20 -3
  133. package/utils/mermaidTheme.ts +3 -51
  134. package/utils/notion.ts +58 -0
  135. package/utils/parser.ts +28 -1
  136. package/utils/platform.ts +46 -0
  137. package/utils/refCard.ts +82 -0
  138. package/utils/richClipboard.ts +380 -0
  139. package/utils/richClipboardLists.ts +57 -0
  140. package/utils/richCopyPolicy.ts +22 -0
  141. package/utils/scrollAnchor.ts +82 -0
  142. package/utils/sectionRefs.ts +123 -0
  143. package/utils/selectionBoundary.ts +40 -0
  144. package/utils/sendFailure.ts +85 -0
  145. package/utils/sharing.ts +5 -2
  146. package/utils/themeRegistry.ts +10 -0
  147. package/utils/themeTokens.ts +74 -0
  148. package/utils/updateNotice.ts +30 -8
  149. package/utils/vscodePanel.ts +9 -0
@@ -13,8 +13,14 @@ import {
13
13
  isAnnotateAgentTerminalSide,
14
14
  type AnnotateAgentTerminalSide,
15
15
  } from '@plannotator/core/agent-terminal';
16
- import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
16
+ import type { DiffLineBgIntensity, ReviewDefaults } from '@plannotator/core/config-types';
17
17
  import { isFaviconStyle, type FaviconStyle } from '@plannotator/core/favicon';
18
+ import {
19
+ agentSwitchConfigValue,
20
+ isAgentSwitchName,
21
+ parseAgentSwitchConfig,
22
+ type AgentSwitchChoice,
23
+ } from '@plannotator/core/agent-switch';
18
24
  import {
19
25
  DEFAULT_TOKEN_HOVER_DELAY_MS,
20
26
  isTokenHoverDelay,
@@ -23,7 +29,14 @@ import {
23
29
  type TokenHoverTrigger,
24
30
  } from '@plannotator/core/token-hover';
25
31
  import { DEFAULT_DIAGRAM_SHADOW, isDiagramShadow } from '../utils/diagramShadow';
32
+ import {
33
+ DEFAULT_DOCUMENT_TEXT_SIZE,
34
+ isDocumentTextSize,
35
+ type DocumentTextSize,
36
+ } from '../utils/documentTextSize';
37
+ import { normalizeFontFamilyName } from '../utils/cssFontFamily';
26
38
  import { storage } from '../utils/storage';
39
+ import { isLookChoiceResolved } from '../utils/lookAndFeelAnnouncement';
27
40
  import { generateIdentity } from '../utils/generateIdentity';
28
41
  import {
29
42
  getDefaultThemePair,
@@ -89,11 +102,45 @@ export function readThemePairCookies(keys?: ThemePairLegacyKeys): ThemePair | un
89
102
  return normalizeThemePair({ mode, light: light ?? seeded.light, dark: dark ?? seeded.dark }, seeded);
90
103
  }
91
104
 
105
+ /** Pre-registry cookie keys of the agent switch, kept so an upgrade keeps the choice. */
106
+ const AGENT_SWITCH_COOKIE = 'plannotator-agent-switch';
107
+ const AGENT_SWITCH_CUSTOM_COOKIE = 'plannotator-agent-custom';
108
+
109
+ function readAgentSwitchCookies(): AgentSwitchChoice | undefined {
110
+ const choice = parseAgentSwitchConfig(storage.getItem(AGENT_SWITCH_COOKIE));
111
+ if (!choice) return undefined;
112
+ const customName = storage.getItem(AGENT_SWITCH_CUSTOM_COOKIE);
113
+ return isAgentSwitchName(customName) ? { switchTo: choice.switchTo, customName } : choice;
114
+ }
115
+
116
+ function writeAgentSwitchCookies(choice: AgentSwitchChoice | null): void {
117
+ // null = never chosen: write nothing, so the surface default keeps applying.
118
+ if (!choice) return;
119
+ storage.setItem(AGENT_SWITCH_COOKIE, choice.switchTo);
120
+ if (choice.customName) storage.setItem(AGENT_SWITCH_CUSTOM_COOKIE, choice.customName);
121
+ // Custom with its name cleared: drop the old name, or the next read would
122
+ // pair it with "custom" and switch to that old agent again.
123
+ else if (choice.switchTo === 'custom') storage.removeItem(AGENT_SWITCH_CUSTOM_COOKIE);
124
+ }
125
+
92
126
  const DIFF_LINE_BG_INTENSITY_VALUES = ['subtle', 'normal', 'strong'] as const;
93
127
  function isDiffLineBgIntensity(v: unknown): v is DiffLineBgIntensity {
94
128
  return typeof v === 'string' && (DIFF_LINE_BG_INTENSITY_VALUES as readonly string[]).includes(v);
95
129
  }
96
130
 
131
+ function parseReviewDefaults(value: unknown): ReviewDefaults | undefined {
132
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
133
+ const defaults: ReviewDefaults = {};
134
+ for (const [providerId, providerValue] of Object.entries(value)) {
135
+ if (!providerId || !providerValue || typeof providerValue !== 'object' || Array.isArray(providerValue)) continue;
136
+ const defaultDiffType = (providerValue as Record<string, unknown>).defaultDiffType;
137
+ if (typeof defaultDiffType === 'string' && defaultDiffType) {
138
+ defaults[providerId] = { defaultDiffType };
139
+ }
140
+ }
141
+ return defaults;
142
+ }
143
+
97
144
  export interface SettingDef<T> {
98
145
  defaultValue: T | (() => T);
99
146
  fromCookie: () => T | undefined;
@@ -160,10 +207,18 @@ export const SETTINGS = {
160
207
  },
161
208
 
162
209
  gridEnabled: {
163
- // Default ON: plans open in the classic grid / floating-card look. The UI 2.0
164
- // flat look is offered as an opt-in via the look-and-feel chooser dialog.
165
- defaultValue: true as boolean,
210
+ // Default OFF: plans open in the Clean look (the document edge to edge).
211
+ // Grid (the classic floating card on grid paper) is a choice, made in the
212
+ // first-run chooser or in Settings.
213
+ defaultValue: false as boolean,
166
214
  fromCookie: () => {
215
+ // ConfigStore writes the default into this cookie on first load, so every
216
+ // browser that ran an older build stored 'true' (the old default) without
217
+ // anyone choosing it. The stored value counts only once the person has
218
+ // resolved the Grid/Clean choice under the Clean default (the v2 marker;
219
+ // a v1 marker with Grid stored is moved to Clean once, see
220
+ // utils/lookAndFeelAnnouncement); until then they get Clean.
221
+ if (!isLookChoiceResolved()) return undefined;
167
222
  const v = storage.getItem('plannotator-grid-enabled');
168
223
  return v === 'true' ? true : v === 'false' ? false : undefined;
169
224
  },
@@ -192,6 +247,54 @@ export const SETTINGS = {
192
247
  serverKey: undefined, fromServer: undefined, toServer: undefined,
193
248
  },
194
249
 
250
+ /**
251
+ * The document's body text size in plan review and annotate, in px (#2066;
252
+ * see `utils/documentTextSize`). Default 15, today's size. Cookie-only, like
253
+ * the other display knobs; an unknown value reads as the default.
254
+ */
255
+ documentTextSize: {
256
+ defaultValue: DEFAULT_DOCUMENT_TEXT_SIZE as DocumentTextSize,
257
+ fromCookie: () => {
258
+ const raw = storage.getItem('plannotator-document-text-size');
259
+ if (raw === null || raw.trim() === '') return undefined;
260
+ const parsed = Number(raw);
261
+ return isDocumentTextSize(parsed) ? parsed : undefined;
262
+ },
263
+ toCookie: (value: DocumentTextSize) =>
264
+ storage.setItem('plannotator-document-text-size', String(value)),
265
+ serverKey: undefined, fromServer: undefined, toServer: undefined,
266
+ },
267
+
268
+ /**
269
+ * The document's font in plan review and annotate (#1657; see
270
+ * `utils/documentFont`): a generic preset or an installed family name.
271
+ * Empty = the theme's font. Cookie-only, like Text size.
272
+ */
273
+ documentFontFamily: {
274
+ defaultValue: '' as string,
275
+ fromCookie: () =>
276
+ normalizeFontFamilyName(storage.getItem('plannotator-document-font-family') ?? '') || undefined,
277
+ toCookie: (value: string) => storage.setItem('plannotator-document-font-family', value),
278
+ serverKey: undefined, fromServer: undefined, toServer: undefined,
279
+ },
280
+
281
+ /**
282
+ * Enter saves a comment and Shift+Enter adds a new line (#1934). Default
283
+ * OFF: comments save with Cmd/Ctrl+Enter only, as they always have, and
284
+ * Cmd/Ctrl+Enter keeps saving with it on. Read by every comment composer
285
+ * through `isCommentSubmitKey` (`utils/commentSubmitKey`). Cookie-only.
286
+ */
287
+ enterSavesComment: {
288
+ defaultValue: false as boolean,
289
+ fromCookie: () => {
290
+ const value = storage.getItem('plannotator-enter-saves-comment');
291
+ return value === 'true' ? true : value === 'false' ? false : undefined;
292
+ },
293
+ toCookie: (value: boolean) =>
294
+ storage.setItem('plannotator-enter-saves-comment', String(value)),
295
+ serverKey: undefined, fromServer: undefined, toServer: undefined,
296
+ },
297
+
195
298
  vimModeEnabled: {
196
299
  // Vim bindings deliberately default OFF. Unmodified letter keys must remain
197
300
  // inert for existing users until they explicitly opt into modal document
@@ -360,6 +463,25 @@ export const SETTINGS = {
360
463
  serverKey: undefined, fromServer: undefined, toServer: undefined,
361
464
  },
362
465
 
466
+ reviewDefaults: {
467
+ defaultValue: {} as ReviewDefaults,
468
+ fromCookie: () => {
469
+ const value = storage.getItem('plannotator-review-defaults');
470
+ if (!value) return undefined;
471
+ try {
472
+ return parseReviewDefaults(JSON.parse(value));
473
+ } catch {
474
+ return undefined;
475
+ }
476
+ },
477
+ toCookie: (value: ReviewDefaults) =>
478
+ storage.setItem('plannotator-review-defaults', JSON.stringify(value)),
479
+ serverKey: 'reviewDefaults',
480
+ fromServer: (serverConfig: Record<string, unknown>) =>
481
+ parseReviewDefaults(serverConfig.reviewDefaults),
482
+ toServer: (value: ReviewDefaults) => ({ reviewDefaults: value }),
483
+ },
484
+
363
485
  defaultDiffType: {
364
486
  defaultValue: 'since-base' as 'since-base' | 'local-vs-remote' | 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all',
365
487
  fromCookie: () => {
@@ -655,6 +777,35 @@ export const SETTINGS = {
655
777
  }
656
778
  },
657
779
  },
780
+ /**
781
+ * OpenCode's agent switch after plan approval / review feedback (#1390).
782
+ * null = never chosen: each surface applies its own default (plan approval
783
+ * switches to "build", review feedback stays), see utils/agentSwitch.
784
+ *
785
+ * Server-synced to `agentSwitch` in ~/.plannotator/config.json because some
786
+ * webviews (Glimpse's WKWebView) keep no cookies between runs, and a lost
787
+ * choice silently hands the session to the build agent. The cookie keys
788
+ * are the pre-registry ones, so an upgrade keeps an existing choice.
789
+ */
790
+ agentSwitch: {
791
+ defaultValue: null as AgentSwitchChoice | null,
792
+ fromCookie: () => readAgentSwitchCookies(),
793
+ toCookie: (v: AgentSwitchChoice | null) => writeAgentSwitchCookies(v),
794
+ serverKey: 'agentSwitch',
795
+ fromServer: (sc: Record<string, unknown>) => {
796
+ const choice = parseAgentSwitchConfig(sc.agentSwitch);
797
+ // Custom with no name means no switch: never pair it with an old name.
798
+ if (!choice || choice.customName || choice.switchTo === 'custom') return choice;
799
+ // A plain choice keeps the name typed in the Custom field earlier, so
800
+ // picking Custom again shows it (the cookie is the only place it lives).
801
+ const customName = storage.getItem(AGENT_SWITCH_CUSTOM_COOKIE);
802
+ return isAgentSwitchName(customName) ? { ...choice, customName } : choice;
803
+ },
804
+ toServer: (v: AgentSwitchChoice | null) => {
805
+ const value = v ? agentSwitchConfigValue(v) : undefined;
806
+ return value === undefined ? {} : { agentSwitch: value };
807
+ },
808
+ },
658
809
  /**
659
810
  * Where the annotate-mode Agent TUI docks: 'left' (where it always docked),
660
811
  * 'right', or 'hidden' (no slot until the user opens it for the session).
package/configure.ts CHANGED
@@ -18,6 +18,9 @@ import { configStore } from './config';
18
18
  import type { ServerSyncFn } from './config/configStore';
19
19
  import type { ExternalAnnotationEvent, VaultNode } from './types';
20
20
  import { setComponentTooltips } from './components/HostTitle';
21
+ import { setRichCopyEnabled } from './utils/richCopyPolicy';
22
+ import { setDigitHexIssueRefs } from './components/InlineMarkdown';
23
+ import { setRefCardTransport, type RefCardTransport } from './utils/refCard';
21
24
 
22
25
  // One-stop type barrel: every seam contract a host implements is importable
23
26
  // from this module, next to configurePlannotatorUI itself.
@@ -44,6 +47,7 @@ export type {
44
47
  IdentityGenerator,
45
48
  AlertIconRenderer,
46
49
  FenceThemeResolver,
50
+ RefCardTransport,
47
51
  };
48
52
 
49
53
  type ExternalAnnotationBase = { id: string; source?: string };
@@ -127,6 +131,27 @@ export interface PlannotatorUIConfig {
127
131
  * as before. The Plannotator Inbox passes true.
128
132
  */
129
133
  componentTooltips?: boolean;
134
+ /**
135
+ * Render a digit-only 6/8-digit `#123456` (word boundary, no leading zero)
136
+ * as an issue ref instead of a hex colour swatch, when a ref link can be
137
+ * built (a repo on a known forge). Default: false, swatches exactly as
138
+ * before. The Plannotator Inbox passes true (#1932).
139
+ */
140
+ digitHexIssueRefs?: boolean;
141
+ /**
142
+ * Make the selection toolbar's Copy button (Viewer and HtmlViewer) copy rich
143
+ * text: text/html plus text/plain in one clipboard write, and show Copy
144
+ * where `hideCopyButton` hid it (the Viewer on desktop). Default: false,
145
+ * Copy exactly as before. Plannotator's plan review, annotate, code review
146
+ * and Inbox pass true (#1972, `utils/richCopyPolicy`).
147
+ */
148
+ richCopy?: boolean;
149
+ /**
150
+ * Fetch an issue / PR / MR hover card's data (`components/RefHoverCard`).
151
+ * Default: none, so no card is drawn and nothing is requested. The
152
+ * Plannotator Inbox passes one over its `/api/inbox/ref` route.
153
+ */
154
+ refCardTransport?: RefCardTransport;
130
155
  /** Re-hydrate settings from the installed (SYNCHRONOUS) storageBackend after install. */
131
156
  loadSettingsFromBackend?: boolean;
132
157
  }
@@ -152,6 +177,9 @@ export function configurePlannotatorUI(config: PlannotatorUIConfig): void {
152
177
  if (config.alertIconRenderer) setAlertIconRenderer(config.alertIconRenderer);
153
178
  if (config.fenceTheme) setFenceThemeResolver(config.fenceTheme);
154
179
  if (config.componentTooltips !== undefined) setComponentTooltips(config.componentTooltips);
180
+ if (config.digitHexIssueRefs !== undefined) setDigitHexIssueRefs(config.digitHexIssueRefs);
181
+ if (config.richCopy !== undefined) setRichCopyEnabled(config.richCopy);
182
+ if (config.refCardTransport) setRefCardTransport(config.refCardTransport);
155
183
  // Re-hydrate AFTER storageBackend is installed (load-bearing order — gated last).
156
184
  if (config.loadSettingsFromBackend) configStore.loadFromBackend();
157
185
  }
@@ -0,0 +1,337 @@
1
+ /*
2
+ * @plannotator/ui/contents-rail.css: the document contents rail
3
+ * (components/MarkdownContentsRail.tsx). Its own stylesheet, NOT part of
4
+ * theme.css or styles.css: import it OUTSIDE any CSS cascade layer, after the
5
+ * host's base reset. A layered copy loses to a later unlayered reset (the
6
+ * marker's margin-inline-start: auto, the tick, title and label paddings and
7
+ * margins, the depth indents), and the ticks then sit over the prose.
8
+ *
9
+ * Moved from the Workspaces app's `ws-markdown-contents-*` rules (approved
10
+ * 2026-10-03). A stable marker dock sits in the document's existing right
11
+ * padding without participating in layout. The Contents surface opens to its
12
+ * LEFT and may overlay the document, never a docked comments column. Pointer
13
+ * events stay on the real navigation buttons; selection and scrolling remain
14
+ * untouched. Plain CSS, no Tailwind. Tokens are the host's (Workspaces defines
15
+ * them all); each has a fallback.
16
+ */
17
+ @keyframes pn-contents-rail-enter {
18
+ from {
19
+ opacity: 0;
20
+ }
21
+ }
22
+
23
+ .pn-contents-rail {
24
+ --pn-contents-dock-width: 36px;
25
+ --pn-contents-panel-gap: 6px;
26
+ --pn-contents-panel-width: 256px;
27
+
28
+ position: fixed;
29
+ z-index: 20;
30
+ translate: 0 -50%;
31
+ width: calc(
32
+ var(--pn-contents-dock-width) + var(--pn-contents-panel-gap) + var(--pn-contents-panel-width)
33
+ );
34
+ pointer-events: none;
35
+ animation: pn-contents-rail-enter var(--motion-duration-fast, 120ms)
36
+ var(--ease-enter, cubic-bezier(0.22, 1, 0.36, 1)) both;
37
+ }
38
+
39
+ .pn-contents-rail-dock {
40
+ position: absolute;
41
+ top: 0;
42
+ right: 0;
43
+ bottom: 0;
44
+ width: var(--pn-contents-dock-width);
45
+ pointer-events: none;
46
+ }
47
+
48
+ /* The floating surface (Workspaces' `floating-surface`: popover fill, no drawn
49
+ * border, the edge is the hairline ring in the menu shadow). */
50
+ .pn-contents-rail-surface {
51
+ position: absolute;
52
+ top: -40px;
53
+ bottom: -8px;
54
+ left: 0;
55
+ width: var(--pn-contents-panel-width);
56
+ border-radius: var(--geometry-floating-radius, 10px);
57
+ background: var(--popover);
58
+ color: var(--popover-foreground);
59
+ box-shadow: var(
60
+ --shadow-menu,
61
+ 0 0 0 1px color-mix(in oklch, var(--foreground) 8%, transparent),
62
+ 0 12px 32px -8px color-mix(in oklch, var(--foreground) 22%, transparent)
63
+ );
64
+ transform: translateX(4px) scale(0.98);
65
+ transform-origin: right center;
66
+ opacity: 0;
67
+ pointer-events: none;
68
+ transition:
69
+ opacity var(--motion-duration-basic, 150ms) cubic-bezier(0.23, 1, 0.32, 1),
70
+ transform var(--motion-duration-basic, 150ms) cubic-bezier(0.23, 1, 0.32, 1);
71
+ }
72
+
73
+ /* The section eyebrow (Workspaces' EYEBROW: 12 px, weight 500). */
74
+ .pn-contents-rail-title {
75
+ position: absolute;
76
+ top: -32px;
77
+ left: 8px;
78
+ display: flex;
79
+ width: calc(var(--pn-contents-panel-width) - 16px);
80
+ height: 28px;
81
+ align-items: center;
82
+ padding-inline: 4px;
83
+ color: var(--foreground);
84
+ font-size: 12px;
85
+ font-weight: 500;
86
+ line-height: 20px;
87
+ transform: translateX(4px);
88
+ opacity: 0;
89
+ pointer-events: none;
90
+ transition:
91
+ opacity var(--motion-duration-basic, 150ms) cubic-bezier(0.23, 1, 0.32, 1),
92
+ transform var(--motion-duration-basic, 150ms) cubic-bezier(0.23, 1, 0.32, 1);
93
+ }
94
+
95
+ /* A host's one control at the right of the open panel's title (panelAction). */
96
+ .pn-contents-rail-action {
97
+ position: absolute;
98
+ z-index: 2;
99
+ top: -32px;
100
+ right: calc(var(--pn-contents-dock-width) + var(--pn-contents-panel-gap) + 8px);
101
+ display: flex;
102
+ height: 28px;
103
+ align-items: center;
104
+ pointer-events: auto;
105
+ }
106
+
107
+ .pn-contents-rail[data-revealed] .pn-contents-rail-surface,
108
+ .pn-contents-rail[data-revealed] .pn-contents-rail-title {
109
+ transform: translateX(0) scale(1);
110
+ opacity: 1;
111
+ }
112
+
113
+ .pn-contents-rail[data-revealed] .pn-contents-rail-title {
114
+ transition-delay: var(--motion-duration-basic, 150ms), var(--motion-duration-basic, 150ms);
115
+ }
116
+
117
+ .pn-contents-rail-list {
118
+ position: relative;
119
+ z-index: 1;
120
+ width: 100%;
121
+ /* The closed rail renders at most ten sampled document spans, so it never
122
+ * needs an internal scroll position. Shorter maps retain their natural
123
+ * height and stay centered by the rail's fixed midpoint anchor. */
124
+ max-height: 160px;
125
+ overflow-x: hidden;
126
+ overflow-y: hidden;
127
+ overscroll-behavior: contain;
128
+ scrollbar-width: none;
129
+ touch-action: none;
130
+ pointer-events: none;
131
+ transition: max-height var(--motion-duration-basic, 150ms) var(--ease-exit, cubic-bezier(0.4, 0, 0.2, 1));
132
+ }
133
+
134
+ .pn-contents-rail[data-revealed] .pn-contents-rail-list {
135
+ max-height: min(62vh, 560px);
136
+ overflow-y: auto;
137
+ transition-duration: var(--motion-duration-relaxed, 300ms);
138
+ transition-timing-function: var(--ease-enter, cubic-bezier(0.22, 1, 0.36, 1));
139
+ }
140
+
141
+ .pn-contents-rail-list::-webkit-scrollbar {
142
+ display: none;
143
+ }
144
+
145
+ .pn-contents-rail-marker {
146
+ --pn-contents-line-width: 26px;
147
+
148
+ position: relative;
149
+ display: flex;
150
+ width: var(--pn-contents-dock-width);
151
+ height: 16px;
152
+ flex-direction: row-reverse;
153
+ align-items: center;
154
+ justify-content: flex-start;
155
+ overflow: hidden;
156
+ margin-inline-start: auto;
157
+ padding: 0;
158
+ border: 0;
159
+ background: transparent;
160
+ cursor: pointer;
161
+ pointer-events: auto;
162
+ }
163
+
164
+ .pn-contents-rail[data-revealed] .pn-contents-rail-marker {
165
+ width: 100%;
166
+ overflow: visible;
167
+ }
168
+
169
+ .pn-contents-rail-tick {
170
+ display: flex;
171
+ width: var(--pn-contents-dock-width);
172
+ flex: none;
173
+ align-items: center;
174
+ justify-content: flex-start;
175
+ padding-inline-start: 4px;
176
+ }
177
+
178
+ .pn-contents-rail-line {
179
+ --marker-progress: 0;
180
+
181
+ display: block;
182
+ width: var(--pn-contents-line-width);
183
+ height: 2px;
184
+ transform: scaleX(calc(0.6154 + 0.3846 * var(--marker-progress)));
185
+ transform-origin: left center;
186
+ background: var(--muted-foreground);
187
+ opacity: 0.4;
188
+ pointer-events: none;
189
+ transition: none;
190
+ }
191
+
192
+ /* The frequently scanned closed map updates immediately. The local lens is
193
+ * motion reserved for the explicitly opened Contents state. */
194
+ .pn-contents-rail[data-revealed] .pn-contents-rail-line {
195
+ transition:
196
+ transform var(--motion-duration-basic, 150ms)
197
+ linear(0, 0.398 10%, 0.682 20%, 0.843 30%, 0.925 40%, 0.972 50%, 1.004 60%, 1.008 70%, 1.003 80%, 1),
198
+ opacity var(--motion-duration-basic, 150ms) ease,
199
+ background-color var(--motion-duration-basic, 150ms) ease;
200
+ }
201
+
202
+ /* H1:H2 is 12px:10px with the shorter line inset 2px toward the document, at
203
+ * a deliberately longer scale, then the same step for H3 and H4. */
204
+ .pn-contents-rail-marker[data-level="2"] {
205
+ --pn-contents-line-width: 22px;
206
+ }
207
+
208
+ .pn-contents-rail-marker[data-level="3"] {
209
+ --pn-contents-line-width: 18px;
210
+ }
211
+
212
+ .pn-contents-rail-marker[data-level="4"] {
213
+ --pn-contents-line-width: 14px;
214
+ }
215
+
216
+ .pn-contents-rail-marker[data-current] .pn-contents-rail-line {
217
+ background: var(--primary);
218
+ opacity: 0.68;
219
+ }
220
+
221
+ .pn-contents-rail-marker[data-highlighted] .pn-contents-rail-line,
222
+ .pn-contents-rail-marker:focus-visible .pn-contents-rail-line {
223
+ --marker-progress: 1;
224
+ background: var(--foreground);
225
+ opacity: 1;
226
+ }
227
+
228
+ .pn-contents-rail-marker:has(+ .pn-contents-rail-marker[data-highlighted]) .pn-contents-rail-line,
229
+ .pn-contents-rail-marker:has(+ .pn-contents-rail-marker:focus-visible) .pn-contents-rail-line,
230
+ .pn-contents-rail-marker[data-highlighted] + .pn-contents-rail-marker .pn-contents-rail-line,
231
+ .pn-contents-rail-marker:focus-visible + .pn-contents-rail-marker .pn-contents-rail-line {
232
+ --marker-progress: 0.7;
233
+ opacity: 0.78;
234
+ }
235
+
236
+ .pn-contents-rail-marker:has(+ .pn-contents-rail-marker + .pn-contents-rail-marker[data-highlighted]) .pn-contents-rail-line,
237
+ .pn-contents-rail-marker:has(+ .pn-contents-rail-marker + .pn-contents-rail-marker:focus-visible) .pn-contents-rail-line,
238
+ .pn-contents-rail-marker[data-highlighted] + .pn-contents-rail-marker + .pn-contents-rail-marker .pn-contents-rail-line,
239
+ .pn-contents-rail-marker:focus-visible + .pn-contents-rail-marker + .pn-contents-rail-marker .pn-contents-rail-line {
240
+ --marker-progress: 0.4;
241
+ opacity: 0.58;
242
+ }
243
+
244
+ .pn-contents-rail-marker:has(+ .pn-contents-rail-marker + .pn-contents-rail-marker + .pn-contents-rail-marker[data-highlighted]) .pn-contents-rail-line,
245
+ .pn-contents-rail-marker:has(+ .pn-contents-rail-marker + .pn-contents-rail-marker + .pn-contents-rail-marker:focus-visible) .pn-contents-rail-line,
246
+ .pn-contents-rail-marker[data-highlighted] + .pn-contents-rail-marker + .pn-contents-rail-marker + .pn-contents-rail-marker .pn-contents-rail-line,
247
+ .pn-contents-rail-marker:focus-visible + .pn-contents-rail-marker + .pn-contents-rail-marker + .pn-contents-rail-marker .pn-contents-rail-line {
248
+ --marker-progress: 0.2;
249
+ opacity: 0.46;
250
+ }
251
+
252
+ .pn-contents-rail-label {
253
+ display: block;
254
+ width: calc(var(--pn-contents-panel-width) - 16px);
255
+ height: 16px;
256
+ min-width: 0;
257
+ flex: none;
258
+ overflow: hidden;
259
+ margin-inline-end: calc(var(--pn-contents-panel-gap) + 8px);
260
+ padding-inline: 4px;
261
+ border-radius: var(--geometry-structure-radius, 4px);
262
+ background: transparent;
263
+ color: var(--muted-foreground);
264
+ font-size: inherit;
265
+ font-weight: 400;
266
+ line-height: 16px;
267
+ text-align: left;
268
+ text-overflow: ellipsis;
269
+ white-space: nowrap;
270
+ opacity: 0;
271
+ transform: translateX(-4px);
272
+ pointer-events: none;
273
+ transition:
274
+ opacity var(--motion-duration-basic, 150ms) cubic-bezier(0.23, 1, 0.32, 1),
275
+ transform var(--motion-duration-basic, 150ms) cubic-bezier(0.23, 1, 0.32, 1),
276
+ color var(--motion-duration-fast, 120ms) ease,
277
+ background-color var(--motion-duration-fast, 120ms) ease;
278
+ }
279
+
280
+ .pn-contents-rail-marker[data-depth="1"] .pn-contents-rail-label {
281
+ padding-inline-start: 16px;
282
+ }
283
+
284
+ .pn-contents-rail-marker[data-depth="2"] .pn-contents-rail-label {
285
+ padding-inline-start: 28px;
286
+ }
287
+
288
+ .pn-contents-rail-marker[data-depth="3"] .pn-contents-rail-label {
289
+ padding-inline-start: 40px;
290
+ }
291
+
292
+ .pn-contents-rail[data-revealed] .pn-contents-rail-label {
293
+ opacity: 1;
294
+ transform: translateX(0);
295
+ transition-delay: var(--motion-duration-basic, 150ms), var(--motion-duration-basic, 150ms), 0ms, 0ms;
296
+ }
297
+
298
+ .pn-contents-rail-marker[data-current] .pn-contents-rail-label {
299
+ color: var(--foreground);
300
+ }
301
+
302
+ .pn-contents-rail-marker[data-highlighted] .pn-contents-rail-label,
303
+ .pn-contents-rail-marker:focus-visible .pn-contents-rail-label {
304
+ background: var(--surface-hover, color-mix(in oklch, var(--foreground) 6%, transparent));
305
+ color: var(--foreground);
306
+ }
307
+
308
+ .pn-contents-rail:focus-within .pn-contents-rail-surface,
309
+ .pn-contents-rail:focus-within .pn-contents-rail-title,
310
+ .pn-contents-rail:focus-within .pn-contents-rail-label,
311
+ .pn-contents-rail:focus-within .pn-contents-rail-list {
312
+ transition-duration: 0ms;
313
+ transition-delay: 0ms;
314
+ }
315
+
316
+ @media (prefers-reduced-motion: reduce) {
317
+ .pn-contents-rail {
318
+ animation: none;
319
+ }
320
+ .pn-contents-rail-line {
321
+ transition: none;
322
+ }
323
+ .pn-contents-rail-surface,
324
+ .pn-contents-rail-title,
325
+ .pn-contents-rail-label,
326
+ .pn-contents-rail[data-revealed] .pn-contents-rail-surface,
327
+ .pn-contents-rail[data-revealed] .pn-contents-rail-title,
328
+ .pn-contents-rail[data-revealed] .pn-contents-rail-label {
329
+ transform: none;
330
+ transition: opacity var(--motion-duration-fast, 120ms) ease;
331
+ transition-delay: 0ms;
332
+ }
333
+ .pn-contents-rail-list,
334
+ .pn-contents-rail[data-revealed] .pn-contents-rail-list {
335
+ transition: none;
336
+ }
337
+ }
@@ -1,4 +1,4 @@
1
- import { useState, useCallback } from "react";
1
+ import { useState, useCallback, useRef } from "react";
2
2
  import { parseCodePath } from "@plannotator/core/code-file";
3
3
 
4
4
  interface CodeFileState {
@@ -9,6 +9,7 @@ interface CodeFileState {
9
9
  requestedPath?: string;
10
10
  line?: number;
11
11
  lineEnd?: number;
12
+ revealKey?: number;
12
13
  }
13
14
 
14
15
  interface UseCodeFilePopoutOptions {
@@ -29,6 +30,8 @@ export interface UseCodeFilePopoutReturn {
29
30
  requestedPath?: string;
30
31
  line?: number;
31
32
  lineEnd?: number;
33
+ /** Changes on every open, so reopening the same file:line scrolls to it again. */
34
+ revealKey?: number;
32
35
  } | null;
33
36
  }
34
37
 
@@ -38,6 +41,7 @@ export function useCodeFilePopout(
38
41
  const { buildUrl } = options;
39
42
  const [state, setState] = useState<CodeFileState | null>(null);
40
43
  const [isLoading, setIsLoading] = useState(false);
44
+ const revealCount = useRef(0);
41
45
 
42
46
  const close = useCallback(() => {
43
47
  setState(null);
@@ -75,6 +79,7 @@ export function useCodeFilePopout(
75
79
  prerenderedHTML: data.prerenderedHTML,
76
80
  line: data.line ?? parsed.line,
77
81
  lineEnd: data.lineEnd ?? parsed.lineEnd,
82
+ revealKey: ++revealCount.current,
78
83
  });
79
84
  setIsLoading(false);
80
85
  } catch {
@@ -105,6 +110,7 @@ export function useCodeFilePopout(
105
110
  requestedPath: state.requestedPath,
106
111
  line: state.line,
107
112
  lineEnd: state.lineEnd,
113
+ revealKey: state.revealKey,
108
114
  }
109
115
  : null,
110
116
  };