@plannotator/ui 0.27.0 → 0.29.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 (106) hide show
  1. package/README.md +22 -0
  2. package/components/AISettingsTab.tsx +5 -4
  3. package/components/ActionMenu.tsx +5 -1
  4. package/components/AgentsTab.tsx +10 -23
  5. package/components/AnnotationPanel.tsx +9 -5
  6. package/components/AnnotationToolbar.tsx +5 -13
  7. package/components/AnnotationToolstrip.tsx +2 -2
  8. package/components/ApproveDropdown.tsx +1 -1
  9. package/components/BlockRenderer.tsx +1 -1
  10. package/components/CodeFilePopout.tsx +5 -4
  11. package/components/CommentPopover.tsx +391 -65
  12. package/components/DocBadges.tsx +29 -11
  13. package/components/ExportModal.tsx +16 -8
  14. package/components/GraphvizBlock.tsx +1 -1
  15. package/components/InlineMarkdown.tsx +30 -13
  16. package/components/KeyboardShortcuts.tsx +37 -2
  17. package/components/Landing.tsx +7 -7
  18. package/components/MarkdownDiff.tsx +60 -0
  19. package/components/MenuVersionSection.tsx +4 -4
  20. package/components/MermaidBlock.tsx +1 -1
  21. package/components/ModeToggle.tsx +7 -6
  22. package/components/OpenInAppButton.tsx +2 -5
  23. package/components/PinpointOverlay.tsx +9 -7
  24. package/components/PlanHeaderMenu.tsx +8 -8
  25. package/components/PopoutDialog.tsx +6 -1
  26. package/components/ResizeHandle.tsx +1 -0
  27. package/components/Settings.tsx +172 -12
  28. package/components/SkillReferenceMenu.tsx +260 -0
  29. package/components/StickyHeaderLane.tsx +7 -0
  30. package/components/ThemeProvider.tsx +131 -32
  31. package/components/ThemeTab.tsx +123 -77
  32. package/components/ToolbarButtons.tsx +29 -8
  33. package/components/Viewer.tsx +396 -130
  34. package/components/VimKeyHud.tsx +695 -0
  35. package/components/VimModeAnnouncementDialog.tsx +557 -0
  36. package/components/VimModeOverlay.tsx +235 -0
  37. package/components/VimTargetReticle.tsx +284 -0
  38. package/components/ai/DocumentAIChatPanel.tsx +1 -1
  39. package/components/blocks/CodeBlock.tsx +18 -18
  40. package/components/blocks/TablePopout.tsx +7 -8
  41. package/components/blocks/TableToolbar.tsx +7 -8
  42. package/components/goal-setup/GoalSetupSurface.tsx +16 -3
  43. package/components/html-viewer/HtmlViewer.tsx +450 -47
  44. package/components/html-viewer/annotationNumbering.ts +37 -0
  45. package/components/html-viewer/bridge-script.ts +4051 -298
  46. package/components/html-viewer/composerYield.ts +51 -0
  47. package/components/html-viewer/srcdoc.ts +18 -3
  48. package/components/html-viewer/useHtmlAnnotation.ts +457 -32
  49. package/components/icons/themeIcons.tsx +1 -1
  50. package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
  51. package/components/plan-diff/PlanDiffBadge.tsx +22 -1
  52. package/components/settings/HooksTab.tsx +12 -8
  53. package/components/sidebar/FileBrowser.tsx +4 -1
  54. package/components/themeModes.tsx +28 -0
  55. package/config/configStore.ts +76 -1
  56. package/config/settings.ts +152 -0
  57. package/configure.ts +9 -0
  58. package/globals.d.ts +7 -1
  59. package/hooks/useAIChat.ts +5 -2
  60. package/hooks/useAIProviderActivation.ts +47 -0
  61. package/hooks/useAIProviderConfig.ts +5 -1
  62. package/hooks/useAgentSettings.ts +64 -23
  63. package/hooks/useAgents.ts +4 -4
  64. package/hooks/useAnnotationHighlighter.ts +100 -3
  65. package/hooks/useArchive.ts +2 -1
  66. package/hooks/useFenceTheme.ts +17 -0
  67. package/hooks/useLinkedDoc.ts +68 -1
  68. package/hooks/usePinpoint.ts +76 -75
  69. package/hooks/usePlanDiff.ts +73 -2
  70. package/hooks/useSkillReferenceAutocomplete.ts +239 -0
  71. package/hooks/useUpdateCheck.ts +1 -2
  72. package/hooks/useVimDocumentFocus.ts +116 -0
  73. package/hooks/useVimSelection.ts +1063 -0
  74. package/package.json +7 -6
  75. package/print.css +14 -13
  76. package/shortcuts/core.ts +38 -13
  77. package/shortcuts/index.ts +10 -0
  78. package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
  79. package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
  80. package/shortcuts/runtime.ts +111 -12
  81. package/styles.css +1 -1
  82. package/theme.css +504 -0
  83. package/themes/colorblind.css +89 -0
  84. package/themes/plannotator.css +2 -2
  85. package/types.ts +93 -10
  86. package/utils/agentSwitch.ts +33 -7
  87. package/utils/blockTargeting.ts +462 -178
  88. package/utils/clipboard.ts +110 -0
  89. package/utils/codeBlockMark.ts +50 -0
  90. package/utils/codeHighlight.ts +293 -0
  91. package/utils/codexModels.ts +79 -0
  92. package/utils/domSelection.ts +84 -0
  93. package/utils/htmlChrome.ts +73 -0
  94. package/utils/inputMethod.ts +79 -6
  95. package/utils/parser.ts +517 -21
  96. package/utils/preferenceTtl.ts +15 -0
  97. package/utils/sharing.ts +0 -1
  98. package/utils/skillCatalog.ts +269 -0
  99. package/utils/skillReferences.ts +475 -0
  100. package/utils/syntaxTheme.ts +83 -0
  101. package/utils/themeRegistry.ts +154 -0
  102. package/utils/vimHud.ts +263 -0
  103. package/utils/vimModeAnnouncement.ts +23 -0
  104. package/utils/vimNavigation.ts +417 -0
  105. package/utils/vimReticle.ts +88 -0
  106. package/utils/vimScroll.ts +162 -0
