Where a theme has to land

In mid-August, an app’s theme could not recolour a single component. The theme arrived with the render and it was valid. The runtime wrote it into the page, and the browser applied it to the page’s root element. Nothing inside the card changed, because nothing inside the card read from there. The fix, one commit on 15 August, added the same values in a second place in the same document.

That bug is the smallest version of a larger rule. Inside the frame a GGUI card renders in, two parties have a say over how it looks. The host decides light or dark, and the app decides the colours. Neither decision reaches the components unless its values are merged into the one block of CSS variables they actually read. Ownership says whose value should win. Placement decides whether a component ever sees it.

Two questions with two owners

A generated card sits inside somebody else’s interface, such as a chat, an IDE panel or a dashboard. Two questions decide whether it looks like it belongs there, and they have different natural owners.

Light or dark is a fact about the surroundings. The user chose it in the host’s settings, or the host follows the operating system. The app that produced the card cannot know it when the theme is authored, and the host knows it.

The colours are a fact about the app. A bank’s card should look like the bank on every host it appears in. The host has no business choosing the bank’s primary colour.

The MCP Apps specification gives the host a channel for its half. A host announces theme: "light" | "dark" and a styles.variables map in its host context, plus styles.css.fonts for font loading. GGUI adds the app’s half: when an app has a theme, each generated card’s render carries it in the _meta["ai.ggui/render"] slice. This is the shape, from a protocol conformance fixture:

{
  "overlayHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "overlays": {
    "light": { "--ggui-color-primary-500": "#3355ff" },
    "dark": { "--ggui-color-primary-500": "#99aaff" }
  },
  "mode": "light",
  "name": "ocean"
}

Two things in that shape follow from the ownership split. The app ships both modes’ colours, because it cannot know which one the host will ask for. The schema makes both required, on the grounds that “an overlay that could not follow the host’s mode is not accepted”. And mode is only a default, the lowest-ranked of several, so a host’s announcement always overrides it. (A real theme sets far more than one variable. This fixture tests only the grammar.)

The mode belongs to the host, as of 0.16.0

It was not always this way. Before 0.16.0 the host came last. Whatever mode the render stamped won, and the host’s announcement only filled in when the render said nothing. The client-side resolution was one line, and here it is before and after:

// before 0.16.0
return s.stamped ?? s.sessionSidecar ?? s.hostAnnounced;
// since 0.16.0
return s.hostAnnounced ?? s.stamped ?? s.sessionSidecar;

The runtime’s own comment on its mode resolver gives the reason in one sentence. “A card that ignored the host’s announced mode painted a light skeleton inside a dark chat — the host’s word is what the user actually sees around the card.” An app that pins light has no way to know it is pinning a white rectangle into a dark conversation. The host sees exactly that, so the host decides.

Reordering a fallback chain sounds like a small edit. It was treated as a breaking change, and the protocol’s conformance kit carries the receipt. Just before the flip, the old order was promoted into the kit as a case, a stamped light and a host dark expecting light, so that the change would be graded. When the order flipped, that case failed, and a case that passes on one version and fails on the next is how GGUI’s version policy defines breaking. (Before 1.0, semantic versioning lets a breaking change ship in a minor release.) The case now expects dark and its description records that history. The 0.16.0 changelog entry states the rule for users. The embedding host’s announced mode wins, and the app’s mode is a default.

Handing the mode to the host is what makes both overlays mandatory, and it has a second payoff. A card holds both sets from the moment it mounts, so when the user flips the host’s theme mid-conversation, the card re-paints from the retained other set with no network activity.

The palette belongs to the app, by document order

The host has a palette too. MCP Apps standardises it as --color-* variables in styles.variables. For a GGUI card that does nothing, and the bridge module says why. The specification’s helper applies the host’s variables as inline custom properties on the frame’s <html> element, while generated UI and the design system “consume exclusively --ggui-* tokens”. The host’s variables are correct behaviour that repaints nothing GGUI renders.

So the runtime translates. A small table maps the specification’s background, text and border colours onto GGUI tokens. The host’s primary background becomes --ggui-color-ground, the canvas a card sits on, and its primary text becomes the ink on both the canvas and the card. Status text goes to each status ramp’s 500 stop. The translated palette then goes into the same block as everything else, underneath the app’s theme. The renderer’s own comment gives the injection order as one cascade, lowest first.

compiled ladder (themeId ?? default) < hostPalette < overlays[mode] < cssVariables < cssOverrides

Mode and palette are resolved by different mechanisms, and the difference is deliberate. Mode is an either/or on a single value, settled with ?? before anything is painted. Palette is settled by the browser, through CSS document order, where later declarations of the same variable win. The runtime’s own comment at the point where it threads the host palette puts the two side by side, noting that “unlike mode there is no either/or gate here, because the precedence is CSS document order inside the scoped block”.

GGUI’s theme write door refuses a theme that leaves a token a card reads unset, in either mode, apart from a few the door exempts, because a theme has no slot for them or the ladder always declares them, such as the motion tempo. Every slot the bridge can fill is among the tokens it checks. An accepted app theme therefore declares each of those slots again, later in the block, and of two declarations with equal importance the later one wins. The host’s colours show only where a render carries no app theme, which is exactly the case where a card should borrow them. A preset referenced by a theme id, from a render’s themeId or the app’s default, is a compiled ladder rather than an app theme, and it sits beneath the host palette too. A preset picked in the console or deployed from ggui.json ships as an app theme and wins like one.

That is the asymmetry of the whole design. The host owns mode, so the host’s value is checked first. The app owns the palette, so the app’s values come last.

