mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -8325,6 +8325,9 @@ Rows: padding 5–8px vertical, 8px horizontal. Section gaps: 8–12px. 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: 5–8px 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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user