@@ -11,6 +11,7 @@ import { getObsidianSettings, getEffectiveVaultPath } from '../utils/obsidian';
11
11
  import { getBearSettings } from '../utils/bear';
12
12
  import { getOctarineSettings } from '../utils/octarine';
13
13
  import { wrapFeedbackForAgent } from '../utils/parser';
14
+ import { copyTextToClipboard } from '../utils/clipboard';
14
15
  import { OverlayScrollArea } from './OverlayScrollArea';
15
16
 
16
17
  /** POST body shape sent to the notes endpoint (mirrors what the Notes tab builds today). */
@@ -57,6 +58,13 @@ interface ExportModalProps {
57
58
  initialTab?: Tab;
58
59
  /** Override the save-to-notes wire. Default: POST /api/save-notes (today's behavior). */
59
60
  onSaveToNotes?: (payload: SaveToNotesPayload) => Promise<SaveToNotesResult>;
61
+ /**
62
+ * Wrap the annotations output for the clipboard Copy button. Annotate
63
+ * sessions pass a mode-aware wrapper so Copy matches Send Feedback instead
64
+ * of the plan-deny framing (#1107). Default: plan-deny wrap (today's
65
+ * plan-review behavior).
66
+ */
67
+ wrapCopiedAnnotations?: (feedback: string) => string;
60
68
  }
61
69
 
62
70
  type Tab = 'share' | 'annotations' | 'notes';
@@ -81,6 +89,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
81
89
  isApiMode = false,
82
90
  initialTab,
83
91
  onSaveToNotes = defaultSaveToNotes,
