Theming and Color Migration

This is the complete reference for Uniweb's theming system and for translating color systems from existing React projects. It's written so that a developer or AI agent reading only this document can correctly theme a foundation and migrate any codebase without creating parallel color systems.


How Theming Works

Uniweb's theming is built on two ideas: semantic tokens resolve per section, and the runtime handles backgrounds. Understanding both is essential before touching any color.

Sections and contexts

A Uniweb page is divided into sections. Each section is a component rendered inside a wrapper that the runtime controls:

<section class="context-light" id="section-hero">
  <!-- Your component renders here -->
</section>

<section class="context-dark" id="section-features">
  <!-- Different component, different context -->
</section>

The context-* class determines how every color token resolves in that section. Content authors set it in frontmatter:

---
type: Features
theme: dark
---

Three contexts exist: light, medium, dark. They represent legibility environments, not brand preferences. A section is dark because there's a dark photo behind the content, or a dark gradient, or a brand-colored background. The content author chooses the context; the component adapts automatically.

What the runtime does

For each section, the runtime:

  1. Reads theme: and background: from the section's frontmatter
  2. Renders any background layer — image, video, gradient, solid color — behind the component
  3. Renders any overlay (e.g., a semi-transparent gradient for legibility)
  4. Applies the context class (.context-light, .context-medium, .context-dark)
  5. Sets background-color: var(--section) on the wrapper

The component inside the wrapper doesn't have to do any of this — the runtime does it for every section, from what the author chose. So a component need not know whether it sits on a photo or a solid color: it uses semantic tokens and they resolve correctly. Relying on this is the recommendation, because it leaves the background and the context to the author.

A component can still paint its own background — a hero with a built-in gradient, a video of its own. It declares background: 'self' in its meta.js, and the runtime then draws no background layer behind it. To draw the author's background itself, somewhere of its choosing, it renders kit's <SectionBackground block={block} />.

Context vs scheme

These are two independent concepts:

Section context (light/medium/dark) answers: "what's behind the content in this section?" It's about legibility. A dark photo needs light text. A light card needs dark text. Each section has its own context.

Site-level scheme (light/dark/system) answers: "does this site prefer a light or dark overall appearance?" When the scheme is dark, sections with no explicit theme: frontmatter default to dark context instead of light. The page background darkens. Browser chrome adapts.

They compose independently. A dark-scheme site can have a bright white CTA section (theme: light). A light-scheme site can have a dramatic dark hero (theme: dark). Components don't need to know about either — they use tokens.


The Token System

Naming convention

Every semantic token follows one rule: CSS variable name = Tailwind class suffix.

--heading → text-heading --card → bg-card --primary → bg-primary --border → border-border

No translation table between the variable and the class. See the variable name, know the class.

Semantic tokens (24 total)

These resolve differently per context class. Components use them and get the right color automatically.

Surfaces:

CSS VariableTailwind ClassPurpose
--sectionbg-sectionSection background (auto-applied by context class)
--cardbg-cardCards, panels, elevated surfaces
--mutedbg-mutedHover states, zebra rows, code blocks, input fields

Text:

CSS VariableTailwind ClassPurpose
--bodytext-bodyBody text, default text
--headingtext-headingHeadings, high-emphasis text
--subtletext-subtleCaptions, timestamps, metadata, fine print
--linktext-linkLink text
--link-hoverhover:text-link-hoverLink hover

Interactive:

CSS VariableTailwind ClassPurpose
--borderborder-borderDividers, card edges, form borders
--ringring-ringFocus rings

Actions:

CSS VariableTailwind ClassPurpose
--primarybg-primaryPrimary button/action background
--primary-foregroundtext-primary-foregroundText on primary buttons
--primary-hoverhover:bg-primary-hoverPrimary button hover
--secondarybg-secondarySecondary button background
--secondary-foregroundtext-secondary-foregroundText on secondary buttons
--secondary-hoverhover:bg-secondary-hoverSecondary button hover

Status:

CSS VariableTailwind ClassPurpose
--successtext-success, border-successPositive outcomes
--success-subtlebg-success-subtleSuccess background tint
--warningtext-warning, border-warningCautions
--warning-subtlebg-warning-subtleWarning background tint
--errortext-error, border-errorErrors, destructive actions
--error-subtlebg-error-subtleError background tint
--infotext-info, border-infoInformation, tips
--info-subtlebg-info-subtleInfo background tint

