FLXN theme system
A theme is a self-contained folder under src/themes/<id>/ containing exactly
two files. Themes are auto-discovered via import.meta.glob — adding a theme
is adding a folder, removing one is deleting it. No shared file is ever edited.
src/themes/
<id>/
manifest.ts # metadata + optional JS-side Mantine override
theme.css # all visual work, scoped under [data-theme="<id>"]
Activation: the provider sets data-theme="<id>" on <html> (mirrored to
localStorage flxn-theme for the pre-hydration script in __root.tsx).
The preference is stored per-user in SuperTokens metadata (metadata.theme).
manifest.ts
import { msg } from "@lingui/core/macro";
import type { ThemeManifest } from "../types";
const manifest: ThemeManifest = {
id: "comic", // MUST equal the folder name
label: msg`Comic`, // i18n — always msg``
colorSchemes: "both", // or "light" / "dark" to force a scheme
fontLinks: [ // injected only while theme is active;
"https://fonts.googleapis.com/css2?family=Bangers&display=swap",
], // browsers fetch font files lazily
chromeColors: { // browser-chrome theme-color meta
light: { base: "#fde047", dimmed: "#ca8a04" },
dark: { base: "#1c1917", dimmed: "#0c0a09" },
},
mantine: { // optional Mantine override, merged onto
defaultRadius: "xs", // the base theme via mergeThemeOverrides.
components: { /* defaultProps, vars, … */ },
},
};
export default manifest;
Rules:
labelmust usemsgfrom@lingui/core/macro(never raw strings, never globalt). Translations are handled centrally afterwards.- Prefer doing visual work in
theme.css; usemantineonly for things CSS cannot express (defaultProps, per-componentvarsresolvers).
Accent color (REQUIRED)
The user picks an accent (blue, red, green, yellow, grape, orange, pink, lime) and EVERY theme must honor it — there is no way to lock the accent.
- Primary/interactive styling must derive from the primary aliases
(
--mantine-primary-color-filled,-light,-6, …) or from the accent ramps generically — never from one hardcoded brand hue. - A theme MAY retune all eight accent ramps (
--mantine-color-blue-*,--mantine-color-red-*, …) to fit its world — e.g. neon versions, pastel versions, phosphor-tinted versions — as long as the eight choices remain visually distinct. Retuning shades is encouraged; ignoring the choice is forbidden. - Theme-flavor decoration may still use fixed hues (a comic's halftone tint, a blueprint's grid ink), but anything that reads as "the accent" — filled buttons, active tabs/nav, focus rings, selection — follows the user.
light-dark() trap
Never use light-dark() in a declaration whose selector matches <html>
(any [data-theme=…] token block): postcss-preset-mantine rewrites it into
a descendant selector that can never match the root element, so the dark
branch silently dies. Use the explicit compound scheme selectors (shape 2)
instead. Inside descendant rules ([data-theme] .mantine-Button-root)
light-dark() is fine.
theme.css — selector templates (specificity is load-bearing)
Mantine emits its variables from a runtime <style> tag that appears AFTER
our bundled CSS, so theme overrides must WIN ON SPECIFICITY, not order. Use
these exact selector shapes:
/* 1. Tokens for any scheme — (0,2,0), beats Mantine's :root block */
[data-theme="comic"][data-theme] {
--mantine-radius-default: 0;
--mantine-font-family: "Comic Neue", cursive;
}
/* 2. Per-scheme tokens — (0,3,0), beats Mantine's :root[scheme] blocks */
[data-theme="comic"][data-theme][data-mantine-color-scheme="light"] {
--mantine-color-body: #fef9c3;
}
[data-theme="comic"][data-theme][data-mantine-color-scheme="dark"] {
--mantine-color-body: #1c1917;
}
/* 3. Component restyles — descendant selectors on static classes */
[data-theme="comic"] .mantine-Button-root {
border: 3px solid #000;
box-shadow: 4px 4px 0 #000;
}
/* 4. Full-page decorations (scanlines, halftone, grain) */
[data-theme="comic"] body::before {
content: "";
position: fixed;
inset: 0;
z-index: 9998; /* above content, below --mantine-z-index-max */
pointer-events: none;
/* embed textures as data-URI SVG/gradients — no external requests */
}
The doubled [data-theme] attribute is intentional (specificity bump).
Still never use html[data-theme] or :root[data-theme] — the doubled
attribute form matches <html> at higher specificity and stays portable.
Previews (no per-theme work required)
The settings picker renders each theme in its own sandboxed same-origin
srcdoc iframe (theme-preview.tsx): the iframe's <html> carries the
theme's data-theme/data-mantine-color-scheme, its head gets a snapshot
of the app's real stylesheets, the theme's fontLinks, and the theme's
Mantine JS-side variables computed with Mantine's own resolver from the
same baseTheme + manifest.mantine + accent merge the live provider
performs. Previews are therefore 1:1 with the active theme by construction
— fonts, textures, html/body-level rules and all — and nothing bleeds
between the active theme and a preview. There is no preview variable
contract to maintain, and --mantine-primary-color-* must still never be
pinned by a theme (the user's accent must show through everywhere,
previews included).
What you can override (cheat sheet)
Mantine components style themselves from CSS variables — overriding variables retints the whole app with zero React work.
- Palettes:
--mantine-color-<name>-<0..9>and per-color variant vars (-filled,-filled-hover,-light,-light-hover,-light-color,-outline,-outline-hover,-text); primary aliases--mantine-primary-color-{0..9,filled,filled-hover,light,light-hover,light-color,contrast}. - Scheme-dependent semantics (define per scheme, selector 2):
--mantine-color-body,-text,-dimmed,-bright,-anchor,-error,-placeholder,-default,-default-hover,-default-color,-default-border. - Shape:
--mantine-radius-{xs..xl},--mantine-radius-default. - Elevation:
--mantine-shadow-{xs..xl}(composite strings — neobrutalism uses4px 4px 0 #000, glass uses soft large blurs). - Type:
--mantine-font-family{,-monospace,-headings},--mantine-font-size-{xs..xl},--mantine-h{1..6}-font-size/-line-height/-font-weight,--mantine-heading-font-weight,--mantine-line-height{,-xs..xl}. - Spacing:
--mantine-spacing-{xs..xl}(be conservative — layout shifts). - App tokens (see
base.css):--flxn-gold/-silver/-bronze,--flxn-toast-success/-error,--flxn-podium-{1,2,3}-{bg,border,label},--flxn-showcase-bg,--flxn-overlay,--flxn-dotgrid-color,--flxn-pop-shadow. Override these with selector 1/2 shapes (they beat base.css's:rooton specificity).
Static classes for restyling (most-used components in this app): Button,
ActionIcon, Paper, Card, TextInput/Input (.mantine-Input-input), Select,
Badge, Tabs (-list, -tab), SegmentedControl (-root, -indicator,
-label), Modal (-content, -header), Avatar, ThemeIcon, Skeleton,
Loader, Alert, Tooltip, Popover (-dropdown), Switch, Checkbox, Progress,
PinInput, NavLink, Notification, AppShell (-header, -navbar, -main),
Divider, Indicator — as .mantine-<Component>-<part>. State comes as data
attributes: [data-variant], [data-active], [data-disabled], etc.
Custom app components (sheet drawers, nav tiles, list rows, carousels) are
built on the same --mantine-*/--flxn-* variables and follow automatically.
App component hooks
Stable classes/tokens for custom components (defaults live in
src/themes/hooks.css; themes override under [data-theme] scopes — theme
files load after hooks.css, so equal-specificity rules win by order):
.flxn-tabs/.flxn-tab(+[data-active]) /.flxn-tabs-indicator— the swipeable tab strip used on profile/tournament pages. Restyle these so tabs belong to the theme (block active states, underlines, boxed tabs…). TWO STATES: resting (mid-page) must stay FLAT — transparent background, no strip chrome, blending into the page; the docked treatment (background, rules, shadows, textures) goes on.flxn-tabs[data-stuck]only, which the component sets while the strip is pinned to the top. hooks.css provides the transition between states. Tab/indicator styling must stay legible over the bare page background in the resting state. STACKING: the indicator is Mantine FloatingIndicator — absolutely positioned, so it would paint OVER the static tab labels; hooks.css lifts.flxn-tab(position: relative; z-index: 1) so labels always render on top. Never z-index the indicator above 1 or an opaque fill swallows the labels. PAIRING RULE: an accent-filled indicator pill/block (or accent-filled.flxn-tab[data-active]) MUST pair its label with--flxn-on-accent(black/white computed from the actual cascaded fill — safe under CSS ramp retunes, unlike--mantine-primary-color-contrast, which Mantine computes from the JS theme colors and can disagree with a CSS-retuned fill). Never accent text over an accent fill. If the indicator is an underline/rule, accent text is fine. NO glow/soft shadows on.flxn-tabs-indicator(owner preference): a clean underline or flat plate only. Rich active-tab chrome (wells, engraving, plates beyond the indicator) belongs under.flxn-tabs[data-stuck]like the strip chrome — resting tabs stay flat..flxn-tile/.flxn-tile-icon— home/tournament nav grid tiles. Treat like Card/Paper: give them the theme's surface language.- (retired)
.flxn-see-all— "see all" actions are plain Mantine subtle Buttons now, accent-driven by design. Do NOT reintroduce per-theme chip styling for them; they inherit only your generic Button treatment. .flxn-bracket-card— the small dense match cards inside the bracket view (they are Mantine Cards). Exclude them from heavy Card decorations (valances, ornaments) that suit full-width cards but overwhelm bracket density..flxn-match-card+.flxn-reaction-row— a match card with a reaction tray tucked underneath (tray = full border + full radius, hidden top edge). Keep the pair reading as one construction in your theme: matching borders and backgrounds, tray background contrasting subtly with the card..flxn-badge-tile— badge grid tile motion hook; tile shape follows--flxn-badge-radius(default 12px)..flxn-avatar-frame+--flxn-avatar-radius(default50%) +--flxn-avatar-border(default1px soliddefault-border) — avatars. The frame owns the avatar's SINGLE border via the token; NEVER put a border/outline on.mantine-Avatar-root(hooks.css force-strips it — doing so doubles every ring). The frame is a plain Box, so generic Paper restyles don't reach it; square-language themes set the radius to 0–4px and restyle via the two tokens (plus.flxn-avatar-framefor anything richer, e.g. shadows/bezels)..flxn-tile-icon— keep icons FLAT: tint the glyph (color), no background plates/borders/padding behind it. A plate is only justified when the theme's whole language demands it (rare); default to flat.
Hard rules
- EVERYTHING scoped under
[data-theme="<id>"]. Zero unscoped selectors — a theme must be inert when inactive. - Support both schemes unless the design genuinely demands one; then set
colorSchemes: "light" | "dark"(the engine forces it). - Accessibility gates:
- Wrap flicker/pulse/marquee animations in
@media (prefers-reduced-motion: no-preference) { … }. - Give
backdrop-filtersurfaces a solid fallback via@supports not (backdrop-filter: blur(1px))and respect@media (prefers-reduced-transparency: reduce). - Keep real borders on interactive elements (forced-colors mode strips shadows/gradients).
- Body text must stay ≥ 4.5:1 contrast in every supported scheme.
- Mantine hardcodes WHITE on
[data-combobox-selected](Select options) andtheme.autoContrastdoes not reach it. hooks.css fixes this globally with--flxn-on-accent; if your theme overrides the selected-option styling, pair the fill with an explicit legible on-color yourself. - Any
background-clip: textgradient text needs a@media (forced-colors: active)fallback (background-image: none; color: CanvasText) or headings vanish in forced-colors mode. - Re-flavored
.flxn-presstransforms MUST be wrapped in@media (prefers-reduced-motion: no-preference)— theme selectors outrank the global reduce kill in the provider. - Placeholder text ≥3:1 and dimmed text ≥4.5:1 against the body color.
- Text must never share its hue with the fill directly behind it
(accent-on-accent, gold-on-gold…). Accent fills pair with
--flxn-on-accent(base.css token: black/white picked from the actual cascaded--mantine-primary-color-filled); accent washes pair with--mantine-primary-color-light-color; accent text needs a neutral backing. Applies to tab indicators, SegmentedControl, active NavLinks, badges, chips — anywhere a label sits on a colored plate.
- Wrap flicker/pulse/marquee animations in
- Performance gates:
- Never animate variables on
:root/html— animate leaf elements. - At most 1–2 full-viewport overlay pseudo-elements,
pointer-events: none. - Limit
backdrop-filterto a handful of surfaces; never animate blur. - Textures/assets as data-URIs inside theme.css; no network requests.
- Never animate variables on
- Don't break layout: the app is a mobile-first PWA (AppShell header + navbar). Radius/borders/shadows/fonts are fair game; avoid changing spacing scales drastically or adding outer margins to shell parts.
- The press feedback (
.flxn-press, scale on :active) is global; themes may re-flavor it, e.g.[data-theme="brutal"] .flxn-press:active { transform: translate(4px, 4px); }. - i18n: manifest strings via
msgonly. Do NOT run lingui extract/compile — handled centrally.
Verifying a theme
bun x tsc --noEmit must pass. Do not run the dev server; visual QA happens
centrally after all themes land.