diff --git a/apps/web/src/components/FileViewer.tsx b/apps/web/src/components/FileViewer.tsx index a950f02cf1..6149715a68 100644 --- a/apps/web/src/components/FileViewer.tsx +++ b/apps/web/src/components/FileViewer.tsx @@ -65,6 +65,7 @@ import { import { buildReactComponentSrcdoc } from '../runtime/react-component'; import { buildLazySrcdocTransport, buildSrcdoc, canActivateSrcDocTransport } from '../runtime/srcdoc'; import { + hasTweaksTemplate, hasUrlModeBridge, htmlNeedsSandboxShim, parseForceInline, @@ -3774,6 +3775,17 @@ function HtmlViewer({ const [selectedSideCommentIds, setSelectedSideCommentIds] = useState>(() => new Set()); const [commentSidePanelCollapsed, setCommentSidePanelCollapsed] = useState(false); const [strokePoints, setStrokePoints] = useState([]); + const [tweaksMode, setTweaksMode] = useState(false); + const [tweaksAvailable, setTweaksAvailable] = useState(false); + // Tracks the `file.name` for which we've already mirrored the artifact's + // initial `__edit_mode_available` announcement into `tweaksMode`. Agent- + // generated `.twk-panel` artifacts mount their panel visible by default, + // so the toolbar toggle should also start ON — otherwise the user has to + // click toggle-on → toggle-off to actually hide the panel they're seeing. + // We only mirror ONCE per file: subsequent re-emissions (iframe remount + // when the user flips render mode by opening Themes, etc.) would otherwise + // re-toggle the user's choice. + const firstEditModeAvailableSeenForFileRef = useRef(null); const previewStateKey = `${projectId}:${file.name}`; const previewScale = zoom / 100; @@ -3984,6 +3996,10 @@ function HtmlViewer({ // When we URL-load the iframe directly, skip every in-host inlining / // srcDoc-rebuilding step. The browser does the asset resolution itself, // which is the whole point of the URL-load path. + // Detect the class based tweaks template so we keep the srcDoc path on + // first load: the bridge that emits `od:tweaks-available` is only injected + // by buildSrcdoc, never on the URL load iframe. + const tweaksBridgeRequired = hasTweaksTemplate(source); // Auto-fall back to the srcDoc path when the artifact will crash under // the URL-load iframe's bare `sandbox="allow-scripts"` — Babel-standalone // React prototypes and any HTML that reads Web Storage at mount throw @@ -4004,6 +4020,7 @@ function HtmlViewer({ inspectMode, paletteActive: palettePopoverOpen || selectedPalette !== null, drawMode: drawOverlayOpen, + tweaksBridge: tweaksBridgeRequired, forceInline: forceInline || needsSandboxShim, }); const basePreviewSrcUrl = useMemo( @@ -4020,9 +4037,25 @@ function HtmlViewer({ useEffect(() => { setPreviewSrcUrl(basePreviewSrcUrl); }, [basePreviewSrcUrl]); + // Keep `iframeRef.current` aligned with whichever iframe is currently + // visible so the existing postMessage send sites do not need to know that + // there are two iframes mounted. Plain `useEffect` (rather than layout) + // because all reads of `iframeRef.current` are in async user handlers or + // postMessage callbacks, never synchronous during render, and `useEffect` + // does not warn under `renderToStaticMarkup`. useEffect(() => { iframeRef.current = useUrlLoadPreview ? urlPreviewIframeRef.current : srcDocPreviewIframeRef.current; }, [useUrlLoadPreview]); + // When the render mode flips, the now-active iframe has already loaded + // (its `onLoad` fired when it first mounted, often long before the user + // toggled), so we manually re-push the current bridge state instead of + // relying on the iframe's load event. `syncBridgeModes` is a closure over + // the latest state, so reading it through a ref keeps this effect's deps + // honest while still firing the up-to-date sync function. + const syncBridgeModesRef = useRef<() => void>(() => {}); + useEffect(() => { + syncBridgeModesRef.current(); + }, [useUrlLoadPreview]); useEffect(() => { if (filesRefreshKey === 0) return; @@ -4276,10 +4309,21 @@ function HtmlViewer({ }, '*'); win.postMessage({ type: 'od-edit-mode', enabled: manualEditMode }, '*'); postSelectedManualEditTargetToIframe(manualEditMode ? selectedManualEditTarget?.id ?? null : null, target); + // Push the toolbar's current `tweaksMode` to both dialects so the artifact + // aligns to host state on every load (including render-mode swaps that + // expose a different iframe. e.g. opening the Themes popover). Without + // this, an artifact that defaults to `open=true` would re-open on every + // swap and visually contradict a toolbar that is currently off. + win.postMessage({ type: 'od:tweaks-panel-visible', visible: tweaksMode }, '*'); + win.postMessage({ type: tweaksMode ? '__activate_edit_mode' : '__deactivate_edit_mode' }, '*'); win.postMessage({ type: 'od:inspect-mode', enabled: inspectMode }, '*'); const palette = previewPalette ?? selectedPalette; win.postMessage({ type: 'od:palette', palette }, '*'); } + // Keep the ref pointing at the latest `syncBridgeModes` closure so the + // render-mode-swap effect above (which can fire before this declaration in + // execution order) always calls the up-to-date function. + syncBridgeModesRef.current = syncBridgeModes; useEffect(() => { const win = iframeRef.current?.contentWindow; @@ -4346,6 +4390,80 @@ function HtmlViewer({ return () => window.removeEventListener('message', onMessage); }, [inspectMode, boardMode, drawClickSelectionMode, file.name, isOurPreviewIframeSource]); + useEffect(() => { + const win = iframeRef.current?.contentWindow; + if (!win) return; + // Send all known dialects so the artifact can pick up whichever it speaks: + // - `od:tweaks-panel-visible` is the bridge protocol used by class-based + // panels emitted from the tweaks skill template (`.tw-panel`). + // - `__activate_edit_mode` / `__deactivate_edit_mode` is the protocol + // agent-generated artifacts use for their own React-mounted `.twk-panel`. + // Deps intentionally exclude `srcDoc`: on iframe remount, sync happens via + // `syncBridgeModes` (bridge) and the artifact's own + // `__edit_mode_available` announcement (postMessage panels). + win.postMessage({ type: 'od:tweaks-panel-visible', visible: tweaksMode }, '*'); + win.postMessage({ type: tweaksMode ? '__activate_edit_mode' : '__deactivate_edit_mode' }, '*'); + }, [tweaksMode]); + + // Receive tweaks-side state from the iframe. Supports both bridge messages + // (`od:tweaks-*` for skill-template artifacts) and the artifact-native + // edit-mode protocol (`__edit_mode_*` for agent-generated artifacts). Either + // surface controls toolbar availability and mirrors local close into the + // toolbar toggle state. + useEffect(() => { + function onMessage(ev: MessageEvent) { + if (!isOurPreviewIframeSource(ev.source)) return; + const data = ev.data as { type?: string; available?: boolean; visible?: boolean } | null; + if (!data?.type) return; + if (data.type === 'od:tweaks-available') { + // Scope this to the active iframe only. The hidden srcDoc iframe's + // tweaks bridge always evaluates `document.querySelector('.tw-panel')` + // and posts `available: false` for agent-protocol (`.twk-panel`) + // artifacts that ship no class based panel. Without this guard that + // `false` would land after `__edit_mode_available` had already set + // `tweaksAvailable = true` and silently disable the toolbar button. + // `__edit_mode_*` below stays accepted from either iframe — those + // signals carry real artifact intent and must survive render mode + // flips. + if (ev.source !== iframeRef.current?.contentWindow) return; + setTweaksAvailable(!!data.available); + } else if (data.type === 'od:tweaks-panel-state') { + setTweaksMode(!!data.visible); + } else if (data.type === '__edit_mode_available') { + setTweaksAvailable(true); + // Mirror the artifact's reported default visibility into `tweaksMode` + // exactly once per file. Per design-templates/tweaks/SKILL.md the + // artifact MAY emit `{ visible: boolean }` on the availability + // payload to declare a default-closed panel; if absent we treat it + // as default-open because the SDK pattern is `useState(true)` and + // omitting `visible` is the backward-compatible signal that the + // panel is already on screen. Without this mirror, the toolbar reads + // OFF while the panel is clearly visible and the user has to click + // toggle-on then toggle-off to actually hide it. Guarded by + // `firstEditModeAvailableSeenForFileRef` so a later iframe remount + // (Themes popover flipping render mode, etc.) doesn't snap a + // user-driven OFF back to ON. `syncBridgeModes` remains the source + // of truth on every subsequent load: it pushes the current + // `tweaksMode` into the artifact via `__activate_edit_mode` / + // `__deactivate_edit_mode` so the artifact tracks the toolbar. + if (firstEditModeAvailableSeenForFileRef.current !== file.name) { + firstEditModeAvailableSeenForFileRef.current = file.name; + setTweaksMode(data.visible !== false); + } + } else if (data.type === '__edit_mode_dismissed') { + setTweaksMode(false); + } + } + window.addEventListener('message', onMessage); + return () => window.removeEventListener('message', onMessage); + // `file.name` is in the dep list so the handler's `firstEditMode- + // AvailableSeenForFileRef.current !== file.name` guard compares against + // the currently-displayed file. Without this, the listener would close + // over the first-render `file.name`; switching to another `.twk-panel` + // artifact would never re-mirror the new artifact's default-open state + // because the stale closure's comparison kept matching. PR #1643 review. + }, [file.name]); + useEffect(() => { setActiveCommentTarget(null); setHoveredCommentTarget(null); @@ -4368,6 +4486,10 @@ function HtmlViewer({ setManualEditError(null); manualEditPendingStyleRef.current = null; clearManualEditStyleTimer(); + // Stale tweaks state can carry across files (especially toolbar "on" with + // no panel underneath). Reset both and let the iframe bridge re-announce. + setTweaksMode(false); + setTweaksAvailable(false); }, [file.name]); // Selecting a new file or turning inspect off resets the panel target. @@ -5623,6 +5745,19 @@ function HtmlViewer({ ) : null} +
{showPreviewToolbarControls ? ( @@ -5632,7 +5767,7 @@ function HtmlViewer({ type="button" className={`viewer-action${selectedPalette || palettePopoverOpen ? ' active' : ''}`} data-testid="palette-tweaks-toggle" - title="Tweaks" + title="Themes" aria-haspopup="dialog" aria-expanded={palettePopoverOpen} onClick={() => { @@ -5640,8 +5775,8 @@ function HtmlViewer({ setPalettePopoverOpen((v) => !v); }} > - - Tweaks + + Themes {selectedPalette ? ( ); + case 'paint-bucket': + return ( + + + + ); case 'palette': return ( diff --git a/apps/web/src/components/PaletteTweaks.tsx b/apps/web/src/components/PaletteTweaks.tsx index ce7eb0e46d..c9f9971cf0 100644 --- a/apps/web/src/components/PaletteTweaks.tsx +++ b/apps/web/src/components/PaletteTweaks.tsx @@ -69,9 +69,9 @@ export function PaletteTweaks({ open, selected, onChange, onPreview, onClose }: const isOriginal = selected === null; return ( -
+
- Tweaks panel + Themes 5 curated theme palettes
    diff --git a/apps/web/src/components/file-viewer-render-mode.ts b/apps/web/src/components/file-viewer-render-mode.ts index 1f0b26b646..1d8060a669 100644 --- a/apps/web/src/components/file-viewer-render-mode.ts +++ b/apps/web/src/components/file-viewer-render-mode.ts @@ -37,10 +37,28 @@ export interface UrlLoadDecision { paletteActive?: boolean; /** Draw annotations need the srcDoc snapshot bridge for screenshot export. */ drawMode?: boolean; + /** + * Artifact ships the class based tweaks template (`.tw-panel` / `.tw-hidden`) + * and therefore needs the srcDoc tweaks bridge so the toolbar toggle can + * detect availability and drive panel visibility. The bridge is injected by + * buildSrcdoc and has no equivalent on the URL load path. + */ + tweaksBridge?: boolean; /** User explicitly opted into the inline path via ?forceInline=1. */ forceInline: boolean; } +/** + * Detect the class based tweaks template in an artifact source string. + * Looks for the fixed `.tw-panel` / `.tw-hidden` selectors the skill ships in + * `design-templates/tweaks/assets/wrap.html`. Returns false for null / empty + * input so callers can pass `source` directly without a guard. + */ +export function hasTweaksTemplate(source: string | null | undefined): boolean { + if (!source) return false; + return /\btw-(?:panel|hidden)\b/.test(source); +} + /** * Returns true when an HTML file's preview iframe should load directly * from its raw URL (via `