@tinrun/ui
v1.0.0
Published
Shared interface of tin apps: theme tokens, base styles and Vue 3 components — one look for the platform and its apps.
Maintainers
Readme
@tinrun/ui
Общий интерфейс приложений tin: цвета, шрифты, отступы, кнопки, поля, окна, раскладка на телефоне — один вид и одно поведение у всех сервисов платформы и у самого tin. Пользовательские приложения могут взять его по желанию — и выглядеть «как tin» без усилий.
Витрина со всеми компонентами во всех состояниях, в обеих темах и на телефоне — tin.bar/ui
(открывается без входа) или npm run dev в этом репозитории.
Два слоя:
- Токены и базовые стили — CSS без фреймворка, для любого стека (React, Svelte, чистый HTML).
- Компоненты Vue 3 —
@tinrun/ui/vue.
Подключение
npm i @tinrun/uiVue 3 — одна строка
import { TinButton, TinAppLayout, applyTinTheme } from '@tinrun/ui/vue'Импорт @tinrun/ui/vue сам подключает стили: токены, базовые стили и стили компонентов (сборщик приложения — Vite и
т.п. — обрабатывает CSS из пакета). Больше ничего не нужно.
Любой стек — только CSS
import '@tinrun/ui/tokens.css' // переменные --tin-* обеих тем
import '@tinrun/ui/base.css' // шрифт, фон, ссылки, фокус, выделение (необязательно)<!-- без сборщика -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tinrun/ui@1/dist/tokens.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tinrun/ui@1/dist/base.css" />
<button style="background: var(--tin-color-accent); color: var(--tin-color-on-accent)">Готово</button>Темы
Тема выбирается атрибутом data-theme="light|dark" на <html>; без него — как в системе. Внутри tin тема
приходит от tin — свяжите их одной строкой:
import { connect } from '@tinrun/client'
import { applyTinTheme } from '@tinrun/ui' // или из '@tinrun/ui/vue'
const tin = await connect({ spa: true })
applyTinTheme(tin) // data-theme = tin.theme, и дальше — вслед за themeChanged. Возвращает «перестать следить»
tin.ready()applyTinTheme принимает любой объект с theme и on('themeChanged', …) — зависимости от @tinrun/client нет.
setTinTheme('dark' | 'light' | null, root?) — поставить тему руками (null — системная). data-theme можно
поставить и на любой элемент — эта часть страницы будет в другой теме (так витрина показывает обе темы рядом).
Токены
Все значения — CSS-переменные --tin-* (из tokens.css) и те же значения в JS (import { tokens } from '@tinrun/ui').
| Группа | Переменные |
|---|---|
| Цвета (свои в каждой теме) | --tin-color-bg, surface (тихие области: код, боковое меню), surface-raised (меню, окна, уведомления), hover, text, text-muted, border (разделители), border-strong (контуры полей, 3:1), accent, on-accent, focus, danger, on-danger, danger-soft, success, success-soft, warning, warning-soft, info, info-soft, overlay, selection, skeleton, chart |
| Тени (свои в каждой теме) | --tin-shadow-sm, --tin-shadow-md |
| Отступы | --tin-space-1 … --tin-space-8: 4, 8, 12, 16, 24, 32, 48, 64 px |
| Радиусы | --tin-radius-sm 4px, md 6px (кнопки, поля), lg 10px (карточки, окна), full |
| Шрифт | --tin-font-sans, --tin-font-mono, --tin-font-size-xs|sm|md|lg|xl, --tin-font-weight-regular|medium|bold, --tin-font-line-height, --tin-font-line-height-tight |
| Размеры | --tin-size-control — высота кнопок и полей: 36px, 44px на телефоне и сенсорных экранах; --tin-size-touch 44px; --tin-size-sidebar 240px; --tin-size-content 960px; --tin-size-icon 20px |
| Слои | --tin-z-dropdown, --tin-z-sticky, --tin-z-dialog, --tin-z-toast |
| Движение | --tin-motion-fast 120ms, --tin-motion-normal 200ms (0ms при prefers-reduced-motion), --tin-motion-easing |
Широкий экран — от 768px (@media (min-width: 768px); в медиазапросах переменные не работают). Контраст всех
пар текст/фон — WCAG AA в обеих темах, контуры и фокус — 3:1 (проверяется тестами).
Компоненты (@tinrun/ui/vue)
Общее для всех: только токены (обе темы), от 360 px ширины, касание ≥ 44 px на телефоне, управление с клавиатуры,
видимый фокус, aria. Тексты по умолчанию — русские, все заменяются свойствами (closeText, copyText, …).
Компоненты не ходят в сеть. Атрибуты и слушатели, не описанные в свойствах, уходят на основной элемент
(<button>, <input>, …).
Действия
TinButton — кнопка вместо голого <button>.
variant: primary (главное действие экрана) | secondary (по умолчанию) | danger | ghost; loading;
disabled; type (button по умолчанию); block (на всю ширину); icon (имя TinIcon); href — ссылка в виде
кнопки.
<TinButton variant="primary" :loading="saving" @click="save">Сохранить</TinButton>TinIconButton — квадратная кнопка с иконкой. icon, label (обязателен: aria-label и подсказка),
variant (ghost по умолчанию), loading, disabled.
<TinIconButton icon="copy" label="Скопировать" @click="copy" />TinMenu — выпадающее меню. items: { id, label, icon?, danger?, disabled? }[], label, align
(start|end), icon кнопки; событие select(id). Своя кнопка — слот trigger (v-bind="props").
Клавиатура: Enter/Space/↓ открывает на первом пункте, ↑ — на последнем, ↑ ↓ Home End, буква — к пункту, Esc
закрывает и возвращает фокус, Tab закрывает.
<TinMenu :items="[{ id: 'delete', label: 'Удалить', icon: 'trash', danger: true }]" label="Действия" @select="onAction" />TinIcon — иконка открытого набора Lucide по имени. name, size (CSS-длина), label
(если иконка значит что-то сама по себе). Встроенные имена: activity, arrow-down|left|right|up, bell, bot,
box, calendar, chart, check, chevron-down|left|right|up, circle-alert, circle-check, circle-help,
circle-x, clock, code, copy, cpu, credit-card, database, download, external-link, eye, eye-off,
file, file-text, filter, folder, globe, grid, history, home, image, inbox, info, key,
layers, link, list, lock, log-in, log-out, menu, message, minus, moon, more-horizontal,
more-vertical, palette, paperclip, pause, pencil, play, plug, plus, refresh, rocket,
rotate-ccw, search, send, server, settings, shield, sparkles, square, star, sun, terminal,
trash, triangle-alert, upload, user, wallet, wrench, x. Другие иконки Lucide:
import { Bike } from 'lucide'
registerTinIcons({ bike: Bike }) // <TinIcon name="bike" />Ввод
TinField — подпись, подсказка и ошибка поля, связанные для экранных дикторов. label, hint, error
(показывается вместо подсказки и делает поле aria-invalid), required. Для своего поля — слот отдаёт id и
describedBy.
<TinField label="Название" hint="Видно только вам" :error="nameError" required>
<TinInput v-model="name" />
</TinField>TinInput — поле в одну строку. v-model, type (text, search, email, url, tel, password,
number), invalid, icon (слева).
TinTextarea — многострочное поле, растёт по высоте. v-model, rows (3), maxRows (12), invalid; метод
focus().
TinSelect — выбор из списка (нативный <select>: системный выбор на телефоне). v-model,
options: { value, label, disabled? }[], placeholder, invalid.
TinCheckbox — флажок с подписью (слот). v-model, disabled.
TinSwitch — переключатель для настройки, которая действует сразу (role="switch"). v-model, disabled,
подпись — слот.
Окна
TinDialog — модальное окно. v-model:open, title, description, size (sm|md|lg), dismissible
(Esc, фон и ✕ закрывают; false — только кнопки окна), closeText; слоты: содержимое и footer; событие close.
Фокус — на элемент с autofocus, иначе на первое поле; Tab ходит по кругу внутри; страница под окном не
прокручивается; после закрытия фокус возвращается.
TinConfirm / confirm() — подтверждение одной строкой кода:
if (await confirm({ title: 'Удалить проект?', text: 'Его нельзя будет вернуть.', confirmText: 'Удалить', danger: true })) {
await remove()
}
if (await confirm('Выйти без сохранения?')) close()confirm() сама показывает окно и убирает его. В шаблоне: <TinConfirm v-model:open="asking" title="…" danger
@confirm="…" @cancel="…" />. Свойства: title, text, confirmText, cancelText, danger (красная кнопка,
фокус на «Отмена»).
TinToast / toast() — короткие уведомления внизу экрана.
toast('Скопировано')
toast({ message: 'Не удалось сохранить', tone: 'danger', action: { label: 'Повторить', onClick: save } })tone: info | success | warning | danger; timeout (мс, 5000; danger — 8000; 0 — пока не закроют);
возвращает id для dismissToast(id). Место для уведомлений <TinToast /> необязательно — toast() создаст его
сам. Уведомления читаются экранными дикторами (ошибки — сразу), таймер стоит, пока на уведомлении указатель или
фокус. Пока открыт модальный TinDialog, уведомления видны под его фоном.
Раскладка
TinAppLayout — рамка экрана приложения: верхняя строка (название и действия), меню разделов и содержимое.
Широкий экран (≥ 768 px) — меню слева; телефон — вкладки снизу (mobile-nav="bottom", по умолчанию) или сверху
("top"). items: { id, label, icon?, href?, badge? }[], active, title, navLabel; событие
navigate(id, event) (для пунктов с href — event.preventDefault(), чтобы перейти роутером приложения). Слоты:
содержимое, brand (вместо названия), actions (справа в строке), nav-footer (под меню, только на широком
экране).
<TinAppLayout title="Студия" :items="sections" :active="route.name" @navigate="(id) => router.push({ name: id })">
<template #actions><TinIconButton icon="settings" label="Настройки" /></template>
<RouterView />
</TinAppLayout>TinTabs — вкладки. v-model (id вкладки), tabs: { id, label, icon?, badge?, disabled? }[], label;
содержимое выбранной — в слоте ({ active }). Клавиатура: ← → Home End (выбирают сразу), Tab — в панель.
TinCard — блок с рамкой. title, as (section|article|div|li), padding (none|sm|md); слоты:
содержимое, actions, footer.
TinList — список строк с разделителями. items, itemKey (поле или функция), selectable (строки — кнопки,
событие select(item)), label; слоты item ({ item, index }) и empty.
Состояния
TinSpinner — загрузка. size, label (тогда это role="status" для дикторов).
TinSkeleton — заготовка загружающегося содержимого. width, height, lines, circle.
TinEmpty — «пусто». icon, title, text; кнопки — в слоте.
TinError — ошибка с «Повторить». title («Что-то пошло не так»), text, retryText, retrying; событие
retry (без слушателя кнопки нет); role="alert".
TinBadge — метка. tone: neutral | accent | success | warning | danger | info.
TinStatus — состояние точкой и словом. status: running (Работает), starting, waking, stopping,
stopped, sleeping, crashed (Упал), error, failed, building, deploying, live, pending, paid,
expired, cancelled, blocked; незнакомый показывается как есть. label, tone — заменить слово и цвет.
Данные
TinCopyField — значение с «Скопировать». value, label, secret (скрыто до «Показать»; копируется
настоящее значение), тексты copyText, copiedText, failedText, showText, hideText; событие copy.
TinCode — моноширинный блок с копированием. code, language (подпись), copyable (true), wrap.
Код показывается только как текст.
TinMeter — «использовано / лимит». label, value, max, format, warnAt (0.8 — с этой доли жёлтый;
на лимите — красный), ofText. role="meter".
TinBarChart — столбцы на SVG без библиотек. data: { label, value }[], label, height (160),
format, hint, emptyText. Подсказка со значением — при наведении и с клавиатуры: Tab на график, ← → Home End,
Esc; значение объявляется дикторам, данные для них — ещё и таблицей.
TinTime — «5 мин. назад» (обновляется сама) или дата, в поясе пользователя; полная дата — в подсказке.
value (Date, ISO-строка, мс), mode (relative|absolute), timeZone, locale.
TinNumber — «1 234 567», compact — «1,2 млн» (точное — в подсказке). maximumFractionDigits, locale.
Без Vue — formatNumber, formatRelativeTime, formatDateTime из @tinrun/ui.
Недоверенный текст
TinMarkdown — markdown ответов ИИ и любых данных пользователей. source, openLink (в приложении tin —
(url) => tin.openLink(url); без него ссылка откроется в новой вкладке).
<TinMarkdown :source="answer" :open-link="(url) => tin.openLink(url)" />Как защищено (renderMarkdown из @tinrun/ui — то же без Vue):
- HTML внутри markdown показывается текстом, а итог проходит через DOMPurify с коротким списком тегов:
нет
<script>, обработчиков (onerror, …),style,class,iframe, форм, SVG; - ссылки — только абсолютные
http(s)(остальные теряют ссылку, текст остаётся),rel="noopener noreferrer"; клик — черезopenLink; - картинки с внешних адресов не загружаются (вместо них — подпись): внешняя картинка сообщила бы постороннему
серверу, кто читает текст. Остаются только встроенные
data:image/png|jpeg|gif|webp.
Правила для своих экранов
- Цвета и размеры — только
var(--tin-…); обе темы; вёрстка от 360 px. - Не делайте свой вход и не показывайте ключи целиком:
TinCopyField secret. - Данные пользователей и ответы ИИ — только текстом или через
TinMarkdown, никогдаv-htmlс сырым текстом. - Внешние ссылки — через
tin.openLink.
Витрина — приложение tin.bar/ui
showcase/ — сайт на Vite: все компоненты во всех состояниях, переключатель темы, «обе темы рядом», «ширина
телефона 390 px». Внутри tin она подключает @tinrun/client с адреса tin (VITE_TIN_URL, по умолчанию
https://tin.bar), вызывает tin.ready() и следует теме tin (applyTinTheme); вне tin работает сама.
npm run dev # http://localhost:5013 (PORT=… — другой порт)
VITE_TIN_URL=https://tin.bar npm run build:showcase # → showcase/dist/, статический сайтРегистрация как нашего приложения с гостевым доступом (открывается по tin.bar/ui без входа) — в tinAPI:
npm run apps:register -- --id ui --title "tin UI" --url https://ui.tin.bar/ --guest-accessХостинг — любой статический (showcase/dist/), с заголовком
Content-Security-Policy: frame-ancestors https://tin.bar (стандарт приложения, п. 7).
Локально: tinAPI с APPS_ALLOW_LOCALHOST=true, --url http://localhost:5013/, tinWeb с VITE_TIN_URL витрины
на адрес этого tinWeb.
Разработка
npm ci
npm run dev # витрина
npm run lint && npm run type-check && npm test && npm run buildПравила изменений — CLAUDE.md; история — CHANGELOG.md.
Версии и публикация
Как у @tinrun/client / @tinrun/server: 1.x — только совместимо (новые компоненты, свойства, токены);
переименование и удаление — 2.0, 1.x поддерживается 12 месяцев. Публикация — npm publish --provenance из CI по
тегу v<версия> (.github/workflows/publish.yml, секрет NPM_TOKEN).
Лицензия
MIT. Иконки — Lucide (ISC).