A valid theme can land where nothing reads it

None of that ownership decides whether a value is read at all, and that is where the August bug lived.

The renderer mounts every card inside a scope element with its own <style>, as the first child of that element. The base ladder that fills it is the theme’s :root { … } block rewritten onto the scope class. The consequence to remember is that the scope element itself declares every token a theme can set. Shortened, with values from GGUI’s own fixtures (the conformance fixture’s overlay and a host-palette test), a dark card in a dark host renders like this:

<div class="ggui-rcr-1">
  <style>
    .ggui-rcr-1 { /* the compiled ladder: every token, dark values */ }
    .ggui-rcr-1 { --ggui-color-ground: #101014; --ggui-color-onGround: #f4f4f5; } /* host palette */
    .ggui-rcr-1 { --ggui-color-primary-500: #99aaff; } /* overlays.dark */
  </style>
  <!-- the generated component tree -->
</div>

Each later rule redeclares variables on the same element, so the app’s overlay wins over the host palette, which wins over the ladder. Now look at the two places a theme can be written and do nothing.

On :root. Custom properties inherit, so a value set on the root element reaches every descendant unless something closer declares it again. The scope element declares every one of those tokens, and an element’s own declaration always beats a value inherited from an ancestor. Document order does not enter into it. A :root rule placed after the whole block would still lose. This was the August bug. The theme sat on the root, and every component below the scope read the scope’s value instead.

In a .ggui-rcr-1 rule in the document head. This one does match the scope element, with the same specificity as the block’s own rules. Now document order decides, and the head comes before the body, so the block redeclares the value and wins.

What takes is a rule that targets the scope element and comes after the block’s own rules. The renderer puts the app’s theme inside the block, after the ladder, which is what the fix did. Its message records one browser probe with three results. A root injection had no effect, a head scope-selector injection had no effect, and a body-end injection took. The message puts both failures down to document order, and the renderer’s own comment still does. That is right for the second and not quite for the first, which lost to the element’s own declaration rather than to a later rule. The difference matters when you debug one of these, because moving a :root rule later in the document fixes nothing.

The :root copy still exists. The renderer writes it for body chrome only, meaning the page’s own font and background around the card, which resolve against the root. Tree content resolves the scoped block.

You can tell which placement a value reached from the browser’s developer tools. Read the variable with getComputedStyle twice, once on the frame’s root element and once on an element inside the card. If the element inside the card reports your value, it reached the scope element, in the block or in a later rule that targets it. If only the root reports it, it is sitting on :root and the scope is shadowing it. If neither reports it, the value either never reached the frame or sits in a rule the block outranks, such as a scope-class rule in the head, and the Styles pane shows which rule won.

Font faces and color-scheme stay outside the block

The rule covers the values components read through var(--ggui-*). Two parts of a theme are handled at the document level instead, on purpose.

Font faces. An @font-face rule is a top-level at-rule that no selector can scope, so a face is global to the document wherever it is written. The composer renders a theme’s declared faces only in the document-level layers, “never by the scoped tree block”. Faces the host sends through styles.css.fonts are installed under one style element that each new payload replaces. The family names a component asks for are still variables, in the block, and a card uses a host’s face only when its theme names that family. The files behind those names are loaded document-wide.

color-scheme. Native controls such as scrollbars, form widgets and the date picker take their appearance from color-scheme, which is an ordinary inherited property. The composer sets it on the root to the effective mode, the same mode that picked the ladder and the overlay, so the three agree.

Tokens were supposed to be constants

The design system’s token module still opens with the original rule, that its values “are platform constants (never per-app)”, and it lists spacing, typography, transitions and motion among them. On main a theme can set every one of those per app.

A theme now projects spacing and radius, shadows and a radius for controls separate from cards, motion durations and easings, and the type scale, weights and tracking. Every primitive’s transition reads the motion variables instead of a hard-coded time.

The reason is the one that gave the host the mode, fitting the surroundings, but these values travel in the app’s theme, not from the host. A card can match a page’s colours exactly and still look foreign if it breathes on a different rhythm, so a theme can carry the rhythm, type scale and tempo of the page the card will sit in. The spacing code puts it concretely. A host whose page “breathes on a 6px rhythm gets every step multiplied by 6/4 and keeps its own unit.” Colour is only the most visible part of fitting in.

Treating these as variables also changed how components are allowed to use them. The MCP Apps specification asks views to set default fallback values for the host variables they use, because a host may send any subset. GGUI keeps that guarantee one level up. The compiled ladder at the bottom of the block is the default for every token a theme can set, so a missing host colour falls through to it. A literal fallback inside a generated component is refused instead. GGUI’s generation check rejects var(--ggui-…, <literal>) on every token, because “the runtime injects every token — colors, spacing, typography, radius, shadows”. With every such token guaranteed, a literal could only paint default pixels under a failed injection, and that failure must be visible rather than silently plausible.

A host announces, an app ships both modes

For a host, the contract is the one MCP Apps already defines. Announce theme, send styles.variables if you have a palette, and send styles.css.fonts so the faces a card’s theme names can load. The runtime does the translation and the placement.

For an app, three things follow. Ship both modes, because the host picks. Cover the tokens a card reads, because the write door refuses a theme that leaves one uncovered. And do not expect a card to change because something wrote a variable to the frame’s root. Components read the block on their scope element, and a value on the root is invisible to them however correct it is.

The how-to for authoring a theme lives in the docs, in Custom Theming and Design Tokens. The host side follows the MCP Apps specification’s Theming section.