From 59c8d72ae4e6b64972704d4291e22ee485c2ec82 Mon Sep 17 00:00:00 2001 From: Chris Seifert Date: Wed, 20 May 2026 06:00:49 -0400 Subject: [PATCH] feat(tweaks): bind toolbar toggle to artifact panel (#2348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tweaks): bind toolbar toggle to artifact panel Wires the host viewer's Tweaks toggle to artifact panels via two protocols: postMessage __edit_mode_* for agent-generated .twk-panel artifacts, and the existing class-based bridge for tweaks-skill .tw-panel artifacts. Toggle disables itself when the artifact exposes no panel, syncs state when the user closes the panel locally, and no longer regenerates srcdoc on toggle (no iframe remount). - bridge: emit od:tweaks-available + MutationObserver-driven od:tweaks-panel-state so host learns availability and mirrors local closes; transform-based hide preserves the artifact's transition - host: listen for both od:tweaks-* (bridge) and __edit_mode_* (artifact) dialects, send both on toggle, disable button when no panel, reset state on file change - skill: document the two protocols as a host integration contract - i18n: add fileViewer.tweaksUnavailable for all 13 locales * fix(tweaks): keep srcDoc path for `.tw-panel` artifacts The class based tweaks template (`.tw-panel` / `.tw-hidden`) needs the bridge `injectTweaksBridge` emits, but the default plain HTML preview URL loads the iframe, which bypasses buildSrcdoc entirely. Without the bridge there is no `od:tweaks-available` ping, so the toolbar toggle stays disabled on first load of a tweaks template artifact unless an unrelated mode (palette, inspect, etc.) coincidentally forces srcDoc. Add a `tweaksBridge` flag to `shouldUrlLoadHtmlPreview` and detect the fixed `.tw-panel` / `.tw-hidden` template selectors in the artifact source via a new `hasTweaksTemplate` helper. FileViewer passes the detected flag through so tweaks template artifacts pick the srcDoc render path on first load. Tests in `file-viewer-render-mode.test.ts` cover the new disqualifier, the helper positive and negative cases, and combinations with the existing flags. * fix(tweaks): resolve v0.7 UI ambiguity between toolbar toggle and palette After rebasing onto v0.7, three problems surfaced. v0.7 ships a palette popover button also labeled `Tweaks` with the same sliders icon as the new toolbar toggle. Toggling that popover flipped render mode (URL load to srcDoc) and reloaded the iframe, flashing the preview. The resulting iframe remount caused agent protocol artifacts to re-announce `__edit_mode_available`, which flipped the toolbar toggle back on without user input. Rename the palette popover button to `Themes` (button label, dialog title, `aria-label`) and swap its icon to a new `paint-bucket` glyph. The artifact tweaks toggle keeps the `tweaks` sliders icon. Internal identifiers (`data-testid="palette-tweaks-toggle"`, CSS classes, the `PaletteTweaks` component name) stay stable so existing tests and styles still target the same elements. Drop the auto set true on `__edit_mode_available`; that signal now flips `tweaksAvailable` only. `syncBridgeModes` posts the current `tweaksMode` to the artifact (both the bridge dialect and `__activate_edit_mode` / `__deactivate_edit_mode`) on every iframe load so the panel matches the toolbar. Mount both URL load and srcDoc iframes simultaneously, absolutely positioned and overlapping, with CSS visibility flipping between them. Toggling render mode no longer reloads the iframe so there is no flash. `isOurIframe(source)` accepts messages from either iframe so startup announcements from the hidden iframe are not lost; six receive filter sites switch from `iframeRef.current?.contentWindow` to the helper. Sends still target `iframeRef.current`, kept aligned with the active iframe via a `useEffect`, and a `syncBridgeModesRef` pushes current bridge state to the now visible iframe whenever the render mode flips. Tests that previously asserted exclusive render mode (`url-load` vs. `srcdoc` presence) now assert the active `data-testid` sits on the expected iframe via a co-attribute regex. The Draw bar element picking test switches from a cached frame reference to a `getFrame()` helper since `data-testid` follows the active iframe across toggles. Add a `paint-bucket` entry to `Icon` (Lucide style stroke icon). * fix(tweaks): scope `od:tweaks-available` to the active iframe The dual iframe setup mounts both the URL load and srcDoc iframes at once and accepts postMessage events from either via `isOurIframe`. The srcDoc iframe always carries the always injected tweaks bridge, which runs `document.querySelector('.tw-panel')` on mount and posts `{ type: 'od:tweaks-available', available: false }` for any artifact that does not ship the class based panel. For an agent protocol artifact (`.twk-panel`, `__edit_mode_*`), the URL load iframe correctly announces `__edit_mode_available` and the host sets `tweaksAvailable = true`. The hidden srcDoc iframe's `available: false` ping arrives shortly after and overrides that to false, silently disabling the toolbar button. Scope `od:tweaks-available` to the active iframe only by re-checking `ev.source === iframeRef.current?.contentWindow` before applying it. `__edit_mode_available` and `__edit_mode_dismissed` stay accepted from either iframe so the artifact's own announcement still drives the toolbar toggle across render mode flips. Spotted by Siri-Ray on PR #1643. * fix(tweaks): start the toolbar toggle ON when the artifact mounts its panel visible Both tweaks dialects (the class-based `.tw-panel` skill template and the `.twk-panel` agent-generated edit-mode protocol) mount their panel visible by default. Before this change the toolbar `Tweaks` toggle started in the OFF state regardless, so the user saw the panel but had to click toggle-on → toggle-off to actually hide it — confusing because the toggle disagreed with what they could plainly see in the preview. Two changes wire the initial state through to the toolbar: - `srcdoc.ts` (class-based dialect): the tweaks bridge's `onReady` now fires `postState()` alongside `postAvailability()`. `postState()` reads `!panel.classList.contains('tw-hidden')` and posts the artifact's actual initial visibility, so the host's existing `od:tweaks-panel-state` handler picks it up and mirrors it into `tweaksMode`. Previously only MutationObserver-driven changes were posted, so the host never learned the artifact's initial state. - `FileViewer.tsx` (twk-panel dialect): the agent dialect's `__edit_mode_available` carries no visibility payload, so we infer default-open from the fact that the artifact bothered to announce availability at all (the SDK pattern is `useState(true)`). Mirror that into `tweaksMode` exactly once per file (tracked by `firstEditModeAvailableSeenForFileRef`), so an iframe remount triggered by, e.g., flipping render mode through the Themes popover does not snap a user-driven OFF back to ON. Also fix a runtime `ReferenceError: panel is not defined` regression this same change introduced when first written with backticks inside the new code comment — the comment lived inside a `\`...\``-delimited script template literal, so the embedded backticks closed and reopened the outer literal and broke the bridge's JS body. Replaced with plain text. Validation: web typecheck clean, 1597/1597 tests pass. Manually verified with a `.twk-panel` artifact: open file → tweaks toggle is ON, panel visible → one click hides both. * fix(tweaks): seed bridge state from the panel's authored class, not from the host hidden attribute The bridge installs `data-od-tweaks-hidden` on `` synchronously in `` so the panel never flashes on initial paint. That attribute is therefore *always* present by the time `onReady()` fires, which meant the previous `applyClassesToPanel(!hasAttribute(...))` call unconditionally forced `.tw-hidden` onto the panel, the follow-up `postState()` read that forced-hidden class, and the host saw `visible: false` even when the artifact had authored the panel as default-visible. The PR-1643 attempt at "start ON when the artifact mounts visible" therefore still reported OFF for the class-based template path. Read the panel's authored class state first (the artifact body has just parsed, so the panel's class is what the artifact wrote and nothing else has touched it yet), then drive the attribute, the applied class, and the `od:tweaks-panel-state` post from that captured value: ```ts var panel = panelEl(); var initialVisible = !!panel && !panel.classList.contains('tw-hidden'); document.documentElement.toggleAttribute('data-od-tweaks-hidden', !initialVisible); applyClassesToPanel(initialVisible); attachObserver(); postAvailability(); postState(); ``` A default-visible `.tw-panel` now reports `visible: true` on mount, the host mirrors that into `tweaksMode = true`, and the toolbar Tweaks toggle starts in the ON state instead of disagreeing with what the user sees in the preview. The `.twk-panel` agent-protocol path is unaffected; its initial-state mirror still goes through the `firstEditModeAvailableSeenForFileRef` guard in `FileViewer.tsx`. Surfaced by Siri-Ray in https://github.com/nexu-io/open-design/pull/1643#discussion_r3263571196. Validation: web typecheck clean, 1597/1597 tests pass. * fix(tweaks): re-mirror __edit_mode_available default-open state when switching .twk-panel files The once-per-file guard that mirrors a `.twk-panel` artifact's default-open state into the toolbar `tweaksMode` lives inside a `window.addEventListener('message', ...)` handler installed in a `useEffect(..., [])` with an empty dep list. The handler therefore closed over the first-render `file.name`. After opening one `.twk-panel` artifact, `firstEditModeAvailableSeenForFileRef.current` got set to that first file; switching to a second `.twk-panel` file left the message listener still comparing the new artifact's `__edit_mode_available` against the stale captured name, so the guard never re-fired and the toolbar stayed OFF while the new artifact's panel was clearly visible — exactly the mismatch the guard was supposed to prevent on initial load. Add `file.name` to the listener effect's dep list so the handler gets a fresh closure on every file switch. The bridge-message setters (`setTweaksAvailable`, `setTweaksMode`), `isOurPreviewIframeSource`, and `firstEditModeAvailableSeenForFileRef` are stable across renders, so re-binding the listener has no other side effects beyond updating the captured `file.name`. Surfaced by Siri-Ray in https://github.com/nexu-io/open-design/pull/1643#discussion_r3266838151. Red-spec regression test added: `FileViewer tweaks toolbar > mirrors __edit_mode_available default-open state for each switched-to .twk-panel file`. Verified to go red on the bug (deps `[]`) and green on the fix (deps `[file.name]`). Validation: web typecheck clean, 1598/1598 tests pass (was 1597). * i18n(tweaks): add fileViewer.tweaksUnavailable to the remaining 6 locales The toolbar's disabled-Tweaks tooltip key landed in 13 locale files but 6 were missed (ar, fr, id, it, th, uk). Those locales were still falling through to the English string via the `...en` spread, which contradicts the repo convention that every key be defined explicitly in each locale. Add the translation alongside the existing `fileViewer.tweaks` entry so the full set of 19 locales now ships native copy for the disabled state. Surfaced by Siri-Ray in https://github.com/nexu-io/open-design/pull/1643#discussion_r3267654385. * fix(tweaks): respect default-closed dynamic panels in __edit_mode_available Protocol A in `design-templates/tweaks/SKILL.md` documents that the artifact may default the panel to either open or closed and the host should sync its toolbar toggle to whichever state the artifact reports. The previous handler ignored that and unconditionally mirrored availability into `tweaksMode = true`, so a default-closed dynamic artifact would be force-opened the moment `syncBridgeModes` ran and fired `__activate_edit_mode` — the artifact could not stay closed even though the contract said it could. Extend the message shape so the artifact can report its initial state on the same payload: { type: '__edit_mode_available', visible?: boolean } The host now reads `data.visible`: - omitted → treat as `true` (back-compat: existing artifacts emitting the legacy zero-arg shape mount with the panel already on screen, which is the SDK pattern `useState(true)`). - `visible: true` → toolbar starts ON. - `visible: false` → toolbar starts OFF, panel stays closed; the user opts in by clicking the toggle, which then fires `__activate_edit_mode` via the existing `syncBridgeModes` path. Update `design-templates/tweaks/SKILL.md` to document the new optional field alongside the legacy shape. Surfaced by Siri-Ray in https://github.com/nexu-io/open-design/pull/1643#discussion_r3269955351. Red-spec regression test added: `FileViewer tweaks toolbar > respects __edit_mode_available { visible: false } for default-closed dynamic artifacts`. Verified red without the fix (always-true mirror) and green with the fix (`data.visible !== false`). Validation: web typecheck clean, 1599/1599 tests pass. --- apps/web/src/components/FileViewer.tsx | 141 +++++++++++++++++- apps/web/src/components/Icon.tsx | 7 + apps/web/src/components/PaletteTweaks.tsx | 4 +- .../src/components/file-viewer-render-mode.ts | 23 +++ apps/web/src/i18n/locales/ar.ts | 1 + apps/web/src/i18n/locales/de.ts | 1 + apps/web/src/i18n/locales/en.ts | 1 + apps/web/src/i18n/locales/es-ES.ts | 1 + apps/web/src/i18n/locales/fa.ts | 1 + apps/web/src/i18n/locales/fr.ts | 1 + apps/web/src/i18n/locales/hu.ts | 1 + apps/web/src/i18n/locales/id.ts | 1 + apps/web/src/i18n/locales/it.ts | 1 + apps/web/src/i18n/locales/ja.ts | 1 + apps/web/src/i18n/locales/ko.ts | 1 + apps/web/src/i18n/locales/pl.ts | 1 + apps/web/src/i18n/locales/pt-BR.ts | 1 + apps/web/src/i18n/locales/ru.ts | 1 + apps/web/src/i18n/locales/th.ts | 1 + apps/web/src/i18n/locales/tr.ts | 1 + apps/web/src/i18n/locales/uk.ts | 1 + apps/web/src/i18n/locales/zh-CN.ts | 1 + apps/web/src/i18n/locales/zh-TW.ts | 1 + apps/web/src/i18n/types.ts | 1 + apps/web/src/runtime/srcdoc.ts | 126 +++++++++++++++- apps/web/tests/components/FileViewer.test.tsx | 139 +++++++++++++++++ .../file-viewer-render-mode.test.ts | 34 +++++ design-templates/tweaks/SKILL.md | 64 ++++++++ 28 files changed, 552 insertions(+), 6 deletions(-) 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 `