fix(widgets): restore iframe content after drag, remove color-cycle button (#2368)

* fix(widgets): restore iframe content after drag, remove color-cycle button

- Fix drag-induced blank content: use WeakMap keyed by iframe element to persist
  HTML across DOM moves; persistent load listener (no {once}) re-posts on every
  browser re-navigation triggered by drag/drop repositioning
- Remove cycleAccentColor, ACCENT_COLORS, and colorBtn from CustomWidgetPanel
  header; chatBtn (sparkle) and PRO badge remain; applyAccentColor kept for
  saved specs
- Update tests: remove ACCENT_COLORS count test, saveWidget persistence test,
  and changeAccent i18n assertion (all for deleted feature)

* fix(widgets): use correct postMessage key 'html' not 'storedHtml'

* fix(widgets): remove duplicate panel title header, fix sandbox CSP beacon error

- System prompt: NEVER add .panel-header or title to widget body; outer panel
  frame already shows the title; updated both basic and PRO prompts
- widget-sanitizer: strip leading .panel-header from generated HTML as safety
  net in both wrapWidgetHtml and wrapProWidgetHtml
- vercel.json: add https://static.cloudflareinsights.com to sandbox script-src
  so Cloudflare beacon injection no longer triggers CSP console errors

* fix(widgets): correct iframe font by anchoring html+body font-family with !important
This commit is contained in:
Elie Habib
2026-03-27 16:52:56 +04:00
committed by GitHub
parent 7f594a31c9
commit 47f0dd133d
5 changed files with 34 additions and 62 deletions

View File

@@ -8325,6 +8325,9 @@ Rows: padding 58px vertical, 8px horizontal. Section gaps: 812px. NEVER us
### Border radius — flat, not rounded
Max 4px. NEVER use border-radius > 4px (no 8px, 12px, 16px rounded cards).
### Titles — NEVER duplicate the panel header
The outer panel frame already displays the widget title. NEVER add an h1/h2/h3 or any top-level title element to the widget body. Start content immediately (tabs, rows, stats grid, or a section label).
### Labels — uppercase monospace
Section headers and column labels: font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)
@@ -8890,7 +8893,8 @@ CSS variables are pre-defined in the iframe: --bg, --surface, --text, --text-sec
- ALWAYS use CSS variables for colors — never hardcode hex values like #3b82f6, #1a1a2e, etc.
- NEVER override font-family (already set — do not change it)
- NEVER use border-radius > 4px
- NEVER use large bold titles (h1/h2/h3) — use .panel-title or section labels only
- NEVER use large bold titles (h1/h2/h3) — use section labels only
- NEVER add a top-level .panel-header or .panel-title — the outer panel frame already shows the widget title; a second header creates an ugly duplicate
- Keep row padding tight: 58px vertical, 8px horizontal
- Numbers/prices: font-variant-numeric: tabular-nums
- Positive values: color: var(--green) | Negative values: color: var(--red)
@@ -8900,8 +8904,7 @@ CSS variables are pre-defined in the iframe: --bg, --surface, --text, --text-sec
## Pre-defined CSS classes — use these, do NOT reinvent them
Panel structure (copy exactly):
<div class="panel-header"><span class="panel-title">SECTION TITLE</span><span style="color:var(--text-muted);font-size:10px">LIVE</span></div>
Tabs (when content has multiple views — start directly with this, NO .panel-header above it):
<div class="panel-tabs">
<button class="panel-tab active" onclick="switchTab(this,'line')">LINE</button>
<button class="panel-tab" onclick="switchTab(this,'bar')">BAR</button>

View File

