@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
JavaScript
/**
* 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