PVR Tech Studio

Design skins

Thirteen switchable visual designs — eleven token-only color skins plus two full "designs" (Bento Studio and Console) that also swap fonts, radius and the home dashboard — all built on the same design tokens, flash-free, and switchable live from four places.

9 min read
Updated August 13, 2026

Overview

Skins are a headline selling point of the template: the entire app can be re-colored (and, for two of them, re-typeset and re-laid-out) instantly, with the choice persisted and applied before first paint so there's never a flash of the wrong design.

Because design tokens are the only place colors live, a skin is nothing more than a block of token overrides scoped under [data-skin="…"] on <html>. Switch the attribute and the whole UI re-skins.

Two categories:

  • Color skins (token-only): Default, Aqua, Lime, Lilac, Prism, Midnight, Ocean, Graphite, Emerald, Amber, Coral. They redefine the raw color tokens (and nothing else) — variants of the standard layout.
  • Full "designs": Bento Studio (bento) and Console (console). In addition to colors they swap fonts and card radius, and render a bespoke home dashboard.

Every skin defines both a light and a dark variant, so skin and light/dark mode are orthogonal.

Architecture & files

FileResponsibility
src/styles/index.cssDefines the default palette, scoped to :root, [data-skin='default'] (+ .dark, [data-skin='default'].dark). Also the @theme inline mapping and skin-aware .font-display/.font-data helpers.
src/styles/_skin-colors.scssAll eleven color skins except default — one [data-skin="x"] + one [data-skin="x"].dark block each (raw --* token overrides only).
src/styles/_skin-bento.scssBento Studio: warm palette, big radius (--radius-card: 1.5rem), tile-tint tokens, flattened shadows.
src/styles/_skin-console.scssConsole: terminal palette, monospace --font-*, tight radius (0.25rem).
src/styles/_skin-prism.scssPrism: neutral base + categorical --c1..--c6, applied to .stat-icon / .chart-bar marker classes.
src/config/skins.tsThe Skin union and the SKINS array — the registry itself, kept free of React imports so build tooling can read it too.
src/context/LayoutContext.tsxskin/setSkin, <html data-skin> reflection, and localStorage('design_skin') persistence. Re-exports SKINS/Skin, so @/context/LayoutContext remains the import site.
src/app/serverPrefs.tsValidates the design_skin cookie against SKINS and renders data-skin server-side, so the first byte already carries the right design.
src/layout/overlays/LayoutCustomizer.tsxThe "Design" skin picker; maps over SKINS, labels via customizer:skin<Cap>Label/Hint.
src/layout/DesignSystemMenu.tsxHeader palette-icon quick switcher with live swatches.
src/pages/HomeRoute.tsxThe home branch — renders BentoDashboard for bento, ConsoleDashboard for console, else CrmDashboard.
src/locales/en/customizer.jsonPer-skin skin<Cap>Label / skin<Cap>Hint strings.

Mechanism at a glance: setSkin → React state → an effect writes <html data-skin> and localStorage('design_skin') → the scoped SCSS block wins → the app re-skins. On reload,

the server reads the mirrored cookie and renders data-skin in the first byte.

Usage

For non-developers. A skin can be switched from four places, all sharing one source of truth:

  • Layout Customizer (header gear → "Design" section) — cards with a label + hint.
  • Layout Settings page (/layout-settings) — the full-page twin of the Customizer.
  • ⌘K command palette — searchable skin entries.
  • Header DesignSystemMenu (palette icon) — quick switch with live color swatches.

The choice is remembered across reloads and sessions.

For developers. Read and set the skin from the layout context:

import {useLayout, SKINS, type Skin} from '@/context/LayoutContext'
 
function SkinPicker() {
    const {skin, setSkin} = useLayout()
    return (
        <div className="flex gap-2">
            {SKINS.map((id) => (
                <button
                    key={id}
                    onClick={() => setSkin(id)}
                    className={id === skin ? 'ring-2 ring-ring' : ''}
                >
                    {id}
                </button>
            ))}
        </div>
    )
}

API / Props

The Skin type & SKINS list

From src/config/skins.ts (re-exported by @/context/LayoutContext, which stays the import site):

export type Skin =
    | 'default' | 'bento' | 'aqua' | 'lime' | 'lilac' | 'prism'
    | 'midnight' | 'ocean' | 'graphite' | 'emerald' | 'amber' | 'coral' | 'console'
 
export const SKINS: Skin[] = [
    'default', 'aqua', 'lime', 'lilac', 'prism', 'midnight',
    'ocean', 'graphite', 'emerald', 'amber', 'coral', 'console', 'bento',
]

