refactor: simplify local design token consumption
This commit is contained in:
@@ -23,18 +23,17 @@ const ColorsView: FC = () => {
|
||||
shared/theme/tokens.json. Цвет не заменяет подпись состояния.
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
|
||||
{Object.entries(colorRoles).map(([role, color]) => (
|
||||
{Object.entries(designTokens.color.roles).map(([role, token]) => (
|
||||
<Paper key={role} p="md" withBorder>
|
||||
<Stack gap="xs">
|
||||
<Box h={48} bg={`var(--wl-color-${role})`} aria-hidden="true" />
|
||||
<Text fw={700}>{role}</Text>
|
||||
<Code>--wl-color-{role}</Code>
|
||||
<Text size="sm">
|
||||
{color} · {parseThemeColor({ color, theme }).value}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{designTokens.color.roles[role].description}
|
||||
{colorRoles[role]} ·{" "}
|
||||
{parseThemeColor({ color: colorRoles[role], theme }).value}
|
||||
</Text>
|
||||
<Text size="sm">{token.description}</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { DEFAULT_THEME, mergeMantineTheme } from "@mantine/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { colorRoles, cssVariablesResolver, theme } from "./theme";
|
||||
import source from "./tokens.json";
|
||||
|
||||
describe("design theme adapter", () => {
|
||||
it("preserves scale-aware sizes, unscaled breakpoints and shadow layers", () => {
|
||||
expect(theme.spacing?.md).toBe("calc(1rem * var(--mantine-scale))");
|
||||
expect(theme.fontSizes?.sm).toBe("calc(0.875rem * var(--mantine-scale))");
|
||||
expect(theme.breakpoints?.sm).toBe("48em");
|
||||
expect(theme.shadows?.sm).toContain(
|
||||
"calc(-0.3125rem * var(--mantine-scale))",
|
||||
);
|
||||
expect(theme.shadows?.sm).toContain("rgba(0, 0, 0, 0.04)");
|
||||
});
|
||||
|
||||
it("keeps default-shade roles attached to native filled variables", () => {
|
||||
const resolved = mergeMantineTheme(DEFAULT_THEME, theme);
|
||||
const variables = cssVariablesResolver(resolved).variables;
|
||||
expect(colorRoles.primary).toBe("blue");
|
||||
expect(variables["--wl-color-primary"]).toBe(
|
||||
"var(--mantine-color-blue-filled)",
|
||||
);
|
||||
expect(variables["--wl-color-canvas"]).toBe("var(--mantine-color-gray-2)");
|
||||
expect(Object.keys(variables)).toHaveLength(
|
||||
Object.keys(source.color.roles).length,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,14 @@
|
||||
import {
|
||||
type CSSVariablesResolver,
|
||||
colorsTuple,
|
||||
createTheme,
|
||||
getThemeColor,
|
||||
type MantinePrimaryShade,
|
||||
rem,
|
||||
rgba,
|
||||
} from "@mantine/core";
|
||||
import source from "./tokens.json";
|
||||
import { designTokensSchema } from "./tokens.schema";
|
||||
import designTokens from "./tokens.json";
|
||||
|
||||
const designTokens = designTokensSchema.parse(source);
|
||||
const { color, typography, spacing, radius, shadows, breakpoints } =
|
||||
designTokens;
|
||||
const mapValues = <T, U>(values: Record<string, T>, convert: (value: T) => U) =>
|
||||
@@ -22,15 +22,11 @@ const colorRoles = mapValues(color.roles, (role) =>
|
||||
|
||||
const theme = createTheme({
|
||||
primaryColor: color.roles.primary.color,
|
||||
primaryShade: color.primaryShade,
|
||||
primaryShade: color.primaryShade as MantinePrimaryShade,
|
||||
defaultRadius: radius.default,
|
||||
white: color.base.white,
|
||||
black: color.base.black,
|
||||
colors: mapValues(color.palettes, (palette) => {
|
||||
// Source schema guarantees exactly ten entries before tuple conversion.
|
||||
const [a, b, c, d, e, f, g, h, i, j] = palette;
|
||||
return [a, b, c, d, e, f, g, h, i, j] as const;
|
||||
}),
|
||||
colors: mapValues(color.palettes, colorsTuple),
|
||||
fontFamily: typography.fontFamily,
|
||||
fontFamilyMonospace: typography.fontFamilyMonospace,
|
||||
fontSizes: mapValues(typography.fontSizes, (value) => rem(value)),
|
||||
@@ -50,7 +46,7 @@ const theme = createTheme({
|
||||
layers
|
||||
.map(
|
||||
(layer) =>
|
||||
`${rem(layer.offsetX)} ${rem(layer.offsetY)} ${rem(layer.blur)} ${rem(layer.spread)} ${rgba(color.base[layer.color], layer.opacity)}`,
|
||||
`${rem(layer.offsetX)} ${rem(layer.offsetY)} ${rem(layer.blur)} ${rem(layer.spread)} ${rgba(color.base[layer.color as keyof typeof color.base], layer.opacity)}`,
|
||||
)
|
||||
.join(", "),
|
||||
),
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const hexColor = z.string().regex(/^#(?:[\da-f]{3}|[\da-f]{6})$/i);
|
||||
const finite = z.number().finite();
|
||||
const positive = finite.positive();
|
||||
const shade = z.union([
|
||||
z.literal(0),
|
||||
z.literal(1),
|
||||
z.literal(2),
|
||||
z.literal(3),
|
||||
z.literal(4),
|
||||
z.literal(5),
|
||||
z.literal(6),
|
||||
z.literal(7),
|
||||
z.literal(8),
|
||||
z.literal(9),
|
||||
]);
|
||||
const scale = <T extends z.ZodTypeAny>(value: T) =>
|
||||
z.object({ xs: value, sm: value, md: value, lg: value, xl: value }).strict();
|
||||
const role = z
|
||||
.object({
|
||||
color: z.string().min(1),
|
||||
shade: shade.nullable(),
|
||||
description: z.string().min(1),
|
||||
})
|
||||
.strict();
|
||||
const heading = z.object({ fontSize: positive, lineHeight: positive }).strict();
|
||||
const shadow = z
|
||||
.object({
|
||||
offsetX: finite,
|
||||
offsetY: finite,
|
||||
blur: finite.nonnegative(),
|
||||
spread: finite,
|
||||
color: z.enum(["white", "black"]),
|
||||
opacity: finite.min(0).max(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const designTokensSchema = z
|
||||
.object({
|
||||
formatVersion: z.literal(1),
|
||||
description: z.string().min(1),
|
||||
color: z
|
||||
.object({
|
||||
base: z.object({ white: hexColor, black: hexColor }).strict(),
|
||||
palettes: z.record(
|
||||
z
|
||||
.string()
|
||||
.regex(/^[a-z][a-z0-9-]*$/)
|
||||
.refine(
|
||||
(name) => name !== "dimmed" && name !== "bright",
|
||||
"Palette name is reserved by Mantine",
|
||||
),
|
||||
z.array(hexColor).length(10),
|
||||
),
|
||||
primaryShade: z.object({ light: shade, dark: shade }).strict(),
|
||||
roles: z.record(z.string().regex(/^[a-z][a-z0-9-]*$/), role),
|
||||
})
|
||||
.strict(),
|
||||
typography: z
|
||||
.object({
|
||||
fontFamily: z.string().min(1),
|
||||
fontFamilyMonospace: z.string().min(1),
|
||||
unit: z.literal("px"),
|
||||
fontSizes: scale(positive),
|
||||
fontWeights: z
|
||||
.object({
|
||||
regular: finite.min(1).max(1000),
|
||||
medium: finite.min(1).max(1000),
|
||||
bold: finite.min(1).max(1000),
|
||||
})
|
||||
.strict(),
|
||||
lineHeights: scale(positive),
|
||||
headings: z
|
||||
.object({
|
||||
h1: heading,
|
||||
h2: heading,
|
||||
h3: heading,
|
||||
h4: heading,
|
||||
h5: heading,
|
||||
h6: heading,
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict(),
|
||||
spacing: z
|
||||
.object({ unit: z.literal("px"), values: scale(finite.nonnegative()) })
|
||||
.strict(),
|
||||
radius: z
|
||||
.object({
|
||||
unit: z.literal("px"),
|
||||
default: z.enum(["xs", "sm", "md", "lg", "xl"]),
|
||||
values: scale(finite.nonnegative()),
|
||||
})
|
||||
.strict(),
|
||||
shadows: z
|
||||
.object({ unit: z.literal("px"), values: scale(z.array(shadow).min(1)) })
|
||||
.strict(),
|
||||
breakpoints: z
|
||||
.object({ unit: z.literal("em"), values: scale(positive) })
|
||||
.strict(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((tokens, context) => {
|
||||
const { base, palettes, roles } = tokens.color;
|
||||
for (const required of ["canvas", "text", "primary"]) {
|
||||
if (!Object.hasOwn(roles, required)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["color", "roles", required],
|
||||
message: "Required semantic role is missing",
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [name, value] of Object.entries(roles)) {
|
||||
const isPalette = Object.hasOwn(palettes, value.color);
|
||||
const isBase = Object.hasOwn(base, value.color);
|
||||
if ((!isPalette && !isBase) || (isBase && value.shade !== null)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["color", "roles", name],
|
||||
message:
|
||||
"Role must reference an existing palette shade or an unshaded base color",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
roles.primary &&
|
||||
(!Object.hasOwn(palettes, roles.primary.color) ||
|
||||
roles.primary.shade !== null)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["color", "roles", "primary"],
|
||||
message: "Primary must reference a palette with the default shade",
|
||||
});
|
||||
}
|
||||
for (const name of Object.keys(base)) {
|
||||
if (Object.hasOwn(palettes, name)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["color", "palettes", name],
|
||||
message: "Palette name conflicts with a base color",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export { designTokensSchema };
|
||||
@@ -1,176 +0,0 @@
|
||||
import { DEFAULT_THEME, mergeMantineTheme } from "@mantine/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { colorRoles, cssVariablesResolver, theme } from "./theme";
|
||||
import source from "./tokens.json";
|
||||
import { designTokensSchema } from "./tokens.schema";
|
||||
|
||||
describe("portable design tokens", () => {
|
||||
it("accepts the document, including negative shadow spread and default-shade aliases", () => {
|
||||
const tokens = designTokensSchema.parse(source);
|
||||
expect(tokens.shadows.values.sm[1].spread).toBe(-5);
|
||||
expect(tokens.color.roles.primary.shade).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"unsupported dimension unit",
|
||||
{ ...source, spacing: { ...source.spacing, unit: "rem" } },
|
||||
],
|
||||
[
|
||||
"incomplete palette",
|
||||
{
|
||||
...source,
|
||||
color: {
|
||||
...source.color,
|
||||
palettes: {
|
||||
...source.color.palettes,
|
||||
blue: source.color.palettes.blue.slice(1),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"missing reference",
|
||||
{
|
||||
...source,
|
||||
color: {
|
||||
...source.color,
|
||||
roles: {
|
||||
...source.color.roles,
|
||||
canvas: { ...source.color.roles.canvas, color: "missing" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"inherited property as reference",
|
||||
{
|
||||
...source,
|
||||
color: {
|
||||
...source.color,
|
||||
roles: {
|
||||
...source.color.roles,
|
||||
canvas: { ...source.color.roles.canvas, color: "constructor" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"shade on base color",
|
||||
{
|
||||
...source,
|
||||
color: {
|
||||
...source.color,
|
||||
roles: {
|
||||
...source.color.roles,
|
||||
surface: { ...source.color.roles.surface, shade: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"fixed primary shade",
|
||||
{
|
||||
...source,
|
||||
color: {
|
||||
...source.color,
|
||||
roles: {
|
||||
...source.color.roles,
|
||||
primary: { ...source.color.roles.primary, shade: 6 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"missing required roles",
|
||||
{ ...source, color: { ...source.color, roles: {} } },
|
||||
],
|
||||
[
|
||||
"ambiguous color name",
|
||||
{
|
||||
...source,
|
||||
color: {
|
||||
...source.color,
|
||||
palettes: {
|
||||
...source.color.palettes,
|
||||
white: source.color.palettes.blue,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"out of range shade",
|
||||
{
|
||||
...source,
|
||||
color: { ...source.color, primaryShade: { light: 10, dark: 8 } },
|
||||
},
|
||||
],
|
||||
[
|
||||
"invalid shadow opacity",
|
||||
{
|
||||
...source,
|
||||
shadows: {
|
||||
...source.shadows,
|
||||
values: {
|
||||
...source.shadows.values,
|
||||
xs: [{ ...source.shadows.values.xs[0], opacity: 2 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
])("rejects %s without fallback", (_name, document) => {
|
||||
expect(designTokensSchema.safeParse(document).success).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["dimmed", "bright"])("rejects reserved palette name %s", (name) => {
|
||||
const document = {
|
||||
...source,
|
||||
color: {
|
||||
...source.color,
|
||||
palettes: {
|
||||
...source.color.palettes,
|
||||
[name]: source.color.palettes.blue,
|
||||
},
|
||||
roles: {
|
||||
...source.color.roles,
|
||||
primary: { ...source.color.roles.primary, color: name },
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(designTokensSchema.safeParse(document).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects weights below the CSS range", () => {
|
||||
const document = {
|
||||
...source,
|
||||
typography: {
|
||||
...source.typography,
|
||||
fontWeights: { ...source.typography.fontWeights, regular: 0.5 },
|
||||
},
|
||||
};
|
||||
expect(designTokensSchema.safeParse(document).success).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves scale-aware sizes, unscaled breakpoints and shadow layers", () => {
|
||||
expect(theme.spacing?.md).toBe("calc(1rem * var(--mantine-scale))");
|
||||
expect(theme.fontSizes?.sm).toBe("calc(0.875rem * var(--mantine-scale))");
|
||||
expect(theme.breakpoints?.sm).toBe("48em");
|
||||
expect(theme.shadows?.sm).toContain(
|
||||
"calc(-0.3125rem * var(--mantine-scale))",
|
||||
);
|
||||
expect(theme.shadows?.sm).toContain("rgba(0, 0, 0, 0.04)");
|
||||
});
|
||||
|
||||
it("keeps default-shade roles attached to native filled variables", () => {
|
||||
const resolved = mergeMantineTheme(DEFAULT_THEME, theme);
|
||||
const variables = cssVariablesResolver(resolved).variables;
|
||||
expect(colorRoles.primary).toBe("blue");
|
||||
expect(variables["--wl-color-primary"]).toBe(
|
||||
"var(--mantine-color-blue-filled)",
|
||||
);
|
||||
expect(variables["--wl-color-canvas"]).toBe("var(--mantine-color-gray-2)");
|
||||
expect(Object.keys(variables)).toHaveLength(
|
||||
Object.keys(source.color.roles).length,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user