Gamut mapping and custom theme selector component

This commit is contained in:
hfcRed 2026-01-13 19:30:07 +01:00
parent 0ae89499ef
commit bebb7ccee0
14 changed files with 752 additions and 384 deletions

View File

@ -0,0 +1,82 @@
.customThemeSelector {
display: flex;
flex-direction: column;
gap: var(--s-6);
padding: var(--s-4);
border: 2px solid var(--color-border);
border-radius: var(--rounded-sm);
background-color: var(--color-bg-medium);
position: relative;
overflow: hidden;
}
.customThemeSelectorSupporter {
display: none;
}
.customThemeSelectorNoSupporter {
position: absolute;
inset: 0;
z-index: 10;
backdrop-filter: blur(3px);
display: flex;
flex-direction: column;
justify-content: center;
}
.customThemeSelectorInfo {
display: flex;
flex-direction: column;
gap: var(--s-2);
margin-bottom: var(--s-4);
text-align: center;
background-color: var(--color-bg);
padding: var(--s-2) var(--s-4);
border-block: 2px solid var(--color-border);
}
.customThemeSelectorActions {
display: grid;
gap: var(--s-2);
grid-template-columns: 1fr auto;
}
.colorSliders {
display: flex;
flex-direction: column;
gap: var(--s-4);
}
.colorSlider {
display: grid;
grid-template-columns: 40% auto;
grid-template-rows: auto auto;
gap: var(--s-1) var(--s-2);
align-items: center;
}
.hueSlider::-webkit-slider-runnable-track {
background: linear-gradient(
to right,
oklch(65% 0.15 0),
oklch(65% 0.15 60),
oklch(65% 0.15 120),
oklch(65% 0.15 180),
oklch(65% 0.15 240),
oklch(65% 0.15 300),
oklch(65% 0.15 360)
) !important;
}
.hueSlider::-moz-range-track {
background: linear-gradient(
to right,
oklch(65% 0.15 0),
oklch(65% 0.15 60),
oklch(65% 0.15 120),
oklch(65% 0.15 180),
oklch(65% 0.15 240),
oklch(65% 0.15 300),
oklch(65% 0.15 360)
) !important;
}

View File

