Skip to content
MCP: theme

Documentation

Velloo documentation

Markdown for LLMs ↗

MCP: theme

set_theme (tokens, presets, seed colors, fonts, typesets, custom CSS), named themes, set_design_language, 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 (reads such as get_theme through read_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, including colorsDark, 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 replaces colors and colorsDark, keeping the rest of the theme. It accepts #hex, named colors, rgb(), hsl(), oklch(), and other CSS color forms; an unparseable color returns InvalidColor. Foregrounds are nudged toward black or white until each pair reaches WCAG AA (4.5:1). Follow up with score_theme_contrast to 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. colorsDark holds only the slots that differ in dark mode; the rest fall back to colors.
  • palette.* / paletteDark.*: a flat passthrough of raw brand colors that does not flip. Set palette.ink to "#1a1a1a" and bg-ink / text-ink / border-ink resolve literally, the mechanism that makes a host app’s verbatim brand classes work. Keys must be lowercase kebab (ink, primary-600). import_theme populates it automatically. A palette key named after a semantic slot (palette.primary) is skipped on emit and in the canvas; the result carries a warnings entry telling you to set colors.<slot> or rename the key.

Token paths are read the way they are written elsewhere, and applied.readAs says how: colors.card-foreground is colors.card.foreground, and a colour the semantic set has no slot for (colors.success, colors.warning.DEFAULT) is stored as a palette colour. A name one slip from a real slot (colors.primry) is still refused, with the slot it probably meant.

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.

google: true loads the weights Velloo’s font catalogue lists for the family. For a family outside the catalogue that is regular only, and the result says so in applied.fontsRegularOnly; give the weights the app uses instead ("wght@400;600"). A family the catalogue knows is loaded even when declared without google. One that nothing loads is reported in applied.fontsNotLoaded: the canvas shows the fallback unless the machine has the font.

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 Kept as given: letters of either case, digits, spaces, -, and _ (“Night”, “Brand B”). Not "default"
from string no Source theme to clone; default "default"
overwrite boolean no Default false; without it an existing name is refused

A theme’s name is the stem of its file, so “Night Shift” is saved as theme/Night Shift.json. A name starts and ends with a letter or digit, runs to 48 characters at most, and is compared without case against the themes that exist, because macOS and Windows keep Night.json and night.json as one file. update_theme and the canvas’s new-theme dialog apply the same rule.

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, under the same rules as add_theme: not "default", not already taken

The default theme can’t be renamed, and an unknown name is refused (InvalidThemePath). A rename that changes only the case of a name moves the file.

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.

set_design_language

State the design’s rules in words: the half of a design language tokens cannot carry. It is the last step of proposing one, after import_theme or set_theme. The canvas shows the result in its Design language stage, beside a specimen of the theme on the design’s own components, where the user reviews and edits it.

Arg Type Required Description
intent string no What the design is going for, in one or two sentences. Omit to keep the saved one
rules array no [{ kind, text }], the complete list; it replaces the saved one. kind is "do" or "dont", and text is the rule without a Do/Don’t label. Omit to keep
{
  "intent": "A calm operations tool: dense tables, quiet chrome, one accent.",
  "rules": [
    { "kind": "do", "text": "One filled button per view" },
    { "kind": "dont", "text": "Shadows on cards" }
  ]
}

The rules are saved to the design folder’s own guidance.md, under Overview and Do's and Don'ts; anything else in the file is kept. Returns { intent, rules, source: { path, writable }, review }, where review tells the agent to send the user to the canvas: a proposal is not agreed until they have looked at it. Once saved, the file outranks Velloo’s generic defaults, and get_theme reports it as designSystem.path.

When the design follows a repository DESIGN.md, the call is refused with BadRequest. That file belongs to the repo; Velloo reads it and never writes it.

On the guided façade, description, overview, summary, or language is read as intent, rules may be plain sentences (“Don’t round corners” becomes a dont), and dos and donts lists are read as rules.

import_theme

Code-to-design: seed the theme from a host app’s stylesheet or a design-system DESIGN.md. Apply this before porting screens so copied classes render.

Arg Type Required Description
css string one source Stylesheet text (an empty string counts as not provided)
cssPath string one source 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
designMd string one source DESIGN.md text; use this or designMdPath, instead of CSS
designMdPath string one source Absolute or host-app-relative path to a DESIGN.md
mode "light" or "dark" no DESIGN.md only: destination palette; default light
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

For DESIGN.md, returns { applied, mode, designSystem, changes, coverage, dropped, prose, warnings }. Review mapping coverage and dropped values before applying. It maps Material 3, Bootstrap-style, and canvas/ink/hairline vocabularies and reads *-dark twins. Importing tokens does not update the source document or configure which document the design follows; that is designSystem.path in config.

For CSS, it parses:

  • shadcn-convention custom properties: :root / .dark vars like --background (raw HSL triplets or any CSS color), with var() indirection resolved
  • Tailwind v4 @theme --color-* vars, plus --radius, explicit @theme --radius-* steps, named --shadow-* tokens, and --font-* roles
  • the stylesheet’s relative @imports, when given a cssPath: each followed sheet is read where it is imported, so an entry sheet that only imports ./theme.css yields that file’s tokens
  • every non-semantic color var (numeric scales like --primary-600, extra roles like --success-500, bare brand names like --paprika and --ink) into the theme’s palette, so verbatim app classes like bg-primary-600 or bg-ink resolve 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 }.

Followed imports. Only files inside the app are read, at most five levels deep and 32 files. A bare file name (@import "theme.css") is followed when that file exists beside the importing sheet; otherwise it is a package. Packages (tailwindcss, @acme/theme/x.css), URLs, and site-root paths are never followed, and nothing is resolved through node_modules. An import with a media condition keeps it, so @import "./dark.css" (prefers-color-scheme: dark) lands in the dark palette; one under supports() is skipped and reported. importedStylesheets lists the files read, and warnings name any relative import left alone. Pasted css has no location, so its imports are untouched.

The corner scale. Slots the CSS doesn’t declare keep their current values, with one exception. --radius is the base the rounded-* steps derive from, so a stylesheet that states it replaces the theme’s radius scale with that base (none and full stay), plus any --radius-<step> it names outright. A sheet that states explicit steps and no base lays them on the existing scale. The steps a replacement drops are listed in changes with to: null, including steps someone set by hand, so read the dry run first.

Fonts. A font next/font sets at runtime is in no stylesheet. The import follows each font variable to the loader call in the app’s layout and sets the role, family, and weights.

Returns { applied, changeCount, coverage, changes, notImported, fonts?, warnings, importedStylesheets?, container? }:

Field Meaning
changes [{ token, from, to }]. to is null for a token the import removed
coverage How many semantic slots mapped versus how many values went to the palette passthrough, with a one-line summary
notImported [{ what, reason }]: what the app states that did not reach the theme. A font it could not trace (a local font file), theme.extend namespaces the theme has no token for, a body font the app never assigns to sans, a dark palette the stylesheet does not define. Empty means the import left nothing it saw behind
fonts [{ role, family, from }]: font roles set from the app’s loader calls rather than a stylesheet
importedStylesheets The stylesheet and the sheets it imports, when any were followed

A dry-run adds a note; pass apply: true to persist. A stylesheet with no theme tokens is not an error: nothing is imported, and the note says why and names any imports that were not followed. For an app themed in JavaScript it names the theme file velloo init already read, to compare with get_theme. An applied import into the default theme records the stylesheet as the design’s themeSource. 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.