432 lines
14 KiB
TypeScript
432 lines
14 KiB
TypeScript
export const themeNames = ["violet", "jade", "sunset"] as const;
|
|
export type ThemeName = (typeof themeNames)[number];
|
|
|
|
export const defaultTheme: ThemeName = "violet";
|
|
|
|
export const themeDetails = {
|
|
violet: {
|
|
label: "Violet Seed",
|
|
note: "A Material baseline seeded from a soft violet, close to the reference M3 demos.",
|
|
seed: "#6750A4"
|
|
},
|
|
jade: {
|
|
label: "Jade Seed",
|
|
note: "Cool green-blue tonal families for calmer product surfaces and lower visual heat.",
|
|
seed: "#0B8F83"
|
|
},
|
|
sunset: {
|
|
label: "Sunset Seed",
|
|
note: "Warm coral tonal families for more human, expressive Material palettes.",
|
|
seed: "#B75A46"
|
|
}
|
|
} as const satisfies Record<ThemeName, { label: string; note: string; seed: string }>;
|
|
|
|
export const motionModeNames = ["interactive", "static"] as const;
|
|
export type MotionModeName = (typeof motionModeNames)[number];
|
|
|
|
export const defaultMotionMode: MotionModeName = "interactive";
|
|
|
|
export const motionModeDetails = {
|
|
interactive: {
|
|
label: "Standard",
|
|
note: "The default Material motion language for state, depth, and spatial feedback."
|
|
},
|
|
static: {
|
|
label: "Static",
|
|
note: "Preserve clarity while removing motion-heavy transitions and hover choreography."
|
|
}
|
|
} as const satisfies Record<MotionModeName, { label: string; note: string }>;
|
|
|
|
export const motionScale = {
|
|
instant: "var(--dur-instant)",
|
|
fast: "var(--dur-fast)",
|
|
base: "var(--dur-base)",
|
|
slow: "var(--dur-slow)",
|
|
deliberate: "var(--dur-deliberate)"
|
|
} as const;
|
|
|
|
export const colorTokens = [
|
|
{ name: "background", cssVar: "--color-background", role: "Application canvas" },
|
|
{ name: "surface", cssVar: "--color-surface", role: "Base surface container" },
|
|
{
|
|
name: "surface-container",
|
|
cssVar: "--color-surface-container",
|
|
role: "Default tonal container for cards and supporting panels"
|
|
},
|
|
{
|
|
name: "surface-container-high",
|
|
cssVar: "--color-surface-container-high",
|
|
role: "Raised tonal container for overlays and prominent groups"
|
|
},
|
|
{
|
|
name: "surface-container-highest",
|
|
cssVar: "--color-surface-container-highest",
|
|
role: "Highest emphasis surface used for fields and selected chips"
|
|
},
|
|
{ name: "foreground", cssVar: "--color-foreground", role: "Primary text and icons" },
|
|
{
|
|
name: "on-surface-variant",
|
|
cssVar: "--color-on-surface-variant",
|
|
role: "Supporting text, dividers, and lower-emphasis iconography"
|
|
},
|
|
{ name: "primary", cssVar: "--color-primary", role: "Filled actions and active emphasis" },
|
|
{
|
|
name: "primary-container",
|
|
cssVar: "--color-primary-container",
|
|
role: "Tonal action backgrounds and highlighted containers"
|
|
},
|
|
{
|
|
name: "secondary-container",
|
|
cssVar: "--color-secondary-container",
|
|
role: "Filled tonal surfaces for secondary emphasis"
|
|
},
|
|
{
|
|
name: "tertiary-container",
|
|
cssVar: "--color-tertiary-container",
|
|
role: "Expressive accent container for supportive highlights"
|
|
},
|
|
{ name: "outline", cssVar: "--color-outline", role: "Primary stroke and input outline color" },
|
|
{
|
|
name: "outline-variant",
|
|
cssVar: "--color-outline-variant",
|
|
role: "Lower-emphasis border and separator color"
|
|
},
|
|
{ name: "surface-tint", cssVar: "--color-surface-tint", role: "Tint color for tonal elevation" },
|
|
{ name: "error", cssVar: "--color-error", role: "Error and destructive feedback" },
|
|
{ name: "success", cssVar: "--color-success", role: "Positive validation and confirmation" },
|
|
{ name: "warning", cssVar: "--color-warning", role: "Cautionary feedback" }
|
|
] as const;
|
|
|
|
export const typographyTokens = [
|
|
{
|
|
name: "label",
|
|
fontVar: "--text-sm",
|
|
lineHeightVar: "--leading-snug",
|
|
familyVar: "--font-sans",
|
|
sample: "Labels stay crisp, compact, and readable across controls."
|
|
},
|
|
{
|
|
name: "body",
|
|
fontVar: "--text-base",
|
|
lineHeightVar: "--leading-normal",
|
|
familyVar: "--font-sans",
|
|
sample: "Body copy stays clear and quiet so color and hierarchy do the expressive work."
|
|
},
|
|
{
|
|
name: "title",
|
|
fontVar: "--text-xl",
|
|
lineHeightVar: "--leading-snug",
|
|
familyVar: "--font-display",
|
|
sample: "Titles feel warm and rounded, without drifting into editorial flourish."
|
|
},
|
|
{
|
|
name: "display",
|
|
fontVar: "--text-4xl",
|
|
lineHeightVar: "--leading-tight",
|
|
familyVar: "--font-display",
|
|
sample: "Display copy should feel optimistic, human, and distinctly Material."
|
|
}
|
|
] as const;
|
|
|
|
export const radiusTokens = [
|
|
{ name: "xs", cssVar: "--radius-xs" },
|
|
{ name: "sm", cssVar: "--radius-sm" },
|
|
{ name: "md", cssVar: "--radius-md" },
|
|
{ name: "lg", cssVar: "--radius-lg" },
|
|
{ name: "xl", cssVar: "--radius-xl" },
|
|
{ name: "full", cssVar: "--radius-full" }
|
|
] as const;
|
|
|
|
export const shadowTokens = [
|
|
{ name: "xs", cssVar: "--shadow-xs" },
|
|
{ name: "sm", cssVar: "--shadow-sm" },
|
|
{ name: "md", cssVar: "--shadow-md" },
|
|
{ name: "lg", cssVar: "--shadow-lg" }
|
|
] as const;
|
|
|
|
export const motionTokens = {
|
|
durations: [
|
|
{ name: "instant", cssVar: "--dur-instant" },
|
|
{ name: "fast", cssVar: "--dur-fast" },
|
|
{ name: "base", cssVar: "--dur-base" },
|
|
{ name: "slow", cssVar: "--dur-slow" },
|
|
{ name: "deliberate", cssVar: "--dur-deliberate" }
|
|
],
|
|
easings: [
|
|
{ name: "standard", cssVar: "--ease-standard" },
|
|
{ name: "emphasized", cssVar: "--ease-emphasized" },
|
|
{ name: "exit", cssVar: "--ease-exit" }
|
|
],
|
|
distances: [
|
|
{ name: "xs", cssVar: "--distance-xs" },
|
|
{ name: "sm", cssVar: "--distance-sm" },
|
|
{ name: "md", cssVar: "--distance-md" },
|
|
{ name: "lg", cssVar: "--distance-lg" }
|
|
],
|
|
scales: [
|
|
{ name: "press", cssVar: "--scale-press" },
|
|
{ name: "hover", cssVar: "--scale-hover" },
|
|
{ name: "pop", cssVar: "--scale-pop" }
|
|
]
|
|
} as const;
|
|
|
|
type DynamicColorVariableName =
|
|
| "--color-background"
|
|
| "--color-foreground"
|
|
| "--color-surface"
|
|
| "--color-surface-strong"
|
|
| "--color-surface-contrast"
|
|
| "--color-surface-dim"
|
|
| "--color-surface-bright"
|
|
| "--color-surface-container-low"
|
|
| "--color-surface-container"
|
|
| "--color-surface-container-high"
|
|
| "--color-surface-container-highest"
|
|
| "--color-border"
|
|
| "--color-border-strong"
|
|
| "--color-input"
|
|
| "--color-ring"
|
|
| "--color-primary"
|
|
| "--color-primary-foreground"
|
|
| "--color-primary-container"
|
|
| "--color-on-primary-container"
|
|
| "--color-secondary"
|
|
| "--color-secondary-foreground"
|
|
| "--color-secondary-container"
|
|
| "--color-on-secondary-container"
|
|
| "--color-tertiary"
|
|
| "--color-tertiary-foreground"
|
|
| "--color-tertiary-container"
|
|
| "--color-on-tertiary-container"
|
|
| "--color-muted"
|
|
| "--color-muted-foreground"
|
|
| "--color-accent"
|
|
| "--color-accent-foreground"
|
|
| "--color-success"
|
|
| "--color-success-foreground"
|
|
| "--color-warning"
|
|
| "--color-warning-foreground"
|
|
| "--color-destructive"
|
|
| "--color-destructive-foreground"
|
|
| "--color-card"
|
|
| "--color-card-foreground"
|
|
| "--color-overlay"
|
|
| "--color-outline"
|
|
| "--color-outline-variant"
|
|
| "--color-on-surface"
|
|
| "--color-on-surface-variant"
|
|
| "--color-surface-tint"
|
|
| "--color-error"
|
|
| "--color-on-error"
|
|
| "--color-error-container"
|
|
| "--color-on-error-container"
|
|
| "--color-inverse-surface"
|
|
| "--color-inverse-on-surface";
|
|
|
|
export type DynamicColorVariables = Record<DynamicColorVariableName, string>;
|
|
|
|
function getTargetElement(root?: HTMLElement) {
|
|
if (root) {
|
|
return root;
|
|
}
|
|
|
|
if (typeof document === "undefined") {
|
|
return undefined;
|
|
}
|
|
|
|
return document.documentElement;
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number) {
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
|
|
function normalizeHexColor(seed: string) {
|
|
const normalized = seed.trim().replace(/^#/, "");
|
|
|
|
if (/^[0-9a-f]{3}$/i.test(normalized)) {
|
|
return `#${normalized
|
|
.split("")
|
|
.map((char) => `${char}${char}`)
|
|
.join("")
|
|
.toLowerCase()}`;
|
|
}
|
|
|
|
if (/^[0-9a-f]{6}$/i.test(normalized)) {
|
|
return `#${normalized.toLowerCase()}`;
|
|
}
|
|
|
|
throw new Error(`Expected a hex seed color such as #6750A4. Received "${seed}".`);
|
|
}
|
|
|
|
function hexToRgb(seed: string) {
|
|
const normalized = normalizeHexColor(seed).slice(1);
|
|
|
|
return {
|
|
b: Number.parseInt(normalized.slice(4, 6), 16),
|
|
g: Number.parseInt(normalized.slice(2, 4), 16),
|
|
r: Number.parseInt(normalized.slice(0, 2), 16)
|
|
};
|
|
}
|
|
|
|
function rgbToHsl({ b, g, r }: { b: number; g: number; r: number }) {
|
|
const red = r / 255;
|
|
const green = g / 255;
|
|
const blue = b / 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, l: lightness * 100, s: 0 };
|
|
}
|
|
|
|
const saturation =
|
|
lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
|
|
|
|
let hue = 0;
|
|
|
|
switch (max) {
|
|
case red:
|
|
hue = (green - blue) / delta + (green < blue ? 6 : 0);
|
|
break;
|
|
case green:
|
|
hue = (blue - red) / delta + 2;
|
|
break;
|
|
default:
|
|
hue = (red - green) / delta + 4;
|
|
break;
|
|
}
|
|
|
|
return {
|
|
h: hue * 60,
|
|
l: lightness * 100,
|
|
s: saturation * 100
|
|
};
|
|
}
|
|
|
|
function shiftHue(hue: number, delta: number) {
|
|
return (hue + delta + 360) % 360;
|
|
}
|
|
|
|
function toColor(hue: number, saturation: number, lightness: number) {
|
|
return `hsl(${Math.round(hue)} ${Math.round(clamp(saturation, 0, 100))}% ${Math.round(
|
|
clamp(lightness, 0, 100)
|
|
)}%)`;
|
|
}
|
|
|
|
export function createDynamicColorVariables(seed: string): DynamicColorVariables {
|
|
const { h, s } = rgbToHsl(hexToRgb(seed));
|
|
const accentSaturation = clamp(Math.max(42, s * 0.92), 42, 82);
|
|
const neutralHue = shiftHue(h, 8);
|
|
const secondaryHue = shiftHue(h, 12);
|
|
const tertiaryHue = shiftHue(h, -118);
|
|
const neutralSaturation = clamp(s * 0.1, 5, 13);
|
|
const neutralVariantSaturation = clamp(s * 0.22, 9, 20);
|
|
const containerSaturation = clamp(s * 0.24, 12, 22);
|
|
|
|
return {
|
|
"--color-background": toColor(neutralHue, neutralSaturation, 97),
|
|
"--color-foreground": toColor(neutralHue, neutralSaturation + 2, 15),
|
|
"--color-surface": toColor(neutralHue, neutralSaturation, 95),
|
|
"--color-surface-strong": toColor(secondaryHue, clamp(neutralVariantSaturation * 1.15, 10, 24), 89),
|
|
"--color-surface-contrast": toColor(neutralHue, neutralVariantSaturation + 2, 28),
|
|
"--color-surface-dim": toColor(neutralHue, neutralSaturation, 87),
|
|
"--color-surface-bright": toColor(neutralHue, neutralSaturation, 99),
|
|
"--color-surface-container-low": toColor(neutralHue, neutralSaturation, 96),
|
|
"--color-surface-container": toColor(secondaryHue, clamp(neutralVariantSaturation * 0.8, 8, 18), 92),
|
|
"--color-surface-container-high": toColor(h, clamp(neutralVariantSaturation * 0.95, 9, 20), 88),
|
|
"--color-surface-container-highest": toColor(tertiaryHue, clamp(s * 0.14, 9, 18), 84),
|
|
"--color-border": toColor(secondaryHue, clamp(neutralVariantSaturation * 0.9, 8, 18), 80),
|
|
"--color-border-strong": toColor(secondaryHue, clamp(neutralVariantSaturation * 1.2, 10, 24), 54),
|
|
"--color-input": toColor(secondaryHue, clamp(neutralVariantSaturation * 1.2, 10, 24), 54),
|
|
"--color-ring": toColor(h, accentSaturation, 40),
|
|
"--color-primary": toColor(h, accentSaturation, 44),
|
|
"--color-primary-foreground": toColor(h, 26, 98),
|
|
"--color-primary-container": toColor(h, clamp(s * 0.56, 24, 58), 86),
|
|
"--color-on-primary-container": toColor(h, accentSaturation, 18),
|
|
"--color-secondary": toColor(secondaryHue, containerSaturation, 90),
|
|
"--color-secondary-foreground": toColor(secondaryHue, clamp(s * 0.28, 14, 22), 22),
|
|
"--color-secondary-container": toColor(secondaryHue, containerSaturation, 89),
|
|
"--color-on-secondary-container": toColor(secondaryHue, clamp(s * 0.28, 14, 22), 22),
|
|
"--color-tertiary": toColor(tertiaryHue, clamp(s * 0.26, 14, 24), 40),
|
|
"--color-tertiary-foreground": toColor(tertiaryHue, 20, 98),
|
|
"--color-tertiary-container": toColor(tertiaryHue, clamp(s * 0.18, 12, 18), 86),
|
|
"--color-on-tertiary-container": toColor(tertiaryHue, clamp(s * 0.22, 14, 22), 18),
|
|
"--color-muted": toColor(neutralHue, neutralSaturation, 93),
|
|
"--color-muted-foreground": toColor(neutralHue, neutralVariantSaturation, 34),
|
|
"--color-accent": toColor(tertiaryHue, clamp(s * 0.18, 12, 18), 86),
|
|
"--color-accent-foreground": toColor(tertiaryHue, clamp(s * 0.22, 14, 22), 18),
|
|
"--color-success": toColor(152, 35, 42),
|
|
"--color-success-foreground": toColor(152, 18, 98),
|
|
"--color-warning": toColor(76, 62, 48),
|
|
"--color-warning-foreground": toColor(76, 20, 14),
|
|
"--color-destructive": toColor(12, 72, 44),
|
|
"--color-destructive-foreground": toColor(12, 20, 98),
|
|
"--color-card": toColor(secondaryHue, clamp(neutralSaturation * 0.9, 5, 14), 96),
|
|
"--color-card-foreground": toColor(neutralHue, neutralSaturation + 2, 15),
|
|
"--color-overlay": "color-mix(in oklch, black 24%, transparent)",
|
|
"--color-outline": toColor(secondaryHue, clamp(neutralVariantSaturation * 1.1, 10, 22), 54),
|
|
"--color-outline-variant": toColor(secondaryHue, clamp(neutralVariantSaturation * 0.95, 9, 20), 82),
|
|
"--color-on-surface": toColor(neutralHue, neutralSaturation + 2, 15),
|
|
"--color-on-surface-variant": toColor(neutralHue, neutralVariantSaturation, 34),
|
|
"--color-surface-tint": toColor(h, accentSaturation, 40),
|
|
"--color-error": toColor(12, 72, 44),
|
|
"--color-on-error": toColor(12, 20, 98),
|
|
"--color-error-container": toColor(12, 58, 88),
|
|
"--color-on-error-container": toColor(12, 72, 20),
|
|
"--color-inverse-surface": toColor(neutralHue, neutralSaturation + 2, 20),
|
|
"--color-inverse-on-surface": toColor(neutralHue, neutralSaturation, 96)
|
|
};
|
|
}
|
|
|
|
function applyDynamicColorVariables(
|
|
variables: DynamicColorVariables,
|
|
target: HTMLElement,
|
|
metadata: { seed: string; theme: string }
|
|
) {
|
|
for (const [name, value] of Object.entries(variables)) {
|
|
target.style.setProperty(name, value);
|
|
}
|
|
|
|
target.dataset.theme = metadata.theme;
|
|
target.dataset.seedColor = normalizeHexColor(metadata.seed);
|
|
}
|
|
|
|
export function setTheme(theme: ThemeName, root?: HTMLElement) {
|
|
const target = getTargetElement(root);
|
|
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
applyDynamicColorVariables(createDynamicColorVariables(themeDetails[theme].seed), target, {
|
|
seed: themeDetails[theme].seed,
|
|
theme
|
|
});
|
|
}
|
|
|
|
export function setDynamicColor(seed: string, root?: HTMLElement) {
|
|
const target = getTargetElement(root);
|
|
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
applyDynamicColorVariables(createDynamicColorVariables(seed), target, {
|
|
seed,
|
|
theme: "dynamic"
|
|
});
|
|
}
|
|
|
|
export function setMotionMode(mode: MotionModeName, root?: HTMLElement) {
|
|
const target = getTargetElement(root);
|
|
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
target.dataset.motion = mode;
|
|
}
|