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_VARSinsrc/hooks/themeCss.ts - Backend:
_THEME_CSS_VARS_SET(built from_THEME_CSS_VARS) insrc/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
| Tier | Surface |
|---|---|
| L0 Color | the 56 CSS vars (dark + light) |
| L1 Brand | logo, favicon, wordmark, botName, fonts, scoped overrides.css |
| L2 Experience | sandboxed 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:
| Option | Resolves to |
|---|---|
| Sans | the pack's sans face, else Kiro Crew's own proportional stack |
| Mono | the pack's mono face, else Kiro Crew's own monospace stack |
| System | the 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):
| Class | Where it is applied |
|---|---|
topbar | the header shell (App.tsx) |
sidebar | the chat session list (ChatSidebar.tsx) |
chat-container | the chat scroll region (ChatPane.tsx, ChatPage.tsx) |
message-bubble | a user or assistant turn (chat/UserMessage.tsx, chat/AssistantMessage.tsx) |
input-area | the composer (ChatInput.tsx) |
code-block | a 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.primaryis a special case: a barebuttonselector is rejected, and abuttoncompound 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
bodyis allowed, but only bare:body,body::before,body::after(a single-colon:before/:afteris tolerated). Any class onbody, 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 .btnnever applies. Style the hook itself, not its descendants. - No ids and no attribute selectors in the compound. This is what blocks
#app-rootand[data-auth]. - One optional
[data-theme="…"]prefix may lead the selector, with or without a leadinghtml, and it is stripped before the compound is checked. So[data-theme="mytheme-dark"] .topbarandhtml[data-theme="mytheme-dark"] bodyare 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 loader → loaderIcons → 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.