mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 15:17:43 -05:00
Custom theme improvements (#3410)
Co-authored-by: hfcRed <hfcred@gmx.net>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, number | null> = {};
|
||||
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<ThemeInput>(initialThemeInput);
|
||||
React.useState<ThemePalette.ThemeInput>(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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
gap: var(--s-1-5);
|
||||
|
||||
&.active {
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
}
|
||||
|
||||
.itemActive {
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
}
|
||||
|
||||
.itemDestructive {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
}
|
||||
|
||||
.chevron,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
font-size: var(--font-md);
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
}
|
||||
|
||||
.noteFlag {
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
}
|
||||
|
||||
.noteFlag {
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
}
|
||||
|
||||
.noteFlag {
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
}
|
||||
|
||||
.noteFlag {
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
}
|
||||
|
||||
.dayDot {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
}
|
||||
|
||||
.inkGridApFocused {
|
||||
color: var(--color-accent);
|
||||
color: var(--color-fg-accent);
|
||||
font-weight: var(--weight-bold);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -15,6 +15,6 @@
|
||||
}
|
||||
|
||||
.bar {
|
||||
background-color: var(--color-text-accent);
|
||||
background-color: var(--color-fg-accent);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
transition: border-color 0.1s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-text-accent);
|
||||
border-color: var(--color-fg-accent);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
1052
app/features/components-showcase/routes/components.colors.tsx
Normal file
1052
app/features/components-showcase/routes/components.colors.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
<Main className="stack lg">
|
||||
<h1>Components</h1>
|
||||
<Link to="/components/colors">Color tokens →</Link>
|
||||
{SECTIONS.map(({ id, component: Component }) => (
|
||||
<Component key={id} id={id} />
|
||||
))}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -50,5 +50,5 @@
|
||||
|
||||
button.outlined {
|
||||
background: transparent;
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,6 @@
|
||||
font-weight: var(--weight-bold);
|
||||
|
||||
& svg {
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
355
app/features/theme/core/ThemePalette.test.ts
Normal file
355
app/features/theme/core/ThemePalette.test.ts
Normal file
@@ -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.ThemeInput> = {}) => ({
|
||||
...ThemePalette.DEFAULT_THEME_INPUT,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const built = (overrides: Partial<ThemePalette.ThemeInput> = {}) =>
|
||||
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<VarsBlock, Map<string, string>>();
|
||||
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<string, string>();
|
||||
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 };
|
||||
}
|
||||
631
app/features/theme/core/ThemePalette.ts
Normal file
631
app/features/theme/core/ThemePalette.ts
Normal file
@@ -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<typeof themeInputSchema>;
|
||||
|
||||
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<keyof ThemeInput> = [
|
||||
"baseHue",
|
||||
"baseChroma",
|
||||
"accentHue",
|
||||
"accentChroma",
|
||||
"radiusBox",
|
||||
"radiusField",
|
||||
"radiusSelector",
|
||||
"borderWidth",
|
||||
"sizeField",
|
||||
"sizeSelector",
|
||||
"sizeSpacing",
|
||||
"chatHue",
|
||||
];
|
||||
const SHARE_CODE_KEYS: ReadonlyArray<keyof ThemeInput> = [
|
||||
...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<Record<number, number>> = {
|
||||
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<AccentSlot>;
|
||||
|
||||
/** 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<string, number | null> = {};
|
||||
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<Prefix extends string> = Record<
|
||||
`--_${Prefix}-${"l" | "c" | "h"}-${0 | 1 | 2 | 3 | 4 | 5}`,
|
||||
number
|
||||
>;
|
||||
|
||||
function slotVars<Prefix extends "acc" | "second">(
|
||||
prefix: Prefix,
|
||||
slots: Oklch[],
|
||||
): SlotVars<Prefix> {
|
||||
const result: Record<string, number> = {};
|
||||
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<Prefix>;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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<CustomThemeVar>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1294,7 +1294,7 @@ function ModeListRow({
|
||||
<div className={styles.mapSelectItem}>
|
||||
<ModeImage mode={item.id} size={20} title={item.name} />
|
||||
<div className={styles.stagePlaceholder}>?</div>
|
||||
<span className={clsx({ "text-accent-high": isCounterpicks })}>
|
||||
<span className={clsx({ "text-accent": isCounterpicks })}>
|
||||
{isCounterpicks
|
||||
? t("tournament:pickInfo.counterpick")
|
||||
: t("tournament:mapList.teamsPick")}
|
||||
@@ -1322,7 +1322,7 @@ function MysteryRow({
|
||||
<li className={styles.mapListRow}>
|
||||
<div
|
||||
className={clsx("stack horizontal items-center xs text-lighter", {
|
||||
"text-accent-high": isCounterpicks,
|
||||
"text-accent": isCounterpicks,
|
||||
})}
|
||||
>
|
||||
<span className="text-lg">{number}.</span>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
.star {
|
||||
min-width: 18px;
|
||||
max-width: 18px;
|
||||
color: var(--color-text-second);
|
||||
color: var(--color-fg-second);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
}
|
||||
|
||||
.calendarDayToday {
|
||||
color: var(--color-text-accent);
|
||||
color: var(--color-fg-accent);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -25,5 +25,5 @@
|
||||
}
|
||||
|
||||
.participating {
|
||||
background-color: var(--color-text-accent);
|
||||
background-color: var(--color-fg-accent);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user