@plannotator/ui 0.53.0 → 0.54.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 (145) hide show
  1. package/HANDOFF.md +284 -27
  2. package/README.md +84 -14
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +177 -34
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/AnnotationToolstrip.tsx +45 -40
  9. package/components/ApproveDropdown.tsx +7 -9
  10. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  11. package/components/AttachmentsButton.tsx +1 -1
  12. package/components/BlockRenderer.tsx +19 -5
  13. package/components/CodeFilePopout.tsx +140 -5
  14. package/components/CommentPopover.tsx +46 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DiagramBlock.tsx +17 -12
  17. package/components/EditorAnnotationCard.tsx +1 -1
  18. package/components/ExportModal.tsx +77 -7
  19. package/components/HostTitle.tsx +0 -4
  20. package/components/HtmlSurfaceControls.tsx +2 -1
  21. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  22. package/components/ImageAnnotator/index.tsx +24 -4
  23. package/components/ImageLightbox.tsx +658 -120
  24. package/components/ImagePeek.tsx +38 -4
  25. package/components/ImageRegions.tsx +369 -0
  26. package/components/ImageThumbnail.tsx +1 -1
  27. package/components/ImageZoomStage.tsx +796 -0
  28. package/components/InboxAnnouncementDialog.tsx +202 -0
  29. package/components/InlineMarkdown.tsx +152 -24
  30. package/components/KeyboardShortcuts.tsx +23 -0
  31. package/components/LazyBlockBoundary.tsx +100 -0
  32. package/components/ListMarker.tsx +3 -3
  33. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  34. package/components/MentionPicker.tsx +51 -0
  35. package/components/MenuVersionSection.tsx +31 -9
  36. package/components/PlanDecisionMenuExtras.tsx +129 -0
  37. package/components/PlanHeaderMenu.tsx +15 -1
  38. package/components/ProviderIcons.tsx +8 -0
  39. package/components/RefHoverCard.tsx +913 -0
  40. package/components/SendFailureToast.tsx +126 -0
  41. package/components/Settings.tsx +617 -126
  42. package/components/SkillReferenceMenu.tsx +39 -12
  43. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  44. package/components/ThemeProvider.tsx +14 -1
  45. package/components/ThemeTab.tsx +16 -24
  46. package/components/Tooltip.tsx +49 -18
  47. package/components/Viewer.tsx +316 -47
  48. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  49. package/components/ai/NewChatButton.tsx +74 -0
  50. package/components/blocks/CodeBlock.tsx +1 -1
  51. package/components/blocks/HtmlBlock.tsx +7 -1
  52. package/components/blocks/QuestionBlock.tsx +522 -62
  53. package/components/blocks/QuestionContext.tsx +3 -0
  54. package/components/diagram/DiagramComposer.tsx +5 -1
  55. package/components/diagram/DiagramPending.tsx +12 -2
  56. package/components/diagram/DiagramViewer.tsx +19 -3
  57. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  58. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  59. package/components/html-embed/index.ts +3 -0
  60. package/components/html-embed/languages.ts +15 -0
  61. package/components/html-embed/loadGate.ts +107 -0
  62. package/components/html-viewer/HtmlViewer.tsx +69 -6
  63. package/components/html-viewer/bridge-script.asset.js +504 -12
  64. package/components/html-viewer/bridge-script.lite.ts +1 -1
  65. package/components/html-viewer/bridge-script.ts +545 -12
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +265 -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/hooks/pfm/useCodeFilePopout.ts +7 -1
  78. package/hooks/useAIChat.ts +72 -25
  79. package/hooks/useAnnotationDraft.ts +71 -6
  80. package/hooks/useAnnotationHighlighter.ts +57 -3
  81. package/hooks/useCodeAnnotationDraft.ts +113 -17
  82. package/hooks/useCookieHostSetting.ts +96 -0
  83. package/hooks/useDocumentFont.ts +17 -0
  84. package/hooks/useDocumentTextSize.ts +20 -0
  85. package/hooks/useFileBrowser.ts +10 -1
  86. package/hooks/useLinkedDoc.ts +226 -13
  87. package/hooks/useMentionAutocomplete.ts +1 -1
  88. package/hooks/usePackageChangesSetting.ts +90 -0
  89. package/hooks/usePlanLookDialog.ts +67 -0
  90. package/hooks/useSharing.ts +11 -7
  91. package/hooks/useSkillReferenceAutocomplete.ts +7 -3
  92. package/hooks/useThemePaletteHalves.ts +37 -0
  93. package/hooks/useUpdateCheck.ts +22 -4
  94. package/hooks/useUpdateToast.ts +1 -1
  95. package/hooks/useVimSelection.ts +9 -0
  96. package/package.json +23 -22
  97. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  98. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  99. package/shortcuts/core.ts +63 -4
  100. package/shortcuts/index.ts +1 -0
  101. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  102. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  103. package/shortcuts/runtime.ts +14 -6
  104. package/styles.css +1 -1
  105. package/theme.css +179 -1
  106. package/themes/plannotator-graphite.css +66 -0
  107. package/types.ts +26 -9
  108. package/utils/agentSwitch.ts +41 -20
  109. package/utils/agentToolSetting.ts +1 -1
  110. package/utils/aiChatFormat.ts +23 -7
  111. package/utils/annotationOwnsHighlight.ts +4 -2
  112. package/utils/asciidocParser.ts +570 -0
  113. package/utils/autoUpdateNotice.ts +24 -0
  114. package/utils/belowTitleSlot.ts +174 -0
  115. package/utils/codeLineScroll.ts +100 -0
  116. package/utils/commentSubmitKey.ts +48 -0
  117. package/utils/cssFontFamily.ts +65 -0
  118. package/utils/decisionSpec.ts +190 -2
  119. package/utils/defaultNotesApp.ts +1 -1
  120. package/utils/diffFonts.ts +4 -1
  121. package/utils/documentFont.ts +68 -0
  122. package/utils/documentTextSize.ts +54 -0
  123. package/utils/fontDetect.ts +63 -0
  124. package/utils/inboxAnnouncement.ts +78 -0
  125. package/utils/lookAndFeelAnnouncement.ts +82 -5
  126. package/utils/markdownExtensions.ts +2 -2
  127. package/utils/mentions.ts +34 -5
  128. package/utils/mermaid.ts +20 -3
  129. package/utils/mermaidTheme.ts +3 -51
  130. package/utils/notion.ts +58 -0
  131. package/utils/parser.ts +28 -1
  132. package/utils/platform.ts +46 -0
  133. package/utils/refCard.ts +82 -0
  134. package/utils/richClipboard.ts +380 -0
  135. package/utils/richClipboardLists.ts +57 -0
  136. package/utils/richCopyPolicy.ts +22 -0
  137. package/utils/scrollAnchor.ts +82 -0
  138. package/utils/sectionRefs.ts +123 -0
  139. package/utils/selectionBoundary.ts +40 -0
  140. package/utils/sendFailure.ts +85 -0
  141. package/utils/sharing.ts +5 -2
  142. package/utils/themeRegistry.ts +10 -0
  143. package/utils/themeTokens.ts +74 -0
  144. package/utils/updateNotice.ts +30 -8
  145. package/utils/vscodePanel.ts +9 -0
