UNPKG

@kitn.ai/ui

Version:

Framework-agnostic, Shadow-DOM web components for building AI chat interfaces — works in React, Vue, Angular, Svelte, or plain HTML. Authored in SolidJS.

341 lines (297 loc) 11.7 kB
import { z } from 'zod'; import type { Tool } from './types'; /** * theme — produce a `--kai-*` CSS token override block from a brand color or * description. Tokens are sourced exclusively from the real names in theme.css. * * Real --kai-color-* names (verified against repo-root theme.css): * --kai-color-primary → brand color * --kai-color-primary-foreground → AA-contrast fg (white or black) * --kai-color-ring → focus ring (brand color) * --kai-color-accent → tinted accent surface * --kai-color-accent-foreground → text on accent surface */ // ─── hex color utilities (no deps) ────────────────────────────────────────── /** Parse #rgb or #rrggbb → { r, g, b } 0-255. Returns null on failure. */ function parseHex(hex: string): { r: number; g: number; b: number } | null { const s = hex.trim().replace(/^#/, ''); if (s.length === 3) { const r = parseInt(s[0] + s[0], 16); const g = parseInt(s[1] + s[1], 16); const b = parseInt(s[2] + s[2], 16); if (isNaN(r) || isNaN(g) || isNaN(b)) return null; return { r, g, b }; } if (s.length === 6) { const r = parseInt(s.slice(0, 2), 16); const g = parseInt(s.slice(2, 4), 16); const b = parseInt(s.slice(4, 6), 16); if (isNaN(r) || isNaN(g) || isNaN(b)) return null; return { r, g, b }; } return null; } function toHex(r: number, g: number, b: number): string { return ( '#' + [r, g, b] .map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')) .join('') ); } /** * Relative luminance (WCAG 2.1) — used for AA contrast check. * Returns 0 (black) to 1 (white). */ function relativeLuminance(r: number, g: number, b: number): number { const lin = (c: number) => { const s = c / 255; return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); }; return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); } /** White or black for maximum contrast against the brand color. */ function foreground(r: number, g: number, b: number): '#ffffff' | '#000000' { const lum = relativeLuminance(r, g, b); // Contrast against white = (1 + 0.05) / (lum + 0.05) // Contrast against black = (lum + 0.05) / (0 + 0.05) const contrastWhite = 1.05 / (lum + 0.05); const contrastBlack = (lum + 0.05) / 0.05; return contrastWhite >= contrastBlack ? '#ffffff' : '#000000'; } /** Lighten a color by a ratio 0-1 (moves toward white). */ function lighten(r: number, g: number, b: number, ratio: number): string { return toHex( r + (255 - r) * ratio, g + (255 - g) * ratio, b + (255 - b) * ratio, ); } /** Darken a color by a ratio 0-1 (moves toward black). */ function darken(r: number, g: number, b: number, ratio: number): string { return toHex(r * (1 - ratio), g * (1 - ratio), b * (1 - ratio)); } /** Produce a tinted accent surface (very light tint of the brand color). */ function accentSurface(r: number, g: number, b: number): string { return lighten(r, g, b, 0.88); } /** Dark-mode variant: lighten the brand so it reads well on dark backgrounds. */ function darkModeBrand(r: number, g: number, b: number): string { const lum = relativeLuminance(r, g, b); // If brand is already light enough for dark bg, keep it; otherwise lighten. if (lum > 0.25) return toHex(r, g, b); return lighten(r, g, b, 0.35); } /** Dark-mode accent surface: subtle dark tint of brand. */ function darkAccentSurface(r: number, g: number, b: number): string { return darken(r, g, b, 0.75); } // ─── keyword → hex fallback map ────────────────────────────────────────────── const KEYWORD_COLORS: Record<string, string> = { blue: '#3b82f6', navy: '#1e3a8a', sky: '#0ea5e9', cyan: '#06b6d4', teal: '#14b8a6', green: '#22c55e', emerald: '#10b981', lime: '#84cc16', yellow: '#eab308', amber: '#f59e0b', orange: '#f97316', red: '#ef4444', rose: '#f43f5e', pink: '#ec4899', purple: '#a855f7', violet: '#7c3aed', indigo: '#6366f1', slate: '#64748b', gray: '#6b7280', zinc: '#71717a', neutral: '#737373', stone: '#78716c', white: '#f8fafc', black: '#0f172a', }; const DEFAULT_BRAND = '#6366f1'; // indigo — kit's own brand feel /** * Resolve a brand hex from input. * Returns { hex, note } where note explains the assumption if we guessed. */ function resolveBrand( brand?: string, description?: string, ): { hex: string; r: number; g: number; b: number; note: string } { // 1. Explicit hex if (brand) { const parsed = parseHex(brand); if (parsed) { return { hex: brand.startsWith('#') ? brand : `#${brand}`, ...parsed, note: '' }; } // brand provided but unparseable — fall through with a note const fallback = parseHex(DEFAULT_BRAND)!; return { hex: DEFAULT_BRAND, ...fallback, note: `Could not parse \`${brand}\` as a hex color — defaulted to **${DEFAULT_BRAND}** (indigo). Pass a valid \`#rgb\` or \`#rrggbb\` value to use your color.`, }; } // 2. Description → keyword match if (description) { const lower = description.toLowerCase(); for (const [keyword, hex] of Object.entries(KEYWORD_COLORS)) { if (lower.includes(keyword)) { const parsed = parseHex(hex)!; return { hex, ...parsed, note: `No brand color provided — inferred **${hex}** (${keyword}) from description. Override with \`brand: '#rrggbb'\` to use an exact color.`, }; } } // Description present but no keyword matched → tasteful default const parsed = parseHex(DEFAULT_BRAND)!; return { hex: DEFAULT_BRAND, ...parsed, note: `No recognizable color keyword in description — defaulted to **${DEFAULT_BRAND}** (indigo). Override with \`brand: '#rrggbb'\` for an exact color.`, }; } // 3. Nothing provided → kit brand default const parsed = parseHex(DEFAULT_BRAND)!; return { hex: DEFAULT_BRAND, ...parsed, note: `No brand color or description provided — defaulted to **${DEFAULT_BRAND}** (indigo, the kit's own brand). Pass \`brand: '#rrggbb'\` to use your color.`, }; } // ─── CSS block builders ─────────────────────────────────────────────────────── interface TokenSet { primary: string; primaryFg: string; ring: string; accent: string; accentFg: string; } function buildTokenSet(r: number, g: number, b: number): TokenSet { const primary = toHex(r, g, b); const primaryFg = foreground(r, g, b); const accent = accentSurface(r, g, b); // foreground on the very-light accent surface const accentFgColor = foreground( ...([accent] .map((h) => parseHex(h)!) .flatMap((c) => [c.r, c.g, c.b]) as [number, number, number]), ); return { primary, primaryFg, ring: primary, accent, accentFg: accentFgColor, }; } function buildDarkTokenSet(r: number, g: number, b: number): TokenSet { const darkBrand = darkModeBrand(r, g, b); const parsed = parseHex(darkBrand)!; const primary = darkBrand; const primaryFg = foreground(parsed.r, parsed.g, parsed.b); const accent = darkAccentSurface(r, g, b); const accentFgParsed = parseHex(accent)!; return { primary, primaryFg, ring: darkBrand, accent, accentFg: foreground(accentFgParsed.r, accentFgParsed.g, accentFgParsed.b), }; } function cssBlock(selector: string, tokens: TokenSet): string { return [ `${selector} {`, ` --kai-color-primary: ${tokens.primary};`, ` --kai-color-primary-foreground: ${tokens.primaryFg};`, ` --kai-color-ring: ${tokens.ring};`, ` --kai-color-accent: ${tokens.accent};`, ` --kai-color-accent-foreground: ${tokens.accentFg};`, `}`, ].join('\n'); } // ─── Tool definition ───────────────────────────────────────────────────────── export const theme: Tool = { name: 'theme', description: 'Generate a --kai-* CSS token override block from a brand color or description. ' + 'Paste the block into your global stylesheet to brand every kai-* element in one shot.', inputSchema: z.object({ brand: z .string() .optional() .describe('Brand color as #rgb or #rrggbb hex (e.g. "#7c3aed")'), description: z .string() .optional() .describe( 'Natural-language description of your product (used to infer a color when `brand` is omitted)', ), mode: z .enum(['light', 'dark', 'both']) .optional() .describe( '"light" → :root only (default); "dark" → .dark only; "both" → :root + .dark', ), }), handler: async (args) => { const { brand, description, mode = 'light', } = args as { brand?: string; description?: string; mode?: 'light' | 'dark' | 'both' }; const { hex, r, g, b, note } = resolveBrand(brand, description); const lightTokens = buildTokenSet(r, g, b); const darkTokens = buildDarkTokenSet(r, g, b); // Which blocks to emit const blocks: string[] = []; if (mode === 'light' || mode === 'both') { blocks.push(cssBlock(':root', lightTokens)); } if (mode === 'dark' || mode === 'both') { blocks.push(cssBlock('.dark', darkTokens)); } const cssOutput = blocks.join('\n\n'); const noteLabel = note.startsWith('Could not parse') ? 'Note' : 'Assumption'; const noteSection = note ? `\n> **${noteLabel}:** ${note}\n` : ''; const text = `\ ## AI/UI theme override — \`${hex}\` ${noteSection} Paste this block into your **global stylesheet** (before the \`@import\` of \`@kitn.ai/ui\` or after — order doesn't matter since \`--kai-*\` tokens are resolved at runtime via \`var()\` fallbacks). For \`mode:'dark'\` overrides, the \`.dark\` class must be on a parent element (e.g. \`<html class="dark">\`) for them to take effect. \`\`\`css ${cssOutput} \`\`\` ### How it works The \`--kai-color-*\` tokens pierce the Shadow DOM via CSS custom-property inheritance. Every \`kai-*\` element reads them through a \`var(--kai-color-primary, <default>)\` fallback chain defined in \`theme.css\`. Setting them on \`:root\` (or a wrapper element) is enough to rebrand everything globally. **Tokens emitted** (verified names from \`theme.css\`): - \`--kai-color-primary\` — primary brand color - \`--kai-color-primary-foreground\` — AA-contrast foreground on primary (auto-selected black/white) - \`--kai-color-ring\` — keyboard focus ring - \`--kai-color-accent\` — tinted accent surface - \`--kai-color-accent-foreground\` — text on accent surface ### Apply to a subtree only \`\`\`css /* scope to a specific container instead of :root */ .my-chat-widget { ${blocks[0] ?.split('\n') .slice(1, -1) .map((l) => ' ' + l) .join('\n')} } \`\`\` ### Fine-tune the full palette The theme editor lets you tweak every token interactively and copy the result: **→ [Open Theme Editor](https://ui.kitn.ai/theme/editor/)** For the complete token list see the [theming guide](https://ui.kitn.ai/guides/theming/) and the source \`theme.css\` in the package root. `; return { content: [{ type: 'text', text }] }; }, };