feat(ui): фаза 1 — шкалы motion/z-index в токенах, boot-скрипт темы в index.html, рецепты компонентов (design-system/recipes.ts) для button/input/badge/card/table, layout.taskDetail в скине, удалён мёртвый CSS (.btn-compact и др.) и зависимость tw-animate-css

This commit is contained in:
2026-09-08 20:26:14 +03:00
parent c8d7f9b336
commit 7a98bf7bf3
17 changed files with 298 additions and 137 deletions

View File

@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { parseDetailLayout } from '@/layouts';
import type { LayoutPresetProps } from '@/layouts/types';
import { useSkinComponents } from '@/themes/ThemeProvider';
import { RelatedTasksTab } from '@/components/RelatedTasksTab';
import { TaskHistory } from '@/components/TaskHistory';
import TaskRegularTable from '@/components/TaskRegularTable';
@@ -94,8 +95,13 @@ export function TaskContent({
}, [currentTab, modulesData]);
const isCustomModuleTab = !!customModule && !!customModule.config;
// Представление области полей: пресет (@preset:<id>) / JS-код / стандартный вид
const detailLayout = useMemo(() => parseDetailLayout(form?.detailLayoutCode), [form?.detailLayoutCode]);
// Представление области полей: пресет (@preset:<id>) / JS-код / стандартный вид.
// Без маркера и кода — пресет по умолчанию из скина (components.layout.taskDetail).
const { layout: skinLayout } = useSkinComponents();
const detailLayout = useMemo(
() => parseDetailLayout(form?.detailLayoutCode, skinLayout.taskDetail),
[form?.detailLayoutCode, skinLayout.taskDetail],
);
const hasCustomLayout = detailLayout.kind !== 'standard';
const customLayoutExtraCtx = useMemo(() => {
if (!hasCustomLayout || !form) return undefined;

View File

@@ -1,37 +1,19 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { badgeRecipe } from "@/design-system/recipes"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors duration-150 focus:outline-none focus:ring-1 focus:ring-ring",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/85",
secondary:
"border-border bg-secondary text-secondary-foreground hover:bg-muted",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/85",
outline: "text-foreground border-border",
success: "border-transparent bg-[hsl(var(--status-done))] text-white",
warning: "border-transparent bg-[hsl(var(--status-progress))] text-black",
},
},
defaultVariants: {
variant: "default",
},
}
)
// Варианты бейджа вынесены в дизайн-систему: client/src/design-system/recipes.ts
const badgeVariants = badgeRecipe
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
function Badge({ className, variant, size, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
<div className={cn(badgeVariants({ variant, size }), className)} {...props} />
)
}

View File

