Velloo documentation
Markdown for LLMs ↗MCP: theme
set_theme (tokens, presets, seed colors, fonts, typesets, custom CSS), named themes, import_theme, and contrast scoring.
Theme operations mutate the folder’s token tree (theme/default.json) or named themes alongside it (theme/<name>.json). On the default guided surface, invoke them through call_velloo and retrieve an exact contract with operation_schema; on the full surface they are advertised as native tools. Read the current tree with get_theme, which takes an optional theme name and also returns the computed typeScale and the folder’s customCss. For the workflow, see the theme guide; for the token model, Themes.
set_theme
Edit a theme through one verb. Pass any combination of the five channels (from, tokens, fonts, typeset, customCss) in a single call; at least one is required (a call with only theme is a BadRequest).
| Arg | Type | Required | Description |
|---|---|---|---|
theme | string | no | Named theme to edit; default "default". Create a named theme with add_theme first |
from | object | no | Reseed the palette: { preset } or { seedColor }, exactly one key |
tokens | object | no | Dot-path patch: { "<path>": <string or number>, ... } |
fonts | array | no | Font roles to declare or drop; at least one entry |
typeset | array | no | Typesets to declare, retune, rename, or remove; at least one entry |
customCss | string | no | Replaces theme/custom.css entirely |
Channels apply in a fixed order (from, then tokens, fonts, typeset, customCss), so a token patch in the same call overrides the freshly seeded palette, and a typeset can name a font role declared by fonts in the same call. Each channel is written as it runs: if a later channel fails, the error is returned but the earlier channels in that call have already been saved.
Returns { theme, applied }, where applied has one key per channel that ran: from (echoed), tokens (the applied paths), warnings (when present), fonts (the roles), typeset (the typeset names), and customCss: { bytes }. Call get_theme when you need the full tree.
{
"from": { "seedColor": "#0f766e" },
"tokens": { "radius.md": "0.75rem", "palette.ink": "#1a1a1a" },
"fonts": [
{ "role": "display", "family": "Fraunces", "fallback": "ui-serif, Georgia, serif", "google": "opsz,wght@9..144,300..900" }
],
"typeset": [{ "fontHeading": "display", "leading": 1.7 }]
}
from
{ "preset": "<name>" }replaces the whole theme with a shipped preset: colors, typography (font roles and typesets), spacing, and radius. Anything else in the theme, includingcolorsDark,palette, and custom font roles, is dropped. An unknown name fails schema validation with the valid options.{ "seedColor": "<color>" }generates a full OKLCH palette from one color and replacescolorsandcolorsDark, keeping the rest of the theme. It accepts#hex, named colors,rgb(),hsl(),oklch(), and other CSS color forms; an unparseable color returnsInvalidColor. Foregrounds are nudged toward black or white until each pair reaches WCAG AA (4.5:1). Follow up withscore_theme_contrastto confirm.
Shipped presets: default-light, default-dark, violet, emerald, amber, rose, indigo, ocean, slate, forest, sunset, plum. Presets define colors only (no colorsDark), all sharing the same Inter-based typography.
tokens
Keys are dot-paths into the theme ("colors.primary.DEFAULT", "colorsDark.background", "radius.md", "spacing.18"); values are strings or numbers. Three color namespaces:
colors.*/colorsDark.*: the semantic slots (background,foreground,primary,secondary,muted,accent,destructive,card,popover,border,input,ring) that drive component chrome.colorsDarkholds only the slots that differ in dark mode; the rest fall back tocolors.palette.*/paletteDark.*: a flat passthrough of raw brand colors that does not flip. Setpalette.inkto"#1a1a1a"andbg-ink/text-ink/border-inkresolve literally, the mechanism that makes a host app’s verbatim brand classes work. Keys must be lowercase kebab (ink,primary-600).import_themepopulates it automatically. A palette key named after a semantic slot (palette.primary) is skipped on emit and in the canvas; the result carries awarningsentry telling you to setcolors.<slot>or rename the key.
The patch is all-or-nothing. Every entry is validated against the theme schema, and a path the schema doesn’t define (a misspelled slot, or a retired key like typography.fontSize) counts as a failure rather than being silently dropped. If any entry fails, nothing is written and the error is BulkTokensInvalid { applied, failed: [{ path, reason }] }, where applied lists the entries that would have landed.
fonts
Each entry declares a font role. A role becomes a --font-<role> token and a font-<role> utility.
| Field | Type | Description |
|---|---|---|
role | string | Lowercase letters, digits, and dashes: "display", "sans", "mono", or any utility-safe name |
family | string | Font family, e.g. "Unbounded". Required unless remove |
fallback | string | CSS stack tail. Defaults by role: serif and mono get serif/monospace stacks, everything else ui-sans-serif, system-ui, sans-serif |
google | string or true | Load the family from Google Fonts in design mode and emit an @import in the emitted globals.css: an axis spec like "wght@400..900", or true for a plain load. Omit for local or system fonts |
remove | boolean | Drop the role. Refused when the role doesn’t exist, or while a typeset still names it |
Re-declaring a family replaces its Google Fonts axis spec, and webfont loads that no role uses any more are pruned.
typeset
A typeset is the type rhythm the whole ladder derives from: three controls plus the font roles for body, headings, and code. The heading ladder (h1–h6), body, lead, small, and caption sizes, leading, tracking, and heading margins are all computed from these, so reach for a typeset instead of per-node text-* sizes.
Typesets live at typography.typesets.<name>. default is the folder baseline: it projects onto :root and styles every screen. Any other name becomes a .typeset-<name> preset class that a Prose region opts into with preset: "<name>". The shipped presets carry default, docs, chat, compact, and reading.
| Field | Type | Description |
|---|---|---|
name | string | Typeset to edit; default "default". Letters, digits, dash, underscore |
size | string, number, or null | Base text size: "1em" follows the container, 15 or "15px" pins it |
leading | number or null | Body line-height, unitless and positive; heading leading derives from it |
flow | string, number, or null | Space between blocks, e.g. "1.25em"; heading margins derive from it |
fontBody | string or null | A declared font role |
fontHeading | string or null | A declared font role |
fontMono | string or null | A declared font role |
renameTo | string | Rename this typeset, carrying its controls over |
remove | boolean | Delete this typeset; regions still using its class fall back to the baseline |
Fields merge into the existing typeset, so tuning one control doesn’t restate the others; null clears a field back to the baseline. A name that doesn’t exist yet is created. Refusals: renameTo with remove, renaming or removing default, renaming or removing a typeset that doesn’t exist, renaming onto an existing name, a face that isn’t a declared font role, and a role named like a scale entry (h1, body, caption, …). All come back as InvalidThemePath with the reason.
{ "typeset": [{ "name": "docs", "size": 15, "flow": "1.5em" }, { "name": "chat", "remove": true }] }
typography itself holds only fontFamily, googleFonts, and typesets. The old fontSize, fontWeight, lineHeight, and letterSpacing records were retired; writing them fails validation.
customCss
Replaces theme/custom.css: keyframes, grain, clip-paths, anything utilities can’t express. It is injected into every render and appended to emitted globals.css. The file is folder-wide, so theme has no effect on this channel. It’s a full replacement: read the current contents from get_theme’s customCss first when editing.
score_theme_contrast
Score WCAG contrast ratios for a theme’s salient color pairs (foreground on background, primary-foreground on primary, muted-foreground on background, …) in the light palette, the dark palette, or both.
| Arg | Type | Required | Description |
|---|---|---|---|
mode | string | no | "light" or "dark" to score one palette; default both |
theme | string | no | Named theme to score; default "default". An unknown name is a BadRequest listing the known themes |
Returns { theme, summary: { total, passes, fails }, results } where each result carries label, mode, fg, bg, ratio, and a tier: AAA, AA, AAlarge, or Fail. Use it after reseeding a palette or tuning dark tokens.
add_theme
Create a named theme (theme/<name>.json) by cloning an existing one. Returns { name, theme }.
| Arg | Type | Required | Description |
|---|---|---|---|
name | string | yes | Lowercase kebab, not "default" |
from | string | no | Source theme to clone; default "default" |
overwrite | boolean | no | Default false; without it an existing name is refused |
Boards pin a named theme via update_board with patch: { theme: "<name>" }; screenshot, render_snippet, and compare_to_url accept a theme param, and a screenshot of a screen on a pinned board uses that board’s theme by default. Edit a named theme afterwards with set_theme and its theme param.
update_theme
Rename a named theme and repoint every board that pinned it. Returns { name, repointedBoards }. To change a theme’s tokens, use set_theme with its theme param.
| Arg | Type | Required | Description |
|---|---|---|---|
name | string | yes | Theme to rename |
renameTo | string | yes | New name: lowercase kebab, not "default", not already taken |
The default theme can’t be renamed, and an unknown name is refused (InvalidThemePath).
remove_theme
Delete a named theme. Returns { removedTheme }.
| Arg | Type | Required | Description |
|---|---|---|---|
name | string | yes |
Refuses while any board still pins the theme, naming those boards; repoint or unpin them first with update_board (patch: { theme: null }). The default theme can’t be removed.
list_themes
List named themes and which boards use each. Takes no arguments. Returns { themes: [{ name, usedByBoards }] }; boards with no pin count toward default.
import_theme
Code-to-design: seed the theme from a host app’s stylesheet instead of picking colors by hand. Apply this before porting screens so copied classes render.
| Arg | Type | Required | Description |
|---|---|---|---|
css | string | one of css/cssPath | Stylesheet text (an empty string counts as not provided) |
cssPath | string | one of css/cssPath | Path to the app’s globals.css, either absolute or relative to the host app root (globals.css normally lives outside the design folder); the design folder’s parent and then the design folder are tried next |
theme | string | no | Named theme to merge into; default "default" |
apply | boolean | no | Persist the merge. Default false; dry-run |
tailwindConfigPath | string | no | Path to the app’s tailwind.config (absolute, or relative to the design folder). Auto-detected near cssPath when omitted |
What it parses:
- shadcn-convention custom properties:
:root/.darkvars like--background(raw HSL triplets or any CSS color), withvar()indirection resolved - Tailwind v4
@theme--color-*vars, plus--radiusand--font-*roles - every non-semantic color var (numeric scales like
--primary-600, extra roles like--success-500, bare brand names like--paprikaand--ink) into the theme’spalette, so verbatim app classes likebg-primary-600orbg-inkresolve literally on the canvas
It also ingests the tailwind.config’s theme.extend (given explicitly, or found near cssPath): brand colors to palette, named spacing to spacing tokens (so w-icon-rail / h-header resolve), boxShadow to shadows, fontFamily to font roles, and keyframes + animation to --animate-* utilities. CSS-derived values win over config literals of the same name. The app’s container config (center/padding/max-width) is applied so class="container" matches the app, and the result reports it as container: { detected, suggestedClasses, note }.
Slots the CSS doesn’t declare keep their current values. Returns { applied, changeCount, coverage, changes, warnings }. changes is [{ token, from, to }], and coverage counts how many semantic slots mapped versus how many values went to the palette passthrough, with a one-line summary. A dry-run adds a note; pass apply: true to persist. When the host app depends on a client chart library, the result carries detectedChartLibs and a chartHint to register the app’s chart components as render: "live" extensions.