@@ -18,6 +18,9 @@ const QUESTION_CONTEXT_CSS = `
18
18
  .question-context > :first-child { margin-top: 0; }
19
19
  .question-context > :last-child { margin-bottom: 0; }
20
20
  .question-context p, .question-context .text-sm { font-size: 13px; line-height: 1.5; }
21
+ /* Context lines stay lines ("Stopped: …" then "Holds up: …"), as the
22
+ authoring guide writes them; the parser keeps them as soft breaks. */
23
+ .question-context p { white-space: pre-line; }
21
24
  .question-context p, .question-context [data-question-context-list] span { color: inherit; }
22
25
  .question-context h1, .question-context h2, .question-context h3 { margin: 0.75rem 0 0.25rem; font-size: 14px; }
23
26
  .question-context h3 { font-size: 13.5px; }
@@ -4,6 +4,7 @@ import { cn } from '../../lib/utils';
4
4
  import type { ScreenRect } from '../../utils/diagram-projection';
5
5
  import { SparklesIcon } from '../SparklesIcon';
6
6
  import { Button } from '../ui/button';
7
+ import { isCommentSubmitKey } from '../../utils/commentSubmitKey';
7
8
  import type { DiagramComposerDraft } from './useDiagramComments';
8
9
 
9
10
  /**
@@ -101,7 +102,10 @@ export function DiagramComposer({
101
102
  onCancel();
102
103
  return;
103
104
  }
104
- if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
105
+ // Enter saves here whatever the "Enter saves a comment" setting says (it
106
+ // always has: a diagram comment is one short line); the shared check adds
107
+ // the IME guard Safari needs (keyCode 229) and keeps Shift+Enter a newline.
108
+ if (isCommentSubmitKey(event, true)) {
105
109
  event.preventDefault();
106
110
  if (canWrite && !asking && text.trim() !== '') onSubmit(text);
107
111
  }
@@ -48,12 +48,22 @@ export const DiagramPending: React.FC<{ block: Block; kind: DiagramKind }> = ({
48
48
  * The same pending state inside the boxes the block itself renders it in
49
49
  * (the block wrapper, the inline host, the viewer's fallback slot), so the
50
50
  * Suspense fallback occupies the same space the block will.
51
+ *
52
+ * With `notice` (the block's chunk failed to load, `LazyBlockBoundary`) the
53
+ * notice takes the status line's place over the same source.
51
54
  */
52
- export const DiagramBlockPending: React.FC<{ block: Block; kind: DiagramKind }> = ({ block, kind }) => (
55
+ export const DiagramBlockPending: React.FC<{ block: Block; kind: DiagramKind; notice?: React.ReactNode }> = ({ block, kind, notice }) => (
53
56
  <div className="annotation-exclude my-5 group relative" data-block-id={block.id} data-pinpoint-ignore="" data-diagram-block={kind}>
54
57
  <div data-diagram-inline="">
55
58
  <div data-diagram-fallback="" className="h-full min-h-0 w-full">
56
- <DiagramPending block={block} kind={kind} />
59
+ {notice === undefined ? (
60
+ <DiagramPending block={block} kind={kind} />
61
+ ) : (
62
+ <>
63
+ {notice}
64
+ <DiagramInlineSource block={block} kind={kind} />
65
+ </>
66
+ )}
57
67
  </div>
58
68
  </div>
59
69
  </div>
@@ -9,6 +9,7 @@ import { DiagramOverlay } from './DiagramOverlay';
9
9
  import { useDiagramComments, type DiagramComment, type DiagramCreateComment } from './useDiagramComments';
10
10
  import { useDiagramRender, type DiagramRenderState } from './useDiagramRender';
11
11
  import { useDiagramSourceDraft, type SaveResult } from './useDiagramSourceDraft';
12
+ import { LazyBlockBoundary } from '../LazyBlockBoundary';
12
13
 
13
14
  /**
14
15
  * The one diagram viewer: the canvas with its overlay, the Source pane on
@@ -204,6 +205,12 @@ export function DiagramViewer({
204
205
  if (!sourceOpen) cancelComposer();
205
206
  }, [cancelComposer, sourceOpen]);
206
207
 
208
+ // A host that takes commenting away (no handler, no reason) closes an open
209
+ // composer: left open it became a Comment form nothing could save.
210
+ useEffect(() => {
211
+ if (!canCreate) cancelComposer();
212
+ }, [cancelComposer, canCreate]);
213
+
207
214
  // The composer handed to the host: the draft's part goes out, and the
208
215
  // host's cancel closes it only while it is still the open one.
209
216
  const onHostDraftRef = useRef(onHostDraft);
@@ -284,9 +291,18 @@ export function DiagramViewer({
284
291
  column and the pane stays stacked UNDER the canvas, which
285
292
  `order-last` keeps while `md:order-first` puts it left from `md`. */}
286
293
  {hasPane && sourceOpen && (
287
- <Suspense fallback={<div data-diagram-source-pane-pending="" aria-hidden="true" className={PANE_CLASS} />}>
288
- <DiagramSourcePane draft={draft} editable={editable} markedLines={markedLines} className={PANE_CLASS} />
289
- </Suspense>
294
+ <LazyBlockBoundary
295
+ fallback={(notice) => (
296
+ <div data-diagram-source-pane-failed="" className={cn(PANE_CLASS, 'overflow-auto p-3')}>
297
+ {notice}
298
+ <pre className="text-xs text-muted-foreground"><code className="font-mono">{draft.draft}</code></pre>
299
+ </div>
300
+ )}
301
+ >
302
+ <Suspense fallback={<div data-diagram-source-pane-pending="" aria-hidden="true" className={PANE_CLASS} />}>
303
+ <DiagramSourcePane draft={draft} editable={editable} markedLines={markedLines} className={PANE_CLASS} />
304
+ </Suspense>
305
+ </LazyBlockBoundary>
290
306
  )}
291
307
  <div className="relative min-h-0 min-w-0 flex-1">
292
308
  {showFallback ? (
@@ -0,0 +1,374 @@
1
+ /**
2
+ * A ```html fence drawn as a live page in the document (approval
3
+ * "inbox-inline-html", 2026-10-28; the policy and the page's protocol are
4
+ * @plannotator/core/html-embed). Only for a host that serves embed documents
5
+ * (`HtmlEmbedHost`, the Viewer's `htmlEmbeds`); without one a fence stays
6
+ * code, as before.
7
+ *
8
+ * A sandboxed frame, as T3 Code draws an agent's HTML: an opaque origin, its
9
+ * height and theme by message (checked against the frame's own window), and
10
+ * a web link opened only while the person is using the frame (it has focus
11
+ * and the window has the person's activation).
12
+ *
13
+ * The look is the owner's: the page sits in the text with no card, border or
14
+ * background of its own (its document is transparent and takes the host's
15
+ * theme and color-scheme), as tall as its page up to `EMBED_CLIP_PX`, then a
16
+ * fade and "Show more". Under it one quiet line: "HTML · sandboxed", Code
17
+ * (the fence's source in place of the page) and Full screen (the same page in
18
+ * ui's popout, scrolling).
19
+ */
20
+
21
+ import { Code } from 'lucide-react';
22
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
23
+ import {
24
+ EMBED_SANDBOX,
25
+ EMBED_THEME_TOKENS,
26
+ embedFragment,
27
+ embedThemeMessage,
28
+ readEmbedMessage,
29
+ type EmbedMode,
30
+ type EmbedTheme,
31
+ } from '@plannotator/core/html-embed';
32
+ import type { Block } from '../../types';
33
+ import { CodeBlock } from '../blocks/CodeBlock';
34
+ import { HostTitle } from '../HostTitle';
35
+ import { PopoutDialog } from '../PopoutDialog';
36
+ import { useTheme } from '../ThemeProvider';
37
+ import { acquireEmbedLoad, claimEmbedLive, onEmbedLiveFreed, releaseEmbedLive, useNearViewport } from './loadGate';
38
+
39
+ /** A host that can serve embed documents (the Inbox: POST /api/inbox/embeds). */
40
+ export interface HtmlEmbedHost {
41
+ /**
42
+ * Keep `html` and give the URL of its embed document, served under
43
+ * `EMBED_RESPONSE_CSP`. Null (or a rejection) shows the fence as code.
44
+ */
45
+ frameUrl(html: string): Promise<string | null>;
46
+ /** Who wrote the page ("Claude Code"), for the caption's hover text. */
47
+ author?: string;
48
+ }
49
+
50
+ /** How tall a page shows in the text before "Show more" (30rem). */
51
+ export const EMBED_CLIP_PX = 480;
52
+ /** The frame's height until its page reports one. */
53
+ const EMBED_FIRST_HEIGHT = 120;
54
+
55
+ /** The host's theme, read from the page's tokens once they have applied. */
56
+ function useEmbedTheme(): EmbedTheme {
57
+ const { colorTheme, resolvedMode } = useTheme();
58
+ const read = useCallback((): EmbedTheme => {
59
+ const style = getComputedStyle(document.documentElement);
60
+ const vars: Record<string, string> = {};
61
+ for (const name of EMBED_THEME_TOKENS) {
62
+ const value = style.getPropertyValue(name).trim();
63
+ if (value) vars[name] = value;
64
+ }
65
+ return { vars, scheme: resolvedMode === 'light' ? 'light' : 'dark' };
66
+ }, [resolvedMode]);
67
+ const [theme, setTheme] = useState<EmbedTheme>(read);
68
+ useEffect(() => {
69
+ // The provider writes the tokens in its own effect, after this one: read on the next frame.
70
+ const frame = requestAnimationFrame(() => setTheme(read()));
71
+ return () => cancelAnimationFrame(frame);
72
+ }, [colorTheme, read]);
73
+ return theme;
74
+ }
75
+
76
+ /** How long a page has to speak (its bootstrap reports a height) before the block shows its code. */
77
+ let readyTimeoutMs = 10_000;
78
+ /** Tests only. */
79
+ export function setEmbedReadyTimeoutForTests(ms: number): void {
80
+ readyTimeoutMs = ms;
81
+ }
82
+
83
+ /**
84
+ * One embed frame and its messages. `onLoaded` runs on its load. A link the
85
+ * page asks for opens only while the person is using this frame: it has
86
+ * focus, and the window has the person's activation (as T3 Code).
87
+ */
88
+ function EmbedFrame({
89
+ url,
90
+ theme,
91
+ mode,
92
+ title,
93
+ height,
94
+ onHeight,
95
+ onEscape,
96
+ onLoaded,
97
+ onReady,
98
+ }: {
99
+ url: string;
100
+ theme: EmbedTheme;
101
+ mode: EmbedMode;
102
+ title: string;
103
+ height?: number;
104
+ onHeight?: (height: number) => void;
105
+ onEscape: (frame: HTMLIFrameElement | null) => void;
106
+ onLoaded?: () => void;
107
+ onReady?: () => void;
108
+ }) {
109
+ const ref = useRef<HTMLIFrameElement>(null);
110
+ const loaded = useRef(false);
111
+ // The theme the page starts with rides in the URL (no flash); later ones go by message.
112
+ const [src] = useState(() => url + embedFragment(theme, mode));
113
+ const handlers = useRef({ onHeight, onEscape, onLoaded, onReady });
114
+ handlers.current = { onHeight, onEscape, onLoaded, onReady };
115
+ const spoke = useRef(false);
116
+
117
+ useEffect(() => {
118
+ const onMessage = (event: MessageEvent) => {
119
+ // Only this frame's own page: every other window (another embed, a page it framed) is ignored.
120
+ const frame = ref.current;
121
+ if (!frame || event.source !== frame.contentWindow) return;
122
+ const message = readEmbedMessage(event.data);
123
+ if (!message) return;
124
+ if (!spoke.current) {
125
+ spoke.current = true;
126
+ handlers.current.onReady?.();
127
+ }
128
+ if (message.kind === 'height') handlers.current.onHeight?.(message.height);
129
+ else if (message.kind === 'link') {
130
+ const activation = (navigator as Navigator & { userActivation?: { isActive: boolean } }).userActivation;
131
+ if (document.activeElement !== frame || activation?.isActive === false) return;
132
+ window.open(message.url, '_blank', 'noopener,noreferrer');
133
+ } else handlers.current.onEscape(frame);
134
+ };
135
+ window.addEventListener('message', onMessage);
136
+ return () => window.removeEventListener('message', onMessage);
137
+ }, []);
138
+
139
+ useEffect(() => {
140
+ if (loaded.current) ref.current?.contentWindow?.postMessage(embedThemeMessage(theme), '*');
141
+ }, [theme]);
142
+
143
+ return (
144
+ <iframe
145
+ ref={ref}
146
+ src={src}
147
+ sandbox={EMBED_SANDBOX}
148
+ referrerPolicy="no-referrer"
149
+ loading="lazy"
150
+ title={title}
151
+ data-html-embed-frame={mode}
152
+ onLoad={() => {
153
+ if (loaded.current) return;
154
+ loaded.current = true;
155
+ handlers.current.onLoaded?.();
156
+ }}
157
+ style={{
158
+ display: 'block',
159
+ width: '100%',
160
+ height: mode === 'full' ? '100%' : `${height ?? EMBED_FIRST_HEIGHT}px`,
161
+ border: 0,
162
+ background: 'transparent',
163
+ // Must match the page's own color-scheme, or the browser paints an opaque backdrop behind it.
164
+ colorScheme: theme.scheme,
165
+ }}
166
+ />
167
+ );
168
+ }
169
+
170
+ type Phase = 'far' | 'waiting' | 'loading' | 'ready' | 'failed';
171
+
172
+ export function HtmlEmbedBlock({ block, host }: { block: Block; host: HtmlEmbedHost }) {
173
+ const figureRef = useRef<HTMLElement>(null);
174
+ const near = useNearViewport(figureRef);
175
+ const theme = useEmbedTheme();
176
+ const [showCode, setShowCode] = useState(false);
177
+ const [phase, setPhase] = useState<Phase>('far');
178
+ const [url, setUrl] = useState<string | null>(null);
179
+ const [height, setHeight] = useState<number | undefined>(undefined);
180
+ const [expanded, setExpanded] = useState(false);
181
+ const [full, setFull] = useState(false);
182
+ const [forced, setForced] = useState(false);
183
+ const releaseLoad = useRef<(() => void) | null>(null);
184
+ // The host is read when a page is asked for: a new host object (a re-render) never reloads the page.
185
+ const hostRef = useRef(host);
186
+ hostRef.current = host;
187
+
188
+ // Near the screen: take a live slot (or wait for one), a load permit, then the page's URL.
189
+ useEffect(() => {
190
+ if (!near || showCode) return;
191
+ let cancelled = false;
192
+ let holdsLive = false;
193
+ let stopWaiting: (() => void) | null = null;
194
+ const start = () => {
195
+ if (cancelled) return;
196
+ if (!claimEmbedLive(forced)) {
197
+ setPhase('waiting');
198
+ stopWaiting = onEmbedLiveFreed(() => {
199
+ stopWaiting?.();
200
+ stopWaiting = null;
201
+ start();
202
+ });
203
+ return;
204
+ }
205
+ holdsLive = true;
206
+ setPhase('loading');
207
+ void acquireEmbedLoad().then(async (release) => {
208
+ if (cancelled) return release();
209
+ releaseLoad.current = release;
210
+ let next: string | null = null;
211
+ try {
212
+ next = await hostRef.current.frameUrl(block.content);
213
+ } catch {
214
+ next = null;
215
+ }
216
+ if (cancelled) return release();
217
+ if (next === null) {
218
+ release();
219
+ setPhase('failed');
220
+ return;
221
+ }
222
+ setUrl(next);
223
+ });
224
+ };
225
+ start();
226
+ return () => {
227
+ cancelled = true;
228
+ stopWaiting?.();
229
+ releaseLoad.current?.();
230
+ releaseLoad.current = null;
231
+ if (holdsLive) releaseEmbedLive();
232
+ setUrl(null);
233
+ };
234
+ }, [near, showCode, forced, block.content]);
235
+
236
+ // Loaded: the load permit goes back. Ready only once the page speaks (a frame the browser
237
+ // refused loads too, blank, and never speaks).
238
+ const loaded = useCallback(() => {
239
+ releaseLoad.current?.();
240
+ releaseLoad.current = null;
241
+ }, []);
242
+ const ready = useCallback(() => {
243
+ releaseLoad.current?.();
244
+ releaseLoad.current = null;
245
+ setPhase((now) => (now === 'loading' ? 'ready' : now));
246
+ }, []);
247
+
248
+ // A page that never speaks (the frame was refused, or the Inbox lost it): its code instead.
249
+ useEffect(() => {
250
+ if (phase !== 'loading' || url === null) return;
251
+ const timer = setTimeout(() => setPhase((now) => (now === 'loading' ? 'failed' : now)), readyTimeoutMs);
252
+ return () => clearTimeout(timer);
253
+ }, [phase, url]);
254
+
255
+ const escapeInline = useCallback((frame: HTMLIFrameElement | null) => {
256
+ // Escape in the page hands the keyboard back to the document (the host's own Escape comes next).
257
+ if (frame && document.activeElement === frame) frame.blur();
258
+ }, []);
259
+
260
+ const clipped = !expanded && height !== undefined && height > EMBED_CLIP_PX;
261
+ const title = host.author ? `HTML from ${host.author}` : 'HTML page';
262
+ const caption = host.author ? `From ${host.author}. It runs in a sandbox and can load from the web.` : 'It runs in a sandbox and can load from the web.';
263
+ const live = !showCode && url !== null;
264
+
265
+ const body = useMemo(() => {
266
+ if (showCode || phase === 'failed') return <CodeBlock block={block} isHovered={false} />;
267
+ if (phase === 'waiting') {
268
+ return (
269
+ <button
270
+ type="button"
271
+ data-html-embed-waiting=""
272
+ className="flex w-full items-center justify-center rounded-lg border border-dashed border-border py-6 text-xs text-muted-foreground hover:text-foreground"
273
+ style={{ minHeight: height ? Math.min(height, EMBED_CLIP_PX) : EMBED_FIRST_HEIGHT }}
274
+ onClick={() => setForced(true)}
275
+ >
276
+ Show page
277
+ </button>
278
+ );
279
+ }
280
+ return null;
281
+ }, [showCode, phase, block, height]);
282
+
283
+ return (
284
+ <figure ref={figureRef} data-block-id={block.id} data-html-embed="" data-html-embed-phase={showCode ? 'code' : phase} className="my-4">
285
+ {body ?? (
286
+ <div className="relative">
287
+ <div
288
+ data-html-embed-clip=""
289
+ style={{
290
+ maxHeight: clipped ? EMBED_CLIP_PX : undefined,
291
+ overflow: 'hidden',
292
+ // A fade that needs no background color: the message shows through it.
293
+ maskImage: clipped ? 'linear-gradient(to bottom, black calc(100% - 72px), transparent)' : undefined,
294
+ WebkitMaskImage: clipped ? 'linear-gradient(to bottom, black calc(100% - 72px), transparent)' : undefined,
295
+ }}
296
+ >
297
+ {live ? (
298
+ <EmbedFrame
299
+ key={url}
300
+ url={url}
301
+ theme={theme}
302
+ mode="inline"
303
+ title={title}
304
+ height={height}
305
+ onHeight={setHeight}
306
+ onEscape={escapeInline}
307
+ onLoaded={loaded}
308
+ onReady={ready}
309
+ />
310
+ ) : (
311
+ <div aria-hidden="true" style={{ height: height ?? EMBED_FIRST_HEIGHT }} />
312
+ )}
313
+ </div>
314
+ {clipped && (
315
+ <div className="absolute inset-x-0 bottom-1 flex justify-center">
316
+ <button
317
+ type="button"
318
+ data-html-embed-more=""
319
+ className="rounded-full border border-border bg-background px-3.5 py-1 text-xs font-medium text-foreground shadow-sm hover:bg-muted"
320
+ onClick={() => setExpanded(true)}
321
+ >
322
+ Show more
323
+ </button>
324
+ </div>
325
+ )}
326
+ </div>
327
+ )}
328
+ <figcaption data-html-embed-caption="" className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
329
+ <Code aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
330
+ <HostTitle title={caption}>
331
+ <span className="truncate">HTML · sandboxed</span>
332
+ </HostTitle>
333
+ <span className="flex-1" />
334
+ {expanded && !showCode && (
335
+ <button type="button" className="rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground" onClick={() => setExpanded(false)}>
336
+ Show less
337
+ </button>
338
+ )}
339
+ <button
340
+ type="button"
341
+ data-html-embed-code=""
342
+ aria-pressed={showCode}
343
+ className={`rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground ${showCode ? 'bg-muted text-foreground' : ''}`}
344
+ onClick={() => setShowCode((value) => !value)}
345
+ >
346
+ Code
347
+ </button>
348
+ <button
349
+ type="button"
350
+ data-html-embed-full=""
351
+ disabled={url === null || showCode}
352
+ className="rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
353
+ onClick={() => setFull(true)}
354
+ >
355
+ Full screen
356
+ </button>
357
+ </figcaption>
358
+ {full && url !== null && (
359
+ <PopoutDialog
360
+ open
361
+ onClose={() => setFull(false)}
362
+ title={title}
363
+ className="h-[calc(100vh-2rem)] w-[calc(100vw-2rem)] max-w-none"
364
+ dataAttributes={{ 'data-html-embed-popout': '' }}
365
+ >
366
+ <div className="flex h-9 shrink-0 items-center border-b border-border pl-4 pr-12 text-xs font-medium text-foreground">{title}</div>
367
+ <div className="min-h-0 flex-1">
368
+ <EmbedFrame url={url} theme={theme} mode="full" title={title} onEscape={() => setFull(false)} />
369
+ </div>
370
+ </PopoutDialog>
371
+ )}
372
+ </figure>
373
+ );
374
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * A ```svg fence drawn as an image (approval "inbox-inline-html"), for a host
3
+ * with `htmlEmbeds`. Through `<img>` an SVG runs no script and loads nothing,
4
+ * so it needs no frame. Code shows the source in its place.
5
+ */
6
+
7
+ import { Code } from 'lucide-react';
8
+ import { useMemo, useState } from 'react';
9
+ import type { Block } from '../../types';
10
+ import { CodeBlock } from '../blocks/CodeBlock';
11
+
12
+ /** Larger than this, the drawing stays code. */
13
+ const SVG_MAX_CHARS = 512 * 1024;
14
+
15
+ export function SvgFenceBlock({ block }: { block: Block }) {
16
+ const [showCode, setShowCode] = useState(false);
17
+ const [broken, setBroken] = useState(false);
18
+ const src = useMemo(() => `data:image/svg+xml;charset=utf-8,${encodeURIComponent(block.content)}`, [block.content]);
19
+ const drawable = block.content.length <= SVG_MAX_CHARS && /<svg[\s>]/i.test(block.content);
20
+ if (!drawable) return <CodeBlock block={block} isHovered={false} />;
21
+ return (
22
+ <figure data-block-id={block.id} data-svg-fence="" className="my-4">
23
+ {showCode || broken ? (
24
+ <CodeBlock block={block} isHovered={false} />
25
+ ) : (
26
+ <img src={src} alt="" className="block h-auto max-w-full" onError={() => setBroken(true)} />
27
+ )}
28
+ <figcaption className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
29
+ <Code aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
30
+ <span>SVG</span>
31
+ <span className="flex-1" />
32
+ {!broken && (
33
+ <button
34
+ type="button"
35
+ data-svg-fence-code=""
36
+ aria-pressed={showCode}
37
+ className={`rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground ${showCode ? 'bg-muted text-foreground' : ''}`}
38
+ onClick={() => setShowCode((value) => !value)}
39
+ >
40
+ Code
41
+ </button>
42
+ )}
43
+ </figcaption>
44
+ </figure>
45
+ );
46
+ }
@@ -0,0 +1,3 @@
1
+ export { HtmlEmbedBlock, EMBED_CLIP_PX, type HtmlEmbedHost } from './HtmlEmbedBlock';
2
+ export { SvgFenceBlock } from './SvgFenceBlock';
3
+ export { isHtmlEmbedLanguage, isSvgFenceLanguage } from './languages';
@@ -0,0 +1,15 @@
1
+ /** Fence languages a host with `htmlEmbeds` draws instead of showing code (approval "inbox-inline-html"). */
2
+
3
+ function fenceLanguage(language?: string): string | undefined {
4
+ return language?.trim().split(/\s+/, 1)[0]?.toLowerCase();
5
+ }
6
+
7
+ /** ```html: a live page in a sandboxed frame. */
8
+ export function isHtmlEmbedLanguage(language?: string): boolean {
9
+ return fenceLanguage(language) === 'html';
10
+ }
11
+
12
+ /** ```svg: a drawing, shown as an image (it never runs). */
13
+ export function isSvgFenceLanguage(language?: string): boolean {
14
+ return fenceLanguage(language) === 'svg';
15
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * How many live HTML embeds a page runs, and when they load (approval
3
+ * "inbox-inline-html"; the gate Workspaces settled on for its previews):
4
+ * - a frame loads only once it comes within `EMBED_NEAR_PX` of the screen;
5
+ * - at most `EMBED_CONCURRENT_LOADS` load at once, each holding its permit
6
+ * until its document has loaded (or `EMBED_LOAD_TIMEOUT_MS` passed);
7
+ * - at most `EMBED_LIVE_MAX` are live at once; past that an embed waits as
8
+ * a "Show page" button until the person asks or a live one goes away.
9
+ */
10
+
11
+ import { useEffect, useState, type RefObject } from 'react';
12
+
13
+ export const EMBED_NEAR_PX = 400;
14
+ export const EMBED_CONCURRENT_LOADS = 2;
15
+ export const EMBED_LIVE_MAX = 12;
16
+ export const EMBED_LOAD_TIMEOUT_MS = 8000;
17
+
18
+ let loading = 0;
19
+ const waiting: Array<() => void> = [];
20
+
21
+ /** Wait for a load permit; call the release it resolves to once the frame has loaded. */
22
+ export function acquireEmbedLoad(): Promise<() => void> {
23
+ return new Promise((resolve) => {
24
+ const grant = () => {
25
+ loading += 1;
26
+ let released = false;
27
+ const release = () => {
28
+ if (released) return;
29
+ released = true;
30
+ loading -= 1;
31
+ waiting.shift()?.();
32
+ };
33
+ setTimeout(release, EMBED_LOAD_TIMEOUT_MS);
34
+ resolve(release);
35
+ };
36
+ if (loading < EMBED_CONCURRENT_LOADS) grant();
37
+ else waiting.push(grant);
38
+ });
39
+ }
40
+
41
+ let live = 0;
42
+ const liveListeners = new Set<() => void>();
43
+
44
+ /** Take a live slot; false when `EMBED_LIVE_MAX` are already live (unless `force`, the person's own ask). */
45
+ export function claimEmbedLive(force = false): boolean {
46
+ if (!force && live >= EMBED_LIVE_MAX) return false;
47
+ live += 1;
48
+ return true;
49
+ }
50
+
51
+ export function releaseEmbedLive(): void {
52
+ live = Math.max(0, live - 1);
53
+ for (const listener of [...liveListeners]) listener();
54
+ }
55
+
56
+ /** Called when a live slot frees up, so a waiting embed can try again. */
57
+ export function onEmbedLiveFreed(listener: () => void): () => void {
58
+ liveListeners.add(listener);
59
+ return () => liveListeners.delete(listener);
60
+ }
61
+
62
+ /** For tests: forget every permit and slot. */
63
+ export function resetEmbedGateForTests(): void {
64
+ loading = 0;
65
+ waiting.length = 0;
66
+ live = 0;
67
+ liveListeners.clear();
68
+ }
69
+
70
+ /** The nearest ancestor that scrolls vertically, or null for the page itself. */
71
+ function scrollParentOf(el: Element): Element | null {
72
+ for (let node = el.parentElement; node; node = node.parentElement) {
73
+ const overflow = getComputedStyle(node).overflowY;
74
+ if (overflow === 'auto' || overflow === 'scroll' || overflow === 'overlay') return node;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * True once the element has come within `EMBED_NEAR_PX` of the screen (and
81
+ * stays true). Measured against the pane that scrolls it: a margin on the
82
+ * viewport would stop at the pane's clip.
83
+ */
84
+ export function useNearViewport(ref: RefObject<Element | null>): boolean {
85
+ const [near, setNear] = useState(false);
86
+ useEffect(() => {
87
+ if (near) return;
88
+ const el = ref.current;
89
+ if (!el) return;
90
+ if (typeof IntersectionObserver === 'undefined') {
91
+ setNear(true);
92
+ return;
93
+ }
94
+ const observer = new IntersectionObserver(
95
+ (entries) => {
96
+ if (entries.some((entry) => entry.isIntersecting)) {
97
+ setNear(true);
98
+ observer.disconnect();
99
+ }
100
+ },
101
+ { root: scrollParentOf(el), rootMargin: `${EMBED_NEAR_PX}px 0px` },
102
+ );
103
+ observer.observe(el);
104
+ return () => observer.disconnect();
105
+ }, [near, ref]);
106
+ return near;
107
+ }