Files
flxn-app/src/themes
2026-08-23 17:17:04 -07:00
..
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00
2026-08-23 17:17:04 -07:00

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:

  • label must use msg from @lingui/core/macro (never raw strings, never global t). Translations are handled centrally afterwards.
  • Prefer doing visual work in theme.css; use mantine only for things CSS cannot express (defaultProps, per-component vars resolvers).

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 uses 4px 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 :root on 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 (default 50%) + --flxn-avatar-border (default 1px solid default-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-frame for 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-filter surfaces 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) and theme.autoContrast does 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: text gradient text needs a @media (forced-colors: active) fallback (background-image: none; color: CanvasText) or headings vanish in forced-colors mode.
    • Re-flavored .flxn-press transforms 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.
  • 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-filter to a handful of surfaces; never animate blur.
    • Textures/assets as data-URIs inside theme.css; no network requests.
  • 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 msg only. 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.