@plannotator/ui 0.30.0 → 0.32.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 (102) hide show
  1. package/README.md +46 -1
  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 +158 -36
  6. package/components/AnnotationToolbar.tsx +50 -30
  7. package/components/AnnotationToolstrip.tsx +9 -0
  8. package/components/CommentPopover.tsx +238 -46
  9. package/components/ConfirmDialog.tsx +42 -28
  10. package/components/GraphvizBlock.tsx +86 -7
  11. package/components/HtmlSurfaceControls.tsx +170 -0
  12. package/components/InlineMarkdown.tsx +25 -4
  13. package/components/KeyboardShortcuts.tsx +9 -0
  14. package/components/Landing.tsx +1 -1
  15. package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
  16. package/components/MarkdownEditor/embedPicker.ts +349 -0
  17. package/components/MarkdownEditor.tsx +12 -0
  18. package/components/MermaidBlock.tsx +60 -26
  19. package/components/ModeToggle.tsx +2 -1
  20. package/components/PermissionModeSetup.tsx +24 -5
  21. package/components/PinpointOverlay.tsx +11 -6
  22. package/components/PlanHeaderMenu.tsx +140 -1
  23. package/components/SearchableSelect.tsx +2 -0
  24. package/components/Settings.tsx +175 -10
  25. package/components/SkillReferenceMenu.tsx +9 -0
  26. package/components/StickyHeaderLane.tsx +9 -2
  27. package/components/TableOfContents.tsx +9 -4
  28. package/components/TextShimmer.tsx +8 -5
  29. package/components/ThemeProvider.tsx +43 -1
  30. package/components/ThemeTab.tsx +52 -1
  31. package/components/Tooltip.tsx +3 -1
  32. package/components/Viewer.tsx +19 -5
  33. package/components/VimTargetReticle.tsx +12 -4
  34. package/components/ai/DocumentAIChatPanel.tsx +1 -0
  35. package/components/blocks/MathBlock.tsx +26 -14
  36. package/components/core/button.tsx +14 -6
  37. package/components/html-viewer/HtmlViewer.tsx +320 -41
  38. package/components/html-viewer/bridge-script.ts +360 -72
  39. package/components/html-viewer/composerYield.ts +1 -51
  40. package/components/html-viewer/hostThreads.ts +37 -0
  41. package/components/html-viewer/index.ts +9 -0
  42. package/components/html-viewer/unanchored.ts +47 -0
  43. package/components/html-viewer/useHtmlAnnotation.ts +240 -61
  44. package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
  45. package/components/sidebar/FileBrowser.tsx +17 -5
  46. package/components/sidebar/SidebarContainer.tsx +124 -28
  47. package/components/ui/button.tsx +10 -8
  48. package/components/ui/dialog.tsx +35 -25
  49. package/config/index.ts +6 -1
  50. package/config/reviewView.ts +42 -9
  51. package/config/settings.ts +141 -0
  52. package/configure.ts +32 -0
  53. package/hooks/useAIProviderConfig.ts +8 -7
  54. package/hooks/useActiveSection.ts +6 -4
  55. package/hooks/useAgentJobs.ts +3 -0
  56. package/hooks/useAnnotationHighlighter.ts +20 -0
  57. package/hooks/useHtmlRefresh.ts +149 -0
  58. package/hooks/useIsMobile.ts +37 -0
  59. package/hooks/useLinkedDoc.ts +7 -0
  60. package/hooks/useMathRenderer.ts +30 -0
  61. package/hooks/useScrollViewport.ts +74 -0
  62. package/hooks/useSharing.ts +31 -5
  63. package/hooks/useViewportEnvironment.ts +350 -0
  64. package/package.json +5 -2
  65. package/shortcuts/index.ts +3 -0
  66. package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
  67. package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
  68. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
  69. package/styles.css +1 -1
  70. package/theme.css +229 -0
  71. package/types.ts +35 -0
  72. package/utils/annotateAgentTerminal.ts +36 -5
  73. package/utils/blockTargeting.ts +6 -3
  74. package/utils/composerYield.ts +45 -0
  75. package/utils/generateIdentity.ts +64 -14
  76. package/utils/htmlChrome.ts +20 -16
  77. package/utils/identity-tater.ts +36 -0
  78. package/utils/lookAndFeelAnnouncement.ts +12 -8
  79. package/utils/markdownExtensions.ts +57 -0
  80. package/utils/math-eager.ts +25 -0
  81. package/utils/math.ts +146 -0
  82. package/utils/mermaid-eager.ts +28 -0
  83. package/utils/mermaid.ts +132 -0
  84. package/utils/parser.ts +75 -2
  85. package/utils/quickLabels.ts +13 -0
  86. package/utils/vimNavigation.ts +4 -1
  87. package/utils/vimScroll.ts +9 -4
  88. package/utils/wideMode.ts +20 -0
  89. package/webmcp/activity.ts +46 -0
  90. package/webmcp/changes.ts +227 -0
  91. package/webmcp/index.ts +72 -0
  92. package/webmcp/modelContext.ts +103 -0
  93. package/webmcp/nudges.ts +174 -0
  94. package/webmcp/policy.ts +50 -0
  95. package/webmcp/preference.ts +50 -0
  96. package/webmcp/schema.ts +81 -0
  97. package/webmcp/toolset.ts +337 -0
  98. package/webmcp/useToolset.ts +74 -0
  99. package/components/PlanAIAnnouncementDialog.tsx +0 -187
  100. package/components/VimModeAnnouncementDialog.tsx +0 -557
  101. package/utils/planAIAnnouncement.ts +0 -17
  102. package/utils/vimModeAnnouncement.ts +0 -23
