@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
@@ -0,0 +1,349 @@
1
+ import {
2
+ pickedCompletion,
3
+ startCompletion,
4
+ type Completion,
5
+ type CompletionSource,
6
+ } from '@codemirror/autocomplete';
7
+ import {
8
+ EditorState,
9
+ MapMode,
10
+ StateEffect,
11
+ StateField,
12
+ type Extension,
13
+ type StateEffectType,
14
+ } from '@codemirror/state';
15
+ import type { EditorView } from '@codemirror/view';
16
+ import type { SlashCommandItem } from '@plannotator/atomic-editor';
17
+ import { planEmbedInsert } from '@plannotator/core/embed-insert';
18
+
19
+ /** What an embed can point at. The union grows as new media kinds ship. */
20
+ export type EmbedKind = 'html';
21
+
22
+ /** One embeddable target supplied by the host. */
23
+ export interface EmbedTarget {
24
+ /** Media kind represented by this target. */
25
+ readonly kind: EmbedKind;
26
+ /** Host-meaningful target identifier or path. */
27
+ readonly path: string;
28
+ /** Display title. Blank and absent titles fall back to {@link path}. */
29
+ readonly title?: string | null;
30
+ }
31
+
32
+ /** Per-editor host callbacks for the `/embed` picker. */
33
+ export interface EmbedPickerConfig {
34
+ /** Live read of embeddable targets. Dynamic data belongs behind this callback. */
35
+ readonly getTargets: () => readonly EmbedTarget[];
36
+ /** Build the exact host-owned embed line for a picked target. */
37
+ readonly buildInsertLine: (target: EmbedTarget) => string;
38
+ /**
39
+ * Optionally upload a target. Resolve with a target to insert it, or `null`
40
+ * when the user cancels. The host owns error presentation.
41
+ */
42
+ readonly uploadTarget?: (kind: EmbedKind) => Promise<EmbedTarget | null>;
43
+ /** Return an optional informational row for the current document text. */
44
+ readonly getNotice?: (docBody: string) => string | null;
45
+ }
46
+
47
+ type IconCompletion = Completion & {
48
+ readonly slashCommandIcon?: string;
49
+ };
50
+
51
+ type UploadAnchor = {
52
+ readonly from: number;
53
+ readonly to: number;
54
+ readonly text: string;
55
+ };
56
+
57
+ type UploadState =
58
+ | { readonly _tag: 'idle' }
59
+ | { readonly _tag: 'uploading'; readonly anchor: UploadAnchor | null };
60
+
61
+ type UploadStateEffect =
62
+ | { readonly _tag: 'start'; readonly anchor: UploadAnchor }
63
+ | { readonly _tag: 'finish' };
64
+
65
+ type UploadStateEffectType = StateEffectType<UploadStateEffect>;
66
+
67
+ const IDLE_UPLOAD_STATE: UploadState = { _tag: 'idle' };
68
+ const EMBED_PREFIX = '/embed ';
69
+ const HTML_KIND: EmbedKind = 'html';
70
+
71
+ // Framed `</>` for an HTML document rendered in place. This follows the
72
+ // editor package convention: 16x16 viewBox, currentColor, 1.5 stroke.
73
+ const EMBED_ICON =
74
+ '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M6.4 6 4.4 8l2 2"/><path d="M9.6 6 11.6 8l-2 2"/></svg>';
75
+
76
+ // A document with an upward arrow. `slashCommands()` owns the shared menu's
77
+ // icon renderer; its completion metadata lets this picker use the same gutter.
78
+ const UPLOAD_ICON =
79
+ '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8.5V13h10V8.5"/><path d="M8 11V3M5 6l3-3 3 3"/></svg>';
80
+
81
+ /**
82
+ * Build the static slash-menu item that hands completion to the `/embed` picker.
83
+ *
84
+ * @returns A slash command that rewrites the typed query to `/embed ` and
85
+ * immediately reopens completion.
86
+ */
87
+ export function embedSlashItem(): SlashCommandItem {
88
+ return {
89
+ label: 'Embed HTML',
90
+ detail: 'live preview',
91
+ icon: EMBED_ICON,
92
+ apply: (view, completion, from, to) => {
93
+ view.dispatch({
94
+ changes: { from, to, insert: EMBED_PREFIX },
95
+ selection: { anchor: from + EMBED_PREFIX.length },
96
+ annotations: pickedCompletion.of(completion),
97
+ });
98
+ startCompletion(view);
99
+ },
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Build the `/embed` target picker extension for one editor mount.
105
+ *
106
+ * The editor captures extensions once per document mount. Keep the returned
107
+ * extension stable and feed changing target data through the config callbacks.
108
+ *
109
+ * @param config - Host target, serialization, upload, and notice callbacks.
110
+ * @returns A CodeMirror extension that composes with `slashCommands()`.
111
+ */
112
+ export function embedPicker(config: EmbedPickerConfig): Extension {
113
+ const uploadStateEffect = StateEffect.define<UploadStateEffect>();
114
+ const uploadStateField = StateField.define<UploadState>({
115
+ create: () => IDLE_UPLOAD_STATE,
116
+ update: (current, transaction) => {
117
+ let next = current;
118
+ if (next._tag === 'uploading' && next.anchor !== null && transaction.docChanged) {
119
+ const mappedFrom = transaction.changes.mapPos(next.anchor.from, 1, MapMode.TrackAfter);
120
+ const mappedTo = transaction.changes.mapPos(next.anchor.to, -1, MapMode.TrackBefore);
121
+ next = {
122
+ _tag: 'uploading',
123
+ anchor:
124
+ mappedFrom === null || mappedTo === null || mappedFrom > mappedTo
125
+ ? null
126
+ : { ...next.anchor, from: mappedFrom, to: mappedTo },
127
+ };
128
+ }
129
+
130
+ for (const effect of transaction.effects) {
131
+ if (!effect.is(uploadStateEffect)) continue;
132
+ next =
133
+ effect.value._tag === 'start'
134
+ ? { _tag: 'uploading', anchor: effect.value.anchor }
135
+ : IDLE_UPLOAD_STATE;
136
+ }
137
+ return next;
138
+ },
139
+ });
140
+
141
+ const source = createEmbedPickerSource(config, uploadStateField, uploadStateEffect);
142
+ return [uploadStateField, EditorState.languageData.of(() => [{ autocomplete: source }])];
143
+ }
144
+
145
+ function createEmbedPickerSource(
146
+ config: EmbedPickerConfig,
147
+ uploadStateField: StateField<UploadState>,
148
+ uploadStateEffect: UploadStateEffectType,
149
+ ): CompletionSource {
150
+ return (context) => {
151
+ const match = context.matchBefore(/\/embed +[^\n]*$/);
152
+ if (match === null) return null;
153
+
154
+ // Like the package slash source, the trigger must open its line. An embed
155
+ // is a block insertion and belongs in its own paragraph.
156
+ const line = context.state.doc.lineAt(match.from);
157
+ if (!/^\s*$/.test(line.text.slice(0, match.from - line.from))) return null;
158
+
159
+ const query = match.text.replace(/^\/embed +/, '');
160
+ const targets = config.getTargets().filter((target) => target.kind === HTML_KIND);
161
+ const matches = filterTargets(targets, query);
162
+ const options: IconCompletion[] = [];
163
+ const notice = config.getNotice?.(context.state.doc.toString()) ?? null;
164
+ if (notice !== null) {
165
+ options.push({
166
+ label: notice,
167
+ apply: () => {
168
+ // Informational row: selection closes the menu and leaves the typed
169
+ // command in place so the author can keep deciding.
170
+ },
171
+ });
172
+ }
173
+
174
+ if (matches.length === 0) {
175
+ options.push({
176
+ label:
177
+ targets.length === 0
178
+ ? 'No HTML files in this workspace'
179
+ : `No HTML files match “${query.trim()}”`,
180
+ apply: (view, completion, _from, to) => {
181
+ view.dispatch({
182
+ changes: { from: line.from, to, insert: '' },
183
+ annotations: pickedCompletion.of(completion),
184
+ });
185
+ },
186
+ });
187
+ } else {
188
+ for (const target of matches) {
189
+ options.push({
190
+ label: targetLabel(target),
191
+ detail: target.path,
192
+ apply: (view, completion, from, to) => {
193
+ applyTargetInsert(view, completion, from, to, target, config.buildInsertLine);
194
+ },
195
+ });
196
+ }
197
+ }
198
+
199
+ const uploadTarget = config.uploadTarget;
200
+ if (uploadTarget !== undefined) {
201
+ const uploadState = context.state.field(uploadStateField);
202
+ options.push(
203
+ uploadState._tag === 'uploading'
204
+ ? {
205
+ label: 'Uploading...',
206
+ slashCommandIcon: UPLOAD_ICON,
207
+ apply: () => {
208
+ // Inert while the existing host callback is still pending.
209
+ },
210
+ }
211
+ : {
212
+ label: 'Upload HTML...',
213
+ slashCommandIcon: UPLOAD_ICON,
214
+ apply: (view, completion, from, to) => {
215
+ startUpload(
216
+ view,
217
+ completion,
218
+ from,
219
+ to,
220
+ config,
221
+ uploadTarget,
222
+ uploadStateField,
223
+ uploadStateEffect,
224
+ );
225
+ },
226
+ },
227
+ );
228
+ }
229
+
230
+ return {
231
+ // The entire `/embed query` is the completion range. Our case-insensitive
232
+ // substring matcher owns filtering because CM's fuzzy matcher closes a
233
+ // completion session when a title query contains spaces.
234
+ from: match.from,
235
+ options,
236
+ filter: false,
237
+ };
238
+ };
239
+ }
240
+
241
+ function filterTargets(targets: readonly EmbedTarget[], query: string): readonly EmbedTarget[] {
242
+ const needle = query.trim().toLowerCase();
243
+ if (needle === '') return targets;
244
+ return targets.filter((target) => {
245
+ const title = (target.title ?? '').toLowerCase();
246
+ return title.includes(needle) || target.path.toLowerCase().includes(needle);
247
+ });
248
+ }
249
+
250
+ function targetLabel(target: EmbedTarget): string {
251
+ const title = target.title ?? '';
252
+ return title.trim() === '' ? target.path : title;
253
+ }
254
+
255
+ function applyTargetInsert(
256
+ view: EditorView,
257
+ completion: Completion,
258
+ from: number,
259
+ to: number,
260
+ target: EmbedTarget,
261
+ buildInsertLine: EmbedPickerConfig['buildInsertLine'],
262
+ ): void {
263
+ const body = view.state.doc.toString();
264
+ const plan = planEmbedInsert(body, from, to, buildInsertLine(target));
265
+ view.dispatch({
266
+ changes: { from: plan.from, to: plan.to, insert: plan.insert },
267
+ selection: { anchor: plan.cursor },
268
+ scrollIntoView: true,
269
+ annotations: pickedCompletion.of(completion),
270
+ });
271
+ }
272
+
273
+ function startUpload(
274
+ view: EditorView,
275
+ completion: Completion,
276
+ from: number,
277
+ to: number,
278
+ config: EmbedPickerConfig,
279
+ uploadTarget: NonNullable<EmbedPickerConfig['uploadTarget']>,
280
+ uploadStateField: StateField<UploadState>,
281
+ uploadStateEffect: UploadStateEffectType,
282
+ ): void {
283
+ const uploadState = view.state.field(uploadStateField, false);
284
+ if (uploadState?._tag === 'uploading') return;
285
+
286
+ const anchor: UploadAnchor = {
287
+ from,
288
+ to,
289
+ text: view.state.doc.sliceString(from, to),
290
+ };
291
+ view.dispatch({
292
+ effects: uploadStateEffect.of({ _tag: 'start', anchor }),
293
+ annotations: pickedCompletion.of(completion),
294
+ });
295
+
296
+ void (async () => {
297
+ try {
298
+ const target = await uploadTarget(HTML_KIND);
299
+ finishUpload(view, completion, target, config, uploadStateField, uploadStateEffect);
300
+ } catch {
301
+ clearUpload(view, uploadStateField, uploadStateEffect);
302
+ }
303
+ })();
304
+ }
305
+
306
+ function finishUpload(
307
+ view: EditorView,
308
+ completion: Completion,
309
+ target: EmbedTarget | null,
310
+ config: EmbedPickerConfig,
311
+ uploadStateField: StateField<UploadState>,
312
+ uploadStateEffect: UploadStateEffectType,
313
+ ): void {
314
+ const uploadState = view.state.field(uploadStateField, false);
315
+ if (uploadState?._tag !== 'uploading') return;
316
+
317
+ const anchor = uploadState.anchor;
318
+ if (target === null || anchor === null || !isLiveAnchor(view, anchor)) {
319
+ clearUpload(view, uploadStateField, uploadStateEffect);
320
+ return;
321
+ }
322
+
323
+ const body = view.state.doc.toString();
324
+ const plan = planEmbedInsert(body, anchor.from, anchor.to, config.buildInsertLine(target));
325
+ view.dispatch({
326
+ changes: { from: plan.from, to: plan.to, insert: plan.insert },
327
+ selection: { anchor: plan.cursor },
328
+ scrollIntoView: true,
329
+ effects: uploadStateEffect.of({ _tag: 'finish' }),
330
+ annotations: pickedCompletion.of(completion),
331
+ });
332
+ }
333
+
334
+ function isLiveAnchor(view: EditorView, anchor: UploadAnchor): boolean {
335
+ if (anchor.from > anchor.to || anchor.to > view.state.doc.length) return false;
336
+ if (view.state.doc.sliceString(anchor.from, anchor.to) !== anchor.text) return false;
337
+ const line = view.state.doc.lineAt(anchor.from);
338
+ return /^\s*$/.test(line.text.slice(0, anchor.from - line.from));
339
+ }
340
+
341
+ function clearUpload(
342
+ view: EditorView,
343
+ uploadStateField: StateField<UploadState>,
344
+ uploadStateEffect: UploadStateEffectType,
345
+ ): void {
346
+ const uploadState = view.state.field(uploadStateField, false);
347
+ if (uploadState?._tag !== 'uploading') return;
348
+ view.dispatch({ effects: uploadStateEffect.of({ _tag: 'finish' }) });
349
+ }
@@ -34,6 +34,18 @@ export type { SlashCommandItem, SlashCommandsConfig } from '@plannotator/atomic-
34
34
  export { selectionToolbar } from '@plannotator/atomic-editor';
35
35
  export type { SelectionToolbarConfig, InlineFormat } from '@plannotator/atomic-editor';
36
36
 
37
+ /* Host-configured embed media authoring. The picker is per editor mount so its
38
+ callbacks can close over live route state; nothing enters configurePlannotatorUI.
39
+ The package owns paragraph-safe splicing while the host owns embed grammar. */
40
+ export { embedPicker, embedSlashItem } from './MarkdownEditor/embedPicker';
41
+ export type {
42
+ EmbedKind,
43
+ EmbedPickerConfig,
44
+ EmbedTarget,
45
+ } from './MarkdownEditor/embedPicker';
46
+ export { planEmbedInsert } from '@plannotator/core/embed-insert';
47
+ export type { EmbedInsertPlan } from '@plannotator/core/embed-insert';
48
+
37
49
  /* Grid-mode card utilities stay here (not in the package): they're Plannotator
38
50
  design-system Tailwind classes, and this file is @source-scanned. */
39
51
  const GRID_CARD_CLASSES = 'px-5 md:px-8 lg:px-10 xl:px-12 shadow-xl border border-border/50';
@@ -1,33 +1,26 @@
1
1
  import React, { useRef, useState, useEffect, useCallback } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
- import mermaid from 'mermaid';
3
+ import type { Mermaid } from 'mermaid';
4
4
  import type { Block } from '../types';
5
5
  import { normalizeMermaidSvgMarkup } from './mermaidSvg';
6
+ import {
7
+ MERMAID_CONFIG,
8
+ getMermaidRetryDelayMs,
9
+ loadMermaidRuntime,
10
+ __setMermaidRuntimeLoaderForTests,
11
+ } from '../utils/mermaid';
6
12
 
7
- mermaid.initialize({
8
- startOnLoad: false,
9
- securityLevel: 'strict',
10
- theme: 'dark',
11
- themeVariables: {
12
- primaryColor: '#3b82f6',
13
- primaryTextColor: '#f8fafc',
14
- primaryBorderColor: '#475569',
15
- lineColor: '#64748b',
16
- secondaryColor: '#1e293b',
17
- tertiaryColor: '#0f172a',
18
- background: '#1e293b',
19
- mainBkg: '#1e293b',
20
- nodeBorder: '#475569',
21
- clusterBkg: '#1e293b',
22
- clusterBorder: '#475569',
23
- titleColor: '#f8fafc',
24
- edgeLabelBackground: '#1e293b',
25
- },
26
- flowchart: {
27
- htmlLabels: true,
28
- curve: 'basis',
29
- },
30
- });
13
+ // Re-exported: the config pin test and the lazy-retry test import them from here.
14
+ export { MERMAID_CONFIG, __setMermaidRuntimeLoaderForTests };
15
+
16
+ /**
17
+ * The runtime comes from the slot in utils/mermaid: filled eagerly by
18
+ * Plannotator (utils/mermaid-eager, imported by the editor App), loaded
19
+ * lazily otherwise. See that module for the retry contract.
20
+ */
21
+ const getMermaid = loadMermaidRuntime;
22
+
23
+ const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
31
24
 
32
25
  interface ViewBox {
33
26
  x: number;
@@ -133,6 +126,11 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
133
126
  const expandedOverlayRef = useRef<HTMLDivElement>(null);
134
127
  const [svg, setSvg] = useState('');
135
128
  const [error, setError] = useState<string | null>(null);
129
+ // True when the failure was the runtime import itself (a chunking host's
130
+ // fetch), which is the only failure a Retry can change; a diagram syntax
131
+ // error keeps the panel exactly as it always was.
132
+ const [runtimeUnavailable, setRuntimeUnavailable] = useState(false);
133
+ const [retryToken, setRetryToken] = useState(0);
136
134
  const [showSource, setShowSource] = useState(false);
137
135
  const [isExpanded, setIsExpanded] = useState(false);
138
136
 
@@ -188,6 +186,27 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
188
186
 
189
187
  // Render mermaid diagram
190
188
  const renderDiagram = async () => {
189
+ let mermaid: Mermaid;
190
+ try {
191
+ try {
192
+ mermaid = await getMermaid();
193
+ } catch {
194
+ // Transient chunk failure on a chunking host: one automatic
195
+ // re-attempt with a fresh import() after a short delay. In a
196
+ // single-file build the first await never rejects, so this branch
197
+ // is unreachable there and the success path is unchanged.
198
+ await wait(getMermaidRetryDelayMs());
199
+ if (cancelled) return;
200
+ mermaid = await getMermaid();
201
+ }
202
+ } catch (err) {
203
+ if (!cancelled) {
204
+ setError(err instanceof Error ? err.message : 'Failed to render diagram');
205
+ setRuntimeUnavailable(true);
206
+ setSvg('');
207
+ }
208
+ return;
209
+ }
191
210
  try {
192
211
  const id = `mermaid-${block.id}`;
193
212
  const { svg: renderedSvg } = await mermaid.render(id, block.content);
@@ -196,10 +215,12 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
196
215
  naturalBoundsRef.current = parseViewBoxFromMarkup(normalizedSvg);
197
216
  setSvg(normalizedSvg);
198
217
  setError(null);
218
+ setRuntimeUnavailable(false);
199
219
  }
200
220
  } catch (err) {
201
221
  if (!cancelled) {
202
222
  setError(err instanceof Error ? err.message : 'Failed to render diagram');
223
+ setRuntimeUnavailable(false);
203
224
  setSvg('');
204
225
  }
205
226
  }
@@ -210,7 +231,7 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
210
231
  return () => {
211
232
  cancelled = true;
212
233
  };
213
- }, [block.content, block.id]);
234
+ }, [block.content, block.id, retryToken]);
214
235
 
