UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

251 lines • 11.5 kB
/** * The app's theme inside the sandbox: the host page's CSS custom properties (`--background`, * `--primary`, `--radius`…) read from `:root` and handed to the frame, and — with Tailwind on — an * `@theme` that maps them to utilities (`bg-primary`, `text-muted-foreground`, `rounded-lg`). * * Isomorphic: the browser renderer collects and pushes the values, the server and the Vite plugin * only need the names (what the model is told it may use). */ /** Custom properties Tailwind and the browser keep for themselves — never the app's theme. */ const INTERNAL_VAR = /^--(tw-|vite-|agora-)/; /** Selectors a theme's variables live on: the root, and the light/dark switches on it. */ const ROOT_SELECTOR = /(^|[\s,(])(:root|html|:host|body)\b|\[data-[\w-]*(theme|mode|scheme)\b|\.(dark|light)\b/; /** * `--name: value` declarations in a stylesheet's text (what the Vite plugin scans). `rootOnly`: * only those on the root (`:root`, `html`, `[data-theme]`, `.dark`…) — a theme's, not a * component's own (`.btn { --bs-btn-color }`). */ export function themeVarsFromCss(css, options = {}) { const vars = {}; const text = css.replace(/\/\*[\s\S]*?\*\//g, ''); const take = (body) => { const declaration = /(?:^|[;{\s])(--[A-Za-z0-9_-]+)\s*:\s*([^;{}]+)/g; for (const match of body.matchAll(declaration)) { const name = match[1]; if (INTERNAL_VAR.test(name)) continue; // The first value seen is the light one (`:root` comes before `.dark` in every theme file). if (!(name in vars)) vars[name] = match[2].trim(); } }; if (options.rootOnly !== true) { take(text); return vars; } // Each innermost rule: its selector (the text before its `{`) and its declarations. for (const match of text.matchAll(/([^{};]*)\{([^{}]*)\}/g)) { if (ROOT_SELECTOR.test((match[1] ?? '').trim())) take(match[2] ?? ''); } return vars; } /** A value that is a color — a function, a hex, a named color, or shadcn's bare `H S% L%` channels. */ export function isColorValue(value) { const v = value.trim().toLowerCase(); return (/^#[0-9a-f]{3,8}$/.test(v) || /^(rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color|color-mix)\(/.test(v) || isHslChannels(v) || ['transparent', 'black', 'white', 'currentcolor'].includes(v)); } /** shadcn v3's `222.2 84% 4.9%`: hue saturation lightness, to be wrapped in `hsl(…)`. */ export function isHslChannels(value) { return /^-?[\d.]+(deg)?\s+[\d.]+%\s+[\d.]+%(\s*\/\s*[\d.]+%?)?$/.test(value.trim()); } /** Sort theme variables into colors, radius, fonts and the rest, by value (else by name). */ export function themeTokens(vars) { const entries = Array.isArray(vars) ? vars.map((name) => [name, undefined]) : Object.entries(vars); const tokens = { colors: [], colorVars: [], radius: false, fonts: [], other: [] }; for (const [name, value] of entries) { if (INTERNAL_VAR.test(name)) continue; const bare = name.replace(/^--/, ''); if (bare === 'radius') tokens.radius = true; else if (bare.startsWith('font-')) tokens.fonts.push(bare); else if (bare.startsWith('color-')) { tokens.colors.push(bare.slice('color-'.length)); tokens.colorVars.push(name); } else if (value !== undefined ? isColorValue(value) : /(^|-)(background|foreground|primary|secondary|muted|accent|destructive|border|input|ring|card|popover|chart|sidebar)/.test(bare)) { tokens.colors.push(bare); tokens.colorVars.push(name); } else tokens.other.push(bare); } return tokens; } /** What the model is told about the theme (inside the sandbox description). */ export function themeToModelText(vars, options = {}) { const tokens = themeTokens(vars ?? []); if (tokens.colors.length === 0 && !tokens.radius && tokens.fonts.length === 0) { return "The app's theme is on :root as CSS custom properties (for example var(--background), var(--foreground), var(--primary)); use them with a fallback, e.g. var(--primary, #2563eb), so the view matches the app in light and dark mode. The page background shows through: leave the body transparent."; } const parts = [ `The app's theme is on :root as CSS custom properties and follows light/dark mode — use these exact names: colors ${tokens.colorVars .map((name) => `var(${name})`) .join(', ')}${tokens.radius ? '; radius var(--radius)' : ''}${tokens.fonts.length > 0 ? `; fonts ${tokens.fonts.map((name) => `var(--${name})`).join(', ')}` : ''}. Use them instead of hard-coded colors, and leave the body transparent (the page background shows through).`, ]; if (options.tailwind === true) { parts.push(`With Tailwind they are utilities: ${tokens.colors .slice(0, 12) .map((name) => `bg-${name}`) .join(', ')}, text-*, border-* of the same names${tokens.radius ? ', rounded-sm/md/lg/xl (from --radius)' : ''}.`); } return parts.join(' '); } /** * `:root { --x: value; … }` — the theme as the frame gets it. `colorScheme` is the host root's * computed `color-scheme`: mirrored when the host sets one (a frame whose scheme differs from its * page's is painted opaque), left out when it is `normal`. */ export function hostThemeCss(vars, options = {}) { const body = Object.entries(vars) .filter(([name, value]) => !INTERNAL_VAR.test(name) && value.trim() !== '') .map(([name, value]) => `${name}:${value.replace(/[<>{}]/g, '')}`) .join(';'); const scheme = options.colorScheme?.trim(); return `:root{${scheme && scheme !== 'normal' ? `color-scheme:${scheme.replace(/[<>{};]/g, '')};` : ''}${body}}`; } /** * The `@theme` that maps the host's variables to Tailwind utilities — colors to `--color-*` * (`hsl(…)`-wrapped for bare channels), `--radius` to the radius scale, fonts as they are — and a * `dark` variant that follows the `dark` class and `data-theme` the frame mirrors from the host. */ export function tailwindThemeCss(vars) { const lines = []; for (const [name, value] of Object.entries(vars)) { if (INTERNAL_VAR.test(name)) continue; const bare = name.replace(/^--/, ''); if (bare === 'radius') { lines.push('--radius-sm: calc(var(--radius) - 4px)', '--radius-md: calc(var(--radius) - 2px)', '--radius-lg: var(--radius)', '--radius-xl: calc(var(--radius) + 4px)'); } else if (bare.startsWith('font-')) { lines.push(`--${bare}: var(--${bare})`); } else if (bare.startsWith('color-')) { lines.push(`--${bare}: var(--${bare})`); } else if (isColorValue(value)) { lines.push(isHslChannels(value) ? `--color-${bare}: hsl(var(--${bare}))` : `--color-${bare}: var(--${bare})`); } } return `@custom-variant dark (&:where(.dark, .dark *, [data-theme=dark], [data-theme=dark] *));\n@theme inline {\n${lines .map((line) => ` ${line};`) .join('\n')}\n}`; } /** The root attributes a light/dark switch lives in: `data-theme`, `data-mode`, `data-bs-theme`, `data-color-scheme`… */ export const MODE_ATTRIBUTE = /^data-([\w-]*-)?(theme|mode|scheme)$/; /** The host root's light/dark attributes (see {@link MODE_ATTRIBUTE}), to mirror on the frame's. */ export function hostModeAttributes(doc) { const out = {}; for (const attribute of Array.from(doc.documentElement.attributes ?? [])) { if (MODE_ATTRIBUTE.test(attribute.name)) out[attribute.name] = attribute.value; } return out; } /** Light or dark: the host's `dark`/`light` class, a `data-*theme|mode|scheme` attribute, else the system preference. */ export function isHostDark(doc) { const root = doc.documentElement; if (root.classList.contains('dark')) return true; if (root.classList.contains('light')) return false; const preferred = ['data-theme', 'data-mode']; const names = [ ...preferred, ...Array.from(root.attributes ?? [], (attribute) => attribute.name).filter((name) => MODE_ATTRIBUTE.test(name) && !preferred.includes(name)), ]; for (const name of names) { const value = root.getAttribute(name); if (value === 'dark') return true; if (value === 'light') return false; } return doc.defaultView?.matchMedia?.('(prefers-color-scheme: dark)').matches === true; } /** Every custom property any same-origin stylesheet declares (and the root's inline style sets). */ function declaredVarNames(doc) { const names = new Set(); const visit = (rules) => { if (rules === undefined) return; for (const rule of Array.from(rules)) { const style = rule.style; if (style !== undefined) { for (let index = 0; index < style.length; index++) { const name = style.item(index); if (name.startsWith('--') && !INTERNAL_VAR.test(name)) names.add(name); } } const nested = rule.cssRules; if (nested !== undefined) visit(nested); } }; for (const sheet of Array.from(doc.styleSheets)) { try { visit(sheet.cssRules); } catch { /* a cross-origin stylesheet: its rules are not readable */ } } const inline = doc.documentElement.style; for (let index = 0; index < inline.length; index++) { const name = inline.item(index); if (name.startsWith('--') && !INTERNAL_VAR.test(name)) names.add(name); } return names; } /** The host's theme as it is NOW: each declared variable's computed value on `:root`. */ export function collectHostThemeVars(doc) { const computed = doc.defaultView?.getComputedStyle(doc.documentElement); const vars = {}; if (computed === undefined) return vars; for (const name of [...declaredVarNames(doc)].sort()) { const value = computed.getPropertyValue(name).trim(); if (value !== '') vars[name] = value; } return vars; } /** * Call `onChange` when the host's theme may have changed: its root's class, style or `data-theme`, * a stylesheet added or removed, or the system's color scheme. Returns the unsubscribe. */ export function watchHostTheme(doc, onChange) { let timer; const changed = () => { clearTimeout(timer); timer = setTimeout(onChange, 30); }; const observer = typeof MutationObserver === 'function' ? new MutationObserver(changed) : undefined; // Every attribute: the class, the style, and whichever `data-*theme` the app switches. observer?.observe(doc.documentElement, { attributes: true }); observer?.observe(doc.head, { childList: true }); const media = doc.defaultView?.matchMedia?.('(prefers-color-scheme: dark)'); media?.addEventListener?.('change', changed); return () => { clearTimeout(timer); observer?.disconnect(); media?.removeEventListener?.('change', changed); }; } //# sourceMappingURL=sandbox-theme.js.map