@@ -1,40 +1,12 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { buttonRecipe } from "@/design-system/recipes"
const buttonVariants = cva(
// Тема 2.0: радиус кнопок — var(--button-radius), fallback = прежний rounded-md
"inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-[var(--button-radius,calc(var(--radius)_-_2px))] text-sm font-medium ring-offset-background transition-[color,background-color,border-color,box-shadow,transform] duration-150 active:not-disabled:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/85",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/85",
outline:
"border border-primary/30 bg-background text-primary hover:bg-primary/10 hover:border-primary/50",
secondary:
"bg-secondary text-secondary-foreground border border-border hover:bg-muted",
ghost: "hover:bg-muted/60 text-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-1.5",
sm: "h-7 px-2.5 text-xs",
md: "h-8 px-3",
lg: "h-10 px-6",
icon: "h-9 w-9",
"icon-sm": "h-7 w-7",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
// Варианты кнопки вынесены в дизайн-систему: client/src/design-system/recipes.ts
const buttonVariants = buttonRecipe
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,

View File

@@ -1,6 +1,12 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { cardRecipe, type ControlSize } from "@/design-system/recipes"
// Размер карточки из дизайн-системы (по умолчанию md — прежний вид)
interface CardSizeProps {
cardSize?: ControlSize
}
const Card = React.forwardRef<
HTMLDivElement,
@@ -8,10 +14,7 @@ const Card = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-md border bg-card text-card-foreground",
className
)}
className={cn(cardRecipe.root, className)}
{...props}
/>
))
@@ -19,11 +22,11 @@ Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement> & CardSizeProps
>(({ className, cardSize, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1 p-4", className)}
className={cn(cardRecipe.header({ size: cardSize }), className)}
{...props}
/>
))
@@ -35,10 +38,7 @@ const CardTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
className={cn(cardRecipe.title, className)}
{...props}
/>
))
@@ -50,7 +50,7 @@ const CardDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn(cardRecipe.description, className)}
{...props}
/>
))
@@ -58,19 +58,19 @@ CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-4 pt-0", className)} {...props} />
React.HTMLAttributes<HTMLDivElement> & CardSizeProps
>(({ className, cardSize, ...props }, ref) => (
<div ref={ref} className={cn(cardRecipe.content({ size: cardSize }), className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
React.HTMLAttributes<HTMLDivElement> & CardSizeProps
>(({ className, cardSize, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-4 pt-0", className)}
className={cn(cardRecipe.footer({ size: cardSize }), className)}
{...props}
/>
))

View File

@@ -1,16 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { inputRecipe, type ControlSize } from "@/design-system/recipes"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
export interface InputProps extends React.ComponentProps<"input"> {
// Размер контрола из дизайн-системы (по умолчанию md — прежний вид)
controlSize?: ControlSize
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, controlSize, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-8 w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-base md:text-sm ring-offset-background transition-[color,background-color,border-color,box-shadow] duration-150 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-primary disabled:cursor-not-allowed disabled:opacity-50 aria-[invalid=true]:border-destructive aria-[invalid=true]:ring-destructive",
className
)}
className={cn(inputRecipe({ size: controlSize }), className)}
ref={ref}
{...props}
/>

View File

@@ -2,15 +2,16 @@ import * as React from "react"
import { cn } from "@/lib/utils"
import { useSkinComponents } from "@/themes/ThemeProvider"
import { tableRecipe } from "@/design-system/recipes"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<div className={tableRecipe.wrapper}>
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
className={cn(tableRecipe.root, className)}
{...props}
/>
</div>
@@ -26,7 +27,7 @@ const TableHeader = React.forwardRef<
return (
<thead
ref={ref}
className={cn("[&_tr]:border-b", table.header !== 'plain' && "[&_tr]:bg-muted/50", className)}
className={cn(tableRecipe.header(table), className)}
{...props}
/>
)
@@ -39,7 +40,7 @@ const TableBody = React.forwardRef<
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
className={cn(tableRecipe.body, className)}
{...props}
/>
))
@@ -51,10 +52,7 @@ const TableFooter = React.forwardRef<
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
className={cn(tableRecipe.footer, className)}
{...props}
/>
))
@@ -69,12 +67,7 @@ const TableRow = React.forwardRef<
return (
<tr
ref={ref}
className={cn(
"h-[var(--table-row-height,40px)] transition-colors duration-150 hover:bg-muted/40 data-[state=selected]:bg-muted",
table.dividers === 'none' ? null : "border-b",
table.dividers === 'grid' && "[&>td]:border-x",
className
)}
className={cn(tableRecipe.row(table), className)}
{...props}
/>
)
@@ -87,10 +80,7 @@ const TableHead = React.forwardRef<
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-[var(--table-row-height,40px)] px-3 text-left align-middle text-[12px] font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
className={cn(tableRecipe.head, className)}
{...props}
/>
))
@@ -102,7 +92,7 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("py-1 px-3 align-middle text-sm [&:has([role=checkbox])]:pr-0", className)}
className={cn(tableRecipe.cell, className)}
{...props}
/>
))
@@ -114,7 +104,7 @@ const TableCaption = React.forwardRef<
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
className={cn(tableRecipe.caption, className)}
{...props}
/>
))

View File

