@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
@@ -1,13 +1,27 @@
1
1
  import type { UpdateCheckSetting } from '../hooks/useUpdateCheckSetting';
2
+ import { cookieHostDescription, type CookieHostSetting } from '../hooks/useCookieHostSetting';
3
+ import type { PackageChangesSetting } from '../hooks/usePackageChangesSetting';
2
4
  import React, { useState, useEffect, useMemo, useRef } from 'react';
3
5
  import { createPortal } from 'react-dom';
4
6
  import type { AnnotateAgentTerminalSide } from '@plannotator/core/agent-terminal';
5
7
  import type { Origin } from '@plannotator/core/agents';
6
8
  import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
7
9
  import type { TokenHoverDelay } from '@plannotator/core/token-hover';
8
- import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType, setReviewAutoViewed } from '../config';
10
+ import {
11
+ configStore,
12
+ useConfigValue,
13
+ setReviewPanelView,
14
+ setReviewDefaultDiffType,
15
+ getProviderReviewDefaultDiffType,
16
+ setProviderReviewDefaultDiffType,
17
+ setReviewAutoViewed,
18
+ type VcsReviewSettingsDescriptor,
19
+ } from '../config';
9
20
  import { setWebMcpToolsEnabled, useWebMcpToolsEnabled } from '../webmcp/preference';
10
21
  import { DIAGRAM_SHADOW_OPTIONS } from '../utils/diagramShadow';
