Skip to content

Styling Architecture: Themeable Skins

Goal

Extract inline styles from TSX into external stylesheets so the app can ship multiple visual "skins" (e.g. Retro TTRPG, Dark Modern, High Contrast) by swapping a single CSS layer — no TSX changes required.


Current State

Metric Value
Mantine version 8.2.1
Component files (.tsx) ~146
Inline style={{}} occurrences ~530
Mantine styles={{}} prop usage ~30
CSS Modules 0
Plain CSS files 4 (index.css, App.css, retroVTT.css, CharacterDetail.css)
CSS custom properties (--vtt-*) ~30, defined in retroVTT.css
PostCSS config None

Current patterns in use

  1. Mantine style props (p="md", fw={500}, c="dimmed") — ~80% of components. These reference Mantine's theme tokens and are already themeable via createTheme().
  2. Inline style={{}} — The biggest problem. 530 occurrences across 146 files. A mix of:
    • Layout (width, flex, overflow, cursor) — often legitimately dynamic or one-off.
    • Visual theming (backgroundColor, color, borderRadius, fontFamily, boxShadow) — these must move to stylesheets.
    • Dynamic/computed (opacity based on state, position based on data) — must stay inline or use CSS variables.
  3. Mantine theme config (mantineTheme.ts, 618 lines) — defines colors, typography, spacing, shadows, and per-component style overrides. Already token-based. This is the heart of theming.
  4. CSS custom properties (retroVTT.css) — ~30 --vtt-* tokens for colors, fonts, shadows. Used inconsistently; many components hardcode the same values instead of referencing these tokens.
  5. Mantine styles prop — 30 occurrences. Used for overriding internal Mantine component parts (e.g. styles={{ input: { textAlign: 'center' } }}).

Why CSS Modules

Requirement CSS Modules Tailwind CSS-in-JS (Emotion/styled) Plain CSS
Markup–style separation Yes — styles live in .module.css files No — classes are in JSX Partial — template literals in JS Yes
Component scoping Automatic — hashed class names Manual (utility classes) Automatic Manual (BEM/naming)
Themeable via CSS vars Yes Yes Awkward Yes
Zero runtime cost Yes Yes No Yes
Vite support Built-in Plugin Plugin Built-in
IDE autocomplete Good (with TS plugin) Excellent Good Basic
Mantine compatibility Excellent — Mantine v8 uses CSS Modules internally Good Good Good

CSS Modules win because they provide hard scoping (no style leaks), zero runtime, complete markup–style separation, and Vite supports them out of the box with no config. They're what Mantine v8 itself uses under the hood, so they're idiomatically aligned with the existing stack.

Tailwind is ruled out per your stated preference. CSS-in-JS adds runtime cost and keeps styles in JS files, partially defeating the separation goal. Plain CSS works but requires manual discipline to avoid naming collisions.

Why CSS Custom Properties (not Sass/LESS variables)

CSS custom properties (--vtt-*) are:

  • Runtime-swappable — changing the value on :root instantly repaints everything. No rebuild needed.
  • Cascade-aware — a skin can override a subset of tokens and inherit the rest.
  • Already in use — retroVTT.css defines ~30 of them. We'd expand to ~60-80 to cover all themeable values.
  • No build step — unlike Sass variables, they work in plain .css files and in Mantine's cssVariablesResolver.

Architecture

Layer 1: Design Tokens (CSS Custom Properties)

A single file defines all themeable values. Every visual property that differs between skins is a token.

apps/web/src/styles/
  tokens/
    _base.css        ← structural tokens (spacing aliases, z-index, breakpoints)
    retro.css         ← Retro TTRPG skin (current default)
    modern-dark.css   ← future skin
    high-contrast.css ← future skin

_base.css defines the token names with neutral defaults:

:root {
  /* Surface colors */
  --vtt-bg-page: #1a1a1a;
  --vtt-bg-card: #2a2a2a;
  --vtt-bg-card-hover: #333;
  --vtt-bg-input: #222;
  --vtt-bg-overlay: rgba(0, 0, 0, 0.6);

  /* Text colors */
  --vtt-text-primary: #e0e0e0;
  --vtt-text-secondary: #999;
  --vtt-text-muted: #666;
  --vtt-text-on-primary: #fff;

  /* Brand / accent */
  --vtt-color-primary: #6366f1;
  --vtt-color-primary-hover: #818cf8;
  --vtt-color-accent: #f59e0b;
  --vtt-color-danger: #ef4444;
  --vtt-color-success: #22c55e;

  /* Borders */
  --vtt-border-color: #444;
  --vtt-border-subtle: rgba(255, 255, 255, 0.1);
  --vtt-border-radius-sm: 4px;
  --vtt-border-radius-md: 6px;
  --vtt-border-radius-lg: 8px;

  /* Typography */
  --vtt-font-heading: system-ui, sans-serif;
  --vtt-font-body: system-ui, sans-serif;
  --vtt-font-mono: 'Fira Code', monospace;

  /* Shadows */
  --vtt-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
  --vtt-shadow-md: 0 4px 8px rgba(0, 0, 0, 0.3);
  --vtt-shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);

  /* Z-index scale (already exists in codebase) */
  --vtt-z-dropdown: 200;
  --vtt-z-modal: 300;
  --vtt-z-tooltip: 400;
  --vtt-z-toast: 500;
}