@@ -0,0 +1,141 @@
/**
* Рецепты базовых контролов дизайн-системы: className-строки для
* button / input / badge / card / table по матрице size × variant.
*
* Связь со скином (ResolvedSkinComponents):
* - button.radius, control.variant/radius, density — применяются через
* CSS-переменные (--button-radius, --control-bg, --control-border-color,
* --control-radius, --control-border-width, --table-row-height),
* которые генерирует componentsToCssVars() из tokens.ts; fallback в var()
* повторяет вид по умолчанию, поэтому без components/density в скине
* визуал не меняется.
* - table (dividers/header) — структурный вариант, передаётся параметром
* (компонент читает его через useSkinComponents()).
*/
import { cva } from 'class-variance-authority';
import type { ResolvedSkinComponents } from '@/themes/ThemeProvider';
export type ControlSize = 'sm' | 'md' | 'lg';
/* ---------------------------------- button ---------------------------------- */
// Тема 2.0: радиус кнопок — var(--button-radius), fallback = прежний rounded-md
export const buttonRecipe = cva(
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-[var(--button-radius,calc(var(--radius)_-_2px))] text-sm font-medium ring-offset-background transition-[color,background-color,border-color,box-shadow,transform] duration-150 active:not-disabled:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/85',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/85',
outline: 'border border-primary/30 bg-background text-primary hover:bg-primary/10 hover:border-primary/50',
secondary: 'bg-secondary text-secondary-foreground border border-border hover:bg-muted',
ghost: 'hover:bg-muted/60 text-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-1.5',
sm: 'h-7 px-2.5 text-xs',
md: 'h-8 px-3',
lg: 'h-10 px-6',
icon: 'h-9 w-9',
'icon-sm': 'h-7 w-7',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
/* ---------------------------------- input ----------------------------------- */
// База инпута: фон/бордер/радиус — через var-ы Темы 2.0 с fallback на прежний вид
// (rounded-md = calc(var(--radius) - 2px), border-border, bg-background).
const INPUT_BASE =
'flex w-full rounded-[var(--control-radius,calc(var(--radius)_-_2px))] border-[length:var(--control-border-width,1px)] border-[color:var(--control-border-color,var(--border))] bg-[color:var(--control-bg,var(--background))] ring-offset-background transition-[color,background-color,border-color,box-shadow] duration-150 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-primary disabled:cursor-not-allowed disabled:opacity-50 aria-[invalid=true]:border-destructive aria-[invalid=true]:ring-destructive';
const INPUT_SIZE: Record<ControlSize, string> = {
sm: 'h-7 px-2 py-1 text-base md:text-xs',
md: 'h-8 px-2.5 py-1.5 text-base md:text-sm',
lg: 'h-9 px-3 py-2 text-base md:text-sm',
};
/** Рецепт инпута. size='md' воспроизводит текущий вид ui/input. */
export function inputRecipe({ size = 'md' }: { size?: ControlSize } = {}): string {
return `${INPUT_BASE} ${INPUT_SIZE[size]}`;
}
/* ---------------------------------- badge ----------------------------------- */
export const badgeRecipe = cva(
'inline-flex items-center rounded-md border font-medium transition-colors duration-150 focus:outline-none focus:ring-1 focus:ring-ring',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/85',
secondary: 'border-border bg-secondary text-secondary-foreground hover:bg-muted',
destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/85',
outline: 'text-foreground border-border',
success: 'border-transparent bg-[hsl(var(--status-done))] text-white',
warning: 'border-transparent bg-[hsl(var(--status-progress))] text-black',
},
size: {
sm: 'px-1.5 py-0 text-[10px]',
md: 'px-2 py-0.5 text-xs',
lg: 'px-2.5 py-1 text-sm',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
}
);
/* ---------------------------------- card ------------------------------------ */
const CARD_PADDING: Record<ControlSize, string> = {
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
};
/** Рецепт карточки. size='md' воспроизводит текущий вид ui/card. */
export const cardRecipe = {
root: 'rounded-md border bg-card text-card-foreground',
header: ({ size = 'md' }: { size?: ControlSize } = {}) =>
`flex flex-col space-y-1 ${CARD_PADDING[size]}`,
title: 'text-lg font-semibold leading-none tracking-tight',
description: 'text-sm text-muted-foreground',
content: ({ size = 'md' }: { size?: ControlSize } = {}) => `${CARD_PADDING[size]} pt-0`,
footer: ({ size = 'md' }: { size?: ControlSize } = {}) =>
`flex items-center ${CARD_PADDING[size]} pt-0`,
};
/* ---------------------------------- table ----------------------------------- */
type TableComponents = ResolvedSkinComponents['table'];
export const tableRecipe = {
wrapper: 'relative w-full overflow-auto',
root: 'w-full caption-bottom text-sm',
// Тема 2.0: шапка — muted (с фоном, дефолт) или plain (без фона)
header: (table: TableComponents) =>
table.header !== 'plain' ? '[&_tr]:border-b [&_tr]:bg-muted/50' : '[&_tr]:border-b',
body: '[&_tr:last-child]:border-0',
footer: 'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
// Тема 2.0: высота строки — var(--table-row-height); разделители — rows (дефолт) / grid / none
row: (table: TableComponents) =>
[
'h-[var(--table-row-height,40px)] transition-colors duration-150 hover:bg-muted/40 data-[state=selected]:bg-muted',
table.dividers !== 'none' ? 'border-b' : '',
table.dividers === 'grid' ? '[&>td]:border-x' : '',
]
.filter(Boolean)
.join(' '),
head: 'h-[var(--table-row-height,40px)] px-3 text-left align-middle text-[12px] font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
cell: 'py-1 px-3 align-middle text-sm [&:has([role=checkbox])]:pr-0',
caption: 'mt-4 text-sm text-muted-foreground',
};

