diff --git a/scripts/ais-relay.cjs b/scripts/ais-relay.cjs
index 30636b226..150908569 100644
--- a/scripts/ais-relay.cjs
+++ b/scripts/ais-relay.cjs
@@ -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):
-
SECTION TITLELIVE
+Tabs (when content has multiple views — start directly with this, NO .panel-header above it):
diff --git a/src/components/CustomWidgetPanel.ts b/src/components/CustomWidgetPanel.ts
index 36927d962..468e960df 100644
--- a/src/components/CustomWidgetPanel.ts
+++ b/src/components/CustomWidgetPanel.ts
@@ -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 = [
- '#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 {
diff --git a/src/utils/widget-sanitizer.ts b/src/utils/widget-sanitizer.ts
index ad31ed70a..737400912 100644
--- a/src/utils/widget-sanitizer.ts
+++ b/src/utils/widget-sanitizer.ts
@@ -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*
`;
@@ -43,6 +50,10 @@ export function wrapWidgetHtml(html: string, extraClass = ''): string {
const widgetBodyStore = new Map();
+// 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();
+
function buildWidgetDoc(bodyContent: string): string {
return `
@@ -52,7 +63,8 @@ function buildWidgetDoc(bodyContent: string): string {