diff --git a/packages/layout_editor/README.md b/packages/layout_editor/README.md
index 5712947860..dbb8b8ed62 100644
--- a/packages/layout_editor/README.md
+++ b/packages/layout_editor/README.md
@@ -46,6 +46,53 @@ To apply your custom theme:
Alternatively, you can paste the theme object into the Theme settings of the EmbeddedChat RC App. Note: These settings will only take effect if the `remoteOpt` prop is set to `true` when configuring EmbeddedChat.
+### AI Theme Generator integration
+
+The Layout Editor can generate a complete, accessible EmbeddedChat theme from a developer's description. It talks directly to Ollama running on the developer's machine—no API key or separate proxy service is required.
+
+Install Ollama, start a local model, then open the AI Theme Generator:
+
+```bash
+ollama run gemma4
+```
+
+The default URL is `http://localhost:11434` and the default model is `gemma4`; both can be changed directly in the panel or configured at build time:
+
+```bash
+VITE_OLLAMA_BASE_URL=http://localhost:11434
+VITE_OLLAMA_MODEL=gemma4
+```
+
+The adapter selector also supports OpenAI and Google Gemini. These providers
+use the shared `@embeddedchat/ai-adapter` package; enter a development API key,
+model, and optional compatible base URL in the panel. Keys are kept in memory
+only and are never stored by the Layout Editor.
+
+If the editor is served from `http://localhost:5173` and the browser reports a
+CORS error, start Ollama once with that origin allowed:
+
+```bash
+OLLAMA_ORIGINS=http://localhost:5173 ollama serve
+```
+
+Use the actual Layout Editor origin if it differs. Ollama permits local API
+access without authentication, and can be configured to allow additional web
+origins when needed.
+
+Ollama is asked for a narrow JSON schema:
+
+```json
+{
+ "primaryHex": "#f59e0b",
+ "accentHex": "#8b5cf6",
+ "radius": "0.5rem",
+ "fontFamily": "Arial, Helvetica, sans-serif",
+ "mode": "dark"
+}
+```
+
+The generator only allows local Ollama URLs (`localhost`, `127.0.0.1`, or `::1`). Follow-up prompts include prior instructions, but patch only the explicitly requested tokens—refining corner radius or typography cannot regenerate the palette. Select **Deterministic fallback** in the adapter selector to use the offline parser instead. The browser validates the response, checks text contrast, and presents a draft before applying or exporting the final JSON.
+
### Development
Clone the repo, navigate to `packages/layout_editor`, then run:
diff --git a/packages/layout_editor/package.json b/packages/layout_editor/package.json
index 5cc6d09171..a568719023 100644
--- a/packages/layout_editor/package.json
+++ b/packages/layout_editor/package.json
@@ -7,11 +7,13 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
+ "test": "node --test src/lib/*.test.js",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
+ "@embeddedchat/ai-adapter": "workspace:*",
"@embeddedchat/markups": "workspace:^",
"@embeddedchat/ui-elements": "workspace:^",
"react": "^19.0.0",
diff --git a/packages/layout_editor/src/components/SortableMenu/Menu.jsx b/packages/layout_editor/src/components/SortableMenu/Menu.jsx
index 7181ff4f0e..803a7cde31 100644
--- a/packages/layout_editor/src/components/SortableMenu/Menu.jsx
+++ b/packages/layout_editor/src/components/SortableMenu/Menu.jsx
@@ -42,6 +42,7 @@ const Menu = ({
icon="kebab"
size={size}
onClick={handleMenuVisibility}
+ style={{ color: theme.theme.colors.foreground }}
/>
diff --git a/packages/layout_editor/src/components/SurfaceMenu/SurfaceItem.jsx b/packages/layout_editor/src/components/SurfaceMenu/SurfaceItem.jsx
index caeb77ec37..8bab42bfec 100644
--- a/packages/layout_editor/src/components/SurfaceMenu/SurfaceItem.jsx
+++ b/packages/layout_editor/src/components/SurfaceMenu/SurfaceItem.jsx
@@ -30,8 +30,12 @@ const SurfaceItem = ({
label,
},
});
- const theme = useTheme();
- const styles = getSurfaceItemStyles(theme);
+ const themeContext = useTheme();
+ const styles = getSurfaceItemStyles(themeContext);
+ const iconColor =
+ type === 'destructive'
+ ? themeContext.theme.colors.destructive
+ : themeContext.theme.colors.foreground;
const style = {
transform: CSS.Transform.toString(transform),
@@ -59,6 +63,7 @@ const SurfaceItem = ({
color={type}
style={{
cursor: cursor,
+ color: iconColor,
}}
/>
diff --git a/packages/layout_editor/src/lib/generateThemeFromColor.js b/packages/layout_editor/src/lib/generateThemeFromColor.js
new file mode 100644
index 0000000000..53b1cb1994
--- /dev/null
+++ b/packages/layout_editor/src/lib/generateThemeFromColor.js
@@ -0,0 +1,377 @@
+const MINIMUM_TEXT_CONTRAST = 4.5;
+
+export const FONT_STACKS = {
+ sans: 'Arial, Helvetica, sans-serif',
+ serif: 'Georgia, "Times New Roman", serif',
+ mono: '"Courier New", Courier, monospace',
+};
+
+export const THEME_RADII = ['0rem', '0.25rem', '0.5rem', '1.5rem'];
+
+const clamp = (value, minimum, maximum) =>
+ Math.min(Math.max(value, minimum), maximum);
+
+const hsl = (hue, saturation, lightness) =>
+ `hsl(${Math.round(hue)}, ${Math.round(saturation)}%, ${Math.round(
+ lightness
+ )}%)`;
+
+export const normalizeHex = (value) => {
+ if (typeof value !== 'string') return null;
+ const match = value.trim().match(/^#?([a-f\d]{3}|[a-f\d]{6})$/i);
+ if (!match) return null;
+ const hex = match[1].length === 3
+ ? match[1].split('').map((character) => character + character).join('')
+ : match[1];
+ return `#${hex.toLowerCase()}`;
+};
+
+export const hexToHsl = (value) => {
+ const hex = normalizeHex(value);
+ if (!hex) return null;
+
+ const channels = [1, 3, 5].map((offset) =>
+ Number.parseInt(hex.slice(offset, offset + 2), 16) / 255
+ );
+ const [red, green, blue] = channels;
+ const maximum = Math.max(...channels);
+ const minimum = Math.min(...channels);
+ const delta = maximum - minimum;
+ const lightness = (maximum + minimum) / 2;
+
+ let hue = 0;
+ if (delta) {
+ if (maximum === red) hue = ((green - blue) / delta) % 6;
+ if (maximum === green) hue = (blue - red) / delta + 2;
+ if (maximum === blue) hue = (red - green) / delta + 4;
+ hue *= 60;
+ if (hue < 0) hue += 360;
+ }
+
+ const saturation = delta
+ ? delta / (1 - Math.abs(2 * lightness - 1))
+ : 0;
+ return { hue, saturation: saturation * 100, lightness: lightness * 100 };
+};
+
+const hslToRgb = ({ hue, saturation, lightness }) => {
+ const normalizedSaturation = saturation / 100;
+ const normalizedLightness = lightness / 100;
+ const chroma =
+ (1 - Math.abs(2 * normalizedLightness - 1)) * normalizedSaturation;
+ const second = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
+ const match = normalizedLightness - chroma / 2;
+ let channels = [0, 0, 0];
+
+ if (hue < 60) channels = [chroma, second, 0];
+ else if (hue < 120) channels = [second, chroma, 0];
+ else if (hue < 180) channels = [0, chroma, second];
+ else if (hue < 240) channels = [0, second, chroma];
+ else if (hue < 300) channels = [second, 0, chroma];
+ else channels = [chroma, 0, second];
+
+ return channels.map((channel) => channel + match);
+};
+
+const parseHsl = (value) => {
+ const match = typeof value === 'string'
+ ? value.match(/^hsl\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)$/i)
+ : null;
+ if (!match) return null;
+ return {
+ hue: Number(match[1]) % 360,
+ saturation: Number(match[2]),
+ lightness: Number(match[3]),
+ };
+};
+
+const toRgb = (value) => {
+ const hslValue = parseHsl(value);
+ if (hslValue) return hslToRgb(hslValue);
+ const hex = normalizeHex(value);
+ if (!hex) return null;
+ return [1, 3, 5].map(
+ (offset) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255
+ );
+};
+
+export const colorToHex = (value) => {
+ const rgb = toRgb(value);
+ if (!rgb) return null;
+ return `#${rgb
+ .map((channel) =>
+ Math.round(clamp(channel, 0, 1) * 255).toString(16).padStart(2, '0')
+ )
+ .join('')}`;
+};
+
+const relativeLuminance = (value) => {
+ const rgb = toRgb(value);
+ if (!rgb) return 0;
+ const [red, green, blue] = rgb.map((channel) =>
+ channel <= 0.03928
+ ? channel / 12.92
+ : ((channel + 0.055) / 1.055) ** 2.4
+ );
+ return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
+};
+
+export const contrastRatio = (first, second) => {
+ const firstLuminance = relativeLuminance(first);
+ const secondLuminance = relativeLuminance(second);
+ return (
+ (Math.max(firstLuminance, secondLuminance) + 0.05) /
+ (Math.min(firstLuminance, secondLuminance) + 0.05)
+ );
+};
+
+const contrastingText = (background) => {
+ const white = 'hsl(0, 0%, 100%)';
+ // True black is necessary here: a softened dark gray can leave highly
+ // saturated mid-tone colors below the 4.5:1 AA threshold against both
+ // foreground options.
+ const black = 'hsl(0, 0%, 0%)';
+ return contrastRatio(background, white) >= contrastRatio(background, black)
+ ? white
+ : black;
+};
+
+const TOKEN_FOREGROUNDS = {
+ background: 'foreground',
+ card: 'cardForeground',
+ popover: 'popoverForeground',
+ primary: 'primaryForeground',
+ secondary: 'secondaryForeground',
+ muted: 'mutedForeground',
+ accent: 'accentForeground',
+ destructive: 'destructiveForeground',
+ warning: 'warningForeground',
+ success: 'successForeground',
+ info: 'infoForeground',
+};
+
+const foregroundColor = (color, background, direction) => {
+ let lightness = color.lightness;
+ for (let step = 0; step <= 100; step += 1) {
+ const candidate = hsl(color.hue, color.saturation, lightness);
+ if (contrastRatio(candidate, background) >= MINIMUM_TEXT_CONTRAST) {
+ return candidate;
+ }
+ lightness = clamp(lightness + direction, 0, 100);
+ }
+ return hsl(color.hue, color.saturation, lightness);
+};
+
+const semanticScheme = (seed, accent, mode) => {
+ const primarySaturation = clamp(Math.max(seed.saturation, 70), 70, 92);
+ const accentSaturation = clamp(Math.max(accent.saturation, 62), 62, 88);
+ const isLight = mode === 'light';
+ const background = hsl(seed.hue, Math.min(seed.saturation, 14), isLight ? 99 : 8);
+ const foreground = hsl(seed.hue, Math.min(seed.saturation, 18), isLight ? 10 : 94);
+ const card = hsl(seed.hue, Math.min(seed.saturation, 12), isLight ? 100 : 12);
+ const primary = foregroundColor(
+ {
+ hue: seed.hue,
+ saturation: primarySaturation,
+ lightness: isLight ? 42 : 66,
+ },
+ background,
+ isLight ? -1 : 1
+ );
+ const secondary = hsl(seed.hue, Math.min(seed.saturation, 30), isLight ? 92 : 21);
+ const accentColor = hsl(accent.hue, accentSaturation, isLight ? 44 : 67);
+ const muted = hsl(seed.hue, Math.min(seed.saturation, 14), isLight ? 95 : 17);
+ const destructive = hsl(0, isLight ? 72 : 68, isLight ? 43 : 64);
+ const warning = hsl(38, 92, isLight ? 40 : 66);
+ const success = hsl(142, 62, isLight ? 32 : 58);
+ const info = hsl(214, 78, isLight ? 42 : 67);
+
+ return {
+ background,
+ foreground,
+ card,
+ cardForeground: contrastingText(card),
+ popover: card,
+ popoverForeground: contrastingText(card),
+ primary,
+ primaryForeground: contrastingText(primary),
+ secondary,
+ secondaryForeground: contrastingText(secondary),
+ muted,
+ mutedForeground: isLight
+ ? hsl(seed.hue, Math.min(seed.saturation, 18), 34)
+ : hsl(seed.hue, Math.min(seed.saturation, 18), 68),
+ accent: accentColor,
+ accentForeground: contrastingText(accentColor),
+ destructive,
+ destructiveForeground: contrastingText(destructive),
+ border: hsl(seed.hue, Math.min(seed.saturation, 18), isLight ? 84 : 27),
+ input: hsl(seed.hue, Math.min(seed.saturation, 18), isLight ? 84 : 27),
+ ring: primary,
+ warning,
+ warningForeground: contrastingText(warning),
+ success,
+ successForeground: contrastingText(success),
+ info,
+ infoForeground: contrastingText(info),
+ };
+};
+
+export const getAccessibilityReport = (theme) => {
+ const pairs = [
+ ['Background text', 'background', 'foreground'],
+ ['Card text', 'card', 'cardForeground'],
+ ['Primary action', 'primary', 'primaryForeground'],
+ ['Secondary action', 'secondary', 'secondaryForeground'],
+ ['Accent action', 'accent', 'accentForeground'],
+ ['Destructive action', 'destructive', 'destructiveForeground'],
+ ['Username link', 'background', 'primary'],
+ ['Message metadata', 'background', 'mutedForeground'],
+ ];
+
+ return ['light', 'dark'].flatMap((mode) =>
+ pairs.map(([label, background, foreground]) => {
+ const ratio = contrastRatio(
+ theme.schemes[mode][background],
+ theme.schemes[mode][foreground]
+ );
+ return {
+ label: `${mode === 'light' ? 'Light' : 'Dark'} ${label}`,
+ ratio: Number(ratio.toFixed(2)),
+ passes: ratio >= MINIMUM_TEXT_CONTRAST,
+ };
+ })
+ );
+};
+
+export const isValidTheme = (theme) => {
+ if (!theme?.schemes?.light || !theme?.schemes?.dark) return false;
+ return getAccessibilityReport(theme).every((result) => result.passes);
+};
+
+/**
+ * Updates one semantic token in one color mode. Background/action tokens
+ * repair their paired text token automatically, so manual palette editing
+ * keeps the generated theme accessible rather than bypassing validation.
+ */
+export const applyThemePaletteChange = (baseTheme, { mode, token, value }) => {
+ const color = normalizeHex(value);
+ if (!color || !['light', 'dark'].includes(mode) || !baseTheme?.schemes?.[mode]) {
+ return null;
+ }
+
+ const scheme = baseTheme.schemes[mode];
+ if (!(token in scheme)) return null;
+ const nextScheme = { ...scheme, [token]: color };
+ const pairedForeground = TOKEN_FOREGROUNDS[token];
+
+ if (pairedForeground) {
+ nextScheme[pairedForeground] = contrastingText(color);
+ }
+
+ if (token === 'primary') {
+ const primary = foregroundColor(
+ hexToHsl(color),
+ nextScheme.background,
+ mode === 'light' ? -1 : 1
+ );
+ nextScheme.primary = primary;
+ nextScheme.primaryForeground = contrastingText(primary);
+ nextScheme.ring = primary;
+ }
+
+ return {
+ ...baseTheme,
+ schemes: { ...baseTheme.schemes, [mode]: nextScheme },
+ };
+};
+
+/**
+ * Applies an intentional refinement without regenerating the entire palette.
+ * This is deliberately separate from initial theme generation: a request such
+ * as "make the corners rounder" must never alter the existing color tokens.
+ */
+export const applyThemeRefinement = (baseTheme, options = {}) => {
+ if (!baseTheme) return null;
+ const primary = normalizeHex(options.primaryHex);
+ const accent = normalizeHex(options.accentHex);
+ const radius = THEME_RADII.includes(options.radius)
+ ? options.radius
+ : baseTheme.radius;
+ const fontFamily = Object.values(FONT_STACKS).includes(options.fontFamily)
+ ? options.fontFamily
+ : baseTheme.typography?.default?.fontFamily;
+
+ const refineScheme = (scheme, mode) => {
+ const nextScheme = { ...scheme };
+ if (primary) {
+ const primaryColor = foregroundColor(
+ hexToHsl(primary),
+ scheme.background,
+ mode === 'light' ? -1 : 1
+ );
+ nextScheme.primary = primaryColor;
+ nextScheme.primaryForeground = contrastingText(primaryColor);
+ nextScheme.ring = primaryColor;
+ }
+ if (accent) {
+ nextScheme.accent = accent;
+ nextScheme.accentForeground = contrastingText(accent);
+ }
+ return nextScheme;
+ };
+
+ return {
+ ...baseTheme,
+ radius,
+ typography: fontFamily
+ ? {
+ ...baseTheme.typography,
+ default: { ...baseTheme.typography?.default, fontFamily },
+ }
+ : baseTheme.typography,
+ schemes: {
+ ...baseTheme.schemes,
+ light: refineScheme(baseTheme.schemes.light, 'light'),
+ dark: refineScheme(baseTheme.schemes.dark, 'dark'),
+ },
+ };
+};
+
+/**
+ * Creates a complete EmbeddedChat theme from two validated brand colors.
+ * Generated foreground tokens are selected by computed WCAG contrast, not by
+ * a model, so a provider cannot introduce inaccessible or arbitrary CSS values.
+ */
+const generateThemeFromColor = (primaryHex, baseTheme, options = {}) => {
+ const primary = hexToHsl(primaryHex);
+ if (!primary || !baseTheme) return null;
+
+ const accent = hexToHsl(options.accentHex) ?? {
+ ...primary,
+ hue: (primary.hue + 30) % 360,
+ };
+ const radius = THEME_RADII.includes(options.radius)
+ ? options.radius
+ : baseTheme.radius;
+ const fontFamily = Object.values(FONT_STACKS).includes(options.fontFamily)
+ ? options.fontFamily
+ : baseTheme.typography?.default?.fontFamily;
+
+ return {
+ ...baseTheme,
+ radius,
+ typography: fontFamily
+ ? {
+ ...baseTheme.typography,
+ default: { ...baseTheme.typography?.default, fontFamily },
+ }
+ : baseTheme.typography,
+ schemes: {
+ light: semanticScheme(primary, accent, 'light'),
+ dark: semanticScheme(primary, accent, 'dark'),
+ },
+ };
+};
+
+export default generateThemeFromColor;
diff --git a/packages/layout_editor/src/lib/generateThemeFromColor.test.js b/packages/layout_editor/src/lib/generateThemeFromColor.test.js
new file mode 100644
index 0000000000..5c3ba44be1
--- /dev/null
+++ b/packages/layout_editor/src/lib/generateThemeFromColor.test.js
@@ -0,0 +1,245 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import DefaultTheme from '../theme/DefaultTheme.js';
+import generateThemeFromColor, {
+ applyThemeRefinement,
+ applyThemePaletteChange,
+ colorToHex,
+ getAccessibilityReport,
+ isValidTheme,
+ normalizeHex,
+} from './generateThemeFromColor.js';
+import {
+ applyExplicitInitialIntent,
+ createLocalSuggestion,
+ limitRefinementSuggestion,
+ requestThemeSuggestion,
+ validateThemeSuggestion,
+} from './themeGenerationService.js';
+
+test('normalizes accepted three and six digit hex colors', () => {
+ assert.equal(normalizeHex('abc'), '#aabbcc');
+ assert.equal(normalizeHex('#0F10aa'), '#0f10aa');
+ assert.equal(normalizeHex('blue'), null);
+});
+
+test('converts generated HSL tokens to color input values', () => {
+ assert.equal(colorToHex('hsl(0, 0%, 100%)'), '#ffffff');
+ assert.equal(colorToHex('hsl(0, 0%, 0%)'), '#000000');
+ assert.equal(colorToHex('not-a-color'), null);
+});
+
+test('generates complete accessible light and dark EmbeddedChat schemes', () => {
+ const theme = generateThemeFromColor('#2563eb', DefaultTheme, {
+ accentHex: '#f97316',
+ radius: '0.5rem',
+ });
+ const report = getAccessibilityReport(theme);
+
+ assert.equal(theme.radius, '0.5rem');
+ assert.ok(theme.schemes.light.primary);
+ assert.ok(theme.schemes.dark.primary);
+ assert.equal(report.length, 16);
+ assert.ok(report.every((result) => result.passes));
+});
+
+test('keeps saturated dark-mode primary actions above the AA contrast threshold', () => {
+ const theme = generateThemeFromColor('#000f94', DefaultTheme);
+ const darkPrimary = getAccessibilityReport(theme).find(
+ (result) => result.label === 'Dark Primary action'
+ );
+
+ assert.ok(darkPrimary.passes);
+ assert.ok(darkPrimary.ratio >= 4.5);
+});
+
+test('refinement only changes requested theme tokens', () => {
+ const original = generateThemeFromColor('#2563eb', DefaultTheme, {
+ accentHex: '#f97316',
+ });
+ const refined = applyThemeRefinement(original, { radius: '1.5rem' });
+
+ assert.equal(refined.radius, '1.5rem');
+ assert.deepEqual(refined.schemes, original.schemes);
+ assert.deepEqual(refined.typography, original.typography);
+});
+
+test('color refinement does not replace unrelated semantic tokens', () => {
+ const original = generateThemeFromColor('#2563eb', DefaultTheme, {
+ accentHex: '#f97316',
+ });
+ const refined = applyThemeRefinement(original, { primaryHex: '#dc2626' });
+
+ assert.notEqual(refined.schemes.light.primary, original.schemes.light.primary);
+ assert.notEqual(refined.schemes.dark.primary, original.schemes.dark.primary);
+ assert.equal(refined.schemes.light.accent, original.schemes.light.accent);
+ assert.equal(refined.schemes.dark.background, original.schemes.dark.background);
+ assert.ok(getAccessibilityReport(refined).every((result) => result.passes));
+});
+
+test('palette changes only update the selected mode and repair paired text', () => {
+ const original = generateThemeFromColor('#2563eb', DefaultTheme);
+ const refined = applyThemePaletteChange(original, {
+ mode: 'dark',
+ token: 'accent',
+ value: '#ffffff',
+ });
+
+ assert.equal(refined.schemes.dark.accent, '#ffffff');
+ assert.equal(refined.schemes.dark.accentForeground, 'hsl(0, 0%, 0%)');
+ assert.equal(refined.schemes.light.accent, original.schemes.light.accent);
+ assert.ok(isValidTheme(refined));
+});
+
+test('generated usernames and timestamps are readable on both chat backgrounds', () => {
+ const theme = generateThemeFromColor('#0003e5', DefaultTheme);
+ const metadataChecks = getAccessibilityReport(theme).filter((result) =>
+ /Username link|Message metadata/.test(result.label)
+ );
+
+ assert.equal(metadataChecks.length, 4);
+ assert.ok(metadataChecks.every((result) => result.passes));
+});
+
+test('only allows a narrow provider response contract', () => {
+ assert.deepEqual(
+ validateThemeSuggestion({
+ primaryHex: '#123456',
+ accentHex: '#abcdef',
+ radius: '0.25rem',
+ mode: 'dark',
+ fontFamily: 'Arial, Helvetica, sans-serif',
+ messageView: undefined,
+ displayName: undefined,
+ arbitraryCss: 'url(javascript:alert(1))',
+ }),
+ {
+ primaryHex: '#123456',
+ accentHex: '#abcdef',
+ radius: '0.25rem',
+ mode: 'dark',
+ fontFamily: 'Arial, Helvetica, sans-serif',
+ messageView: undefined,
+ displayName: undefined,
+ }
+ );
+ assert.throws(() => validateThemeSuggestion({ primaryHex: 'invalid' }));
+});
+
+test('local fallback extracts developer-provided theme intent without network access', () => {
+ assert.deepEqual(
+ createLocalSuggestion('A dark purple theme with amber accent and rounded corners'),
+ {
+ primaryHex: '#a855f7',
+ accentHex: '#f59e0b',
+ radius: '0.5rem',
+ fontFamily: undefined,
+ mode: 'dark',
+ messageView: undefined,
+ displayName: undefined,
+ }
+ );
+});
+
+test('initial prompts enforce explicit visual intent over an adapter response', () => {
+ assert.deepEqual(
+ applyExplicitInitialIntent(
+ {
+ primaryHex: '#ef4444',
+ accentHex: '#22c55e',
+ radius: '0rem',
+ fontFamily: 'Georgia, "Times New Roman", serif',
+ mode: 'light',
+ },
+ 'violet theme with light blue accents, dark theme, pill shaped corners, professional font'
+ ),
+ {
+ primaryHex: '#8b5cf6',
+ accentHex: '#3b82f6',
+ radius: '1.5rem',
+ fontFamily: 'Arial, Helvetica, sans-serif',
+ mode: 'dark',
+ }
+ );
+});
+
+test('local follow-ups only return tokens that the developer asked to change', () => {
+ assert.deepEqual(
+ createLocalSuggestion('make it pill-shaped and light', {
+ preserveExisting: true,
+ }),
+ {
+ primaryHex: undefined,
+ accentHex: undefined,
+ radius: '1.5rem',
+ fontFamily: undefined,
+ mode: 'light',
+ messageView: undefined,
+ displayName: undefined,
+ }
+ );
+});
+
+test('initial prompts apply explicit message view intent', () => {
+ assert.equal(
+ applyExplicitInitialIntent(
+ { messageView: 'flat' },
+ 'cool violet with blue accent, square shaped corners, dark theme, mono font, bubble type message view'
+ ).messageView,
+ 'bubble'
+ );
+});
+
+test('model follow-ups cannot overwrite token types that were not requested', () => {
+ assert.deepEqual(
+ limitRefinementSuggestion(
+ {
+ primaryHex: '#ef4444',
+ accentHex: '#22c55e',
+ radius: '1.5rem',
+ fontFamily: 'Georgia, "Times New Roman", serif',
+ mode: 'light',
+ },
+ 'Keep the palette and make the corners pill-shaped'
+ ),
+ { radius: '1.5rem' }
+ );
+});
+
+test('an explicit light or dark mode refinement overrides an adapter response', () => {
+ assert.deepEqual(
+ limitRefinementSuggestion(
+ { mode: 'dark', primaryHex: '#ef4444' },
+ 'Switch to light theme'
+ ),
+ { mode: 'light' }
+ );
+ assert.deepEqual(
+ limitRefinementSuggestion(
+ { mode: 'light', accentHex: '#22c55e' },
+ 'Switch to dark theme'
+ ),
+ { mode: 'dark' }
+ );
+});
+
+test('explicit corner and font refinements override an adapter response', () => {
+ assert.deepEqual(
+ limitRefinementSuggestion(
+ { radius: '0rem', fontFamily: 'Arial, Helvetica, sans-serif' },
+ 'Make the corners pill-shaped and use a serif font'
+ ),
+ { radius: '1.5rem', fontFamily: 'Georgia, "Times New Roman", serif' }
+ );
+});
+
+test('does not allow direct Ollama connections to non-local hosts', async () => {
+ await assert.rejects(
+ requestThemeSuggestion({
+ description: 'blue theme',
+ baseUrl: 'http://theme.example.com',
+ model: 'gemma4',
+ }),
+ /restricted to your local machine/
+ );
+});
diff --git a/packages/layout_editor/src/lib/themeExport.js b/packages/layout_editor/src/lib/themeExport.js
new file mode 100644
index 0000000000..1fb81dc936
--- /dev/null
+++ b/packages/layout_editor/src/lib/themeExport.js
@@ -0,0 +1,18 @@
+const serializeTheme = (theme) => JSON.stringify(theme, null, 2);
+
+export const copyThemeToClipboard = async (theme) => {
+ if (!navigator.clipboard?.writeText) {
+ throw new Error('Clipboard access is unavailable in this browser.');
+ }
+ await navigator.clipboard.writeText(serializeTheme(theme));
+};
+
+export const downloadTheme = (theme, fileName = 'embeddedchat-theme.json') => {
+ const blob = new Blob([serializeTheme(theme)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = fileName;
+ anchor.click();
+ URL.revokeObjectURL(url);
+};
diff --git a/packages/layout_editor/src/lib/themeGenerationService.js b/packages/layout_editor/src/lib/themeGenerationService.js
new file mode 100644
index 0000000000..970a1e97d0
--- /dev/null
+++ b/packages/layout_editor/src/lib/themeGenerationService.js
@@ -0,0 +1,379 @@
+import {
+ FONT_STACKS,
+ THEME_RADII,
+ normalizeHex,
+} from './generateThemeFromColor.js';
+import { GeminiAdapter, OpenAIAdapter } from '@embeddedchat/ai-adapter';
+
+export const THEME_PROVIDERS = {
+ ollama: { label: 'Ollama (local)', model: 'gemma4' },
+ openai: { label: 'OpenAI', model: 'gpt-4o', baseUrl: 'https://api.openai.com/v1' },
+ gemini: {
+ label: 'Google Gemini',
+ model: 'gemini-2.0-flash',
+ baseUrl: 'https://generativelanguage.googleapis.com',
+ },
+ fallback: { label: 'Deterministic fallback' },
+};
+
+const NAMED_COLORS = {
+ red: '#ef4444', orange: '#f97316', amber: '#f59e0b', yellow: '#eab308',
+ green: '#22c55e', teal: '#14b8a6', cyan: '#06b6d4', blue: '#3b82f6',
+ indigo: '#6366f1', violet: '#8b5cf6', purple: '#a855f7', pink: '#ec4899',
+ rose: '#f43f5e', slate: '#64748b', gray: '#6b7280', black: '#0f172a',
+};
+
+export const THEME_SUGGESTION_SCHEMA = {
+ type: 'object',
+ properties: {
+ primaryHex: { type: 'string', pattern: '^#[0-9a-fA-F]{6}$' },
+ accentHex: { type: 'string', pattern: '^#[0-9a-fA-F]{6}$' },
+ radius: { enum: THEME_RADII },
+ fontFamily: { enum: Object.values(FONT_STACKS) },
+ mode: { enum: ['light', 'dark'] },
+ messageView: { enum: ['flat', 'bubble'] },
+ displayName: { enum: ['normal', 'colorize'] },
+ },
+ required: ['primaryHex'],
+ additionalProperties: false,
+};
+
+const REFINEMENT_SUGGESTION_SCHEMA = {
+ ...THEME_SUGGESTION_SCHEMA,
+ required: [],
+};
+
+const findColors = (description) => {
+ const hexes = description.match(/#(?:[a-f\d]{3}|[a-f\d]{6})\b/gi) ?? [];
+ const names = description.toLowerCase().match(/[a-z]+/g)
+ ?.map((word) => NAMED_COLORS[word]).filter(Boolean) ?? [];
+ return [...hexes, ...names].map(normalizeHex).filter(Boolean);
+};
+
+const inferRadius = (description) => {
+ const prompt = description.toLowerCase();
+ if (/\b(pill|fully rounded|very rounded|circular)\b/.test(prompt)) return '1.5rem';
+ if (/\b(rounded|round)\b/.test(prompt)) return '0.5rem';
+ if (/\b(subtle|soft corners?|slightly rounded)\b/.test(prompt)) return '0.25rem';
+ if (/\b(sharp|flat|square|squared|angular|no radius)\b/.test(prompt)) return '0rem';
+ return undefined;
+};
+
+const inferFontFamily = (description) => {
+ const prompt = description.toLowerCase();
+ if (/\b(serif|editorial|traditional)\b/.test(prompt)) return FONT_STACKS.serif;
+ if (/\b(mono|monospace|developer|code|technical)\b/.test(prompt)) return FONT_STACKS.mono;
+ if (/\b(sans|minimal|modern|friendly|professional|corporate)\b/.test(prompt)) return FONT_STACKS.sans;
+ return undefined;
+};
+
+const inferMode = (description) => {
+ const prompt = description.toLowerCase();
+ const requestedMode = prompt.match(
+ /\b(?:switch|change|set|turn)\s+(?:it\s+)?to\s+(light|dark)\b/
+ );
+ if (requestedMode) return requestedMode[1];
+
+ const namedThemeMode = prompt.match(/\b(light|dark)\s+(?:mode|theme)\b/);
+ if (namedThemeMode) return namedThemeMode[1];
+ if (/\b(dark|night)\b/.test(prompt)) return 'dark';
+ if (/\b(light|bright|day)\b/.test(prompt)) return 'light';
+ return undefined;
+};
+
+const inferMessageView = (description) => {
+ const prompt = description.toLowerCase();
+ if (/\b(bubble|bubbled)\b/.test(prompt)) return 'bubble';
+ if (/\bflat\b/.test(prompt)) return 'flat';
+ return undefined;
+};
+
+const inferDisplayName = (description) => {
+ const prompt = description.toLowerCase();
+ if (/\b(colorize|colorized|colourize|colourized)\b/.test(prompt)) {
+ return 'colorize';
+ }
+ if (/\bnormal\b.*\b(display name|username)|\b(display name|username)\b.*\bnormal\b/.test(prompt)) {
+ return 'normal';
+ }
+ return undefined;
+};
+
+const requestedRefinementFields = (description) => {
+ const prompt = description.toLowerCase();
+ const fields = new Set();
+ const preservesPalette =
+ /\b(keep|retain|preserve)\s+(?:the\s+)?(?:colou?rs?|palette|brand|primary|accent)\b/.test(
+ prompt
+ );
+
+ if (
+ findColors(description).length ||
+ (!preservesPalette &&
+ /\b(colou?r|palette|brand|primary|accent|hue|saturation|warmer|cooler|lighten|darken)\b/.test(
+ prompt
+ ))
+ ) {
+ fields.add('primaryHex');
+ fields.add('accentHex');
+ }
+ if (
+ inferRadius(description) ||
+ /\b(radius|corner|corners)\b/.test(prompt)
+ ) fields.add('radius');
+ if (
+ inferFontFamily(description) ||
+ /\b(font|typeface|typography)\b/.test(prompt)
+ ) fields.add('fontFamily');
+ if (/\b(dark|light)\s+(mode|theme)\b|\bswitch\s+to\s+(dark|light)\b/.test(prompt)) {
+ fields.add('mode');
+ }
+ if (/\b(message view|message type|bubble|flat)\b/.test(prompt)) {
+ fields.add('messageView');
+ }
+ if (/\b(display name|username|colorize|colourize)\b/.test(prompt)) {
+ fields.add('displayName');
+ }
+ return fields;
+};
+
+export const limitRefinementSuggestion = (suggestion, description) => {
+ const allowedFields = requestedRefinementFields(description);
+ const limitedSuggestion = Object.fromEntries(
+ Object.entries(suggestion).filter(([key, value]) =>
+ allowedFields.has(key) && value !== undefined
+ )
+ );
+
+ // Direct style requests are commands, not creative suggestions. An adapter
+ // may misread the surrounding conversation, so deterministic local parsing
+ // always takes precedence for shape, typography, and mode.
+ const requestedSuggestion = createLocalSuggestion(description, {
+ preserveExisting: true,
+ });
+ ['radius', 'fontFamily', 'mode', 'messageView', 'displayName'].forEach((field) => {
+ if (allowedFields.has(field) && requestedSuggestion[field]) {
+ limitedSuggestion[field] = requestedSuggestion[field];
+ }
+ });
+
+ return limitedSuggestion;
+};
+
+export const validateThemeSuggestion = (value, { allowPartial = false } = {}) => {
+ if (!value || typeof value !== 'object') {
+ throw new Error('Ollama returned an invalid theme response.');
+ }
+ const primaryHex = normalizeHex(value.primaryHex ?? value.primaryColor);
+ const accentHex = normalizeHex(value.accentHex ?? value.accentColor);
+ if (!primaryHex && !allowPartial) {
+ throw new Error('Ollama did not return a valid primaryHex color.');
+ }
+
+ return {
+ primaryHex: primaryHex ?? undefined,
+ accentHex: accentHex ?? undefined,
+ radius: THEME_RADII.includes(value.radius) ? value.radius : undefined,
+ fontFamily: Object.values(FONT_STACKS).includes(value.fontFamily)
+ ? value.fontFamily
+ : undefined,
+ mode: value.mode === 'dark' || value.mode === 'light' ? value.mode : undefined,
+ messageView: ['flat', 'bubble'].includes(value.messageView)
+ ? value.messageView
+ : undefined,
+ displayName: ['normal', 'colorize'].includes(value.displayName)
+ ? value.displayName
+ : undefined,
+ };
+};
+
+export const createLocalSuggestion = (description, { preserveExisting = false } = {}) => {
+ const colors = findColors(description);
+ return {
+ primaryHex: colors[0] ?? (preserveExisting ? undefined : '#2563eb'),
+ accentHex: colors[1] ?? undefined,
+ radius: inferRadius(description),
+ fontFamily: inferFontFamily(description),
+ mode: inferMode(description),
+ messageView: inferMessageView(description),
+ displayName: inferDisplayName(description),
+ };
+};
+
+export const applyExplicitInitialIntent = (suggestion, description) => {
+ const explicit = createLocalSuggestion(description);
+ const colors = findColors(description);
+ return {
+ ...suggestion,
+ ...(colors[0] ? { primaryHex: explicit.primaryHex } : {}),
+ ...(colors[1] ? { accentHex: explicit.accentHex } : {}),
+ ...(explicit.radius ? { radius: explicit.radius } : {}),
+ ...(explicit.fontFamily ? { fontFamily: explicit.fontFamily } : {}),
+ ...(explicit.mode ? { mode: explicit.mode } : {}),
+ ...(explicit.messageView ? { messageView: explicit.messageView } : {}),
+ ...(explicit.displayName ? { displayName: explicit.displayName } : {}),
+ };
+};
+
+const getLocalOllamaUrl = (baseUrl) => {
+ let url;
+ try {
+ url = new URL(baseUrl);
+ } catch {
+ throw new Error('Enter a valid local Ollama URL.');
+ }
+ const isLocalHost = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
+ if (!isLocalHost) {
+ throw new Error('Direct Ollama access is restricted to your local machine.');
+ }
+ return `${url.toString().replace(/\/$/, '')}/api/generate`;
+};
+
+const getPrompt = (description, context, preserveExisting) => `
+You create EmbeddedChat theme suggestions for developers.
+Return only JSON matching the supplied schema.
+${preserveExisting
+ ? 'This is a refinement: include only properties the latest request explicitly changes. Omit every unchanged property.'
+ : 'Create a complete initial theme suggestion.'}
+
+Existing instructions:\n${context || 'None'}
+
+Latest request:\n${description}`;
+
+const getAdapterPrompt = (description, context, preserveExisting) => `${getPrompt(
+ description,
+ context,
+ preserveExisting
+)}
+
+JSON schema:
+${JSON.stringify(
+ preserveExisting ? REFINEMENT_SUGGESTION_SCHEMA : THEME_SUGGESTION_SCHEMA
+)}`;
+
+const parseResponseText = (text) => {
+ const json = text
+ .trim()
+ .replace(/^```(?:json)?\s*/i, '')
+ .replace(/\s*```$/, '');
+ return JSON.parse(json);
+};
+
+const requestAdapterSuggestion = async ({
+ provider,
+ model,
+ baseUrl,
+ apiKey,
+ description,
+ context,
+ preserveExisting,
+}) => {
+ if (!apiKey?.trim()) {
+ throw new Error(`${THEME_PROVIDERS[provider].label} requires an API key.`);
+ }
+
+ const Adapter = provider === 'gemini' ? GeminiAdapter : OpenAIAdapter;
+ const adapter = new Adapter({
+ apiKey: apiKey.trim(),
+ model,
+ baseUrl,
+ tasks: { composer: { temperature: 0, maxTokens: 300 } },
+ });
+ const response = await adapter.sendPrompt(
+ {
+ roomId: 'layout-editor',
+ userId: 'theme-generator',
+ history: [],
+ // The adapter's generic composer task provides a deterministic,
+ // short-form request profile. The prompt below still defines the
+ // theme-specific JSON contract.
+ metadata: { task: 'composer' },
+ },
+ getAdapterPrompt(description, context, preserveExisting)
+ );
+
+ const suggestion = validateThemeSuggestion(parseResponseText(response.text), {
+ allowPartial: preserveExisting,
+ });
+ return {
+ ...(preserveExisting
+ ? limitRefinementSuggestion(suggestion, description)
+ : applyExplicitInitialIntent(suggestion, description)),
+ source: provider,
+ };
+};
+
+export const requestThemeSuggestion = async ({
+ description,
+ context,
+ provider = 'ollama',
+ baseUrl,
+ model,
+ apiKey,
+ preserveExisting = false,
+ signal,
+}) => {
+ if (provider === 'fallback') {
+ return {
+ ...createLocalSuggestion(description, { preserveExisting }),
+ source: 'fallback',
+ };
+ }
+
+ if (provider !== 'ollama') {
+ try {
+ return await requestAdapterSuggestion({
+ provider,
+ model,
+ baseUrl,
+ apiKey,
+ description,
+ context,
+ preserveExisting,
+ });
+ } catch (error) {
+ throw new Error(error.message || 'The AI adapter could not generate a theme.');
+ }
+ }
+
+ const ollamaUrl = getLocalOllamaUrl(baseUrl);
+ let response;
+ try {
+ response = await fetch(ollamaUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ signal,
+ body: JSON.stringify({
+ model,
+ prompt: getPrompt(description, context, preserveExisting),
+ format: preserveExisting
+ ? REFINEMENT_SUGGESTION_SCHEMA
+ : THEME_SUGGESTION_SCHEMA,
+ options: { temperature: 0 },
+ stream: false,
+ }),
+ });
+ } catch (error) {
+ if (error.name === 'AbortError') throw error;
+ throw new Error('Could not reach Ollama. Start it locally or use the fallback.');
+ }
+
+ if (!response.ok) {
+ throw new Error(`Ollama request failed (${response.status}). Check the model name.`);
+ }
+
+ const result = await response.json();
+ try {
+ const suggestion = validateThemeSuggestion(parseResponseText(result.response), {
+ allowPartial: preserveExisting,
+ });
+ return {
+ ...(preserveExisting
+ ? limitRefinementSuggestion(suggestion, description)
+ : applyExplicitInitialIntent(suggestion, description)),
+ source: 'ollama',
+ };
+ } catch {
+ throw new Error('Ollama returned an invalid theme response. Try the request again.');
+ }
+};
diff --git a/packages/layout_editor/src/views/ChatHeader/ChatHeader.jsx b/packages/layout_editor/src/views/ChatHeader/ChatHeader.jsx
index f30880b017..13e9dc790d 100644
--- a/packages/layout_editor/src/views/ChatHeader/ChatHeader.jsx
+++ b/packages/layout_editor/src/views/ChatHeader/ChatHeader.jsx
@@ -19,7 +19,8 @@ import SurfaceMenu from '../../components/SurfaceMenu/SurfaceMenu';
import useHeaderItemsStore from '../../store/headerItemsStore';
const ChatHeader = () => {
- const styles = getChatHeaderStyles(useTheme());
+ const theme = useTheme();
+ const styles = getChatHeaderStyles(theme);
const { surfaceItems, menuItems, setSurfaceItems, setMenuItems } =
useHeaderItemsStore((state) => ({
surfaceItems: state.surfaceItems,
@@ -235,7 +236,11 @@ const ChatHeader = () => {
+ Generate an accessible EmbeddedChat theme, then tune it before applying.
+
+ Localhost only. Configure OLLAMA_ORIGINS if your browser reports CORS.
+
+ This key is held only for this editor session.
+
+ Paired text colors are repaired automatically; unsafe changes are rejected.
+ Connection settings
+ {provider === 'ollama' && (
+ Customize palette
+ {groupLabel}
+ Typography