@ -0,0 +1,220 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import {
CUSTOM_THEME_VARS,
type CustomTheme,
type CustomThemeVar,
} from "~/db/tables";
import {
ACCENT_CHROMA_MULTIPLIERS,
BASE_CHROMA_MULTIPLIERS,
clampThemeToGamut,
type ThemeInput,
} from "~/utils/oklch-gamut";
import { THEME_INPUT_LIMITS } from "~/utils/zod";
import styles from "./CustomThemeSelector.module.css";
import { LinkButton, SendouButton } from "./elements/Button";
import { Label } from "./Label";
const COLOR_SLIDERS = [
{
id: "base-hue",
inputKey: "baseHue",
min: THEME_INPUT_LIMITS.BASE_HUE_MIN,
max: THEME_INPUT_LIMITS.BASE_HUE_MAX,
step: 1,
labelKey: "baseHue",
isHue: true,
},
{
id: "base-chroma",
inputKey: "baseChroma",
min: THEME_INPUT_LIMITS.BASE_CHROMA_MIN,
max: THEME_INPUT_LIMITS.BASE_CHROMA_MAX,
step: 0.001,
labelKey: "baseChroma",
isHue: false,
},
{
id: "accent-hue",
inputKey: "accentHue",
min: THEME_INPUT_LIMITS.ACCENT_HUE_MIN,
max: THEME_INPUT_LIMITS.ACCENT_HUE_MAX,
step: 1,
labelKey: "accentHue",
isHue: true,
},
{
id: "accent-chroma",
inputKey: "accentChroma",
min: THEME_INPUT_LIMITS.ACCENT_CHROMA_MIN,
max: THEME_INPUT_LIMITS.ACCENT_CHROMA_MAX,
step: 0.01,
labelKey: "accentChroma",
isHue: false,
},
] as const;
type ThemeInputKey = (typeof COLOR_SLIDERS)[number]["inputKey"];
export const DEFAULT_THEME_INPUT: ThemeInput = {
baseHue: 260,
baseChroma: 0.012,
accentHue: 270,
accentChroma: 0.24,
};
export function themeInputFromCustomTheme(
customTheme: CustomTheme,
): ThemeInput {
return {
baseHue: customTheme["--base-h"] ?? DEFAULT_THEME_INPUT.baseHue,
baseChroma:
(customTheme["--base-c-1"] ?? 0) / BASE_CHROMA_MULTIPLIERS[1] ||
DEFAULT_THEME_INPUT.baseChroma,
accentHue: customTheme["--acc-h"] ?? DEFAULT_THEME_INPUT.accentHue,
accentChroma:
(customTheme["--acc-c-1"] ?? 0) / ACCENT_CHROMA_MULTIPLIERS[1] ||
DEFAULT_THEME_INPUT.accentChroma,
};
}
function applyThemeInput(input: ThemeInput) {
const clampedTheme = clampThemeToGamut(input);
for (const [key, value] of Object.entries(clampedTheme)) {
document.documentElement.style.setProperty(key, String(value));
}
}
function ColorSlider({
id,
inputKey,
min,
max,
step,
label,
isHue,
value,
onChange,
}: {
id: string;
inputKey: ThemeInputKey;
min: number;
max: number;
step: number;
label: string;
isHue: boolean;
value: number;
onChange: (inputKey: ThemeInputKey, value: number) => void;
}) {
return (
<div className={styles.colorSlider}>
<Label htmlFor={id}>{label}</Label>
<input
id={id}
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(inputKey, Number(e.target.value))}
className={isHue ? styles.hueSlider : undefined}
/>
</div>
);
}
export function CustomThemeSelector({
initialTheme,
isSupporter,
onSave,
onReset,
hidePatreonInfo,
}: {
initialTheme: CustomTheme | null | undefined;
isSupporter: boolean;
onSave: (themeInput: ThemeInput) => void;
onReset: () => void;
hidePatreonInfo?: boolean;
}) {
const { t } = useTranslation(["common"]);
const initialThemeInput = initialTheme
? themeInputFromCustomTheme(initialTheme)
: DEFAULT_THEME_INPUT;
const [themeInput, setThemeInput] =
React.useState<ThemeInput>(initialThemeInput);
const handleSliderChange = (inputKey: ThemeInputKey, value: number) => {
const updatedInput = { ...themeInput, [inputKey]: value };
setThemeInput(updatedInput);
applyThemeInput(updatedInput);
};
const handleSave = () => {
onSave(themeInput);
};
const handleReset = () => {
setThemeInput(DEFAULT_THEME_INPUT);
CUSTOM_THEME_VARS.forEach((varDef: CustomThemeVar) => {
document.documentElement.style.removeProperty(varDef);
});
onReset();
};
return (
<div className={styles.customThemeSelector}>
{hidePatreonInfo ? null : (
<div
className={
isSupporter
? styles.customThemeSelectorSupporter
: styles.customThemeSelectorNoSupporter
}
>
<div className={styles.customThemeSelectorInfo}>
<p>{t("common:settings.customTheme.patreonText")}</p>
<LinkButton
to="https://www.patreon.com/sendou"
isExternal
size="small"
>
{t("common:settings.customTheme.joinPatreon")}
</LinkButton>
</div>
</div>
)}
<div className={styles.colorSliders}>
{COLOR_SLIDERS.map((slider) => (
<ColorSlider
key={slider.id}
id={slider.id}
inputKey={slider.inputKey}
min={slider.min}
max={slider.max}
step={slider.step}
label={t(`common:settings.customTheme.${slider.labelKey}`)}
isHue={slider.isHue}
value={themeInput[slider.inputKey]}
onChange={handleSliderChange}
/>
))}
</div>
<div className={styles.customThemeSelectorActions}>
<SendouButton isDisabled={!isSupporter} onPress={handleSave}>
{t("common:actions.save")}
</SendouButton>
<SendouButton
isDisabled={!isSupporter}
variant="destructive"
onPress={handleReset}
>
{t("common:actions.reset")}
</SendouButton>
</div>
</div>
);
}

View File

@ -35,10 +35,22 @@ export type MemberRole = (typeof TEAM_MEMBER_ROLES)[number];
export type DBBoolean = number;
export const CUSTOM_THEME_VARS = [
"--base-c",
"--base-h",
"--acc-c",
"--base-c-0",
"--base-c-1",
"--base-c-2",
"--base-c-3",
"--base-c-4",
"--base-c-5",
"--base-c-6",
"--base-c-7",
"--acc-h",
"--acc-c-0",
"--acc-c-1",
"--acc-c-2",
"--acc-c-3",
"--acc-c-4",
"--acc-c-5",
] as const;
export type CustomThemeVar = (typeof CUSTOM_THEME_VARS)[number];
export type CustomTheme = Record<CustomThemeVar, number>;

View File

@ -3,6 +3,7 @@ import { requireUser } from "~/features/auth/core/user.server";
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { isSupporter } from "~/modules/permissions/utils";
import { clampThemeToGamut } from "~/utils/oklch-gamut";
import {
errorToast,
parseRequestPayload,
@ -23,7 +24,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
if (!isSupporter(user)) {
throw errorToast("Custom themes are for supporters only");
}
await UserRepository.updateCustomTheme(user.id, data.newValue);
const clampedTheme = data.newValue
? clampThemeToGamut(data.newValue)
: null;
await UserRepository.updateCustomTheme(user.id, clampedTheme);
break;
}
case "UPDATE_DISABLE_BUILD_ABILITY_SORTING": {

View File

@ -8,5 +8,6 @@ export const loader = async () => {
noScreen: user
? await UserRepository.anyUserPrefersNoScreen([user.id])
: null,
customTheme: user?.customTheme,
};
};

View File

