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
- Mantine style props (
p="md",fw={500},c="dimmed") — ~80% of components. These reference Mantine's theme tokens and are already themeable viacreateTheme(). - 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.
- 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. - 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. - Mantine
stylesprop — 30 occurrences. Used for overriding internal Mantine component parts (e.g.styles={{ input: { textAlign: 'center' } }}).
Recommended Approach: CSS Modules + CSS Custom Properties
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
:rootinstantly 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
.cssfiles and in Mantine'scssVariablesResolver.
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
- Audit every
--vtt-*variable in retroVTT.css. Rename for consistency. - Add missing tokens (there are hardcoded colors like
#fee,rgba(30, 30, 35, 0.9)scattered throughout). - Create
_base.csswith neutral defaults andretro.cssthat overrides them. - Update
mantineTheme.tsto referencevar(--vtt-*)instead of hex literals. - Update
cssVariablesResolverto 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:
- Create a co-located
.module.cssfile. - Move static visual styles (
backgroundColor,color,borderRadius,fontFamily,boxShadow,cursor) to CSS classes that reference--vtt-*tokens. - Move static layout styles (
width: '100%',flex: 1,textAlign: 'center',overflow: 'hidden',maxHeight) to CSS classes. - Leave truly dynamic styles inline.
- Import the module and apply
className={classes.foo}.
Priority order (highest traffic / most visual impact first):
- Layout components — AppShell, TopNav, MainContent, Footer (~5 files)
- Token UI — TokenContext, CurrentTokenBar, TokenHoverCard (~5 files)
- List/card components — CampaignList, MapList, EntityList (~5 files)
- Chat — ChatPanel, ChatMessage (~3 files)
- Modals — all modal components (~15 files)
- Map overlays — PartyPanel, InitiativeTracker (~5 files)
- 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'sspacing.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
-
Mantine's
stylesprop on 30 components: Move these to CSS Modules too, or keep as theme-level overrides inmantineTheme.ts? Recommend keeping them in the theme file since they target Mantine internal class names, which CSS Modules can't scope to. -
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. -
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.