tfrere HF Staff Cursor commited on
Commit
27abbe5
·
1 Parent(s): 3afbbdf

feat(embed-studio): make agent-generated charts theme-aware

Browse files

The 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>

backend/src/agent/embed-chat.ts CHANGED
@@ -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");
backend/src/agent/embed-system-prompt.ts CHANGED
@@ -80,14 +80,63 @@ Every chart is a self-contained HTML fragment with:
80
  \`\`\`
81
 
82
  ### Colors & theming (MANDATORY)
83
- - Get colors from \`window.ColorPalettes\`:
84
- - \`ColorPalettes.getColors('categorical', 8)\` - 8-color categorical palette
85
- - \`ColorPalettes.getColors('sequential', 8)\` - sequential blues
86
- - \`ColorPalettes.getColors('diverging', 9)\` - red-blue diverging
87
- - \`ColorPalettes.getPrimary()\` - current primary accent color
88
- - Use CSS variables for theming: \`--primary-color\`, \`--text-color\`, \`--muted-color\`, \`--surface-bg\`, \`--border-color\`
89
- - Axis/tick/grid: \`--axis-color\`, \`--tick-color\`, \`--grid-color\`
90
- - NEVER hardcode color arrays
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}` : "";
frontend/src/components/EmbedStudio.tsx CHANGED
@@ -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
 
frontend/src/hooks/useEmbedChat.ts CHANGED
@@ -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