The Wallpaper Runs as You

How to build a Claude Code desktop theme with makeColors and an animated SVG, why my first palette passed validation but would have painted nothing, and how to read what a theme hooks before you install it.

A frosted-glass app window over an animated aurora night landscape, a fan of color swatches, and glowing circuit lines running under the wallpaper to a golden key.

You can now repaint the Claude Code desktop app: your own colors, and an animated scene behind the conversation with frosted panels over it. There is no theme file to drop in a folder. A theme is a mod, a small TypeScript program that Claude Code loads at session start. A mod runs with your permissions, which is worth knowing before you install one.

Start here

A theme is one call

Mods arrived on October 1. Anthropic's launch post called them "small TypeScript functions that change how Claude Code works." In September I wrote about the proposal behind them.

The whole theme goes through one method, $.theme.set, usually called when the session starts. It takes three attributes: colors, a palette for the app to paint itself with; backdrop, what sits behind the conversation; and overrideColorScheme, which forces light or dark (the type docs say the desktop app ignores it for now). Two rules follow from "one call." A set replaces the whole theme, so whatever it leaves out is gone. And there is only one theme per session, whichever mod set it last. $.theme.clear() clears any mod's theme, which is why the type docs say to clear only on a person's ask, never at session start.

Here is my whole theme module, Tokyo Night colors and a slow night sky (validated on Claude Code 2.1.325, not yet run in the desktop app); the SVG string lives in a second file.

import type { Register }
  from 'claude-code'
import { makeColors }
  from './make-colors.js'
import { SKY } from './sky'

const { theme } = makeColors({
  label: 'Tokyo Night',
  only: 'dark',
  roles: {
    accent: '#7aa2f7',
    danger: '#f7768e',
    success: '#9ece6a',
    warning: '#e0af68',
    pro: '#bb9af7',
  },
  dark: { gray: { anchors: {
    'surface-0': '#16161e',
    'surface-1': '#1a1b26',
    'surface-2': '#1c1e2b',
    'surface-3': '#1f2335',
    'text-primary': '#c0caf5',
    'text-secondary': '#a9b1d6',
    'text-muted': '#8089b3',
  } } },
})

const glass = {
  blur: 14,
  tint: '#1a1b26',
  opacity: 0.6,
}

export const register:
  Register = on => {
  on('session.start',
    async ($, e, next) => {
      await $.theme.set({
        colors: theme,
        backdrop:
          { scene: 'sky', glass },
      })
      return next(e)
    })

  on('ui.render',
    { component: 'Backdrop' },
    ($, e, next) => {
      if (e.surface != 'desktop')
        return next(e)
      const ui = $.ui.resolve(e)
      return <ui.Svg
        isInteractive
        source={SKY}
        alt="A starry sky" />
    })
}

Colors: don't write them by hand

On my first attempt I wrote the palette the obvious way: the Tokyo Night hexes under names like surface-0, text-primary and fill-accent, straight into colors. claude plugin validate passed, with one unrelated TypeScript warning and nothing about the colors. The mod would have loaded. Then I ran checkColors, a function that ships next to makeColors in the built-in plugin-authoring skill, and it answered every key the same way: "isn't a theme color name, so it paints nothing."

The skill warns about exactly this. A theme "is about 100 colors in each mode, under design-system names you can't guess, and a wrong name paints nothing and says nothing. So don't write one by hand." Names like surface-0 and text-primary are anchors, which only makeColors understands. The engine checks the shape and size of what you send, not whether the names mean anything, and the skill says plainly that "claude plugin validate doesn't look inside a look."

So the palette goes through makeColors. You give it up to seven gray anchors (surface-0 is the darkest surface, then cards, panels and menus, then three text levels) and five role colors: accent, danger, success, warning and pro. Code highlighting is derived from the roles. From my seven anchors and five roles it produced 101 dark-mode tokens.

It also returns problems, a list of contrast checks against the design system's own bars. My first makeColors palette had 49 lines, none of them a won't-paint line. Pulling surface-2 and surface-3 closer to surface-0, as the skill suggests, brought it to 37. Only a line starting "Desktop won't paint this theme" is fatal, because then the desktop app ignores the whole theme. The rest still paint, and "an empty list isn't the goal."

Three failures get past claude plugin validate:

