feat: skin engine + Twenty CRM theme for iistwin

- Add client/src/themes/ with tokens, built-in skins (iistwin, twenty, omegaprom)
- Add ThemeProvider with data-skin/data-theme and runtime CSS for custom skins
- Add npm run skins:generate script for build-time CSS generation
- Add organization_skins table, migration and CRUD API
- Add activeSkinId to organizations and expose it in /api/auth/me
- Add skin switcher in /profile and admin skin editor in /settings/appearance
- Adapt index.css hardcoded colors to skin tokens
- Update AGENTS.md and Obsidian notes
This commit is contained in:
2026-07-09 20:25:49 +03:00
parent 7ed89f7f97
commit 95279b8fcc
24 changed files with 2562 additions and 223 deletions

View File

@@ -0,0 +1,218 @@
/**
* ThemeProvider с поддержкой скинов.
* Управляет data-skin / data-theme на <html> и runtime CSS для пользовательских скинов.
*/
import { createContext, useContext, useEffect, useLayoutEffect, useState, useCallback, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { Skin } from './tokens';
import { BUILT_IN_SKINS, DEFAULT_SKIN_ID, getBuiltInSkinById, isBuiltInSkin } from './index';
import { flattenTokens } from './tokens';
import { useAuth } from '@/hooks/useAuth';
import { apiRequest } from '@/lib/queryClient';
const THEME_STORAGE_KEY = 'theme';
const SKIN_STORAGE_KEY = 'skin';
export type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
skinId: string;
setSkinId: (skinId: string) => void;
availableSkins: Skin[];
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
function getSystemTheme(): Theme {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function getInitialTheme(): Theme {
if (typeof window === 'undefined') return 'light';
const stored = localStorage.getItem(THEME_STORAGE_KEY);
if (stored === 'light' || stored === 'dark') return stored;
return getSystemTheme();
}
function getStoredSkinId(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(SKIN_STORAGE_KEY);
}
/** Вставляет или обновляет runtime CSS для пользовательского скина. */
function applyRuntimeSkinCss(skinId: string, theme: Theme, skinTokens?: Skin['tokens']) {
if (!skinTokens) {
const style = document.getElementById('skin-runtime');
if (style) style.textContent = '';
return;
}
const vars = flattenTokens(skinTokens[theme]);
vars['--radius'] = skinTokens[theme].radius.lg;
const lines = Object.entries(vars)
.map(([name, value]) => ` ${name}: ${value};`)
.join('\n');
const css = `[data-skin="${skinId}"][data-theme="${theme}"] {\n${lines}\n}`;
let style = document.getElementById('skin-runtime');
if (!style) {
style = document.createElement('style');
style.id = 'skin-runtime';
document.head.appendChild(style);
}
style.textContent = css;
}
interface ThemeProviderProps {
children: React.ReactNode;
}
export function ThemeProvider({ children }: ThemeProviderProps) {
const { user } = useAuth();
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
const [skinId, setSkinIdState] = useState<string>(DEFAULT_SKIN_ID);
const [customSkinsMap, setCustomSkinsMap] = useState<Record<string, Skin>>({});
const organizationSkinId = user?.organization?.activeSkinId;
// Загружаем пользовательские скины организации.
const { data: customSkinsData } = useQuery({
queryKey: ['/api/organization/skins'],
queryFn: async () => {
const res = await apiRequest('GET', '/api/organization/skins');
return res.json() as Promise<{ success: boolean; skins: Array<{ id: string; dbId?: number; name: string; description?: string; isEditable: boolean; isBuiltIn: boolean }> }>;
},
enabled: !!user,
staleTime: 5 * 60 * 1000,
});
// Загружаем детали текущего активного пользовательского скина, если он не встроенный.
const isCurrentCustom = Boolean(skinId) && !isBuiltInSkin(skinId);
const { data: currentSkinData } = useQuery({
queryKey: ['/api/organization/skins', skinId],
queryFn: async () => {
const res = await apiRequest('GET', `/api/organization/skins/${encodeURIComponent(skinId)}`);
return res.json() as Promise<{ success: boolean; skin: Skin }>;
},
enabled: !!user && isCurrentCustom,
staleTime: 5 * 60 * 1000,
});
useEffect(() => {
if (!customSkinsData?.skins) return;
const map: Record<string, Skin> = {};
for (const item of customSkinsData.skins) {
if (item.isBuiltIn) continue;
map[item.id] = {
id: item.id,
name: item.name,
description: item.description,
isEditable: item.isEditable,
tokens: {} as Skin['tokens'], // tokens подгружаются отдельно при выборе
};
}
setCustomSkinsMap(map);
}, [customSkinsData]);
useEffect(() => {
if (currentSkinData?.skin) {
setCustomSkinsMap((prev) => ({ ...prev, [currentSkinData.skin.id]: currentSkinData.skin }));
}
}, [currentSkinData]);
// Инициализация skinId: организационный > localStorage > default.
useEffect(() => {
if (typeof window === 'undefined') return;
const allSkinIds = new Set([...BUILT_IN_SKINS.map((s) => s.id), ...Object.keys(customSkinsMap)]);
const stored = getStoredSkinId();
if (stored && allSkinIds.has(stored)) {
setSkinIdState(stored);
} else if (organizationSkinId && allSkinIds.has(organizationSkinId)) {
setSkinIdState(organizationSkinId);
} else {
setSkinIdState(DEFAULT_SKIN_ID);
}
}, [organizationSkinId, customSkinsData]);
const setTheme = useCallback((newTheme: Theme) => {
setThemeState(newTheme);
if (typeof window !== 'undefined') {
localStorage.setItem(THEME_STORAGE_KEY, newTheme);
}
}, []);
const setSkinId = useCallback((newSkinId: string) => {
setSkinIdState(newSkinId);
if (typeof window !== 'undefined') {
localStorage.setItem(SKIN_STORAGE_KEY, newSkinId);
}
}, []);
const toggleTheme = useCallback(() => {
setTheme(theme === 'light' ? 'dark' : 'light');
}, [theme, setTheme]);
useLayoutEffect(() => {
const root = document.documentElement;
root.setAttribute('data-skin', skinId);
root.setAttribute('data-theme', theme);
root.classList.remove('light', 'dark');
root.classList.add(theme);
// Для встроенных скинов CSS уже есть в generated-skins.css.
// Для пользовательских — генерируем runtime.
const customSkin = customSkinsMap[skinId];
applyRuntimeSkinCss(skinId, theme, customSkin?.tokens);
}, [theme, skinId, customSkinsMap]);
// Слушаем системную тему, если нет явного сохранённого выбора.
useEffect(() => {
if (typeof window === 'undefined') return;
const media = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
if (!localStorage.getItem(THEME_STORAGE_KEY)) {
setThemeState(e.matches ? 'dark' : 'light');
}
};
media.addEventListener('change', handler);
return () => media.removeEventListener('change', handler);
}, []);
const availableSkins = useMemo(() => {
const custom = Object.values(customSkinsMap);
return [...BUILT_IN_SKINS, ...custom];
}, [customSkinsMap]);
const value: ThemeContextType = {
theme,
setTheme,
toggleTheme,
skinId,
setSkinId,
availableSkins,
};
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
/** Удобный хук только для текущего скина. */
export function useSkin() {
const { skinId, setSkinId, availableSkins } = useTheme();
const skin = availableSkins.find((s) => s.id === skinId) ?? getBuiltInSkinById(DEFAULT_SKIN_ID)!;
return { skinId, skin, setSkinId, availableSkins };
}

View File

@@ -0,0 +1,23 @@
import type { Skin } from './tokens';
import { iistwinSkin } from './skins/iistwin';
import { omegapromSkin } from './skins/omegaprom';
import { twentySkin } from './skins/twenty';
export * from './tokens';
export { iistwinSkin, omegapromSkin, twentySkin };
/** Встроенные скины, доступные из коробки. */
export const BUILT_IN_SKINS: Skin[] = [iistwinSkin, twentySkin, omegapromSkin];
/** Скин по умолчанию. */
export const DEFAULT_SKIN_ID = 'iistwin';
/** Найти встроенный скин по id. */
export function getBuiltInSkinById(id: string): Skin | undefined {
return BUILT_IN_SKINS.find((skin) => skin.id === id);
}
/** Проверить, является ли скин встроенным. */
export function isBuiltInSkin(id: string): boolean {
return BUILT_IN_SKINS.some((skin) => skin.id === id);
}

View File

@@ -0,0 +1,206 @@
import type { Skin } from '../tokens';
/**
* Скин iistwin — текущий вид системы, fallback по умолчанию.
*/
export const iistwinSkin: Skin = {
id: 'iistwin',
name: 'iistwin Classic',
description: 'Классический минималистичный вид iistwin.',
author: 'iistwin',
isEditable: false,
tokens: {
light: {
colors: {
background: 'hsl(0 0% 100%)',
foreground: 'hsl(220 10% 15%)',
card: 'hsl(0 0% 100%)',
cardForeground: 'hsl(220 10% 15%)',
popover: 'hsl(0 0% 100%)',
popoverForeground: 'hsl(220 10% 15%)',
primary: 'hsl(220 10% 15%)',
primaryForeground: 'hsl(0 0% 100%)',
secondary: 'hsl(220 5% 96%)',
secondaryForeground: 'hsl(220 10% 15%)',
muted: 'hsl(220 5% 96%)',
mutedForeground: 'hsl(220 5% 45%)',
accent: 'hsl(220 5% 93%)',
accentForeground: 'hsl(220 10% 15%)',
destructive: 'hsl(0 72% 51%)',
destructiveForeground: 'hsl(0 0% 100%)',
border: 'hsl(220 5% 90%)',
input: 'hsl(0 0% 100%)',
ring: 'hsl(220 10% 15%)',
backgroundSecondary: 'hsl(0 0% 98%)',
backgroundTertiary: 'hsl(0 0% 96%)',
backgroundTransparentPrimary: 'hsl(220 10% 15% / 0.04)',
backgroundTransparentSecondary: 'hsl(220 10% 15% / 0.08)',
backgroundTransparentTertiary: 'hsl(220 10% 15% / 0.12)',
backgroundTransparentMedium: 'hsl(220 10% 15% / 0.06)',
backgroundTransparentLight: 'hsl(220 10% 15% / 0.03)',
backgroundTransparentLighter: 'hsl(220 10% 15% / 0.02)',
backgroundNoisy: 'hsl(0 0% 100%)',
fontPrimary: 'hsl(220 10% 15%)',
fontSecondary: 'hsl(220 5% 45%)',
fontTertiary: 'hsl(220 5% 55%)',
fontLight: 'hsl(220 5% 65%)',
fontExtraLight: 'hsl(220 5% 75%)',
fontInverted: 'hsl(0 0% 100%)',
borderLight: 'hsl(220 5% 94%)',
borderMedium: 'hsl(220 5% 90%)',
borderStrong: 'hsl(220 5% 80%)',
statusDone: 'hsl(142 50% 45%)',
statusProgress: 'hsl(45 90% 50%)',
statusOverdue: 'hsl(0 70% 55%)',
statusPaused: 'hsl(220 5% 60%)',
chart1: 'hsl(220 10% 25%)',
chart2: 'hsl(142 50% 45%)',
chart3: 'hsl(45 90% 50%)',
chart4: 'hsl(200 70% 50%)',
chart5: 'hsl(0 70% 55%)',
sidebar: 'hsl(0 0% 100%)',
sidebarForeground: 'hsl(220 10% 15%)',
sidebarPrimary: 'hsl(220 10% 15%)',
sidebarPrimaryForeground: 'hsl(0 0% 100%)',
sidebarAccent: 'hsl(220 5% 96%)',
sidebarAccentForeground: 'hsl(220 10% 15%)',
sidebarBorder: 'hsl(220 5% 92%)',
sidebarRing: 'hsl(220 10% 15%)',
},
radius: {
xs: '0.125rem',
sm: '0.25rem',
md: '0.375rem',
lg: '0.5rem',
xl: '0.75rem',
},
shadows: {
sm: 'none',
md: '0 1px 2px 0 rgba(0, 0, 0, 0.03)',
lg: '0 1px 3px 0 rgba(0, 0, 0, 0.05)',
xl: '0 2px 4px 0 rgba(0, 0, 0, 0.05)',
},
font: {
sans: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
mono: '"SF Mono", "Monaco", "Inconsolata", monospace',
size: {
xs: '0.625rem',
sm: '0.75rem',
md: '0.8125rem',
lg: '1rem',
xl: '1.25rem',
},
weight: {
regular: 400,
medium: 500,
semibold: 600,
},
},
spacing: {
unit: '0.25rem',
},
},
dark: {
colors: {
background: 'hsl(220 10% 8%)',
foreground: 'hsl(220 5% 92%)',
card: 'hsl(220 10% 10%)',
cardForeground: 'hsl(220 5% 92%)',
popover: 'hsl(220 10% 10%)',
popoverForeground: 'hsl(220 5% 92%)',
primary: 'hsl(0 0% 100%)',
primaryForeground: 'hsl(220 10% 8%)',
secondary: 'hsl(220 10% 14%)',
secondaryForeground: 'hsl(220 5% 92%)',
muted: 'hsl(220 10% 14%)',
mutedForeground: 'hsl(220 5% 55%)',
accent: 'hsl(220 10% 16%)',
accentForeground: 'hsl(220 5% 92%)',
destructive: 'hsl(0 70% 55%)',
destructiveForeground: 'hsl(0 0% 100%)',
border: 'hsl(220 10% 18%)',
input: 'hsl(220 10% 12%)',
ring: 'hsl(0 0% 100%)',
backgroundSecondary: 'hsl(220 10% 10%)',
backgroundTertiary: 'hsl(220 10% 14%)',
backgroundTransparentPrimary: 'hsl(0 0% 100% / 0.04)',
backgroundTransparentSecondary: 'hsl(0 0% 100% / 0.08)',
backgroundTransparentTertiary: 'hsl(0 0% 100% / 0.12)',
backgroundTransparentMedium: 'hsl(0 0% 100% / 0.06)',
backgroundTransparentLight: 'hsl(0 0% 100% / 0.03)',
backgroundTransparentLighter: 'hsl(0 0% 100% / 0.02)',
backgroundNoisy: 'hsl(220 10% 8%)',
fontPrimary: 'hsl(220 5% 92%)',
fontSecondary: 'hsl(220 5% 65%)',
fontTertiary: 'hsl(220 5% 55%)',
fontLight: 'hsl(220 5% 45%)',
fontExtraLight: 'hsl(220 5% 35%)',
fontInverted: 'hsl(220 10% 8%)',
borderLight: 'hsl(220 10% 16%)',
borderMedium: 'hsl(220 10% 20%)',
borderStrong: 'hsl(220 10% 30%)',
statusDone: 'hsl(142 50% 55%)',
statusProgress: 'hsl(45 90% 55%)',
statusOverdue: 'hsl(0 70% 60%)',
statusPaused: 'hsl(220 5% 55%)',
chart1: 'hsl(220 5% 70%)',
chart2: 'hsl(142 50% 55%)',
chart3: 'hsl(45 90% 55%)',
chart4: 'hsl(200 70% 55%)',
chart5: 'hsl(0 70% 60%)',
sidebar: 'hsl(220 10% 8%)',
sidebarForeground: 'hsl(220 5% 92%)',
sidebarPrimary: 'hsl(0 0% 100%)',
sidebarPrimaryForeground: 'hsl(220 10% 8%)',
sidebarAccent: 'hsl(220 10% 14%)',
sidebarAccentForeground: 'hsl(220 5% 92%)',
sidebarBorder: 'hsl(220 10% 16%)',
sidebarRing: 'hsl(0 0% 100%)',
},
radius: {
xs: '0.125rem',
sm: '0.25rem',
md: '0.375rem',
lg: '0.5rem',
xl: '0.75rem',
},
shadows: {
sm: 'none',
md: '0 1px 2px 0 rgba(0, 0, 0, 0.15)',
lg: '0 2px 4px 0 rgba(0, 0, 0, 0.2)',
xl: '0 4px 6px -1px rgba(0, 0, 0, 0.2)',
},
font: {
sans: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
mono: '"SF Mono", "Monaco", "Inconsolata", monospace',
size: {
xs: '0.625rem',
sm: '0.75rem',
md: '0.8125rem',
lg: '1rem',
xl: '1.25rem',
},
weight: {
regular: 400,
medium: 500,
semibold: 600,
},
},
spacing: {
unit: '0.25rem',
},
},
},
};

View File

@@ -0,0 +1,206 @@
import type { Skin } from '../tokens';
/**
* Корпоративный скин ОмегаПром — синий акцент, нейтральные серые.
*/
export const omegapromSkin: Skin = {
id: 'omegaprom',
name: 'ОмегаПром',
description: 'Корпоративный скин с синим акцентом.',
author: 'iistwin',
isEditable: false,
tokens: {
light: {
colors: {
background: 'hsl(0 0% 100%)',
foreground: 'hsl(220 15% 18%)',
card: 'hsl(0 0% 100%)',
cardForeground: 'hsl(220 15% 18%)',
popover: 'hsl(0 0% 100%)',
popoverForeground: 'hsl(220 15% 18%)',
primary: 'hsl(221 83% 53%)',
primaryForeground: 'hsl(0 0% 100%)',
secondary: 'hsl(217 91% 96%)',
secondaryForeground: 'hsl(221 83% 40%)',
muted: 'hsl(220 14% 96%)',
mutedForeground: 'hsl(220 9% 46%)',
accent: 'hsl(214 100% 92%)',
accentForeground: 'hsl(221 83% 40%)',
destructive: 'hsl(0 72% 51%)',
destructiveForeground: 'hsl(0 0% 100%)',
border: 'hsl(220 13% 90%)',
input: 'hsl(0 0% 100%)',
ring: 'hsl(221 83% 53%)',
backgroundSecondary: 'hsl(220 20% 98%)',
backgroundTertiary: 'hsl(220 14% 96%)',
backgroundTransparentPrimary: 'hsl(221 83% 53% / 0.04)',
backgroundTransparentSecondary: 'hsl(221 83% 53% / 0.08)',
backgroundTransparentTertiary: 'hsl(221 83% 53% / 0.12)',
backgroundTransparentMedium: 'hsl(220 15% 18% / 0.06)',
backgroundTransparentLight: 'hsl(220 15% 18% / 0.03)',
backgroundTransparentLighter: 'hsl(220 15% 18% / 0.02)',
backgroundNoisy: 'hsl(0 0% 100%)',
fontPrimary: 'hsl(220 15% 18%)',
fontSecondary: 'hsl(220 9% 46%)',
fontTertiary: 'hsl(220 9% 56%)',
fontLight: 'hsl(220 9% 66%)',
fontExtraLight: 'hsl(220 9% 76%)',
fontInverted: 'hsl(0 0% 100%)',
borderLight: 'hsl(220 13% 94%)',
borderMedium: 'hsl(220 13% 90%)',
borderStrong: 'hsl(220 13% 80%)',
statusDone: 'hsl(142 55% 45%)',
statusProgress: 'hsl(45 90% 50%)',
statusOverdue: 'hsl(0 70% 55%)',
statusPaused: 'hsl(220 9% 60%)',
chart1: 'hsl(221 83% 53%)',
chart2: 'hsl(142 55% 45%)',
chart3: 'hsl(45 90% 50%)',
chart4: 'hsl(200 70% 50%)',
chart5: 'hsl(0 70% 55%)',
sidebar: 'hsl(220 20% 98%)',
sidebarForeground: 'hsl(220 15% 18%)',
sidebarPrimary: 'hsl(221 83% 53%)',
sidebarPrimaryForeground: 'hsl(0 0% 100%)',
sidebarAccent: 'hsl(220 14% 96%)',
sidebarAccentForeground: 'hsl(220 15% 18%)',
sidebarBorder: 'hsl(220 13% 90%)',
sidebarRing: 'hsl(221 83% 53%)',
},
radius: {
xs: '0.125rem',
sm: '0.25rem',
md: '0.5rem',
lg: '0.75rem',
xl: '1rem',
},
shadows: {
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.03)',
md: '0 2px 4px 0 rgba(0, 0, 0, 0.05)',
lg: '0 4px 8px -2px rgba(0, 0, 0, 0.06)',
xl: '0 8px 16px -4px rgba(0, 0, 0, 0.08)',
},
font: {
sans: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
mono: '"SF Mono", "Monaco", "Inconsolata", monospace',
size: {
xs: '0.625rem',
sm: '0.75rem',
md: '0.875rem',
lg: '1rem',
xl: '1.25rem',
},
weight: {
regular: 400,
medium: 500,
semibold: 600,
},
},
spacing: {
unit: '0.25rem',
},
},
dark: {
colors: {
background: 'hsl(220 20% 8%)',
foreground: 'hsl(220 10% 92%)',
card: 'hsl(220 20% 10%)',
cardForeground: 'hsl(220 10% 92%)',
popover: 'hsl(220 20% 10%)',
popoverForeground: 'hsl(220 10% 92%)',
primary: 'hsl(217 91% 60%)',
primaryForeground: 'hsl(220 20% 8%)',
secondary: 'hsl(220 20% 16%)',
secondaryForeground: 'hsl(220 10% 92%)',
muted: 'hsl(220 20% 14%)',
mutedForeground: 'hsl(220 10% 60%)',
accent: 'hsl(220 20% 18%)',
accentForeground: 'hsl(220 10% 92%)',
destructive: 'hsl(0 70% 55%)',
destructiveForeground: 'hsl(0 0% 100%)',
border: 'hsl(220 20% 18%)',
input: 'hsl(220 20% 12%)',
ring: 'hsl(217 91% 60%)',
backgroundSecondary: 'hsl(220 20% 10%)',
backgroundTertiary: 'hsl(220 20% 14%)',
backgroundTransparentPrimary: 'hsl(217 91% 60% / 0.04)',
backgroundTransparentSecondary: 'hsl(217 91% 60% / 0.08)',
backgroundTransparentTertiary: 'hsl(217 91% 60% / 0.12)',
backgroundTransparentMedium: 'hsl(0 0% 100% / 0.06)',
backgroundTransparentLight: 'hsl(0 0% 100% / 0.03)',
backgroundTransparentLighter: 'hsl(0 0% 100% / 0.02)',
backgroundNoisy: 'hsl(220 20% 8%)',
fontPrimary: 'hsl(220 10% 92%)',
fontSecondary: 'hsl(220 10% 65%)',
fontTertiary: 'hsl(220 10% 55%)',
fontLight: 'hsl(220 10% 45%)',
fontExtraLight: 'hsl(220 10% 35%)',
fontInverted: 'hsl(220 20% 8%)',
borderLight: 'hsl(220 20% 16%)',
borderMedium: 'hsl(220 20% 20%)',
borderStrong: 'hsl(220 20% 30%)',
statusDone: 'hsl(142 55% 55%)',
statusProgress: 'hsl(45 90% 55%)',
statusOverdue: 'hsl(0 70% 60%)',
statusPaused: 'hsl(220 10% 55%)',
chart1: 'hsl(217 91% 60%)',
chart2: 'hsl(142 55% 55%)',
chart3: 'hsl(45 90% 55%)',
chart4: 'hsl(200 70% 55%)',
chart5: 'hsl(0 70% 60%)',
sidebar: 'hsl(220 20% 8%)',
sidebarForeground: 'hsl(220 10% 92%)',
sidebarPrimary: 'hsl(217 91% 60%)',
sidebarPrimaryForeground: 'hsl(220 20% 8%)',
sidebarAccent: 'hsl(220 20% 14%)',
sidebarAccentForeground: 'hsl(220 10% 92%)',
sidebarBorder: 'hsl(220 20% 16%)',
sidebarRing: 'hsl(217 91% 60%)',
},
radius: {
xs: '0.125rem',
sm: '0.25rem',
md: '0.5rem',
lg: '0.75rem',
xl: '1rem',
},
shadows: {
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.2)',
md: '0 2px 4px 0 rgba(0, 0, 0, 0.25)',
lg: '0 4px 8px -2px rgba(0, 0, 0, 0.3)',
xl: '0 8px 16px -4px rgba(0, 0, 0, 0.35)',
},
font: {
sans: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
mono: '"SF Mono", "Monaco", "Inconsolata", monospace',
size: {
xs: '0.625rem',
sm: '0.75rem',
md: '0.875rem',
lg: '1rem',
xl: '1.25rem',
},
weight: {
regular: 400,
medium: 500,
semibold: 600,
},
},
spacing: {
unit: '0.25rem',
},
},
},
};

