Add harness workflow and Material showcase design system
This commit is contained in:
+296
-47
@@ -1,22 +1,25 @@
|
||||
export const themeNames = ["morandi", "earth", "brand"] as const;
|
||||
export const themeNames = ["violet", "jade", "sunset"] as const;
|
||||
export type ThemeName = (typeof themeNames)[number];
|
||||
|
||||
export const defaultTheme: ThemeName = "morandi";
|
||||
export const defaultTheme: ThemeName = "violet";
|
||||
|
||||
export const themeDetails = {
|
||||
morandi: {
|
||||
label: "Morandi",
|
||||
note: "Muted dusty neutrals with a calm, understated luxury mood"
|
||||
violet: {
|
||||
label: "Violet Seed",
|
||||
note: "A Material baseline seeded from a soft violet, close to the reference M3 demos.",
|
||||
seed: "#6750A4"
|
||||
},
|
||||
earth: {
|
||||
label: "Earth",
|
||||
note: "Organic browns, terracotta warmth, olive depth, and sandstone calm"
|
||||
jade: {
|
||||
label: "Jade Seed",
|
||||
note: "Cool green-blue tonal families for calmer product surfaces and lower visual heat.",
|
||||
seed: "#0B8F83"
|
||||
},
|
||||
brand: {
|
||||
label: "Brand",
|
||||
note: "Verdant accent scaffold"
|
||||
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 }>;
|
||||
} as const satisfies Record<ThemeName, { label: string; note: string; seed: string }>;
|
||||
|
||||
export const motionModeNames = ["interactive", "static"] as const;
|
||||
export type MotionModeName = (typeof motionModeNames)[number];
|
||||
@@ -25,12 +28,12 @@ export const defaultMotionMode: MotionModeName = "interactive";
|
||||
|
||||
export const motionModeDetails = {
|
||||
interactive: {
|
||||
label: "Interactive",
|
||||
note: "Micro-interactions with hover lift, press feedback, focus transitions, and animated state changes"
|
||||
label: "Standard",
|
||||
note: "The default Material motion language for state, depth, and spatial feedback."
|
||||
},
|
||||
static: {
|
||||
label: "Static",
|
||||
note: "Keep visual states readable while removing motion-heavy feedback and animation"
|
||||
note: "Preserve clarity while removing motion-heavy transitions and hover choreography."
|
||||
}
|
||||
} as const satisfies Record<MotionModeName, { label: string; note: string }>;
|
||||
|
||||
@@ -44,66 +47,84 @@ export const motionScale = {
|
||||
|
||||
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: "surface", cssVar: "--color-surface", role: "Secondary surface backgrounds" },
|
||||
{
|
||||
name: "surface-strong",
|
||||
cssVar: "--color-surface-strong",
|
||||
role: "Elevated surface emphasis"
|
||||
name: "on-surface-variant",
|
||||
cssVar: "--color-on-surface-variant",
|
||||
role: "Supporting text, dividers, and lower-emphasis iconography"
|
||||
},
|
||||
{ name: "card", cssVar: "--color-card", role: "Cards and floating panels" },
|
||||
{ name: "border", cssVar: "--color-border", role: "Default dividers and input borders" },
|
||||
{ name: "primary", cssVar: "--color-primary", role: "Filled actions and active emphasis" },
|
||||
{
|
||||
name: "border-strong",
|
||||
cssVar: "--color-border-strong",
|
||||
role: "Higher emphasis dividers"
|
||||
name: "primary-container",
|
||||
cssVar: "--color-primary-container",
|
||||
role: "Tonal action backgrounds and highlighted containers"
|
||||
},
|
||||
{ name: "primary", cssVar: "--color-primary", role: "Primary actions and highlights" },
|
||||
{
|
||||
name: "secondary",
|
||||
cssVar: "--color-secondary",
|
||||
role: "Secondary fills and supporting actions"
|
||||
name: "secondary-container",
|
||||
cssVar: "--color-secondary-container",
|
||||
role: "Filled tonal surfaces for secondary emphasis"
|
||||
},
|
||||
{ name: "muted", cssVar: "--color-muted", role: "Subtle supporting surfaces" },
|
||||
{
|
||||
name: "muted-foreground",
|
||||
cssVar: "--color-muted-foreground",
|
||||
role: "Secondary text and captions"
|
||||
name: "tertiary-container",
|
||||
cssVar: "--color-tertiary-container",
|
||||
role: "Expressive accent container for supportive highlights"
|
||||
},
|
||||
{ name: "accent", cssVar: "--color-accent", role: "Moments of emphasis or delight" },
|
||||
{ name: "success", cssVar: "--color-success", role: "Success feedback" },
|
||||
{ name: "warning", cssVar: "--color-warning", role: "Warning feedback" },
|
||||
{ name: "destructive", cssVar: "--color-destructive", role: "Destructive actions" }
|
||||
{ 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: "caption",
|
||||
fontVar: "--text-xs",
|
||||
lineHeightVar: "--leading-normal",
|
||||
name: "label",
|
||||
fontVar: "--text-sm",
|
||||
lineHeightVar: "--leading-snug",
|
||||
familyVar: "--font-sans",
|
||||
sample: "Small labels, metadata, and supporting notes."
|
||||
sample: "Labels stay crisp, compact, and readable across controls."
|
||||
},
|
||||
{
|
||||
name: "body",
|
||||
fontVar: "--text-base",
|
||||
lineHeightVar: "--leading-normal",
|
||||
familyVar: "--font-sans",
|
||||
sample: "Body copy stays warm, readable, and stable across themes."
|
||||
sample: "Body copy stays clear and quiet so color and hierarchy do the expressive work."
|
||||
},
|
||||
{
|
||||
name: "lead",
|
||||
name: "title",
|
||||
fontVar: "--text-xl",
|
||||
lineHeightVar: "--leading-loose",
|
||||
familyVar: "--font-sans",
|
||||
sample: "Lead text introduces a surface without becoming display copy."
|
||||
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 text carries the editorial voice of the system."
|
||||
sample: "Display copy should feel optimistic, human, and distinctly Material."
|
||||
}
|
||||
] as const;
|
||||
|
||||
@@ -149,6 +170,61 @@ export const motionTokens = {
|
||||
]
|
||||
} 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;
|
||||
@@ -161,6 +237,163 @@ function getTargetElement(root?: HTMLElement) {
|
||||
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);
|
||||
|
||||
@@ -168,7 +401,23 @@ export function setTheme(theme: ThemeName, root?: HTMLElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.dataset.theme = theme;
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user