What fails silentlyWhat catches it
A colors key that isn't a design-system namecheckColors
A palette Desktop refuses to paintA problems line starting "Desktop won't paint this theme"
A backdrop scene with no ui.render hook on BackdropNothing automatic: check the hook exists

The scene: one SVG

A still background needs no hook: backdrop can be a color, a gradient, a vector drawing or a JPEG or PNG in the mod's folder (at most 1 MiB and 4096 pixels a side). Anything that moves is a scene. You name it in backdrop.scene, and a ui.render hook on the Backdrop component returns an Svg element when the app asks.

The SVG can be at most 131,072 characters. It animates with CSS inside its own <style> element, and per the skill, "It runs no script and loads nothing." Glass is how the app keeps text readable over the art: blur from 0 to 64 points (16 by default), a tint and an opacity (0.6 by default) for the frosted panels.

A dark Tokyo Night sky with scattered stars, a soft blue glow at the top left, a violet glow at the bottom right and a dark mountain ridge along the bottom.
A still render of the Tokyo Night scene's SVG. Animated, the stars twinkle and the glows drift.

The SVG my scene builds is about 5,600 characters: a gradient sky, 70 stars twinkling on three offset CSS timers, a blue glow and a violet glow drifting over 90 seconds, a dark ridge along the bottom, and one media query that stops all of it under prefers-reduced-motion. Anthropic's examples follow the same rules and add a design rule in a code comment: large, soft, slow shapes, no filters so no GPU is needed, and the middle of the window, where the conversation sits, stays calm.

What the example plugin teaches

Anthropic's theme-examples plugin, merged into the official marketplace on November 13, is the worked example for scenes. Its twenty scenes run from an ASCII lagoon with a swimming turtle to a departure board, a braille starfield and pen-plotter tides; eight form four light-and-dark pairs. It sets backdrops and glass only, not colors. The command is /custom-theme because /theme already belongs to the terminal's older theme system, JSON files in ~/.claude/themes/.

It's also explicit that it's an example. The pull request that added it calls it a renamed copy of a non-public plugin and "not a set of themes that will be maintained long-term."

Its README is the part I'd copy into every mod. It says what the plugin reads and keeps: it scans what Claude says for one word, "merged", to set off fireworks, and keeps none of it; it reads no prompt text; it stores theme names per project path and per session ID for the last 200 sessions; "It sends nothing anywhere." It lists its own known limits too, down to the keyboard focus on its theme card.

The trust question

A theme is a mod, and the mods docs don't soften what that means: "A mod is code that runs with your permissions. It can read and write your files, start processes, and make network requests." Turning on Claude Code's sandbox doesn't change it: "a process that a mod starts runs outside it."

The scene is the safe part. An SVG with no script that loads nothing can't do much. The module that hands it over can do everything you can.

So read what claude plugin validate reports before you install. For the example plugin, it lists hooks on session start, prompt submit, its two commands, Claude's responses, and three drawing sites (the backdrop, rows in the transcript and the band above the prompt). Its calls include the mod's own store, a file stat, an environment variable, the session's ID, folder and repository, and setting and clearing the theme. The lines match the README: the fireworks are why it sees Claude's responses, and the project lookup is why it reads the folder and repository. The README tells you what it does with that access; the validate output tells you it has it.

A rule of thumb for themes from strangers: a static theme needs session.start, and a moving one adds a ui.render hook on Backdrop. Anything beyond that, such as reading prompts or responses, tool-call hooks, files, environment or processes, should have a reason you can find in the README or the code.

One limit: "A mod can restyle much of Claude Code's interface, but not the permission prompt." And on Team and Enterprise plans, and on machines with managed settings, Anthropic's built-in guard mod loads first and stops user-installed mods from risky things, such as overriding your permission deny rules.

What's rough

This section is opinion.

  • Validation stops at the shape. checkColors exists and catches a palette that paints nothing; the validator should run it.
  • Two things called "theme." The terminal's /theme and the desktop's $.theme are unrelated.
  • The trust model is all or nothing. A wallpaper and a tool-call interceptor install the same way, with the same access, and the validate output is the only automatic map of what a theme touches. An install-time summary in plain words ("this theme reads Claude's responses"), or a narrower permission for mods that only call $.theme.set, would make the common case safer.