@@ -1,16 +1,9 @@
import { Panel } from './Panel';
import type { CustomWidgetSpec } from '@/services/widget-store';
import { saveWidget } from '@/services/widget-store';
import { t } from '@/services/i18n';
import { wrapWidgetHtml, wrapProWidgetHtml } from '@/utils/widget-sanitizer';
import { h } from '@/utils/dom-utils';
const ACCENT_COLORS: Array<string | null> = [
'#44ff88', '#ff8844', '#4488ff', '#ff44ff',
'#ffff44', '#ff4444', '#44ffff', '#3b82f6',
null,
];
export class CustomWidgetPanel extends Panel {
private spec: CustomWidgetSpec;
@@ -29,17 +22,6 @@ export class CustomWidgetPanel extends Panel {
private addHeaderButtons(): void {
const closeBtn = this.header.querySelector('.panel-close-btn');
const colorBtn = h('button', {
className: 'icon-btn widget-color-btn widget-header-btn',
title: t('widgets.changeAccent'),
'aria-label': t('widgets.changeAccent'),
});
colorBtn.style.setProperty('background', this.spec.accentColor ?? 'var(--accent)');
colorBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.cycleAccentColor(colorBtn);
});
const chatBtn = h('button', {
className: 'icon-btn panel-widget-chat-btn widget-header-btn',
title: t('widgets.modifyWithAi'),
@@ -63,24 +45,12 @@ export class CustomWidgetPanel extends Panel {
}
if (closeBtn) {
this.header.insertBefore(colorBtn, closeBtn);
this.header.insertBefore(chatBtn, closeBtn);
} else {
this.header.appendChild(colorBtn);
this.header.appendChild(chatBtn);
}
}
private cycleAccentColor(btn: HTMLElement): void {
const current = this.spec.accentColor;
const idx = ACCENT_COLORS.indexOf(current);
const next = ACCENT_COLORS[(idx + 1) % ACCENT_COLORS.length] ?? null;
this.spec = { ...this.spec, accentColor: next, updatedAt: Date.now() };
saveWidget(this.spec);
btn.style.setProperty('background', next ?? 'var(--accent)');
this.applyAccentColor();
}
renderWidget(): void {
if (this.spec.tier === 'pro') {
this.setContent(wrapProWidgetHtml(this.spec.html));
@@ -103,8 +73,6 @@ export class CustomWidgetPanel extends Panel {
const titleEl = this.header.querySelector('.panel-title');
if (titleEl) titleEl.textContent = spec.title;
this.renderWidget();
const colorBtn = this.header.querySelector('.widget-color-btn') as HTMLElement | null;
if (colorBtn) colorBtn.style.setProperty('background', spec.accentColor ?? 'var(--accent)');
}
getSpec(): CustomWidgetSpec {

View File

@@ -30,12 +30,19 @@ export function sanitizeWidgetHtml(html: string): string {
return DOMPurify.sanitize(html, PURIFY_CONFIG) as unknown as string;
}
// Strip a leading .panel-header that the agent may generate — the outer
// CustomWidgetPanel frame already displays the title, so a second one is
// always a duplicate. Only the very first element is removed.
function stripLeadingPanelHeader(html: string): string {
return html.replace(/^\s*<div[^>]*\bclass="panel-header"[^>]*>[\s\S]*?<\/div>\s*/i, '');
}
export function wrapWidgetHtml(html: string, extraClass = ''): string {
const shellClass = ['wm-widget-shell', extraClass].filter(Boolean).join(' ');
return `
<div class="${shellClass}">
<div class="wm-widget-body">
<div class="wm-widget-generated">${sanitizeWidgetHtml(html)}</div>
<div class="wm-widget-generated">${sanitizeWidgetHtml(stripLeadingPanelHeader(html))}</div>
</div>
</div>
`;
@@ -43,6 +50,10 @@ export function wrapWidgetHtml(html: string, extraClass = ''): string {
const widgetBodyStore = new Map<string, string>();
// Keyed by iframe element — persists HTML across DOM moves so the load listener
// can re-post whenever the browser re-navigates the iframe after a drag.
const iframeHtmlStore = new WeakMap<HTMLIFrameElement, string>();
function buildWidgetDoc(bodyContent: string): string {
return `<!DOCTYPE html>
<html>
@@ -52,7 +63,8 @@ function buildWidgetDoc(bodyContent: string): string {
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<style>
:root{--bg:#0a0a0a;--surface:#141414;--text:#e8e8e8;--text-secondary:#ccc;--text-dim:#888;--text-muted:#666;--border:#2a2a2a;--border-subtle:#1a1a1a;--overlay-subtle:rgba(255,255,255,0.03);--green:#44ff88;--red:#ff4444;--yellow:#ffaa00;--accent:#44ff88}
body{margin:0;padding:12px;background:var(--bg);color:var(--text);font-family:'SF Mono','Monaco','Cascadia Code','Fira Code','DejaVu Sans Mono','Liberation Mono',monospace;font-size:12px;line-height:1.5;overflow-y:auto;box-sizing:border-box}
html,body{font-family:'SF Mono','Monaco','Cascadia Code','Fira Code','DejaVu Sans Mono','Liberation Mono',monospace!important}
body{margin:0;padding:12px;background:var(--bg);color:var(--text);font-size:12px;line-height:1.5;overflow-y:auto;box-sizing:border-box}
*{box-sizing:inherit;font-family:inherit!important}
table{border-collapse:collapse;width:100%}
th{text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);padding:4px 8px;border-bottom:1px solid var(--border);font-weight:600}
@@ -78,17 +90,23 @@ td{padding:5px 8px;border-bottom:1px solid var(--border-subtle);color:var(--text
function mountProWidget(iframe: HTMLIFrameElement): void {
const id = iframe.dataset.wmId;
if (!id) return;
// Already wired up — the persistent load listener will re-post on every
// navigation (including after the panel is dragged to a new position).
if (iframeHtmlStore.has(iframe)) return;
const body = widgetBodyStore.get(id);
if (!body) return;
// Delete immediately — new ID is generated on every render, so no re-use
widgetBodyStore.delete(id);
const html = buildWidgetDoc(body);
// Always use load event: when MutationObserver fires, iframe.contentDocument is
// still about:blank (readyState 'complete'), not the sandbox page. Sending here
// would target the wrong window. Wait for the real load instead.
iframeHtmlStore.set(iframe, html);
// Persistent (no { once }) — fires on initial load AND whenever the browser
// re-navigates the iframe after its DOM position changes (drag/drop).
iframe.addEventListener('load', () => {
iframe.contentWindow?.postMessage({ type: 'wm-html', html }, '*');
}, { once: true });
const storedHtml = iframeHtmlStore.get(iframe);
if (storedHtml) iframe.contentWindow?.postMessage({ type: 'wm-html', html: storedHtml }, '*');
});
}
if (typeof document !== 'undefined') {
@@ -116,6 +134,6 @@ if (typeof document !== 'undefined') {
export function wrapProWidgetHtml(bodyContent: string): string {
const id = `wm-${Math.random().toString(36).slice(2)}`;
widgetBodyStore.set(id, bodyContent);
widgetBodyStore.set(id, stripLeadingPanelHeader(bodyContent));
return `<div class="wm-widget-shell wm-widget-pro"><iframe src="/wm-widget-sandbox.html" data-wm-id="${id}" sandbox="allow-scripts" style="width:100%;height:400px;border:none;display:block;" title="Interactive widget"></iframe></div>`;
}

View File

@@ -812,7 +812,6 @@ describe('i18n — widgets section completeness', () => {
assert.ok(modal.includes("t('widgets.chatTitle')"), 'WidgetChatModal must use widgets.chatTitle');
assert.ok(modal.includes("t('widgets.modifyTitle')"), 'WidgetChatModal must use widgets.modifyTitle');
assert.ok(modal.includes("t('widgets.inputPlaceholder')"), 'WidgetChatModal must use widgets.inputPlaceholder');
assert.ok(panel.includes("t('widgets.changeAccent')"), 'CustomWidgetPanel must use widgets.changeAccent');
assert.ok(panel.includes("t('widgets.modifyWithAi')"), 'CustomWidgetPanel must use widgets.modifyWithAi');
assert.ok(events.includes("t('widgets.confirmDelete')"), 'Delete confirmation must use widgets.confirmDelete');
});
@@ -842,22 +841,6 @@ describe('CustomWidgetPanel — header buttons and events', () => {
);
});
it('ACCENT_COLORS has 9 entries (8 colors + null reset)', () => {
// Array spans multiple lines — use [\s\S]*? to capture across newlines
const match = panel.match(/ACCENT_COLORS[^=]*=\s*\[([\s\S]*?)\];/);
assert.ok(match, 'ACCENT_COLORS array not found');
const entries = match[1].split(',').map(s => s.trim()).filter(Boolean);
assert.equal(entries.length, 9, `ACCENT_COLORS must have 9 entries (8 colors + null), found ${entries.length}: [${entries.join(', ')}]`);
assert.ok(entries.includes('null'), 'ACCENT_COLORS must include null for reset');
});
it('accent color persists via saveWidget after color cycle', () => {
assert.ok(
panel.includes('saveWidget'),
'Color cycle must call saveWidget() to persist accentColor',
);
});
it('applies --widget-accent CSS variable', () => {
assert.ok(
panel.includes('--widget-accent'),

View File

@@ -136,7 +136,7 @@
{
"source": "/wm-widget-sandbox.html",
"headers": [
{ "key": "Content-Security-Policy", "value": "default-src 'none'; script-src 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'unsafe-inline'; img-src data:; connect-src https://cdn.jsdelivr.net;" },
{ "key": "Content-Security-Policy", "value": "default-src 'none'; script-src 'unsafe-inline' https://cdn.jsdelivr.net https://static.cloudflareinsights.com; style-src 'unsafe-inline'; img-src data:; connect-src https://cdn.jsdelivr.net;" },
{ "key": "Cache-Control", "value": "public, max-age=86400" }
]
}