Status colors have fixed hues (green, amber, red, blue) that don't come from the brand palette. Their shade adjusts per context for legibility, but the hue stays recognizable.

What tokens resolve to

In light context (the default):

TokenValueVisual
--sectionneutral-50Warm off-white
--cardneutral-100Light warm gray
--mutedneutral-200Lighter gray
--bodyneutral-950Near-black
--headingneutral-900Dark gray (slightly lighter than body)
--subtleneutral-600Medium gray
--borderneutral-200Light gray
--linkprimary-600Brand color, medium shade
--primaryprimary-600Brand color, medium shade
--primary-foregroundwhiteWhite text on brand buttons

In dark context:

TokenValueVisual
--sectionneutral-900Dark surface
--cardneutral-800Slightly lighter dark
--mutedneutral-700Medium dark
--bodyneutral-50Near-white
--headingwhitePure white
--subtleneutral-400Light gray
--borderneutral-700Dark gray
--linkprimary-400Brand color, lighter shade
--primaryprimary-500Brand color, slightly lighter
--primary-foregroundwhiteWhite

In medium context, values sit between light and dark — slightly shifted from light. --section is neutral-100 instead of neutral-50, --subtle is neutral-700 instead of neutral-600, etc.

Palette tokens (global, not context-aware)

Four color palettes with shades 50–950, set in theme.yml:

--primary-{50,100,200,300,400,500,600,700,800,900,950}
--secondary-{50,100,200,300,400,500,600,700,800,900,950}
--accent-{50,100,200,300,400,500,600,700,800,900,950}
--neutral-{50,100,200,300,400,500,600,700,800,900,950}

Palette colors are fixed — they don't change with context. Use them for intentional brand touches where you want a specific shade regardless of the section's visual environment:

{/* Brand badge — always a primary tint */}
<span className="bg-primary-50 text-primary-700 rounded px-2 py-1">{tag}</span>

{/* Accent callout — always accent-tinted */}
<div className="bg-accent-50 border border-accent-200 p-4">{callout}</div>

bg-primary vs bg-primary-600: Both exist as separate Tailwind classes. bg-primary is the semantic token (context-aware, used for buttons). bg-primary-600 is the palette shade (fixed, used for brand touches). In light context they're often the same value, but bg-primary shifts in dark context while bg-primary-600 doesn't.

accent is palette-only. There is no semantic --accent token. Use palette shades directly: bg-accent-100, text-accent-700. This avoids a collision between the accent palette namespace and a hypothetical semantic token.

The dual-role summary

NeedUseType
Card background that adapts to contextbg-cardSemantic token
Brand-tinted badge that stays fixedbg-primary-50Palette shade
Button that adapts to contextbg-primarySemantic token
Specific brand shade for decorationbg-primary-600Palette shade
Accent-colored calloutbg-accent-100Palette shade

How theme.yml Controls Everything

The site author writes theme.yml. The build generates all CSS from it. Components never read theme.yml directly.

# theme.yml
colors:
  primary: "#e85d04"          # Orange — generates primary-{50..950}
  secondary: "#2563eb"        # Blue — generates secondary-{50..950}
  accent: "#7c3aed"           # Violet — generates accent-{50..950}
  neutral: stone              # Warm grays — named preset

fonts:
  heading: "'Instrument Sans', system-ui, sans-serif"
  body: "'Source Sans 3', system-ui, sans-serif"

appearance: system            # light | dark | system

background: "#faf9f7"         # Page background (body element)

# Optional: override specific token values per context
contexts:
  light:
    section: "#faf9f7"        # Exact warm paper instead of auto-generated neutral-50
    card: "#f0efed"           # Exact warm card color
  dark:
    section: "#1a1917"        # Custom dark surface

Neutral presets: Instead of a hex value, neutral accepts a preset name: stone (warm, default), zinc (neutral), gray (balanced), slate (cool), neutral (pure gray). The preset determines the warmth/coolness of all gray shades throughout the site.

