diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index bf30706..2fe7189 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -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. diff --git a/src/shared/theme/Foundations.stories.tsx b/src/shared/theme/Foundations.stories.tsx index 9adebbf..7e999e5 100644 --- a/src/shared/theme/Foundations.stories.tsx +++ b/src/shared/theme/Foundations.stories.tsx @@ -23,18 +23,17 @@ const ColorsView: FC = () => { shared/theme/tokens.json. Цвет не заменяет подпись состояния. - {Object.entries(colorRoles).map(([role, color]) => ( + {Object.entries(designTokens.color.roles).map(([role, token]) => ( ))} diff --git a/src/shared/theme/theme.test.ts b/src/shared/theme/theme.test.ts new file mode 100644 index 0000000..cd1dc79 --- /dev/null +++ b/src/shared/theme/theme.test.ts @@ -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, + ); + }); +}); diff --git a/src/shared/theme/theme.ts b/src/shared/theme/theme.ts index 66b532b..c1bde40 100644 --- a/src/shared/theme/theme.ts +++ b/src/shared/theme/theme.ts @@ -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 = (values: Record, 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(", "), ), diff --git a/src/shared/theme/tokens.schema.ts b/src/shared/theme/tokens.schema.ts deleted file mode 100644 index bc5180d..0000000 --- a/src/shared/theme/tokens.schema.ts +++ /dev/null @@ -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 = (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 }; diff --git a/src/shared/theme/tokens.test.ts b/src/shared/theme/tokens.test.ts deleted file mode 100644 index d7e9ef7..0000000 --- a/src/shared/theme/tokens.test.ts +++ /dev/null @@ -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, - ); - }); -});