Theming / Customization Contract

September 6, 2026 · View on GitHub

The dashboard is fully themable. A theme ranges from a color palette (Level 0) up to a full experience pack; a color theme is the degenerate case of a pack. Themes are a standalone subsystem built on useTheme, not apps. Source of truth: the in-repo system spec docs/system-specs/modules/themes.md. This document is the frontend pack-author contract; the spec governs the end-to-end subsystem (install pipeline, validation, routes, security model).

The rule for contributors

Pack manifest versioning: every theme.json MUST declare "formatVersion": 1 (integer). KiroCrew rejects packs with a missing value or an unknown major with an explicit "this pack requires a newer version of KiroCrew" error. Author against the current major; breaking manifest changes bump it.

Every new UI element MUST be themable at least at the color layer. Style it with the theme CSS custom properties or Tailwind classes mapped to them, never a hardcoded #hex / rgb(...) / rgba(...) literal.

// don't
<div style={{ background: '#16213e', color: '#fff' }} />
<div className="bg-gray-900 text-white" />

// do
<div style={{ background: 'var(--card)', color: 'var(--card-fg)' }} />
<div className="bg-[var(--card)] text-[var(--card-fg)]" />

The 56 CSS variables are the single source of truth for color. They are the customization surface a theme (built-in, custom, or installed) can set.

Fills are flat. The brand system is flat: a new decorative gradient fill (linear-, radial-, or conic-gradient used as a background or surface color) on chrome, a dialog, or an exported image such as a share card is a UX review finding. A fill is one solid token, or the brand purple #7c3aed on an outward-facing artifact. "Looks premium" is not an exception; a gradient also bands under every social platform's re-encode. Functional gradients are not fills and are fine: mask-image scroll-edge fades, loading shimmer, and the streaming glow. So are the shipped gradient mechanisms — the appstore gradient art in components/appstore/gradient.ts (which reads as content, not chrome) and the session 'gradient' color mode. The UX review lane (.github/workflows/ux-review.yml and its fork variant) applies this rule. Theme blocks in index.css also set a few non-color properties that are deliberately NOT on the allowlist: the font tokens (--font-body, --mono) and radii are injected as fixed defaults by buildCustomThemeCss (fonts are a pack-level L1 surface, not per-color-mode data), and the --search-highlight* trio is an internal find-in-page surface not exposed to theme packs.

A card must carry its own edge. --card is not guaranteed to differ from --bg: in kiro-light both are #ffffff, because the canvas is white and the shell (nav rail, sessions list) steps back onto --panel instead. So a bg-card surface that sits directly on the page and has no border, ring, or shadow paints nothing visible there — it "works" in every other theme and ships invisible in that one, with no gate failing. The rule for a new component: a bg-card box on --bg gets a border-border, a ring-1 ring-border, or a shadow-*, the way the top-bar search field and the settings cards already do. The three surfaces that deliberately stay borderless (the user bubble, the two top-bar capsules) are handled by kiro-light-scoped hooks in index.css, and src/test/kiroLightShellHooks.test.ts pins that list; a new borderless card is a fourth hook there, not an unmarked exception. The inverse holds too: a bg-bg well nested inside a bg-card container is the same pair of values seen from the other side, so a code or output block that relies on the well being darker than its card gets the same border-border — and a hover:bg-card on a row that sits on the page is not a hover at all in this theme; hover states use bg-bg-hover.

Adding a new color role

When you genuinely need a new color role, add the variable to both sides in parity (a parity test guards drift), then define it in every built-in theme:

  • Frontend: ALLOWED_CSS_VARS in src/hooks/themeCss.ts
  • Backend: _THEME_CSS_VARS_SET (built from _THEME_CSS_VARS) in src/kiro_crew/dashboard/theme_validate.py

Never introduce a one-off literal instead of a variable.

