Colors & Theming
Urbic’s colours are CSS-first. There is no JavaScript theme object, no tailwind.config.js colour map to keep in sync — everything is custom properties across two files, in three layers.
The three layers
Layer 1 lives in src/styles/tailwind-theme.css under @theme: palette aliases that name a Tailwind ramp as yours.
--color-primary-50: var(--color-violet-50);
/* … through 950 */
--color-base-50: var(--color-stone-50);
/* … through 950 */
Urbic ships violet as the primary and stone as the neutral. Both are full eleven-step ramps.
Layer 2 lives in src/styles/global.css under @layer base: semantic runtime variables on :root, redefined on .dark.
:root {
--background: var(--color-base-50);
--foreground: var(--color-base-900);
--primary: var(--color-primary-700);
--muted: var(--color-base-100);
/* … */
}
Layer 3 is back in tailwind-theme.css, under @theme inline, bridging those runtime variables to utilities so bg-background and text-primary resolve to the variable and flip with the theme. The inline keyword is required — without it they resolve to literal values and dark mode stops working.
Rebranding
Repoint the Layer 1 aliases. That is the whole edit:
@theme {
--color-primary-50: var(--color-emerald-50);
--color-primary-100: var(--color-emerald-100);
/* … through 950 */
}
Markup only ever uses bg-primary, text-foreground, text-base-700 and their kin — never bg-violet-700 or text-stone-300 — so light and dark follow automatically. That rule is the fourth item in the primitive contract, and it is what keeps re-theming a one-file change across 45 primitives and every section.
If you want a different relationship between the palette and the UI rather than a different palette — a lighter primary, a warmer muted surface — edit Layer 2 instead. Both :root and .dark are complete, so a token changed in one needs its pair considered in the other.
The semantic set
Beyond background, foreground, card, primary, secondary, muted and accent, there are four status families — info, success, warning, error — each with a -foreground pair, and three of them with an extra -accent.
The -accent tokens are worth understanding before you delete them. --success and --warning are surfaces: they pair with a foreground and they are 300-level, which fails contrast as text or as a hairline on a neutral ground. The -accent form is the same status read as text or as a border. The CMS’s sign-in and review screens draw both forms, which is where the distinction came from.
The two that do not follow the palette
--input is base-500 in both themes, deliberately. It is control chrome, not decoration — the resting border on ui/_field, on Checkbox and Radio’s boxes, on AdvancedSelect’s search field, on Switch’s off-track — and every one of those sits on a transparent or near-ground fill, so it is the only edge there is. WCAG 1.4.11 asks 3:1 of the visual information identifying a UI component. Measured on this ramp: base-100 on base-50 is 1.04:1 and base-800 on base-950 is 1.30:1, two invisible boundaries. base-500 is the one value clearing 3:1 against both ends — 4.59:1 on base-50, 4.12:1 on base-950 — so it needs no per-theme pair.
A decorative hairline is not a control and still uses --border, which does follow the theme.
--error-accent is the other exception: red-700 in light, red-400 in dark. red-800 is a surface colour, and as a border on bg-background it is all but invisible — which is how an invalid field ends up looking valid.
Dark mode
Class-based, via @variant dark (&:where(.dark, .dark *)). BaseHead sets .dark on <html> pre-paint, so there is no flash. The theme-toggle primitive in src/components/ui/theme-toggle/ is the control; the header draws it.
Because every component reads semantic tokens, dark mode costs nothing per component. The way to check a new primitive is /examples/ui in both modes — a missing token shows up instantly as an un-themed element.
What else is in the token layer
--radius is 0.5rem, and the --radius-xs through --radius-xl scale in @theme inline derives from it, so one edit changes every corner on the site.
Two geometry tokens are site facts rather than component ones. --row-line: 30px is the site’s line grid — the Figma file pins the nav’s 32px serif, the footer’s 16px body and the about section’s 18px terms all to the same 30px. Pin it on the font size (text-base/(--row-line)), never as a separate leading-*: tailwind-merge treats font-size as conflicting with leading and silently drops the leading-*. And --bar-h: 74px is the header height, declared at :root because three pages need a band to run under the bar with -mt-(--bar-h) and cannot read a variable declared on <header>.
Breakpoints add one of their own: xs at 400px, with the rest mirroring Tailwind’s defaults so they are explicit. There is also a viewport-height variant, tall, at min-height: 800px — every Tailwind breakpoint is a width, and a full-viewport section competing for vertical space is exactly the case a lg: misses.