This commit is contained in:
parent
23c4585bc1
commit
7bc6d002a9
90 changed files with 13401 additions and 1 deletions
358
src/theme/ThemeProvider.tsx
Normal file
358
src/theme/ThemeProvider.tsx
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
import type {
|
||||
Base16Palette,
|
||||
ResolvedThemePolarity,
|
||||
ThemeProviderProps,
|
||||
ThemeProviderState,
|
||||
ThemeTint,
|
||||
} from "./types";
|
||||
import {
|
||||
COLOR_SCHEME_QUERY,
|
||||
DEFAULT_BORDER_RADIUS,
|
||||
DEFAULT_THEME_COLOR,
|
||||
THEME_VARIABLE_NAMES,
|
||||
disableTransitionsTemporarily,
|
||||
getBase16ThemeVariables,
|
||||
getPrimaryThemeVariables,
|
||||
getSystemTheme,
|
||||
getThemeVariables,
|
||||
isTheme,
|
||||
isThemeColor,
|
||||
isTint,
|
||||
parseBorderRadius,
|
||||
parseStoredBase16Palette,
|
||||
readStoredValue,
|
||||
saveStoredValue,
|
||||
serializeBase16Palette,
|
||||
} from "./utils";
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const CUSTOM_CSS_STYLE_ID = "tensamin-theme-custom-css";
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
defaultColor = DEFAULT_THEME_COLOR,
|
||||
defaultPalette = null,
|
||||
defaultPrimaryColor = "",
|
||||
defaultTint = "soft",
|
||||
defaultBorderRadius = DEFAULT_BORDER_RADIUS,
|
||||
defaultCustomCss = "",
|
||||
storageKey = "theme",
|
||||
colorStorageKey = "theme_color",
|
||||
paletteStorageKey = "theme_palette",
|
||||
primaryColorStorageKey = "theme_primary_color",
|
||||
tintStorageKey = "theme_tint",
|
||||
borderRadiusStorageKey = "theme_border_radius",
|
||||
customCssStorageKey = "theme_custom_css",
|
||||
disableTransitionOnChange = true,
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [themePolarity, setThemePolarityState] = useState(() =>
|
||||
readStoredValue(storageKey, isTheme, defaultTheme),
|
||||
);
|
||||
const [themeColor, setThemeColorState] = useState<string>(() =>
|
||||
readStoredValue(colorStorageKey, isThemeColor, defaultColor),
|
||||
);
|
||||
const [themePalette, setThemePaletteState] = useState<Base16Palette | null>(
|
||||
() => {
|
||||
if (!paletteStorageKey) return defaultPalette;
|
||||
return parseStoredBase16Palette(localStorage.getItem(paletteStorageKey));
|
||||
},
|
||||
);
|
||||
const [themePrimaryColor, setThemePrimaryColorState] = useState(() =>
|
||||
readStoredValue(primaryColorStorageKey, isThemeColor, defaultPrimaryColor),
|
||||
);
|
||||
const [themeTint, setThemeTintState] = useState<ThemeTint>(() =>
|
||||
readStoredValue(tintStorageKey, isTint, defaultTint),
|
||||
);
|
||||
const [themeBorderRadius, setThemeBorderRadiusState] = useState(
|
||||
() =>
|
||||
parseBorderRadius(
|
||||
borderRadiusStorageKey
|
||||
? localStorage.getItem(borderRadiusStorageKey)
|
||||
: null,
|
||||
) ?? defaultBorderRadius,
|
||||
);
|
||||
const [themeCustomCss, setThemeCustomCssState] = useState(() =>
|
||||
customCssStorageKey
|
||||
? (localStorage.getItem(customCssStorageKey) ?? defaultCustomCss)
|
||||
: defaultCustomCss,
|
||||
);
|
||||
const [systemPolarity, setSystemPolarity] = useState<ResolvedThemePolarity>(
|
||||
() => getSystemTheme(),
|
||||
);
|
||||
|
||||
const resolvedPolarity =
|
||||
themePolarity === "system" ? systemPolarity : themePolarity;
|
||||
|
||||
const setThemePolarity = useCallback(
|
||||
(nextPolarity: typeof themePolarity) => {
|
||||
saveStoredValue(storageKey, nextPolarity);
|
||||
setThemePolarityState(nextPolarity);
|
||||
},
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const setThemeColor = useCallback(
|
||||
(nextColor: string) => {
|
||||
if (!isThemeColor(nextColor)) return;
|
||||
|
||||
saveStoredValue(colorStorageKey, nextColor);
|
||||
setThemeColorState(nextColor);
|
||||
},
|
||||
[colorStorageKey],
|
||||
);
|
||||
|
||||
const setThemePalette = useCallback(
|
||||
(nextPalette: Base16Palette | null) => {
|
||||
saveStoredValue(paletteStorageKey, serializeBase16Palette(nextPalette));
|
||||
setThemePaletteState(nextPalette);
|
||||
},
|
||||
[paletteStorageKey],
|
||||
);
|
||||
|
||||
const setThemePrimaryColor = useCallback(
|
||||
(nextColor: string) => {
|
||||
if (!isThemeColor(nextColor)) return;
|
||||
|
||||
saveStoredValue(primaryColorStorageKey, nextColor);
|
||||
setThemePrimaryColorState(nextColor);
|
||||
},
|
||||
[primaryColorStorageKey],
|
||||
);
|
||||
|
||||
const setThemeTint = useCallback(
|
||||
(nextTint: ThemeTint) => {
|
||||
saveStoredValue(tintStorageKey, nextTint);
|
||||
setThemeTintState(nextTint);
|
||||
},
|
||||
[tintStorageKey],
|
||||
);
|
||||
|
||||
const setThemeBorderRadius = useCallback(
|
||||
(nextRadius: number) => {
|
||||
const radius = parseBorderRadius(String(nextRadius));
|
||||
if (radius === null) return;
|
||||
|
||||
saveStoredValue(borderRadiusStorageKey, String(radius));
|
||||
setThemeBorderRadiusState(radius);
|
||||
},
|
||||
[borderRadiusStorageKey],
|
||||
);
|
||||
|
||||
const setThemeCustomCss = useCallback(
|
||||
(nextCss: string) => {
|
||||
saveStoredValue(customCssStorageKey, nextCss);
|
||||
setThemeCustomCssState(nextCss);
|
||||
},
|
||||
[customCssStorageKey],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY);
|
||||
const handleChange = () => {
|
||||
setSystemPolarity(getSystemTheme());
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const restoreTransitions = disableTransitionOnChange
|
||||
? disableTransitionsTemporarily()
|
||||
: null;
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(resolvedPolarity);
|
||||
|
||||
for (const name of THEME_VARIABLE_NAMES) {
|
||||
root.style.removeProperty(name);
|
||||
}
|
||||
|
||||
const variables = themePalette
|
||||
? getBase16ThemeVariables(themePalette)
|
||||
: themeColor === ""
|
||||
? null
|
||||
: getThemeVariables(themeColor, resolvedPolarity, themeTint);
|
||||
|
||||
if (variables) {
|
||||
for (const [name, value] of Object.entries(variables)) {
|
||||
root.style.setProperty(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (themePrimaryColor !== "") {
|
||||
for (const [name, value] of Object.entries(
|
||||
getPrimaryThemeVariables(themePrimaryColor),
|
||||
)) {
|
||||
root.style.setProperty(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (restoreTransitions) restoreTransitions();
|
||||
}, [
|
||||
disableTransitionOnChange,
|
||||
resolvedPolarity,
|
||||
themeColor,
|
||||
themePalette,
|
||||
themePrimaryColor,
|
||||
themeTint,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--radius",
|
||||
`${themeBorderRadius}rem`,
|
||||
);
|
||||
}, [themeBorderRadius]);
|
||||
|
||||
useEffect(() => {
|
||||
const existingStyle = document.getElementById(CUSTOM_CSS_STYLE_ID);
|
||||
|
||||
if (themeCustomCss.trim() === "") {
|
||||
existingStyle?.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const style = existingStyle ?? document.createElement("style");
|
||||
style.id = CUSTOM_CSS_STYLE_ID;
|
||||
style.textContent = themeCustomCss;
|
||||
|
||||
if (!existingStyle) document.head.appendChild(style);
|
||||
}, [themeCustomCss]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (event: StorageEvent) => {
|
||||
if (event.storageArea !== localStorage) return;
|
||||
|
||||
if (event.key === storageKey) {
|
||||
setThemePolarityState(
|
||||
isTheme(event.newValue) ? event.newValue : defaultTheme,
|
||||
);
|
||||
}
|
||||
|
||||
if (event.key === colorStorageKey) {
|
||||
setThemeColorState(
|
||||
isThemeColor(event.newValue) ? event.newValue : defaultColor,
|
||||
);
|
||||
}
|
||||
|
||||
if (event.key === tintStorageKey) {
|
||||
setThemeTintState(
|
||||
isTint(event.newValue) ? event.newValue : defaultTint,
|
||||
);
|
||||
}
|
||||
|
||||
if (event.key === borderRadiusStorageKey) {
|
||||
setThemeBorderRadiusState(
|
||||
parseBorderRadius(event.newValue) ?? defaultBorderRadius,
|
||||
);
|
||||
}
|
||||
|
||||
if (event.key === customCssStorageKey) {
|
||||
setThemeCustomCssState(event.newValue ?? defaultCustomCss);
|
||||
}
|
||||
|
||||
if (event.key === paletteStorageKey) {
|
||||
setThemePaletteState(parseStoredBase16Palette(event.newValue));
|
||||
}
|
||||
|
||||
if (event.key === primaryColorStorageKey) {
|
||||
setThemePrimaryColorState(
|
||||
isThemeColor(event.newValue) ? event.newValue : defaultPrimaryColor,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorageChange);
|
||||
};
|
||||
}, [
|
||||
borderRadiusStorageKey,
|
||||
colorStorageKey,
|
||||
customCssStorageKey,
|
||||
defaultBorderRadius,
|
||||
defaultColor,
|
||||
defaultCustomCss,
|
||||
defaultPrimaryColor,
|
||||
defaultTheme,
|
||||
defaultTint,
|
||||
paletteStorageKey,
|
||||
primaryColorStorageKey,
|
||||
storageKey,
|
||||
tintStorageKey,
|
||||
]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
theme: themePolarity,
|
||||
setTheme: setThemePolarity,
|
||||
themeColor,
|
||||
setThemeColor,
|
||||
themePalette,
|
||||
setThemePalette,
|
||||
themePrimaryColor,
|
||||
setThemePrimaryColor,
|
||||
themePolarity,
|
||||
setThemePolarity,
|
||||
resolvedPolarity,
|
||||
themeTint,
|
||||
setThemeTint,
|
||||
themeBorderRadius,
|
||||
setThemeBorderRadius,
|
||||
themeCustomCss,
|
||||
setThemeCustomCss,
|
||||
}),
|
||||
[
|
||||
resolvedPolarity,
|
||||
setThemeBorderRadius,
|
||||
setThemeColor,
|
||||
setThemeCustomCss,
|
||||
setThemePalette,
|
||||
setThemePolarity,
|
||||
setThemePrimaryColor,
|
||||
setThemeTint,
|
||||
themeColor,
|
||||
themeBorderRadius,
|
||||
themeCustomCss,
|
||||
themePalette,
|
||||
themePolarity,
|
||||
themePrimaryColor,
|
||||
themeTint,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
10
src/theme/index.ts
Normal file
10
src/theme/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export * from "./types";
|
||||
export * from "./utils";
|
||||
export * from "./ThemeProvider";
|
||||
export * from "./pickers/ColorPicker";
|
||||
export * from "./pickers/PolarityPicker";
|
||||
export * from "./pickers/PrimaryPicker";
|
||||
export * from "./pickers/Base16Picker";
|
||||
export * from "./pickers/BorderRadiusPicker";
|
||||
export * from "./pickers/CustomCssPicker";
|
||||
export * from "./pickers/StylePicker";
|
||||
68
src/theme/pickers/Base16Picker.tsx
Normal file
68
src/theme/pickers/Base16Picker.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import * as React from "react";
|
||||
import { Trash } from "lucide-react";
|
||||
|
||||
import { Button, Textarea } from "../../index";
|
||||
import { useTheme } from "../ThemeProvider";
|
||||
import { formatPaletteJson, parseBase16Palette } from "../utils";
|
||||
|
||||
export function Base16Picker() {
|
||||
const { themePalette, setThemePalette } = useTheme();
|
||||
const [jsonValue, setJsonValue] = React.useState(() =>
|
||||
formatPaletteJson(themePalette),
|
||||
);
|
||||
const [error, setError] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
setJsonValue(formatPaletteJson(themePalette));
|
||||
}, [themePalette]);
|
||||
|
||||
const applyJson = () => {
|
||||
try {
|
||||
const palette = parseBase16Palette(JSON.parse(jsonValue));
|
||||
|
||||
if (!palette) {
|
||||
setError(
|
||||
"Palette JSON must include base00 through base0F as #rrggbb values.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
setThemePalette(palette);
|
||||
} catch {
|
||||
setError("Palette JSON is not valid JSON.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Textarea
|
||||
className="h-80 resize-none font-mono text-xs leading-relaxed"
|
||||
value={jsonValue}
|
||||
onChange={(event) => setJsonValue(event.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" onClick={applyJson}>
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
aria-label="Clear palette"
|
||||
title="Clear palette"
|
||||
disabled={themePalette === null}
|
||||
onClick={() => {
|
||||
setError("");
|
||||
setThemePalette(null);
|
||||
}}
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/theme/pickers/BorderRadiusPicker.tsx
Normal file
43
src/theme/pickers/BorderRadiusPicker.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { Slider, Button } from "../../index";
|
||||
import { useTheme } from "../ThemeProvider";
|
||||
import { Trash } from "lucide-react";
|
||||
import {
|
||||
DEFAULT_BORDER_RADIUS,
|
||||
MAX_BORDER_RADIUS,
|
||||
MIN_BORDER_RADIUS,
|
||||
} from "../utils";
|
||||
|
||||
export function BorderRadiusPicker() {
|
||||
const { themeBorderRadius, setThemeBorderRadius } = useTheme();
|
||||
const updateBorderRadius = (value: number | readonly number[]) => {
|
||||
setThemeBorderRadius(Array.isArray(value) ? (value[0] ?? 0) : value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-65 flex-col gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex flex-col justify-start flex-1 gap-1">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{themeBorderRadius.toFixed(3)}
|
||||
</span>
|
||||
<Slider
|
||||
className="w-full"
|
||||
min={MIN_BORDER_RADIUS}
|
||||
max={MAX_BORDER_RADIUS}
|
||||
step={0.025}
|
||||
value={[themeBorderRadius]}
|
||||
onValueChange={updateBorderRadius}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="h-8 w-8"
|
||||
variant="destructive"
|
||||
disabled={themeBorderRadius === DEFAULT_BORDER_RADIUS}
|
||||
onClick={() => updateBorderRadius(DEFAULT_BORDER_RADIUS)}
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/theme/pickers/ColorPicker.tsx
Normal file
44
src/theme/pickers/ColorPicker.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { Trash } from "lucide-react";
|
||||
import { Button, Input } from "../../index";
|
||||
import { COLOR_PICKER_FALLBACK } from "../utils";
|
||||
|
||||
export function ColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
fallback = COLOR_PICKER_FALLBACK,
|
||||
allowEmpty = false,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
value: string;
|
||||
onChange: (color: string) => void;
|
||||
fallback?: string;
|
||||
allowEmpty?: boolean;
|
||||
}) {
|
||||
const isEmpty = value === "";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-65">
|
||||
<input
|
||||
className="w-9 h-9 rounded-md aspect-square"
|
||||
type="color"
|
||||
value={value || fallback}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
className="No color selected"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
{allowEmpty && (
|
||||
<Button
|
||||
className="h-8 w-8"
|
||||
variant="destructive"
|
||||
disabled={isEmpty}
|
||||
onClick={() => onChange("")}
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/theme/pickers/CustomCssPicker.tsx
Normal file
34
src/theme/pickers/CustomCssPicker.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { Button } from "../../cmp/button";
|
||||
import { Textarea } from "../../cmp/textarea";
|
||||
import { useTheme } from "../ThemeProvider";
|
||||
|
||||
const CUSTOM_CSS_PLACEHOLDER = `* {
|
||||
color: black;
|
||||
}`;
|
||||
|
||||
export function CustomCssPicker() {
|
||||
const { themeCustomCss, setThemeCustomCss } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="flex w-90 flex-col gap-2">
|
||||
<Textarea
|
||||
value={themeCustomCss}
|
||||
onChange={(event) => setThemeCustomCss(event.currentTarget.value)}
|
||||
placeholder={CUSTOM_CSS_PLACEHOLDER}
|
||||
spellCheck={false}
|
||||
className="h-80 resize-none font-mono text-xs leading-relaxed"
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setThemeCustomCss("")}
|
||||
disabled={themeCustomCss === ""}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/theme/pickers/PolarityPicker.tsx
Normal file
38
src/theme/pickers/PolarityPicker.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../cmp/select";
|
||||
import type { ThemePolarity } from "../types";
|
||||
import { useTheme } from "../ThemeProvider";
|
||||
|
||||
export function PolarityPicker() {
|
||||
const { themePolarity, setThemePolarity } = useTheme();
|
||||
|
||||
return (
|
||||
<label className="flex flex-col gap-2 text-sm font-medium">
|
||||
Color scheme
|
||||
<Select
|
||||
value={themePolarity}
|
||||
onValueChange={(value) => setThemePolarity(value as ThemePolarity)}
|
||||
>
|
||||
<SelectTrigger className="w-65">
|
||||
<SelectValue>
|
||||
{themePolarity === "system"
|
||||
? "System"
|
||||
: themePolarity === "dark"
|
||||
? "Dark"
|
||||
: "Light"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
15
src/theme/pickers/PrimaryPicker.tsx
Normal file
15
src/theme/pickers/PrimaryPicker.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { ColorPicker } from "./ColorPicker";
|
||||
import { useTheme } from "../ThemeProvider";
|
||||
|
||||
export function PrimaryPicker() {
|
||||
const { themePrimaryColor, setThemePrimaryColor } = useTheme();
|
||||
|
||||
return (
|
||||
<ColorPicker
|
||||
label="Primary color"
|
||||
value={themePrimaryColor}
|
||||
onChange={setThemePrimaryColor}
|
||||
allowEmpty
|
||||
/>
|
||||
);
|
||||
}
|
||||
58
src/theme/pickers/StylePicker.tsx
Normal file
58
src/theme/pickers/StylePicker.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { Base16Picker } from "./Base16Picker";
|
||||
import { BorderRadiusPicker } from "./BorderRadiusPicker";
|
||||
import { CustomCssPicker } from "./CustomCssPicker";
|
||||
import { PolarityPicker } from "./PolarityPicker";
|
||||
import { PrimaryPicker } from "./PrimaryPicker";
|
||||
import { TintPicker } from "./TintPicker";
|
||||
|
||||
type StyleCategoryProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function StyleCategory({ title, children }: StyleCategoryProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StylePicker() {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5">
|
||||
<PolarityPicker />
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(16rem,100%),15rem))] gap-8">
|
||||
<div className="flex shrink-0 flex-col gap-8">
|
||||
<StyleCategory title="Tint">
|
||||
<TintPicker />
|
||||
</StyleCategory>
|
||||
|
||||
<StyleCategory title="Primary Color">
|
||||
<PrimaryPicker />
|
||||
</StyleCategory>
|
||||
|
||||
<StyleCategory title="Border Radius">
|
||||
<BorderRadiusPicker />
|
||||
</StyleCategory>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<StyleCategory title="Base16">
|
||||
<Base16Picker />
|
||||
</StyleCategory>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<StyleCategory title="Custom CSS">
|
||||
<CustomCssPicker />
|
||||
</StyleCategory>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/theme/pickers/TintPicker.tsx
Normal file
64
src/theme/pickers/TintPicker.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../cmp/select";
|
||||
import { useTheme } from "../ThemeProvider";
|
||||
import type { ThemeTint } from "../types";
|
||||
import { THEME_TINT_OPTIONS } from "../utils";
|
||||
|
||||
import { ColorPicker } from "./ColorPicker";
|
||||
|
||||
export function TintPicker() {
|
||||
const {
|
||||
themeTint,
|
||||
setThemeTint,
|
||||
themeColor,
|
||||
setThemeColor,
|
||||
setThemePalette,
|
||||
} = useTheme();
|
||||
|
||||
const setSingleColor = (color: string) => {
|
||||
setThemePalette(null);
|
||||
setThemeColor(color);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<ColorPicker
|
||||
label="Theme color"
|
||||
value={themeColor}
|
||||
onChange={(color) => {
|
||||
if (color === "") {
|
||||
setThemePalette(null);
|
||||
setThemeColor("");
|
||||
return;
|
||||
}
|
||||
|
||||
setSingleColor(color);
|
||||
}}
|
||||
allowEmpty
|
||||
/>
|
||||
<Select
|
||||
value={themeTint}
|
||||
onValueChange={(value) => setThemeTint(value as ThemeTint)}
|
||||
>
|
||||
<SelectTrigger className="w-65">
|
||||
<SelectValue>
|
||||
{THEME_TINT_OPTIONS.find((option) => option.value === themeTint)
|
||||
?.label ?? "Soft"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THEME_TINT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
src/theme/types.ts
Normal file
69
src/theme/types.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import type * as React from "react";
|
||||
|
||||
export type ThemePolarity = "dark" | "light" | "system";
|
||||
export type ResolvedThemePolarity = "dark" | "light";
|
||||
export type ThemeTint = "soft" | "hard" | "extreme";
|
||||
export type Base16Key =
|
||||
| "base00"
|
||||
| "base01"
|
||||
| "base02"
|
||||
| "base03"
|
||||
| "base04"
|
||||
| "base05"
|
||||
| "base06"
|
||||
| "base07"
|
||||
| "base08"
|
||||
| "base09"
|
||||
| "base0A"
|
||||
| "base0B"
|
||||
| "base0C"
|
||||
| "base0D"
|
||||
| "base0E"
|
||||
| "base0F";
|
||||
|
||||
export type Base16Palette = Record<Base16Key, string>;
|
||||
|
||||
export type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: ThemePolarity;
|
||||
defaultColor?: string;
|
||||
defaultPalette?: Base16Palette | null;
|
||||
defaultPrimaryColor?: string;
|
||||
defaultTint?: ThemeTint;
|
||||
defaultBorderRadius?: number;
|
||||
defaultCustomCss?: string;
|
||||
storageKey?: string | null;
|
||||
colorStorageKey?: string | null;
|
||||
paletteStorageKey?: string | null;
|
||||
primaryColorStorageKey?: string | null;
|
||||
tintStorageKey?: string | null;
|
||||
borderRadiusStorageKey?: string | null;
|
||||
customCssStorageKey?: string | null;
|
||||
disableTransitionOnChange?: boolean;
|
||||
};
|
||||
|
||||
export type ThemeProviderState = {
|
||||
theme: ThemePolarity;
|
||||
setTheme: (theme: ThemePolarity) => void;
|
||||
themeColor: string;
|
||||
setThemeColor: (color: string) => void;
|
||||
themePalette: Base16Palette | null;
|
||||
setThemePalette: (palette: Base16Palette | null) => void;
|
||||
themePrimaryColor: string;
|
||||
setThemePrimaryColor: (color: string) => void;
|
||||
themePolarity: ThemePolarity;
|
||||
setThemePolarity: (polarity: ThemePolarity) => void;
|
||||
resolvedPolarity: ResolvedThemePolarity;
|
||||
themeTint: ThemeTint;
|
||||
setThemeTint: (tint: ThemeTint) => void;
|
||||
themeBorderRadius: number;
|
||||
setThemeBorderRadius: (radius: number) => void;
|
||||
themeCustomCss: string;
|
||||
setThemeCustomCss: (css: string) => void;
|
||||
};
|
||||
|
||||
export type HslColor = {
|
||||
h: number;
|
||||
s: number;
|
||||
l: number;
|
||||
};
|
||||
563
src/theme/utils.ts
Normal file
563
src/theme/utils.ts
Normal file
|
|
@ -0,0 +1,563 @@
|
|||
import config from "../config.json" with { type: "json" };
|
||||
|
||||
import type {
|
||||
Base16Key,
|
||||
Base16Palette,
|
||||
HslColor,
|
||||
ResolvedThemePolarity,
|
||||
ThemePolarity,
|
||||
ThemeTint,
|
||||
} from "./types";
|
||||
|
||||
export const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)";
|
||||
export const THEME_VALUES: ThemePolarity[] = ["dark", "light", "system"];
|
||||
export const BASE16_KEYS: Base16Key[] = [
|
||||
"base00",
|
||||
"base01",
|
||||
"base02",
|
||||
"base03",
|
||||
"base04",
|
||||
"base05",
|
||||
"base06",
|
||||
"base07",
|
||||
"base08",
|
||||
"base09",
|
||||
"base0A",
|
||||
"base0B",
|
||||
"base0C",
|
||||
"base0D",
|
||||
"base0E",
|
||||
"base0F",
|
||||
];
|
||||
export const THEME_TINT_OPTIONS = [
|
||||
{ value: "soft", label: "Soft" },
|
||||
{ value: "hard", label: "Hard" },
|
||||
{ value: "extreme", label: "Extreme" },
|
||||
] as const satisfies { value: ThemeTint; label: string }[];
|
||||
|
||||
export const TINT_VALUES = THEME_TINT_OPTIONS.map((option) => option.value);
|
||||
export const DEFAULT_THEME_COLOR = "";
|
||||
export const DEFAULT_BORDER_RADIUS = 0.5;
|
||||
export const MIN_BORDER_RADIUS = 0;
|
||||
export const MAX_BORDER_RADIUS = 1.5;
|
||||
export const COLOR_PICKER_FALLBACK = "#2f9b9b";
|
||||
export const DEFAULT_BASE16_PALETTE: Base16Palette = {
|
||||
base00: "#151718",
|
||||
base01: "#1d2224",
|
||||
base02: "#2c3336",
|
||||
base03: "#6b767a",
|
||||
base04: "#8f9a9e",
|
||||
base05: "#d7dee0",
|
||||
base06: "#edf1f2",
|
||||
base07: "#ffffff",
|
||||
base08: "#e05f65",
|
||||
base09: "#d98f45",
|
||||
base0A: "#d1b85c",
|
||||
base0B: "#7dbb72",
|
||||
base0C: "#65b8b4",
|
||||
base0D: "#5da8c7",
|
||||
base0E: "#b18fd6",
|
||||
base0F: "#c58b70",
|
||||
};
|
||||
export const THEME_VARIABLE_NAMES = [
|
||||
"--background",
|
||||
"--foreground",
|
||||
"--card",
|
||||
"--card-foreground",
|
||||
"--popover",
|
||||
"--popover-foreground",
|
||||
"--primary",
|
||||
"--primary-foreground",
|
||||
"--primary-foreground-alt",
|
||||
"--secondary",
|
||||
"--secondary-foreground",
|
||||
"--muted",
|
||||
"--muted-foreground",
|
||||
"--accent",
|
||||
"--accent-foreground",
|
||||
"--destructive",
|
||||
"--border",
|
||||
"--input",
|
||||
"--ring",
|
||||
"--chart-1",
|
||||
"--chart-2",
|
||||
"--chart-3",
|
||||
"--chart-4",
|
||||
"--chart-5",
|
||||
"--sidebar",
|
||||
"--sidebar-foreground",
|
||||
"--sidebar-primary",
|
||||
"--sidebar-primary-foreground",
|
||||
"--sidebar-accent",
|
||||
"--sidebar-accent-foreground",
|
||||
"--sidebar-border",
|
||||
"--sidebar-ring",
|
||||
];
|
||||
|
||||
export function isTheme(value: string | null): value is ThemePolarity {
|
||||
if (value === null) return false;
|
||||
return THEME_VALUES.includes(value as ThemePolarity);
|
||||
}
|
||||
|
||||
export function isTint(value: string | null): value is ThemeTint {
|
||||
if (value === null) return false;
|
||||
return TINT_VALUES.includes(value as ThemeTint);
|
||||
}
|
||||
|
||||
export function isHexColor(value: string | null): value is string {
|
||||
return value !== null && /^#[0-9a-f]{6}$/i.test(value);
|
||||
}
|
||||
|
||||
export function isThemeColor(value: string | null): value is string {
|
||||
return value === "" || isHexColor(value);
|
||||
}
|
||||
|
||||
export function parseBorderRadius(value: string | null) {
|
||||
if (value === null || value === "") return null;
|
||||
|
||||
const radius = Number(value);
|
||||
if (!Number.isFinite(radius)) return null;
|
||||
|
||||
return clamp(radius, MIN_BORDER_RADIUS, MAX_BORDER_RADIUS);
|
||||
}
|
||||
|
||||
export function parseBase16Palette(value: unknown): Base16Palette | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const palette: Partial<Base16Palette> = {};
|
||||
const record = value as Record<string, unknown>;
|
||||
|
||||
for (const key of BASE16_KEYS) {
|
||||
const color = record[key];
|
||||
|
||||
if (typeof color !== "string" || !isHexColor(color)) return null;
|
||||
palette[key] = color.toLowerCase();
|
||||
}
|
||||
|
||||
return palette as Base16Palette;
|
||||
}
|
||||
|
||||
export function parseStoredBase16Palette(value: string | null) {
|
||||
if (value === null || value === "") return null;
|
||||
|
||||
try {
|
||||
return parseBase16Palette(JSON.parse(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeBase16Palette(palette: Base16Palette | null) {
|
||||
return palette === null ? "" : JSON.stringify(palette);
|
||||
}
|
||||
|
||||
export function formatPaletteJson(palette: Base16Palette | null) {
|
||||
return JSON.stringify(palette ?? DEFAULT_BASE16_PALETTE, null, 2);
|
||||
}
|
||||
|
||||
export function getSystemTheme(): ResolvedThemePolarity {
|
||||
if (window.matchMedia(COLOR_SCHEME_QUERY).matches) return "dark";
|
||||
return "light";
|
||||
}
|
||||
|
||||
export function disableTransitionsTemporarily() {
|
||||
const style = document.createElement("style");
|
||||
style.appendChild(
|
||||
document.createTextNode(
|
||||
"*,*::before,*::after{-webkit-transition:none!important;transition:none!important}",
|
||||
),
|
||||
);
|
||||
document.head.appendChild(style);
|
||||
|
||||
return () => {
|
||||
window.getComputedStyle(document.body);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
style.remove();
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function smoothToward(value: number, target: number, smoothness: number) {
|
||||
return value + (target - value) * smoothness;
|
||||
}
|
||||
|
||||
function hexToHsl(hex: string): HslColor {
|
||||
const value = isHexColor(hex) ? hex : COLOR_PICKER_FALLBACK;
|
||||
const red = Number.parseInt(value.slice(1, 3), 16) / 255;
|
||||
const green = Number.parseInt(value.slice(3, 5), 16) / 255;
|
||||
const blue = Number.parseInt(value.slice(5, 7), 16) / 255;
|
||||
const max = Math.max(red, green, blue);
|
||||
const min = Math.min(red, green, blue);
|
||||
const lightness = (max + min) / 2;
|
||||
const delta = max - min;
|
||||
|
||||
if (delta === 0) return { h: 0, s: 0, l: Math.round(lightness * 100) };
|
||||
|
||||
const saturation = delta / (1 - Math.abs(2 * lightness - 1));
|
||||
let hue = 0;
|
||||
|
||||
if (max === red) hue = ((green - blue) / delta) % 6;
|
||||
else if (max === green) hue = (blue - red) / delta + 2;
|
||||
else hue = (red - green) / delta + 4;
|
||||
|
||||
return {
|
||||
h: Math.round((hue * 60 + 360) % 360),
|
||||
s: Math.round(saturation * 100),
|
||||
l: Math.round(lightness * 100),
|
||||
};
|
||||
}
|
||||
|
||||
function hsl({ h, s, l }: HslColor, alpha?: number) {
|
||||
const base = `hsl(${Math.round(h)} ${Math.round(s)}% ${Math.round(l)}%`;
|
||||
return alpha === undefined ? `${base})` : `${base} / ${alpha})`;
|
||||
}
|
||||
|
||||
function withHsl(color: HslColor, next: Partial<HslColor>) {
|
||||
return {
|
||||
h: next.h ?? color.h,
|
||||
s: clamp(next.s ?? color.s, 0, 100),
|
||||
l: clamp(next.l ?? color.l, 0, 100),
|
||||
};
|
||||
}
|
||||
|
||||
function getForegroundFor(lightness: number) {
|
||||
return lightness > 58 ? "hsl(222 47% 11%)" : "hsl(0 0% 100%)";
|
||||
}
|
||||
|
||||
export function getThemeVariables(
|
||||
color: string,
|
||||
polarity: ResolvedThemePolarity,
|
||||
tint: ThemeTint,
|
||||
) {
|
||||
const source = hexToHsl(color);
|
||||
const tintConfig = config.theme[tint];
|
||||
const colorIntensity = tintConfig.colorIntensity;
|
||||
const borderIntensity = tintConfig.borderIntensity;
|
||||
const backgroundSmoothness = tintConfig.backgroundSmoothness;
|
||||
const darkBackgroundLightness = tint === "soft" ? 12 : 8;
|
||||
const lightBackgroundLightness =
|
||||
tint === "soft" ? 99 : tint === "hard" ? 98 : 97;
|
||||
const smoothDark = (lightness: number) =>
|
||||
smoothToward(lightness, darkBackgroundLightness, backgroundSmoothness);
|
||||
const smoothLight = (lightness: number) =>
|
||||
smoothToward(lightness, lightBackgroundLightness, backgroundSmoothness);
|
||||
const borderAlpha = (alpha: number) => clamp(alpha * borderIntensity, 0, 1);
|
||||
const smoothLightBorder = (lightness: number) =>
|
||||
smoothToward(
|
||||
smoothLight(lightness),
|
||||
lightBackgroundLightness,
|
||||
clamp(1 - borderIntensity, 0, 1),
|
||||
);
|
||||
const primary = withHsl(source, {
|
||||
s: clamp(source.s * clamp(colorIntensity * 1.08, 0.66, 1.18), 24, 96),
|
||||
l:
|
||||
polarity === "dark"
|
||||
? clamp(
|
||||
source.l + (tint === "extreme" ? -1 : tint === "hard" ? 2 : 9),
|
||||
34,
|
||||
68,
|
||||
)
|
||||
: clamp(
|
||||
source.l - (tint === "extreme" ? 8 : tint === "hard" ? 5 : -4),
|
||||
25,
|
||||
62,
|
||||
),
|
||||
});
|
||||
const quietSaturation = clamp(source.s * colorIntensity, 6, 72);
|
||||
|
||||
if (polarity === "dark") {
|
||||
return {
|
||||
"--background": hsl(
|
||||
withHsl(source, {
|
||||
s: quietSaturation * 0.13,
|
||||
l: darkBackgroundLightness,
|
||||
}),
|
||||
),
|
||||
"--foreground": "hsl(210 40% 98%)",
|
||||
"--card": hsl(
|
||||
withHsl(source, {
|
||||
s: quietSaturation * 0.18,
|
||||
l: smoothDark(tint === "extreme" ? 11 : tint === "hard" ? 13 : 14),
|
||||
}),
|
||||
),
|
||||
"--card-foreground": "hsl(210 40% 98%)",
|
||||
"--popover": hsl(
|
||||
withHsl(source, {
|
||||
s: quietSaturation * 0.2,
|
||||
l: smoothDark(tint === "extreme" ? 9 : tint === "hard" ? 11 : 13),
|
||||
}),
|
||||
),
|
||||
"--popover-foreground": "hsl(210 40% 98%)",
|
||||
"--primary": hsl(primary),
|
||||
"--primary-foreground": getForegroundFor(primary.l),
|
||||
"--primary-foreground-alt": hsl(
|
||||
withHsl(primary, { l: clamp(primary.l + 16, 55, 82) }),
|
||||
),
|
||||
"--secondary": hsl(
|
||||
withHsl(source, {
|
||||
s: quietSaturation * 0.22,
|
||||
l: smoothDark(tint === "extreme" ? 18 : tint === "hard" ? 20 : 19),
|
||||
}),
|
||||
),
|
||||
"--secondary-foreground": "hsl(210 40% 98%)",
|
||||
"--muted": hsl(
|
||||
withHsl(source, {
|
||||
s: quietSaturation * 0.16,
|
||||
l: smoothDark(tint === "extreme" ? 16 : tint === "hard" ? 18 : 18),
|
||||
}),
|
||||
),
|
||||
"--muted-foreground": "hsl(215 20% 68%)",
|
||||
"--accent": hsl(
|
||||
withHsl(source, {
|
||||
s: quietSaturation * 0.4,
|
||||
l: smoothDark(tint === "extreme" ? 19 : tint === "hard" ? 22 : 20),
|
||||
}),
|
||||
),
|
||||
"--accent-foreground": "hsl(210 40% 98%)",
|
||||
"--destructive": "hsl(0 72% 51%)",
|
||||
"--border": hsl(
|
||||
withHsl(source, {
|
||||
s: quietSaturation * 0.25,
|
||||
l: smoothDark(tint === "extreme" ? 30 : tint === "hard" ? 27 : 21),
|
||||
}),
|
||||
borderAlpha(tint === "soft" ? 0.5 : 0.65),
|
||||
),
|
||||
"--input": hsl(
|
||||
withHsl(source, {
|
||||
s: quietSaturation * 0.28,
|
||||
l: smoothDark(tint === "extreme" ? 32 : tint === "hard" ? 29 : 22),
|
||||
}),
|
||||
borderAlpha(tint === "soft" ? 0.58 : 0.72),
|
||||
),
|
||||
"--ring": hsl(withHsl(primary, { l: clamp(primary.l + 8, 45, 75) })),
|
||||
"--chart-1": hsl(
|
||||
withHsl(source, { h: source.h, s: quietSaturation + 16, l: 70 }),
|
||||
),
|
||||
"--chart-2": hsl(
|
||||
withHsl(source, { h: source.h + 32, s: quietSaturation + 12, l: 63 }),
|
||||
),
|
||||
"--chart-3": hsl(
|
||||
withHsl(source, { h: source.h + 64, s: quietSaturation + 8, l: 57 }),
|
||||
),
|
||||
"--chart-4": hsl(
|
||||
withHsl(source, { h: source.h + 96, s: quietSaturation + 4, l: 52 }),
|
||||
),
|
||||
"--chart-5": hsl(
|
||||
withHsl(source, { h: source.h + 128, s: quietSaturation, l: 47 }),
|
||||
),
|
||||
"--sidebar": hsl(
|
||||
withHsl(source, {
|
||||
s: quietSaturation * 0.16,
|
||||
l: tint === "extreme" ? 8 : tint === "hard" ? 10 : 12,
|
||||
}),
|
||||
),
|
||||
"--sidebar-foreground": "hsl(210 40% 98%)",
|
||||
"--sidebar-primary": hsl(primary),
|
||||
"--sidebar-primary-foreground": getForegroundFor(primary.l),
|
||||
"--sidebar-accent": hsl(
|
||||
withHsl(source, {
|
||||
s: quietSaturation * 0.36,
|
||||
l: smoothDark(tint === "extreme" ? 17 : tint === "hard" ? 20 : 18),
|
||||
}),
|
||||
),
|
||||
"--sidebar-accent-foreground": "hsl(210 40% 98%)",
|
||||
"--sidebar-border": hsl(
|
||||
withHsl(source, {
|
||||
s: quietSaturation * 0.22,
|
||||
l: smoothDark(tint === "soft" ? 20 : 25),
|
||||
}),
|
||||
borderAlpha(tint === "soft" ? 0.55 : 0.7),
|
||||
),
|
||||
"--sidebar-ring": hsl(primary),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
"--background": hsl(
|
||||
withHsl(source, {
|
||||
s: quietSaturation * 0.12,
|
||||
l: lightBackgroundLightness,
|
||||
}),
|
||||
),
|
||||
"--foreground": "hsl(222 47% 11%)",
|
||||
"--card":
|
||||
tint === "soft"
|
||||
? hsl(
|
||||
withHsl(source, { s: quietSaturation * 0.08, l: smoothLight(99) }),
|
||||
)
|
||||
: "hsl(0 0% 100%)",
|
||||
"--card-foreground": "hsl(222 47% 11%)",
|
||||
"--popover":
|
||||
tint === "soft"
|
||||
? hsl(
|
||||
withHsl(source, { s: quietSaturation * 0.08, l: smoothLight(99) }),
|
||||
)
|
||||
: "hsl(0 0% 100%)",
|
||||
"--popover-foreground": "hsl(222 47% 11%)",
|
||||
"--primary": hsl(primary),
|
||||
"--primary-foreground": getForegroundFor(primary.l),
|
||||
"--primary-foreground-alt": hsl(
|
||||
withHsl(primary, { l: clamp(primary.l - 8, 24, 48) }),
|
||||
),
|
||||
"--secondary": hsl(
|
||||
withHsl(source, {
|
||||
s: quietSaturation * 0.16,
|
||||
l: smoothLight(tint === "extreme" ? 87 : tint === "hard" ? 90 : 96),
|
||||
}),
|
||||
),
|
||||
"--secondary-foreground": "hsl(222 47% 11%)",
|
||||
"--muted": hsl(
|
||||
withHsl(source, {
|
||||
s: quietSaturation * 0.12,
|
||||
l: smoothLight(tint === "extreme" ? 88 : tint === "hard" ? 91 : 97),
|
||||
}),
|
||||
),
|
||||
"--muted-foreground": "hsl(215 16% 43%)",
|
||||
"--accent": hsl(
|
||||
withHsl(source, {
|
||||
s: quietSaturation * 0.34,
|
||||
l: smoothLight(tint === "extreme" ? 84 : tint === "hard" ? 87 : 96),
|
||||
}),
|
||||
),
|
||||
"--accent-foreground": "hsl(222 47% 11%)",
|
||||
"--destructive": "hsl(0 84% 60%)",
|
||||
"--border": hsl(
|
||||
withHsl(source, {
|
||||
s: quietSaturation * 0.15,
|
||||
l: smoothLightBorder(
|
||||
tint === "extreme" ? 78 : tint === "hard" ? 82 : 92,
|
||||
),
|
||||
}),
|
||||
),
|
||||
"--input": hsl(
|
||||
withHsl(source, {
|
||||
s: quietSaturation * 0.16,
|
||||
l: smoothLightBorder(
|
||||
tint === "extreme" ? 76 : tint === "hard" ? 80 : 91,
|
||||
),
|
||||
}),
|
||||
),
|
||||
"--ring": hsl(withHsl(primary, { l: clamp(primary.l + 4, 34, 64) })),
|
||||
"--chart-1": hsl(
|
||||
withHsl(source, { h: source.h, s: quietSaturation + 16, l: 72 }),
|
||||
),
|
||||
"--chart-2": hsl(
|
||||
withHsl(source, { h: source.h + 32, s: quietSaturation + 12, l: 64 }),
|
||||
),
|
||||
"--chart-3": hsl(
|
||||
withHsl(source, { h: source.h + 64, s: quietSaturation + 8, l: 56 }),
|
||||
),
|
||||
"--chart-4": hsl(
|
||||
withHsl(source, { h: source.h + 96, s: quietSaturation + 4, l: 48 }),
|
||||
),
|
||||
"--chart-5": hsl(
|
||||
withHsl(source, { h: source.h + 128, s: quietSaturation, l: 40 }),
|
||||
),
|
||||
"--sidebar": hsl(
|
||||
withHsl(source, {
|
||||
s: quietSaturation * 0.12,
|
||||
l: tint === "extreme" ? 93 : tint === "hard" ? 95 : 98,
|
||||
}),
|
||||
),
|
||||
"--sidebar-foreground": "hsl(222 47% 11%)",
|
||||
"--sidebar-primary": hsl(primary),
|
||||
"--sidebar-primary-foreground": getForegroundFor(primary.l),
|
||||
"--sidebar-accent": hsl(
|
||||
withHsl(source, {
|
||||
s: quietSaturation * 0.28,
|
||||
l: smoothLight(tint === "extreme" ? 85 : tint === "hard" ? 88 : 96),
|
||||
}),
|
||||
),
|
||||
"--sidebar-accent-foreground": "hsl(222 47% 11%)",
|
||||
"--sidebar-border": hsl(
|
||||
withHsl(source, {
|
||||
s: quietSaturation * 0.14,
|
||||
l: smoothLightBorder(
|
||||
tint === "extreme" ? 79 : tint === "hard" ? 83 : 92,
|
||||
),
|
||||
}),
|
||||
),
|
||||
"--sidebar-ring": hsl(primary),
|
||||
};
|
||||
}
|
||||
|
||||
export function getBase16ThemeVariables(palette: Base16Palette) {
|
||||
return {
|
||||
"--background": palette.base00,
|
||||
"--foreground": palette.base05,
|
||||
"--card": palette.base01,
|
||||
"--card-foreground": palette.base05,
|
||||
"--popover": palette.base01,
|
||||
"--popover-foreground": palette.base05,
|
||||
"--primary": palette.base0D,
|
||||
"--primary-foreground": palette.base00,
|
||||
"--primary-foreground-alt": palette.base0C,
|
||||
"--secondary": palette.base01,
|
||||
"--secondary-foreground": palette.base05,
|
||||
"--muted": palette.base01,
|
||||
"--muted-foreground": palette.base04,
|
||||
"--accent": palette.base0B,
|
||||
"--accent-foreground": palette.base05,
|
||||
"--destructive": palette.base08,
|
||||
"--border": palette.base02,
|
||||
"--input": palette.base02,
|
||||
"--ring": palette.base03,
|
||||
"--chart-1": palette.base0B,
|
||||
"--chart-2": palette.base0C,
|
||||
"--chart-3": palette.base0D,
|
||||
"--chart-4": palette.base0E,
|
||||
"--chart-5": palette.base0A,
|
||||
"--sidebar": palette.base00,
|
||||
"--sidebar-foreground": palette.base05,
|
||||
"--sidebar-primary": palette.base0D,
|
||||
"--sidebar-primary-foreground": palette.base00,
|
||||
"--sidebar-accent": palette.base01,
|
||||
"--sidebar-accent-foreground": palette.base05,
|
||||
"--sidebar-border": palette.base02,
|
||||
"--sidebar-ring": palette.base03,
|
||||
};
|
||||
}
|
||||
|
||||
export function getPrimaryThemeVariables(color: string) {
|
||||
const primary = hexToHsl(color);
|
||||
|
||||
return {
|
||||
"--primary": color,
|
||||
"--primary-foreground": getForegroundFor(primary.l),
|
||||
"--primary-foreground-alt": hsl(
|
||||
withHsl(primary, {
|
||||
l:
|
||||
primary.l > 58
|
||||
? clamp(primary.l - 18, 18, 62)
|
||||
: clamp(primary.l + 18, 42, 86),
|
||||
}),
|
||||
),
|
||||
"--ring": color,
|
||||
"--sidebar-primary": color,
|
||||
"--sidebar-primary-foreground": getForegroundFor(primary.l),
|
||||
};
|
||||
}
|
||||
|
||||
export function readStoredValue<T extends string>(
|
||||
key: string | null | undefined,
|
||||
validate: (value: string | null) => value is T,
|
||||
fallback: T,
|
||||
) {
|
||||
if (!key) return fallback;
|
||||
|
||||
const value = localStorage.getItem(key);
|
||||
return validate(value) ? value : fallback;
|
||||
}
|
||||
|
||||
export function saveStoredValue(key: string | null | undefined, value: string) {
|
||||
if (!key) return;
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
Loading…
Reference in a new issue