mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-06 05:07:36 -05:00
Gamut mapping and custom theme selector component
This commit is contained in:
parent
0ae89499ef
commit
bebb7ccee0
82
app/components/CustomThemeSelector.module.css
Normal file
82
app/components/CustomThemeSelector.module.css
Normal 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;
|
||||
}
|
||||
220
app/components/CustomThemeSelector.tsx
Normal file
220
app/components/CustomThemeSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -8,5 +8,6 @@ export const loader = async () => {
|
|||
noScreen: user
|
||||
? await UserRepository.anyUserPrefersNoScreen([user.id])
|
||||
: null,
|
||||
customTheme: user?.customTheme,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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
320
app/utils/oklch-gamut.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user