Both sides are checked from Python: in test/test_theme_css_security.py, TestAllowlistParity parses ALLOWED_CSS_VARS out of themeCss.ts and asserts set equality with the backend _THEME_CSS_VARS_SET; TestCssVarsSetSync asserts the required roles and the shadow roles are in the backend set and that an unknown name is not, and TestThemeVarsFilter asserts the filter keeps known keys, drops unknown ones, and drops unsafe values. The CSS parsers on the two sides are pinned against each other by a shared fixture, test/fixtures/theme_css_corpus.json: test/test_theme_install.py (TestCssParserCorpus) asserts the installAccepts column and src/test/themeCssCorpus.test.tsx asserts the runtimeKeeps column of the same cases, so a future divergence between them fails a test rather than a user. The two parsers differ by design (install-time is a denylist, runtime is a positive allowlist), which is exactly why the corpus pins both verdicts.

src/test/PhasedViewTheme.test.tsx guards the other drift direction: it parses index.css for every custom property any theme block defines, and asserts a view references no token that does not exist. A var(--nope, #16213e) fallback would otherwise always win and silently ignore the active theme.

What is / isn't customizable

TierSurface
L0 Colorthe 56 CSS vars (dark + light)
L1 Brandlogo, favicon, wordmark, botName, fonts, scoped overrides.css
L2 Experiencesandboxed overlays, topbar, audio, persona

Out of contract: app structure/routing, functional-control behavior, security chrome, and anything outside the CSS-var set + the overrides.css selector allowlist.

L2 overlay stacking. A pack's assets.overlays render inside the dashboard shell's own stacking context, strictly below the top bar (OVERLAY_Z_MAX in src/lib/themeDecorLayer.ts, derived from the header's z-indexes), whatever zIndex the manifest asks for — so a fullscreen overlay decorates the chat surface but never paints over the header's controls (#7377). The topbar asset is the opposite by design: it is branding laid OVER the header strip and stays above it. The body::before / body::after idiom in overrides.css is not covered by this rule: it paints at the document root and therefore over the whole shell, header included.

Brand identity

An installed L1/L2 theme can brand the main left command palette without CSS or executable code. Put the display label in theme.json and use the conventional packaged asset names:

{
  "level": 1,
  "branding": { "botName": "KIRO CREW" }
}
branding/logo.svg       # left command-palette mark
branding/favicon.svg    # browser-tab icon
branding/wordmark.svg   # reserved brand artwork for supporting surfaces

logo accepts .svg or .png; favicon accepts .ico, .png, or .svg. The backend serves only validated files from the installed pack, and the label is trimmed to 48 printable characters. When an asset or label is absent, the shell falls back independently to its configured Kiro Crew branding. Compiled edition branding registered through registerThemeBranding() has precedence over an installed pack.

Fonts

A pack ships faces in theme.json's fonts list, tagging each with the role it feeds — sans (proportional) or mono. Absent means sans, so a pack written before roles existed keeps its meaning.

"fonts": [
  { "family": "Manrope",       "file": "manrope-400.ttf", "weight": 400, "role": "sans" },
  { "family": "Manrope",       "file": "manrope-600.ttf", "weight": 600, "role": "sans" },
  { "family": "IBM Plex Mono", "file": "plex-400.ttf",    "weight": 400, "role": "mono" }
]

Files live under styles/fonts/ as .woff2 or .ttf, at most _THEME_MAX_FONTS faces across both roles, each within the per-file cap.

Each role fills a token — --theme-font-sans, --theme-font-mono — and Settings → Display → Font Family reads through them:

OptionResolves to
Sansthe pack's sans face, else Kiro Crew's own proportional stack
Monothe pack's mono face, else Kiro Crew's own monospace stack
Systemthe OS face — no token, so a pack cannot reach the body font here

--mono reads the mono token too, so code blocks, inline code and diffs follow a pack's monospace face without the user having to switch the whole UI to monospace. That applies under every option, System included — System governs the body font, not the code font. The terminal is separate: it reads its family from a Settings field, not from CSS, so a pack never changes it.

overrides.css must not declare a font. Declaring --font-body, --mono, either role token, or font / font-family on a whole-UI surface (body, html, *, :root) is rejected at install and dropped at runtime. Such a pin lands the font below where the Font Family preference is applied, so the user's Mono/System choice would silently stop working with nothing on screen explaining why. A font-family on ONE allowlisted surface (.topbar, .code-block, button.primary) is fine — that is theming, not a pin.

A pack already installed with such a pin keeps working: the rule is applied when a pack is installed, not when an installed pack is re-read, so the theme still loads and keeps its colours. Its font pin is dropped when the stylesheet is scoped, so the typeface falls back to the built-in stack until the face moves into the fonts list. Re-installing the pack surfaces the rejection message that explains what to change.

Stable hooks

An L1 pack's overrides.css may only target the surfaces below. This is the list that the source comments citing this file point at, and it is the runtime boundary, not a style suggestion: _scopeOverridesCss in src/hooks/useTheme.tsx DROPS every rule whose selector group does not pass, so a rule aimed at anything else never reaches the document.

Six class hooks (_ALLOWED_CLASSES):

ClassWhere it is applied
topbarthe header shell (App.tsx)
sidebarthe chat session list (ChatSidebar.tsx)
chat-containerthe chat scroll region (ChatPane.tsx, ChatPage.tsx)
message-bubblea user or assistant turn (chat/UserMessage.tsx, chat/AssistantMessage.tsx)
input-areathe composer (ChatInput.tsx)
code-blocka rendered fenced block (CodeBlock.tsx, MonacoCodeBlock.tsx)

Do not rename or drop one of these classes when refactoring the component that carries it. There is no compiler reference to break, so the only signal is a shipped pack quietly losing its styling. Keep the comment next to the class too: it is what tells the next reader the class is API.

Element hooks (_ALLOWED_ELEMENTS is '', body, button):

  • button.primary is a special case: a bare button selector is rejected, and a button compound is kept ONLY when it also carries .primary. So a pack can restyle the primary action and cannot restyle every button in the app.
  • Bare body is allowed, but only bare: body, body::before, body::after (a single-colon :before / :after is tolerated). Any class on body, or any other pseudo-element on it, is rejected. The two pseudo-elements exist for the decorative-overlay idiom (a scanline, a vignette).
  • The empty-string element means a class-only compound such as .topbar:hover, which is the normal case.

Forbidden classes (_FORBIDDEN_CLASSES, rejected even when chained onto an otherwise-allowed compound): token, credential. These name credential-bearing chrome, and a pack that could restyle them could hide or spoof them.

Selector shape. Every selector in a comma group must pass, and each one must be a single compound:

  • No combinators. Whitespace, >, + and ~ are all rejected, so .topbar .btn never applies. Style the hook itself, not its descendants.
  • No ids and no attribute selectors in the compound. This is what blocks #app-root and [data-auth].
  • One optional [data-theme="…"] prefix may lead the selector, with or without a leading html, and it is stripped before the compound is checked. So [data-theme="mytheme-dark"] .topbar and html[data-theme="mytheme-dark"] body are both fine, but a second prefix is not.
  • Chained classes and pseudo-classes on the SAME base are fine (.topbar:hover, .message-bubble.mine). Single-colon pseudo-classes are ignored by the check.

An @media block is recursed into with its wrapper preserved and its inner rules filtered the same way; every other at-rule is dropped. A kept rule's declaration body is then denylisted (@import, expression(), javascript:, -moz-binding, an external url()), both raw and after CSS escape-decoding, so an escaped token cannot hide from the scoper.

Install-time forbidden selectors. The backend has its own, independent check. _THEME_CSS_FORBIDDEN in src/kiro_crew/dashboard/theme_validate.py rejects a pack outright if its overrides.css contains any of iframe, script, [data-auth], .token, .credential, #app-root, matched case-insensitively against both the raw text and a comment-stripped, escape-decoded copy. The same module also rejects rules that could hijack the viewport or block interaction (z-index above 9999, display:none, pointer-events:none, a viewport-covering position:fixed), with an exemption for purely decorative body::before / body::after.

The two layers are deliberately different models: install-time is a denylist that refuses the pack with an explainable error, and the runtime scoper is the positive allowlist that is the actual enforced boundary. A rule that slips past the former still gets dropped by the latter.

Chat loader

The loading indicator in the chat footer (shown while a turn is running) supports both installed packs and compiled themes.

Installed packs: stock symbols

A Level-1 or Level-2 pack may select 4–8 distinct bundled symbols in theme.json. The names are a closed allowlist; packs never ship executable components or inline SVG through this field.

"loaderIcons": ["star", "sparkles", "moon", "cloud"]

Allowed names: cloud, flower, heart, moon, sparkles, star, sun, zap. The existing four-slot carousel supplies the cross-fade, cascade timing, and reduced-motion behavior. An absent declaration preserves the default Kiro ghost poses. Invalid names, duplicates, fewer than four entries, or declarations on a Level-0 pack are rejected during install.

Compiled themes: component seam

Themes bundled into the build may still register arbitrary trusted artwork or a whole custom loader through registerThemeBranding() (src/themeBranding.tsx), which runs at module load from the composition root (src/extensions.ts):

import { registerThemeBranding } from '@/themeBranding'

registerThemeBranding({
  mytheme: {
    logo: '/mytheme/logo.png',

    // Level 1: keep the stock carousel, swap the artwork it cycles.
    loaderIcons: [Sun, Moon, Star, Cloud, Comet],

    // Level 2: replace the indicator outright (wins over loaderIcons).
    loader: MyMascotLoader,
  },
})

loaderIcons is the easy path and the one to reach for first. The default loader is a 4-slot carousel: each slot cross-fades between two icons, the slots cascade 0.25s apart on a 2.8s beat, and every beat re-samples 4 distinct icons from your pool (never repeating the set it replaces or the other layer). Supply at least 4; more gives more variety. You inherit the cross-fade, the cascade timing and the reduced-motion handling for free.

loader replaces the whole indicator with your component: a mascot animation, a progress bar, a canvas, anything. It renders with no wrapper beyond the footer's padding, so it owns its size, layout and motion. Keep it small (the band is ~32px tall), mark it aria-hidden (it is decorative), and honour prefers-reduced-motion yourself.

Resolution is loaderloaderIcons → artwork bundled for a core theme → the default icons, so a theme that registers neither renders exactly what it does today, and an empty pool falls back rather than rendering nothing. Both branches render inside an ErrorBoundary fallback={null}: the loader is decorative, so a component that throws collapses to nothing instead of escaping to the route boundary and replacing the chat UI with an error card.

Colour belongs in your CSS, not in the artwork. Each icon renders itself, takes no props, and is sized to 14px by the carousel. A lucide-react glyph inherits currentColor (the accent) and needs no styling at all.

For bespoke brand art, mind the use-lucide-icons rule (website/AUTOSDE.yaml): lucide ships no mascot marks, so your own art is exempt, but only while it stays an asset. Keep the art in an .svg file, import it by URL, and render it in an <img>; no <svg> element or path data may appear in a .tsx file (the CI gate blocks that in every file, tests included). Theme it by filtering the <img>, which traces the rendered alpha, so one asset serves every palette:

[data-theme="mytheme-light"] .csb4 .lyr > .my-mark {
  filter: drop-shadow(.6px 0 0 #000) drop-shadow(0 .6px 0 #000)
          drop-shadow(-.6px 0 0 #000) drop-shadow(0 -.6px 0 #000);
}

That is how the bundled Kiro poses get their light-palette outline; see src/components/GhostPoses.tsx and src/assets/onboarding/GhostIcons.tsx.

One implementation constraint if you write a custom loader: the carousel's cross-fade animation lives on a persistent .lyr wrapper rather than on the icon, because swapping an icon changes the rendered component type and remounts its element, and animating the icon itself would restart that animation and desync it from the other layer. If your loader swaps artwork on a timer, animate a stable wrapper for the same reason.

Registration is read at module load (see src/extensions.ts); registering after the shell has rendered does not take effect until the next theme switch.

Authoring a compiled (edition) theme end to end — CSS specificity against the core palette, module resolution, typechecking — is covered in extension-seams § Authoring an edition.

Checker (advisory)

npm run lint:theme-colors          # report raw literals in src/ (exit 0)
node scripts/check-theme-colors.mjs --strict   # exit 1 if any (future ratchet)

The checker excludes the five files where a raw literal is legitimate (src/hooks/useTheme.tsx, src/components/themeEditor.tsx, src/index.css, src/lib/cssSanitize.ts, src/utils/sessionColors.ts), plus tests, generated code, and type declarations. It is advisory (the existing tree has legitimate literals in themes, icons and palettes) and is not wired into the blocking CI gate; --strict becomes a CI gate once the baseline is burned down.