@@ -2,7 +2,8 @@
2
2
  * Reusable confirmation dialog component
3
3
  */
4
4
 
5
- import React, { useEffect } from 'react';
5
+ import React, { useEffect, useId, useRef } from 'react';
6
+ import { Dialog, DialogContent, DialogTitle } from './ui/dialog';
6
7
 
7
8
  export interface ConfirmDialogProps {
8
9
  isOpen: boolean;
@@ -31,6 +32,10 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
31
32
  showCancel = false,
32
33
  wide = false,
33
34
  }) => {
35
+ const descriptionId = useId();
36
+ const cancelButtonRef = useRef<HTMLButtonElement>(null);
37
+ const confirmButtonRef = useRef<HTMLButtonElement>(null);
38
+
34
39
  useEffect(() => {
35
40
  if (!isOpen) return;
36
41
  const handleKey = (event: KeyboardEvent) => {
@@ -40,11 +45,6 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
40
45
  if (onConfirm) onConfirm();
41
46
  else onClose();
42
47
  }
43
- if (event.key === 'Escape') {
44
- event.preventDefault();
45
- event.stopPropagation();
46
- onClose();
47
- }
48
48
  };
49
49
  window.addEventListener('keydown', handleKey);
50
50
  return () => window.removeEventListener('keydown', handleKey);
@@ -58,8 +58,8 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
58
58
  };
59
59
 
60
60
  const buttonColors = {
61
- info: 'bg-primary text-primary-foreground hover:opacity-90',
62
- warning: 'bg-warning text-warning-foreground hover:opacity-90',
61
+ info: 'bg-primary text-primary-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:opacity-90',
62
+ warning: 'bg-warning text-warning-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:opacity-90',
63
63
  };
64
64
 
65
65
  const icons = {
@@ -76,40 +76,54 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
76
76
  };
77
77
 
78
78
  return (
79
- <div
80
- className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 backdrop-blur-sm p-4"
81
- data-plannotator-confirm-dialog="true"
79
+ <Dialog
80
+ open={isOpen}
81
+ disablePointerDismissal
82
+ onOpenChange={(open) => {
83
+ if (!open) onClose();
84
+ }}
82
85
  >
83
- <div
84
- role="dialog"
85
- aria-modal="true"
86
- className={`bg-card border border-border rounded-xl w-full shadow-2xl p-6 ${wide ? 'max-w-md' : 'max-w-sm'}`}
86
+ <DialogContent
87
+ hideClose
88
+ aria-describedby={descriptionId}
89
+ initialFocus={() => (showCancel ? cancelButtonRef.current : confirmButtonRef.current)}
90
+ backdropClassName="bg-background/80 backdrop-blur-sm"
91
+ className={`bg-card text-foreground rounded-xl shadow-2xl p-6 transition-none ${wide ? 'max-w-md' : 'max-w-sm'}`}
92
+ data-plannotator-confirm-dialog="true"
87
93
  >
88
94
  <div className="flex items-center gap-3 mb-4">
89
- <div className={`w-10 h-10 rounded-full flex items-center justify-center ${iconColors[variant]}`}>
95
+ <div className={`w-10 h-10 shrink-0 rounded-full flex items-center justify-center ${iconColors[variant]}`}>
90
96
  {icons[variant]}
91
97
  </div>
92
- <h3 className="font-semibold">{title}</h3>
98
+ <DialogTitle className="font-semibold tracking-normal">{title}</DialogTitle>
93
99
  </div>
94
- <div className="text-sm text-muted-foreground mb-2">
95
- {message}
96
- </div>
97
- {subMessage && (
98
- <div className="text-xs text-muted-foreground mb-6">
99
- {subMessage}
100
+ <div id={descriptionId}>
101
+ <div className="text-sm text-muted-foreground mb-2">
102
+ {message}
100
103
  </div>
101
- )}
102
- {!subMessage && <div className="mb-4" />}
104
+ {subMessage && (
105
+ <div className="text-xs text-muted-foreground mb-6">
106
+ {subMessage}
107
+ </div>
108
+ )}
109
+ {!subMessage && <div className="mb-4" />}
110
+ </div>
103
111
  <div className="flex justify-end gap-2">
104
112
  {showCancel && (
105
113
  <button
114
+ ref={cancelButtonRef}
115
+ type="button"
116
+ data-pn-touch-target="true"
106
117
  onClick={onClose}
107
- className="px-4 py-2 rounded-md text-sm font-medium bg-muted text-muted-foreground hover:bg-muted/80 transition-opacity"
118
+ className="px-4 py-2 rounded-md text-sm font-medium bg-muted text-muted-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:bg-muted/80 transition-opacity"
108
119
  >
109
120
  {cancelText}
110
121
  </button>
111
122
  )}
112
123
  <button
124
+ ref={confirmButtonRef}
125
+ type="button"
126
+ data-pn-touch-target="true"
113
127
  onClick={() => {
114
128
  if (onConfirm) {
115
129
  onConfirm();
@@ -122,7 +136,7 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
122
136
  {confirmText}
123
137
  </button>
124
138
  </div>
125
- </div>
126
- </div>
139
+ </DialogContent>
140
+ </Dialog>
127
141
  );
128
142
  };
@@ -1,6 +1,6 @@
1
1
  import React, { useRef, useState, useEffect, useCallback } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
- import { instance } from '@viz-js/viz';
3
+ import type { Viz } from '@viz-js/viz';
4
4
  import type { Block } from '../types';
5
5
 
6
6
  interface ViewBox {
@@ -14,13 +14,52 @@ const ZOOM_STEP = 0.25;
14
14
  const MIN_ZOOM = 0.25;
15
15
  const MAX_ZOOM = 8;
16
16
 
17
- let vizInstancePromise: ReturnType<typeof instance> | null = null;
18
-
19
- function getVizInstance() {
20
- vizInstancePromise ??= instance();
17
+ /**
18
+ * The Graphviz engine (about 1.2 MB of Emscripten JS) is imported inside the
19
+ * render effect, not statically, so a host that bundles by route only fetches
20
+ * it when a dot fence is on the page. WASM instantiation was already deferred
21
+ * to first render; in Plannotator's single-file builds the import is inlined
22
+ * and resolves in a microtask ahead of a render that was already asynchronous.
23
+ */
24
+ const loadVizInstance = (): Promise<Viz> => import('@viz-js/viz').then((m) => m.instance());
25
+
26
+ let vizLoader = loadVizInstance;
27
+ let vizInstancePromise: Promise<Viz> | null = null;
28
+
29
+ /**
30
+ * Delay before the one automatic re-attempt after a failed engine import.
31
+ * Only chunking hosts can fail here (a single-file build never fetches).
32
+ */
33
+ let runtimeRetryDelayMs = 750;
34
+
35
+ /**
36
+ * Memoized engine. A rejected load is dropped from the memo so the next call
37
+ * (the automatic re-attempt, a later mount, or the Retry button) issues a
38
+ * fresh import() instead of replaying the cached rejection.
39
+ */
40
+ function getVizInstance(): Promise<Viz> {
41
+ if (!vizInstancePromise) {
42
+ const attempt = vizLoader().catch((err: unknown) => {
43
+ if (vizInstancePromise === attempt) vizInstancePromise = null;
44
+ throw err;
45
+ });
46
+ vizInstancePromise = attempt;
47
+ }
21
48
  return vizInstancePromise;
22
49
  }
23
50
 
51
+ /** Test hook: stand in for the engine import and shorten the retry delay. */
52
+ export function __setVizLoaderForTests(
53
+ loader: (() => Promise<Viz>) | undefined,
54
+ options?: { retryDelayMs?: number },
55
+ ): void {
56
+ vizLoader = loader ?? loadVizInstance;
57
+ vizInstancePromise = null;
58
+ runtimeRetryDelayMs = options?.retryDelayMs ?? 750;
59
+ }
60
+
61
+ const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
62
+
24
63
  function parseViewBox(svgEl: SVGSVGElement): ViewBox | null {
25
64
  const raw = svgEl.getAttribute('viewBox');
26
65
  if (!raw) return null;
@@ -107,6 +146,11 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
107
146
  const containerRef = useRef<HTMLDivElement>(null);
108
147
  const [svg, setSvg] = useState('');
109
148
  const [error, setError] = useState<string | null>(null);
149
+ // True when the failure was the engine import itself (a chunking host's
150
+ // fetch), which is the only failure a Retry can change; a dot syntax error
151
+ // keeps the panel exactly as it always was.
152
+ const [runtimeUnavailable, setRuntimeUnavailable] = useState(false);
153
+ const [retryToken, setRetryToken] = useState(0);
110
154
  const [showSource, setShowSource] = useState(false);
111
155
  const [isExpanded, setIsExpanded] = useState(false);
112
156
 
@@ -158,8 +202,28 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
158
202
  let cancelled = false;
159
203
 
160
204
  const renderDiagram = async () => {
205
+ let viz: Viz;
206
+ try {
207
+ try {
208
+ viz = await getVizInstance();
209
+ } catch {
210
+ // Transient chunk failure on a chunking host: one automatic
211
+ // re-attempt with a fresh import() after a short delay. In a
212
+ // single-file build the first await never rejects, so this branch
213
+ // is unreachable there and the success path is unchanged.
214
+ await wait(runtimeRetryDelayMs);
215
+ if (cancelled) return;
216
+ viz = await getVizInstance();
217
+ }
218
+ } catch (err) {
219
+ if (!cancelled) {
220
+ setError(err instanceof Error ? err.message : 'Failed to render diagram');
221
+ setRuntimeUnavailable(true);
222
+ setSvg('');
223
+ }
224
+ return;
225
+ }
161
226
  try {
162
- const viz = await getVizInstance();
163
227
  const renderedSvg = await viz.renderString(block.content, { format: 'svg' });
164
228
  const cleaned = renderedSvg
165
229
  .replace(/ width="[^"]*"/, ' width="100%"')
@@ -177,10 +241,12 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
177
241
  naturalBoundsRef.current = parseViewBoxFromMarkup(cleaned);
178
242
  setSvg(cleaned);
179
243
  setError(null);
244
+ setRuntimeUnavailable(false);
180
245
  }
181
246
  } catch (err) {
182
247
  if (!cancelled) {
183
248
  setError(err instanceof Error ? err.message : 'Failed to render diagram');
249
+ setRuntimeUnavailable(false);
184
250
  setSvg('');
185
251
  }
186
252
  }
@@ -191,7 +257,7 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
191
257
  return () => {
192
258
  cancelled = true;
193
259
  };
194
- }, [block.content]);
260
+ }, [block.content, retryToken]);
195
261
 
196
262
  useEffect(() => {
197
263
  zoomLevelRef.current = 1;
@@ -362,6 +428,19 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
362
428
  <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
363
429
  </svg>
364
430
  <span className="text-xs text-destructive font-medium">Graphviz Error</span>
431
+ {runtimeUnavailable && (
432
+ <button
433
+ type="button"
434
+ onClick={() => {
435
+ setError(null);
436
+ setRetryToken((token) => token + 1);
437
+ }}
438
+ className="ml-auto rounded-md border border-destructive/30 px-2 py-0.5 text-xs text-destructive hover:bg-destructive/10"
439
+ title="Retry loading the diagram renderer"
440
+ >
441
+ Retry
442
+ </button>
443
+ )}
365
444
  </div>
366
445
  <pre className="p-3 text-xs text-destructive/80 overflow-x-auto">{error}</pre>
367
446
  <pre className="p-3 text-xs text-muted-foreground bg-muted/30 border-t border-border/30 overflow-x-auto">
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Header controls for a raw-HTML or live-app annotation surface: the eye
3
+ * (show/hide the floating tools over the page), the optional refresh, and
4
+ * the pen (Annotate/Interact toggle). Presentation only; every state lives
5
+ * in the host. Each control renders only when its handler is passed, so a
6
+ * read-only document can show the eye without a pen.
7
+ *
8
+ * The markup, data attributes (`data-html-tools-toggle`, `data-html-refresh`,
9
+ * `data-html-annotate-toggle`), aria state and the pixel-stable pen border
10
+ * are the exact ones Plannotator's header shipped with; hosts get the same
11
+ * control, and `labels` overrides the strings without touching the DOM.
12
+ */
13
+
14
+ /** String overrides. Every key optional; defaults are Plannotator's strings. */
15
+ export interface HtmlSurfaceControlLabels {
16
+ /** Pen title while Annotate is armed. */
17
+ annotateTitle?: string;
18
+ /** Pen title while in Interact mode. */
19
+ interactTitle?: string;
20
+ /** Pen aria-label while armed. Default: none (the title carries the name). */
21
+ annotateLabel?: string;
22
+ /** Pen aria-label while in Interact mode. Default: none. */
23
+ interactLabel?: string;
24
+ /** Eye title and screen-reader text while the tools are visible. */
25
+ hideTools?: string;
26
+ /** Eye title and screen-reader text while the tools are hidden. */
27
+ showTools?: string;
28
+ /** Refresh visible text while idle. */
29
+ refresh?: string;
30
+ /** Refresh visible text while a refresh is in flight. */
31
+ refreshing?: string;
32
+ /** Refresh title and aria-label while idle. */
33
+ refreshTitle?: string;
34
+ /** Refresh title and aria-label while a refresh is in flight. */
35
+ refreshingTitle?: string;
36
+ }
37
+
38
+ export const DEFAULT_HTML_SURFACE_CONTROL_LABELS: Required<
39
+ Omit<HtmlSurfaceControlLabels, 'annotateLabel' | 'interactLabel'>
40
+ > = {
41
+ annotateTitle: 'Annotate mode: click an element or select text to comment. Esc to interact',
42
+ interactTitle: 'Interact mode: clicks reach the page (text selection still comments). Click to annotate',
43
+ hideTools: 'Hide tools',
44
+ showTools: 'Show tools',
45
+ refresh: 'Refresh',
46
+ refreshing: 'Refreshing',
47
+ refreshTitle: 'Refresh document',
48
+ refreshingTitle: 'Refreshing document',
49
+ };
50
+
51
+ export interface HtmlSurfaceControlsProps {
52
+ /** Whether Annotate is armed (pen pressed). */
53
+ armed: boolean;
54
+ /** Flip Annotate/Interact. The pen renders only when provided. */
55
+ onToggleArmed?: () => void;
56
+ /** Whether the floating tools over the page are hidden (eye-off). */
57
+ toolsHidden?: boolean;
58
+ /** Flip the tools. The eye (and the refresh beside it) render only when provided. */
59
+ onToggleTools?: () => void;
60
+ /** Whether a refresh is offered for this document. */
61
+ canRefresh?: boolean;
62
+ onRefresh?: () => void;
63
+ isRefreshing?: boolean;
64
+ /** Compact touch shells put these actions in a menu instead: render nothing. */
65
+ compact?: boolean;
66
+ labels?: HtmlSurfaceControlLabels;
67
+ }
68
+
69
+ export function HtmlSurfaceControls({
70
+ armed,
71
+ onToggleArmed,
72
+ toolsHidden = false,
73
+ onToggleTools,
74
+ canRefresh = false,
75
+ onRefresh,
76
+ isRefreshing = false,
77
+ compact = false,
78
+ labels,
79
+ }: HtmlSurfaceControlsProps) {
80
+ if (compact) return null;
81
+ const text = { ...DEFAULT_HTML_SURFACE_CONTROL_LABELS, ...labels };
82
+ const penLabel = armed ? labels?.annotateLabel : labels?.interactLabel;
83
+ return (
84
+ <>
85
+ {/* Show/hide tools: removes ALL floating chrome (sidebar tongue tabs +
86
+ the comment/attachments cluster) from the DOM, leaving nothing over
87
+ the page. Sits left of the pen; this button is the only way back,
88
+ so it never hides itself. Eye = tools visible, eye-off = hidden. */}
89
+ {onToggleTools && (
90
+ <div className="ml-1 flex items-center gap-0.5">
91
+ {canRefresh && onRefresh && (
92
+ <button
93
+ type="button"
94
+ data-html-refresh
95
+ // aria-disabled rather than disabled: a disabled control drops
96
+ // keyboard focus to body when activated. useHtmlRefresh already
97
+ // dedups in-flight requests, so an extra click is harmless.
98
+ onClick={isRefreshing ? undefined : onRefresh}
99
+ aria-disabled={isRefreshing}
100
+ className="flex items-center gap-1.5 rounded px-1.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground aria-disabled:cursor-wait aria-disabled:opacity-70"
101
+ title={isRefreshing ? text.refreshingTitle : text.refreshTitle}
102
+ aria-label={isRefreshing ? text.refreshingTitle : text.refreshTitle}
103
+ >
104
+ <svg
105
+ aria-hidden="true"
106
+ viewBox="0 0 24 24"
107
+ fill="none"
108
+ stroke="currentColor"
109
+ strokeWidth="2"
110
+ strokeLinecap="round"
111
+ strokeLinejoin="round"
112
+ className={`h-3.5 w-3.5 ${isRefreshing ? 'animate-spin motion-reduce:animate-none' : ''}`}
113
+ >
114
+ <path d="M20 11a8.1 8.1 0 0 0-15.5-2M4 4v5h5" />
115
+ <path d="M4 13a8.1 8.1 0 0 0 15.5 2M20 20v-5h-5" />
116
+ </svg>
117
+ <span className="hidden sm:inline">{isRefreshing ? text.refreshing : text.refresh}</span>
118
+ </button>
119
+ )}
120
+ <button
121
+ type="button"
122
+ data-html-tools-toggle
123
+ onClick={onToggleTools}
124
+ aria-pressed={toolsHidden}
125
+ className="cursor-pointer rounded-md border border-transparent p-1.5 text-xs font-medium text-muted-foreground transition-all hover:bg-muted hover:text-foreground"
126
+ title={toolsHidden ? text.showTools : text.hideTools}
127
+ >
128
+ {toolsHidden ? (
129
+ <svg aria-hidden="true" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
130
+ <path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
131
+ </svg>
132
+ ) : (
133
+ <svg aria-hidden="true" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
134
+ <path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
135
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
136
+ </svg>
137
+ )}
138
+ <span className="sr-only">{toolsHidden ? text.showTools : text.hideTools}</span>
139
+ </button>
140
+ </div>
141
+ )}
142
+
143
+ {/* Interact/Annotate toggle. A PEN icon (deliberately not a speech
144
+ bubble: an annotations-panel bubble beside it must stay
145
+ distinguishable at a glance, and so must AI sparkles). Always the
146
+ same icon: armed shows the accent color plus a visible border;
147
+ unarmed is muted with a TRANSPARENT border of the same width, so
148
+ the button's box is pixel-identical in both states. */}
149
+ {onToggleArmed && (
150
+ <button
151
+ type="button"
152
+ data-html-annotate-toggle
153
+ onClick={onToggleArmed}
154
+ aria-pressed={armed}
155
+ className={`p-1.5 rounded-md border text-xs font-medium transition-all cursor-pointer ${
156
+ armed
157
+ ? 'border-primary/60 bg-primary/15 text-primary'
158
+ : 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted'
159
+ }`}
160
+ title={armed ? text.annotateTitle : text.interactTitle}
161
+ {...(penLabel !== undefined ? { 'aria-label': penLabel } : {})}
162
+ >
163
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
164
+ <path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.862 4.487zm0 0L19.5 7.125" />
165
+ </svg>
166
+ </button>
167
+ )}
168
+ </>
169
+ );
170
+ }
@@ -4,11 +4,13 @@ import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePa
4
4
  import { ensureHighlight, highlightToHtml } from "../utils/codeHighlight";
5
5
  import { useFenceTheme } from "../hooks/useFenceTheme";
6
6
  import { transformPlainText } from "../utils/inlineTransforms";
7
+ import { hasLinkedDocExtension } from "../utils/markdownExtensions";
7
8
  import { getImageSrc } from "./ImageThumbnail";
8
9
  import { useCodePathValidation, type CodePathValidationContextValue } from "./CodePathValidationContext";
9
10
  import type { ValidationEntry } from "../hooks/useValidatedCodePaths";
10
11
  import { CodeFilePicker } from "./CodeFilePicker";
11
- import { normalizeMathTex, renderMathToHtml } from "./blocks/MathBlock";
12
+ import { normalizeMathTex, renderMathToHtml } from "../utils/math";
13
+ import { useMathRenderer } from "../hooks/useMathRenderer";
12
14
 
13
15
  export interface DocPreviewResult {
14
16
  contents?: string;
@@ -41,6 +43,9 @@ export const resetDocPreviewFetcher = (): void => {
41
43
  docPreviewFetcher = defaultDocPreviewFetcher;
42
44
  };
43
45
 
46
+ /** Read the active fetcher at call time (so a late override is honored). */
47
+ export const getDocPreviewFetcher = (): DocPreviewFetcher => docPreviewFetcher;
48
+
44
49
  /**
45
50
  * Decide how a candidate code-file path should render based on validation state:
46
51
  * - 'link' → clickable, opens directly via onOpenCodeFile(resolvedOrInput)
@@ -308,7 +313,23 @@ const CodeFileIcon = () => (
308
313
 
309
314
  const InlineMath: React.FC<{ tex: string }> = ({ tex }) => {
310
315
  const normalizedTex = normalizeMathTex(tex);
311
- const html = useMemo(() => renderMathToHtml(normalizedTex, false), [normalizedTex]);
316
+ const renderer = useMathRenderer();
317
+ const html = useMemo(() => renderMathToHtml(normalizedTex, false, renderer), [normalizedTex, renderer]);
318
+
319
+ // Same wrapper in both branches (see MathBlock): the placeholder carries the
320
+ // attributes annotation restore keys on, with the TeX as a text child.
321
+ if (html === null) {
322
+ return (
323
+ <span
324
+ className="math-inline math-annotatable text-foreground"
325
+ data-math-tex={normalizedTex}
326
+ data-math-display="false"
327
+ aria-label={normalizedTex}
328
+ >
329
+ {normalizedTex}
330
+ </span>
331
+ );
332
+ }
312
333
 
313
334
  return (
314
335
  <span
@@ -877,7 +898,7 @@ export const InlineMarkdown: React.FC<{
877
898
  // targets are opaque doc ids, not paths. null → today's rendering.
878
899
  const resolution = resolveLinkedDoc?.(target) ?? null;
879
900
  const display = resolution?.label || storedLabel || target;
880
- const targetPath = /\.(mdx?|txt|html?)$/i.test(target)
901
+ const targetPath = hasLinkedDocExtension(target)
881
902
  ? target
882
903
  : `${target}.md`;
883
904
 
@@ -1009,7 +1030,7 @@ export const InlineMarkdown: React.FC<{
1009
1030
  // Fragment is stripped before handing to onOpenLinkedDoc (overlay has
1010
1031
  // no anchor-scroll support today).
1011
1032
  const isLocalDoc =
1012
- /\.(mdx?|txt|html?)(#.*)?$/i.test(linkUrl) &&
1033
+ hasLinkedDocExtension(linkUrl, { allowFragment: true }) &&
1013
1034
  !linkUrl.startsWith("http://") &&
1014
1035
  !linkUrl.startsWith("https://");
1015
1036
  const isCodeFile = !isLocalDoc && isCodeFilePath(linkUrl);
@@ -84,9 +84,17 @@ const inputMethodShortcuts: ShortcutSection = {
84
84
  ],
85
85
  };
86
86
 
87
+ const documentViewShortcuts: ShortcutSection = {
88
+ title: 'View',
89
+ shortcuts: [
90
+ { keys: [modKey, '.'], desc: 'Toggle focus mode', hint: 'Collapses the Contents sidebar and the right-hand panel together; press again to restore whatever was open before. Markdown documents only; HTML pages keep their own layout.' },
91
+ ],
92
+ };
93
+
87
94
  const annotationShortcuts: ShortcutSection = {
88
95
  title: 'Annotations',
89
96
  shortcuts: [
97
+ { keys: [shiftKey, '1-4'], desc: 'Switch annotation mode', hint: '1 Markup, 2 Comment, 3 Redline, 4 Label — matches the toolstrip order' },
90
98
  { keys: ['a-z'], desc: 'Start typing comment', hint: 'When the annotation toolbar is open, any letter key opens the comment editor with that character' },
91
99
  { keys: [altKey, '1-0'], desc: 'Apply quick label', hint: 'Instantly applies the Nth preset label (0 = 10th). When the label picker is open, bare digits also work.' },
92
100
  { keys: [modKey, enter], desc: 'Submit comment' },
@@ -108,6 +116,7 @@ const imageAnnotatorShortcuts: ShortcutSection = {
108
116
  };
109
117
 
110
118
  const sharedPlanEditorShortcuts: ShortcutSection[] = [
119
+ documentViewShortcuts,
111
120
  inputMethodShortcuts,
112
121
  annotationShortcuts,
113
122
  imageAnnotatorShortcuts,
@@ -50,7 +50,7 @@ export const Landing: React.FC<LandingProps> = ({ onEnter, shareBaseUrl }) => {
50
50
  <div className="max-w-2xl">
51
51
  <div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium mb-6">
52
52
  <span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
53
- For Claude Code & OpenCode
53
+ For Claude Code &amp; OpenCode
54
54
  </div>
55
55
 
56
56
  <div className="flex items-center gap-6 mb-4">