@ -2,86 +2,3 @@
margin-top: var(--s-4);
margin-bottom: calc(var(--s-2) * -1);
}
.customColorSelector {
display: flex;
flex-direction: column;
gap: var(--s-6);
padding: var(--s-4);
border: 2px solid var(--color-border);
border-radius: var(--rounded-sm);
background-color: var(--color-bg-medium);
position: relative;
overflow: hidden;
}
.customColorSelectorSupporter {
display: none;
}
.customColorSelectorNoSupporter {
position: absolute;
inset: 0;
z-index: 10;
backdrop-filter: blur(3px);
display: flex;
flex-direction: column;
justify-content: center;
}
.customColorSelectorInfo {
display: flex;
flex-direction: column;
gap: var(--s-2);
margin-bottom: var(--s-4);
text-align: center;
background-color: var(--color-bg);
padding: var(--s-2) var(--s-4);
border-block: 2px solid var(--color-border);
}
.customColorSelectorActions {
display: grid;
gap: var(--s-2);
grid-template-columns: 1fr auto;
}
.colorSliders {
display: flex;
flex-direction: column;
gap: var(--s-4);
}
.colorSlider {
display: grid;
grid-template-columns: 40% auto;
grid-template-rows: auto auto;
gap: var(--s-1) var(--s-2);
align-items: center;
}
.hueSlider::-webkit-slider-runnable-track {
background: linear-gradient(
to right,
oklch(65% 0.15 0),
oklch(65% 0.15 60),
oklch(65% 0.15 120),
oklch(65% 0.15 180),
oklch(65% 0.15 240),
oklch(65% 0.15 300),
oklch(65% 0.15 360)
) !important;
}
.hueSlider::-moz-range-track {
background: linear-gradient(
to right,
oklch(65% 0.15 0),
oklch(65% 0.15 60),
oklch(65% 0.15 120),
oklch(65% 0.15 180),
oklch(65% 0.15 240),
oklch(65% 0.15 300),
oklch(65% 0.15 360)
) !important;
}

View File

