refactor: source shared design tokens from JSON
This commit is contained in:
+8
-4
@@ -12,9 +12,13 @@
|
|||||||
|
|
||||||
Для cross-slice imports используйте alias `@/` и public API, например `@/entities/portfolio`. Vite и TypeScript используют одинаковый alias.
|
Для cross-slice imports используйте alias `@/` и public API, например `@/entities/portfolio`. Vite и TypeScript используют одинаковый alias.
|
||||||
|
|
||||||
`shared/theme/index.ts` экспортирует единую Mantine theme, семантические color
|
`shared/theme/tokens.json` владеет опубликованными токенами и описаниями ролей
|
||||||
roles и CSSVariablesResolver и подключает базовые styles. App и Storybook
|
в переносимом формате WealthLedger v1. `tokens.schema.ts` проверяет source,
|
||||||
подключают свои MantineProvider к этому public API. Scales принимаются из Mantine,
|
`theme.ts` адаптирует его в Mantine. `shared/theme/index.ts` экспортирует theme,
|
||||||
локальные domain styles остаются у владельцев. `shared/ui` предоставляет
|
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.
|
StateView и LoadingSkeleton; их каталог не включает route-aware AppShell.
|
||||||
Подробнее: [дизайн-система](../../docs/development/design-system.md).
|
Подробнее: [дизайн-система](../../docs/development/design-system.md).
|
||||||
|
|||||||
@@ -11,21 +11,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { colorRoles } from "./index";
|
import { colorRoles, designTokens } from "./index";
|
||||||
|
|
||||||
const roleUsage: Record<keyof typeof colorRoles, string> = {
|
|
||||||
canvas: "Фон приложения",
|
|
||||||
surface: "Поверхность Paper / формы",
|
|
||||||
subtle: "Приглушённая поверхность сводки",
|
|
||||||
text: "Основной текст страницы",
|
|
||||||
muted: "Вторичный текст; проверяйте контраст при выборе фона",
|
|
||||||
border: "Граница поверхности",
|
|
||||||
primary: "Основное действие и активная навигация",
|
|
||||||
info: "Информационное сообщение",
|
|
||||||
positive: "Положительный результат; дополните знаком и текстом",
|
|
||||||
negative: "Ошибка / отрицательный результат; дополните текстом",
|
|
||||||
warning: "Предупреждение; не мелкий текст на белом фоне",
|
|
||||||
};
|
|
||||||
|
|
||||||
const ColorsView: FC = () => {
|
const ColorsView: FC = () => {
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
@@ -33,8 +19,8 @@ const ColorsView: FC = () => {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>Цвета и семантические роли</Title>
|
<Title order={1}>Цвета и семантические роли</Title>
|
||||||
<Text>
|
<Text>
|
||||||
Светлая схема WealthLedger. Роли ссылаются на палитры Mantine, а не
|
Светлая схема WealthLedger. Источник цветов и ролей —
|
||||||
хранят вторую копию цветов. Цвет не заменяет подпись состояния.
|
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(colorRoles).map(([role, color]) => (
|
||||||
@@ -47,34 +33,33 @@ const ColorsView: FC = () => {
|
|||||||
{color} · {parseThemeColor({ color, theme }).value}
|
{color} · {parseThemeColor({ color, theme }).value}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
{roleUsage[role as keyof typeof colorRoles]}
|
{designTokens.color.roles[role].description}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
{["blue", "gray", "dark", "green", "red", "yellow", "grape"].map(
|
{Object.keys(designTokens.color.palettes).map((palette) => (
|
||||||
(palette) => (
|
<Stack key={palette} gap="xs">
|
||||||
<Stack key={palette} gap="xs">
|
<Title order={2}>{palette}</Title>
|
||||||
<Title order={2}>{palette}</Title>
|
<SimpleGrid cols={{ base: 2, sm: 5, lg: 10 }}>
|
||||||
<SimpleGrid cols={{ base: 2, sm: 5, lg: 10 }}>
|
{theme.colors[palette].map((value, shade) => (
|
||||||
{theme.colors[palette].map((value, shade) => (
|
<Paper key={value} p="xs" withBorder>
|
||||||
<Paper key={value} p="xs" withBorder>
|
<Box h={40} bg={value} aria-hidden="true" />
|
||||||
<Box h={40} bg={value} aria-hidden="true" />
|
<Text size="sm">
|
||||||
<Text size="sm">
|
{palette}.{shade}
|
||||||
{palette}.{shade}
|
</Text>
|
||||||
</Text>
|
<Text size="xs">{value}</Text>
|
||||||
<Text size="xs">{value}</Text>
|
</Paper>
|
||||||
</Paper>
|
))}
|
||||||
))}
|
</SimpleGrid>
|
||||||
</SimpleGrid>
|
</Stack>
|
||||||
</Stack>
|
))}
|
||||||
),
|
|
||||||
)}
|
|
||||||
<Text>
|
<Text>
|
||||||
Blue — фирменная палитра. Gray/dark — нейтрали; green/red/yellow —
|
Blue — фирменная палитра. Gray/dark — нейтрали; green/red/yellow —
|
||||||
статусы. Grape — дополнительный цвет графиков, его предметное значение
|
статусы; orange — дополнительный акцент. Grape — цвет графиков, его
|
||||||
задаёт Product. Палитры не гарантируют контраст любых сочетаний.
|
предметное значение задаёт Product. Палитры не гарантируют контраст
|
||||||
|
любых сочетаний.
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -131,7 +116,8 @@ const LayoutView: FC = () => {
|
|||||||
<Title order={1}>Интервалы, форма и адаптивность</Title>
|
<Title order={1}>Интервалы, форма и адаптивность</Title>
|
||||||
<Text>
|
<Text>
|
||||||
Используйте Mantine props gap/p/radius/shadow или --mantine-* в CSS.
|
Используйте Mantine props gap/p/radius/shadow или --mantine-* в CSS.
|
||||||
Шкалы приняты из установленной Mantine без копий чисел. Default radius:
|
Шкалы принадлежат tokens.json; адаптер Mantine сохраняет
|
||||||
|
масштабирование. Default radius:
|
||||||
{theme.defaultRadius}; scale: {theme.scale}. Размеры конкретных карточек
|
{theme.defaultRadius}; scale: {theme.scale}. Размеры конкретных карточек
|
||||||
и их особые тени остаются локальными.
|
и их особые тени остаются локальными.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -183,7 +169,7 @@ const meta = {
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
component:
|
||||||
"Единый shared/theme приложения и каталога. Все образцы читают effective Mantine theme. Показаны используемые шкалы и роли, а не весь Mantine. Светлая схема; контраст, смысловые заголовки и отсутствие передачи статуса только цветом проверяются в конкретной композиции.",
|
"Единый источник shared/theme/tokens.json; theme.ts адаптирует его к Mantine. Все образцы читают effective theme; описания ролей берутся из JSON. Показаны используемые шкалы и роли, а не весь Mantine. Светлая схема; контраст, смысловые заголовки и отсутствие передачи статуса только цветом проверяются в конкретной композиции.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
export { colorRoles, cssVariablesResolver, theme } from "./theme";
|
export { colorRoles, cssVariablesResolver, designTokens, theme } from "./theme";
|
||||||
|
|||||||
+53
-48
@@ -1,69 +1,74 @@
|
|||||||
import {
|
import {
|
||||||
type CSSVariablesResolver,
|
type CSSVariablesResolver,
|
||||||
createTheme,
|
createTheme,
|
||||||
DEFAULT_THEME,
|
|
||||||
getThemeColor,
|
getThemeColor,
|
||||||
|
rem,
|
||||||
|
rgba,
|
||||||
} from "@mantine/core";
|
} 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 designTokens = designTokensSchema.parse(source);
|
||||||
const colorRoles = {
|
const { color, typography, spacing, radius, shadows, breakpoints } =
|
||||||
canvas: "gray.2",
|
designTokens;
|
||||||
surface: "white",
|
const mapValues = <T, U>(values: Record<string, T>, convert: (value: T) => U) =>
|
||||||
subtle: "gray.0",
|
Object.fromEntries(
|
||||||
text: "dark.7",
|
Object.entries(values).map(([name, value]) => [name, convert(value)]),
|
||||||
muted: "gray.6",
|
);
|
||||||
border: "gray.3",
|
|
||||||
primary: "blue",
|
|
||||||
info: "blue.6",
|
|
||||||
positive: "green.6",
|
|
||||||
negative: "red.6",
|
|
||||||
warning: "yellow.6",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const fontFamily =
|
const colorRoles = mapValues(color.roles, (role) =>
|
||||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
role.shade === null ? role.color : `${role.color}.${role.shade}`,
|
||||||
|
);
|
||||||
|
|
||||||
const theme = createTheme({
|
const theme = createTheme({
|
||||||
primaryColor: "blue",
|
primaryColor: color.roles.primary.color,
|
||||||
defaultRadius: "md",
|
primaryShade: color.primaryShade,
|
||||||
fontFamily,
|
defaultRadius: radius.default,
|
||||||
// Explicitly adopt the installed Mantine scales without duplicating values.
|
white: color.base.white,
|
||||||
fontFamilyMonospace: DEFAULT_THEME.fontFamilyMonospace,
|
black: color.base.black,
|
||||||
fontSizes: DEFAULT_THEME.fontSizes,
|
colors: mapValues(color.palettes, (palette) => {
|
||||||
fontWeights: DEFAULT_THEME.fontWeights,
|
// Source schema guarantees exactly ten entries before tuple conversion.
|
||||||
lineHeights: DEFAULT_THEME.lineHeights,
|
const [a, b, c, d, e, f, g, h, i, j] = palette;
|
||||||
headings: { ...DEFAULT_THEME.headings, fontFamily },
|
return [a, b, c, d, e, f, g, h, i, j] as const;
|
||||||
spacing: DEFAULT_THEME.spacing,
|
}),
|
||||||
radius: DEFAULT_THEME.radius,
|
fontFamily: typography.fontFamily,
|
||||||
shadows: DEFAULT_THEME.shadows,
|
fontFamilyMonospace: typography.fontFamilyMonospace,
|
||||||
breakpoints: DEFAULT_THEME.breakpoints,
|
fontSizes: mapValues(typography.fontSizes, (value) => rem(value)),
|
||||||
primaryShade: DEFAULT_THEME.primaryShade,
|
fontWeights: mapValues(typography.fontWeights, String),
|
||||||
focusRing: DEFAULT_THEME.focusRing,
|
lineHeights: mapValues(typography.lineHeights, String),
|
||||||
colors: {
|
headings: {
|
||||||
blue: [
|
fontFamily: typography.fontFamily,
|
||||||
"#eff6ff",
|
fontWeight: String(typography.fontWeights.bold),
|
||||||
"#dbeafe",
|
sizes: mapValues(typography.headings, (value) => ({
|
||||||
"#bfdbfe",
|
fontSize: rem(value.fontSize),
|
||||||
"#93c5fd",
|
lineHeight: String(value.lineHeight),
|
||||||
"#60a5fa",
|
})),
|
||||||
"#3b82f6",
|
|
||||||
"#2563eb",
|
|
||||||
"#1d4ed8",
|
|
||||||
"#1e40af",
|
|
||||||
"#1e3a8a",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
|
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) => ({
|
const cssVariablesResolver: CSSVariablesResolver = (resolvedTheme) => ({
|
||||||
variables: Object.fromEntries(
|
variables: Object.fromEntries(
|
||||||
Object.entries(colorRoles).map(([role, color]) => [
|
Object.entries(colorRoles).map(([role, reference]) => [
|
||||||
`--wl-color-${role}`,
|
`--wl-color-${role}`,
|
||||||
getThemeColor(color, resolvedTheme),
|
getThemeColor(reference, resolvedTheme),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
light: {},
|
light: {},
|
||||||
dark: {},
|
dark: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export { colorRoles, cssVariablesResolver, theme };
|
export { colorRoles, cssVariablesResolver, designTokens, theme };
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = <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 };
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
|
|||||||
Reference in New Issue
Block a user