Skip to content

Theming the site

This app consumes @evolve-storefront/*. Packages decide structure and role; the brand decides appearance. Everything below happens in this repo — never in node_modules, never by forking a package.

Read top down and stop at the first row that covers the change.

Band The change you want Mechanism
Values Colour, radius, spacing, control size Tokens
Values Wording — labels, empty states, aria-labels Messages — see evolve-site-messages
Appearance What a role looks like, or a whole colour set Role CSS and surfaces
Appearance Position, spacing, grid — within the existing DOM CSS on a data-component hook
Behaviour What the feature does Typed options on the feature’s hook
Behaviour Content, arrangement, structure Compose — children, renderItem, slots
Behaviour More or different data Own the query
Markup Genuinely different markup, everywhere in the build Alias the subpath — escape hatch

The bands are blast radius. Values and appearance are CSS and JSON: per-site, no code. Behaviour is typed and per-instance. Markup is build-wide.

Compose first. Anything that is content, arrangement or structure is the compose band — not the alias band. Composition is runtime, typed and per-instance.

Aliasing needs a markup problem. resolveAlias is commented out in next.config.ts and should stay that way. It lives in one config per build, so it cannot differ per site, and if the change is visual it buys nothing that CSS did not already buy. Reach for it only when you need different DOM everywhere and have ruled out every band above.

The brand’s global.css, unlayered. Package styles ship inside Tailwind’s components layer, and unlayered CSS beats every layer regardless of specificity — so a plain selector wins with no escalation.

[data-component="cart-summary-total"] {
@apply text-3xl font-bold;
}

Never wrap an override in @layer — that re-enters the specificity contest for nothing. Never reach for !important: it reverses layer order and hands the win back to the package.

@apply for properties, plain CSS for tokens. An unknown utility fails the build; an undefined var() renders nothing and is miserable to debug. @apply has nothing to apply to a custom property, so tokens are always plain CSS.

[data-component="checkout"] {
--flow-gap: 2rem;
} /* token: plain CSS */
[data-component="cart-total"] {
@apply text-3xl font-bold;
} /* properties: @apply */

A property is an output; a token is an input. Set the property when you are selecting the element you mean. Set a token when the value has to take effect somewhere you are not selecting, or when the component decides how to apply it.

Components name roles and expose them in the DOM. The brand says what a role looks like.

[data-component="button"][data-variant="primary"] {
background: var(--color-primary);
color: var(--color-primary-foreground);
}
[data-component="button"][data-tone="destructive"] {
--color-primary: var(--color-destructive);
--color-primary-foreground: var(--color-destructive-foreground);
}

data-variant is primary | secondary | tertiary — how much the action stands out. data-tone is neutral | destructive — what kind of action it is. They are orthogonal, because prominence and consequence are: a row’s delete is quiet, the delete that confirms it is the loudest thing on the dialog.

A rank reads a role colour, never inherit. Tone works by re-pointing role variables on the button itself, and inherit takes the parent’s computed colour — so a quiet destructive action would render exactly as neutral.

Outline, ghost, pill and link are results of these definitions, not options a component offers. Buttons have no size prop: size is appearance, set through --control-*, and it varies by region and breakpoint in a way a fixed scale cannot.

Rule Because
Style through data-component It is invariant and on every primitive; nothing else is a hook
Override files are unlayered Layer order beats specificity, so plain selectors win
No @theme value contains var() An alias resolves at :root and can never be re-pointed in a subtree
A retired token is removed, never aliased A stale override should be inert and greppable
Reach specific cases by container + role, or by state :first-child breaks the day a button is added
Never use CSS order to move interactive elements Visual order must match DOM order for keyboard and screen-reader users
Do not extend a local shadcn-derived ui package Its CVA/size/data-slot model conflicts with everything above

Every @evolve-storefront/* package’s styles.css must be imported in global.css, or Tailwind never scans that package and generates none of its classes. The failure is silent — it shows up as one broken component rather than an obviously unstyled page.