Theming
See how Litestore's themes are defined and kept separate for the storefront and the admin, so you know where a visual change belongs.
A theme decides how the storefront or the admin looks. Read this page before you change a colour, add a theme, or style a single block.
A theme is a complete visual definition. It sets colours, typography, spacing, borders, shadows, effects and layout, and a Zod schema in lib/theme/theme-schema.ts validates it.
Themes are files in config/themes/, and a store setting picks which one is active.
The idea behind this design is that a theme is data, not code. A component never asks which theme is active. It reads the tokens that the active theme provides. Because of that, you can add a new theme without editing components all over the codebase.
The four themes
config/theme.ts collects the theme files into one registry:
starteris for the admin and is the admin default.starter-webis for the storefront.ameliais for the storefront and is the storefront default.brutalis for the storefront.
Every theme declares a target, which is either web or admin. Three of the four themes are for the storefront. Only starter styles the dashboard.
The storefront and the admin are separate
Litestore has two independent settings for the active theme:
storefront_active_theme
admin_active_themeChanging one does not change the other. lib/theme/settings.ts reads them with getStorefrontActiveThemeId() and getAdminActiveThemeId(), so each side looks up its theme under its own key.
The code enforces this split. assertThemeTarget throws if you apply a theme to a target it was not written for:
Theme "amelia" is a storefront theme and cannot be used for the admin dashboard.Applying an admin theme to the storefront fails in the same way. The theme-switching action in server/admin/theme/actions.ts calls assertThemeTarget before it writes the setting, so an invalid combination never reaches the database.
There is no dark mode
Each theme has a single palette. Nothing in config/themes/ or lib/theme/ generates a dark version, and shoppers have no theme toggle.
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(),
})The semantic colours are required and the accents are optional. A theme can't ship without a colour for the danger state.
The palette is one part of the full theme. themeSchema also requires typography, spacing, borders, shadows and effects. It requires metadata such as id, name and target too. A theme can also have optional customCss, and storefront themes can have optional webTokens.
If a theme is missing a required group, it doesn't render badly. It fails Zod validation.
The boundary the harness enforces
scripts/check-theme-boundaries.ts makes sure that theme-specific CSS selectors and theme ids appear only in config/theme.ts and config/themes/.
The script reads every file in the repository with one of these extensions, skipping node_modules and the build directories:
.css .ts .tsx .js .jsx .json .md .mjsFor each file, it asks one question:
Does this file name a specific theme?
It flags four cases:
- A
[data-theme="amelia"]selector outside the theme config. - Any theme-specific selector or Amelia override in
app/styles.css. - Storefront theme CSS in
config/theme.tsthat targetsdata-admin-theme. - The bare theme id
ameliaoutside the theme config, the settings defaults, tests or the theme detail route.
The script removes block comments before scanning. A comment that only mentions a theme is therefore not reported as a leak.
A short allowlist covers files where naming a theme is legitimate. These are the theme CSS test, the settings schema, lib/defaults.ts, prisma/seed.ts and the discipline scripts that describe theme rules in prose.
As a result, a component can't behave differently depending on which theme is active. If components could do that, every new theme would mean editing each of them.
A separate rule covers hard-coded colours. The web-hardcoded-color rule in scripts/check-discipline.ts scans components/web and app/(web) for a Tailwind arbitrary value that contains a raw hex, rgb() or hsl() value. A var(--…) reference is fine, because it contains no literal colour. OG images are allowed, since Satori cannot read CSS variables.
Block palettes
Litestore derives block colour palettes from the active theme, and operators can't edit them.
If an operator wants a palette of their own, they duplicate the derived palette into custom_color_palettes instead of editing the theme.
A block palette overrides the same --color-* tokens the block already reads from the theme, but only for that block. The values are CSS hex strings so that the admin colour inputs can read and save them unchanged.
Where a visual change goes
If the change should follow whichever theme is active, use theme tokens. This is the usual case, and you don't need a new file.
If the change really belongs to one theme only, put it in that theme's customCss in config/themes/. That is the only place a [data-theme="…"] selector is allowed.
If the change belongs to one block on one page rather than to the whole store, use a block palette. Duplicate the derived palette instead of editing the theme it came from.
If the change is a whole new look, add a new file under config/themes/ and set its target to the side it was written for.
Before you add a colour, don't ask where you can put it so that it renders. Ask this:
If someone switches the theme tomorrow, does this change follow them, or does it become the one thing that still looks wrong?