Design all nine surfaces before you open an editor
Decide the header, footer, home sections, listing page, product page, product card, the four money-page variants, the token palette and the settings you will expose. Changing your mind about the header once six components assume it is expensive.
Register the catalogue entry first
Append your definition to the theme catalogue: a lowercase-and-dashes id, bilingual name and description, the premium flag, a preview image path, and the settings schema.
Write the components
Add your id to the theme id union — from there the compiler helps you, because the registry is a total map and forgetting to register is a type error. Then create one file per slot plus an index that exports the definition. You only implement the slots you want to change; the rest fall through to the default design.
// A slot is a SERVER component. No 'use client' here, ever:
// it would put every theme's markup into every shopper's bundle.
export function LayaliFooter({locale, settings, columns}: FooterSlotProps) {
// `locale` arrives as a prop because this renders inside a cached
// scope — reading request data here would break the prerendered
// shell for every shopper of every store.
const variant = settingString(settings, 'footerVariant', 'full');
return (
<footer className="border-t border-[var(--border)] px-6 py-14 text-center">
{/* Logical properties only: ms-/me-/start/end, never left/right. */}
<div className="mx-auto flex max-w-3xl flex-col items-center gap-6">
{variant !== 'minimal' ? <FooterColumns columns={columns} /> : null}
<FooterLegal locale={locale} />
</div>
</footer>
);
}- Listing and product slots are shells, not pages. They receive already-rendered nodes — filters, grid, gallery, reviews — and own the arrangement only. Reorder or omit; never re-fetch.
- Parse settings defensively with the provided helpers. A hand-edited settings blob must never break rendering.
- No interpolated utility classes. The CSS scanner reads source text, so a computed class name compiles to nothing. Use a fixed lookup table.
- Logical properties only — start and end, never left and right.
Tokens
Tokens are a delta over the base stylesheet, inlined into a style element. Names must be lowercase and dashed; values are checked for characters that could break out of the declaration, and an unsafe one throws at module load in development. A typical theme overrides a handful — radius, muted, border, font.
// A DELTA over the base stylesheet — not a full palette.
// The merchant's own brand colour still layers on top of this.
export const LAYALI_TOKENS = {
'--background': '#12100e',
'--foreground': '#f5efe6',
'--muted': '#1c1917',
'--border': '#2a2521',
'--radius': '0rem',
'--font-display': 'var(--font-cairo)',
};Storefronts are pinned to the light palette, so only the light record actually renders. Fill in the dark one anyway, a shade deeper, so the theme is whole if dark mode ever returns.
Preview art and translations
Add a small hand-drawn wireframe SVG rather than a screenshot — it cannot go stale. Any new shopper-facing string goes into both message catalogues; reuse existing keys wherever you can rather than inventing near-duplicates.
Tests
- Unit: the catalogue-wide assertions — bilingual labels, valid defaults, unique ids — pick your theme up automatically. Add cases for your own settings on top.
- End to end: switch the fixture store to your theme, assert your header selector appears and the others do not, assert the merchant’s authored layout survived the switch, and assert the default home page has exactly one h1. If your theme is premium, assert a free-plan store is rejected instead.
Verify
A production build is the load-bearing check: it catches a client-component slot, a request-data read inside a cached scope, and every type error. A theme that builds, renders.
A known gap to check by hand
The catalogue’s default values and the theme module’s defaults are two hand-maintained copies with no shared import and no parity test between them. If they drift, a store whose owner never opened the settings form renders with different defaults than the form displays. Pin at least one side in a test so a drift fails loudly somewhere.