@plannotator/ui 0.30.0 → 0.31.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 (74) hide show
  1. package/README.md +1 -0
  2. package/components/ActionMenu.tsx +6 -1
  3. package/components/AgentsTab.tsx +8 -9
  4. package/components/AnalysisLayerToggle.tsx +48 -0
  5. package/components/AnnotationPanel.tsx +62 -32
  6. package/components/AnnotationToolbar.tsx +28 -8
  7. package/components/AnnotationToolstrip.tsx +9 -0
  8. package/components/CommentPopover.tsx +212 -46
  9. package/components/ConfirmDialog.tsx +42 -28
  10. package/components/InlineMarkdown.tsx +3 -2
  11. package/components/KeyboardShortcuts.tsx +9 -0
  12. package/components/Landing.tsx +1 -1
  13. package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
  14. package/components/MarkdownEditor/embedPicker.ts +349 -0
  15. package/components/MarkdownEditor.tsx +12 -0
  16. package/components/ModeToggle.tsx +2 -1
  17. package/components/PermissionModeSetup.tsx +24 -5
  18. package/components/PinpointOverlay.tsx +11 -6
  19. package/components/PlanHeaderMenu.tsx +140 -1
  20. package/components/SearchableSelect.tsx +2 -0
  21. package/components/Settings.tsx +136 -10
  22. package/components/SkillReferenceMenu.tsx +9 -0
  23. package/components/StickyHeaderLane.tsx +9 -2
  24. package/components/TableOfContents.tsx +9 -4
  25. package/components/TextShimmer.tsx +8 -5
  26. package/components/ThemeProvider.tsx +43 -1
  27. package/components/ThemeTab.tsx +52 -1
  28. package/components/Tooltip.tsx +3 -1
  29. package/components/Viewer.tsx +19 -5
  30. package/components/VimTargetReticle.tsx +12 -4
  31. package/components/ai/DocumentAIChatPanel.tsx +1 -0
  32. package/components/core/button.tsx +14 -6
  33. package/components/html-viewer/HtmlViewer.tsx +208 -39
  34. package/components/html-viewer/bridge-script.ts +319 -65
  35. package/components/html-viewer/composerYield.ts +1 -51
  36. package/components/html-viewer/useHtmlAnnotation.ts +146 -57
  37. package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
  38. package/components/sidebar/FileBrowser.tsx +17 -5
  39. package/components/sidebar/SidebarContainer.tsx +124 -28
  40. package/components/ui/button.tsx +10 -8
  41. package/components/ui/dialog.tsx +35 -25
  42. package/config/index.ts +6 -1
  43. package/config/reviewView.ts +42 -9
  44. package/config/settings.ts +141 -0
  45. package/hooks/useAIProviderConfig.ts +8 -7
  46. package/hooks/useActiveSection.ts +6 -4
  47. package/hooks/useAgentJobs.ts +3 -0
  48. package/hooks/useAnnotationHighlighter.ts +20 -0
  49. package/hooks/useIsMobile.ts +37 -0
  50. package/hooks/useLinkedDoc.ts +7 -0
  51. package/hooks/useScrollViewport.ts +74 -0
  52. package/hooks/useViewportEnvironment.ts +350 -0
  53. package/package.json +3 -2
  54. package/shortcuts/index.ts +3 -0
  55. package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
  56. package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
  57. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
  58. package/styles.css +1 -1
  59. package/theme.css +229 -0
  60. package/types.ts +34 -0
  61. package/utils/annotateAgentTerminal.ts +36 -5
  62. package/utils/blockTargeting.ts +6 -3
  63. package/utils/composerYield.ts +45 -0
  64. package/utils/htmlChrome.ts +20 -16
  65. package/utils/lookAndFeelAnnouncement.ts +12 -8
  66. package/utils/markdownExtensions.ts +57 -0
  67. package/utils/parser.ts +37 -2
  68. package/utils/vimNavigation.ts +4 -1
  69. package/utils/vimScroll.ts +9 -4
  70. package/utils/wideMode.ts +20 -0
  71. package/components/PlanAIAnnouncementDialog.tsx +0 -187
  72. package/components/VimModeAnnouncementDialog.tsx +0 -557
  73. package/utils/planAIAnnouncement.ts +0 -17
  74. package/utils/vimModeAnnouncement.ts +0 -23
@@ -1,5 +1,6 @@
1
1
  import React, { useState, useEffect, useMemo, useRef } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
+ import type { AnnotateAgentTerminalSide } from '@plannotator/core/agent-terminal';
3
4
  import type { Origin } from '@plannotator/core/agents';
4
5
  import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
5
6
  import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType } from '../config';
@@ -73,8 +74,9 @@ import {
73
74
  type FileBrowserSettings,
74
75
  } from '../utils/fileBrowser';