View File

@@ -0,0 +1,208 @@
import type { Skin } from '../tokens';
/**
* Скин в стиле Twenty CRM.
* Референс: https://github.com/twentyhq/twenty
* Акцентный цвет — indigo/blue, нейтральная серая палитра, мягкие тени.
*/
export const twentySkin: Skin = {
id: 'twenty',
name: 'Twenty',
description: 'Минималистичный светлый/тёмный скин в стиле Twenty CRM.',
author: 'iistwin',
isEditable: false,
tokens: {
light: {
colors: {
background: '#ffffff',
foreground: '#333333',
card: '#ffffff',
cardForeground: '#333333',
popover: '#ffffff',
popoverForeground: '#333333',
primary: '#3e63dd',
primaryForeground: '#ffffff',
secondary: '#f4f4f5',
secondaryForeground: '#333333',
muted: '#f4f4f5',
mutedForeground: '#666666',
accent: '#e0e7ff',
accentForeground: '#1e1b4b',
destructive: '#dc2626',
destructiveForeground: '#ffffff',
border: '#e4e4e7',
input: '#ffffff',
ring: '#3e63dd',
backgroundSecondary: '#fcfcfc',
backgroundTertiary: '#f4f4f5',
backgroundTransparentPrimary: 'rgba(0, 0, 0, 0.04)',
backgroundTransparentSecondary: 'rgba(0, 0, 0, 0.08)',
backgroundTransparentTertiary: 'rgba(0, 0, 0, 0.12)',
backgroundTransparentMedium: 'rgba(0, 0, 0, 0.06)',
backgroundTransparentLight: 'rgba(0, 0, 0, 0.03)',
backgroundTransparentLighter: 'rgba(0, 0, 0, 0.02)',
backgroundNoisy: '#ffffff',
fontPrimary: '#333333',
fontSecondary: '#666666',
fontTertiary: '#999999',
fontLight: '#a1a1aa',
fontExtraLight: '#c4c4c7',
fontInverted: '#ffffff',
borderLight: '#f4f4f5',
borderMedium: '#e4e4e7',
borderStrong: '#d4d4d8',
statusDone: '#22c55e',
statusProgress: '#f59e0b',
statusOverdue: '#ef4444',
statusPaused: '#a1a1aa',
chart1: '#333333',
chart2: '#22c55e',
chart3: '#f59e0b',
chart4: '#3b82f6',
chart5: '#ef4444',
sidebar: '#fcfcfc',
sidebarForeground: '#333333',
sidebarPrimary: '#3e63dd',
sidebarPrimaryForeground: '#ffffff',
sidebarAccent: '#f4f4f5',
sidebarAccentForeground: '#333333',
sidebarBorder: '#e4e4e7',
sidebarRing: '#3e63dd',
},
radius: {
xs: '2px',
sm: '4px',
md: '8px',
lg: '16px',
xl: '20px',
},
shadows: {
sm: '0px 1px 2px 0px rgba(0, 0, 0, 0.04)',
md: '0px 2px 4px 0px rgba(0, 0, 0, 0.04), 0px 0px 4px 0px rgba(0, 0, 0, 0.08)',
lg: '2px 4px 16px 0px rgba(0, 0, 0, 0.08), 0px 2px 4px 0px rgba(0, 0, 0, 0.04)',
xl: '0px 0px 8px 0px rgba(0, 0, 0, 0.08), 0px 8px 32px -8px rgba(0, 0, 0, 0.12)',
},
font: {
sans: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
mono: '"DM Mono", "SF Mono", "Monaco", monospace',
size: {
xs: '0.625rem',
sm: '0.85rem',
md: '0.92rem',
lg: '1rem',
xl: '1.23rem',
},
weight: {
regular: 400,
medium: 500,
semibold: 600,
},
},
spacing: {
unit: '4px',
},
},
dark: {
colors: {
background: '#171717',
foreground: '#ededed',
card: '#1f1f1f',
cardForeground: '#ededed',
popover: '#1f1f1f',
popoverForeground: '#ededed',
primary: '#5b7ee3',
primaryForeground: '#0a0a0a',
secondary: '#262626',
secondaryForeground: '#ededed',
muted: '#262626',
mutedForeground: '#a1a1aa',
accent: '#312e81',
accentForeground: '#e0e7ff',
destructive: '#ef4444',
destructiveForeground: '#ffffff',
border: '#27272a',
input: '#1f1f1f',
ring: '#5b7ee3',
backgroundSecondary: '#1f1f1f',
backgroundTertiary: '#262626',
backgroundTransparentPrimary: 'rgba(255, 255, 255, 0.04)',
backgroundTransparentSecondary: 'rgba(255, 255, 255, 0.08)',
backgroundTransparentTertiary: 'rgba(255, 255, 255, 0.12)',
backgroundTransparentMedium: 'rgba(255, 255, 255, 0.06)',
backgroundTransparentLight: 'rgba(255, 255, 255, 0.03)',
backgroundTransparentLighter: 'rgba(255, 255, 255, 0.02)',
backgroundNoisy: '#171717',
fontPrimary: '#ededed',
fontSecondary: '#a1a1aa',
fontTertiary: '#71717a',
fontLight: '#52525b',
fontExtraLight: '#3f3f46',
fontInverted: '#171717',
borderLight: '#262626',
borderMedium: '#27272a',
borderStrong: '#3f3f46',
statusDone: '#4ade80',
statusProgress: '#fbbf24',
statusOverdue: '#f87171',
statusPaused: '#a1a1aa',
chart1: '#d4d4d8',
chart2: '#4ade80',
chart3: '#fbbf24',
chart4: '#60a5fa',
chart5: '#f87171',
sidebar: '#171717',
sidebarForeground: '#ededed',
sidebarPrimary: '#5b7ee3',
sidebarPrimaryForeground: '#0a0a0a',
sidebarAccent: '#262626',
sidebarAccentForeground: '#ededed',
sidebarBorder: '#27272a',
sidebarRing: '#5b7ee3',
},
radius: {
xs: '2px',
sm: '4px',
md: '8px',
lg: '16px',
xl: '20px',
},
shadows: {
sm: '0px 1px 2px 0px rgba(0, 0, 0, 0.2)',
md: '0px 2px 4px 0px rgba(0, 0, 0, 0.2), 0px 0px 4px 0px rgba(0, 0, 0, 0.3)',
lg: '2px 4px 16px 0px rgba(0, 0, 0, 0.35), 0px 2px 4px 0px rgba(0, 0, 0, 0.2)',
xl: '0px 0px 8px 0px rgba(0, 0, 0, 0.3), 0px 8px 32px -8px rgba(0, 0, 0, 0.4)',
},
font: {
sans: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
mono: '"DM Mono", "SF Mono", "Monaco", monospace',
size: {
xs: '0.625rem',
sm: '0.85rem',
md: '0.92rem',
lg: '1rem',
xl: '1.23rem',
},
weight: {
regular: 400,
medium: 500,
semibold: 600,
},
},
spacing: {
unit: '4px',
},
},
},
};

