Theming
A brand is a database row. Bring your own design by editing data, not components.
Millroom's components are brand-neutral. Every visual identity (color tokens, radius, fonts, logo, favicon, OG image, metadata) is data in a core.brands row, resolved per request and applied as CSS variables. The three worked products in the kit wear three different skins off the same components. Yours is a fourth row, not a fork, which is what keeps one fix to a component true for every brand at once.
The model
core.brands one row per brand
slug text e.g. "studio"
product text which product it themes
config jsonb validated by src/lib/branding/schema.ts (zod)
core.domains host -> tenant; references a brand
host text pk
product text
brand_id uuid the shared brand for this host
brand jsonb optional per-host override, merged on top
resolveTenant(host) loads the referenced brand config and deep-merges any per-host override. One brand can serve many hosts; a single host (a partner domain, a co-branded landing) can still override a few tokens without a new brand.
Token reference
config.tokens.light and config.tokens.dark each carry the full set (TOKEN_KEYS in src/lib/branding/schema.ts):
| Token | Drives |
|---|---|
background | page background |
foreground | default text |
card | raised surfaces |
muted | secondary text |
border | borders and dividers |
brand | the primary accent: buttons, links, the one loud color |
brandForeground | readable text on top of brand |
ring | focus rings (defaults to brand) |
accent | the secondary accent |
Beyond tokens: radius (base corner radius, default 0.375rem), font ({sans, mono} family names), assets ({logo, logoDark, favicon, ogImage} as root-relative URLs or brand-assets bucket refs), and meta ({title, description} for metadata). Everything is defaulted by the zod schema, so a partial row always resolves to a complete brand.
How it applies at runtime
<BrandStyle> is a server component in the root layout. It emits the resolved brand's tokens as an inline :root { ... } / .dark { ... } style block in the initial HTML, so there is no flash of the wrong brand. globals.css maps the variables onto Tailwind utilities (bg-background, text-brand, ...), and next-themes toggles .dark to switch light and dark within the active brand.
A brand can also be dark-first: planer ships the same pine-dark token set in both modes. Nothing in the chassis assumes light and dark differ.
The three shipped skins
Look at supabase/migrations/20260803100700_seed_millroom.sql with the site open on all three hosts:
- studio: cream, charcoal, amber actions, pine dark mode. The flagship.
- cutlist: lighter cream with pine primary buttons and amber demoted to accent. Proves the accent color is data.
- planer: pine-dark in both OS themes, amber CTAs. Proves dark-first is a row, not a build.
The theme switcher on the studio's what's inside page renders one card under all three token sets, live.
Rebrand the studio (make it your page)
The studio is millroom.dev's marketing site, and it is deliberately the page you rebrand first. Three edits:
- Swap the brand row. In the seed migration (locally) or a new migration (for prod), replace the studio brand's
tokens,radius,font, andmetawith yours. Runpnpm db:resetand reload; the whole site re-skins. - Swap the marks. Replace the SVGs in
public/studio/(favicon, icon, mark) and the source files inbrand/with your own. The studio'smark.tsxcomponent is an inline SVG; replace its paths with your mark so it inheritscurrentColorlike the original. - Point the CTA at your product. Set
NEXT_PUBLIC_GUMROAD_URLto your own purchase link, or replace the CTA components with your own signup flow.
Then rewrite the landing copy under src/app/(studio)/ to say what your studio sells. The docs pages, changelog, and structure all carry over.
Adding a brand (checklist)
INSERT INTO core.brands (slug, product, config)with your token sets and asset refs (migration).- Drop assets in
public/<slug>/, or upload to thebrand-assetsstorage bucket (brand/<slug>/...);brandAsset()resolves either. - Point hosts at it:
INSERT INTO core.domains (host, product, brand_id, ...). - Optional per-host token overrides in
domains.brandjsonb.
No component changes. If you find yourself editing a component to change how a brand looks, stop: the fix is a token, a prop, or a variant, in that order.
Why not per-brand CSS files
One compiled Tailwind build serves every brand; variation is pure CSS-variable data resolved per request. Brands can be added at runtime (a row, not a rebuild), components never branch on brand, and the bundle stays single. This is the standard multi-tenant theming approach for the App Router, chosen for cacheability and zero-redeploy onboarding. It is the theming half of the rule the rest of the chassis follows: one implementation, however many products are wearing it.