215
236
  // Reset zoom and pan when content changes
216
237
  useEffect(() => {
@@ -419,6 +440,19 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
419
440
  <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" />
420
441
  </svg>
421
442
  <span className="text-xs text-destructive font-medium">Mermaid Error</span>
443
+ {runtimeUnavailable && (
444
+ <button
445
+ type="button"
446
+ onClick={() => {
447
+ setError(null);
448
+ setRetryToken((token) => token + 1);
449
+ }}
450
+ className="ml-auto rounded-md border border-destructive/30 px-2 py-0.5 text-xs text-destructive hover:bg-destructive/10"
451
+ title="Retry loading the diagram renderer"
452
+ >
453
+ Retry
454
+ </button>
455
+ )}
422
456
  </div>
423
457
  <pre className="p-3 text-xs text-destructive/80 overflow-x-auto">{error}</pre>
424
458
  <pre className="p-3 text-xs text-muted-foreground bg-muted/30 border-t border-border/30 overflow-x-auto">
@@ -21,8 +21,9 @@ export function ModeToggle() {
21
21
  <div className="relative" ref={dropdownRef}>
22
22
  <button
23
23
  onClick={() => setIsOpen(!isOpen)}
24
- className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
24
+ className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors relative pointer-coarse:before:absolute pointer-coarse:before:-inset-2 pointer-coarse:before:content-['']"
25
25
  title="Toggle theme"
26
+ aria-label="Toggle theme"
26
27
  >
27
28
  {/* Sun */}
28
29
  <svg
@@ -25,10 +25,29 @@ export const PermissionModeSetup: React.FC<PermissionModeSetupProps> = ({
25
25
  };
26
26
 
27
27
  return createPortal(
28
- <div className="fixed inset-0 z-[100] flex items-center justify-center bg-background/90 backdrop-blur-sm p-4">
29
- <div className="bg-card border border-border rounded-xl w-full max-w-lg shadow-2xl">
28
+ // Same bounded shell as the sibling one-time dialogs (see
29
+ // LookAndFeelAnnouncementDialog): safe-area padding, a card capped to the
30
+ // visible viewport, and the option list as the only scrolling region. The
31
+ // hand-rolled unbounded card used to overflow both edges of a short
32
+ // landscape phone with nothing left to tap.
33
+ <div
34
+ className="fixed inset-0 z-[100] flex items-center justify-center overflow-y-auto bg-background/90 backdrop-blur-sm"
35
+ style={{
36
+ paddingTop: 'max(1rem, var(--pn-safe-top, 0px))',
37
+ paddingRight: 'max(1rem, var(--pn-safe-right, 0px))',
38
+ paddingBottom: 'max(1rem, var(--pn-safe-bottom, 0px))',
39
+ paddingLeft: 'max(1rem, var(--pn-safe-left, 0px))',
40
+ }}
41
+ >
42
+ <div
43
+ className="bg-card border border-border rounded-xl w-full max-w-lg shadow-2xl flex flex-col overflow-hidden"
44
+ style={{
45
+ maxHeight:
46
+ 'calc(var(--pn-viewport-height, 100vh) - max(1rem, var(--pn-safe-top, 0px)) - max(1rem, var(--pn-safe-bottom, 0px)))',
47
+ }}
48
+ >
30
49
  {/* Header */}
31
- <div className="p-5 border-b border-border">
50
+ <div className="p-5 border-b border-border flex-shrink-0">
32
51
  <div className="flex items-center gap-2 mb-2">
33
52
  <div className="p-1.5 rounded-lg bg-primary/15">
34
53
  <svg className="w-5 h-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -47,7 +66,7 @@ export const PermissionModeSetup: React.FC<PermissionModeSetupProps> = ({
47
66
  </div>
48
67
 
49
68
  {/* Options */}
50
- <div className="p-4 space-y-2">
69
+ <div className="min-h-0 flex-1 overflow-y-auto p-4 space-y-2">
51
70
  {PERMISSION_MODE_OPTIONS.map((option) => (
52
71
  <label
53
72
  key={option.value}
@@ -74,7 +93,7 @@ export const PermissionModeSetup: React.FC<PermissionModeSetupProps> = ({
74
93
  </div>
75
94
 
76
95
  {/* Footer */}
77
- <div className="p-4 border-t border-border flex justify-between items-center">
96
+ <div className="p-4 border-t border-border flex flex-shrink-0 flex-wrap gap-2 justify-between items-center">
78
97
  <p className="text-xs text-muted-foreground">
79
98
  You can change this later in Settings.
80
99
  </p>
@@ -1,5 +1,8 @@
1
1
  import React, { useLayoutEffect, useState, useRef } from 'react';
2
- import { useScrollViewport } from '../hooks/useScrollViewport';
2
+ import {
3
+ addScrollViewportListener,
4
+ useScrollViewport,
5
+ } from '../hooks/useScrollViewport';
3
6
 
4
7
  interface PinpointOverlayProps {
5
8
  target: { element: HTMLElement; label: string } | null;
@@ -49,15 +52,17 @@ export const PinpointOverlay: React.FC<PinpointOverlayProps> = ({ target, contai
49
52
  // delivers its viewport.
50
53
  window.addEventListener('resize', handleUpdate, { passive: true });
51
54
 
52
- // The scroll element is the OverlayScrollArea viewport. Falling back to
53
- // <main> or window would attach to the wrong node and the overlay
54
- // position would drift silently on scroll.
55
- scrollViewport?.addEventListener('scroll', handleUpdate, { passive: true });
55
+ // The shared viewport is the nested main element on desktop and the page
56
+ // scroller on compact touch layouts. The helper binds the latter to the
57
+ // window scroll event so the overlay does not drift as Safari chrome moves.
58
+ const removeScrollListener = scrollViewport
59
+ ? addScrollViewportListener(scrollViewport, handleUpdate)
60
+ : undefined;
56
61
 
57
62
  return () => {
58
63
  cancelAnimationFrame(rafRef.current);
59
64
  window.removeEventListener('resize', handleUpdate);
60
- scrollViewport?.removeEventListener('scroll', handleUpdate);
65
+ removeScrollListener?.();
61
66
  };
62
67
  }, [target, containerRef, scrollViewport]);
63
68