Skin catalog

data-skinLabel / hint (i18n)KindCharacterNotes
defaultDefault · Indigo · cleanColorClean blue SaaSBaseline palette in index.css.
aquaAqua · Teal · cyanColorTeal / cyan
limeLime · Sky · limeColorSky + vivid limeUses dark --primary-foreground (#0b0f0a) for contrast on lime.
lilacLilac · LavenderColorLavender / mauve
prismPrism · MulticolorColorViolet chrome + multicolor dataCategorical --c1..--c6; see below.
midnightMidnight · Slate · indigoColorSlate-indigo
oceanOcean · Azure · blueColorAzure / blue
graphiteGraphite · Mono · minimalColorMono near-black / light-grayinfo stays blue for signal.
emeraldEmerald · greenColorGreen
amberAmber · goldColorGoldUses dark --primary-foreground (#1f1500) for contrast on amber.
coralCoral · warmColorSalmon / warm
consoleConsole · Dev · terminalDesignTerminal green, JetBrains Mono, tight radius 0.25remRenders ConsoleDashboard.
bentoBento Studio · Warm · bentoDesignWarm tangerine, bento-grid, big radius 1.5remKeeps default font; renders BentoDashboard.

Each color skin overrides the standard token set (--background, --surface, --surface-muted, --foreground, --muted-foreground, --border, --primary, --primary-hover, --primary-foreground, --ring, and typically --info) under [data-skin="x"] and again under [data-skin="x"].dark.

Layout context skin API

From useLayout():

MemberTypeDescription
skinSkinThe active skin.
setSkin(s)(Skin) => voidSwitch skin; an effect reflects <html data-skin> and persists localStorage('design_skin').

Persistence key: localStorage('design_skin'). Invalid/unknown values fall back to 'default' (both at load time in loadSkin() and in the pre-paint allowlist).

Prism categorical palette

Prism keeps chrome on a single violet --primary but colors data elements with a six-hue categorical palette ( src/styles/_skin-prism.scss):

VarLightDark
--c1#7c3aed (violet)#a78bfa
--c2#ec4899 (pink)#f472b6
--c3#f97316 (orange)#fb923c
--c4#14b8a6 (teal)#2dd4bf
--c5#3b82f6 (blue)#60a5fa
--c6#f59e0b (amber)#fbbf24

Applied via marker classes: .stat-grid > * .stat-icon (each card a different hue, cycled 4n) and .chart-bar ( cycles the full palette 6n). These marker classes are harmless no-ops under every other skin.

Configuration & customization

Adding a skin

To ship a new color skin (say sunset), touch exactly these places:

  1. src/styles/_skin-colors.scss — add a [data-skin="sunset"] block and a [data-skin="sunset"].dark block, each redefining the 11 raw --* tokens (use raw --*, never --color-*).
  2. src/config/skins.ts — add 'sunset' to the Skin union and to the SKINS array (its position sets the display order). That module is deliberately framework-free, so everything else — the customizer, Layout Settings, the ⌘K palette, the header menu — reads the list from one place.
  3. src/layout/overlays/LayoutCustomizer.tsx — the Customizer maps over SKINS, so it picks the new skin up automatically; just make sure its label keys exist (step 5). (Historically this was a separate skinIds list; the current customizer iterates SKINS directly.)
  4. Nothing to register for the first paint. The server validates the design_skin cookie against the same SKINS array, so a skin added in step 2 is already trusted and rendered server-side.
  5. src/locales/en/customizer.json — add skinSunsetLabel and skinSunsetHint strings (the Customizer/Settings/⌘K read customizer:skin<Cap>Label/Hint).

Layout Settings, the ⌘K palette and the header DesignSystemMenu then pick it up automatically from SKINS.

Adding a full "design"

A full design (like Bento or Console) does everything a color skin does plus:

  • Its own _skin-*.scss partial with font/radius overrides (e.g. --font-sans/-display/-mono, --radius-card, extra tokens like Bento's tile tints). Bento also softens .shadow-sm and maps .rounded-xl/.rounded-lg to its larger radius.
  • A branch in HomeRoute() (src/pages/HomeRoute.tsx) to render its bespoke dashboard:
function HomeDashboard() {
    const {skin} = useLayout()
    if (skin === 'bento') return <BentoDashboard/>
    if (skin === 'console') return <ConsoleDashboard/>
    return <Dashboard/>
}

Bento tiles live in src/components/bento/; the Console dashboard is src/pages/dashboards/ConsoleDashboard.tsx.

Examples

A skin's SCSS block (raw tokens, light + dark) — the shape you copy for a new color skin:

/* src/styles/_skin-colors.scss */
[data-skin='ocean'] {
  --background: #f0f6fc;
  --surface: #ffffff;
  --surface-muted: #e6f0f9;
  --foreground: #0c2030;
  --muted-foreground: #51687a;
  --border: #d7e6f1;
  --primary: #0a84ff;
  --primary-hover: #0a6fd6;
  --primary-foreground: #ffffff;
  --ring: #0a84ff;
  --info: #22a7f0;
}
 
[data-skin='ocean'].dark {
  --background: #04121f;
  --surface: #0a1f30;
  /* …the dark counterparts… */
  --primary: #3aa0ff;
  --primary-foreground: #04121f;
}

A full-design partial adds fonts + radius (Console):

/* src/styles/_skin-console.scss */
[data-skin='console'] {
  --primary: #15803d;
  --font-sans: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
  --font-display: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
  --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
  --radius-card: 0.25rem;
}

Rendering a per-skin swatch preview (scope a nested data-skin so it shows that skin's colors regardless of the active one):

<div data-skin="emerald" className="bg-surface border border-border p-3 rounded-lg">
    <span className="inline-block h-4 w-4 rounded-full bg-primary"/>
</div>

Best practices

  • Skin = token overrides only. Keep color skins purely to --* values; don't add layout/behavior. Reserve font/radius/dashboard changes for genuine full "designs".
  • Always use raw --* tokens in the SCSS, never --color-* (those are Tailwind aliases that resolve transparent in plain CSS). See design-tokens-and-dark-mode.md.
  • Define both light and dark for every skin — they're orthogonal to the theme mode.
  • Watch --primary-foreground contrast on light/vivid primaries. Lime and Amber deliberately use a dark --primary-foreground so text on the accent stays legible.
  • Add the skin to SKINS and nothing else needs trusting. The server validates the cookie against that array, so an id that is in the list survives a reload and one that isn't falls back to default.
  • Register persisted keys. design_skin is already handled by the "Reset to defaults" flow ( src/lib/appStorage.ts); any new skin-related key must be added there too.

Troubleshooting

SymptomLikely cause & fix
New skin reverts to default after reload.Its id isn't in the SKINS array, so the server rejects the cookie. Add it there.
New skin doesn't appear in the picker.Missing from SKINS in LayoutContext, or its customizer:skin<Cap>Label key is absent.
Skin colors look transparent / broken in SCSS.You used var(--color-*). Use raw var(--surface) etc.
Text on the accent is unreadable (light primary).Set a dark --primary-foreground for that skin (as Lime/Amber do).
Prism multicolor isn't showing.The data element is missing its marker class (.stat-icon inside .stat-grid, or .chart-bar).
Full design's dashboard doesn't change.Add the skin === 'x' branch in HomeRoute() (src/pages/HomeRoute.tsx).
Nested "default" swatch inherits the active skin.Ensure the default palette stays scoped to :root, [data-skin='default'] (it is, in index.css); wrap the swatch in data-skin="default".

FAQ

How many skins ship? Thirteen: eleven color skins (including Default) plus two full designs (Bento Studio, Console).

Is a skin the same as dark mode? No. Skin (color identity) and mode (light/dark) are independent; every skin defines both light and dark. See design-tokens-and-dark-mode.md.

Do color skins change the layout? No — they're pure token overrides. Only the two full designs also swap fonts, radius and the home dashboard.

Why do Lime and Amber use dark text on the accent? Their --primary is a bright yellow/gold; white text would fail contrast, so --primary-foreground is a near-black.

Where does the skin persist? localStorage('design_skin'), mirrored to a cookie of the same name so the server can render <html data-skin> in the first byte — which is why switching skins survives a reload with no flash of the default design.

Can OLED combine with a skin? Yes — OLED deepens the dark neutrals while the skin's --primary accent is preserved.

Notes for designers & content editors

  • To design a new skin, you only pick eleven colors × two modes (light + dark). Match the token roles: canvas ( --background), cards (--surface), recessed (--surface-muted), text (--foreground / --muted-foreground), lines (--border), accent (--primary + hover + foreground), focus (--ring), and --info.
  • Verify the accent contrast: whatever sits on a --primary fill uses --primary-foreground. If the accent is light, make that foreground dark.
  • Bento and Console are more than palettes — they change type and layout. Treat them as distinct product looks, not just color themes.
  • Labels and one-line hints for each skin are editable in src/locales/en/customizer.json (skin<Name>Label / skin<Name>Hint) and translated per language.

Was this page helpful?