@krometra/ui-kit 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +228 -2
- package/dist/ui-kit.css +2 -0
- package/dist/ui-kit.js +14955 -0
- package/dist/ui-kit.umd.cjs +3 -0
- package/package.json +74 -3
- package/src/base.css +32 -0
- package/src/components/KAlert.vue +65 -0
- package/src/components/KAvatar.vue +67 -0
- package/src/components/KBadge.vue +47 -0
- package/src/components/KButton.vue +98 -0
- package/src/components/KCard.vue +63 -0
- package/src/components/KCheckbox.vue +105 -0
- package/src/components/KCombobox.vue +232 -0
- package/src/components/KCountdown.vue +74 -0
- package/src/components/KDescriptions.vue +40 -0
- package/src/components/KDropdown.vue +111 -0
- package/src/components/KEmpty.vue +39 -0
- package/src/components/KFileUpload.vue +121 -0
- package/src/components/KFilterBar.vue +91 -0
- package/src/components/KIcon.vue +53 -0
- package/src/components/KInput.vue +108 -0
- package/src/components/KMenuItem.vue +56 -0
- package/src/components/KModal.vue +136 -0
- package/src/components/KMoney.vue +44 -0
- package/src/components/KPage.vue +93 -0
- package/src/components/KPagination.vue +113 -0
- package/src/components/KProgress.vue +63 -0
- package/src/components/KRadioGroup.vue +103 -0
- package/src/components/KSegmented.vue +88 -0
- package/src/components/KSelect.vue +231 -0
- package/src/components/KShell.vue +176 -0
- package/src/components/KStat.vue +74 -0
- package/src/components/KSteps.vue +119 -0
- package/src/components/KSwitch.vue +82 -0
- package/src/components/KTable.vue +161 -0
- package/src/components/KTabs.vue +102 -0
- package/src/components/KTextarea.vue +79 -0
- package/src/components/KTimeline.vue +72 -0
- package/src/components/KToast.vue +34 -0
- package/src/components/KToastItem.vue +86 -0
- package/src/components/KTooltip.vue +67 -0
- package/src/components/icon-registry.js +143 -0
- package/src/composables/useToast.js +52 -0
- package/src/format.js +137 -0
- package/src/index.js +84 -0
- package/src/theme.js +417 -0
- package/src/tokens.css +109 -0
package/src/format.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Форматтеры для интерфейса.
|
|
3
|
+
*
|
|
4
|
+
* Небольшой набор общих утилит: числа, деньги, даты, размеры файлов,
|
|
5
|
+
* склонение. Живут в ките, потому что одни и те же значения показываются на
|
|
6
|
+
* десятках экранов и должны выглядеть одинаково. Все функции чистые и не
|
|
7
|
+
* зависят от Vue.
|
|
8
|
+
*
|
|
9
|
+
* Локаль по умолчанию — ru-RU; переопределяется параметром `locale`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const DEFAULT_LOCALE = 'ru-RU'
|
|
13
|
+
|
|
14
|
+
/** Денежная сумма: formatMoney(1234567) → «1 234 567 ₽» */
|
|
15
|
+
export function formatMoney(value, { fraction = false, currency = 'RUB', compact = false, locale = DEFAULT_LOCALE } = {}) {
|
|
16
|
+
const n = Number(value)
|
|
17
|
+
if (!Number.isFinite(n)) return '—'
|
|
18
|
+
return new Intl.NumberFormat(locale, {
|
|
19
|
+
style: 'currency',
|
|
20
|
+
currency,
|
|
21
|
+
minimumFractionDigits: fraction ? 2 : 0,
|
|
22
|
+
maximumFractionDigits: fraction ? 2 : 0,
|
|
23
|
+
notation: compact ? 'compact' : 'standard',
|
|
24
|
+
}).format(n)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Число с разделителями разрядов. */
|
|
28
|
+
export function formatNumber(value, { digits = 0, compact = false, locale = DEFAULT_LOCALE } = {}) {
|
|
29
|
+
const n = Number(value)
|
|
30
|
+
if (!Number.isFinite(n)) return '—'
|
|
31
|
+
return new Intl.NumberFormat(locale, {
|
|
32
|
+
minimumFractionDigits: digits,
|
|
33
|
+
maximumFractionDigits: digits,
|
|
34
|
+
notation: compact ? 'compact' : 'standard',
|
|
35
|
+
}).format(n)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Процент: formatPercent(0.125) → «12,5 %» */
|
|
39
|
+
export function formatPercent(value, { digits = 1, locale = DEFAULT_LOCALE } = {}) {
|
|
40
|
+
const n = Number(value)
|
|
41
|
+
if (!Number.isFinite(n)) return '—'
|
|
42
|
+
return new Intl.NumberFormat(locale, {
|
|
43
|
+
style: 'percent',
|
|
44
|
+
minimumFractionDigits: digits,
|
|
45
|
+
maximumFractionDigits: digits,
|
|
46
|
+
}).format(n)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Дата: «05.09.2026» */
|
|
50
|
+
export function formatDate(value, { locale = DEFAULT_LOCALE, style = 'short' } = {}) {
|
|
51
|
+
if (!value) return '—'
|
|
52
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: style }).format(new Date(value))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Дата и время: «05.09.2026, 14:30» */
|
|
56
|
+
export function formatDateTime(value, { locale = DEFAULT_LOCALE } = {}) {
|
|
57
|
+
if (!value) return '—'
|
|
58
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'short', timeStyle: 'short' }).format(new Date(value))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Относительное время: «через 3 дня», «2 часа назад». */
|
|
62
|
+
export function formatRelative(value, { locale = DEFAULT_LOCALE } = {}) {
|
|
63
|
+
if (!value) return '—'
|
|
64
|
+
const diff = new Date(value).getTime() - Date.now()
|
|
65
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
|
|
66
|
+
const units = [
|
|
67
|
+
['year', 31536000000],
|
|
68
|
+
['month', 2592000000],
|
|
69
|
+
['day', 86400000],
|
|
70
|
+
['hour', 3600000],
|
|
71
|
+
['minute', 60000],
|
|
72
|
+
['second', 1000],
|
|
73
|
+
]
|
|
74
|
+
for (const [unit, ms] of units) {
|
|
75
|
+
if (Math.abs(diff) >= ms || unit === 'second') return rtf.format(Math.round(diff / ms), unit)
|
|
76
|
+
}
|
|
77
|
+
return ''
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Длительность в секундах → «02:45:10» или «3 д 04:12:00». */
|
|
81
|
+
export function formatDuration(totalSeconds) {
|
|
82
|
+
const s = Math.max(0, Math.floor(Number(totalSeconds) || 0))
|
|
83
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
84
|
+
const d = Math.floor(s / 86400)
|
|
85
|
+
const h = Math.floor((s % 86400) / 3600)
|
|
86
|
+
const m = Math.floor((s % 3600) / 60)
|
|
87
|
+
const sec = s % 60
|
|
88
|
+
return d > 0 ? `${d} д ${pad(h)}:${pad(m)}:${pad(sec)}` : `${pad(h)}:${pad(m)}:${pad(sec)}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Размер файла: formatBytes(482301) → «471 КБ» */
|
|
92
|
+
export function formatBytes(bytes, { units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'] } = {}) {
|
|
93
|
+
const b = Number(bytes)
|
|
94
|
+
if (!Number.isFinite(b) || b <= 0) return '—'
|
|
95
|
+
let n = b
|
|
96
|
+
let u = 0
|
|
97
|
+
while (n >= 1024 && u < units.length - 1) {
|
|
98
|
+
n /= 1024
|
|
99
|
+
u++
|
|
100
|
+
}
|
|
101
|
+
return `${n.toFixed(u === 0 ? 0 : 1).replace('.', ',')} ${units[u]}`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Обрезка строки по длине с многоточием. */
|
|
105
|
+
export function truncate(text, max = 80) {
|
|
106
|
+
const s = String(text ?? '')
|
|
107
|
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Инициалы из имени: «Иванов Иван» → «ИИ» */
|
|
111
|
+
export function initials(name, count = 2) {
|
|
112
|
+
return String(name ?? '')
|
|
113
|
+
.replace(/["«»]/g, '')
|
|
114
|
+
.split(/[\s-]+/)
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.slice(0, count)
|
|
117
|
+
.map((w) => w[0].toUpperCase())
|
|
118
|
+
.join('')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Склонение по числу: plural(3, ['файл', 'файла', 'файлов']) → «файла».
|
|
123
|
+
* Формы: [одна, две-четыре, много].
|
|
124
|
+
*/
|
|
125
|
+
export function plural(count, forms) {
|
|
126
|
+
const n = Math.abs(Number(count)) % 100
|
|
127
|
+
const n1 = n % 10
|
|
128
|
+
if (n > 10 && n < 20) return forms[2]
|
|
129
|
+
if (n1 > 1 && n1 < 5) return forms[1]
|
|
130
|
+
if (n1 === 1) return forms[0]
|
|
131
|
+
return forms[2]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Число вместе со склонённым словом: «3 файла». */
|
|
135
|
+
export function pluralize(count, forms, { locale = DEFAULT_LOCALE } = {}) {
|
|
136
|
+
return `${formatNumber(count, { locale })} ${plural(count, forms)}`
|
|
137
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @krometra/ui-kit — UI-kit на Vue 3 со сменными темами оформления и светлой /
|
|
3
|
+
* тёмной схемой. Интерактивные компоненты построены на машинах @zag-js.
|
|
4
|
+
*
|
|
5
|
+
* Точка входа: именованные экспорты компонентов + плагин для глобальной
|
|
6
|
+
* регистрации. Стили токенов подключаются автоматически; базовый reset
|
|
7
|
+
* (`@krometra/ui-kit/src/base.css`) приложение импортирует само, чтобы кит не
|
|
8
|
+
* навязывал глобальные правила.
|
|
9
|
+
*
|
|
10
|
+
* import { KButton, KTable } from '@krometra/ui-kit'
|
|
11
|
+
* import '@krometra/ui-kit/style.css'
|
|
12
|
+
*
|
|
13
|
+
* либо всё сразу:
|
|
14
|
+
* import KrometraUiKit from '@krometra/ui-kit'
|
|
15
|
+
* app.use(KrometraUiKit)
|
|
16
|
+
*/
|
|
17
|
+
import './tokens.css'
|
|
18
|
+
|
|
19
|
+
import KAlert from './components/KAlert.vue'
|
|
20
|
+
import KAvatar from './components/KAvatar.vue'
|
|
21
|
+
import KBadge from './components/KBadge.vue'
|
|
22
|
+
import KButton from './components/KButton.vue'
|
|
23
|
+
import KCard from './components/KCard.vue'
|
|
24
|
+
import KCheckbox from './components/KCheckbox.vue'
|
|
25
|
+
import KCombobox from './components/KCombobox.vue'
|
|
26
|
+
import KCountdown from './components/KCountdown.vue'
|
|
27
|
+
import KDescriptions from './components/KDescriptions.vue'
|
|
28
|
+
import KDropdown from './components/KDropdown.vue'
|
|
29
|
+
import KEmpty from './components/KEmpty.vue'
|
|
30
|
+
import KFileUpload from './components/KFileUpload.vue'
|
|
31
|
+
import KFilterBar from './components/KFilterBar.vue'
|
|
32
|
+
import KIcon from './components/KIcon.vue'
|
|
33
|
+
import KInput from './components/KInput.vue'
|
|
34
|
+
import KMenuItem from './components/KMenuItem.vue'
|
|
35
|
+
import KModal from './components/KModal.vue'
|
|
36
|
+
import KMoney from './components/KMoney.vue'
|
|
37
|
+
import KPage from './components/KPage.vue'
|
|
38
|
+
import KPagination from './components/KPagination.vue'
|
|
39
|
+
import KProgress from './components/KProgress.vue'
|
|
40
|
+
import KRadioGroup from './components/KRadioGroup.vue'
|
|
41
|
+
import KSegmented from './components/KSegmented.vue'
|
|
42
|
+
import KSelect from './components/KSelect.vue'
|
|
43
|
+
import KShell from './components/KShell.vue'
|
|
44
|
+
import KStat from './components/KStat.vue'
|
|
45
|
+
import KSteps from './components/KSteps.vue'
|
|
46
|
+
import KSwitch from './components/KSwitch.vue'
|
|
47
|
+
import KTable from './components/KTable.vue'
|
|
48
|
+
import KTabs from './components/KTabs.vue'
|
|
49
|
+
import KTextarea from './components/KTextarea.vue'
|
|
50
|
+
import KTimeline from './components/KTimeline.vue'
|
|
51
|
+
import KToast from './components/KToast.vue'
|
|
52
|
+
import KTooltip from './components/KTooltip.vue'
|
|
53
|
+
|
|
54
|
+
export {
|
|
55
|
+
KAlert, KAvatar, KBadge, KButton, KCard, KCheckbox, KCombobox, KCountdown,
|
|
56
|
+
KDescriptions, KDropdown, KEmpty, KFileUpload, KFilterBar, KIcon,
|
|
57
|
+
KInput, KMenuItem, KModal, KMoney, KPage, KPagination, KProgress,
|
|
58
|
+
KRadioGroup, KSegmented, KSelect, KShell, KStat, KSteps, KSwitch, KTable,
|
|
59
|
+
KTabs, KTextarea, KTimeline, KToast, KTooltip,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { iconNames, iconRegistry, registerIcons } from './components/icon-registry.js'
|
|
63
|
+
export {
|
|
64
|
+
applyTheme, buildTheme, themeToCss, resolveScheme, themes, schemes,
|
|
65
|
+
presets, neutrals, semantics, radiusScales, densities, elevations, fontStacks,
|
|
66
|
+
} from './theme.js'
|
|
67
|
+
export { useToast, pushToast, dismissToast, toastStore } from './composables/useToast.js'
|
|
68
|
+
export * from './format.js'
|
|
69
|
+
|
|
70
|
+
const components = {
|
|
71
|
+
KAlert, KAvatar, KBadge, KButton, KCard, KCheckbox, KCombobox, KCountdown,
|
|
72
|
+
KDescriptions, KDropdown, KEmpty, KFileUpload, KFilterBar, KIcon,
|
|
73
|
+
KInput, KMenuItem, KModal, KMoney, KPage, KPagination, KProgress,
|
|
74
|
+
KRadioGroup, KSegmented, KSelect, KShell, KStat, KSteps, KSwitch, KTable,
|
|
75
|
+
KTabs, KTextarea, KTimeline, KToast, KTooltip,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export default {
|
|
79
|
+
install(app) {
|
|
80
|
+
for (const [name, component] of Object.entries(components)) {
|
|
81
|
+
app.component(name, component)
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
}
|
package/src/theme.js
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Темы оформления.
|
|
3
|
+
*
|
|
4
|
+
* Кит намеренно не привязан к одному фирменному стилю: под каждый проект
|
|
5
|
+
* меняется палитра, скругления и плотность, а разметка и поведение компонентов
|
|
6
|
+
* остаются теми же. Тема — это набор значений для CSS-переменных `--k-*`,
|
|
7
|
+
* который применяется к любому элементу-контейнеру (обычно к :root).
|
|
8
|
+
*
|
|
9
|
+
* import { applyTheme, themes } from '@krometra/ui-kit'
|
|
10
|
+
* applyTheme('slate') // готовый пресет
|
|
11
|
+
* applyTheme({ preset: 'ocean', radius: 'sharp', density: 'compact' })
|
|
12
|
+
* applyTheme({ accent: '#7c3aed' }) // свой акцент
|
|
13
|
+
* applyTheme({ preset: 'scanflow', scheme: 'dark' }) // тёмная схема
|
|
14
|
+
* applyTheme({ scheme: 'system' }) // за системой
|
|
15
|
+
*
|
|
16
|
+
* Всё, что не задано, наследуется из tokens.css, поэтому частичная тема —
|
|
17
|
+
* законная и самая частая форма настройки.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const schemes = ['light', 'dark', 'system']
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Пресеты акцента и нейтралей.
|
|
24
|
+
*
|
|
25
|
+
* Нейтрали задаются отдельно от акцента: одинаковый синий на холодном и на
|
|
26
|
+
* тёплом сером выглядит как два разных бренда, и подбирать их порознь удобнее,
|
|
27
|
+
* чем держать десяток почти одинаковых палитр. Пресет может уточнить нейтрали
|
|
28
|
+
* точечно через `neutralOverrides` — так живут проектные темы.
|
|
29
|
+
*/
|
|
30
|
+
export const presets = {
|
|
31
|
+
default: {
|
|
32
|
+
label: 'Default',
|
|
33
|
+
accent: '#2563eb',
|
|
34
|
+
accentHover: '#1d4ed8',
|
|
35
|
+
accentSoft: '#eff6ff',
|
|
36
|
+
neutral: 'cool',
|
|
37
|
+
},
|
|
38
|
+
scanflow: {
|
|
39
|
+
label: 'ScanFlow',
|
|
40
|
+
accent: '#2563eb',
|
|
41
|
+
accentHover: '#1d4ed8',
|
|
42
|
+
accentSoft: '#eff6ff',
|
|
43
|
+
neutral: 'cool',
|
|
44
|
+
neutralOverrides: {
|
|
45
|
+
light: { bg: '#f8fafc', surfaceMuted: '#f1f5f9', border: '#e2e8f0', text: '#1e293b' },
|
|
46
|
+
dark: { bg: '#0f172a', surface: '#1e293b', surfaceMuted: '#273549', border: '#334155', borderStrong: '#475569' },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
slate: {
|
|
50
|
+
label: 'Slate',
|
|
51
|
+
accent: '#475569',
|
|
52
|
+
accentHover: '#334155',
|
|
53
|
+
accentSoft: '#f1f5f9',
|
|
54
|
+
neutral: 'cool',
|
|
55
|
+
},
|
|
56
|
+
ocean: {
|
|
57
|
+
label: 'Ocean',
|
|
58
|
+
accent: '#0891b2',
|
|
59
|
+
accentHover: '#0e7490',
|
|
60
|
+
accentSoft: '#ecfeff',
|
|
61
|
+
neutral: 'cool',
|
|
62
|
+
},
|
|
63
|
+
forest: {
|
|
64
|
+
label: 'Forest',
|
|
65
|
+
accent: '#059669',
|
|
66
|
+
accentHover: '#047857',
|
|
67
|
+
accentSoft: '#ecfdf5',
|
|
68
|
+
neutral: 'warm',
|
|
69
|
+
},
|
|
70
|
+
violet: {
|
|
71
|
+
label: 'Violet',
|
|
72
|
+
accent: '#7c3aed',
|
|
73
|
+
accentHover: '#6d28d9',
|
|
74
|
+
accentSoft: '#f5f3ff',
|
|
75
|
+
neutral: 'cool',
|
|
76
|
+
},
|
|
77
|
+
amber: {
|
|
78
|
+
label: 'Amber',
|
|
79
|
+
accent: '#d97706',
|
|
80
|
+
accentHover: '#b45309',
|
|
81
|
+
accentSoft: '#fffbeb',
|
|
82
|
+
neutral: 'warm',
|
|
83
|
+
},
|
|
84
|
+
rose: {
|
|
85
|
+
label: 'Rose',
|
|
86
|
+
accent: '#e11d48',
|
|
87
|
+
accentHover: '#be123c',
|
|
88
|
+
accentSoft: '#fff1f2',
|
|
89
|
+
neutral: 'warm',
|
|
90
|
+
},
|
|
91
|
+
mono: {
|
|
92
|
+
label: 'Mono',
|
|
93
|
+
accent: '#18181b',
|
|
94
|
+
accentHover: '#000000',
|
|
95
|
+
accentSoft: '#f4f4f5',
|
|
96
|
+
neutral: 'plain',
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Нейтральные шкалы: фон, поверхности, границы, текст — для обеих схем. */
|
|
101
|
+
export const neutrals = {
|
|
102
|
+
cool: {
|
|
103
|
+
light: {
|
|
104
|
+
bg: '#f6f8fb',
|
|
105
|
+
surface: '#ffffff',
|
|
106
|
+
surfaceMuted: '#f8fafc',
|
|
107
|
+
border: '#e4e9f0',
|
|
108
|
+
borderStrong: '#cbd5e1',
|
|
109
|
+
text: '#172033',
|
|
110
|
+
textMuted: '#64748b',
|
|
111
|
+
textFaint: '#94a3b8',
|
|
112
|
+
},
|
|
113
|
+
dark: {
|
|
114
|
+
bg: '#0b1220',
|
|
115
|
+
surface: '#111a2b',
|
|
116
|
+
surfaceMuted: '#18233a',
|
|
117
|
+
border: '#24314a',
|
|
118
|
+
borderStrong: '#354764',
|
|
119
|
+
text: '#e5eaf3',
|
|
120
|
+
textMuted: '#9aa7bb',
|
|
121
|
+
textFaint: '#66748c',
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
warm: {
|
|
125
|
+
light: {
|
|
126
|
+
bg: '#faf9f7',
|
|
127
|
+
surface: '#ffffff',
|
|
128
|
+
surfaceMuted: '#f7f5f2',
|
|
129
|
+
border: '#ebe7e1',
|
|
130
|
+
borderStrong: '#d6cfc5',
|
|
131
|
+
text: '#26211c',
|
|
132
|
+
textMuted: '#6f665c',
|
|
133
|
+
textFaint: '#a39a8f',
|
|
134
|
+
},
|
|
135
|
+
dark: {
|
|
136
|
+
bg: '#15120f',
|
|
137
|
+
surface: '#1c1814',
|
|
138
|
+
surfaceMuted: '#24201b',
|
|
139
|
+
border: '#302a24',
|
|
140
|
+
borderStrong: '#443c34',
|
|
141
|
+
text: '#efe9e2',
|
|
142
|
+
textMuted: '#a89e92',
|
|
143
|
+
textFaint: '#776d62',
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
plain: {
|
|
147
|
+
light: {
|
|
148
|
+
bg: '#fafafa',
|
|
149
|
+
surface: '#ffffff',
|
|
150
|
+
surfaceMuted: '#f4f4f5',
|
|
151
|
+
border: '#e7e7e9',
|
|
152
|
+
borderStrong: '#d0d0d4',
|
|
153
|
+
text: '#18181b',
|
|
154
|
+
textMuted: '#6b7280',
|
|
155
|
+
textFaint: '#a1a1aa',
|
|
156
|
+
},
|
|
157
|
+
dark: {
|
|
158
|
+
bg: '#0f0f11',
|
|
159
|
+
surface: '#17171a',
|
|
160
|
+
surfaceMuted: '#1f1f23',
|
|
161
|
+
border: '#2a2a30',
|
|
162
|
+
borderStrong: '#3c3c44',
|
|
163
|
+
text: '#ededf0',
|
|
164
|
+
textMuted: '#a1a1aa',
|
|
165
|
+
textFaint: '#6b6b75',
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Семантические цвета статусов. `strong` — текст на мягком фоне (бейджи,
|
|
172
|
+
* плашки), `soft` в тёмной схеме выводится смешиванием с поверхностью.
|
|
173
|
+
*/
|
|
174
|
+
export const semantics = {
|
|
175
|
+
light: {
|
|
176
|
+
success: { base: '#059669', hover: '#047857', soft: '#ecfdf5', strong: '#065f46' },
|
|
177
|
+
warning: { base: '#f59e0b', hover: '#d97706', soft: '#fffbeb', strong: '#b45309' },
|
|
178
|
+
danger: { base: '#dc2626', hover: '#b91c1c', soft: '#fef2f2', strong: '#991b1b' },
|
|
179
|
+
info: { base: '#4f46e5', hover: '#4338ca', soft: '#eef2ff', strong: '#3730a3' },
|
|
180
|
+
},
|
|
181
|
+
dark: {
|
|
182
|
+
success: { base: '#34d399', hover: '#6ee7b7', strong: '#6ee7b7' },
|
|
183
|
+
warning: { base: '#fbbf24', hover: '#fcd34d', strong: '#fcd34d' },
|
|
184
|
+
danger: { base: '#f87171', hover: '#fca5a5', strong: '#fca5a5' },
|
|
185
|
+
info: { base: '#818cf8', hover: '#a5b4fc', strong: '#a5b4fc' },
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Скругления: от строгих прямых углов до подчёркнуто мягких. */
|
|
190
|
+
export const radiusScales = {
|
|
191
|
+
sharp: { sm: '2px', md: '3px', lg: '5px' },
|
|
192
|
+
subtle: { sm: '4px', md: '5px', lg: '8px' },
|
|
193
|
+
default: { sm: '6px', md: '8px', lg: '14px' },
|
|
194
|
+
soft: { sm: '9px', md: '12px', lg: '20px' },
|
|
195
|
+
pill: { sm: '12px', md: '16px', lg: '28px' },
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Плотность: высота контролов и базовый шаг. */
|
|
199
|
+
export const densities = {
|
|
200
|
+
compact: { controlH: '2.125rem', controlHSm: '1.75rem', gap: '0.375rem' },
|
|
201
|
+
default: { controlH: '2.5rem', controlHSm: '2rem', gap: '0.5rem' },
|
|
202
|
+
comfortable: { controlH: '2.875rem', controlHSm: '2.25rem', gap: '0.625rem' },
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Тени: от плоского интерфейса до выраженной глубины. */
|
|
206
|
+
export const elevations = {
|
|
207
|
+
flat: {
|
|
208
|
+
light: { sm: 'none', md: '0 1px 2px rgba(15, 23, 42, 0.06)', lg: '0 6px 20px rgba(15, 23, 42, 0.10)' },
|
|
209
|
+
dark: { sm: 'none', md: '0 1px 2px rgba(0, 0, 0, 0.3)', lg: '0 6px 20px rgba(0, 0, 0, 0.45)' },
|
|
210
|
+
},
|
|
211
|
+
default: {
|
|
212
|
+
light: { sm: '0 1px 2px rgba(15, 23, 42, 0.05)', md: '0 4px 16px rgba(15, 23, 42, 0.08)', lg: '0 18px 44px rgba(15, 23, 42, 0.14)' },
|
|
213
|
+
dark: { sm: '0 1px 2px rgba(0, 0, 0, 0.35)', md: '0 4px 16px rgba(0, 0, 0, 0.45)', lg: '0 18px 44px rgba(0, 0, 0, 0.6)' },
|
|
214
|
+
},
|
|
215
|
+
lifted: {
|
|
216
|
+
light: { sm: '0 1px 3px rgba(15, 23, 42, 0.08)', md: '0 8px 24px rgba(15, 23, 42, 0.12)', lg: '0 24px 60px rgba(15, 23, 42, 0.20)' },
|
|
217
|
+
dark: { sm: '0 1px 3px rgba(0, 0, 0, 0.45)', md: '0 8px 24px rgba(0, 0, 0, 0.55)', lg: '0 24px 60px rgba(0, 0, 0, 0.7)' },
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Шрифтовые пары. */
|
|
222
|
+
export const fontStacks = {
|
|
223
|
+
system: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
|
|
224
|
+
grotesk: "'Inter', 'Segoe UI', system-ui, sans-serif",
|
|
225
|
+
serif: "'Georgia', 'Times New Roman', serif",
|
|
226
|
+
mono: "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace",
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Сводит `system` к конкретной схеме по настройке ОС; вне браузера — light. */
|
|
230
|
+
export function resolveScheme(scheme = 'light') {
|
|
231
|
+
if (scheme !== 'system') return scheme === 'dark' ? 'dark' : 'light'
|
|
232
|
+
if (typeof window === 'undefined' || !window.matchMedia) return 'light'
|
|
233
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Собирает карту CSS-переменных для темы.
|
|
238
|
+
*
|
|
239
|
+
* Возвращает простой объект, а не строку стилей: его одинаково удобно
|
|
240
|
+
* применить через element.style.setProperty и отрендерить в <style> при SSR.
|
|
241
|
+
*/
|
|
242
|
+
export function buildTheme(theme = {}) {
|
|
243
|
+
const spec = typeof theme === 'string' ? { preset: theme } : theme
|
|
244
|
+
const scheme = resolveScheme(spec.scheme)
|
|
245
|
+
const dark = scheme === 'dark'
|
|
246
|
+
const preset = presets[spec.preset] ?? presets.default
|
|
247
|
+
const neutralFamily = neutrals[spec.neutral ?? preset.neutral] ?? neutrals.cool
|
|
248
|
+
const neutral = {
|
|
249
|
+
...neutralFamily[scheme],
|
|
250
|
+
...(spec.neutral ? {} : preset.neutralOverrides?.[scheme]),
|
|
251
|
+
...(spec.bg ? { bg: spec.bg } : {}),
|
|
252
|
+
...(spec.surface ? { surface: spec.surface } : {}),
|
|
253
|
+
...(spec.text ? { text: spec.text } : {}),
|
|
254
|
+
...(spec.textMuted ? { textMuted: spec.textMuted } : {}),
|
|
255
|
+
...(spec.textFaint ? { textFaint: spec.textFaint } : {}),
|
|
256
|
+
}
|
|
257
|
+
const radius = radiusScales[spec.radius] ?? radiusScales.default
|
|
258
|
+
const density = densities[spec.density] ?? densities.default
|
|
259
|
+
const elevation = (elevations[spec.elevation] ?? elevations.default)[scheme]
|
|
260
|
+
const font = fontStacks[spec.font] ?? spec.font ?? fontStacks.system
|
|
261
|
+
|
|
262
|
+
const baseAccent = spec.accent ?? preset.accent
|
|
263
|
+
const accent = dark ? tint(baseAccent, 0.12) : baseAccent
|
|
264
|
+
const accentHover = spec.accentHover
|
|
265
|
+
?? (dark ? tint(accent, 0.14) : spec.accent ? shade(accent, -0.12) : preset.accentHover)
|
|
266
|
+
const accentSoft = spec.accentSoft
|
|
267
|
+
?? (dark ? mix(accent, neutral.surface, 0.22) : spec.accent ? tint(accent, 0.92) : preset.accentSoft)
|
|
268
|
+
|
|
269
|
+
const vars = {
|
|
270
|
+
'--k-primary': accent,
|
|
271
|
+
'--k-primary-hover': accentHover,
|
|
272
|
+
'--k-primary-soft': accentSoft,
|
|
273
|
+
'--k-on-primary': onColor(accent),
|
|
274
|
+
|
|
275
|
+
'--k-bg': neutral.bg,
|
|
276
|
+
'--k-surface': neutral.surface,
|
|
277
|
+
'--k-surface-muted': neutral.surfaceMuted,
|
|
278
|
+
'--k-border': neutral.border,
|
|
279
|
+
'--k-border-strong': neutral.borderStrong,
|
|
280
|
+
'--k-text': neutral.text,
|
|
281
|
+
'--k-text-muted': neutral.textMuted,
|
|
282
|
+
'--k-text-faint': neutral.textFaint,
|
|
283
|
+
|
|
284
|
+
'--k-overlay': dark ? 'rgba(0, 0, 0, 0.6)' : 'rgba(15, 23, 42, 0.35)',
|
|
285
|
+
'--k-tooltip-bg': dark ? neutral.text : '#172033',
|
|
286
|
+
'--k-tooltip-text': dark ? neutral.bg : '#ffffff',
|
|
287
|
+
'--k-selection': mix(accent, neutral.surface, dark ? 0.4 : 0.2),
|
|
288
|
+
|
|
289
|
+
'--k-radius-sm': radius.sm,
|
|
290
|
+
'--k-radius': radius.md,
|
|
291
|
+
'--k-radius-lg': radius.lg,
|
|
292
|
+
|
|
293
|
+
'--k-control-h': density.controlH,
|
|
294
|
+
'--k-control-h-sm': density.controlHSm,
|
|
295
|
+
'--k-gap': density.gap,
|
|
296
|
+
|
|
297
|
+
'--k-shadow-sm': elevation.sm,
|
|
298
|
+
'--k-shadow-md': elevation.md,
|
|
299
|
+
'--k-shadow-lg': elevation.lg,
|
|
300
|
+
|
|
301
|
+
'--k-font': font,
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
for (const [name, tone] of Object.entries(semantics[scheme])) {
|
|
305
|
+
vars[`--k-${name}`] = tone.base
|
|
306
|
+
vars[`--k-${name}-hover`] = tone.hover
|
|
307
|
+
vars[`--k-${name}-soft`] = tone.soft ?? mix(tone.base, neutral.surface, 0.18)
|
|
308
|
+
vars[`--k-${name}-strong`] = tone.strong
|
|
309
|
+
vars[`--k-on-${name}`] = onColor(tone.base)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return vars
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Применяет тему к элементу (по умолчанию к :root).
|
|
317
|
+
*
|
|
318
|
+
* Помимо переменных выставляет `data-k-scheme` и `color-scheme`, чтобы
|
|
319
|
+
* нативные контролы и скроллбары следовали схеме. При `scheme: 'system'`
|
|
320
|
+
* подписывается на смену системной темы. Возвращает функцию отката — удобно
|
|
321
|
+
* для витрин и предпросмотра, где тема временная.
|
|
322
|
+
*/
|
|
323
|
+
export function applyTheme(theme = {}, target = null) {
|
|
324
|
+
const el = target ?? (typeof document !== 'undefined' ? document.documentElement : null)
|
|
325
|
+
if (!el) return () => {}
|
|
326
|
+
|
|
327
|
+
const spec = typeof theme === 'string' ? { preset: theme } : theme
|
|
328
|
+
const previous = {}
|
|
329
|
+
const previousScheme = el.getAttribute('data-k-scheme')
|
|
330
|
+
const previousColorScheme = el.style.colorScheme
|
|
331
|
+
|
|
332
|
+
const paint = () => {
|
|
333
|
+
const vars = buildTheme(spec)
|
|
334
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
335
|
+
if (!(key in previous)) previous[key] = el.style.getPropertyValue(key)
|
|
336
|
+
el.style.setProperty(key, value)
|
|
337
|
+
}
|
|
338
|
+
const scheme = resolveScheme(spec.scheme)
|
|
339
|
+
el.setAttribute('data-k-scheme', scheme)
|
|
340
|
+
el.style.colorScheme = scheme
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
paint()
|
|
344
|
+
|
|
345
|
+
let media = null
|
|
346
|
+
if (spec.scheme === 'system' && typeof window !== 'undefined' && window.matchMedia) {
|
|
347
|
+
media = window.matchMedia('(prefers-color-scheme: dark)')
|
|
348
|
+
media.addEventListener('change', paint)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return () => {
|
|
352
|
+
media?.removeEventListener('change', paint)
|
|
353
|
+
for (const [key, value] of Object.entries(previous)) {
|
|
354
|
+
if (value) el.style.setProperty(key, value)
|
|
355
|
+
else el.style.removeProperty(key)
|
|
356
|
+
}
|
|
357
|
+
if (previousScheme) el.setAttribute('data-k-scheme', previousScheme)
|
|
358
|
+
else el.removeAttribute('data-k-scheme')
|
|
359
|
+
el.style.colorScheme = previousColorScheme
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Тема как строка для атрибута style или тега <style>. */
|
|
364
|
+
export function themeToCss(theme = {}, selector = ':root') {
|
|
365
|
+
const spec = typeof theme === 'string' ? { preset: theme } : theme
|
|
366
|
+
const lines = Object.entries(buildTheme(spec)).map(([k, v]) => ` ${k}: ${v};`)
|
|
367
|
+
lines.push(` color-scheme: ${resolveScheme(spec.scheme)};`)
|
|
368
|
+
return `${selector} {\n${lines.join('\n')}\n}`
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Список готовых тем для переключателей в интерфейсе. */
|
|
372
|
+
export const themes = Object.entries(presets).map(([value, p]) => ({
|
|
373
|
+
value,
|
|
374
|
+
label: p.label,
|
|
375
|
+
accent: p.accent,
|
|
376
|
+
}))
|
|
377
|
+
|
|
378
|
+
// --- вспомогательное: смешивание цветов ---
|
|
379
|
+
|
|
380
|
+
function parseHex(hex) {
|
|
381
|
+
const s = hex.replace('#', '')
|
|
382
|
+
const full = s.length === 3 ? s.split('').map((c) => c + c).join('') : s
|
|
383
|
+
return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16))
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function toHex(rgb) {
|
|
387
|
+
return `#${rgb.map((c) => Math.round(Math.min(255, Math.max(0, c))).toString(16).padStart(2, '0')).join('')}`
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Затемняет (amount < 0) или осветляет (amount > 0) цвет. */
|
|
391
|
+
function shade(hex, amount) {
|
|
392
|
+
const rgb = parseHex(hex)
|
|
393
|
+
const target = amount < 0 ? 0 : 255
|
|
394
|
+
return toHex(rgb.map((c) => c + (target - c) * Math.abs(amount)))
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Смешивает цвет с белым: 0 — исходный, 1 — белый. */
|
|
398
|
+
function tint(hex, amount) {
|
|
399
|
+
return shade(hex, amount)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Смешивает два цвета: weight — доля первого. */
|
|
403
|
+
function mix(a, b, weight) {
|
|
404
|
+
const ra = parseHex(a)
|
|
405
|
+
const rb = parseHex(b)
|
|
406
|
+
return toHex(ra.map((c, i) => c * weight + rb[i] * (1 - weight)))
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Цвет текста поверх заливки: белый на тёмном, почти чёрный на светлом. */
|
|
410
|
+
function onColor(hex) {
|
|
411
|
+
const [r, g, b] = parseHex(hex).map((c) => {
|
|
412
|
+
const v = c / 255
|
|
413
|
+
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
|
|
414
|
+
})
|
|
415
|
+
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
416
|
+
return luminance > 0.45 ? '#0f172a' : '#ffffff'
|
|
417
|
+
}
|