View File

@@ -0,0 +1,469 @@
/* Auto-generated from client/src/themes/skins/*.ts */
[data-skin="iistwin"][data-theme="light"] {
--background: hsl(0 0% 100%);
--foreground: hsl(220 10% 15%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(220 10% 15%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(220 10% 15%);
--primary: hsl(220 10% 15%);
--primary-foreground: hsl(0 0% 100%);
--secondary: hsl(220 5% 96%);
--secondary-foreground: hsl(220 10% 15%);
--muted: hsl(220 5% 96%);
--muted-foreground: hsl(220 5% 45%);
--accent: hsl(220 5% 93%);
--accent-foreground: hsl(220 10% 15%);
--destructive: hsl(0 72% 51%);
--destructive-foreground: hsl(0 0% 100%);
--border: hsl(220 5% 90%);
--input: hsl(0 0% 100%);
--ring: hsl(220 10% 15%);
--tw-background-secondary: hsl(0 0% 98%);
--tw-background-tertiary: hsl(0 0% 96%);
--tw-background-transparent-primary: hsl(220 10% 15% / 0.04);
--tw-background-transparent-secondary: hsl(220 10% 15% / 0.08);
--tw-background-transparent-tertiary: hsl(220 10% 15% / 0.12);
--tw-background-transparent-medium: hsl(220 10% 15% / 0.06);
--tw-background-transparent-light: hsl(220 10% 15% / 0.03);
--tw-background-transparent-lighter: hsl(220 10% 15% / 0.02);
--tw-background-noisy: hsl(0 0% 100%);
--tw-font-primary: hsl(220 10% 15%);
--tw-font-secondary: hsl(220 5% 45%);
--tw-font-tertiary: hsl(220 5% 55%);
--tw-font-light: hsl(220 5% 65%);
--tw-font-extra-light: hsl(220 5% 75%);
--tw-font-inverted: hsl(0 0% 100%);
--tw-border-light: hsl(220 5% 94%);
--tw-border-medium: hsl(220 5% 90%);
--tw-border-strong: hsl(220 5% 80%);
--status-done: hsl(142 50% 45%);
--status-progress: hsl(45 90% 50%);
--status-overdue: hsl(0 70% 55%);
--status-paused: hsl(220 5% 60%);
--chart1: hsl(220 10% 25%);
--chart2: hsl(142 50% 45%);
--chart3: hsl(45 90% 50%);
--chart4: hsl(200 70% 50%);
--chart5: hsl(0 70% 55%);
--sidebar: hsl(0 0% 100%);
--sidebar-foreground: hsl(220 10% 15%);
--sidebar-primary: hsl(220 10% 15%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(220 5% 96%);
--sidebar-accent-foreground: hsl(220 10% 15%);
--sidebar-border: hsl(220 5% 92%);
--sidebar-ring: hsl(220 10% 15%);
--tw-border-radius-xs: 0.125rem;
--tw-border-radius-sm: 0.25rem;
--tw-border-radius-md: 0.375rem;
--tw-border-radius-lg: 0.5rem;
--tw-border-radius-xl: 0.75rem;
--tw-box-shadow-sm: none;
--tw-box-shadow-md: 0 1px 2px 0 rgba(0, 0, 0, 0.03);
--tw-box-shadow-lg: 0 1px 3px 0 rgba(0, 0, 0, 0.05);
--tw-box-shadow-xl: 0 2px 4px 0 rgba(0, 0, 0, 0.05);
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "SF Mono", "Monaco", "Inconsolata", monospace;
--tw-font-size-xs: 0.625rem;
--tw-font-size-sm: 0.75rem;
--tw-font-size-md: 0.8125rem;
--tw-font-size-lg: 1rem;
--tw-font-size-xl: 1.25rem;
--tw-font-weight-regular: 400;
--tw-font-weight-medium: 500;
--tw-font-weight-semibold: 600;
--tw-spacing-unit: 0.25rem;
--radius: 0.5rem;
}
[data-skin="iistwin"][data-theme="dark"] {
--background: hsl(220 10% 8%);
--foreground: hsl(220 5% 92%);
--card: hsl(220 10% 10%);
--card-foreground: hsl(220 5% 92%);
--popover: hsl(220 10% 10%);
--popover-foreground: hsl(220 5% 92%);
--primary: hsl(0 0% 100%);
--primary-foreground: hsl(220 10% 8%);
--secondary: hsl(220 10% 14%);
--secondary-foreground: hsl(220 5% 92%);
--muted: hsl(220 10% 14%);
--muted-foreground: hsl(220 5% 55%);
--accent: hsl(220 10% 16%);
--accent-foreground: hsl(220 5% 92%);
--destructive: hsl(0 70% 55%);
--destructive-foreground: hsl(0 0% 100%);
--border: hsl(220 10% 18%);
--input: hsl(220 10% 12%);
--ring: hsl(0 0% 100%);
--tw-background-secondary: hsl(220 10% 10%);
--tw-background-tertiary: hsl(220 10% 14%);
--tw-background-transparent-primary: hsl(0 0% 100% / 0.04);
--tw-background-transparent-secondary: hsl(0 0% 100% / 0.08);
--tw-background-transparent-tertiary: hsl(0 0% 100% / 0.12);
--tw-background-transparent-medium: hsl(0 0% 100% / 0.06);
--tw-background-transparent-light: hsl(0 0% 100% / 0.03);
--tw-background-transparent-lighter: hsl(0 0% 100% / 0.02);
--tw-background-noisy: hsl(220 10% 8%);
--tw-font-primary: hsl(220 5% 92%);
--tw-font-secondary: hsl(220 5% 65%);
--tw-font-tertiary: hsl(220 5% 55%);
--tw-font-light: hsl(220 5% 45%);
--tw-font-extra-light: hsl(220 5% 35%);
--tw-font-inverted: hsl(220 10% 8%);
--tw-border-light: hsl(220 10% 16%);
--tw-border-medium: hsl(220 10% 20%);
--tw-border-strong: hsl(220 10% 30%);
--status-done: hsl(142 50% 55%);
--status-progress: hsl(45 90% 55%);
--status-overdue: hsl(0 70% 60%);
--status-paused: hsl(220 5% 55%);
--chart1: hsl(220 5% 70%);
--chart2: hsl(142 50% 55%);
--chart3: hsl(45 90% 55%);
--chart4: hsl(200 70% 55%);
--chart5: hsl(0 70% 60%);
--sidebar: hsl(220 10% 8%);
--sidebar-foreground: hsl(220 5% 92%);
--sidebar-primary: hsl(0 0% 100%);
--sidebar-primary-foreground: hsl(220 10% 8%);
--sidebar-accent: hsl(220 10% 14%);
--sidebar-accent-foreground: hsl(220 5% 92%);
--sidebar-border: hsl(220 10% 16%);
--sidebar-ring: hsl(0 0% 100%);
--tw-border-radius-xs: 0.125rem;
--tw-border-radius-sm: 0.25rem;
--tw-border-radius-md: 0.375rem;
--tw-border-radius-lg: 0.5rem;
--tw-border-radius-xl: 0.75rem;
--tw-box-shadow-sm: none;
--tw-box-shadow-md: 0 1px 2px 0 rgba(0, 0, 0, 0.15);
--tw-box-shadow-lg: 0 2px 4px 0 rgba(0, 0, 0, 0.2);
--tw-box-shadow-xl: 0 4px 6px -1px rgba(0, 0, 0, 0.2);
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "SF Mono", "Monaco", "Inconsolata", monospace;
--tw-font-size-xs: 0.625rem;
--tw-font-size-sm: 0.75rem;
--tw-font-size-md: 0.8125rem;
--tw-font-size-lg: 1rem;
--tw-font-size-xl: 1.25rem;
--tw-font-weight-regular: 400;
--tw-font-weight-medium: 500;
--tw-font-weight-semibold: 600;
--tw-spacing-unit: 0.25rem;
--radius: 0.5rem;
}
[data-skin="twenty"][data-theme="light"] {
--background: #ffffff;
--foreground: #333333;
--card: #ffffff;
--card-foreground: #333333;
--popover: #ffffff;
--popover-foreground: #333333;
--primary: #3e63dd;
--primary-foreground: #ffffff;
--secondary: #f4f4f5;
--secondary-foreground: #333333;
--muted: #f4f4f5;
--muted-foreground: #666666;
--accent: #e0e7ff;
--accent-foreground: #1e1b4b;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #e4e4e7;
--input: #ffffff;
--ring: #3e63dd;
--tw-background-secondary: #fcfcfc;
--tw-background-tertiary: #f4f4f5;
--tw-background-transparent-primary: rgba(0, 0, 0, 0.04);
--tw-background-transparent-secondary: rgba(0, 0, 0, 0.08);
--tw-background-transparent-tertiary: rgba(0, 0, 0, 0.12);
--tw-background-transparent-medium: rgba(0, 0, 0, 0.06);
--tw-background-transparent-light: rgba(0, 0, 0, 0.03);
--tw-background-transparent-lighter: rgba(0, 0, 0, 0.02);
--tw-background-noisy: #ffffff;
--tw-font-primary: #333333;
--tw-font-secondary: #666666;
--tw-font-tertiary: #999999;
--tw-font-light: #a1a1aa;
--tw-font-extra-light: #c4c4c7;
--tw-font-inverted: #ffffff;
--tw-border-light: #f4f4f5;
--tw-border-medium: #e4e4e7;
--tw-border-strong: #d4d4d8;
--status-done: #22c55e;
--status-progress: #f59e0b;
--status-overdue: #ef4444;
--status-paused: #a1a1aa;
--chart1: #333333;
--chart2: #22c55e;
--chart3: #f59e0b;
--chart4: #3b82f6;
--chart5: #ef4444;
--sidebar: #fcfcfc;
--sidebar-foreground: #333333;
--sidebar-primary: #3e63dd;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #f4f4f5;
--sidebar-accent-foreground: #333333;
--sidebar-border: #e4e4e7;
--sidebar-ring: #3e63dd;
--tw-border-radius-xs: 2px;
--tw-border-radius-sm: 4px;
--tw-border-radius-md: 8px;
--tw-border-radius-lg: 16px;
--tw-border-radius-xl: 20px;
--tw-box-shadow-sm: 0px 1px 2px 0px rgba(0, 0, 0, 0.04);
--tw-box-shadow-md: 0px 2px 4px 0px rgba(0, 0, 0, 0.04), 0px 0px 4px 0px rgba(0, 0, 0, 0.08);
--tw-box-shadow-lg: 2px 4px 16px 0px rgba(0, 0, 0, 0.08), 0px 2px 4px 0px rgba(0, 0, 0, 0.04);
--tw-box-shadow-xl: 0px 0px 8px 0px rgba(0, 0, 0, 0.08), 0px 8px 32px -8px rgba(0, 0, 0, 0.12);
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "DM Mono", "SF Mono", "Monaco", monospace;
--tw-font-size-xs: 0.625rem;
--tw-font-size-sm: 0.85rem;
--tw-font-size-md: 0.92rem;
--tw-font-size-lg: 1rem;
--tw-font-size-xl: 1.23rem;
--tw-font-weight-regular: 400;
--tw-font-weight-medium: 500;
--tw-font-weight-semibold: 600;
--tw-spacing-unit: 4px;
--radius: 16px;
}
[data-skin="twenty"][data-theme="dark"] {
--background: #171717;
--foreground: #ededed;
--card: #1f1f1f;
--card-foreground: #ededed;
--popover: #1f1f1f;
--popover-foreground: #ededed;
--primary: #5b7ee3;
--primary-foreground: #0a0a0a;
--secondary: #262626;
--secondary-foreground: #ededed;
--muted: #262626;
--muted-foreground: #a1a1aa;
--accent: #312e81;
--accent-foreground: #e0e7ff;
--destructive: #ef4444;
--destructive-foreground: #ffffff;
--border: #27272a;
--input: #1f1f1f;
--ring: #5b7ee3;
--tw-background-secondary: #1f1f1f;
--tw-background-tertiary: #262626;
--tw-background-transparent-primary: rgba(255, 255, 255, 0.04);
--tw-background-transparent-secondary: rgba(255, 255, 255, 0.08);
--tw-background-transparent-tertiary: rgba(255, 255, 255, 0.12);
--tw-background-transparent-medium: rgba(255, 255, 255, 0.06);
--tw-background-transparent-light: rgba(255, 255, 255, 0.03);
--tw-background-transparent-lighter: rgba(255, 255, 255, 0.02);
--tw-background-noisy: #171717;
--tw-font-primary: #ededed;
--tw-font-secondary: #a1a1aa;
--tw-font-tertiary: #71717a;
--tw-font-light: #52525b;
--tw-font-extra-light: #3f3f46;
--tw-font-inverted: #171717;
--tw-border-light: #262626;
--tw-border-medium: #27272a;
--tw-border-strong: #3f3f46;
--status-done: #4ade80;
--status-progress: #fbbf24;
--status-overdue: #f87171;
--status-paused: #a1a1aa;
--chart1: #d4d4d8;
--chart2: #4ade80;
--chart3: #fbbf24;
--chart4: #60a5fa;
--chart5: #f87171;
--sidebar: #171717;
--sidebar-foreground: #ededed;
--sidebar-primary: #5b7ee3;
--sidebar-primary-foreground: #0a0a0a;
--sidebar-accent: #262626;
--sidebar-accent-foreground: #ededed;
--sidebar-border: #27272a;
--sidebar-ring: #5b7ee3;
--tw-border-radius-xs: 2px;
--tw-border-radius-sm: 4px;
--tw-border-radius-md: 8px;
--tw-border-radius-lg: 16px;
--tw-border-radius-xl: 20px;
--tw-box-shadow-sm: 0px 1px 2px 0px rgba(0, 0, 0, 0.2);
--tw-box-shadow-md: 0px 2px 4px 0px rgba(0, 0, 0, 0.2), 0px 0px 4px 0px rgba(0, 0, 0, 0.3);
--tw-box-shadow-lg: 2px 4px 16px 0px rgba(0, 0, 0, 0.35), 0px 2px 4px 0px rgba(0, 0, 0, 0.2);
--tw-box-shadow-xl: 0px 0px 8px 0px rgba(0, 0, 0, 0.3), 0px 8px 32px -8px rgba(0, 0, 0, 0.4);
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "DM Mono", "SF Mono", "Monaco", monospace;
--tw-font-size-xs: 0.625rem;
--tw-font-size-sm: 0.85rem;
--tw-font-size-md: 0.92rem;
--tw-font-size-lg: 1rem;
--tw-font-size-xl: 1.23rem;
--tw-font-weight-regular: 400;
--tw-font-weight-medium: 500;
--tw-font-weight-semibold: 600;
--tw-spacing-unit: 4px;
--radius: 16px;
}
[data-skin="omegaprom"][data-theme="light"] {
--background: hsl(0 0% 100%);
--foreground: hsl(220 15% 18%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(220 15% 18%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(220 15% 18%);
--primary: hsl(221 83% 53%);
--primary-foreground: hsl(0 0% 100%);
--secondary: hsl(217 91% 96%);
--secondary-foreground: hsl(221 83% 40%);
--muted: hsl(220 14% 96%);
--muted-foreground: hsl(220 9% 46%);
--accent: hsl(214 100% 92%);
--accent-foreground: hsl(221 83% 40%);
--destructive: hsl(0 72% 51%);
--destructive-foreground: hsl(0 0% 100%);
--border: hsl(220 13% 90%);
--input: hsl(0 0% 100%);
--ring: hsl(221 83% 53%);
--tw-background-secondary: hsl(220 20% 98%);
--tw-background-tertiary: hsl(220 14% 96%);
--tw-background-transparent-primary: hsl(221 83% 53% / 0.04);
--tw-background-transparent-secondary: hsl(221 83% 53% / 0.08);
--tw-background-transparent-tertiary: hsl(221 83% 53% / 0.12);
--tw-background-transparent-medium: hsl(220 15% 18% / 0.06);
--tw-background-transparent-light: hsl(220 15% 18% / 0.03);
--tw-background-transparent-lighter: hsl(220 15% 18% / 0.02);
--tw-background-noisy: hsl(0 0% 100%);
--tw-font-primary: hsl(220 15% 18%);
--tw-font-secondary: hsl(220 9% 46%);
--tw-font-tertiary: hsl(220 9% 56%);
--tw-font-light: hsl(220 9% 66%);
--tw-font-extra-light: hsl(220 9% 76%);
--tw-font-inverted: hsl(0 0% 100%);
--tw-border-light: hsl(220 13% 94%);
--tw-border-medium: hsl(220 13% 90%);
--tw-border-strong: hsl(220 13% 80%);
--status-done: hsl(142 55% 45%);
--status-progress: hsl(45 90% 50%);
--status-overdue: hsl(0 70% 55%);
--status-paused: hsl(220 9% 60%);
--chart1: hsl(221 83% 53%);
--chart2: hsl(142 55% 45%);
--chart3: hsl(45 90% 50%);
--chart4: hsl(200 70% 50%);
--chart5: hsl(0 70% 55%);
--sidebar: hsl(220 20% 98%);
--sidebar-foreground: hsl(220 15% 18%);
--sidebar-primary: hsl(221 83% 53%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(220 14% 96%);
--sidebar-accent-foreground: hsl(220 15% 18%);
--sidebar-border: hsl(220 13% 90%);
--sidebar-ring: hsl(221 83% 53%);
--tw-border-radius-xs: 0.125rem;
--tw-border-radius-sm: 0.25rem;
--tw-border-radius-md: 0.5rem;
--tw-border-radius-lg: 0.75rem;
--tw-border-radius-xl: 1rem;
--tw-box-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.03);
--tw-box-shadow-md: 0 2px 4px 0 rgba(0, 0, 0, 0.05);
--tw-box-shadow-lg: 0 4px 8px -2px rgba(0, 0, 0, 0.06);
--tw-box-shadow-xl: 0 8px 16px -4px rgba(0, 0, 0, 0.08);
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "SF Mono", "Monaco", "Inconsolata", monospace;
--tw-font-size-xs: 0.625rem;
--tw-font-size-sm: 0.75rem;
--tw-font-size-md: 0.875rem;
--tw-font-size-lg: 1rem;
--tw-font-size-xl: 1.25rem;
--tw-font-weight-regular: 400;
--tw-font-weight-medium: 500;
--tw-font-weight-semibold: 600;
--tw-spacing-unit: 0.25rem;
--radius: 0.75rem;
}
[data-skin="omegaprom"][data-theme="dark"] {
--background: hsl(220 20% 8%);
--foreground: hsl(220 10% 92%);
--card: hsl(220 20% 10%);
--card-foreground: hsl(220 10% 92%);
--popover: hsl(220 20% 10%);
--popover-foreground: hsl(220 10% 92%);
--primary: hsl(217 91% 60%);
--primary-foreground: hsl(220 20% 8%);
--secondary: hsl(220 20% 16%);
--secondary-foreground: hsl(220 10% 92%);
--muted: hsl(220 20% 14%);
--muted-foreground: hsl(220 10% 60%);
--accent: hsl(220 20% 18%);
--accent-foreground: hsl(220 10% 92%);
--destructive: hsl(0 70% 55%);
--destructive-foreground: hsl(0 0% 100%);
--border: hsl(220 20% 18%);
--input: hsl(220 20% 12%);
--ring: hsl(217 91% 60%);
--tw-background-secondary: hsl(220 20% 10%);
--tw-background-tertiary: hsl(220 20% 14%);
--tw-background-transparent-primary: hsl(217 91% 60% / 0.04);
--tw-background-transparent-secondary: hsl(217 91% 60% / 0.08);
--tw-background-transparent-tertiary: hsl(217 91% 60% / 0.12);
--tw-background-transparent-medium: hsl(0 0% 100% / 0.06);
--tw-background-transparent-light: hsl(0 0% 100% / 0.03);
--tw-background-transparent-lighter: hsl(0 0% 100% / 0.02);
--tw-background-noisy: hsl(220 20% 8%);
--tw-font-primary: hsl(220 10% 92%);
--tw-font-secondary: hsl(220 10% 65%);
--tw-font-tertiary: hsl(220 10% 55%);
--tw-font-light: hsl(220 10% 45%);
--tw-font-extra-light: hsl(220 10% 35%);
--tw-font-inverted: hsl(220 20% 8%);
--tw-border-light: hsl(220 20% 16%);
--tw-border-medium: hsl(220 20% 20%);
--tw-border-strong: hsl(220 20% 30%);
--status-done: hsl(142 55% 55%);
--status-progress: hsl(45 90% 55%);
--status-overdue: hsl(0 70% 60%);
--status-paused: hsl(220 10% 55%);
--chart1: hsl(217 91% 60%);
--chart2: hsl(142 55% 55%);
--chart3: hsl(45 90% 55%);
--chart4: hsl(200 70% 55%);
--chart5: hsl(0 70% 60%);
--sidebar: hsl(220 20% 8%);
--sidebar-foreground: hsl(220 10% 92%);
--sidebar-primary: hsl(217 91% 60%);
--sidebar-primary-foreground: hsl(220 20% 8%);
--sidebar-accent: hsl(220 20% 14%);
--sidebar-accent-foreground: hsl(220 10% 92%);
--sidebar-border: hsl(220 20% 16%);
--sidebar-ring: hsl(217 91% 60%);
--tw-border-radius-xs: 0.125rem;
--tw-border-radius-sm: 0.25rem;
--tw-border-radius-md: 0.5rem;
--tw-border-radius-lg: 0.75rem;
--tw-border-radius-xl: 1rem;
--tw-box-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.2);
--tw-box-shadow-md: 0 2px 4px 0 rgba(0, 0, 0, 0.25);
--tw-box-shadow-lg: 0 4px 8px -2px rgba(0, 0, 0, 0.3);
--tw-box-shadow-xl: 0 8px 16px -4px rgba(0, 0, 0, 0.35);
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "SF Mono", "Monaco", "Inconsolata", monospace;
--tw-font-size-xs: 0.625rem;
--tw-font-size-sm: 0.75rem;
--tw-font-size-md: 0.875rem;
--tw-font-size-lg: 1rem;
--tw-font-size-xl: 1.25rem;
--tw-font-weight-regular: 400;
--tw-font-weight-medium: 500;
--tw-font-weight-semibold: 600;
--tw-spacing-unit: 0.25rem;
--radius: 0.75rem;
}

290
client/src/themes/tokens.ts Normal file
View File

@@ -0,0 +1,290 @@
/**
* Единый набор дизайн-токенов для skin-движка iistwin.
* Каждый скин содержит tokens.light и tokens.dark.
* Токены flatten-ятся в CSS-переменные.
*/
export interface Skin {
id: string;
name: string;
description?: string;
author?: string;
isEditable?: boolean;
tokens: {
light: SkinThemeTokens;
dark: SkinThemeTokens;
};
}
export interface SkinThemeTokens {
colors: ColorTokens;
radius: RadiusTokens;
shadows: ShadowTokens;
font: FontTokens;
spacing: SpacingTokens;
}
export interface ColorTokens {
// shadcn/ui base tokens
background: string;
foreground: string;
card: string;
cardForeground: string;
popover: string;
popoverForeground: string;
primary: string;
primaryForeground: string;
secondary: string;
secondaryForeground: string;
muted: string;
mutedForeground: string;
accent: string;
accentForeground: string;
destructive: string;
destructiveForeground: string;
border: string;
input: string;
ring: string;
// Twenty-style extended tokens
backgroundSecondary: string;
backgroundTertiary: string;
backgroundTransparentPrimary: string;
backgroundTransparentSecondary: string;
backgroundTransparentTertiary: string;
backgroundTransparentMedium: string;
backgroundTransparentLight: string;
backgroundTransparentLighter: string;
backgroundNoisy: string;
fontPrimary: string;
fontSecondary: string;
fontTertiary: string;
fontLight: string;
fontExtraLight: string;
fontInverted: string;
borderLight: string;
borderMedium: string;
borderStrong: string;
// Status colors
statusDone: string;
statusProgress: string;
statusOverdue: string;
statusPaused: string;
// Chart colors
chart1: string;
chart2: string;
chart3: string;
chart4: string;
chart5: string;
// Sidebar colors
sidebar: string;
sidebarForeground: string;
sidebarPrimary: string;
sidebarPrimaryForeground: string;
sidebarAccent: string;
sidebarAccentForeground: string;
sidebarBorder: string;
sidebarRing: string;
}
export interface RadiusTokens {
xs: string;
sm: string;
md: string;
lg: string;
xl: string;
}
export interface ShadowTokens {
sm: string;
md: string;
lg: string;
xl: string;
}
export interface FontTokens {
sans: string;
mono: string;
size: {
xs: string;
sm: string;
md: string;
lg: string;
xl: string;
};
weight: {
regular: number;
medium: number;
semibold: number;
};
}
export interface SpacingTokens {
unit: string;
}
/** Список редактируемых цветовых токенов для UI-редактора. */
export const EDITABLE_COLOR_TOKENS: Array<{ key: string; label: string; category: string }> = [
{ key: 'colors.background', label: 'Фон', category: 'Base' },
{ key: 'colors.foreground', label: 'Текст', category: 'Base' },
{ key: 'colors.card', label: 'Фон карточки', category: 'Base' },
{ key: 'colors.cardForeground', label: 'Текст карточки', category: 'Base' },
{ key: 'colors.popover', label: 'Фон поповера', category: 'Base' },
{ key: 'colors.popoverForeground', label: 'Текст поповера', category: 'Base' },
{ key: 'colors.primary', label: 'Primary', category: 'Base' },
{ key: 'colors.primaryForeground', label: 'Primary текст', category: 'Base' },
{ key: 'colors.secondary', label: 'Secondary', category: 'Base' },
{ key: 'colors.secondaryForeground', label: 'Secondary текст', category: 'Base' },
{ key: 'colors.muted', label: 'Muted', category: 'Base' },
{ key: 'colors.mutedForeground', label: 'Muted текст', category: 'Base' },
{ key: 'colors.accent', label: 'Accent', category: 'Base' },
{ key: 'colors.accentForeground', label: 'Accent текст', category: 'Base' },
{ key: 'colors.destructive', label: 'Destructive', category: 'Base' },
{ key: 'colors.destructiveForeground', label: 'Destructive текст', category: 'Base' },
{ key: 'colors.border', label: 'Граница', category: 'Base' },
{ key: 'colors.input', label: 'Фон input', category: 'Base' },
{ key: 'colors.ring', label: 'Кольцо фокуса', category: 'Base' },
{ key: 'colors.backgroundSecondary', label: 'Secondary фон', category: 'Twenty' },
{ key: 'colors.backgroundTertiary', label: 'Tertiary фон', category: 'Twenty' },
{ key: 'colors.fontPrimary', label: 'Primary текст', category: 'Twenty' },
{ key: 'colors.fontSecondary', label: 'Secondary текст', category: 'Twenty' },
{ key: 'colors.fontTertiary', label: 'Tertiary текст', category: 'Twenty' },
{ key: 'colors.borderLight', label: 'Light граница', category: 'Twenty' },
{ key: 'colors.borderMedium', label: 'Medium граница', category: 'Twenty' },
{ key: 'colors.borderStrong', label: 'Strong граница', category: 'Twenty' },
{ key: 'colors.statusDone', label: 'Готово', category: 'Status' },
{ key: 'colors.statusProgress', label: 'В работе', category: 'Status' },
{ key: 'colors.statusOverdue', label: 'Просрочено', category: 'Status' },
{ key: 'colors.statusPaused', label: 'Приостановлено', category: 'Status' },
{ key: 'colors.sidebar', label: 'Фон сайдбара', category: 'Sidebar' },
{ key: 'colors.sidebarForeground', label: 'Текст сайдбара', category: 'Sidebar' },
{ key: 'colors.sidebarPrimary', label: 'Sidebar primary', category: 'Sidebar' },
{ key: 'colors.sidebarPrimaryForeground', label: 'Sidebar primary текст', category: 'Sidebar' },
{ key: 'colors.sidebarAccent', label: 'Sidebar accent', category: 'Sidebar' },
{ key: 'colors.sidebarAccentForeground', label: 'Sidebar accent текст', category: 'Sidebar' },
{ key: 'colors.sidebarBorder', label: 'Граница сайдбара', category: 'Sidebar' },
{ key: 'colors.sidebarRing', label: 'Кольцо сайдбара', category: 'Sidebar' },
];
/** Список редактируемых числовых токенов (радиусы). */
export const EDITABLE_RADIUS_TOKENS: Array<{ key: string; label: string }> = [
{ key: 'radius.xs', label: 'XS radius' },
{ key: 'radius.sm', label: 'SM radius' },
{ key: 'radius.md', label: 'MD radius' },
{ key: 'radius.lg', label: 'LG radius' },
{ key: 'radius.xl', label: 'XL radius' },
];
/** Список редактируемых теней. */
export const EDITABLE_SHADOW_TOKENS: Array<{ key: string; label: string }> = [
{ key: 'shadows.sm', label: 'SM shadow' },
{ key: 'shadows.md', label: 'MD shadow' },
{ key: 'shadows.lg', label: 'LG shadow' },
{ key: 'shadows.xl', label: 'XL shadow' },
];
const SHADCN_COLOR_VARS = new Set([
'background', 'foreground', 'card', 'cardForeground', 'popover', 'popoverForeground',
'primary', 'primaryForeground', 'secondary', 'secondaryForeground',
'muted', 'mutedForeground', 'accent', 'accentForeground',
'destructive', 'destructiveForeground', 'border', 'input', 'ring',
]);
const STATUS_VARS = new Set(['statusDone', 'statusProgress', 'statusOverdue', 'statusPaused']);
const CHART_VARS = new Set(['chart1', 'chart2', 'chart3', 'chart4', 'chart5']);
const SIDEBAR_VARS = new Set([
'sidebar', 'sidebarForeground', 'sidebarPrimary', 'sidebarPrimaryForeground',
'sidebarAccent', 'sidebarAccentForeground', 'sidebarBorder', 'sidebarRing',
]);
const camelToKebab = (s: string) => s.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
/** Преобразует путь токена в имя CSS-переменной.
* shadcn/status/chart/sidebar цвета остаются без префикса;
* Twenty-style tokens получают префикс --tw-*.
*/
export function tokenPathToCssVar(path: string[]): string {
if (path[0] === 'colors') {
const colorName = path[1];
if (
SHADCN_COLOR_VARS.has(colorName) ||
STATUS_VARS.has(colorName) ||
CHART_VARS.has(colorName) ||
SIDEBAR_VARS.has(colorName)
) {
return '--' + camelToKebab(colorName);
}
return '--tw-' + camelToKebab(colorName);
}
if (path[0] === 'radius') {
return '--tw-border-radius-' + camelToKebab(path[1]);
}
if (path[0] === 'shadows') {
return '--tw-box-shadow-' + camelToKebab(path[1]);
}
if (path[0] === 'font') {
if (path[1] === 'sans') return '--font-sans';
if (path[1] === 'mono') return '--font-mono';
if (path[1] === 'size') return '--tw-font-size-' + camelToKebab(path[2]);
if (path[1] === 'weight') return '--tw-font-weight-' + camelToKebab(path[2]);
}
if (path[0] === 'spacing') {
return '--tw-spacing-' + camelToKebab(path[1]);
}
return '--' + path.map(camelToKebab).join('-');
}
/** Flatten объекта токенов в плоскую карту CSS-переменных. */
export function flattenTokens(
tokens: object,
prefix: string[] = [],
): Record<string, string | number> {
const result: Record<string, string | number> = {};
for (const [key, value] of Object.entries(tokens)) {
const path = [...prefix, key];
if (value && typeof value === 'object' && !Array.isArray(value)) {
Object.assign(result, flattenTokens(value as Record<string, unknown>, path));
} else {
result[tokenPathToCssVar(path)] = value as string | number;
}
}
return result;
}
/** Генерирует CSS-блок для скина и темы. */
export function generateSkinCss(skin: Skin, theme: 'light' | 'dark'): string {
const themeTokens = skin.tokens[theme];
const vars = flattenTokens(themeTokens);
// shadcn/ui использует --radius как базовый радиус lg.
vars['--radius'] = themeTokens.radius.lg;
const lines = Object.entries(vars)
.map(([name, value]) => ` ${name}: ${value};`)
.join('\n');
return `[data-skin="${skin.id}"][data-theme="${theme}"] {\n${lines}\n}`;
}
/** Генерирует CSS для всех скинов и тем. */
export function generateAllSkinsCss(skins: Skin[]): string {
return skins
.flatMap((skin) => [
generateSkinCss(skin, 'light'),
generateSkinCss(skin, 'dark'),
])
.join('\n\n');
}