mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
feat(tweaks): bind toolbar toggle to artifact panel (#2348)
* 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 `<html>` synchronously in
`<head>` 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.
This commit is contained in:
parent
7905e72962
commit
59c8d72ae4
28 changed files with 552 additions and 6 deletions
|
|
@ -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<Set<string>>(() => new Set());
|
||||
const [commentSidePanelCollapsed, setCommentSidePanelCollapsed] = useState(false);
|
||||
const [strokePoints, setStrokePoints] = useState<StrokePoint[]>([]);
|
||||
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<string | null>(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({
|
|||
</button>
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`viewer-toggle${tweaksMode ? ' on' : ''}`}
|
||||
title={tweaksAvailable ? t('fileViewer.tweaks') : t('fileViewer.tweaksUnavailable')}
|
||||
aria-pressed={tweaksMode}
|
||||
disabled={!tweaksAvailable}
|
||||
data-coming-soon={!tweaksAvailable ? 'true' : undefined}
|
||||
onClick={() => setTweaksMode((v) => !v)}
|
||||
>
|
||||
<Icon name="tweaks" size={13} />
|
||||
<span>{t('fileViewer.tweaks')}</span>
|
||||
<span className="switch" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<div className="viewer-toolbar-actions">
|
||||
{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);
|
||||
}}
|
||||
>
|
||||
<Icon name="tweaks" size={13} />
|
||||
<span>Tweaks</span>
|
||||
<Icon name="paint-bucket" size={13} />
|
||||
<span>Themes</span>
|
||||
{selectedPalette ? (
|
||||
<span
|
||||
className="palette-tweaks-badge"
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export type IconName =
|
|||
| 'minus'
|
||||
| 'more-horizontal'
|
||||
| 'orbit'
|
||||
| 'paint-bucket'
|
||||
| 'palette'
|
||||
| 'pencil'
|
||||
| 'plus'
|
||||
|
|
@ -383,6 +384,12 @@ export function Icon({ name, size = 14, strokeWidth = 1.6, ...rest }: Props) {
|
|||
<circle cx="16" cy="6.8" r="1.5" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
case 'paint-bucket':
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="M11 7 6 2m12.992 10H2.041m19.104 6.38A3.34 3.34 0 0 1 20 16.5a3.3 3.3 0 0 1-1.145 1.88c-.575.46-.855 1.02-.855 1.595A2 2 0 0 0 20 22a2 2 0 0 0 2-2.025c0-.58-.285-1.13-.855-1.595M8.5 4.5l2.148-2.148a1.205 1.205 0 0 1 1.704 0l7.296 7.296a1.205 1.205 0 0 1 0 1.704l-7.592 7.592a3.615 3.615 0 0 1-5.112 0l-3.888-3.888a3.615 3.615 0 0 1 0-5.112L5.67 7.33" />
|
||||
</svg>
|
||||
);
|
||||
case 'palette':
|
||||
return (
|
||||
<svg {...common}>
|
||||
|
|
|
|||
|
|
@ -69,9 +69,9 @@ export function PaletteTweaks({ open, selected, onChange, onPreview, onClose }:
|
|||
const isOriginal = selected === null;
|
||||
|
||||
return (
|
||||
<div className="palette-tweaks" ref={rootRef} role="dialog" aria-label="Tweaks">
|
||||
<div className="palette-tweaks" ref={rootRef} role="dialog" aria-label="Themes">
|
||||
<div className="palette-tweaks-header">
|
||||
<span className="palette-tweaks-title">Tweaks panel</span>
|
||||
<span className="palette-tweaks-title">Themes</span>
|
||||
<span className="palette-tweaks-sub">5 curated theme palettes</span>
|
||||
</div>
|
||||
<ul className="palette-tweaks-list" role="listbox">
|
||||
|
|
|
|||
|
|
@ -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 `<iframe src=...>`) rather than through the
|
||||
|
|
@ -59,6 +77,11 @@ export function shouldUrlLoadHtmlPreview(d: UrlLoadDecision): boolean {
|
|||
// no parent-injected listener to recolor against.
|
||||
if (d.paletteActive) return false;
|
||||
if (d.drawMode) return false;
|
||||
// The class based tweaks template relies on the srcDoc tweaks bridge
|
||||
// emitting `od:tweaks-available` on mount; on the URL load path the bridge
|
||||
// is never injected, so the toolbar toggle would stay disabled even though
|
||||
// the artifact ships a `.tw-panel`.
|
||||
if (d.tweaksBridge) return false;
|
||||
if (d.forceInline) return false;
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -934,6 +934,7 @@ export const ar: Dict = {
|
|||
'fileViewer.preview': 'معاينة',
|
||||
'fileViewer.source': 'المصدر',
|
||||
'fileViewer.tweaks': 'تعديلات',
|
||||
'fileViewer.tweaksUnavailable': 'لا توجد لوحة تعديلات في هذا العمل',
|
||||
'fileViewer.comment': 'تعليق',
|
||||
'fileViewer.edit': 'تعديل',
|
||||
'fileViewer.draw': 'رسم',
|
||||
|
|
|
|||
|
|
@ -822,6 +822,7 @@ export const de: Dict = {
|
|||
'fileViewer.preview': 'Vorschau',
|
||||
'fileViewer.source': 'Quelle',
|
||||
'fileViewer.tweaks': 'Tweaks',
|
||||
'fileViewer.tweaksUnavailable': 'Kein Tweaks-Panel in diesem Artefakt',
|
||||
'fileViewer.comment': 'Kommentieren',
|
||||
'fileViewer.edit': 'Bearbeiten',
|
||||
'fileViewer.draw': 'Zeichnen',
|
||||
|
|
|
|||
|
|
@ -1416,6 +1416,7 @@ export const en: Dict = {
|
|||
'fileViewer.preview': 'Preview',
|
||||
'fileViewer.source': 'Code',
|
||||
'fileViewer.tweaks': 'Tweaks',
|
||||
'fileViewer.tweaksUnavailable': 'No tweaks panel in this artifact',
|
||||
'fileViewer.comment': 'Comment',
|
||||
'fileViewer.edit': 'Edit',
|
||||
'fileViewer.draw': 'Draw',
|
||||
|
|
|
|||
|
|
@ -823,6 +823,7 @@ export const esES: Dict = {
|
|||
'fileViewer.preview': 'Vista previa',
|
||||
'fileViewer.source': 'Código fuente',
|
||||
'fileViewer.tweaks': 'Ajustes',
|
||||
'fileViewer.tweaksUnavailable': 'Sin panel de ajustes en este artefacto',
|
||||
'fileViewer.comment': 'Comentar',
|
||||
'fileViewer.edit': 'Editar',
|
||||
'fileViewer.draw': 'Dibujar',
|
||||
|
|
|
|||
|
|
@ -958,6 +958,7 @@ export const fa: Dict = {
|
|||
'fileViewer.preview': 'پیشنمایش',
|
||||
'fileViewer.source': 'منبع',
|
||||
'fileViewer.tweaks': 'تنظیمات جزئی',
|
||||
'fileViewer.tweaksUnavailable': 'پنل تنظیمات در این مصنوع وجود ندارد',
|
||||
'fileViewer.comment': 'نظر',
|
||||
'fileViewer.edit': 'ویرایش',
|
||||
'fileViewer.draw': 'رسم',
|
||||
|
|
|
|||
|
|
@ -934,6 +934,7 @@ export const fr: Dict = {
|
|||
'fileViewer.preview': 'Aperçu',
|
||||
'fileViewer.source': 'Source',
|
||||
'fileViewer.tweaks': 'Ajustements',
|
||||
'fileViewer.tweaksUnavailable': "Aucun panneau d'ajustements dans cet artefact",
|
||||
'fileViewer.comment': 'Commenter',
|
||||
'fileViewer.edit': 'Modifier',
|
||||
'fileViewer.draw': 'Dessiner',
|
||||
|
|
|
|||
|
|
@ -934,6 +934,7 @@ export const hu: Dict = {
|
|||
'fileViewer.preview': 'Előnézet',
|
||||
'fileViewer.source': 'Forrás',
|
||||
'fileViewer.tweaks': 'Finomhangolás',
|
||||
'fileViewer.tweaksUnavailable': 'Nincs finomhangolási panel ebben az artefaktumban',
|
||||
'fileViewer.comment': 'Megjegyzés',
|
||||
'fileViewer.edit': 'Szerkesztés',
|
||||
'fileViewer.draw': 'Rajz',
|
||||
|
|
|
|||
|
|
@ -1049,6 +1049,7 @@ export const id: Dict = {
|
|||
'fileViewer.preview': 'Pratinjau',
|
||||
'fileViewer.source': 'Sumber',
|
||||
'fileViewer.tweaks': 'Tweaks',
|
||||
'fileViewer.tweaksUnavailable': 'Tidak ada panel tweaks pada artefak ini',
|
||||
'fileViewer.comment': 'Komentar',
|
||||
'fileViewer.edit': 'Edit',
|
||||
'fileViewer.draw': 'Gambar',
|
||||
|
|
|
|||
|
|
@ -852,6 +852,7 @@ export const it: Dict = {
|
|||
'fileViewer.preview': 'Anteprima',
|
||||
'fileViewer.source': 'Sorgente',
|
||||
'fileViewer.tweaks': 'Modifiche',
|
||||
'fileViewer.tweaksUnavailable': 'Nessun pannello di modifiche in questo artefatto',
|
||||
'fileViewer.comment': 'Commenta',
|
||||
'fileViewer.edit': 'Modifica',
|
||||
'fileViewer.draw': 'Disegna',
|
||||
|
|
|
|||
|
|
@ -821,6 +821,7 @@ export const ja: Dict = {
|
|||
'fileViewer.preview': 'プレビュー',
|
||||
'fileViewer.source': 'ソース',
|
||||
'fileViewer.tweaks': '調整',
|
||||
'fileViewer.tweaksUnavailable': 'このアーティファクトには調整パネルがありません',
|
||||
'fileViewer.comment': 'コメント',
|
||||
'fileViewer.edit': '編集',
|
||||
'fileViewer.draw': '描画',
|
||||
|
|
|
|||
|
|
@ -934,6 +934,7 @@ export const ko: Dict = {
|
|||
'fileViewer.preview': '미리보기',
|
||||
'fileViewer.source': '소스 코드',
|
||||
'fileViewer.tweaks': '조정 (Tweaks)',
|
||||
'fileViewer.tweaksUnavailable': '이 아티팩트에 조정 패널이 없습니다',
|
||||
'fileViewer.comment': '댓글',
|
||||
'fileViewer.edit': '편집',
|
||||
'fileViewer.draw': '그리기',
|
||||
|
|
|
|||
|
|
@ -934,6 +934,7 @@ export const pl: Dict = {
|
|||
'fileViewer.preview': 'Podgląd',
|
||||
'fileViewer.source': 'Źródło',
|
||||
'fileViewer.tweaks': 'Poprawki',
|
||||
'fileViewer.tweaksUnavailable': 'Brak panelu poprawek w tym artefakcie',
|
||||
'fileViewer.comment': 'Komentarz',
|
||||
'fileViewer.edit': 'Edytuj',
|
||||
'fileViewer.draw': 'Rysuj',
|
||||
|
|
|
|||
|
|
@ -957,6 +957,7 @@ export const ptBR: Dict = {
|
|||
'fileViewer.preview': 'Prévia',
|
||||
'fileViewer.source': 'Código-fonte',
|
||||
'fileViewer.tweaks': 'Ajustes',
|
||||
'fileViewer.tweaksUnavailable': 'Sem painel de ajustes neste artefato',
|
||||
'fileViewer.comment': 'Comentar',
|
||||
'fileViewer.edit': 'Editar',
|
||||
'fileViewer.draw': 'Desenhar',
|
||||
|
|
|
|||
|
|
@ -957,6 +957,7 @@ export const ru: Dict = {
|
|||
'fileViewer.preview': 'Предпросмотр',
|
||||
'fileViewer.source': 'Исходный код',
|
||||
'fileViewer.tweaks': 'Настройки',
|
||||
'fileViewer.tweaksUnavailable': 'В этом артефакте нет панели настроек',
|
||||
'fileViewer.comment': 'Комментарий',
|
||||
'fileViewer.edit': 'Редактировать',
|
||||
'fileViewer.draw': 'Рисовать',
|
||||
|
|
|
|||
|
|
@ -871,6 +871,7 @@ export const th: Dict = {
|
|||
'fileViewer.preview': 'หน้าพรีวิว',
|
||||
'fileViewer.source': 'ซอร์สไฟล์',
|
||||
'fileViewer.tweaks': 'ตั้งปรับแต่ง',
|
||||
'fileViewer.tweaksUnavailable': 'ไม่มีพาเนลตั้งปรับแต่งในชิ้นงานนี้',
|
||||
'fileViewer.comment': 'ช่วยคอมเมนต์',
|
||||
'fileViewer.edit': 'จัดการแก้ไข',
|
||||
'fileViewer.draw': 'วาดรูป',
|
||||
|
|
|
|||
|
|
@ -921,6 +921,7 @@ export const tr: Dict = {
|
|||
'fileViewer.preview': 'Önizle',
|
||||
'fileViewer.source': 'Kaynak',
|
||||
'fileViewer.tweaks': 'Düzenlemeler',
|
||||
'fileViewer.tweaksUnavailable': 'Bu artefaktta düzenleme paneli yok',
|
||||
'fileViewer.comment': 'Yorum',
|
||||
'fileViewer.edit': 'Düzenle',
|
||||
'fileViewer.draw': 'Çiz',
|
||||
|
|
|
|||
|
|
@ -976,6 +976,7 @@ export const uk: Dict = {
|
|||
'fileViewer.preview': 'Попередній перегляд',
|
||||
'fileViewer.source': 'Джерело',
|
||||
'fileViewer.tweaks': 'Настройки',
|
||||
'fileViewer.tweaksUnavailable': 'Немає панелі налаштувань у цьому артефакті',
|
||||
'fileViewer.comment': 'Коментар',
|
||||
'fileViewer.edit': 'Редагувати',
|
||||
'fileViewer.draw': 'Малювати',
|
||||
|
|
|
|||
|
|
@ -1406,6 +1406,7 @@ export const zhCN: Dict = {
|
|||
'fileViewer.preview': '预览',
|
||||
'fileViewer.source': '代码',
|
||||
'fileViewer.tweaks': '调整',
|
||||
'fileViewer.tweaksUnavailable': '此作品中没有调整面板',
|
||||
'fileViewer.comment': '评论',
|
||||
'fileViewer.edit': '编辑',
|
||||
'fileViewer.draw': '绘制',
|
||||
|
|
|
|||
|
|
@ -1009,6 +1009,7 @@ export const zhTW: Dict = {
|
|||
'fileViewer.preview': '預覽',
|
||||
'fileViewer.source': '程式碼',
|
||||
'fileViewer.tweaks': '調整',
|
||||
'fileViewer.tweaksUnavailable': '此作品中沒有調整面板',
|
||||
'fileViewer.comment': '評論',
|
||||
'fileViewer.edit': '編輯',
|
||||
'fileViewer.draw': '繪製',
|
||||
|
|
|
|||
|
|
@ -1702,6 +1702,7 @@ export interface Dict {
|
|||
'fileViewer.preview': string;
|
||||
'fileViewer.source': string;
|
||||
'fileViewer.tweaks': string;
|
||||
'fileViewer.tweaksUnavailable': string;
|
||||
'fileViewer.comment': string;
|
||||
'fileViewer.edit': string;
|
||||
'fileViewer.draw': string;
|
||||
|
|
|
|||
|
|
@ -72,7 +72,12 @@ export function buildSrcdoc(
|
|||
? injectPaletteBridge(withSelection, { initialPalette: options.initialPalette ?? null })
|
||||
: withSelection;
|
||||
const withEdit = options.editBridge ? injectManualEditBridge(withPalette) : withPalette;
|
||||
return injectSrcdocTransportActivationBridge(injectSnapshotBridge(withEdit));
|
||||
// The tweaks bridge is always injected — it's a passive listener that
|
||||
// toggles a `.tw-panel`'s visibility in response to host postMessage. Tying
|
||||
// it to a per-call option would force iframe srcdoc regeneration (and a
|
||||
// visible flash) every time the host toggle flips.
|
||||
const withTweaks = injectTweaksBridge(withEdit);
|
||||
return injectSrcdocTransportActivationBridge(injectSnapshotBridge(withTweaks));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1632,3 +1637,122 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
|
|||
})();</script>`;
|
||||
return injectBeforeBodyEnd(injectBeforeHeadEnd(doc, styleFix), script);
|
||||
}
|
||||
|
||||
// The tweaks bridge lets the host toolbar toggle the visibility of the artifact's
|
||||
// native tweaks panel. Bidirectional: host posts `od:tweaks-panel-visible` to
|
||||
// drive panel visibility; bridge posts `od:tweaks-panel-state` back whenever the
|
||||
// artifact's own `× close` button or `T` shortcut flips the `.tw-hidden` class,
|
||||
// so the toolbar toggle stays in sync. Also reports `od:tweaks-available` so the
|
||||
// host can disable the toggle on artifacts without a `.tw-panel`.
|
||||
function injectTweaksBridge(doc: string): string {
|
||||
// Hide-state styling mirrors the artifact's own `.tw-hidden` (transform +
|
||||
// opacity) so the CSS transition plays in both directions. `.tw-restore` is
|
||||
// kept permanently hidden — the host toolbar is the only entry point.
|
||||
const style = `<style data-od-tweaks-bridge-style>
|
||||
[data-od-tweaks-hidden] .tw-panel {
|
||||
transform: translateX(calc(100% + 32px)) !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
.tw-restore { display: none !important; }
|
||||
</style>`;
|
||||
const script = `<script data-od-tweaks-bridge>(function(){
|
||||
// Synchronously hide BEFORE the artifact body parses so the panel never
|
||||
// flashes on initial paint. The host removes the attribute via postMessage
|
||||
// once it knows the desired state.
|
||||
document.documentElement.setAttribute('data-od-tweaks-hidden', '');
|
||||
|
||||
var suppressEcho = false;
|
||||
var observer = null;
|
||||
|
||||
function panelEl(){ return document.querySelector('.tw-panel'); }
|
||||
|
||||
function applyClassesToPanel(visible){
|
||||
var panel = panelEl();
|
||||
if (panel) panel.classList.toggle('tw-hidden', !visible);
|
||||
}
|
||||
|
||||
function setPanelVisible(visible){
|
||||
suppressEcho = true;
|
||||
document.documentElement.toggleAttribute('data-od-tweaks-hidden', !visible);
|
||||
applyClassesToPanel(visible);
|
||||
// Clear flag after the MutationObserver has had a chance to fire for this
|
||||
// change so we don't echo our own host-driven toggles back to the host.
|
||||
Promise.resolve().then(function(){ suppressEcho = false; });
|
||||
}
|
||||
|
||||
function postState(){
|
||||
var panel = panelEl();
|
||||
if (!panel) return;
|
||||
try {
|
||||
parent.postMessage({
|
||||
type: 'od:tweaks-panel-state',
|
||||
visible: !panel.classList.contains('tw-hidden'),
|
||||
}, '*');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function postAvailability(){
|
||||
try {
|
||||
parent.postMessage({
|
||||
type: 'od:tweaks-available',
|
||||
available: !!panelEl(),
|
||||
}, '*');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function attachObserver(){
|
||||
var panel = panelEl();
|
||||
if (!panel || observer) return;
|
||||
observer = new MutationObserver(function(){
|
||||
if (suppressEcho) return;
|
||||
postState();
|
||||
});
|
||||
observer.observe(panel, { attributes: true, attributeFilter: ['class'] });
|
||||
}
|
||||
|
||||
function onReady(){
|
||||
// Capture the panel authored visibility BEFORE we apply the host hidden
|
||||
// attribute. The bridge sets data-od-tweaks-hidden synchronously in head
|
||||
// (before the body parses), so on entry to onReady the attribute is
|
||||
// always present even though the artifact may have authored the panel
|
||||
// as default-visible. Reading the panel class first is the only place
|
||||
// we can still observe the author intent. Then drive the attribute,
|
||||
// classes, and posted state from that captured value so a default
|
||||
// visible tw-panel reports visible:true and the toolbar toggle starts
|
||||
// ON. Issue surfaced in PR #1643 review.
|
||||
var panel = panelEl();
|
||||
var initialVisible = !!panel && !panel.classList.contains('tw-hidden');
|
||||
document.documentElement.toggleAttribute('data-od-tweaks-hidden', !initialVisible);
|
||||
applyClassesToPanel(initialVisible);
|
||||
attachObserver();
|
||||
postAvailability();
|
||||
// Post the captured initial visibility so the toolbar toggle reflects
|
||||
// the default state on mount. Without this the toggle reads OFF while
|
||||
// a default-visible tw-panel artifact clearly shows its panel and the
|
||||
// user would have to click toggle-on then toggle-off to actually hide.
|
||||
postState();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', onReady);
|
||||
} else {
|
||||
onReady();
|
||||
}
|
||||
|
||||
window.addEventListener('message', function(ev){
|
||||
if (!ev.data || ev.data.type !== 'od:tweaks-panel-visible') return;
|
||||
setPanelVisible(!!ev.data.visible);
|
||||
});
|
||||
})();</script>`;
|
||||
const withStyle = /<\/head>/i.test(doc)
|
||||
? doc.replace(/<\/head>/i, style + '</head>')
|
||||
: /<head[^>]*>/i.test(doc)
|
||||
? doc.replace(/<head[^>]*>/i, (m) => m + style)
|
||||
: style + doc;
|
||||
// Inject the bridge as early as possible (inside <head>) so the synchronous
|
||||
// attribute set runs before the artifact body parses.
|
||||
if (/<\/head>/i.test(withStyle)) return withStyle.replace(/<\/head>/i, script + '</head>');
|
||||
if (/<head[^>]*>/i.test(withStyle)) return withStyle.replace(/<head[^>]*>/i, (m) => m + script);
|
||||
return script + withStyle;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -367,6 +367,10 @@ describe('FileViewer SVG artifacts', () => {
|
|||
<FileViewer projectId="project-1" projectKind="prototype" file={file} liveHtml="<html><body>hi</body></html>" />,
|
||||
);
|
||||
|
||||
// Both iframes are always mounted (the lazy srcDoc transport avoids
|
||||
// booting the artifact in the inactive frame). `data-od-active` and
|
||||
// the testid pair identify which iframe is currently the user-facing
|
||||
// one without unmounting either side.
|
||||
expect(markup).toContain('data-testid="artifact-preview-frame"');
|
||||
expect(markup).toContain('data-od-render-mode="url-load"');
|
||||
expect(markup).toContain('data-od-render-mode="url-load" data-od-active="true"');
|
||||
|
|
@ -1684,6 +1688,141 @@ describe('FileViewer tweaks toolbar', () => {
|
|||
expect(screen.queryByTestId('comment-side-selectbar')).toBeNull();
|
||||
expect(screen.queryByTestId('comment-side-collapsed-rail')).toBeNull();
|
||||
});
|
||||
|
||||
// PR #1643 regression: the once-per-file guard that mirrors a `.twk-panel`
|
||||
// artifact's default-open state into the toolbar `tweaksMode` lives in a
|
||||
// message-event listener that previously had an empty deps array. The
|
||||
// handler therefore closed over the first-render `file.name`, so switching
|
||||
// to a second `.twk-panel` file left the guard comparing against the
|
||||
// stale captured name and never re-mirrored the new artifact's open state
|
||||
// back to ON. Surfaced by Siri-Ray in
|
||||
// https://github.com/nexu-io/open-design/pull/1643#discussion_r3266838151.
|
||||
it('mirrors __edit_mode_available default-open state for each switched-to .twk-panel file', async () => {
|
||||
function twkFile(name: string): ProjectFile {
|
||||
return baseFile({
|
||||
name,
|
||||
path: name,
|
||||
mime: 'text/html',
|
||||
kind: 'html',
|
||||
artifactManifest: {
|
||||
version: 1,
|
||||
kind: 'html',
|
||||
title: name,
|
||||
entry: name,
|
||||
renderer: 'html',
|
||||
exports: ['html'],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function Switcher() {
|
||||
const [file, setFile] = useState<ProjectFile>(twkFile('first.html'));
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => setFile(twkFile('second.html'))}>
|
||||
Switch file
|
||||
</button>
|
||||
<FileViewer
|
||||
projectId="project-1"
|
||||
projectKind="prototype"
|
||||
file={file}
|
||||
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Switcher />);
|
||||
|
||||
const firstFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
||||
const tweaksButton = () =>
|
||||
Array.from(document.querySelectorAll('button')).find(
|
||||
(b) => b.getAttribute('title') === 'Tweaks' || b.getAttribute('aria-label') === 'Tweaks',
|
||||
) as HTMLButtonElement | undefined;
|
||||
|
||||
// First file: artifact posts __edit_mode_available → toolbar starts ON.
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
source: firstFrame.contentWindow,
|
||||
data: { type: '__edit_mode_available' },
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(tweaksButton()?.getAttribute('aria-pressed')).toBe('true'));
|
||||
|
||||
// User toggles OFF on first file.
|
||||
fireEvent.click(tweaksButton()!);
|
||||
await waitFor(() => expect(tweaksButton()?.getAttribute('aria-pressed')).toBe('false'));
|
||||
|
||||
// Switch to second file. The second artifact also mounts panel-visible
|
||||
// and emits __edit_mode_available. The toolbar must mirror that into ON
|
||||
// again — the bug was that the handler kept comparing against the first
|
||||
// file's name in a stale closure, so the second emission was treated as
|
||||
// a "second emission for the same file" and the OFF state stuck.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Switch file' }));
|
||||
const secondFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
source: secondFrame.contentWindow,
|
||||
data: { type: '__edit_mode_available' },
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(tweaksButton()?.getAttribute('aria-pressed')).toBe('true'));
|
||||
});
|
||||
|
||||
// PR #1643 regression: Protocol A in `design-templates/tweaks/SKILL.md`
|
||||
// says the artifact MAY declare a default-closed panel via
|
||||
// `{ type: '__edit_mode_available', visible: false }`. The handler used
|
||||
// to unconditionally mirror availability into `tweaksMode = true`, so a
|
||||
// default-closed dynamic artifact would be force-opened by the next
|
||||
// `syncBridgeModes` posting `__activate_edit_mode`. The host must now
|
||||
// read `visible` and only flip to ON when the panel reports itself open
|
||||
// (or omits `visible` — back-compat shim for the common open-by-default
|
||||
// case). Surfaced by Siri-Ray in
|
||||
// https://github.com/nexu-io/open-design/pull/1643#discussion_r3269955351.
|
||||
it('respects __edit_mode_available { visible: false } for default-closed dynamic artifacts', async () => {
|
||||
const file = baseFile({
|
||||
name: 'closed.html',
|
||||
path: 'closed.html',
|
||||
mime: 'text/html',
|
||||
kind: 'html',
|
||||
artifactManifest: {
|
||||
version: 1,
|
||||
kind: 'html',
|
||||
title: 'closed',
|
||||
entry: 'closed.html',
|
||||
renderer: 'html',
|
||||
exports: ['html'],
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<FileViewer
|
||||
projectId="project-1"
|
||||
projectKind="prototype"
|
||||
file={file}
|
||||
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||
/>,
|
||||
);
|
||||
|
||||
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
||||
const tweaksButton = () =>
|
||||
Array.from(document.querySelectorAll('button')).find(
|
||||
(b) => b.getAttribute('title') === 'Tweaks' || b.getAttribute('aria-label') === 'Tweaks',
|
||||
) as HTMLButtonElement | undefined;
|
||||
|
||||
// Artifact announces availability AND declares the panel is currently
|
||||
// closed. The toolbar must enable (panel exists) but stay OFF — opening
|
||||
// it without intent would override the artifact-declared default.
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
source: frame.contentWindow,
|
||||
data: { type: '__edit_mode_available', visible: false },
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(tweaksButton()?.disabled).toBe(false));
|
||||
expect(tweaksButton()?.getAttribute('aria-pressed')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyInspectOverridesToSource', () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
hasTweaksTemplate,
|
||||
hasUrlModeBridge,
|
||||
htmlNeedsSandboxShim,
|
||||
parseForceInline,
|
||||
|
|
@ -42,6 +43,13 @@ describe('shouldUrlLoadHtmlPreview', () => {
|
|||
expect(shouldUrlLoadHtmlPreview({ ...base, drawMode: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to srcDoc when the artifact ships the class based tweaks template', () => {
|
||||
// Without this, a plain `.tw-panel` artifact would URL load on first
|
||||
// open, skip the tweaks bridge entirely, and leave the toolbar toggle
|
||||
// disabled (no `od:tweaks-available` ever fires).
|
||||
expect(shouldUrlLoadHtmlPreview({ ...base, tweaksBridge: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to srcDoc when the user opts in via forceInline', () => {
|
||||
expect(shouldUrlLoadHtmlPreview({ ...base, forceInline: true })).toBe(false);
|
||||
});
|
||||
|
|
@ -54,10 +62,36 @@ describe('shouldUrlLoadHtmlPreview', () => {
|
|||
expect(shouldUrlLoadHtmlPreview({ ...base, isDeck: true, commentMode: true })).toBe(false);
|
||||
expect(shouldUrlLoadHtmlPreview({ ...base, isDeck: true, forceInline: true })).toBe(false);
|
||||
expect(shouldUrlLoadHtmlPreview({ ...base, commentMode: true, forceInline: true })).toBe(false);
|
||||
expect(shouldUrlLoadHtmlPreview({ ...base, tweaksBridge: true, forceInline: true })).toBe(false);
|
||||
expect(shouldUrlLoadHtmlPreview({ ...base, commentMode: true, urlModeBridge: true, inspectMode: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasTweaksTemplate', () => {
|
||||
it('matches a plain `.tw-panel` artifact', () => {
|
||||
const source = '<!doctype html><html><body><aside class="tw-panel"></aside></body></html>';
|
||||
expect(hasTweaksTemplate(source)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches the `.tw-hidden` toggle class even without an explicit `.tw-panel`', () => {
|
||||
// Defensive: the template ships both selectors and either one signals a
|
||||
// tweaks-template artifact that needs the bridge.
|
||||
const source = '<style>.tw-hidden { display: none; }</style>';
|
||||
expect(hasTweaksTemplate(source)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match unrelated identifiers that merely contain `tw`', () => {
|
||||
expect(hasTweaksTemplate('<div class="container">tweet</div>')).toBe(false);
|
||||
expect(hasTweaksTemplate('twk-panel, btw-panel, mtw-hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty / null / undefined input', () => {
|
||||
expect(hasTweaksTemplate('')).toBe(false);
|
||||
expect(hasTweaksTemplate(null)).toBe(false);
|
||||
expect(hasTweaksTemplate(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasUrlModeBridge', () => {
|
||||
it('detects an artifact-owned direct-edit bridge script', () => {
|
||||
expect(hasUrlModeBridge('<script src="od-direct-edit.js"></script>')).toBe(true);
|
||||
|
|
|
|||
|
|
@ -140,6 +140,70 @@ all `transition-duration` / `animation-duration` declarations:
|
|||
Respect `prefers-reduced-motion`: default to *Off* if the user has
|
||||
that set, regardless of stored preference.
|
||||
|
||||
## Host integration contract (REQUIRED)
|
||||
|
||||
The Open Design viewer toolbar has a **Tweaks** toggle that drives panel
|
||||
visibility from outside the iframe. For the toggle to bind to your panel,
|
||||
your artifact **must** speak one of these two protocols (pick one; don't
|
||||
mix). The toolbar enables itself the moment it sees either signal.
|
||||
|
||||
### Protocol A — postMessage (recommended for agent-generated artifacts)
|
||||
|
||||
Use this when the panel mounts via JS (React, vanilla, anything dynamic).
|
||||
|
||||
**Artifact → host:**
|
||||
- On mount, post `{ type: '__edit_mode_available', visible?: boolean }` to
|
||||
`window.parent`. Tells the toolbar a panel exists; the optional `visible`
|
||||
reports the panel's initial state so the toolbar toggle starts in sync.
|
||||
Omit `visible` for the common "panel is already on screen" case (the host
|
||||
treats a missing field as `true` so the legacy zero-arg message keeps
|
||||
working). Pass `visible: false` to declare a default-closed panel.
|
||||
- When the user closes the panel locally (× button, Esc, etc.), post
|
||||
`{ type: '__edit_mode_dismissed' }`. Toolbar flips to "off".
|
||||
|
||||
**Host → artifact:**
|
||||
- `{ type: '__activate_edit_mode' }` — open the panel (`setOpen(true)`).
|
||||
- `{ type: '__deactivate_edit_mode' }` — close the panel (`setOpen(false)`).
|
||||
|
||||
Minimal listener:
|
||||
|
||||
```js
|
||||
window.addEventListener('message', (e) => {
|
||||
const t = e?.data?.type;
|
||||
if (t === '__activate_edit_mode') setOpen(true);
|
||||
else if (t === '__deactivate_edit_mode') setOpen(false);
|
||||
});
|
||||
// Or, for a default-closed panel:
|
||||
// window.parent.postMessage({ type: '__edit_mode_available', visible: open }, '*');
|
||||
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
|
||||
// in your close handler:
|
||||
const dismiss = () => {
|
||||
setOpen(false);
|
||||
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
|
||||
};
|
||||
```
|
||||
|
||||
Panel may default to open or closed — the host syncs its toggle to
|
||||
whichever state the artifact reports.
|
||||
|
||||
### Protocol B — class-based (used by `assets/wrap.html`)
|
||||
|
||||
Use this only when you wrap the template verbatim. The artifact ships a
|
||||
`.tw-panel` element and toggles a `.tw-hidden` class for visibility. The
|
||||
viewer's iframe bridge (in `apps/web/src/runtime/srcdoc.ts`) hides the
|
||||
panel on initial paint, watches the class via `MutationObserver`, and
|
||||
relays state both directions. No JS required in the artifact beyond what
|
||||
the template already includes.
|
||||
|
||||
Selectors are fixed: `.tw-panel` (the panel root) and `.tw-hidden` (the
|
||||
hidden state). If you rename either, the bridge can't find it.
|
||||
|
||||
### Anti-pattern
|
||||
|
||||
Don't invent a third protocol or rename either set of identifiers. The
|
||||
toolbar toggle only binds to A or B. Custom panels with custom classes
|
||||
and no postMessage will leave the toolbar greyed out.
|
||||
|
||||
## Implementation primitives
|
||||
|
||||
Read `assets/wrap.html` — it ships the panel + bridge as an
|
||||
|
|
|
|||
Loading…
Reference in a new issue