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:
@@ -24,6 +24,45 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
// Boot-скрипт: применяет тему/скин до загрузки React, чтобы не было вспышки
|
||||
// дефолтного скина, и восстанавливает rem-размер шрифта в PWA до первого рендера.
|
||||
// Зеркалит client/src/themes/ThemeProvider.tsx (ключи THEME_STORAGE_KEY='theme',
|
||||
// SKIN_STORAGE_KEY='skin') и client/src/hooks/usePwaFontSize.ts
|
||||
// (префикс 'pwa-font-size-', clamp 12..24) — при изменении там править и здесь.
|
||||
(function () {
|
||||
try {
|
||||
var root = document.documentElement;
|
||||
var theme = localStorage.getItem('theme');
|
||||
if (theme !== 'light' && theme !== 'dark') {
|
||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
var skin = localStorage.getItem('skin') || 'iistwin';
|
||||
root.setAttribute('data-theme', theme);
|
||||
root.setAttribute('data-skin', skin);
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(theme);
|
||||
|
||||
var isPwa =
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
window.navigator.standalone === true;
|
||||
if (isPwa) {
|
||||
for (var i = 0; i < localStorage.length; i++) {
|
||||
var key = localStorage.key(i);
|
||||
if (key && key.indexOf('pwa-font-size-') === 0) {
|
||||
var n = parseInt(localStorage.getItem(key), 10);
|
||||
if (!isNaN(n)) {
|
||||
root.style.fontSize = Math.max(12, Math.min(24, n)) + 'px';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
/* ignore: не блокируем загрузку приложения */
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
|
||||
141
client/src/design-system/recipes.ts
Normal file
141
client/src/design-system/recipes.ts
Normal 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',
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/пресета
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -121,7 +121,6 @@
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"to-words": "^5.6.1",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"vaul": "^1.1.2",
|
||||
"web-push": "^3.6.7",
|
||||
"wouter": "^3.3.5",
|
||||
@@ -17409,15 +17408,6 @@
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tw-animate-css": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz",
|
||||
"integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Wombosvideo"
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
|
||||
@@ -129,7 +129,6 @@
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"to-words": "^5.6.1",
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"vaul": "^1.1.2",
|
||||
"web-push": "^3.6.7",
|
||||
"wouter": "^3.3.5",
|
||||
|
||||
@@ -149,6 +149,15 @@ export default {
|
||||
"tw-md": "var(--tw-line-height-md)",
|
||||
"tw-lg": "var(--tw-line-height-lg)",
|
||||
},
|
||||
// Шкала motion дизайн-системы (значения — в :root из generated-skins.css)
|
||||
transitionDuration: {
|
||||
fast: "var(--duration-fast)",
|
||||
base: "var(--duration-base)",
|
||||
slow: "var(--duration-slow)",
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
standard: "var(--ease-standard)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["var(--font-sans)"],
|
||||
serif: ["var(--font-serif)"],
|
||||
|
||||
Reference in New Issue
Block a user