Colors & Theming
Develi’s colors are a three-layer system, and once you can name the three layers, rebranding the theme is a handful of edits rather than a search-and-replace across two hundred files.
The rule that makes it work is short: markup only ever uses token utilities. bg-primary, text-foreground, border-border, bg-muted, or the palette aliases text-base-700 and from-primary-800. Never a raw Tailwind color like bg-orange-700 or text-stone-300 — those bypass theming and dark mode entirely, and a component written that way will look correct in whichever theme you happened to be viewing while you wrote it.
The three layers
Layer 1 — palette aliases, in @theme in src/styles/tailwind-theme.css. These name your brand on top of Tailwind’s own scale, so a rebrand is one edit per ramp:
@theme {
--color-primary-50: var(--color-orange-50);
/* … */
--color-primary-700: var(--color-orange-700); /* Primary Accent — #ca3500 */
--color-primary-600: var(--color-orange-600); /* Accent Hover — #f54a00 */
/* … through 950 */
}
Develi’s accent is a terracotta, and Tailwind’s orange ramp lands on the design’s accent exactly at 700 and 600. To rebrand, repoint these eleven lines at another Tailwind ramp — --color-primary-700: var(--color-teal-700) and so on down the stop list. Nothing else needs to change.
Layer 2 — semantic runtime variables, in @layer base in src/styles/global.css. These map the palette onto meaning, once per theme, and they are what flips:
:root {
--background: var(--color-base-50);
--foreground: var(--color-base-900);
--card: var(--color-white);
--muted: var(--color-base-100);
--border: var(--color-base-200);
/* … */
}
.dark {
--background: var(--color-gray-950);
--foreground: var(--color-white);
--card: var(--color-gray-900);
--muted: var(--color-gray-800);
--border: var(--color-slate-800);
/* … */
}
Layer 3 — the bridge, back in tailwind-theme.css, using @theme inline:
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
/* … */
}
inline is required here. Without it these resolve to literal values at build time rather than to the runtime variable, and the theme stops flipping. This is the single most common way to break the system while thinking you have extended it.
The two ramps are not parallel, and it matters
Light is a warm family — Tailwind’s stone as the backbone, with the top three stops overridden by the design’s own paper tones:
--color-base-50: #faf9f6; /* Background */
--color-base-100: #f4f3ee; /* Surface Deep */
--color-base-200: #e5e5e0; /* Border/Stroke */
--color-base-300: var(--color-stone-300);
/* … through 950 */
Dark is a cool graphite family, referenced straight from Tailwind’s gray and slate ramps on .dark rather than through a second alias ramp — the same trick info/success/warning already use.
The consequence is worth stating plainly: a bare base-* utility is warm in both themes. text-base-700 does not become cool in dark mode, because base-* is Layer 1 and Layer 1 does not flip. Reach for the semantic tokens — bg-muted, text-muted-foreground, border-border — on anything that must follow the theme’s neutral hue.
The non-parallel ramps also explain a pattern you will see in several components. A surface that sits one step below a bg-card band is --background in dark (a clear step from #101828 to #030712) but --muted in light, because base-50 on white is almost no step at all. That pair is factored into a shared .recessed-panel class precisely so the two cannot drift apart at one call site.
The accent does not flip
Three tokens are declared once in :root and deliberately not repeated in .dark:
--primary: var(--color-primary-700);
--primary-hover: var(--color-primary-600);
--primary-foreground: var(--color-white);
The design gives both themes the same terracotta, and a value that does not flip has no business in a per-theme block.
One contrast caveat comes with that. White on --primary clears AA in both themes, which covers every solid button. But --primary used as text on the dark background only reaches large-text and UI-component contrast. Use text-primary-hover for small accent text on dark.
The full semantic token set
| Token | Job |
|---|---|
background / foreground |
The page ground and its text |
card / card-foreground |
Raised surfaces and their text |
primary / primary-hover / primary-foreground |
The accent, theme-invariant |
secondary / secondary-foreground |
Inverted emphasis |
muted / muted-foreground |
Deep surfaces and secondary text |
accent / accent-foreground |
Subtle fills |
info / success / warning / error (+ -foreground) |
Status |
border |
Dividers and card edges |
input |
Interactive control strokes |
outline |
Focus rings |
border and input are separate on purpose, and it is an accessibility decision. --border is the divider token and sits at roughly 1.2:1 against the background, which is right for a hairline between two cards. --input is the stroke on an interactive control — a text input, a checkbox, a switch track — and WCAG 1.4.11 holds those to 3:1. Aliasing one to the other would quietly fail that criterion on every form in the site.
Dark mode
Class-based, declared once in global.css:
@variant dark (&:where(.dark, .dark *));
Dark is the default and it is server-rendered. BaseLayout ships class="dark" on <html>, so dark mode holds with JavaScript disabled or broken. The inline pre-paint script in BaseHead only ever removes it:
document.documentElement.classList.toggle("dark", savedTheme() !== "light");
savedTheme() reads localStorage("colorTheme"), wrapped in a try because localStorage access throws in a sandboxed iframe — degrading to null lands on dark, which is the right default anyway. The device prefers-color-scheme is deliberately not consulted.
Two things follow from this that catch people out:
Keep that script inline and pre-paint. Moving it to a bundled <script> reintroduces a flash of the wrong theme. It also re-runs on astro:after-swap so view-transition navigations do not lose the pinned theme.
A page with no ThemeToggle mounted is dark-only. Light mode is unreachable there, which means light-mode bugs on that page are invisible. This is not hypothetical — the 404 illustration in this theme shipped with a stock palette of raw hex values for a while, rendering a light-mode-white figure on a near-black page, and nobody noticed because the 404 mounts no toggle. Grep the artwork rather than trusting the screenshot.
Pairing light and dark
Either pair the two explicitly on the element so they cannot drift:
<p class="text-base-700 dark:text-base-300">
Or let a semantic token do it, which is almost always better:
<p class="text-muted-foreground">
The one exception to token discipline
A third party’s brand colour. A logo tile’s fill is a fact about someone else’s mark, not a themeable value — there is no dark-mode Airbnb red to flip to, and tokenising it would let a rebrand silently recolour a company’s logo.
The test is ownership, not inconvenience: if you could restyle it, it is a token. Develi’s own Logo primitive is therefore a token, not an exception — its wordmark is fill-current and its mark is fill-primary.
When a logo wall lands, keep each hex on the data entry beside the company that owns it and apply it via an inline style. Not a computed bg-[…] class — an interpolated class name is invisible to the Tailwind compiler and produces no CSS at all.
Decorative artwork does not qualify for this exception. It is the theme’s own, so it themes: brand shapes take currentColor so they follow the wrapper’s text-*, and surfaces and shading take fill-card / fill-foreground / fill-muted so they follow the theme rather than the wrapper.
Retheming: what to edit
- A different accent — repoint the eleven
--color-primary-*aliases intailwind-theme.cssat another Tailwind ramp. - A different neutral — repoint
--color-base-*for light, and thegray/slatereferences inside.darkinglobal.cssfor dark. - Different meaning — edit the
:rootand.darkblocks inglobal.css. This is where you decide that cards are flat, or that muted surfaces are warmer. - A new token — add it to
:rootand.dark, then bridge it in@theme inline. All three, or it will not flip. - Corner radius — one variable,
--radius: 0.5remin:root. The whole--radius-xsthrough--radius-3xlscale is computed from it in@theme inline.
After any of these, open /examples/ui and check both themes. A missing token shows up instantly as an un-themed element.