diff --git a/CLAUDE.md b/CLAUDE.md index 2c1ffd2..aced48d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,6 +119,7 @@ src/ exportShader.ts / exportSingleGlb.ts / glbShaderContract.ts — export assembly teapotData.ts / teapotGeometry.ts — runtime Utah teapot tessellator eval/ — the user-study mode (arm, consent, telemetry, SUS, package, upload) + embed/ — FastShaders inside another app's iframe (`?embed=1`, SuperWXR's shader panel): `embedMode` (a LEAF, sampled once at module init; never true at the top level) + `EmbedBridge` (a `lazyOr` chunk from App.tsx, never in a study): postMessage to and from the PARENT at the page's own origin only — `superwxr:open` restores a module through the shared "Load Script…" path, `superwxr:module` broadcasts the settled export module (what EXPORT would write), `superwxr:export` hands EXPORT's bytes to the host instead of the download folder (exportShader.ts) intro/ — the first-open intro window (toolbar "i" reopens it): fourteen live slides (drag-connect, wire values, socket labels, insert/delete, Preview/Attach, point budget, image drop, exposing sockets, Organize, code→graph, uniforms, dropped models, Work folder, drawing + notes) on the real cards, menus, planners, encoder, parser and preview document; writes nothing; in a study only after Agree (every entry, /evalp without the budget slide), logged tutorials/ — the Tutorials tab: twenty-two hands-on lessons built on the REAL canvas (takeover, Previous/Next, Do it for me, Keep/New); lesson data a LEAF of registry ids, a pure evaluator, a store-writing auto step; in a study only on `/evalpro` hooks/ — useSyncEngine, useLongPress, usePersistedState, useDismiss, useFitText, useHistoryBracket, useFrameLoop, diff --git a/docs/dev/project-structure.md b/docs/dev/project-structure.md index b91a61e..cd08310 100644 --- a/docs/dev/project-structure.md +++ b/docs/dev/project-structure.md @@ -110,6 +110,7 @@ src/ exportShader.ts — shared export assembly, read imperatively from the store: `buildShaderBundle(opts?)` (module generation + project embed + exportBundle) is the ONE bundle builder — the toolbar EXPORT download, the NEW-shader save-first path, the desktop Work-folder Save and the study package all emit byte-identical bundles through it (`opts.includeMesh` overrides the session `exportIncludeMesh` flag for ONE build and is only ever passed `false`, by the pre-flight). `buildShaderBundleChecked(ask)` is the pre-flight wrapper the three USER surfaces go through; the study package (SusModal) calls `buildShaderBundle()` directly and never pauses on a dialog. `buildShaderExportChecked(asks)` is now the ONE wrapper those three surfaces call: for the bundle format it delegates to `buildShaderBundleChecked`, and for one `.glb` it runs `prepareSingleGlb` (engine/exportSingleGlb.ts, loaded by a dynamic `import()` so the repacker stays off the boot path) behind the GlbExportModal — under `isEvalMode()` it never reaches that path. `downloadShader(bundle?: ShaderExport)` is the blob/anchor browser-download tail and the `evalLog('export')` chokepoint, and it posts what the delivery has to say after the click (`announceExportDelivered`: a `.glb`'s report lines plus N1's desktop-only line, in ONE note, since `showImportNote` replaces) intro/ — the window FastShaders opens with: `introGate` (a LEAF on the boot graph — never at boot in a study session (every entry opens it after Agree, `introInStudy`; logged, writes nothing, no Work-folder slide, no budget slide where points are hidden), `?intro` reopens it, `fs:introSeen` while "Don't show this again" is ticked), `IntroWindow` (a `lazyOr` chunk from App.tsx; slides stepped by arrows, boxes or ←/→, only the shown one mounted; their ORDER is its `SLIDES` list, never the order the scenes are described in here — owner 2026-10-23: Preview/Attach right after insert/delete, code→graph after Organize, then uniforms, dropped models, the Work folder, and drawing + notes last), `IntroStage` (the shared small canvas, pointer and key cap), `timeline` (the scenes' clock), `sceneGraph` (their tiny graphs built like palette drops, wired values, TypedEdge's still-wire geometry, `GraphCard` — one node drawn by its flow type's card replica). `ConnectScene` + `connectTour`: drag-connect, sockets picked each frame by `planDragConnect` on the measured cards, the drop at `CONNECT_SNAP_GAP`. Then `ValuesScene` + `valuesTour`: one wire at a time, close up (zoom 1.25), each fading in beside the same Output and clicked — a Float (a number), UV (a range on two channels), Position (three), Time → Sine (live, off the scene's clock with the clock hand and the wave) — each opening EdgeInfoCard's own chip (`edgeChipChannels`/`EdgeChipValues` over `evaluateEdgeRange`) at mid-arc, the wire thickened by TypedEdge's `SELECTED_EDGE_BOOST`; off-phase cards are hidden by OPACITY, never visibility (`applyWaveFrame` sets its dot's own visibility, which shows through a hidden card). Then `LabelsScene` + `labelsTour`: Image → Mix → Output; a double-click on the Image (its output sockets carry no text; a fresh one has no inputs) and a fingertip double-tap on Mix (A, B, Factor in, Result out) pin every socket label through the canvas's own `[data-fs-labels-shown]` attribute (labelPeek.ts) — NodeVisual's static sockets carry TypedHandle's `data-tooltip` under `CardSocketLabels` (`NodeEditor/cardSocketLabels.ts`, its own module so NodeVisual can read it); the picture is `introPhotoThumbnail`. The pointer's `touch` state (`paintCursor`) swaps the arrow for a fingertip. `InsertScene` + `insertTour`: right-click a wire → Insert node → search (the real `searchNodes`) → Round lands on the wire (`pickSpliceInputPort`) → Delete bridges it back (`bridgeEdgesAcrossDeletedNodes`). `BudgetScene` + `budgetTour`: the right end of the real Gas Giant texture (imported when the slide first shows — building it needs the Babel chunk, never on the boot), GroupNode's own `GroupHeader`, the cost bar's own `CostMeter`/`deviceOptionText` over the user's device; a wire dragged from the asset's last node (its `outputEdges`) into Color takes `computeReachableCost` from 0 past the budget. `ImageScene` + `imageTour` + `introPhoto`: a painted 12 MP photo (no photo ships in the app) run through the REAL drop — `encodeImageFile` at the device cap in 'convert' mode (imported on slide show), `resolveImageDrop` with the stash answered "kept" (nothing reaches IndexedDB), `makeImageNodeFromEncode` with `filePictureOrientation` — then the app's own `ImageImportPanel` question and `LimitNoticePanel` device-downscale notice, and the card through ShaderNode's `ImageCardPicture`. `FolderScene` + `folderTour`: an EXAMPLE folder in a strip of the toolbar (its own classes, `WorkFolderRow`/`WorkFolderCaretIcon` from Layout/WorkFolderView.tsx — WorkFolder.tsx itself is desktop-only); the shader name is `adoptShaderName`, the current row `sameShaderFile`. `CodeScene` + `codeTour`: edits typed into `graphToCode`'s fresh-Output text, each Apply built by `codeToGraph` → `autoExposeConnectedParamPorts` → `autoLayout` (both imported on slide show); the first edit retypes the red-fallback `return`, which would otherwise parse into a Vec3; Time/Sin/noise move through `applyClockFrame`/`applyWaveFrame`/`renderNoisePreview`, live wires march; the code panel is its own tab bar (CodeEditor.css) over a Monaco-coloured body, never Monaco. `ExposeScene` + `exposeTour`: right-click menus cropped to the rows that expose — the generic menu's `ParamRowView` (noise "scale" → a socket at PreviewNode's `handleTop`) and the Output menu's `OutputPortToggles` (Emissive) — then a wire into the new socket, aimed at a hidden probe Output that already shows it. `DrawScene` + `drawTour`: the canvas bar's markup with the real draw cluster (`DrawToolbarView`), a freehand flower (`flowerStrokes` — five uneven petals, a scribbled centre, a swaying stem and a leaf, every deviation from a SEEDED random source so each loop draws the same flower) in the user's own pen colour/opacity at width 7, drawn as DrawingLayer draws ink, then the Add menu's Annotate row and a note (`NoteFace`, NoteTileCard) typed into; the canvas there is at 80%, the chrome at chrome size. `OrganizeScene` + `organizeTour`: nine nodes in a heap, a Partial marquee (`marqueeHits`) in React Flow's own `.react-flow__selection`/`.react-flow__nodesselection-rect` classes, a right-click on the selection opens AddNodeMenu's Selection rows, and Organize glides the nodes to where `organizeLayout` (engine/layoutEngine.ts — the pure half of the store's `organizeSelection`) puts them from each card's measured layout box; the canvas moves them at once. Three scenes run the REAL preview document (`IntroPreview`: `tslToPreviewHTML` + the pane's message contract — `fs:obj-model`, hot `fs:shader`, `fs:uniform`; cold-built only per model; never ShaderPreview, which owns the store): `PreviewScene` + `previewTour` — Alt/⌥-click → `previewGraph`'s derived module, the canvas's own `fs-previewing` dim and PreviewRoute line; Ctrl/⌘-click → the Attach rows over `attachSinks` → wired; `UniformScene` + `uniformTour` — a Property node's slider (the pane's uniform-row classes, `seedBounds`) posting `fs:uniform`, then Set as default as a graph change swapped in hot; `ModelScene` + `modelTour` — `public/models/intro/` Rodin then PoseableFigure dropped onto the view (1.5× the pane's framing) under Vertex Wave + Checker, each with its own `ShaderTune` (the sphere: amplitude 0.03, the Checker's own UVs; Rodin: 0.03 and a 3D checker over `positionGeometry` — x, y AND z, since a flat x/y projection smears into a band — instead of `uv()`; the figure: 0.001, frequency 150, count 16, the same 3D checker), the Metals palette's Iron for its dark squares (`combinePresets`, the presets' own `outputEdges`), the import question only where `planGlbImportDialog` offers it, drawn by `GlbImportPanel`. Shared by every scene: `placeMenu` (IntroStage), `IntroConnectionLine`/`connectionEnd`/`connectionPath` (sceneGraph). The header carries the study dialogs' `LangSwitch`; there is no ✕; the toolbar's "i" (after reload; in a study too) fires `INTRO_OPEN_EVENT` with its opener (`IntroVia`), which App answers. Cards are the tiles' replicas at canvas size; nothing writes the store, storage or IndexedDB (`introScenes.test.ts` sweeps every file) — GraphModal's feed-the-store trick would let the autosave write the intro over the user's work eval/ — the user-study mode (`…/eval`, `…/evalpro`, `…/evalp`): `evalMode` + `evalArm` (the sessionStorage arm, sampled once at module init — a LEAF so the registry can read it), `EvalGate` + `ConsentModal` + `DataDisclosureModal` (consent BEFORE any logging) + `LangSwitch` (the ONE EN/LV button every study dialog renders — the consent, its Details, the finish dialog, the questionnaire and its thank-you screen; they are modal, so the toolbar's is unreachable behind the backdrop), `answerLanguage` (which language the SUS was ANSWERED in — per item, or `mixed`), `telemetry` + `telemetryModel` (the closed event vocabulary and the derived summary), `evalTask` + `evalContext` (which task, which price table, which device), `background` + `proQuestions` + `SusModal` + `susScore` (the questionnaire), `EvalFinishModal`, `previewShot`, `evalPackage` (the zip) and `evalUpload`. See the Eval-mode convention for what each one is FOR — this tree entry is only the map + embed/ — FastShaders inside another app's iframe (`?embed=1`, SuperWXR's shader panel): `embedMode` (a LEAF like `eval/evalArm.ts`, sampled ONCE at module init and never true at the top level — only an embedding page can answer the bridge) + `EmbedBridge` (a `lazyOr` chunk from App.tsx on EvalGate's terms, so an ordinary boot never fetches the import path and the export assembly it pulls; never in a study, since it is a second way to change the graph). postMessage to and from the PARENT window at the page's own origin only: in `superwxr:open` (a shader `.js` export — a FASTSHADERS_PROJECT_V1 block restores the graph, a bare module is parsed back — through `importShaderText`, the "Load Script…" path, no Open/Add dialog); out `superwxr:ready` once it listens, `superwxr:module` (the settled EXPORT module with images inlined and the project block appended, trailing-debounced like the preview, deduped on the text) and `superwxr:export` (what `downloadShader` would have downloaded — exportShader.ts posts it instead of saving when embedded). Its store subscription compares three references and the Output node's settings object by reference — it runs once per drag frame tutorials/ — the Tutorials tab's hands-on LESSONS on the REAL canvas — the intro's opposite: it WRITES the store, which is why it lives outside `intro/`'s no-write sweep (introScenes.test.ts). `tutorialTypes.ts` (the step model — add / wire / set / expose / look — the `TutorialRun` slice, `graphIsBlank`), `tutorialDefs.ts` (the twenty-two lessons: a LEAF of registry ids, never TSL — bootWave's rule), `tutorialRun.ts` (the pure evaluator: bindings, `evaluateStep`, `stepTargets`, `wantedNodeType`, `planAddPosition`), `tutorialAutoStep.ts` ("Do it for me" through `addNode` / `connectNodes` / `updateNodeData`, one undo entry per step), `tutorialsTab.ts` (the pseudo-tab's pure rules: offered outside a study and on `/evalpro`, a stored tab derived to All), `TutorialCard` (the numbered tile, a button — not a drag tile), `TutorialPanel` (bottom-right canvas chrome inside NodeEditor's canvas div, NOT a dialog: the canvas stays usable, plain keys on its buttons are stopped from the window handlers, Next reads `reached`), `tutorialHighlights.ts` (`data-fs-tutorial` marks on wrappers and handles, attributes never classes) hooks/ useSyncEngine.ts — bidirectional graph↔code sync, undo/redo, complexity calc; graph→code is `panelCodegen` (the connected-only panel, whole in a study) and its Apply carries what that text hid (`utils/panelCarry.ts`) (its Cmd/Ctrl+Z handler stands down behind an aria-modal dialog and leaves a text field its native undo — `keepsNativeUndo` in `utils/isTypingTarget.ts`; number fields and selects keep graph undo) diff --git a/src/App.tsx b/src/App.tsx index 52e1931..f63f449 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import { import { AppLayout } from './components/Layout/AppLayout'; import { useSyncEngine } from './hooks/useSyncEngine'; import { isEvalMode } from './eval/evalMode'; +import { isEmbedMode } from './embed/embedMode'; import { installFileDropGuard } from './utils/fileDropGuard'; import complexityData from './registry/complexity.json'; import type { AppNode, AppEdge, OutputNodeData, ShaderNodeData } from './types'; @@ -96,6 +97,24 @@ const EvalGate = lazyOr( EvalGateUnavailable, ); +/** + * The SuperWXR embed bridge (embed/EmbedBridge.tsx), a lazy chunk on the same + * terms as EvalGate: `isEmbedMode()` is sampled once at module init + * (embed/embedMode.ts), so an ordinary visitor's boot never creates the + * element and never fetches the chunk — the bridge pulls the import path and + * the export assembly, which the boot wave must not carry for a feature only an + * embedding page can use. A chunk that fails to load leaves the host page + * without its `superwxr:ready`; the host's panel says so itself, and nothing + * rendered here could reach it. + */ +function EmbedBridgeUnavailable() { + return null; +} +const EmbedBridge = lazyOr( + () => import('./embed/EmbedBridge').then((m) => m.EmbedBridge), + EmbedBridgeUnavailable, +); + /** * The intro window, a lazy chunk fetched only on a boot that shows it (the * first one, or `?intro`). A failed fetch shows nothing — the intro is @@ -348,6 +367,15 @@ export default function App() { return ( + {/* Embedded in another app's iframe (?embed=1, SuperWXR's shader panel): + the postMessage bridge that opens a module, broadcasts the settled + export module and hands EXPORT to the host. Never in a study. The + bridge renders nothing, so the Suspense fallback is null too. */} + {isEmbedMode() && !isEvalMode() && ( + + + + )} {/* User-study mode (…/eval): consent gate + telemetry boot. The guard keeps every eval-mode module inert for normal sessions — and, since EvalGate is lazy, unfetched. Suspense fallback is null on purpose: the diff --git a/src/embed/EmbedBridge.tsx b/src/embed/EmbedBridge.tsx new file mode 100644 index 0000000..fc345cd --- /dev/null +++ b/src/embed/EmbedBridge.tsx @@ -0,0 +1,119 @@ +/** + * The SuperWXR embed bridge (embed/embedMode.ts decides when it mounts). + * + * Messages, all with `type: 'superwxr:*'`, posted to and accepted from the + * PARENT window at the page's own origin only: + * + * in { type: 'superwxr:open', module: string, name?: string } + * — open a shader .js export (a FASTSHADERS_PROJECT_V1 block restores the + * graph; a bare module is parsed back), the "Load Script…" path: no + * Open/Add dialog. + * out { type: 'superwxr:ready' } + * — posted once the bridge listens. + * out { type: 'superwxr:module', module: string, name: string } + * — the settled EXPORT module (images inlined, project block appended): + * what the EXPORT button would write, whenever the graph, the module + * settings or the name changed, trailing-debounced like the preview. + * out { type: 'superwxr:export', kind, fileName, mime, bytes } + * — what downloadShader would have downloaded (exportShader.ts). + * + * Never in a study session: the bridge is a second way to change the graph. + */ +import { useEffect } from 'react'; +import { useAppStore } from '@/store/useAppStore'; +import { buildExportModule, buildProjectState, EXPORT_ERROR_PREFIX } from '@/engine/exportShader'; +import { embedProjectState, extractProjectState } from '@/engine/fastShadersProject'; +import { importShaderText, preloadShaderImport } from '@/engine/projectImport'; +import { moduleSettingsOutput } from '@/utils/outputMaterials'; +import type { AppNode, OutputNodeData } from '@/types'; +import { embedTargetOrigin } from './embedMode'; + +/** The preview's own settle delay (PREVIEW_REBUILD_DEBOUNCE_MS) plus a margin. */ +const SETTLE_MS = 300; + +interface OpenMessage { + type: 'superwxr:open'; + module?: unknown; + name?: unknown; +} + +function post(message: Record): void { + window.parent.postMessage(message, embedTargetOrigin()); +} + +/** A name a dropped file would get: the stem, trimmed, bounded. */ +function nameOf(raw: unknown): string { + if (typeof raw !== 'string') return ''; + return raw.replace(/\.(m?js|zip)$/i, '').trim().slice(0, 80); +} + +export function EmbedBridge() { + useEffect(() => { + let lastPosted = ''; + let lastOpened = ''; + let timer: ReturnType | null = null; + + const broadcast = () => { + timer = null; + const module = buildExportModule({ inlineImages: true }); + if (module.startsWith(EXPORT_ERROR_PREFIX)) return; + const text = embedProjectState(module, buildProjectState()); + if (text === lastPosted || text === lastOpened) return; + lastPosted = text; + post({ type: 'superwxr:module', module: text, name: useAppStore.getState().shaderName }); + }; + + // The store notifies once per drag frame, so the question "did anything the + // module depends on change?" must cost nothing on the frames where nothing + // did: three references, and the Output node's settings OBJECT by reference + // when the nodes array moved (a drag replaces the array and the moved + // cards, never a card's data). No string is built here; `broadcast` dedupes + // the module text itself. + const settingsOf = (nodes: AppNode[]) => (moduleSettingsOutput(nodes)?.data as OutputNodeData | undefined)?.materialSettings; + const initial = useAppStore.getState(); + let last = { previewCode: initial.previewCode, shaderName: initial.shaderName, nodes: initial.nodes, settings: settingsOf(initial.nodes) }; + const unsubscribe = useAppStore.subscribe((state) => { + if (state.previewCode === last.previewCode && state.shaderName === last.shaderName && state.nodes === last.nodes) return; + const settings = state.nodes === last.nodes ? last.settings : settingsOf(state.nodes); + const changed = state.previewCode !== last.previewCode || state.shaderName !== last.shaderName || settings !== last.settings; + last = { previewCode: state.previewCode, shaderName: state.shaderName, nodes: state.nodes, settings }; + if (!changed) return; + if (timer !== null) clearTimeout(timer); + timer = setTimeout(broadcast, SETTLE_MS); + }); + + const onMessage = (e: MessageEvent) => { + if (e.source !== window.parent || e.origin !== embedTargetOrigin()) return; + const data = e.data as OpenMessage | null; + if (!data || typeof data !== 'object' || data.type !== 'superwxr:open') return; + if (typeof data.module !== 'string' || data.module.length === 0) return; + const text = data.module; + const nameFallback = nameOf(data.name); + lastOpened = text; + // An open supersedes the mount-time broadcast below: the import's own settle broadcasts. + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + void preloadShaderImport().then(() => { + try { + importShaderText(text, { nameFallback, parsed: extractProjectState(text) }); + } catch (err) { + console.error('[FastShaders embed] open failed', err); + } + }); + }; + window.addEventListener('message', onMessage); + post({ type: 'superwxr:ready' }); + // A lazy chunk comes up AFTER the first settle, so nothing would change the + // store again on a host that just shows what the editor holds: tell it, at + // the same trailing delay — unless an open arrives first (above). + timer = setTimeout(broadcast, SETTLE_MS); + return () => { + window.removeEventListener('message', onMessage); + unsubscribe(); + if (timer !== null) clearTimeout(timer); + }; + }, []); + return null; +} diff --git a/src/embed/embedBridge.test.ts b/src/embed/embedBridge.test.ts new file mode 100644 index 0000000..262f835 --- /dev/null +++ b/src/embed/embedBridge.test.ts @@ -0,0 +1,49 @@ +/** + * The SuperWXR embed bridge (embed/EmbedBridge.tsx) is a second way to change + * the graph and a second way out for EXPORT, so the shape that keeps it safe is + * pinned here, on the source: + * + * - the switch (embed/embedMode.ts) is a LEAF sampled once, and never true + * without a window — every node suite runs an un-embedded app; + * - App.tsx mounts the bridge only behind that switch AND outside a study, + * as a lazy chunk: an ordinary boot never imports it (bootWave.test.ts + * walks the static graph; this pins the reason the edge is dynamic); + * - the bridge answers the PARENT window at the page's own origin only; + * - exportShader.ts hands EXPORT's bytes to the host at that origin instead + * of the download folder, and only when embedded. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { isEmbedMode, EMBED_PARAM } from './embedMode'; + +const read = (rel: string): string => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8'); + +describe('embed mode', () => { + it('is never on without a window, and the switch module is a leaf', () => { + expect(isEmbedMode()).toBe(false); + expect(EMBED_PARAM).toBe('embed'); + const src = read('./embedMode.ts'); + expect(src).not.toMatch(/^\s*import\s/m); + expect(src).toMatch(/window\.parent !== window/); + }); + + it('App.tsx mounts the bridge lazily, behind the switch and outside a study', () => { + const app = read('../App.tsx'); + expect(app).not.toMatch(/from '\.\/embed\/EmbedBridge'/); + expect(app).toMatch(/import\('\.\/embed\/EmbedBridge'\)/); + expect(app).toMatch(/isEmbedMode\(\) && !isEvalMode\(\) && \(/); + }); + + it('the bridge accepts messages from the parent at its own origin only', () => { + const bridge = read('./EmbedBridge.tsx'); + expect(bridge).toMatch(/e\.source !== window\.parent \|\| e\.origin !== embedTargetOrigin\(\)/); + expect(bridge).toMatch(/typeof data\.module !== 'string'/); + expect(bridge).toMatch(/window\.parent\.postMessage\(message, embedTargetOrigin\(\)\)/); + }); + + it('EXPORT posts its bytes to the host instead of downloading, only when embedded', () => { + const exp = read('../engine/exportShader.ts'); + expect(exp).toMatch(/if \(isEmbedMode\(\)\) \{[\s\S]*?type: 'superwxr:export'[\s\S]*?embedTargetOrigin\(\),?\s*\);[\s\S]*?\} else \{[\s\S]*?downloadBlob\(/); + }); +}); diff --git a/src/embed/embedMode.ts b/src/embed/embedMode.ts new file mode 100644 index 0000000..37e9eee --- /dev/null +++ b/src/embed/embedMode.ts @@ -0,0 +1,27 @@ +/** + * The EMBED switch: FastShaders running inside another app's iframe (SuperWXR's + * shader panel) at `?embed=1`. A LEAF module (imports nothing), sampled ONCE at + * module init like `eval/evalArm.ts`, so every consumer sees one answer for the + * whole page. Never true at the top level — an embedding page is the only + * thing that can answer the bridge's messages. + */ + +export const EMBED_PARAM = 'embed'; + +const embedded = (() => { + try { + return new URLSearchParams(window.location.search).get(EMBED_PARAM) === '1' && window.parent !== window; + } catch { + // node tests / no window: never embedded. + return false; + } +})(); + +export function isEmbedMode(): boolean { + return embedded; +} + +/** The host page's origin, the only `targetOrigin` the bridge ever posts to. */ +export function embedTargetOrigin(): string { + return window.location.origin; +} diff --git a/src/engine/exportShader.ts b/src/engine/exportShader.ts index a4d1c0a..60e5411 100644 --- a/src/engine/exportShader.ts +++ b/src/engine/exportShader.ts @@ -36,6 +36,7 @@ import type { SingleGlbBuildMode } from './exportSingleGlb'; import type { ImportNoteLine } from '@/utils/importNote'; import { safeJsonReviver } from '@/utils/safeJson'; import { downloadBlob } from '@/utils/downloadBlob'; +import { embedTargetOrigin, isEmbedMode } from '@/embed/embedMode'; /** * Shared "Download Shader" path. Lives outside any component so the toolbar @@ -541,7 +542,16 @@ export async function buildShaderExportChecked(asks: ShaderExportAsks): Promise< export function downloadShader(bundle: ShaderExport): void { // Eval telemetry only — a no-op outside a study session. evalLog('export', { kind: bundle.kind }); - downloadBlob(new Blob([bundle.bytes], { type: bundle.mime }), bundle.fileName); + if (isEmbedMode()) { + // Embedded (embed/EmbedBridge.tsx): the host page takes delivery instead of + // the browser's download folder — the same bytes, the same name. + window.parent.postMessage( + { type: 'superwxr:export', kind: bundle.kind, fileName: bundle.fileName, mime: bundle.mime, bytes: bundle.bytes }, + embedTargetOrigin(), + ); + } else { + downloadBlob(new Blob([bundle.bytes], { type: bundle.mime }), bundle.fileName); + } announceExportDelivered(bundle); }