View File

@@ -189,24 +189,6 @@
@apply text-lg font-medium text-foreground;
}
/* Compact buttons */
.btn-compact {
@apply h-7 px-2.5 text-xs font-medium;
}
.btn-small {
@apply h-6 px-2 text-xs;
}
.btn-icon {
@apply h-7 w-7 p-0;
}
/* Compact inputs */
.input-compact {
@apply h-7 px-2 text-xs;
}
/* Clean table */
.table-minimal {
@apply w-full text-xs;

View File

@@ -32,9 +32,18 @@ export type ParsedDetailLayout =
// Разбор detailLayoutCode: маркер `@preset:<id>` (первая строка), JS-код или стандартный вид.
// Неизвестный preset-id и compact трактуются как стандартный вид (безопасный fallback).
export function parseDetailLayout(code?: string | null): ParsedDetailLayout {
// defaultPresetId — пресет из скина (components.layout.taskDetail): применяется только когда
// у формы нет ни маркера, ни JS-кода; явный выбор в форме всегда имеет приоритет.
export function parseDetailLayout(
code?: string | null,
defaultPresetId?: 'compact' | 'vertical' | 'pyrus',
): ParsedDetailLayout {
const trimmed = (code ?? '').trim();
if (!trimmed) return { kind: 'standard' };
if (!trimmed) {
const preset = defaultPresetId ? LAYOUT_PRESETS[defaultPresetId] : undefined;
if (preset?.component) return { kind: 'preset', preset };
return { kind: 'standard' };
}
const firstLine = trimmed.split('\n', 1)[0].trim();
const match = firstLine.match(/^@preset:([a-z0-9-]+)$/);
if (match) {

View File

@@ -13,6 +13,7 @@ import { JsComponentRenderer } from '@/components/tabs/JsComponentRenderer';
import type { TaskData, TaskFieldValueData } from '@/components/task-fields/FieldValueRow';
import { parseDetailLayout } from '@/layouts';
import type { LayoutPresetProps } from '@/layouts/types';
import { useSkinComponents } from '@/themes/ThemeProvider';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { useIsMobile } from '@/hooks/use-mobile';
import { useAuth } from '@/hooks/useAuth';
@@ -246,8 +247,11 @@ const TaskDetail = () => {
});
// Разбор представления карточки (пресет @preset:<id> / JS-код / стандарт) — до early return,
// чтобы хук исполнителей ниже всегда вызывался в одном порядке
const detailLayoutInfo = parseDetailLayout(effectiveDetailData?.form?.detailLayoutCode);
// чтобы хук исполнителей ниже всегда вызывался в одном порядке.
// Если у формы нет ни маркера, ни кода — пресет по умолчанию берётся из скина
// (components.layout.taskDetail, дефолт 'compact' = стандартный вид).
const { layout: skinLayout } = useSkinComponents();
const detailLayoutInfo = parseDetailLayout(effectiveDetailData?.form?.detailLayoutCode, skinLayout.taskDetail);
const hasCustomLayoutEarly = detailLayoutInfo.kind !== 'standard';
// Исполнители задачи для слотов кастомного layout/пресета

View File

@@ -26,6 +26,7 @@ export interface ResolvedSkinComponents {
button: { radius: 'sm' | 'md' | 'pill' };
motion: { enabled: boolean };
density: { preset: DensityPreset };
layout: { taskDetail: 'compact' | 'vertical' | 'pyrus' };
}
/** Дефолты соответствуют текущему виду интерфейса (до Темы 2.0). */
@@ -40,6 +41,7 @@ export const DEFAULT_SKIN_COMPONENTS: ResolvedSkinComponents = {
button: { radius: 'md' },
motion: { enabled: true },
density: { preset: 'comfortable' },
layout: { taskDetail: 'compact' },
};
interface ThemeContextType {
@@ -232,6 +234,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
button: { radius: c?.button?.radius ?? 'md' },
motion: { enabled: c?.motion?.enabled ?? true },
density: { preset: themeTokens?.density?.preset ?? 'comfortable' },
layout: { taskDetail: c?.layout?.taskDetail ?? 'compact' },
};
}, [skinId, theme, customSkinsMap]);

View File

@@ -1,5 +1,16 @@
/* Auto-generated from client/src/themes/skins/*.ts */
:root {
--duration-fast: 100ms;
--duration-base: 150ms;
--duration-slow: 200ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--z-nav: 40;
--z-dropdown: 50;
--z-dialog: 60;
--z-toast: 70;
}
[data-skin="iistwin"][data-theme="light"] {
--background: hsl(0 0% 100%);
--foreground: hsl(220 10% 15%);

View File

@@ -4,6 +4,20 @@
* Токены flatten-ятся в CSS-переменные.
*/
/** Базовые CSS-переменные дизайн-системы: шкалы motion и z-index.
* Выносятся в :root первым блоком generateAllSkinsCss — общие для всех скинов.
* Tailwind читает их через theme.extend.transitionDuration/transitionTimingFunction. */
export const BASE_CSS_VARS: Record<string, string> = {
'--duration-fast': '100ms',
'--duration-base': '150ms',
'--duration-slow': '200ms',
'--ease-standard': 'cubic-bezier(0.2, 0, 0, 1)',
'--z-nav': '40',
'--z-dropdown': '50',
'--z-dialog': '60',
'--z-toast': '70',
};
export interface Skin {
id: string;
name: string;
@@ -50,6 +64,7 @@ export interface ComponentTokens {
table?: { dividers?: 'rows' | 'grid' | 'none'; header?: 'muted' | 'plain' };
button?: { radius?: 'sm' | 'md' | 'pill' };
motion?: { enabled?: boolean };
layout?: { taskDetail?: 'compact' | 'vertical' | 'pyrus' };
}
export interface ColorTokens {
@@ -455,12 +470,18 @@ export function generateSkinCss(skin: Skin, theme: 'light' | 'dark'): string {
return css;
}
/** Генерирует CSS для всех скинов и тем. */
/** Генерирует CSS для всех скинов и тем.
* Первым блоком идут базовые переменные дизайн-системы (motion, z-index) в :root. */
export function generateAllSkinsCss(skins: Skin[]): string {
return skins
const baseLines = Object.entries(BASE_CSS_VARS)
.map(([name, value]) => ` ${name}: ${value};`)
.join('\n');
const baseBlock = `:root {\n${baseLines}\n}`;
const skinsCss = skins
.flatMap((skin) => [
generateSkinCss(skin, 'light'),
generateSkinCss(skin, 'dark'),
])
.join('\n\n');
return `${baseBlock}\n\n${skinsCss}`;
}