Theming
Learn how the four themes are defined, why the storefront and admin carry separate settings, and what the harness confines to the theme config.
A theme is a complete visual definition — colours, typography, spacing, borders, shadows, effects and layout — validated by a Zod schema in lib/theme/theme-schema.ts.
Themes live in config/themes/ and are selected by a store setting.
The decision behind that shape is that a theme is data, not code. A component never asks which theme is active; it reads the tokens the active theme supplies. That is what keeps a new theme from needing edits scattered across the component tree.
The four themes
config/theme.ts aggregates the per-theme files into one registry:
starter— admin, and the admin defaultstarter-web— storefrontamelia— storefront, and the storefront defaultbrutal— storefront
Every theme declares a target of either web or admin. Three of the four are storefront themes; only starter dresses the dashboard.
The storefront and the admin are separate
There are two independent settings, not one:
storefront_active_theme
admin_active_themeChanging one does not change the other. lib/theme/settings.ts reads them through getStorefrontActiveThemeId() and getAdminActiveThemeId(), so the two surfaces resolve their theme by different keys.
The split is enforced, not merely conventional. assertThemeTarget throws if a theme is applied to the target it was not written for:
Theme "amelia" is a storefront theme and cannot be used for the admin dashboard.An admin theme applied to the storefront fails the same way. The theme-switching action in server/admin/theme/actions.ts calls it before writing the setting, so the invalid combination never reaches the database.
There is no dark mode
Each theme is a single palette. Nothing in config/themes/ or lib/theme/ generates a dark variant, and there is no theme toggle for a shopper.
What a palette declares
export const colorPaletteSchema = z.object({
primary: z.string(),
primaryForeground: z.string(),
secondary: z.string(),
secondaryForeground: z.string(),
background: z.string(),
foreground: z.string(),
card: z.string(),
cardForeground: z.string(),
muted: z.string(),
mutedForeground: z.string(),
border: z.string(),
input: z.string(),
ring: z.string(),
success: z.string(),
// …warning, danger, info, each with a foreground
accent: z.string().optional(),
accentForeground: z.string().optional(),
})Semantic colours are required and accents are optional, so a theme cannot ship without a definition for a danger state.
The palette is one field of the full theme. themeSchema also requires typography, spacing, borders, shadows and effects, alongside metadata such as id, name and target, plus optional customCss and storefront-only webTokens.
A theme that is missing a required group does not render badly — it fails Zod validation.
The boundary the harness enforces
scripts/check-theme-boundaries.ts confines theme-specific CSS selectors and theme ids to config/theme.ts and config/themes/.
It walks the repository, reading every file with one of these extensions outside node_modules and the build directories:
.css .ts .tsx .js .jsx .json .md .mjsOf each one it asks a single question:
Does this file name a specific theme?
It flags four things:
- A
[data-theme="amelia"]selector outside the theme config. - Any theme-specific selector or Amelia override inside
app/styles.css. - Storefront theme CSS in
config/theme.tstargetingdata-admin-theme. - The bare theme id
ameliaoutside theme config, settings defaults, tests, or the theme detail route.
Block comments are stripped before scanning, so prose that merely names a theme is not read as a theme leak.
A small allowlist covers the places where naming a theme is legitimate — the theme CSS test, the settings schema, lib/defaults.ts, prisma/seed.ts, and the discipline scripts that describe theme rules in prose.
The effect is that a component cannot branch on which theme is active. If it could, every new theme would mean touching every component that branched.
Hard-coded colours are a separate rule — web-hardcoded-color in scripts/check-discipline.ts. It scans components/web and app/(web) for a Tailwind arbitrary value carrying a raw hex, rgb() or hsl() literal. A var(--…) reference is fine, because it carries no literal. OG images are allowlisted, since Satori cannot read CSS variables.
Block palettes
Block colour palettes are derived from the active theme and read-only.
An operator who wants their own duplicates the derived palette into custom_color_palettes rather than editing the theme.
A palette overrides the same --color-* tokens the block already reads from the theme, but only on that block. Values are CSS hex strings so the admin colour inputs round-trip them.
Where a visual change goes
Use theme tokens when the change should follow the active theme. That is the ordinary case, and it needs no new file.
Use a theme's own customCss in config/themes/ when the change is genuinely specific to one theme — that is the only place a [data-theme="…"] selector is allowed to live.
Use a block palette when the change belongs to one block on one page rather than to the store, and duplicate the derived palette rather than editing the theme it came from.
Add a new file under config/themes/ when the change is a whole look, and set its target to the surface it was written for.
The question to ask before adding a colour is not "where can I put this so it renders?" It is:
If someone switches the theme tomorrow, does this change follow them — or does it become the one thing that still looks wrong?
Related
Config
Learn what each file in config/ controls — site identity, activity logging, AI prompts, themes, color palettes, pricing defaults and the shipping country list.
Blocks
Learn how storefront pages resolve to typed blocks, what the ten block kinds do, and every file adding an eleventh touches.
Harness
Learn about the 25 CI checks that run on your fork, and the 8-guide rule set behind them.