diff --git a/app/components/Chart.tsx b/app/components/Chart.tsx index 2d2325447..b1499b71c 100644 --- a/app/components/Chart.tsx +++ b/app/components/Chart.tsx @@ -87,12 +87,12 @@ export function Chart({ const colors = useThemeColors({ // "high" variants for the curve lines so they stay legible on the dark chart - accentHigh: "--color-text-accent", + accentHigh: "--color-fg-accent", infoHigh: "--color-info-high", - secondHigh: "--color-second-high", - // low variants for the highlight marker fills (paired with a light border) - accentLow: "--color-accent-low", - secondLow: "--color-second-low", + secondHigh: "--color-fg-second", + // tinted surfaces for the highlight marker fills (paired with a light border) + accentLow: "--color-bg-accent", + secondLow: "--color-bg-second", border: "--color-border", borderHigh: "--color-border-high", text: "--color-text-high", diff --git a/app/components/CustomThemeSelector.module.css b/app/components/CustomThemeSelector.module.css index 786419eef..539a181b5 100644 --- a/app/components/CustomThemeSelector.module.css +++ b/app/components/CustomThemeSelector.module.css @@ -84,7 +84,7 @@ } .chatColorPreview { - color: oklch(from var(--color-text-accent) l c var(--_chat-h)); + color: oklch(from var(--color-fg-accent) l c var(--_chat-h)); } .themeShare { diff --git a/app/components/CustomThemeSelector.tsx b/app/components/CustomThemeSelector.tsx index a896e6162..ad4720db2 100644 --- a/app/components/CustomThemeSelector.tsx +++ b/app/components/CustomThemeSelector.tsx @@ -1,17 +1,11 @@ import { Check, Clipboard, PencilLine } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import * as v from "valibot"; import type { CustomTheme } from "~/db/tables-json"; +import * as ThemePalette from "~/features/theme/core/ThemePalette"; import { CUSTOM_THEME_VARS } from "~/features/theme/theme-constants"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { - ACCENT_CHROMA_MULTIPLIERS, - BASE_CHROMA_MULTIPLIERS, - clampThemeToGamut, - type ThemeInput, -} from "~/utils/oklch-gamut"; -import { THEME_INPUT_LIMITS, themeInputSchema } from "~/utils/schema"; +import { THEME_INPUT_LIMITS } from "~/utils/schema"; import styles from "./CustomThemeSelector.module.css"; import { Divider } from "./Divider"; import { LinkButton, SendouButton } from "./elements/Button"; @@ -55,6 +49,15 @@ const COLOR_SLIDERS = [ labelKey: "accentChroma", isHue: false, }, + { + id: "bg-lightness", + inputKey: "bgLightness", + min: THEME_INPUT_LIMITS.BG_LIGHTNESS_MIN, + max: THEME_INPUT_LIMITS.BG_LIGHTNESS_MAX, + step: THEME_INPUT_LIMITS.BG_LIGHTNESS_STEP, + labelKey: "bgLightness", + isHue: false, + }, ] as const; const RADIUS_SLIDERS = [ @@ -129,91 +132,10 @@ type ThemeInputKey = | (typeof SIZE_SLIDERS)[number]["inputKey"] | "chatHue"; -const THEME_STRING_KEYS: readonly ThemeInputKey[] = [ - ...COLOR_SLIDERS.map((s) => s.inputKey), - ...RADIUS_SLIDERS.map((s) => s.inputKey), - ...BORDER_SLIDERS.map((s) => s.inputKey), - ...SIZE_SLIDERS.map((s) => s.inputKey), - "chatHue", -]; +function applyThemeInput(input: ThemePalette.ThemeInput) { + const theme = ThemePalette.build(input); -function themeInputToString(input: ThemeInput): string { - return THEME_STRING_KEYS.map((key) => { - const value = input[key]; - return value === null ? "_" : String(value); - }).join(";"); -} - -function themeInputFromString(str: string): ThemeInput | null { - const parts = str.split(";"); - if (parts.length !== THEME_STRING_KEYS.length) return null; - - const raw: Record = {}; - for (let i = 0; i < THEME_STRING_KEYS.length; i++) { - const key = THEME_STRING_KEYS[i]; - const part = parts[i].trim(); - - if (key === "chatHue" && part === "_") { - raw[key] = null; - continue; - } - - const num = Number(part); - if (Number.isNaN(num)) return null; - raw[key] = num; - } - - const parsed = v.safeParse(themeInputSchema, raw); - return parsed.success ? parsed.output : null; -} - -const DEFAULT_THEME_INPUT: ThemeInput = { - baseHue: 268, - baseChroma: 0.05, - accentHue: 253, - accentChroma: 0.24, - chatHue: null, - radiusBox: 3, - radiusField: 2, - radiusSelector: 2, - borderWidth: 2, - sizeField: 1, - sizeSelector: 1, - sizeSpacing: 1, -}; - -function themeInputFromCustomTheme(customTheme: CustomTheme): ThemeInput { - return { - baseHue: customTheme["--_base-h"] ?? DEFAULT_THEME_INPUT.baseHue, - baseChroma: - typeof customTheme["--_base-c-2"] === "number" - ? customTheme["--_base-c-2"] / BASE_CHROMA_MULTIPLIERS[2] - : DEFAULT_THEME_INPUT.baseChroma, - accentHue: customTheme["--_acc-h"] ?? DEFAULT_THEME_INPUT.accentHue, - accentChroma: - typeof customTheme["--_acc-c-2"] === "number" - ? customTheme["--_acc-c-2"] / ACCENT_CHROMA_MULTIPLIERS[2] - : DEFAULT_THEME_INPUT.accentChroma, - chatHue: customTheme["--_chat-h"], - radiusBox: customTheme["--_radius-box"] ?? DEFAULT_THEME_INPUT.radiusBox, - radiusField: - customTheme["--_radius-field"] ?? DEFAULT_THEME_INPUT.radiusField, - radiusSelector: - customTheme["--_radius-selector"] ?? DEFAULT_THEME_INPUT.radiusSelector, - borderWidth: - customTheme["--_border-width"] ?? DEFAULT_THEME_INPUT.borderWidth, - sizeField: customTheme["--_size-field"] ?? DEFAULT_THEME_INPUT.sizeField, - sizeSelector: - customTheme["--_size-selector"] ?? DEFAULT_THEME_INPUT.sizeSelector, - sizeSpacing: - customTheme["--_size-spacing"] ?? DEFAULT_THEME_INPUT.sizeSpacing, - }; -} - -function applyThemeInput(input: ThemeInput) { - const clampedTheme = clampThemeToGamut(input); - - for (const [key, value] of Object.entries(clampedTheme)) { + for (const [key, value] of Object.entries(theme)) { document.documentElement.style.setProperty(key, String(value)); } } @@ -268,7 +190,7 @@ export function CustomThemeSelector({ initialTheme: CustomTheme | null | undefined; isSupporter: boolean; isPersonalTheme: boolean; - onSave: (themeInput: ThemeInput) => void; + onSave: (themeInput: ThemePalette.ThemeInput) => void; onReset: () => void; hidePatreonInfo?: boolean; fetcherState?: "idle" | "submitting" | "loading"; @@ -276,11 +198,11 @@ export function CustomThemeSelector({ const { t } = useTranslation(["common"]); const initialThemeInput = initialTheme - ? themeInputFromCustomTheme(initialTheme) - : DEFAULT_THEME_INPUT; + ? ThemePalette.toThemeInput(initialTheme) + : ThemePalette.DEFAULT_THEME_INPUT; const [themeInput, setThemeInput] = - React.useState(initialThemeInput); + React.useState(initialThemeInput); const handleSliderChange = (inputKey: ThemeInputKey, value: number) => { const updatedInput = { ...themeInput, [inputKey]: value }; @@ -304,7 +226,7 @@ export function CustomThemeSelector({ }; const handleReset = () => { - setThemeInput(DEFAULT_THEME_INPUT); + setThemeInput(ThemePalette.DEFAULT_THEME_INPUT); for (const varDef of CUSTOM_THEME_VARS) { document.documentElement.style.removeProperty(varDef); } @@ -464,17 +386,17 @@ function ThemeShareInput({ themeInput, onImport, }: { - themeInput: ThemeInput; - onImport: (input: ThemeInput) => void; + themeInput: ThemePalette.ThemeInput; + onImport: (input: ThemePalette.ThemeInput) => void; }) { const { t } = useTranslation(["common"]); const { copyToClipboard, copySuccess } = useCopyToClipboard(); - const themeString = themeInputToString(themeInput); + const themeString = ThemePalette.toShareCode(themeInput); const handlePaste = async () => { const text = await navigator.clipboard.readText(); - const parsed = themeInputFromString(text); + const parsed = ThemePalette.fromShareCode(text); if (parsed) onImport(parsed); }; diff --git a/app/components/Divider.module.css b/app/components/Divider.module.css index 8633ebfbb..2c3a9271b 100644 --- a/app/components/Divider.module.css +++ b/app/components/Divider.module.css @@ -2,7 +2,7 @@ display: flex; width: 100%; align-items: center; - color: var(--color-text-accent); + color: var(--color-fg-accent); font-size: var(--font-lg); text-align: center; diff --git a/app/components/DotPagination.module.css b/app/components/DotPagination.module.css index b07544875..b7ed1bfc6 100644 --- a/app/components/DotPagination.module.css +++ b/app/components/DotPagination.module.css @@ -21,7 +21,7 @@ } .buttonActive { - color: var(--color-text-accent); + color: var(--color-fg-accent); background-color: var(--color-bg-high); border-color: var(--color-border-high); } diff --git a/app/components/MapPoolSelector.module.css b/app/components/MapPoolSelector.module.css index 958061795..31e693e9e 100644 --- a/app/components/MapPoolSelector.module.css +++ b/app/components/MapPoolSelector.module.css @@ -49,7 +49,7 @@ border: var(--border-style); border-radius: var(--radius-full); background-color: var(--color-bg); - color: var(--color-accent); + color: var(--color-fg-accent); opacity: 1 !important; outline: initial; diff --git a/app/components/MobileNav.module.css b/app/components/MobileNav.module.css index 3f480267f..8425dcb3a 100644 --- a/app/components/MobileNav.module.css +++ b/app/components/MobileNav.module.css @@ -62,7 +62,7 @@ &:hover, &[data-active="true"] { - color: var(--color-text-accent); + color: var(--color-fg-accent); } } @@ -282,7 +282,7 @@ text-align: center; &:hover { - color: var(--color-text-accent); + color: var(--color-fg-accent); } } @@ -380,8 +380,8 @@ right: -8px; font-size: var(--font-2xs); font-weight: var(--weight-bold); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); + color: var(--color-fg-on-accent); + background-color: var(--color-fill-accent); min-width: 16px; height: 16px; padding: 0 var(--s-0-5); diff --git a/app/components/NotificationDot.module.css b/app/components/NotificationDot.module.css index 72d9b3592..eb986c1ac 100644 --- a/app/components/NotificationDot.module.css +++ b/app/components/NotificationDot.module.css @@ -12,7 +12,7 @@ width: 100%; height: 100%; position: absolute; - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); border-radius: 100%; outline: 2px solid var(--color-bg); } @@ -25,7 +25,7 @@ top: -10px; left: -10px; border-radius: 100%; - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); animation: pulse 2s infinite; opacity: 0; } diff --git a/app/components/SideNav.module.css b/app/components/SideNav.module.css index cf0b2cf18..e8fc7507c 100644 --- a/app/components/SideNav.module.css +++ b/app/components/SideNav.module.css @@ -167,8 +167,8 @@ margin-left: auto; font-size: var(--font-2xs); font-weight: var(--weight-semi); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); + color: var(--color-fg-on-accent); + background-color: var(--color-fill-accent); padding: 0 var(--s-1); border-radius: var(--radius-selector); height: var(--selector-size-xs); @@ -178,7 +178,8 @@ } .listLinkBadgeWarning { - background-color: var(--color-text-second); + color: var(--color-fg-on-second); + background-color: var(--color-fill-second); } .sideNavHeader { diff --git a/app/components/SubNav.module.css b/app/components/SubNav.module.css index 25182e85a..35ee4c541 100644 --- a/app/components/SubNav.module.css +++ b/app/components/SubNav.module.css @@ -21,7 +21,7 @@ gap: var(--s-1-5); &.active { - color: var(--color-text-accent); + color: var(--color-fg-accent); } } diff --git a/app/components/elements/Button.module.css b/app/components/elements/Button.module.css index 9b9196da7..a2eaa9130 100644 --- a/app/components/elements/Button.module.css +++ b/app/components/elements/Button.module.css @@ -3,17 +3,17 @@ width: auto; align-items: center; justify-content: center; - border: var(--border-style-accent); + border: var(--border-width) solid var(--color-fill-accent); border-radius: var(--radius-field); appearance: none; - background: var(--color-text-accent); - color: var(--color-text-inverse); + background: var(--color-fill-accent); + color: var(--color-fg-on-accent); cursor: pointer; font-size: var(--font-sm); font-weight: var(--weight-bold); padding: 0 var(--field-padding); user-select: none; - outline-color: var(--color-text-accent); + outline-color: var(--color-fg-accent); height: var(--field-size); white-space: nowrap; @@ -36,8 +36,9 @@ } .outlined { + border-color: var(--color-fg-accent); background-color: transparent; - color: var(--color-text-accent); + color: var(--color-fg-accent); } .outlinedSuccess { @@ -84,7 +85,7 @@ padding: 0; border: none; background-color: transparent; - color: var(--color-text-accent); + color: var(--color-fg-accent); outline: initial; &:focus-visible { @@ -103,6 +104,7 @@ .success { border-color: var(--color-success); background-color: var(--color-success); + color: var(--color-text-inverse); outline-color: var(--color-success); } diff --git a/app/components/elements/Calendar.module.css b/app/components/elements/Calendar.module.css index 586112629..3ea5805f1 100644 --- a/app/components/elements/Calendar.module.css +++ b/app/components/elements/Calendar.module.css @@ -22,7 +22,7 @@ .navButton { background-color: transparent; - color: var(--color-text-accent); + color: var(--color-fg-accent); border: none; padding: 0; border-radius: 100%; @@ -54,7 +54,7 @@ width: 35px; height: 35px; border-radius: var(--radius-field); - outline-color: var(--color-accent); + outline-color: var(--color-fg-accent); border: none; background: transparent; color: var(--color-text); @@ -68,7 +68,7 @@ &[data-selected] { background-color: var(--color-bg-high); - color: var(--color-text-accent); + color: var(--color-fg-accent); } &:hover { diff --git a/app/components/elements/ChipRadio.module.css b/app/components/elements/ChipRadio.module.css index 069b3aa13..b56637834 100644 --- a/app/components/elements/ChipRadio.module.css +++ b/app/components/elements/ChipRadio.module.css @@ -46,7 +46,7 @@ } &:checked + .label { - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); } } diff --git a/app/components/elements/Menu.module.css b/app/components/elements/Menu.module.css index d383e1897..878749b13 100644 --- a/app/components/elements/Menu.module.css +++ b/app/components/elements/Menu.module.css @@ -62,7 +62,7 @@ } .itemActive { - color: var(--color-text-accent); + color: var(--color-fg-accent); } .itemDestructive { diff --git a/app/components/elements/Select.module.css b/app/components/elements/Select.module.css index bce5375a5..9efa381e9 100644 --- a/app/components/elements/Select.module.css +++ b/app/components/elements/Select.module.css @@ -129,7 +129,7 @@ } .itemSelected { - color: var(--color-text-accent); + color: var(--color-fg-accent); font-weight: var(--weight-bold); } @@ -140,7 +140,7 @@ border-bottom: 1px solid var(--color-border); border-radius: 0; - accent-color: var(--color-accent); + accent-color: var(--color-fg-accent); color: var(--color-text); outline: none; padding: var(--s-1-5) var(--s-1-5) calc(var(--s-0-5) + var(--s-2)) diff --git a/app/components/elements/Switch.module.css b/app/components/elements/Switch.module.css index abd257faa..0bb5c982b 100644 --- a/app/components/elements/Switch.module.css +++ b/app/components/elements/Switch.module.css @@ -36,12 +36,12 @@ margin-block-end: 0; &:has(> .input:checked) .indicator { - background: var(--color-text-accent); - border-color: var(--color-text-accent); + background: var(--color-fill-accent); + border-color: var(--color-fill-accent); grid-template-columns: 1fr 1fr 0fr; &:before { - background: var(--color-text-inverse); + background: var(--color-fg-on-accent); } } diff --git a/app/components/elements/Tabs.module.css b/app/components/elements/Tabs.module.css index 69b04c441..86afd6913 100644 --- a/app/components/elements/Tabs.module.css +++ b/app/components/elements/Tabs.module.css @@ -88,12 +88,12 @@ } &[data-selected] .tabButton { - border-color: var(--color-text-accent); + border-color: var(--color-fg-accent); color: var(--color-text); } &:focus-visible .tabButton { - color: var(--color-text-accent) !important; + color: var(--color-fg-accent) !important; outline: none; } } @@ -141,7 +141,7 @@ } .tabNumber { - color: var(--color-text-accent); + color: var(--color-fg-accent); margin-inline-start: var(--s-2); } @@ -186,7 +186,7 @@ &[data-selected] .tabButton { border-bottom-color: transparent; - border-inline-end-color: var(--color-text-accent); + border-inline-end-color: var(--color-fg-accent); } } diff --git a/app/components/filter-bar/FilterBar.module.css b/app/components/filter-bar/FilterBar.module.css index 66f042b2f..02e987e8c 100644 --- a/app/components/filter-bar/FilterBar.module.css +++ b/app/components/filter-bar/FilterBar.module.css @@ -128,7 +128,7 @@ } .value { - color: var(--color-text-accent); + color: var(--color-fg-accent); } .chevron, diff --git a/app/components/layout/ChatSidebar.module.css b/app/components/layout/ChatSidebar.module.css index 83b0e854e..77850aa11 100644 --- a/app/components/layout/ChatSidebar.module.css +++ b/app/components/layout/ChatSidebar.module.css @@ -101,8 +101,8 @@ .unreadBadge { font-size: var(--font-2xs); font-weight: var(--weight-bold); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); + color: var(--color-fg-on-accent); + background-color: var(--color-fill-accent); min-width: 18px; height: 18px; padding: 0 var(--s-1); @@ -146,8 +146,8 @@ right: -2px; font-size: var(--font-3xs); font-weight: var(--weight-bold); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); + color: var(--color-fg-on-accent); + background-color: var(--color-fill-accent); min-width: 14px; height: 14px; padding: 0 3px; diff --git a/app/components/layout/Footer.module.css b/app/components/layout/Footer.module.css index b0e7cb34d..e941db273 100644 --- a/app/components/layout/Footer.module.css +++ b/app/components/layout/Footer.module.css @@ -122,7 +122,7 @@ padding: var(--s-1) var(--s-2-5); border-radius: var(--radius-full); background-color: var(--color-bg-higher); - color: var(--color-text-accent); + color: var(--color-fg-accent); } .cta { diff --git a/app/components/layout/GlobalSearch.module.css b/app/components/layout/GlobalSearch.module.css index 484379722..9e3f214be 100644 --- a/app/components/layout/GlobalSearch.module.css +++ b/app/components/layout/GlobalSearch.module.css @@ -123,7 +123,7 @@ border-bottom: 1px solid var(--color-border); &:focus-within { - border-color: var(--color-text-accent); + border-color: var(--color-fg-accent); } } @@ -201,8 +201,8 @@ } .searchTypeRadioSelected { - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); border-color: transparent; } diff --git a/app/components/layout/TopRightButtons.module.css b/app/components/layout/TopRightButtons.module.css index 44e075861..6e3a8274a 100644 --- a/app/components/layout/TopRightButtons.module.css +++ b/app/components/layout/TopRightButtons.module.css @@ -93,8 +93,8 @@ right: -6px; font-size: var(--font-2xs); font-weight: var(--weight-bold); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); + color: var(--color-fg-on-accent); + background-color: var(--color-fill-accent); min-width: 18px; height: 18px; padding: 0 var(--s-1); diff --git a/app/components/layout/index.module.css b/app/components/layout/index.module.css index a8c756bd0..425f1bf2b 100644 --- a/app/components/layout/index.module.css +++ b/app/components/layout/index.module.css @@ -48,10 +48,10 @@ justify-content: center; width: 36px; height: 36px; - background-color: var(--color-text-accent); + background-color: var(--color-fill-accent); border-radius: var(--radius-field); font-weight: var(--weight-bold); - color: var(--color-text-inverse); + color: var(--color-fg-on-accent); text-decoration: none; flex-shrink: 0; transition: background-color 0.2s; @@ -178,8 +178,8 @@ .friendRequestsBadge { font-size: var(--font-2xs); font-weight: var(--weight-bold); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); + color: var(--color-fg-on-accent); + background-color: var(--color-fill-accent); min-width: 18px; height: 18px; padding: 0 var(--s-1); @@ -196,8 +196,8 @@ inset-inline-end: -5px; font-size: var(--font-2xs); font-weight: var(--weight-bold); - color: var(--color-text-inverse); - background-color: var(--color-text-accent); + color: var(--color-fg-on-accent); + background-color: var(--color-fill-accent); min-width: 16px; height: 16px; padding: 0 var(--s-0-5); diff --git a/app/components/match-page/MatchActionPickBanTab.module.css b/app/components/match-page/MatchActionPickBanTab.module.css index bcf773d08..799ed7f2b 100644 --- a/app/components/match-page/MatchActionPickBanTab.module.css +++ b/app/components/match-page/MatchActionPickBanTab.module.css @@ -175,13 +175,13 @@ .tileNumber { position: absolute; - background-color: var(--color-text-accent); + background-color: var(--color-fill-accent); border-radius: 100%; width: 18px; height: 18px; display: grid; place-items: center; - color: var(--color-text-inverse); + color: var(--color-fg-on-accent); font-size: var(--font-2xs); font-weight: var(--weight-semi); top: -5px; diff --git a/app/components/match-page/MatchActionTab.module.css b/app/components/match-page/MatchActionTab.module.css index 870054a45..d14ace46c 100644 --- a/app/components/match-page/MatchActionTab.module.css +++ b/app/components/match-page/MatchActionTab.module.css @@ -175,7 +175,7 @@ height: 100%; &:hover .checkCircle { - border-color: var(--color-accent-high); + border-color: var(--color-fill-accent); } @container (max-width: 599px) { @@ -199,9 +199,9 @@ } .checkCircleSelected { - background-color: var(--color-accent-high); - border-color: var(--color-accent-high); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + border-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); & svg { stroke-width: 3px; diff --git a/app/components/match-page/MatchRosterTab.module.css b/app/components/match-page/MatchRosterTab.module.css index dbc9e7f92..a5f5c99d3 100644 --- a/app/components/match-page/MatchRosterTab.module.css +++ b/app/components/match-page/MatchRosterTab.module.css @@ -216,10 +216,10 @@ flex-shrink: 0; &[data-side="alpha"] { - background-color: var(--color-accent); + background-color: var(--color-fg-accent); } &[data-side="bravo"] { - background-color: var(--color-second); + background-color: var(--color-fg-second); } } diff --git a/app/features/art/components/ArtGrid.module.css b/app/features/art/components/ArtGrid.module.css index 0393bf380..9b4924431 100644 --- a/app/features/art/components/ArtGrid.module.css +++ b/app/features/art/components/ArtGrid.module.css @@ -172,7 +172,8 @@ } .dialogTagUser { - background-color: var(--color-accent); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); } @keyframes lightbox-zoom-in { diff --git a/app/features/articles/routes/a.module.css b/app/features/articles/routes/a.module.css index 899727371..0f479fd24 100644 --- a/app/features/articles/routes/a.module.css +++ b/app/features/articles/routes/a.module.css @@ -7,6 +7,6 @@ } .title { - color: var(--color-text-accent); + color: var(--color-fg-accent); font-size: var(--font-md); } diff --git a/app/features/availability/components/RegistrationAvailabilityPanel.module.css b/app/features/availability/components/RegistrationAvailabilityPanel.module.css index 9df9945fe..c3231a116 100644 --- a/app/features/availability/components/RegistrationAvailabilityPanel.module.css +++ b/app/features/availability/components/RegistrationAvailabilityPanel.module.css @@ -122,7 +122,7 @@ } .noteFlag { - color: var(--color-text-accent); + color: var(--color-fg-accent); flex-shrink: 0; } diff --git a/app/features/availability/components/ScheduleDayCell.module.css b/app/features/availability/components/ScheduleDayCell.module.css index a6aa02830..b79992c1d 100644 --- a/app/features/availability/components/ScheduleDayCell.module.css +++ b/app/features/availability/components/ScheduleDayCell.module.css @@ -50,5 +50,5 @@ } .noteFlag { - color: var(--color-text-accent); + color: var(--color-fg-accent); } diff --git a/app/features/availability/components/ScheduleHeatmap.module.css b/app/features/availability/components/ScheduleHeatmap.module.css index cffe88bb5..2d29edabd 100644 --- a/app/features/availability/components/ScheduleHeatmap.module.css +++ b/app/features/availability/components/ScheduleHeatmap.module.css @@ -46,8 +46,8 @@ transition: background-color 0.15s; &[aria-pressed="true"] { - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); } &:focus-visible { diff --git a/app/features/availability/components/ScheduleNudge.module.css b/app/features/availability/components/ScheduleNudge.module.css index 7ed4c490d..13397cd91 100644 --- a/app/features/availability/components/ScheduleNudge.module.css +++ b/app/features/availability/components/ScheduleNudge.module.css @@ -24,7 +24,7 @@ gap: var(--s-2); font-size: var(--font-2xs); font-weight: var(--weight-bold); - color: var(--color-text-accent); + color: var(--color-fg-accent); &:hover { text-decoration: underline; diff --git a/app/features/availability/components/ScheduleTracks.module.css b/app/features/availability/components/ScheduleTracks.module.css index 64f74a5c7..6fb9036e5 100644 --- a/app/features/availability/components/ScheduleTracks.module.css +++ b/app/features/availability/components/ScheduleTracks.module.css @@ -90,7 +90,7 @@ } .noteFlag { - color: var(--color-text-accent); + color: var(--color-fg-accent); flex-shrink: 0; } diff --git a/app/features/availability/components/WeekAvailabilityEditor.module.css b/app/features/availability/components/WeekAvailabilityEditor.module.css index 923b5afd3..67040c080 100644 --- a/app/features/availability/components/WeekAvailabilityEditor.module.css +++ b/app/features/availability/components/WeekAvailabilityEditor.module.css @@ -147,7 +147,7 @@ border: none; border-radius: var(--radius-full); font-size: var(--font-xs); - color: var(--color-text-accent); + color: var(--color-fg-accent); cursor: pointer; &:focus-visible { diff --git a/app/features/availability/routes/t.$customUrl.schedule.module.css b/app/features/availability/routes/t.$customUrl.schedule.module.css index c4809e01e..d3427f30b 100644 --- a/app/features/availability/routes/t.$customUrl.schedule.module.css +++ b/app/features/availability/routes/t.$customUrl.schedule.module.css @@ -81,7 +81,7 @@ } .noteFlag { - color: var(--color-text-accent); + color: var(--color-fg-accent); } .dayDot { diff --git a/app/features/badges/components/BadgeDisplay.module.css b/app/features/badges/components/BadgeDisplay.module.css index dd4b95e82..cdf758876 100644 --- a/app/features/badges/components/BadgeDisplay.module.css +++ b/app/features/badges/components/BadgeDisplay.module.css @@ -48,7 +48,7 @@ margin-top: -8px; margin-right: auto; margin-left: auto; - color: var(--color-accent-high); + color: var(--color-fg-accent); font-size: var(--font-2xs); font-weight: var(--weight-bold); } diff --git a/app/features/badges/routes/badges.$id.module.css b/app/features/badges/routes/badges.$id.module.css index 731ed3d7e..024a61b15 100644 --- a/app/features/badges/routes/badges.$id.module.css +++ b/app/features/badges/routes/badges.$id.module.css @@ -1,5 +1,5 @@ .explanation { - color: var(--color-text-accent); + color: var(--color-fg-accent); font-weight: var(--weight-semi); text-align: center; } @@ -33,6 +33,6 @@ } .count { - color: var(--color-accent-high); + color: var(--color-fg-accent); font-size: var(--font-xs); } diff --git a/app/features/build-analyzer/components/PerInkTankGrid.module.css b/app/features/build-analyzer/components/PerInkTankGrid.module.css index 78952f850..2a00a7595 100644 --- a/app/features/build-analyzer/components/PerInkTankGrid.module.css +++ b/app/features/build-analyzer/components/PerInkTankGrid.module.css @@ -23,7 +23,7 @@ } .inkGridApFocused { - color: var(--color-accent); + color: var(--color-fg-accent); font-weight: var(--weight-bold); text-decoration: underline; } diff --git a/app/features/build-analyzer/routes/analyzer.module.css b/app/features/build-analyzer/routes/analyzer.module.css index 9151f9998..521106a6c 100644 --- a/app/features/build-analyzer/routes/analyzer.module.css +++ b/app/features/build-analyzer/routes/analyzer.module.css @@ -75,7 +75,7 @@ width: 100%; align-items: center; border-radius: var(--radius-box); - background-color: var(--color-accent-low); + background-color: var(--color-bg-accent); font-size: var(--font-2xs); font-weight: var(--weight-semi); gap: var(--s-2); @@ -179,7 +179,7 @@ padding: 0; border: none; background-color: transparent; - color: var(--color-accent); + color: var(--color-fg-accent); font-size: var(--font-md); font-weight: var(--weight-bold); outline: initial; @@ -245,8 +245,8 @@ .patch { border-radius: var(--radius-selector); - background-color: var(--color-accent-low); - color: var(--color-accent-high); + background-color: var(--color-bg-accent); + color: var(--color-fg-accent); font-size: var(--font-2xs); font-weight: var(--weight-bold); padding-inline: var(--s-2); diff --git a/app/features/build-stats/routes/builds.$slug.stats.module.css b/app/features/build-stats/routes/builds.$slug.stats.module.css index 03f0e1679..536f8f196 100644 --- a/app/features/build-stats/routes/builds.$slug.stats.module.css +++ b/app/features/build-stats/routes/builds.$slug.stats.module.css @@ -15,6 +15,6 @@ } .bar { - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); height: 100%; } diff --git a/app/features/calendar/components/TournamentCard.module.css b/app/features/calendar/components/TournamentCard.module.css index e365a83c8..e9894bc54 100644 --- a/app/features/calendar/components/TournamentCard.module.css +++ b/app/features/calendar/components/TournamentCard.module.css @@ -31,7 +31,7 @@ } .cardRanked { - box-shadow: inset 0 -3px 0 var(--color-accent); + box-shadow: inset 0 -3px 0 var(--color-fg-accent); } .imgContainer { diff --git a/app/features/changelog/components/ChangelogGraphic.module.css b/app/features/changelog/components/ChangelogGraphic.module.css index 82002562c..5507f2531 100644 --- a/app/features/changelog/components/ChangelogGraphic.module.css +++ b/app/features/changelog/components/ChangelogGraphic.module.css @@ -16,10 +16,10 @@ avatar slot down to the entry icons */ justify-content: center; width: 2.5em; height: 2.25em; - background-color: var(--color-text-accent); + background-color: var(--color-fill-accent); border-radius: var(--radius-field); font-weight: var(--weight-bold); - color: var(--color-text-inverse); + color: var(--color-fg-on-accent); line-height: 1; } diff --git a/app/features/chat/components/Chat.module.css b/app/features/chat/components/Chat.module.css index 5e97da44d..093d6996d 100644 --- a/app/features/chat/components/Chat.module.css +++ b/app/features/chat/components/Chat.module.css @@ -47,8 +47,8 @@ top: 28px; left: 50%; transform: translateX(-50%); - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); font-size: var(--font-2xs); font-weight: var(--weight-bold); padding: 1px 4px; @@ -58,7 +58,7 @@ .pronounsTag { background-color: var(--color-bg-higher); - color: var(--color-text-accent); + color: var(--color-fg-accent); font-size: var(--font-2xs); font-weight: var(--weight-semi); padding: 1px 5px; @@ -69,7 +69,7 @@ .messageUser { font-weight: var(--weight-semi); font-size: var(--font-sm); - color: oklch(from var(--color-text-accent) l c var(--chat-hue)); + color: oklch(from var(--color-fg-accent) l c var(--chat-hue)); max-width: 110px; overflow: hidden; text-overflow: ellipsis; @@ -88,8 +88,8 @@ .sendButton.sendButton { border-radius: var(--radius-full); - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); flex-shrink: 0; &:disabled { @@ -121,7 +121,7 @@ } .roomLink { - color: var(--color-text-accent); + color: var(--color-fg-accent); text-decoration: underline; word-break: break-all; } diff --git a/app/features/comp-analyzer/components/DamageComboBar.module.css b/app/features/comp-analyzer/components/DamageComboBar.module.css index 279ce9a69..23faab933 100644 --- a/app/features/comp-analyzer/components/DamageComboBar.module.css +++ b/app/features/comp-analyzer/components/DamageComboBar.module.css @@ -73,7 +73,7 @@ gap: var(--s-1); height: 36px; padding: 0 var(--s-2); - color: var(--color-text-inverse); + color: var(--color-text-on-light); font-weight: var(--weight-semi); &[data-slot-color="yellow"] { @@ -331,7 +331,7 @@ .inkTimeLabel { font-size: var(--font-2xs); - color: var(--color-text-second); + color: var(--color-fg-second); text-align: center; white-space: nowrap; padding: var(--s-0-5) var(--s-0-5); diff --git a/app/features/comp-analyzer/components/WeaponGrid.module.css b/app/features/comp-analyzer/components/WeaponGrid.module.css index 95a9f594b..2f16b30b8 100644 --- a/app/features/comp-analyzer/components/WeaponGrid.module.css +++ b/app/features/comp-analyzer/components/WeaponGrid.module.css @@ -85,7 +85,7 @@ transition: border-color 0.1s; &:hover { - border-color: var(--color-text-accent); + border-color: var(--color-fg-accent); } &:disabled { diff --git a/app/features/components-showcase/routes/components.colors.module.css b/app/features/components-showcase/routes/components.colors.module.css new file mode 100644 index 000000000..12be72808 --- /dev/null +++ b/app/features/components-showcase/routes/components.colors.module.css @@ -0,0 +1,351 @@ +.preview { + padding: var(--s-4); + border-radius: var(--radius-box); + background-color: var(--color-bg); + color: var(--color-text); +} + +.sectionTitle { + font-size: var(--font-xl); + border-bottom: 2px solid var(--color-border); +} + +.tokenName { + display: inline-flex; + align-items: center; + gap: var(--s-1-5); + font-size: var(--font-xs); + word-break: break-all; +} + +.swatch { + flex-shrink: 0; + width: 1rem; + height: 1rem; + border: 1px solid var(--color-border-high); + border-radius: var(--radius-selector); +} + +.strikethrough { + text-decoration: line-through; +} + +.annotated { + container-type: inline-size; + position: relative; +} + +.annotatedGrid { + display: grid; + gap: var(--s-6); + + @container (width > 44rem) { + grid-template-columns: minmax(0, 30rem) 18rem; + justify-content: space-between; + gap: var(--s-24); + align-items: center; + } +} + +.stage { + padding: var(--s-6); + border: var(--border-style); + border-radius: var(--radius-box); + background-color: var(--color-bg); +} + +.notes { + display: flex; + flex-direction: column; + gap: var(--s-3); + padding: 0; + margin: 0; + list-style: none; +} + +.note { + display: flex; + gap: var(--s-2); + padding: var(--s-1-5); + border-radius: var(--radius-field); + cursor: default; + + &[data-active="true"] { + background-color: var(--color-bg-high); + } +} + +.marker, +.pin { + display: grid; + flex-shrink: 0; + place-items: center; + width: 1.25rem; + height: 1.25rem; + border-radius: var(--radius-full); + background-color: var(--color-text); + color: var(--color-text-inverse); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); +} + +.pin { + position: absolute; + translate: -50% -50%; + pointer-events: none; +} + +.arrows { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} + +.arrow { + fill: none; + stroke: var(--color-text-high); + stroke-width: 1.5; + opacity: 0.6; + + &[data-active="true"] { + stroke: var(--color-text); + stroke-width: 2; + opacity: 1; + } +} + +.arrowHead { + fill: var(--color-text-high); + + &[data-active="true"] { + fill: var(--color-text); + } +} + +.targetHighlight { + position: absolute; + outline: 2px dashed var(--color-text); + outline-offset: 3px; + border-radius: var(--radius-field); + pointer-events: none; +} + +.noteTarget { + display: inline-block; + width: fit-content; +} + +.mockCard { + padding: var(--s-4); + border: var(--border-style); + border-radius: var(--radius-box); + background-color: var(--color-bg-high); +} + +.mockTitle { + width: fit-content; + color: var(--color-text); + font-size: var(--font-md); + font-weight: var(--weight-bold); +} + +.mockMeta { + width: fit-content; + color: var(--color-text-high); + font-size: var(--font-sm); +} + +.mockDivider { + width: 100%; + margin: 0; + border: none; + border-top: var(--border-style); +} + +.mockNeutralChip { + padding: var(--s-1) var(--s-2-5); + border-radius: var(--radius-selector); + background-color: var(--color-bg-higher); + color: var(--color-text); + font-size: var(--font-xs); + font-weight: var(--weight-semi); +} + +.mockAccentChip { + display: inline-flex; + align-items: center; + gap: var(--s-1); + padding: var(--s-1) var(--s-2-5); + border-radius: var(--radius-selector); + background-color: var(--color-bg-accent); + color: var(--color-fg-accent); + font-size: var(--font-xs); + font-weight: var(--weight-semi); +} + +.mockTabs { + display: flex; + gap: var(--s-4); + border-bottom: var(--border-style); +} + +.mockTab { + padding-block: var(--s-1-5); + margin-bottom: calc(var(--border-width) * -1); + border-bottom: var(--border-width) solid transparent; + color: var(--color-text-high); + font-size: var(--font-sm); + font-weight: var(--weight-semi); + + &[data-active] { + border-color: var(--color-fg-accent); + color: var(--color-fg-accent); + } +} + +.mockRow, +.mockRowHighlighted { + display: flex; + justify-content: space-between; + padding: var(--s-1-5) var(--s-2); + border-radius: var(--radius-field); + font-size: var(--font-sm); +} + +.mockRowHighlighted { + background-color: var(--color-bg-accent); + color: var(--color-text); + font-weight: var(--weight-semi); +} + +.mockLink { + width: fit-content; + color: var(--color-fg-accent); + font-size: var(--font-sm); + font-weight: var(--weight-semi); + text-decoration: underline; +} + +.mockFocused { + width: fit-content; + padding: var(--s-1) var(--s-2); + border-radius: var(--radius-field); + outline: var(--focus-ring); + outline-offset: 2px; + font-size: var(--font-sm); +} + +.mockSecondBadge { + padding: 0 var(--s-1-5); + border-radius: var(--radius-selector); + background-color: var(--color-fill-second); + color: var(--color-fg-on-second); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); +} + +.mockStat { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: var(--s-2); + font-size: var(--font-sm); +} + +.mockTrack { + height: 0.5rem; + border-radius: var(--radius-full); + background-color: var(--color-bg-higher); +} + +.mockBar { + height: 100%; + border-radius: var(--radius-full); + background-color: var(--color-fg-second); +} + +.mockIconText { + display: inline-flex; + align-items: center; + gap: var(--s-1); + width: fit-content; + font-size: var(--font-sm); + font-weight: var(--weight-semi); +} + +.mockSecondTint { + padding: var(--s-2) var(--s-3); + border-radius: var(--radius-field); + background-color: var(--color-bg-second); + color: var(--color-text); + font-size: var(--font-sm); +} + +.mockErrorInput { + display: flex; + align-items: center; + justify-content: space-between; + max-width: 16rem; + height: var(--field-size); + padding-inline: var(--field-padding); + border: var(--border-width) solid var(--color-error); + border-radius: var(--radius-field); + background-color: var(--color-bg); + font-size: var(--font-sm); + + & svg { + color: var(--color-error); + } +} + +.pairings { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: var(--s-3); +} + +.pairing { + display: flex; + flex-direction: column; + gap: var(--s-2); + padding: var(--s-2); + border: var(--border-style); + border-radius: var(--radius-box); + background-color: var(--color-bg-high); +} + +.pairingSample { + padding: var(--s-4) var(--s-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-field); + font-size: var(--font-lg); + font-weight: var(--weight-bold); +} + +.verdict, +.contrastBadge { + padding: 0 var(--s-1-5); + border-radius: var(--radius-selector); + font-size: var(--font-xs); + font-weight: var(--weight-bold); +} + +.verdict[data-verdict="do"], +.contrastBadge[data-level="pass"] { + background-color: var(--color-success-low); + color: var(--color-success-high); +} + +.verdict[data-verdict="dont"], +.contrastBadge[data-level="fail"] { + background-color: var(--color-error-low); + color: var(--color-error-high); +} + +.contrastBadge[data-level="large"] { + background-color: var(--color-warning-low); + color: var(--color-warning-high); +} diff --git a/app/features/components-showcase/routes/components.colors.tsx b/app/features/components-showcase/routes/components.colors.tsx new file mode 100644 index 000000000..282efca09 --- /dev/null +++ b/app/features/components-showcase/routes/components.colors.tsx @@ -0,0 +1,1052 @@ +import clsx from "clsx"; +import { Check, CircleAlert, LogOut, Star } from "lucide-react"; +import * as React from "react"; +import { Link } from "react-router"; +import { isDeepEqual } from "remeda"; +import { Alert } from "~/components/Alert"; +import { SendouButton } from "~/components/elements/Button"; +import { + SendouChipRadio, + SendouChipRadioGroup, +} from "~/components/elements/ChipRadio"; +import { SendouSwitch } from "~/components/elements/Switch"; +import { Main } from "~/components/Main"; +import { Table } from "~/components/Table"; +import styles from "./components.colors.module.css"; + +type Scheme = "current" | "light" | "dark"; + +type AnchorSide = "right" | "top" | "bottom"; + +interface Note { + id: string; + tokens: string[]; + text: React.ReactNode; + anchor?: AnchorSide; +} + +const MIN_AA_CONTRAST = 4.5; +const MIN_LARGE_TEXT_CONTRAST = 3; + +const FAMILIES = [ + { + pattern: "--color-bg-x", + role: "Surfaces", + use: "Page, cards, tinted areas like a highlighted row or a status strip", + }, + { + pattern: "--color-fg-x", + role: "Foreground on surfaces", + use: "Text, icons, borders, outlines and indicators (dots, bars, checkmarks)", + }, + { + pattern: "--color-fill-x", + role: "Fills that carry content", + use: "Button, badge and pill backgrounds", + }, + { + pattern: "--color-fg-on-x", + role: "Foreground on a fill", + use: "The label or icon inside a --color-fill-x. Never anywhere else", + }, +] as const; + +const OVERVIEW_NOTES: Note[] = [ + { + id: "bg", + tokens: ["--color-bg-accent"], + text: "bg: a tinted surface. Any foreground token (here --color-fg-accent) sits on it.", + anchor: "top", + }, + { + id: "fg", + tokens: ["--color-fg-accent"], + text: "fg: the text and border of an outlined button, readable on every surface.", + anchor: "bottom", + }, + { + id: "fill", + tokens: ["--color-fill-accent"], + text: "fill: a solid background that holds content.", + anchor: "top", + }, + { + id: "fg-on", + tokens: ["--color-fg-on-accent"], + text: "fg-on: the label on that fill. Dark on bright custom accents, light otherwise, so never hardcode white.", + anchor: "bottom", + }, +]; + +const NEUTRAL_NOTES: Note[] = [ + { + id: "page", + tokens: ["--color-bg"], + text: "The page. Default surface everything else sits on.", + }, + { + id: "card", + tokens: ["--color-bg-high"], + text: "One step up: cards, sections, dialogs, popovers.", + anchor: "top", + }, + { + id: "chip", + tokens: ["--color-bg-higher"], + text: "Raised inside a card: chips, inputs, hovered rows, progress tracks.", + }, + { + id: "title", + tokens: ["--color-text"], + text: "Primary text. Readable on every neutral surface and on --color-bg-accent / --color-bg-second.", + }, + { + id: "meta", + tokens: ["--color-text-high"], + text: "Secondary text: metadata, hints, labels. 'high' is the scale step, not more emphasis.", + }, + { + id: "divider", + tokens: ["--color-border", "--color-border-high"], + text: "Dividers and card outlines. The -high variant for borders that need to stand out, like inputs.", + }, +]; + +const ACCENT_NOTES: Note[] = [ + { + id: "tab", + tokens: ["--color-fg-accent"], + text: "Active tab text and its underline: foreground on a surface.", + anchor: "top", + }, + { + id: "highlight", + tokens: ["--color-bg-accent"], + text: "Highlights 'you' or 'selected'. Text on it stays --color-text, accents on it --color-fg-accent.", + }, + { + id: "link", + tokens: ["--color-fg-accent"], + text: "Links and accent icons.", + }, + { + id: "button", + tokens: ["--color-fill-accent", "--color-fg-on-accent"], + text: "Primary button: always pair the fill with its fg-on.", + anchor: "bottom", + }, + { + id: "switch", + tokens: ["--color-fill-accent", "--color-fg-on-accent"], + text: "Selected switches and chips use the same fill pair: fill for the track, fg-on for the thumb.", + }, + { + id: "focus", + tokens: ["--color-fg-accent"], + text: "Focus rings (--focus-ring) and accent borders (--border-style-accent) are foreground too.", + }, +]; + +const SECOND_NOTES: Note[] = [ + { + id: "badge", + tokens: ["--color-fill-second", "--color-fg-on-second"], + text: "Badges that call attention: fill with its fg-on.", + }, + { + id: "bar", + tokens: ["--color-fg-second"], + text: "Indicators (bars, dots, checkmarks) are foreground, not fill: they need contrast against the surface, not a label on top.", + }, + { + id: "icon", + tokens: ["--color-fg-second"], + text: "Secondary text and icons, also available as the .text-theme-secondary utility.", + }, + { + id: "tint", + tokens: ["--color-bg-second"], + text: "Tinted secondary surface, with --color-text or --color-fg-second on it.", + }, +]; + +const STATUS_NOTES: Note[] = [ + { + id: "alert", + tokens: ["--color-info-low", "--color-info-high"], + text: "Status surface: -low background with -high text. Same for success, warning and error.", + }, + { + id: "success", + tokens: ["--color-success", "--color-text-inverse"], + text: "Solid status fill with inverse text.", + anchor: "bottom", + }, + { + id: "error-border", + tokens: ["--color-error"], + text: "The plain status color is for borders and icons.", + }, +]; + +const PAIRINGS = [ + { + fg: "--color-text", + bg: "--color-bg", + verdict: "do", + why: "Body text", + }, + { + fg: "--color-text-high", + bg: "--color-bg-high", + verdict: "do", + why: "Secondary text in a card", + }, + { + fg: "--color-fg-accent", + bg: "--color-bg-high", + verdict: "do", + why: "Accent link or icon in a card", + }, + { + fg: "--color-fg-accent", + bg: "--color-bg-accent", + verdict: "do", + why: "Accent text on a tinted surface", + }, + { + fg: "--color-text", + bg: "--color-bg-accent", + verdict: "do", + why: "Normal text on a tinted surface", + }, + { + fg: "--color-fg-on-accent", + bg: "--color-fill-accent", + verdict: "do", + why: "Button label", + }, + { + fg: "--color-fg-second", + bg: "--color-bg-second", + verdict: "do", + why: "Secondary text on its tint", + }, + { + fg: "--color-fg-on-second", + bg: "--color-fill-second", + verdict: "do", + why: "Badge label", + }, + { + fg: "--color-fg-on-accent", + bg: "--color-bg", + verdict: "dont", + why: "fg-on only belongs on its fill. In dark mode it is the page background color itself.", + }, + { + fg: "--color-fg-accent", + bg: "--color-fill-accent", + verdict: "dont", + why: "fg and fill are the same color in dark mode, use --color-fg-on-accent.", + }, + { + fg: "--color-text-inverse", + bg: "--color-fill-accent", + verdict: "dont", + why: "Works with the default theme but a bright custom accent (yellow, cyan...) gets a light fill that needs dark text. Use --color-fg-on-accent.", + }, + { + fg: "--color-fill-accent", + bg: "--color-bg", + verdict: "dont", + why: "Fills are not text colors, a bright light mode fill can vanish on the page. Use --color-fg-accent.", + }, +] as const; + +const REPLACEMENTS = [ + { legacy: "--color-accent-low", use: "--color-bg-accent" }, + { + legacy: "--color-accent", + use: "--color-fill-accent (fills) or --color-fg-accent (text, borders)", + }, + { legacy: "--color-accent-high", use: "--color-fg-accent" }, + { legacy: "--color-text-accent", use: "--color-fg-accent (renamed)" }, + { + legacy: "--color-second-low / --color-second / --color-second-high", + use: "the matching --color-*-second token", + }, + { + legacy: "--color-base-0 ... --color-base-7", + use: "--color-bg-*, --color-text-* or --color-border-*", + }, + { legacy: "--_*", use: "nothing, these are custom theme inputs" }, +] as const; + +const REFERENCE = [ + { tokens: ["--color-bg"], use: "Page background" }, + { tokens: ["--color-bg-high"], use: "Cards, sections, dialogs" }, + { tokens: ["--color-bg-higher"], use: "Raised elements inside a card" }, + { tokens: ["--color-bg-nav"], use: "Top nav, side nav and mobile nav" }, + { + tokens: ["--color-bg-accent", "--color-bg-second"], + use: "Tinted surfaces", + }, + { + tokens: ["--color-fg-accent", "--color-fg-second"], + use: "Text, icons, borders, outlines and indicators on any surface", + }, + { + tokens: ["--color-fill-accent", "--color-fill-second"], + use: "Button, badge and pill backgrounds", + }, + { + tokens: ["--color-fg-on-accent", "--color-fg-on-second"], + use: "Content on the matching fill, nowhere else", + }, + { tokens: ["--color-text"], use: "Primary text" }, + { tokens: ["--color-text-high"], use: "Secondary text" }, + { + tokens: ["--color-text-inverse"], + use: "Text on solid status fills (--color-success, --color-error)", + }, + { + tokens: ["--color-text-on-light", "--color-text-on-dark"], + use: "Text on colors that don't follow the scheme, like user picked tier list and tag colors. Fixed in both modes", + }, + { + tokens: ["--color-border", "--color-border-high"], + use: "Dividers and outlines, -high for inputs and emphasis", + }, + { + tokens: [ + "--color-info-low", + "--color-success-low", + "--color-warning-low", + "--color-error-low", + ], + use: "Status surfaces", + }, + { + tokens: [ + "--color-info", + "--color-success", + "--color-warning", + "--color-error", + ], + use: "Status borders, icons and solid fills (with --color-text-inverse)", + }, + { + tokens: [ + "--color-info-high", + "--color-success-high", + "--color-warning-high", + "--color-error-high", + ], + use: "Text on the matching -low surface", + }, + { + tokens: ["--color-bg-badge", "--color-bg-ability"], + use: "Fixed backdrops of badge and ability images", + }, + { + tokens: ["--color-chart-alpha", "--color-chart-bravo"], + use: "The two sides in charts and timelines", + }, +] as const; + +export default function ComponentsColorsPage() { + const [scheme, setScheme] = React.useState("current"); + + return ( +
+
+ + ← Components + +

Colors

+

+ Live documentation of the color tokens in{" "} + app/styles/vars.css. Every example below is rendered with + the real tokens, so it follows your custom theme. Hover a note to + highlight what it points at. +

+
+ +
+ Preview in + + {(["current", "light", "dark"] as const).map((option) => ( + setScheme(option)} + > + {option === "current" ? "Current scheme" : option} + + ))} + +
+ +
+ + + + + + + + +
+
+ ); +} + +function OverviewSection() { + return ( + +
+

+ Accent and secondary colors are consumed through four kinds of token. + Pick the surface or fill first, then the foreground that belongs to + it. Every fg/bg combination below is contrast checked for any custom + theme, other combinations are not. +

+ + + + + + + + + + {FAMILIES.map((family) => ( + + + + + + ))} + +
TokenRoleUse for
+ {family.pattern} + {family.role}{family.use}
+ +
+
+ In The Zone 42 + + Registered + +
+ Saturday 20:00 · 48 teams +
+ + }> + Leave + + + + + Check in + + +
+
+
+
+
+ ); +} + +function NeutralSection() { + return ( + + +
+
+ + Swim or Sink #17 + + + Starts in 2 hours · 32 teams + +
+
+
+ + Splat Zones + + Tower Control +
+
+
+
+ ); +} + +function AccentSection() { + return ( + + +
+
+ + Standings + + Bracket + Teams +
+
+
+ Olive Branch + 3-1 +
+
+ Your team + 2-2 +
+
+ Inkling Squad + 1-3 +
+
+ + View full standings + +
+ + Report score + + + Notify me + +
+ + Focused element + +
+
+
+ ); +} + +function SecondSection() { + return ( + + +
+
+ Season stats + + NEW + +
+
+ Win rate +
+
+
+ 64% +
+ + Top 500 + +
+ Season 8 ends in 3 days +
+
+ + + ); +} + +function StatusSection() { + return ( + + +
+
+ Check-in opens in 30 minutes +
+ Your roster is not full + Registration complete + Match reported incorrectly +
+ + Confirm + + Delete +
+
+ abc + +
+
+
+
+ ); +} + +function PairingsSection() { + return ( + +
+

+ Contrast ratios are measured live for the current preview. A + "don't" can pass here and still fail for another scheme + or custom theme, which is why the pairing matters and not the number + you happen to see. +

+
+ {PAIRINGS.map((pairing) => ( + + ))} +
+
+
+ ); +} + +function ReplacementsSection() { + return ( + +
+

+ These are the building blocks behind the tokens above. They change + meaning between light and dark mode and custom themes can push them to + values that only work in their intended role. +

+ + + + + + + + + {REPLACEMENTS.map((replacement) => ( + + + + + ))} + +
Instead ofUse
+ + {replacement.legacy} + + {replacement.use}
+
+
+ ); +} + +function ReferenceSection() { + return ( + + + + + + + + + + {REFERENCE.map((row) => ( + + + + + ))} + +
TokenUse for
+
+ {row.tokens.map((token) => ( + + ))} +
+
{row.use}
+
+ ); +} + +function DocSection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function TokenName({ token }: { token: string }) { + return ( + + + {token} + + ); +} + +function ContrastSample({ + fg, + bg, + verdict, + why, +}: { + fg: string; + bg: string; + verdict: "do" | "dont"; + why: string; +}) { + const sampleRef = React.useRef(null); + const [ratio, setRatio] = React.useState(null); + + React.useLayoutEffect(() => { + if (!sampleRef.current) return; + + const style = getComputedStyle(sampleRef.current); + setRatio(contrastRatio(style.color, style.backgroundColor)); + }, []); + + return ( +
+
+ Aa Check in +
+
+
+ + {verdict === "do" ? "Do" : "Don't"} + + {ratio !== null ? : null} +
+ + on + + {why} +
+
+ ); +} + +function ContrastBadge({ ratio }: { ratio: number }) { + const level = + ratio >= MIN_AA_CONTRAST + ? "pass" + : ratio >= MIN_LARGE_TEXT_CONTRAST + ? "large" + : "fail"; + + return ( + + {ratio.toFixed(1)}:1{" "} + {level === "pass" ? "AA" : level === "large" ? "AA large" : "fail"} + + ); +} + +interface Point { + x: number; + y: number; +} + +interface Rect extends Point { + width: number; + height: number; +} + +interface NoteGeometry { + id: string; + side: AnchorSide; + target: Rect; + anchor: Point; + start: Point; +} + +interface AnnotationGeometry { + isSideLayout: boolean; + gutterX: number; + notes: NoteGeometry[]; +} + +function Annotated({ + notes, + stageNote, + children, +}: { + notes: Note[]; + /** note id pointing at the stage itself */ + stageNote?: string; + children: React.ReactNode; +}) { + const markerId = React.useId(); + const containerRef = React.useRef(null); + const stageRef = React.useRef(null); + const labelsRef = React.useRef(null); + const [geometry, setGeometry] = React.useState( + null, + ); + const [activeNoteId, setActiveNoteId] = React.useState(null); + + React.useLayoutEffect(() => { + const container = containerRef.current; + const stage = stageRef.current; + const labels = labelsRef.current; + if (!container || !stage || !labels) return; + + const measure = () => { + const next = measureAnnotations({ container, stage, labels }); + setGeometry((previous) => + previous && isDeepEqual(previous, next) ? previous : next, + ); + }; + + const observer = new ResizeObserver(measure); + observer.observe(container); + for (const target of stage.querySelectorAll("[data-note]")) { + observer.observe(target); + } + + return () => observer.disconnect(); + }, []); + + const activeTarget = geometry?.notes.find( + (note) => note.id === activeNoteId, + )?.target; + + return ( +
+
+
+ {children} +
+
    + {notes.map((note, index) => ( +
  1. setActiveNoteId(note.id)} + onMouseLeave={() => setActiveNoteId(null)} + > + + {index + 1} + +
    + {note.tokens.map((token) => ( + + ))} + {note.text} +
    +
  2. + ))} +
+
+ + {activeTarget ? ( +
+ ) : null} + + {geometry?.isSideLayout ? ( + + ) : ( + geometry?.notes.map((note) => ( + + )) + )} +
+ ); +} + +function measureAnnotations({ + container, + stage, + labels, +}: { + container: HTMLElement; + stage: HTMLElement; + labels: HTMLElement; +}): AnnotationGeometry { + const origin = container.getBoundingClientRect(); + const relative = (element: Element): Rect => { + const rect = element.getBoundingClientRect(); + return { + x: Math.round(rect.left - origin.left), + y: Math.round(rect.top - origin.top), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + }; + + const notes: NoteGeometry[] = []; + for (const label of labels.querySelectorAll( + "[data-note-label]", + )) { + const id = label.dataset.noteLabel; + const target = + stage.dataset.note === id + ? stage + : stage.querySelector(`[data-note="${id}"]`); + const marker = label.querySelector("[data-note-marker]"); + if (!id || !target || !marker) continue; + + const side = (label.dataset.noteAnchor ?? "right") as AnchorSide; + const targetRect = relative(target); + const markerRect = relative(marker); + + notes.push({ + id, + side, + target: targetRect, + anchor: anchorPoint(targetRect, side), + start: { x: markerRect.x - 4, y: markerRect.y + markerRect.height / 2 }, + }); + } + + const stageRight = stage.getBoundingClientRect().right; + const labelsLeft = labels.getBoundingClientRect().left; + + return { + isSideLayout: labelsLeft >= stageRight, + gutterX: Math.round( + stageRight - origin.left + (labelsLeft - stageRight) / 4, + ), + notes, + }; +} + +function anchorPoint(rect: Rect, side: AnchorSide): Point { + switch (side) { + case "top": + return { x: rect.x + rect.width / 2, y: rect.y }; + case "bottom": + return { x: rect.x + rect.width / 2, y: rect.y + rect.height }; + case "right": + return { x: rect.x + rect.width, y: rect.y + rect.height / 2 }; + } +} + +/** S-curves through the gap between stage and notes, entering a right anchor horizontally so the arrow doesn't cross content */ +function arrowPath({ start, anchor, side }: NoteGeometry, gutterX: number) { + const midX = (start.x + gutterX) / 2; + const verticalBend = 36; + + if (side === "right") { + return `M ${start.x} ${start.y} C ${midX} ${start.y}, ${midX} ${anchor.y}, ${gutterX} ${anchor.y} L ${anchor.x} ${anchor.y}`; + } + + const controlY = + side === "top" ? anchor.y - verticalBend : anchor.y + verticalBend; + + return `M ${start.x} ${start.y} C ${midX} ${start.y}, ${anchor.x} ${controlY}, ${anchor.x} ${anchor.y}`; +} + +let colorCanvasContext: CanvasRenderingContext2D | null = null; + +/** WCAG 2 contrast ratio between two CSS colors, resolved by drawing them to a canvas */ +function contrastRatio(first: string, second: string) { + const [lighter, darker] = [ + relativeLuminance(first), + relativeLuminance(second), + ].sort((a, b) => b - a); + + return (lighter! + 0.05) / (darker! + 0.05); +} + +function relativeLuminance(color: string) { + colorCanvasContext ??= document + .createElement("canvas") + .getContext("2d", { willReadFrequently: true }); + if (!colorCanvasContext) return 0; + + colorCanvasContext.clearRect(0, 0, 1, 1); + colorCanvasContext.fillStyle = color; + colorCanvasContext.fillRect(0, 0, 1, 1); + const [r, g, b] = colorCanvasContext.getImageData(0, 0, 1, 1).data; + + const linear = (channel = 0) => { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }; + + return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b); +} diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx index da2eed9ee..1c14ea5c9 100644 --- a/app/features/components-showcase/routes/components.tsx +++ b/app/features/components-showcase/routes/components.tsx @@ -1,5 +1,6 @@ import { Check, Plus, RotateCcw, Search, SquarePen, Trash } from "lucide-react"; import { useState } from "react"; +import { Link } from "react-router"; import { Ability } from "~/components/Ability"; import { Alert } from "~/components/Alert"; import { Avatar } from "~/components/Avatar"; @@ -202,6 +203,7 @@ export default function ComponentsShowcasePage() { return (

Components

+ Color tokens → {SECTIONS.map(({ id, component: Component }) => ( ))} diff --git a/app/features/global-status/components/GlobalStatusIndicator.module.css b/app/features/global-status/components/GlobalStatusIndicator.module.css index 8b832abc1..5e65434d5 100644 --- a/app/features/global-status/components/GlobalStatusIndicator.module.css +++ b/app/features/global-status/components/GlobalStatusIndicator.module.css @@ -55,8 +55,8 @@ } .countBadgeAction { - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); } .alertBadge { diff --git a/app/features/img-export/components/Graphic.module.css b/app/features/img-export/components/Graphic.module.css index a07193a1f..77c65dda4 100644 --- a/app/features/img-export/components/Graphic.module.css +++ b/app/features/img-export/components/Graphic.module.css @@ -2,7 +2,7 @@ --graphic-row-bg: var(--color-bg-high); --graphic-row-border: color-mix(in oklch, var(--color-text) 8%, transparent); --graphic-text-dim: var(--color-text-high); - --graphic-accent: var(--color-text-accent); + --graphic-accent: var(--color-fg-accent); --graphic-first: oklch(87% 0.13 85); --graphic-second: oklch(83% 0.015 268); --graphic-third: oklch(74% 0.09 55); @@ -17,7 +17,7 @@ background: radial-gradient( ellipse 90% 45% at 50% -5%, - color-mix(in oklch, var(--color-accent) 25%, transparent), + color-mix(in oklch, var(--color-fg-accent) 25%, transparent), transparent 70% ), var(--color-bg); diff --git a/app/features/info/routes/support.module.css b/app/features/info/routes/support.module.css index 9b4eaf54b..58427ce4d 100644 --- a/app/features/info/routes/support.module.css +++ b/app/features/info/routes/support.module.css @@ -17,7 +17,7 @@ padding: 0; border: none; background-color: transparent; - color: var(--color-text-accent); + color: var(--color-fg-accent); font-size: var(--font-md); font-weight: var(--weight-bold); outline: initial; diff --git a/app/features/info/routes/welcome.module.css b/app/features/info/routes/welcome.module.css index 5580551f8..25686d695 100644 --- a/app/features/info/routes/welcome.module.css +++ b/app/features/info/routes/welcome.module.css @@ -18,7 +18,7 @@ content: ""; position: absolute; inset: 0; - background-color: var(--color-second); + background-color: var(--color-fg-second); opacity: 0.3; pointer-events: none; } diff --git a/app/features/map-planner/components/Planner.module.css b/app/features/map-planner/components/Planner.module.css index 07edab0cb..d367624ea 100644 --- a/app/features/map-planner/components/Planner.module.css +++ b/app/features/map-planner/components/Planner.module.css @@ -77,8 +77,8 @@ } .outlineToggleButtonOutlined { - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); } .weaponsWrapper { diff --git a/app/features/map-planner/plans-global.css b/app/features/map-planner/plans-global.css index ec49a19ce..01c934b16 100644 --- a/app/features/map-planner/plans-global.css +++ b/app/features/map-planner/plans-global.css @@ -65,8 +65,8 @@ body:has(.planner) footer { .tl-theme__light.tl-theme__light { --color-panel: var(--color-bg); --color-divider: var(--color-border); - --color-selected: var(--color-text-accent); - --color-selected-contrast: var(--color-text-inverse); + --color-selected: var(--color-fill-accent); + --color-selected-contrast: var(--color-fg-on-accent); --color-hint: var(--color-bg-high); --color-muted-2: var(--color-bg-high); --color-background: var(--color-bg); diff --git a/app/features/notifications/components/NotificationList.module.css b/app/features/notifications/components/NotificationList.module.css index f0b26b59a..5c55a9cd9 100644 --- a/app/features/notifications/components/NotificationList.module.css +++ b/app/features/notifications/components/NotificationList.module.css @@ -30,12 +30,12 @@ } &:focus-within .imageContainer { - outline: 3px solid var(--color-accent-low); + outline: 3px solid var(--color-bg-accent); } } .unseenDot { - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); border-radius: 100%; width: 8px; height: 8px; diff --git a/app/features/object-damage-calculator/routes/object-damage-calculator.module.css b/app/features/object-damage-calculator/routes/object-damage-calculator.module.css index 3b1d42183..427e0fedf 100644 --- a/app/features/object-damage-calculator/routes/object-damage-calculator.module.css +++ b/app/features/object-damage-calculator/routes/object-damage-calculator.module.css @@ -112,7 +112,7 @@ } .multiplier { - color: var(--color-text-accent); + color: var(--color-fg-accent); font-size: var(--font-2xs); font-weight: var(--weight-bold); letter-spacing: 0.5px; @@ -128,8 +128,8 @@ .patch { border-radius: var(--radius-selector); - background-color: var(--color-accent-low); - color: var(--color-accent-high); + background-color: var(--color-bg-accent); + color: var(--color-fg-accent); font-size: var(--font-2xs); font-weight: var(--weight-bold); padding-inline: var(--s-2); diff --git a/app/features/params/components/ParamComparisonDialog.module.css b/app/features/params/components/ParamComparisonDialog.module.css index 6cb222282..b4886ac09 100644 --- a/app/features/params/components/ParamComparisonDialog.module.css +++ b/app/features/params/components/ParamComparisonDialog.module.css @@ -38,11 +38,11 @@ height: 18px; min-width: 2px; border-radius: var(--radius-field); - background-color: var(--color-accent); + background-color: var(--color-fg-accent); } .barFillCurrent { - background-color: var(--color-second); + background-color: var(--color-fg-second); } .value { diff --git a/app/features/params/components/WeaponParamsTable.module.css b/app/features/params/components/WeaponParamsTable.module.css index b1c3e22e4..5e27442d2 100644 --- a/app/features/params/components/WeaponParamsTable.module.css +++ b/app/features/params/components/WeaponParamsTable.module.css @@ -145,7 +145,7 @@ display: flex; align-items: center; flex-shrink: 0; - color: var(--color-accent); + color: var(--color-fg-accent); } .paramCell { @@ -185,8 +185,8 @@ height: 16px; padding-inline: var(--s-0-5); border-radius: var(--radius-full); - background-color: var(--color-accent-low); - color: var(--color-accent); + background-color: var(--color-bg-accent); + color: var(--color-fg-accent); font-size: var(--font-xs); font-weight: var(--weight-bold); } diff --git a/app/features/plus-voting/plus-voting-results.module.css b/app/features/plus-voting/plus-voting-results.module.css index d00d1404a..ed4de56c5 100644 --- a/app/features/plus-voting/plus-voting-results.module.css +++ b/app/features/plus-voting/plus-voting-results.module.css @@ -21,7 +21,7 @@ .tierHeader { display: flex; flex-direction: row; - color: var(--color-text-accent); + color: var(--color-fg-accent); &::before, &::after { @@ -54,8 +54,8 @@ .userStatus { border-radius: var(--radius-selector); height: var(--selector-size-sm); - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); font-size: var(--font-xs); font-weight: var(--weight-semi); padding-inline: var(--s-1-5); diff --git a/app/features/scanner/components/EventsSummary.module.css b/app/features/scanner/components/EventsSummary.module.css index 5e1b1cfef..a1b7d4d69 100644 --- a/app/features/scanner/components/EventsSummary.module.css +++ b/app/features/scanner/components/EventsSummary.module.css @@ -30,7 +30,7 @@ button.toggle { border: none; background: transparent; - color: var(--color-text-accent); + color: var(--color-fg-accent); height: auto; padding: 0; font-size: var(--font-2xs); diff --git a/app/features/scanner/components/LivePage.module.css b/app/features/scanner/components/LivePage.module.css index 12ceb84e4..6c1b8e543 100644 --- a/app/features/scanner/components/LivePage.module.css +++ b/app/features/scanner/components/LivePage.module.css @@ -50,5 +50,5 @@ button.outlined { background: transparent; - color: var(--color-text-accent); + color: var(--color-fg-accent); } diff --git a/app/features/scanner/components/MetaChips.module.css b/app/features/scanner/components/MetaChips.module.css index ce8fabf81..b764bcaa7 100644 --- a/app/features/scanner/components/MetaChips.module.css +++ b/app/features/scanner/components/MetaChips.module.css @@ -22,6 +22,6 @@ font-weight: var(--weight-bold); & svg { - color: var(--color-text-accent); + color: var(--color-fg-accent); } } diff --git a/app/features/scanner/components/ScannerApp.module.css b/app/features/scanner/components/ScannerApp.module.css index db78a0520..afdcdb4fc 100644 --- a/app/features/scanner/components/ScannerApp.module.css +++ b/app/features/scanner/components/ScannerApp.module.css @@ -14,11 +14,11 @@ from sendou.ink's app/styles/vars.css. align-items: center; justify-content: center; gap: var(--s-1-5); - border: var(--border-style-accent); + border: var(--border-width) solid var(--color-fill-accent); border-radius: var(--radius-field); appearance: none; - background: var(--color-text-accent); - color: var(--color-text-inverse); + background: var(--color-fill-accent); + color: var(--color-fg-on-accent); cursor: pointer; font-family: inherit; font-size: var(--font-xs); @@ -80,7 +80,7 @@ from sendou.ink's app/styles/vars.css. } &.active { - color: var(--color-text-accent); + color: var(--color-fg-accent); background: var(--color-bg-high); } } diff --git a/app/features/scanner/components/ScannerChrome.module.css b/app/features/scanner/components/ScannerChrome.module.css index eefbf2edc..b48569ed4 100644 --- a/app/features/scanner/components/ScannerChrome.module.css +++ b/app/features/scanner/components/ScannerChrome.module.css @@ -11,7 +11,7 @@ button.iconMenu { color: var(--color-text); &:hover { - color: var(--color-text-accent); + color: var(--color-fg-accent); } & > svg { @@ -101,8 +101,8 @@ button.iconMenu { } .over { - border-color: var(--color-text-accent); - color: var(--color-text-accent); + border-color: var(--color-fg-accent); + color: var(--color-fg-accent); } /* a 640px wide preview would starve the feed next to it: split evenly instead */ diff --git a/app/features/scanner/components/VodPage.module.css b/app/features/scanner/components/VodPage.module.css index 12cc92a8b..9d97e2874 100644 --- a/app/features/scanner/components/VodPage.module.css +++ b/app/features/scanner/components/VodPage.module.css @@ -6,10 +6,10 @@ gap: var(--s-1-5); padding: 0 var(--field-padding); height: var(--field-size-sm); - border: var(--border-style-accent); + border: var(--border-width) solid var(--color-fill-accent); border-radius: var(--radius-field); - background: var(--color-text-accent); - color: var(--color-text-inverse); + background: var(--color-fill-accent); + color: var(--color-fg-on-accent); font-size: var(--font-xs); font-weight: var(--weight-bold); white-space: nowrap; diff --git a/app/features/scrims/components/ScrimCard.module.css b/app/features/scrims/components/ScrimCard.module.css index c3cd176d2..4a7900cf9 100644 --- a/app/features/scrims/components/ScrimCard.module.css +++ b/app/features/scrims/components/ScrimCard.module.css @@ -102,7 +102,7 @@ .expandButton { background: none; border: none; - color: var(--color-text-accent); + color: var(--color-fg-accent); cursor: pointer; font-size: var(--font-2xs); font-weight: var(--weight-semi); @@ -138,7 +138,7 @@ } .filteredFooter { - background-color: var(--color-accent-low); + background-color: var(--color-bg-accent); } .canceledContainer { diff --git a/app/features/sendouq/routes/q.info.module.css b/app/features/sendouq/routes/q.info.module.css index 5aee43d83..20d40ff41 100644 --- a/app/features/sendouq/routes/q.info.module.css +++ b/app/features/sendouq/routes/q.info.module.css @@ -7,7 +7,7 @@ & h2 { font-size: var(--font-lg); margin-block-end: var(--s-2); - color: var(--color-text-accent); + color: var(--color-fg-accent); } & h3 { diff --git a/app/features/sendouq/routes/q.looking.module.css b/app/features/sendouq/routes/q.looking.module.css index 85ee0afa1..50868d86c 100644 --- a/app/features/sendouq/routes/q.looking.module.css +++ b/app/features/sendouq/routes/q.looking.module.css @@ -20,7 +20,7 @@ font-size: var(--font-2xs); font-weight: var(--weight-semi); text-transform: uppercase; - color: var(--color-text-accent); + color: var(--color-fg-accent); display: flex; align-items: center; diff --git a/app/features/settings/actions/settings.server.ts b/app/features/settings/actions/settings.server.ts index 051542e81..6dc852148 100644 --- a/app/features/settings/actions/settings.server.ts +++ b/app/features/settings/actions/settings.server.ts @@ -11,10 +11,10 @@ import { SENDOUQ_LOOKING_CHANNEL, sqGroupChannel, } from "~/features/sendouq/q-constants"; +import * as ThemePalette from "~/features/theme/core/ThemePalette"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { parseFormData } from "~/form/parse.server"; import { isSupporter } from "~/modules/permissions/utils"; -import { clampThemeToGamut } from "~/utils/oklch-gamut"; import { errorToast } from "~/utils/remix.server"; import { toDBBoolean } from "~/utils/sql"; import { assertUnreachable } from "~/utils/types"; @@ -40,7 +40,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { } const clampedTheme = data.newValue - ? clampThemeToGamut(data.newValue) + ? ThemePalette.build(data.newValue) : null; await UserRepository.updateOwnCustomTheme(clampedTheme); diff --git a/app/features/settings/components/ThemeTab.tsx b/app/features/settings/components/ThemeTab.tsx index 1b7dc8560..237173c48 100644 --- a/app/features/settings/components/ThemeTab.tsx +++ b/app/features/settings/components/ThemeTab.tsx @@ -3,11 +3,11 @@ import { useMatches } from "react-router"; import { CustomThemeSelector } from "~/components/CustomThemeSelector"; import { FormMessage } from "~/components/FormMessage"; import { Theme, useTheme } from "~/features/theme/core/provider"; +import type { ThemeInput } from "~/features/theme/core/ThemePalette"; import { SelectFormField } from "~/form/fields/SelectFormField"; import { useActionSubmit } from "~/hooks/useActionSubmit"; import { useHasRole } from "~/modules/permissions/hooks"; import type { RootLoaderData } from "~/root"; -import type { ThemeInput } from "~/utils/oklch-gamut"; import { customThemeSchema } from "../settings-schemas"; export function ThemeTab() { diff --git a/app/features/team/actions/t.$customUrl.edit.server.test.ts b/app/features/team/actions/t.$customUrl.edit.server.test.ts index 8add65bfb..12791d735 100644 --- a/app/features/team/actions/t.$customUrl.edit.server.test.ts +++ b/app/features/team/actions/t.$customUrl.edit.server.test.ts @@ -5,8 +5,8 @@ import * as TeamFactory from "~/db/seed/factories/TeamFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import * as ImageRepository from "~/features/img-upload/ImageRepository.server"; import * as TeamRepository from "~/features/team/TeamRepository.server"; +import * as ThemePalette from "~/features/theme/core/ThemePalette"; import { invariant } from "~/utils/invariant"; -import { clampThemeToGamut } from "~/utils/oklch-gamut"; import { assertResponseErrored, wrappedAction } from "~/utils/Test"; import type { editTeamActionSchema } from "../team-schemas"; import { action as _editTeamProfileAction } from "./t.$customUrl.edit.server"; @@ -31,6 +31,7 @@ const VALID_CUSTOM_THEME = { baseChroma: 0.05, accentHue: 200, accentChroma: 0.1, + bgLightness: 0.17, chatHue: null, radiusBox: 3, radiusField: 2, @@ -42,7 +43,7 @@ const VALID_CUSTOM_THEME = { } as const; const expectedStoredTheme = () => - JSON.parse(JSON.stringify(clampThemeToGamut(VALID_CUSTOM_THEME))); + JSON.parse(JSON.stringify(ThemePalette.build(VALID_CUSTOM_THEME))); const users = UserFactory.pool(); const victimId = () => users.id(1); @@ -131,20 +132,28 @@ describe("team page editing", () => { expect((await teamRow()).customTheme).toBeNull(); }); - test("prevents setting an invalid custom theme", async () => { - const response = await editTeamProfileAction( - { - _action: "UPDATE_CUSTOM_THEME", - newValue: { - ...VALID_CUSTOM_THEME, - baseHue: 500, // Invalid: max is 360 + test.each([ + { why: "base hue above max", field: "baseHue", value: 500 }, + { why: "bg lightness below min", field: "bgLightness", value: 0.05 }, + { why: "bg lightness above max", field: "bgLightness", value: 0.18 }, + { why: "bg lightness off step", field: "bgLightness", value: 0.125 }, + ])( + "prevents setting an invalid custom theme ($why)", + async ({ field, value }) => { + const response = await editTeamProfileAction( + { + _action: "UPDATE_CUSTOM_THEME", + newValue: { + ...VALID_CUSTOM_THEME, + [field]: value, + }, }, - }, - { user: "regular", params: { customUrl } }, - ); + { user: "regular", params: { customUrl } }, + ); - expect(response.fieldErrors["newValue.baseHue"]).toBeTruthy(); - }); + expect(response.fieldErrors[`newValue.${field}`]).toBeTruthy(); + }, + ); test("preserves an existing custom theme when editing the team profile", async () => { await editTeamProfileAction( diff --git a/app/features/team/actions/t.$customUrl.edit.server.ts b/app/features/team/actions/t.$customUrl.edit.server.ts index ba23f930f..0b84fe660 100644 --- a/app/features/team/actions/t.$customUrl.edit.server.ts +++ b/app/features/team/actions/t.$customUrl.edit.server.ts @@ -2,9 +2,9 @@ import type { ActionFunction } from "react-router"; import { redirect } from "react-router"; import * as v from "valibot"; import { requireUser } from "~/features/auth/core/user.server"; +import * as ThemePalette from "~/features/theme/core/ThemePalette"; import { parseFormDataWithImages } from "~/form/parse.server"; import { requirePermission } from "~/modules/permissions/guards.server"; -import { clampThemeToGamut } from "~/utils/oklch-gamut"; import { errorToastIfFalsy, notFoundIfNullish } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; import { mySlugify, teamPage } from "~/utils/urls"; @@ -45,7 +45,7 @@ export const action: ActionFunction = async ({ request, params }) => { await TeamRepository.updateCustomTheme({ id: team.id, - customTheme: data.newValue ? clampThemeToGamut(data.newValue) : null, + customTheme: data.newValue ? ThemePalette.build(data.newValue) : null, }); return { ok: true }; diff --git a/app/features/team/routes/t.$customUrl.edit.tsx b/app/features/team/routes/t.$customUrl.edit.tsx index 3e1d98e55..2e349e675 100644 --- a/app/features/team/routes/t.$customUrl.edit.tsx +++ b/app/features/team/routes/t.$customUrl.edit.tsx @@ -12,10 +12,10 @@ import { preferencesFromRaw, } from "~/features/settings/components/MapModePreferencesField"; import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton"; +import type { ThemeInput } from "~/features/theme/core/ThemePalette"; import { existingImage } from "~/form/image-field"; import { SendouForm } from "~/form/SendouForm"; import { useActionSubmit } from "~/hooks/useActionSubmit"; -import type { ThemeInput } from "~/utils/oklch-gamut"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { action } from "../actions/t.$customUrl.edit.server"; diff --git a/app/features/team/routes/t.$customUrl.module.css b/app/features/team/routes/t.$customUrl.module.css index 20efdc73b..f5d94f62e 100644 --- a/app/features/team/routes/t.$customUrl.module.css +++ b/app/features/team/routes/t.$customUrl.module.css @@ -18,7 +18,7 @@ .bannerPlaceholder { height: 6rem; - background-color: var(--color-accent-low); + background-color: var(--color-bg-accent); } .bannerFlags { @@ -104,8 +104,8 @@ .bannerTag { font-size: var(--font-sm); - background-color: var(--color-accent-low); - color: var(--color-accent); + background-color: var(--color-bg-accent); + color: var(--color-fg-accent); padding: var(--s-1) var(--s-1-5); border-radius: var(--radius-box); } diff --git a/app/features/theme/core/ThemePalette.test.ts b/app/features/theme/core/ThemePalette.test.ts new file mode 100644 index 000000000..73c239841 --- /dev/null +++ b/app/features/theme/core/ThemePalette.test.ts @@ -0,0 +1,355 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; +import type { CustomTheme } from "~/db/tables-json"; +import { isInSrgbGamut, type Oklch } from "~/utils/oklch-gamut"; +import { THEME_INPUT_LIMITS } from "~/utils/schema"; +import * as ThemePalette from "./ThemePalette"; + +const input = (overrides: Partial = {}) => ({ + ...ThemePalette.DEFAULT_THEME_INPUT, + ...overrides, +}); + +const built = (overrides: Partial = {}) => + ThemePalette.build(input(overrides)); + +describe("ThemePalette.build", () => { + test("reproduces the defaults of vars.css for the default input", () => { + const cssDefaults = varsCssThemeDefaults(); + const theme = built(); + + expect(Object.keys(cssDefaults).length).toBeGreaterThan(50); + for (const [key, value] of Object.entries(cssDefaults)) { + expect(theme[key as keyof CustomTheme], key).toBeCloseTo(value, 10); + } + }); + + test("vars.css resolves every color to the same value as resolveColors", () => { + const css = varsCssColors(); + const { base, dark, light } = ThemePalette.resolveColors(built()); + const kebab = (key: string) => + key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); + const expectColor = (label: string, actual: Oklch, expected: Oklch) => { + expect(actual.l, `${label} lightness`).toBeCloseTo(expected.l, 6); + expect(actual.c, `${label} chroma`).toBeCloseTo(expected.c, 6); + expect(actual.h, `${label} hue`).toBeCloseTo(expected.h, 6); + }; + + for (const [mode, colors] of [ + ["dark", dark], + ["light", light], + ] as const) { + for (const [key, color] of Object.entries(colors)) { + const name = `--color-${kebab(key)}`; + expectColor(`${mode} ${name}`, css.resolve(name, mode), color); + } + } + for (const [index, color] of base.entries()) { + if (index < 5) { + expectColor( + `dark base-${index}`, + css.resolve(`--color-base-${index}`, "dark"), + color, + ); + } + expectColor( + `light base-${7 - index}`, + css.resolve(`--color-base-${7 - index}`, "light"), + color, + ); + } + }); + + test("every text color has at least WCAG AA contrast for any input", () => { + const failures: string[] = []; + + for (const sample of sampledInputs()) { + for (const pair of ThemePalette.textContrastPairs(built(sample))) { + if (pair.contrast >= 4.5) continue; + + failures.push( + `${pair.name} ${pair.contrast.toFixed(2)} ${JSON.stringify(sample)}`, + ); + } + } + + expect(failures).toEqual([]); + }); + + test("every color is inside the sRGB gamut for any input", () => { + const failures: string[] = []; + + for (const sample of sampledInputs()) { + const { base, dark, light } = ThemePalette.resolveColors(built(sample)); + const colors = [ + ...base.map((color, index) => [`base-${index}`, color] as const), + ...Object.entries(dark).map( + ([name, color]) => [`dark ${name}`, color] as const, + ), + ...Object.entries(light).map( + ([name, color]) => [`light ${name}`, color] as const, + ), + ]; + + for (const [name, color] of colors) { + if (isInSrgbGamut(color)) continue; + + failures.push( + `${name} ${JSON.stringify(color)} ${JSON.stringify(sample)}`, + ); + } + } + + expect(failures).toEqual([]); + }); + + test("gives a yellow accent a bright light mode fill with dark text on it", () => { + const theme = built({ accentHue: 100, accentChroma: 0.3 }); + + expect(theme["--_acc-fill-dark-text"]).toBe(1); + expect(theme["--_acc-l-6"]).toBeGreaterThan(0.8); + }); + + test("keeps the default accent's light mode fill as its text color with white text on it", () => { + const theme = built(); + + expect(theme["--_acc-fill-dark-text"]).toBe(0); + expect(theme["--_acc-l-6"]).toBe(theme["--_acc-l-4"]); + }); + + test("gives the default theme's amber secondary a bright light mode fill with dark text on it", () => { + const theme = built(); + + expect(theme["--_second-fill-dark-text"]).toBe(1); + expect(theme["--_second-l-6"]).toBeGreaterThan(0.8); + }); + + test("keeps a yellow accent's blue secondary fill as its text color with white text on it", () => { + const theme = built({ accentHue: 100 }); + + expect(theme["--_second-fill-dark-text"]).toBe(0); + expect(theme["--_second-l-6"]).toBe(theme["--_second-l-4"]); + }); + + test("raises a yellow dark mode accent to where yellow is at its most vivid", () => { + const theme = built({ accentHue: 100 }); + + expect(theme["--_acc-l-2"]).toBeGreaterThan(0.88); + expect(theme["--_acc-c-2"]).toBeGreaterThan(0.15); + }); + + test("rotates dark shades of yellow toward amber", () => { + const theme = built({ accentHue: 100 }); + + expect(theme["--_acc-h-4"]).toBeLessThan(85); + expect(theme["--_acc-h-2"]).toBe(100); + }); +}); + +describe("ThemePalette.toThemeInput", () => { + test.each([ + { why: "default", overrides: {} }, + { + why: "yellow", + overrides: { accentHue: 100, accentChroma: 0.3, bgLightness: 0.08 }, + }, + { + why: "gray", + overrides: { baseChroma: 0, accentChroma: 0, bgLightness: 0.12 }, + }, + ])("recovers the input of a $why theme", ({ overrides }) => { + const recovered = ThemePalette.toThemeInput(built(overrides)); + + for (const [key, value] of Object.entries(input(overrides))) { + if (typeof value === "number") { + expect( + recovered[key as keyof ThemePalette.ThemeInput], + key, + ).toBeCloseTo(value, 6); + } else { + expect(recovered[key as keyof ThemePalette.ThemeInput], key).toBe( + value, + ); + } + } + }); +}); + +describe("ThemePalette.fromShareCode", () => { + test.each([ + { why: "default", overrides: {} }, + { + why: "custom", + overrides: { accentHue: 100, bgLightness: 0.08, chatHue: 30 }, + }, + ])("round-trips a $why theme through toShareCode", ({ overrides }) => { + const code = ThemePalette.toShareCode(input(overrides)); + + expect(ThemePalette.fromShareCode(code)).toEqual(input(overrides)); + }); + + test("decodes a legacy code without background lightness", () => { + expect( + ThemePalette.fromShareCode("180;0.05;200;0.1;3;2;2;2;1;1;1;_"), + ).toEqual( + input({ + baseHue: 180, + accentHue: 200, + accentChroma: 0.1, + }), + ); + }); + + test.each([ + { why: "too few parts", code: "180;0.05;200" }, + { why: "not a number", code: "180;0.05;200;x;3;2;2;2;1;1;1;_;0.17" }, + { + why: "out of range value", + code: "180;0.05;200;0.1;3;2;2;2;1;1;1;_;0.5", + }, + ])("returns null for $why", ({ code }) => { + expect(ThemePalette.fromShareCode(code)).toBeNull(); + }); +}); + +function* sampledInputs() { + const { BG_LIGHTNESS_MIN, BG_LIGHTNESS_MAX } = THEME_INPUT_LIMITS; + + for (let baseHue = 0; baseHue < 360; baseHue += 30) { + for (const baseChroma of [0, 0.025, 0.05, 0.075, 0.1]) { + for (let accentHue = 0; accentHue < 360; accentHue += 5) { + for (const accentChroma of [0, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5]) { + for (const bgLightness of [ + BG_LIGHTNESS_MIN, + (BG_LIGHTNESS_MIN + BG_LIGHTNESS_MAX) / 2, + BG_LIGHTNESS_MAX, + ]) { + yield { baseHue, baseChroma, accentHue, accentChroma, bgLightness }; + } + } + } + } + } +} + +const VARS_CSS = readFileSync( + new URL("../../../styles/vars.css", import.meta.url), + "utf8", +).replace(/\r\n/g, "\n"); + +type VarsBlock = "defaults" | "dark" | "light" | "semantic"; + +/** Custom property declarations of the top level blocks of vars.css, keyed by what the block is for */ +function varsCssBlocks() { + const blocks = new Map>(); + const blockFor = (selector: string): VarsBlock | null => { + const lastLine = selector.trim().split("\n").at(-1)!; + if (lastLine.includes("[data-default-theme]")) return "defaults"; + if (lastLine.includes('[data-theme="dark"]')) return "dark"; + if (lastLine.includes('[data-theme="light"]')) return "light"; + if (lastLine === "[data-theme]") return "semantic"; + return null; + }; + + for (const [, selector, body] of VARS_CSS.matchAll( + /^([^{}]+?)\{\n([^{}]*)\n\}/gm, + )) { + const block = blockFor(selector); + if (!block) continue; + + const declarations = new Map(); + for (const [, key, value] of body.matchAll(/(--[\w-]+):\s*([^;]+);/g)) { + declarations.set(key, value.replace(/\s+/g, " ").trim()); + } + blocks.set(block, declarations); + } + + return blocks; +} + +/** Theme variable defaults from vars.css, `var()` references resolved */ +function varsCssThemeDefaults() { + const raw = varsCssBlocks().get("defaults")!; + + const resolve = (value: string): number => { + const reference = value.match(/^var\((--_[\w-]+)\)$/); + if (reference) return resolve(raw.get(reference[1])!); + + return Number(value); + }; + + return Object.fromEntries( + [...raw.entries()].map(([key, value]) => [key, resolve(value)]), + ); +} + +/** Evaluates the oklch() colors of vars.css for a color scheme the way the browser would */ +function varsCssColors() { + const blocks = varsCssBlocks(); + + const lookup = (name: string, mode: "dark" | "light"): string => { + const value = + blocks.get(mode)?.get(name) ?? + blocks.get("semantic")?.get(name) ?? + blocks.get("defaults")?.get(name); + if (value === undefined) + throw new Error(`${name} is not defined for ${mode}`); + + return value; + }; + + const substituteVars = (value: string, mode: "dark" | "light"): string => { + let result = value; + while (result.includes("var(")) { + result = result.replace(/var\((--[\w-]+)\)/g, (_, name) => + lookup(name, mode), + ); + } + + return result; + }; + + const evaluate = (expression: string): number => { + const arithmetic = expression + .replaceAll("calc", "") + .replace(/(\d+(?:\.\d+)?)%/g, "($1/100)"); + if (!/^[\d\s.+\-*/()]+$/.test(arithmetic)) { + throw new Error(`cannot evaluate ${expression}`); + } + + return new Function(`return ${arithmetic}`)(); + }; + + const splitTopLevel = (value: string) => { + const parts: string[] = []; + let depth = 0; + let current = ""; + for (const char of value) { + if (char === "(") depth++; + if (char === ")") depth--; + if (char === " " && depth === 0) { + if (current) parts.push(current); + current = ""; + } else { + current += char; + } + } + if (current) parts.push(current); + + return parts; + }; + + const resolve = (name: string, mode: "dark" | "light"): Oklch => { + const value = substituteVars(lookup(name, mode), mode); + const inner = value.match(/^oklch\((.*)\)$/s)?.[1]; + if (!inner) + throw new Error( + `${name} does not resolve to oklch() for ${mode}: ${value}`, + ); + const [l, c, h] = splitTopLevel(inner.trim()).map(evaluate); + + return { l, c, h }; + }; + + return { resolve }; +} diff --git a/app/features/theme/core/ThemePalette.ts b/app/features/theme/core/ThemePalette.ts new file mode 100644 index 000000000..aa1e06099 --- /dev/null +++ b/app/features/theme/core/ThemePalette.ts @@ -0,0 +1,631 @@ +import * as v from "valibot"; +import type { CustomTheme } from "~/db/tables-json"; +import { + contrastRatio, + cuspLightness, + maxChroma, + type Oklch, +} from "~/utils/oklch-gamut"; +import { THEME_INPUT_LIMITS, themeInputSchema } from "~/utils/schema"; + +export type ThemeInput = v.InferOutput; + +export const DEFAULT_THEME_INPUT: ThemeInput = { + baseHue: 268, + baseChroma: 0.05, + accentHue: 253, + accentChroma: 0.24, + bgLightness: THEME_INPUT_LIMITS.BG_LIGHTNESS_DEFAULT, + chatHue: null, + radiusBox: 3, + radiusField: 2, + radiusSelector: 2, + borderWidth: 2, + sizeField: 1, + sizeSelector: 1, + sizeSpacing: 1, +}; + +/** Keys added after share codes were introduced go last, codes made before them just omit them */ +const LEGACY_SHARE_CODE_KEYS: ReadonlyArray = [ + "baseHue", + "baseChroma", + "accentHue", + "accentChroma", + "radiusBox", + "radiusField", + "radiusSelector", + "borderWidth", + "sizeField", + "sizeSelector", + "sizeSpacing", + "chatHue", +]; +const SHARE_CODE_KEYS: ReadonlyArray = [ + ...LEGACY_SHARE_CODE_KEYS, + "bgLightness", +]; + +/** WCAG AA for normal sized text */ +const MIN_TEXT_CONTRAST = 4.5; +const LIGHTNESS_SEARCH_STEP = 0.005; + +// Any changes to the lightness values or offsets NEED to be reflected in vars.css as well + +const BASE_LIGHTNESS_VALUES = [ + 1.0, // --_base-c-0 + 0.95, // --_base-c-1 + 0.9, // --_base-c-2 + 0.64, // --_base-c-3 + 0.46, // --_base-c-4 + 0.32, // --_base-c-5 + 0.25, // --_base-c-6 + 0.17, // --_base-c-7 +] as const; + +const BASE_CHROMA_MULTIPLIERS = [ + 0.01, 0.49, 0.62, 1.4, 1.29, 1.36, 1.29, 0.67, +] as const; + +/** In dark mode --color-base-5...7 sit this much above the background lightness (`--_base-l`) */ +const DARK_SURFACE_OFFSETS: Partial> = { + 5: 0.15, + 6: 0.08, + 7: 0, +}; + +/** Lightness of text drawn on top of accent fills when the fill is too light for white text */ +const DARK_TEXT_LIGHTNESS = BASE_LIGHTNESS_VALUES[7]; + +interface AccentSlot { + /** Lightness the slot is designed around, the solver only moves away from it when needed */ + lightness: number; + chromaMultiplier: number; + /** Hues that are at their most colorful above `lightness` (yellow, cyan...) are raised toward that point, at most up to this value, so they stay clean tints instead of muddy shades */ + maxCuspLift?: number; +} + +const ACCENT_SLOTS = [ + { lightness: 0.26, chromaMultiplier: 0.38 }, // dark --color-accent-low + { lightness: 0.52, chromaMultiplier: 1.11 }, // dark --color-accent + { lightness: 0.83, chromaMultiplier: 0.34, maxCuspLift: 0.92 }, // dark --color-accent-high + { lightness: 0.88, chromaMultiplier: 0.25, maxCuspLift: 0.92 }, // light --color-accent-low + { lightness: 0.53, chromaMultiplier: 1.09 }, // light --color-accent + { lightness: 0.32, chromaMultiplier: 0.56 }, // light --color-accent-high +] as const satisfies ReadonlyArray; + +/** Light mode --color-fill-accent when it is swapped to a bright fill with dark text */ +const BRIGHT_FILL = { + minLightness: 0.8, + maxLightness: 0.92, + chromaMultiplier: 1.09, + /** How much more chroma the bright fill has to reach before it is preferred over the dark one */ + minChromaGain: 1.25, +}; + +/** + * Hues with a larger gamut than the default accent hue get proportionally more chroma + * so e.g. yellow can be as vivid as blue is at the same slider value. + */ +const GAMUT_BOOST = { + referenceHue: DEFAULT_THEME_INPUT.accentHue, + max: 2, +}; + +/** Dark yellows look olive, so shades of them are rotated toward amber (like e.g. Tailwind's yellow palette does) */ +const SHADE_HUE_SHIFT = { + targetHue: 70, + fullWeightHues: [85, 115], + zeroWeightHues: [75, 135], + strength: 0.85, + /** How far below the hue's cusp lightness the full shift is applied */ + fullShiftDepth: 0.4, +}; + +/** + * Expands the supporter's slider values into the CSS variables of a custom theme. + * Every text color is solved to have at least WCAG AA contrast against the surfaces it's shown on. + */ +export function build(input: ThemeInput): CustomTheme { + const bgLightness = input.bgLightness; + const baseChromas = BASE_LIGHTNESS_VALUES.map((lightness, index) => { + const desiredChroma = input.baseChroma * BASE_CHROMA_MULTIPLIERS[index]; + const darkSurfaceOffset = DARK_SURFACE_OFFSETS[index]; + const lightnesses = + darkSurfaceOffset === undefined + ? [lightness] + : [lightness, bgLightness + darkSurfaceOffset]; + + return Math.min( + desiredChroma, + ...lightnesses.map((l) => maxChroma(l, input.baseHue)), + ); + }); + + const surfaces: Surfaces = { + dark: { + bgHigher: { + l: bgLightness + DARK_SURFACE_OFFSETS[5]!, + c: baseChromas[5], + h: input.baseHue, + }, + }, + light: { + bg: { l: BASE_LIGHTNESS_VALUES[0], c: baseChromas[0], h: input.baseHue }, + bgHigh: { + l: BASE_LIGHTNESS_VALUES[1], + c: baseChromas[1], + h: input.baseHue, + }, + darkText: { + l: DARK_TEXT_LIGHTNESS, + c: baseChromas[7], + h: input.baseHue, + }, + }, + }; + + const accent = buildPalette({ + hue: input.accentHue, + chroma: input.accentChroma, + boostChroma: true, + surfaces, + }); + + const secondaryHue = (input.accentHue + 180) % 360; + const secondary = buildPalette({ + hue: secondaryHue, + chroma: input.accentChroma, + boostChroma: false, + surfaces, + }); + + const lightFill = buildLightFill({ + hue: input.accentHue, + chroma: input.accentChroma, + boostChroma: true, + darkFill: accent[4], + darkText: surfaces.light.darkText, + }); + const secondaryLightFill = buildLightFill({ + hue: secondaryHue, + chroma: input.accentChroma, + boostChroma: false, + darkFill: secondary[4], + darkText: surfaces.light.darkText, + }); + + return { + "--_base-h": input.baseHue, + "--_base-c-0": baseChromas[0], + "--_base-c-1": baseChromas[1], + "--_base-c-2": baseChromas[2], + "--_base-c-3": baseChromas[3], + "--_base-c-4": baseChromas[4], + "--_base-c-5": baseChromas[5], + "--_base-c-6": baseChromas[6], + "--_base-c-7": baseChromas[7], + "--_base-l": bgLightness, + "--_acc-h": input.accentHue, + "--_acc-c": input.accentChroma, + ...slotVars("acc", accent), + "--_acc-l-6": lightFill.color.l, + "--_acc-c-6": lightFill.color.c, + "--_acc-h-6": lightFill.color.h, + "--_acc-fill-dark-text": lightFill.hasDarkText ? 1 : 0, + ...slotVars("second", secondary), + "--_second-l-6": secondaryLightFill.color.l, + "--_second-c-6": secondaryLightFill.color.c, + "--_second-h-6": secondaryLightFill.color.h, + "--_second-fill-dark-text": secondaryLightFill.hasDarkText ? 1 : 0, + "--_chat-h": input.chatHue, + "--_radius-box": input.radiusBox, + "--_radius-field": input.radiusField, + "--_radius-selector": input.radiusSelector, + "--_border-width": input.borderWidth, + "--_size-field": input.sizeField, + "--_size-selector": input.sizeSelector, + "--_size-spacing": input.sizeSpacing, + }; +} + +/** Recovers the slider values a stored custom theme was built from. */ +export function toThemeInput(theme: CustomTheme): ThemeInput { + return { + baseHue: theme["--_base-h"] ?? DEFAULT_THEME_INPUT.baseHue, + baseChroma: + typeof theme["--_base-c-2"] === "number" + ? theme["--_base-c-2"] / BASE_CHROMA_MULTIPLIERS[2] + : DEFAULT_THEME_INPUT.baseChroma, + accentHue: theme["--_acc-h"] ?? DEFAULT_THEME_INPUT.accentHue, + accentChroma: theme["--_acc-c"] ?? DEFAULT_THEME_INPUT.accentChroma, + bgLightness: theme["--_base-l"] ?? DEFAULT_THEME_INPUT.bgLightness, + chatHue: theme["--_chat-h"], + radiusBox: theme["--_radius-box"] ?? DEFAULT_THEME_INPUT.radiusBox, + radiusField: theme["--_radius-field"] ?? DEFAULT_THEME_INPUT.radiusField, + radiusSelector: + theme["--_radius-selector"] ?? DEFAULT_THEME_INPUT.radiusSelector, + borderWidth: theme["--_border-width"] ?? DEFAULT_THEME_INPUT.borderWidth, + sizeField: theme["--_size-field"] ?? DEFAULT_THEME_INPUT.sizeField, + sizeSelector: theme["--_size-selector"] ?? DEFAULT_THEME_INPUT.sizeSelector, + sizeSpacing: theme["--_size-spacing"] ?? DEFAULT_THEME_INPUT.sizeSpacing, + }; +} + +/** Serializes the slider values into a share code other users can paste into their theme selector. */ +export function toShareCode(input: ThemeInput): string { + return SHARE_CODE_KEYS.map((key) => { + const value = input[key]; + return value === null ? "_" : String(value); + }).join(";"); +} + +/** Parses a share code made by `toShareCode()` (also older ones without the later added keys), returns null if it is not a valid theme. */ +export function fromShareCode(code: string): ThemeInput | null { + const parts = code.split(";"); + if ( + parts.length !== SHARE_CODE_KEYS.length && + parts.length !== LEGACY_SHARE_CODE_KEYS.length + ) { + return null; + } + + const raw: Record = {}; + for (let i = 0; i < parts.length; i++) { + const key = SHARE_CODE_KEYS[i]; + const part = parts[i].trim(); + + if (key === "chatHue" && part === "_") { + raw[key] = null; + continue; + } + + const num = Number(part); + if (Number.isNaN(num)) return null; + raw[key] = num; + } + + const parsed = v.safeParse(themeInputSchema, raw); + return parsed.success ? parsed.output : null; +} + +/** + * Every color of a built theme as vars.css resolves them, for verifying the accessibility and gamut guarantees. + */ +export function resolveColors(theme: CustomTheme) { + const base = (lightness: number, chromaIndex: number): Oklch => ({ + l: lightness, + c: theme[`--_base-c-${chromaIndex}` as "--_base-c-0"], + h: theme["--_base-h"], + }); + const slot = (prefix: "acc" | "second", index: number): Oklch => ({ + l: theme[`--_${prefix}-l-${index}` as "--_acc-l-0"], + c: theme[`--_${prefix}-c-${index}` as "--_acc-c-0"], + h: theme[`--_${prefix}-h-${index}` as "--_acc-h-0"], + }); + const lightBase = BASE_LIGHTNESS_VALUES.map((lightness, index) => + base(lightness, index), + ); + + const darkBg = base(theme["--_base-l"], 7); + + return { + /** light mode lightnesses, also used as dark mode text colors */ + base: lightBase, + dark: { + bg: darkBg, + bgHigh: base(theme["--_base-l"] + DARK_SURFACE_OFFSETS[6]!, 6), + bgHigher: base(theme["--_base-l"] + DARK_SURFACE_OFFSETS[5]!, 5), + text: lightBase[0], + textHigh: lightBase[3], + accentLow: slot("acc", 0), + accent: slot("acc", 1), + accentHigh: slot("acc", 2), + secondLow: slot("second", 0), + second: slot("second", 1), + secondHigh: slot("second", 2), + bgAccent: slot("acc", 0), + fgAccent: slot("acc", 2), + fillAccent: slot("acc", 2), + fgOnAccent: darkBg, + bgSecond: slot("second", 0), + fgSecond: slot("second", 2), + fillSecond: slot("second", 2), + fgOnSecond: darkBg, + }, + light: { + bg: lightBase[0], + bgHigh: lightBase[1], + bgHigher: lightBase[2], + textHigh: lightBase[4], + text: lightBase[7], + accentLow: slot("acc", 3), + accent: slot("acc", 4), + accentHigh: slot("acc", 5), + secondLow: slot("second", 3), + second: slot("second", 4), + secondHigh: slot("second", 5), + bgAccent: slot("acc", 3), + fgAccent: slot("acc", 4), + fillAccent: slot("acc", 6), + fgOnAccent: + theme["--_acc-fill-dark-text"] === 1 ? lightBase[7] : lightBase[0], + bgSecond: slot("second", 3), + fgSecond: slot("second", 4), + fillSecond: slot("second", 6), + fgOnSecond: + theme["--_second-fill-dark-text"] === 1 ? lightBase[7] : lightBase[0], + }, + }; +} + +/** + * Text/background color pairs of a built theme as vars.css resolves them, for verifying the accessibility guarantee. + */ +export function textContrastPairs(theme: CustomTheme) { + const { dark, light } = resolveColors(theme); + + return [ + { name: "dark text", fg: dark.text, bg: dark.bgHigher }, + { name: "dark text-high", fg: dark.textHigh, bg: dark.bgHigh }, + { name: "dark fg-accent", fg: dark.fgAccent, bg: dark.bgHigher }, + { + name: "dark fg-accent on bg-accent", + fg: dark.fgAccent, + bg: dark.bgAccent, + }, + { name: "dark fg-on-accent", fg: dark.fgOnAccent, bg: dark.fillAccent }, + { name: "dark text on bg-accent", fg: dark.text, bg: dark.bgAccent }, + { name: "dark fg-second", fg: dark.fgSecond, bg: dark.bgHigher }, + { + name: "dark fg-second on bg-second", + fg: dark.fgSecond, + bg: dark.bgSecond, + }, + { name: "dark fg-on-second", fg: dark.fgOnSecond, bg: dark.fillSecond }, + { name: "dark text on bg-second", fg: dark.text, bg: dark.bgSecond }, + { name: "light text", fg: light.text, bg: light.bgHigh }, + { name: "light text-high", fg: light.textHigh, bg: light.bg }, + { name: "light fg-accent", fg: light.fgAccent, bg: light.bgHigh }, + { + name: "light accent-high on low", + fg: light.accentHigh, + bg: light.accentLow, + }, + { name: "light fg-on-accent", fg: light.fgOnAccent, bg: light.fillAccent }, + { + name: "light fg-accent on bg-accent", + fg: light.fgAccent, + bg: light.bgAccent, + }, + { name: "light text on bg-accent", fg: light.text, bg: light.bgAccent }, + { name: "light fg-second", fg: light.fgSecond, bg: light.bgHigh }, + { + name: "light second-high on low", + fg: light.secondHigh, + bg: light.secondLow, + }, + { name: "light fg-on-second", fg: light.fgOnSecond, bg: light.fillSecond }, + { + name: "light fg-second on bg-second", + fg: light.fgSecond, + bg: light.bgSecond, + }, + { name: "light text on bg-second", fg: light.text, bg: light.bgSecond }, + ].map((pair) => ({ ...pair, contrast: contrastRatio(pair.fg, pair.bg) })); +} + +interface Surfaces { + dark: { bgHigher: Oklch }; + light: { bg: Oklch; bgHigh: Oklch; darkText: Oklch }; +} + +function buildPalette({ + hue, + chroma, + boostChroma, + surfaces, +}: { + hue: number; + chroma: number; + boostChroma: boolean; + surfaces: Surfaces; +}): Oklch[] { + const colorAt = (slot: AccentSlot) => (lightness: number) => + slotColor({ + lightness, + hue, + desiredChroma: chroma * slot.chromaMultiplier, + boostChroma, + }); + const startLightness = (slot: AccentSlot) => + slot.maxCuspLift + ? clamp(cuspLightness(hue), slot.lightness, slot.maxCuspLift) + : slot.lightness; + + const [darkLow, darkMid, darkHigh, lightLow, lightMid, lightHigh] = + ACCENT_SLOTS.map((slot) => colorAt(slot)(startLightness(slot))); + + return [ + darkLow, + darkMid, + ensureContrast({ + colorAt: colorAt(ACCENT_SLOTS[2]), + start: darkHigh, + against: [surfaces.dark.bgHigher, darkLow], + direction: "lighter", + }), + lightLow, + ensureContrast({ + colorAt: colorAt(ACCENT_SLOTS[4]), + start: lightMid, + against: [surfaces.light.bg, surfaces.light.bgHigh, lightLow], + direction: "darker", + }), + ensureContrast({ + colorAt: colorAt(ACCENT_SLOTS[5]), + start: lightHigh, + against: [lightLow], + direction: "darker", + }), + ]; +} + +/** Light mode fill (buttons, badges...), a bright fill with dark text is used when the hue can't be vivid while dark */ +function buildLightFill({ + hue, + chroma, + boostChroma, + darkFill, + darkText, +}: { + hue: number; + chroma: number; + boostChroma: boolean; + darkFill: Oklch; + darkText: Oklch; +}) { + const colorAt = (lightness: number) => + slotColor({ + lightness, + hue, + desiredChroma: chroma * BRIGHT_FILL.chromaMultiplier, + boostChroma, + }); + const brightFill = ensureContrast({ + colorAt, + start: colorAt( + clamp( + cuspLightness(hue), + BRIGHT_FILL.minLightness, + BRIGHT_FILL.maxLightness, + ), + ), + against: [darkText], + direction: "lighter", + }); + + if (brightFill.c > darkFill.c * BRIGHT_FILL.minChromaGain) { + return { color: brightFill, hasDarkText: true }; + } + + return { color: darkFill, hasDarkText: false }; +} + +function slotColor({ + lightness, + hue, + desiredChroma, + boostChroma, +}: { + lightness: number; + hue: number; + desiredChroma: number; + boostChroma: boolean; +}): Oklch { + // rounded before the gamut is computed so the stored values are exactly the checked ones + const roundedLightness = round(lightness); + const shiftedHue = round(shadeShiftedHue(hue, roundedLightness)); + const gamut = maxChroma(roundedLightness, shiftedHue); + const boost = boostChroma ? gamutBoost(roundedLightness, gamut) : 1; + + return { + l: roundedLightness, + c: Math.min(desiredChroma * boost, gamut), + h: shiftedHue, + }; +} + +function gamutBoost(lightness: number, gamut: number) { + const referenceGamut = maxChroma(lightness, GAMUT_BOOST.referenceHue); + if (referenceGamut <= 0) return 1; + + return clamp(gamut / referenceGamut, 1, GAMUT_BOOST.max); +} + +function shadeShiftedHue(hue: number, lightness: number) { + const weight = yellowWeight(hue); + if (weight === 0) return hue; + + const depth = clamp( + (cuspLightness(hue) - lightness) / SHADE_HUE_SHIFT.fullShiftDepth, + 0, + 1, + ); + + return ( + hue + + (SHADE_HUE_SHIFT.targetHue - hue) * + weight * + depth * + SHADE_HUE_SHIFT.strength + ); +} + +function yellowWeight(hue: number) { + const [fullStart, fullEnd] = SHADE_HUE_SHIFT.fullWeightHues; + const [zeroStart, zeroEnd] = SHADE_HUE_SHIFT.zeroWeightHues; + + if (hue <= zeroStart || hue >= zeroEnd) return 0; + if (hue < fullStart) return (hue - zeroStart) / (fullStart - zeroStart); + if (hue > fullEnd) return (zeroEnd - hue) / (zeroEnd - fullEnd); + return 1; +} + +/** Moves the color's lightness until it has enough contrast against every given color */ +function ensureContrast({ + colorAt, + start, + against, + direction, +}: { + colorAt: (lightness: number) => Oklch; + start: Oklch; + against: Oklch[]; + direction: "lighter" | "darker"; +}): Oklch { + const step = + direction === "lighter" ? LIGHTNESS_SEARCH_STEP : -LIGHTNESS_SEARCH_STEP; + const hasContrast = (candidate: Oklch) => + against.every( + (other) => contrastRatio(candidate, other) >= MIN_TEXT_CONTRAST, + ); + + let color = start; + while (!hasContrast(color)) { + const nextLightness = color.l + step; + if (nextLightness < 0 || nextLightness > 1) { + return colorAt(direction === "lighter" ? 1 : 0); + } + color = colorAt(nextLightness); + } + + return color; +} + +type SlotVars = Record< + `--_${Prefix}-${"l" | "c" | "h"}-${0 | 1 | 2 | 3 | 4 | 5}`, + number +>; + +function slotVars( + prefix: Prefix, + slots: Oklch[], +): SlotVars { + const result: Record = {}; + for (const [index, color] of slots.entries()) { + result[`--_${prefix}-l-${index}`] = color.l; + result[`--_${prefix}-c-${index}`] = color.c; + result[`--_${prefix}-h-${index}`] = color.h; + } + + return result as SlotVars; +} + +function round(value: number) { + return Math.round(value * 10_000) / 10_000; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} diff --git a/app/features/theme/theme-constants.ts b/app/features/theme/theme-constants.ts index 31548c359..7d0c45adf 100644 --- a/app/features/theme/theme-constants.ts +++ b/app/features/theme/theme-constants.ts @@ -16,7 +16,6 @@ export const CUSTOM_THEME_VARS = [ "--_acc-c-3", "--_acc-c-4", "--_acc-c-5", - "--_second-h", "--_second-c-0", "--_second-c-1", "--_second-c-2", @@ -31,6 +30,41 @@ export const CUSTOM_THEME_VARS = [ "--_size-field", "--_size-selector", "--_size-spacing", + + "--_base-l", + "--_acc-c", + "--_acc-l-0", + "--_acc-l-1", + "--_acc-l-2", + "--_acc-l-3", + "--_acc-l-4", + "--_acc-l-5", + "--_acc-l-6", + "--_acc-c-6", + "--_acc-h-0", + "--_acc-h-1", + "--_acc-h-2", + "--_acc-h-3", + "--_acc-h-4", + "--_acc-h-5", + "--_acc-h-6", + "--_acc-fill-dark-text", + "--_second-l-0", + "--_second-l-1", + "--_second-l-2", + "--_second-l-3", + "--_second-l-4", + "--_second-l-5", + "--_second-h-0", + "--_second-h-1", + "--_second-h-2", + "--_second-h-3", + "--_second-h-4", + "--_second-h-5", + "--_second-l-6", + "--_second-c-6", + "--_second-h-6", + "--_second-fill-dark-text", ] as const; export type CustomThemeVar = (typeof CUSTOM_THEME_VARS)[number]; @@ -40,7 +74,12 @@ export const PATRON_CHIP_THEME_VARS = [ "--_base-h", "--_base-c-2", "--_base-c-5", + "--_base-l", "--_acc-h", "--_acc-c-2", "--_acc-c-4", + "--_acc-l-2", + "--_acc-l-4", + "--_acc-h-2", + "--_acc-h-4", ] as const satisfies ReadonlyArray; diff --git a/app/features/tournament-admin/routes/to.$id.admin.seeds.module.css b/app/features/tournament-admin/routes/to.$id.admin.seeds.module.css index 414b7d321..57cbbc005 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.seeds.module.css +++ b/app/features/tournament-admin/routes/to.$id.admin.seeds.module.css @@ -135,8 +135,8 @@ .newBadge { flex-shrink: 0; - background-color: var(--color-second); - color: var(--color-text-inverse); + background-color: var(--color-fill-second); + color: var(--color-fg-on-second); font-size: var(--font-2xs); font-weight: var(--weight-bold); padding: 1px 4px; @@ -166,8 +166,8 @@ } .playerNewBadge { - background-color: var(--color-second); - color: var(--color-text-inverse); + background-color: var(--color-fill-second); + color: var(--color-fg-on-second); font-size: var(--font-2xs); font-weight: var(--weight-bold); padding: 1px 3px; diff --git a/app/features/tournament-bracket/components/Bracket/Match.module.css b/app/features/tournament-bracket/components/Bracket/Match.module.css index eadef155a..fb5eb716d 100644 --- a/app/features/tournament-bracket/components/Bracket/Match.module.css +++ b/app/features/tournament-bracket/components/Bracket/Match.module.css @@ -109,7 +109,7 @@ a.match:hover { } .matchSeed { - color: var(--color-text-accent); + color: var(--color-fg-accent); margin-inline-end: var(--s-0-5); min-width: 15px; max-width: 15px; diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.module.css b/app/features/tournament-bracket/components/BracketMapListDialog.module.css index 015e294fc..da1a2e8d8 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.module.css +++ b/app/features/tournament-bracket/components/BracketMapListDialog.module.css @@ -58,9 +58,9 @@ } .roundButtonActive { - background-color: var(--color-text-accent); - color: var(--color-text-inverse); - border-color: var(--color-text-accent); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); + border-color: var(--color-fg-accent); } .roundButtonNumber { diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.tsx b/app/features/tournament-bracket/components/BracketMapListDialog.tsx index dcd9613b8..721d2ff90 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.tsx +++ b/app/features/tournament-bracket/components/BracketMapListDialog.tsx @@ -1294,7 +1294,7 @@ function ModeListRow({
?
- + {isCounterpicks ? t("tournament:pickInfo.counterpick") : t("tournament:mapList.teamsPick")} @@ -1322,7 +1322,7 @@ function MysteryRow({
  • {number}. diff --git a/app/features/tournament-bracket/components/CustomFlowBuilder.module.css b/app/features/tournament-bracket/components/CustomFlowBuilder.module.css index 00c1ff33a..8ba9e8d3f 100644 --- a/app/features/tournament-bracket/components/CustomFlowBuilder.module.css +++ b/app/features/tournament-bracket/components/CustomFlowBuilder.module.css @@ -24,7 +24,7 @@ .paletteLabel { font-size: var(--font-xs); font-weight: var(--weight-semi); - color: var(--color-text-second); + color: var(--color-fg-second); min-width: 3rem; } @@ -96,7 +96,7 @@ .dragHandle { cursor: grab; - color: var(--color-text-second); + color: var(--color-fg-second); display: flex; align-items: center; touch-action: none; @@ -131,12 +131,8 @@ } .dropZoneOver { - border-color: var(--color-text-accent); - background-color: color-mix( - in srgb, - var(--color-text-accent) 10%, - transparent - ); + border-color: var(--color-fg-accent); + background-color: color-mix(in srgb, var(--color-fg-accent) 10%, transparent); } .dropZoneInvalid { @@ -154,7 +150,7 @@ .removeButton { background: none; border: none; - color: var(--color-text-second); + color: var(--color-fg-second); cursor: pointer; padding: 2px; display: flex; diff --git a/app/features/tournament-bracket/routes/to.$id.divisions.module.css b/app/features/tournament-bracket/routes/to.$id.divisions.module.css index f739e1d3e..bd7ab120f 100644 --- a/app/features/tournament-bracket/routes/to.$id.divisions.module.css +++ b/app/features/tournament-bracket/routes/to.$id.divisions.module.css @@ -15,7 +15,7 @@ border: var(--border-style); &:focus-visible { - outline: 3px solid var(--color-accent); + outline: 3px solid var(--color-fg-accent); outline-offset: 3px; } } @@ -25,7 +25,7 @@ outline-offset: 3px; & svg { - fill: var(--color-accent); + fill: var(--color-fg-accent); } } diff --git a/app/features/tournament-lfg/components/LFGGroupCard.module.css b/app/features/tournament-lfg/components/LFGGroupCard.module.css index 150c0ac1a..321e13ae1 100644 --- a/app/features/tournament-lfg/components/LFGGroupCard.module.css +++ b/app/features/tournament-lfg/components/LFGGroupCard.module.css @@ -51,7 +51,7 @@ .star { min-width: 18px; max-width: 18px; - color: var(--color-text-second); + color: var(--color-fg-second); stroke-width: 2; } diff --git a/app/features/tournament-lfg/routes/to.$id.looking.module.css b/app/features/tournament-lfg/routes/to.$id.looking.module.css index 2413ff38c..b988951a6 100644 --- a/app/features/tournament-lfg/routes/to.$id.looking.module.css +++ b/app/features/tournament-lfg/routes/to.$id.looking.module.css @@ -20,7 +20,7 @@ font-size: var(--font-2xs); font-weight: var(--weight-semi); text-transform: uppercase; - color: var(--color-text-accent); + color: var(--color-fg-accent); display: flex; align-items: center; diff --git a/app/features/tournament-organization/components/BannedPlayersList.module.css b/app/features/tournament-organization/components/BannedPlayersList.module.css index 38d5f1692..48417307e 100644 --- a/app/features/tournament-organization/components/BannedPlayersList.module.css +++ b/app/features/tournament-organization/components/BannedPlayersList.module.css @@ -20,7 +20,7 @@ .expandButton { background: none; border: none; - color: var(--color-accent); + color: var(--color-fg-accent); cursor: pointer; font-size: var(--font-2xs); padding: 0; diff --git a/app/features/tournament-organization/components/EventCalendar.module.css b/app/features/tournament-organization/components/EventCalendar.module.css index ccdf9cd1f..19f7e5c1e 100644 --- a/app/features/tournament-organization/components/EventCalendar.module.css +++ b/app/features/tournament-organization/components/EventCalendar.module.css @@ -68,7 +68,7 @@ } .calendarDayToday { - color: var(--color-text-accent); + color: var(--color-fg-accent); font-weight: var(--weight-bold); } diff --git a/app/features/tournament-organization/routes/org.$slug.module.css b/app/features/tournament-organization/routes/org.$slug.module.css index b970f4b28..6f4a727c1 100644 --- a/app/features/tournament-organization/routes/org.$slug.module.css +++ b/app/features/tournament-organization/routes/org.$slug.module.css @@ -50,7 +50,7 @@ & li::marker { font-size: var(--font-lg); font-weight: var(--weight-bold); - color: var(--color-accent); + color: var(--color-fg-accent); padding-inline-end: var(--s-2); } } diff --git a/app/features/tournament-organization/routes/org.$slug.stats.module.css b/app/features/tournament-organization/routes/org.$slug.stats.module.css index db0f9c5e8..93e62d8f3 100644 --- a/app/features/tournament-organization/routes/org.$slug.stats.module.css +++ b/app/features/tournament-organization/routes/org.$slug.stats.module.css @@ -27,7 +27,7 @@ .progressBar { height: 100%; border-radius: var(--radius-full); - background-color: var(--color-accent); + background-color: var(--color-fg-accent); transition: width 0.3s ease; } @@ -64,7 +64,7 @@ .breakdownBar { height: 100%; border-radius: var(--radius-full); - background-color: var(--color-accent); + background-color: var(--color-fg-accent); } .breakdownCount { diff --git a/app/features/tournament/components/TeamWithRoster.module.css b/app/features/tournament/components/TeamWithRoster.module.css index 78ec8ffb1..39ab0c981 100644 --- a/app/features/tournament/components/TeamWithRoster.module.css +++ b/app/features/tournament/components/TeamWithRoster.module.css @@ -97,8 +97,8 @@ .teamMemberNameRole { position: absolute; - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); width: 12px; height: 12px; border-radius: var(--radius-full); diff --git a/app/features/tournament/components/TournamentNav.module.css b/app/features/tournament/components/TournamentNav.module.css index 3a2d974ee..0f57cf928 100644 --- a/app/features/tournament/components/TournamentNav.module.css +++ b/app/features/tournament/components/TournamentNav.module.css @@ -98,7 +98,7 @@ } .linkActive { - color: var(--color-text-accent); + color: var(--color-fg-accent); background-color: var(--color-bg-high); } @@ -153,5 +153,5 @@ } .overflowLink.linkActive { - color: var(--color-text-accent); + color: var(--color-fg-accent); } diff --git a/app/features/tournament/routes/to.$id.register.module.css b/app/features/tournament/routes/to.$id.register.module.css index 58ae5bf10..3217828ae 100644 --- a/app/features/tournament/routes/to.$id.register.module.css +++ b/app/features/tournament/routes/to.$id.register.module.css @@ -55,7 +55,7 @@ gap: var(--s-1-5); font-size: var(--font-xs); font-weight: var(--weight-semi); - color: var(--color-text-accent); + color: var(--color-fg-accent); } .emptySlotRowOptional { @@ -70,11 +70,11 @@ place-items: center; border-radius: var(--radius-full); border: var(--border-style-accent); - color: var(--color-text-accent); + color: var(--color-fg-accent); } .emptySlotCircleOptional { - border: var(--border-width) dashed var(--color-text-accent); + border: var(--border-width) dashed var(--color-fg-accent); } .addMembers { diff --git a/app/features/tournament/routes/to.$id.results.module.css b/app/features/tournament/routes/to.$id.results.module.css index d7150c4a6..0eee54bbc 100644 --- a/app/features/tournament/routes/to.$id.results.module.css +++ b/app/features/tournament/routes/to.$id.results.module.css @@ -5,7 +5,7 @@ place-items: center; font-size: var(--font-xs); font-weight: var(--weight-semi); - border: 3px solid var(--color-accent); + border: 3px solid var(--color-fg-accent); border-radius: var(--radius-field); color: var(--color-text); } diff --git a/app/features/tournament/routes/to.$id.teams.$tid.module.css b/app/features/tournament/routes/to.$id.teams.$tid.module.css index 94c957b72..41fc270c1 100644 --- a/app/features/tournament/routes/to.$id.teams.$tid.module.css +++ b/app/features/tournament/routes/to.$id.teams.$tid.module.css @@ -147,7 +147,7 @@ display: flex; width: 100%; align-items: center; - color: var(--color-accent); + color: var(--color-fg-accent); font-size: var(--font-lg); &::before, diff --git a/app/features/trophies/components/TournamentSummaryRow.module.css b/app/features/trophies/components/TournamentSummaryRow.module.css index 3f5708a44..8fdf1ca36 100644 --- a/app/features/trophies/components/TournamentSummaryRow.module.css +++ b/app/features/trophies/components/TournamentSummaryRow.module.css @@ -30,8 +30,8 @@ height: var(--selector-size-xs); padding: 0 var(--s-1-5); border-radius: var(--radius-selector); - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); font-size: var(--font-2xs); font-weight: var(--weight-bold); } diff --git a/app/features/trophies/routes/trophies.new.module.css b/app/features/trophies/routes/trophies.new.module.css index 4310f22f2..64f423280 100644 --- a/app/features/trophies/routes/trophies.new.module.css +++ b/app/features/trophies/routes/trophies.new.module.css @@ -194,8 +194,8 @@ .editingBadge { padding: 0 var(--s-1-5); border-radius: var(--radius-selector); - background-color: var(--color-text-accent); - color: var(--color-text-inverse); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); font-size: var(--font-2xs); font-weight: var(--weight-bold); } diff --git a/app/features/user-page/components/ParticipationPill.module.css b/app/features/user-page/components/ParticipationPill.module.css index 9b1f2443b..91fd577d2 100644 --- a/app/features/user-page/components/ParticipationPill.module.css +++ b/app/features/user-page/components/ParticipationPill.module.css @@ -25,5 +25,5 @@ } .participating { - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); } diff --git a/app/features/user-page/components/UserPageIconNav.module.css b/app/features/user-page/components/UserPageIconNav.module.css index a73f3dd36..561fb6a3f 100644 --- a/app/features/user-page/components/UserPageIconNav.module.css +++ b/app/features/user-page/components/UserPageIconNav.module.css @@ -25,12 +25,12 @@ } &:focus-visible { - outline: 2px solid var(--color-text-second); + outline: 2px solid var(--color-fg-second); outline-offset: 2px; } &.active { - border-color: var(--color-text-second); + border-color: var(--color-fg-second); background-color: var(--color-bg-higher); } } diff --git a/app/features/user-page/components/Widget.module.css b/app/features/user-page/components/Widget.module.css index 596110979..eb7618341 100644 --- a/app/features/user-page/components/Widget.module.css +++ b/app/features/user-page/components/Widget.module.css @@ -33,7 +33,7 @@ font-weight: var(--weight-semi); text-transform: uppercase; letter-spacing: 0.05em; - color: var(--color-text-accent); + color: var(--color-fg-accent); } .content { @@ -468,8 +468,8 @@ height: var(--selector-size-xs); padding: 0 var(--s-1); border-radius: var(--radius-selector); - background-color: var(--color-text-second); - color: var(--color-text-inverse); + background-color: var(--color-fill-second); + color: var(--color-fg-on-second); font-size: var(--font-2xs); font-weight: var(--weight-semi); text-transform: uppercase; diff --git a/app/features/user-page/custom-theme-json.server.test.ts b/app/features/user-page/custom-theme-json.server.test.ts index 70afe1f7f..66753a1b0 100644 --- a/app/features/user-page/custom-theme-json.server.test.ts +++ b/app/features/user-page/custom-theme-json.server.test.ts @@ -1,13 +1,14 @@ import { describe, expect, test } from "vitest"; import * as UserFactory from "~/db/seed/factories/UserFactory"; -import { clampThemeToGamut } from "~/utils/oklch-gamut"; +import * as ThemePalette from "~/features/theme/core/ThemePalette"; import * as UserRepository from "./UserRepository.server"; -const CUSTOM_THEME = clampThemeToGamut({ +const CUSTOM_THEME = ThemePalette.build({ baseHue: 268, baseChroma: 0.05, accentHue: 253, accentChroma: 0.24, + bgLightness: 0.17, chatHue: null, radiusBox: 3, radiusField: 2, diff --git a/app/features/user-page/routes/u.$identifier.edit-widgets.module.css b/app/features/user-page/routes/u.$identifier.edit-widgets.module.css index f98c0f53e..8d6c7f8a6 100644 --- a/app/features/user-page/routes/u.$identifier.edit-widgets.module.css +++ b/app/features/user-page/routes/u.$identifier.edit-widgets.module.css @@ -247,12 +247,12 @@ .supporterMax { margin-left: var(--s-1); - color: var(--color-text-accent); + color: var(--color-fg-accent); font-weight: var(--weight-semi); } .supporterOnly { - color: var(--color-text-accent); + color: var(--color-fg-accent); font-size: var(--font-xs); font-weight: var(--weight-semi); white-space: nowrap; diff --git a/app/features/user-page/routes/u.$identifier.seasons.index.module.css b/app/features/user-page/routes/u.$identifier.seasons.index.module.css index f57c1c662..379e3f73a 100644 --- a/app/features/user-page/routes/u.$identifier.seasons.index.module.css +++ b/app/features/user-page/routes/u.$identifier.seasons.index.module.css @@ -67,7 +67,7 @@ place-items: center; font-size: var(--font-xs); font-weight: var(--weight-semi); - border: 3px solid var(--color-accent); + border: 3px solid var(--color-fg-accent); border-radius: var(--radius-field); color: var(--color-text); border-color: var(--color-error); diff --git a/app/features/user-page/routes/u.$identifier.seasons.stats.module.css b/app/features/user-page/routes/u.$identifier.seasons.stats.module.css index fcac860b0..7b7083ead 100644 --- a/app/features/user-page/routes/u.$identifier.seasons.stats.module.css +++ b/app/features/user-page/routes/u.$identifier.seasons.stats.module.css @@ -11,7 +11,7 @@ .seasonWeaponBorderOuter { --degree: 80deg; --smoothing: 0.5deg; - --color: var(--color-text-accent); + --color: var(--color-fg-accent); display: block; content: ""; diff --git a/app/features/user-report/components/ReportsBarChart.tsx b/app/features/user-report/components/ReportsBarChart.tsx index 0423e5ed1..9e22c5066 100644 --- a/app/features/user-report/components/ReportsBarChart.tsx +++ b/app/features/user-report/components/ReportsBarChart.tsx @@ -26,7 +26,7 @@ export function ReportsBarChart({ const isHydrated = useHydrated(); const colors = useThemeColors({ - bar: "--color-text-accent", + bar: "--color-fg-accent", border: "--color-border", borderHigh: "--color-border-high", text: "--color-text-high", diff --git a/app/form/fields/WeaponPoolFormField.module.css b/app/form/fields/WeaponPoolFormField.module.css index 72d13b81f..e6cc5a2bd 100644 --- a/app/form/fields/WeaponPoolFormField.module.css +++ b/app/form/fields/WeaponPoolFormField.module.css @@ -62,9 +62,9 @@ } .starIconOutlined { - color: var(--color-text-accent); + color: var(--color-fg-accent); } .starIconFilled { - fill: var(--color-text-accent); + fill: var(--color-fg-accent); } diff --git a/app/routes.ts b/app/routes.ts index 537659414..389bb3325 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -22,6 +22,10 @@ const devOnlyRoutes = "/components", "features/components-showcase/routes/components.tsx", ), + route( + "/components/colors", + "features/components-showcase/routes/components.colors.tsx", + ), route( "/comp-analyzer/all-ranges", "features/comp-analyzer/routes/comp-analyzer.all-ranges.tsx", diff --git a/app/styles/common.css b/app/styles/common.css index eea711042..7645502d7 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -8,7 +8,7 @@ color-scheme: light dark; /* only elements React names take part in view transitions, the rest of the page swaps instantly */ view-transition-name: none; - accent-color: var(--color-text-accent); + accent-color: var(--color-fg-accent); scroll-padding-top: var(--layout-sticky-top); } @@ -106,7 +106,7 @@ } a { - color: var(--color-text-accent); + color: var(--color-fg-accent); text-decoration: none; border-radius: var(--radius-field); @@ -216,6 +216,7 @@ input[type="checkbox"] { appearance: none; + position: relative; cursor: pointer; padding: 0; width: var(--selector-size-sm); @@ -235,19 +236,22 @@ &:checked, &:indeterminate { - border-color: var(--color-text-accent); - background-color: var(--color-text-accent); - background-repeat: no-repeat; - background-position: center; - background-size: var(--field-size-icon) auto; + border-color: var(--color-fill-accent); + background-color: var(--color-fill-accent); + + &::before { + content: ""; + position: absolute; + inset: 0; + background-color: var(--color-fg-on-accent); + mask: + var(--field-icon-checkbox) center / var(--field-size-icon) auto + no-repeat; + } } - &:checked { - background-image: var(--field-icon-checkbox); - } - - &:indeterminate { - background-image: var(--field-icon-minus); + &:indeterminate::before { + mask-image: var(--field-icon-minus); } /* Hacky selector to remove the margin bottom from the container from Label.tsx */ @@ -288,10 +292,10 @@ } &:checked { - border-color: var(--color-text-accent); + border-color: var(--color-fg-accent); &::after { - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); } } @@ -540,7 +544,7 @@ &:focus-within { --track-color: var(--color-bg-higher); - --thumb-color: var(--color-text-accent); + --thumb-color: var(--color-fg-accent); } &::-webkit-slider-runnable-track { @@ -603,7 +607,7 @@ border: none; border-radius: var(--radius-full); background-color: var(--color-bg-high); - color: var(--color-text-accent); + color: var(--color-fg-accent); vertical-align: baseline; &::-webkit-progress-bar { @@ -612,11 +616,11 @@ } &::-moz-progress-bar { - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); } &[value]::-webkit-progress-value { - background-color: var(--color-text-accent); + background-color: var(--color-fg-accent); transition: inline-size 0.2s ease; } } @@ -659,7 +663,7 @@ #nprogress { & .bar { - background: var(--color-text-accent) !important; + background: var(--color-fg-accent) !important; } & .spinner { diff --git a/app/styles/front.module.css b/app/styles/front.module.css index 8a2748c30..351a7d9f7 100644 --- a/app/styles/front.module.css +++ b/app/styles/front.module.css @@ -23,7 +23,7 @@ color: var(--color-text-high); &:hover { - color: var(--color-text-accent); + color: var(--color-fg-accent); } } diff --git a/app/styles/utils.css b/app/styles/utils.css index f7486f01d..541b171d9 100644 --- a/app/styles/utils.css +++ b/app/styles/utils.css @@ -43,8 +43,8 @@ color: var(--color-success); } - .text-accent-high { - color: var(--color-accent-high); + .text-accent { + color: var(--color-fg-accent); } .text-warning { @@ -52,11 +52,11 @@ } .text-theme { - color: var(--color-text-accent); + color: var(--color-fg-accent); } .text-theme-secondary { - color: var(--color-text-second); + color: var(--color-fg-second); } .text-uppercase { diff --git a/app/styles/vars.css b/app/styles/vars.css index 393fc096c..14df2d430 100644 --- a/app/styles/vars.css +++ b/app/styles/vars.css @@ -4,16 +4,17 @@ /* Tokens settable by custom themes and their defaults. Not to be consumed directly, only as a base -for other vars. Hue and chroma values are generated with oklch-gamut.ts, do not edit by hand. +for other vars. Values are generated with ThemePalette.ts, do not edit by hand. A subtree can force a color scheme with [data-theme="dark"|"light"] and opt out of an active custom theme with [data-default-theme] (e.g. image export previews) */ html, [data-default-theme] { + --_base-l: 0.17; --_base-h: 268; --_base-c-0: 0; - --_base-c-1: 0.02357547860966049; + --_base-c-1: 0.02356547860966049; --_base-c-2: 0.031; --_base-c-3: 0.06999999999999999; --_base-c-4: 0.0645; @@ -21,21 +22,53 @@ html, --_base-c-6: 0.0645; --_base-c-7: 0.0335; + --_acc-c: 0.24; --_acc-h: 253; - --_acc-c-0: 0.0801740871856143; - --_acc-c-1: 0.1603481743712286; + --_acc-l-0: 0.26; + --_acc-l-1: 0.52; + --_acc-l-2: 0.83; + --_acc-l-3: 0.88; + --_acc-l-4: 0.485; + --_acc-l-5: 0.32; + --_acc-l-6: 0.485; + --_acc-c-0: 0.08016408718561431; + --_acc-c-1: 0.1603381743712286; --_acc-c-2: 0.0816; --_acc-c-3: 0.06; - --_acc-c-4: 0.16343179310913686; - --_acc-c-5: 0.09867579961306376; + --_acc-c-4: 0.14954550878854975; + --_acc-c-5: 0.09866579961306376; + --_acc-c-6: 0.14954550878854975; + --_acc-h-0: 253; + --_acc-h-1: 253; + --_acc-h-2: 253; + --_acc-h-3: 253; + --_acc-h-4: 253; + --_acc-h-5: 253; + --_acc-h-6: 253; + --_acc-fill-dark-text: 0; - --_second-h: 73; + --_second-l-0: 0.26; + --_second-l-1: 0.52; + --_second-l-2: 0.83; + --_second-l-3: 0.88; + --_second-l-4: 0.485; + --_second-l-5: 0.32; --_second-c-0: 0.055379201356259726; --_second-c-1: 0.11075840271251945; --_second-c-2: 0.0816; --_second-c-3: 0.06; - --_second-c-4: 0.11288837199545253; + --_second-c-4: 0.10330351022225373; --_second-c-5: 0.06815901705385813; + --_second-h-0: 73; + --_second-h-1: 73; + --_second-h-2: 73; + --_second-h-3: 73; + --_second-h-4: 73; + --_second-h-5: 73; + --_second-l-6: 0.8007; + --_second-c-6: 0.1704874227120647; + --_second-h-6: 73; + --_second-fill-dark-text: 1; --_radius-box: 3; --_radius-field: 2; @@ -54,15 +87,25 @@ html, --color-base-x is rarely consumed directly, it is the base for vars like --color-text and --color-bg. ---color-text-accent and --color-text-second keep high contrast as a background or highlight in -both modes; pair with --color-text-inverse as the text color. +Accent and secondary colors come in four kinds of token: +- --color-bg-x: surfaces (tinted chips, highlighted rows...) +- --color-fg-x: foreground on surfaces: text, icons, borders, outlines and indicators (dots, + bars, checkboxes...). Readable on any --color-bg-x surface +- --color-fill-x: fills that carry content (buttons, badges...) +- --color-fg-on-x: foreground on a --color-fill-x fill. In light mode a yellow accent e.g. gets a + bright fill with dark text while --color-fg-accent stays dark enough to read +--color-accent-x and --color-second-x are the building blocks behind these and not meant to be +consumed directly. Info, success, warning, error: --color-x-high for text, --color-x-low for backgrounds, --color-x for borders and icons. -Field icons are defined per mode because SVGs cannot use currentColor inside data URLs. +The checkbox icons are masks colored by the checkbox. The select and date/time icons are background +images with a hardcoded color per mode, since those elements render no pseudo-element to mask and +SVGs cannot use currentColor inside data URLs. -Any changes here NEED to be reflected in oklch-gamut.ts as well +Custom theme lightness, chroma and hue values are generated by ThemePalette.ts which guarantees +their contrast. Any changes here NEED to be reflected in ThemePalette.ts as well */ html.dark, html.dark [data-custom-theme], @@ -72,26 +115,34 @@ html.dark [data-custom-theme], --color-base-2: oklch(90% var(--_base-c-2) var(--_base-h)); --color-base-3: oklch(64% var(--_base-c-3) var(--_base-h)); --color-base-4: oklch(46% var(--_base-c-4) var(--_base-h)); - --color-base-5: oklch(32% var(--_base-c-5) var(--_base-h)); - --color-base-6: oklch(25% var(--_base-c-6) var(--_base-h)); - --color-base-7: oklch(17% var(--_base-c-7) var(--_base-h)); + --color-base-5: oklch( + calc(var(--_base-l) + 0.15) var(--_base-c-5) var(--_base-h) + ); + --color-base-6: oklch( + calc(var(--_base-l) + 0.08) var(--_base-c-6) var(--_base-h) + ); + --color-base-7: oklch(var(--_base-l) var(--_base-c-7) var(--_base-h)); - --color-accent-low: oklch(26% var(--_acc-c-0) var(--_acc-h)); - --color-accent: oklch(52% var(--_acc-c-1) var(--_acc-h)); - --color-accent-high: oklch(83% var(--_acc-c-2) var(--_acc-h)); + --color-accent-low: oklch(var(--_acc-l-0) var(--_acc-c-0) var(--_acc-h-0)); + --color-accent: oklch(var(--_acc-l-1) var(--_acc-c-1) var(--_acc-h-1)); + --color-accent-high: oklch(var(--_acc-l-2) var(--_acc-c-2) var(--_acc-h-2)); --color-second-low: oklch( - from var(--color-accent-low) l var(--_second-c-0) var(--_second-h) + var(--_second-l-0) var(--_second-c-0) var(--_second-h-0) ); --color-second: oklch( - from var(--color-accent) l var(--_second-c-1) var(--_second-h) + var(--_second-l-1) var(--_second-c-1) var(--_second-h-1) ); --color-second-high: oklch( - from var(--color-accent-high) l var(--_second-c-2) var(--_second-h) + var(--_second-l-2) var(--_second-c-2) var(--_second-h-2) ); - --color-text-accent: var(--color-accent-high); - --color-text-second: var(--color-second-high); + --color-fill-accent: var(--color-accent-high); + --color-fill-second: var(--color-second-high); + --color-fg-accent: var(--color-accent-high); + --color-fg-second: var(--color-second-high); + --color-fg-on-accent: var(--color-base-7); + --color-fg-on-second: var(--color-base-7); --color-info-low: oklch(26% 0.09 275); --color-info: oklch(52% 0.27 275); @@ -112,8 +163,6 @@ html.dark [data-custom-theme], --color-chart-alpha: #da5b76; --color-chart-bravo: #6572e4; - --field-icon-checkbox: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(20, 20, 20)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); - --field-icon-minus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(20, 20, 20)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E"); /* inlined instead of var() because data URIs can't read custom properties: keep in sync with --color-text-high */ --field-icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(122, 139, 183)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); --field-icon-date: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(122, 139, 183)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E"); @@ -132,22 +181,34 @@ html.light [data-custom-theme], --color-base-6: oklch(95% var(--_base-c-1) var(--_base-h)); --color-base-7: oklch(100% var(--_base-c-0) var(--_base-h)); - --color-accent-low: oklch(88% var(--_acc-c-3) var(--_acc-h)); - --color-accent: oklch(53% var(--_acc-c-4) var(--_acc-h)); - --color-accent-high: oklch(32% var(--_acc-c-5) var(--_acc-h)); + --color-accent-low: oklch(var(--_acc-l-3) var(--_acc-c-3) var(--_acc-h-3)); + --color-accent: oklch(var(--_acc-l-4) var(--_acc-c-4) var(--_acc-h-4)); + --color-accent-high: oklch(var(--_acc-l-5) var(--_acc-c-5) var(--_acc-h-5)); --color-second-low: oklch( - from var(--color-accent-low) l var(--_second-c-0) var(--_second-h) + var(--_second-l-3) var(--_second-c-3) var(--_second-h-3) ); --color-second: oklch( - from var(--color-accent) l var(--_second-c-1) var(--_second-h) + var(--_second-l-4) var(--_second-c-4) var(--_second-h-4) ); --color-second-high: oklch( - from var(--color-accent-high) l var(--_second-c-2) var(--_second-h) + var(--_second-l-5) var(--_second-c-5) var(--_second-h-5) ); - --color-text-accent: var(--color-accent); - --color-text-second: var(--color-second); + --color-fill-accent: oklch(var(--_acc-l-6) var(--_acc-c-6) var(--_acc-h-6)); + --color-fill-second: oklch( + var(--_second-l-6) var(--_second-c-6) var(--_second-h-6) + ); + --color-fg-accent: var(--color-accent); + --color-fg-second: var(--color-second); + --color-fg-on-accent: oklch( + calc(1 - 0.83 * var(--_acc-fill-dark-text)) + calc(var(--_base-c-7) * var(--_acc-fill-dark-text)) var(--_base-h) + ); + --color-fg-on-second: oklch( + calc(1 - 0.83 * var(--_second-fill-dark-text)) + calc(var(--_base-c-7) * var(--_second-fill-dark-text)) var(--_base-h) + ); --color-info-low: oklch(87.897% 0.0555 279.573); --color-info: oklch(53.03% 0.24331 270.147); @@ -168,8 +229,6 @@ html.light [data-custom-theme], --color-chart-alpha: #b82851; --color-chart-bravo: #4246c2; - --field-icon-checkbox: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); - --field-icon-minus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E"); /* inlined instead of var() because data URIs can't read custom properties: keep in sync with --color-text-high */ --field-icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(72, 87, 124)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); --field-icon-date: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(72, 87, 124)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E"); @@ -196,15 +255,21 @@ html, ); --color-bg-ability: oklch(11.736% 0.00867 215.976); --color-bg-badge: oklch(0% 0 0); + --color-bg-accent: var(--color-accent-low); + --color-bg-second: var(--color-second-low); + + /* masks, so the color is set by the checkbox */ + --field-icon-checkbox: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); + --field-icon-minus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E"); --color-border: var(--color-base-5); --color-border-high: var(--color-base-4); --border-width: calc(var(--_border-width) * 1px); --border-style: var(--border-width) solid var(--color-border); --border-style-high: var(--border-width) solid var(--color-border-high); - --border-style-accent: var(--border-width) solid var(--color-text-accent); + --border-style-accent: var(--border-width) solid var(--color-fg-accent); - --focus-ring: 2px solid var(--color-text-accent); + --focus-ring: 2px solid var(--color-fg-accent); --focus-ring-error: 2px solid var(--color-error); --font-xl: 1.5rem; diff --git a/app/utils/oklch-gamut.ts b/app/utils/oklch-gamut.ts index 29683635b..95a71f35f 100644 --- a/app/utils/oklch-gamut.ts +++ b/app/utils/oklch-gamut.ts @@ -1,8 +1,10 @@ -import type * as v from "valibot"; -import type { CustomTheme } from "~/db/tables-json"; -import type { themeInputSchema } from "~/utils/schema"; - -export type ThemeInput = v.InferOutput; +export interface Oklch { + /** Lightness, 0-1 */ + l: number; + c: number; + /** Hue in degrees */ + h: number; +} interface Lab { L: number; @@ -224,117 +226,67 @@ function maximum_chroma_for_lh(L: number, h: number): number { return t; } -// These are the lightness values used in vars.css -// Any changes here NEED to be reflected in vars.css as well. +const GAMUT_TIGHTEN_STEP = 0.00001; -const BASE_LIGHTNESS_VALUES = [ - 1.0, // --base-c-0 - 0.95, // --base-c-1 - 0.9, // --base-c-2 - 0.64, // --base-c-3 - 0.46, // --base-c-4 - 0.32, // --base-c-5 - 0.25, // --base-c-6 - 0.17, // --base-c-7 -] as const; +/** Highest chroma that stays inside the sRGB gamut at the given lightness (0-1) and hue (degrees). */ +export function maxChroma(lightness: number, hueDegrees: number): number { + let chroma = Math.max( + 0, + maximum_chroma_for_lh(lightness, toRadians(hueDegrees)), + ); -const ACCENT_LIGHTNESS_VALUES = [ - 0.26, // --acc-c-0: dark mode low - 0.52, // --acc-c-1: dark mode mid - 0.83, // --acc-c-2: dark mode high - 0.88, // --acc-c-3: light mode low - 0.53, // --acc-c-4: light mode mid - 0.32, // --acc-c-5: light mode high -] as const; + // the estimate can land a hair outside the gamut + while ( + chroma > 0 && + !isInSrgbGamut({ l: lightness, c: chroma, h: hueDegrees }) + ) { + chroma = Math.max(0, chroma - GAMUT_TIGHTEN_STEP); + } -export const BASE_CHROMA_MULTIPLIERS = [ - 0.01, // --base-c-0 - 0.49, // --base-c-1 - 0.62, // --base-c-2 - 1.4, // --base-c-3 - 1.29, // --base-c-4 - 1.36, // --base-c-5 - 1.29, // --base-c-6 - 0.67, // --base-c-7 -] as const; - -export const ACCENT_CHROMA_MULTIPLIERS = [ - 0.38, // --acc-c-0 - 1.11, // --acc-c-1 - 0.34, // --acc-c-2 - 0.25, // --acc-c-3 - 1.09, // --acc-c-4 - 0.56, // --acc-c-5 -] as const; - -function clampChromaForColor( - lightness: number, - desiredChroma: number, - hueRadians: number, -): number { - const maxChroma = maximum_chroma_for_lh(lightness, hueRadians); - return Math.min(desiredChroma, maxChroma); + return chroma; } -export function clampThemeToGamut(input: ThemeInput): CustomTheme { - const baseHueRadians = input.baseHue * (Math.PI / 180); - const accentHueRadians = input.accentHue * (Math.PI / 180); +/** Whether the color can be displayed in sRGB without clipping. */ +export function isInSrgbGamut(color: Oklch): boolean { + const { r, g, b } = oklchToLinearSrgb(color); - const clampedBaseChromas = BASE_LIGHTNESS_VALUES.map((lightness, index) => { - const desiredChroma = input.baseChroma * BASE_CHROMA_MULTIPLIERS[index]; - return clampChromaForColor(lightness, desiredChroma, baseHueRadians); + return [r, g, b].every((channel) => channel >= 0 && channel <= 1); +} + +/** Lightness (0-1) at which the hue reaches its most saturated in-gamut color. */ +export function cuspLightness(hueDegrees: number): number { + const radians = toRadians(hueDegrees); + return find_cusp(Math.cos(radians), Math.sin(radians)).L; +} + +/** WCAG 2 contrast ratio (1-21) between two colors. */ +export function contrastRatio(first: Oklch, second: Oklch): number { + const [lighter, darker] = [ + relativeLuminance(first), + relativeLuminance(second), + ].sort((a, b) => b - a); + + return (lighter + 0.05) / (darker + 0.05); +} + +function relativeLuminance(color: Oklch): number { + const { r, g, b } = oklchToLinearSrgb(color); + + return ( + 0.2126 * clamp(r, 0, 1) + 0.7152 * clamp(g, 0, 1) + 0.0722 * clamp(b, 0, 1) + ); +} + +function oklchToLinearSrgb(color: Oklch) { + const radians = toRadians(color.h); + + return oklab_to_linear_srgb({ + L: color.l, + a: color.c * Math.cos(radians), + b: color.c * Math.sin(radians), }); - - const clampedAccentChromas = ACCENT_LIGHTNESS_VALUES.map( - (lightness, index) => { - const desiredChroma = - input.accentChroma * ACCENT_CHROMA_MULTIPLIERS[index]; - return clampChromaForColor(lightness, desiredChroma, accentHueRadians); - }, - ); - - const secondaryHue = (input.accentHue + 180) % 360; - const secondaryHueRadians = secondaryHue * (Math.PI / 180); - - const clampedSecondaryChromas = ACCENT_LIGHTNESS_VALUES.map( - (lightness, index) => { - const desiredChroma = - input.accentChroma * ACCENT_CHROMA_MULTIPLIERS[index]; - return clampChromaForColor(lightness, desiredChroma, secondaryHueRadians); - }, - ); - - return { - "--_base-h": input.baseHue, - "--_base-c-0": clampedBaseChromas[0], - "--_base-c-1": clampedBaseChromas[1], - "--_base-c-2": clampedBaseChromas[2], - "--_base-c-3": clampedBaseChromas[3], - "--_base-c-4": clampedBaseChromas[4], - "--_base-c-5": clampedBaseChromas[5], - "--_base-c-6": clampedBaseChromas[6], - "--_base-c-7": clampedBaseChromas[7], - "--_acc-h": input.accentHue, - "--_acc-c-0": clampedAccentChromas[0], - "--_acc-c-1": clampedAccentChromas[1], - "--_acc-c-2": clampedAccentChromas[2], - "--_acc-c-3": clampedAccentChromas[3], - "--_acc-c-4": clampedAccentChromas[4], - "--_acc-c-5": clampedAccentChromas[5], - "--_second-h": secondaryHue, - "--_second-c-0": clampedSecondaryChromas[0], - "--_second-c-1": clampedSecondaryChromas[1], - "--_second-c-2": clampedSecondaryChromas[2], - "--_second-c-3": clampedSecondaryChromas[3], - "--_second-c-4": clampedSecondaryChromas[4], - "--_second-c-5": clampedSecondaryChromas[5], - "--_chat-h": input.chatHue, - "--_radius-box": input.radiusBox, - "--_radius-field": input.radiusField, - "--_radius-selector": input.radiusSelector, - "--_border-width": input.borderWidth, - "--_size-field": input.sizeField, - "--_size-selector": input.sizeSelector, - "--_size-spacing": input.sizeSpacing, - }; +} + +function toRadians(degrees: number) { + return degrees * (Math.PI / 180); } diff --git a/app/utils/schema.ts b/app/utils/schema.ts index 1d63266ba..69fb50330 100644 --- a/app/utils/schema.ts +++ b/app/utils/schema.ts @@ -127,7 +127,11 @@ export const THEME_INPUT_LIMITS = { ACCENT_HUE_MIN: 0, ACCENT_HUE_MAX: 360, ACCENT_CHROMA_MIN: 0, - ACCENT_CHROMA_MAX: 0.3, + ACCENT_CHROMA_MAX: 0.5, + BG_LIGHTNESS_MIN: 0.06, + BG_LIGHTNESS_MAX: 0.17, + BG_LIGHTNESS_STEP: 0.01, + BG_LIGHTNESS_DEFAULT: 0.17, RADIUS_MIN: 0, RADIUS_MAX: 5, RADIUS_STEP: 1, @@ -166,6 +170,23 @@ export const themeInputSchema = v.object({ v.minValue(THEME_INPUT_LIMITS.ACCENT_CHROMA_MIN), v.maxValue(THEME_INPUT_LIMITS.ACCENT_CHROMA_MAX), ), + bgLightness: v.optional( + v.pipe( + v.number(), + v.minValue(THEME_INPUT_LIMITS.BG_LIGHTNESS_MIN), + v.maxValue(THEME_INPUT_LIMITS.BG_LIGHTNESS_MAX), + v.check( + (val) => + isValidStep( + val, + THEME_INPUT_LIMITS.BG_LIGHTNESS_MIN, + THEME_INPUT_LIMITS.BG_LIGHTNESS_STEP, + ), + "Must be a valid step increment", + ), + ), + THEME_INPUT_LIMITS.BG_LIGHTNESS_DEFAULT, + ), chatHue: v.nullable( v.pipe( v.number(), diff --git a/changelog/2026-09-22-custom-theme-colors.md b/changelog/2026-09-22-custom-theme-colors.md new file mode 100644 index 000000000..bd0f4aa25 --- /dev/null +++ b/changelog/2026-09-22-custom-theme-colors.md @@ -0,0 +1,10 @@ +--- +type: feature +--- +Custom theme improvements + +- Yellow and other light accent colors are now vivid instead of muddy +- In light mode buttons, toggles, checkboxes and badges with a light accent or secondary color are bright with dark text +- New slider to make the dark mode background darker +- Accent chroma slider goes higher so every hue can be as vivid as the screen allows +- Accent colored text on tinted backgrounds (chips, tags) is easier to read in light mode diff --git a/docs/styles.md b/docs/styles.md index 9d98a5217..2a9e40b82 100644 --- a/docs/styles.md +++ b/docs/styles.md @@ -1,54 +1,64 @@ # 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 as a small set of inputs which are expanded into a full set of CSS properties via gamut clamping. +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/utils/oklch-gamut.ts` | Gamut clamping math, lightness values, chroma multipliers | +| `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/db/tables.ts` | `CustomTheme` type and `CUSTOM_THEME_VARS` list | -| `app/components/CustomThemeSelector.tsx` | UI component, `DEFAULT_THEME_INPUT` | +| `app/features/theme/theme-constants.ts` | `CUSTOM_THEME_VARS` list | +| `app/components/CustomThemeSelector.tsx` | UI component | | `app/root.tsx` | `useCustomThemeVars()` applies theme to `` 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 -The default theme is defined in **three places that must stay in sync**: - -### 1. `vars.css` — CSS defaults - -The `html { }` block at the beginning of `vars.css` contains the default values that apply when no custom theme is active. These are the output of `clampThemeToGamut(DEFAULT_THEME_INPUT)`. - -### 2. `oklch-gamut.ts` — Lightness and multiplier constants - -`BASE_LIGHTNESS_VALUES`, `ACCENT_LIGHTNESS_VALUES`, `BASE_CHROMA_MULTIPLIERS`, and `ACCENT_CHROMA_MULTIPLIERS` define how the input chroma/hue maps to each color step. The lightness values must match the percentages used in the `oklch()` calls in `vars.css`. - -### 3. `CustomThemeSelector.tsx` — `DEFAULT_THEME_INPUT` - -The `DEFAULT_THEME_INPUT` object defines the default slider positions (hue, chroma, radius, etc.). Running `clampThemeToGamut(DEFAULT_THEME_INPUT)` should produce values matching the CSS defaults in `vars.css`. +`ThemePalette.build(DEFAULT_THEME_INPUT)` must produce the values in the first block of `vars.css`. A unit test verifies this. ### Update procedure -When changing the default theme: - -1. Edit `DEFAULT_THEME_INPUT` in `CustomThemeSelector.tsx` with the new input values -2. Run `clampThemeToGamut(DEFAULT_THEME_INPUT)` to get the output CSS variable values -3. Update `vars.css` with the output values in the `html { }` block -4. Verify lightness values in `vars.css` `oklch()` calls still match `BASE_LIGHTNESS_VALUES` and `ACCENT_LIGHTNESS_VALUES` in `oklch-gamut.ts` - -When changing lightness values or chroma multipliers: - -1. Edit the constants in `oklch-gamut.ts` -2. Update the matching `oklch()` percentages in `vars.css` (e.g. if you change `BASE_LIGHTNESS_VALUES[1]` from `0.94873` you must also update `oklch(94.873% ...)` in `vars.css`) -3. Recompute and update the default chroma values in `vars.css` by running `clampThemeToGamut(DEFAULT_THEME_INPUT)` with the new multipliers +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 -### Gamut clamping makes reverse engineering unreliable +### Adding theme variables -You cannot always recover the original `baseChroma` from a stored `CustomTheme`. At high lightness values, sRGB cannot display much chroma, so the clamping function reduces it. Dividing a clamped output by its multiplier gives a number **smaller** than the true input. Indices 2–7 are more reliable but recovery success depends on the specific hue. ``themeInputFromCustomTheme()`` uses the second indice for input recovery. +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 diff --git a/locales/da/common.json b/locales/da/common.json index 25b40ab51..7acaf0d5c 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/de/common.json b/locales/de/common.json index 560fb7633..8cdcc4ec2 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/en/common.json b/locales/en/common.json index 3725ae932..2126831b1 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "Base chroma", "settings.customTheme.accentHue": "Accent hue", "settings.customTheme.accentChroma": "Accent chroma", + "settings.customTheme.bgLightness": "Dark mode background brightness", "settings.customTheme.chatHueToggle": "Use custom chat name color", "settings.customTheme.chatHue": "Chat name color", "settings.customTheme.boxes": "Boxes", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 310a93927..15a995655 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "Croma base", "settings.customTheme.accentHue": "Tono de contraste", "settings.customTheme.accentChroma": "Croma de contraste", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "Usar color de nombre personalizado en el chat", "settings.customTheme.chatHue": "Color del nombre en el chat", "settings.customTheme.boxes": "Cajas", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index b3321022a..fb12eaf6b 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "Croma base", "settings.customTheme.accentHue": "Tono de contraste", "settings.customTheme.accentChroma": "Croma de contraste", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "Usar color de nombre personalizado en el chat", "settings.customTheme.chatHue": "Color del nombre en el chat", "settings.customTheme.boxes": "Cajas", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 1d760c4a3..68aef4638 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index 1f8d8b680..7cf0248a8 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/he/common.json b/locales/he/common.json index 49edd753b..6a4ecb14b 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/it/common.json b/locales/it/common.json index 54e2e3ce8..bceff16e7 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/ja/common.json b/locales/ja/common.json index 46a3474f7..9ba0ba8ad 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "彩度一色目", "settings.customTheme.accentHue": "色合い二色目", "settings.customTheme.accentChroma": "彩度二色目", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "カスタムカラーをチャットの名前に使用する", "settings.customTheme.chatHue": "チャットの名前の色", "settings.customTheme.boxes": "", diff --git a/locales/ko/common.json b/locales/ko/common.json index a2e2289e2..23add5c0a 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/nl/common.json b/locales/nl/common.json index 714f11d27..a892c1c0b 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/pl/common.json b/locales/pl/common.json index d69c02eb1..c8d3d7139 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index 244903da1..bcf1b821b 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/ru/common.json b/locales/ru/common.json index cb8d048bd..c1fe2df7f 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "", "settings.customTheme.accentHue": "", "settings.customTheme.accentChroma": "", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "", "settings.customTheme.chatHue": "", "settings.customTheme.boxes": "", diff --git a/locales/zh/common.json b/locales/zh/common.json index 3f056ea26..5b5b6518e 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -411,6 +411,7 @@ "settings.customTheme.baseChroma": "基础色彩度", "settings.customTheme.accentHue": "强调色色调", "settings.customTheme.accentChroma": "强调色彩度", + "settings.customTheme.bgLightness": "", "settings.customTheme.chatHueToggle": "使用自定义聊天名称颜色", "settings.customTheme.chatHue": "聊天名称颜色", "settings.customTheme.boxes": "卡片", diff --git a/migrations/20260922181213-custom-theme-palette-vars.ts b/migrations/20260922181213-custom-theme-palette-vars.ts new file mode 100644 index 000000000..6381afd2b --- /dev/null +++ b/migrations/20260922181213-custom-theme-palette-vars.ts @@ -0,0 +1,61 @@ +import { type Kysely, sql } from "kysely"; + +/** + * Custom themes now store lightness and hue per color slot, the dark mode + * background lightness and the raw accent chroma. Existing themes get the + * values they were rendered with until now (fixed lightness, unshifted hue) + * so they look exactly the same until re-saved. Light mode secondary colors + * used the dark mode chroma slots, now they have their own. The secondary + * hue is always the accent's opposite so it is no longer stored. + */ +export async function up(db: Kysely): Promise { + await db.transaction().execute(async (trx) => { + for (const table of ["User", "AllTeam"]) { + await sql` + update ${sql.table(table)} + set "customTheme" = json_remove(json_set( + "customTheme", + '$."--_base-l"', 0.17, + '$."--_acc-c"', json_extract("customTheme", '$."--_acc-c-2"') / 0.34, + '$."--_acc-l-0"', 0.26, + '$."--_acc-l-1"', 0.52, + '$."--_acc-l-2"', 0.83, + '$."--_acc-l-3"', 0.88, + '$."--_acc-l-4"', 0.53, + '$."--_acc-l-5"', 0.32, + '$."--_acc-l-6"', 0.53, + '$."--_acc-c-6"', json_extract("customTheme", '$."--_acc-c-4"'), + '$."--_acc-h-0"', json_extract("customTheme", '$."--_acc-h"'), + '$."--_acc-h-1"', json_extract("customTheme", '$."--_acc-h"'), + '$."--_acc-h-2"', json_extract("customTheme", '$."--_acc-h"'), + '$."--_acc-h-3"', json_extract("customTheme", '$."--_acc-h"'), + '$."--_acc-h-4"', json_extract("customTheme", '$."--_acc-h"'), + '$."--_acc-h-5"', json_extract("customTheme", '$."--_acc-h"'), + '$."--_acc-h-6"', json_extract("customTheme", '$."--_acc-h"'), + '$."--_acc-fill-dark-text"', 0, + '$."--_second-l-0"', 0.26, + '$."--_second-l-1"', 0.52, + '$."--_second-l-2"', 0.83, + '$."--_second-l-3"', 0.88, + '$."--_second-l-4"', 0.53, + '$."--_second-l-5"', 0.32, + '$."--_second-h-0"', json_extract("customTheme", '$."--_second-h"'), + '$."--_second-h-1"', json_extract("customTheme", '$."--_second-h"'), + '$."--_second-h-2"', json_extract("customTheme", '$."--_second-h"'), + '$."--_second-h-3"', json_extract("customTheme", '$."--_second-h"'), + '$."--_second-h-4"', json_extract("customTheme", '$."--_second-h"'), + '$."--_second-h-5"', json_extract("customTheme", '$."--_second-h"'), + '$."--_second-c-3"', json_extract("customTheme", '$."--_second-c-0"'), + '$."--_second-c-4"', json_extract("customTheme", '$."--_second-c-1"'), + '$."--_second-c-5"', json_extract("customTheme", '$."--_second-c-2"'), + '$."--_second-l-6"', 0.53, + '$."--_second-c-6"', json_extract("customTheme", '$."--_second-c-1"'), + '$."--_second-h-6"', json_extract("customTheme", '$."--_second-h"'), + '$."--_second-fill-dark-text"', 0 + ), '$."--_second-h"') + where "customTheme" is not null + and json_extract("customTheme", '$."--_base-l"') is null + `.execute(trx); + } + }); +}