diff --git a/package.json b/package.json index 30bde612d..950c10041 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,7 @@ "katex": "0.18.4", "linkedom": "0.18.13", "lucide-react": "^1.31.0", + "mathjs": "^15.2.0", "mermaid": "11.16.1", "motion": "^13.1.0", "nanoid": "^6.0.1", @@ -144,6 +145,7 @@ "tw-animate-css": "^1.4.0", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", + "uplot": "^1.6.32", "y-indexeddb": "9.0.12", "y-partyserver": "^2.2.0", "y-protocols": "^1.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4da79ae3..6d9734e89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,6 +249,9 @@ importers: lucide-react: specifier: ^1.31.0 version: 1.31.0(react@19.2.8) + mathjs: + specifier: ^15.2.0 + version: 15.2.0 mermaid: specifier: 11.16.1 version: 11.16.1 @@ -324,6 +327,9 @@ importers: unist-util-visit: specifier: ^5.1.0 version: 5.1.0 + uplot: + specifier: ^1.6.32 + version: 1.6.32 y-indexeddb: specifier: 9.0.12 version: 9.0.12(yjs@13.6.32) @@ -351,7 +357,7 @@ importers: version: 1.52.0(@voidzero-dev/vite-plus-core@0.2.9(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(wrangler@4.122.0(@cloudflare/workers-types@5.20260812.1)) '@cloudflare/vitest-pool-workers': specifier: ^0.21.2 - version: 0.21.2(@cloudflare/workers-types@5.20260812.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10) + version: 0.21.2(@cloudflare/workers-types@5.20260812.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.9(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(jsdom@28.1.0(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@7.0.2))) '@cloudflare/workers-types': specifier: ^5.20260812.1 version: 5.20260812.1 @@ -5458,6 +5464,9 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + complex.js@2.4.3: + resolution: {integrity: sha512-UrQVSUur14tNX6tiP4y8T4w4FeJAX3bi2cIv0pu/DTLFNxoq7z2Yh83Vfzztj6Px3X/lubqQ9IrPp7Bpn6p4MQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -6117,6 +6126,9 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-latex@1.2.0: + resolution: {integrity: sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==} + escape-string-regexp@2.0.0: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} @@ -6383,6 +6395,9 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + framer-motion@13.1.0: resolution: {integrity: sha512-QSZrF0Id3QGuHJ+OL+9PSY9pk86C8ERFalwAGSchzTm65+ZoGH/RM26lmEARLljcHj2lqhv0jZOOks+EI3COOw==} peerDependencies: @@ -6940,6 +6955,9 @@ packages: isomorphic.js@0.2.5: resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + javascript-natural-sort@0.7.1: + resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -7311,6 +7329,11 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mathjs@15.2.0: + resolution: {integrity: sha512-UAQzSVob9rNLdGpqcFMYmSu9dkuLYy7Lr2hBEQS5SHQdknA9VppJz3cy2KkpMzTODunad6V6cNv+5kOLsePLow==} + engines: {node: '>= 18'} + hasBin: true + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -8396,6 +8419,9 @@ packages: resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} engines: {node: '>=4'} + seedrandom@3.0.5: + resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==} + seek-bzip@2.0.0: resolution: {integrity: sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg==} hasBin: true @@ -8757,6 +8783,9 @@ packages: resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} engines: {node: '>=18'} + tiny-emitter@2.1.0: + resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -8878,6 +8907,10 @@ packages: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} + typed-function@4.2.2: + resolution: {integrity: sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==} + engines: {node: '>= 18'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -8994,6 +9027,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uplot@1.6.32: + resolution: {integrity: sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -9954,7 +9990,7 @@ snapshots: - bufferutil - utf-8-validate - '@cloudflare/vitest-pool-workers@0.21.2(@cloudflare/workers-types@5.20260812.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10)': + '@cloudflare/vitest-pool-workers@0.21.2(@cloudflare/workers-types@5.20260812.1)(@vitest/runner@4.1.10)(@vitest/snapshot@4.1.10)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.9(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0))(jsdom@28.1.0(@noble/hashes@2.3.0))(msw@2.14.6(@types/node@26.2.0)(typescript@7.0.2)))': dependencies: '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -13837,6 +13873,8 @@ snapshots: commander@8.3.0: {} + complex.js@2.4.3: {} + concat-map@0.0.1: {} conf@10.2.0: @@ -14193,8 +14231,7 @@ snapshots: dependencies: ms: 2.1.3 - decimal.js@10.6.0: - optional: true + decimal.js@10.6.0: {} decode-named-character-reference@1.3.0: dependencies: @@ -14466,6 +14503,8 @@ snapshots: escape-html@1.0.3: {} + escape-latex@1.2.0: {} + escape-string-regexp@2.0.0: {} escape-string-regexp@4.0.0: {} @@ -14801,6 +14840,8 @@ snapshots: forwarded@0.2.0: {} + fraction.js@5.3.4: {} + framer-motion@13.1.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: motion-dom: 13.0.0 @@ -15401,6 +15442,8 @@ snapshots: isomorphic.js@0.2.5: {} + javascript-natural-sort@0.7.1: {} + jiti@2.6.1: {} jiti@2.7.0: {} @@ -15731,6 +15774,18 @@ snapshots: math-intrinsics@1.1.0: {} + mathjs@15.2.0: + dependencies: + '@babel/runtime': 7.29.7 + complex.js: 2.4.3 + decimal.js: 10.6.0 + escape-latex: 1.2.0 + fraction.js: 5.3.4 + javascript-natural-sort: 0.7.1 + seedrandom: 3.0.5 + tiny-emitter: 2.1.0 + typed-function: 4.2.2 + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -17355,6 +17410,8 @@ snapshots: extend-shallow: 2.0.1 kind-of: 6.0.3 + seedrandom@3.0.5: {} + seek-bzip@2.0.0: dependencies: commander: 6.2.1 @@ -17806,6 +17863,8 @@ snapshots: throttleit@2.1.0: {} + tiny-emitter@2.1.0: {} + tiny-invariant@1.3.3: {} tinybench@2.9.0: {} @@ -17919,6 +17978,8 @@ snapshots: media-typer: 1.1.1 mime-types: 3.0.2 + typed-function@4.2.2: {} + typescript@5.9.3: {} typescript@7.0.2: @@ -18033,6 +18094,8 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uplot@1.6.32: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 diff --git a/scripts/copy-widget-libs.mjs b/scripts/copy-widget-libs.mjs index 14a494406..c845309f9 100644 --- a/scripts/copy-widget-libs.mjs +++ b/scripts/copy-widget-libs.mjs @@ -1,15 +1,15 @@ /* - * Copies KaTeX's browser build into public/ so sandboxed widget iframes can - * load it. + * Copies the browser builds of the widget libraries into public/ so sandboxed + * widget iframes can load them. * * Widgets run in an opaque-origin iframe under a strict CSP with no network * access, so they cannot pull anything from a CDN — the assets have to be - * served from this app's own origin. KaTeX's stylesheet references its fonts - * relatively (`url(fonts/...)`), so the whole directory is copied verbatim - * rather than passed through the bundler. + * served from this app's own origin. Each build is copied verbatim rather than + * passed through the bundler, so relative asset references (KaTeX fonts, uPlot + * styles) keep working. * - * Runs from `prepare`, which keeps the copy in lockstep with the installed - * katex version. The output is generated, not committed. + * Runs from `prepare`, which keeps the copies in lockstep with the installed + * versions. The output is generated, not committed. */ import { cp, mkdir, rm } from "node:fs/promises"; import { createRequire } from "node:module"; @@ -17,26 +17,41 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const require = createRequire(import.meta.url); -const katexDist = dirname(require.resolve("katex/dist/katex.min.js")); -const outDir = join( - dirname(fileURLToPath(import.meta.url)), - "..", - "public", - "widget-libs", - "katex", -); +const widgetLibsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "public", "widget-libs"); -await rm(outDir, { recursive: true, force: true }); -await mkdir(outDir, { recursive: true }); +// Each entry names a library, the directory its browser build sits in, and the +// files to copy from there. All three are CSP-safe: none use eval or +// new Function, so the frame needs no script-src 'unsafe-eval'. +const libraries = [ + { + name: "katex", + from: dirname(require.resolve("katex/dist/katex.min.js")), + // Chemistry (\ce) and units (\pu), matching what chat and documents import. + entries: ["katex.min.css", "katex.min.js", "fonts", "contrib/mhchem.min.js"], + }, + { + // mathjs parses and evaluates the expressions a graphing widget plots. + name: "mathjs", + from: dirname(require.resolve("mathjs/lib/browser/math.js")), + entries: ["math.js", "math.js.LICENSE.txt"], + }, + { + // uPlot draws the axes, grid, and lines a graphing widget would otherwise + // hand-roll in SVG. + name: "uplot", + from: dirname(require.resolve("uplot/dist/uPlot.iife.min.js")), + entries: ["uPlot.iife.min.js", "uPlot.min.css"], + }, +]; -for (const entry of [ - "katex.min.css", - "katex.min.js", - "fonts", - // Chemistry (\ce) and units (\pu), matching what chat and documents import. - "contrib/mhchem.min.js", -]) { - await cp(join(katexDist, entry), join(outDir, entry), { recursive: true }); -} +for (const library of libraries) { + const outDir = join(widgetLibsDir, library.name); + await rm(outDir, { recursive: true, force: true }); + await mkdir(outDir, { recursive: true }); + + for (const entry of library.entries) { + await cp(join(library.from, entry), join(outDir, entry), { recursive: true }); + } -console.log(`Copied KaTeX browser build to ${outDir}`); + console.log(`Copied ${library.name} browser build to ${outDir}`); +} diff --git a/src/features/workspaces/ai/skill-tools.ts b/src/features/workspaces/ai/skill-tools.ts index 14d74af46..3eb438f4b 100644 --- a/src/features/workspaces/ai/skill-tools.ts +++ b/src/features/workspaces/ai/skill-tools.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { defineAIThreadTool } from "#/features/workspaces/ai/ai-thread-tool"; import widgetAuthoringCanvas from "#/features/workspaces/ai/skills/widget-authoring/references/canvas.md?raw"; +import widgetAuthoringGraphing from "#/features/workspaces/ai/skills/widget-authoring/references/graphing.md?raw"; import widgetAuthoringStarter from "#/features/workspaces/ai/skills/widget-authoring/references/starter.md?raw"; import widgetAuthoringSkill from "#/features/workspaces/ai/skills/widget-authoring/SKILL.md?raw"; @@ -19,6 +20,7 @@ import widgetAuthoringSkill from "#/features/workspaces/ai/skills/widget-authori const BUNDLED_SKILLS = { "widget-authoring": parseSkillMarkdown(widgetAuthoringSkill, { "references/canvas.md": widgetAuthoringCanvas, + "references/graphing.md": widgetAuthoringGraphing, "references/starter.md": widgetAuthoringStarter, }), } as const; diff --git a/src/features/workspaces/ai/skills/widget-authoring/SKILL.md b/src/features/workspaces/ai/skills/widget-authoring/SKILL.md index 1324b8cb3..9b197e3ee 100644 --- a/src/features/workspaces/ai/skills/widget-authoring/SKILL.md +++ b/src/features/workspaces/ai/skills/widget-authoring/SKILL.md @@ -37,8 +37,9 @@ Bundled references are included below; consult them when they apply: - For a new widget or a ground-up rebuild, follow `references/starter.md` before writing source. - For any `` drawing, follow `references/canvas.md` before writing source. +- For a function graph, follow `references/graphing.md` before writing source. -Use the document tool's HTML math markup. The runtime renders matching math elements when they are added or changed. KaTeX is the only bundled library; create other diagrams and charts with inline SVG or canvas. +Use the document tool's HTML math markup. The runtime renders matching math elements when they are added or changed. Three libraries are bundled: KaTeX for math markup, `math` (mathjs) to parse and evaluate expressions, and `uPlot` to plot function graphs with axes and a grid. Reference a library only when the widget needs it. Create other diagrams and charts with inline SVG or canvas. ## Edit diff --git a/src/features/workspaces/ai/skills/widget-authoring/references/graphing.md b/src/features/workspaces/ai/skills/widget-authoring/references/graphing.md new file mode 100644 index 000000000..967b937a2 --- /dev/null +++ b/src/features/workspaces/ai/skills/widget-authoring/references/graphing.md @@ -0,0 +1,83 @@ +# Graphing + +Two libraries are bundled for function graphs. Reference them only when the widget plots a function; an ordinary widget loads neither. + +- `math` is [mathjs](https://mathjs.org). Use `math.compile(source)` once, then `compiled.evaluate({ x })` for each point. It parses the user's own expression, so never build a graph's expression parser by hand. +- `uPlot` draws the axes, grid, ticks, and lines. Give its target element an explicit height and pass the width from the element's measured box. + +uPlot draws to a canvas, so its colors must be concrete values, not CSS variables. Read the theme tokens with `getComputedStyle` and rebuild the plot on `thinkex:themechange`. Pass `null` for a point that is not finite so a discontinuity leaves a gap instead of a spike. + +```html + +
+
+ + +
+
+
+ +``` diff --git a/src/features/workspaces/components/widget/WorkspaceWidgetSandbox.test.tsx b/src/features/workspaces/components/widget/WorkspaceWidgetSandbox.test.tsx index 40e3b13c2..a9f6789cb 100644 --- a/src/features/workspaces/components/widget/WorkspaceWidgetSandbox.test.tsx +++ b/src/features/workspaces/components/widget/WorkspaceWidgetSandbox.test.tsx @@ -90,6 +90,29 @@ describe("WorkspaceWidgetSandbox", () => { expect(frame?.querySelector("button")).toBeNull(); }); + it("reports first render once and a runtime error with its frame state", async () => { + const onFirstRender = vi.fn(); + const onRuntimeError = vi.fn(); + await render( + root, + , + ); + const iframe = getIframe(container); + + await sendFrameMessage(iframe, { kind: "ready", sessionId: 1 }); + await sendFrameMessage(iframe, { kind: "ready", sessionId: 1 }); + expect(onFirstRender).toHaveBeenCalledTimes(1); + + await sendFrameMessage(iframe, { kind: "error", message: "Click failed", sessionId: 1 }); + await sendFrameMessage(iframe, { kind: "error", message: "Click failed", sessionId: 1 }); + expect(onRuntimeError).toHaveBeenCalledTimes(1); + expect(onRuntimeError).toHaveBeenCalledWith(true); + }); + it("keeps a ready widget mounted after a runtime error", async () => { await render(root, ); const iframe = getIframe(container); diff --git a/src/features/workspaces/components/widget/WorkspaceWidgetSandbox.tsx b/src/features/workspaces/components/widget/WorkspaceWidgetSandbox.tsx index cc6efd094..1b7b394f0 100644 --- a/src/features/workspaces/components/widget/WorkspaceWidgetSandbox.tsx +++ b/src/features/workspaces/components/widget/WorkspaceWidgetSandbox.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; import { useTheme } from "#/components/theme-provider"; import { Button } from "#/components/ui/button"; @@ -23,6 +23,13 @@ type WorkspaceWidgetSandboxProps = { * crashed widget. Omit to hide the affordance. */ onAskAiToFix?: (error: string) => void; + /** Called once the first time the frame reports it rendered. */ + onFirstRender?: () => void; + /** + * Called once per rendered source when the frame reports a runtime error. + * `preservedFrame` is true when the crash followed a working render. + */ + onRuntimeError?: (preservedFrame: boolean) => void; }; type WidgetSandboxError = { @@ -42,6 +49,8 @@ export function WorkspaceWidgetSandbox({ className, label, onAskAiToFix, + onFirstRender, + onRuntimeError, }: WorkspaceWidgetSandboxProps) { const { resolvedTheme } = useTheme(); const iframeRef = useRef(null); @@ -49,6 +58,14 @@ export function WorkspaceWidgetSandbox({ const sessionIdRef = useRef(0); const readySessionIdRef = useRef(null); const themeRef = useRef(null); + // Reported once per session so a repeated error does not flood the metric. + const reportedErrorSessionRef = useRef(null); + // The message listener is bound once, so reach the latest callbacks through + // effect events rather than effect dependencies. + const reportFirstRender = useEffectEvent(() => onFirstRender?.()); + const reportRuntimeError = useEffectEvent((preservedFrame: boolean) => + onRuntimeError?.(preservedFrame), + ); const [srcDoc, setSrcDoc] = useState(null); const [error, setError] = useState(null); const [height, setHeight] = useState(WIDGET_SANDBOX_MIN_HEIGHT); @@ -102,10 +119,12 @@ export function WorkspaceWidgetSandbox({ return; } if (event.data.kind === "error") { - setError({ - message: event.data.message, - preserveFrame: readySessionIdRef.current === event.data.sessionId, - }); + const preserveFrame = readySessionIdRef.current === event.data.sessionId; + setError({ message: event.data.message, preserveFrame }); + if (reportedErrorSessionRef.current !== event.data.sessionId) { + reportedErrorSessionRef.current = event.data.sessionId; + reportRuntimeError(preserveFrame); + } return; } if (event.data.kind === "height") { @@ -117,7 +136,11 @@ export function WorkspaceWidgetSandbox({ ); return; } + const firstRenderForSession = readySessionIdRef.current !== event.data.sessionId; readySessionIdRef.current = event.data.sessionId; + if (firstRenderForSession) { + reportFirstRender(); + } if (themeRef.current) { postWidgetSandboxTheme(iframeRef.current, event.data.sessionId, themeRef.current); } diff --git a/src/features/workspaces/components/widget/workspace-widget-sandbox-document.test.ts b/src/features/workspaces/components/widget/workspace-widget-sandbox-document.test.ts index 65c6c0224..0579af833 100644 --- a/src/features/workspaces/components/widget/workspace-widget-sandbox-document.test.ts +++ b/src/features/workspaces/components/widget/workspace-widget-sandbox-document.test.ts @@ -2,17 +2,24 @@ import { describe, expect, it } from "vitest"; import { buildWidgetSandboxDocument, + detectWidgetLibraries, isWidgetSandboxFrameMessage, } from "#/features/workspaces/components/widget/workspace-widget-sandbox-document"; -function loadsKatex(html: string) { +function documentFor(html: string) { return buildWidgetSandboxDocument({ html, theme: "light", tokens: { "--background": "oklch(1 0 0)" }, origin: "https://app.test", sessionId: 1, - }).includes(''); + }); +} + +function loadsKatex(html: string) { + return documentFor(html).includes( + '', + ); } describe("buildWidgetSandboxDocument", () => { @@ -23,7 +30,26 @@ describe("buildWidgetSandboxDocument", () => { expect(loadsKatex("

Plans cost $30 or $75.

")).toBe(false); }); - it("limits external access to the bundled KaTeX assets", () => { + it("loads the graphing libraries only when the widget references them", () => { + const graph = documentFor( + "", + ); + expect(graph).toContain(''); + expect(graph).toContain( + '', + ); + expect(graph).toContain( + '', + ); + + // The CSP allow-list names every bundled library, but a plain widget must + // not inject their script or style tags. + const plain = documentFor("

No graph here

"); + expect(plain).not.toContain(''); + }); + + it("limits external access to the bundled assets", () => { const authoredScript = ''; const document = buildWidgetSandboxDocument({ html: authoredScript, @@ -35,18 +61,33 @@ describe("buildWidgetSandboxDocument", () => { expect(document).toContain("default-src 'none'"); expect(document).toContain( - "style-src 'unsafe-inline' https://app.test/widget-libs/katex/katex.min.css", + "style-src 'unsafe-inline' https://app.test/widget-libs/katex/katex.min.css https://app.test/widget-libs/uplot/uPlot.min.css", ); expect(document).toContain( - "script-src 'unsafe-inline' https://app.test/widget-libs/katex/katex.min.js https://app.test/widget-libs/katex/contrib/mhchem.min.js", + "script-src 'unsafe-inline' https://app.test/widget-libs/katex/katex.min.js https://app.test/widget-libs/katex/contrib/mhchem.min.js https://app.test/widget-libs/mathjs/math.js https://app.test/widget-libs/uplot/uPlot.iife.min.js", ); expect(document).toContain("font-src data: https://app.test/widget-libs/katex/fonts/"); expect(document).toContain("connect-src 'none'"); + // The frame runs untrusted inline scripts already, so it must never gain eval. + expect(document).not.toContain("'unsafe-eval'"); expect(document).not.toContain("--not-a-widget-token"); expect(document).not.toContain("script-src 'unsafe-inline' https://app.test;"); }); }); +describe("detectWidgetLibraries", () => { + it("flags each bundled library from its authored markup", () => { + expect(detectWidgetLibraries('').katex).toBe(true); + expect(detectWidgetLibraries("").mathjs).toBe(true); + expect(detectWidgetLibraries("").uplot).toBe(true); + expect(detectWidgetLibraries("

plain widget

")).toEqual({ + katex: false, + mathjs: false, + uplot: false, + }); + }); +}); + describe("isWidgetSandboxFrameMessage", () => { it("rejects messages outside the frame protocol", () => { expect(isWidgetSandboxFrameMessage({ kind: "ready", sessionId: 1, source: "other" })).toBe( diff --git a/src/features/workspaces/components/widget/workspace-widget-sandbox-document.ts b/src/features/workspaces/components/widget/workspace-widget-sandbox-document.ts index a63b1b277..6d8503be1 100644 --- a/src/features/workspaces/components/widget/workspace-widget-sandbox-document.ts +++ b/src/features/workspaces/components/widget/workspace-widget-sandbox-document.ts @@ -103,23 +103,59 @@ const WIDGET_KATEX_SCRIPT_PATHS = [ ] as const; const WIDGET_KATEX_FONT_PATH = `${WIDGET_KATEX_BASE_PATH}/fonts/`; +/** Path mathjs's browser build is served from (see scripts/copy-widget-libs.mjs). */ +const WIDGET_MATHJS_SCRIPT_PATH = "/widget-libs/mathjs/math.js"; + +/** Paths uPlot's browser build is served from (see scripts/copy-widget-libs.mjs). */ +const WIDGET_UPLOT_BASE_PATH = "/widget-libs/uplot"; +const WIDGET_UPLOT_STYLESHEET_PATH = `${WIDGET_UPLOT_BASE_PATH}/uPlot.min.css`; +const WIDGET_UPLOT_SCRIPT_PATH = `${WIDGET_UPLOT_BASE_PATH}/uPlot.iife.min.js`; + const WIDGET_MATH_PATTERN = /data-latex|dataset\.latex/; +// The graphing skill names these globals, so an authored widget that plots +// references them by name. Matching is deliberately loose, like the math test +// below: over-matching only costs a cached fetch, under-matching leaves the +// graph unrendered. +const WIDGET_MATHJS_PATTERN = /\bmath\.(?:evaluate|compile|parse|derivative|simplify|chain)\b/; +const WIDGET_UPLOT_PATTERN = /\buPlot\b/; + +/** + * Which bundled libraries an authored widget references. Drives both the tags + * injected into the frame and the render/error analytics, so a graph widget is + * countable apart from an ordinary one. + */ +export function detectWidgetLibraries(html: string) { + return { + katex: WIDGET_MATH_PATTERN.test(html), + mathjs: WIDGET_MATHJS_PATTERN.test(html), + uplot: WIDGET_UPLOT_PATTERN.test(html), + }; +} /** * Restrictive CSP for the frame. Widgets are self-contained HTML: only inline - * scripts/styles and the exact bundled KaTeX resources may load. Connection - * APIs such as fetch, XHR, and WebSocket are blocked. A script can still - * navigate its own sandboxed frame, so this policy promises isolation from the - * host app rather than impossible zero communication. + * scripts/styles and the exact bundled libraries (KaTeX, mathjs, uPlot) may + * load. No library uses eval, so the frame never needs script-src + * 'unsafe-eval'. Connection APIs such as fetch, XHR, and WebSocket are blocked. + * A script can still navigate its own sandboxed frame, so this policy promises + * isolation from the host app rather than impossible zero communication. */ function getWidgetSandboxCsp(origin: string) { - const stylesheetUrl = `${origin}${WIDGET_KATEX_STYLESHEET_PATH}`; - const scriptUrls = WIDGET_KATEX_SCRIPT_PATHS.map((path) => `${origin}${path}`).join(" "); + const styleUrls = [WIDGET_KATEX_STYLESHEET_PATH, WIDGET_UPLOT_STYLESHEET_PATH] + .map((path) => `${origin}${path}`) + .join(" "); + const scriptUrls = [ + ...WIDGET_KATEX_SCRIPT_PATHS, + WIDGET_MATHJS_SCRIPT_PATH, + WIDGET_UPLOT_SCRIPT_PATH, + ] + .map((path) => `${origin}${path}`) + .join(" "); const fontUrl = `${origin}${WIDGET_KATEX_FONT_PATH}`; return [ "default-src 'none'", - `style-src 'unsafe-inline' ${stylesheetUrl}`, + `style-src 'unsafe-inline' ${styleUrls}`, `script-src 'unsafe-inline' ${scriptUrls}`, "img-src data: blob:", `font-src data: ${fontUrl}`, @@ -152,13 +188,21 @@ export function buildWidgetSandboxDocument({ if (value) tokenDeclarations += `${name}: ${value};`; } - // Only widgets that actually contain math pay for KaTeX (~290KB plus fonts). - // Over-matching just costs a cached fetch; under-matching leaves math - // unrendered, so the test is deliberately loose. - const katexTags = WIDGET_MATH_PATTERN.test(html) - ? ` -${WIDGET_KATEX_SCRIPT_PATHS.map((path) => ``).join("\n")}` - : ""; + // Only widgets that reference a library pay for it: KaTeX is ~290KB plus + // fonts, mathjs ~650KB. Over-matching just costs a cached fetch; + // under-matching leaves the feature unrendered, so the tests stay loose. + const libraries = detectWidgetLibraries(html); + const libraryTags = [ + libraries.katex && + ` +${WIDGET_KATEX_SCRIPT_PATHS.map((path) => ``).join("\n")}`, + libraries.mathjs && ``, + libraries.uplot && + ` +`, + ] + .filter(Boolean) + .join("\n"); // Widgets and documents use the same data-latex markup. The iframe still has // to render it locally because its opaque origin cannot reuse the parent DOM. @@ -173,7 +217,7 @@ ${WIDGET_KATEX_SCRIPT_PATHS.map((path) => `