92
+ wrapCopiedAnnotations = wrapFeedbackForAgent,
84
93
  }) => {
85
94
  const defaultTab = initialTab || (sharingEnabled ? 'share' : 'annotations');
86
95
  const [activeTab, setActiveTab] = useState<Tab>(defaultTab);
@@ -115,17 +124,16 @@ export const ExportModal: React.FC<ExportModalProps> = ({
115
124
  const isOctarineReady = octarineSettings.enabled && octarineSettings.workspace.trim().length > 0;
116
125
 
117
126
  const handleCopy = async (text: string, which: 'short' | 'full' | 'annotations') => {
118
- try {
119
- await navigator.clipboard.writeText(text);
127
+ if (await copyTextToClipboard(text)) {
120
128
  setCopied(which);
121
129
  setTimeout(() => setCopied(false), 2000);
122
- } catch (e) {
123
- console.error('Failed to copy:', e);
130
+ } else {
131
+ console.error('Failed to copy');
124
132
  }
125
133
  };
126
134
 
127
135
  const handleCopyAnnotations = async () => {
128
- await handleCopy(wrapFeedbackForAgent(annotationsOutput), 'annotations');
136
+ await handleCopy(wrapCopiedAnnotations(annotationsOutput), 'annotations');
129
137
  };
130
138
 
131
139
  // Whether the hash URL is large enough to warrant a short URL option
@@ -309,7 +317,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
309
317
  </button>
310
318
  </div>
311
319
  <p className="text-[10px] text-muted-foreground mt-1">
312
- Encrypted short link. Your plan is end-to-end encrypted before it leaves your browser — not even the server can read it.
320
+ Encrypted short link. The browser uploads AES-256-GCM ciphertext; the decryption key stays in the URL fragment.
313
321
  </p>
314
322
  </div>
315
323
  ) : isGeneratingShortUrl ? (
@@ -376,13 +384,13 @@ export const ExportModal: React.FC<ExportModalProps> = ({
376
384
  </div>
377
385
  {!shortShareUrl && !isGeneratingShortUrl && !urlIsLarge && (
378
386
  <p className="text-[10px] text-muted-foreground mt-1">
379
- Your plan is encoded entirely in the URL — it never touches a server.
387
+ Shared content is compressed, not encrypted, in the URL fragment. The portal request does not include that fragment.
380
388
  </p>
381
389
  )}
382
390
  </div>}
383
391
 
384
392
  <p className="text-xs text-muted-foreground">
385
- Only someone with this exact link can view your plan. Short links are end-to-end encrypted — the decryption key is in the URL and never sent to the server.
393
+ Anyone with the full link can view the shared content, including services used to send the link. Hosted short-link ciphertext expires after 7 days.
386
394
  </p>
387
395
  </div>
388
396
  ) : activeTab === 'notes' && showNotesTab ? (
@@ -458,7 +458,7 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
458
458
 
459
459
  const inlineSource = (
460
460
  <pre className="rounded-lg text-[13px] overflow-x-auto bg-muted/50 border border-border/30 p-4">
461
- <code className={`hljs font-mono language-${block.language ?? 'graphviz'}`}>{block.content}</code>
461
+ <code className={`pn-code font-mono language-${block.language ?? 'graphviz'}`}>{block.content}</code>
462
462
  </pre>
463
463
  );
464
464
 
@@ -1,7 +1,8 @@
1
1
  import React, { useState, useRef, useCallback, useEffect, useMemo } from "react";
2
2
  import { createPortal } from "react-dom";
3
- import hljs from "highlight.js";
4
3
  import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePath } from "@plannotator/core/code-file";
4
+ import { ensureHighlight, highlightToHtml } from "../utils/codeHighlight";
5
+ import { useFenceTheme } from "../hooks/useFenceTheme";
5
6
  import { transformPlainText } from "../utils/inlineTransforms";
6
7
  import { getImageSrc } from "./ImageThumbnail";
7
8
  import { useCodePathValidation, type CodePathValidationContextValue } from "./CodePathValidationContext";
@@ -93,18 +94,34 @@ const CodeSnippetPreview: React.FC<{
93
94
  const end = Math.min(allLines.length, (lineEnd ?? line));
94
95
  const snippet = allLines.slice(start, end).join('\n');
95
96
 
96
- const highlightedLines = useMemo(() => {
97
- const lang = extToLanguage(filepath);
98
- const lines = snippet.split('\n');
99
- return lines.map(line => {
100
- try {
101
- if (lang) return hljs.highlight(line, { language: lang }).value;
102
- return hljs.highlightAuto(line).value;
103
- } catch {
104
- return line.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
105
- }
97
+ const fenceTheme = useFenceTheme();
98
+ // Bumped once the grammar is attached, to re-run the memo below with the
99
+ // highlighter warm. Until then the snippet renders as plain text — no
100
+ // auto-detection, and an unknown extension simply stays plain.
101
+ const [highlighterGeneration, setHighlighterGeneration] = useState(0);
102
+ const lang = extToLanguage(filepath);
103
+
104
+ useEffect(() => {
105
+ if (!lang) return;
106
+ let cancelled = false;
107
+ void ensureHighlight(lang, fenceTheme).then((ok) => {
108
+ if (ok && !cancelled) setHighlighterGeneration((n) => n + 1);
106
109
  });
107
- }, [snippet, filepath]);
110
+ return () => { cancelled = true; };
111
+ }, [lang, fenceTheme]);
112
+
113
+ const highlightedLines = useMemo(() => {
114
+ // Highlight the snippet as one unit so multi-line constructs (block
115
+ // comments, template literals) tokenise correctly, then split back into
116
+ // rows: the highlighter joins lines with "\n" and never emits one inside a
117
+ // span, so the split is exact.
118
+ const html = lang ? highlightToHtml(snippet, lang, fenceTheme) : null;
119
+ if (html !== null) return html.split('\n');
120
+ return snippet.split('\n').map(line =>
121
+ line.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'));
122
+ // highlighterGeneration is the "grammar just became available" signal.
123
+ // eslint-disable-next-line react-hooks/exhaustive-deps
124
+ }, [snippet, lang, fenceTheme, highlighterGeneration]);
108
125
 
109
126
  if (!anchorEl) return null;
110
127
 
@@ -126,7 +143,7 @@ const CodeSnippetPreview: React.FC<{
126
143
  <span>{filepath.split('/').pop()}</span>
127
144
  <span className="opacity-60">{lineEnd && lineEnd !== line ? `lines ${line}–${lineEnd}` : `line ${line}`}</span>
128
145
  </div>
129
- <div className="hljs code-snippet-preview overflow-auto text-[12px] leading-5 min-h-0" style={{ padding: 0, background: 'var(--code-bg, #1e293b)' }}>
146
+ <div className="code-snippet-preview overflow-auto text-[12px] leading-5 min-h-0" style={{ padding: 0, background: 'var(--code-bg, #1e293b)' }}>
130
147
  <table className="border-collapse w-full">
131
148
  <tbody>
132
149
  {snippet.split('\n').map((_, i) => (
@@ -1,4 +1,9 @@
1
1
  import React from 'react';
2
+ import {
3
+ formatShortcutBindingTokens,
4
+ listScopeShortcuts,
5
+ vimSelectionShortcuts,
6
+ } from '../shortcuts';
2
7
  import { isMac, modKey, altKey } from '../utils/platform';
3
8
 
4
9
  /* ─── Key cap component ─── */
@@ -108,6 +113,24 @@ const sharedPlanEditorShortcuts: ShortcutSection[] = [
108
113
  imageAnnotatorShortcuts,
109
114
  ];
110
115
 
116
+ const vimShortcuts: ShortcutSection[] = (() => {
117
+ const sections = new Map<string, Shortcut[]>();
118
+ const entries = listScopeShortcuts(vimSelectionShortcuts)
119
+ .sort((left, right) => (left.displayOrder ?? 0) - (right.displayOrder ?? 0));
120
+
121
+ for (const entry of entries) {
122
+ const shortcuts = sections.get(entry.section) ?? [];
123
+ shortcuts.push({
124
+ keys: formatShortcutBindingTokens(entry.bindings[0] ?? ''),
125
+ desc: entry.description,
126
+ hint: entry.hint,
127
+ });
128
+ sections.set(entry.section, shortcuts);
129
+ }
130
+
131
+ return Array.from(sections, ([title, shortcuts]) => ({ title, shortcuts }));
132
+ })();
133
+
111
134
  const planActionShortcuts: ShortcutSection = {
112
135
  title: 'Actions',
113
136
  shortcuts: [
@@ -152,6 +175,7 @@ const reviewShortcuts: ShortcutSection[] = [
152
175
  title: 'Actions',
153
176
  shortcuts: [
154
177
  { keys: [modKey, enter], desc: 'Approve / Send feedback' },
178
+ { keys: [modKey, shiftKey, 'Y'], desc: 'Copy feedback', hint: 'Copies the same feedback that gets submitted' },
155
179
  { keys: [altKey, altKey], desc: 'Toggle destination', hint: 'Double-tap to switch between GitHub and Agent in PR review mode' },
156
180
  { keys: [modKey, 'B'], desc: 'Toggle file tree' },
157
181
  { keys: [modKey, '.'], desc: 'Toggle sidebar' },
@@ -191,8 +215,19 @@ const reviewShortcuts: ShortcutSection[] = [
191
215
 
192
216
  /* ─── Exported panel ─── */
193
217
 
194
- export const KeyboardShortcuts: React.FC<{ mode: 'plan' | 'annotate' | 'review' }> = ({ mode }) => {
195
- const sections = mode === 'review' ? reviewShortcuts : mode === 'annotate' ? annotateShortcuts : planShortcuts;
218
+ /** Render the surface shortcut reference, including opt-in Vim commands. */
219
+ export const KeyboardShortcuts: React.FC<{
220
+ mode: 'plan' | 'annotate' | 'review';
221
+ vimModeEnabled?: boolean;
222
+ }> = ({ mode, vimModeEnabled = false }) => {
223
+ const baseSections = mode === 'review'
224
+ ? reviewShortcuts
225
+ : mode === 'annotate'
226
+ ? annotateShortcuts
227
+ : planShortcuts;
228
+ const sections = vimModeEnabled && mode !== 'review'
229
+ ? [...vimShortcuts, ...baseSections]
230
+ : baseSections;
196
231
 
197
232
  return (
198
233
  <div className="space-y-4">
@@ -197,8 +197,8 @@ export const Landing: React.FC<LandingProps> = ({ onEnter, shareBaseUrl }) => {
197
197
  <div>
198
198
  <h3 className="font-semibold mb-1">Runs locally.</h3>
199
199
  <p className="text-muted-foreground text-sm">
200
- Local plugin. No network requests. Plannotator runs entirely
201
- in your browser. Plans never leave your machine.
200
+ Reviews and saved data stay local by default. Every load checks
201
+ GitHub for updates, with no current opt-out, but sends no review content.
202
202
  </p>
203
203
  </div>
204
204
  </div>
@@ -219,11 +219,11 @@ export const Landing: React.FC<LandingProps> = ({ onEnter, shareBaseUrl }) => {
219
219
  </svg>
220
220
  </div>
221
221
  <div>
222
- <h3 className="font-semibold mb-1">Share privately.</h3>
222
+ <h3 className="font-semibold mb-1">Know what a link contains.</h3>
223
223
  <p className="text-muted-foreground text-sm">
224
- Collaborate without a backend. Plans and annotations compress
225
- into the URL itself — share a link. No accounts, no database,
226
- no third parties. Inspired by{" "}
224
+ Small shares contain compressed, unencrypted content in the
225
+ URL fragment. Short links upload encrypted ciphertext. Anyone
226
+ with the full link can read the shared content. Inspired by{" "}
227
227
  <a
228
228
  href="https://github.com/antonmedv/textarea"
229
229
  target="_blank"
@@ -418,7 +418,7 @@ export const Landing: React.FC<LandingProps> = ({ onEnter, shareBaseUrl }) => {
418
418
  </li>
419
419
  <li className="flex items-center gap-2">
420
420
  <span className="text-primary">•</span>
421
- URL sharing via deflate compression — no backend required
421
+ Hash-based URL sharing, with an optional encrypted paste service
422
422
  </li>
423
423
  </ul>
424
424
 
@@ -0,0 +1,60 @@
1
+ import React from 'react';
2
+ import {
3
+ MarkdownDiff as PackagedMarkdownDiff,
4
+ type MarkdownDiffHandle,
5
+ type MarkdownDiffProps as PackagedMarkdownDiffProps,
6
+ } from '@plannotator/markdown-editor';
7
+ import '@plannotator/markdown-editor/themes/plannotator.css';
8
+ import { useTheme } from './ThemeProvider';
9
+
10
+ export type { MarkdownDiffHandle };
11
+
12
+ /* @plannotator/ui is the single supported contract for hosts — do NOT import
13
+ AtomicDiffEditor or @plannotator/atomic-editor directly (both are outside
14
+ the import allowlist). Extension builders (wikiLinks, slashCommands,
15
+ selectionToolbar) are re-exported from ./MarkdownEditor; build them there
16
+ and pass the result through the `extensions` prop below — the frozen diff
17
+ view composes them the same way the editor does. */
18
+
19
+ /* Grid-mode card utilities mirror components/MarkdownEditor.tsx exactly, so a
20
+ host toggling editor <-> diff keeps identical card chrome. They stay here
21
+ (not in the package) because they're Plannotator design-system Tailwind
22
+ classes and this file is @source-scanned. */
23
+ const GRID_CARD_CLASSES = 'px-5 md:px-8 lg:px-10 xl:px-12 shadow-xl border border-border/50';
24
+
25
+ export interface MarkdownDiffProps
26
+ extends Omit<PackagedMarkdownDiffProps, 'mode' | 'cardClassName'> {
27
+ /** Mirrors MarkdownEditor's grid card chrome so editor <-> diff doesn't jump. */
28
+ gridEnabled?: boolean;
29
+ /** Theme color mode. Defaults to the ThemeProvider's resolved mode (Plannotator
30
+ passes nothing); a host without ThemeProvider can supply it directly. */
31
+ mode?: PackagedMarkdownDiffProps['mode'];
32
+ }
33
+
34
+ /* Theme-bridging shim around @plannotator/markdown-editor's MarkdownDiff — the
35
+ frozen two-revision comparison surface (newer revision as the real document,
36
+ deletions projected struck-through in place). Same pattern as the
37
+ MarkdownEditor shim: resolve the color mode from ThemeProvider beneath the
38
+ host's provider and pass it down as a prop.
39
+
40
+ The byte contract lives on `editorHandleRef`: getMarkdown() returns the
41
+ exact `modifiedMarkdown` supplied, getOriginalMarkdown() the exact
42
+ `originalMarkdown` — both byte-identical to the inputs. The surface itself
43
+ is frozen: document-changing transactions are rejected at the state and
44
+ view boundaries, and the content DOM is contenteditable="false".
45
+
46
+ `extensions` follows the editor's calling convention: CAPTURED ONCE per
47
+ mounted comparison (keyed on `documentId` + both document strings) — pass a
48
+ stable array and feed changing data through callbacks that close over live
49
+ state. Build extensions against YOUR copy of the `@codemirror/*` packages;
50
+ two live copies of `@codemirror/state` break the view. */
51
+ export const MarkdownDiff: React.FC<MarkdownDiffProps> = ({ gridEnabled, mode, ...props }) => {
52
+ const { resolvedMode } = useTheme();
53
+ return (
54
+ <PackagedMarkdownDiff
55
+ {...props}
56
+ mode={mode ?? resolvedMode}
57
+ cardClassName={gridEnabled ? GRID_CARD_CLASSES : undefined}
58
+ />
59
+ );
60
+ };
@@ -3,6 +3,7 @@ import { TextShimmer } from './TextShimmer';
3
3
  import type { UpdateInfo } from '../hooks/useUpdateCheck';
4
4
  import type { Origin } from '@plannotator/core/agents';
5
5
  import { isWindows } from '../utils/platform';
6
+ import { copyTextToClipboard } from '../utils/clipboard';
6
7
 
7
8
  const PI_INSTALL_COMMAND = 'pi install npm:@plannotator/pi-extension';
8
9
 
@@ -32,12 +33,11 @@ export const MenuVersionSection: React.FC<MenuVersionSectionProps> = ({
32
33
  const hasUpdate = !!updateInfo?.updateAvailable;
33
34
 
34
35
  const handleCopy = async () => {
35
- try {
36
- await navigator.clipboard.writeText(getInstallCommand(origin, isWSL));
36
+ if (await copyTextToClipboard(getInstallCommand(origin, isWSL))) {
37
37
  setCopied(true);
38
38
  setTimeout(() => setCopied(false), 2000);
39
- } catch (e) {
40
- console.error('Failed to copy:', e);
39
+ } else {
40
+ console.error('Failed to copy');
41
41
  }
42
42
  };
43
43
 
@@ -523,7 +523,7 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
523
523
 
524
524
  const inlineSource = (
525
525
  <pre className="rounded-lg text-[13px] overflow-x-auto bg-muted/50 border border-border/30 p-4">
526
- <code className="hljs font-mono language-mermaid">{block.content}</code>
526
+ <code className="pn-code font-mono language-mermaid">{block.content}</code>
527
527
  </pre>
528
528
  );
529
529
 
@@ -1,5 +1,6 @@
1
1
  import React, { useState, useRef, useEffect } from 'react';
2
2
  import { useTheme } from './ThemeProvider';
3
+ import { THEME_MODES } from './themeModes';
3
4
 
4
5
  export function ModeToggle() {
5
6
  const { theme, setTheme } = useTheme();
@@ -48,17 +49,17 @@ export function ModeToggle() {
48
49
 
49
50
  {isOpen && (
50
51
  <div className="absolute right-0 mt-1 w-32 rounded-lg border border-border bg-popover shadow-xl z-50 overflow-hidden py-1">
51
- {(['light', 'dark', 'system'] as const).map((t) => (
52
+ {THEME_MODES.map(({ id, label }) => (
52
53
  <button
53
- key={t}
54
- onClick={() => { setTheme(t); setIsOpen(false); }}
55
- className={`w-full px-3 py-1.5 text-left text-xs capitalize transition-colors ${
56
- theme === t
54
+ key={id}
55
+ onClick={() => { setTheme(id); setIsOpen(false); }}
56
+ className={`w-full px-3 py-1.5 text-left text-xs transition-colors ${
57
+ theme === id
57
58
  ? 'text-primary bg-primary/10 font-medium'
58
59
  : 'text-popover-foreground hover:bg-muted'
59
60
  }`}
60
61
  >
61
- {t}
62
+ {label}
62
63
  </button>
63
64
  ))}
64
65
  </div>
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
2
2
  import { ChevronDown, Check, Copy, MoreHorizontal } from 'lucide-react';
3
3
  import { AppIcon } from './icons/AppIcon';
4
4
  import { getLastOpenInApp, setLastOpenInApp } from '../utils/storage';
5
+ import { copyTextToClipboard } from '../utils/clipboard';
5
6
  import type { OpenInKind } from '@plannotator/core/open-in-apps';
6
7
  import {
7
8
  DropdownMenu,
@@ -163,11 +164,7 @@ export const OpenInAppButton: React.FC<OpenInAppButtonProps> = ({
163
164
  };
164
165
 
165
166
  const copyText = async (text: string) => {
166
- try {
167
- await navigator.clipboard.writeText(text);
168
- } catch {
169
- /* ignore */
170
- }
167
+ await copyTextToClipboard(text);
171
168
  setMenuOpen(false);
172
169
  };
173
170
 
@@ -65,22 +65,24 @@ export const PinpointOverlay: React.FC<PinpointOverlayProps> = ({ target, contai
65
65
 
66
66
  return (
67
67
  <>
68
- {/* Outline box */}
68
+ {/* Background wash */}
69
69
  <div
70
- className="border-2 border-dashed border-primary/50 bg-primary/5 rounded"
70
+ data-pinpoint-overlay
71
+ className="bg-primary/10 rounded-sm"
71
72
  style={{
72
73
  position: 'absolute',
73
- top: position.top - 2,
74
- left: position.left - 2,
75
- width: position.width + 4,
76
- height: position.height + 4,
74
+ top: position.top,
75
+ left: position.left,
76
+ width: position.width,
77
+ height: position.height,
77
78
  pointerEvents: 'none',
78
79
  zIndex: 20,
79
- transition: 'all 100ms ease-out',
80
+ transition: 'top 100ms ease-out, left 100ms ease-out, width 100ms ease-out, height 100ms ease-out',
80
81
  }}
81
82
  />
82
83
  {/* Label badge */}
83
84
  <div
85
+ data-pinpoint-label={target.label}
84
86
  style={{
85
87
  position: 'absolute',
86
88
  top: position.top - 22,
@@ -6,7 +6,7 @@ import {
6
6
  ActionMenuSectionLabel,
7
7
  } from './ActionMenu';
8
8
  import { useTheme } from './ThemeProvider';
9
- import { SunIcon, MoonIcon, SystemIcon } from './icons/themeIcons';
9
+ import { THEME_MODES } from './themeModes';
10
10
  import { ReviewAgentsIcon } from './ReviewAgentsIcon';
11
11
  import { MenuVersionSection } from './MenuVersionSection';
12
12
  import { TextShimmer } from './TextShimmer';
@@ -67,6 +67,7 @@ export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({
67
67
 
68
68
  return (
69
69
  <ActionMenu
70
+ panelWidth="wide"
70
71
  renderTrigger={({ isOpen, toggleMenu }) => (
71
72
  <button
72
73
  onClick={() => {
@@ -101,21 +102,21 @@ export const PlanHeaderMenu: React.FC<PlanHeaderMenuProps> = ({
101
102
  <div className="px-3 py-2 space-y-1.5">
102
103
  <ActionMenuSectionLabel>Theme</ActionMenuSectionLabel>
103
104
  <div className="flex items-center gap-1 rounded-lg bg-muted/50 p-0.5">
104
- {(['light', 'dark', 'system'] as const).map((mode) => (
105
+ {THEME_MODES.map(({ id, label, Icon }) => (
105
106
  <button
106
- key={mode}
107
+ key={id}
107
108
  onClick={() => {
108
109
  closeMenu();
109
- setTheme(mode);
110
+ setTheme(id);
110
111
  }}
111
112
  className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium transition-colors ${
112
- theme === mode
113
+ theme === id
113
114
  ? 'bg-background text-foreground shadow-sm'
114
115
  : 'text-muted-foreground hover:text-foreground'
115
116
  }`}
116
117
  >
117
- {mode === 'light' ? <SunIcon /> : mode === 'dark' ? <MoonIcon /> : <SystemIcon />}
118
- <span className="capitalize">{mode}</span>
118
+ <Icon />
119
+ <span>{label}</span>
119
120
  </button>
120
121
  ))}
121
122
  </div>
@@ -296,4 +297,3 @@ const NoteIcon = () => (
296
297
  <path strokeLinecap="round" strokeLinejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
297
298
  </svg>
298
299
  );
299
-
@@ -1,10 +1,15 @@
1
1
  import React, { useCallback } from 'react';
2
2
  import { Dialog } from '@base-ui/react/dialog';
3
3
 
4
- const ANNOTATION_SELECTORS = [
4
+ export const ANNOTATION_SELECTORS = [
5
5
  '.annotation-toolbar',
6
6
  '[data-comment-popover="true"]',
7
7
  '[data-floating-picker="true"]',
8
+ // Transient hidden textarea created by the legacy clipboard fallback
9
+ // (packages/ui/utils/clipboard.ts). It briefly steals focus during copy;
10
+ // without this guard the focus-out close reason would dismiss the popout
11
+ // whenever a copy button inside it falls back to execCommand.
12
+ '[data-clipboard-fallback="true"]',
8
13
  ];
9
14
 
10
15
  interface PopoutDialogProps {
@@ -89,6 +89,7 @@ export const ResizeHandle: React.FC<Props> = ({
89
89
  portaled to body so the zero-width parent doesn't clip it. */}
90
90
  {showTip && typeof document !== 'undefined' && createPortal(
91
91
  <div
92
+ data-print-hide
92
93
  style={{ position: 'fixed', left: tip.x + 14, top: tip.y + 16, zIndex: 100 }}
93
94
  className="pointer-events-none whitespace-nowrap rounded-md bg-surface-1 px-2 py-1 text-xs text-foreground shadow-md ring-1 ring-border/60"
94
95
  >