Files
sendou.ink/docs/styles.md
Kalle 23875c7c67
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled
Custom theme improvements (#3410)
Co-authored-by: hfcRed <hfcred@gmx.net>
2026-09-23 17:43:48 +03:00

5.3 KiB

Custom Theme System

The custom theme system lets Patreon supporters customize the sites colors, border radii, sizes, and border widths. Custom themes are created using a small set of inputs (slider values) which are expanded into a full set of CSS properties by ThemePalette.build(). The original intention of the system is that no combination of inputs can produce inaccessible colors.

Files

File Purpose
app/styles/vars.css Default CSS custom property values and semantic tokens
app/features/theme/core/ThemePalette.ts Expands the inputs into theme variables, DEFAULT_THEME_INPUT, lightness values, chroma multipliers
app/utils/oklch-gamut.ts Color math: sRGB gamut limits and WCAG contrast
app/utils/schema.ts themeInputSchema and THEME_INPUT_LIMITS for validation
app/features/theme/theme-constants.ts CUSTOM_THEME_VARS list
app/components/CustomThemeSelector.tsx UI component
app/root.tsx useCustomThemeVars() applies theme to <html> element

Semantic tokens

Feature code never uses the palette slots (--color-accent, --color-accent-low...) directly. Accent and secondary colors are consumed through four kinds of tokens:

Token Role Example
--color-bg-x Tinted surface Container background, highlighted row
--color-fg-x Foreground on surfaces Text, icons, borders, outlines, indicator dots and bars
--color-fill-x Fill that holds content Button, badge background
--color-fg-on-x Foreground on a fill Button label, badge text

--color-fg-x is readable on the page surfaces and on --color-bg-x. --color-fg-on-x is only readable on --color-fill-x, so always put a fill and its foreground together.

How colors are generated

Every color slot has a designed lightness (e.g. dark mode --color-accent-high is 83%). build() then:

  1. Lifts light slots toward the hue's cusp. Hues like yellow are only vivid when very light, at 83% they'd be a muddy khaki. Slots with maxCuspLift are raised toward the lightness where the hue is at its most saturated.
  2. Rotates dark yellow shades toward amber. Dark yellow reads as olive, so yellow hues get their hue shifted (SHADE_HUE_SHIFT) the further below their cusp they are.
  3. Boosts chroma for hues with a larger gamut. The accent chroma is scaled by how much more chroma the hue can have than the default accent hue at that lightness (GAMUT_BOOST), so a yellow can be as vivid as the default blue.
  4. Solves for contrast. Text colors are moved lighter/darker until they have at least 4.5:1 (WCAG AA) against every surface they are shown on, including the tinted --color-bg-accent / --color-bg-second surfaces.
  5. Picks the light mode fills. --color-fill-accent and --color-fill-second (buttons, badges) are normally the same as the foreground color with white text on it. When a bright fill would be much more colorful (yellow, cyan...) it becomes a bright fill with dark text instead (--_acc-fill-dark-text, --_second-fill-dark-text).

The dark mode background lightness (--_base-l) is a slider of its own. Dark mode surfaces (--color-base-5...7) keep their distance from it.

ThemePalette.test.ts sweeps inputs, checks the contrast of every text/background pair (textContrastPairs()) and that every resolved color (resolveColors()) is inside the sRGB gamut. It also evaluates the oklch() and calc() expressions of vars.css for the default theme and checks that every --color-x resolves to the same value as the matching key of resolveColors(), so the lightness values, surface offsets and slot mapping cannot drift apart between the two files.

Changing Default Theme Values

ThemePalette.build(DEFAULT_THEME_INPUT) must produce the values in the first block of vars.css. A unit test verifies this.

Update procedure

  1. Edit DEFAULT_THEME_INPUT or the constants in ThemePalette.ts
  2. Run ThemePalette.build(DEFAULT_THEME_INPUT) to get the output CSS variable values
  3. Update vars.css with the output values
  4. If lightness values or offsets changed, update the oklch() / calc() calls in vars.css to match (the sync test fails until they do)

Gotchas

Adding theme variables

Stored themes are the output of build() at the time they were saved, and every stored theme is expected to have every variable in CUSTOM_THEME_VARS. When adding a variable, add a migration that backfills it into User.customTheme and AllTeam.customTheme (see migrations/20260922181213-custom-theme-palette-vars.ts). Backfill the value that reproduces how existing themes render, so they only change when re-saved.

toThemeInput() recovers the slider values from a stored theme. The accent chroma is stored as is (--_acc-c) because the gamut boost makes it impossible to reverse from the output.

Size and border vars are for users only

useCustomThemeVars() in root.tsx only applies --_size-* and --_border-width from the users own theme. When viewing the page of another user or team, their theme only overrides color and radius variables, never size or border, to prevent layout shifts and accessibility issues.

The --_base-c-0 slot is always near zero

At lightness 1.0 (pure white), no hue can have meaningful chroma in sRGB. This slot effectively rounds to 0 for all inputs.