75
76
  import { requestVimDocumentFocus } from '../hooks/useVimDocumentFocus';
77
+ import { AnalysisLayerToggle } from './AnalysisLayerToggle';
76
78
 
77
- type SettingsTab = 'general' | 'theme' | 'git' | 'display' | 'saving' | 'labels' | 'vim' | 'shortcuts' | 'ai' | 'files' | 'obsidian' | 'bear' | 'octarine' | 'comments' | 'hooks';
79
+ type SettingsTab = 'general' | 'theme' | 'git' | 'display' | 'analysis' | 'saving' | 'labels' | 'vim' | 'shortcuts' | 'ai' | 'files' | 'obsidian' | 'bear' | 'octarine' | 'comments' | 'hooks';
78
80
 
79
81
  interface SettingsProps {
80
82
  taterMode: boolean;
@@ -94,8 +96,17 @@ interface SettingsProps {
94
96
  * (base ref unresolvable) — the Git tab shows a note that the Git-status
95
97
  * preference can't take effect in THIS repo. */
96
98
  sinceBaseUnavailable?: boolean;
99
+ /** The host is rendering its compact touch shell (review only). Display
100
+ * settings that the compact shell overrides for the session are hidden
101
+ * there instead of silently editing the desktop preference. */
102
+ isCompactTouchLayout?: boolean;
97
103
  /** Override Obsidian vault detection (default = GET /api/obsidian/vaults). */
98
104
  onDetectObsidianVaults?: () => Promise<string[]>;
105
+ /** This annotate session actually offers the Agent TUI, so its Position
106
+ * setting is worth showing. Default false: the setting is the only way back
107
+ * from a Hidden position, but offering it where no terminal can ever run
108
+ * would just be a dead control. */
109
+ agentTerminalAvailable?: boolean;
99
110
  }
100
111
 
101
112
  // --- Review-mode Display tab (diff display options) ---
@@ -148,6 +159,12 @@ const DEFAULT_DIFF_TYPE_OPTIONS = [
148
159
  { value: 'all' as const, label: 'All Files (HEAD)', description: "Every tracked file at HEAD, shown as additions" },
149
160
  ];
150
161
 
162
+ const AGENT_TERMINAL_SIDE_OPTIONS: { value: AnnotateAgentTerminalSide; label: string }[] = [
163
+ { value: 'left', label: 'Left' },
164
+ { value: 'right', label: 'Right' },
165
+ { value: 'hidden', label: 'Hidden' },
166
+ ];
167
+
151
168
  function SegmentedControl<T extends string>({ options, value, onChange }: {
152
169
  options: { value: T; label: string }[];
153
170
  value: T;
@@ -192,12 +209,17 @@ function ToggleSwitch({ checked, onChange, label, description, disabled = false
192
209
  aria-checked={checked}
193
210
  disabled={disabled}
194
211
  onClick={() => onChange(!checked)}
195
- className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:cursor-not-allowed ${
196
- checked ? 'bg-primary' : 'bg-muted'
197
- }`}
212
+ className="relative inline-flex h-11 w-11 shrink-0 items-center disabled:cursor-not-allowed"
198
213
  >
199
214
  <span
200
- className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
215
+ aria-hidden="true"
216
+ className={`absolute inset-x-0 top-2.5 h-6 rounded-full transition-colors ${
217
+ checked ? 'bg-primary' : 'bg-muted'
218
+ }`}
219
+ />
220
+ <span
221
+ aria-hidden="true"
222
+ className={`absolute top-3.5 inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
201
223
  checked ? 'translate-x-6' : 'translate-x-1'
202
224
  }`}
203
225
  />
@@ -288,6 +310,63 @@ function VimSettingsTab({
288
310
  );
289
311
  }
290
312
 
313
+ function ReviewAnalysisTab() {
314
+ const semanticDiffEnabled = useConfigValue('semanticDiffEnabled');
315
+ const callFlowEnabled = useConfigValue('callFlowEnabled');
316
+ return (
317
+ <div className="space-y-5">
318
+ <div>
319
+ <div className="text-sm font-semibold">Analysis layers</div>
320
+ <p className="mt-1 text-xs leading-relaxed text-muted-foreground">
321
+ Add structural context to the ordinary code diff. Each layer is independent and does no work while disabled.
322
+ </p>
323
+ </div>
324
+
325
+ <div className="space-y-4 rounded-xl border border-border bg-muted/20 p-4">
326
+ <AnalysisLayerToggle
327
+ checked={semanticDiffEnabled}
328
+ onChange={(enabled) => configStore.set('semanticDiffEnabled', enabled)}
329
+ label="Semantic changes"
330
+ description="Organize added, changed, moved, and removed functions, classes, and other named code. Enabled by default."
331
+ />
332
+ <div className="border-t border-border" />
333
+ <AnalysisLayerToggle
334
+ checked={callFlowEnabled}
335
+ onChange={(enabled) => configStore.set('callFlowEnabled', enabled)}
336
+ label="Call flow"
337
+ description="Diffs for function call stacks across git commits. 22 languages supported (AST-based, built using Tree-sitter)."
338
+ />
339
+ </div>
340
+
341
+ <div className="grid gap-3 sm:grid-cols-2">
342
+ <div className="rounded-xl border border-border p-3.5">
343
+ <div className="flex items-center gap-2 text-xs font-semibold">
344
+ <span className="grid h-6 w-6 place-items-center rounded-md bg-primary/10 font-mono text-primary">▣</span>
345
+ Dock
346
+ </div>
347
+ <p className="mt-2 text-xs leading-relaxed text-muted-foreground">
348
+ Open the complete analysis from the file panel. Rows jump to the relevant code location.
349
+ </p>
350
+ </div>
351
+ <div className="rounded-xl border border-border p-3.5">
352
+ <div className="flex items-center gap-2 text-xs font-semibold">
353
+ <span className="grid h-6 w-6 place-items-center rounded-md bg-primary/10 font-mono text-primary">⌁</span>
354
+ File lens
355
+ </div>
356
+ <p className="mt-2 text-xs leading-relaxed text-muted-foreground">
357
+ File headers show compact counts and a focused popover for that file.
358
+ </p>
359
+ </div>
360
+ </div>
361
+
362
+ <div className="rounded-xl border border-warning/25 bg-warning/[0.05] p-4 text-xs leading-relaxed text-muted-foreground">
363
+ <span className="font-semibold text-foreground">Call flow is syntactic.</span>{' '}
364
+ It does not use type resolution or runtime traces, so treat it as navigation context—not proof that a path executes.
365
+ </div>
366
+ </div>
367
+ );
368
+ }
369
+
291
370
  const GitTab: React.FC<{ sinceBaseUnavailable?: boolean }> = ({ sinceBaseUnavailable }) => {
292
371
  const defaultDiffType = useConfigValue('defaultDiffType');
293
372
  const reviewPanelView = useConfigValue('reviewPanelView');
@@ -358,7 +437,7 @@ const GitTab: React.FC<{ sinceBaseUnavailable?: boolean }> = ({ sinceBaseUnavail
358
437
  );
359
438
  };
360
439
 
361
- const ReviewDisplayTab: React.FC = () => {
440
+ const ReviewDisplayTab: React.FC<{ isCompactTouchLayout?: boolean }> = ({ isCompactTouchLayout = false }) => {
362
441
  const diffStyle = useConfigValue('diffStyle');
363
442
  const diffOverflow = useConfigValue('diffOverflow');
364
443
  const diffIndicators = useConfigValue('diffIndicators');
@@ -456,13 +535,23 @@ const ReviewDisplayTab: React.FC = () => {
456
535
 
457
536
  <div className="border-t border-border" />
458
537
 
459
- {/* Diff Style */}
538
+ {/* Diff Style. The compact touch shell renders a session-only unified
539
+ diff, so the control there would look dead while quietly rewriting
540
+ the DESKTOP preference. Say what the phone is doing instead. */}
460
541
  <div className="space-y-2">
461
542
  <div>
462
543
  <div className="text-sm font-medium">Diff Style</div>
463
544
  <div className="text-xs text-muted-foreground">Side-by-side or inline diff view</div>
545
+ {isCompactTouchLayout && (
546
+ <div className="text-xs text-muted-foreground mt-1">
547
+ This layout shows unified diffs for the session; your desktop
548
+ preference is unchanged.
549
+ </div>
550
+ )}
464
551
  </div>
465
- <SegmentedControl options={DIFF_STYLE_OPTIONS} value={diffStyle} onChange={(v) => configStore.set('diffStyle', v)} />
552
+ {!isCompactTouchLayout && (
553
+ <SegmentedControl options={DIFF_STYLE_OPTIONS} value={diffStyle} onChange={(v) => configStore.set('diffStyle', v)} />
554
+ )}
466
555
  </div>
467
556
 
468
557
  <div className="border-t border-border" />
@@ -759,7 +848,7 @@ const CommentsTab: React.FC = () => {
759
848
  );
760
849
  };
761
850
 
762
- export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, sinceBaseUnavailable, onDetectObsidianVaults }) => {
851
+ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, sinceBaseUnavailable, isCompactTouchLayout = false, onDetectObsidianVaults, agentTerminalAvailable = false }) => {
763
852
  const [showDialog, setShowDialog] = useState(false);
764
853
  const settingsWasOpenRef = useRef(false);
765
854
  const [themePreview, setThemePreview] = useState(false);
@@ -791,6 +880,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
791
880
  const vimModeEnabled = useConfigValue('vimModeEnabled');
792
881
  const vimHudEnabled = useConfigValue('vimHudEnabled');
793
882
  const vimHudKeyPanelEnabled = useConfigValue('vimHudKeyPanelEnabled');
883
+ const agentTerminalSide = useConfigValue('agentTerminalSide');
794
884
  const [identity, setIdentity] = useState('');
795
885
  const [obsidian, setObsidian] = useState<ObsidianSettings>({
796
886
  enabled: false,
@@ -835,6 +925,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
835
925
  if (mode === 'review') {
836
926
  t.push({ id: 'git', label: 'Git' });
837
927
  t.push({ id: 'display', label: 'Editor' });
928
+ t.push({ id: 'analysis', label: 'Analysis' });
838
929
  t.push({ id: 'comments', label: 'Comments' });
839
930
  if (aiProviders.length > 0) {
840
931
  t.push({ id: 'ai', label: 'AI' });
@@ -1306,6 +1397,37 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1306
1397
  </>
1307
1398
  )}
1308
1399
 
1400
+ {/* Agent TUI position. The same control lives in the
1401
+ terminal's own Display popover, but that one is
1402
+ unreachable once the position is Hidden — this is the
1403
+ way back. Both write the same config value. Gated on the
1404
+ terminal actually being available in this session, so a
1405
+ remote or runtime-less annotate never offers a dead
1406
+ control. */}
1407
+ {mode === 'annotate' && agentTerminalAvailable && (
1408
+ <>
1409
+ <div className="border-t border-border" />
1410
+ <div className="space-y-2">
1411
+ <div>
1412
+ <div className="text-sm font-medium">Agent TUI Position</div>
1413
+ <div className="text-xs text-muted-foreground">
1414
+ Which side the agent terminal docks on, or Hidden to keep it out of the layout
1415
+ </div>
1416
+ </div>
1417
+ <SegmentedControl
1418
+ options={AGENT_TERMINAL_SIDE_OPTIONS}
1419
+ value={agentTerminalSide}
1420
+ onChange={(v) => configStore.set('agentTerminalSide', v)}
1421
+ />
1422
+ {agentTerminalSide === 'hidden' && (
1423
+ <div className="text-[10px] text-muted-foreground/70">
1424
+ You can still open it for this session from the sidebar rail.
1425
+ </div>
1426
+ )}
1427
+ </div>
1428
+ </>
1429
+ )}
1430
+
1309
1431
  <div className="border-t border-border" />
1310
1432
 
1311
1433
  {/* Auto-close Tab */}
@@ -1343,7 +1465,11 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1343
1465
 
1344
1466
  {/* === DISPLAY TAB === */}
1345
1467
  {activeTab === 'display' && mode === 'review' && (
1346
- <ReviewDisplayTab />
1468
+ <ReviewDisplayTab isCompactTouchLayout={isCompactTouchLayout} />
1469
+ )}
1470
+
1471
+ {activeTab === 'analysis' && mode === 'review' && (
1472
+ <ReviewAnalysisTab />
1347
1473
  )}
1348
1474
 
1349
1475
  {activeTab === 'display' && mode !== 'review' && (
@@ -9,6 +9,8 @@ const ROOT_LABELS: Record<SkillCatalogEntry['root'], string> = {
9
9
  };
10
10
 
11
11
  interface SkillReferenceMenuProps {
12
+ /** Id referenced by the focused textarea's aria-controls attribute. */
13
+ id: string;
12
14
  items: SkillCatalogEntry[];
13
15
  /** Explicitly activated row, or null — the menu opens with nothing active. */
14
16
  activeIndex: number | null;
@@ -79,6 +81,7 @@ function computeMenuPlacement(
79
81
  * a purely visual affordance; a pointer click inserts directly.
80
82
  */
81
83
  export const SkillReferenceMenu: React.FC<SkillReferenceMenuProps> = ({
84
+ id,
82
85
  items,
83
86
  activeIndex,
84
87
  onSelect,
@@ -140,6 +143,8 @@ export const SkillReferenceMenu: React.FC<SkillReferenceMenuProps> = ({
140
143
  return (
141
144
  <div
142
145
  ref={menuRef}
146
+ id={id}
147
+ role="listbox"
143
148
  data-skill-menu="true"
144
149
  data-skill-menu-placement={placement.direction}
145
150
  data-popover-layer="true"
@@ -157,6 +162,10 @@ export const SkillReferenceMenu: React.FC<SkillReferenceMenuProps> = ({
157
162
  <button
158
163
  key={item.name}
159
164
  type="button"
165
+ id={`${id}-option-${index}`}
166
+ role="option"
167
+ aria-selected={index === activeIndex}
168
+ tabIndex={-1}
160
169
  data-skill-item={item.name}
161
170
  data-skill-item-active={index === activeIndex ? 'true' : undefined}
162
171
  // Insert on pointerdown so the textarea never loses focus. A
@@ -27,7 +27,10 @@
27
27
  import React, { useEffect, useRef, useState } from 'react';
28
28
  import { AnnotationToolstrip } from './AnnotationToolstrip';
29
29
  import { DocBadges } from './DocBadges';
30
- import { useScrollViewport } from '../hooks/useScrollViewport';
30
+ import {
31
+ getScrollViewportIntersectionRoot,
32
+ useScrollViewport,
33
+ } from '../hooks/useScrollViewport';
31
34
  import type { EditorMode, InputMethod } from '../types';
32
35
  import type { PlanDiffStats } from '../utils/planDiffEngine';
33
36
 
@@ -172,7 +175,11 @@ export const StickyHeaderLane: React.FC<StickyHeaderLaneProps> = ({
172
175
  if (!sentinelRef.current || !scrollViewport) return;
173
176
  const observer = new IntersectionObserver(
174
177
  ([entry]) => setIsStuck(!entry.isIntersecting),
175
- { root: scrollViewport, rootMargin: '80px 0px 0px 0px', threshold: 0 }
178
+ {
179
+ root: getScrollViewportIntersectionRoot(scrollViewport),
180
+ rootMargin: '80px 0px 0px 0px',
181
+ threshold: 0,
182
+ }
176
183
  );
177
184
  observer.observe(sentinelRef.current);
178
185
  return () => observer.disconnect();
@@ -5,7 +5,12 @@ import {
5
5
  getAnnotationCountBySection,
6
6
  type TocItem,
7
7
  } from '../utils/annotationHelpers';
8
- import { useScrollViewport } from '../hooks/useScrollViewport';
8
+ import {
9
+ getScrollViewportRect,
10
+ getScrollViewportTop,
11
+ scrollViewportTo,
12
+ useScrollViewport,
13
+ } from '../hooks/useScrollViewport';
9
14
 
10
15
  interface TableOfContentsProps {
11
16
  blocks: Block[];
@@ -82,11 +87,11 @@ export function TableOfContents({
82
87
  if (target && scrollViewport) {
83
88
  const scrollContainer = scrollViewport;
84
89
  const headerOffset = 80; // sticky header (h-12) + breathing room
85
- const containerRect = scrollContainer.getBoundingClientRect();
90
+ const containerRect = getScrollViewportRect(scrollContainer);
86
91
  const targetRect = target.getBoundingClientRect();
87
92
  const offsetPosition =
88
- scrollContainer.scrollTop + (targetRect.top - containerRect.top) - headerOffset;
89
- scrollContainer.scrollTo({ top: offsetPosition, behavior: 'smooth' });
93
+ getScrollViewportTop(scrollContainer) + (targetRect.top - containerRect.top) - headerOffset;
94
+ scrollViewportTo(scrollContainer, { top: offsetPosition, behavior: 'smooth' });
90
95
  }
91
96
  },
92
97
  [onNavigate, scrollViewport]
@@ -1,5 +1,5 @@
1
1
  import React, { useMemo, type JSX } from 'react';
2
- import { motion } from 'motion/react';
2
+ import { motion, useReducedMotion } from 'motion/react';
3
3
 
4
4
  export interface TextShimmerProps {
5
5
  children: string;
@@ -16,6 +16,7 @@ function TextShimmerComponent({
16
16
  duration = 2,
17
17
  spread = 2,
18
18
  }: TextShimmerProps) {
19
+ const shouldReduceMotion = useReducedMotion();
19
20
  const MotionComponent = motion.create(
20
21
  Component as keyof JSX.IntrinsicElements
21
22
  );
@@ -35,9 +36,9 @@ function TextShimmerComponent({
35
36
  ]
36
37
  .filter(Boolean)
37
38
  .join(' ')}
38
- initial={{ backgroundPosition: '100% center' }}
39
- animate={{ backgroundPosition: '0% center' }}
40
- transition={{
39
+ initial={shouldReduceMotion ? false : { backgroundPosition: '100% center' }}
40
+ animate={shouldReduceMotion ? undefined : { backgroundPosition: '0% center' }}
41
+ transition={shouldReduceMotion ? undefined : {
41
42
  repeat: Infinity,
42
43
  duration,
43
44
  ease: 'linear',
@@ -45,7 +46,9 @@ function TextShimmerComponent({
45
46
  style={
46
47
  {
47
48
  '--spread': `${dynamicSpread}px`,
48
- backgroundImage: `var(--bg), linear-gradient(var(--base-color), var(--base-color))`,
49
+ backgroundImage: shouldReduceMotion
50
+ ? 'linear-gradient(var(--base-color), var(--base-color))'
51
+ : 'var(--bg), linear-gradient(var(--base-color), var(--base-color))',
49
52
  } as React.CSSProperties
50
53
  }
51
54
  >
@@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
2
2
  import { configStore } from '../config/configStore';
3
3
  import { readThemePairCookies, writeThemePairCookies } from '../config/settings';
4
4
  import { useConfigValue } from '../config/useConfig';
5
+ import { faviconDataUrl } from '@plannotator/core/favicon';
5
6
  import { storage } from '../utils/storage';
6
7
  import {
7
8
  BUILT_IN_THEMES,
@@ -36,6 +37,12 @@ type ThemeProviderState = {
36
37
  darkTheme: string;
37
38
  setHalfTheme: (half: ThemeHalf, theme: string) => void;
38
39
  availableThemes: ThemeInfo[];
40
+ /**
41
+ * Whether this provider owns the document's favicon. Published so the Settings
42
+ * UI can hide the favicon control on hosts that did not opt in: a knob that
43
+ * changes nothing is worse than no knob.
44
+ */
45
+ manageFavicon: boolean;
39
46
  };
40
47
 
41
48
  const ThemeProviderContext = createContext<ThemeProviderState>({
@@ -51,6 +58,7 @@ const ThemeProviderContext = createContext<ThemeProviderState>({
51
58
  darkTheme: 'plannotator',
52
59
  setHalfTheme: () => null,
53
60
  availableThemes: BUILT_IN_THEMES,
61
+ manageFavicon: false,
54
62
  });
55
63
 
56
64
  /** Sync theme classes on <html> without stripping non-theme classes (e.g. transitions-ready). */
@@ -94,6 +102,18 @@ interface ThemeProviderProps {
94
102
  * `plannotator-dark-theme`; these props rename only the two legacy values.
95
103
  */
96
104
  colorThemeStorageKey?: string;
105
+ /**
106
+ * Opt in to letting this provider own `<link rel="icon">` on the document.
107
+ *
108
+ * OFF by default, and deliberately so: `@plannotator/ui` is installed into
109
+ * host applications with their own branding, and a mounted provider must not
110
+ * silently replace a host page's favicon with Plannotator's. Plannotator's own
111
+ * apps pass `manageFavicon`; hosts opt in only if they want the same feature.
112
+ *
113
+ * The value also gates the Settings favicon control (see ThemeTab), so a host
114
+ * that leaves it off never renders a switch that would do nothing.
115
+ */
116
+ manageFavicon?: boolean;
97
117
  }
98
118
 
99
119
  export function ThemeProvider({
@@ -102,6 +122,7 @@ export function ThemeProvider({
102
122
  defaultColorTheme = 'plannotator',
103
123
  storageKey = 'plannotator-theme',
104
124
  colorThemeStorageKey = 'plannotator-color-theme',
125
+ manageFavicon = false,
105
126
  }: ThemeProviderProps) {
106
127
  const legacyKeys = useMemo(
107
128
  () => ({ mode: storageKey, colorTheme: colorThemeStorageKey }),
@@ -124,6 +145,26 @@ export function ThemeProvider({
124
145
  const storePair = useConfigValue('themePair');
125
146
  const pair = pendingSeed.current ?? storePair;
126
147
  const mode = pair.mode;
148
+ const faviconStyle = useConfigValue('faviconStyle');
149
+
150
+ useEffect(() => {
151
+ if (!manageFavicon) return;
152
+ if (typeof document === 'undefined') return;
153
+ let link = document.head.querySelector<HTMLLinkElement>('link[rel="icon"]');
154
+ if (!link) {
155
+ link = document.createElement('link');
156
+ link.rel = 'icon';
157
+ document.head.appendChild(link);
158
+ }
159
+ if (faviconStyle === 'classic') {
160
+ link.type = 'image/svg+xml';
161
+ link.removeAttribute('sizes');
162
+ } else {
163
+ link.type = 'image/png';
164
+ link.setAttribute('sizes', '64x64');
165
+ }
166
+ link.href = faviconDataUrl(faviconStyle);
167
+ }, [faviconStyle, manageFavicon]);
127
168
 
128
169
  // Hand the resolved pair to the store as a SEED, not a user choice: seeding
129
170
  // writes memory + cookies only. Routing it through set() would queue a
@@ -243,7 +284,8 @@ export function ThemeProvider({
243
284
  darkTheme: pair.dark,
244
285
  setHalfTheme,
245
286
  availableThemes: BUILT_IN_THEMES,
246
- }), [mode, preferredMode, resolvedMode, colorTheme, pair.light, pair.dark, setMode, setColorTheme, setHalfTheme]);
287
+ manageFavicon,
288
+ }), [mode, preferredMode, resolvedMode, colorTheme, pair.light, pair.dark, setMode, setColorTheme, setHalfTheme, manageFavicon]);
247
289
 
248
290
  return (
249
291
  <ThemeProviderContext.Provider value={value}>
@@ -2,6 +2,9 @@ import React, { useEffect, useState } from 'react';
2
2
  import { useTheme } from './ThemeProvider';
3
3
  import { THEME_MODES } from './themeModes';
4
4
  import { themesForHalf, type ThemeHalf } from '../utils/themeRegistry';
5
+ import { configStore } from '../config/configStore';
6
+ import { useConfigValue } from '../config/useConfig';
7
+ import { faviconDataUrl, type FaviconStyle } from '@plannotator/core/favicon';
5
8
 
6
9
  interface ThemeTabProps {
7
10
  onPreview?: () => void;
@@ -13,12 +16,46 @@ const HALVES: { id: ThemeHalf; label: string }[] = [
13
16
  { id: 'dark', label: 'Dark' },
14
17
  ];
15
18
 
19
+ const FAVICON_STYLES: { id: FaviconStyle; label: string }[] = [
20
+ { id: 'totman', label: 'Totman' },
21
+ { id: 'classic', label: 'Classic P' },
22
+ ];
23
+
16
24
  const SyntaxLinesIcon: React.FC<{ className?: string }> = ({ className }) => (
17
25
  <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
18
26
  <path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h7" />
19
27
  </svg>
20
28
  );
21
29
 
30
+ /**
31
+ * The favicon style choices. Rendered in both layouts, so it lives in one place.
32
+ * The compact layout drops the visible "Favicon" heading the full layout has, so
33
+ * the group carries its own accessible name and two unlabelled image buttons are
34
+ * never all a screen reader gets.
35
+ */
36
+ const FaviconStyleControl: React.FC<{ selected: FaviconStyle }> = ({ selected }) => (
37
+ <div className="flex gap-1" role="group" aria-label="Favicon style">
38
+ {FAVICON_STYLES.map(({ id, label }) => (
39
+ <button
40
+ key={id}
41
+ type="button"
42
+ aria-pressed={selected === id}
43
+ onClick={() => configStore.set('faviconStyle', id)}
44
+ className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors ${
45
+ selected === id
46
+ ? 'bg-primary text-primary-foreground'
47
+ : 'bg-muted text-muted-foreground hover:text-foreground'
48
+ }`}
49
+ >
50
+ <span className="flex items-center gap-1.5">
51
+ <img src={faviconDataUrl(id)} alt="" className="w-5 h-5 rounded-sm shrink-0" />
52
+ {label}
53
+ </span>
54
+ </button>
55
+ ))}
56
+ </div>
57
+ );
58
+
22
59
  export const ThemeTab: React.FC<ThemeTabProps> = ({ onPreview, compact }) => {
23
60
  const {
24
61
  mode,
@@ -28,7 +65,9 @@ export const ThemeTab: React.FC<ThemeTabProps> = ({ onPreview, compact }) => {
28
65
  setHalfTheme,
29
66
  availableThemes,
30
67
  preferredMode,
68
+ manageFavicon,
31
69
  } = useTheme();
70
+ const faviconStyle = useConfigValue('faviconStyle');
32
71
 
33
72
  // Which half the grid assigns to. Follows the mode you are actually seeing,
34
73
  // so opening Settings in dark mode edits the dark half first.
@@ -68,6 +107,7 @@ export const ThemeTab: React.FC<ThemeTabProps> = ({ onPreview, compact }) => {
68
107
  {THEME_MODES.map(({ id, label, Icon }) => (
69
108
  <button
70
109
  key={id}
110
+ type="button"
71
111
  onClick={() => setMode(id)}
72
112
  className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors ${
73
113
  mode === id
@@ -82,12 +122,23 @@ export const ThemeTab: React.FC<ThemeTabProps> = ({ onPreview, compact }) => {
82
122
  </button>
83
123
  ))}
84
124
  </div>
125
+ {!compact && manageFavicon && (
126
+ <div className="space-y-1.5 pt-1">
127
+ <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Favicon</label>
128
+ <FaviconStyleControl selected={faviconStyle} />
129
+ </div>
130
+ )}
85
131
  {!compact && (
86
132
  <p className="text-[11px] text-muted-foreground/70">
87
133
  System follows your OS and switches between the two themes below.
88
134
  </p>
89
135
  )}
90
- {compact && <div className="ml-auto">{summary}</div>}
136
+ {compact && (
137
+ <>
138
+ {manageFavicon && <FaviconStyleControl selected={faviconStyle} />}
139
+ <div className="ml-auto">{summary}</div>
140
+ </>
141
+ )}
91
142
  </div>
92
143
 
93
144
  {/* Theme pair */}
@@ -63,7 +63,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
63
63
  <BaseTooltip.Positioner side={side} align={align} sideOffset={sideOffset} className="isolate z-50">
64
64
  <BaseTooltip.Popup
65
65
  className={`z-50 px-2 py-1 text-xs bg-popover text-popover-foreground border border-border rounded shadow-md origin-[var(--transform-origin)] transition-[opacity,scale] duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 ${
66
- wide ? 'max-w-[260px] leading-snug whitespace-normal' : 'whitespace-nowrap'
66
+ wide
67
+ ? 'max-w-[min(260px,calc(100vw-1rem))] leading-snug whitespace-normal [overflow-wrap:anywhere]'
68
+ : 'whitespace-nowrap'
67
69
  }`}
68
70
  >
69
71
  {content}
@@ -49,7 +49,13 @@ import { PinpointOverlay } from './PinpointOverlay';
49
49
  import { usePinpoint } from '../hooks/usePinpoint';
50
50
  import { useAnnotationHighlighter } from '../hooks/useAnnotationHighlighter';
51
51
  import { useVimSelection } from '../hooks/useVimSelection';
52
- import { useScrollViewport } from '../hooks/useScrollViewport';
52
+ import {
53
+ getScrollViewportIntersectionRoot,
54
+ getScrollViewportRect,
55
+ getScrollViewportTop,
56
+ scrollViewportTo,
57
+ useScrollViewport,
58
+ } from '../hooks/useScrollViewport';
53
59
  import { decodeAnchorHash } from '../utils/anchors';
54
60
  import { VimModeOverlay } from './VimModeOverlay';
55
61
 
@@ -602,7 +608,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
602
608
  if (!stickyActions || !stickySentinelRef.current || !scrollViewport) return;
603
609
  const observer = new IntersectionObserver(
604
610
  ([entry]) => setIsStuck(!entry.isIntersecting),
605
- { root: scrollViewport, threshold: 0 }
611
+ { root: getScrollViewportIntersectionRoot(scrollViewport), threshold: 0 }
606
612
  );
607
613
  observer.observe(stickySentinelRef.current);
608
614
  return () => observer.disconnect();
@@ -635,12 +641,12 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
635
641
  const headerOffset = stickyActionsEl
636
642
  ? stickyActionsEl.getBoundingClientRect().height + stickyTop
637
643
  : 0;
638
- const containerRect = scrollViewport.getBoundingClientRect();
644
+ const containerRect = getScrollViewportRect(scrollViewport);
639
645
  const targetRect = target.getBoundingClientRect();
640
646
  const relativeTop = targetRect.top - containerRect.top;
641
- const offsetPosition = scrollViewport.scrollTop + relativeTop - headerOffset;
647
+ const offsetPosition = getScrollViewportTop(scrollViewport) + relativeTop - headerOffset;
642
648
 
643
- scrollViewport.scrollTo({
649
+ scrollViewportTo(scrollViewport, {
644
650
  top: Math.max(0, offsetPosition),
645
651
  behavior: 'smooth',
646
652
  });
@@ -778,6 +784,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
778
784
  setViewerCommentPopover(null);
779
785
  }, []);
780
786
 
787
+ const commentDraftScope = linkedDocInfo?.filepath ?? sourceInfo ?? markdown.slice(0, 120);
788
+
781
789
  const codePathValidation = useValidatedCodePaths(markdown, codePathBaseDir, disableCodePathValidation);
782
790
 
783
791
  return (
@@ -1154,6 +1162,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1154
1162
  contextText={hookCommentPopover.contextText}
1155
1163
  isGlobal={false}
1156
1164
  initialText={hookCommentPopover.initialText}
1165
+ draftKey={`plan:${commentDraftScope}:${hookCommentPopover.draftKey}`}
1157
1166
  onSubmit={hookCommentSubmit}
1158
1167
  onClose={hookCommentClose}
1159
1168
  allowImages={allowImages}
@@ -1173,6 +1182,11 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1173
1182
  contextText={viewerCommentPopover.contextText}
1174
1183
  isGlobal={viewerCommentPopover.isGlobal}
1175
1184
  initialText={viewerCommentPopover.initialText}
1185
+ draftKey={`plan:${commentDraftScope}:${
1186
+ viewerCommentPopover.isGlobal
1187
+ ? 'global'
1188
+ : `code-block:${viewerCommentPopover.codeBlock?.block.id ?? viewerCommentPopover.contextText}`
1189
+ }`}
1176
1190
  onSubmit={handleViewerCommentSubmit}
1177
1191
  onClose={handleViewerCommentClose}
1178
1192
  allowImages={allowImages}