Context overrides let a site use exact hex values for specific tokens instead of auto-generated shades. The component doesn't know whether --section came from auto-generation or a manual override — it uses bg-section either way.

What is NOT a CSS variable

Some theme concepts are site-level configuration, not tokens:

ConceptConfigured inWhy not a variable
Page backgroundtheme.yml → background:No component references it — it's the outermost canvas
Light/dark behaviourtheme.yml → appearance:Which schemes exist, the default, and whether visitors can switch
Code block colourstheme.yml → code:A syntax theme by name, or a named theme adjusted — see Site Theming
Favicon / metasite.ymlSite metadata, not visual theming

The design test: "Would a section component ever write a Tailwind class that references this?" If yes → CSS variable. If no → configuration.


Foundation Setup

A foundation uses semantic tokens through Tailwind. The required setup:

styles.css — import the bridge that maps CSS variables to Tailwind's --color-* namespace:

@import "tailwindcss";
@import "@uniweb/kit/theme-tokens.css";

That's it. theme-tokens.css registers all semantic tokens and palette shades as Tailwind colors. You can now write bg-card, text-heading, border-border, bg-primary-50, etc.

Foundation-specific variables (like --radius, --header-height) are declared in main.js and set by the site in theme.yml. These are separate from semantic tokens — they don't change with context:

// src/main.js
export default {
  vars: {
    'radius': { default: '0.5rem' },
    'header-height': { default: '4rem' },
  },
}

Translating Legacy React Projects

When migrating an existing React project to a Uniweb foundation, the core insight is: you write less color code, not more. The token system provides what you used to hardcode. Custom color variables and theme-switching logic get deleted, not translated.

The three categories of color

Every color in a React project falls into one of three categories. Recognizing which one determines what you do with it.

1. Structural colors — tied to what an element is: heading text, body text, card backgrounds, borders, buttons. They're the bulk of any codebase.

<h2 className="text-gray-900">...</h2>          // heading
<p className="text-gray-600">...</p>             // secondary text
<div className="bg-white border border-gray-200"> // card

These translate to semantic tokens:

What the element isLegacy exampleSemantic token
Heading texttext-gray-900, text-whitetext-heading
Body texttext-gray-700, text-gray-300text-body
Secondary/caption texttext-gray-500, text-gray-600text-subtle
Link texttext-blue-600text-link
Card/panel backgroundbg-white, bg-gray-50, bg-gray-800bg-card
Hover/zebra backgroundbg-gray-100, bg-gray-700bg-muted
Border/dividerborder-gray-200, border-gray-700border-border
Primary buttonbg-blue-600 text-whitebg-primary text-primary-foreground
Primary hoverhover:bg-blue-700hover:bg-primary-hover
Secondary buttonbg-gray-100 text-gray-900bg-secondary text-secondary-foreground

2. Brand touches — intentional fixed-shade brand references. These don't change with context.

<span className="bg-blue-50 text-blue-700">{tag}</span>     // brand badge
<div className="bg-violet-100 border border-violet-200">     // accent callout

These translate to palette shades. Map the original hue to the right palette based on the design's intent:

<span className="bg-primary-50 text-primary-700">{tag}</span>
<div className="bg-accent-100 border border-accent-200">

3. Section backgrounds — the outermost wrapper color on a component. Delete these. The runtime handles section backgrounds through context classes and frontmatter.

// BEFORE
function Features({ content }) {
  return (
    <div className="bg-white py-16 px-6">          {/* ← delete bg-white */}
      <div className="max-w-6xl mx-auto">...</div>
    </div>
  )
}

// AFTER
function Features({ content }) {
  return (
    <div className="max-w-6xl mx-auto px-6">       {/* ← just content */}
      ...
    </div>
  )
}
Features.className = 'py-16'                         // ← padding on the wrapper

Step-by-step migration

For each component:

1. Remove the section background. Find the outermost background color. Delete it. Move section-level padding (vertical) to Component.className. This single change enables the context system.

2. Classify each remaining color. Ask for each Tailwind color class: structural (heading, text, border, card), brand touch (badge, accent), or status (error, success)?

3. Replace structural colors with semantic tokens. Ask: "what is this element's role?" A caption is text-subtle. A card is bg-card. A divider is border-border.

