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);