diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index 058f494..bf30706 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -12,9 +12,13 @@ Для cross-slice imports используйте alias `@/` и public API, например `@/entities/portfolio`. Vite и TypeScript используют одинаковый alias. -`shared/theme/index.ts` экспортирует единую Mantine theme, семантические color -roles и CSSVariablesResolver и подключает базовые styles. App и Storybook -подключают свои MantineProvider к этому public API. Scales принимаются из Mantine, -локальные domain styles остаются у владельцев. `shared/ui` предоставляет +`shared/theme/tokens.json` владеет опубликованными токенами и описаниями ролей +в переносимом формате WealthLedger v1. `tokens.schema.ts` проверяет source, +`theme.ts` адаптирует его в Mantine. `shared/theme/index.ts` экспортирует theme, +designTokens, семантические color roles и CSSVariablesResolver и подключает +базовые styles. App и Storybook подключают свои MantineProvider к этому public API. +Внешние системы читают raw JSON напрямую, без frontend barrel и его runtime. +Неопубликованные defaults остаются у Mantine, локальные domain styles — у владельцев. +`shared/ui` предоставляет StateView и LoadingSkeleton; их каталог не включает route-aware AppShell. Подробнее: [дизайн-система](../../docs/development/design-system.md). diff --git a/src/shared/theme/Foundations.stories.tsx b/src/shared/theme/Foundations.stories.tsx index 324249f..9adebbf 100644 --- a/src/shared/theme/Foundations.stories.tsx +++ b/src/shared/theme/Foundations.stories.tsx @@ -11,21 +11,7 @@ import { } from "@mantine/core"; import type { Meta, StoryObj } from "@storybook/react-vite"; import type { FC } from "react"; -import { colorRoles } from "./index"; - -const roleUsage: Record = { - canvas: "Фон приложения", - surface: "Поверхность Paper / формы", - subtle: "Приглушённая поверхность сводки", - text: "Основной текст страницы", - muted: "Вторичный текст; проверяйте контраст при выборе фона", - border: "Граница поверхности", - primary: "Основное действие и активная навигация", - info: "Информационное сообщение", - positive: "Положительный результат; дополните знаком и текстом", - negative: "Ошибка / отрицательный результат; дополните текстом", - warning: "Предупреждение; не мелкий текст на белом фоне", -}; +import { colorRoles, designTokens } from "./index"; const ColorsView: FC = () => { const theme = useMantineTheme(); @@ -33,8 +19,8 @@ const ColorsView: FC = () => { Цвета и семантические роли - Светлая схема WealthLedger. Роли ссылаются на палитры Mantine, а не - хранят вторую копию цветов. Цвет не заменяет подпись состояния. + Светлая схема WealthLedger. Источник цветов и ролей — + shared/theme/tokens.json. Цвет не заменяет подпись состояния. {Object.entries(colorRoles).map(([role, color]) => ( @@ -47,34 +33,33 @@ const ColorsView: FC = () => { {color} · {parseThemeColor({ color, theme }).value} - {roleUsage[role as keyof typeof colorRoles]} + {designTokens.color.roles[role].description} ))} - {["blue", "gray", "dark", "green", "red", "yellow", "grape"].map( - (palette) => ( - - {palette} - - {theme.colors[palette].map((value, shade) => ( - - - ))} - - - ), - )} + {Object.keys(designTokens.color.palettes).map((palette) => ( + + {palette} + + {theme.colors[palette].map((value, shade) => ( + + + ))} + + + ))} Blue — фирменная палитра. Gray/dark — нейтрали; green/red/yellow — - статусы. Grape — дополнительный цвет графиков, его предметное значение - задаёт Product. Палитры не гарантируют контраст любых сочетаний. + статусы; orange — дополнительный акцент. Grape — цвет графиков, его + предметное значение задаёт Product. Палитры не гарантируют контраст + любых сочетаний. ); @@ -131,7 +116,8 @@ const LayoutView: FC = () => { Интервалы, форма и адаптивность Используйте Mantine props gap/p/radius/shadow или --mantine-* в CSS. - Шкалы приняты из установленной Mantine без копий чисел. Default radius: + Шкалы принадлежат tokens.json; адаптер Mantine сохраняет + масштабирование. Default radius: {theme.defaultRadius}; scale: {theme.scale}. Размеры конкретных карточек и их особые тени остаются локальными. @@ -183,7 +169,7 @@ const meta = { docs: { description: { component: - "Единый shared/theme приложения и каталога. Все образцы читают effective Mantine theme. Показаны используемые шкалы и роли, а не весь Mantine. Светлая схема; контраст, смысловые заголовки и отсутствие передачи статуса только цветом проверяются в конкретной композиции.", + "Единый источник shared/theme/tokens.json; theme.ts адаптирует его к Mantine. Все образцы читают effective theme; описания ролей берутся из JSON. Показаны используемые шкалы и роли, а не весь Mantine. Светлая схема; контраст, смысловые заголовки и отсутствие передачи статуса только цветом проверяются в конкретной композиции.", }, }, }, diff --git a/src/shared/theme/index.ts b/src/shared/theme/index.ts index 0bb6bdd..87153c4 100644 --- a/src/shared/theme/index.ts +++ b/src/shared/theme/index.ts @@ -1,3 +1,3 @@ import "./styles.css"; -export { colorRoles, cssVariablesResolver, theme } from "./theme"; +export { colorRoles, cssVariablesResolver, designTokens, theme } from "./theme"; diff --git a/src/shared/theme/theme.ts b/src/shared/theme/theme.ts index 8530b43..66b532b 100644 --- a/src/shared/theme/theme.ts +++ b/src/shared/theme/theme.ts @@ -1,69 +1,74 @@ import { type CSSVariablesResolver, createTheme, - DEFAULT_THEME, getThemeColor, + rem, + rgba, } from "@mantine/core"; +import source from "./tokens.json"; +import { designTokensSchema } from "./tokens.schema"; -// Native Mantine color references, not a second palette. Light scheme only. -const colorRoles = { - canvas: "gray.2", - surface: "white", - subtle: "gray.0", - text: "dark.7", - muted: "gray.6", - border: "gray.3", - primary: "blue", - info: "blue.6", - positive: "green.6", - negative: "red.6", - warning: "yellow.6", -} as const; +const designTokens = designTokensSchema.parse(source); +const { color, typography, spacing, radius, shadows, breakpoints } = + designTokens; +const mapValues = (values: Record, convert: (value: T) => U) => + Object.fromEntries( + Object.entries(values).map(([name, value]) => [name, convert(value)]), + ); -const fontFamily = - '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; +const colorRoles = mapValues(color.roles, (role) => + role.shade === null ? role.color : `${role.color}.${role.shade}`, +); const theme = createTheme({ - primaryColor: "blue", - defaultRadius: "md", - fontFamily, - // Explicitly adopt the installed Mantine scales without duplicating values. - fontFamilyMonospace: DEFAULT_THEME.fontFamilyMonospace, - fontSizes: DEFAULT_THEME.fontSizes, - fontWeights: DEFAULT_THEME.fontWeights, - lineHeights: DEFAULT_THEME.lineHeights, - headings: { ...DEFAULT_THEME.headings, fontFamily }, - spacing: DEFAULT_THEME.spacing, - radius: DEFAULT_THEME.radius, - shadows: DEFAULT_THEME.shadows, - breakpoints: DEFAULT_THEME.breakpoints, - primaryShade: DEFAULT_THEME.primaryShade, - focusRing: DEFAULT_THEME.focusRing, - colors: { - blue: [ - "#eff6ff", - "#dbeafe", - "#bfdbfe", - "#93c5fd", - "#60a5fa", - "#3b82f6", - "#2563eb", - "#1d4ed8", - "#1e40af", - "#1e3a8a", - ], + primaryColor: color.roles.primary.color, + primaryShade: color.primaryShade, + 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; + }), + fontFamily: typography.fontFamily, + fontFamilyMonospace: typography.fontFamilyMonospace, + fontSizes: mapValues(typography.fontSizes, (value) => rem(value)), + fontWeights: mapValues(typography.fontWeights, String), + lineHeights: mapValues(typography.lineHeights, String), + headings: { + fontFamily: typography.fontFamily, + fontWeight: String(typography.fontWeights.bold), + sizes: mapValues(typography.headings, (value) => ({ + fontSize: rem(value.fontSize), + lineHeight: String(value.lineHeight), + })), }, + spacing: mapValues(spacing.values, (value) => rem(value)), + radius: mapValues(radius.values, (value) => rem(value)), + shadows: mapValues(shadows.values, (layers) => + layers + .map( + (layer) => + `${rem(layer.offsetX)} ${rem(layer.offsetY)} ${rem(layer.blur)} ${rem(layer.spread)} ${rgba(color.base[layer.color], layer.opacity)}`, + ) + .join(", "), + ), + breakpoints: mapValues( + breakpoints.values, + (value) => `${value}${breakpoints.unit}`, + ), }); const cssVariablesResolver: CSSVariablesResolver = (resolvedTheme) => ({ variables: Object.fromEntries( - Object.entries(colorRoles).map(([role, color]) => [ + Object.entries(colorRoles).map(([role, reference]) => [ `--wl-color-${role}`, - getThemeColor(color, resolvedTheme), + getThemeColor(reference, resolvedTheme), ]), ), light: {}, dark: {}, }); -export { colorRoles, cssVariablesResolver, theme }; +export { colorRoles, cssVariablesResolver, designTokens, theme }; diff --git a/src/shared/theme/tokens.json b/src/shared/theme/tokens.json new file mode 100644 index 0000000..32cab6d --- /dev/null +++ b/src/shared/theme/tokens.json @@ -0,0 +1,377 @@ +{ + "formatVersion": 1, + "description": "Переносимые токены WealthLedger. Размеры px адаптируются к масштабу runtime; breakpoints em не масштабируются. Светлая схема; роли с shade null используют primaryShade.light для палитры или base color без оттенка.", + "color": { + "base": { + "white": "#fff", + "black": "#000" + }, + "palettes": { + "blue": [ + "#eff6ff", + "#dbeafe", + "#bfdbfe", + "#93c5fd", + "#60a5fa", + "#3b82f6", + "#2563eb", + "#1d4ed8", + "#1e40af", + "#1e3a8a" + ], + "gray": [ + "#f8f9fa", + "#f1f3f5", + "#e9ecef", + "#dee2e6", + "#ced4da", + "#adb5bd", + "#868e96", + "#495057", + "#343a40", + "#212529" + ], + "dark": [ + "#C9C9C9", + "#b8b8b8", + "#828282", + "#696969", + "#424242", + "#3b3b3b", + "#2e2e2e", + "#242424", + "#1f1f1f", + "#141414" + ], + "green": [ + "#ebfbee", + "#d3f9d8", + "#b2f2bb", + "#8ce99a", + "#69db7c", + "#51cf66", + "#40c057", + "#37b24d", + "#2f9e44", + "#2b8a3e" + ], + "red": [ + "#fff5f5", + "#ffe3e3", + "#ffc9c9", + "#ffa8a8", + "#ff8787", + "#ff6b6b", + "#fa5252", + "#f03e3e", + "#e03131", + "#c92a2a" + ], + "yellow": [ + "#fff9db", + "#fff3bf", + "#ffec99", + "#ffe066", + "#ffd43b", + "#fcc419", + "#fab005", + "#f59f00", + "#f08c00", + "#e67700" + ], + "grape": [ + "#f8f0fc", + "#f3d9fa", + "#eebefa", + "#e599f7", + "#da77f2", + "#cc5de8", + "#be4bdb", + "#ae3ec9", + "#9c36b5", + "#862e9c" + ], + "orange": [ + "#fff4e6", + "#ffe8cc", + "#ffd8a8", + "#ffc078", + "#ffa94d", + "#ff922b", + "#fd7e14", + "#f76707", + "#e8590c", + "#d9480f" + ] + }, + "primaryShade": { + "light": 6, + "dark": 8 + }, + "roles": { + "canvas": { + "color": "gray", + "shade": 2, + "description": "Фон приложения" + }, + "surface": { + "color": "white", + "shade": null, + "description": "Поверхность Paper / формы" + }, + "subtle": { + "color": "gray", + "shade": 0, + "description": "Приглушённая поверхность сводки" + }, + "text": { + "color": "dark", + "shade": 7, + "description": "Основной текст страницы" + }, + "muted": { + "color": "gray", + "shade": 6, + "description": "Вторичный текст; проверяйте контраст при выборе фона" + }, + "border": { + "color": "gray", + "shade": 3, + "description": "Граница поверхности" + }, + "primary": { + "color": "blue", + "shade": null, + "description": "Основное действие и активная навигация" + }, + "info": { + "color": "blue", + "shade": 6, + "description": "Информационное сообщение" + }, + "positive": { + "color": "green", + "shade": 6, + "description": "Положительный результат; дополните знаком и текстом" + }, + "negative": { + "color": "red", + "shade": 6, + "description": "Ошибка / отрицательный результат; дополните текстом" + }, + "warning": { + "color": "yellow", + "shade": 6, + "description": "Предупреждение; не мелкий текст на белом фоне" + } + } + }, + "typography": { + "fontFamily": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", + "fontFamilyMonospace": "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace", + "unit": "px", + "fontSizes": { + "xs": 12.0, + "sm": 14.0, + "md": 16.0, + "lg": 18.0, + "xl": 20.0 + }, + "fontWeights": { + "regular": 400, + "medium": 600, + "bold": 700 + }, + "lineHeights": { + "xs": 1.4, + "sm": 1.45, + "md": 1.55, + "lg": 1.6, + "xl": 1.65 + }, + "headings": { + "h1": { + "fontSize": 34.0, + "lineHeight": 1.3 + }, + "h2": { + "fontSize": 26.0, + "lineHeight": 1.35 + }, + "h3": { + "fontSize": 22.0, + "lineHeight": 1.4 + }, + "h4": { + "fontSize": 18.0, + "lineHeight": 1.45 + }, + "h5": { + "fontSize": 16.0, + "lineHeight": 1.5 + }, + "h6": { + "fontSize": 14.0, + "lineHeight": 1.5 + } + } + }, + "spacing": { + "unit": "px", + "values": { + "xs": 10.0, + "sm": 12.0, + "md": 16.0, + "lg": 20.0, + "xl": 32.0 + } + }, + "radius": { + "unit": "px", + "default": "md", + "values": { + "xs": 2.0, + "sm": 4.0, + "md": 8.0, + "lg": 16.0, + "xl": 32.0 + } + }, + "shadows": { + "unit": "px", + "values": { + "xs": [ + { + "offsetX": 0.0, + "offsetY": 1.0, + "blur": 3.0, + "spread": 0, + "color": "black", + "opacity": 0.05 + }, + { + "offsetX": 0.0, + "offsetY": 1.0, + "blur": 2.0, + "spread": 0, + "color": "black", + "opacity": 0.1 + } + ], + "sm": [ + { + "offsetX": 0.0, + "offsetY": 1.0, + "blur": 3.0, + "spread": 0, + "color": "black", + "opacity": 0.05 + }, + { + "offsetX": 0.0, + "offsetY": 10.0, + "blur": 15.0, + "spread": -5.0, + "color": "black", + "opacity": 0.05 + }, + { + "offsetX": 0.0, + "offsetY": 7.0, + "blur": 7.0, + "spread": -5.0, + "color": "black", + "opacity": 0.04 + } + ], + "md": [ + { + "offsetX": 0.0, + "offsetY": 1.0, + "blur": 3.0, + "spread": 0, + "color": "black", + "opacity": 0.05 + }, + { + "offsetX": 0.0, + "offsetY": 20.0, + "blur": 25.0, + "spread": -5.0, + "color": "black", + "opacity": 0.05 + }, + { + "offsetX": 0.0, + "offsetY": 10.0, + "blur": 10.0, + "spread": -5.0, + "color": "black", + "opacity": 0.04 + } + ], + "lg": [ + { + "offsetX": 0.0, + "offsetY": 1.0, + "blur": 3.0, + "spread": 0, + "color": "black", + "opacity": 0.05 + }, + { + "offsetX": 0.0, + "offsetY": 28.0, + "blur": 23.0, + "spread": -7.0, + "color": "black", + "opacity": 0.05 + }, + { + "offsetX": 0.0, + "offsetY": 12.0, + "blur": 12.0, + "spread": -7.0, + "color": "black", + "opacity": 0.04 + } + ], + "xl": [ + { + "offsetX": 0.0, + "offsetY": 1.0, + "blur": 3.0, + "spread": 0, + "color": "black", + "opacity": 0.05 + }, + { + "offsetX": 0.0, + "offsetY": 36.0, + "blur": 28.0, + "spread": -7.0, + "color": "black", + "opacity": 0.05 + }, + { + "offsetX": 0.0, + "offsetY": 17.0, + "blur": 17.0, + "spread": -7.0, + "color": "black", + "opacity": 0.04 + } + ] + } + }, + "breakpoints": { + "unit": "em", + "values": { + "xs": 36.0, + "sm": 48.0, + "md": 62.0, + "lg": 75.0, + "xl": 88.0 + } + } +} diff --git a/src/shared/theme/tokens.schema.ts b/src/shared/theme/tokens.schema.ts new file mode 100644 index 0000000..bc5180d --- /dev/null +++ b/src/shared/theme/tokens.schema.ts @@ -0,0 +1,149 @@ +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 new file mode 100644 index 0000000..d7e9ef7 --- /dev/null +++ b/src/shared/theme/tokens.test.ts @@ -0,0 +1,176 @@ +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, + ); + }); +}); diff --git a/tsconfig.app.json b/tsconfig.app.json index 0ec48ae..6170c9e 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -13,6 +13,7 @@ "skipLibCheck": true, "strict": true, "moduleResolution": "bundler", + "resolveJsonModule": true, "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "moduleDetection": "force",