Colors & Theming
Olsa uses Tailwind CSS v4, which means configuration lives in CSS rather than a JavaScript file. There is no tailwind.config.js anywhere in the project. Colour is organised in three layers across two files, and understanding them is the difference between rebranding in one edit and hunting through components.
The cardinal rule the whole theme follows: markup uses tokens — bg-primary, text-foreground, border-input, text-base-700 — and never raw Tailwind colours like bg-purple-700 or text-slate-300, which bypass theming and dark mode entirely. There is one deliberate exception, covered below.
The three layers
Layer 1 — palette aliases. src/styles/tailwind-theme.css opens with an @theme block that names a brand palette on top of Tailwind’s own scale:
@theme {
--color-primary-50: var(--color-purple-50);
/* … through 950 … */
--color-base-50: var(--color-slate-50);
/* … through 950 … */
}
Purple is Olsa’s brand hit; slate is the neutral. Repointing these two ramps is the entire rebrand. Change --color-primary-* to point at --color-emerald-* and every primary surface, ring, gradient stop and hover state follows.
Layer 2 — semantic runtime variables. src/styles/global.css defines two sets in @layer base: one on :root and one on .dark. These are the values that flip with the theme.
:root {
--background: var(--color-violet-200);
--foreground: var(--color-base-900);
--card: var(--color-base-50);
--primary: var(--color-primary-700);
--muted: var(--color-base-100);
--border: var(--color-base-300);
--radius: 0.5rem;
/* …plus info / success / warning / error and their foregrounds… */
}
.dark {
--background: var(--color-violet-950);
--foreground: var(--color-base-100);
--primary: var(--color-primary-400);
/* … */
}
Note that --primary is a different step in each theme — primary-700 on light, primary-400 on dark — because the same purple that reads well on a light violet page is too dark on a deep violet one. That per-theme step choice is the layer’s whole job.
Layer 3 — the bridge. Back in tailwind-theme.css, an @theme inline block maps the semantic variables onto utility colours:
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
/* … */
}
The inline keyword is not decorative. Without it, Tailwind would resolve the utility to the value at build time — freezing whichever theme happened to be active — instead of to the runtime variable. The file carries a comment and an upstream link explaining exactly that.
So text-foreground resolves to --color-foreground, which resolves to --foreground, which is whatever :root or .dark currently says. One utility, both themes, no dark: variant needed.
The full semantic set
| Token pair | Used for |
|---|---|
background / foreground |
the page surface and its text |
card / card-foreground |
raised surfaces |
primary / primary-foreground |
the brand action colour |
secondary / secondary-foreground |
the second-tier action |
muted / muted-foreground |
quiet surfaces and secondary text |
accent / accent-foreground |
hover and highlight surfaces |
info, success, warning, error (+ foregrounds) |
status |
border, input, outline |
borders, field borders, focus rings |
Plus --radius, from which seven radius steps are derived (--radius-xs through --radius-3xl), so rounded-lg everywhere follows one number.
The violet page surface
One choice will surprise you if you assume the page is white: --background is violet-200 in light mode and violet-950 in dark. The whole body carries a brand tint, extended from the design’s footer band.
Violet here is a raw Tailwind default, kept deliberately outside the primary and base aliases. If you want a neutral page, that is a single-line change:
:root {
--background: var(--color-base-50);
}
--foreground already reads on both, so nothing else has to move.
Dark mode
Class-based, declared once in global.css:
@variant dark (&:where(.dark, .dark *));
The .dark class lands on <html> before paint, set by an inline script in BaseHead. Two things about that script matter:
Light is the default. Dark applies only when the user has explicitly pinned it through the ThemeToggle, which writes localStorage("colorTheme"). The device prefers-color-scheme is deliberately not followed — a product decision, not an oversight.
It must stay inline. Moving it to a bundled <script> reintroduces a flash of the wrong theme on first paint. It also re-runs on astro:after-swap, because a view-transition navigation resets <html>’s attributes to the new page’s static ones and would otherwise wipe both the theme class and the js marker class that the reveal primitives depend on.
The script reads localStorage defensively inside a try, because accessing it throws in a sandboxed iframe or when site data is blocked — and an unguarded throw would skip the rest of the initialisation.
Because semantic tokens already flip, text-foreground is enough. Where you use a palette alias directly, pair it so it cannot drift: text-base-700 dark:text-base-300.
The brand-ramp exception
Not everything is themeable, on purpose. Three families of surface sit deliberately outside the semantic layer:
- The purple → fuchsia gradient in
.main-text-gradient. Fuchsia is a second brand hue rather than a themeable semantic; repoint both stops together when rebranding. - The violet-band sections — the light-violet panels behind the FAQ, the integration setup checklist and similar blocks.
- The fixed-dark
GrainyPanelsurfaces — the hero, CTA and 404 panels arebase-950in both themes, so a dark panel stays dark on a light page. Text on them is fixed white (.panel-title), which is why those two classes exist separately from the light-band.section-title.
These are documented exceptions, not drift. If you rebrand, they are the places to check by eye after the ramps are repointed.
Rebranding, in order
- Repoint the two ramps in
tailwind-theme.css. Two edits, eleven lines each. - Repoint the fuchsia stop in
.main-text-gradient(global.css, utilities layer) if your second hue changes. - Decide about the violet page surface — keep the brand tint, or set
--backgroundto--color-base-50for a neutral page. - Check the fixed-dark panels and the violet-band sections by eye in both themes.
- Adjust
--radiusif your brand is squarer or rounder. One value, seven derived steps.
Then open /examples/ui and look at the catalog in light and dark. A missing token shows up instantly as an un-themed element — that is what the catalog is for.
Adding a semantic token
Three edits, always in this order. Define it on :root and .dark in global.css, bridge it in the @theme inline block in tailwind-theme.css, then use bg-<name> / text-<name> in markup. Skipping the bridge gives you a variable that exists but no utility that reads it; skipping the .dark entry gives you a token that silently keeps its light value in dark mode.
What lives in global.css besides tokens
The same file carries the shared @apply classes — .h1, .h2, .h3, .description, .site-container, .section-band, .panel-title and the rest. Those are covered in Layout & Spacing and Typography. The rule for putting something there is repetition across genuinely unrelated elements; anything with structure or variants should be a component or a tailwind-variants config instead.