Theming
read as.mdA @guuey/chat theme is data, not CSS — a serializable token object you can keep in code, in config, or in a database. The React kit projects it as --guuey-chat-* CSS custom properties on the transcript root; the React Native tier maps the same object to style values. One schema, two projections.
The schema
Section titled “The schema”interface GuueyChatTheme { name: string; /** Default appearance. Optional — your own mounts pass `mode`; hosted surfaces use it when the host announces nothing. */ mode?: "light" | "dark"; /** BOTH palettes always present — which one paints is a runtime choice, never a second theme object. */ colors: { light: GuueyChatPalette; dark: GuueyChatPalette }; typography: { fontFamily?: string; monoFontFamily?: string; headingFontFamily?: string; // display face; falls back to fontFamily scale?: number; // one size knob — generated cards derive their eight stops from it faces?: GuueyChatFace[]; // declared web fonts — the widget injects their @font-face rules and hands them to the cards }; shape: { radius: "none" | "soft" | "round"; density: "compact" | "comfortable"; // the widget's own spacing knob — never reaches cards shadow?: { color?: string; intensity?: number }; // intensity 0–1 glass?: { opacity: number; blur?: number }; // chrome translucency — FROSTED IS THE DEFAULT (0.82 / 14px); state { opacity: 1 } to opt out; the widget's chrome only, never cards }; /** The four ggui token members guuey writes for generated cards (ggui#1093 R1). All optional and default-less: unstated = ggui derives it, byte-identical to a document that never had the key. None of them touches the widget's own chrome. */ typeScale?: { display?: GuueyChatTypeRole; // the roles ggui projects to its --ggui-font-* stops h1?: GuueyChatTypeRole; h2?: GuueyChatTypeRole; body?: GuueyChatTypeRole; // ONE WRITER: derived from typography.scale when only the scale is stated; a document stating BOTH scale and body.size is refused label?: GuueyChatTypeRole; }; rhythm?: { base: string; section?: string; inset?: string }; // CSS lengths — base re-derives the cards' spacing scale; section / inset are named steps motion?: { duration?: { fast?: string; base?: string; slow?: string }; // CSS times — a tempo override over ggui's shipped scale, never a new scale easing?: { standard?: string; emphasized?: string; exit?: string }; // CSS easing functions }; scrim?: { tone: "light" | "dark"; opacity: number; blur: number }; // the three knobs together — opacity 0–1, blur in px; the scrim colours derive from the ground pair /** Per-court overrides, resolved over the base theme — advanced. */ courts?: Record<string, unknown>;}
interface GuueyChatTypeRole { size?: string; // CSS length weight?: number; // 100–900 tracking?: string; // CSS length leading?: number; // unitless line-height}
interface GuueyChatPalette { accent: string; // primary action + user bubble onAccent: string; // text on accent ink: string; // primary text inkMuted: string; // secondary text surface: string; // bubbles, cards canvas: string; // page background behind the transcript canvasMuted: string; // grouped/collapsed rows, wells error: string; link?: string; // anchors; unstated = derived from accent on cards secondaryAccent?: string; // a second brand colour for cards' tertiary actions onSecondaryAccent?: string; // text on the secondary accent success?: string; // tone anchors — a family exists on cards only once stated warning?: string; info?: string;}
interface GuueyChatFace { family: string; src: string; // https:// on Google Fonts, Adobe Fonts, Fontshare, Bunny Fonts, or one of the app's allowed domains weight?: string; // e.g. "400 700" style?: string; display?: string;}Both palettes are always present in a theme; which one paints is the mode prop ("light" / "dark") on your own mounts — so switching modes is a runtime choice, never a second theme object. The theme’s own mode is its default appearance: on hosted surfaces it applies when the host announces nothing, and a host’s explicit theme outranks it (see Precedence below).
Frosted glass is the default. An app that states no glass paints guuey.com’s own look: the panel and ask bar are a translucent canvas (opacity 0.82) with a 14px backdrop blur and a soft shadow (shape.shadow 0e1014 at 0.22), and the transcript frame runs transparent so the host page shows through; bubbles and cards stay solid. A theme inherits it unless it states glass: { opacity: 1 } (opaque) — in the file, the console’s Glass control, or a “Start from” document (“Opaque (no glass)”). One residual: the frame’s transparency depends on the host’s color-scheme matching the widget’s effective mode — the widget states its own on both sides, and a host page that declares color-scheme: dark around a light-mode widget may show an opaque panel canvas instead of the through-blur (the tint still paints).
What reaches where today. The palette tokens paint both the widget’s own chrome and the generated cards inside it. A save always lands on the widget; when the generated cards’ side does not follow it — ggui refuses an overlay that would drop members it holds that guuey does not write, or a cleared theme is kept there until a member-level clear exists — the console’s Design page and the CLI say so beside the save, naming the members, instead of a bare “Saved”. typography.fontFamily and monoFontFamily reach both. typography.fontFamily also sets the widget’s own chrome — header, composer, buttons — not only the transcript. typography.scale and shape.radius style the widget’s chrome and reach generated cards as well — the scale as the base of the cards’ font ramp, the radius as a five-stop ladder and, in the widget, as the transcript’s bubble radius (0 / 10 / 18px) and the expanded layout’s panel radius (none 0, soft 16px, round 24px); shape.glass.blur is the blur behind those panels as well as behind the host-page panel; shape.density is the widget’s own spacing knob and never reaches a card. Generated cards derive their colour ladders — hover and pressed steps, tints, container pairs — from the palette anchors (accent, error, and the optional secondaryAccent / success / warning / info); there is no ladder to state. link paints anchors on generated cards; unstated, the cards derive it from the accent. typeScale, rhythm, motion and scrim are the cards’ side only: stated, they travel with the theme into the projection that builds each generated card’s variables — the type roles to the cards’ font stops, rhythm.base re-deriving their spacing scale, motion as a tempo override over ggui’s shipped motion scale, scrim as three knobs whose colours derive from the ground pair — and the widget’s own chrome does not read them. Unstated, ggui derives every one, byte-identical to a document that never had the key, so a theme written before these members existed renders unchanged. The body size has one writer: typography.scale already feeds the cards’ font ramp, so when only the scale is stated typeScale.body.size is derived from it, and a document stating both is refused at apply time rather than letting one silently override the other. typography.faces declares web fonts to load: the widget injects each face’s @font-face rule for its own chrome and transcript, and the faces travel with the theme to the generated cards’ side. A face must be an https font file (.woff2, not a stylesheet) on fonts.gstatic.com, fonts.googleapis.com or one of your app’s own allowed domains; anything else is refused when you save. A face that fails to load in the browser falls back down your fontFamily stack, so state fallbacks you are happy with. courts are per-court overrides resolved over the base theme: stored with the document and read by the live widget; no console or CLI reader shows them yet. shape.glass is the widget’s chrome only: the panel, bar and cold-open shell on the host page and the frame’s own chrome strips turn translucent over the page at that opacity, with the backdrop blurred; cards and bubbles stay solid, and a standalone page (nothing behind it) is never glass. Both palettes reach an app’s hosted cards: the projection derives one overlay per mode and stamps both, with the theme’s default mode.
The derived ladders run in opposite directions per mode: in light mode hover and pressed are darker than the anchor; in dark mode they are lighter. You state one anchor per palette and the direction is handled for you. The pre-revision ramps member has left the vocabulary, and the two doors treat a stray one differently: a manifest theme.json (the file guuey.json names under app.theme.file) that still states it is refused by the CLI’s strict manifest schema — move the values to anchors; a chat-kit theme document (--chat-theme-file, or the stored chatTheme) that still carries it is accepted and the key is ignored — the anchors are what paint either way.
The two shipped themes
Section titled “The two shipped themes”DEFAULT_CHAT_THEME— brand-neutral and polished; what you get without configuring anything. Designed to sit inside your product without announcing guuey.GUUEY_CHAT_THEME— the guuey visual identity (the widget’s shipped palette). Use it when you want the surface to read as guuey.
import { GUUEY_CHAT_THEME } from "@guuey/chat";
<GuueyChat endpointUrl="…" appId="…" theme={GUUEY_CHAT_THEME} mode="dark" />;Your own theme
Section titled “Your own theme”resolveTheme merges a partial theme over the default per token — supply only what you change:
import { resolveTheme, type GuueyChatTheme } from "@guuey/chat";
const brandTheme: GuueyChatTheme = resolveTheme({ name: "acme", colors: { light: { accent: "#7c3aed", onAccent: "#ffffff" }, dark: { accent: "#a78bfa" }, }, shape: { radius: "round", density: "compact" },});
<GuueyChat endpointUrl="…" appId="…" theme={brandTheme} mode="dark" />;Every token you omit falls back to the default theme’s value — a half-configured theme can never produce an unreadable surface.
The evolution promise
Section titled “The evolution promise”The schema is built to be stored: parsing is lenient (unknown keys pass through rather than failing), changes to the schema are additive-only, and every new token ships with a default fallback — so a theme object saved today keeps parsing against every future version of the package.
That posture is what makes the theme safe to store as platform data — which is exactly what per-app theme configuration does.
Theme the hosted surfaces
Section titled “Theme the hosted surfaces”You can set a theme once per app in the console: your app’s Design page on platform.guuey.com has a theme editor over this same schema, with a live transcript preview, validated on save. Every surface guuey hosts for the app picks it up — the embedded widget, the app’s standalone page, and Portal chat — with no redeploy.
The editor lives on the app’s Design page in the console; branding and the distribution tabs are covered on the same docs page — Design & distribution.
Managing the app from its repo with agents-as-code? Declare the same theme in guuey.json under app.theme — either the document inline, or a file reference:
"app": { "theme": { "file": "theme.json" } }theme.json carries the theme document itself (mode, both colors palettes, and optional typography / shape / typeScale / rhythm / motion / scrim). This tier is strict: lengths are px / rem / em, weights 100–900, leading 0.8–2.5, durations ms / s, easings a CSS keyword or cubic-bezier() / steps(), scrim opacity 0–1 and blur 0–64, and an unknown key is refused. The CLI resolves the path relative to guuey.json at apply time and submits the resolved document — the platform validates colour values on the resolved bytes, and guuey agent apply converges the stored theme like any other declared field.
Whichever door you use, the platform validates the document before it lands: name at most 64 characters; typography.scale between 0.75 and 1.5; font strings at most 200 characters; colours in one grammar — lowercase #rrggbb or #rrggbbaa, or rgb(r, g, b) / rgba(r, g, b, a) — no three-digit shorthand, no colour names, no hsl(), var() or calc(); the whole document under 16 KiB. A rejected field is named in the error, so you fix that one token.
From the CLI, guuey apps update <appId> --chat-theme-file theme.json sets the same theme on a deployed app without a repo binding, and --clear-chat-theme removes it. Every door writes the same document: the console’s Design → Chat theme editor; the CLI flag; the platform MCP server’s apps_update tool ({ "id", "chatTheme" } — for a coding agent that has the Guuey platform server connected); app.theme in guuey.json — converged by guuey agent apply for declarative apps, and applied by guuey deploy right after the build goes live when the app has no chat theme yet (the same PUT as the CLI flag; a deploy never overwrites an existing theme — the console’s or an earlier write — it prints the doors that do; a refused document prints the field and the door, never a failed deploy); the theme prop on @guuey/chat surfaces you render yourself; and the coding-agent prompt below, which ends in one of those.
Precedence. On every surface guuey paints for an app — the widget panel and its launcher chrome, the standalone page, generated cards, the share-image card, and the console’s Embed preview — the chat theme (resolved over the kit default, for the effective mode) wins over brandAccent (Settings → General: accent only, on the guuey default palette), which wins over the guuey default; the effective mode is the host’s explicit theme (light or dark), else the theme’s declared mode, else light.
Your own @guuey/chat mounts are unaffected: the theme prop you pass always wins on surfaces you render yourself.
Give this to your coding agent
Section titled “Give this to your coding agent”Copiable, for Claude Code, Codex or Cursor. It asks only for what this page and the Embed page describe.
Theme the Guuey chat widget to match this site. Before anything, readhttps://docs.guuey.com/chat-theming.md andhttps://docs.guuey.com/embed.md (section "Customize the launcher") and stayinside what they describe — use only the theme fields the schema names.
1. Read this site's styles and pick its palette (light and dark if it has both), font family, corner radius and density.2. Write a theme document as theme.json: "name", "mode", both "colors" palettes ("link", "secondaryAccent", "success", "warning" and "info" are optional anchors), "typography" (optionally "headingFontFamily" and "faces"), and "shape" (optionally "shadow" and "glass"; frosted glass is the default — omit "glass" to keep it, state { "opacity": 1 } to opt out) — nothing outside the schema; leave "courts" alone.3. If this repo manages the Guuey app as code (guuey.json with an "app" section), set "app": { "theme": { "file": "theme.json" } } and tell me to run guuey agent apply. Otherwise tell me to run guuey apps update <appId> --chat-theme-file theme.json (or the platform MCP server's apps_update tool, if it is connected to you), or hand me the JSON to paste into the console's Design → Chat theme editor.4. The launcher bubble is styled by the embed snippet, not the theme: propose values for the init options color, iconColor, shape, position and theme (light or dark) that match the site, and show me the updated snippet.Stop and ask me before any step those two pages do not describe.Fine-grained control
Section titled “Fine-grained control”The projection is ordinary CSS custom properties, so anything the token schema doesn’t express can be adjusted in your own stylesheet against the kit’s class names — or by replacing a component slot entirely. Prefer tokens first: they’re what survives schema evolution, and what the console’s per-app theme stores.