Skip to content
AstroCraft Docs
On this theme

Colors

Medice is on Tailwind CSS v4, which is CSS-first: the configuration lives in CSS rather than in a JavaScript config file. The theme builds a three-layer token system on top of that, and the payoff is that a rebrand is one block of edits.

Every value is transcribed from the Medice Figma design system rather than invented, and each line in the semantic layer cites the Figma variable it came from.

The cardinal rule for markup: use tokensbg-primary, text-foreground, text-base-700 — and never raw Tailwind colors like bg-sky-600 or text-gray-300, which bypass the token layer and survive a rebrand unchanged.

The three layers

Layer 1 — palette aliases, in @theme in src/styles/tailwind-theme.css. These name the brand on top of Tailwind’s own scale:

--color-primary-600: var(--color-sky-600);
--color-mint-400:    var(--color-teal-400);
--color-base-700:    var(--color-gray-700);

Each alias points at the ramp the design’s value already sits on. color/primary #0084d1 is sky-600; color/mint #00d5be is teal-400; the neutrals sit on Tailwind’s gray ramp. So the ramps are derived rather than hand-listed as eleven hexes each.

Layer 2 — semantic runtime vars, in @layer base in src/styles/global.css. One :root block maps meaning onto the aliases:

--background: #ffffff;                  /* color/surface */
--foreground: #404751;                  /* color/body */
--primary:    var(--color-primary-600); /* color/primary #0084d1 */
--secondary:  var(--color-mint-400);    /* color/mint #00d5be */
--border:     #e3ecf0;                  /* color/hairline */

This is the single retheme surface. Seven values are literals rather than aliases — #ffffff, #eef4f7, #404751, #001f2a, #e3ecf0, #fbbf24, #e7000b — because they sit off Tailwind’s ramps and cannot be expressed as an alias.

Layer 3 — the bridge, @theme inline in tailwind-theme.css, which maps the semantic vars to utility colors:

--color-background: var(--background);
--color-foreground: var(--foreground);

The inline keyword is required, not stylistic: without it the utilities resolve to a frozen value rather than to the runtime variable, and the whole point of layer 2 is lost.

So text-foreground resolves to --color-foreground, which resolves to --foreground, which resolves to the :root value. Markup never needs to know which layer it is touching.

Rebranding

Repointing the three ramps in layer 1 is the entire rebrand. Change --color-primary-* from Tailwind’s sky to your own ramp, and every button, link, focus ring, icon chip and accent on all 52 pages follows.

If your brand color does not sit on a Tailwind ramp, write the eleven steps out as hexes in the same block. The layer above does not care where the values come from.

Layer 2 is where you go for a change of meaning rather than of hue — making cards a tinted surface rather than white, say, or making the muted text darker.

The semantic vocabulary

Token Role
background / background-alt the page ground and the alternating band
foreground default running text
heading display headings
ink / ink-foreground card titles, emphasised labels, and the footer ground
card / card-foreground card surfaces
primary / primary-foreground the brand sky
secondary / secondary-foreground the brand mint
muted / muted-foreground tinted panels and secondary text
accent / accent-foreground the mint surface tint
info, success, warning, error status, each with a -foreground
border every card and chrome edge
input the edge of a field
outline focus rings
radius the corner scale

--input is an edge, not a fill

This one is worth reading even if you never change it, because it is the clearest example of what the token layer can hide.

--input pointed at --background, so border-input was #ffffff — and every input, select, textarea, checkbox and radio on the site drew a white border on a white card, along with the Switch primitive’s off-state track. It survived four filter bars and a newsletter panel, because a select still reads as a control without its edge. It took the booking form, where the control is the border, to make it visible.

It is var(--border) now, which is what the design draws for every field. The lesson generalises: a semantic token pointed at the wrong value fails silently everywhere at once.

Shadows

Three geometries the design repeats, tinted with one navy at two opacities:

--shadow-cta:   0 8px 12px var(--color-shadow-raise);  /* primary button lift */
--shadow-raise: 0 8px 24px var(--color-shadow-raise);  /* cards, images, panels */
--shadow-float: 0 12px 16px var(--color-shadow-float); /* chips overlapping content */

The tints are exposed as colors as well, so a one-off geometry can still compose them rather than inventing a fourth navy.

Theming an SVG

The token discipline extends to illustrations. NotFound/NotFoundIllustration.astro sets text-primary on the root <svg> and fills its brand shapes with fill="currentColor", so the artwork inherits --primary for free with no per-color CSS. Same for <Icon>.

If you draw your own illustration, use currentColor for anything that should follow a rebrand.

No dark mode, deliberately

The site is light-only, and this is a design decision rather than an omission.

The Figma design system defines exactly one palette — color/surface #ffffff, color/surface-alt #eef4f7, color/ink #001f2a — with no dark counterpart. Inventing one would make the dark theme a developer’s design decision rather than the designer’s. So the @variant dark declaration, the .dark block, the pre-paint theme script in BaseHead and the ui/theme-toggle/ primitive were all removed together, and the reasoning is restated at the head of global.css so it cannot be mistaken for an oversight.

src/styles/tokens.test.ts fails the build if a .dark block reappears, because a .dark block with no script to set the class is dead CSS shipped to every visitor.

Re-adding dark mode means restoring all four pieces at once — the variant, the block, the pre-paint script and the toggle — from real designed values. One at a time does not work: a block with no script is dead CSS, and a script with no block is a no-op. Git history has the old shape.

Where to put a shared class

global.css uses @apply inside @layer components for genuinely cross-cutting classes — .h1, .h2, .h3, .eyebrow, .description, .section, .site-container, .article-prose, .form__input — and inside @layer utilities for .primary-focus, .decorative-glow and .main-text-gradient.

The bar for reaching for @apply at all is many unrelated consumers. A class with one consumer gets its own file imported into the layer instead, so the entry file stays the place cross-cutting classes live rather than the place each feature appends its own.

One ordering trap: @apply cannot reach a class in a later layer. The declared order is @layer theme, base, components, utilities, so a rule in components cannot @apply .primary-focus from utilities — the build fails with “Cannot apply unknown utility class”. The fix is to spell the four focus utilities out, which is what .article-prose a does.

NEXT STEPTypography