22
+ import { markLookAndFeelChoiceResolved } from '../utils/lookAndFeelAnnouncement';
23
+ import { DEFAULT_DOCUMENT_TEXT_SIZE, DOCUMENT_TEXT_SIZE_OPTIONS } from '../utils/documentTextSize';
24
+ import { DOCUMENT_FONT_OPTIONS, documentFontStack } from '../utils/documentFont';
11
25
  import {
12
26
  agentToolAppliesWhen,
13
27
  agentToolCostNote,
@@ -15,6 +29,8 @@ import {
15
29
  type AgentToolSetting,
16
30
  } from '../utils/agentToolSetting';
17
31
  import { loadDiffFont } from '../utils/diffFonts';
32
+ import { isFontInstalled } from '../utils/fontDetect';
33
+ import { MAX_FONT_FAMILY_LENGTH, monoFontStack, normalizeCssFontSize, normalizeFontFamilyName } from '../utils/cssFontFamily';
18
34
  import { TaterSpritePullup } from './TaterSpritePullup';
19
35
  import { getIdentity, regenerateIdentity, setCustomIdentity, isIdentityEditable } from '../utils/identity';
20
36
  import { GitUser } from '../icons/GitUser';
@@ -37,6 +53,13 @@ import {
37
53
  saveOctarineSettings,
38
54
  type OctarineSettings,
39
55
  } from '../utils/octarine';
56
+ import {
57
+ getNotionSettings,
58
+ saveNotionSettings,
59
+ getNotionParentPageId,
60
+ isNotionAvailable,
61
+ type NotionSettings,
62
+ } from '../utils/notion';
40
63
  import {
41
64
  getAgentSwitchSettings,
42
65
  getAgentSwitchDefaults,
@@ -86,7 +109,7 @@ import {
86
109
  import { requestVimDocumentFocus } from '../hooks/useVimDocumentFocus';
87
110
  import { AnalysisLayerToggle } from './AnalysisLayerToggle';
88
111
 
89
- type SettingsTab = 'general' | 'theme' | 'git' | 'display' | 'analysis' | 'saving' | 'labels' | 'vim' | 'shortcuts' | 'ai' | 'files' | 'obsidian' | 'bear' | 'octarine' | 'comments' | 'hooks';
112
+ type SettingsTab = 'general' | 'theme' | 'review' | 'display' | 'analysis' | 'saving' | 'labels' | 'vim' | 'shortcuts' | 'ai' | 'files' | 'obsidian' | 'bear' | 'octarine' | 'notion' | 'comments' | 'hooks' | 'experimental';
90
113
 
91
114
  interface SettingsProps {
92
115
  taterMode: boolean;
@@ -101,7 +124,30 @@ interface SettingsProps {
101
124
  * true; a host that omits it keeps the pre-parity annotate tab set.
102
125
  */
103
126
  annotateParity?: boolean;
127
+ /**
128
+ * Hide General's "Enter saves a comment" row (#1934). Default false: the
129
+ * row shows. A host that drives the `enterSavesComment` setting itself, or
130
+ * never wants it offered, passes true.
131
+ */
132
+ hideEnterSavesComment?: boolean;
133
+ /**
134
+ * Show Display's Text size row (#2066). Default false: the row does
135
+ * something only where the page applies the `documentTextSize` setting
136
+ * (`hooks/useDocumentTextSize`), so a host opts in. Plannotator passes true
137
+ * in plan review and annotate on a markdown or text document.
138
+ */
139
+ showDocumentTextSize?: boolean;
140
+ /**
141
+ * Show Display's Document font row (#1657). Default false, like
142
+ * `showDocumentTextSize`: the row does something only where the page
143
+ * applies the `documentFontFamily` setting (`hooks/useDocumentFont`).
144
+ */
145
+ showDocumentFont?: boolean;
104
146
  onUIPreferencesChange?: (prefs: UIPreferences) => void;
147
+ /** Claude Code's permission mode was changed here (plan review). The host
148
+ * keeps its own copy in step, since its Approve sends the mode and its
149
+ * decision menu shows it. Optional: hosts that omit it are unchanged. */
150
+ onPermissionModeChange?: (mode: PermissionMode) => void;
105
151
  /** Externally controlled open state (for mobile menu integration) */
106
152
  externalOpen?: boolean;
107
153
  onExternalClose?: () => void;
@@ -117,6 +163,11 @@ interface SettingsProps {
117
163
  * useUpdateCheckSetting); `env` is PLANNOTATOR_UPDATE_CHECK when it overrides the
118
164
  * config file. Hosts that omit it get no row. */
119
165
  updateCheckSetting?: UpdateCheckSetting;
166
+ /** "Open Plannotator at plannotator.localhost" (#2184, useCookieHostSetting): present
167
+ * only when the server reports it (a local session). Hosts that omit it get no row. */
168
+ cookieHostSetting?: CookieHostSetting;
169
+ /** Code review's package changes switches (usePackageChangesSetting). Hosts that omit it get no rows. */
170
+ packageChangesSetting?: PackageChangesSetting;
120
171
  /** The `plannotator` agent tool switch, as the server reports it
121
172
  * (parseAgentToolSetting of serverConfig: present only when the session's
122
173
  * host registers the tool). Shown only together with onAgentToolChange;
@@ -126,8 +177,8 @@ interface SettingsProps {
126
177
  * readable Error, which the row shows. */
127
178
  onAgentToolChange?: (enabled: boolean) => Promise<void>;
128
179
  /** Current session is a local git review where since-base ISN'T offered
129
- * (base ref unresolvable) — the Git tab shows a note that the Git-status
130
- * preference can't take effect in THIS repo. */
180
+ * (base ref unresolvable) — the Review tab's Git section shows a note that
181
+ * the Git-status preference can't take effect in THIS repo. */
131
182
  sinceBaseUnavailable?: boolean;
132
183
  /** The host is rendering its compact touch shell (review only). Display
133
184
  * settings that the compact shell overrides for the session are hidden
@@ -213,7 +264,20 @@ const DEFAULT_DIFF_TYPE_OPTIONS = [
213
264
  { value: 'merge-base' as const, label: 'Committed changes (PR view)', description: "Everything you've committed on this branch" },
214
265
  { value: 'all' as const, label: 'All Files (HEAD)', description: "Every tracked file at HEAD, shown as additions" },
215
266
  ];
216
-
267
+ // Labels match the jj diff picker (getJjContext in packages/shared/jj-core.ts).
268
+ const JJ_DEFAULT_DIFF_TYPE_OPTIONS = [
269
+ { value: 'jj-current', label: 'Current change', description: 'Only the working-copy change (@)' },
270
+ { value: 'jj-last', label: 'Last change', description: 'The change just before the working copy (@-)' },
271
+ { value: 'jj-line', label: 'Line of work', description: 'Every change on your line of work since it left trunk' },
272
+ { value: 'jj-evolog', label: 'Evolution diff', description: 'How the current change has evolved' },
273
+ { value: 'jj-all', label: 'Every file', description: 'Every file at the working copy, shown as additions' },
274
+ ] as const;
275
+ const JJ_REVIEW_DEFAULTS: VcsReviewSettingsDescriptor = {
276
+ id: 'jj',
277
+ label: 'Jujutsu',
278
+ defaultDiffType: 'jj-current',
279
+ diffOptions: JJ_DEFAULT_DIFF_TYPE_OPTIONS.map(({ value, label, description }) => ({ id: value, label, description })),
280
+ };
217
281
  const AGENT_TERMINAL_SIDE_OPTIONS: { value: AnnotateAgentTerminalSide; label: string }[] = [
218
282
  { value: 'left', label: 'Left' },
219
283
  { value: 'right', label: 'Right' },
@@ -249,6 +313,13 @@ function SegmentedControl<T extends string>({ options, value, onChange, disabled
249
313
  );
250
314
  }
251
315
 
316
+ /** Which of the cookie switch's states the row shows (a test and screenshot hook). */
317
+ function cookieHostSettingState(setting: CookieHostSetting): string {
318
+ if (setting.env !== undefined) return 'locked-env';
319
+ if (setting.urlHost !== undefined) return 'locked-url-host';
320
+ return setting.on ? 'on' : 'off';
321
+ }
322
+
252
323
  function ToggleSwitch({ checked, onChange, label, description, disabled = false }: {
253
324
  checked: boolean;
254
325
  onChange: (v: boolean) => void;
@@ -417,7 +488,7 @@ function VimSettingsTab({
417
488
  );
418
489
  }
419
490
 
420
- function ReviewAnalysisTab() {
491
+ function ReviewAnalysisTab({ packageChangesSetting }: { packageChangesSetting?: PackageChangesSetting }) {
421
492
  const semanticDiffEnabled = useConfigValue('semanticDiffEnabled');
422
493
  const callFlowEnabled = useConfigValue('callFlowEnabled');
423
494
  return (
@@ -443,6 +514,27 @@ function ReviewAnalysisTab() {
443
514
  label="Call flow"
444
515
  description="Diffs for function call stacks across git commits. 22 languages supported (AST-based, built using Tree-sitter)."
445
516
  />
517
+ {packageChangesSetting && (
518
+ <>
519
+ <div className="border-t border-border" />
520
+ <AnalysisLayerToggle
521
+ checked={packageChangesSetting.card}
522
+ onChange={(on) => packageChangesSetting.setCard(on)}
523
+ label="Package changes"
524
+ description="Added, removed and changed dependencies above package.json and lockfile diffs."
525
+ />
526
+ <div className="border-t border-border" />
527
+ <AnalysisLayerToggle
528
+ checked={packageChangesSetting.lookup}
529
+ onChange={(on) => void packageChangesSetting.setLookup(on)}
530
+ disabled={packageChangesSetting.lookupEnv === false}
531
+ label="Look up packages on npm"
532
+ description={packageChangesSetting.lookupEnv === false
533
+ ? 'Off: PLANNOTATOR_PACKAGE_LOOKUP=0.'
534
+ : 'License, release date and weekly downloads. Sends the names of changed public packages to registry.npmjs.org and api.npmjs.org.'}
535
+ />
536
+ </>
537
+ )}
446
538
  </div>
447
539
 
448
540
  <div className="grid gap-3 sm:grid-cols-2">
@@ -474,16 +566,56 @@ function ReviewAnalysisTab() {
474
566
  );
475
567
  }
476
568
 
477
- const GitTab: React.FC<{ sinceBaseUnavailable?: boolean }> = ({ sinceBaseUnavailable }) => {
569
+ /** One radio card list for a saved default diff (Git or Jujutsu). */
570
+ function DefaultDiffChoices<T extends string>({ options, value, onChange }: {
571
+ options: ReadonlyArray<{ value: T; label: string; description: string }>;
572
+ value: string;
573
+ onChange: (value: T) => void;
574
+ }) {
575
+ return (
576
+ <div className="space-y-2" role="radiogroup">
577
+ {options.map((opt) => (
578
+ <button
579
+ key={opt.value}
580
+ type="button"
581
+ role="radio"
582
+ aria-checked={value === opt.value}
583
+ onClick={() => onChange(opt.value)}
584
+ className={`w-full flex items-start gap-3 p-3 rounded-lg border transition-colors text-left ${
585
+ value === opt.value
586
+ ? 'border-primary bg-primary/5'
587
+ : 'border-border hover:border-muted-foreground/30 hover:bg-muted/50'
588
+ }`}
589
+ >
590
+ <div className={`mt-0.5 w-4 h-4 rounded-full border-2 flex-shrink-0 flex items-center justify-center ${
591
+ value === opt.value ? 'border-primary' : 'border-muted-foreground/40'
592
+ }`}>
593
+ {value === opt.value && (
594
+ <div className="w-2 h-2 rounded-full bg-primary" />
595
+ )}
596
+ </div>
597
+ <div>
598
+ <div className="text-sm font-medium">{opt.label}</div>
599
+ <div className="text-xs text-muted-foreground">{opt.description}</div>
600
+ </div>
601
+ </button>
602
+ ))}
603
+ </div>
604
+ );
605
+ }
606
+
607
+ const REVIEW_SECTION_HEADING = 'text-xs font-semibold uppercase tracking-wider text-muted-foreground';
608
+
609
+ const ReviewTab: React.FC<{ sinceBaseUnavailable?: boolean }> = ({ sinceBaseUnavailable }) => {
478
610
  const defaultDiffType = useConfigValue('defaultDiffType');
611
+ useConfigValue('reviewDefaults');
612
+ const jjDefaultDiffType = getProviderReviewDefaultDiffType(JJ_REVIEW_DEFAULTS);
479
613
  const reviewPanelView = useConfigValue('reviewPanelView');
480
614
  const reviewAutoViewed = useConfigValue('reviewAutoViewed');
481
615
  return (
482
616
  <div className="space-y-5">
483
617
  <div className="space-y-2">
484
- <div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
485
- Viewed files
486
- </div>
618
+ <div className={REVIEW_SECTION_HEADING}>Viewed files</div>
487
619
  {/* Never write `reviewAutoViewed` directly — setReviewAutoViewed also
488
620
  consumes the first-time notice, since an explicit toggle is proof
489
621
  the reviewer already found the switch. */}
@@ -494,67 +626,253 @@ const GitTab: React.FC<{ sinceBaseUnavailable?: boolean }> = ({ sinceBaseUnavail
494
626
  description="Mark a file viewed when you scroll past it or move on to another file. Files you un-view stay un-viewed, and files that change on refresh become un-viewed."
495
627
  />
496
628
  </div>
497
- <div className="space-y-2">
498
- <div>
499
- <div className="text-sm font-medium">Default review view</div>
500
- <div className="text-xs text-muted-foreground">Which panel a code review opens in</div>
501
- {/* This is a GLOBAL preference — never hide the options because the
502
- CURRENT repo can't serve them; just say so. Without this note,
503
- picking Git status on a repo whose base ref doesn't resolve
504
- silently falls back to Tree and the setting looks broken. */}
505
- {sinceBaseUnavailable && (
506
- <div className="text-xs text-warning mt-1">
507
- Git status view isn't available in this repository (its base branch
508
- couldn't be resolved) — reviews here open in Tree. The preference
509
- still applies in repositories where it works.
510
- </div>
511
- )}
629
+
630
+ <div className="border-t border-border" />
631
+
632
+ <section className="space-y-4" aria-labelledby="settings-review-git">
633
+ <div id="settings-review-git" className={REVIEW_SECTION_HEADING}>Git</div>
634
+ <div className="space-y-2">
635
+ <div>
636
+ <div className="text-sm font-medium">Default review view</div>
637
+ <div className="text-xs text-muted-foreground">Which panel a code review opens in</div>
638
+ {/* This is a GLOBAL preference — never hide the options because the
639
+ CURRENT repo can't serve them; just say so. Without this note,
640
+ picking Git status on a repo whose base ref doesn't resolve
641
+ silently falls back to Tree and the setting looks broken. */}
642
+ {sinceBaseUnavailable && (
643
+ <div className="text-xs text-warning mt-1">
644
+ Git status view isn't available in this repository (its base branch
645
+ couldn't be resolved) — reviews here open in Tree. The preference
646
+ still applies in repositories where it works.
647
+ </div>
648
+ )}
649
+ </div>
650
+ {/* No Commits option here: the Commits view is session-only (entered
651
+ via the panel toggle) and is never the opening view. */}
652
+ <SegmentedControl
653
+ options={[
654
+ { value: 'sections' as const, label: 'Git status' },
655
+ { value: 'tree' as const, label: 'Tree' },
656
+ ]}
657
+ value={reviewPanelView}
658
+ onChange={setReviewPanelView}
659
+ />
512
660
  </div>
513
- {/* No Commits option here: the Commits view is session-only (entered
514
- via the panel toggle) and is never the opening view. */}
515
- <SegmentedControl
516
- options={[
517
- { value: 'sections' as const, label: 'Git status' },
518
- { value: 'tree' as const, label: 'Tree' },
519
- ]}
520
- value={reviewPanelView}
521
- onChange={setReviewPanelView}
522
- />
523
- </div>
524
- <div className="space-y-2">
661
+ <div className="space-y-2">
662
+ <div>
663
+ <div className="text-sm font-medium">Default diff view</div>
664
+ <div className="text-xs text-muted-foreground">Which changes to show when you open a code review</div>
665
+ </div>
666
+ {/* Coupling (sections ⟺ since-base) lives in the shared setter —
667
+ never write the pair by hand (see config/reviewView). */}
668
+ <DefaultDiffChoices
669
+ options={DEFAULT_DIFF_TYPE_OPTIONS}
670
+ value={defaultDiffType}
671
+ onChange={(value) => setReviewDefaultDiffType(value)}
672
+ />
673
+ </div>
674
+ </section>
675
+
676
+ <div className="border-t border-border" />
677
+
678
+ <section className="space-y-4" aria-labelledby="settings-review-jj">
679
+ <div id="settings-review-jj" className={REVIEW_SECTION_HEADING}>Jujutsu</div>
680
+ <div className="space-y-2">
681
+ <div>
682
+ <div className="text-sm font-medium">Default diff view</div>
683
+ <div className="text-xs text-muted-foreground">Which changes to show when you open a review in a Jujutsu repository</div>
684
+ </div>
685
+ {/* Its own saved value: Git modes mean nothing in a jj repo, and jj has
686
+ no Git status view, so nothing here is coupled to the Git half. */}
687
+ <DefaultDiffChoices
688
+ options={JJ_DEFAULT_DIFF_TYPE_OPTIONS}
689
+ value={jjDefaultDiffType}
690
+ onChange={(value) => setProviderReviewDefaultDiffType('jj', value)}
691
+ />
692
+ </div>
693
+ </section>
694
+ </div>
695
+ );
696
+ };
697
+
698
+ /** Sentinel for the "Custom…" select option — never persisted as a font name. */
699
+ const CUSTOM_FONT_SENTINEL = '__custom__';
700
+
701
+ /** Inline style rendering a control in the chosen font (quoted and escaped: free text). */
702
+ function stackStyle(stack: string): React.CSSProperties | undefined {
703
+ return stack ? { fontFamily: stack } : undefined;
704
+ }
705
+
706
+ /**
707
+ * A font row (#851, shared by Code Font and Document font): a select of the
708
+ * built-in choices plus "Custom…", which opens a field for the family name of
709
+ * any font installed on this machine. The typed name is saved after a short
710
+ * pause (configStore.set writes the cookie and may POST /api/config), flushed
711
+ * on unmount, and dropped when a built-in is picked mid-pause.
712
+ */
713
+ const FontFamilyField: React.FC<{
714
+ label: string;
715
+ description: string;
716
+ value: string;
717
+ onSave: (value: string) => void;
718
+ options: readonly { value: string; label: string }[];
719
+ /** The `font-family` the controls preview a value in. */
720
+ previewStack: (value: string) => string;
721
+ previewText: string;
722
+ customAriaLabel: string;
723
+ customPlaceholder: string;
724
+ notFoundText: string;
725
+ /** Load a built-in's web font for the preview, if it has one. */
726
+ loadFont?: (value: string) => void;
727
+ previewClassName?: string;
728
+ }> = ({ label, description, value, onSave, options, previewStack, previewText, customAriaLabel, customPlaceholder, notFoundText, loadFont, previewClassName = '' }) => {
729
+ const isBuiltIn = (v: string) => options.some((opt) => opt.value === v);
730
+ // Custom font: progressive disclosure. customFontMode keeps the input open
731
+ // while the user picks "Custom…" or has a non-built-in font saved.
732
+ const [customFontMode, setCustomFontMode] = useState(() => !!value && !isBuiltIn(value));
733
+ const [customFontDraft, setCustomFontDraft] = useState(() => (value && !isBuiltIn(value) ? value : ''));
734
+ const customFontSaveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
735
+ // Pending debounced value. Non-null means "typed but not yet saved" — it is
736
+ // flushed (not discarded) on unmount, and cancelled when a built-in is
737
+ // picked so a stale timer can't overwrite the new selection.
738
+ const pendingFontSave = useRef<string | null>(null);
739
+ const onSaveRef = useRef(onSave);
740
+ onSaveRef.current = onSave;
741
+ const customActive = customFontMode || (!!value && !isBuiltIn(value));
742
+ // Width-measurement check — deterministic per machine, no permissions.
743
+ const customFontInstalled = useMemo(
744
+ () => (normalizeFontFamilyName(customFontDraft) ? isFontInstalled(customFontDraft) : null),
745
+ [customFontDraft],
746
+ );
747
+
748
+ // Load font for the preview swatch
749
+ useEffect(() => {
750
+ if (value) loadFont?.(value);
751
+ }, [value, loadFont]);
752
+
753
+ // Adopt external changes (late server hydration, edits from elsewhere) into
754
+ // the draft — but never clobber typing that hasn't been saved yet.
755
+ useEffect(() => {
756
+ if (pendingFontSave.current !== null) return;
757
+ if (value && !isBuiltIn(value) && value !== normalizeFontFamilyName(customFontDraft)) {
758
+ setCustomFontDraft(value);
759
+ }
760
+ // eslint-disable-next-line react-hooks/exhaustive-deps
761
+ }, [value]);
762
+
763
+ const cancelPendingFontSave = () => {
764
+ clearTimeout(customFontSaveTimer.current);
765
+ pendingFontSave.current = null;
766
+ };
767
+
768
+ // Flush a pending save on unmount so closing the panel mid-debounce
769
+ // doesn't lose the typed font.
770
+ useEffect(() => () => {
771
+ clearTimeout(customFontSaveTimer.current);
772
+ if (pendingFontSave.current !== null) {
773
+ onSaveRef.current(pendingFontSave.current);
774
+ }
775
+ }, []);
776
+
777
+ const handleCustomFontInput = (next: string) => {
778
+ setCustomFontDraft(next);
779
+ // Debounce the save — per-keystroke writes would spam the cookie and
780
+ // /api/config.
781
+ clearTimeout(customFontSaveTimer.current);
782
+ pendingFontSave.current = normalizeFontFamilyName(next);
783
+ customFontSaveTimer.current = setTimeout(() => {
784
+ if (pendingFontSave.current !== null) {
785
+ onSaveRef.current(pendingFontSave.current);
786
+ pendingFontSave.current = null;
787
+ }
788
+ }, 400);
789
+ };
790
+
791
+ return (
792
+ <div className="space-y-2">
525
793
  <div>
526
- <div className="text-sm font-medium">Default Diff View</div>
527
- <div className="text-xs text-muted-foreground">Which changes to show when you open a code review</div>
794
+ <div className="text-sm font-medium">{label}</div>
795
+ <div className="text-xs text-muted-foreground">{description}</div>
528
796
  </div>
529
- <div className="space-y-2">
530
- {DEFAULT_DIFF_TYPE_OPTIONS.map((opt) => (
531
- <button
532
- key={opt.value}
533
- type="button"
534
- // Coupling (sections ⟺ since-base) lives in the shared setter —
535
- // never write the pair by hand (see config/reviewView).
536
- onClick={() => setReviewDefaultDiffType(opt.value)}
537
- className={`w-full flex items-start gap-3 p-3 rounded-lg border transition-colors text-left ${
538
- defaultDiffType === opt.value
539
- ? 'border-primary bg-primary/5'
540
- : 'border-border hover:border-muted-foreground/30 hover:bg-muted/50'
541
- }`}
542
- >
543
- <div className={`mt-0.5 w-4 h-4 rounded-full border-2 flex-shrink-0 flex items-center justify-center ${
544
- defaultDiffType === opt.value ? 'border-primary' : 'border-muted-foreground/40'
545
- }`}>
546
- {defaultDiffType === opt.value && (
547
- <div className="w-2 h-2 rounded-full bg-primary" />
548
- )}
549
- </div>
550
- <div>
551
- <div className="text-sm font-medium">{opt.label}</div>
552
- <div className="text-xs text-muted-foreground">{opt.description}</div>
553
- </div>
554
- </button>
797
+ <select
798
+ value={customActive ? CUSTOM_FONT_SENTINEL : value}
799
+ aria-label={label}
800
+ onChange={(e) => {
801
+ if (e.target.value === CUSTOM_FONT_SENTINEL) {
802
+ setCustomFontMode(true);
803
+ } else {
804
+ // Cancel any in-flight debounced custom save so a stale timer
805
+ // can't overwrite this selection.
806
+ cancelPendingFontSave();
807
+ setCustomFontMode(false);
808
+ setCustomFontDraft('');
809
+ onSave(e.target.value);
810
+ }
811
+ }}
812
+ className="w-full px-3 py-1.5 text-sm rounded-md bg-muted/50 border border-border text-foreground"
813
+ style={value && !customActive ? stackStyle(previewStack(value)) : undefined}
814
+ >
815
+ {options.map((opt) => (
816
+ <option key={opt.value} value={opt.value}>{opt.label}</option>
555
817
  ))}
818
+ <option value={CUSTOM_FONT_SENTINEL}>Custom…</option>
819
+ </select>
820
+ {customActive && (
821
+ <div className="space-y-1">
822
+ <input
823
+ type="text"
824
+ value={customFontDraft}
825
+ onChange={(e) => handleCustomFontInput(e.target.value)}
826
+ placeholder={customPlaceholder}
827
+ aria-label={customAriaLabel}
828
+ maxLength={MAX_FONT_FAMILY_LENGTH}
829
+ autoFocus={!customFontDraft}
830
+ spellCheck={false}
831
+ className="w-full px-3 py-1.5 text-sm rounded-md bg-muted/50 border border-border text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring"
832
+ style={stackStyle(previewStack(customFontDraft))}
833
+ />
834
+ <div className="text-[11px]" aria-live="polite">
835
+ {!normalizeFontFamilyName(customFontDraft) ? (
836
+ <span className="text-muted-foreground">Any font installed on this machine works — type its family name.</span>
837
+ ) : customFontInstalled === true ? (
838
+ <span className="text-success">✓ Found on this machine</span>
839
+ ) : customFontInstalled === false ? (
840
+ <span className="text-warning">{notFoundText}</span>
841
+ ) : null}
842
+ </div>
843
+ </div>
844
+ )}
845
+ {value && (
846
+ <div
847
+ className={`text-xs text-muted-foreground px-1 py-1 rounded bg-muted/30 ${previewClassName}`}
848
+ style={stackStyle(previewStack(value))}
849
+ >
850
+ {previewText}
851
+ </div>
852
+ )}
853
+ </div>
854
+ );
855
+ };
856
+
857
+ /**
858
+ * Experimental (the owner, 2026-10-27: "its own page on settings", the last
859
+ * tab): each experimental setting, off by default. Today one, code review's
860
+ * Edit Code to Suggest (`editSuggestions`, the
861
+ * `plannotator-experimental-edit-suggestions` cookie, unchanged).
862
+ */
863
+ const ExperimentalTab: React.FC = () => {
864
+ const editSuggestions = useConfigValue('editSuggestions');
865
+ return (
866
+ <div className="space-y-3" data-settings-experimental="">
867
+ <div className="text-xs text-muted-foreground">
868
+ Features still being tried. Each stays off until you turn it on.
556
869
  </div>
557
- </div>
870
+ <ToggleSwitch
871
+ checked={editSuggestions}
872
+ onChange={(v) => configStore.set('editSuggestions', v)}
873
+ label="Edit Code to Suggest"
874
+ description="Edit a file in place in the all-files view; your net change becomes a suggestion comment. Files are never written from the browser. Uses an experimental upstream editor."
875
+ />
558
876
  </div>
559
877
  );
560
878
  };
@@ -568,35 +886,14 @@ const ReviewDisplayTab: React.FC<{ isCompactTouchLayout?: boolean }> = ({ isComp
568
886
  const diffShowBackground = useConfigValue('diffShowBackground');
569
887
  const diffLineBgIntensity = useConfigValue('diffLineBgIntensity');
570
888
  const diffHideWhitespace = useConfigValue('diffHideWhitespace');
571
- const editSuggestions = useConfigValue('editSuggestions');
572
889
  const diffExpandUnchanged = useConfigValue('diffExpandUnchanged');
573
890
  const diffFontFamily = useConfigValue('diffFontFamily');
574
891
  const diffFontSize = useConfigValue('diffFontSize');
575
892
  const tokenHoverTrigger = useConfigValue('tokenHoverTrigger');
576
893
  const tokenHoverDelay = useConfigValue('tokenHoverDelay');
577
894
 
578
- // Load font for the preview swatch
579
- useEffect(() => {
580
- if (diffFontFamily) loadDiffFont(diffFontFamily);
581
- }, [diffFontFamily]);
582
-
583
895
  return (
584
896
  <>
585
- {/* Experimental: edit code to author suggestions */}
586
- <div className="space-y-3">
587
- <div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
588
- Experimental
589
- </div>
590
- <ToggleSwitch
591
- checked={editSuggestions}
592
- onChange={(v) => configStore.set('editSuggestions', v)}
593
- label="Edit Code to Suggest"
594
- description="Edit a file in place in the all-files view; your net change becomes a suggestion comment. Files are never written from the browser. Uses an experimental upstream editor."
595
- />
596
- </div>
597
-
598
- <div className="border-t border-border" />
599
-
600
897
  {/* Hover cards (internally tokenHover*; the label is what changed, not
601
898
  the ids). One trigger select rather than a toggle plus a mode: `Off`
602
899
  is a value of the same question, so there is no unreachable
@@ -633,30 +930,20 @@ const ReviewDisplayTab: React.FC<{ isCompactTouchLayout?: boolean }> = ({ isComp
633
930
  <div className="border-t border-border" />
634
931
 
635
932
  {/* Font Family */}
636
- <div className="space-y-2">
637
- <div>
638
- <div className="text-sm font-medium">Code Font</div>
639
- <div className="text-xs text-muted-foreground">Font family for diff code lines</div>
640
- </div>
641
- <select
642
- value={diffFontFamily}
643
- onChange={(e) => configStore.set('diffFontFamily', e.target.value)}
644
- className="w-full px-3 py-1.5 text-sm rounded-md bg-muted/50 border border-border text-foreground"
645
- style={diffFontFamily ? { fontFamily: `'${diffFontFamily}', monospace` } : undefined}
646
- >
647
- {DIFF_FONT_OPTIONS.map((opt) => (
648
- <option key={opt.value} value={opt.value}>{opt.label}</option>
649
- ))}
650
- </select>
651
- {diffFontFamily && (
652
- <div
653
- className="text-xs text-muted-foreground px-1 py-1 rounded bg-muted/30 font-mono"
654
- style={{ fontFamily: `'${diffFontFamily}', monospace` }}
655
- >
656
- Preview: const x = fn(42);
657
- </div>
658
- )}
659
- </div>
933
+ <FontFamilyField
934
+ label="Code Font"
935
+ description="Font family for diff code lines"
936
+ value={diffFontFamily}
937
+ onSave={(v) => configStore.set('diffFontFamily', v)}
938
+ options={DIFF_FONT_OPTIONS}
939
+ previewStack={monoFontStack}
940
+ previewText="Preview: const x = fn(42);"
941
+ customAriaLabel="Custom code font name"
942
+ customPlaceholder="Font name as installed, e.g. Iosevka"
943
+ notFoundText="Not found — diffs will fall back to monospace"
944
+ loadFont={loadDiffFont}
945
+ previewClassName="font-mono"
946
+ />
660
947
 
661
948
  <div className="border-t border-border" />
662
949
 
@@ -668,7 +955,7 @@ const ReviewDisplayTab: React.FC<{ isCompactTouchLayout?: boolean }> = ({ isComp
668
955
  <div className="text-xs text-muted-foreground">Font size for diff code lines</div>
669
956
  </div>
670
957
  <div className="text-xs tabular-nums text-muted-foreground min-w-[4ch] text-right">
671
- {diffFontSize || 'Auto'}
958
+ {normalizeCssFontSize(diffFontSize) || 'Auto'}
672
959
  </div>
673
960
  </div>
674
961
  <div className="flex items-center gap-2">
@@ -1007,7 +1294,7 @@ const CommentsTab: React.FC = () => {
1007
1294
  );
1008
1295
  };
1009
1296
 
1010
- export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', annotateParity = false, onUIPreferencesChange, externalOpen, onExternalClose, aiProviders = [], gitUser, autoUpdateSetting, updateCheckSetting, agentToolSetting, onAgentToolChange, sinceBaseUnavailable, isCompactTouchLayout = false, onDetectObsidianVaults, agentTerminalAvailable = false, webmcpAvailable = false }) => {
1297
+ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange, onIdentityChange, origin, mode = 'plan', annotateParity = false, onUIPreferencesChange, onPermissionModeChange, externalOpen, onExternalClose, aiProviders = [], gitUser, autoUpdateSetting, updateCheckSetting, cookieHostSetting, packageChangesSetting, agentToolSetting, onAgentToolChange, sinceBaseUnavailable, isCompactTouchLayout = false, onDetectObsidianVaults, agentTerminalAvailable = false, webmcpAvailable = false, hideEnterSavesComment = false, showDocumentTextSize = false, showDocumentFont = false }) => {
1011
1298
  const webmcpTools = useWebMcpToolsEnabled();
1012
1299
  const autoUpdate = useConfigValue('autoUpdate');
1013
1300
  const updateChecksOn = updateCheckSetting ? updateCheckSetting.on : true;
@@ -1040,7 +1327,10 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1040
1327
  const [activeTab, setActiveTab] = useState<SettingsTab>('general');
1041
1328
  const gridEnabled = useConfigValue('gridEnabled');
1042
1329
  const diagramShadow = useConfigValue('diagramShadow');
1330
+ const documentTextSize = useConfigValue('documentTextSize');
1331
+ const documentFontFamily = useConfigValue('documentFontFamily');
1043
1332
  const vimModeEnabled = useConfigValue('vimModeEnabled');
1333
+ const enterSavesComment = useConfigValue('enterSavesComment');
1044
1334
  const vimHudEnabled = useConfigValue('vimHudEnabled');
1045
1335
  const vimHudKeyPanelEnabled = useConfigValue('vimHudKeyPanelEnabled');
1046
1336
  const agentTerminalSide = useConfigValue('agentTerminalSide');
@@ -1057,6 +1347,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1057
1347
  const [vaultsLoading, setVaultsLoading] = useState(false);
1058
1348
  const [bear, setBear] = useState<BearSettings>({ enabled: false, customTags: '', tagPosition: 'append', autoSave: false });
1059
1349
  const [octarine, setOctarine] = useState<OctarineSettings>({ enabled: false, workspace: '', folder: 'plannotator', autoSave: false });
1350
+ const [notion, setNotion] = useState<NotionSettings>({ enabled: false, parentPageId: '', autoSave: false });
1060
1351
  // The agent switch default depends on the surface: plan approval hands off to
1061
1352
  // the build agent, review feedback stays on the current agent.
1062
1353
  const agentSurface: AgentSwitchSurface = mode === 'review' ? 'review' : 'plan';
@@ -1089,7 +1380,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1089
1380
  t.push({ id: 'labels', label: 'Labels' });
1090
1381
  }
1091
1382
  if (mode === 'review') {
1092
- t.push({ id: 'git', label: 'Git' });
1383
+ t.push({ id: 'review', label: 'Review' });
1093
1384
  t.push({ id: 'display', label: 'Editor' });
1094
1385
  t.push({ id: 'analysis', label: 'Analysis' });
1095
1386
  t.push({ id: 'comments', label: 'Comments' });
@@ -1110,12 +1401,16 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1110
1401
  const integrationTabs: { id: SettingsTab; label: string }[] = [
1111
1402
  { id: 'files', label: 'Files' },
1112
1403
  ...(mode === 'plan' || annotateDocumentTabs
1113
- ? [{ id: 'obsidian' as SettingsTab, label: 'Obsidian' }, { id: 'bear' as SettingsTab, label: 'Bear' }, { id: 'octarine' as SettingsTab, label: 'Octarine' }]
1404
+ ? [{ id: 'obsidian' as SettingsTab, label: 'Obsidian' }, { id: 'bear' as SettingsTab, label: 'Bear' }, { id: 'octarine' as SettingsTab, label: 'Octarine' }, ...(isNotionAvailable() ? [{ id: 'notion' as SettingsTab, label: 'Notion' }] : [])]
1114
1405
  : []),
1115
1406
  ];
1407
+ // Experimental is the last tab, on its own below the integrations (the owner,
1408
+ // 2026-10-27). Only where it has a setting: code review's Edit Code to Suggest.
1409
+ const experimentalTabs: { id: SettingsTab; label: string }[] = mode === 'review' ? [{ id: 'experimental', label: 'Experimental' }] : [];
1116
1410
  const obsidianDefaultSaveAvailable = obsidian.enabled && getEffectiveVaultPath(obsidian).trim().length > 0;
1117
1411
  const bearDefaultSaveAvailable = bear.enabled;
1118
1412
  const octarineDefaultSaveAvailable = octarine.enabled && octarine.workspace.trim().length > 0;
1413
+ const notionDefaultSaveAvailable = getNotionParentPageId(notion) !== null;
1119
1414
 
1120
1415
  // Sync external open state
1121
1416
  useEffect(() => {
@@ -1131,6 +1426,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1131
1426
  setObsidian(getObsidianSettings());
1132
1427
  setBear(getBearSettings());
1133
1428
  setOctarine(getOctarineSettings());
1429
+ setNotion(getNotionSettings());
1134
1430
  setAgent(getAgentSwitchSettings(agentSurface));
1135
1431
  setPlanSave(getPlanSaveSettings());
1136
1432
  setUiPrefs(getUIPreferences());
@@ -1156,7 +1452,9 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1156
1452
  defaultNotesApp === 'download' ||
1157
1453
  (defaultNotesApp === 'obsidian' && obsidianDefaultSaveAvailable) ||
1158
1454
  (defaultNotesApp === 'bear' && bearDefaultSaveAvailable) ||
1159
- (defaultNotesApp === 'octarine' && octarineDefaultSaveAvailable);
1455
+ (defaultNotesApp === 'octarine' && octarineDefaultSaveAvailable) ||
1456
+ // A session without Notion (remote, or no NOTION_TOKEN) keeps the choice for the next one.
1457
+ (defaultNotesApp === 'notion' && (notionDefaultSaveAvailable || !isNotionAvailable()));
1160
1458
 
1161
1459
  if (!defaultSaveAvailable) {
1162
1460
  setDefaultNotesApp('ask');
@@ -1168,6 +1466,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1168
1466
  obsidianDefaultSaveAvailable,
1169
1467
  bearDefaultSaveAvailable,
1170
1468
  octarineDefaultSaveAvailable,
1469
+ notionDefaultSaveAvailable,
1171
1470
  ]);
1172
1471
 
1173
1472
  // Fetch detected vaults when Obsidian is enabled
@@ -1224,6 +1523,12 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1224
1523
  saveOctarineSettings(newSettings);
1225
1524
  };
1226
1525
 
1526
+ const handleNotionChange = (updates: Partial<NotionSettings>) => {
1527
+ const newSettings = { ...notion, ...updates };
1528
+ setNotion(newSettings);
1529
+ saveNotionSettings(newSettings);
1530
+ };
1531
+
1227
1532
  const handleAgentChange = (switchTo: AgentSwitchSettings['switchTo'], customName?: string) => {
1228
1533
  const newSettings = { switchTo, customName: customName ?? agent.customName };
1229
1534
  setAgent(newSettings);
@@ -1246,6 +1551,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1246
1551
  const handlePermissionModeChange = (mode: PermissionMode) => {
1247
1552
  setPermissionMode(mode);
1248
1553
  savePermissionModeSettings(mode);
1554
+ onPermissionModeChange?.(mode);
1249
1555
  };
1250
1556
 
1251
1557
  const handleDefaultNotesAppChange = (app: DefaultNotesApp) => {
@@ -1331,7 +1637,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1331
1637
  <div className="flex flex-col md:flex-row md:min-h-[420px] flex-1 min-h-0 overflow-hidden">
1332
1638
  {/* Mobile: horizontal tab bar */}
1333
1639
  <nav className="md:hidden flex overflow-x-auto border-b border-border px-2 py-1.5 gap-1 flex-shrink-0">
1334
- {[...mainTabs, ...integrationTabs].map(tab => (
1640
+ {[...mainTabs, ...integrationTabs, ...experimentalTabs].map(tab => (
1335
1641
  <button
1336
1642
  key={tab.id}
1337
1643
  onClick={() => setActiveTab(tab.id)}
@@ -1386,6 +1692,26 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1386
1692
  </div>
1387
1693
  </>
1388
1694
  )}
1695
+ {experimentalTabs.length > 0 && (
1696
+ <>
1697
+ <div className="mx-2 my-2 border-t border-border/50" />
1698
+ <div className="space-y-0.5">
1699
+ {experimentalTabs.map(tab => (
1700
+ <button
1701
+ key={tab.id}
1702
+ onClick={() => setActiveTab(tab.id)}
1703
+ className={`w-full text-left px-3 py-1.5 rounded text-sm transition-colors ${
1704
+ activeTab === tab.id
1705
+ ? 'bg-primary/10 text-primary font-medium'
1706
+ : 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
1707
+ }`}
1708
+ >
1709
+ {tab.label}
1710
+ </button>
1711
+ ))}
1712
+ </div>
1713
+ </>
1714
+ )}
1389
1715
  </nav>
1390
1716
 
1391
1717
  {/* Content — scrollable */}
@@ -1447,6 +1773,22 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1447
1773
  </div>
1448
1774
  </div>
1449
1775
 
1776
+ {/* Enter saves a comment (#1934): opt-in, cookie-only.
1777
+ Governs every comment composer; never the decision keys. */}
1778
+ {!hideEnterSavesComment && (
1779
+ <>
1780
+ <div className="border-t border-border" />
1781
+ <div data-enter-saves-comment-setting="true">
1782
+ <ToggleSwitch
1783
+ checked={enterSavesComment}
1784
+ onChange={(v) => configStore.set('enterSavesComment', v)}
1785
+ label="Enter saves a comment"
1786
+ description={`Shift+Enter adds a new line. ${modKeyWord}+Enter still saves.`}
1787
+ />
1788
+ </div>
1789
+ </>
1790
+ )}
1791
+
1450
1792
  {/* Update notices: on by default. Writes updateCheck to
1451
1793
  ~/.plannotator/config.json; off makes no request to GitHub. */}
1452
1794
  {updateCheckSetting && (
@@ -1460,7 +1802,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1460
1802
  description={
1461
1803
  updateCheckSetting.env !== undefined
1462
1804
  ? 'Set by PLANNOTATOR_UPDATE_CHECK in your environment.'
1463
- : 'This computer asks GitHub once a day, never your browser. Off makes no request at all.'
1805
+ : 'This computer asks GitHub every few minutes, never your browser. Off makes no request at all.'
1464
1806
  }
1465
1807
  />
1466
1808
  </>
@@ -1488,6 +1830,28 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1488
1830
  </>
1489
1831
  )}
1490
1832
 
1833
+ {/* Open Plannotator at plannotator.localhost (#2184): writes or
1834
+ removes urlHost "plannotator.localhost" in config.json.
1835
+ Locked while PLANNOTATOR_URL_HOST is set or config.json
1836
+ holds another urlHost, which it never replaces. */}
1837
+ {cookieHostSetting && (
1838
+ <>
1839
+ <div className="border-t border-border" />
1840
+ <div data-cookie-host-setting={cookieHostSettingState(cookieHostSetting)}>
1841
+ <ToggleSwitch
1842
+ checked={cookieHostSetting.on}
1843
+ onChange={(v) => cookieHostSetting.onChange(v)}
1844
+ disabled={cookieHostSetting.env !== undefined || cookieHostSetting.urlHost !== undefined}
1845
+ label="Open Plannotator at plannotator.localhost"
1846
+ description={cookieHostDescription(cookieHostSetting)}
1847
+ />
1848
+ {cookieHostSetting.error && (
1849
+ <div role="alert" className="mt-1 text-xs text-destructive">{cookieHostSetting.error}</div>
1850
+ )}
1851
+ </div>
1852
+ </>
1853
+ )}
1854
+
1491
1855
  {/* The `plannotator` agent tool switch: only where the
1492
1856
  server reports a tool host (Claude Code mod, Pi,
1493
1857
  OpenCode 2). Writes agentTool to config.json; applies
@@ -1709,9 +2073,9 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1709
2073
  {/* === THEME TAB === */}
1710
2074
  {activeTab === 'theme' && <ThemeTab onPreview={() => { setShowDialog(false); setThemePreview(true); }} />}
1711
2075
 
1712
- {/* === GIT TAB === */}
1713
- {activeTab === 'git' && mode === 'review' && (
1714
- <GitTab sinceBaseUnavailable={sinceBaseUnavailable} />
2076
+ {/* === REVIEW TAB === */}
2077
+ {activeTab === 'review' && mode === 'review' && (
2078
+ <ReviewTab sinceBaseUnavailable={sinceBaseUnavailable} />
1715
2079
  )}
1716
2080
 
1717
2081
  {/* === DISPLAY TAB === */}
@@ -1719,12 +2083,64 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1719
2083
  <ReviewDisplayTab isCompactTouchLayout={isCompactTouchLayout} />
1720
2084
  )}
1721
2085
 
2086
+ {activeTab === 'experimental' && mode === 'review' && <ExperimentalTab />}
2087
+
1722
2088
  {activeTab === 'analysis' && mode === 'review' && (
1723
- <ReviewAnalysisTab />
2089
+ <ReviewAnalysisTab packageChangesSetting={packageChangesSetting} />
1724
2090
  )}
1725
2091
 
1726
2092
  {activeTab === 'display' && mode !== 'review' && (
1727
2093
  <>
2094
+ {/* Text size (#2066): the document only, not the app around it */}
2095
+ {showDocumentTextSize && (<>
2096
+ <div className="space-y-3" data-document-text-size-setting>
2097
+ <div>
2098
+ <div className="text-sm font-medium" id="document-text-size-label">Text size</div>
2099
+ <div className="text-xs text-muted-foreground">
2100
+ {mode === 'annotate' ? 'Body text of the document' : 'Body text of the plan'}, in px ({DEFAULT_DOCUMENT_TEXT_SIZE} is the default)
2101
+ </div>
2102
+ </div>
2103
+ <div className="flex items-center gap-1 bg-muted/50 rounded-lg p-0.5" role="group" aria-labelledby="document-text-size-label">
2104
+ {DOCUMENT_TEXT_SIZE_OPTIONS.map((value) => (
2105
+ <button
2106
+ key={value}
2107
+ onClick={() => configStore.set('documentTextSize', value)}
2108
+ aria-pressed={documentTextSize === value}
2109
+ className={`flex-1 px-3 py-1.5 text-xs rounded-md transition-colors tabular-nums ${
2110
+ documentTextSize === value
2111
+ ? 'bg-background text-foreground shadow-sm font-medium'
2112
+ : 'text-muted-foreground hover:text-foreground'
2113
+ }`}
2114
+ >
2115
+ {value}
2116
+ </button>
2117
+ ))}
2118
+ </div>
2119
+ </div>
2120
+
2121
+ <div className="border-t border-border" />
2122
+ </>)}
2123
+
2124
+ {/* Document font (#1657): the document only; code keeps its font */}
2125
+ {showDocumentFont && (<>
2126
+ <div data-document-font-setting>
2127
+ <FontFamilyField
2128
+ label="Document font"
2129
+ description={`${mode === 'annotate' ? 'Text of the document' : 'Text of the plan'}; code keeps its monospace font`}
2130
+ value={documentFontFamily}
2131
+ onSave={(v) => configStore.set('documentFontFamily', v)}
2132
+ options={DOCUMENT_FONT_OPTIONS}
2133
+ previewStack={documentFontStack}
2134
+ previewText="Preview: The quick brown fox jumps over the lazy dog."
2135
+ customAriaLabel="Custom document font name"
2136
+ customPlaceholder="Font name as installed, e.g. Iowan Old Style"
2137
+ notFoundText="Not found — the document keeps the theme font"
2138
+ />
2139
+ </div>
2140
+
2141
+ <div className="border-t border-border" />
2142
+ </>)}
2143
+
1728
2144
  {/* Auto-open Sidebar */}
1729
2145
  <div className="flex items-center justify-between">
1730
2146
  <div>
@@ -1783,8 +2199,12 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
1783
2199
  <div className="text-sm font-medium">Grid Background</div>
1784
2200
  <div className="text-xs text-muted-foreground">Show the plan as a floating card on a grid</div>
1785
2201
  </div>
1786
- <button role="switch" aria-checked={gridEnabled}
1787
- onClick={() => configStore.set('gridEnabled', !gridEnabled)}
2202
+ <button role="switch" aria-checked={gridEnabled} aria-label="Grid Background"
2203
+ onClick={() => {
2204
+ configStore.set('gridEnabled', !gridEnabled);
2205
+ // A Settings flip is a real choice: keep it across reloads.
2206
+ markLookAndFeelChoiceResolved();
2207
+ }}
1788
2208
  className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${gridEnabled ? 'bg-primary' : 'bg-muted'}`}>
1789
2209
  <span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${gridEnabled ? 'translate-x-6' : 'translate-x-1'}`} />
1790
2210
  </button>
@@ -2014,13 +2434,14 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
2014
2434
  {obsidianDefaultSaveAvailable && <option value="obsidian">Obsidian</option>}
2015
2435
  {bearDefaultSaveAvailable && <option value="bear">Bear</option>}
2016
2436
  {octarineDefaultSaveAvailable && <option value="octarine">Octarine</option>}
2437
+ {(notionDefaultSaveAvailable || defaultNotesApp === 'notion') && <option value="notion">Notion</option>}
2017
2438
  </select>
2018
2439
  <div className="text-[10px] text-muted-foreground/70">
2019
2440
  {defaultNotesApp === 'ask'
2020
2441
  ? 'Opens Export dialog with Notes tab'
2021
2442
  : defaultNotesApp === 'download'
2022
2443
  ? `${modKey}+S downloads the annotations file`
2023
- : `${modKey}+S saves directly to ${{ obsidian: 'Obsidian', bear: 'Bear', octarine: 'Octarine' }[defaultNotesApp] ?? defaultNotesApp}`}
2444
+ : `${modKey}+S saves directly to ${{ obsidian: 'Obsidian', bear: 'Bear', octarine: 'Octarine', notion: 'Notion' }[defaultNotesApp] ?? defaultNotesApp}`}
2024
2445
  </div>
2025
2446
  </div>
2026
2447
 
@@ -2156,7 +2577,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
2156
2577
  onClick={() => handleFileBrowserChange({
2157
2578
  directories: fileBrowserSettings.directories.filter((d) => d !== dir),
2158
2579
  })}
2159
- className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
2580
+ className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100"
2160
2581
  title="Remove directory"
2161
2582
  >
2162
2583
  <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -2574,6 +2995,76 @@ tags: [${mode === 'annotate' ? 'plannotator' : 'plan'}, ...]
2574
2995
  </>
2575
2996
  )}
2576
2997
 
2998
+ {/* === NOTION TAB === */}
2999
+ {activeTab === 'notion' && (
3000
+ <>
3001
+ <div className="flex items-center justify-between">
3002
+ <div>
3003
+ <div className="text-sm font-medium">Notion</div>
3004
+ <div className="text-xs text-muted-foreground">
3005
+ {mode === 'annotate' ? 'Save documents to Notion from the Options menu. When on, plan review also saves each approved plan here.' : 'Save approved plans as Notion pages'}
3006
+ </div>
3007
+ </div>
3008
+ <button
3009
+ role="switch"
3010
+ aria-checked={notion.enabled}
3011
+ onClick={() => handleNotionChange({ enabled: !notion.enabled })}
3012
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
3013
+ notion.enabled ? 'bg-primary' : 'bg-muted'
3014
+ }`}
3015
+ >
3016
+ <span
3017
+ className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
3018
+ notion.enabled ? 'translate-x-6' : 'translate-x-1'
3019
+ }`}
3020
+ />
3021
+ </button>
3022
+ </div>
3023
+ {notion.enabled && (
3024
+ <div className="mt-3 space-y-3">
3025
+ <div className="space-y-1.5 pl-0.5">
3026
+ <label className="text-xs text-muted-foreground">Parent Page</label>
3027
+ <input
3028
+ type="text"
3029
+ value={notion.parentPageId}
3030
+ onChange={(e) => handleNotionChange({ parentPageId: e.target.value })}
3031
+ placeholder="https://www.notion.so/..."
3032
+ className="w-full px-3 py-2 bg-muted rounded-lg text-xs font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary/50"
3033
+ />
3034
+ <div className="text-[10px] text-muted-foreground">
3035
+ A page link or ID. Each save adds a page under it. Share it with your Notion integration first.
3036
+ </div>
3037
+ </div>
3038
+
3039
+ {mode === 'plan' && (<>
3040
+ <div className="border-t border-border/30" />
3041
+
3042
+ <div className="flex items-center justify-between">
3043
+ <div>
3044
+ <div className="text-xs font-medium">Auto-save on Plan Arrival</div>
3045
+ <div className="text-[10px] text-muted-foreground">
3046
+ Automatically save to Notion when a plan loads, before you approve or deny
3047
+ </div>
3048
+ </div>
3049
+ <button
3050
+ role="switch"
3051
+ aria-checked={notion.autoSave}
3052
+ onClick={() => handleNotionChange({ autoSave: !notion.autoSave })}
3053
+ className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${
3054
+ notion.autoSave ? 'bg-primary' : 'bg-muted'
3055
+ }`}
3056
+ >
3057
+ <span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
3058
+ notion.autoSave ? 'translate-x-6' : 'translate-x-1'
3059
+ }`} />
3060
+ </button>
3061
+ </div>
3062
+ </>)}
3063
+ </div>
3064
+ )}
3065
+ </>
3066
+ )}
3067
+
2577
3068
  </div>
2578
3069
  </OverlayScrollArea>
2579
3070
  </div>