feat(embed-studio): make agent-generated charts theme-aware
Browse filesThe iframe already supports live light/dark swap via CSS variables and
a postMessage(setTheme) hot-swap, but the LLM kept generating charts
that hardcode colours into SVG attributes (fill="#333", stroke="black",
...). Those values can't be overridden by the parent's data-theme
attribute, so the chart looked correct on whatever theme the model
guessed and broken on the other one.
Two complementary fixes:
1. Forward the current theme (light/dark) from EmbedStudio through
useEmbedChat into the request context, and surface it in the
system prompt as a "Current theme" section. The model now knows
which background it's painting against and can self-correct.
2. Rewrite the "Colors & theming" section of the embed system prompt
with a concrete CSS pattern (scoped .d3-* rules driving fill/stroke
via var(--text-color), --axis-color, --grid-color, ...) and an
explicit ban on hardcoded SVG colour attributes. Add a hint about
window.__chartRerender for JS-driven palettes.
No public API change - the new context.theme field is opt-in and the
backend gracefully falls back to no theme block when it's missing.
Co-authored-by: Cursor <cursoragent@cursor.com>
|
@@ -3,6 +3,7 @@ import {
|
|
| 3 |
BANNER_SYSTEM_PROMPT,
|
| 4 |
buildEmbedContext,
|
| 5 |
buildDataFilesContext,
|
|
|
|
| 6 |
} from "./embed-system-prompt.js";
|
| 7 |
import { embedTools } from "./embed-tools.js";
|
| 8 |
import { streamChatResponse } from "./stream-handler.js";
|
|
@@ -14,6 +15,7 @@ export async function handleEmbedChat(req: Request, res: Response) {
|
|
| 14 |
const isBanner = Boolean(context?.isBanner);
|
| 15 |
const isScratch = Boolean(context?.isScratch);
|
| 16 |
const dataFilesBlock = buildDataFilesContext(context?.dataFiles);
|
|
|
|
| 17 |
|
| 18 |
const parts = [EMBED_SYSTEM_PROMPT];
|
| 19 |
if (isBanner) parts.push(BANNER_SYSTEM_PROMPT);
|
|
@@ -26,6 +28,7 @@ export async function handleEmbedChat(req: Request, res: Response) {
|
|
| 26 |
"underlying file and keep the document in sync.",
|
| 27 |
);
|
| 28 |
}
|
|
|
|
| 29 |
if (contextBlock) parts.push(`## Current chart\n\n${contextBlock}`);
|
| 30 |
if (dataFilesBlock) parts.push(`## Attached data\n\n${dataFilesBlock}`);
|
| 31 |
const systemPrompt = parts.join("\n\n");
|
|
|
|
| 3 |
BANNER_SYSTEM_PROMPT,
|
| 4 |
buildEmbedContext,
|
| 5 |
buildDataFilesContext,
|
| 6 |
+
buildThemeContext,
|
| 7 |
} from "./embed-system-prompt.js";
|
| 8 |
import { embedTools } from "./embed-tools.js";
|
| 9 |
import { streamChatResponse } from "./stream-handler.js";
|
|
|
|
| 15 |
const isBanner = Boolean(context?.isBanner);
|
| 16 |
const isScratch = Boolean(context?.isScratch);
|
| 17 |
const dataFilesBlock = buildDataFilesContext(context?.dataFiles);
|
| 18 |
+
const themeBlock = buildThemeContext(context?.theme);
|
| 19 |
|
| 20 |
const parts = [EMBED_SYSTEM_PROMPT];
|
| 21 |
if (isBanner) parts.push(BANNER_SYSTEM_PROMPT);
|
|
|
|
| 28 |
"underlying file and keep the document in sync.",
|
| 29 |
);
|
| 30 |
}
|
| 31 |
+
if (themeBlock) parts.push(themeBlock);
|
| 32 |
if (contextBlock) parts.push(`## Current chart\n\n${contextBlock}`);
|
| 33 |
if (dataFilesBlock) parts.push(`## Attached data\n\n${dataFilesBlock}`);
|
| 34 |
const systemPrompt = parts.join("\n\n");
|
|
@@ -80,14 +80,63 @@ Every chart is a self-contained HTML fragment with:
|
|
| 80 |
\`\`\`
|
| 81 |
|
| 82 |
### Colors & theming (MANDATORY)
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
### Layout rules
|
| 93 |
- SVG for chart primitives (marks, axes, gridlines) only
|
|
@@ -162,6 +211,31 @@ export function buildEmbedContext(embedHtml?: string): string {
|
|
| 162 |
return `<embed>\n${embedHtml}\n</embed>`;
|
| 163 |
}
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
function formatDataFileLine(file: DataFileMeta): string {
|
| 166 |
const sizeKb = (file.size / 1024).toFixed(1);
|
| 167 |
const rows = file.rowCount !== undefined ? ` rows=${file.rowCount}` : "";
|
|
|
|
| 80 |
\`\`\`
|
| 81 |
|
| 82 |
### Colors & theming (MANDATORY)
|
| 83 |
+
|
| 84 |
+
The host iframe ships with both **light** and **dark** themes, switched
|
| 85 |
+
live by the user via a \`data-theme\` attribute on \`<html>\`. Your chart
|
| 86 |
+
MUST adapt to both without re-rendering.
|
| 87 |
+
|
| 88 |
+
**Categorical / sequential / diverging palettes** - get from
|
| 89 |
+
\`window.ColorPalettes\`. The palettes are tuned to read on both light
|
| 90 |
+
and dark backgrounds:
|
| 91 |
+
- \`ColorPalettes.getColors('categorical', 8)\`
|
| 92 |
+
- \`ColorPalettes.getColors('sequential', 8)\`
|
| 93 |
+
- \`ColorPalettes.getColors('diverging', 9)\`
|
| 94 |
+
- \`ColorPalettes.getPrimary()\` - current primary accent color
|
| 95 |
+
|
| 96 |
+
**Text / axes / grid / surfaces** - drive them with CSS variables, NEVER
|
| 97 |
+
with SVG \`fill=\` / \`stroke=\` / inline JS color strings. SVG presentation
|
| 98 |
+
attributes don't parse \`var(--*)\`, so you MUST either:
|
| 99 |
+
|
| 100 |
+
1. **Set them in the scoped \`<style>\` block (preferred)** - the
|
| 101 |
+
browser hot-swaps them when the theme flips, no JS needed:
|
| 102 |
+
|
| 103 |
+
\`\`\`css
|
| 104 |
+
.d3-yourname { color: var(--text-color); }
|
| 105 |
+
.d3-yourname .axis path,
|
| 106 |
+
.d3-yourname .axis line { stroke: var(--axis-color); }
|
| 107 |
+
.d3-yourname .axis text { fill: var(--tick-color); }
|
| 108 |
+
.d3-yourname .grid line { stroke: var(--grid-color); }
|
| 109 |
+
.d3-yourname .tooltip { background: var(--surface-bg); color: var(--text-color); border: 1px solid var(--border-color); }
|
| 110 |
+
\`\`\`
|
| 111 |
+
|
| 112 |
+
2. **Use \`currentColor\`** on SVG elements that should follow the
|
| 113 |
+
container's CSS \`color\`:
|
| 114 |
+
|
| 115 |
+
\`\`\`js
|
| 116 |
+
svg.append('text').attr('fill', 'currentColor')...
|
| 117 |
+
\`\`\`
|
| 118 |
+
|
| 119 |
+
Available CSS variables (light + dark values are pre-defined):
|
| 120 |
+
\`--text-color\`, \`--muted-color\`, \`--surface-bg\`, \`--page-bg\`,
|
| 121 |
+
\`--border-color\`, \`--axis-color\`, \`--tick-color\`, \`--grid-color\`,
|
| 122 |
+
\`--primary-color\`.
|
| 123 |
+
|
| 124 |
+
**Forbidden patterns** (they freeze the chart on one theme):
|
| 125 |
+
- \`fill="#333"\`, \`stroke="black"\`, \`d3.color('white')\`, \`'rgba(0,0,0,.5)'\`
|
| 126 |
+
for axes / text / grid / tooltips.
|
| 127 |
+
- Reading \`getComputedStyle(...)\` once at mount and baking the value
|
| 128 |
+
into attributes. CSS handles it; don't reinvent it.
|
| 129 |
+
- Conditional palettes based on \`document.documentElement.dataset.theme\`
|
| 130 |
+
for axes/text - just use the CSS vars above.
|
| 131 |
+
|
| 132 |
+
**Live theme swap**: if your chart paints categorical / sequential
|
| 133 |
+
colours via JS (which is fine), define \`window.__chartRerender =
|
| 134 |
+
function(){ /* re-run draw() */ }\` so the host can re-tint when the
|
| 135 |
+
user flips the theme. CSS-driven properties (text, axes, grid) update
|
| 136 |
+
automatically and don't need this.
|
| 137 |
+
|
| 138 |
+
NEVER hardcode categorical palettes - always go through
|
| 139 |
+
\`window.ColorPalettes\`.
|
| 140 |
|
| 141 |
### Layout rules
|
| 142 |
- SVG for chart primitives (marks, axes, gridlines) only
|
|
|
|
| 211 |
return `<embed>\n${embedHtml}\n</embed>`;
|
| 212 |
}
|
| 213 |
|
| 214 |
+
/**
|
| 215 |
+
* Tell the agent which theme the user is currently looking at, so it
|
| 216 |
+
* can sanity-check its colour choices in context (e.g. "is this axis
|
| 217 |
+
* label readable on the current background?") and know which variant
|
| 218 |
+
* to debug visually. The CSS variables already cover both themes, but
|
| 219 |
+
* this also surfaces accidental hardcoded colours early - the agent
|
| 220 |
+
* will see e.g. "current theme: dark" and notice if it just wrote
|
| 221 |
+
* `fill="#000"`.
|
| 222 |
+
*/
|
| 223 |
+
export function buildThemeContext(theme?: string): string {
|
| 224 |
+
const t = theme === "dark" ? "dark" : theme === "light" ? "light" : null;
|
| 225 |
+
if (!t) return "";
|
| 226 |
+
return [
|
| 227 |
+
"## Current theme",
|
| 228 |
+
"",
|
| 229 |
+
`The user is currently viewing the article in **${t}** mode. The`,
|
| 230 |
+
"iframe automatically swaps CSS variables when the theme flips, so",
|
| 231 |
+
"any chart that drives axes/text/grid via the `--*` variables (see",
|
| 232 |
+
"the Colors & theming section) will adapt without any extra work.",
|
| 233 |
+
"Use this as a sanity check: if you ever feel tempted to hardcode a",
|
| 234 |
+
`colour, ask yourself "would this still be readable in ${t === "dark" ? "light" : "dark"} mode?" - the`,
|
| 235 |
+
"answer is almost always no.",
|
| 236 |
+
].join("\n");
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
function formatDataFileLine(file: DataFileMeta): string {
|
| 240 |
const sizeKb = (file.size / 1024).toFixed(1);
|
| 241 |
const rows = file.rowCount !== undefined ? ` rows=${file.rowCount}` : "";
|
|
@@ -162,7 +162,7 @@ export function EmbedStudio({
|
|
| 162 |
activeRef.current = activeFrame;
|
| 163 |
|
| 164 |
const data = useEmbedData({ dataStore, userId });
|
| 165 |
-
const chat = useEmbedChat({ embedStore, dataStore, src, modelRef, userId, onRename });
|
| 166 |
|
| 167 |
const selectedFile = selectedDataFile ? data.getFile(selectedDataFile) : undefined;
|
| 168 |
|
|
|
|
| 162 |
activeRef.current = activeFrame;
|
| 163 |
|
| 164 |
const data = useEmbedData({ dataStore, userId });
|
| 165 |
+
const chat = useEmbedChat({ embedStore, dataStore, src, modelRef, userId, isDark, onRename });
|
| 166 |
|
| 167 |
const selectedFile = selectedDataFile ? data.getFile(selectedDataFile) : undefined;
|
| 168 |
|
|
@@ -16,6 +16,13 @@ interface UseEmbedChatOptions {
|
|
| 16 |
src: string;
|
| 17 |
modelRef: React.RefObject<string>;
|
| 18 |
userId: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
/**
|
| 20 |
* Called when the agent picks a descriptive filename via createEmbed.
|
| 21 |
* Parent is expected to update the htmlEmbed node's src attribute in
|
|
@@ -73,7 +80,7 @@ function scopeKey(src: string): string {
|
|
| 73 |
return `embed:${src}`;
|
| 74 |
}
|
| 75 |
|
| 76 |
-
export function useEmbedChat({ embedStore, dataStore, src, modelRef, userId, onRename }: UseEmbedChatOptions) {
|
| 77 |
const [input, setInput] = useState("");
|
| 78 |
// srcRef is the source of truth for the active embed filename. It
|
| 79 |
// starts from the `src` prop but may change mid-session when the
|
|
@@ -98,6 +105,11 @@ export function useEmbedChat({ embedStore, dataStore, src, modelRef, userId, onR
|
|
| 98 |
// restored messages end with completed tool calls.
|
| 99 |
const userHasSentRef = useRef(false);
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
const getEmbedContext = useCallback(() => {
|
| 102 |
if (!embedStore || !srcRef.current) return {};
|
| 103 |
const currentSrc = srcRef.current;
|
|
@@ -123,6 +135,7 @@ export function useEmbedChat({ embedStore, dataStore, src, modelRef, userId, onR
|
|
| 123 |
isBanner,
|
| 124 |
isScratch,
|
| 125 |
dataFiles,
|
|
|
|
| 126 |
};
|
| 127 |
}, [embedStore, dataStore]);
|
| 128 |
|
|
|
|
| 16 |
src: string;
|
| 17 |
modelRef: React.RefObject<string>;
|
| 18 |
userId: string;
|
| 19 |
+
/**
|
| 20 |
+
* Current article theme (light/dark). Forwarded to the agent so it
|
| 21 |
+
* can sanity-check its colour choices and bake theme-aware CSS into
|
| 22 |
+
* generated charts (instead of hardcoding axis/text colours that
|
| 23 |
+
* only happen to look right under one theme).
|
| 24 |
+
*/
|
| 25 |
+
isDark?: boolean;
|
| 26 |
/**
|
| 27 |
* Called when the agent picks a descriptive filename via createEmbed.
|
| 28 |
* Parent is expected to update the htmlEmbed node's src attribute in
|
|
|
|
| 80 |
return `embed:${src}`;
|
| 81 |
}
|
| 82 |
|
| 83 |
+
export function useEmbedChat({ embedStore, dataStore, src, modelRef, userId, isDark, onRename }: UseEmbedChatOptions) {
|
| 84 |
const [input, setInput] = useState("");
|
| 85 |
// srcRef is the source of truth for the active embed filename. It
|
| 86 |
// starts from the `src` prop but may change mid-session when the
|
|
|
|
| 105 |
// restored messages end with completed tool calls.
|
| 106 |
const userHasSentRef = useRef(false);
|
| 107 |
|
| 108 |
+
// Track the current theme via ref so getEmbedContext always reads the
|
| 109 |
+
// latest value without forcing a callback identity churn on toggle.
|
| 110 |
+
const isDarkRef = useRef(!!isDark);
|
| 111 |
+
isDarkRef.current = !!isDark;
|
| 112 |
+
|
| 113 |
const getEmbedContext = useCallback(() => {
|
| 114 |
if (!embedStore || !srcRef.current) return {};
|
| 115 |
const currentSrc = srcRef.current;
|
|
|
|
| 135 |
isBanner,
|
| 136 |
isScratch,
|
| 137 |
dataFiles,
|
| 138 |
+
theme: isDarkRef.current ? "dark" : "light",
|
| 139 |
};
|
| 140 |
}, [embedStore, dataStore]);
|
| 141 |
|