diff --git a/app/components/CustomThemeSelector.module.css b/app/components/CustomThemeSelector.module.css
new file mode 100644
index 000000000..f3ee0fa92
--- /dev/null
+++ b/app/components/CustomThemeSelector.module.css
@@ -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;
+}
diff --git a/app/components/CustomThemeSelector.tsx b/app/components/CustomThemeSelector.tsx
new file mode 100644
index 000000000..c64d55e22
--- /dev/null
+++ b/app/components/CustomThemeSelector.tsx
@@ -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 (
+
+
+ onChange(inputKey, Number(e.target.value))}
+ className={isHue ? styles.hueSlider : undefined}
+ />
+
+ );
+}
+
+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(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 (
+
+ {hidePatreonInfo ? null : (
+
+
+
{t("common:settings.customTheme.patreonText")}
+
+ {t("common:settings.customTheme.joinPatreon")}
+
+
+
+ )}
+
+ {COLOR_SLIDERS.map((slider) => (
+
+ ))}
+
+
+
+ {t("common:actions.save")}
+
+
+ {t("common:actions.reset")}
+
+
+
+ );
+}
diff --git a/app/db/tables.ts b/app/db/tables.ts
index d7b00f958..9c3a4bf3f 100644
--- a/app/db/tables.ts
+++ b/app/db/tables.ts
@@ -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;
diff --git a/app/features/settings/actions/settings.server.ts b/app/features/settings/actions/settings.server.ts
index 4dc29638a..d8d597bdb 100644
--- a/app/features/settings/actions/settings.server.ts
+++ b/app/features/settings/actions/settings.server.ts
@@ -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": {
diff --git a/app/features/settings/loaders/settings.server.ts b/app/features/settings/loaders/settings.server.ts
index 02f356d98..489cc8b98 100644
--- a/app/features/settings/loaders/settings.server.ts
+++ b/app/features/settings/loaders/settings.server.ts
@@ -8,5 +8,6 @@ export const loader = async () => {
noScreen: user
? await UserRepository.anyUserPrefersNoScreen([user.id])
: null,
+ customTheme: user?.customTheme,
};
};
diff --git a/app/features/settings/routes/settings.module.css b/app/features/settings/routes/settings.module.css
index 36678bd38..a627cda46 100644
--- a/app/features/settings/routes/settings.module.css
+++ b/app/features/settings/routes/settings.module.css
@@ -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;
-}
diff --git a/app/features/settings/routes/settings.tsx b/app/features/settings/routes/settings.tsx
index 142d5d9f1..c66078623 100644
--- a/app/features/settings/routes/settings.tsx
+++ b/app/features/settings/routes/settings.tsx
@@ -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) => {
- 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 (
-
-
-
-
- );
-}
-
function CustomColorSelector() {
- const { t } = useTranslation(["common"]);
+ const data = useLoaderData();
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[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 (
-
-
-
-
{t("common:settings.customTheme.patreonText")}
-
- {t("common:settings.customTheme.joinPatreon")}
-
-
-
-
- {COLOR_SLIDERS.map((slider) => (
-
- ))}
-
-
-
- {t("common:actions.save")}
-
-
- {t("common:actions.reset")}
-
-
-
+
);
}
diff --git a/app/features/settings/settings-schemas.ts b/app/features/settings/settings-schemas.ts
index 0a70a2452..7c7e3a38b 100644
--- a/app/features/settings/settings-schemas.ts
+++ b/app/features/settings/settings-schemas.ts
@@ -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)
- .nullable(),
+ newValue: themeInputSchema.nullable(),
}),
z.object({
_action: _action("UPDATE_DISABLE_BUILD_ABILITY_SORTING"),
diff --git a/app/features/team/actions/t.$customUrl.edit.server.test.ts b/app/features/team/actions/t.$customUrl.edit.server.test.ts
deleted file mode 100644
index 55dd0d0c4..000000000
--- a/app/features/team/actions/t.$customUrl.edit.server.test.ts
+++ /dev/null
@@ -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({
- action: teamIndexPageAction,
-});
-
-const editTeamProfileAction = wrappedAction({
- 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);
- });
-});
diff --git a/app/features/team/actions/t.$customUrl.edit.server.ts b/app/features/team/actions/t.$customUrl.edit.server.ts
index f490776e6..fe0f28065 100644
--- a/app/features/team/actions/t.$customUrl.edit.server.ts
+++ b/app/features/team/actions/t.$customUrl.edit.server.ts
@@ -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));
diff --git a/app/features/team/team-schemas.server.ts b/app/features/team/team-schemas.server.ts
index e5ea52e1e..dcde132db 100644
--- a/app/features/team/team-schemas.server.ts
+++ b/app/features/team/team-schemas.server.ts
@@ -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)
- .nullable(),
+ themeInputSchema.nullable(),
),
}),
]);
diff --git a/app/styles/vars.css b/app/styles/vars.css
index 2f753d264..8b56ed77f 100644
--- a/app/styles/vars.css
+++ b/app/styles/vars.css
@@ -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));
diff --git a/app/utils/oklch-gamut.ts b/app/utils/oklch-gamut.ts
new file mode 100644
index 000000000..91d89d06b
--- /dev/null
+++ b/app/utils/oklch-gamut.ts
@@ -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;
+
+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,
+};
diff --git a/app/utils/zod.ts b/app/utils/zod.ts
index fef5bb0cf..b1d4af172 100644
--- a/app/utils/zod.ts
+++ b/app/utils/zod.ts
@@ -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);