Skip to content

Theming

Settings → Look holds the theme: a primary colour, an accent, an optional page background, light or dark scheme, a corner radius from 0 to 4 px, a logo that replaces the wordmark, a font stack, and a font stylesheet URL. All of it is one theme object on the store, validated by themeSchema in shared/schema.ts, and sent to every shopper in /api/store.

The radius is capped at 4 on purpose. Past that the storefront stops reading as a shop and starts reading as a dashboard, and the range 4 to 24 was almost entirely occupied by looks no shop wanted.

There is one scheme per store, not a per-viewer toggle. A shop’s look is the same in every screenshot anyone takes of it.

ThemeVars in the storefront injects the saved theme onto :root as custom properties. src/lib/theme.ts holds both base palettes and derives the rest: --beluga-on-primary and --beluga-on-accent are picked by WCAG contrast against the chosen colour, so white or near-black text is chosen for you.

Token Meaning
--beluga-ink Body text
--beluga-muted Secondary text
--beluga-line Hairlines and borders
--beluga-surface Cards, panels
--beluga-page Page background
--beluga-primary / --beluga-on-primary Buttons and the text on them
--beluga-accent / --beluga-on-accent Highlights, sale badges
--beluga-radius Corner radius
--beluga-gutter / --beluga-measure Layout rhythm and line length

src/index.css carries the light values as a first-paint fallback only. The dark palette lives only in theme.ts, so there is nowhere for it to drift. src/lib/theme.test.ts checks the dark palette’s own contrast as arithmetic, because no browser in CI renders it.

A font stack alone only renders on a machine that already has the face installed. Font stylesheet URL holds the stylesheet that defines the faces: the Google Fonts css2?family=… href, or a self-hosted sheet under /assets/.

The store’s Content-Security-Policy allows stylesheets and fonts from 'self' only, so before this field a Google Fonts <link> added by hand was refused by the browser with nothing on screen to say why. Saving a URL widens the policy by exactly the origins that stylesheet needs: Beluga fetches the sheet once and reads them out of it, which is how fonts.gstatic.com gets allowed. A URL that cannot be fetched is refused at save. Clearing the field puts the header back exactly as it was. The preview cannot show a typeface you have not saved yet, because the policy naming it is the one the store is currently serving.

.card {
background: var(--beluga-surface);
color: var(--beluga-ink);
border: 1px solid var(--beluga-line);
border-radius: var(--beluga-radius);
}
.card button {
background: var(--beluga-primary);
color: var(--beluga-on-primary);
}

That is the whole rule. A component styled this way renders correctly today and still matches after a merchant changes the palette, switches to dark, or sets a radius. One that hardcodes #fff does not.

The admin has its own theme in src/admin/adminTheme.ts, deliberately not the store’s. Its blue is meant to look like a tool, not a shop.

The editor cannot change layout, typography scale, or what a section contains. For that, replace the component. The seams says which ones are safe, and Restyle the storefront does it.