refactor: simplify local design token consumption

This commit is contained in:
2026-09-08 20:18:26 +03:00
parent 0f50f15658
commit 8b29cdfd98
6 changed files with 41 additions and 342 deletions
+2 -2
View File
@@ -13,8 +13,8 @@
Для cross-slice imports используйте alias `@/` и public API, например `@/entities/portfolio`. Vite и TypeScript используют одинаковый alias.
`shared/theme/tokens.json` владеет опубликованными токенами и описаниями ролей
в переносимом формате WealthLedger v1. `tokens.schema.ts` проверяет source,
`theme.ts` адаптирует его в Mantine. `shared/theme/index.ts` экспортирует theme,
в переносимом формате WealthLedger v1. `theme.ts` напрямую адаптирует доверенный
локальный JSON в Mantine без runtime-схемы. `shared/theme/index.ts` экспортирует theme,
designTokens, семантические color roles и CSSVariablesResolver и подключает
базовые styles. App и Storybook подключают свои MantineProvider к этому public API.
Внешние системы читают raw JSON напрямую, без frontend barrel и его runtime.
+4 -5
View File
@@ -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>
))}
+29
View File
@@ -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,
);
});
});
+6 -10
View File
@@ -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(", "),
),
-149
View File
@@ -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 };
-176
View File
@@ -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,
);
});
});