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 = () => { - + { background-color: ${mode === 'light' ? darken(theme.colors.background, 0.03) : lighten(theme.colors.background, 1)}; + color: ${theme.colors.foreground}; width: 100%; z-index: 1200; display: flex; diff --git a/packages/layout_editor/src/views/Message/Message.styles.js b/packages/layout_editor/src/views/Message/Message.styles.js index 4a0b39f434..805ac2e66e 100644 --- a/packages/layout_editor/src/views/Message/Message.styles.js +++ b/packages/layout_editor/src/views/Message/Message.styles.js @@ -145,7 +145,7 @@ export const getMessageHeaderStyles = ({ theme }) => { `, userName: css` - color: ${theme.colors.accentForeground}; + color: ${theme.colors.primary}; font-weight: 700; letter-spacing: 0rem; font-size: 0.875rem; @@ -171,7 +171,7 @@ export const getMessageHeaderStyles = ({ theme }) => { `, userActions: css` - color: ${theme.colors.accentForeground}; + color: ${theme.colors.primary}; letter-spacing: 0rem; font-size: 0.875rem; line-height: 1.25rem; @@ -182,7 +182,7 @@ export const getMessageHeaderStyles = ({ theme }) => { `, timestamp: css` - color: ${theme.colors.accentForeground}; + color: ${theme.colors.mutedForeground}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/packages/layout_editor/src/views/ThemeLab/AIThemePanel.jsx b/packages/layout_editor/src/views/ThemeLab/AIThemePanel.jsx new file mode 100644 index 0000000000..62bab151bb --- /dev/null +++ b/packages/layout_editor/src/views/ThemeLab/AIThemePanel.jsx @@ -0,0 +1,763 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + Box, + StaticSelect, + useTheme, + useToastBarDispatch, +} from '@embeddedchat/ui-elements'; +import generateThemeFromColor, { + applyThemeRefinement, + applyThemePaletteChange, + colorToHex, + getAccessibilityReport, + isValidTheme, + normalizeHex, +} from '../../lib/generateThemeFromColor'; +import { + requestThemeSuggestion, + THEME_PROVIDERS, +} from '../../lib/themeGenerationService'; +import { copyThemeToClipboard, downloadTheme } from '../../lib/themeExport'; +import { getAIThemePanelStyles } from './AIThemePanel.styles'; +import ColorPicker from './ColorPicker'; +import { + DISPLAY_NAME_OPTIONS, + FONT_FAMILY_OPTIONS, + MESSAGE_VIEW_OPTIONS, +} from './themeOptions'; +import useLayoutStore from '../../store/layoutStore'; + +const SWATCH_KEYS = [ + { key: 'background', label: 'Background' }, + { key: 'card', label: 'Card' }, + { key: 'primary', label: 'Primary' }, + { key: 'accent', label: 'Accent' }, + { key: 'foreground', label: 'Text' }, +]; + +const RADIUS_OPTIONS = [ + ['0rem', 'Square'], + ['0.25rem', 'Soft'], + ['0.5rem', 'Round'], + ['1.5rem', 'Pill'], +]; + +const PALETTE_GROUPS = [ + { + label: 'Surfaces', + tokens: [ + ['background', 'Background'], ['foreground', 'Text'], + ['card', 'Card'], ['cardForeground', 'Card text'], + ['popover', 'Popover'], ['popoverForeground', 'Popover text'], + ['border', 'Border'], ['input', 'Input border'], ['ring', 'Focus ring'], + ], + }, + { + label: 'Actions', + tokens: [ + ['primary', 'Primary'], ['primaryForeground', 'Primary text'], + ['secondary', 'Secondary'], ['secondaryForeground', 'Secondary text'], + ['accent', 'Accent'], ['accentForeground', 'Accent text'], + ['muted', 'Muted'], ['mutedForeground', 'Muted text'], + ], + }, + { + label: 'Feedback', + tokens: [ + ['destructive', 'Destructive'], ['destructiveForeground', 'Destructive text'], + ['warning', 'Warning'], ['warningForeground', 'Warning text'], + ['success', 'Success'], ['successForeground', 'Success text'], + ['info', 'Info'], ['infoForeground', 'Info text'], + ], + }, +]; + +const DEFAULT_OLLAMA_URL = + import.meta.env.VITE_OLLAMA_BASE_URL ?? 'http://localhost:11434'; +const DEFAULT_OLLAMA_MODEL = import.meta.env.VITE_OLLAMA_MODEL ?? 'gemma4'; +const REQUEST_TIMEOUT_MS = 15_000; + +const mergeSuggestions = (previous, next) => + Object.fromEntries( + Object.entries({ ...previous, ...next }).filter( + ([key, value]) => key !== 'source' && value !== undefined + ) + ); + +const AIThemePanel = () => { + const { theme, mode, setTheme, setMode } = useTheme(); + const { messageView, displayName, setMessageView, setDisplayName } = + useLayoutStore((state) => ({ + messageView: state.messageView, + displayName: state.displayName, + setMessageView: state.setMessageView, + setDisplayName: state.setDisplayName, + })); + const styles = getAIThemePanelStyles(theme); + const dispatchToastMessage = useToastBarDispatch(); + const [open, setOpen] = useState(false); + const [prompt, setPrompt] = useState(''); + const [provider, setProvider] = useState('ollama'); + const [ollamaUrl, setOllamaUrl] = useState(DEFAULT_OLLAMA_URL); + const [ollamaModel, setOllamaModel] = useState(DEFAULT_OLLAMA_MODEL); + const [cloudUrl, setCloudUrl] = useState(''); + const [cloudModel, setCloudModel] = useState(''); + const [apiKey, setApiKey] = useState(''); + const [candidate, setCandidate] = useState(null); + const [followUp, setFollowUp] = useState(''); + const [visiblePalettePicker, setVisiblePalettePicker] = useState(null); + const [paletteMode, setPaletteMode] = useState(mode); + const [draftHistory, setDraftHistory] = useState([]); + const [isGenerating, setIsGenerating] = useState(false); + const [status, setStatus] = useState('Describe a brand direction to create a reviewable theme draft.'); + const originalThemeRef = useRef(null); + const controllerRef = useRef(null); + const paletteEditRef = useRef(null); + const palettePickerRef = useRef(null); + + const endPaletteEdit = useCallback(() => { + paletteEditRef.current = null; + }, []); + + useEffect( + () => () => { + controllerRef.current?.abort(); + }, + [] + ); + + useEffect(() => { + const handleClickOutside = (event) => { + if (palettePickerRef.current && !palettePickerRef.current.contains(event.target)) { + setVisiblePalettePicker(null); + endPaletteEdit(); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [endPaletteEdit]); + + const createDraft = useCallback(async ({ + description, + context = [], + baseTheme, + previousSuggestion, + variants, + }) => { + if (!description || isGenerating) return null; + const controller = new AbortController(); + const timeout = window.setTimeout( + () => controller.abort(), + REQUEST_TIMEOUT_MS + ); + controllerRef.current = controller; + setIsGenerating(true); + setStatus('Creating a theme draft…'); + + try { + const suggestion = await requestThemeSuggestion({ + description, + context: context.length ? context.join('\n') : undefined, + provider, + baseUrl: provider === 'ollama' ? ollamaUrl.trim() : cloudUrl.trim(), + model: provider === 'ollama' ? ollamaModel.trim() : cloudModel.trim(), + apiKey, + preserveExisting: Boolean(previousSuggestion), + signal: controller.signal, + }); + const mergedSuggestion = mergeSuggestions(previousSuggestion, suggestion); + const generatedTheme = previousSuggestion + ? applyThemeRefinement(baseTheme, suggestion) + : generateThemeFromColor( + mergedSuggestion.primaryHex, + baseTheme, + mergedSuggestion + ); + if (!generatedTheme || !isValidTheme(generatedTheme)) { + throw new Error('The generated theme did not pass accessibility validation.'); + } + + const generatedMode = mergedSuggestion.mode ?? mode; + const nextCandidate = { + theme: generatedTheme, + mode: generatedMode, + source: suggestion.source, + report: getAccessibilityReport(generatedTheme), + suggestion: mergedSuggestion, + context: [...context, description], + variants: { + messageView: mergedSuggestion.messageView ?? variants.messageView, + displayName: mergedSuggestion.displayName ?? variants.displayName, + }, + }; + setStatus( + suggestion.source === 'fallback' + ? 'Fallback draft ready. Select an adapter for model-assisted suggestions.' + : `${THEME_PROVIDERS[suggestion.source].label} draft ready. Review it before applying.` + ); + return nextCandidate; + } catch (error) { + const message = error.name === 'AbortError' + ? 'Generation timed out or was cancelled. Try again or check your theme service.' + : error.message; + setStatus(message); + dispatchToastMessage({ type: 'error', message }); + } finally { + window.clearTimeout(timeout); + controllerRef.current = null; + setIsGenerating(false); + } + }, [apiKey, cloudModel, cloudUrl, dispatchToastMessage, isGenerating, mode, ollamaModel, ollamaUrl, provider]); + + const handleProviderChange = useCallback((nextProvider) => { + setProvider(nextProvider); + const configuration = THEME_PROVIDERS[nextProvider]; + if (nextProvider === 'ollama') { + setOllamaModel(configuration.model); + return; + } + if (nextProvider !== 'fallback') { + setCloudModel(configuration.model); + setCloudUrl(configuration.baseUrl); + } + }, []); + + const handleGenerate = useCallback(async () => { + const nextCandidate = await createDraft({ + description: prompt.trim(), + baseTheme: theme, + variants: { messageView, displayName }, + }); + if (!nextCandidate) return; + setCandidate(nextCandidate); + setPaletteMode(nextCandidate.mode); + setDraftHistory([]); + setFollowUp(''); + paletteEditRef.current = null; + }, [createDraft, displayName, messageView, prompt, theme]); + + const handleFollowUp = useCallback(async () => { + if (!candidate || !followUp.trim()) return; + const nextCandidate = await createDraft({ + description: followUp.trim(), + context: candidate.context, + baseTheme: candidate.theme, + previousSuggestion: candidate.suggestion, + variants: candidate.variants, + }); + if (!nextCandidate) return; + setDraftHistory((history) => [candidate, ...history].slice(0, 5)); + setCandidate(nextCandidate); + setPaletteMode(nextCandidate.mode); + setFollowUp(''); + paletteEditRef.current = null; + }, [candidate, createDraft, followUp]); + + const handleUndoDraft = useCallback(() => { + if (!draftHistory.length) return; + const [previous, ...remainingHistory] = draftHistory; + setCandidate(previous); + setPaletteMode(previous.mode); + setDraftHistory(remainingHistory); + setFollowUp(''); + paletteEditRef.current = null; + setStatus('Restored the previous draft revision.'); + }, [draftHistory]); + + const handleCancel = useCallback(() => { + controllerRef.current?.abort(); + }, []); + + const handleApply = useCallback(() => { + if (!candidate) return; + if (!originalThemeRef.current) { + originalThemeRef.current = { theme, mode }; + } + setTheme(candidate.theme); + setMode(candidate.mode); + setMessageView(candidate.variants.messageView); + setDisplayName(candidate.variants.displayName); + setStatus('Theme applied. Export the JSON when you are ready to use it in your website.'); + dispatchToastMessage({ type: 'success', message: 'Accessible theme applied.' }); + }, [ + candidate, + dispatchToastMessage, + mode, + setDisplayName, + setMessageView, + setMode, + setTheme, + theme, + ]); + + const handleReset = useCallback(() => { + if (!originalThemeRef.current) return; + setTheme(originalThemeRef.current.theme); + setMode(originalThemeRef.current.mode); + originalThemeRef.current = null; + setCandidate(null); + setStatus('Restored the theme that was active before this generator was used.'); + dispatchToastMessage({ type: 'success', message: 'Theme restored.' }); + }, [dispatchToastMessage, setMode, setTheme]); + + const handleCopy = useCallback(async () => { + const exportableTheme = candidate + ? { + ...candidate.theme, + variants: { + Message: candidate.variants.messageView, + MessageHeader: candidate.variants.displayName, + }, + } + : theme; + try { + await copyThemeToClipboard(exportableTheme); + dispatchToastMessage({ type: 'success', message: 'Theme JSON copied to clipboard.' }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error.message }); + } + }, [candidate, dispatchToastMessage, theme]); + + const handleDownload = useCallback(() => { + const exportableTheme = candidate + ? { + ...candidate.theme, + variants: { + Message: candidate.variants.messageView, + MessageHeader: candidate.variants.displayName, + }, + } + : theme; + downloadTheme(exportableTheme); + dispatchToastMessage({ type: 'success', message: 'Theme JSON downloaded.' }); + }, [candidate, dispatchToastMessage, theme]); + + const beginPaletteEdit = useCallback(() => { + if (!candidate || paletteEditRef.current === candidate) return; + setDraftHistory((history) => [candidate, ...history].slice(0, 5)); + paletteEditRef.current = candidate; + }, [candidate]); + + const handlePaletteChange = useCallback((token, value) => { + const color = normalizeHex(value); + if (!candidate || !color) return; + + const nextTheme = applyThemePaletteChange(candidate.theme, { + mode: paletteMode, + token, + value: color, + }); + if (!nextTheme || !isValidTheme(nextTheme)) { + setStatus('That color would fail accessibility validation. Try a different value or adjust its paired text token.'); + return; + } + + const suggestionKey = token === 'primary' + ? 'primaryHex' + : token === 'accent' + ? 'accentHex' + : null; + + setCandidate({ + ...candidate, + theme: nextTheme, + report: getAccessibilityReport(nextTheme), + suggestion: suggestionKey + ? { ...candidate.suggestion, [suggestionKey]: color } + : candidate.suggestion, + }); + setStatus('Palette adjusted. All light and dark contrast checks still pass.'); + }, [candidate, paletteMode]); + + const handleStyleChange = useCallback((options) => { + if (!candidate) return; + const nextTheme = applyThemeRefinement(candidate.theme, options); + if (!nextTheme || !isValidTheme(nextTheme)) return; + + setDraftHistory((history) => [candidate, ...history].slice(0, 5)); + setCandidate({ + ...candidate, + theme: nextTheme, + report: getAccessibilityReport(nextTheme), + suggestion: { ...candidate.suggestion, ...options }, + }); + setStatus('Draft style updated.'); + }, [candidate]); + + const handleVariantChange = useCallback((variant, value) => { + if (!candidate || candidate.variants[variant] === value) return; + setDraftHistory((history) => [candidate, ...history].slice(0, 5)); + setCandidate({ + ...candidate, + variants: { ...candidate.variants, [variant]: value }, + }); + setStatus('Draft layout updated.'); + }, [candidate]); + + const previewScheme = candidate?.theme.schemes[candidate.mode]; + const editableScheme = candidate?.theme.schemes[paletteMode]; + + return ( + + + + {open && ( + +

+ Generate an accessible EmbeddedChat theme, then tune it before applying. +

+ + + + + + {provider === 'ollama' + ? 'No API key or proxy required.' + : provider === 'fallback' + ? 'No AI request is made.' + : 'Credentials remain in memory and are never saved.'} + + + + {provider !== 'fallback' && ( +
+ Connection settings + {provider === 'ollama' && ( + + + setOllamaUrl(event.target.value)} + placeholder="http://localhost:11434" + inputMode="url" + /> + + setOllamaModel(event.target.value)} + placeholder="gemma4" + /> +

+ Localhost only. Configure OLLAMA_ORIGINS if your browser reports CORS. +

+
+ )} + + {provider !== 'ollama' && ( + + + setCloudUrl(event.target.value)} + inputMode="url" + /> + + setCloudModel(event.target.value)} + /> + + setApiKey(event.target.value)} + autoComplete="off" + /> +

+ This key is held only for this editor session. +

+
+ )} +
+ )} + + + +