refactor: simplify local design token consumption
This commit is contained in:
+2
-2
@@ -13,8 +13,8 @@
|
|||||||
Для cross-slice imports используйте alias `@/` и public API, например `@/entities/portfolio`. Vite и TypeScript используют одинаковый alias.
|
Для cross-slice imports используйте alias `@/` и public API, например `@/entities/portfolio`. Vite и TypeScript используют одинаковый alias.
|
||||||
|
|
||||||
`shared/theme/tokens.json` владеет опубликованными токенами и описаниями ролей
|
`shared/theme/tokens.json` владеет опубликованными токенами и описаниями ролей
|
||||||
в переносимом формате WealthLedger v1. `tokens.schema.ts` проверяет source,
|
в переносимом формате WealthLedger v1. `theme.ts` напрямую адаптирует доверенный
|
||||||
`theme.ts` адаптирует его в Mantine. `shared/theme/index.ts` экспортирует theme,
|
локальный JSON в Mantine без runtime-схемы. `shared/theme/index.ts` экспортирует theme,
|
||||||
designTokens, семантические color roles и CSSVariablesResolver и подключает
|
designTokens, семантические color roles и CSSVariablesResolver и подключает
|
||||||
базовые styles. App и Storybook подключают свои MantineProvider к этому public API.
|
базовые styles. App и Storybook подключают свои MantineProvider к этому public API.
|
||||||
Внешние системы читают raw JSON напрямую, без frontend barrel и его runtime.
|
Внешние системы читают raw JSON напрямую, без frontend barrel и его runtime.
|
||||||
|
|||||||
@@ -23,18 +23,17 @@ const ColorsView: FC = () => {
|
|||||||
shared/theme/tokens.json. Цвет не заменяет подпись состояния.
|
shared/theme/tokens.json. Цвет не заменяет подпись состояния.
|
||||||
</Text>
|
</Text>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
|
<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>
|
<Paper key={role} p="md" withBorder>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Box h={48} bg={`var(--wl-color-${role})`} aria-hidden="true" />
|
<Box h={48} bg={`var(--wl-color-${role})`} aria-hidden="true" />
|
||||||
<Text fw={700}>{role}</Text>
|
<Text fw={700}>{role}</Text>
|
||||||
<Code>--wl-color-{role}</Code>
|
<Code>--wl-color-{role}</Code>
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
{color} · {parseThemeColor({ color, theme }).value}
|
{colorRoles[role]} ·{" "}
|
||||||
</Text>
|
{parseThemeColor({ color: colorRoles[role], theme }).value}
|
||||||
<Text size="sm">
|
|
||||||
{designTokens.color.roles[role].description}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text size="sm">{token.description}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</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 {
|
import {
|
||||||
type CSSVariablesResolver,
|
type CSSVariablesResolver,
|
||||||
|
colorsTuple,
|
||||||
createTheme,
|
createTheme,
|
||||||
getThemeColor,
|
getThemeColor,
|
||||||
|
type MantinePrimaryShade,
|
||||||
rem,
|
rem,
|
||||||
rgba,
|
rgba,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import source from "./tokens.json";
|
import designTokens from "./tokens.json";
|
||||||
import { designTokensSchema } from "./tokens.schema";
|
|
||||||
|
|
||||||
const designTokens = designTokensSchema.parse(source);
|
|
||||||
const { color, typography, spacing, radius, shadows, breakpoints } =
|
const { color, typography, spacing, radius, shadows, breakpoints } =
|
||||||
designTokens;
|
designTokens;
|
||||||
const mapValues = <T, U>(values: Record<string, T>, convert: (value: T) => U) =>
|
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({
|
const theme = createTheme({
|
||||||
primaryColor: color.roles.primary.color,
|
primaryColor: color.roles.primary.color,
|
||||||
primaryShade: color.primaryShade,
|
primaryShade: color.primaryShade as MantinePrimaryShade,
|
||||||
defaultRadius: radius.default,
|
defaultRadius: radius.default,
|
||||||
white: color.base.white,
|
white: color.base.white,
|
||||||
black: color.base.black,
|
black: color.base.black,
|
||||||
colors: mapValues(color.palettes, (palette) => {
|
colors: mapValues(color.palettes, colorsTuple),
|
||||||
// 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;
|
|
||||||
}),
|
|
||||||
fontFamily: typography.fontFamily,
|
fontFamily: typography.fontFamily,
|
||||||
fontFamilyMonospace: typography.fontFamilyMonospace,
|
fontFamilyMonospace: typography.fontFamilyMonospace,
|
||||||
fontSizes: mapValues(typography.fontSizes, (value) => rem(value)),
|
fontSizes: mapValues(typography.fontSizes, (value) => rem(value)),
|
||||||
@@ -50,7 +46,7 @@ const theme = createTheme({
|
|||||||
layers
|
layers
|
||||||
.map(
|
.map(
|
||||||
(layer) =>
|
(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(", "),
|
.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