@ -8,16 +8,12 @@ import {
useNavigate,
useSearchParams,
} from "react-router";
import { CustomThemeSelector } from "~/components/CustomThemeSelector";
import { Divider } from "~/components/Divider";
import { SendouSwitch } from "~/components/elements/Switch";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import {
CUSTOM_THEME_VARS,
type CustomTheme,
type CustomThemeVar,
} from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { Theme, useTheme } from "~/features/theme/core/provider";
import { languages } from "~/modules/i18n/config";
@ -25,12 +21,13 @@ import { useHasRole } from "~/modules/permissions/hooks";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { LOG_OUT_URL, navIconUrl, SETTINGS_PAGE } from "~/utils/urls";
import { LinkButton, SendouButton } from "../../../components/elements/Button";
import { SendouButton } from "../../../components/elements/Button";
import { SendouPopover } from "../../../components/elements/Popover";
import { action } from "../actions/settings.server";
import { loader } from "../loaders/settings.server";
import styles from "./settings.module.css";
import "./settings.global.css";
import type { ThemeInput } from "~/utils/oklch-gamut";
export { loader, action };
export const handle: SendouRouteHandle = {
@ -195,131 +192,22 @@ function ThemeSelector() {
);
}
const COLOR_SLIDERS = [
{
id: "base-hue",
cssVar: "--base-h",
defaultValue: 260,
min: 0,
max: 360,
step: 1,
labelKey: "baseHue",
isHue: true,
},
{
id: "base-chroma",
cssVar: "--base-c",
defaultValue: 0.012,
min: 0,
max: 0.1,
step: 0.001,
labelKey: "baseChroma",
isHue: false,
},
{
id: "accent-hue",
cssVar: "--acc-h",
defaultValue: 270,
min: 0,
max: 360,
step: 1,
labelKey: "accentHue",
isHue: true,
},
{
id: "accent-chroma",
cssVar: "--acc-c",
defaultValue: 0.24,
min: 0,
max: 0.3,
step: 0.01,
labelKey: "accentChroma",
isHue: false,
},
] as const;
function useCssVariableState(cssVar: string, defaultValue: number) {
const [value, setValue] = React.useState(() => {
if (typeof window === "undefined") return defaultValue;
return Number(
getComputedStyle(document.documentElement)
.getPropertyValue(cssVar)
.trim() || defaultValue,
);
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = Number(e.target.value);
setValue(newValue);
document.documentElement.style.setProperty(cssVar, String(newValue));
};
return [value, handleChange] as const;
}
function ColorSlider({
id,
cssVar,
defaultValue,
min,
max,
step,
label,
isHue,
}: {
id: string;
cssVar: string;
defaultValue: number;
min: number;
max: number;
step: number;
label: string;
isHue: boolean;
}) {
const [value, handleChange] = useCssVariableState(cssVar, defaultValue);
return (
<div className={styles.colorSlider}>
<Label htmlFor={id}>{label}</Label>
<input
id={id}
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={handleChange}
className={isHue ? styles.hueSlider : undefined}
/>
</div>
);
}
function CustomColorSelector() {
const { t } = useTranslation(["common"]);
const data = useLoaderData<typeof loader>();
const isSupporter = useHasRole("SUPPORTER");
const fetcher = useFetcher();
const handleSave = () => {
const computedStyle = getComputedStyle(document.documentElement);
const themeValues = CUSTOM_THEME_VARS.reduce((acc, varDef) => {
acc[varDef] = Number(computedStyle.getPropertyValue(varDef).trim());
return acc;
}, {} as CustomTheme);
const handleSave = (themeInput: ThemeInput) => {
fetcher.submit(
{ _action: "UPDATE_CUSTOM_THEME", newValue: themeValues },
{
_action: "UPDATE_CUSTOM_THEME",
newValue: themeInput,
} as unknown as Parameters<typeof fetcher.submit>[0],
{ method: "post", encType: "application/json" },
);
};
const handleReset = () => {
CUSTOM_THEME_VARS.forEach((varDef: CustomThemeVar) => {
document.documentElement.style.removeProperty(varDef);
});
fetcher.submit(
{ _action: "UPDATE_CUSTOM_THEME", newValue: null },
{ method: "post", encType: "application/json" },
@ -327,56 +215,12 @@ function CustomColorSelector() {
};
return (
<div className={styles.customColorSelector}>
<div
className={
isSupporter
? styles.customColorSelectorSupporter
: styles.customColorSelectorNoSupporter
}
>
<div className={styles.customColorSelectorInfo}>
<p>{t("common:settings.customTheme.patreonText")}</p>
<LinkButton
to="https://www.patreon.com/sendou"
isExternal
size="small"
>
{t("common:settings.customTheme.joinPatreon")}
</LinkButton>
</div>
</div>
<div className={styles.colorSliders}>
{COLOR_SLIDERS.map((slider) => (
<ColorSlider
key={slider.id}
id={slider.id}
cssVar={slider.cssVar}
defaultValue={slider.defaultValue}
min={slider.min}
max={slider.max}
step={slider.step}
label={t(`common:settings.customTheme.${slider.labelKey}`)}
isHue={slider.isHue}
/>
))}
</div>
<div className={styles.customColorSelectorActions}>
<SendouButton
isDisabled={!isSupporter || fetcher.state !== "idle"}
onPress={handleSave}
>
{t("common:actions.save")}
</SendouButton>
<SendouButton
isDisabled={!isSupporter || fetcher.state !== "idle"}
variant="destructive"
onPress={handleReset}
>
{t("common:actions.reset")}
</SendouButton>
</div>
</div>
<CustomThemeSelector
initialTheme={data.customTheme}
isSupporter={isSupporter}
onSave={handleSave}
onReset={handleReset}
/>
);
}

View File

@ -1,18 +1,12 @@
import { z } from "zod";
import type { CustomThemeVar } from "~/db/tables";
import { _action } from "~/utils/zod";
import { _action, themeInputSchema } from "~/utils/zod";
export { themeInputSchema };
export const settingsEditSchema = z.union([
z.object({
_action: _action("UPDATE_CUSTOM_THEME"),
newValue: z
.object({
"--base-h": z.number().min(0).max(360),
"--base-c": z.number().min(0).max(0.1),
"--acc-h": z.number().min(0).max(360),
"--acc-c": z.number().min(0).max(0.3),
} satisfies Record<CustomThemeVar, z.ZodNumber>)
.nullable(),
newValue: themeInputSchema.nullable(),
}),
z.object({
_action: _action("UPDATE_DISABLE_BUILD_ABILITY_SORTING"),

View File

@ -1,81 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
assertResponseErrored,
dbInsertUsers,
dbReset,
wrappedAction,
} from "~/utils/Test";
import { action as teamIndexPageAction } from "../actions/t.server";
import type { createTeamSchema, editTeamSchema } from "../team-schemas.server";
import { action as _editTeamProfileAction } from "./t.$customUrl.edit.server";
const createTeamAction = wrappedAction<typeof createTeamSchema>({
action: teamIndexPageAction,
});
const editTeamProfileAction = wrappedAction<typeof editTeamSchema>({
action: _editTeamProfileAction,
});
const DEFAULT_FIELDS = {
_action: "EDIT",
name: "Team 1",
bio: "",
bsky: "",
tag: "",
} as const;
describe("team page editing", () => {
beforeEach(async () => {
await dbInsertUsers();
await createTeamAction({ name: "Team 1" }, { user: "regular" });
});
afterEach(() => {
dbReset();
});
it("adds valid custom css vars", async () => {
const response = await editTeamProfileAction(
{
customTheme: {
"--base-c": 210,
"--base-h": 50,
"--acc-c": 160,
"--acc-h": 80,
},
...DEFAULT_FIELDS,
},
{ user: "regular", params: { customUrl: "team-1" } },
);
expect(response.status).toBe(302);
});
it("prevents adding custom css var of unknown property", async () => {
const response = await editTeamProfileAction(
{
customTheme: Object.assign({
"backdrop-filter": "#fff",
}),
...DEFAULT_FIELDS,
},
{ user: "regular", params: { customUrl: "team-1" } },
);
assertResponseErrored(response);
});
it("prevents adding custom css var of unknown value", async () => {
const response = await editTeamProfileAction(
{
customTheme: Object.assign({
"--base-c": "not-a-number",
}),
...DEFAULT_FIELDS,
},
{ user: "regular", params: { customUrl: "team-1" } },
);
assertResponseErrored(response);
});
});

View File

@ -1,6 +1,7 @@
import type { ActionFunction } from "react-router";
import { redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { clampThemeToGamut } from "~/utils/oklch-gamut";
import {
errorToastIfFalsy,
notFoundIfFalsy,
@ -10,7 +11,11 @@ import { assertUnreachable } from "~/utils/types";
import { mySlugify, TEAM_SEARCH_PAGE, teamPage } from "~/utils/urls";
import * as TeamRepository from "../TeamRepository.server";
import { editTeamSchema, teamParamsSchema } from "../team-schemas.server";
import { isTeamManager, isTeamOwner } from "../team-utils";
import {
canAddCustomizedColors,
isTeamManager,
isTeamOwner,
} from "../team-utils";
export const action: ActionFunction = async ({ request, params }) => {
const user = requireUser();
@ -64,10 +69,16 @@ export const action: ActionFunction = async ({ request, params }) => {
};
}
const customTheme =
canAddCustomizedColors(team) && data.customTheme
? clampThemeToGamut(data.customTheme)
: null;
const editedTeam = await TeamRepository.update({
id: team.id,
customUrl: newCustomUrl,
...data,
customTheme,
});
throw redirect(teamPage(editedTeam.customUrl));

View File

@ -1,6 +1,11 @@
import { z } from "zod";
import type { CustomThemeVar } from "~/db/tables";
import { _action, falsyToNull, id, safeStringSchema } from "~/utils/zod";
import {
_action,
falsyToNull,
id,
safeStringSchema,
themeInputSchema,
} from "~/utils/zod";
import { TEAM, TEAM_MEMBER_ROLES } from "./team-constants";
export const teamParamsSchema = z.object({ customUrl: z.string() });
@ -48,14 +53,7 @@ export const editTeamSchema = z.union([
),
customTheme: z.preprocess(
(val) => (!val || val === "null" ? null : val),
z
.object({
"--base-h": z.number().min(0).max(360),
"--base-c": z.number().min(0).max(0.1),
"--acc-h": z.number().min(0).max(360),
"--acc-c": z.number().min(0).max(0.3),
} satisfies Record<CustomThemeVar, z.ZodNumber>)
.nullable(),
themeInputSchema.nullable(),
),
}),
]);

View File

@ -1,8 +1,20 @@
html {
--base-c: 0.012;
--base-h: 260;
--acc-c: 0.24;
--base-c-0: 0.00012;
--base-c-1: 0.00588;
--base-c-2: 0.00744;
--base-c-3: 0.0168;
--base-c-4: 0.01548;
--base-c-5: 0.01632;
--base-c-6: 0.01548;
--base-c-7: 0.00804;
--acc-h: 270;
--acc-c-0: 0.0912;
--acc-c-1: 0.2664;
--acc-c-2: 0.0816;
--acc-c-3: 0.06;
--acc-c-4: 0.2616;
--acc-c-5: 0.1344;
}
/* 'low' and 'high' do not indicate lightness */
@ -11,19 +23,21 @@ html {
/* --color-base-x should rarely be consumed directly */
/* they mostly exist as a base for other vars like --color-text and --color-bg */
html.dark {
--color-base-0: oklch(100% calc(var(--base-c) * 0.01) var(--base-h));
--color-base-1: oklch(94.873% calc(var(--base-c) * 0.49) var(--base-h));
--color-base-2: oklch(81.397% calc(var(--base-c) * 0.62) var(--base-h));
--color-base-3: oklch(63.785% calc(var(--base-c) * 1.4) var(--base-h));
--color-base-4: oklch(46.004% calc(var(--base-c) * 1.29) var(--base-h));
--color-base-5: oklch(34.138% calc(var(--base-c) * 1.36) var(--base-h));
--color-base-6: oklch(27.313% calc(var(--base-c) * 1.29) var(--base-h));
--color-base-7: oklch(20.97% calc(var(--base-c) * 0.67) var(--base-h));
/* Any changes here NEED to be reflected in oklch-gamut.ts as well. */
--color-accent-low: oklch(25.912% calc(var(--acc-c) * 0.38) var(--acc-h));
--color-accent: oklch(52.262% calc(var(--acc-c) * 1.11) var(--acc-h));
--color-accent-high: oklch(83.419% calc(var(--acc-c) * 0.34) var(--acc-h));
html.dark {
--color-base-0: oklch(100% var(--base-c-0) var(--base-h));
--color-base-1: oklch(94.873% var(--base-c-1) var(--base-h));
--color-base-2: oklch(81.397% var(--base-c-2) var(--base-h));
--color-base-3: oklch(63.785% var(--base-c-3) var(--base-h));
--color-base-4: oklch(46.004% var(--base-c-4) var(--base-h));
--color-base-5: oklch(34.138% var(--base-c-5) var(--base-h));
--color-base-6: oklch(27.313% var(--base-c-6) var(--base-h));
--color-base-7: oklch(20.97% var(--base-c-7) var(--base-h));
--color-accent-low: oklch(25.912% var(--acc-c-0) var(--acc-h));
--color-accent: oklch(52.262% var(--acc-c-1) var(--acc-h));
--color-accent-high: oklch(83.419% var(--acc-c-2) var(--acc-h));
--color-second-low: oklch(from var(--color-accent-low) l c calc(h + 180));
--color-second: oklch(from var(--color-accent) l c calc(h + 180));
@ -56,18 +70,18 @@ html.dark {
}
html.light {
--color-base-0: oklch(20.97% calc(var(--base-c) * 0.67) var(--base-h));
--color-base-1: oklch(27.313% calc(var(--base-c) * 1.29) var(--base-h));
--color-base-2: oklch(34.138% calc(var(--base-c) * 1.36) var(--base-h));
--color-base-3: oklch(46.004% calc(var(--base-c) * 1.29) var(--base-h));
--color-base-4: oklch(63.785% calc(var(--base-c) * 1.4) var(--base-h));
--color-base-5: oklch(85.397% calc(var(--base-c) * 0.62) var(--base-h));
--color-base-6: oklch(94.873% calc(var(--base-c) * 0.49) var(--base-h));
--color-base-7: oklch(100% calc(var(--base-c) * 0.01) var(--base-h));
--color-base-0: oklch(20.97% var(--base-c-7) var(--base-h));
--color-base-1: oklch(27.313% var(--base-c-6) var(--base-h));
--color-base-2: oklch(34.138% var(--base-c-5) var(--base-h));
--color-base-3: oklch(46.004% var(--base-c-4) var(--base-h));
--color-base-4: oklch(63.785% var(--base-c-3) var(--base-h));
--color-base-5: oklch(81.397% var(--base-c-2) var(--base-h));
--color-base-6: oklch(94.873% var(--base-c-1) var(--base-h));
--color-base-7: oklch(100% var(--base-c-0) var(--base-h));
--color-accent-low: oklch(87.817% calc(var(--acc-c) * 0.25) var(--acc-h));
--color-accent: oklch(52.919% calc(var(--acc-c) * 1.09) var(--acc-h));
--color-accent-high: oklch(31.777% calc(var(--acc-c) * 0.56) var(--acc-h));
--color-accent-low: oklch(87.817% var(--acc-c-3) var(--acc-h));
--color-accent: oklch(52.919% var(--acc-c-4) var(--acc-h));
--color-accent-high: oklch(31.777% var(--acc-c-5) var(--acc-h));
--color-second-low: oklch(from var(--color-accent-low) l c calc(h + 180));
--color-second: oklch(from var(--color-accent) l c calc(h + 180));

320
app/utils/oklch-gamut.ts Normal file
View File

@ -0,0 +1,320 @@
import type { z } from "zod";
import type { CustomTheme } from "~/db/tables";
import type { themeInputSchema } from "~/utils/zod";
export type ThemeInput = z.infer<typeof themeInputSchema>;
interface Lab {
L: number;
a: number;
b: number;
}
interface RGB {
r: number;
g: number;
b: number;
}
interface LC {
L: number;
C: number;
}
function oklab_to_linear_srgb(c: Lab): RGB {
const l_ = c.L + 0.3963377774 * c.a + 0.2158037573 * c.b;
const m_ = c.L - 0.1055613458 * c.a - 0.0638541728 * c.b;
const s_ = c.L - 0.0894841775 * c.a - 1.291485548 * c.b;
const l = l_ * l_ * l_;
const m = m_ * m_ * m_;
const s = s_ * s_ * s_;
return {
r: +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
};
}
function compute_max_saturation(a: number, b: number): number {
let k0: number;
let k1: number;
let k2: number;
let k3: number;
let k4: number;
let wl: number;
let wm: number;
let ws: number;
if (-1.88170328 * a - 0.80936493 * b > 1) {
k0 = +1.19086277;
k1 = +1.76576728;
k2 = +0.59662641;
k3 = +0.75515197;
k4 = +0.56771245;
wl = +4.0767416621;
wm = -3.3077115913;
ws = +0.2309699292;
} else if (1.81444104 * a - 1.19445276 * b > 1) {
k0 = +0.73956515;
k1 = -0.45954404;
k2 = +0.08285427;
k3 = +0.1254107;
k4 = +0.14503204;
wl = -1.2684380046;
wm = +2.6097574011;
ws = -0.3413193965;
} else {
k0 = +1.35733652;
k1 = -0.00915799;
k2 = -1.1513021;
k3 = -0.50559606;
k4 = +0.00692167;
wl = -0.0041960863;
wm = -0.7034186147;
ws = +1.707614701;
}
let S = k0 + k1 * a + k2 * b + k3 * a * a + k4 * a * b;
const k_l = +0.3963377774 * a + 0.2158037573 * b;
const k_m = -0.1055613458 * a - 0.0638541728 * b;
const k_s = -0.0894841775 * a - 1.291485548 * b;
{
const l_ = 1 + S * k_l;
const m_ = 1 + S * k_m;
const s_ = 1 + S * k_s;
const l = l_ * l_ * l_;
const m = m_ * m_ * m_;
const s = s_ * s_ * s_;
const l_dS = 3 * k_l * l_ * l_;
const m_dS = 3 * k_m * m_ * m_;
const s_dS = 3 * k_s * s_ * s_;
const l_dS2 = 6 * k_l * k_l * l_;
const m_dS2 = 6 * k_m * k_m * m_;
const s_dS2 = 6 * k_s * k_s * s_;
const f = wl * l + wm * m + ws * s;
const f1 = wl * l_dS + wm * m_dS + ws * s_dS;
const f2 = wl * l_dS2 + wm * m_dS2 + ws * s_dS2;
S = S - (f * f1) / (f1 * f1 - 0.5 * f * f2);
}
return S;
}
function find_cusp(a: number, b: number): LC {
const S_cusp = compute_max_saturation(a, b);
const rgb_at_max = oklab_to_linear_srgb({
L: 1,
a: S_cusp * a,
b: S_cusp * b,
});
const L_cusp = Math.cbrt(
1 / Math.max(rgb_at_max.r, rgb_at_max.g, rgb_at_max.b),
);
const C_cusp = L_cusp * S_cusp;
return { L: L_cusp, C: C_cusp };
}
function find_gamut_intersection(
a: number,
b: number,
L1: number,
C1: number,
L0: number,
): number {
const cusp = find_cusp(a, b);
let t: number;
if ((L1 - L0) * cusp.C - (cusp.L - L0) * C1 <= 0) {
t = (cusp.C * L0) / (C1 * cusp.L + cusp.C * (L0 - L1));
} else {
t = (cusp.C * (L0 - 1)) / (C1 * (cusp.L - 1) + cusp.C * (L0 - L1));
{
const dL = L1 - L0;
const dC = C1;
const k_l = +0.3963377774 * a + 0.2158037573 * b;
const k_m = -0.1055613458 * a - 0.0638541728 * b;
const k_s = -0.0894841775 * a - 1.291485548 * b;
const l_dt = dL + dC * k_l;
const m_dt = dL + dC * k_m;
const s_dt = dL + dC * k_s;
{
const L = L0 * (1 - t) + t * L1;
const C = t * C1;
const l_ = L + C * k_l;
const m_ = L + C * k_m;
const s_ = L + C * k_s;
const l = l_ * l_ * l_;
const m = m_ * m_ * m_;
const s = s_ * s_ * s_;
const ldt = 3 * l_dt * l_ * l_;
const mdt = 3 * m_dt * m_ * m_;
const sdt = 3 * s_dt * s_ * s_;
const ldt2 = 6 * l_dt * l_dt * l_;
const mdt2 = 6 * m_dt * m_dt * m_;
const sdt2 = 6 * s_dt * s_dt * s_;
const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s - 1;
const r1 = 4.0767416621 * ldt - 3.3077115913 * mdt + 0.2309699292 * sdt;
const r2 =
4.0767416621 * ldt2 - 3.3077115913 * mdt2 + 0.2309699292 * sdt2;
const u_r = r1 / (r1 * r1 - 0.5 * r * r2);
let t_r = -r * u_r;
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s - 1;
const g1 =
-1.2684380046 * ldt + 2.6097574011 * mdt - 0.3413193965 * sdt;
const g2 =
-1.2684380046 * ldt2 + 2.6097574011 * mdt2 - 0.3413193965 * sdt2;
const u_g = g1 / (g1 * g1 - 0.5 * g * g2);
let t_g = -g * u_g;
const b = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s - 1;
const b1 = -0.0041960863 * ldt - 0.7034186147 * mdt + 1.707614701 * sdt;
const b2 =
-0.0041960863 * ldt2 - 0.7034186147 * mdt2 + 1.707614701 * sdt2;
const u_b = b1 / (b1 * b1 - 0.5 * b * b2);
let t_b = -b * u_b;
t_r = u_r >= 0 ? t_r : Number.MAX_VALUE;
t_g = u_g >= 0 ? t_g : Number.MAX_VALUE;
t_b = u_b >= 0 ? t_b : Number.MAX_VALUE;
t += Math.min(t_r, Math.min(t_g, t_b));
}
}
}
return t;
}
function clamp(x: number, min: number, max: number): number {
return x < min ? min : x > max ? max : x;
}
function maximum_chroma_for_lh(L: number, h: number): number {
const a = Math.cos(h);
const b = Math.sin(h);
const L0 = clamp(L, 0, 1);
const t = find_gamut_intersection(a, b, L, 1, L0);
return t;
}
// These are the lightness values used in vars.css
// Any changes here NEED to be reflected in vars.css as well.
const BASE_LIGHTNESS_VALUES = [
1.0, // --base-c-0
0.94873, // --base-c-1
0.81397, // --base-c-2
0.63785, // --base-c-3
0.46004, // --base-c-4
0.34138, // --base-c-5
0.27313, // --base-c-6
0.2097, // --base-c-7
] as const;
const ACCENT_LIGHTNESS_VALUES = [
0.25912, // --acc-c-0: dark mode low
0.52262, // --acc-c-1: dark mode mid
0.83419, // --acc-c-2: dark mode high
0.87817, // --acc-c-3: light mode low
0.52919, // --acc-c-4: light mode mid
0.31777, // --acc-c-5: light mode high
] as const;
const BASE_CHROMA_MULTIPLIERS = [
0.01, // --base-c-0
0.49, // --base-c-1
0.62, // --base-c-2
1.4, // --base-c-3
1.29, // --base-c-4
1.36, // --base-c-5
1.29, // --base-c-6
0.67, // --base-c-7
] as const;
const ACCENT_CHROMA_MULTIPLIERS = [
0.38, // --acc-c-0
1.11, // --acc-c-1
0.34, // --acc-c-2
0.25, // --acc-c-3
1.09, // --acc-c-4
0.56, // --acc-c-5
] as const;
function clampChromaForColor(
lightness: number,
desiredChroma: number,
hueRadians: number,
): number {
const maxChroma = maximum_chroma_for_lh(lightness, hueRadians);
return Math.min(desiredChroma, maxChroma);
}
export function clampThemeToGamut(input: ThemeInput): CustomTheme {
const baseHueRadians = input.baseHue * (Math.PI / 180);
const accentHueRadians = input.accentHue * (Math.PI / 180);
const clampedBaseChromas = BASE_LIGHTNESS_VALUES.map((lightness, index) => {
const desiredChroma = input.baseChroma * BASE_CHROMA_MULTIPLIERS[index];
return clampChromaForColor(lightness, desiredChroma, baseHueRadians);
});
const clampedAccentChromas = ACCENT_LIGHTNESS_VALUES.map(
(lightness, index) => {
const desiredChroma =
input.accentChroma * ACCENT_CHROMA_MULTIPLIERS[index];
return clampChromaForColor(lightness, desiredChroma, accentHueRadians);
},
);
return {
"--base-h": input.baseHue,
"--base-c-0": clampedBaseChromas[0],
"--base-c-1": clampedBaseChromas[1],
"--base-c-2": clampedBaseChromas[2],
"--base-c-3": clampedBaseChromas[3],
"--base-c-4": clampedBaseChromas[4],
"--base-c-5": clampedBaseChromas[5],
"--base-c-6": clampedBaseChromas[6],
"--base-c-7": clampedBaseChromas[7],
"--acc-h": input.accentHue,
"--acc-c-0": clampedAccentChromas[0],
"--acc-c-1": clampedAccentChromas[1],
"--acc-c-2": clampedAccentChromas[2],
"--acc-c-3": clampedAccentChromas[3],
"--acc-c-4": clampedAccentChromas[4],
"--acc-c-5": clampedAccentChromas[5],
};
}
export {
BASE_LIGHTNESS_VALUES,
ACCENT_LIGHTNESS_VALUES,
BASE_CHROMA_MULTIPLIERS,
ACCENT_CHROMA_MULTIPLIERS,
};

View File

@ -35,6 +35,36 @@ export const dbBoolean = z.coerce.number().min(0).max(1).int();
const hexCodeRegex = /^#(?:[0-9a-fA-F]{3}){1,2}[0-9]{0,2}$/; // https://stackoverflow.com/a/1636354
export const hexCode = z.string().regex(hexCodeRegex);
export const THEME_INPUT_LIMITS = {
BASE_HUE_MIN: 0,
BASE_HUE_MAX: 360,
BASE_CHROMA_MIN: 0,
BASE_CHROMA_MAX: 0.1,
ACCENT_HUE_MIN: 0,
ACCENT_HUE_MAX: 360,
ACCENT_CHROMA_MIN: 0,
ACCENT_CHROMA_MAX: 0.3,
} as const;
export const themeInputSchema = z.object({
baseHue: z
.number()
.min(THEME_INPUT_LIMITS.BASE_HUE_MIN)
.max(THEME_INPUT_LIMITS.BASE_HUE_MAX),
baseChroma: z
.number()
.min(THEME_INPUT_LIMITS.BASE_CHROMA_MIN)
.max(THEME_INPUT_LIMITS.BASE_CHROMA_MAX),
accentHue: z
.number()
.min(THEME_INPUT_LIMITS.ACCENT_HUE_MIN)
.max(THEME_INPUT_LIMITS.ACCENT_HUE_MAX),
accentChroma: z
.number()
.min(THEME_INPUT_LIMITS.ACCENT_CHROMA_MIN)
.max(THEME_INPUT_LIMITS.ACCENT_CHROMA_MAX),
});
const timeStringRegex = /^([01]\d|2[0-3]):([0-5]\d)$/;
export const timeString = z.string().regex(timeStringRegex);