retro.css overrides just the values that differ:

:root[data-skin="retro"],
:root {
  --vtt-bg-page: #2a2520;
  --vtt-bg-card: #3d362e;
  --vtt-bg-card-hover: #42392f;
  --vtt-bg-input: #332b24;
  --vtt-text-primary: #e8dcc4;
  --vtt-text-secondary: #b8a890;
  --vtt-color-primary: #8b4513;
  --vtt-color-primary-hover: #9d5219;
  --vtt-font-heading: 'Cinzel', serif;
  --vtt-font-body: 'Crimson Text', serif;
  --vtt-border-color: #8b7355;
  /* ... */
}

Switching skins at runtime = setting document.documentElement.dataset.skin = 'retro' and loading the corresponding CSS file. The Mantine cssVariablesResolver already bridges CSS vars into Mantine's theme system, so Mantine components automatically pick up the new skin's values.

Layer 2: Component Styles (CSS Modules)

Each component that currently has inline styles gets a co-located .module.css file:

components/
  UI/
    TokenContext/
      TokenContext.tsx
      TokenContext.module.css    ← NEW
    PartyPanel/
      PartyPanel.tsx
      PartyPanel.module.css     ← NEW
  Layout/
    AppShell.tsx
    AppShell.module.css         ← NEW
    TopNav.tsx
    TopNav.module.css           ← NEW

Usage pattern:

// Before
<Stack gap="md" style={{ width: '100%', padding: '12px' }}>
  <Text fw={700} size="xl" style={{ lineHeight: 1.2 }}>
    {token.name}
  </Text>
</Stack>

// After
import classes from './TokenContext.module.css';

<Stack gap="md" className={classes.root}>
  <Text fw={700} size="xl" className={classes.tokenName}>
    {token.name}
  </Text>
</Stack>
/* TokenContext.module.css */
.root {
  width: 100%;
  padding: 12px;
}

.tokenName {
  line-height: 1.2;
}

Themeable properties reference tokens:

.errorBox {
  background-color: var(--vtt-color-danger);
  border-radius: var(--vtt-border-radius-sm);
  padding: 8px;
  color: var(--vtt-text-on-primary);
}

Layer 3: Mantine Theme (already exists)

The existing mantineTheme.ts stays and continues to configure Mantine components. Its hardcoded hex values should be replaced with references to our tokens via the cssVariablesResolver:

// mantineTheme.ts — component overrides reference CSS vars
Paper: {
  styles: {
    root: {
      backgroundColor: 'var(--vtt-bg-card)',
      border: '2px solid var(--vtt-border-color)',
    },
  },
},

This means Mantine's built-in components (Button, Card, Modal, etc.) are automatically skinned by the token layer, and our custom components are skinned by their CSS Modules.


What Stays Inline

Not everything moves. These remain as style={{}}:

Category Example Reason
Truly dynamic values style={{ opacity: visibility.brightness }} Computed from runtime data
Position/transform style={{ left: x * gridSize, top: y * gridSize }} Data-driven coordinates
User-chosen colors style={{ backgroundColor: token.backgroundColor }} User-configured per-entity
Conditional one-offs style={{ borderColor: isSelected ? 'blue' : undefined }} Can also move to [data-selected] attribute + CSS, but low priority

A rough estimate: ~100-150 of the 530 inline styles are legitimately dynamic and should stay. The remaining ~380-400 are static visual/layout styles that should move to CSS Modules.


Skin Switching Mechanism

// apps/web/src/stores/settingsStore.ts (new or existing)
interface SettingsState {
  skin: 'retro' | 'modern-dark' | 'high-contrast';
  setSkin: (skin: string) => void;
}

// On skin change:
// 1. Set data-skin attribute on <html>
// 2. Load the corresponding token CSS file (dynamic import or link swap)
// 3. Persist to localStorage

Because all visual values flow through --vtt-* tokens, and all component styles reference those tokens, changing the :root variables repaints the entire app instantly. No component re-renders needed.


Migration Strategy