4. Replace brand hues with palette references. bg-blue-600 on a button → bg-primary. bg-violet-50 on a callout → bg-accent-50. bg-green-50 border-green-200 text-green-800 on a success alert → bg-success-subtle border-success text-success.

5. Delete all theme-switching code. Theme maps, isDark conditionals, variant-based color objects, custom CSS variables — all replaced by the context system.

Recognizing common legacy patterns

Theme maps — the most common pattern in projects that support dark mode:

const themes = {
  light: { bg: 'bg-white', title: 'text-gray-900', text: 'text-gray-600' },
  dark:  { bg: 'bg-gray-900', title: 'text-white', text: 'text-gray-300' },
}

Delete the entire object. t.bg → removed (engine handles it). t.title → text-heading. t.text → text-body or text-subtle.

Tone/color systems — colored section backgrounds as a component param:

const tones = { neutral: 'bg-gray-50', blue: 'bg-blue-50', violet: 'bg-violet-50' }

Delete it. Colored backgrounds are content, not code. Move to frontmatter:

---
type: Features
theme: light
background:
  color: var(--secondary-50)
---

Dark mode conditionals:

const isDark = params.theme === 'dark'
<h2 className={isDark ? 'text-white' : 'text-gray-900'}>...</h2>

Delete the conditional. Replace with text-heading. The context class handles the inversion. (params.theme no longer reaches a component anyway; when logic truly needs the context — a light or dark logo — use kit's useColorContext(block).)

Custom CSS variables:

:root {
  --ink: #1a1a1a;
  --paper: #faf9f7;
  --warm: #f0efed;
  --signal: #e85d04;
  --stroke: #d6d3d1;
}

Delete the variables. Each one maps to a token or to theme.yml:

Custom variableDestination
--ink (heading/body text)text-heading / text-body (tokens in JSX)
--paper (section/page bg)contexts.light.section: "#faf9f7" in theme.yml
--warm (card surfaces)contexts.light.card: "#f0efed" in theme.yml
--signal (primary brand)colors.primary: "#e85d04" in theme.yml
--stroke (borders)border-border (token, auto-derived from neutral)

Exact values go to theme.yml. Component code uses semantic tokens. Custom variables disappear.

Hardcoded white/black:

<h1 className="text-white">...</h1>          // in a dark section
<div className="bg-black/80">...</div>         // overlay

text-white inside a dark section → text-heading (resolves to white in dark context). bg-black/80 as a section overlay → frontmatter background.overlay. If it's a card-level overlay, consider bg-section/80 which adapts to context.


The Parallel System Trap

The most common migration mistake — and the hardest to undo — is copying the original project's color system into the foundation's styles.css:

/* DON'T DO THIS */
@theme inline {
  --color-ink: #1a1a1a;
  --color-paper: #faf9f7;
  --color-warm: #f0efed;
  --color-signal: #e85d04;
}

This creates a parallel color system. The CCA tokens (--heading, --card, --primary) still exist — the build generates them from theme.yml — but no component uses them. The foundation becomes a sealed unit that ignores the site's theme.

Symptoms:

  • Changing colors in theme.yml does nothing visible
  • theme: dark in frontmatter has no effect or breaks the layout
  • The foundation only works with one specific theme.yml
  • Deploying the foundation to a different site requires editing component code

The fix is always the same: delete the custom variables, use semantic tokens in components, move the exact color values to theme.yml.

When bridge aliases are justified

During a large migration, temporary aliases avoid rewriting every component at once:

/* Temporary bridge — delete after migration is complete */
@theme inline {
  --color-ink: var(--body);
  --color-paper: var(--section);
  --color-warm: var(--card);
  --color-signal: var(--primary);
}

These map to semantic tokens, not to fixed colors. text-ink now resolves through var(--body), which changes with context. The bridge lets existing component code work while you replace text-ink with text-body one file at a time.

Delete the bridge when migration is complete. New components should always use the standard names.


Worked Example: Before and After

A features component from a typical React project:

// BEFORE: hardcoded colors, theme map, section background
const themes = {
  light: { card: 'bg-white', title: 'text-gray-900', text: 'text-gray-600', border: 'border-gray-200' },
  dark:  { card: 'bg-gray-800', title: 'text-white', text: 'text-gray-300', border: 'border-gray-700' },
}

function Features({ items, theme = 'light' }) {
  const t = themes[theme]
  return (
    <section className={`py-16 ${theme === 'dark' ? 'bg-gray-900' : 'bg-gray-50'}`}>
      <div className="max-w-6xl mx-auto px-6">
        <h2 className={`text-3xl font-bold ${t.title}`}>Features</h2>
        <div className="grid md:grid-cols-3 gap-8 mt-10">
          {items.map((item, i) => (
            <div key={i} className={`p-6 rounded-xl ${t.card} ${t.border} border`}>
              <h3 className={`text-xl font-semibold ${t.title}`}>{item.title}</h3>
              <p className={`mt-2 ${t.text}`}>{item.description}</p>
            </div>
          ))}
        </div>
      </div>
    </section>
  )
}
// AFTER: semantic tokens, no theme logic, no section background
function Features({ content }) {
  const { title, items } = content

  return (
    <div className="max-w-6xl mx-auto px-6">
      <h2 className="text-3xl font-bold text-heading">{title}</h2>
      <div className="grid md:grid-cols-3 gap-8 mt-10">
        {items.map((item, i) => (
          <div key={i} className="p-6 rounded-xl bg-card border border-border">
            <h3 className="text-xl font-semibold text-heading">{item.title}</h3>
            <p className="mt-2 text-subtle">{item.paragraphs?.[0]}</p>
          </div>
        ))}
      </div>
    </div>
  )
}

Features.className = 'py-16'

What happened:

  • The themes object and theme param → deleted (context system replaces them)
  • Section background (bg-gray-900 / bg-gray-50) → deleted (runtime handles it)
  • t.title → text-heading
  • t.text → text-subtle
  • t.card → bg-card
  • t.border → border-border
  • Vertical padding → moved to Features.className
  • Props → { content } (text comes from markdown)
  • 25 fewer lines, zero color decisions in the component

Content author controls the visual environment:

---
type: Features
theme: dark
---

One line in frontmatter replaces 8 lines of theme-switching code. And it works with any site's color palette, because the component never mentions a specific color.


Button Example

Buttons are a good test of whether the migration is right, because they combine background, text, and hover in a tight space:

// BEFORE
<button className="bg-blue-600 text-white hover:bg-blue-700 px-4 py-2 rounded-lg">
  Get Started
</button>
<button className="bg-gray-100 text-gray-900 hover:bg-gray-200 px-4 py-2 rounded-lg">
  Learn More
</button>

// AFTER
<button className="bg-primary text-primary-foreground hover:bg-primary-hover px-4 py-2 rounded-lg">
  {links[0]?.label}
</button>
<button className="bg-secondary text-secondary-foreground hover:bg-secondary-hover px-4 py-2 rounded-lg">
  {links[1]?.label}
</button>

The primary and secondary action tokens adapt to context. In a dark section, the primary button shifts shade for contrast. In a different site with red branding, it's red. The component doesn't know.

A ghost/outline button variant:

// Ghost: transparent background, hover uses muted surface
<button className="text-body hover:bg-muted px-4 py-2 rounded-lg">
  Cancel
</button>

What Stays, What Goes, What Moves

CategoryStays in JSXDeletedMoves to theme.yml / frontmatter
Layoutgrid, flex, max-w-6xl, gap-8, rounded-xl
Spacingp-6, mt-4, space-y-2, py-16 (on wrapper)
Typography scaletext-3xl, font-bold, leading-relaxed
Animationstransition-colors, hover:scale-105
Section bgbg-white, bg-gray-900Frontmatter background:
Text colorstext-gray-900, text-gray-600, text-whiteTokens: text-heading, text-body, text-subtle
Card bgbg-white, bg-gray-50, bg-gray-800Token: bg-card
Bordersborder-gray-200, border-gray-700Token: border-border
Button colorsbg-blue-600 text-whiteTokens: bg-primary text-primary-foreground
Brand hex values#e85d04, #2563ebcolors.primary, colors.secondary
Theme mapsconst themes = {...}Context system replaces
Dark conditionalsisDark ? ... : ...Context system replaces
Custom CSS vars--ink, --paper, etc.Values → theme.yml; refs → tokens

Exceptions

Two patterns legitimately use hardcoded colors.

Headers float across contexts — transparent over a dark hero, opaque when scrolled. They manage their own background and may use hardcoded colors for the transparent state. Even so, nav links can use text-subtle and hover:text-body when the header is in a normal state:

function Header({ content, block }) {
  const [scrolled, setScrolled] = useState(false)
  return (
    <header className={cn(
      'sticky top-0 z-50 backdrop-blur-md transition-shadow',
      scrolled ? 'bg-section/80 shadow-sm' : 'bg-transparent'
    )}>
      <nav className="text-subtle">
        <a className="hover:text-body transition-colors">{label}</a>
      </nav>
    </header>
  )
}

Self-managing components render their own background and set their own context class, overriding the wrapper's context:

<div className="bg-gradient-to-br from-primary-700 to-primary-950 context-dark">
  <h1 className="text-heading">{title}</h1>       {/* resolves to white */}
</div>

These should declare background: 'self' in meta.js so the runtime doesn't render an occluded background layer behind the component.

For most components, neither exception applies. Use semantic tokens.


Verification Checklist

After migrating a component or foundation:

  1. Change colors.primary in theme.yml. Every button, link, and brand accent should shift. If something keeps the old color, you missed a hardcoded reference.

  2. Set theme: dark on a section. All text should be light, cards should be dark, borders should adapt. If something is invisible or shows the wrong color, there's a hardcoded class.

  3. Set theme: medium on a section. Surfaces should be slightly darker than light context. If nothing changes from theme: light, the component is using hardcoded grays.

  4. Search for hardcoded color classes:

    grep -r 'text-gray\|text-white\|text-black\|bg-white\|bg-gray\|bg-black\|border-gray' src/sections/

    Any matches outside of Header are candidates for token replacement.

  5. Search for custom CSS variables. Any --color- definition in styles.css that maps to a fixed hex value (not var(--heading) etc.) is a parallel system.

  6. Verify styles.css imports the bridge:

    @import "tailwindcss";
    @import "@uniweb/kit/theme-tokens.css";
  7. Verify no component sets its own section background. Search for bg-white, bg-gray-50, bg-gray-900 etc. on the outermost wrapper element. These should be removed — the runtime handles section backgrounds.


Quick Reference: Legacy → Token Translation

For fast lookup during migration:

text-gray-900, text-slate-900, text-black    →  text-heading  (or text-body)
text-gray-700, text-gray-600                 →  text-body
text-gray-500, text-gray-400                 →  text-subtle
text-blue-600, text-indigo-600               →  text-link
text-white (in dark sections)                →  text-heading

bg-white, bg-gray-50 (section wrapper)       →  REMOVE (runtime handles it)
bg-white, bg-gray-50 (card/panel)            →  bg-card
bg-gray-100 (hover/zebra/code)               →  bg-muted
bg-gray-900 (section wrapper)                →  REMOVE (use theme: dark in frontmatter)

border-gray-200, border-gray-300             →  border-border
border-gray-700 (dark)                       →  border-border (auto-adapts)
border-gray-200/50 (lighter)                 →  border-border/50

bg-blue-600 text-white (button)              →  bg-primary text-primary-foreground
hover:bg-blue-700 (button)                   →  hover:bg-primary-hover
bg-gray-100 text-gray-900 (sec. button)      →  bg-secondary text-secondary-foreground

bg-red-50 border-red-200 text-red-800        →  bg-error-subtle border-error text-error
bg-green-50 border-green-200 text-green-800  →  bg-success-subtle border-success text-success
bg-amber-50 border-amber-200 text-amber-800  →  bg-warning-subtle border-warning text-warning
bg-blue-50 border-blue-200 text-blue-800     →  bg-info-subtle border-info text-info

bg-blue-50, text-blue-700 (brand badge)      →  bg-primary-50, text-primary-700
bg-violet-100 (accent decoration)            →  bg-accent-100

isDark ? 'text-white' : 'text-gray-900'     →  text-heading (delete conditional)
themes[theme].title                          →  text-heading (delete theme map)