| author | |
| committer | |
| log | 64275ae39653379e63f49f87be021c190fd20e81 |
| tree | 97870797e4a7d7be463ee11e3ac1464fc5af22f1 |
| parent | db4d0a8e6e49d92a9efcd8d6f82bbed10e2bd748 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>5 files changed, 111 insertions(+), 24 deletions(-)
apps/server/src/mcp/toolkits/html/tools.ts+2-2| ... | @@ -68,7 +68,7 @@ export const HtmlPreviewTool = Tool.make("html_preview", { | ... | @@ -68,7 +68,7 @@ export const HtmlPreviewTool = Tool.make("html_preview", { |
| 68 | // touches no workspace, so plan mode and read-only sandboxes can use it. | 68 | // touches no workspace, so plan mode and read-only sandboxes can use it. |
| 69 | // Open-world, since the page may load remote resources, as in a preview. | 69 | // Open-world, since the page may load remote resources, as in a preview. |
| 70 | const HtmlRenderTool = Tool.make(HTML_RENDER_TOOL_NAME, { | 70 | const HtmlRenderTool = Tool.make(HTML_RENDER_TOOL_NAME, { |
| 71 | description: `Show a finished HTML page (chart, table, diagram, collage, mockup) inline in this thread, above your final text reply; call it before writing that reply. The reader already sees the page, so the reply should not announce it, say where it is, or restate it: add only what the page doesn't say. Preview with html_preview first. T3 fits the frame to the page's height at each reader's width, up to height; anything taller scrolls inside the frame. ${PAGE_RULES} ${HTML_RENDER_LAYOUT_GUIDE} ${HTML_RENDER_THEME_GUIDE}`, | 71 | description: `Show a finished HTML page (chart, table, diagram, collage, mockup) inline in this thread, above your final text reply; call it before writing that reply. The reader already sees the page, so the reply should not announce it, say where it is, or restate it: add only what the page doesn't say. Preview with html_preview first. T3 fits the frame to the page's height at each reader's width. A height below the page's contentHeight caps the frame there, and the rest scrolls inside it. ${PAGE_RULES} ${HTML_RENDER_LAYOUT_GUIDE} ${HTML_RENDER_THEME_GUIDE}`, |
| 72 | parameters: Schema.Struct({ | 72 | parameters: Schema.Struct({ |
| 73 | html: Html, | 73 | html: Html, |
| 74 | title: Schema.String.check( | 74 | title: Schema.String.check( |
| ... | @@ -76,7 +76,7 @@ const HtmlRenderTool = Tool.make(HTML_RENDER_TOOL_NAME, { | ... | @@ -76,7 +76,7 @@ const HtmlRenderTool = Tool.make(HTML_RENDER_TOOL_NAME, { |
| 76 | Schema.isMaxLength(HTML_RENDER_MAX_TITLE_LENGTH), | 76 | Schema.isMaxLength(HTML_RENDER_MAX_TITLE_LENGTH), |
| 77 | ).annotate({ description: "Short name for the page." }), | 77 | ).annotate({ description: "Short name for the page." }), |
| 78 | height: Schema.Int.annotate({ | 78 | height: Schema.Int.annotate({ |
| 79 | description: `The most the frame may take, in CSS pixels, ${HTML_RENDER_MIN_HEIGHT}-${HTML_RENDER_MAX_HEIGHT}. Use html_preview's contentHeight, or less to make long content scroll inside the frame.`, | 79 | description: `The frame height in CSS pixels, ${HTML_RENDER_MIN_HEIGHT}-${HTML_RENDER_MAX_HEIGHT}. Use html_preview's contentHeight, or less to make long content scroll inside the frame.`, |
| 80 | }), | 80 | }), |
| 81 | }), | 81 | }), |
| 82 | success: Schema.Struct({ | 82 | success: Schema.Struct({ |
apps/web/src/components/chat/HtmlRenderFrame.tsx+13-4| ... | @@ -20,8 +20,9 @@ const MIN_URL_LIFE_MS = 5 * 60_000; | ... | @@ -20,8 +20,9 @@ const MIN_URL_LIFE_MS = 5 * 60_000; |
| 20 | 20 | ||
| 21 | /** | 21 | /** |
| 22 | * An agent's HTML render inline in the thread: the page itself on the thread's | 22 | * An agent's HTML render inline in the thread: the page itself on the thread's |
| 23 | * own background, at its measured height for this width (capped at the agent's | 23 | * own background, at the server's measured height for this width until the |
| 24 | * height). Loading and failure hold the same box so nothing below it moves. | 24 | * page reports its own. Loading and failure hold the same box so nothing below |
| 25 | * it moves. | ||
| 25 | */ | 26 | */ |
| 26 | export function HtmlRenderFrame(props: { | 27 | export function HtmlRenderFrame(props: { |
| 27 | readonly environmentId: EnvironmentId; | 28 | readonly environmentId: EnvironmentId; |
| ... | @@ -43,7 +44,10 @@ export function HtmlRenderFrame(props: { | ... | @@ -43,7 +44,10 @@ export function HtmlRenderFrame(props: { |
| 43 | observer.observe(box); | 44 | observer.observe(box); |
| 44 | return () => observer.disconnect(); | 45 | return () => observer.disconnect(); |
| 45 | }, []); | 46 | }, []); |
| 46 | const height = htmlRenderFrameHeight(props.htmlRender, width); | 47 | // Client fonts can wrap a page taller than the server measured it; a frame |
| 48 | // left short would scroll inside the thread and take the reader's scroll. | ||
| 49 | const [contentHeight, setContentHeight] = useState<number>(); | ||
| 50 | const height = htmlRenderFrameHeight(props.htmlRender, width, contentHeight); | ||
| 47 | const fileName = htmlRenderFileName(title); | 51 | const fileName = htmlRenderFileName(title); |
| 48 | const resource = useMemo( | 52 | const resource = useMemo( |
| 49 | () => ({ | 53 | () => ({ |
| ... | @@ -91,7 +95,12 @@ export function HtmlRenderFrame(props: { | ... | @@ -91,7 +95,12 @@ export function HtmlRenderFrame(props: { |
| 91 | <div ref={boxRef} className="group/html-render relative" style={{ height }}> | 95 | <div ref={boxRef} className="group/html-render relative" style={{ height }}> |
| 92 | {src !== null ? ( | 96 | {src !== null ? ( |
| 93 | <> | 97 | <> |
| 94 | <HtmlRenderDocument src={src} title={title} className="block size-full" /> | 98 | <HtmlRenderDocument |
| 99 | src={src} | ||
| 100 | title={title} | ||
| 101 | className="block size-full" | ||
| 102 | onContentHeight={setContentHeight} | ||
| 103 | /> | ||
| 95 | <div className="absolute end-2 top-2 opacity-0 transition-opacity duration-150 focus-within:opacity-100 group-hover/html-render:opacity-100 pointer-coarse:opacity-100"> | 104 | <div className="absolute end-2 top-2 opacity-0 transition-opacity duration-150 focus-within:opacity-100 group-hover/html-render:opacity-100 pointer-coarse:opacity-100"> |
| 96 | <Tooltip> | 105 | <Tooltip> |
| 97 | <TooltipTrigger | 106 | <TooltipTrigger |
apps/web/src/components/files/BrowserDocumentFrame.tsx+18-1| ... | @@ -2,9 +2,10 @@ import { | ... | @@ -2,9 +2,10 @@ import { |
| 2 | htmlRenderThemeFragment, | 2 | htmlRenderThemeFragment, |
| 3 | htmlRenderThemeMessage, | 3 | htmlRenderThemeMessage, |
| 4 | htmlRenderResult, | 4 | htmlRenderResult, |
| 5 | readHtmlRenderContentHeight, | ||
| 5 | readHtmlRenderLinkRequest, | 6 | readHtmlRenderLinkRequest, |
| 6 | } from "@t3tools/shared/htmlRender"; | 7 | } from "@t3tools/shared/htmlRender"; |
| 7 | import { useEffect, useRef, useState } from "react"; | 8 | import { useEffect, useLayoutEffect, useRef, useState } from "react"; |
| 8 | 9 | ||
| 9 | import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme"; | 10 | import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme"; |
| 10 | import { cn } from "~/lib/utils"; | 11 | import { cn } from "~/lib/utils"; |
| ... | @@ -69,6 +70,8 @@ export function HtmlRenderDocument(props: { | ... | @@ -69,6 +70,8 @@ export function HtmlRenderDocument(props: { |
| 69 | readonly src: string; | 70 | readonly src: string; |
| 70 | readonly title: string; | 71 | readonly title: string; |
| 71 | readonly className?: string; | 72 | readonly className?: string; |
| 73 | /** Receives the page's content height whenever it changes, so an inline frame can fit it. */ | ||
| 74 | readonly onContentHeight?: (height: number) => void; | ||
| 72 | }) { | 75 | }) { |
| 73 | const theme = useHtmlRenderTheme(); | 76 | const theme = useHtmlRenderTheme(); |
| 74 | const frameRef = useRef<HTMLIFrameElement>(null); | 77 | const frameRef = useRef<HTMLIFrameElement>(null); |
| ... | @@ -102,6 +105,20 @@ export function HtmlRenderDocument(props: { | ... | @@ -102,6 +105,20 @@ export function HtmlRenderDocument(props: { |
| 102 | window.addEventListener("message", openLink); | 105 | window.addEventListener("message", openLink); |
| 103 | return () => window.removeEventListener("message", openLink); | 106 | return () => window.removeEventListener("message", openLink); |
| 104 | }, []); | 107 | }, []); |
| 108 | const { onContentHeight } = props; | ||
| 109 | // A page posts its height once per change, so listen from the commit that | ||
| 110 | // inserts the frame; a passive effect could run after a fast page's first post. | ||
| 111 | useLayoutEffect(() => { | ||
| 112 | if (onContentHeight === undefined) return; | ||
| 113 | const resize = (event: MessageEvent) => { | ||
| 114 | const height = readHtmlRenderContentHeight(event.data); | ||
| 115 | if (height !== undefined && event.source === frameRef.current?.contentWindow) { | ||
| 116 | onContentHeight(height); | ||
| 117 | } | ||
| 118 | }; | ||
| 119 | window.addEventListener("message", resize); | ||
| 120 | return () => window.removeEventListener("message", resize); | ||
| 121 | }, [onContentHeight]); | ||
| 105 | return ( | 122 | return ( |
| 106 | <iframe | 123 | <iframe |
| 107 | ref={frameRef} | 124 | ref={frameRef} |
packages/shared/src/htmlRender.test.ts+32-2| ... | @@ -1,12 +1,14 @@ | ... | @@ -1,12 +1,14 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | HTML_RENDER_MAX_HEIGHT, | ||
| 4 | htmlRenderFrameHeight, | 5 | htmlRenderFrameHeight, |
| 5 | htmlRenderReferencesEqual, | 6 | htmlRenderReferencesEqual, |
| 6 | htmlRenderTheme, | 7 | htmlRenderTheme, |
| 7 | htmlRenderThemeFragment, | 8 | htmlRenderThemeFragment, |
| 8 | injectHtmlRenderBootstrap, | 9 | injectHtmlRenderBootstrap, |
| 9 | htmlRenderThemeMessage, | 10 | htmlRenderThemeMessage, |
| 11 | readHtmlRenderContentHeight, | ||
| 10 | readHtmlRenderLinkRequest, | 12 | readHtmlRenderLinkRequest, |
| 11 | readHtmlRenderReference, | 13 | readHtmlRenderReference, |
| 12 | } from "./htmlRender.ts"; | 14 | } from "./htmlRender.ts"; |
| ... | @@ -99,6 +101,22 @@ describe("readHtmlRenderLinkRequest", () => { | ... | @@ -99,6 +101,22 @@ describe("readHtmlRenderLinkRequest", () => { |
| 99 | }); | 101 | }); |
| 100 | }); | 102 | }); |
| 101 | 103 | ||
| 104 | describe("readHtmlRenderContentHeight", () => { | ||
| 105 | it("reads only the height of an MCP Apps size-changed notification", () => { | ||
| 106 | const notification = (params: unknown) => ({ | ||
| 107 | jsonrpc: "2.0", | ||
| 108 | method: "ui/notifications/size-changed", | ||
| 109 | params, | ||
| 110 | }); | ||
| 111 | expect(readHtmlRenderContentHeight(notification({ height: 412 }))).toBe(412); | ||
| 112 | expect(readHtmlRenderContentHeight(notification({ height: "412" }))).toBe(undefined); | ||
| 113 | expect(readHtmlRenderContentHeight(notification({ height: 0 }))).toBe(undefined); | ||
| 114 | expect(readHtmlRenderContentHeight({ ...notification({ height: 412 }), method: "x" })).toBe( | ||
| 115 | undefined, | ||
| 116 | ); | ||
| 117 | }); | ||
| 118 | }); | ||
| 119 | |||
| 102 | describe("htmlRenderThemeMessage", () => { | 120 | describe("htmlRenderThemeMessage", () => { |
| 103 | it("is an MCP Apps host-context-changed notification carrying the theme variables", () => { | 121 | it("is an MCP Apps host-context-changed notification carrying the theme variables", () => { |
| 104 | const theme = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark"); | 122 | const theme = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark"); |
| ... | @@ -193,9 +211,21 @@ describe("htmlRenderFrameHeight", () => { | ... | @@ -193,9 +211,21 @@ describe("htmlRenderFrameHeight", () => { |
| 193 | expect(htmlRenderFrameHeight(responsive, 640)).toBe(450); | 211 | expect(htmlRenderFrameHeight(responsive, 640)).toBe(450); |
| 194 | }); | 212 | }); |
| 195 | 213 | ||
| 196 | it("never exceeds the agent's height and falls back to it without measurements", () => { | 214 | it("fits a page the client lays out taller than the server measured", () => { |
| 197 | expect(htmlRenderFrameHeight(measured, 1400)).toBe(1500); | 215 | // The agent passed contentHeight at the column width, so the page should never scroll. |
| 216 | const fitted = { ...measured, height: 1403 }; | ||
| 217 | expect(htmlRenderFrameHeight(fitted, 728, 1415)).toBe(1415); | ||
| 218 | expect(htmlRenderFrameHeight(fitted, 1400)).toBe(1660); | ||
| 219 | expect(htmlRenderFrameHeight(fitted, 728, 5000)).toBe(HTML_RENDER_MAX_HEIGHT); | ||
| 220 | }); | ||
| 221 | |||
| 222 | it("keeps the agent's height when it asked for a scrolling frame or the page is unmeasured", () => { | ||
| 223 | const scrolling = { ...measured, height: 600 }; | ||
| 224 | expect(htmlRenderFrameHeight(scrolling, 728, 1415)).toBe(600); | ||
| 225 | expect(htmlRenderFrameHeight(scrolling, 1400)).toBe(600); | ||
| 198 | expect(htmlRenderFrameHeight(reference, 728)).toBe(reference.height); | 226 | expect(htmlRenderFrameHeight(reference, 728)).toBe(reference.height); |
| 227 | expect(htmlRenderFrameHeight(reference, 728, 900)).toBe(reference.height); | ||
| 228 | expect(htmlRenderFrameHeight(reference, 728, 300)).toBe(300); | ||
| 199 | }); | 229 | }); |
| 200 | 230 | ||
| 201 | it("drops a malformed table and compares tables by value", () => { | 231 | it("drops a malformed table and compares tables by value", () => { |
packages/shared/src/htmlRender.ts+46-15| ... | @@ -98,22 +98,37 @@ export function htmlRenderReferencesEqual(left: HtmlRenderReference, right: Html | ... | @@ -98,22 +98,37 @@ export function htmlRenderReferencesEqual(left: HtmlRenderReference, right: Html |
| 98 | ); | 98 | ); |
| 99 | } | 99 | } |
| 100 | 100 | ||
| 101 | /** | 101 | // The taller of the heights measured at the nearest widths on each side. A |
| 102 | * The frame height for a page at a frame width: the taller of the heights | 102 | // breakpoint between two measured widths can make the page as tall as either. |
| 103 | * measured at the nearest widths on each side, capped at the agent's height. | 103 | function measuredHeight(heights: NonNullable<HtmlRenderReference["heights"]>, width: number) { |
| 104 | * A breakpoint between two measured widths can make the page as tall as | ||
| 105 | * either, so the frame takes the taller. A page whose height grows with its | ||
| 106 | * width inside one layout can still run slightly past the frame between | ||
| 107 | * measured widths. | ||
| 108 | */ | ||
| 109 | export function htmlRenderFrameHeight(reference: HtmlRenderReference, width: number) { | ||
| 110 | const heights = reference.heights; | ||
| 111 | if (heights === undefined || heights.length === 0) return reference.height; | ||
| 112 | const above = heights.findIndex(([measuredWidth]) => measuredWidth >= width); | 104 | const above = heights.findIndex(([measuredWidth]) => measuredWidth >= width); |
| 113 | const high = above === -1 ? heights.length - 1 : above; | 105 | const high = above === -1 ? heights.length - 1 : above; |
| 114 | const low = heights[high]![0] === width ? high : Math.max(0, high - 1); | 106 | const low = heights[high]![0] === width ? high : Math.max(0, high - 1); |
| 115 | const measured = Math.max(heights[low]![1], heights[high]![1]); | 107 | return Math.max(heights[low]![1], heights[high]![1]); |
| 116 | return clampHtmlRenderHeight(Math.min(reference.height, measured)); | 108 | } |
| 109 | |||
| 110 | /** | ||
| 111 | * The frame height for a page at a frame width. It is the page's own reported | ||
| 112 | * `contentHeight` when the client has one, else the server's measurement for | ||
| 113 | * that width. A page even a few pixels taller than its frame scrolls inside it | ||
| 114 | * and takes the reader's scroll, so the frame fits the page. The agent's height | ||
| 115 | * caps it only when it is below the page's height at the column width (the | ||
| 116 | * agent asked for a scrolling frame) or when the page was never measured. | ||
| 117 | */ | ||
| 118 | export function htmlRenderFrameHeight( | ||
| 119 | reference: HtmlRenderReference, | ||
| 120 | width: number, | ||
| 121 | contentHeight?: number, | ||
| 122 | ) { | ||
| 123 | const heights = reference.heights; | ||
| 124 | if (heights === undefined || heights.length === 0) { | ||
| 125 | return clampHtmlRenderHeight(Math.min(reference.height, contentHeight ?? reference.height)); | ||
| 126 | } | ||
| 127 | const cap = | ||
| 128 | measuredHeight(heights, HTML_RENDER_COLUMN_WIDTH) > reference.height | ||
| 129 | ? reference.height | ||
| 130 | : HTML_RENDER_MAX_HEIGHT; | ||
| 131 | return clampHtmlRenderHeight(Math.min(cap, contentHeight ?? measuredHeight(heights, width))); | ||
| 117 | } | 132 | } |
| 118 | 133 | ||
| 119 | /** A readable download name: the title without characters file systems reject. */ | 134 | /** A readable download name: the title without characters file systems reject. */ |
| ... | @@ -248,6 +263,7 @@ export const HTML_RENDER_LAYOUT_GUIDE = [ | ... | @@ -248,6 +263,7 @@ export const HTML_RENDER_LAYOUT_GUIDE = [ |
| 248 | `The frame is borderless on the thread's background, as wide as the reply column (${HTML_RENDER_COLUMN_WIDTH}px on desktop by default, wider if the reader widens chat, about 360px on phones), and its left edge lines up with your reply text.`, | 263 | `The frame is borderless on the thread's background, as wide as the reply column (${HTML_RENDER_COLUMN_WIDTH}px on desktop by default, wider if the reader widens chat, about 360px on phones), and its left edge lines up with your reply text.`, |
| 249 | "Use a fluid width with no horizontal padding on the outermost element, and no outer card, border, or banner title: the page is part of your reply.", | 264 | "Use a fluid width with no horizontal padding on the outermost element, and no outer card, border, or banner title: the page is part of your reply.", |
| 250 | "Give charts fixed pixel heights rather than heights that scale with width.", | 265 | "Give charts fixed pixel heights rather than heights that scale with width.", |
| 266 | "Let content set the page's height. Avoid viewport-based heights such as 100vh or height:100% on html or body; the frame grows to fit the page, so they can make it grow again and again.", | ||
| 251 | ].join(" "); | 267 | ].join(" "); |
| 252 | 268 | ||
| 253 | // The bridge between a render and its client speaks the MCP Apps protocol | 269 | // The bridge between a render and its client speaks the MCP Apps protocol |
| ... | @@ -255,6 +271,19 @@ export const HTML_RENDER_LAYOUT_GUIDE = [ | ... | @@ -255,6 +271,19 @@ export const HTML_RENDER_LAYOUT_GUIDE = [ |
| 255 | // MCP apps: https://github.com/modelcontextprotocol/ext-apps | 271 | // MCP apps: https://github.com/modelcontextprotocol/ext-apps |
| 256 | const HOST_CONTEXT_CHANGED_METHOD = "ui/notifications/host-context-changed"; | 272 | const HOST_CONTEXT_CHANGED_METHOD = "ui/notifications/host-context-changed"; |
| 257 | const OPEN_LINK_METHOD = "ui/open-link"; | 273 | const OPEN_LINK_METHOD = "ui/open-link"; |
| 274 | const SIZE_CHANGED_METHOD = "ui/notifications/size-changed"; | ||
| 275 | |||
| 276 | /** The content height in a framed render's `ui/notifications/size-changed` notification. */ | ||
| 277 | export function readHtmlRenderContentHeight(data: unknown): number | undefined { | ||
| 278 | if (typeof data !== "object" || data === null) return undefined; | ||
| 279 | const { jsonrpc, method, params } = data as Record<string, unknown>; | ||
| 280 | if (jsonrpc !== "2.0" || method !== SIZE_CHANGED_METHOD) return undefined; | ||
| 281 | const height = | ||
| 282 | typeof params === "object" && params !== null | ||
| 283 | ? (params as { height?: unknown }).height | ||
| 284 | : undefined; | ||
| 285 | return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : undefined; | ||
| 286 | } | ||
| 258 | 287 | ||
| 259 | /** A render's `ui/open-link` request, if `data` is one with an http(s) URL. */ | 288 | /** A render's `ui/open-link` request, if `data` is one with an http(s) URL. */ |
| 260 | export function readHtmlRenderLinkRequest( | 289 | export function readHtmlRenderLinkRequest( |
| ... | @@ -309,8 +338,10 @@ function rootRule(theme: HtmlRenderTheme): string { | ... | @@ -309,8 +338,10 @@ function rootRule(theme: HtmlRenderTheme): string { |
| 309 | // then drops the fragment so a page's own hash routing never sees it. A link | 338 | // then drops the fragment so a page's own hash routing never sees it. A link |
| 310 | // the reader clicks to another page never replaces the page inside the thread: | 339 | // the reader clicks to another page never replaces the page inside the thread: |
| 311 | // a framed page asks its client to open it, and a top-level page (mobile) | 340 | // a framed page asks its client to open it, and a top-level page (mobile) |
| 312 | // opens it as a new window, which the client sends to the browser. | 341 | // opens it as a new window, which the client sends to the browser. A framed |
| 313 | const BOOTSTRAP_SCRIPT = `(function(){var s=document.getElementById("t3-theme"),n=0;if(!s)return;var b=${JSON.stringify(BASE_CSS)};function a(t){if(!t||typeof t!=="object"||!t.variables||typeof t.variables!=="object")return;var c=":root{color-scheme:"+(t.appearance==="light"?"light":"dark")+";";for(var k in t.variables){if(/^--[a-z0-9-]+$/.test(k))c+=k+":"+String(t.variables[k]).replace(/[;{}<>]/g,"")+";";}s.textContent=c+"}"+b;}try{var m=/[#&]${THEME_FRAGMENT_KEY}=([^&]*)/.exec(location.hash);if(m){a(JSON.parse(decodeURIComponent(m[1])));history.replaceState(history.state,"",location.pathname+location.search);}}catch(e){}window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(HOST_CONTEXT_CHANGED_METHOD)}&&p&&p.styles)a({appearance:p.theme,variables:p.styles.variables});});document.addEventListener("click",function(e){var l=e.isTrusted?e.composedPath().find(function(t){return t&&t.matches&&t.matches("a[href]");}):null,u;if(!l)return;try{u=new URL(l.getAttribute("href"),document.baseURI);}catch(x){return;}if(!/^https?:$/.test(u.protocol)||u.href.split("#")[0]===location.href.split("#")[0])return;if(window.parent!==window){e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",id:"t3-link-"+(++n),method:${JSON.stringify(OPEN_LINK_METHOD)},params:{url:u.href}},"*");}else{l.setAttribute("target","_blank");l.setAttribute("rel","noopener");}},true);})();`; | 342 | // page also reports its content height, measured as the server measures it, |
| 343 | // so its client can fit the frame to the page. | ||
| 344 | const BOOTSTRAP_SCRIPT = `(function(){var s=document.getElementById("t3-theme"),n=0;if(!s)return;var b=${JSON.stringify(BASE_CSS)};function a(t){if(!t||typeof t!=="object"||!t.variables||typeof t.variables!=="object")return;var c=":root{color-scheme:"+(t.appearance==="light"?"light":"dark")+";";for(var k in t.variables){if(/^--[a-z0-9-]+$/.test(k))c+=k+":"+String(t.variables[k]).replace(/[;{}<>]/g,"")+";";}s.textContent=c+"}"+b;}try{var m=/[#&]${THEME_FRAGMENT_KEY}=([^&]*)/.exec(location.hash);if(m){a(JSON.parse(decodeURIComponent(m[1])));history.replaceState(history.state,"",location.pathname+location.search);}}catch(e){}window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(HOST_CONTEXT_CHANGED_METHOD)}&&p&&p.styles)a({appearance:p.theme,variables:p.styles.variables});});document.addEventListener("click",function(e){var l=e.isTrusted?e.composedPath().find(function(t){return t&&t.matches&&t.matches("a[href]");}):null,u;if(!l)return;try{u=new URL(l.getAttribute("href"),document.baseURI);}catch(x){return;}if(!/^https?:$/.test(u.protocol)||u.href.split("#")[0]===location.href.split("#")[0])return;if(window.parent!==window){e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",id:"t3-link-"+(++n),method:${JSON.stringify(OPEN_LINK_METHOD)},params:{url:u.href}},"*");}else{l.setAttribute("target","_blank");l.setAttribute("rel","noopener");}},true);if(window.parent!==window){var h,o,z=function(){var r=document.documentElement,v=Math.ceil(r.scrollHeight>r.clientHeight?r.scrollHeight:r.getBoundingClientRect().height);if(v===h)return;h=v;window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(SIZE_CHANGED_METHOD)},params:{height:v}},"*");};if(window.ResizeObserver){o=new ResizeObserver(z);o.observe(document.documentElement);}document.addEventListener("DOMContentLoaded",function(){if(o&&document.body)o.observe(document.body);z();});window.addEventListener("load",z);}})();`; | ||
| 314 | 345 | ||
| 315 | function bootstrapMarkup(markup: string): string { | 346 | function bootstrapMarkup(markup: string): string { |
| 316 | const dark = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark"); | 347 | const dark = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark"); |