Phase 1: Expand and consolidate token vocabulary

  1. Audit every --vtt-* variable in retroVTT.css. Rename for consistency.
  2. Add missing tokens (there are hardcoded colors like #fee, rgba(30, 30, 35, 0.9) scattered throughout).
  3. Create _base.css with neutral defaults and retro.css that overrides them.
  4. Update mantineTheme.ts to reference var(--vtt-*) instead of hex literals.
  5. Update cssVariablesResolver to bridge the new tokens into Mantine's system.

Scope: ~2 files changed, no component changes yet.

Phase 2: Extract static visual styles to CSS Modules (file by file)

For each component file with inline styles:

  1. Create a co-located .module.css file.
  2. Move static visual styles (backgroundColor, color, borderRadius, fontFamily, boxShadow, cursor) to CSS classes that reference --vtt-* tokens.
  3. Move static layout styles (width: '100%', flex: 1, textAlign: 'center', overflow: 'hidden', maxHeight) to CSS classes.
  4. Leave truly dynamic styles inline.
  5. Import the module and apply className={classes.foo}.

Priority order (highest traffic / most visual impact first):

  1. Layout components — AppShell, TopNav, MainContent, Footer (~5 files)
  2. Token UI — TokenContext, CurrentTokenBar, TokenHoverCard (~5 files)
  3. List/card components — CampaignList, MapList, EntityList (~5 files)
  4. Chat — ChatPanel, ChatMessage (~3 files)
  5. Modals — all modal components (~15 files)
  6. Map overlays — PartyPanel, InitiativeTracker (~5 files)
  7. Everything else (~30+ files)

Each file is an independent unit of work — no cross-file dependencies.

Phase 3: Create a second skin

Build modern-dark.css as a proof that the token system works. This validates that:

  • Swapping the token file changes the entire look.
  • No component has hardcoded visual values that escape theming.
  • Mantine components and custom components both respond to the skin change.

Phase 4: User-facing skin selector

Add a skin dropdown to the settings UI. Wire to the settingsStore. Persist in localStorage.


Mantine Style Props: Keep or Replace?

Mantine style props (p="md", mb="xl", fw={500}, c="dimmed", gap="sm") are not inline styles in the problematic sense. They:

  • Reference Mantine theme tokens (so p="md" = the theme's spacing.md, which changes with the theme).
  • Don't produce hardcoded pixel values.
  • Have semantic meaning within the Mantine system.

Recommendation: keep them. They're the Mantine-idiomatic way to apply spacing and typography. Moving mb="xl" to CSS would make the code harder to read without improving themeability, since the value already flows through the theme. The only Mantine style props that should move are those using hardcoded values like c="#8b4513" instead of token names.


File Structure (final state)

apps/web/src/
  styles/
    tokens/
      _base.css              ← all token names, neutral defaults
      retro.css              ← Retro TTRPG skin (override values)
      modern-dark.css        ← Modern dark skin
      high-contrast.css      ← Accessibility skin
    retroVTT.css             ← custom scrollbar, cardstock-panel, etc (non-token styles)
    index.css                ← global resets, font imports
  theme/
    mantineTheme.ts          ← Mantine createTheme() using var(--vtt-*) tokens
  components/
    Layout/
      AppShell.tsx
      AppShell.module.css
      TopNav.tsx
      TopNav.module.css
    UI/
      TokenContext/
        TokenContext.tsx
        TokenContext.module.css
      PartyPanel/
        PartyPanel.tsx
        PartyPanel.module.css
      ...

Estimated Scope

Phase Files touched New files Effort
1 — Token consolidation ~3 2 (base + retro token files) Small
2 — CSS Module extraction ~80 components ~60 .module.css files Large (bulk of the work)
3 — Second skin 0 components 1 CSS file Small
4 — Skin selector UI ~2 components 0 Small

Phase 2 is the heavy lift, but each file is independent and mechanical — no architectural decisions per file, just moving styles and adding var() references.


Open Questions

  1. Mantine's styles prop on 30 components: Move these to CSS Modules too, or keep as theme-level overrides in mantineTheme.ts? Recommend keeping them in the theme file since they target Mantine internal class names, which CSS Modules can't scope to.

  2. Dynamic conditional styles: Cases like borderColor: isSelected ? 'blue' : undefined — convert to data attributes + CSS ([data-selected] { border-color: var(--vtt-color-primary) }) or leave inline? Data attributes are cleaner but add more refactoring per instance. Recommend: convert high-frequency patterns (selected, hovered, active states), leave rare one-offs inline.

  3. retroVTT.css utility classes (.cardstock-panel, .stat-grid, .retro-divider): Keep as global CSS classes or convert to CSS Module exports? Recommend: keep as global since they're intentionally shared across components — just ensure they reference tokens.