(feat): initial stuff
All checks were successful
/ package (push) Successful in 1m6s

This commit is contained in:
Alois 2026-07-26 02:56:07 +02:00
commit 29098d1f61
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
40 changed files with 3113 additions and 515 deletions

View file

@ -51,6 +51,8 @@ function Button({
return (
<ButtonPrimitive
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>

View file

@ -12,7 +12,7 @@ function Card({
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground border has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
"group/card flex flex-col gap-4 overflow-hidden rounded-xl border border-dotted bg-card/30 py-4 text-sm text-card-foreground has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className,
)}
{...props}

View file

@ -14,6 +14,9 @@ import { Button } from "./button";
interface OnboardingStep {
id: string;
title: ReactNode;
description?: ReactNode;
logo?: ReactNode;
content: ReactNode;
defaultCanContinue?: boolean;
}
@ -51,6 +54,9 @@ function OnboardingFlow({
steps: OnboardingStep[];
onFinish: () => void | Promise<void>;
}) {
if (steps.length === 0) {
throw new Error("OnboardingFlow requires at least one step");
}
const [stepIndex, setStepIndex] = useState(0);
const [controls, setControls] = useState<OnboardingStepControls>(() =>
defaultControls(steps[0]),
@ -90,7 +96,7 @@ function OnboardingFlow({
useEffect(() => {
const heading = document.querySelector<HTMLElement>(
`[data-onboarding-step="${activeStep.id}"] h1`,
`[data-onboarding-step="${activeStep.id}"] [data-onboarding-title]`,
);
heading?.focus();
}, [activeStep.id]);
@ -102,8 +108,27 @@ function OnboardingFlow({
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
data-onboarding-step={activeStep.id}
>
<header className="mx-auto flex w-full max-w-6xl items-start gap-4 px-6 pt-12 pb-7 md:px-10 md:pt-16">
{activeStep.logo}
<div className="min-w-0 flex-1">
<h1
data-onboarding-title
className="font-heading text-3xl font-bold md:text-4xl"
tabIndex={-1}
>
{activeStep.title}
</h1>
{activeStep.description && (
<div className="mt-2 text-lg text-muted-foreground">
{activeStep.description}
</div>
)}
</div>
</header>
<ConfigureStepContext.Provider value={configure}>
{activeStep.content}
<div className="mx-auto w-full max-w-6xl px-6 pb-12 md:px-10">
{activeStep.content}
</div>
</ConfigureStepContext.Provider>
</main>
@ -111,6 +136,7 @@ function OnboardingFlow({
<div className="flex justify-center md:justify-end">
<Button
size="lg"
className="w-full md:w-auto"
disabled={!controls.canContinue || pending}
onClick={() => void advance()}
>
@ -119,7 +145,7 @@ function OnboardingFlow({
</div>
<nav
className="mt-4 flex items-center justify-center gap-2"
className="mt-4 hidden items-center justify-center gap-2 md:flex"
aria-label="Onboarding steps"
>
<Button

View file

@ -37,6 +37,8 @@ function Toggle({
return (
<TogglePrimitive
data-slot="toggle"
data-variant={variant}
data-size={size}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>

View file

@ -1,12 +1,13 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/public-sans/wght.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans), sans-serif;
--font-sans: "Inter", sans-serif;
--font-sans: "Public Sans Variable", system-ui, sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@ -50,15 +51,22 @@
}
:root {
--ui-density: 1;
--ui-border-width: 1px;
--ui-shadow-strength: 0;
--ui-surface-contrast: 0;
--ui-font-scale: 1;
--ui-heading-weight: 600;
--ui-motion: 1;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.511 0.096 186.391);
--primary-foreground: oklch(98.358% 0.0142 181.171);
--primary-foreground-alt: oklch(0.704 0.14 182.503);
--primary: oklch(0.24 0 0);
--primary-foreground: oklch(0.985 0 0);
--primary-foreground-alt: oklch(0.42 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0 0);
@ -68,7 +76,7 @@
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--ring: oklch(0.55 0 0);
--chart-1: oklch(0.855 0.138 181.071);
--chart-2: oklch(0.704 0.14 182.503);
--chart-3: oklch(0.6 0.118 184.704);
@ -77,8 +85,8 @@
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.6 0.118 184.704);
--sidebar-primary-foreground: oklch(0.984 0.014 180.72);
--sidebar-primary: oklch(0.3 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
@ -92,9 +100,9 @@
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.437 0.078 188.216);
--primary-foreground: oklch(98.358% 0.0142 181.171);
--primary-foreground-alt: oklch(0.704 0.14 182.503);
--primary: oklch(0.82 0 0);
--primary-foreground: oklch(0.18 0 0);
--primary-foreground-alt: oklch(0.72 0 0);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
@ -112,8 +120,8 @@
--chart-5: oklch(0.437 0.078 188.216);
--sidebar: oklch(0.19 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.704 0.14 182.503);
--sidebar-primary-foreground: oklch(0.277 0.046 192.524);
--sidebar-primary: oklch(0.78 0 0);
--sidebar-primary-foreground: oklch(0.18 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
@ -194,8 +202,58 @@
}
html {
@apply font-sans;
font-size: calc(16px * var(--ui-font-scale));
}
html[data-font="system"] {
--font-sans:
system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
}
:where(
[data-slot="button"],
[data-slot="toggle"],
[data-slot="input"],
[data-slot="textarea"],
[data-slot="native-select"],
[data-slot="select-trigger"],
[data-slot="input-group"],
[data-slot="card"],
[data-slot="item"]
) {
border-width: var(--ui-border-width);
transition-duration: calc(140ms * var(--ui-motion));
}
:where([data-slot="button"], [data-slot="toggle"])[data-size="default"],
[data-slot="input"],
[data-slot="select-trigger"][data-size="default"] {
height: calc(2rem * var(--ui-density));
}
:where([data-slot="button"], [data-slot="toggle"]):not([data-size^="icon"]) {
padding-inline: calc(0.625rem * var(--ui-density));
}
[data-slot="item"] {
padding: calc(0.625rem * var(--ui-density)) calc(0.75rem * var(--ui-density));
}
[data-slot="card"] {
background: color-mix(
in oklch,
var(--card),
var(--foreground) calc(var(--ui-surface-contrast) * 1%)
);
box-shadow: 0 calc(0.4rem * var(--ui-shadow-strength))
calc(1.8rem * var(--ui-shadow-strength))
rgb(0 0 0 / calc(0.12 * var(--ui-shadow-strength)));
}
:where(h1, h2, h3, [data-slot="card-title"], [data-slot="item-title"]) {
font-weight: var(--ui-heading-weight);
}
@layer base {
::selection {
background: var(--color-primary, var(--primary));
color: var(--color-primary-foreground, var(--primary-foreground));

View file

@ -0,0 +1,118 @@
import { useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../cmp/alert-dialog";
import { Button } from "../cmp/button";
import { cn } from "../lib/utils";
import { useTheme } from "./Provider";
export type ThemeSelectorProps = {
value?: string | null;
onValueChange?: (id: string) => void;
className?: string;
};
export function ThemeSelector({
value,
onValueChange,
className,
}: ThemeSelectorProps) {
const { themes, parentThemeId, applyThemePreset, isThemeCustomized } =
useTheme();
const selected = value === undefined ? parentThemeId : value;
const [pendingThemeId, setPendingThemeId] = useState<string | null>(null);
const apply = (id: string) => {
applyThemePreset(id);
onValueChange?.(id);
};
const choose = (id: string) => {
if (id === selected) return;
if (isThemeCustomized && parentThemeId && parentThemeId !== id) {
setPendingThemeId(id);
return;
}
apply(id);
};
return (
<>
<div
role="group"
aria-label="Parent theme"
className={cn("flex flex-wrap gap-3", className)}
>
{themes.map((theme) => (
<div
key={theme.id}
className="flex w-16 flex-col items-center gap-1.5"
>
<Button
type="button"
variant="outline"
aria-label={theme.title}
aria-pressed={selected === theme.id}
onClick={() => choose(theme.id)}
className={cn(
"size-14 overflow-hidden rounded-md p-1.5",
selected === theme.id &&
"border-primary ring-2 ring-primary/20 shadow-lg",
)}
>
<img
src={theme.logo}
alt=""
className="size-full object-contain"
/>
</Button>
<span className="font-heading text-xs font-semibold">
{theme.title}
{selected === theme.id && isThemeCustomized && (
<span className="ml-1 font-normal text-muted-foreground">
Edited
</span>
)}
</span>
</div>
))}
</div>
<AlertDialog
open={pendingThemeId !== null}
onOpenChange={(open) => {
if (!open) setPendingThemeId(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Replace your custom theme?</AlertDialogTitle>
<AlertDialogDescription>
Choosing another parent theme replaces your current visual
settings and custom CSS. This cannot be undone after leaving this
page.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep editing</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (pendingThemeId) apply(pendingThemeId);
setPendingThemeId(null);
}}
>
Replace theme
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -0,0 +1,29 @@
import { useState } from "react";
import { useOnboardingStep } from "../cmp/onboarding-flow";
import { ThemeCustomizer } from "./pickers/StylePicker";
export function ThemeOnboardingPage({
value,
onValueChange,
}: {
value?: string | null;
onValueChange?: (id: string) => void;
}) {
const [localThemeId, setLocalThemeId] = useState<string | null>(null);
const chosenThemeId = value === undefined ? localThemeId : value;
useOnboardingStep({ canContinue: chosenThemeId !== null });
const choose = (id: string) => {
setLocalThemeId(id);
onValueChange?.(id);
};
return (
<ThemeCustomizer
value={chosenThemeId}
onValueChange={choose}
showHeader={false}
/>
);
}

502
src/theme/Provider.tsx Normal file
View file

@ -0,0 +1,502 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useState,
} from "react";
import { BUILT_IN_THEMES, DEFAULT_THEME_DESIGN, findTheme } from "./presets";
import type {
Base16Palette,
ResolvedThemePolarity,
ThemeDesign,
ThemeFontFamily,
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";
declare const __METHANIUM_UI_DEFAULT_THEME_ID__: string | undefined;
const ThemeProviderContext = createContext<ThemeProviderState | undefined>(
undefined,
);
const CUSTOM_CSS_STYLE_ID = "methanium-theme-custom-css";
const FONT_FAMILIES: ThemeFontFamily[] = ["public-sans", "system"];
const DESIGN_NUMBER_FIELDS = [
"density",
"borderWidth",
"shadowStrength",
"surfaceContrast",
"fontScale",
"headingWeight",
"motion",
] as const;
const useIsomorphicLayoutEffect =
typeof window === "undefined" ? useEffect : useLayoutEffect;
function configuredDefaultThemeId() {
return typeof __METHANIUM_UI_DEFAULT_THEME_ID__ === "string"
? __METHANIUM_UI_DEFAULT_THEME_ID__
: null;
}
function storageValue(key: string | null) {
return key && typeof window !== "undefined"
? localStorage.getItem(key)
: null;
}
function isThemeDesign(value: unknown): value is ThemeDesign {
if (!value || typeof value !== "object") return false;
const design = value as Record<string, unknown>;
return (
DESIGN_NUMBER_FIELDS.every((field) => typeof design[field] === "number") &&
FONT_FAMILIES.includes(design.fontFamily as ThemeFontFamily)
);
}
function parseDesign(value: string | null, fallback: ThemeDesign) {
if (!value) return fallback;
try {
const parsed: unknown = JSON.parse(value);
return isThemeDesign(parsed) ? parsed : fallback;
} catch {
return fallback;
}
}
function storedDesign(key: string | null, fallback: ThemeDesign) {
return parseDesign(storageValue(key), fallback);
}
function designsEqual(left: ThemeDesign, right: ThemeDesign) {
return JSON.stringify(left) === JSON.stringify(right);
}
export function ThemeProvider({
children,
defaultTheme = "system",
defaultColor = DEFAULT_THEME_COLOR,
defaultPalette = null,
defaultPrimaryColor = "",
defaultTint = "soft",
defaultBorderRadius = DEFAULT_BORDER_RADIUS,
defaultCustomCss = "",
themes = BUILT_IN_THEMES,
defaultParentThemeId = configuredDefaultThemeId(),
defaultDesign = DEFAULT_THEME_DESIGN,
storageKey = "theme",
colorStorageKey = "theme_color",
paletteStorageKey = "theme_palette",
primaryColorStorageKey = "theme_primary_color",
tintStorageKey = "theme_tint",
borderRadiusStorageKey = "theme_border_radius",
customCssStorageKey = "theme_custom_css",
parentThemeStorageKey = "theme_parent",
designStorageKey = "theme_design",
disableTransitionOnChange = true,
...props
}: ThemeProviderProps) {
const configuredParent = findTheme(themes, defaultParentThemeId);
const [parentThemeId, setParentThemeIdState] = useState<string | null>(() => {
const stored = storageValue(parentThemeStorageKey);
return findTheme(themes, stored)?.id ?? configuredParent?.id ?? null;
});
const initialParent = findTheme(themes, parentThemeId);
const [themePolarity, setThemePolarityState] = useState(() =>
readStoredValue(storageKey, isTheme, defaultTheme),
);
const [themeColor, setThemeColorState] = useState<string>(() =>
readStoredValue(colorStorageKey, isThemeColor, defaultColor),
);
const [themePalette, setThemePaletteState] = useState<Base16Palette | null>(
() => {
const stored = storageValue(paletteStorageKey);
return stored === null
? defaultPalette
: parseStoredBase16Palette(stored);
},
);
const [themePrimaryColor, setThemePrimaryColorState] = useState(() =>
readStoredValue(primaryColorStorageKey, isThemeColor, defaultPrimaryColor),
);
const [themeTint, setThemeTintState] = useState<ThemeTint>(() =>
readStoredValue(tintStorageKey, isTint, defaultTint),
);
const [themeBorderRadius, setThemeBorderRadiusState] = useState(
() =>
parseBorderRadius(storageValue(borderRadiusStorageKey)) ??
defaultBorderRadius,
);
const [themeCustomCss, setThemeCustomCssState] = useState(() => {
const storedCss = storageValue(customCssStorageKey);
const hadStoredParent = storageValue(parentThemeStorageKey) !== null;
if (storedCss === null || (storedCss === "" && !hadStoredParent)) {
return initialParent?.css ?? defaultCustomCss;
}
return storedCss;
});
const [themeDesign, setThemeDesignState] = useState(() =>
storedDesign(designStorageKey, defaultDesign),
);
const [systemPolarity, setSystemPolarity] = useState<ResolvedThemePolarity>(
() => getSystemTheme(),
);
const parentTheme = findTheme(themes, parentThemeId);
const resolvedPolarity =
themePolarity === "system" ? systemPolarity : themePolarity;
const setParentThemeId = useCallback(
(id: string | null) => {
const next = findTheme(themes, id)?.id ?? null;
saveStoredValue(parentThemeStorageKey, next ?? "");
setParentThemeIdState(next);
},
[parentThemeStorageKey, themes],
);
const setThemePolarity = useCallback(
(next: typeof themePolarity) => {
saveStoredValue(storageKey, next);
setThemePolarityState(next);
},
[storageKey],
);
const setThemeColor = useCallback(
(next: string) => {
if (!isThemeColor(next)) return;
saveStoredValue(colorStorageKey, next);
setThemeColorState(next);
},
[colorStorageKey],
);
const setThemePalette = useCallback(
(next: Base16Palette | null) => {
saveStoredValue(paletteStorageKey, serializeBase16Palette(next));
setThemePaletteState(next);
},
[paletteStorageKey],
);
const setThemePrimaryColor = useCallback(
(next: string) => {
if (!isThemeColor(next)) return;
saveStoredValue(primaryColorStorageKey, next);
setThemePrimaryColorState(next);
},
[primaryColorStorageKey],
);
const setThemeTint = useCallback(
(next: ThemeTint) => {
saveStoredValue(tintStorageKey, next);
setThemeTintState(next);
},
[tintStorageKey],
);
const setThemeBorderRadius = useCallback(
(next: number) => {
const radius = parseBorderRadius(String(next));
if (radius === null) return;
saveStoredValue(borderRadiusStorageKey, String(radius));
setThemeBorderRadiusState(radius);
},
[borderRadiusStorageKey],
);
const setThemeCustomCss = useCallback(
(next: string) => {
saveStoredValue(customCssStorageKey, next);
setThemeCustomCssState(next);
},
[customCssStorageKey],
);
const setThemeDesign = useCallback(
(next: ThemeDesign | ((current: ThemeDesign) => ThemeDesign)) => {
setThemeDesignState((current) => {
const value = typeof next === "function" ? next(current) : next;
saveStoredValue(designStorageKey, JSON.stringify(value));
return value;
});
},
[designStorageKey],
);
const applyThemePreset = useCallback(
(id: string) => {
const preset = findTheme(themes, id);
if (!preset) throw new Error(`Unknown theme preset: ${id}`);
setParentThemeId(preset.id);
setThemeColor("");
setThemePalette(null);
setThemePrimaryColor("");
setThemeTint("soft");
setThemeBorderRadius(DEFAULT_BORDER_RADIUS);
setThemeDesign(defaultDesign);
setThemeCustomCss(preset.css);
},
[
defaultDesign,
setParentThemeId,
setThemeBorderRadius,
setThemeColor,
setThemeCustomCss,
setThemeDesign,
setThemePalette,
setThemePrimaryColor,
setThemeTint,
themes,
],
);
const resetThemePreset = useCallback(() => {
if (parentTheme) applyThemePreset(parentTheme.id);
}, [applyThemePreset, parentTheme]);
useEffect(() => {
if (typeof window === "undefined") return;
const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY);
const handleChange = () => setSystemPolarity(getSystemTheme());
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
useIsomorphicLayoutEffect(() => {
if (typeof document === "undefined") return;
const root = document.documentElement;
const restoreTransitions = disableTransitionOnChange
? disableTransitionsTemporarily()
: null;
root.classList.remove("light", "dark");
root.classList.add(resolvedPolarity);
if (parentThemeId) root.dataset.theme = parentThemeId;
else delete root.dataset.theme;
for (const name of THEME_VARIABLE_NAMES) root.style.removeProperty(name);
const variables = themePalette
? getBase16ThemeVariables(themePalette)
: themeColor
? getThemeVariables(themeColor, resolvedPolarity, themeTint)
: null;
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);
}
}
root.style.setProperty("--radius", `${themeBorderRadius}rem`);
root.style.setProperty("--ui-density", String(themeDesign.density));
root.style.setProperty("--ui-border-width", `${themeDesign.borderWidth}px`);
root.style.setProperty(
"--ui-shadow-strength",
String(themeDesign.shadowStrength),
);
root.style.setProperty(
"--ui-surface-contrast",
String(themeDesign.surfaceContrast),
);
root.style.setProperty("--ui-font-scale", String(themeDesign.fontScale));
root.style.setProperty(
"--ui-heading-weight",
String(themeDesign.headingWeight),
);
root.style.setProperty("--ui-motion", String(themeDesign.motion));
root.dataset.font = themeDesign.fontFamily;
restoreTransitions?.();
}, [
disableTransitionOnChange,
parentThemeId,
resolvedPolarity,
themeBorderRadius,
themeColor,
themeDesign,
themePalette,
themePrimaryColor,
themeTint,
]);
useIsomorphicLayoutEffect(() => {
if (typeof document === "undefined") return;
const existing = document.getElementById(CUSTOM_CSS_STYLE_ID);
if (!themeCustomCss.trim()) {
existing?.remove();
return;
}
const style = existing ?? document.createElement("style");
style.id = CUSTOM_CSS_STYLE_ID;
style.textContent = themeCustomCss;
if (!existing) document.head.appendChild(style);
}, [themeCustomCss]);
useEffect(() => {
if (typeof window === "undefined") return;
const handlers = new Map<string, (value: string | null) => void>();
const register = (
key: string | null,
handler: (value: string | null) => void,
) => {
if (key) handlers.set(key, handler);
};
register(storageKey, (stored) =>
setThemePolarityState(isTheme(stored) ? stored : defaultTheme),
);
register(colorStorageKey, (stored) =>
setThemeColorState(isThemeColor(stored) ? stored : defaultColor),
);
register(paletteStorageKey, (stored) =>
setThemePaletteState(parseStoredBase16Palette(stored)),
);
register(primaryColorStorageKey, (stored) =>
setThemePrimaryColorState(
isThemeColor(stored) ? stored : defaultPrimaryColor,
),
);
register(tintStorageKey, (stored) =>
setThemeTintState(isTint(stored) ? stored : defaultTint),
);
register(borderRadiusStorageKey, (stored) =>
setThemeBorderRadiusState(
parseBorderRadius(stored) ?? defaultBorderRadius,
),
);
register(customCssStorageKey, (stored) =>
setThemeCustomCssState(stored ?? defaultCustomCss),
);
register(parentThemeStorageKey, (stored) =>
setParentThemeIdState(findTheme(themes, stored)?.id ?? null),
);
register(designStorageKey, (stored) =>
setThemeDesignState(parseDesign(stored, defaultDesign)),
);
const handleStorageChange = (event: StorageEvent) => {
if (event.storageArea !== localStorage) return;
if (event.key) handlers.get(event.key)?.(event.newValue);
};
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}, [
borderRadiusStorageKey,
colorStorageKey,
customCssStorageKey,
defaultBorderRadius,
defaultColor,
defaultCustomCss,
defaultDesign,
defaultPrimaryColor,
defaultTheme,
defaultTint,
designStorageKey,
paletteStorageKey,
parentThemeStorageKey,
primaryColorStorageKey,
storageKey,
themes,
tintStorageKey,
]);
const isThemeCustomized = Boolean(
parentTheme &&
(themeCustomCss !== parentTheme.css ||
themeColor !== "" ||
themePalette !== null ||
themePrimaryColor !== "" ||
themeTint !== "soft" ||
themeBorderRadius !== DEFAULT_BORDER_RADIUS ||
!designsEqual(themeDesign, defaultDesign)),
);
const value = useMemo<ThemeProviderState>(
() => ({
theme: themePolarity,
setTheme: setThemePolarity,
themeColor,
setThemeColor,
themePalette,
setThemePalette,
themePrimaryColor,
setThemePrimaryColor,
themePolarity,
setThemePolarity,
resolvedPolarity,
themeTint,
setThemeTint,
themeBorderRadius,
setThemeBorderRadius,
themeCustomCss,
setThemeCustomCss,
themes,
parentThemeId,
parentTheme,
setParentThemeId,
applyThemePreset,
resetThemePreset,
isThemeCustomized,
themeDesign,
setThemeDesign,
}),
[
applyThemePreset,
isThemeCustomized,
parentTheme,
parentThemeId,
resetThemePreset,
resolvedPolarity,
setParentThemeId,
setThemeBorderRadius,
setThemeColor,
setThemeCustomCss,
setThemeDesign,
setThemePalette,
setThemePolarity,
setThemePrimaryColor,
setThemeTint,
themeBorderRadius,
themeColor,
themeCustomCss,
themeDesign,
themePalette,
themePolarity,
themePrimaryColor,
themeTint,
themes,
],
);
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeProviderContext);
if (!context) throw new Error("useTheme must be used within a ThemeProvider");
return context;
}

View file

@ -1,358 +0,0 @@
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;
};

View file

@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" width="4441.4" height="6468.8" shape-rendering="geometricPrecision" viewBox="0 0 4441 6469">
<path fill="#331c4e" d="m1714 6033-33-248-5-676 69-1110-247-2069-388-302 196 3614Z"/>
<path fill="#6a438a" d="m1864 2038-214 3093 31 640 820-1486 125-2262Z"/>
<path fill="#291641" d="m3130 1586-504 437-125 2262 305 315Z"/>
<path fill="#0e0619" d="m2501 4285 305 315-120 1018-384 830h-56Z"/>
<path fill="#211136" d="m1762 6196 247 254 237-2 255-2163-822 1476 17 221Z"/>
<path fill="#3f245b" d="m740 4907 255-267 719 1393 48 163-340-112Z"/>
<path fill="#05020d" d="m1407 6080-147 198 414 172h335l-247-254Z"/>
<path fill="#030106" d="m1273 6283-85 173 486-6Z"/>
<path fill="#0a0413" d="m10 5229 297-41 966 1095-85 173-100 3Z"/>
<path fill="#5b3878" d="m740 4907 682 1177-149 199-966-1095Z"/>
<path fill="#794f9a" d="M159 4369 10 5229l297-41Z"/>
<path fill="#b984c5" d="m307 5188 433-281-581-538Z"/>
<path fill="#d4abda" d="m159 4369 581 538 255-267Z"/>
<path d="m2686 5604 312 564-179 263-517 17Z"/>
<path fill="#130922" d="m2686 5604 294-421 588-268 48 443-618 810Z"/>
<path fill="#975dac" d="m2686 5604 1115-1590-379-274-666 1279Z"/>
<path fill="#6a438a" d="m2980 5183 588-268 511-814-429-152Z"/>
<path fill="#190c2b" d="m4079 4101 352 111-815 1146-48-443Z"/>
<path fill="#f0d7f2" d="m2045 11-547 1919 366 108 762-15 70-59Z"/>
<path fill="#a558aa" d="m2045 11 651 1953 434-378Z"/>
<path fill="#b06ab6" d="m2045 11-547 1919-388-302Z"/>
<path fill="#885ca8" d="m1498 1930 238 1992 128-1884Z"/>
<path fill="#c698d0" d="m3650 3949 429 152 273-790Z"/>
<path fill="#4c2d68" d="m4079 4101 352 111-79-901Z"/>
<path fill="#e2c1e6" d="m3422 3740 228 209 702-638Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="200mm"
height="200mm"
viewBox="0 0 200 200"
version="1.1"
id="svg1"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
sodipodi:docname="secret.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="1.0574425"
inkscape:cx="362.66748"
inkscape:cy="372.12426"
inkscape:window-width="2500"
inkscape:window-height="1403"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-26.522326,-57.048019)">
<rect
style="fill:#000000;stroke-width:0.379397"
id="rect1"
width="200"
height="200"
x="26.522326"
y="57.048019" />
<text
xml:space="preserve"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:3.175px;font-family:'Public Sans';-inkscape-font-specification:'Public Sans, Normal';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;writing-mode:lr-tb;direction:ltr;white-space:pre;inline-size:11.1898;fill:#ffffff;fill-opacity:1;stroke-width:0.264583"
x="119.35046"
y="151.87784"
id="text2"
transform="matrix(32.473549,0,0,32.473181,-3775.2425,-4737.1215)"><tspan
x="119.35046"
y="151.87784"
id="tspan1">?</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,236 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="84.12928mm"
height="88.726425mm"
viewBox="0 0 84.12928 88.726425"
version="1.1"
id="svg1"
xml:space="preserve"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
sodipodi:docname="tensamin.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#ffffff"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="1"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="2.8284271"
inkscape:cx="133.4664"
inkscape:cy="169.52885"
inkscape:window-width="2466"
inkscape:window-height="1369"
inkscape:window-x="26"
inkscape:window-y="23"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" /><defs
id="defs1"><linearGradient
id="swatch15"
inkscape:swatch="solid"><stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop15" /></linearGradient><linearGradient
id="swatch10"
inkscape:swatch="solid"><stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop10" /></linearGradient><linearGradient
id="swatch3"
inkscape:swatch="solid"><stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop3" /></linearGradient><linearGradient
id="swatch2"
inkscape:swatch="solid"><stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop2" /></linearGradient><linearGradient
id="swatch1"
inkscape:swatch="solid"><stop
style="stop-color:#031616;stop-opacity:1;"
offset="0"
id="stop1" /></linearGradient><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect2"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1 @ F,0,0,1,0,0.79374998,0,1"
radius="3"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="bspline"
id="path-effect6"
is_visible="true"
lpeversion="1.3"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false"
uniform="false" /><inkscape:path-effect
effect="spiro"
id="path-effect5"
is_visible="true"
lpeversion="1" /><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath1"><path
id="path3"
style="fill:#043b3c;stroke-width:0.320821"
inkscape:label="arrow"
d="m 244.98648,261.60864 -9.19072,26.71639 23.35523,-11.66051 c -4.69011,-5.05439 -10.12762,-9.5023 -14.16451,-15.05588 z m 40.12749,-74.6916 50.72412,18.76364 c 6.70121,39.09457 -10.8946,70.48625 -50.72412,85.29337 z m 0,0 -50.72412,18.76364 c -6.7012,39.09457 10.89461,70.48625 50.72412,85.29337 z"
sodipodi:nodetypes="cccccccccccc" /></clipPath><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient15"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554"
gradientUnits="userSpaceOnUse" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient16"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5"
gradientUnits="userSpaceOnUse" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient17"
gradientUnits="userSpaceOnUse"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient18"
gradientUnits="userSpaceOnUse"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient19"
gradientUnits="userSpaceOnUse"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient20"
gradientUnits="userSpaceOnUse"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient21"
gradientUnits="userSpaceOnUse"
x1="232.99823"
y1="238.94554"
x2="337.22971"
y2="238.94554" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient22"
gradientUnits="userSpaceOnUse"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient23"
gradientUnits="userSpaceOnUse"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient24"
gradientUnits="userSpaceOnUse"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5" /><linearGradient
inkscape:collect="always"
xlink:href="#swatch1"
id="linearGradient25"
gradientUnits="userSpaceOnUse"
x1="63.191292"
y1="148.5"
x2="146.82355"
y2="148.5" /></defs><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-62.941767,-104.13688)"><g
id="g2"
transform="matrix(0.78671048,0,0,0.83608014,-119.72576,-51.488345)"
inkscape:label="background"
style="display:inline;stroke:url(#linearGradient15);stroke-width:0.616508;stroke-dasharray:none"
clip-path="url(#clipPath1)"><path
id="rect1"
style="fill:#004b4a;stroke:url(#linearGradient17);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)"
d="m -227.83395,273.59631 h 146.303341 v 24.39636 H -227.83395 Z" /><path
id="rect1-1"
style="fill:#00524e;stroke:url(#linearGradient18);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)"
d="m -227.83395,297.99268 h 146.303341 v 24.39636 H -227.83395 Z" /><path
id="rect1-1-3"
style="fill:#006560;stroke:url(#linearGradient19);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)"
d="m -227.83395,322.3891 h 146.303341 v 24.39636 H -227.83395 Z" /><path
id="rect1-1-3-6"
style="fill:#02857d;stroke:url(#linearGradient20);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)"
d="m -227.83395,346.78546 h 146.303341 v 24.39636 H -227.83395 Z" /><path
id="rect1-1-3-6-2"
style="fill:#12a89e;stroke:url(#linearGradient21);stroke-width:0.616508;stroke-dasharray:none"
transform="rotate(-75)"
d="m -227.83395,371.18179 h 146.303341 v 24.39636 H -227.83395 Z" /></g><g
id="g3"
inkscape:label="foreground"
style="stroke:url(#linearGradient16);stroke-width:0.5;stroke-dasharray:none"><g
id="g1"
inkscape:label="outline"
style="stroke:url(#linearGradient23);stroke-width:0.5;stroke-dasharray:none"><path
id="path15-3-5"
style="opacity:0.352;fill:#000000;fill-opacity:1;stroke:url(#linearGradient22);stroke-width:0.5;stroke-dasharray:none"
inkscape:label="filler"
d="m -54.535435,65.443954 -38.869976,15.14533 c -2.902661,17.83718 0.143161,33.687786 8.852689,46.257116 l -5.823934,18.52239 15.016655,-8.2765 c 5.699447,4.98296 12.651108,9.14382 20.824566,12.34447 30.52177,-11.95203 44.00571,-37.29104 38.8705,-68.847476 z m -0.01808,2.85099 36.24585,14.11438 c 4.61111,37.589746 -14.17784,55.293626 -36.24585,64.158876 -21.698042,-8.5864 -40.914886,-26.53641 -36.245337,-64.158876 z"
transform="translate(159.54375,40.87617)" /></g><path
style="opacity:1;fill:#b8f8ff;fill-opacity:1;stroke:url(#linearGradient24);stroke-width:0.5;stroke-dasharray:none"
d="m 98.103726,126.47621 21.412544,0.16758 a 0.41189664,0.41189664 63.022469 0 1 0.33171,0.65164 l -8.29596,11.58914 a 0.41184923,0.41184923 63.019773 0 0 0.33171,0.65157 l 10.05254,0.0777 a 0.3104736,0.3104736 69.080154 0 1 0.20649,0.54017 l -30.495516,27.73194 a 0.15466019,0.15466019 36.691517 0 1 -0.243455,-0.18141 l 10.172421,-21.16912 a 0.50438266,0.50438266 58.099348 0 0 -0.44993,-0.72282 l -11.312564,-0.10524 a 0.52748832,0.52748832 56.926855 0 1 -0.479488,-0.73628 l 7.661544,-17.7722 a 1.1963277,1.1963277 146.88457 0 1 1.107954,-0.72269 z"
id="path2"
sodipodi:nodetypes="cccccccc"
inkscape:label="bolt" /><path
id="path1"
style="fill:#043b3c;stroke:url(#linearGradient25);stroke-width:0.5;stroke-dasharray:none"
inkscape:label="dark_outline"
d="m 104.99968,104.40528 -40.706558,15.90239 c -2.948156,18.16379 0.05742,34.31077 8.566402,47.3046 l -7.438306,22.73763 18.798853,-9.90998 c 5.772204,4.86637 12.68201,8.97564 20.779609,12.15481 31.96374,-12.54919 46.08489,-39.15399 40.70708,-72.28706 z m 0,1.91926 38.88755,15.13603 c 4.9985,39.45937 -15.37196,59.52899 -38.88755,68.8573 -7.461219,-2.95976 -14.605741,-7.00102 -20.723799,-12.39976 l -1.849499,0.95188 -13.267924,7.37991 5.196582,-16.46463 0.704866,-2.3027 c 0.02259,0.0331 0.04422,0.065 0,-5.2e-4 -0.02016,-0.0296 -0.04036,-0.0594 -0.01292,-0.0196 -7.707836,-11.18647 -11.48502,-25.8661 -8.934338,-46.00184 z m 5.2e-4,2.85151 -36.245852,14.11438 c -4.788427,29.4077 7.785188,53.02119 36.245852,64.15939 28.46066,-11.1382 41.03376,-34.75169 36.24533,-64.15939 z m 0,1.70377 34.62527,13.43381 c 4.45069,35.0227 -13.68688,52.83618 -34.62527,61.11564 -20.938395,-8.27946 -39.076477,-26.09294 -34.625796,-61.11564 z" /></g></g></svg>

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -1,10 +1,7 @@
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 "./presets";
export * from "./Provider";
export * from "./BaseThemeSelector";
export * from "./OnboardingPage";
export * from "./pickers/StylePicker";

View file

@ -1,8 +1,9 @@
import * as React from "react";
import { Trash } from "lucide-react";
import { Button, Textarea } from "../../index";
import { useTheme } from "../ThemeProvider";
import { Button } from "../../cmp/button";
import { Textarea } from "../../cmp/textarea";
import { useTheme } from "../Provider";
import { formatPaletteJson, parseBase16Palette } from "../utils";
export function Base16Picker() {

View file

@ -1,6 +1,10 @@
import { Slider, Button } from "../../index";
import { useTheme } from "../ThemeProvider";
import { useId } from "react";
import { Trash } from "lucide-react";
import { Button } from "../../cmp/button";
import { Label } from "../../cmp/label";
import { Slider } from "../../cmp/slider";
import { useTheme } from "../Provider";
import {
DEFAULT_BORDER_RADIUS,
MAX_BORDER_RADIUS,
@ -8,6 +12,7 @@ import {
} from "../utils";
export function BorderRadiusPicker() {
const id = useId();
const { themeBorderRadius, setThemeBorderRadius } = useTheme();
const updateBorderRadius = (value: number | readonly number[]) => {
setThemeBorderRadius(Array.isArray(value) ? (value[0] ?? 0) : value);
@ -15,12 +20,19 @@ export function BorderRadiusPicker() {
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>
<div className="flex justify-between gap-4">
<Label htmlFor={id}>Border radius</Label>
<output
htmlFor={id}
className="font-mono text-xs text-muted-foreground"
>
{themeBorderRadius.toFixed(3)}rem
</output>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-1 flex-col justify-start gap-1">
<Slider
id={id}
className="w-full"
min={MIN_BORDER_RADIUS}
max={MAX_BORDER_RADIUS}

View file

@ -1,5 +1,6 @@
import { Trash } from "lucide-react";
import { Button, Input } from "../../index";
import { Button } from "../../cmp/button";
import { Input } from "../../cmp/input";
import { COLOR_PICKER_FALLBACK } from "../utils";
export function ColorPicker({

View file

@ -1,32 +1,59 @@
import { useRef, useState } from "react";
import { RotateCcw, Undo2 } from "lucide-react";
import { Button } from "../../cmp/button";
import { Textarea } from "../../cmp/textarea";
import { useTheme } from "../ThemeProvider";
import { useTheme } from "../Provider";
const CUSTOM_CSS_PLACEHOLDER = `* {
color: black;
}`;
function hasBalancedBlocks(css: string) {
return css.split("{").length === css.split("}").length;
}
export function CustomCssPicker() {
const { themeCustomCss, setThemeCustomCss } = useTheme();
const { themeCustomCss, setThemeCustomCss, parentTheme, resetThemePreset } =
useTheme();
const editStart = useRef(themeCustomCss);
const [canUndo, setCanUndo] = useState(false);
const valid = hasBalancedBlocks(themeCustomCss);
return (
<div className="flex w-90 flex-col gap-2">
<div className="flex min-w-0 flex-col gap-3">
{!valid && <p className="text-destructive">Invalid Syntax</p>}
<Textarea
value={themeCustomCss}
onChange={(event) => setThemeCustomCss(event.currentTarget.value)}
placeholder={CUSTOM_CSS_PLACEHOLDER}
onFocus={() => {
editStart.current = themeCustomCss;
setCanUndo(false);
}}
onChange={(event) => {
setThemeCustomCss(event.currentTarget.value);
setCanUndo(event.currentTarget.value !== editStart.current);
}}
placeholder={":root {\n --primary: oklch(0.6 0.2 260);\n}"}
spellCheck={false}
className="h-80 resize-none font-mono text-xs leading-relaxed"
className="min-h-80 resize-y font-mono text-xs leading-relaxed"
/>
<div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setThemeCustomCss("")}
disabled={themeCustomCss === ""}
disabled={!canUndo}
onClick={() => {
setThemeCustomCss(editStart.current);
setCanUndo(false);
}}
>
Clear
<Undo2 data-icon="inline-start" /> Undo edits
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={!parentTheme}
onClick={resetThemePreset}
>
<RotateCcw data-icon="inline-start" /> Reset to parent
</Button>
</div>
</div>

View file

@ -1,3 +1,6 @@
import { useId } from "react";
import { Label } from "../../cmp/label";
import {
Select,
SelectContent,
@ -6,19 +9,20 @@ import {
SelectValue,
} from "../../cmp/select";
import type { ThemePolarity } from "../types";
import { useTheme } from "../ThemeProvider";
import { useTheme } from "../Provider";
export function PolarityPicker() {
const id = useId();
const { themePolarity, setThemePolarity } = useTheme();
return (
<label className="flex flex-col gap-2 text-sm font-medium">
Color scheme
<div className="flex flex-col gap-2">
<Label htmlFor={id}>Polarity</Label>
<Select
value={themePolarity}
onValueChange={(value) => setThemePolarity(value as ThemePolarity)}
>
<SelectTrigger className="w-65">
<SelectTrigger id={id} className="w-65">
<SelectValue>
{themePolarity === "system"
? "System"
@ -33,6 +37,6 @@ export function PolarityPicker() {
<SelectItem value="light">Light</SelectItem>
</SelectContent>
</Select>
</label>
</div>
);
}

View file

@ -1,15 +1,19 @@
import { Label } from "../../cmp/label";
import { ColorPicker } from "./ColorPicker";
import { useTheme } from "../ThemeProvider";
import { useTheme } from "../Provider";
export function PrimaryPicker() {
const { themePrimaryColor, setThemePrimaryColor } = useTheme();
return (
<ColorPicker
label="Primary color"
value={themePrimaryColor}
onChange={setThemePrimaryColor}
allowEmpty
/>
<div className="flex flex-col gap-2">
<Label>Custom Primary Color</Label>
<ColorPicker
label="Primary color"
value={themePrimaryColor}
onChange={setThemePrimaryColor}
allowEmpty
/>
</div>
);
}

View file

@ -1,5 +1,19 @@
import * as React from "react";
import { useId, useState, type ReactNode } from "react";
import { ChevronDown, RotateCcw } from "lucide-react";
import { Button } from "../../cmp/button";
import { Label } from "../../cmp/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../cmp/select";
import { Slider } from "../../cmp/slider";
import { ThemeSelector } from "../BaseThemeSelector";
import { useTheme } from "../Provider";
import type { ThemeDesign, ThemeFontFamily } from "../types";
import { Base16Picker } from "./Base16Picker";
import { BorderRadiusPicker } from "./BorderRadiusPicker";
import { CustomCssPicker } from "./CustomCssPicker";
@ -7,52 +21,229 @@ 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) {
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-2">
<h2 className="text-sm font-semibold">{title}</h2>
<section className="flex flex-col gap-4">
<h2 className="font-heading font-semibold">{title}</h2>
{children}
</section>
);
}
function DesignSlider({
label,
field,
min,
max,
step,
format = (value) => String(value),
}: {
label: string;
field: keyof Pick<
ThemeDesign,
| "density"
| "borderWidth"
| "shadowStrength"
| "surfaceContrast"
| "fontScale"
| "headingWeight"
| "motion"
>;
min: number;
max: number;
step: number;
format?: (value: number) => string;
}) {
const { themeDesign, setThemeDesign } = useTheme();
const value = themeDesign[field];
const id = useId();
return (
<div className="grid gap-2 text-sm">
<div className="flex justify-between gap-4">
<Label htmlFor={id}>{label}</Label>
<output
htmlFor={id}
className="font-mono text-xs text-muted-foreground"
>
{format(value)}
</output>
</div>
<Slider
id={id}
min={min}
max={max}
step={step}
value={[value]}
onValueChange={(next) =>
setThemeDesign((current) => ({
...current,
[field]: Array.isArray(next) ? (next[0] ?? value) : next,
}))
}
/>
</div>
);
}
export function StylePicker() {
export function ThemeCustomizer({
value,
onValueChange,
showHeader = true,
}: {
value?: string | null;
onValueChange?: (id: string) => void;
showHeader?: boolean;
}) {
const [advancedOpen, setAdvancedOpen] = useState(false);
const fontId = useId();
const {
themeDesign,
setThemeDesign,
parentTheme,
isThemeCustomized,
resetThemePreset,
} = useTheme();
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">
<div className="w-full min-w-0 space-y-7">
<div className="border border-dotted p-5 rounded-2xl bg-card/30 flex flex-col gap-5">
<ThemeSelector value={value} onValueChange={onValueChange} />
<div className="grid gap-7 lg:grid-cols-2">
<Section title="Color scheme">
<PolarityPicker />
<TintPicker />
</StyleCategory>
<StyleCategory title="Primary Color">
<PrimaryPicker />
</StyleCategory>
<StyleCategory title="Border Radius">
<BorderRadiusPicker />
</StyleCategory>
</Section>
<div className="space-y-7">
<Section title="Shape and spacing">
<div className="grid gap-6 md:grid-cols-2">
<DesignSlider
label="Density"
field="density"
min={0.8}
max={1.3}
step={0.025}
format={(v) => `${Math.round(v * 100)}%`}
/>
<DesignSlider
label="Border width"
field="borderWidth"
min={0}
max={3}
step={0.25}
format={(v) => `${v}px`}
/>
<BorderRadiusPicker />
<DesignSlider
label="Surface contrast"
field="surfaceContrast"
min={0}
max={8}
step={0.5}
/>
</div>
</Section>
<Section title="Depth and motion">
<div className="grid gap-6 md:grid-cols-2">
<DesignSlider
label="Shadow strength"
field="shadowStrength"
min={0}
max={1.5}
step={0.05}
format={(v) => `${Math.round(v * 100)}%`}
/>
<DesignSlider
label="Motion"
field="motion"
min={0}
max={1.75}
step={0.05}
format={(v) =>
v === 0 ? "Reduced" : `${Math.round(v * 100)}%`
}
/>
</div>
</Section>
</div>
</div>
<div className="shrink-0">
<StyleCategory title="Base16">
<Base16Picker />
</StyleCategory>
</div>
<div className="shrink-0">
<StyleCategory title="Custom CSS">
<CustomCssPicker />
</StyleCategory>
<Section title="Typography">
<div className="grid gap-6 md:grid-cols-2">
<div className="grid gap-2 text-sm">
<Label htmlFor={fontId}>Font</Label>
<Select
value={themeDesign.fontFamily}
onValueChange={(value) =>
setThemeDesign((current) => ({
...current,
fontFamily: value as ThemeFontFamily,
}))
}
>
<SelectTrigger id={fontId} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="public-sans">Public Sans</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
</div>
<DesignSlider
label="Font scale"
field="fontScale"
min={0.875}
max={1.2}
step={0.025}
format={(v) => `${Math.round(v * 100)}%`}
/>
<DesignSlider
label="Heading boldness"
field="headingWeight"
min={400}
max={800}
step={50}
format={(v) => `${Math.round((v - 400) / 4)}%`}
/>
</div>
</Section>
</div>
<div>
<div className="flex gap-2 w-full">
<Button
className="flex-1/2 md:flex-none"
variant="outline"
aria-expanded={advancedOpen}
onClick={() => setAdvancedOpen((open) => !open)}
>
Advanced Options
<ChevronDown
data-icon="inline-end"
className={advancedOpen ? "rotate-180" : undefined}
/>
</Button>
<Button
className="flex-1/2 md:flex-none"
variant="outline"
disabled={!parentTheme || !isThemeCustomized}
onClick={resetThemePreset}
>
<RotateCcw data-icon="inline-start" />
Reset to {parentTheme?.title ?? "parent"}
</Button>
</div>
{advancedOpen && (
<div className="mt-6 space-y-7">
<Section title="Base16 colors">
<Base16Picker />
</Section>
<Section title="Custom CSS">
<CustomCssPicker />
</Section>
</div>
)}
</div>
</div>
);
}
export const StylePicker = ThemeCustomizer;

View file

@ -5,7 +5,8 @@ import {
SelectTrigger,
SelectValue,
} from "../../cmp/select";
import { useTheme } from "../ThemeProvider";
import { Label } from "../../cmp/label";
import { useTheme } from "../Provider";
import type { ThemeTint } from "../types";
import { THEME_TINT_OPTIONS } from "../utils";
@ -27,6 +28,7 @@ export function TintPicker() {
return (
<div className="flex flex-col gap-2">
<Label>Tint</Label>
<ColorPicker
label="Theme color"
value={themeColor}

388
src/theme/presets.ts Normal file
View file

@ -0,0 +1,388 @@
import type { ThemeDesign, ThemePreset } from "./types";
export const DEFAULT_THEME_DESIGN: ThemeDesign = {
density: 1,
borderWidth: 1,
shadowStrength: 0,
surfaceContrast: 0,
fontScale: 1,
headingWeight: 600,
motion: 1,
fontFamily: "public-sans",
};
const TENSAMIN_LOGO = new URL("./assets/tensamin-logo.svg", import.meta.url)
.href;
const METHANIUM_LOGO = new URL("./assets/methanium-logo.svg", import.meta.url)
.href;
const SECRET_LOGO = new URL("./assets/secret-logo.svg", import.meta.url).href;
export function defineThemes<const T extends readonly ThemePreset[]>(
themes: T,
): T {
const ids = new Set<string>();
for (const theme of themes) {
if (!theme.id || ids.has(theme.id)) {
throw new Error(`Theme ids must be non-empty and unique: ${theme.id}`);
}
ids.add(theme.id);
}
return themes;
}
export const BUILT_IN_THEMES = defineThemes([
{
id: "tensamin",
title: "Tensamin",
logo: TENSAMIN_LOGO,
css: `:root[data-theme="tensamin"] {
--card: oklch(0.97 0 0);
--primary: oklch(0.54 0.105 186);
--primary-foreground: oklch(0.985 0.012 184);
--primary-foreground-alt: oklch(0.58 0.13 185);
--ring: oklch(0.62 0.12 185);
--sidebar-primary: oklch(0.58 0.12 185);
}
:root.dark[data-theme="tensamin"] {
--card: oklch(0.2 0 0);
--primary: oklch(0.68 0.12 184);
--primary-foreground: oklch(0.16 0.025 190);
--primary-foreground-alt: oklch(0.72 0.13 183);
}
:root[data-theme="tensamin"] [data-slot] {
--tensamin-edge: var(--border);
border-left-width: 0;
border-right-width: 0;
border-top-color: color-mix(in oklch, var(--tensamin-edge) 98%, white);
border-bottom-color: color-mix(in oklch, var(--tensamin-edge) 88%, black);
transition: border-color 160ms ease, background-color 160ms ease;
}
:root[data-theme="tensamin"] :where([data-slot="input"], [data-slot="textarea"], [data-slot="select-trigger"], [data-slot="native-select"], [data-slot="input-group"]) {
--tensamin-edge: var(--input);
}
:root[data-theme="tensamin"] :where([data-slot="button"], [data-slot="badge"], [data-slot="toggle"])[data-variant="default"] {
--tensamin-edge: var(--primary);
}
:root[data-theme="tensamin"] [data-slot="button"] {
background-color: color-mix(in oklch, var(--background) 94%, white);
}
:root[data-theme="tensamin"] [data-slot="button"]:hover {
background-color: color-mix(in oklch, var(--background) 88%, var(--foreground));
}
:root[data-theme="tensamin"] [data-slot="button"][data-variant="default"] {
background-color: color-mix(in oklch, var(--primary) 93%, white);
}
:root[data-theme="tensamin"] [data-slot="button"][data-variant="default"]:hover {
background-color: color-mix(in oklch, var(--primary) 85%, var(--primary-foreground));
}
:root[data-theme="tensamin"] :where([data-slot="button"], [data-slot="badge"], [data-slot="toggle"])[data-variant="subtleDefault"] {
--tensamin-edge: var(--primary-foreground-alt);
}
:root[data-theme="tensamin"] [data-slot="button"][data-variant="subtleDefault"] {
background-color: color-mix(in oklch, var(--background) 88%, var(--primary-foreground-alt));
border-top-color: color-mix(in oklch, color-mix(in oklch, var(--background) 88%, var(--primary-foreground-alt)) 82%, white);
border-bottom-color: color-mix(in oklch, color-mix(in oklch, var(--background) 88%, var(--primary-foreground-alt)) 90%, white);
}
:root[data-theme="tensamin"] [data-slot="button"][data-variant="subtleDefault"]:hover {
background-color: color-mix(in oklch, var(--background) 80%, var(--primary-foreground-alt));
}
:root[data-theme="tensamin"] :where([data-slot="button"], [data-slot="badge"], [data-slot="toggle"])[data-variant="secondary"] {
--tensamin-edge: var(--secondary);
}
:root[data-theme="tensamin"] [data-slot="button"][data-variant="secondary"] {
background-color: color-mix(in oklch, var(--secondary) 94%, white);
}
:root[data-theme="tensamin"] [data-slot="button"][data-variant="secondary"]:hover {
background-color: color-mix(in oklch, var(--secondary) 86%, var(--secondary-foreground));
}
:root[data-theme="tensamin"] :where([data-slot="button"], [data-slot="badge"], [data-slot="toggle"])[data-variant="destructive"] {
--tensamin-edge: var(--destructive);
}
:root[data-theme="tensamin"] [data-slot="button"][data-variant="destructive"] {
background-color: color-mix(in oklch, var(--background) 86%, var(--destructive));
}
:root[data-theme="tensamin"] [data-slot="button"][data-variant="destructive"]:hover {
background-color: color-mix(in oklch, var(--background) 76%, var(--destructive));
}
:root[data-theme="tensamin"] [data-slot][data-variant="destructive"] {
border-top-color: color-mix(in oklch, color-mix(in oklch, var(--background) 86%, var(--destructive)) 82%, white);
border-bottom-color: color-mix(in oklch, color-mix(in oklch, var(--background) 86%, var(--destructive)) 90%, white);
}
:root[data-theme="tensamin"] :where([data-slot="button"], [data-slot="badge"], [data-slot="toggle"])[data-variant="outline"] {
--tensamin-edge: color-mix(in oklch, var(--primary) 16%, var(--border));
}
:root[data-theme="tensamin"] :where([data-slot="button"], [data-slot="badge"], [data-slot="toggle"]):where([data-variant="ghost"], [data-variant="link"]) {
--tensamin-edge: color-mix(in oklch, currentColor 12%, var(--border));
}
:root[data-theme="tensamin"] [data-slot][data-checked],
:root[data-theme="tensamin"] [data-slot][data-active] {
--tensamin-edge: var(--primary);
}
:root[data-theme="tensamin"] [data-slot][aria-invalid="true"] {
--tensamin-edge: var(--destructive);
}`,
},
{
id: "methanium",
title: "Methanium",
logo: METHANIUM_LOGO,
css: `:root[data-theme="methanium"] {
--background: #d2e0f0;
--foreground: #10161d;
--card: #c0cfe0;
--card-foreground: #10161d;
--popover: #d2e0f0;
--popover-foreground: #10161d;
--primary: #8c47ad;
--primary-foreground: #ffffff;
--primary-foreground-alt: #7b3d98;
--secondary: #aebed0;
--secondary-foreground: #171e26;
--muted: #c0cfe0;
--muted-foreground: #515f70;
--accent: #cf8fe633;
--accent-foreground: #5a2a6d;
--destructive: #b91c1c;
--border: #8c9caf;
--input: #9dadc0;
--ring: #9d53bc;
--chart-1: #8c47ad;
--chart-2: #2563eb;
--chart-3: #15803d;
--chart-4: #ca8a04;
--chart-5: #db2777;
--sidebar: #aebed0;
--sidebar-foreground: #10161d;
--sidebar-primary: #8c47ad;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #cf8fe633;
--sidebar-accent-foreground: #5a2a6d;
--sidebar-border: #8c9caf;
--sidebar-ring: #9d53bc;
}
:root.dark[data-theme="methanium"] {
--background: #10161d;
--foreground: #d2e0f0;
--card: #242d38;
--card-foreground: #d2e0f0;
--popover: #242d38;
--popover-foreground: #d2e0f0;
--primary: #cf8fe6;
--primary-foreground: #000000;
--primary-foreground-alt: #bb6fd6;
--secondary: #374351;
--secondary-foreground: #d2e0f0;
--muted: #1d262f;
--muted-foreground: #9dadc0;
--accent: #cf8fe633;
--accent-foreground: #faecfc;
--destructive: #cc4848;
--border: #374351;
--input: #445161;
--ring: #cf8fe6;
--chart-1: #cf8fe6;
--chart-2: #4e96cc;
--chart-3: #93b373;
--chart-4: #eaaf03;
--chart-5: #d74397;
--sidebar: #10161d;
--sidebar-foreground: #d2e0f0;
--sidebar-primary: #cf8fe6;
--sidebar-primary-foreground: #000000;
--sidebar-accent: #2b3642;
--sidebar-accent-foreground: #d2e0f0;
--sidebar-border: #374351;
--sidebar-ring: #cf8fe6;
}
:root[data-theme="methanium"] body {
background-color: var(--background);
}
:root[data-theme="methanium"] :where([data-slot="card"], [data-slot="button"], [data-slot="input"], [data-slot="textarea"], [data-slot="native-select"], [data-slot="select-trigger"], [data-slot="input-group"], [data-slot="dialog-content"], [data-slot="alert-dialog-content"], [data-slot="popover-content"]) {
border-radius: 0.25rem;
border-style: solid;
}
:root[data-theme="methanium"] [data-slot="button"] {
min-height: 0;
font-weight: 500;
transition-duration: 100ms;
}
:root[data-theme="methanium"] [data-slot="button"][data-size="xs"] {
height: calc(2rem * var(--ui-density));
}
:root[data-theme="methanium"] :where([data-slot="button"], [data-slot="select-trigger"])[data-size="sm"] {
height: calc(2.125rem * var(--ui-density));
}
:root[data-theme="methanium"] :where([data-slot="button"], [data-slot="select-trigger"])[data-size="default"],
:root[data-theme="methanium"] :where([data-slot="input"], [data-slot="native-select"], [data-slot="input-group"]) {
height: calc(2.25rem * var(--ui-density));
}
:root[data-theme="methanium"] [data-slot="button"][data-size="lg"] {
height: calc(2.5rem * var(--ui-density));
}
:root[data-theme="methanium"] [data-slot="button"]:not([data-size^="icon"]) {
padding-inline: calc(1.35rem * var(--ui-density));
}
:root[data-theme="methanium"] [data-slot="button"][data-size="sm"]:not([data-size^="icon"]),
:root[data-theme="methanium"] [data-slot="button"][data-size="xs"]:not([data-size^="icon"]) {
padding-inline: calc(1.25rem * var(--ui-density));
}
:root[data-theme="methanium"] :where([data-slot="input"], [data-slot="native-select"], [data-slot="select-trigger"]) {
padding-inline: calc(1rem * var(--ui-density));
}
:root[data-theme="methanium"] [data-slot="textarea"] {
padding: calc(0.785rem * var(--ui-density)) calc(1rem * var(--ui-density));
}
:root[data-theme="methanium"] :where([data-slot="card-header"], [data-slot="card-content"], [data-slot="card-footer"]) {
padding-inline: calc(1rem * var(--ui-density));
}
:root[data-theme="methanium"] [data-slot="card"] {
padding-block: calc(1rem * var(--ui-density));
}
:root[data-theme="methanium"] [data-slot="button"][data-variant="default"] {
background-color: var(--primary);
color: var(--primary-foreground);
}
:root[data-theme="methanium"] [data-slot="button"][data-variant="default"]:hover {
background-color: #bb6fd6;
}
:root[data-theme="methanium"] [data-slot="button"][data-variant="secondary"] {
background-color: var(--secondary);
border-color: color-mix(in srgb, white 16%, transparent);
color: var(--secondary-foreground);
}
:root[data-theme="methanium"] [data-slot="button"][data-variant="secondary"]:hover,
:root[data-theme="methanium"] [data-slot="button"][data-variant="outline"]:hover {
background-color: color-mix(in srgb, var(--secondary) 80%, var(--foreground) 20%);
}
:root[data-theme="methanium"] [data-slot="button"][data-variant="outline"] {
background-color: transparent;
border-color: var(--input);
color: var(--foreground);
}
:root[data-theme="methanium"] :where([data-slot="input"], [data-slot="textarea"], [data-slot="native-select"], [data-slot="select-trigger"], [data-slot="input-group"]) {
background-color: color-mix(in srgb, var(--input) 55%, var(--background));
border-color: var(--input);
transition: background-color 100ms ease, border-color 100ms ease;
}
:root[data-theme="methanium"] :where([data-slot="input"], [data-slot="textarea"], [data-slot="native-select"], [data-slot="select-trigger"], [data-slot="input-group"]):not(:disabled):not([data-disabled]):not(:has(:disabled)):hover {
background-color: color-mix(in srgb, var(--input) 72%, var(--background));
border-color: color-mix(in srgb, var(--input) 75%, var(--foreground) 25%);
}`,
},
{
id: "secret",
title: "Secret",
logo: SECRET_LOGO,
css: `:root[data-theme="secret"] {
--background: oklch(0.93 0.04 150);
--foreground: oklch(0.2 0.025 150);
--card: oklch(0.84 0.1 84);
--card-foreground: oklch(0.2 0.025 150);
--popover: oklch(0.975 0.012 135);
--popover-foreground: oklch(0.2 0.025 150);
--primary: oklch(0.72 0.14 80);
--primary-foreground: oklch(0.19 0.025 75);
--primary-foreground-alt: oklch(0.59 0.13 76);
--secondary: oklch(0.925 0.025 145);
--secondary-foreground: oklch(0.25 0.035 150);
--muted: oklch(0.935 0.016 105);
--muted-foreground: oklch(0.48 0.025 125);
--accent: oklch(0.91 0.035 145);
--accent-foreground: oklch(0.3 0.06 150);
--destructive: oklch(0.52 0.11 95);
--border: oklch(0.79 0.045 78);
--input: oklch(0.86 0.025 110);
--ring: oklch(0.7 0.13 80);
--sidebar: oklch(0.945 0.018 145);
--sidebar-foreground: oklch(0.22 0.03 150);
--sidebar-primary: oklch(0.7 0.13 80);
--sidebar-primary-foreground: oklch(0.19 0.025 75);
--sidebar-accent: oklch(0.9 0.03 145);
--sidebar-accent-foreground: oklch(0.3 0.06 150);
--sidebar-border: oklch(0.78 0.04 78);
}
:root.dark[data-theme="secret"] {
--background: oklch(0.16 0.025 155);
--foreground: oklch(0.92 0.018 88);
--card: oklch(0.38 0.08 82);
--card-foreground: oklch(0.92 0.018 88);
--popover: oklch(0.205 0.035 150);
--popover-foreground: oklch(0.93 0.018 88);
--primary: oklch(0.78 0.13 82);
--primary-foreground: oklch(0.2 0.025 75);
--primary-foreground-alt: oklch(0.8 0.12 84);
--secondary: oklch(0.235 0.035 150);
--secondary-foreground: oklch(0.9 0.018 88);
--muted: oklch(0.225 0.025 145);
--muted-foreground: oklch(0.7 0.025 95);
--accent: oklch(0.235 0.055 150);
--accent-foreground: oklch(0.91 0.025 82);
--destructive: oklch(0.66 0.13 95);
--border: oklch(0.34 0.045 76);
--input: oklch(0.275 0.035 145);
--ring: oklch(0.76 0.12 82);
--sidebar: oklch(0.175 0.03 150);
--sidebar-foreground: oklch(0.92 0.018 88);
--sidebar-primary: oklch(0.76 0.12 82);
--sidebar-primary-foreground: oklch(0.2 0.025 75);
--sidebar-accent: oklch(0.225 0.05 150);
--sidebar-accent-foreground: oklch(0.91 0.025 82);
--sidebar-border: oklch(0.32 0.04 75);
}
:root[data-theme="secret"] body {
background-color: var(--background);
background-image: radial-gradient(circle at 12% 8%, oklch(0.3 0.07 150 / 9%), transparent 34%), radial-gradient(circle at 90% 5%, oklch(0.58 0.09 165 / 8%), transparent 30%);
background-attachment: fixed;
}
:root.dark[data-theme="secret"] body {
background-image: radial-gradient(circle at 12% 8%, oklch(0.29 0.075 150 / 52%), transparent 38%), radial-gradient(circle at 92% 4%, oklch(0.25 0.08 165 / 42%), transparent 34%);
}
:root[data-theme="secret"] [data-slot="button"][data-variant="default"] {
background-color: var(--primary);
background-image: linear-gradient(115deg, oklch(0.57 0.12 72) 0%, oklch(0.76 0.15 82) 30%, oklch(0.96 0.035 92) 47%, oklch(0.79 0.14 84) 58%, oklch(0.62 0.13 74) 100%);
border-color: oklch(0.62 0.12 76);
color: var(--primary-foreground);
text-shadow: 0 1px rgb(255 255 255 / 35%);
box-shadow: inset 0 1px rgb(255 255 255 / 45%), 0 0.45rem 1.2rem oklch(0.35 0.07 75 / 16%);
}
:root[data-theme="secret"] :where([data-slot="card"], [data-slot="dialog-content"], [data-slot="alert-dialog-content"]) {
border-color: color-mix(in oklch, var(--border) 72%, var(--primary));
box-shadow: inset 0 1px color-mix(in oklch, white 8%, transparent), 0 1rem 3rem oklch(0.1 0.03 150 / 12%);
}
:root[data-theme="secret"] [data-slot="card"] {
background: linear-gradient(115deg, oklch(0.68 0.1 74) 0%, oklch(0.86 0.12 84) 30%, oklch(0.98 0.025 94) 47%, oklch(0.88 0.11 86) 58%, oklch(0.72 0.1 76) 100%);
}
:root.dark[data-theme="secret"] [data-slot="card"] {
background: linear-gradient(115deg, oklch(0.27 0.055 72) 0%, oklch(0.43 0.09 82) 30%, oklch(0.68 0.075 91) 47%, oklch(0.46 0.09 84) 58%, oklch(0.3 0.06 74) 100%);
}
:root[data-theme="secret"] :where([data-slot="button"], [data-slot="badge"], [data-slot="toggle"])[data-variant="outline"] {
background-color: oklch(0.86 0.055 150);
border-color: oklch(0.5 0.09 150);
color: oklch(0.27 0.07 150);
}
:root[data-theme="secret"] [data-slot="button"][data-variant="outline"]:hover {
background-color: oklch(0.8 0.075 150);
}
:root.dark[data-theme="secret"] :where([data-slot="button"], [data-slot="badge"], [data-slot="toggle"])[data-variant="outline"] {
background-color: oklch(0.22 0.055 150);
border-color: oklch(0.52 0.1 150);
color: oklch(0.82 0.08 150);
}
:root.dark[data-theme="secret"] [data-slot="button"][data-variant="outline"]:hover {
background-color: oklch(0.3 0.07 150);
}
:root[data-theme="secret"] :where([data-slot="card-title"], [data-slot="item-media"][data-variant="icon"], [data-slot="badge"][data-variant="outline"]) svg {
color: var(--primary);
}
:root[data-theme="secret"] [data-slot="separator"] {
background: linear-gradient(90deg, transparent, color-mix(in oklch, var(--primary) 42%, var(--border)), transparent);
}`,
},
] as const);
export function findTheme(
themes: readonly ThemePreset[],
id: string | null | undefined,
) {
return id ? (themes.find((theme) => theme.id === id) ?? null) : null;
}

View file

@ -23,6 +23,26 @@ export type Base16Key =
export type Base16Palette = Record<Base16Key, string>;
export type ThemeFontFamily = "public-sans" | "system";
export type ThemeDesign = {
density: number;
borderWidth: number;
shadowStrength: number;
surfaceContrast: number;
fontScale: number;
headingWeight: number;
motion: number;
fontFamily: ThemeFontFamily;
};
export type ThemePreset = {
id: string;
title: string;
logo: string;
css: string;
};
export type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: ThemePolarity;
@ -32,6 +52,9 @@ export type ThemeProviderProps = {
defaultTint?: ThemeTint;
defaultBorderRadius?: number;
defaultCustomCss?: string;
themes?: readonly ThemePreset[];
defaultParentThemeId?: string | null;
defaultDesign?: ThemeDesign;
storageKey?: string | null;
colorStorageKey?: string | null;
paletteStorageKey?: string | null;
@ -39,6 +62,8 @@ export type ThemeProviderProps = {
tintStorageKey?: string | null;
borderRadiusStorageKey?: string | null;
customCssStorageKey?: string | null;
parentThemeStorageKey?: string | null;
designStorageKey?: string | null;
disableTransitionOnChange?: boolean;
};
@ -60,6 +85,17 @@ export type ThemeProviderState = {
setThemeBorderRadius: (radius: number) => void;
themeCustomCss: string;
setThemeCustomCss: (css: string) => void;
themes: readonly ThemePreset[];
parentThemeId: string | null;
parentTheme: ThemePreset | null;
setParentThemeId: (id: string | null) => void;
applyThemePreset: (id: string) => void;
resetThemePreset: () => void;
isThemeCustomized: boolean;
themeDesign: ThemeDesign;
setThemeDesign: (
design: ThemeDesign | ((current: ThemeDesign) => ThemeDesign),
) => void;
};
export type HslColor = {

View file

@ -158,6 +158,7 @@ export function formatPaletteJson(palette: Base16Palette | null) {
}
export function getSystemTheme(): ResolvedThemePolarity {
if (typeof window === "undefined") return "light";
if (window.matchMedia(COLOR_SCHEME_QUERY).matches) return "dark";
return "light";
}
@ -551,13 +552,13 @@ export function readStoredValue<T extends string>(
validate: (value: string | null) => value is T,
fallback: T,
) {
if (!key) return fallback;
if (!key || typeof window === "undefined") return fallback;
const value = localStorage.getItem(key);
return validate(value) ? value : fallback;
}
export function saveStoredValue(key: string | null | undefined, value: string) {
if (!key) return;
if (!key || typeof window === "undefined") return;
localStorage.setItem(key, value);
}

31
src/vite.ts Normal file
View file

@ -0,0 +1,31 @@
export type MethaniumUiPluginOptions = {
defaultThemeId: string;
};
export type MethaniumUiPlugin = {
name: "methanium-ui";
config: () => {
define: Record<string, string>;
};
};
export function methaniumUi(
options: MethaniumUiPluginOptions,
): MethaniumUiPlugin {
if (!options.defaultThemeId.trim()) {
throw new Error("methaniumUi defaultThemeId must not be empty");
}
return {
name: "methanium-ui",
config() {
return {
define: {
__METHANIUM_UI_DEFAULT_THEME_ID__: JSON.stringify(
options.defaultThemeId,
),
},
};
},
};
}