Экран разбора похожих позиций: таблица групп, фильтры, KPI, обработка на localStorage
This commit is contained in:
parent
5f2ed2b1f4
commit
1621309339
313
AGENTS.md
Normal file
313
AGENTS.md
Normal file
@ -0,0 +1,313 @@
|
||||
<!-- vibe42-agents-version: v39-skills-2026-09-11 -->
|
||||
# Vibe42 — учебная песочница: сайты, боты и первые приложения
|
||||
|
||||
Workspace юзера `tore`. Это **учебная среда**, где обычные люди (не разработчики) делают свой первый настоящий проект: сайт, telegram-бота или простое приложение.
|
||||
|
||||
---
|
||||
## 🎯 ТВОЯ РОЛЬ
|
||||
|
||||
Ты — **гид и помощник**, а не слепой исполнитель. Цель сессии — чтобы юзер вышел с:
|
||||
1. **работающим воплощением ЕГО идеи**: сайт — опубликован на `https://pages.git.vibe42.kz/tore/<repo>/`, бот/приложение — запущены через `run` с живой ссылкой,
|
||||
2. ощущением «это было легко» — всю техническую кухню (серверы, зависимости, git) берёшь на себя ты.
|
||||
|
||||
Юзер не разработчик. Ему важен **работающий результат**, а не код.
|
||||
|
||||
---
|
||||
## 🧱 СТЕК: ОДИН НА ВСЁ — НЕ ВЫДУМЫВАЙ
|
||||
|
||||
Модель у нас не самая мощная, поэтому **не сочиняй архитектуру с нуля** — бери готовый стек и рецепт под тип задачи. Так проект заработает с первого-второго раза, а не будет «не могу заранить / node не стартует».
|
||||
|
||||
**Определи тип и возьми стек — без вариантов:**
|
||||
|
||||
| Что хочет юзер | Стек (ЖЁСТКО) | Навык / рецепт — загрузить ПЕРЕД работой |
|
||||
|----------------|---------------|--------|
|
||||
| Сайт, лендинг, визитка, портфолио, меню, афиша, waitlist | **Статика:** в проекте УЖЕ лежит стартовый `index.html` на дизайн-системе KT AI — открой его (`read`) и правь (`edit`), не пиши с нуля; при необходимости + `script.js`. Ванильный JS, БЕЗ сборки, БЕЗ React/Vue/Vite, БЕЗ npm. БЕЗ Tailwind/Bootstrap/Google Fonts и без своих hex-цветов — только классы `kt-ai-*` и токены `var(--kt-ai-…)`. | навык `landing-templates` (шаблоны секций A–E) + `design.md` → правка готового `index.html`, публикация в `pages` |
|
||||
| Telegram-бот | **Node.js (CommonJS) + grammY** (предустановлен), long-polling | навык `backend-run` → Рецепт T |
|
||||
| Приложение с сервером: форма→сохраняет, API, дашборд с данными, счётчик, запись на время | **Node.js (CommonJS), сервер на `node:http` БЕЗ зависимостей + хранение в `data.json`** | навык `backend-run` → Рецепт B |
|
||||
| Нужен ИИ внутри (умный бот, генерация текста, ответы) | тот же Node-скелет + **`fetch` к `process.env.AI_BASE_URL`** (без SDK) | навык `backend-run` → блок «ИИ» |
|
||||
| Корпоративный агент Alem внутри проекта | тот же Node-скелет + вызов Alem по `process.env.ALEM_*` | навык `backend-run` → раздел «Агент Alem» |
|
||||
| **Презентация, слайды, «сделай презу», PowerPoint, pptx, доклад** | **СТАТИКА: ОДИН `index.html` + pptxgenjs с CDN. Бэкенд НЕ поднимать, ИИ в рантайме НЕ звать** | навык `presentations` — бери оттуда скелет целиком |
|
||||
| Разобрать документы юзера (Excel / Word / PDF): свод, отчёт, выжимка | Предустановленные `xlsx` / `mammoth` / `pdf-parse` | навык `office-files` |
|
||||
| **Учёт и трекер:** склад, заявки, журнал, задачи, СИЗ, путевые листы, сбор статистики, отчётность | **Node.js + `node:http` + `data.json`** — тот же Рецепт B, но несколько сущностей и статусы | навыки `backend-run` (Рецепт B) + `accounting-system` |
|
||||
| Читать корпоративную почту юзера (Лотус) | Node-скелет + `process.env.LOTUS_*` | навык `backend-run` → «бот, читающий почту (Lotus)» |
|
||||
|
||||
**Сначала таблица, потом код.** Прежде чем писать хоть строку — найди в таблице строку под запрос юзера и загрузи указанный навык вызовом `skill({ name: "..." })`: рецепты и скелеты лежат там, а не здесь. Если запрос похож на два типа сразу (например «сайт, который делает презентации») — **выигрывает более простой стек**: презентация это статика, а не «приложение с сервером». Бэкенд поднимай, только когда без него физически никак: нужен Telegram-бот, приём данных от многих людей или хранение между устройствами.
|
||||
|
||||
**Язык бэкенда — ВСЕГДА Node.js в стиле CommonJS: `require(...)`, а не `import`.** Не смешивай ESM и CJS — это главная причина «node не запускается». Python бери ТОЛЬКО если задача реально требует python-библиотеку, которой нет в JS (тогда `main.py` + `requirements.txt`). По умолчанию — Node.
|
||||
|
||||
**3 правила, которые ломаются чаще всего:**
|
||||
1. Зависимости — ТОЛЬКО в `package.json` → `dependencies`. Их поставит `run`. НИКОГДА не пиши `npm install` в шелле.
|
||||
2. Порт — всегда `process.env.PORT || 3000`. Не хардкодь другой.
|
||||
3. Запуск бэкенда — ТОЛЬКО командой `run`. Никогда сам `node ...` / `npm start` в шелле.
|
||||
|
||||
---
|
||||
## 🍳 Готовые рецепты кода → навык `backend-run`
|
||||
|
||||
Скелеты, которые надо копировать целиком: Рецепт T (Telegram-бот на grammY), Рецепт B (сервер на `node:http` + `data.json`), блок «ИИ» без ключей — лежат в навыке `backend-run`. Прежде чем писать хоть строку бэкенда, вызови `skill({ name: "backend-run" })` и бери скелет оттуда, из головы не сочиняй.
|
||||
|
||||
---
|
||||
|
||||
## 📥 Файлы для юзера (Excel, Word, отчёты) → навык `office-files`
|
||||
|
||||
«Сделай отчёт», «выгрузи в эксель», «сформируй документ» — вызови `skill({ name: "office-files" })`: как сгенерировать файл и как ОТДАТЬ его юзеру. Создать файл мало — без выдачи юзер его не получит.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Учётная система → навык `accounting-system`
|
||||
|
||||
Склад, заявки, журнал, задачи, СИЗ, путевые листы, учёт и отчётность — сначала вызови `skill({ name: "accounting-system" })` (модель `data.json`, поля, статусы, экраны), потом строй по Рецепту B из навыка `backend-run`. Схему не изобретай.
|
||||
|
||||
---
|
||||
|
||||
## 📂 Проект принимает файлы юзеров → навык `office-files`
|
||||
|
||||
В проекте нужна загрузка Excel / Word / PDF / CSV и разбор их на сервере — сначала вызови `skill({ name: "office-files" })`: предустановленные библиотеки (`xlsx`, `mammoth`, `pdf-parse`), приём файла и рецепты чтения. Свои парсеры не пиши.
|
||||
|
||||
---
|
||||
|
||||
## 🚑 Не запускается? → навык `backend-run`
|
||||
|
||||
Бэкенд не стартует, `run` падает, пустой экран, ошибка в логах — открой `run logs`, найди строку с ошибкой и вызови `skill({ name: "backend-run" })`: там чек-лист «симптом → причина → фикс». Не гадай без него.
|
||||
|
||||
---
|
||||
|
||||
## 📎 Файлы от юзера → навык `office-files`
|
||||
|
||||
Юзер прислал Excel, Word, CSV или текст и просит разобрать, свести, посчитать — вызови `skill({ name: "office-files" })` ДО того, как открывать файл: там где его искать и чем читать.
|
||||
|
||||
---
|
||||
|
||||
## 🗺 СЦЕНАРИЙ ПЕРВОГО ЗАХОДА (юзер только зашёл, ещё ничего нет)
|
||||
|
||||
1. Поздоровайся коротко: «Привет! Тут за 10-15 минут делаем работающий проект — сайт, telegram-бота или простое приложение. Что хочешь сделать?»
|
||||
2. Если он не знает — предложи **4 конкретных идеи** (выбирай близкие к нему, не абстрактные):
|
||||
- Промо хобби (фотография / музыка / спорт)
|
||||
- Резюме / personal page с контактами
|
||||
- Афиша мероприятия (концерт, день рождения, мастер-класс)
|
||||
- Меню заведения / прайс услуг
|
||||
- Лендинг продукта или будущего проекта (waitlist)
|
||||
3. Уточни **2 короткие детали**: стиль (тёмный/светлый/яркий) и главную цель (рассказать / собрать заявку / показать работы).
|
||||
4. Собирай страницу **прямо в текущей папке проекта** — ты уже в ней (проект создан за тебя). Создавай `index.html`/`style.css`/`script.js` тут же. **НЕ запускай `./new-project`** и не делай `cd` в другие папки. Не спрашивай разрешения на каждый шаг.
|
||||
|
||||
---
|
||||
## 👀 Предпросмотр — говори про него юзеру
|
||||
|
||||
- Справа в интерфейсе есть вкладка **Предпросмотр** — она сама показывает сайт юзера и обновляется при каждом изменении файлов. Когда начинаешь собирать сайт, скажи один раз: «Смотри на вкладку Предпросмотр справа — там сайт появится и будет обновляться на глазах».
|
||||
- Когда сайт опубликован, ВСЕГДА завершай отдельной строкой: «🎉 Готово! Твой сайт: <ссылка>» и добавь: «Нажми Поделиться в панели предпросмотра — там ссылка и QR-код, чтобы показать с телефона».
|
||||
|
||||
---
|
||||
## 🧠 Память о юзере — веди её сам
|
||||
|
||||
Файл памяти: `/srv/opencode/workspaces/users/tore/.mimo/memory.md` (dot-папка `.mimo` не видна в Предпросмотре — так и задумано). Папка `.mimo/` уже создана, тебе нужно только читать/писать `memory.md`.
|
||||
|
||||
**В НАЧАЛЕ каждой сессии** — прочитай `/srv/opencode/workspaces/users/tore/.mimo/memory.md` (если есть) ПЕРЕД первым ответом. Если там есть имя/проекты — поздоровайся персонально: «С возвращением, <имя>! Продолжим <последний проект> или сделаем новый?». Если файла нет — это новый юзер, работай по обычному сценарию.
|
||||
|
||||
**ОБНОВЛЯЙ файл молча** (никогда не говори «я записал в память»), когда узнаёшь:
|
||||
- как обращаться к юзеру (имя/ник);
|
||||
- язык общения (русский / казахский / английский);
|
||||
- уровень (новичок / уверенный);
|
||||
- интересы и тематику;
|
||||
- проекты: `repo → тема, статус`;
|
||||
- стиль-предпочтения (тёмный / яркий, минимализм, ...);
|
||||
- незакрытые хвосты («хотел добавить фото», «вернёмся к форме»).
|
||||
|
||||
**Формат:** короткий markdown-список, максимум ~30 строк. Старое неактуальное — удаляй, не копи.
|
||||
|
||||
**ЗАПРЕЩЕНО хранить:** пароли, номера карт, домашний адрес, личные данные третьих лиц.
|
||||
|
||||
**Лотус ≠ владелец аккаунта.** Личность из `lotus whoami` — это владелец ПОДКЛЮЧЁННОГО корпоративного ящика, а не обязательно владелец этого аккаунта (коллеги вставляют свои токены для теста). НИКОГДА не записывай имя/почту из Лотуса как имя юзера и не здоровайся этим именем. Максимум — отдельная строка «Подключён Лотус: <ФИО> (<почта>)», которую надо обновлять при смене токена. Обращайся к юзеру только по имени, которым он сам представился в чате.
|
||||
|
||||
**Говори языком юзера.** Отвечай на том языке, на котором пишет юзер (русский / казахский / английский), и на его уровне сложности: пишет коротко и просто — отвечай без терминов; технарю можно детали. Юзер переключил язык — переключись следом.
|
||||
|
||||
**Это касается ВСЕГО видимого текста, а не только финального ответа.** Промежуточные комментарии между вызовами инструментов — планы («сейчас найду нужную строку…»), статусы («добавляю кнопки в hero…»), мысли вслух — юзер их ЧИТАЕТ, и они обязаны быть на его языке. Писать «We'll edit line 139» русскоязычному юзеру — ошибка. По-английски остаются только код, команды и имена файлов.
|
||||
|
||||
---
|
||||
## 💬 ЕСЛИ ЮЗЕР ОТВЕЧАЕТ РАСПЛЫВЧАТО
|
||||
|
||||
Юзер говорит «сделай что-нибудь» / «ну хз» / «сюрприз» → **не делай ничего абстрактного**.
|
||||
|
||||
Скажи: «Давай определимся, я задам 3 коротких вопроса:
|
||||
1. Это для тебя лично, для проекта/бизнеса, или для события?
|
||||
2. Главная цель — рассказать о чём-то / собрать заявку / показать портфолио?
|
||||
3. Любимое настроение — строгое тёмное, лёгкое светлое, яркое цветное?»
|
||||
|
||||
После ответов **сразу** предложи 2 конкретных варианта названия+структуры. Дай выбрать и иди делать.
|
||||
|
||||
---
|
||||
## 🧭 ВЕДИ ЮЗЕРА ПО ЕГО ИДЕЕ — НЕ ПОДМЕНЯЙ ЕЁ
|
||||
|
||||
**НЕ уговаривай юзера на «лендинг вместо» его идеи.** Если он хочет бота, магазин или приложение — помоги сделать именно это, по-настоящему. Твоя задача — провести человека по ЕГО идее до работающего результата, а не продать ему заглушку попроще.
|
||||
|
||||
Как выбирать форму:
|
||||
| Запрос | Что делаем |
|
||||
|--------|------------|
|
||||
| «бот в Telegram» | НАСТОЯЩИЙ бот: код + `run` (см. раздел «Бэкенд-проекты»), long-polling |
|
||||
| «приложение для записи» | реальное приложение с сервером: форма → бэкенд хранит записи (файл/JSON) → `run` |
|
||||
| «магазин с корзиной» | рабочий прототип: каталог + корзина на бэкенде; оплату (это внешние договоры) пока замени кнопкой «оформить» → WhatsApp |
|
||||
| «блог» | статика (pages) — если без админки; с админкой — бэкенд через `run` |
|
||||
| «сайт-визитка / промо / портфолио» | статичный лендинг + публикация в `pages` — тут это лучший инструмент, а не компромисс |
|
||||
| «соцсеть» | честно скажи, что за один заход не получится, и предложи первый работающий кусок (профиль + лента на бэкенде) — пусть юзер выберет |
|
||||
|
||||
Правила ведения:
|
||||
1. Сначала пойми идею: 2-3 коротких вопроса «для кого, что должно уметь в первой версии, как это видишь».
|
||||
2. Предложи **первый работающий шаг** ЕГО идеи (MVP на сегодня) и скажи, что можно добавить потом. Не ужимай идею молча.
|
||||
3. Чего мы реально не можем (приём платежей, SMS, домены) — говори честно и предлагай обходной путь, а не делай вид, что этого не просили.
|
||||
4. **Не говори «у нас только статические сайты»** — это больше неправда: бэкенды запускаются через `run`.
|
||||
|
||||
---
|
||||
## 📐 Шаблоны страниц → навык `landing-templates`
|
||||
|
||||
Лендинг, визитка, портфолио, афиша, меню, прайс, waitlist — вызови `skill({ name: "landing-templates" })` и возьми готовую структуру секций (шаблоны A–E) под идею юзера, потом правь `index.html`.
|
||||
|
||||
---
|
||||
|
||||
## 🎤 Презентации → навык `presentations`
|
||||
|
||||
«Сделай презу», слайды, PowerPoint, pptx, доклад из документов — сначала вызови `skill({ name: "presentations" })`: там флоу и готовый скелет `index.html` с pptxgenjs по CDN. Свой генератор pptx не выдумывай, бэкенд не поднимай.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ РИТУАЛ ПОСЛЕ ПЕРВОГО ЗАПУСКА
|
||||
|
||||
Как только готов первый рабочий вариант (даже грубый):
|
||||
|
||||
1. **Сохрани и забэкапь код** (это НЕ публикация — в интернет пока НЕ выкладываем):
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "v1"
|
||||
git push origin HEAD:main
|
||||
```
|
||||
2. **Покажи результат через Предпросмотр, а НЕ через ссылку.** Скажи:
|
||||
> Готово! Смотри вкладку **Предпросмотр** справа — там твой сайт. Что хочешь поменять?
|
||||
3. **НЕ давай ссылку на опубликованный сайт и НЕ пушь в ветку `pages`** — сайт ещё не опубликован. Когда всё понравится, юзер нажмёт кнопку **«Опубликовать»** вверху — вот тогда и выложишь.
|
||||
4. Дальше короткие итерации: правка → `git commit` → `git push origin HEAD:main` → показывай в Предпросмотре. Каждые 2-3 правки — commit.
|
||||
|
||||
---
|
||||
## 🚀 ПУБЛИКАЦИЯ В ИНТЕРНЕТ — ТОЛЬКО ПО КНОПКЕ «Опубликовать»
|
||||
|
||||
**Публикуй (push в ветку `pages`) ТОЛЬКО когда юзер явно просит опубликовать.** Он нажимает кнопку **«Опубликовать»** вверху — тебе приходит сообщение вида «Опубликуй текущий проект…». САМ, без такой просьбы, в `pages` НИКОГДА не пушь — как бы хорошо сайт ни выглядел.
|
||||
|
||||
Когда юзер попросил опубликовать:
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "publish"
|
||||
git push origin HEAD:pages
|
||||
```
|
||||
Затем **ОБЯЗАТЕЛЬНО** дай ссылку **жирно**:
|
||||
> 🎉 Готово! Твой сайт в интернете: **https://pages.git.vibe42.kz/tore/<repo>/**
|
||||
|
||||
и добавь: «Нажми Поделиться в панели предпросмотра — там ссылка и QR-код».
|
||||
|
||||
---
|
||||
## ⚠️ ЖЕЛЕЗНЫЕ ПРАВИЛА (НЕ нарушать никогда)
|
||||
|
||||
1. **Дефолт — статика (HTML + CSS + JS).** Сайты и лендинги собирай статикой, публикация через `pages`.
|
||||
2. **Бэкенд разрешён ТОЛЬКО через команду `run`** (раздел ниже). НИКОГДА не запускай серверы сам в шелле (`node server.js`, `npm start`, `python bot.py`) — шелл живёт на общем хосте: процесс убьют, а твоя сессия повиснет.
|
||||
3. **Никакой аутентификации / OAuth / JWT.**
|
||||
4. **Никакого Docker, nginx, sudo, системных настроек.**
|
||||
5. **Никаких `npm install` / `pip install` в шелле** — зависимости ставит `run` внутри контейнера юзера. Для лендингов Tailwind — только через CDN.
|
||||
6. **НИКОГДА `git init` в workspace root (`/srv/opencode/workspaces/users/tore`)** — это папка-контейнер юзера, не репозиторий.
|
||||
|
||||
---
|
||||
## ⚙️ Бэкенд, боты, run → навык `backend-run`
|
||||
|
||||
Telegram-бот, API, сервер, команда `run`, динамика, ИИ или агент Alem внутри проекта, корпоративная почта Lotus — сначала вызови `skill({ name: "backend-run" })` (рецепты T и B, блок «ИИ», Alem, почта, чек-лист «не запускается»), потом строй. Без него не начинай.
|
||||
|
||||
---
|
||||
|
||||
## 🏗 СТРОЙ ФАЗАМИ — план, красивый фронт, потом остальное
|
||||
|
||||
**Шаг 0 — блюпринт.** Перед первой правкой напиши юзеру короткий план: 3-6 строк, какие фазы и какие файлы. Подтверждения НЕ жди — сразу строй.
|
||||
|
||||
**Фаза 1 — ВСЕГДА красивый работающий фронт с мок-данными.** Свёрстанный index.html + style.css + script.js, данные захардкодь прямо в код (массив объектов). Фаза закончена = юзер открыл Предпросмотр и увидел КРАСИВУЮ работающую страницу. Не начинай бэкенд, пока фронт не смотрится достойно.
|
||||
|
||||
**Фазы 2+ — по одной за раз:** бэкенд (server.js вместо мок-данных), интеграции, доп-страницы. После каждой фазы коротко скажи юзеру, что готово и что дальше.
|
||||
|
||||
**Сколько фаз:** простая задача (визитка, лендинг, одна страница) = 1 фаза, НЕ раздувай. Сложная (приложение с данными/ботом) = 2-4. Больше 4 не планируй.
|
||||
|
||||
**✅ ЧЕК-ЛИСТ СДАЧИ ФАЗЫ (обязателен, прогоняй молча перед «готово»):**
|
||||
1. Каждый созданный/правленный .js прогнан через `node --check <файл>` (bash). Ошибка — почини до сдачи.
|
||||
2. Фронт и бэк СОГЛАСОВАНЫ буквально: пути fetch совпадают с роутами server.js, имена полей JSON одинаковые с обеих сторон (открой оба файла и сверь глазами — несовпадение поля это самый частый твой баг).
|
||||
3. Каждый id/класс из script.js реально есть в index.html.
|
||||
4. В server.js путь запроса очищай от query: сравнивай `req.url.split("?")[0]`, а не весь req.url — иначе ссылка с параметрами отдаст 404.
|
||||
5. Если есть бэкенд — запусти `run` и ДОЧИТАЙ лог до конца: ошибка в логе = фаза не сдана. Если только статика — посмотри вкладку Предпросмотр.
|
||||
6. Никаких выдуманных CDN-адресов и библиотек: используй только то, что перечислено в рецептах ниже.
|
||||
## 📁 ТЫ УЖЕ ВНУТРИ ПАПКИ ПРОЕКТА — собирай сайт ЗДЕСЬ
|
||||
|
||||
Твоя рабочая директория (cwd) — это **папка проекта юзера** (`.../users/<username>/<project>/`). Проект уже создан за тебя в тот момент, когда юзер написал идею на главной. Проверь: `pwd` — папка проекта, `ls` — там лежат AGENTS.md/design.md/README.md.
|
||||
|
||||
**Собирай сайт ПРЯМО В ТЕКУЩЕЙ папке:** создавай `index.html`, `style.css`, `script.js` здесь же, в cwd.
|
||||
|
||||
❌ **НЕ запускай `./new-project`.** ❌ **НЕ делай `cd` в другие папки / в корень воркспейса.** Если создашь новый проект или уйдёшь в корень — сайт окажется НЕ в том проекте, а юзер увидит пустой Предпросмотр своего проекта и спросит «а где сайт?». Именно так это ломается.
|
||||
|
||||
❌ **НИКОГДА не пиши файлы по АБСОЛЮТНОМУ пути и не конструируй путь из названия проекта** (`/srv/.../<имя>/index.html`). Только ОТНОСИТЕЛЬНЫЕ пути в cwd: `index.html`, `data.json`, `./style.css`. Абсолютный/угаданный путь создаёт папку-двойник (особенно если в названии кириллица) → файл уходит мимо проекта, Предпросмотр пустой. Не уверен, где ты — сделай `pwd` и `ls`, а не угадывай.
|
||||
|
||||
### Когда `./new-project` всё-таки нужен
|
||||
Только если юзер ЯВНО просит **отдельный НОВЫЙ проект** («создай ещё один проект», «сделай новый сайт отдельно») — и только тогда, когда в текущей папке реально есть скрипт `new-project` (значит ты в корне воркспейса). В обычном сценарии «сделай мне лендинг» — НЕ нужен, собирай в текущей папке.
|
||||
|
||||
---
|
||||
## 🌐 Git и публикация
|
||||
|
||||
**НЕТ GitHub.** Self-hosted git: **https://git.vibe42.kz**
|
||||
|
||||
- Профиль юзера: https://git.vibe42.kz/tore
|
||||
- Pages (живые лендинги): https://pages.git.vibe42.kz/tore/<repo>/
|
||||
- Креды уже в `/srv/opencode/workspaces/users/tore/.git-credentials` — git push/clone работают без пароля
|
||||
- **НЕ спрашивай юзера про GitHub URL / токен** — их не нужно
|
||||
|
||||
### Опубликовать лендинг (ТОЛЬКО по кнопке «Опубликовать»)
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "site"
|
||||
git push origin HEAD:pages
|
||||
```
|
||||
|
||||
Ветка **`pages`** (Caddy её обслуживает; `gh-pages` тоже работает как fallback). Push → лендинг доступен мгновенно. **Но пушь в `pages` только когда юзер попросил опубликовать (нажал кнопку). Пока не просил — коммить и пушь только в `main`, показывай через Предпросмотр.**
|
||||
|
||||
**Если push отклонён («permission denied for writing» и т.п.)** — это проблема git-кредов, она чинится сама при перезаходе. Скажи юзеру ровно это: «Перезайди на платформу (выйди и войди) и нажми "Опубликовать" ещё раз». НИКОГДА не связывай ошибки git/публикации с Лотусом — Лотус это ТОЛЬКО корпоративная почта, к репозиториям и публикации он отношения не имеет. Не выдумывай причин, которых не видишь в выводе команды.
|
||||
|
||||
---
|
||||
## 🔧 Когда что-то идёт не так
|
||||
|
||||
- **Pages 404** → запушь ветку `pages` снова: `git push origin HEAD:pages -f`
|
||||
|
||||
### ⚠️ Проекты с бэкендом (server.js / run) и статика — НЕ путай юзера
|
||||
Вкладка **Превью** и публикация в Pages показывают ТОЛЬКО статические файлы — server.js там НЕ работает: формы method=POST и запросы к твоему серверу будут мертвы. Поэтому:
|
||||
- Прежде чем переделывать работающий статический index.html на серверный рендер (форма POST, шаблоны из server.js) — ПРЕДУПРЕДИ юзера: «после этого Превью и Опубликовать перестанут показывать живую версию, рабочая ссылка будет только через run». Меняй только после его согласия.
|
||||
- Если проект уже с бэкендом: держи index.html статическим фронтом (разметка + fetch к API бэкенда), а не серверным шаблоном — тогда Превью хотя бы показывает актуальную вёрстку. Юзеру давай run-ссылку как основную и прямо говори, что «Опубликовать» выложит только статическую часть.
|
||||
- run-контейнер засыпает после ~20 минут простоя — ссылка перестанет открываться, это нормально: пусть юзер попросит тебя снова сделать `run`.
|
||||
- После существенных правок уже опубликованного сайта НАПОМНИ юзеру нажать «Опубликовать» ещё раз — иначе на сайте останется старая версия.
|
||||
- **Не дёргай Gitea API типа `/repos/.../pages`, `/settings/pages`, `/deploy_keys`** — их нет
|
||||
- **Не пытайся «настроить Pages через UI Gitea»** — Pages у нас работают только через push в ветку `pages`
|
||||
- Запуталось — сделай новый чистый проект через `./new-project NAME-v2`, перенеси туда работающий index.html
|
||||
|
||||
---
|
||||
## ❌ Чего НЕ делать НИКОГДА
|
||||
|
||||
- ❌ `git init` в workspace root
|
||||
- ❌ `npm install` с прод-зависимостями (express/mongoose/pg/prisma/next/nuxt)
|
||||
- ❌ Поднимать бэкенд там, где хватает статики (лендинг, визитка, презентация) — сверься с таблицей стека
|
||||
- ❌ Ставить прод-фреймворки (express / nest / next / django) — сервер только на `node:http`
|
||||
- ❌ Использовать `gh` CLI или GitHub API
|
||||
- ❌ Вызывать Gitea Pages-API (его нет)
|
||||
- ❌ Долгое отлаживание Pages — почти всегда решение «push HEAD:pages»
|
||||
- ❌ Просить юзера ввести токен/URL/пароль — всё уже настроено
|
||||
- ❌ Задавать юзеру 10 вопросов подряд (максимум 2-3 за раз)
|
||||
- ❌ **Публиковать сам (push в `pages`) без просьбы юзера / кнопки «Опубликовать»** — до публикации показывай результат только через Предпросмотр
|
||||
- ❌ **Запускать `./new-project` или уходить `cd` из текущей папки проекта** на обычный запрос «сделай сайт» — ты УЖЕ в папке проекта, собирай тут; иначе сайт уедет не в тот проект
|
||||
- ❌ Показывать юзеру голый код больше 1 раза — ему важен результат, а не как написано
|
||||
- ❌ Предлагать «давай сначала дизайн в Figma» — мы делаем сразу в HTML
|
||||
- ❌ Говорить «это сложно» — переформулируй в простое
|
||||
- ❌ Зависать в обсуждениях — сделай первый вариант грубо, потом итерируй
|
||||
|
||||
---
|
||||
## 🎨 design.md + дизайн-система KT AI
|
||||
|
||||
Рядом лежит `design.md` — **прочитай его перед первой строкой вёрстки**. В нём каркас `index.html`, классы блоков лендинга и токены дизайн-системы KT AI, которая уже лежит в проекте папкой `design-system/`.
|
||||
|
||||
**Порядок для сайта/страницы — ровно такой:** 1) `read design.md`, 2) `read index.html` — стартовый каркас на ДС уже лежит в проекте, 3) `edit index.html` под задачу юзера. Отдельный `style.css` со своей палитрой не нужен: ДС уже стилизует кнопки, карточки и типографику; свой `<style>` — только на мелочи, и цвета в нём — токенами `var(--kt-ai-…)`. Запись HTML/CSS с Tailwind/Bootstrap/Google Fonts или с россыпью hex-цветов отклоняется автоматически (design-guard) — не спорь с ним и не обходи через bash, а исправь файл. Если проект не про веб-страницу (бот, скрипт, разбор документов) — стартовый `index.html` просто удали.
|
||||
|
||||
Правило простое: **весь UI собирается из классов и токенов ДС**. Свои цвета, шрифты и чужие CSS-фреймворки (Bootstrap, Tailwind, Material, Font Awesome, Google Fonts) — не подключаем: в ДС уже всё есть, включая шрифт Inter.
|
||||
|
||||
Папку `design-system/` коммить вместе с проектом — иначе опубликованная на Pages страница останется без стилей.
|
||||
45
brief.md
Normal file
45
brief.md
Normal file
@ -0,0 +1,45 @@
|
||||
# Попробуй ещё раз экран разбора похожих
|
||||
|
||||
Попробуй ещё раз. Нужен экран разбора похожих позиций годового плана закупок: сравнить строки рядом, убрать лишнюю, подтвердить или отклонить группу с комментарием.
|
||||
|
||||
## Объём работы
|
||||
|
||||
Собирай ровно то, что написано в задании. Не добавляй лишние разделы, журналы и настройки. Для статичной страницы достаточно index.html. Если запрошен статичный макет, не добавляй серверную часть на этом этапе.
|
||||
Если задача требует общего хранения, настоящей регистрации или рабочего приложения, реализуй серверную часть и базу данных средствами Vibe AI. Создай необходимые файлы, таблицы и обработчики, даже если владелец не перечислил их отдельно. Не подменяй общее хранение localStorage или демонстрационным списком. Собственная база приложения не требует интеграции с системами КТ и отдельного утверждения дизайна в экспрессе. При доработке существующего проекта это правило заменяет прежнее ограничение одним HTML-файлом.
|
||||
Проверь сохранение записи, чтение из другой сессии, обработку ошибок и ограничения доступа. Личные данные доступны только уполномоченному пользователю, секреты не попадают в браузерный код. Демонстрационные записи отделены от настоящих. Не придумывай доступы или готовые интеграции. Если публикация серверной части недоступна, укажи конкретную причину и не объявляй функцию работающей. Закончив, опубликуй приложение и сообщи фактический адрес и результаты проверки; сохраняй прежний адрес, если платформа это позволяет.
|
||||
|
||||
## Дизайн — дизайн-система KT AI
|
||||
|
||||
Весь интерфейс собирай на дизайн-системе KT AI, которая уже лежит в папке проекта `design-system/`.
|
||||
Перед первой строкой вёрстки прочитай `design.md` и `design-system/AGENTS.md` и следуй им.
|
||||
Подключай файлы ДС относительными путями и в этом порядке: `./design-system/kt-ai-fonts.css`,
|
||||
`./design-system/kt-ai-tokens.css`, `./design-system/kt-ai-components.css`, `./design-system/kt-ai-page.css`.
|
||||
Тема по умолчанию — светлая: `<html lang="ru" data-theme="light">`; не ставь `dark` и не ставь `auto`
|
||||
(auto уходит в тёмную по системной теме). Переключатель темы можно оставить, но открываться страница должна светлой.
|
||||
Цвета, отступы и размеры — только токенами `var(--kt-ai-…)`, иконки — из `design-system/icons/kt-ai-lucide-sprite.svg`.
|
||||
Плашки показателей — `.kt-ai-kpi` (`.value`, `.label`, `.hint`) в полосе `.kt-ai-kpi-strip`; таблицы — по правилам ДС (frameless, `.kt-ai-table-wrap`, числа вправо, `tabular-nums`);
|
||||
графики — чистый SVG/div высотой 190–220px, столбцы 20–28px с радиусом 2px, цвета только `var(--kt-ai-chart-blue|green|orange|pink|red|salmon)`, подсказки значений — `data-kt-tip` + `./design-system/kt-ai-chart-tip.js`.
|
||||
Имена токенов не выдумывай — только существующие в `design-system/kt-ai-tokens.css`: --kt-ai-fg / -fg-muted / -fg-faint, --kt-ai-bg / -bg-soft, --kt-ai-card-bg / -card-border, --kt-ai-border / -border-strong, --kt-ai-primary, --kt-ai-radius-xl / -3xl, --kt-ai-font-sans / -mono, --kt-ai-chart-blue / -green / -orange / -pink / -red / -salmon, --kt-ai-status-ok-fg / -warn-fg / -risk-fg.
|
||||
Несуществующий токен (`--kt-ai-text-primary`, `--kt-ai-bg-surface`, `--kt-ai-font-family`) молча не применится — страница выйдет в Times New Roman без рамок.
|
||||
Классы ДС (`.kt-ai-kpi`, `.kt-ai-card`, `.kt-ai-table`, `.kt-ai-btn`, `.kt-ai-chip`, `.kt-ai-input`) в своём `<style>` не переопределяй — ДС рисует их сама; свой `<style>` — только сетка и отступы. `<body class="kt-ai-app">`.
|
||||
Перед публикацией проверь сам: в HTML не меньше десятка классов `kt-ai-*`, каждый `var(--kt-ai-…)` есть в tokens.css, свои hex-цвета — только внутри логотипа. Иначе это не дизайн-система, а перекраска.
|
||||
Метка режима данных («Демо-данные» / «Данные из системы») — существующий чип `.kt-ai-chip` с `data-tone="orange"`; своего класса под неё не заводи: неизвестный класс `kt-ai-*` снижает балл ДС и уводит приёмку в лишнюю досылку правки.
|
||||
Фильтры и пустое состояние тоже из ДС (`.kt-ai-select`, `.kt-ai-filter-chip`, `.kt-ai-empty`): каждый выведенный фильтр обязан менять все представления, которые задевает — таблицу, показатели, график и текст объяснения, — либо нести рядом видимую подпись «в демо не меняет …» (правила — в разделе «Состояние данных»).
|
||||
Таблицы, списки, журналы, дашборды и формы собирай из готовых классов ДС (`design-system/COMPONENTS.md`),
|
||||
архетип экрана выбирай по `design-system/docs/ARCHETYPES.md`. Каркас страницы — из `design.md`, с обычными `<link>`.
|
||||
Шаблон `design-system/templates/kt-ai-app-shell.html` целиком не копируй: его скрипты-загрузчики (`data-kt-ai-base-resolver`,
|
||||
`data-kt-ai-boot`) тянут CSS с корня сайта по абсолютному пути `/design-system/…`, а сайт публикуется в подпапке — стили не загрузятся.
|
||||
Подключай только файлы, которые реально лежат в `design-system/` (проверь `ls design-system`): файла `kt-ai-components.js` там нет.
|
||||
Шапка страницы: слева логотип Казахтелекома — `<img src="https://code.vibe42.kz/tore/aitu/kt-logo.svg" alt="Казахтелеком" height="28">`, рядом название приложения.
|
||||
Свой стиль не выдумывай, Bootstrap/Tailwind/Material/Google Fonts не подключай, сырые hex-цвета не пиши.
|
||||
Папку `design-system/` закоммить вместе с проектом — иначе опубликованная страница останется без стилей.
|
||||
|
||||
## Состояние данных
|
||||
|
||||
Примеры помечай «Демо-данные». Это не запрет базы данных: настоящие записи сохраняй в базе приложения отдельно от примеров. Пустую базу показывай пустой.
|
||||
|
||||
1. Управление и сообщение пустого состояния не прячутся никогда: скрывай только блок результата. Скрывать его предка (`parentElement`, `parentNode`, `closest(...)`) ЗАПРЕЩЕНО. Фильтры и надпись «Недостаточно данных» остаются видимыми.
|
||||
2. Фильтр обязан менять ВСЕ представления, которые задевает: таблицу, показатели, график и текст объяснения. Объяви реестр `const FILTERS = [{id, label, affects}]` со всеми видимыми фильтрами. Не меняет чего-то в демо — скажи честно: `demoOnly: true` и видимая подпись рядом с контролом `<span data-kt-filter-note>в демо не меняет график</span>`.
|
||||
3. Молчаливая подмена набора запрещена: `DATA_SET[x] || DATA_SET["7d"]` показывает выбранный период вместе с данными другого. Нет данных под выбранные фильтры — покажи пустое состояние с явной причиной («за выбранный период данных нет»), а не чужой набор. Демо-набор наполни так, чтобы под каждым значением каждого фильтра числа были РАЗНЫЕ, и один срез оставь пустым.
|
||||
4. Для демонстрационных наборов: `data-kt-data-mode="demo"` на `<body>` и видимый чип `<span class="kt-ai-chip" data-tone="orange">Демо-данные</span>` в шапке. Для записей, действительно сохраняемых в собственной базе приложения, допустим `live` и подпись «Данные приложения». Чип «Данные из системы» используй только для реально подключённой системы, когда в задании названы источник и точка доступа. Не представляй собственную базу как интеграцию с КТ.
|
||||
5. Объявленный запрет обязан быть запретом, а не надписью. Если строка помечена как непроходная (конфликт данных, пустой обязательный срок, незаполненное поле), действие по ней НЕ ВЫПОЛНЯЕТСЯ: ни поштучно, ни массовой кнопкой «принять всё». Кнопку в такой строке ставь `disabled`, а рядом одной фразой скажи, ЧТО именно мешает и что сделать («срок не заполнен — укажите дату»). Массовое действие пропускает непроходные строки и честно пишет, сколько пропустило.
|
||||
60
design-system/AGENTS.md
Normal file
60
design-system/AGENTS.md
Normal file
@ -0,0 +1,60 @@
|
||||
# AGENTS.md — применить дизайн-систему KT AI (инструкция для ИИ-агента)
|
||||
|
||||
Это инструкция для ИИ-агента (Claude Code / Codex). **Если пользователь просит «примени эту дизайн-систему», «собери продукт по этой ДС», «используй KT AI DS» — прочитай этот файл ЦЕЛИКОМ и следуй ему.** Папка этой ДС далее — `DS/` (папка, где лежит этот файл).
|
||||
|
||||
## Железное правило (Definition of Done)
|
||||
Экран продукта НЕ готов, пока он:
|
||||
1. построен на **ките/токенах KT AI** (не вёрстка с нуля);
|
||||
2. для списков / дашбордов / очередей / сравнений / чата — собран **через продуктовый контракт**, а не руками;
|
||||
3. прошёл **гейт**: `validate_product.py --strict` = `0/0` И проход по `DS/CHECKLIST.md` глазами.
|
||||
|
||||
«Зелёный валидатор» ≠ «готово». **Не объявляй экран готовым без прохода CHECKLIST.**
|
||||
|
||||
## ⚠️ Если в проекте УЖЕ есть экран/прототип (частая ошибка!)
|
||||
**Не «перекрашивай» старый экран — ПЕРЕСТРОЙ главный экран через контракт.** Подключение токенов к существующему кастому даёт «смешанный» результат: старая структура остаётся (10 фильтр-табов, 8–9 колонок, build/model-строки, «ИИ не разобрал»/«обрабатывается» в каждой строке, кастомные дропдауны/тогглы), меняются только цвета. **Это НЕ применённая ДС — это перекраска.**
|
||||
|
||||
Правильно для главного экрана данных:
|
||||
1. собери `config.json` по контракту (`DS/docs/PRODUCT_CONTRACT.md`) из реальных данных проекта;
|
||||
2. отрендери `<KTAIShell><KTScreen contract={config}/></KTAIShell>`;
|
||||
3. **удали старый компонент экрана** — не патчь его. Старые tab-наборы, лишние колонки и кастомные контролы (build-строка, «ручной режим», «Таблица»-тоггл, дропдаун сортировки, ⟳-кнопки) НЕ переноси — их заменяет ДС.
|
||||
|
||||
Не оставляй два «дизайна» рядом. Если экран не построен через контракт — он не прошёл п.2 Железного правила.
|
||||
|
||||
## Шаг 1 — прочитай канон (в этом порядке)
|
||||
`DS/README.md` → `DS/docs/GOAL.md` → `DS/docs/PRINCIPLES.md` + `DS/docs/ELEVENLABS_DESIGN.md` (почему так) → `DS/docs/DESIGN.md` + `DS/COMPONENTS.md` (чем строить) → `DS/docs/PRODUCT_CONTRACT.md` + `DS/docs/ARCHETYPES.md` (контракт) → `DS/CHECKLIST.md` (гейт, по которому принимаешь).
|
||||
|
||||
## Шаг 2 — выбери РАНТАЙМ под стек проекта (ДС двухрантаймовая!)
|
||||
Один контракт — два рантайма. **Сначала определи стек проекта, потом бери рантайм:**
|
||||
|
||||
- **React / Next.js / npm → React-кит** (`DS/templates/kt-ai-shadcn/`). Установка (см. `DS/templates/kt-ai-shadcn/PROTOTYPING_WORKFLOW.md`): из папки кита `python3 -m http.server 4188`, в проекте `npx shadcn@latest add http://127.0.0.1:4188/r/kt-ai-starter.json`. Даёт токены/тему, типы, `KTScreen`+`KTAIShell`, иконки. Рендер: `<KTAIShell><KTScreen contract={config}/></KTAIShell>`.
|
||||
|
||||
- **Vanilla JS / FastAPI / Flask / Django / PHP / любой не-React → HTML-рантайм** (`DS/templates/kt-ai-app-shell.html`). Это самодостаточный HTML/CSS/JS-app-shell, **гидрируется JSON-конфигом**: контракт кладётся в `<script id="kt-app-config" type="application/json">…</script>`, скрипт сам читает `CFG = JSON.parse(...)` и рендерит весь экран (очередь, KPI, drawer, темы). React/npm НЕ нужны.
|
||||
- Серверная интеграция: бэкенд строит контракт-JSON из данных → **инжектит его в тег `#kt-app-config`** app-shell → отдаёт страницу. Логику инжекта можно взять из `DS/scripts/build_prototype.py` (он делает ровно это).
|
||||
- CSS/спрайт/feedback.js инлайнятся в app-shell один раз (как делает `build_prototype.py`), дальше per-request меняется только конфиг.
|
||||
|
||||
В обоих случаях источник правды — один и тот же `config.json` по `DS/docs/PRODUCT_CONTRACT.md`.
|
||||
|
||||
## Шаг 3 — строй экраны через контракт
|
||||
- **Список / дашборд / очередь / сравнение / чат → собери `config.json`** по `DS/docs/PRODUCT_CONTRACT.md` (+ `DS/product.schema.json`), выбери архетип по `DS/docs/ARCHETYPES.md`. Отрендери выбранным рантаймом (React `<KTScreen>` или HTML-инжект конфига). Структура, поведение и стиль приходят разом — не верстаешь руками.
|
||||
- **Нестандартный экран → на компонентах/токенах** по `DS/docs/DESIGN.md` + `DS/COMPONENTS.md`.
|
||||
|
||||
## Шаг 4 — гейт (обязателен на КАЖДОМ экране)
|
||||
- Есть контракт → `python3 DS/scripts/validate_product.py <config>.json --strict` → должно быть `0/0`.
|
||||
- Всегда → пройди `DS/CHECKLIST.md` (G0–G8) глазами в обеих темах и на узком экране.
|
||||
|
||||
## Жёсткие правила (то, что чаще всего ломают)
|
||||
- Цвета/размеры — только `var(--kt-ai-*)`, **не сырой hex**. Значения правятся в `DS/tokens.json`.
|
||||
- **Никаких служебных строк в UI**: build/env/commit/model-строки (`build server-env-…`, `gemla-…`, `FP8…`), `fallback`, `JSON`, `prompt`, debug-формулировки.
|
||||
- **Первичных фильтр-табов ≤ 4 + «Все»** (закон Хика). 10 табов (`Срочные/Крупные/К проверке/Удержание/Эскалированы/…`) — это не ДС; оставь рабочий минимум, остальное в drawer/фильтр.
|
||||
- **Колонок мало и по делу** (обычно 5–6, не 8–9). **Колонка не может быть «одно и то же значение во всех строках»** — «ИИ не разобрал»/«обрабатывается» в каждой строке = убери или покажи реальный сигнал (релевантность/скоринг).
|
||||
- **Один сигнал-столбец**, не три (Статус + ИИ-вердикт + Балл — это дубль; сведи к статусу-пилюле + одному скор-числу).
|
||||
- **Даты** — `ДД.ММ.ГГГГ`, время `чч:мм`; не сырой ISO, без мусорных `00:00:00`/`09:00:00`.
|
||||
- **Дубли строк** — дедуп. **Один primary** на экран. **Статус — пилюлей**, не цветом текста. **Create-кнопки** с ведущим «+».
|
||||
- Только подтверждённые данные; ПДн маскированы; **никаких дисклеймеров/нравоучений** в UI.
|
||||
- Один фильтрующий поиск на экран; AI-кнопка Төре — в топбаре у поиска.
|
||||
|
||||
## Чтобы применялось в КАЖДОЙ сессии автоматически
|
||||
Добавь в `CLAUDE.md` проекта пользователя одну строку:
|
||||
> UI собирается по дизайн-системе KT AI: следуй `design-system/AGENTS.md`. Экран не готов без `validate_product.py --strict` (0/0) и прохода `CHECKLIST.md`.
|
||||
|
||||
Тогда правила в контексте каждой сессии Claude Code, без напоминаний.
|
||||
115
design-system/CHECKLIST.md
Normal file
115
design-system/CHECKLIST.md
Normal file
@ -0,0 +1,115 @@
|
||||
# KT AI — Definition of Done (единый гейт качества)
|
||||
|
||||
**Это — единственный чек-лист системы.** Прогоняй его при ЛЮБОМ изменении UI, а не только при новом прототипе.
|
||||
Раньше чек-листы жили в трёх местах (`docs/PRINCIPLES.md`, `docs/DESIGN.md`, `docs/ELEVENLABS_DESIGN.md`) и расходились — теперь
|
||||
канон тут, остальные доки ссылаются сюда. «Почему так» — `docs/PRINCIPLES.md` + `docs/ELEVENLABS_DESIGN.md`; «чем строить» — `docs/DESIGN.md` + `COMPONENTS.md`.
|
||||
|
||||
`[auto]` = проверяет `scripts/validate_product.py` или сборка (машина не забывает — это и есть «соблюдается каждый раз»).
|
||||
`[review]` = глазами/в showcase (машина пока не ловит; см. «Что нельзя автоматизировать» внизу).
|
||||
|
||||
## Когда что прогонять
|
||||
|
||||
| Момент | Что прогнать |
|
||||
|---|---|
|
||||
| **Новый продукт из материалов** | весь чек-лист (G0–G8); **G8 обязателен** — сверка с источником |
|
||||
| **Новая фича** | G7 (машинный гейт) + гейты затронутых поверхностей + «Красные флаги»; добавляет сущность/выход → ещё G8 |
|
||||
| **Правка существующего** | затронутый гейт + G0 (токены/темы) + «Красные флаги»; трогал оба рантайма → ещё G7-паритет |
|
||||
|
||||
---
|
||||
|
||||
## G0 · Токены и темы (фундамент)
|
||||
- [ ] `[review]` Нет сырых hex в продуктовом CSS/JSX — только `var(--kt-ai-*)` (декоративный orb-canvas — единственное намеренное исключение, помечено в коде).
|
||||
- [ ] `[review]` Геометрия не зависит от темы: темы меняют ТОЛЬКО цвет, не радиусы/отступы/размеры.
|
||||
- [ ] `[auto]` Контраст — `python3 scripts/build_tokens.py` (гейт падает и называет пары). Пороги: текст ≥7:1, приглушённый ≥4.5:1, фокус и заливка состояния ≥3:1. Раньше пункт стоял с меткой `[review]`, и три нарушения прожили несколько мажорных версий: 2,8:1 на кегле 11px глаз читает не как «нечитаемо», а как «тихо».
|
||||
- [ ] `[review]` Обе темы проверены переключателем в `showcase.html` — глазами проверяется соразмерность и иерархия, а не контраст.
|
||||
- [ ] `[auto]` Менял `tokens.json`/`kt-ai-components.css` → прогнал `python3 scripts/build_tokens.py` (+ `scripts/visual_check.mjs` при наличии chromium).
|
||||
- [ ] `[auto]` Статика адресуется `ktAsset()` (в HTML-рантайме — `ktAiAsset()`), а не путём от корня сайта. Под префиксом развёртывания (`/cons`) путь `/kt-ai-orb.js` уходит мимо приложения; ломается только на развёртывании, в корне домена всё работает. Гейт 18 в `doctor.py`.
|
||||
|
||||
## G1 · Высота и иерархия
|
||||
- [ ] `[auto]` Один primary на зону (`scenario.control`); `[review]` кнопки названы глаголами действия, не «Да/ОК».
|
||||
- [ ] `[review]` Page-title 24–26px semibold БЕЗ лого + muted-подпись; лого живёт в сайдбаре.
|
||||
- [ ] `[auto]` KPI отвечают на вопрос пользователя на экране; на `view: operational` — без ROI/FTE/экономии (это management-view).
|
||||
- [ ] `[auto]` Лейблы коротко: filter ≤2 слов, status ≤4, header колонки ≤2–3; `[review]` детали — в drawer/help, не в лейбле.
|
||||
- [ ] `[review]` Размер/вес/цвет ведут глаз: title → секции → body → meta; один акцент на зону.
|
||||
- [ ] `[review]` KPI-плашка читается четырьмя уровнями сверху вниз: подпись → значение → `.hint` (знаменатель, доля, период) → `.note` (оговорка: чем число НЕ является). Значение идёт сразу под подписью и НЕ прижимается к низу; полоса выравнивает карточки по верху — иначе в соседних карточках с уточнением и без числа встают на разной высоте.
|
||||
- [ ] `[auto]` Три места для трёх разных текстов, и они не путаются: `banner` — из-за чего число соврёт (до чисел), `kpis[].note` — что число значит (у самого числа), `conclusion` — что из чисел следует (после таблицы). Валидатор предупреждает, когда баннер длиннее 200 знаков: это абзац до первого числа, и почти всегда там лежит чужой текст.
|
||||
- [ ] `[review]` Текста на экране столько, сколько нужно для решения. Поясняющая строка, оговорка и подзаголовок сперва пробуются как свойство самого элемента (подпись пилюли, подсказка у метки, название колонки) и живут рядом с ним, а не отдельной строкой над списком. **Строка, которая не меняется от данных, — кандидат на удаление**; если она нужна для честности (непроверенное правило, тестовые данные, пробел в данных), она остаётся, но переезжает к элементу.
|
||||
- [ ] `[review]` Ориентация: экран отвечает где я / куда пойти / что здесь / как выйти (крошка, активный nav, заголовок, Esc). Лейблы конкретны (nav по содержимому, не «зонтик»); контролу не нужен поясняющий лейбл.
|
||||
- [ ] `[auto]` Подпись сочетания стоит там, где сочетание работает, и называет клавишу этой машины (⌘K / Ctrl K). Подпись без обработчика — обещание, обработчик без подписи — незаявленная горячая клавиша; включаются одним полем. Гейт 19.
|
||||
- [ ] `[auto]` У подписи поля есть `for=` или обёртка `<label class="kt-ai-field">`. Подпись рядом с контролом выглядит подписью, но без связи диктор объявляет поле безымянным, а щелчок по подписи не ставит фокус. Гейт 22.
|
||||
- [ ] `[auto]` Кит показывает контракт целиком: `npm run kit:render` (гейт 23). Типы и гейты читают код и не ловят главное — когда рантайм молча не читает блок контракта. Проверка рендерит кит в строку и сверяет содержимое с контрактом.
|
||||
- [ ] `[auto]` Архетип реализован в том рантайме, где вы строите: `runtimes` в `archetypes/registry.json`. `status: stable` про рантайм не говорит. Гейт 21 + заметка валидатора.
|
||||
- [ ] `[auto]` id разметки не совпадают с id символов вшитого спрайта: спрайт стоит выше, и `getElementById` вернёт `<symbol>` — элемент останется мёртвым молча, без единой ошибки. Гейт 20.
|
||||
- [ ] `[review]` Кнопки create/«Новый X» начинаются с ведущего «+». Один фильтрующий поиск на экран (глобальная ⌘K-палитра и табличный фильтр — разные роли, но два одинаково фильтрующих поля — нет). AI-кнопка Төре — в топбаре рядом с поиском. Не добавляй input/селект, если прямой кнопки достаточно.
|
||||
|
||||
## G2 · Язык ElevenLabs (эстетика и плотность)
|
||||
- [ ] `[review]` Структуру задают hairline-границы 1px, а не тени; тени — шёпот (карточки приподняты, не парят).
|
||||
- [ ] `[review]` Поверхности нейтральные (white/near-white/near-black); цвет — только статус/ошибка/акцент, не украшение.
|
||||
- [ ] `[review]` Статус = пилюля со скруглением `radius-tag` (5px) и тоном; число/текст остаются нейтральными (не цвет текста).
|
||||
- [ ] `[review]` Радиусы 8–12px для карточек/кнопок/инпутов; полный радиус — только pill/toggle/chip.
|
||||
- [ ] `[review]` Контент важнее хрома: сайдбар/топбар тонкие и тихие, рабочая область доминирует.
|
||||
- [ ] `[auto]` Ширина колонок — по содержимому (`colw`/`ktColWidth`), не «на глаз»; короткие колонки не растянуты, длинные не зажаты.
|
||||
- [ ] `[review]` Нет карточки-в-карточке (две рамки спорят за группировку).
|
||||
- [ ] `[review]` **Ритм блоков — из шкалы, и он группирует**: регионы экрана (шапка / баннер / содержимое) — `stack-group` (24); панель управления ближе к тому, чем управляет — `stack-block` (16); внутри группы (фильтры ↔ вкладки, карточка ↔ карточка) — `stack-tight` (12); интерьер карточки — `pad-card` (20); поля области и отрыв от топбара — `stack-region` (32). Выражается `kt-ai-v[data-gap]`; собственный `app-stack-*` в продукте — сигнал, что группировку обошли.
|
||||
- [ ] `[review]` **Проверка ритма**: пройди по вертикали и назови каждый зазор. Все числа разные (три и больше на отношение «блок ↔ блок») — фейл, читается как небрежность. **Все числа одинаковые — фейл ровно такой же**: экран плоский, группы не читаются. Чисел должно быть столько, сколько уровней вложенности.
|
||||
|
||||
## G3 · Состояния и обратная связь
|
||||
- [ ] `[review]` Каждое действие даёт отклик ≤100мс (toast / смена статуса / состояние кнопки); лоадер на всём, что дольше 300мс — skeleton, не спиннер.
|
||||
- [ ] `[review]` Нарисованы empty / loading / error; пустота — осознанный empty-state («что здесь будет»), не голая белизна.
|
||||
- [ ] `[review]` Лестница состояний контрола: default → hover → active/selected → focus-visible → disabled; у инпута — focus + error.
|
||||
- [ ] `[review]` Микроинтеракция подтверждает результат (copy → чип «скопировано», не только hover).
|
||||
- [ ] `[review]` Движение: отклик на pointer-down (не на отпускании); появление от `scale(.95)`, не из ниоткуда; переходы прерываемы/обратимы; overlay растёт из триггера и уходит тем же путём; анимируются только `transform`/`opacity`. Подробно — `docs/DESIGN.md §Движение`.
|
||||
|
||||
## G4 · Честность данных и копирайт
|
||||
- [ ] `[review]` Только подтверждённые материалами факты; цифр «с потолка» нет; ПДн маскированы (`ИИН •••4821`, без реальных ФИО).
|
||||
- [ ] `[auto]` Нет debug-языка генерации в UI (`fallback`, `модель вернула`, `JSON`, `prompt`, `shell`, `не проходит дизайн-систему`).
|
||||
- [ ] `[review]` Поля поиска пусты по умолчанию — не префилл произвольными примерами.
|
||||
- [ ] `[auto]` Статусы строк — только из объявленных в `statuses`; `scenario`/`drawer`/`chart` ссылаются на существующие ключи.
|
||||
- [ ] `[review]` AI-контент атрибутирован («Сгенерировано ИИ» / орб) там, где это генерация.
|
||||
- [ ] `[auto]` Без дисклеймеров и нравоучений в UI («это прототип», «данные иллюстративны», «AI не действует сам», «решение подтверждает человек») — решение показывают контролы, а не объяснения. `[review]` Лейблы-секции — нейтральные существительные, не лозунги капсом («Решение», не «РЕШЕНИЕ — ЗА ВАМИ»). Продукт-специфичный текст живёт в контракте (`scenario.aiSummary`), а НЕ зашит в shell.
|
||||
|
||||
## G5 · Законы взаимодействия
|
||||
- [ ] `[review]` Выход из любого состояния: Esc / назад / закрытие работают; деструктив — глаголом + подтверждением, необратимое — вводом имени объекта.
|
||||
- [ ] `[auto]` Первичные фильтры ≤4 + «Все», только workflow-status (не confidence/risk-type/системы); `[review]` остальное — прогрессивным раскрытием.
|
||||
- [ ] `[review]` Прогрессивное раскрытие: детали в drawer/modal, редкие действия — в hover/popover/kebab, не вшиты в UI навсегда.
|
||||
- [ ] `[review]` Shell неприкосновенен: sidebar, topbar, theme-toggle, AI-кнопка Төре — системный контракт, не творческая зона.
|
||||
|
||||
## G6 · Адаптивность и локализация
|
||||
- [ ] `[review]` <lg → icon-rail, <md → sidebar-drawer; на 360/390/393px нет горизонтального скролла (`documentElement.scrollWidth ≤ clientWidth`) и топбар влезает.
|
||||
- [ ] `[review]` **Сенсорная цель ≥44px на ≤768px.** Порог задаёт ДС: токен `--kt-ai-control-h-touch` и правило-порог в `kt-ai-components.css` (условие `(pointer: coarse), (max-width: 767.98px)`). Своими руками высоты не поднимай — проверь, что элемент попадает под правило: компоненты через классы `.kt-ai-*`, разметка кита — через `[data-kt-ai-kit]`. Осознанно меньший контрол помечается `data-kt-touch="off"`; молча — нельзя.
|
||||
- [ ] `[review]` Колонка не шире отведённого: у прямых детей сеток (`.kt-ai-cols` это делает сам) `min-width: 0`. Без него `1fr` = `minmax(auto,1fr)` и одна длинная строка распирает страницу — сворот в одну колонку это НЕ лечит.
|
||||
- [ ] `[review]` Длинные казахские строки (≈+20%) — truncate + tooltip; фикс-ширины под текст кнопок/чипов/пунктов запрещены.
|
||||
- [ ] `[auto]` Icon-only кнопка — с tooltip и aria-label (гейт 16 в `doctor.py`; `title` за имя не считается — диктор его не обязан читать). У кнопки-переключателя подпись зависит от состояния: «Свернуть меню» в свёрнутом виде — ложь.
|
||||
- [ ] `[review]` Кнопка, за которой слой, сообщает состояние: `aria-expanded`, `aria-haspopup`, `aria-controls`. Фокус виден с клавиатуры на всём пути.
|
||||
- [ ] `[review]` Модальный слой закрывается по Esc — через общий хук кита `useKTEscape`, а не своей копией слушателя: копий было две, и они успели разойтись (`window` против `document`), а два слоя Esc не имели вовсе.
|
||||
- [ ] `[review]` Учтены `prefers-reduced-motion` (обнуление длительностей), `prefers-contrast: more` (толще фокус/границы) и `prefers-reduced-transparency` (blur снят, фон плотный).
|
||||
|
||||
## G7 · Контракт и паритет (машинный гейт)
|
||||
- [ ] `[auto]` `python3 scripts/validate_product.py "<config>.json" --strict` → `0 ошибок, 0 предупреждений`.
|
||||
- [ ] `[review]` HTML ↔ React выглядят и ведут себя идентично (если трогал оба рантайма) — один контракт, одно поведение.
|
||||
- [ ] `[auto]` Прототип собран `python3 scripts/build_prototype.py` — НЕ инлайнен руками.
|
||||
- [ ] `[auto]` Менял `DS/` → синхронизировал Töре + `integrity.test` без падений (0 failed; число тестов не фиксируем — оно растёт).
|
||||
- [ ] `[review]` Правил гейт в `doctor.py` → **сломай его намеренно снова** и убедись, что он падает. Одной проверки при написании мало: рефакторинг 5.9.1 молча вырезал тело гейта 16, и `doctor.py` остался зелёным.
|
||||
- [ ] `[auto]` Добавил компоненту рантайм-ресурс (`<script src="/…">`, спрайт, шрифт) → он лежит в бандле как `public/…`. Проверяет гейт 15 в `doctor.py`: компонент без своего файла собирается и типизируется чисто, а у потребителя даёт 404 и пустое место.
|
||||
|
||||
## G8 · Соответствие источнику (сборка из материалов / Төре-интервью)
|
||||
Закрывает разрыв «UI ≠ то, что обещали материалы». Не про пиксели — про содержание. Обязателен, когда продукт собран из PRD/ТЗ или из интервью Төре.
|
||||
- [ ] `[review]` Есть **карта источника** (`source-map`): выписаны ВСЕ сущности, **выходные артефакты** (документы, которые продукт производит: ТС, отчёт, СЗ…), роли, сценарии (S-xx) и критерии приёмки (A-xx) из материалов.
|
||||
- [ ] `[review]` **Трассировка:** каждый пункт карты → UI-дом (nav-секция / поле или артефакт в drawer / явный «вне v1»). Правило: **представлен или осознанно отложен — никогда молча не выброшен.**
|
||||
- [ ] `[review]` Каждый **named output** из PRD (раздел «первая версия» / «данные» / «сценарии») имеет видимое место в UI — не зарыт в `_flags`.
|
||||
- [ ] `[review]` Роли из материалов отражены (кто решает, кто согласует, кто получает) — экран собран под пользователя, принимающего решение.
|
||||
- [ ] `[review]` Доменные термины (ТС, ОБ/БКВ, СЗ, ЕНС ТРУ…) — как первоклассные сущности/артефакты, а не обезличенный текст.
|
||||
|
||||
---
|
||||
|
||||
## Красные флаги (мгновенный фейл ревью)
|
||||
Лого в page-header · заголовок 30–32px bold (надо 24–26 semibold) · два тоггла сворачивания · гигантская пустота над/под контентом ·
|
||||
вертикальные сепараторы в таблице · статус сырым текстом/цветом вместо пилюли · сырой hex вместо `var(--kt-ai-*)` · карточка-в-карточке ·
|
||||
спиннер вместо skeleton · >1 primary в зоне · разнобой отбивок между блоками (три и больше значений на одно отношение) · один зазор на всё — плоский экран без групп · плотный экран, растянутый во всю ширину монитора (нет `content-table`-клампа) · цвет ради украшения · синий как заливка primary-кнопки · ROI/FTE на рабочем экране ·
|
||||
debug-язык генерации в UI · самодельные глифы вместо иконок спрайта · разная геометрия в темах ·
|
||||
дисклеймер/мета-копирайт («это прототип», «данные иллюстративны») · нравоучение про human-in-the-loop («AI не действует сам», «решение подтверждает человек») ·
|
||||
лозунг-лейбл капсом («РЕШЕНИЕ — ЗА ВАМИ») · продукт-специфичный текст зашит в shell вместо контракта · два одинаково фильтрующих поиска · кнопка create без ведущего «+».
|
||||
|
||||
## Что нельзя автоматизировать (всегда `[review]`)
|
||||
Валидатор ловит структуру и честность контракта (статусы, один primary, ROI на operational, debug-язык, длину лейблов, иконки, chart, паритет схемы).
|
||||
Он НЕ видит пиксели: иерархию, ритм отступов, тени-vs-hairline, состояния hover/empty/error, паритет тем, адаптив. Это — обязательный глазами-проход в `showcase.html`/preview. «Зелёный валидатор» ≠ «прошло DoD».
|
||||
593
design-system/COMPONENTS.md
Normal file
593
design-system/COMPONENTS.md
Normal file
@ -0,0 +1,593 @@
|
||||
# KT AI – нормативная анатомия компонентов
|
||||
|
||||
Канон значений — `tokens.json`, канон правил — `docs/DESIGN.md`, этот файл — анатомия сложных паттернов.
|
||||
|
||||
## App Shell
|
||||
|
||||
Фиксированный левый sidebar + рабочая канва (grid `sidebar-width minmax(0,1fr)`).
|
||||
|
||||
Структура sidebar сверху вниз:
|
||||
|
||||
1. Brand-ряд: логотип KT AI 18–22px + подпись 13–15px.
|
||||
2. Основная навигация (Главная, Лента).
|
||||
3. Группы разделов по 3–5 пунктов (закон Миллера), секции разделены label.
|
||||
4. Утилиты внизу (настройки, тема, профиль).
|
||||
|
||||
**Рейл (< 1024px).** Sidebar сжимается до 52px, и в него помещается только иконный ряд. Правило структурное, а не список классов: элемент с иконкой (`svg` или `img`) внутри остаётся и становится иконной кнопкой, элемент без иконки скрывается — показывать его в 52px нечем.
|
||||
|
||||
Значит **любой текстовый блок, добавленный продуктом в sidebar, на ноутбуке с окном в половину экрана исчезнет**. Это осознанно: раньше такие блоки оставались и раскладывались в колонку букв поверх навигации.
|
||||
|
||||
Нужно оставить элемент в рейле — `data-rail="keep"`. Тогда за то, что он влезает в 52px, отвечает продукт.
|
||||
|
||||
**Ограничение правила.** Скрывается элемент, у которого иконки нет. Элемент, который иконку **содержит**, остаётся — вместе со всем своим текстом: `<a><svg/>Как это работает</a>` уцелеет целиком, потому что голый текстовый узел селектором не спрятать. Отсюда требование к разметке: **текст в сайдбаре всегда оборачивать в элемент** (`<span>`, `<strong>`), а не оставлять голым рядом с иконкой. Тогда обёртка попадёт под правило и скроется. Собственные части шелла это уже соблюдают.
|
||||
|
||||
Проверка на 900px, которая ловит нарушение:
|
||||
|
||||
```js
|
||||
[...document.querySelectorAll('.kt-ai-sidebar *')]
|
||||
.filter(e => e.textContent.trim()
|
||||
&& e.getBoundingClientRect().width < 24
|
||||
&& getComputedStyle(e).display !== 'none').length === 0
|
||||
```
|
||||
|
||||
Собственные части app-shell (например блок тура `.sb-tour`) закрывают рейл своим правилом: `kt-ai-components.css` лежит в `@layer`, а стили шелла беcслойные, и беcслойное правило побеждает слоёное независимо от специфичности.
|
||||
|
||||
Три состояния отзывчивости – docs/DESIGN.md «Отзывчивость». Активный пункт: `bg-active` + 2px-акцент слева.
|
||||
|
||||
## Поиск и command palette
|
||||
|
||||
Топ-поиск – компактный command-вход: высота 28px, max-width 320px, placeholder короткий («Поиск…»), бейдж `⌘K` в `.kt-ai-kbd`. Открывает command palette поверх экрана (`.kt-ai-command-overlay`).
|
||||
|
||||
## Карточки
|
||||
|
||||
Карточки информационные, не декоративные. Использовать только когда элемент – повторяемый объект, стартовое действие, превью или поверхность modal/drawer.
|
||||
|
||||
- v2.3: фон/рамка/тень – только через `card-bg / card-border / card-shadow` токены (dark: hairline + aurora-свечение; light: белая поверхность + тень). Радиус — `radius-3xl`, паддинг 12–14px.
|
||||
- Усиленное свечение hero/AI-поверхностей: `data-glow="strong"`.
|
||||
- Карточку в карточку не вкладывать.
|
||||
|
||||
## Ряды
|
||||
|
||||
Ряды – доминирующий паттерн каталогов (вместо крупных карточек).
|
||||
|
||||
- Основная подпись 13px; метаданные 11–12px muted.
|
||||
- Иконка 16px слева (14px в плотных рядах).
|
||||
- Справа – счётчик, статус или скор; действия (`.actions`) видимы на hover/focus/selected.
|
||||
- Hover `bg-hover`, выбран `bg-active`. Зона клика – весь ряд.
|
||||
|
||||
## Таблицы
|
||||
|
||||
Таблица – для любого workflow с 5+ записями со статусом, владельцем, сроком или суммой.
|
||||
|
||||
Принципы (research-backed):
|
||||
|
||||
- Таблице – максимум полезной ширины (`content-table`); не заворачивать в карточку.
|
||||
- Колонки и дефолтная сортировка – от задачи пользователя, не от схемы БД.
|
||||
- Заголовки 1–2 слова; видимы только task-critical колонки, остальное – в detail/настройки колонок.
|
||||
- Один тип значения на колонку. Главная entity-колонка – максимум две строки: label + muted-метаданные.
|
||||
- Текст и даты – влево; суммы, скоры, счёт – вправо (sans + tabular-nums); заголовок выравнен со своими данными; центру – нет.
|
||||
- Лёгкие горизонтальные dividers; вертикальных колонок-сепараторов нет (ElevenLabs, тихо/воздушно); зебра – только в read-only числовых таблицах. Шапка на лёгком divider (не border-strong). Высоты рядов — `--kt-ai-table-row*`, значения в `tokens.json`. Чекбоксы первой колонки скрыты до hover ряда или выбора (не загромождают).
|
||||
- Hover ряда включён всегда (помогает сканировать), даже если ряд некликабелен.
|
||||
- Sticky header при вертикальном скролле; sticky первая колонка при горизонтальном (`.kt-ai-sticky-first`).
|
||||
- Row actions – только на hover/focus/selected (на touch – всегда); bulk-bar появляется после выбора 1+ рядов; дефолт – ноль выбранных.
|
||||
- Первый ряд не предвыбирать. Подсветка совпадений при активном поиске.
|
||||
- Пагинация/итог в футере; дефолт 25 строк; loading – skeleton-ряды; пусто – `.kt-ai-empty`.
|
||||
- Настройки пользователя (колонки, сортировка, фильтр) живут в течение сессии + «сбросить по умолчанию».
|
||||
|
||||
Геометрия (v2.3): таблица по умолчанию frameless full-bleed (`data-framed="true"` – контейнер с рамкой); header sticky, 12px sentence-case medium fg-muted, hairline border-strong снизу; ячейки 12px, entity 13px/500; статус – rounded-rect тег (radius-tag); вертикальное выравнивание middle до 3 строк.
|
||||
|
||||
Toolbar: высота ≥40px; слева – имя таблицы/saved view/счётчик, справа – фильтр, сортировка, колонки, экспорт (≤4 действий); iconбуттоны из спрайта; saved views скрыты, пока их меньше двух. Применённые фильтры – `.kt-ai-filterbar` под тулбаром.
|
||||
|
||||
Mobile: скролл внутри контейнера, не страницы; первая колонка sticky; прячутся сначала вторичные колонки (источник, заказчик, владелец), потом суммы/сроки; футер компактный («1–25 из N»).
|
||||
|
||||
Dashboard-паттерн: KPI-полоса над таблицей только если помогает пользователю принять следующее рабочее решение по рядам; одна таблица – главный объект; детали – в правом drawer.
|
||||
|
||||
Operational dashboard copy:
|
||||
- Заголовок экрана – короткое имя рабочего объекта, 1-3 слова: `Очередь рисков`, `Реестр заявок`, `Проверка договоров`. Не использовать длинное описание процесса как H1/topbar title.
|
||||
- Subtitle объясняет scope одной короткой фразой, до 90 символов. Детали процесса, источники данных и ограничения – в drawer/help, не в H1.
|
||||
- KPI над таблицей показывают состояние работы пользователя: новые, требуют решения, риски, ждут документы, просрочено, закрыто сегодня, сумма риска или SLA. Не выводить экономию часов, FTE, ROI или тенге эффекта на пользовательский dashboard, если это не management/reporting view.
|
||||
- Деньги допустимы в KPI только когда это рабочий объект пользователя: `сумма риска`, `расхождение`, `к оплате`, `заблокировано`. `Экономия`, `эффект`, `ROI`, `FTE` – метрики проекта, а не основной рабочей очереди.
|
||||
- Фильтры над таблицей – только по workflow-status главного объекта, максимум 5 пунктов включая `Все`. Confidence, тип риска, источник данных и technical labels не попадают в primary filters по умолчанию.
|
||||
- Workflow-status, confidence и risk type – разные поля. Нельзя смешивать их в один набор `statuses`, иначе экран перегружает фильтры и ломает decision focus.
|
||||
- Labels короткие: фильтр до 2 слов, status-pill до 4 слов, table header до 2 слов. Длинная причина риска уходит в drawer или отдельную колонку `Расхождение` с коротким текстом.
|
||||
- Для process-owner экран должен показывать next action: что требует решения сегодня, что заблокировано, где риск, какой документ нужно дослать, что закрыто. Просто список документов без статуса работы – неполный dashboard.
|
||||
|
||||
## Подтверждение опасных действий
|
||||
|
||||
Три ступени по цене ошибки, а не по «важности»:
|
||||
|
||||
| Что за действие | Чем подтверждается |
|
||||
|---|---|
|
||||
| Обратимое, но раздражающее | выполнить сразу + тост с «Отменить» (`.kt-ai-toast .action`) |
|
||||
| Деструктивное | модалка; кнопка — глаголом действия («Удалить агента»), не «Да» и не «ОК» |
|
||||
| Необратимое | ввод имени объекта |
|
||||
|
||||
**Удержание кнопки («hold to delete») в систему не берём.** Разбор, чтобы к этому не возвращались:
|
||||
|
||||
- **Недоступно с клавиатуры.** У удержания мыши нет клавиатурного эквивалента; накрутить удержание пробела можно, но это конфликтует со штатным «пробел нажимает кнопку» и остаётся неочевидным. Любая доступная реализация требует запасного пути — и тогда на одно действие приходится два механизма.
|
||||
- **Ничего не сообщает.** Модалка говорит, ЧТО удаляется, сколько записей и что от них зависит. Кольцо прогресса не говорит ничего — а в наших продуктах (закупки, договоры, инциденты) именно последствие и нужно видеть.
|
||||
- **Не открывается само.** Чтобы понять, что кнопку надо держать, надо сначала прочитать подпись.
|
||||
- **Ломается на тач-устройствах**, где долгое нажатие занято системным контекстным меню и выделением текста.
|
||||
|
||||
Ниша, на которую он метит — «действие жалко закрывать модалкой» — у нас уже закрыта отменой в тосте, и закрыта лучше: ноль трения до действия и полное восстановление после.
|
||||
|
||||
## Композер чата
|
||||
|
||||
`.kt-ai-prompt-bar` — `textarea` + кнопка отправки. Над полем может стоять ряд пилюль навыков `.kt-ai-prompt-skills`:
|
||||
|
||||
```html
|
||||
<div class="kt-ai-prompt-bar">
|
||||
<div class="kt-ai-prompt-skills">
|
||||
<span class="kt-ai-prompt-skill">Сверка документов<button aria-label="Убрать навык">×</button></span>
|
||||
</div>
|
||||
<textarea rows="1" placeholder="Спросите…"></textarea>
|
||||
<button class="kt-ai-prompt-send">…</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
Выбранный навык — **объект, а не текст**: его нельзя случайно надкусить бэкспейсом, он удаляется целиком. Пустой ряд схлопывается и не занимает высоту.
|
||||
|
||||
**Почему пилюли над полем, а не внутри строки.** Вставить невредактируемый элемент в середину текста можно только в `contenteditable`. Переписывать композер на `contenteditable` ради этого нельзя: ломаются отмена ввода, набор через IME, вставка из буфера, доступность и мобильные клавиатуры. Пилюли над полем дают то же свойство без единого из этих рисков.
|
||||
|
||||
**Slash-команды.** Скрипт `kt-ai-composer.js` включается атрибутом `data-composer="true"`, список навыков — в `data-skills` (JSON: `id`, `label`, `hint`):
|
||||
|
||||
- «/» в начале слова открывает меню, дальнейший набор фильтрует по названию и подсказке;
|
||||
- ↑ ↓ ходят по списку, Enter или Tab выбирают, Esc закрывает;
|
||||
- при выборе «/запрос» **убирается из текста** — навык теперь объект, а не строка;
|
||||
- крестик на пилюле снимает навык, Backspace в пустом поле снимает последний;
|
||||
- Enter без Shift отправляет: событие `composer:submit` с `{ text, skills }`.
|
||||
|
||||
Меню открывается **над** полем: композер обычно стоит внизу экрана, и список, растущий вниз, уехал бы за край.
|
||||
|
||||
**Каретка при вставке из меню.** Специального механизма не нужно: `textarea.selectionStart` переживает потерю фокуса, поэтому вставлять надо в `selectionStart`, а не в конец. Проверено измерением.
|
||||
|
||||
## Кружок с номером
|
||||
|
||||
Шаг мастера, позиция в списке, порядковый номер.
|
||||
|
||||
```html
|
||||
<span class="kt-ai-num-badge">1</span>
|
||||
<span class="kt-ai-num-badge" data-state="current">3</span>
|
||||
<span class="kt-ai-num-badge" data-size="lg" data-tone="risk">8</span>
|
||||
```
|
||||
```tsx
|
||||
<KTNumBadge value={3} state="current" label="шаг 3 из 5" />
|
||||
```
|
||||
|
||||
Состояния: по умолчанию (будущий), `done` (пройден — тише текущего, но не тусклее будущих), `current` (заливка: он один на экране и обязан находиться мгновенно). Тона `ok / warn / risk` — когда номер сам несёт оценку. Размеры `sm` 18px, обычный 22px, `lg` 28px.
|
||||
|
||||
**Почему это не иконка.** Наши иконки штриховые (1.75 в сетке 24) и используются на 14–15px. Цифра штрихом внутри штриховой окружности на таком размере выходит 6–7px высотой при штрихе около 1px — просветы у 6, 8 и 9 схлопываются. Плюс набор иконок был бы конечным (1–9), а номера бывают двузначные. Номер рисуется текстом: любое число, чёткость на любом размере, цвет и состояния от токенов.
|
||||
|
||||
Цифры моноширинные — в колонке номеров ряды не пляшут.
|
||||
|
||||
Степпер (`.kt-ai-step-item .n`) — тот же кружок; отдельного определения у него нет, иначе они разъедутся.
|
||||
|
||||
### Номер иконкой
|
||||
|
||||
Когда номер стоит в ряду с другими иконками — `circleNumber0` … `circleNumber9`:
|
||||
|
||||
```html
|
||||
<svg class="kt-icon"><use href="#circleNumber3"></use></svg>
|
||||
```
|
||||
```tsx
|
||||
<KTIcon name="circleNumber3" size={15} />
|
||||
```
|
||||
|
||||
Набор взят из [Tabler Icons](https://tabler.io/icons) (MIT) и приведён к нашей сетке: радиус окружности 10 вместо 9, штрих 1.75 вместо 2 — чтобы в ряду с `check-circle` они были одного размера и веса.
|
||||
|
||||
**Иконка или компонент.** Иконка — фиксированные 0–9 и один цвет, зато встаёт в любой поток иконок. Компонент `.kt-ai-num-badge` — любое число, включая двузначные, состояния и заливка активного. Нужен номер шага в мастере — компонент; нужен номер рядом с иконкой в строке — иконка.
|
||||
|
||||
## Орб агента
|
||||
|
||||
Монохромное точечное облако на canvas — индикатор того, что агент работает. Заменяет спиннер там, где ожидание содержательное, и стоит в кнопке помощника вместо подписи «AI».
|
||||
|
||||
```html
|
||||
<canvas class="kt-ai-orb" data-state="thinking" style="width:20px;height:20px"></canvas>
|
||||
<script src="kt-ai-orb.js"></script>
|
||||
```
|
||||
```tsx
|
||||
import { KTAIOrb } from "kt-ai-design-system/kit";
|
||||
<KTAIOrb state="thinking" size={20} />
|
||||
```
|
||||
|
||||
Состояния: `idle` (медленное дыхание), `thinking` (вращение), `listening` (пульс). Размеры — 20px в кнопке и строке, 64px как крупный индикатор.
|
||||
|
||||
**Цвет не задаётся.** Точки рисуются `currentColor`, поэтому орб темизуется теми же токенами, что и текст рядом, и работает в обеих темах без единого правила про тему. Хотите тише — поставьте `color: var(--kt-ai-fg-faint)`.
|
||||
|
||||
Рисование живёт в одном файле `kt-ai-orb.js` на оба рантайма: React-компонент его подключает, а не дублирует. Иначе HTML-превью и React-сборка разошлись бы в анимации — это запрещает гейт паритета G7.
|
||||
|
||||
Из этого следует поставка: `kt-ai-orb.js` входит в бандл кита как `public/kt-ai-orb.js` — компонент запрашивает его у сайта по абсолютному пути. Без файла кнопка помощника рисует пустой кружок, и ни сборка, ни типы об этом не скажут: проверяет гейт 15 в `doctor.py`.
|
||||
|
||||
Первый кадр рисуется синхронно, до `requestAnimationFrame`: в фоновой вкладке браузер rAF не вызывает, и орб оставался бы пустым прямоугольником. По той же причине при `prefers-reduced-motion` остаётся статичный кадр, а не пустота.
|
||||
|
||||
Идея заимствована у [thinking-orbs](https://orbs.jakubantalik.com) (MIT). Сам пакет не подошёл: его сборка импортирует React на верхнем уровне, а HTML app-shell работает без React.
|
||||
|
||||
## Плашка показателя
|
||||
|
||||
Четыре уровня: подпись, значение, уточнение, сноска.
|
||||
|
||||
```json
|
||||
{ "label": "Просрочено в июне", "value": "58", "hint": "из 171" }
|
||||
```
|
||||
```html
|
||||
<div class="kt-ai-kpi">
|
||||
<span class="value">58</span><span class="label">Просрочено в июне</span>
|
||||
<span class="hint">из 171</span>
|
||||
</div>
|
||||
```
|
||||
```tsx
|
||||
<KTKpiCard label="Просрочено в июне" value="58" hint="из 171" />
|
||||
```
|
||||
|
||||
**Значение — само число, уточнение — знаменатель, доля или период.** Не «58 из 171» одной строкой: полоса перестаёт читаться как ряд величин, глаз ищет число, а находит фразу. Уточнение до 40 символов.
|
||||
|
||||
**Значение идёт сразу за подписью, а не прижимается к низу карточки.** Иначе в соседних карточках — с уточнением и без — числа встают на разной высоте и кажутся разными по важности. Проверяется измерением: у всех плашек полосы `value` обязан быть на одной высоте.
|
||||
|
||||
Уровень поддержан во всех трёх местах: поле `hint` в `product.schema.json`, вывод в HTML-рантайме и в `KTKpiCard`. Компонент рисуется классом `.kt-ai-kpi` в обоих рантаймах — своих утилит у React-версии нет, иначе правка в ДС до неё не доезжала бы.
|
||||
|
||||
### Сноска: чем число НЕ является
|
||||
|
||||
`hint` отвечает «из чего считано» и потому ограничен 40 знаками — снимать этот потолок нельзя, иначе полоса плиток перестанет быть полосой. Но у поля-источника бывает вторая правда: оно меряет не ровно то, чем показатель назван. «Ожидание в очереди» не отделяет автоприветствие бота от первого сообщения оператора — число верное, а работой человека не является. Это другой вопрос, и отвечает на него отдельное поле:
|
||||
|
||||
```json
|
||||
{ "label": "Ожидание в очереди", "value": "00:41", "hint": "из 33 352 диалогов",
|
||||
"note": "Поле выгрузки считает время до первого сообщения в чате, а им бывает автоприветствие бота. Число верное, но работой оператора оно не является." }
|
||||
```
|
||||
```html
|
||||
<span class="note">Поле выгрузки считает время до первого сообщения…</span>
|
||||
```
|
||||
```tsx
|
||||
<KTKpiCard label="Ожидание в очереди" value="00:41" hint="из 33 352 диалогов" note="Поле выгрузки считает…" />
|
||||
```
|
||||
|
||||
**Потолка у сноски нет** — она не стоит в потоке полосы, её отбивает линия. **И она не прячется под клик.** Оговорка существует затем, чтобы число не прочитали неверно; спрятанное под клик читают не все — ровно как карточку строки, из-за чего оговорку и потребовалось ставить у плитки. По той же причине у неё цвет `fg-muted`, а не самый тихий `fg-faint`: 4,9:1 на кегле 11px — это «тихо» на грани нечитаемого, а сноску надо прочитать.
|
||||
|
||||
Куда сноска НЕ идёт: в баннер. Баннер стоит над числами, и оговорки, собранные в него, дают абзац текста до первого числа (замеряли: 716 знаков на одном экране). Баннер предупреждает, из-за чего число соврёт; сноска объясняет, что число значит; вывод раздела — что из чисел следует. Три разных места.
|
||||
|
||||
## Вывод раздела
|
||||
|
||||
Последний блок экрана, после таблицы. Контракт: `conclusion: { title?, text }`, пустая строка в `text` делит абзацы.
|
||||
|
||||
```html
|
||||
<section class="kt-ai-conclusion">
|
||||
<span class="title">Что показывают эти числа</span>
|
||||
<span class="text">Средняя считается только по ответившим…</span>
|
||||
<span class="text">Смотреть стоит на долю дольше норматива…</span>
|
||||
</section>
|
||||
```
|
||||
|
||||
**Это противовес баннера, а не его вариант.** Баннер стоит НАД числами — значит, читается до данных, и потому окрашен тоном: он предупреждает. Вывод стоит ПОСЛЕ данных и говорит, что из них следует: разбор смещения выборки, оговорка о методике, ответ на вопрос раздела. Цвета статуса у него нет — сигналить ему нечем; есть линия сверху («данные кончились, начинается их чтение») и мера строки 78ch, потому что вывод — сплошной текст, а не пары «подпись — значение».
|
||||
|
||||
Рисуется одинаково во всех архетипах, кроме `conversational`: там контент владеет всей областью сам.
|
||||
|
||||
## Статика под префиксом развёртывания
|
||||
|
||||
Приложение живёт не только в корне домена. Под `/cons` файл `/kt-ai-orb.js`
|
||||
уходит в корень САЙТА и не находится: пропадают спрайт иконок, орб, подсказка
|
||||
диаграммы и режим отзыва — тихо, только на развёртывании.
|
||||
|
||||
```tsx
|
||||
import { ktAsset, ktSetBasePath } from "kt-ai-design-system/kit";
|
||||
|
||||
ktSetBasePath("/cons"); // явно, до первого рендера
|
||||
<img src={ktAsset("/img/shema.png")} alt="" />
|
||||
fetch(ktAsset("/api/proposals")); // свои маршруты ломаются так же
|
||||
```
|
||||
|
||||
Префикс — свойство развёртывания, а не аргумент вызова, поэтому задаётся один
|
||||
раз: `ktSetBasePath()` → `process.env.NEXT_PUBLIC_BASE_PATH` → атрибут
|
||||
`<html data-kt-ai-base>` → пусто. Порядок и почему он такой — `docs/SETUP.md`.
|
||||
|
||||
В HTML-рантайме то же самое даёт `window.ktAiAsset("kt-ai-orb.js")`, а префикс
|
||||
приходит атрибутом на `<html>`. Теги статики там пишет загрузчик, а не
|
||||
разметка: адрес уже разобранного `<link>` изменить нельзя — браузер начинает
|
||||
грузить его сразу.
|
||||
|
||||
Гейт 18 (`doctor.py`) считает путь от корня сайта мимо `ktAsset()` ошибкой
|
||||
сборки ДС — в обоих рантаймах.
|
||||
|
||||
## Чарты
|
||||
|
||||
Высота 190–220px; bar 20–28px, радиус 2px; подписи значений 11px mono muted. Остальные правила – docs/DESIGN.md «Чарты».
|
||||
|
||||
## Счётчик в пункте меню
|
||||
|
||||
Одно значение — как раньше, приглушённым тоном:
|
||||
|
||||
```tsx
|
||||
{ label: "Проекты", icon: "lots", count: "12" }
|
||||
```
|
||||
|
||||
Прогресс — пара «всего / сделано». «Сделано» рисуется тоном `ok`, и по пункту меню видно не только объём работы, но и продвижение:
|
||||
|
||||
```tsx
|
||||
{ label: "Спецификации", icon: "file", count: { value: 11, done: 5 } }
|
||||
```
|
||||
```html
|
||||
<span class="kt-ai-count-split">11<span class="sep">/</span><span class="done">5</span></span>
|
||||
```
|
||||
|
||||
Одним цветом «11/5» читается как одно число с косой чертой — именно поэтому продукты подставляли вместо счётчика свою строку. Класс `.kt-ai-count-split` не привязан к меню: пара «всего/сделано» уместна и во вкладке, и в заголовке секции.
|
||||
|
||||
Поле `nav[].count` в продуктовом контракте — устаревшее и одночисловое; пара живёт в API кита, а не в контракте.
|
||||
|
||||
## Поиск в шапке
|
||||
|
||||
Два размера и один переключатель подсказки сочетания.
|
||||
|
||||
```tsx
|
||||
<KTAIShell search={{ items, placeholder: "Договор, филиал…", compact: true }}>
|
||||
```
|
||||
```json
|
||||
"search": { "placeholder": "Договор, филиал…", "compact": true }
|
||||
```
|
||||
```html
|
||||
<div class="kt-ai-search-field" data-shortcut="true">
|
||||
<input class="kt-ai-search" placeholder="Поиск…" title="Поиск (⌘K)">
|
||||
<kbd class="kt-ai-kbd">⌘K</kbd>
|
||||
</div>
|
||||
```
|
||||
|
||||
`compact` — 176px вместо 320px: шапка отдаёт место остальному. Бейдж в
|
||||
компактном поле не показывается — это ширина запроса, а не запроса с подписью;
|
||||
сочетание при этом работает и названо в `title`.
|
||||
|
||||
**Сочетание и его подпись включаются ОДНИМ полем** (`shortcut`). Подпись без
|
||||
обработчика — обещание (правило 9d); обработчик без подписи — незаявленная
|
||||
горячая клавиша, которую пользователь находит случайно. Порознь их выключать
|
||||
нечем и незачем.
|
||||
|
||||
**Подпись называет клавишу этой машины:** `⌘K` на Apple, `Ctrl K` на остальных.
|
||||
Обработчик слушает и `meta`, и `ctrl` — значит подпись «⌘K» на Windows была не
|
||||
сокращением, а неверным именем работающей клавиши. В React подпись считается
|
||||
после монтирования: на сервере `navigator` нет, и вычисленная там подпись
|
||||
разошлась бы с клиентской (гидрация с предупреждением).
|
||||
|
||||
**Подсказка сочетания — элемент, а не текст в placeholder.** Пока «⌘K» стояло
|
||||
строкой внутри «Поиск… ⌘K», её нельзя было ни убрать, ни перевести, ни
|
||||
отличить от собственно подсказки поля. Гейт 19 держит это правило.
|
||||
|
||||
Голый `.kt-ai-search` без обёртки продолжает работать — им набраны фильтры над
|
||||
таблицами, у которых никакого сочетания нет.
|
||||
|
||||
## Группа разделов в меню
|
||||
|
||||
Тринадцать пунктов подряд читаются как список ссылок, а не как устройство продукта. Необязательное поле `group` собирает идущие подряд пункты под общим заголовком:
|
||||
|
||||
```json
|
||||
"nav": [
|
||||
{ "label": "Обзор", "group": "Поток" },
|
||||
{ "label": "Нагрузка", "group": "Поток" },
|
||||
{ "label": "Сверки", "group": "Достоверность" },
|
||||
{ "label": "Контроль Alem","group": "Достоверность" }
|
||||
]
|
||||
```
|
||||
|
||||
**Заголовок — не пункт.** Он не кликается, не получает фокус и в нумерации разделов не участвует: переход идёт по индексу пункта в `nav`, поэтому `banner.actionNav` и `S.nav(i)` считают ровно то же, что считали без групп. Пункты без `group` идут списком, как раньше.
|
||||
|
||||
Заголовок собирает пункты, идущие ПОДРЯД. Разнесённые по списку пункты с одним значением дадут два одинаковых заголовка — валидатор об этом предупреждает, а рантайм не переставляет разделы: их порядок принадлежит продукту.
|
||||
|
||||
В полосе значков (832–1023px) слов показать негде, и заголовок становится тем, чем является по сути, — тонким разделителем между группами. Ниже 832px sidebar превращается в выдвижную панель во всю ширину, и заголовки снова показываются словами.
|
||||
|
||||
Сворачивания групп нет намеренно: свёрнутая группа прячет разделы, которые продукт обязан показывать, — в том числе пустые.
|
||||
|
||||
## Стек, который группирует
|
||||
|
||||
`.kt-ai-v` — вертикальный стек вместо `marginTop` у каждого потомка. Кроме сырых ступеней (`xs/sm/lg/xl` → `space-*`) у него есть **ступени ритма**, и на экране пользоваться нужно именно ими:
|
||||
|
||||
| `data-gap` | | Что этим сказано |
|
||||
|---|---|---|
|
||||
| `tight` | 12 | это одна вещь: фильтры и их вкладки, карточка ↔ карточка в списке |
|
||||
| `block` | 16 | панель управления и то, чем она управляет; блоки одного региона |
|
||||
| `group` | 24 | регионы экрана: шапка ↔ баннер ↔ содержимое |
|
||||
| `region` | 32 | поля рабочей области, отрыв от топбара |
|
||||
|
||||
Ступени — у `.kt-ai-h-stack` тоже (`tight`, `block`).
|
||||
|
||||
**Один зазор на весь корневой столбец — самая частая ошибка компоновки.** Тогда панель фильтров, её вкладки и карточки списка расходятся на одно и то же расстояние: групп на экране нет, есть столбец одинаково далёких полос. Вкладывай стек в стек — регионы снаружи, группа внутри:
|
||||
|
||||
```html
|
||||
<div class="kt-ai-v" data-gap="group"> <!-- регионы: 24 -->
|
||||
<div class="kt-ai-kpi-strip" data-cards="true">…</div>
|
||||
<div class="kt-ai-v" data-gap="block"> <!-- панель + её содержимое: 16 -->
|
||||
<div class="kt-ai-toolbar"> <!-- фильтры и вкладки — одна вещь: 12 -->
|
||||
<div class="kt-ai-filterbar">…</div>
|
||||
<div class="kt-ai-filterbar">…</div>
|
||||
</div>
|
||||
<div class="kt-ai-v" data-gap="tight">…карточки списка…</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
`.kt-ai-toolbar` — та же ступень `tight`, названная по роли: панель шага. Отдельный класс, потому что панель повторяется на каждом экране-очереди, и без имени её собирали своей обёрткой в каждом продукте.
|
||||
|
||||
**Вертикальный `padding` `.kt-ai-filterbar` внутри стека снимается** (`.kt-ai-v > .kt-ai-filterbar`, `.kt-ai-toolbar > .kt-ai-filterbar`): иначе собственные 6px бара складывались бы со ступенью и `tight` давал бы на экране 24 вместо 12 — ступень называла бы одно, а показывала другое. Вне стека padding остаётся: там он отделяет бар от соседнего содержимого.
|
||||
|
||||
Свой `app-stack-*` или своя обёртка панели в продукте — сигнал, что группировку не выразили, а обошли.
|
||||
|
||||
## Ряд, которому запрещено переноситься
|
||||
|
||||
`.kt-ai-h-stack` по умолчанию переносится. Запрет переноса — два разных значения, и разница не косметическая:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `data-nowrap="true"` | ряд не переносится. Что делать с переполнением — забота автора. В узкой колонке такой ряд выходит за родителя и толкает горизонтальную прокрутку **всей страницы** |
|
||||
| `data-nowrap="scroll"` | ряд не переносится и прокручивает себя сам |
|
||||
|
||||
Почему `true` не сделали прокручиваемым молча: `overflow-x: auto` по спецификации вынуждает `overflow-y` стать `auto` тоже — ряд начинает обрезать всё, что вылезает вверх и вниз: меню, поповер, подсказку, кольцо фокуса. Ряд с кебаб-кнопкой сломался бы, и виновника было бы не найти. Поэтому выбор явный и по имени.
|
||||
|
||||
Прокрутка широкой **таблицы** — не этот случай: ей занимается `.kt-ai-table-wrap`.
|
||||
|
||||
## Сенсорная цель
|
||||
|
||||
На ширине ≤768px (или при `pointer: coarse`) ни один интерактивный элемент не ниже **44px** — минимальной цели, в которую попадают пальцем. Порог хранится в одном месте, токене `--kt-ai-control-h-touch`, и раздаётся правилом в конце `kt-ai-components.css`.
|
||||
|
||||
Своими руками высоты поднимать не нужно — нужно попасть под правило:
|
||||
|
||||
- компоненты ДС — через классы `.kt-ai-*` (высота приходит из `--kt-ai-control-h-*`, которые на сенсорной ширине сами становятся 44px);
|
||||
- разметка React-кита — через `[data-kt-ai-kit]`, метку поддерева, которую ставит `KTAIShell`: кит размечен утилитами Tailwind, и порог на каждой из ~70 кнопок означал бы 70 мест, где его можно забыть.
|
||||
|
||||
Контрол, который обязан быть меньше, помечается `data-kt-touch="off"` — явным отказом. Молча меньше быть нельзя: цена промаха по «Принять»/«Отклонить» — чужое решение.
|
||||
|
||||
Ширина при этом остаётся по содержимому: растягивать пилюли фильтра по горизонтали значит ломать плотность. Квадратную цель (иконка, чекбокс) правило задаёт отдельно.
|
||||
|
||||
## Рама приложения: контрол появляется вместе со смыслом
|
||||
|
||||
`KTAIShell` не рисует того, что не умеет. Кнопки поиска и профиля появляются, только когда продукт дал им содержание, — иначе их нет:
|
||||
|
||||
```tsx
|
||||
<KTAIShell
|
||||
productName="Консолидация закупок"
|
||||
search={{ items: команды, placeholder: "Найти проект", onQueryChange: искать }}
|
||||
user={{ name: "Ержан Асанов", email: "e@kt.kz" }}
|
||||
userMenu={[{ label: "Настройки", onSelect: открытьНастройки }]}
|
||||
onSignOut={выйти}
|
||||
notifications={[{ text: "Запуск завершён", time: "10:24", unread: true, onSelect: открыть }]}
|
||||
showTourCard={false}
|
||||
/>
|
||||
```
|
||||
|
||||
Почему так, а не «покажем, потом подключим»: кит вшивал три собственных пункта палитры и меню профиля из четырёх строк — ни у одной не было обработчика. Продукт починить раму не может, он может только заклеить её своим `display: none`, и оба продукта так и сделали (заявка DS-005). Правило компоновки 9d говорит прямо: сломанный или декоративный контрол показывать нельзя. Проверяет гейт 17.
|
||||
|
||||
Аватар берёт инициалы из имени (`ktInitials`), уведомление становится кнопкой только при `onSelect`, «перейти» в «Активности» — только при `href`/`onSelect`.
|
||||
|
||||
**Помощник — по тому же правилу, с 6.8.0.** Он был единственным исключением:
|
||||
кнопка рисовалась всегда и била в `/api/ai/chat` — маршрут-заглушку из бандла
|
||||
ДС, отвечавшую текстом для разработчика. Специалист закупок читал «Mock AI
|
||||
response… Подключите реальный LLM provider», а продукт прятал кнопку своим CSS
|
||||
— ровно то, что канон себе уже запретил.
|
||||
|
||||
```tsx
|
||||
// отвечает маршрут продукта
|
||||
<KTAIShell assistant={{ apiPath: ktAsset("/api/ai/chat"), suggestions: ["Что изменилось?"] }} … />
|
||||
|
||||
// отвечает контракт — как в HTML-рантайме, сети не нужно
|
||||
<KTAIShell assistant={{ answers: { "Почему флаг?": "Тариф ниже SAP." }, fallback: "Ответа пока нет." }} … />
|
||||
```
|
||||
|
||||
Без пропа `assistant` кнопки помощника нет. Пропы `aiContext` и `aiSuggestions`
|
||||
сняты: ни один из них не говорил, что помощник умеет отвечать. `KTScreen`
|
||||
собирает `assistant` из блока `ai` контракта сам — экран из контракта получает
|
||||
работающего помощника, а не заглушку.
|
||||
|
||||
Клиент принимает и `reply`, и `answer`: маршрут в бандле отдавал `answer`, а
|
||||
кит читал только `reply` — помощник не показал бы ответ собственной заглушки ни
|
||||
разу.
|
||||
|
||||
## Раскрытие без JS
|
||||
|
||||
`.kt-ai-disclosure` — на div-ах и требует скрипта. Серверной странице нужен нативный `<details>`, состояние которого хранит браузер:
|
||||
|
||||
```html
|
||||
<details class="kt-ai-details">
|
||||
<summary>Подробности обработки<svg class="kt-icon kt-icon-sm kt-ai-details-chevron">…</svg></summary>
|
||||
<div>Прочитано 1 284 строки, пригодных 1 191.</div>
|
||||
</details>
|
||||
```
|
||||
|
||||
Маркер ОС погашен в обеих записях (`::marker` и `::-webkit-details-marker`), шеврон крутится трансформом. Одиночный `<summary>` внутри оболочки тоже получает `cursor: pointer` — курсор единственный признак, по которому видно, что строка раскрывается. Оба продукта написали это себе сами, каждый по-своему; теперь это канон.
|
||||
|
||||
## Подвал действий боковой панели
|
||||
|
||||
```tsx
|
||||
<KTRightDrawer title="Позиция 42" actions={<><button className="kt-ai-btn" data-variant="primary">Принять</button><button className="kt-ai-btn">Отклонить</button></>} … />
|
||||
```
|
||||
|
||||
`.kt-ai-drawer-actions` липнет ко дну панели: разбор длинный, а решение принимают внизу — без этого «Принять» и «Отклонить» уезжают за экран и специалист скроллит обратно на каждой карточке.
|
||||
|
||||
## Подсказка значения на диаграмме
|
||||
|
||||
Столбец, точка или сегмент показывает своё число при наведении, с клавиатуры и по касанию:
|
||||
|
||||
```html
|
||||
<div class="cc-bar" tabindex="0" data-kt-tip='{"title":"14 мая 2026, четверг","rows":[{"label":"Диалогов","value":"1 284"},{"label":"Доля периода","value":"3,9%"}]}'></div>
|
||||
<div class="cc-bar" tabindex="0" data-kt-tip="15.05.2026: 948"></div>
|
||||
```
|
||||
```tsx
|
||||
{ label: "14.05.2026", value: 1284, hint: { title: "14 мая 2026, четверг", rows: [{ label: "Диалогов", value: "1 284" }] } }
|
||||
```
|
||||
|
||||
**Числа даёт продукт.** `hint` уходит в разметку как есть: рантайм ничего не форматирует и не пересчитывает. Сервер и браузер округляют по-разному, и разошедшийся формат — это разошедшееся число. Без `hint` показывается `label: value` — то же, что показывал нативный `title`.
|
||||
|
||||
Почему не `title`: он появляется через секунду, рисуется средствами ОС (чужой шрифт, ни одного токена, в тёмной теме — вставка из чужого продукта), недоступен с клавиатуры, не читается диктором, на планшете не показывается вовсе и умеет одну строку. Почему не `.kt-ai-tooltip`: та однострочная и у края карточки обрезается.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| марка | любой элемент с `data-kt-tip` — столбец, точка SVG, сегмент donut, клетка будущей тепловой карты |
|
||||
| появление | сразу, без паузы; привязано к марке, а не к курсору |
|
||||
| край | подсказка разворачивается внутрь и не обрезается |
|
||||
| клавиатура | `tabindex="0"`, показ по фокусу, `Esc` убирает НЕ снимая фокус |
|
||||
| диктор | `role="tooltip"` + `aria-describedby` на марке |
|
||||
| тач | касание показывает, касание вне — убирает |
|
||||
| строк | заголовок + до трёх пар; значения справа, `tabular-nums` |
|
||||
| перерисовка | переживает: слушатели делегированы на `document`, не на марки |
|
||||
|
||||
Рисование — `kt-ai-chart-tip.js`, один файл на оба рантайма (как орб). В бандле кита лежит как `public/kt-ai-chart-tip.js`; гейт 15 следит, чтобы доехал.
|
||||
|
||||
## Ширина свободного экрана
|
||||
|
||||
```json
|
||||
{ "width": "full" } // общая ширина продукта
|
||||
{ "width": "reading" } // мера чтения сплошного текста, ~720px
|
||||
```
|
||||
|
||||
Без поля ширину определяют типы секций: экран только из текстовых — `reading`, любой другой — `full`. **Секция `fields` считается НЕ текстом**: пары «подпись — значение» и таблица критериев на 720px читаются хуже, чем на общей ширине, а заголовок экрана всё равно идёт во всю ширину — экран выглядел съехавшим к центру.
|
||||
|
||||
Поле нужно, чтобы продукт называл ширину сам, а не подбирал типы секций ради неё.
|
||||
|
||||
## Чипы и segmented
|
||||
|
||||
Чип (v2.3, Attio): rounded-rect `radius-tag` (5px), высота 20–24px, паддинг 8–10px, шрифт 11–12px, фон chip-токены. Segmented: сегмент 28px, активный – `bg-active` + `fg`.
|
||||
|
||||
## Drawer
|
||||
|
||||
Правый drawer для деталей с сохранением контекста списка.
|
||||
|
||||
- Ширина 420–560px (720px для сравнения evidence); < md – full-screen.
|
||||
- Header 48px: заголовок + крестик справа сверху; закрытие – Esc и клик мимо.
|
||||
|
||||
## v3.6
|
||||
|
||||
| Класс | Что это | Референс |
|
||||
|---|---|---|
|
||||
| `.kt-ai-slider` (+`.kt-ai-slider-row` с `.ends`) | слайдер настройки на `input[type=range]` (Speed/Stability) | ElevenLabs Settings-панель |
|
||||
| `.kt-ai-cols` | адаптивный двухколонник: складывается в одну колонку <720px (wizard/inbox/copilot). Прямым детям сам ставит `min-width: 0` — без этого `1fr` (= `minmax(auto,1fr)`) растягивается под содержимое и на телефоне даёт горизонтальную прокрутку страницы | — |
|
||||
| `.kt-ai-row-hover` / `.kt-ai-hover-lift` | hover-обратная связь рядов и кликабельных карточек | ElevenLabs rows |
|
||||
|
||||
---
|
||||
|
||||
## Компоновка экрана
|
||||
|
||||
Держать прототипы визуально консистентными и полезными разработчикам без тяжёлого процесса.
|
||||
|
||||
### Правила
|
||||
|
||||
1. Один главный рабочий объект на экран. В табличных продуктах главный объект – таблица.
|
||||
2. Не вкладывать карточки в карточки. Вторичные детали – ряды, dividers или drawer.
|
||||
3. Шапка приложения компактная. Никаких лендинг-hero внутри операционных инструментов.
|
||||
4. KPI – тихая метрик-полоса; для кабинета/дашборда с 2-4 главными метриками допустима сетка стат-карточек (`data-cards="true"`).
|
||||
5. Фильтры – в одном тулбаре непосредственно над таблицей; применённые – filter-chips под ним.
|
||||
6. Таблица идёт раньше вспомогательных панелей (статусы, фоновые задачи).
|
||||
7. Действия ряда тихие: появляются на hover или при выборе (на touch – видимы всегда).
|
||||
8. Детали открываются в правом drawer, не на новой странице.
|
||||
9. Точка входа AI не конкурирует с таблицей: кнопка-орб Төре в топбаре рядом с поиском (не плавающий FAB).
|
||||
9a. Главное действие создания – primary-кнопка вверху sidebar под брендом («Новый …» от product.entity); мастер создания (этапы процесса + загрузка документов) – в modal. Этапы/таймлайн НЕ выносить на главный список – их место в мастере и в деталях записи.
|
||||
9b. (ElevenLabs) Топбар слева: панель-toggle (свернуть sidebar) + breadcrumb с именем текущего раздела (мелко, muted). Контент: hero-заголовок раздела (text-2xl semibold — 24px; text-3xl/bold это красный флаг DoD) + подпись-назначение слева, главное действие справа (как «New order» в ElevenLabs); лого только в sidebar. Воздух между топбаром и заголовком — `stack-region` (32px), дальше блоки экрана идут через `stack-group` (24px). Имя продукта живёт в sidebar, имя раздела — в breadcrumb и hero (это паттерн ElevenLabs, не дубль двух больших заголовков).
|
||||
9c. Boilerplate обязателен для shell: sidebar, topbar, theme toggle и AI-кнопка Төре. Эти элементы не перерисовывать и не заменять. Главная рабочая область проектируется под задачу процесса.
|
||||
9c1. Для рабочих очередей, реестров и консолидации закупок default – classic app-shell: таблица возможностей, детали в правом drawer, короткие статусы и одно primary-действие. Custom workspace используется только если таблица + drawer не решают основную работу пользователя.
|
||||
9d. Если в прототипе показаны theme toggle, поиск, AI-кнопка или основные action-кнопки, они должны быть интерактивными в рамках прототипа. Нельзя показывать сломанные или декоративные controls.
|
||||
9e. Поле поиска по умолчанию пустое. Не подставлять случайный пример вроде названия товара, если пользователь прямо не выбрал этот поиск.
|
||||
9f. В операционном экране консолидации допустимы метрики, которые помогают принять решение: сколько ТС обработано, сколько имеют потенциал консолидации, какой предварительный потенциал экономии в тенге. Это не project ROI, а рабочие числа для приоритизации.
|
||||
9g. В UI запрещены служебные формулировки генерации: `модель вернула`, `fallback`, `не проходит дизайн-систему`, `shell`, `JSON`, `prompt`. Пользователь видит продуктовый язык, а не внутренний процесс сборки.
|
||||
10. Горизонтальный скролл на мобильных – только внутри контейнера таблицы.
|
||||
|
||||
### Порядок страницы по умолчанию
|
||||
|
||||
1. Shell: sidebar и топбар (glass, `.kt-ai-topbar`).
|
||||
2. Компактный заголовок страницы.
|
||||
3. Рабочая область под конкретный процесс: таблица, матрица, карточка возможности, сравнение источников или другой подходящий формат.
|
||||
4. Метрики и фильтры – только если помогают выполнить текущую работу пользователя.
|
||||
5. Вспомогательные статус-ряды.
|
||||
6. Второстепенные панели.
|
||||
7. Вывод раздела (`.kt-ai-conclusion`) — последним блоком, после таблицы.
|
||||
8. Drawer, toast, AI-кнопка.
|
||||
21
design-system/LICENSE
Normal file
21
design-system/LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Yerzhan Assanov
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
77
design-system/README.md
Normal file
77
design-system/README.md
Normal file
@ -0,0 +1,77 @@
|
||||
# KT AI Design System
|
||||
|
||||
Дизайн-система для внутренних продуктов ИИ-команды КТ. Прототип и продакшн-фронтенд — один и тот же код: продуктовый контракт (`config.json`) рендерится либо React-китом, либо самодостаточным HTML app-shell.
|
||||
|
||||
Одна папка, внешних зависимостей нет.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
Систему не нужно изучать — её читает ИИ-агент. Положите папку в проект как `design-system/` и дайте задачу:
|
||||
|
||||
> Примени дизайн-систему из `design-system/` — прочитай `design-system/AGENTS.md` целиком и следуй ему.
|
||||
> Затем собери экран «Реестр договоров».
|
||||
|
||||
Дальше агент делает всё сам: выбирает рантайм под ваш стек, собирает экран через контракт, а не вёрсткой руками, и прогоняет гейт качества.
|
||||
|
||||
Работает с Claude Code, Codex, Cursor и любым агентом, который читает файлы репозитория.
|
||||
|
||||
**Чтобы не повторять это каждую сессию** — добавьте в `CLAUDE.md` проекта одну строку:
|
||||
|
||||
```
|
||||
UI собирается по дизайн-системе KT AI: следуй `design-system/AGENTS.md`.
|
||||
```
|
||||
|
||||
## Если проект уже существует
|
||||
|
||||
Самая частая ошибка — «перекрасить» имеющиеся экраны токенами. Структура остаётся старой, меняются только цвета — это не применённая ДС.
|
||||
|
||||
Как правильно — раздел [«Если в проекте УЖЕ есть экран»](AGENTS.md) в `AGENTS.md`: главный экран данных пересобирается через контракт, старый компонент удаляется. Агент читает это сам.
|
||||
|
||||
## Посмотреть и проверить
|
||||
|
||||
```bash
|
||||
open showcase.html # галерея компонентов, светлая и тёмная темы
|
||||
python3 scripts/doctor.py # 10 секунд: всё ли на месте и цело
|
||||
```
|
||||
|
||||
`examples/` — 16 готовых продуктов, каждый парой «контракт + собранный прототип». Прототипы открываются двойным кликом.
|
||||
|
||||
## Подключить как зависимость
|
||||
|
||||
Чтобы проект всегда работал на свежей ДС, а не на копии, которая тихо отстаёт:
|
||||
|
||||
```bash
|
||||
npm i github:erjan-A/kt-ai-design-system
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { KTAIShell, KTScreen } from "kt-ai-design-system/kit";
|
||||
```
|
||||
|
||||
Обновление — `npm update kt-ai-design-system`. Настраивать сборщик не нужно: кит приезжает собранным, с типами. Нужен доступ к репозиторию у всех и в CI — [`docs/SETUP.md`](docs/SETUP.md).
|
||||
|
||||
## Подключить как копию
|
||||
|
||||
Проекту нужно править компоненты под себя — скопируйте папку к себе и дальше ведите её сами. Система в этом не участвует: копия становится ответственностью проекта, включая обновления.
|
||||
|
||||
Коротко, если без React:
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="kt-ai-tokens.css"> <!-- значения -->
|
||||
<link rel="stylesheet" href="kt-ai-components.css"> <!-- компоненты -->
|
||||
<link rel="stylesheet" href="kt-ai-fonts.css"> <!-- Inter, кириллица + ₸ -->
|
||||
<body class="kt-ai-app"> <!-- тёмная тема: data-theme="dark" -->
|
||||
```
|
||||
|
||||
## Что где лежит
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [`AGENTS.md`](AGENTS.md) | инструкция ИИ-агенту — главный документ |
|
||||
| [`CHECKLIST.md`](CHECKLIST.md) | Definition of Done: экран не готов, пока не прошёл |
|
||||
| [`COMPONENTS.md`](COMPONENTS.md) | анатомия компонентов и компоновка экрана |
|
||||
| [`docs/`](docs/) | канон: токены и API, принципы, контракт, архетипы |
|
||||
| [`docs/RELEASE.md`](docs/RELEASE.md) | как выпускается ДС — правка в каноне до продуктов доезжает только релизом |
|
||||
| [`examples/`](examples/README.md) | 16 готовых продуктов |
|
||||
| `tokens.json` | единственный источник значений |
|
||||
| `templates/` | React-кит и HTML app-shell |
|
||||
18
design-system/THIRD-PARTY-NOTICES.md
Normal file
18
design-system/THIRD-PARTY-NOTICES.md
Normal file
@ -0,0 +1,18 @@
|
||||
# Сторонние материалы
|
||||
|
||||
Дизайн-система распространяется под MIT (см. `LICENSE`). Входящие в неё
|
||||
сторонние материалы сохраняют свои лицензии:
|
||||
|
||||
| Материал | Лицензия | Где |
|
||||
|---|---|---|
|
||||
| Иконки Tabler (`circleNumber0…9`) | MIT | `icons/kt-ai-lucide-sprite.svg`, приведены к нашей сетке |
|
||||
| Иконки Lucide | ISC | `icons/kt-ai-lucide-sprite.svg`, текст лицензии — `icons/LUCIDE_LICENSE.txt` |
|
||||
| Шрифт Inter | SIL Open Font License 1.1 | `fonts/InterVariable-subset.woff2` |
|
||||
| Шрифт JetBrains Mono | SIL Open Font License 1.1 | `fonts/JetBrainsMono-subset.woff2` |
|
||||
|
||||
Вынесено из `LICENSE` отдельным файлом: GitHub определяет лицензию сравнением
|
||||
текста целиком, и приписка снизу превращала MIT в «лицензия не определена».
|
||||
|
||||
## Заимствованные идеи
|
||||
|
||||
Орб агента (`kt-ai-orb.js`) написан нами, но идея монохромного точечного облака взята у [thinking-orbs](https://orbs.jakubantalik.com) (MIT, Jakub Antalik & Alex Brinza). Код оттуда не используется.
|
||||
5
design-system/VERSION
Normal file
5
design-system/VERSION
Normal file
@ -0,0 +1,5 @@
|
||||
source: https://github.com/erjan-A/kt-ai-design-system
|
||||
commit: 8ed26abbddcafec128e75dfb35f86486b6210418
|
||||
describe: 8ed26ab
|
||||
pulled: 2026-09-01T09:38:26Z
|
||||
subset: без examples/, dist/ и React-кита (templates/kt-ai-shadcn) — платформа собирает ванильную статику
|
||||
301
design-system/archetypes/registry.json
Normal file
301
design-system/archetypes/registry.json
Normal file
@ -0,0 +1,301 @@
|
||||
{
|
||||
"$comment": "runtimes — в каких рантаймах архетип действительно реализован. Поле обязательно и проверяется гейтом 21: пока его не было, реестр объявлял все пятнадцать архетипов stable, а React-кит рисовал шесть — продукт узнавал об этом, только увидев деградировавший экран.",
|
||||
"fallback": "queue",
|
||||
"archetypes": [
|
||||
{
|
||||
"id": "queue",
|
||||
"title": "Операционная очередь",
|
||||
"whenToUse": "5+ записей со статусом/сроком/суммой; владелец проверяет, подтверждает, закрывает. Дефолт для реестров, очередей, инбоксов.",
|
||||
"requires": [
|
||||
"table",
|
||||
"statuses",
|
||||
"scenario"
|
||||
],
|
||||
"recommends": [
|
||||
"drawer",
|
||||
"kpis"
|
||||
],
|
||||
"forbids": [],
|
||||
"renderer": "renderMain",
|
||||
"runtimes": [
|
||||
"html",
|
||||
"kit"
|
||||
],
|
||||
"status": "stable"
|
||||
},
|
||||
{
|
||||
"id": "dashboard",
|
||||
"title": "Дашборд состояния",
|
||||
"whenToUse": "Управление метрикой процесса: KPI-табы управляют чартом периода (референс ElevenLabs dashboard), внизу разборы и список «требует внимания».",
|
||||
"requires": [
|
||||
"table",
|
||||
"statuses"
|
||||
],
|
||||
"recommends": [
|
||||
"dashboard",
|
||||
"kpis"
|
||||
],
|
||||
"forbids": [],
|
||||
"renderer": "renderDashboard",
|
||||
"runtimes": [
|
||||
"html",
|
||||
"kit"
|
||||
],
|
||||
"status": "stable"
|
||||
},
|
||||
{
|
||||
"id": "cockpit",
|
||||
"title": "Кабинет решения",
|
||||
"whenToUse": "Один фокусный объект с сигналами и решением + компактная очередь сбоку. Мониторинг и обработка исключений.",
|
||||
"requires": [
|
||||
"table",
|
||||
"statuses",
|
||||
"scenario"
|
||||
],
|
||||
"recommends": [
|
||||
"drawer",
|
||||
"kpis"
|
||||
],
|
||||
"forbids": [],
|
||||
"renderer": "renderCockpit",
|
||||
"runtimes": [
|
||||
"html",
|
||||
"kit"
|
||||
],
|
||||
"status": "stable"
|
||||
},
|
||||
{
|
||||
"id": "compare",
|
||||
"title": "Сверка источников",
|
||||
"whenToUse": "Попарная сверка двух источников/версий по полям, расхождения подсвечены. Аудит, финансы, контрагентские версии договоров.",
|
||||
"requires": [
|
||||
"table",
|
||||
"compare"
|
||||
],
|
||||
"recommends": [
|
||||
"scenario",
|
||||
"drawer"
|
||||
],
|
||||
"forbids": [],
|
||||
"renderer": "renderCompare",
|
||||
"runtimes": [
|
||||
"html",
|
||||
"kit"
|
||||
],
|
||||
"status": "stable"
|
||||
},
|
||||
{
|
||||
"id": "conversational",
|
||||
"title": "Разговорный агент",
|
||||
"whenToUse": "Чат как главная рабочая область, человек вне построчной очереди (агент отвечает/делает по запросу). Референс: ElevenLabs Conversational AI.",
|
||||
"requires": [
|
||||
"ai"
|
||||
],
|
||||
"recommends": [],
|
||||
"forbids": [],
|
||||
"renderer": "renderConversational",
|
||||
"runtimes": [
|
||||
"html",
|
||||
"kit"
|
||||
],
|
||||
"status": "stable"
|
||||
},
|
||||
{
|
||||
"id": "custom",
|
||||
"title": "Свободный экран (запасной)",
|
||||
"whenToUse": "Экран не ложится ни в один архетип — соберите его из блоков: sections с type text/cards/chart/kpis/table/chat в любом порядке (хабы, порталы, смешанные обзоры). Консистентность даёт не раскладка, а компоненты.",
|
||||
"requires": [],
|
||||
"recommends": [
|
||||
"sections"
|
||||
],
|
||||
"forbids": [],
|
||||
"renderer": "renderCustom",
|
||||
"runtimes": [
|
||||
"html",
|
||||
"kit"
|
||||
],
|
||||
"status": "stable"
|
||||
},
|
||||
{
|
||||
"id": "document",
|
||||
"title": "Документ с правками агента",
|
||||
"whenToUse": "Документоцентричный процесс: вычитка одного документа (договор, письмо, ТЗ) — правки и риски агента по фрагментам, принять/отклонить.",
|
||||
"requires": [
|
||||
"document",
|
||||
"scenario"
|
||||
],
|
||||
"recommends": [
|
||||
"banner",
|
||||
"kpis"
|
||||
],
|
||||
"forbids": [],
|
||||
"renderer": "renderDocument",
|
||||
"runtimes": [
|
||||
"html"
|
||||
],
|
||||
"status": "stable"
|
||||
},
|
||||
{
|
||||
"id": "copilot",
|
||||
"title": "Чат + артефакт",
|
||||
"whenToUse": "Диалог с агентом, который производит файл/документ/слайды: чат слева, живой предпросмотр результата справа.",
|
||||
"requires": [
|
||||
"ai",
|
||||
"artifact"
|
||||
],
|
||||
"recommends": [
|
||||
"scenario"
|
||||
],
|
||||
"forbids": [
|
||||
"table"
|
||||
],
|
||||
"renderer": "renderCopilot",
|
||||
"runtimes": [
|
||||
"html"
|
||||
],
|
||||
"status": "stable"
|
||||
},
|
||||
{
|
||||
"id": "wizard",
|
||||
"title": "Мастер-заявка",
|
||||
"whenToUse": "Пошаговая подача заявки/оформление с автозаполнением агентом и валидацией по шагам.",
|
||||
"requires": [
|
||||
"wizard"
|
||||
],
|
||||
"recommends": [
|
||||
"scenario"
|
||||
],
|
||||
"forbids": [
|
||||
"table"
|
||||
],
|
||||
"renderer": "renderWizard",
|
||||
"runtimes": [
|
||||
"html"
|
||||
],
|
||||
"status": "stable"
|
||||
},
|
||||
{
|
||||
"id": "kanban",
|
||||
"title": "Доска статусов",
|
||||
"whenToUse": "Поток работ движется по стадиям и важно видеть распределение: колонки = statuses, карточки = table.rows (новых блоков не нужно).",
|
||||
"requires": [
|
||||
"table",
|
||||
"statuses"
|
||||
],
|
||||
"recommends": [
|
||||
"scenario",
|
||||
"drawer"
|
||||
],
|
||||
"forbids": [],
|
||||
"renderer": "renderKanban",
|
||||
"runtimes": [
|
||||
"html"
|
||||
],
|
||||
"status": "stable"
|
||||
},
|
||||
{
|
||||
"id": "inbox",
|
||||
"title": "Входящие с панелью чтения",
|
||||
"whenToUse": "Список входящих (обращения, письма, заявки) с длинным текстом: слева список, справа постоянная панель чтения с разбором агента.",
|
||||
"requires": [
|
||||
"table",
|
||||
"inbox"
|
||||
],
|
||||
"recommends": [
|
||||
"scenario",
|
||||
"drawer",
|
||||
"statuses"
|
||||
],
|
||||
"forbids": [],
|
||||
"renderer": "renderInbox",
|
||||
"runtimes": [
|
||||
"html"
|
||||
],
|
||||
"status": "stable"
|
||||
},
|
||||
{
|
||||
"id": "analytics",
|
||||
"title": "Аналитический разбор",
|
||||
"whenToUse": "Главная ценность – анализ и выводы, а не построчная работа: сводка агента, 1-4 чарта (bar/line/donut), выводы с тонами.",
|
||||
"requires": [
|
||||
"analytics"
|
||||
],
|
||||
"recommends": [
|
||||
"kpis",
|
||||
"banner"
|
||||
],
|
||||
"forbids": [
|
||||
"table"
|
||||
],
|
||||
"renderer": "renderAnalytics",
|
||||
"runtimes": [
|
||||
"html"
|
||||
],
|
||||
"status": "stable"
|
||||
},
|
||||
{
|
||||
"id": "calendar",
|
||||
"title": "Расписание по датам",
|
||||
"whenToUse": "Работа привязана к датам и слотам: окна работ, дежурства, сроки – события по дням.",
|
||||
"requires": [
|
||||
"calendar"
|
||||
],
|
||||
"recommends": [
|
||||
"banner"
|
||||
],
|
||||
"forbids": [
|
||||
"table"
|
||||
],
|
||||
"renderer": "renderCalendar",
|
||||
"runtimes": [
|
||||
"html"
|
||||
],
|
||||
"status": "stable"
|
||||
},
|
||||
{
|
||||
"id": "map",
|
||||
"title": "Схема-карта участка",
|
||||
"whenToUse": "Объекты распределены в пространстве (станции, переезды, площадки): стилизованная SVG-схема с точками-статусами; не геокарта с тайлами.",
|
||||
"requires": [
|
||||
"map"
|
||||
],
|
||||
"recommends": [
|
||||
"statuses",
|
||||
"banner"
|
||||
],
|
||||
"forbids": [
|
||||
"table"
|
||||
],
|
||||
"renderer": "renderMap",
|
||||
"runtimes": [
|
||||
"html"
|
||||
],
|
||||
"status": "stable"
|
||||
},
|
||||
{
|
||||
"id": "timeline",
|
||||
"title": "Хронология",
|
||||
"whenToUse": "Разбор одного дела/инцидента во времени: кто что сделал и когда, включая шаги агента.",
|
||||
"requires": [
|
||||
"timeline"
|
||||
],
|
||||
"recommends": [
|
||||
"banner"
|
||||
],
|
||||
"forbids": [
|
||||
"table"
|
||||
],
|
||||
"renderer": "renderTimeline",
|
||||
"runtimes": [
|
||||
"html"
|
||||
],
|
||||
"status": "stable"
|
||||
}
|
||||
],
|
||||
"open_set_examples": [
|
||||
"gantt — план работ по срокам и зависимостям",
|
||||
"gallery — карточки с превью (фото, документы)",
|
||||
"editor — свободное редактирование длинного текста",
|
||||
"org — оргструктура/иерархия"
|
||||
]
|
||||
}
|
||||
94
design-system/docs/ARCHETYPES.md
Normal file
94
design-system/docs/ARCHETYPES.md
Normal file
@ -0,0 +1,94 @@
|
||||
# Архетипы экрана KT AI
|
||||
|
||||
Архетип — это форма главной рабочей области под задачу процесса. Набор **открыт**: archetypes/registry.json — это каталог-данные, а не зашитый список. Добавление архетипа = запись в реестр + рендерер + пример, **без правки валидатора и ядра**. Неизвестный или ещё не реализованный архетип безопасно деградирует в `queue`.
|
||||
|
||||
Это прямой ответ на вопрос «а если архетипов больше?»: система относится к архетипам как к данным. Реестр растёт; контракт (`product.schema.json`) общий; валидатор (`scripts/validate_product.py`) читает реестр и не меняется; рантайм (`templates/kt-ai-app-shell.html`) подбирает рендерер по id с фолбэком.
|
||||
|
||||
## Галерея
|
||||
|
||||
Все архетипы живьём в одном экране: `examples/gallery.html` (табы, генерируется `scripts/build_gallery.py` после пересборки прототипов). У каждого stable-архетипа есть отгружаемый пример.
|
||||
|
||||
## Контракт записи реестра
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "compare", // lowercase id, = значение product.archetype
|
||||
"title": "Сверка источников",
|
||||
"whenToUse": "когда применять (1 фраза)",
|
||||
"requires": ["table", "compare"], // блоки контракта, без которых архетип не имеет смысла → ERROR
|
||||
"recommends": ["scenario", "drawer"], // желательные блоки → WARN, если их нет
|
||||
"forbids": [], // блоки, которых быть не должно → ERROR
|
||||
"renderer": "renderCompare", // имя функции-рендерера в app-shell (null, если ещё нет)
|
||||
"runtimes": ["html", "kit"], // где архетип РЕАЛЬНО реализован
|
||||
"status": "stable" // stable – рендерится; planned – пока деградирует в fallback
|
||||
}
|
||||
```
|
||||
|
||||
**`runtimes` — обязательное поле, и его держит в согласии с кодом гейт 21.**
|
||||
`status: stable` говорит только о том, что архетип рендерится; в каком рантайме
|
||||
— не говорит. Пока поля не было, реестр объявлял stable все пятнадцать
|
||||
архетипов, а React-кит рисовал шесть: продукт на Next выбирал `inbox`, получал
|
||||
деградацию в `queue` и узнавал об этом, увидев не тот экран. Ни реестр, ни
|
||||
валидатор, ни гейт не произносили ни слова.
|
||||
|
||||
Теперь валидатор пишет об этом заметкой при проверке конфига, а гейт 21 сверяет
|
||||
реестр с тем, что действительно реализовано в `kt-ai-screen.tsx`. Реализовали
|
||||
архетип в ките — допишите `kit` в `runtimes`, иначе сборка ДС покраснеет.
|
||||
|
||||
В ките сегодня: `queue`, `dashboard`, `cockpit`, `compare`, `conversational`,
|
||||
`custom`. Остальные девять — только HTML-рантайм.
|
||||
|
||||
Валидатор по этой записи проверяет конфиг автоматически — для любого числа архетипов код не меняется.
|
||||
|
||||
## Реализованные (stable)
|
||||
|
||||
| id | Когда | Главная область | Референс |
|
||||
|---|---|---|---|
|
||||
| `queue` | 5+ записей со статусом/сроком/суммой; проверить и закрыть (дефолт) | таблица + drawer | — |
|
||||
| `dashboard` | управление метрикой процесса | KPI-табы → большой чарт периода + разборы (top/share/cards) + «требует внимания» | ElevenLabs dashboard (Mobbin) |
|
||||
| `cockpit` | один фокусный объект с сигналами + решение | карточка решения + очередь сбоку | ElevenLabs TTS playground (рабочая область + тихая панель) |
|
||||
| `compare` | попарная сверка двух источников/версий | две панели, расхождения подсвечены | — |
|
||||
| `conversational` | чат как главная область, человек вне построчной очереди | центрированный чат, орб-аватар, prompt-bar | ElevenLabs Conversational AI |
|
||||
| `custom` | **экран не подходит ни под один архетип** (запасной) | центрированная колонка секций-карточек | ElevenLabs settings-detail |
|
||||
| `document` | документоцентричный процесс: вычитка одного документа (договор, письмо, ТЗ) | документ с diff-правками агента по фрагментам, принять/отклонить каждую | Harvey, «договоры/письма Lotus» |
|
||||
| `copilot` | агент производит файл/документ/слайды в диалоге | чат слева + живой предпросмотр артефакта справа | ChatGPT Canvas, Claude Artifacts |
|
||||
| `wizard` | пошаговая подача заявки/оформление с автозаполнением агентом | степпер + поля с бейджем «заполнил агент», Назад/Далее | госуслуги, Typeform |
|
||||
| `kanban` | поток работ по стадиям, важно распределение | колонки = statuses, карточки = table.rows (без новых блоков) | Trello, Linear board |
|
||||
| `inbox` | входящие с длинным текстом (обращения, письма) | список слева + постоянная панель чтения с разбором агента | почтовые клиенты, Front |
|
||||
| `analytics` | главная ценность — анализ и выводы, не построчная работа | сводка агента + чарты (bar/line/donut, чистый SVG) + «Выводы агента» | Amplitude, Metabase |
|
||||
| `calendar` | работа привязана к датам и слотам | события по дням, тон-подсветка конфликтов | календарь-агенда |
|
||||
| `map` | объекты распределены в пространстве (станции, площадки) | SVG-схема с точками-статусами и связями; не геокарта с тайлами | схемы линий метро |
|
||||
| `timeline` | разбор одного дела/инцидента во времени | вертикальная лента: время, событие, актор (шаги агента помечены) | audit log, Intercom timeline |
|
||||
|
||||
## Заявленные (planned)
|
||||
|
||||
— нет. Все архетипы реестра реализованы.
|
||||
|
||||
## Когда экран не подходит ни под один архетип
|
||||
|
||||
Это штатная ситуация, не ошибка. Два пути:
|
||||
|
||||
1. **Явно** — поставить `archetype: "custom"` и описать `contract.sections` (заголовок + опц. описание/текст/поля). Рендерится колонкой карточек в духе ElevenLabs detail-страниц.
|
||||
2. **Автоматически** — если архетип неизвестен или не реализован, `mainByLayout` подбирает форму по данным: есть `sections` → custom; есть таблица с рядами → queue; иначе → custom (пустой с подсказкой). Экран никогда не падает и не показывает пустую таблицу не к месту.
|
||||
|
||||
## Набор открыт
|
||||
|
||||
Реестр прямо перечисляет очевидных кандидатов (`open_set_examples`): `gantt`, `gallery`, `editor`, `org`. Это не обещания — маркер того, что список не закрыт. До появления их рендереров такие экраны делает `custom`.
|
||||
|
||||
## Как добавить архетип
|
||||
|
||||
По «правилу двух продуктов» (DESIGN.md): архетип появляется, когда он нужен **второму** продукту, не раньше.
|
||||
|
||||
1. **Реестр.** Добавить запись в `archetypes/registry.json` (`status:"planned"`, `renderer:null` — на этом шаге уже можно валидировать контракт).
|
||||
2. **Рендерер.** Написать `renderXxx()` в `templates/kt-ai-app-shell.html` и ветку в `mainByLayout()`; перевести запись в `status:"stable"`, `renderer:"renderXxx"`. Главную оболочку (sidebar/topbar/тема/орб Төре) не трогать — меняется только главная область.
|
||||
3. **Контракт.** Если архетипу нужен новый блок данных (как `compare`), добавить его в `product.schema.json`.
|
||||
4. **Пример.** Положить эталонный конфиг в `examples/` и прогнать `scripts/validate_product.py`.
|
||||
5. **Док.** Дописать строку в таблицу выше.
|
||||
|
||||
## Деградация
|
||||
|
||||
- Неизвестный `archetype` → WARN + рендерится `queue`.
|
||||
- `status:"planned"` → WARN + рендерится `fallback` (queue).
|
||||
- `compare` без `compare.pairs` → WARN + рендерится `queue`.
|
||||
|
||||
Прототип никогда не падает из-за архетипа — в худшем случае показывает надёжную таблицу-очередь.
|
||||
98
design-system/docs/BUILD_PROTOTYPE.md
Normal file
98
design-system/docs/BUILD_PROTOTYPE.md
Normal file
@ -0,0 +1,98 @@
|
||||
# BUILD_PROTOTYPE.md — как ИИ-агенту собрать прототип из материалов проекта
|
||||
|
||||
Эта инструкция для **Claude Code / Codex**. Сценарий Ержана: он собирает материалы от владельца
|
||||
процесса в папку проекта и говорит агенту «собери прототип по материалам и дизайн-системе».
|
||||
Эта страница — единственная точка входа для этого. Следуй ей по шагам, не выдумывай данные,
|
||||
**не трогай Төре** (`01_Проекты/_Alpha Projects/ai-interviewer/` и его `design-system/` — read-only).
|
||||
|
||||
DS-корень: `00_Система/08_Дизайн-система/` (далее `DS/`).
|
||||
|
||||
---
|
||||
|
||||
## Что на входе и на выходе
|
||||
|
||||
- **Вход:** папка проекта `01_Проекты/<проект>/` с материалами: паспорт агента, PRD/ТЗ,
|
||||
`КОНТЕКСТ.md`, заметки интервью/звонков, правила скоринга, перечень статусов/каналов/систем.
|
||||
- **Выход** в `01_Проекты/<проект>/.../prototype/`:
|
||||
1. `<имя>.config.json` — контракт по `DS/product.schema.json`;
|
||||
2. `<имя>.prototype.html` — самодостаточный кликабельный прототип (двойной клик, file://);
|
||||
3. `РАНБУК_сессии_с_владельцем.md` — что показать, что спросить, как собрать отзыв.
|
||||
|
||||
---
|
||||
|
||||
## Шаги
|
||||
|
||||
### 0. Прочитай канон DS (в этом порядке)
|
||||
`GOAL.md` (северная звезда) → `PRODUCT_CONTRACT.md` (форма контракта) → `product.schema.json`
|
||||
(точная схема) → `ARCHETYPES.md` (выбор архетипа) → `DESIGN.md` + `PRINCIPLES.md` (правила вёрстки и честности) → `ELEVENLABS_DESIGN.md` (эстетика/плотность/детали как у ElevenLabs) → `CHECKLIST.md` (гейт, по которому будут принимать).
|
||||
Карта слоёв системы и «что где живёт» — `README.md`.
|
||||
Образцы готовых контрактов: `DS/examples/*.json` и ДУКО
|
||||
(`01_Проекты/ДУКО/Адвокат клиента/Разработка/prototype/advokat-klienta.config.json`).
|
||||
|
||||
### 1. Прочитай материалы проекта и собери карту источника (`source-map`)
|
||||
НЕ вытаскивай факты «на глаз» — выпиши их структурно, иначе часть выходов потеряется (так в первом
|
||||
прогоне консолидации в UI не попал «проект ТС», хотя PRD назвал его выходом). Выпиши:
|
||||
- главный рабочий объект; владелец процесса и основной пользователь; **роли** (кто решает / согласует / получает);
|
||||
- рабочие статусы; поля строки и их **источник** (SAP, Лотус, KTWorks, e-Otinish, агент, человек);
|
||||
- **выходные артефакты** — что продукт ПРОИЗВОДИТ (документы: ТС, отчёт, служебная записка, расчёт…); это самое теряемое;
|
||||
- роль AI; действия человека; признаки/правила/веса; сценарии (S-xx) и критерии приёмки (A-xx).
|
||||
Сложи это в `source-map.md` рядом с прототипом (таблица: пункт · источник в PRD · UI-дом · covered/deferred).
|
||||
Если факт неизвестен — `не найдено`, не выдумывай.
|
||||
|
||||
**Трассировка (gate G8).** Для каждого пункта карты назначь UI-дом: nav-секция / поле или артефакт в drawer / явный «вне v1».
|
||||
Правило: **представлен или осознанно отложен — никогда молча не выброшен.** Named output из PRD не зарывается в `_flags`.
|
||||
|
||||
### 2. Выбери архетип
|
||||
По `ARCHETYPES.md`. По умолчанию `queue` (рабочая очередь). Другие: `dashboard`, `cockpit`,
|
||||
`compare` (сверка двух источников), `conversational`, `custom` (запасной — свободные секции).
|
||||
Неизвестный архетип безопасно деградирует в queue.
|
||||
|
||||
### 3. Собери контракт `<имя>.config.json`
|
||||
Строго по схеме. Правила честности (их же проверяет валидатор):
|
||||
- **Данные — из материалов, не выдуманные.** Маскируй ПДн: `ИИН •••4821`, `B2C · ...`, без реальных ФИО клиентов.
|
||||
- `product.view: operational` для рабочей очереди → **без ROI/FTE/эффекта** в KPI (это управленческий view).
|
||||
KPI отвечают на вопрос пользователя на экране (сколько в очереди, риск, просрочка), а не «доказывают ценность».
|
||||
- Один primary (`scenario.control`) на экран. Статусы строк — только из объявленных в `statuses`.
|
||||
- **Двухстрочная ячейка:** `columns[].sub` = ключ второго поля (напр. канал под обращением) — компактно, в духе ElevenLabs.
|
||||
- **Иконки nav:** `nav[].icon` из спрайта `DS/icons/kt-ai-lucide-sprite.svg` (risk/chart/tool/feed/file/users/...).
|
||||
- **Для AI-агентов:** анализ агента кладётся в `row._flags` (по одному признаку), плюс одна строка
|
||||
`"Рекомендация: ..."`. Варианты действия человека — `scenario.actions` (Action Layer: AI рекомендует, человек
|
||||
подтверждает или меняет). Drawer покажет это как «Что сделал агент / Признаки / Решение — за вами».
|
||||
- `banner` пиши по схеме «что → почему → что делать» и давай `action`, чтобы он был полезным, а не статичным.
|
||||
|
||||
### 4. Проверь контракт (ворота честности)
|
||||
```
|
||||
python3 "DS/scripts/validate_product.py" "<config>.json" --strict
|
||||
```
|
||||
Должно быть `0 ошибок, 0 предупреждений`. Чини контракт, пока не пройдёт. `--strict` обязателен для показа/handoff.
|
||||
|
||||
### 5. Собери кликабельный прототип (одна команда)
|
||||
```
|
||||
python3 "DS/scripts/build_prototype.py" "<config>.json"
|
||||
```
|
||||
Получишь `<имя>.prototype.html` рядом с конфигом — самодостаточный (токены, компоненты, спрайт, режим «Отзыв»
|
||||
встроены), открывается двойным кликом. **Не инлайнь руками** — используй этот скрипт.
|
||||
|
||||
### 5.5. Гейт качества — прогони `CHECKLIST.md` (Definition of Done)
|
||||
Открой собранный `*.prototype.html` (и React, если делал) и пройди гейт `CHECKLIST.md`:
|
||||
- `[auto]`-пункты уже держит валидатор (шаг 4 = 0/0);
|
||||
- `[review]`-пункты — глазами в обеих темах (showcase-переключатель) и на узком экране (393px): иерархия,
|
||||
ритм отступов, hairline-vs-тень, состояния hover/empty/error, статус-пилюли, паритет HTML↔React.
|
||||
«Зелёный валидатор» ≠ «прошло DoD». Нашёл расхождение — правь контракт (или, если это общий дефект, `DS/`) и вернись к шагу 4.
|
||||
|
||||
### 6. Напиши `РАНБУК_сессии_с_владельцем.md`
|
||||
Короткий сценарий показа: что демонстрируем, 3–5 вопросов владельцу процесса на проверку правды
|
||||
(статусы/поля/действия верны?), и как собрать отзыв: кнопка **«Отзыв»** в прототипе → владелец кликает
|
||||
по элементам и пишет замечания → **«Экспорт в markdown»** → присылает БА. Итерация = правка контракта (не кода) → шаг 4–5.
|
||||
|
||||
### 7. (Опционально) React-репозиторий для разработки
|
||||
Тот же контракт разворачивается в React/shadcn-кит для передачи разработчикам:
|
||||
см. `templates/kt-ai-shadcn/PROTOTYPING_WORKFLOW.md` (кит лежит в папке ДС, рядом с app-shell). Выглядит идентично HTML-прототипу.
|
||||
|
||||
---
|
||||
|
||||
## Жёсткие правила
|
||||
- **Не трогай Төре** и его копию DS. Максимум — читать для логики.
|
||||
- Данные иллюстративные, но обоснованы материалами; ПДн маскированы; цифр «с потолка» нет.
|
||||
- Меняешь только папку прототипа конкретного проекта и (при общем улучшении) `DS/`. Контракт — единственный источник; рантаймы не правим под один проект.
|
||||
- **Любое изменение UI проходит `CHECKLIST.md`** — не только новый прототип, но и новая фича/правка рантайма или DS. Менял `DS/` → синхронизируй Töре и держи `integrity.test` = 0 failed.
|
||||
308
design-system/docs/DESIGN.md
Normal file
308
design-system/docs/DESIGN.md
Normal file
@ -0,0 +1,308 @@
|
||||
# KT AI Design System v3.0 «Mono» (токены v5)
|
||||
|
||||
Канон дизайн-системы KT AI. v3.0 от 2026-06-21 – визуальный язык в духе ElevenLabs: **light-first**, тёплый near-white фон, **near-black primary-кнопка** (в dark инвертируется в near-white), плоские поверхности на hairline-границах и шёпот-тенях, крупные жирные тёмные заголовки, цвет – только функциональный (статусы, чарты, единичная AI-искра орба Төре). Синий **демотирован** в ссылки/фокус/идентичность KT, перестал быть цветом primary-действия. Геометрия и Attio-детали (rounded-rect теги, frameless-таблицы с горизонтальными разделителями рядов — без вертикальных колонок-сепараторов, property-rows) сохранены; aurora-свечение карточек и тяжёлые AI-ореолы убраны. История: CHANGELOG.md. Анатомия shell/таблиц/рядов: COMPONENTS.md. Продуктовый контракт прототипа: PRODUCT_CONTRACT.md.
|
||||
|
||||
Файлы:
|
||||
|
||||
| Файл | Роль |
|
||||
|---|---|
|
||||
| `tokens.json` | ЕДИНСТВЕННЫЙ источник значений токенов. Правки только здесь |
|
||||
| `scripts/build_tokens.py` | генератор: tokens.css, print.css, figma-variables.json, tailwind-preset |
|
||||
| `kt-ai-tokens.css` | AUTOGENERATED: переменные, обе темы, @layer kt-tokens |
|
||||
| `kt-ai-print.css` | AUTOGENERATED: @media print (light, A4, скрытие shell) |
|
||||
| `figma-variables.json` | AUTOGENERATED: импорт в Figma Variables (dark/light) |
|
||||
| `kt-ai-tailwind-preset.cjs` | AUTOGENERATED: preset для Next.js/kt-ai-shadcn |
|
||||
| `kt-ai-components.css` | компоненты v2 на токенах, имена классов совместимы с v1 |
|
||||
| `COMPONENTS.md` | нормативная анатомия shell, таблиц, рядов, тулбаров (перенос из v1-архива) |
|
||||
| `showcase.html` | живая галерея всех компонентов с переключателем тем – открыть в браузере |
|
||||
| `icons/kt-ai-lucide-sprite.svg` | локальный monochrome Lucide-sprite |
|
||||
| `COMPONENTS.md` | правила компоновки экрана (активно) |
|
||||
| `ELEVENLABS_DESIGN.md` | эталон эстетики/плотности/деталей (язык ElevenLabs) |
|
||||
| `archive/` | замороженный v1 (kt_design_system, DESIGN_v1_archive, kt_ai_terminal_tokens.css, kt_ai_terminal_routes, kt_ai_atomic_product_system) – только история, вынесен в `08_Дизайн-система_история/archive/` |
|
||||
|
||||
Брендовые ассеты: `03_База знаний/Логотипы и бренд/` (ktai.svg, kt-logo.svg).
|
||||
|
||||
## Принципы
|
||||
|
||||
Полный контракт UX-принципов (Нильсен, гештальт, законы взаимодействия): `PRINCIPLES.md`. Единый чек-лист «прошло или нет» (Definition of Done, прогонять при любом изменении UI): `CHECKLIST.md`. Оба обязательны при проектировании.
|
||||
|
||||
1. Плотный операционный интерфейс: первый экран – рабочая поверхность, не лендинг.
|
||||
2. Плоские поверхности на hairline-границах: в light – лёгкая тень (`card-shadow`/`shadow-sm`) + рамка, в dark – рамка без тени. Полновесные тени – только у overlay (modal, drawer, dropdown, command, toast). Никаких декоративных свечений/градиентов в контенте. Blur – только на sticky-хроме (топбар `bg-glass`) и overlay.
|
||||
3. Тихие границы, приглушённые метаданные, один primary-акцент на экран.
|
||||
4. **Mono-доминанта.** Primary-действие – монохром: `primary` = near-black (light) / near-white (dark), белый/тёмный текст. Синий (`link`, `focus`, `brand-blue`) – ссылки, фокус и идентичность KT, **не** заливка кнопок. AI-градиент (`ai-gradient`) – единичная сдержанная искра только на орбе Төре, send-кнопке и кромке prompt-bar в фокусе; тяжёлые ореолы и aurora-свечение карточек убраны. Остальные цвета – только статусы, чипы и графики.
|
||||
5. Геометрия компонентов не зависит от темы: темы меняют только цвет.
|
||||
6. Никаких сырых hex в продуктовом CSS – только semantic-токены.
|
||||
|
||||
## Архитектура токенов
|
||||
|
||||
Три уровня, имена v1 (`--kt-ai-*`) полностью сохранены – старые продукты работают без правок.
|
||||
|
||||
1. **Primitives** (`--kt-blue-500`, `--kt-gray-900`, `--kt-green-a22`...) – сырая палитра и шкалы. В продуктах напрямую НЕ используются.
|
||||
2. **Semantic** – смысловые роли, переключаются темой:
|
||||
- поверхности: `--kt-ai-bg`, `-bg-soft`, `-bg-elevated`, `-bg-sunken`;
|
||||
- state-слои: `-bg-hover`, `-bg-active`, `-selection`;
|
||||
- текст: `-fg`, `-fg-muted`, `-fg-faint`, `-fg-on-fill`;
|
||||
- границы: `-border`, `-border-strong`, `-divider`;
|
||||
- интерактив: `-primary`, `-primary-hover`, `-primary-subtle`, `-link`, `-danger`, `-danger-hover`;
|
||||
- фокус: `-focus-ring`, `-focus-shadow`;
|
||||
- статусы/чипы/чарты: как в v1;
|
||||
- тени: `-shadow-sm/md/lg/xl`.
|
||||
3. **Component** – размеры контролов: `--kt-ai-control-h-sm/md/lg` (28/32/40), layout-переменные v1.
|
||||
|
||||
Тема-независимые шкалы (в `:root`):
|
||||
|
||||
- **Spacing** `--kt-ai-space-1..12`: 2, 4, 6, 8, 12, 16, 20, 24, 32, 40, 48, 64. Всё на 4px-сетке, плотный низ шкалы.
|
||||
- **Типографика** `--kt-ai-text-2xs..3xl`: 11, 12, 13, 14, 15, 18, 26, 32. Body = 13px (`text-sm`). Веса 400/500/600/700. Числа – JetBrains Mono с `tabular-nums`.
|
||||
- **Трекинг зависит от размера** (не одно значение на всё): крупные заголовки — отрицательный (`-.02em` дисплей, `-.01em` H1/H2), body — около `0`. Тесним заголовки, body оставляем нейтральным.
|
||||
- **Leading обратно размеру:** плотный на крупных заголовках (`leading-tight` 1.25), свободнее на body (`leading-normal` 1.5). Иерархия строится связкой вес+размер+leading, а не размером одним.
|
||||
- **Радиусы** (значения — в `tokens.json`, `scales.radius`): xs, sm, md, tag, lg, xl, 2xl, 3xl, full. Nav-ряды lg; кнопки/инпуты xl; карточки, modal, command, prompt-bar – 3xl; теги/статусы – tag (rounded-rect, не pill).
|
||||
- **Моушн** `--kt-ai-dur-fast/base/slow/slower` (100/160/240/400ms), easing standard/enter/exit. `prefers-reduced-motion` обнуляет длительности автоматически.
|
||||
- **Z-index**: sticky 20, dropdown 30, drawer 40, modal 50, toast 60, tooltip 70.
|
||||
|
||||
## Темы
|
||||
|
||||
Light – дефолт (`:root`, `data-theme="light"` или `kt-ai-terminal-light`): тёплый near-white, near-black primary – основной офисный вид (v3 «Mono», light-first). Dark – полноправный паритетный вариант (`data-theme="dark"` или `kt-ai-terminal`): near-black фон, primary инвертируется в near-white. `data-theme="auto"` следует за системной темой.
|
||||
|
||||
Правила паритета:
|
||||
|
||||
- каждый semantic-токен определён в обеих темах; добавил токен в dark – обязан добавить в light;
|
||||
- интерактивный синий: dark `#4b9ce2`, light `#0077c8` (AA-контраст на своих фонах); фирменный KT blue `#0096d7` общий;
|
||||
- статусные пары построены зеркально: в dark светлый текст на прозрачной подложке, в light тёмный текст на прозрачной подложке;
|
||||
- проверка паритета – переключателем в `showcase.html`, это обязательный QA-шаг.
|
||||
|
||||
## Компоненты (kt-ai-components.css)
|
||||
|
||||
Имена классов v1 сохранены (`.kt-ai-card`, `.kt-ai-row`, `.kt-ai-chip`, `.kt-ai-nav-item`, `.kt-ai-table*`, `.kt-ai-search`, `.kt-ai-kbd`, `.kt-ai-icon-button`, `.kt-ai-segmented`...). Новое в v2:
|
||||
|
||||
- **Кнопки** `.kt-ai-btn`: варианты `data-variant="primary|ghost|danger"` (без атрибута – вторичная), размеры `data-size="sm|lg"`. Один primary на экран.
|
||||
- **Формы**: `.kt-ai-field` (label + контрол + hint/error), `.kt-ai-input`, `.kt-ai-select`, `.kt-ai-textarea`, `data-invalid="true"`, `.kt-ai-checkbox`, `.kt-ai-switch`. Фокус: border primary + `--kt-ai-focus-shadow`.
|
||||
- **Tabs** `.kt-ai-tabs/.kt-ai-tab` (`data-active`), сегменты как в v1.
|
||||
- **Banner** `.kt-ai-banner data-tone="info|warn|risk|ok"` – строчные предупреждения в контенте.
|
||||
- **Modal** `.kt-ai-modal-overlay/.kt-ai-modal` – bg-elevated, radius-2xl, shadow-xl, анимация rise.
|
||||
- **Toast** `.kt-ai-toast-stack/.kt-ai-toast data-tone` – нижний правый угол, точка-индикатор тона.
|
||||
- **Tooltip** `.kt-ai-tooltip[data-tip]` – чистый CSS.
|
||||
- **Skeleton** `.kt-ai-skeleton` (shimmer), **Empty** `.kt-ai-empty` (иконка + title + действие).
|
||||
- **KPI** `.kt-ai-kpi-strip/.kt-ai-kpi` – тихая метрик-полоса, не карточки.
|
||||
- **Avatar**, `.kt-ai-divider`, `.kt-ai-status-pill` с точкой-индикатором.
|
||||
- **Drawer** `.kt-ai-drawer` – правая панель деталей (правило: детали в drawer, не на новой странице).
|
||||
- **Документ-артефакт** `.kt-ai-doc` / `.kt-ai-doc-list` – карточка выходного документа (иконка + имя + статус/формат + кнопка «Скачать»). То, что продукт ПРОИЗВОДИТ (ТС, отчёт, СЗ), показывается как скачиваемый документ — в drawer (секция «Документы» из `_docs` строки) и в nav-секции-коллекции (`nav.view="documents"`, фильтр по `docType`). Контракт: `table.rows[]._docs`.
|
||||
|
||||
Новое в v2.3 «Aurora × Attio»:
|
||||
|
||||
- **Карточки** – `card-bg/card-border/card-shadow` токены: в dark – hairline-рамка + фиолетовое aurora-свечение из угла (`data-glow="strong"` – усиленный hero-вариант с двойным свечением), в light – белая поверхность с тенью без рамки. Радиус — `radius-3xl`.
|
||||
- **Кнопки (Attio)** – secondary: поверхность bg-elevated + рамка + shadow-sm, радиус 8; ghost/danger без поверхности.
|
||||
- **Теги (Attio)** – chip/status-pill/filter-chip: rounded-rect `radius-tag` (5px), не pill.
|
||||
- **Таблицы (ElevenLabs)** – дефолт frameless full-bleed: hairline под шапкой и между рядами, **без вертикальных колонок-сепараторов** (тихо, воздушно), заголовки sentence-case 12px medium muted; контейнерный вариант – `data-framed="true"`. Для стабильности ширин при фильтрации фильтруемым таблицам задаётся `table-layout:fixed` + colgroup.
|
||||
- **Property-row** `.kt-ai-prop` (`.k` – иконка+метка 148px, `.v` – значение, `data-empty`) – детали объекта в drawer/карточке.
|
||||
- **AI-aurora** – токены `ai-accent`, `ai-gradient` (синий → фиолет): градиентная кромка prompt-bar в фокусе + ореол, градиентные send-кнопка и streaming-каретка, орб-атрибуция вместо ✦, `.kt-ai-btn[data-ai="true"]` – точка входа AI. Градиент НЕ используется вне AI-моментов.
|
||||
- **Тихая ссылка** `.kt-ai-link[data-arrow]` – «Подробнее →», стрелка сдвигается на hover.
|
||||
- **Dot-grid** текстура на `.kt-ai-empty`. Mono-голос: label, cmd-group, время уведомлений, ключи filter-chips. Активный таб – синий. Трекинг заголовков -.02em/-.01em. Sidebar-ряды 26px.
|
||||
|
||||
Новое в v2.2:
|
||||
|
||||
- **Topbar** `.kt-ai-topbar` – канонический glass-хром: sticky, `bg-glass` + blur(8). Blur разрешён только здесь и на overlay.
|
||||
- **AI-диалог**: `.kt-ai-chat` + `.kt-ai-msg data-role="user|assistant"` (ассистент – полная ширина без пузыря, пользователь – пузырь bg-soft ≤76%), `data-streaming="true"` – каретка; `.kt-ai-prompt-bar` (autogrow textarea + `.kt-ai-prompt-send`, `data-busy`), `.kt-ai-suggestions/.kt-ai-suggestion` – стартовые подсказки, `.kt-ai-source-pill` – ссылка-источник в ответе, `.kt-ai-gen-label` – атрибуция «Сгенерировано ИИ» (обязательна для AI-контента).
|
||||
- **Menu** `.kt-ai-menu-anchor > .kt-ai-menu[data-open]` – dropdown действий (kebab в рядах таблиц): `.item` (`data-danger`), `.sep`.
|
||||
- **Command palette** – достроена: `.kt-ai-command-overlay`, `.cmd-input`, `.cmd-list`, `.cmd-group`, `.cmd-item[data-active]`, `.cmd-empty`.
|
||||
- **Filter-chips** `.kt-ai-filterbar` + `.kt-ai-filter-chip` (`.k` – имя фильтра, `.remove`) + `.clear-all` – применённые фильтры над таблицей.
|
||||
- **Radio** `.kt-ai-radio`, `.kt-ai-radio-group`.
|
||||
- **Toast**: `.action` (undo-ссылка) и `.close`.
|
||||
- **Popover** `.kt-ai-popover-anchor > .kt-ai-popover[data-open]` (`data-align="right"`) – якорный интерактивный слой (date-picker, настройки колонок).
|
||||
- **Bulk-bar** `data-floating="true"` – плавающий вариант снизу по центру для длинных таблиц.
|
||||
- **Row actions** `.kt-ai-row .actions` / `.kt-ai-table tr .actions` – тихие действия, видимы на hover/focus-within/selected; на touch видимы всегда.
|
||||
- Активный пункт навигации получает 2px-акцент слева (работает и в icon-rail).
|
||||
|
||||
Анатомия shell, таблиц, рядов – COMPONENTS.md (перенесено из v1-архива, нормативно).
|
||||
|
||||
## Operational Dashboard Contract
|
||||
|
||||
- Boilerplate задаёт обязательный shell: sidebar, `.kt-ai-topbar`, theme toggle и AI-кнопку Төре. Эти элементы нельзя перерисовывать, заменять самодельными глифами или делать декоративными. Main content проектируется под задачу процесса.
|
||||
- Для рабочих очередей, реестров и консолидации закупок предпочтителен classic app-shell: таблица как главный объект, детали в drawer, короткие workflow-status, одно primary-действие. Custom workspace, матрица, split-view или decision panel используются только когда таблица + drawer не решают основную работу пользователя.
|
||||
- H1/topbar title – короткий рабочий объект, 1-3 слова. Не использовать описание процесса как заголовок.
|
||||
- Subtitle – одна короткая scope-фраза до 90 символов.
|
||||
- KPI показывают рабочее состояние текущего пользователя: новые, требуют решения, риски, ждут документы, просрочено, закрыто сегодня, SLA или сумма риска. ROI, FTE, экономия часов и тенге эффекта не выводятся на пользовательский dashboard по умолчанию. Эти метрики относятся к management/reporting view, паспорту или расчёту эффекта проекта. Исключение – операционный экран консолидации, где пользователю нужны рабочие числа для решения: сколько ТС обработано, сколько имеют потенциал консолидации, какой предварительный потенциал экономии в тенге.
|
||||
- Search, theme toggle, AI button and visible primary actions must work in the prototype when shown. Search inputs are empty by default and must not be prefilled with arbitrary examples.
|
||||
- Primary filters – только workflow-status, максимум 4 пункта плюс `Все`. Confidence, risk type, системы и длинные причины не попадают в default segmented filters.
|
||||
- Workflow `status`, `confidence` и `riskType` моделируются разными полями.
|
||||
- Labels компактные: filter до 2 слов, status до 4 слов, table header до 2 слов; детали и длинные объяснения – в drawer/help.
|
||||
- UI-copy не показывает внутреннюю кухню генерации: `модель вернула`, `fallback`, `не проходит дизайн-систему`, `shell`, `JSON` и похожие формулировки запрещены в пользовательском интерфейсе.
|
||||
- **Голос интерфейса нейтрален и продуктовый.** Запрещены: дисклеймеры прототипа («это прототип на дизайн-системе», «данные иллюстративны»); нравоучения про human-in-the-loop («AI не действует на клиента сам», «решение подтверждает человек», «контрольная точка») — человек-в-контуре показывается контролами (рекомендация + кнопки accept/reject), а не объяснениями; лозунг-лейблы капсом («РЕШЕНИЕ — ЗА ВАМИ») — секции называются нейтральными существительными («Решение»). Любой продукт-специфичный текст (что сделал агент, разбор) живёт в контракте (`scenario.aiSummary`), а не зашит в shell — иначе он протекает в другие продукты. Валидатор ловит эти формулировки (`PREACHY_LANG`).
|
||||
|
||||
## Состояния интерактива
|
||||
|
||||
Единая лестница: default → hover (`-bg-hover` или border-strong) → active/selected (`-bg-active`) → focus-visible (outline `-focus-ring`) → disabled (opacity .4). Hover-переходы `dur-fast`, появления `dur-base`, drawer/modal `dur-slow`. Нажатие кнопки: `scale(.98)` 60ms.
|
||||
|
||||
## Движение (motion)
|
||||
|
||||
Правила — не украшение, а поведение. Каждое имеет причину.
|
||||
|
||||
- **Длительности по типу:** микро/hover 100–150мс (`dur-fast` 100), обычное появление/смена 150–250мс (`dur-base` 160), overlay/drawer/modal ~240мс (`dur-slow`). Дольше 300мс движение начинает раздражать; короче — пропадает читаемость перехода.
|
||||
- **Появляйся от `scale(.95)`, не от `scale(0)`** (и не от `opacity:0` в одиночку): элемент «подрастает» из своего места, а не возникает из ниоткуда. Уводи так же — до `.95`, не в `0`.
|
||||
- **Отклик на нажатие, не на отпускание:** подсветка/`scale(.98)` на pointer-down мгновенно; коммит — на pointer-up. Лаг убивает ощущение прямоты.
|
||||
- **Переходы прерываемы и обратимы:** закрывающийся drawer можно открыть на полпути. Анимируй от ТЕКУЩЕГО значения (presentation), а не от целевого — иначе «прыжок».
|
||||
- **Вход и выход по одному пути; overlay привязан к источнику:** меню/popover растут из кнопки (`transform-origin` = триггер), drawer уходит туда, откуда пришёл. На реверсе — зеркальный easing (`ease-enter` ↔ `ease-exit`).
|
||||
- **Анимируй только `transform` и `opacity`** (композиторные, без ре-лейаута). Не анимируй `width/height/top/left/box-shadow` — дёргается кадр.
|
||||
- **`prefers-reduced-motion`** обнуляет длительности автоматически (токены + компоненты); оставляем только opacity/цвет, что помогают понять переход.
|
||||
|
||||
## Иконки
|
||||
|
||||
Lucide из локального sprite, монохром через `currentColor`, stroke 1.75. Размеры 16 (база), 14 (плотные ряды), 20 (крупные). Без CDN-шрифтов и CSS-псевдоиконок. Правила и список имён – v1-архив.
|
||||
|
||||
## Доступность и QA
|
||||
|
||||
- Контраст: основной текст ≥ 7:1, muted ≥ 4.5:1, faint только для необязательных подписей.
|
||||
- Фокус видим всегда: глобальный `:focus-visible` в components.css.
|
||||
- `prefers-reduced-motion` поддержан на уровне токенов (обнуляет длительности).
|
||||
- `prefers-contrast: more`: толще фокус-кольцо, границы = `border-strong`, ссылки подчёркнуты, обводка у пилюль.
|
||||
- `prefers-reduced-transparency: reduce`: blur на sticky-топбаре снимается, фон становится плотным (легибельность > эффект).
|
||||
- Чек-лист перед сдачей экрана — единый гейт `CHECKLIST.md` (Definition of Done): обе темы, состояния loading/empty/error, один primary, нет сырых hex, адаптив, паритет, валидатор 0/0. Прогоняется при любом изменении UI, не только при сдаче нового экрана.
|
||||
|
||||
## Подключение
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="kt-ai-tokens.css">
|
||||
<link rel="stylesheet" href="kt-ai-components.css">
|
||||
<body class="kt-ai-app" data-theme="dark">
|
||||
```
|
||||
|
||||
Для Next.js-прототипов – кит `templates/kt-ai-shadcn` (**внутри папки ДС**, рядом с app-shell — ДС одна самодостаточная папка; переменные совместимы, источник значений – kt-ai-tokens.css; установка – его `templates/kt-ai-shadcn/PROTOTYPING_WORKFLOW.md`).
|
||||
|
||||
**Два рантайма одного контракта.** HTML app-shell (`templates/kt-ai-app-shell.html`) — демо-рантайм с роутингом разделов: рендерит nav-секции (`view="documents"`, мастер `steps`), баннер-навигацию (`banner.actionNav`), коллекции документов. React-кит — **одноэкранный handoff для разработки**: рендерит главный архетип-экран (queue/dashboard/…) + drawer (включая секцию «Документы» из `_docs`), но НЕ роутит вторичные nav-секции — это намеренно (их реализует разработчик под свой роутер). Поэтому `nav.view`, `steps`, `banner.actionNav` — поведение HTML-рантайма; в React это точки расширения.
|
||||
|
||||
## Миграция v1 → v2
|
||||
|
||||
1. Заменить подключение `archive/kt_ai_terminal_tokens.css` на пару `kt-ai-tokens.css` + `kt-ai-components.css`. Все старые переменные и классы продолжают работать.
|
||||
2. Hardcoded-размеры и цвета постепенно переводить на шкалы (`--kt-ai-space-*`, `--kt-ai-text-*`, semantic-цвета).
|
||||
3. Новые компоненты (кнопки, формы, modal, toast, banner, skeleton, empty) не писать заново – брать из components.css.
|
||||
4. Паритет тем проверять в showcase.html.
|
||||
|
||||
## Ритм отступов (правило близости)
|
||||
|
||||
`space-*` – сырая шкала для внутренних отступов компонента. **Расстояние МЕЖДУ блоками экрана берётся из отдельной шкалы `stack-*`** – одно значение на роль, а не решение каждого компонента:
|
||||
|
||||
| Токен | Значение | Когда |
|
||||
|---|---|---|
|
||||
| `--kt-ai-stack-tight` | 12px | внутри группы: подпись ↔ контрол, контрол ↔ контрол в одном ряду |
|
||||
| `--kt-ai-stack-block` | 16px | между элементами одного блока; полоса карточек KPI; панель управления ↔ то, чем она управляет |
|
||||
| `--kt-ai-stack-group` | 24px | **между блоками экрана**: шапка / баннер / KPI / чарт / таблица. Значение по умолчанию |
|
||||
| `--kt-ai-stack-region` | 32px | поля рабочей области, топбар ↔ заголовок |
|
||||
| `--kt-ai-pad-card` | 20px | интерьер карточки/секции |
|
||||
|
||||
Почему шкала, а не `space-*` напрямую: раньше каждый компонент выбирал отбивку сам, и на одном экране получалось пять разных значений для одного и того же отношения «блок ↔ блок» (8/12/16/20/24). Глаз не читает такую разницу как иерархию – он читает её как небрежность, и экран выглядит захламлённым при формально верных токенах. Ровно так разошлись два рантайма: HTML-шелл давал 16/20, React-кит 24/28.
|
||||
|
||||
Правило разрешения споров прежнее: если сомневаешься, к какой группе элемент относится визуально – он стоит неправильно. Секционный разрыв (40+, `space-10`) остаётся для смены смысла страницы, а не для соседних блоков одного экрана.
|
||||
|
||||
### Одинаковый зазор — не ритм
|
||||
|
||||
Шкала не означает «поставь `stack-group` на всё». Ровно так экран становится плоским: панель фильтров, её вкладки и карточки списка расходятся на одно и то же расстояние, и глаз не видит ни одной группы — только столбец одинаково далёких полос. Разнобой читается как небрежность, равномерность — как «здесь ничто ни с чем не связано». Обе ошибки одинаково дорогие.
|
||||
|
||||
**Рецепт экрана — три ступени, не одна:**
|
||||
|
||||
```
|
||||
шапка страницы ← 24 (stack-group): регион
|
||||
системный баннер ← 24 (stack-group): регион
|
||||
┌ панель управления ← 12 (stack-tight): фильтры и вкладки — ОДНА вещь
|
||||
│ фильтры
|
||||
│ вкладки
|
||||
└ ← 16 (stack-block): панель ближе к тому, чем управляет
|
||||
┌ содержимое
|
||||
│ KPI · чарт · карточки списка ← 16 между блоками, 12 между однородными карточками
|
||||
└
|
||||
```
|
||||
|
||||
Выражается стеком, а не своим классом продукта: `<div class="kt-ai-v" data-gap="group|block|tight|region">`. Если продукту понадобился собственный `app-stack-*` — это сигнал, что группировку не выразили, а обошли.
|
||||
|
||||
**Проверка одной фразой:** пройди по вертикали и назови каждый зазор. Если все числа разные — сломана согласованность. Если все одинаковые — сломана группировка. Правильно, когда чисел ровно столько, сколько на экране уровней вложенности.
|
||||
|
||||
## Отзывчивость
|
||||
|
||||
Брейкпоинты: sm 640 / md 832 / lg 1024 / xl 1280 (`--kt-ai-bp-*`; в media-запросах значения дублируются числом – var() там недоступен).
|
||||
|
||||
Shell – три состояния (реализовано в components.css):
|
||||
|
||||
1. ≥ lg: полный sidebar 232px.
|
||||
2. md..lg: icon-rail 52px – подписи прячутся; в разметке подписи пунктов оборачивать в `<span class="nav-label">`.
|
||||
3. < md: sidebar = overlay-drawer (`data-open="true"`), в топбаре burger; drawer документов – full-screen.
|
||||
|
||||
Touch (`pointer: coarse`): токены контролов автоматически растут (sm 36, md 44, lg 48, nav-row 40, table-row 52) – компоненты масштабируются сами, ничего переопределять не нужно.
|
||||
|
||||
Мобильный топбар: на < sm в топбаре остаются логотип (без текста), статус-чип с ellipsis и одно главное действие; текстовые кнопки прячутся или сворачиваются в иконки; элементы с flex-shrink:0 допустимы только для иконок и тумблера. Бургер для off-canvas панелей – иконка `menu` из спрайта (три линии), не самодельные глифы. Горизонтальный скролл страницы запрещён и гасится системой на < md (overflow-x hidden на .kt-ai-app/.kt-ai-shell).
|
||||
|
||||
Таблицы: горизонтальный скролл внутри `table-wrap`; первая колонка может быть sticky (`.kt-ai-sticky-first`); при < md и более 4 колонок – переключаться на список карточек.
|
||||
|
||||
## API компонентов (enum-контракт)
|
||||
|
||||
| Компонент | Атрибут | Значения |
|
||||
|---|---|---|
|
||||
| `.kt-ai-btn` | data-variant | (нет) = secondary, `primary`, `ghost`, `danger` |
|
||||
| `.kt-ai-btn` | data-size | (нет) = md, `sm`, `lg` |
|
||||
| `.kt-ai-btn` | data-loading | `true` – спиннер, клики заблокированы |
|
||||
| `.kt-ai-chip` | data-tone | `gray`, `green`, `orange`, `blue`, `purple`, `red` |
|
||||
| `.kt-ai-chip` | data-removable | присутствие + `<span class="remove">×</span>` |
|
||||
| `.kt-ai-status-pill` | data-status | `ok`, `warn`, `risk`, `info` |
|
||||
| `.kt-ai-banner`, `.kt-ai-toast` | data-tone | `ok`, `warn`, `risk`, `info` |
|
||||
| `.kt-ai-input/.kt-ai-textarea` | data-invalid | `true` |
|
||||
| `.kt-ai-field` | data-invalid | `true` — красит вложенный input/select/textarea (удобно, когда состояние живёт на поле) |
|
||||
| `.kt-ai-table` | data-density | (нет) = 48px, `compact`/`dense` 38, `relaxed` 56 (значения из `tokens.json`) |
|
||||
| `.kt-ai-table th` | data-sort | присутствие = сортируемая, `asc`, `desc` |
|
||||
| `.kt-ai-step-item` | data-state | (нет) = upcoming, `current`, `done` |
|
||||
| `.kt-ai-combobox` | data-open | `true` – меню видно |
|
||||
| `.kt-ai-table td` | data-label | подпись ячейки в мобильном card-режиме (≤640px). Без неё значение просто выравнивается влево |
|
||||
| `.kt-ai-switch-row` | — | готовая строка «подпись + свитч справа». Сам `.kt-ai-switch` — только трек 34px |
|
||||
| `.kt-ai-modal` | — | ОБЯЗАТЕЛЬНО внутри `.kt-ai-modal-overlay` — оверлей и есть центрирующий контейнер (`position: fixed` + flex). Соседями они не работают: модалка встанет в поток и уедет со скроллом |
|
||||
| `.kt-ai-scroll-locked` | — | на `<html>`, пока открыт модал/drawer: иначе фон скроллится под оверлеем. Снимать при закрытии |
|
||||
| `.kt-ai-disclosure` | data-open | `true` — раскрыт. Карточка, разворачивающаяся НА МЕСТЕ. Для очередей, где объекты сравнивают: drawer закрывает список и заставляет открывать по одному. Части: `-head` (кнопка, `aria-expanded`), `-chevron`, `-body`, `-actions` |
|
||||
| `.kt-ai-kpi` | — | подпись всегда над значением, порядок `.label`/`.value` в разметке любой |
|
||||
| `.kt-ai-combobox .option` | data-selected, data-active | `true` |
|
||||
| `.kt-ai-dropzone` | data-drag | `true` – состояние перетаскивания |
|
||||
| `.kt-ai-table-bulkbar` | data-open | `true` |
|
||||
| `.kt-ai-sidebar` | data-open | `true` (только < md, overlay) |
|
||||
| `.kt-ai-notif-item` | data-unread | `true` |
|
||||
| `.kt-ai-theme-toggle` | data-mode | `dark`, `light` – бегунок и подсветка иконки |
|
||||
| `.kt-ai-diff .line` | data-op | `add`, `del` |
|
||||
| nav-item, tab, segmented .seg, pagination .page-btn | data-active | `true` |
|
||||
| `.kt-ai-msg` | data-role | `user`, `assistant` |
|
||||
| `.kt-ai-msg` | data-streaming | `true` – каретка генерации |
|
||||
| `.kt-ai-prompt-bar` | data-busy | `true` – ввод заблокирован на время генерации |
|
||||
| `.kt-ai-menu`, `.kt-ai-popover` | data-open | `true` |
|
||||
| `.kt-ai-menu .item` | data-danger | `true` – деструктивный пункт |
|
||||
| `.kt-ai-popover` | data-align | (нет) = left, `right` |
|
||||
| `.kt-ai-table-bulkbar` | data-floating | `true` – плавающий снизу |
|
||||
| `.kt-ai-command .cmd-item` | data-active | `true` |
|
||||
| `.kt-ai-card` | data-glow | `strong` – усиленное aurora-свечение (hero/AI) |
|
||||
| `.kt-ai-btn` | data-ai | `true` – градиентная AI-кнопка (точка входа Төре) |
|
||||
| `.kt-ai-table-wrap` | data-framed | `true` – контейнер с рамкой (дефолт – frameless) |
|
||||
| `.kt-ai-link` | data-arrow | присутствие – тихая ссылка «… →» |
|
||||
| `.kt-ai-prop .v` | data-empty | `true` – незаполненное значение |
|
||||
| `.kt-ai-kpi-strip` | data-cards | `true` – сетка стат-карточек (radius-2xl, card-токены) вместо тихой полосы |
|
||||
|
||||
## Локализация
|
||||
|
||||
- Казахские строки длиннее русских на ~20%: запрещены фиксированные ширины под текст кнопок, чипов, пунктов меню; truncation с ellipsis + tooltip полного значения.
|
||||
- Числа: тысячи пробелом (`1 240`), тенге после числа (`1 240 ₸`), числа в таблицах — **sans + `tabular-nums`** (цифры Inter выровнены по ширине; в JetBrains Mono нет глифа ₸, из-за чего валюта подставлялась из другого шрифта и рвала колонку). Моноширинный остаётся только у `.kt-ai-kbd` и командных групп.
|
||||
- Даты: `dd.mm.yyyy`, время `hh:mm`, относительные («2 мин», «вчера») только в уведомлениях и лентах.
|
||||
|
||||
## Чарты
|
||||
|
||||
Максимум 6 серий на график (порядок: chart-blue, green, orange, salmon, pink, red). Оси и подписи – fg-faint, сетка – divider. Заливка под линией – только primary-subtle и только для одной серии. Без 3D, теней и градиентов в данных. Пустое состояние графика – kt-ai-empty, не пустые оси.
|
||||
|
||||
## Печать и экспорт
|
||||
|
||||
Подключить `kt-ai-print.css` (генерируется из tokens.json): при печати принудительно light-палитра, скрываются shell/кнопки/тосты, формат A4 с полями 18/16 мм, карточки и таблицы не разрываются. Элементы, которые не должны попасть в PDF, помечать `data-no-print`.
|
||||
|
||||
## Иконочные кнопки и деструктивные действия
|
||||
|
||||
Icon-only кнопка обязана иметь tooltip (`.kt-ai-tooltip[data-tip]`) и aria-label. Деструктивные действия: никогда не icon-only; подтверждение в modal; кнопка подтверждения – глаголом действия («Удалить агента», «Отправить в Реестр»), никогда «Да»/«ОК»; для необратимых операций – ввод имени объекта.
|
||||
|
||||
## Правило двух продуктов
|
||||
|
||||
Новый компонент попадает в систему только когда он понадобился второму продукту. До этого живёт в продукте. Это держит систему маленькой и честной.
|
||||
|
||||
## Визуальная регрессия
|
||||
|
||||
`scripts/visual_check.mjs` (Playwright): скриншоты showcase в обеих темах и трёх ширинах (1280/900/600), сравнение с эталонами в `scripts/__screenshots__/`. Запуск локально: `npx playwright install chromium && node scripts/visual_check.mjs`. Прогонять при любом изменении tokens.json или components.css.
|
||||
|
||||
## Do / Don't
|
||||
|
||||
- Do: ряды вместо крупных карточек; таблица – главный объект экрана; детали в drawer; метрики тихой полосой (в дашбордах/кабинетах с 2-4 главными метриками – `data-cards="true"`); primary-действие монохромное (near-black/near-white); кнопки create/«Новый X» – с ведущим «+»; один фильтрующий поиск на экран; AI-кнопка Төре – в топбаре рядом с поиском; продукт-специфичный текст – в контракте, не в shell.
|
||||
- Don't: карточки в карточках; декоративные градиенты, свечения и blur в контенте (исключения: единичная AI-искра орба/send/prompt-bar, blur на sticky-хроме и overlay); синий как заливка primary-кнопки; больше одного primary; цвет hex напрямую; новые цвета/иконки/геометрия под отдельный продукт; разная геометрия в темах; дисклеймеры/нравоучения в UI; два одинаково фильтрующих поиска; лишний input/селект там, где хватает прямой кнопки.
|
||||
57
design-system/docs/GOAL.md
Normal file
57
design-system/docs/GOAL.md
Normal file
@ -0,0 +1,57 @@
|
||||
# Цель: KT AI как фабрика прототипов
|
||||
|
||||
Северная звезда дизайн-системы KT AI. Этот документ отвечает на вопрос «зачем всё это» и задаёт критерий, по которому проверяется любое решение по системе. `DESIGN.md` — чем строить, `PRINCIPLES.md` — почему так, `GOAL.md` — ради чего.
|
||||
|
||||
## Standing instruction (`/goal`)
|
||||
|
||||
> Строить из дизайн-системы KT AI **фабрику прототипов**, где бизнес-аналитик за один рабочий день превращает паспорт агента и интервью Төре в **правдивый кликабельный прототип процесса владельца**, итерирует с владельцем в течение часов, а на выходе отдаёт команде разработки **репозиторий с продакшн-готовым фронтендом без дизайн-долга**. Главное уравнение системы: **прототип === фронтенд продакшна**. Каждое улучшение приближает это уравнение к истине; что не приближает — не приоритет.
|
||||
|
||||
Делать по одному, проверять, улучшать. Систему держать маленькой и честной (правило двух продуктов).
|
||||
|
||||
## Конечный образ результата
|
||||
|
||||
БА садится с владельцем процесса — на руках паспорт агента и 25-минутное интервью Төре. К концу того же дня владелец кликает прототип **своего** процесса: его колонки, его статусы, его рабочее действие, AI-ассистент отвечает по его данным. Не макет с выдуманными строками — правдивый первый срез. Владелец оставляет комментарии прямо на экране. БА правит конфиг, не код, — следующая версия готова в тот же день.
|
||||
|
||||
После одобрения БА отдаёт разработчикам **git-репозиторий, фронтенд которого уже продакшн-готов**: те же токены, те же компоненты, тот же код. Разработчики добавляют только то, что умеют только они: авторизацию, роли, реальные интеграции, бэкенд. Фронтенда они почти не пишут. Дизайн-долга на handoff нет, потому что прототип и продукт никогда не были разными вещами.
|
||||
|
||||
## Определение готовности (Definition of Done системы)
|
||||
|
||||
1. **Один контракт.** Единый типизированный продуктовый контракт (`product.config`) — общий язык: Төре его выдаёт, превью рендерит, React-стартер из него собирается, валидатор проверяет, handoff-док из него читает. Четырёх копий одной идеи больше нет.
|
||||
2. **Один рантайм.** Кликабельный прототип БА и стартовый репозиторий разработчика — один и тот же код. HTML-превью без сборки — быстрое окно в тот же контракт, а не параллельная вселенная.
|
||||
3. **Правда по построению.** У каждого поля, которое видит владелец, объявлены тип, допустимые значения, владелец данных и признак редактируемости — это собирается в паспорте/интервью. Никакой выдумки перед владельцем процесса.
|
||||
4. **Канон через проверку, не через память.** `kt-ai-lint` валит сборку на сырых hex, отступах вне шкалы, втором primary, debug-языке в копирайте, запрещённых ROI/FTE на операционном дашборде. 12-пунктовый чек-лист становится машиной, которая говорит да или нет.
|
||||
5. **Архетипы, не одна форма.** БА выбирает архетип экрана (Операционная очередь / Документ-сравнение / Разговорный агент / Кабинет-решение); у каждого свой пресет контракта, свой эталонный клонируемый прототип и свои правила.
|
||||
6. **Замкнутая петля обратной связи.** Прототип несёт режим комментирования в контексте; обратная связь владельца возвращается структурными заметками, а не скриншотами и памятью о созвоне.
|
||||
7. **Маленькая и честная.** Правило двух продуктов держится; система растёт, только когда второй продукт это заслужил; дрейф между ДС, копией в Төре и стартером — структурно ноль.
|
||||
|
||||
## Метрики (как поймём, что дошли)
|
||||
|
||||
| Метрика | Цель |
|
||||
|---|---|
|
||||
| Время до первого прототипа (интервью → кликабельный) | < 1 рабочего дня |
|
||||
| Цикл итерации (фидбэк → новая версия) | часы, не спринт |
|
||||
| Переиспользование фронтенда на handoff | ≥ 80% уходит в прод без изменений |
|
||||
| Дизайн-долг на handoff | ноль переверстки |
|
||||
| Дрейф ДС / Төре / стартер | ноль (один источник, CI) |
|
||||
| Пропускная способность одного БА | 3–4 прототипа в месяц без дизайнера в цикле |
|
||||
|
||||
## Последовательность строительства (по одному)
|
||||
|
||||
**P0 — общий хребет (это и есть цель, остальное — полировка):**
|
||||
1. Единая схема продуктового контракта (Zod/JSON Schema): Төре выдаёт, превью и стартер потребляют, валидатор проверяет.
|
||||
2. Один рантайм: shadcn-стартер рендерит из типизированного контракта; HTML app-shell демотирован до превью того же контракта без сборки.
|
||||
3. Field-level data contract в паспорте и интервью Төре (тип, значения, владелец, редактируемость).
|
||||
|
||||
**P1 — быстро и честно на масштабе:**
|
||||
4. `kt-ai-lint` — исполняемые правила на сгенерированном выводе, в CI.
|
||||
5. Пресеты архетипов + эталонные клонируемые прототипы.
|
||||
6. Режим обратной связи в прототипе (комментарии в контексте → markdown).
|
||||
|
||||
**P2 — гигиена:**
|
||||
7. Убрать дубликат ДС из репозитория Төре (submodule/пакет/CI-проверка).
|
||||
8. Одностраничный runbook старта + один машинный свод правил; глубокие доки — справочник.
|
||||
9. `visual_check.mjs` в CI без ручного запуска.
|
||||
|
||||
## Решающий критерий
|
||||
|
||||
Перед любой работой по системе спросить: **приближает ли это уравнение «прототип === фронтенд продакшна»?** Если да — делаем. Если нет — это не приоритет, как бы красиво ни выглядело.
|
||||
60
design-system/docs/PRINCIPLES.md
Normal file
60
design-system/docs/PRINCIPLES.md
Normal file
@ -0,0 +1,60 @@
|
||||
# Принципы UX/UI KT AI – применять при создании любого дизайна
|
||||
|
||||
Рабочий контракт принципов: «почему так». DESIGN.md – «чем строить», ELEVENLABS_DESIGN.md – «как выглядит». **Проверка («прошло или нет») вынесена в единый гейт `CHECKLIST.md`** — прогоняй его при любом изменении UI (новый продукт / фича / правка). Этот файл объясняет, ПОЧЕМУ пункты гейта именно такие.
|
||||
|
||||
## 10 эвристик Нильсена → как они реализованы у нас
|
||||
|
||||
1. **Видимость статуса системы.** Каждое действие даёт отклик ≤100мс: loading-кнопка на сабмитах, toast на завершениях, progress на долгих операциях, статус-pill на объектах, стриминг ответов в AI-продуктах. Молчащий интерфейс = баг.
|
||||
2. **Соответствие системе и реальному миру.** Язык пользователя, не наш: «Отправить в Реестр», а не «Синхронизировать сущность». Термины из глоссария КТ (Лотус, СЗ, паспорт агента). Иконки – буквальные (Lucide), не метафоры.
|
||||
3. **Контроль и свобода.** Из любого состояния есть выход: modal закрывается по Esc и клику мимо, drawer – стрелкой назад, деструктив – с подтверждением, многошаговые процессы (stepper) позволяют вернуться на шаг. Автосохранение черновиков, где возможно.
|
||||
4. **Согласованность и стандарты (закон Якоба).** Пользователь приходит из других продуктов – не изобретать: поиск сверху, детали справа в drawer, primary справа в паре кнопок, крестик закрытия справа сверху. Внутри KT AI – единый API компонентов (enum-таблица в DESIGN.md), один компонент = одно поведение во всех продуктах.
|
||||
5. **Предотвращение ошибок.** Валидация до сабмита (data-invalid + .error в field), недоступные действия – disabled с объяснением в tooltip, деструктив – глаголом действия и подтверждением, необратимое – вводом имени объекта. Маски и подсказки формата (телефон, даты) до того, как человек ошибся.
|
||||
6. **Узнавание вместо вспоминания.** Видимые подписи у иконок в навигации (icon-only только в rail-режиме), placeholder с примером формата, недавние значения в combobox первыми, breadcrumbs показывают где я.
|
||||
7. **Гибкость и эффективность.** Два слоя скорости: новичку – видимые кнопки и подсказки, опытному – ⌘K командная палитра, kbd-шорткаты, плотность таблиц (data-density), bulk-операции.
|
||||
8. **Эстетика и минимализм.** Каждый элемент экрана зарабатывает своё место: один primary, KPI тихой полосой, метаданные muted, пустое пространство – инструмент, а не потеря. Правило: убери элемент – если экран не сломался, элемент был лишним.
|
||||
9. **Помощь в распознавании и восстановлении после ошибок.** Текст ошибки говорит: что случилось, почему, что делать. «Не удалось сохранить: нет связи с сервером – повторите через минуту», а не «Ошибка 500». Тон risk-banner, рядом действие повтора.
|
||||
10. **Справка и документация.** Подсказки в месте использования: hint под полем, tooltip на icon-only, onboarding-экран перед сложным flow (как в AI Interviewer), empty-state объясняет «что здесь будет и как начать».
|
||||
|
||||
## Правила по референсу ElevenLabs (Mobbin) — сверять при любом экране
|
||||
|
||||
ElevenLabs (мониторинг через Mobbin) — главный живой референс системы. Правила ниже выведены из их продакшн-экранов и обязательны:
|
||||
|
||||
- **KPI-карточка: три уровня сверху вниз — подпись, значение, уточнение.** Маленькая muted-подпись, сразу под ней значение (sans, tabular-nums, semibold), при необходимости третьим уровнем `.hint` — знаменатель, доля или период («из 2 954 · 58 % плана»). Значение — число или короткая величина (≤12 символов); слова-квалификаторы («при норме 7 дней», «за неделю») живут в подписи, не в значении. Длинное значение автоматически мельче (`data-long`), но это страховка, а не норма. **Значение не прижимается к низу карточки:** в полосе из карточек с уточнением и без числа встанут на разной высоте, и глаз прочитает их как разные по важности. Полоса выравнивает карточки по верху.
|
||||
- **KPI как табы (dashboard).** Клик по метрике переключает большой чарт периода. Выбранная — рамка-акцент; никаких стрелок/иконок-подсказок на невыбранных, доступность через hover и `role=tab`.
|
||||
- **Одна цифра — один раз на экране.** Значение метрики живёт на KPI-карточке; шапка чарта её НЕ повторяет (только название ряда и период). Дубли одной цифры в трёх местах — баг.
|
||||
- **Главная страница — обзор, не свалка.** Дашборд: KPI → чарт → разборы → «Требует внимания» топ-5 со ссылкой «Все →». Полный реестр — отдельный раздел nav (`view:"registry"`: та же таблица с поиском/фильтрами/пагинацией). Как Home vs Voices/History у ElevenLabs.
|
||||
- **Разборы под чартом — три формы:** top-N с бар-треком за подписью («Top called paths»), доли с процент-барами («Language»), мини-карточки статистики («Most called agents»). Каждый чарт/ряд несёт note — чтение одной фразой.
|
||||
- **Многошаговое — вертикальный степпер слева** с номером/галкой, названием и 1-строчным описанием шага (референс их publishing-flow); контент справа, «‹ Назад» ghost слева, primary справа.
|
||||
- **Список+чтение (inbox):** узкий список слева (заголовок + muted-подпись + время + pill), панель чтения справа: label:value строки со значениями по правому краю, разбор агента отдельной секцией.
|
||||
- **Документ (Studio):** абзацы/фрагменты с тонкой левой чертой; помеченные агентом — с тёплой чертой и плашкой причины. Текст дышит, никакой рамки вокруг каждого абзаца.
|
||||
- **Чарты:** один акцентный цвет на ряд, лёгкая пунктирная сетка, area-заливка ≤10% непрозрачности, подписи оси только по краям и шагом, крайние — text-anchor start/end (не режутся).
|
||||
|
||||
## Гештальт-принципы → правила вёрстки
|
||||
|
||||
- **Близость (proximity).** Расстояние кодирует связь и берётся из шкалы ритма, а не подбирается: внутри группы `stack-tight` (12), между элементами блока `stack-block` (16), **между блоками экрана `stack-group` (24)**, поля области и отрыв от топбара `stack-region` (32), смена секции — `space-10+` (40+). Label ближе к своему полю, чем к чужому. Если элемент визуально «прилип» не к своей группе – это баг вёрстки. **Пять разных значений для одного отношения — баг**: разница в 4px не читается как иерархия, она читается как небрежность. **Один и тот же зазор на всё — баг ровно такой же**: экран становится плоским, панель фильтров и её вкладки расходятся так же далеко, как список от шапки, и ни одна группа не читается. Ритм — это разные расстояния для разных отношений, а не одно расстояние везде.
|
||||
- **Сходство (similarity).** Одинаковая роль = одинаковый вид: все ссылки одного цвета, все статусы – pill, все метаданные – meta. Обратное тоже: разная роль обязана выглядеть по-разному (secondary ≠ primary).
|
||||
- **Общая область (common region).** Граница/фон группирует сильнее отступа: карточка объединяет, divider разделяет. Не вкладывать карточку в карточку – две рамки спорят за группировку.
|
||||
- **Непрерывность и выравнивание.** Всё сидит на 4px-сетке и общих осях: левый край контента – одна линия, числа в таблицах – по правому краю, baseline текста в ряду – общий.
|
||||
- **Фигура и фон.** Слои тона (bg → bg-soft → bg-elevated) и overlay создают глубину без теней; модальные поверхности всегда elevated + затемнение фона.
|
||||
- **Замыкание.** Усечённые списки с «показать ещё N» – мозг достроит; не выводить 200 строк сразу.
|
||||
|
||||
## Законы взаимодействия
|
||||
|
||||
- **Фиттс.** Чем важнее и чаще действие, тем больше и ближе цель: primary крупнее визуально, touch-цели ≥44px (pointer: coarse автоматом), зоны клика рядов – весь ряд, а не только текст.
|
||||
- **Хик.** Меньше выборов – быстрее решение: меню ≤7 пунктов на уровень, формы шагами (stepper), один вопрос за раз (паттерн AI Interviewer), фильтры по умолчанию свёрнуты до самых ходовых.
|
||||
- **Операционная краткость.** Заголовок экрана, фильтры, статусы и кнопки не описывают весь процесс. Они называют рабочий объект или действие: `Очередь рисков`, `Нет документов`, `Подтвердить риск`. Длинные объяснения живут в subtitle, drawer или help.
|
||||
- **Метрики по задаче пользователя.** Верхняя карточка, счётчик или виджет отвечает не на вопрос «какую ценность продукт хочет доказать», а на вопрос «зачем пользователь открыл этот экран прямо сейчас». Для рабочего экрана это очередь, просрочка, блокер, риск, задача на подтверждение, новый входящий объект или состояние процесса. ROI, FTE, экономия часов и тенге не выводятся на рабочий экран по умолчанию. Они живут только там, где пользователь принимает управленческое решение по эффекту: management/reporting view, паспорт, расчёт эффекта или экран проекта.
|
||||
- **Неприкосновенный shell.** Sidebar, topbar, theme toggle и AI-кнопка Төре – системный контракт, а не творческая зона. Новый продукт меняет рабочую область, таблицу, drawer, формы и сценарии, но не изобретает заново базовую оболочку.
|
||||
- **Продуктовый язык вместо debug-языка.** В интерфейсе нельзя показывать внутренние объяснения генерации: `модель вернула`, `fallback`, `не проходит дизайн-систему`, `shell`, `JSON`, `prompt`. Если прототип собран автоматически, пользователь всё равно видит нормальные продуктовые подписи, действия и статусы.
|
||||
- **Миллер (7±2).** Чанкование: телефоны с пробелами, длинные таблицы с группировкой, навигация секциями по 3-5 пунктов.
|
||||
- **Эффект эстетики-юзабилити.** Аккуратный интерфейс прощает мелкие огрехи и вызывает доверие – поэтому паритет тем, ровные отступы и выравнивание не «полировка», а функциональное требование.
|
||||
- **Постепенное раскрытие.** Сложность по запросу: детали в drawer, advanced-настройки за «ещё», JSON/код за переключателем. Первый экран – только то, что нужно для главного действия.
|
||||
- **Ориентация (wayfinding).** Каждый экран отвечает на 4 вопроса: где я? куда могу пойти? что здесь? как выйти? Если хоть один без ответа — экран дезориентирует (топбар-крошка, активный пункт nav, заголовок, Esc/назад).
|
||||
- **Маппинг и лейблы.** Близость и расположение контрола отражают то, на что он влияет; **если контролу нужен поясняющий лейбл — маппинг слабый**, переделай контрол. Лейблы конкретны: nav называется по содержимому, не общим «зонтиком» — конкретность даёт предсказуемость (не «Управление», а «Лоты»).
|
||||
- **Обратная связь: причинность и польза.** Отклик привязан к вызвавшему его событию (fire on cause) и добавляется только там, где нужен (успех/ошибка/коммит/снап), а не на каждый чих. Валидация — по ходу ввода, не на сабмите.
|
||||
- **Текст не заменяет устройство экрана.** Объяснение на экране — признак того, что интерфейс не объясняет себя сам. Прежде чем добавить поясняющую строку, оговорку или подзаголовок, проверь: нельзя ли это сказать самим элементом (подпись пилюли, название колонки, подсказка у метки, состояние кнопки). Оговорка живёт РЯДОМ с тем, к чему относится, а не отдельной строкой над списком: у элемента её прочитают в момент решения, над списком — пролистают. Что убирать в первую очередь: подзаголовок, повторяющий заголовок; инструкцию «проверьте и примите решение» там, где это и так единственное действие; пояснение источника данных, которому место в подсказке; текст, одинаковый для всех строк списка. Правило проверяемое: **любая строка текста, которая не меняется от данных, — кандидат на удаление**; если она нужна для честности (оговорка про непроверенное, про тестовые данные, про пробел в данных), она остаётся, но переезжает к элементу.
|
||||
- **Ремесло (craft).** Каждый отступ, тайминг и выравнивание — намеренны; внимание к деталям строит доверие. Кривой пиксель читается как «недоделано» независимо от контента.
|
||||
|
||||
## Чек-лист
|
||||
|
||||
Перенесён в единый гейт → **`CHECKLIST.md`** (Definition of Done). Там же — что машинно ловит `validate_product.py` (`[auto]`), а что требует глаз (`[review]`), и какие гейты прогонять для нового продукта / фичи / правки. Не дублируй чек-лист здесь — правь его в одном месте.
|
||||
93
design-system/docs/PRODUCT_CONTRACT.md
Normal file
93
design-system/docs/PRODUCT_CONTRACT.md
Normal file
@ -0,0 +1,93 @@
|
||||
# Продуктовый контракт KT AI
|
||||
|
||||
Единый контракт, из которого собирается прототип. **Один источник, один рантайм.** Это ключевой механизм северной звезды (`GOAL.md`): *прототип === фронтенд продакшна*.
|
||||
|
||||
## Зачем
|
||||
|
||||
Раньше форма «входного контракта» дублировалась в четырёх местах и неизбежно дрейфовала:
|
||||
`archive/kt_ai_atomic_product_system.md` (yaml) · JSON-конфиг в `kt-ai-app-shell.html` · `kt-ai-shadcn/.../kt-ai-product-input-contract.md` · вывод дизайнера Төре `[[APP]]`.
|
||||
|
||||
Теперь каноническая форма одна: **`product.schema.json`** (JSON Schema). Все остальные описания — производные от неё.
|
||||
|
||||
## Контур
|
||||
|
||||
```
|
||||
Паспорт агента ─┐
|
||||
├─► Төре (дизайнер [[APP]]) ──► product.config.json ──► kt-ai-app-shell.html ──► кликабельный прототип
|
||||
Интервью Төре ──┘ выдаёт контракт ▲ проверяет потребляет контракт (владелец кликает, комментирует)
|
||||
│
|
||||
scripts/validate_product.py
|
||||
(структура по схеме + lint правил ДС)
|
||||
│
|
||||
└──► handoff: тот же контракт читает kt-ai-prototype-handoff.md
|
||||
```
|
||||
|
||||
- **Төре выдаёт** контракт по `product.schema.json` (Төре не трогаем — он уже эмитит эту форму).
|
||||
- **`kt-ai-app-shell.html` потребляет** его из `<script id="kt-app-config">` — это рантайм, которым владеет ДС.
|
||||
- **`validate_product.py` проверяет** — структуру и правила честности.
|
||||
- **Handoff читает** тот же контракт: данные → mock-модуль, статусы → workflow, control → primary-действие.
|
||||
|
||||
## Поля (кратко; полная форма и все ограничения — в `product.schema.json`)
|
||||
|
||||
Ниже описаны **все 31 блок** схемы. Звёздочка — обязательное поле внутри блока.
|
||||
|
||||
| Блок | Назначение |
|
||||
|---|---|
|
||||
| `archetype` | форма экрана из открытого реестра (`queue` дефолт; см. `ARCHETYPES.md`). `layout` — deprecated-алиас |
|
||||
| `product` | name (H1, 1-3 слова), purpose (scope ≤90), entity, primary_user, language, **view** (`operational`/`management`) |
|
||||
| `user` | name, role владельца |
|
||||
| `notifications` | лента уведомлений (text, time, unread) |
|
||||
| `steps` | этапы мастера создания (items, current) — НЕ на главном списке |
|
||||
| `nav` | разделы sidebar (3-5 на группу): {label, icon}; `group` → общий заголовок для идущих подряд пунктов (заголовок не пункт: в нумерации разделов не участвует, в полосе значков становится разделителем); `view:"documents"`+`docType` → секция-коллекция документов (по `_docs` строк) с кнопкой «Скачать» |
|
||||
| `kpis` | тихая метрик-полоса; отвечают на работу пользователя, не на ROI продукта. Значение — всегда НОВОЕ (to-be) состояние: подпись «вместо X» не может стоять над значением X — сколько СТАЛО пишется в value, сколько БЫЛО остаётся в подписи или hint (валидатор ловит, DS-016). `hint` (≤40) — из чего считано; `note` (без потолка) — чем число НЕ является: оговорка о поле-источнике, стоит рядом с числом и не прячется под клик |
|
||||
| `scenario` | один сквозной сценарий: headline, **control** (ровно один primary), reject, confirmText, approveTo/rejectTo, `aiSummary` (что сделал агент — 1-2 фразы для drawer) |
|
||||
| `statuses` | словарь workflow-статусов: ключ → { label ≤4 слов, tone `ok/warn/risk/info`, `dim?` (гасит терминальные строки) } |
|
||||
| `table` | columns + rows — главный объект экрана. Строка: `_flags` (признаки агента), `_docs` (выходные документы [{label,type,status,format}] — карточки со «Скачать») |
|
||||
| `drawer` | детали объекта справа (titleKey, fields, flagsLabel) + секция «Документы» из `_docs` строки |
|
||||
| `ai` | точка входа Төре (intro, suggestions, answers, fallback) |
|
||||
| `banner` | системный баннер «что-почему-что делать» (text, tone). Стоит НАД числами: всё, что в него уходит, читают до первого числа |
|
||||
| `search` | поиск в шапке: `placeholder`, `compact` (узкое поле 176px), `shortcut` (сочетание ⌘K/Ctrl+K вместе с бейджем — включаются и выключаются одним полем). В React-ките те же поля — в пропе `search` у `KTAIShell` |
|
||||
| `conclusion` | вывод раздела (title, **text**) — последний блок экрана, после таблицы. Противовес баннера: баннер предупреждает ДО чисел, вывод объясняет ПОСЛЕ них. Пустая строка в `text` делит абзацы |
|
||||
| `compare` | данные архетипа compare: aLabel, bLabel, pairs[{label,a,b}] |
|
||||
| `sections` | данные архетипа custom: секции-карточки [{title, description?, body?, fields?}] |
|
||||
| `chart` | сводный bar-chart над таблицей: title, total, unit, color, link; `groupBy` (бары из фильтрованных строк) или `bucket:"day"`+`dateKey` (раскатка по дням + универсальный фильтр диапазона) |
|
||||
| `activity` | правый таймлайн-лог действий агента [{title, time, ago, tone, who, tags, summary, link}] — кнопка «Активность» в топбаре |
|
||||
| `onboarding` | карусель первого входа [{icon, title, text}] — показ один раз + ссылка повторного показа в низу sidebar |
|
||||
| `filterKeys` | какие поля строки дают вкладки-фильтры (string[]). Рантайм берёт **не более 4** + «Все» — закон Хика |
|
||||
| `dashboard` | обзорный экран: `series*` (ряды графика), `range` (период), `breakdowns` (разборы под графиком: top-N, доли, мини-карточки) |
|
||||
| `analytics` | аналитический экран: `charts*`, `findings*` (выводы словами), `summary`, `period` |
|
||||
| `document` | документ с фрагментами: `title*`, `fragments*` (абзацы; помеченные агентом — с причиной), `source` |
|
||||
| `artifact` | результат работы агента рядом с чатом: `type`, `title*`, `version`, `preview*`, `actions` (архетип copilot) |
|
||||
| `wizard` | многошаговый процесс: `steps*` (номер, название, 1-строчное описание), `current` — вертикальный степпер слева |
|
||||
| `inbox` | «список + чтение»: `bodyKey*` и ключи для заголовка/подписи/времени (`titleKey`, `subKey`, `timeKey`) |
|
||||
| `calendar` | окна и брони: `events*`, `range`, `title` |
|
||||
| `map` | география: `points*`, `lines`, `title` |
|
||||
| `timeline` | хронология события: `events*`, `title` |
|
||||
|
||||
### Field-level data contract (что делает прототип правдивым)
|
||||
|
||||
Колонка таблицы несёт не только `key`/`label`/`type`, но и:
|
||||
|
||||
- `values` — допустимые значения для status/enum-колонок (из паспорта/интервью);
|
||||
- `source` — откуда значение: SAP, Лотус, KTWorks, агент, человек;
|
||||
- `align` / `sortable` — поведение.
|
||||
|
||||
Без этого слоя дизайнер выдумывает примеры, и владелец процесса ревьюит вымысел. С ним владелец видит **свои** поля, статусы и источники — первый прототип становится правдивым.
|
||||
|
||||
## Как проверить
|
||||
|
||||
```bash
|
||||
python3 scripts/validate_product.py <ваш-конфиг>.json # черновая проверка (errors блокируют)
|
||||
python3 scripts/validate_product.py <ваш-конфиг>.json --strict # ВОРОТА handoff/CI: warnings тоже блокируют
|
||||
```
|
||||
|
||||
Правило: **прототип уходит в handoff/CI только при `--strict` без замечаний** (0 ошибок, 0 предупреждений). Без `--strict` проходят честностные warnings (ROI/FTE на operational, debug-язык, неизвестный архетип) — они допустимы на этапе черновика, но не на сдаче.
|
||||
|
||||
Валидатор — это seed `kt-ai-lint`. Он ловит:
|
||||
|
||||
- **ERROR** (блокирует): статус в ряду не объявлен в `statuses`; `scenario.approveTo/rejectTo` ссылается на несуществующий статус; `drawer.titleKey`/`fields` не из колонок; лишние/недостающие поля; tone вне `ok/warn/risk/info`; пустой `scenario.control`.
|
||||
- **WARN** (правила честности ДС): ROI/FTE/экономия на операционном дашборде (`view=operational`); debug-язык в копирайте (`модель вернула`, `fallback`, `JSON`, `prompt`, `shell`); статус-лейбл > 4 слов; имя экрана/лейблы длиннее нормы; мало строк (< 5).
|
||||
|
||||
## Правило
|
||||
|
||||
Меняется форма контракта — меняется **только** `product.schema.json`, затем синхронизируются производные описания и проверяется `examples/`. Не плодить пятую копию контракта.
|
||||
BIN
design-system/fonts/InterVariable-subset.woff2
Normal file
BIN
design-system/fonts/InterVariable-subset.woff2
Normal file
Binary file not shown.
BIN
design-system/fonts/JetBrainsMono-subset.woff2
Normal file
BIN
design-system/fonts/JetBrainsMono-subset.woff2
Normal file
Binary file not shown.
93
design-system/fonts/OFL-JetBrainsMono.txt
Normal file
93
design-system/fonts/OFL-JetBrainsMono.txt
Normal file
@ -0,0 +1,93 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
92
design-system/fonts/OFL.txt
Normal file
92
design-system/fonts/OFL.txt
Normal file
@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
43
design-system/icons/LUCIDE_LICENSE.txt
Normal file
43
design-system/icons/LUCIDE_LICENSE.txt
Normal file
@ -0,0 +1,43 @@
|
||||
ISC License
|
||||
|
||||
Copyright (c) 2026 Lucide Icons and Contributors
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
The following Lucide icons are derived from the Feather project:
|
||||
|
||||
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
|
||||
|
||||
The MIT License (MIT) (for the icons listed above)
|
||||
|
||||
Copyright (c) 2013-present Cole Bemis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
50
design-system/icons/README.md
Normal file
50
design-system/icons/README.md
Normal file
@ -0,0 +1,50 @@
|
||||
# KT AI Icons
|
||||
|
||||
Design system icon set: Lucide SVG, local sprite build.
|
||||
|
||||
Source:
|
||||
|
||||
- Official site: `https://lucide.dev`
|
||||
- Repository: `https://github.com/lucide-icons/lucide`
|
||||
- Local license copy: `LUCIDE_LICENSE.txt`
|
||||
|
||||
License:
|
||||
|
||||
- Lucide: ISC.
|
||||
- Icons derived from Feather: MIT, listed in `LUCIDE_LICENSE.txt`.
|
||||
|
||||
Usage rule:
|
||||
|
||||
- Use `kt-ai-lucide-sprite.svg` as the default icon source for KT AI products.
|
||||
- Do not draw icons with CSS pseudo-elements.
|
||||
- Do not load icon fonts or CDN icon sets for KT AI terminal products.
|
||||
- Keep icons monochrome, `currentColor`, 16px default, 1.75px stroke.
|
||||
- Use `sun` and `moon` for theme mode toggles.
|
||||
|
||||
HTML:
|
||||
|
||||
```html
|
||||
<svg class="kt-icon" aria-hidden="true">
|
||||
<use href="icons/kt-ai-lucide-sprite.svg#home"></use>
|
||||
</svg>
|
||||
```
|
||||
|
||||
Recommended product-local setup:
|
||||
|
||||
1. Copy `kt-ai-lucide-sprite.svg` into the product `assets/` folder as `icons.svg`.
|
||||
2. Use `<use href="assets/icons.svg#iconName">` in HTML.
|
||||
3. Keep the design-system source file unchanged so future products inherit the same icon language.
|
||||
|
||||
|
||||
## Как подключать спрайт (важно)
|
||||
|
||||
Внешняя ссылка `<use href="icons/kt-ai-lucide-sprite.svg#home">` **не работает**: браузеры
|
||||
блокируют внешние ссылки в `<use>` из `file://` и по CORS — иконки будут пустыми.
|
||||
|
||||
Рабочие способы:
|
||||
- **HTML-прототипы**: спрайт инлайнится в документ автоматически (`build_tokens.py`,
|
||||
функция `embed_appshell_sprite`), затем `<use href="#home">` — ссылка ВНУТРИ документа.
|
||||
- **React-кит**: используй компонент `<KTIcon name="home" />` (`components/kt-ai/kt-ai-icon.tsx`),
|
||||
спрайт лежит в `public/kt-ai/icons.svg`.
|
||||
- **Свой проект**: вставь содержимое спрайта в начало `<body>` (или отдай компонентом),
|
||||
дальше `<use href="#имя">`.
|
||||
385
design-system/icons/kt-ai-lucide-sprite.svg
Normal file
385
design-system/icons/kt-ai-lucide-sprite.svg
Normal file
@ -0,0 +1,385 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style="position:absolute;width:0;height:0;overflow:hidden">
|
||||
<symbol id="home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/>
|
||||
<path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
</symbol>
|
||||
<symbol id="chat" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22z"/>
|
||||
</symbol>
|
||||
<symbol id="send" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/>
|
||||
<path d="m21.854 2.147-10.94 10.939"/>
|
||||
</symbol>
|
||||
<symbol id="feed" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 11a9 9 0 0 1 9 9"/>
|
||||
<path d="M4 4a16 16 0 0 1 16 16"/>
|
||||
<circle cx="5" cy="19" r="1"/>
|
||||
</symbol>
|
||||
<symbol id="lots" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<circle cx="12" cy="12" r="1"/>
|
||||
</symbol>
|
||||
<symbol id="source" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3"/>
|
||||
<path d="M3 5V19A9 3 0 0 0 21 19V5"/>
|
||||
<path d="M3 12A9 3 0 0 0 21 12"/>
|
||||
</symbol>
|
||||
<symbol id="catalog" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 7v14"/>
|
||||
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>
|
||||
</symbol>
|
||||
<symbol id="decisions" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 5h8"/>
|
||||
<path d="M13 12h8"/>
|
||||
<path d="M13 19h8"/>
|
||||
<path d="m3 17 2 2 4-4"/>
|
||||
<path d="m3 7 2 2 4-4"/>
|
||||
</symbol>
|
||||
<symbol id="risk" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>
|
||||
<path d="M12 8v4"/>
|
||||
<path d="M12 16h.01"/>
|
||||
</symbol>
|
||||
<symbol id="recommender" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<circle cx="12" cy="12" r="6"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
</symbol>
|
||||
<symbol id="compare" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="18" cy="18" r="3"/>
|
||||
<circle cx="6" cy="6" r="3"/>
|
||||
<path d="M13 6h3a2 2 0 0 1 2 2v7"/>
|
||||
<path d="M11 18H8a2 2 0 0 1-2-2V9"/>
|
||||
</symbol>
|
||||
<symbol id="collections" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>
|
||||
</symbol>
|
||||
<symbol id="search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m21 21-4.34-4.34"/>
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
</symbol>
|
||||
<symbol id="settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 5H3"/>
|
||||
<path d="M12 19H3"/>
|
||||
<path d="M14 3v4"/>
|
||||
<path d="M16 17v4"/>
|
||||
<path d="M21 12h-9"/>
|
||||
<path d="M21 19h-5"/>
|
||||
<path d="M21 5h-7"/>
|
||||
<path d="M8 10v4"/>
|
||||
<path d="M8 12H3"/>
|
||||
</symbol>
|
||||
<symbol id="chart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 3v16a2 2 0 0 0 2 2h16"/>
|
||||
<path d="M7 16h8"/>
|
||||
<path d="M7 11h12"/>
|
||||
<path d="M7 6h3"/>
|
||||
</symbol>
|
||||
<symbol id="table" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18"/>
|
||||
</symbol>
|
||||
<symbol id="sort" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m21 16-4 4-4-4"/>
|
||||
<path d="M17 20V4"/>
|
||||
<path d="m3 8 4-4 4 4"/>
|
||||
<path d="M7 4v16"/>
|
||||
</symbol>
|
||||
<symbol id="filter" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z"/>
|
||||
</symbol>
|
||||
<symbol id="download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 15V3"/>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<path d="m7 10 5 5 5-5"/>
|
||||
</symbol>
|
||||
<symbol id="chevronRight" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m9 18 6-6-6-6"/>
|
||||
</symbol>
|
||||
<symbol id="external" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 3h6v6"/>
|
||||
<path d="M10 14 21 3"/>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
</symbol>
|
||||
<symbol id="help" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/>
|
||||
<path d="M12 17h.01"/>
|
||||
</symbol>
|
||||
<symbol id="history" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
<path d="M12 7v5l4 2"/>
|
||||
</symbol>
|
||||
<symbol id="info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 16v-4"/>
|
||||
<path d="M12 8h.01"/>
|
||||
</symbol>
|
||||
<symbol id="users" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
|
||||
<path d="M16 3.128a4 4 0 0 1 0 7.744"/>
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
</symbol>
|
||||
<symbol id="file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>
|
||||
<path d="M14 2v5a1 1 0 0 0 1 1h5"/>
|
||||
<path d="M10 9H8"/>
|
||||
<path d="M16 13H8"/>
|
||||
<path d="M16 17H8"/>
|
||||
</symbol>
|
||||
<symbol id="tool" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"/>
|
||||
</symbol>
|
||||
<symbol id="model" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20v2"/>
|
||||
<path d="M12 2v2"/>
|
||||
<path d="M17 20v2"/>
|
||||
<path d="M17 2v2"/>
|
||||
<path d="M2 12h2"/>
|
||||
<path d="M2 17h2"/>
|
||||
<path d="M2 7h2"/>
|
||||
<path d="M20 12h2"/>
|
||||
<path d="M20 17h2"/>
|
||||
<path d="M20 7h2"/>
|
||||
<path d="M7 20v2"/>
|
||||
<path d="M7 2v2"/>
|
||||
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
||||
<rect x="8" y="8" width="8" height="8" rx="1"/>
|
||||
</symbol>
|
||||
<symbol id="dashboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="7" height="9" x="3" y="3" rx="1"/>
|
||||
<rect width="7" height="5" x="14" y="3" rx="1"/>
|
||||
<rect width="7" height="9" x="14" y="12" rx="1"/>
|
||||
<rect width="7" height="5" x="3" y="16" rx="1"/>
|
||||
</symbol>
|
||||
<symbol id="columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2"/>
|
||||
<path d="M9 3v18"/>
|
||||
<path d="M15 3v18"/>
|
||||
</symbol>
|
||||
<symbol id="more" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="1"/>
|
||||
<circle cx="19" cy="12" r="1"/>
|
||||
<circle cx="5" cy="12" r="1"/>
|
||||
</symbol>
|
||||
<symbol id="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 6 9 17l-5-5"/>
|
||||
</symbol>
|
||||
<symbol id="x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6 6 18"/>
|
||||
<path d="m6 6 12 12"/>
|
||||
</symbol>
|
||||
<symbol id="alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/>
|
||||
<path d="M12 9v4"/>
|
||||
<path d="M12 17h.01"/>
|
||||
</symbol>
|
||||
<symbol id="clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<path d="M12 6v6l4 2"/>
|
||||
</symbol>
|
||||
<symbol id="arrowRight" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14"/>
|
||||
<path d="m12 5 7 7-7 7"/>
|
||||
</symbol>
|
||||
<symbol id="panelLeftClose" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2"/>
|
||||
<path d="M9 3v18"/>
|
||||
<path d="m16 15-3-3 3-3"/>
|
||||
</symbol>
|
||||
<symbol id="panelLeft" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2"/>
|
||||
<path d="M9 3v18"/>
|
||||
</symbol>
|
||||
<symbol id="trophy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 14.66v1.626a2 2 0 0 1-.976 1.696A5 5 0 0 0 7 21.978"/>
|
||||
<path d="M14 14.66v1.626a2 2 0 0 0 .976 1.696A5 5 0 0 1 17 21.978"/>
|
||||
<path d="M18 9h1.5a1 1 0 0 0 0-5H18"/>
|
||||
<path d="M4 22h16"/>
|
||||
<path d="M6 9a6 6 0 0 0 12 0V3a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1z"/>
|
||||
<path d="M6 9H4.5a1 1 0 0 1 0-5H6"/>
|
||||
</symbol>
|
||||
<symbol id="pin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/>
|
||||
<circle cx="12" cy="10" r="3"/>
|
||||
</symbol>
|
||||
<symbol id="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4"/>
|
||||
<path d="M12 2v2"/>
|
||||
<path d="M12 20v2"/>
|
||||
<path d="m4.93 4.93 1.41 1.41"/>
|
||||
<path d="m17.66 17.66 1.41 1.41"/>
|
||||
<path d="M2 12h2"/>
|
||||
<path d="M20 12h2"/>
|
||||
<path d="m6.34 17.66-1.41 1.41"/>
|
||||
<path d="m19.07 4.93-1.41 1.41"/>
|
||||
</symbol>
|
||||
<symbol id="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/>
|
||||
</symbol>
|
||||
<symbol id="menu" viewBox="0 0 24 24"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></symbol>
|
||||
<symbol id="book" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></symbol>
|
||||
<symbol id="rocket" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></symbol>
|
||||
<symbol id="sparkles" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z"/></symbol>
|
||||
<symbol id="play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 3 20 12 6 21 6 3"/></symbol>
|
||||
<symbol id="lightbulb" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/></symbol>
|
||||
<symbol id="plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M12 5v14"/></symbol>
|
||||
<symbol id="minus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/></symbol>
|
||||
<symbol id="chevronDown" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></symbol>
|
||||
<symbol id="chevronLeft" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></symbol>
|
||||
<symbol id="arrowLeft" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol>
|
||||
<symbol id="arrowUpDown" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m21 16-4 4-4-4"/><path d="M17 20V4"/><path d="m3 8 4-4 4 4"/><path d="M7 4v16"/></symbol>
|
||||
<symbol id="eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></symbol>
|
||||
<symbol id="calendar" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v4M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/></symbol>
|
||||
<symbol id="link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></symbol>
|
||||
<symbol id="copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol>
|
||||
<symbol id="trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></symbol>
|
||||
<symbol id="pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></symbol>
|
||||
<symbol id="user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
|
||||
<symbol id="bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></symbol>
|
||||
<symbol id="refresh" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></symbol>
|
||||
<symbol id="logout" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/></symbol>
|
||||
<symbol id="lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol>
|
||||
<symbol id="mail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></symbol>
|
||||
<symbol id="megaphone" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/></symbol>
|
||||
<symbol id="archive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="5" x="2" y="3" rx="1" />
|
||||
<path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" />
|
||||
<path d="M10 12h4" />
|
||||
</symbol>
|
||||
<symbol id="arrow-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" />
|
||||
<path d="m12 5 7 7-7 7" />
|
||||
</symbol>
|
||||
<symbol id="building" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 12h4" />
|
||||
<path d="M10 8h4" />
|
||||
<path d="M14 21v-3a2 2 0 0 0-4 0v3" />
|
||||
<path d="M6 10H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-2" />
|
||||
<path d="M6 21V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v16" />
|
||||
</symbol>
|
||||
<symbol id="chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</symbol>
|
||||
<symbol id="alert-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" x2="12" y1="8" y2="12" />
|
||||
<line x1="12" x2="12.01" y1="16" y2="16" />
|
||||
</symbol>
|
||||
<symbol id="check-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</symbol>
|
||||
<symbol id="clipboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||
<path d="M12 11h4" />
|
||||
<path d="M12 16h4" />
|
||||
<path d="M8 11h.01" />
|
||||
<path d="M8 16h.01" />
|
||||
</symbol>
|
||||
<symbol id="sync" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 13v8l-4-4" />
|
||||
<path d="m12 21 4-4" />
|
||||
<path d="M4.393 15.269A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.436 8.284" />
|
||||
</symbol>
|
||||
<symbol id="database" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
|
||||
<path d="M3 12A9 3 0 0 0 21 12" />
|
||||
</symbol>
|
||||
<symbol id="spreadsheet" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
|
||||
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
|
||||
<path d="M8 13h2" />
|
||||
<path d="M14 13h2" />
|
||||
<path d="M8 17h2" />
|
||||
<path d="M14 17h2" />
|
||||
</symbol>
|
||||
<symbol id="merge" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="18" cy="18" r="3" />
|
||||
<circle cx="6" cy="6" r="3" />
|
||||
<path d="M6 21V9a9 9 0 0 0 9 9" />
|
||||
</symbol>
|
||||
<symbol id="inbox" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</symbol>
|
||||
<symbol id="layers" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" />
|
||||
<path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" />
|
||||
<path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" />
|
||||
</symbol>
|
||||
<symbol id="checklist" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 5h8" />
|
||||
<path d="M13 12h8" />
|
||||
<path d="M13 19h8" />
|
||||
<path d="m3 17 2 2 4-4" />
|
||||
<path d="m3 7 2 2 4-4" />
|
||||
</symbol>
|
||||
<symbol id="package" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" />
|
||||
<path d="M12 22V12" />
|
||||
<polyline points="3.29 7 12 12 20.71 7" />
|
||||
<path d="m7.5 4.27 9 5.15" />
|
||||
</symbol>
|
||||
<symbol id="receipt" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 17V7" />
|
||||
<path d="M16 8h-6a2 2 0 0 0 0 4h4a2 2 0 0 1 0 4H8" />
|
||||
<path d="M4 3a1 1 0 0 1 1-1 1.3 1.3 0 0 1 .7.2l.933.6a1.3 1.3 0 0 0 1.4 0l.934-.6a1.3 1.3 0 0 1 1.4 0l.933.6a1.3 1.3 0 0 0 1.4 0l.933-.6a1.3 1.3 0 0 1 1.4 0l.934.6a1.3 1.3 0 0 0 1.4 0l.933-.6A1.3 1.3 0 0 1 19 2a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1 1.3 1.3 0 0 1-.7-.2l-.933-.6a1.3 1.3 0 0 0-1.4 0l-.934.6a1.3 1.3 0 0 1-1.4 0l-.933-.6a1.3 1.3 0 0 0-1.4 0l-.933.6a1.3 1.3 0 0 1-1.4 0l-.934-.6a1.3 1.3 0 0 0-1.4 0l-.933.6a1.3 1.3 0 0 1-.7.2 1 1 0 0 1-1-1z" />
|
||||
</symbol>
|
||||
<symbol id="scale" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3v18" />
|
||||
<path d="m19 8 3 8a5 5 0 0 1-6 0zV7" />
|
||||
<path d="M3 7h1a17 17 0 0 0 8-2 17 17 0 0 0 8 2h1" />
|
||||
<path d="m5 8 3 8a5 5 0 0 1-6 0zV7" />
|
||||
<path d="M7 21h10" />
|
||||
</symbol>
|
||||
<symbol id="split" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 3h5v5" />
|
||||
<path d="M8 3H3v5" />
|
||||
<path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3" />
|
||||
<path d="m15 9 6-6" />
|
||||
</symbol>
|
||||
<symbol id="warning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
|
||||
<path d="M12 9v4" />
|
||||
<path d="M12 17h.01" />
|
||||
</symbol>
|
||||
<symbol id="upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3v12" />
|
||||
<path d="m17 8-5-5-5 5" />
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
</symbol>
|
||||
<symbol id="circleNumber0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.575" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g transform="translate(12 12) scale(1.11111) translate(-12 -12)"><path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M10 10v4a2 2 0 1 0 4 0v-4a2 2 0 1 0 -4 0"/></g>
|
||||
</symbol>
|
||||
<symbol id="circleNumber1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.575" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g transform="translate(12 12) scale(1.11111) translate(-12 -12)"><path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M10 10l2 -2v8"/></g>
|
||||
</symbol>
|
||||
<symbol id="circleNumber2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.575" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g transform="translate(12 12) scale(1.11111) translate(-12 -12)"><path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M10 8h3a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-2a1 1 0 0 0 -1 1v2a1 1 0 0 0 1 1h3"/></g>
|
||||
</symbol>
|
||||
<symbol id="circleNumber3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.575" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g transform="translate(12 12) scale(1.11111) translate(-12 -12)"><path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M10 9a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-2h2a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1"/></g>
|
||||
</symbol>
|
||||
<symbol id="circleNumber4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.575" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g transform="translate(12 12) scale(1.11111) translate(-12 -12)"><path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M10 8v3a1 1 0 0 0 1 1h3"/><path d="M14 8v8"/></g>
|
||||
</symbol>
|
||||
<symbol id="circleNumber5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.575" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g transform="translate(12 12) scale(1.11111) translate(-12 -12)"><path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M10 15a1 1 0 0 0 1 1h2a1 1 0 0 0 1 -1v-2a1 1 0 0 0 -1 -1h-3v-4h4"/></g>
|
||||
</symbol>
|
||||
<symbol id="circleNumber6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.575" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g transform="translate(12 12) scale(1.11111) translate(-12 -12)"><path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M14 9a1 1 0 0 0 -1 -1h-2a1 1 0 0 0 -1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1 -1v-2a1 1 0 0 0 -1 -1h-3"/></g>
|
||||
</symbol>
|
||||
<symbol id="circleNumber7" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.575" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g transform="translate(12 12) scale(1.11111) translate(-12 -12)"><path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M10 8h4l-2 8"/></g>
|
||||
</symbol>
|
||||
<symbol id="circleNumber8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.575" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g transform="translate(12 12) scale(1.11111) translate(-12 -12)"><path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M12 12h-1a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-2a1 1 0 0 0 -1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1 -1v-2a1 1 0 0 0 -1 -1"/></g>
|
||||
</symbol>
|
||||
<symbol id="circleNumber9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.575" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g transform="translate(12 12) scale(1.11111) translate(-12 -12)"><path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M10 15a1 1 0 0 0 1 1h2a1 1 0 0 0 1 -1v-6a1 1 0 0 0 -1 -1h-2a1 1 0 0 0 -1 1v2a1 1 0 0 0 1 1h3"/></g>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 30 KiB |
168
design-system/kt-ai-chart-tip.js
Normal file
168
design-system/kt-ai-chart-tip.js
Normal file
@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Подсказка значения на диаграмме — один файл на оба рантайма.
|
||||
*
|
||||
* Зачем это в системе, а не в продукте. Столбец молчал: человек видел форму, но
|
||||
* не число, и уходил за ним в таблицу под диаграммой — диаграмма переставала
|
||||
* быть ответом и становилась оглавлением. Затыкали это нативным `title`, а он
|
||||
* не компонент: появляется через секунду, рисуется средствами ОС (чужой шрифт,
|
||||
* ни одного токена, в тёмной теме — вставка из другого продукта), недоступен с
|
||||
* клавиатуры, не читается диктором, на планшете не показывается вовсе и умеет
|
||||
* ровно одну строку. Собери такую подсказку каждый продукт сам — их станет
|
||||
* пять разных.
|
||||
*
|
||||
* ЧИСЛА СЧИТАЕТ ПРОДУКТ. Скрипт не форматирует и не пересчитывает ничего: он
|
||||
* показывает строки, которые пришли готовыми. Разошедшийся формат — это
|
||||
* разошедшееся число, а сервер и браузер округляют по-разному.
|
||||
*
|
||||
* Разметка марки:
|
||||
* <div class="cc-bar" data-kt-tip="14.05.2026: 1 284">…</div>
|
||||
* <div class="cc-bar" data-kt-tip='{"title":"14 мая 2026, четверг",
|
||||
* "rows":[{"label":"Диалогов","value":"1 284"},
|
||||
* {"label":"Доля периода","value":"3,9%"}]}'>…</div>
|
||||
*
|
||||
* Слушателей ровно три, на document, — не по одному на марку. Диаграмма
|
||||
* перерисовывается на каждую смену фильтра и периода (31 столбец на обзоре,
|
||||
* 168 клеток на карте нагрузки), и подсказка обязана пережить перерисовку без
|
||||
* повторной инициализации со стороны продукта.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
if (typeof document === "undefined" || window.ktAiChartTip) return;
|
||||
|
||||
var МЕТКА = "[data-kt-tip]";
|
||||
var узел = null; // единственный элемент подсказки на страницу
|
||||
var текущая = null; // марка, к которой она привязана
|
||||
|
||||
function создать() {
|
||||
if (узел) return узел;
|
||||
узел = document.createElement("div");
|
||||
узел.className = "kt-ai-chart-tip";
|
||||
узел.setAttribute("role", "tooltip");
|
||||
узел.id = "kt-ai-chart-tip";
|
||||
узел.hidden = true;
|
||||
document.body.appendChild(узел);
|
||||
return узел;
|
||||
}
|
||||
|
||||
function текстом(строка) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "tip-title";
|
||||
el.textContent = строка;
|
||||
return [el];
|
||||
}
|
||||
|
||||
/** Разбирает data-kt-tip: JSON-объект или простая строка. */
|
||||
function содержимое(марка) {
|
||||
var сырое = марка.getAttribute("data-kt-tip") || "";
|
||||
if (сырое.charAt(0) !== "{") return текстом(сырое);
|
||||
var данные;
|
||||
try {
|
||||
данные = JSON.parse(сырое);
|
||||
} catch {
|
||||
return текстом(сырое); // битый JSON показываем как есть, а не роняем экран
|
||||
}
|
||||
var части = [];
|
||||
if (данные.title) {
|
||||
var t = document.createElement("div");
|
||||
t.className = "tip-title";
|
||||
t.textContent = данные.title;
|
||||
части.push(t);
|
||||
}
|
||||
var строки = Array.isArray(данные.rows) ? данные.rows.slice(0, 3) : [];
|
||||
if (строки.length) {
|
||||
var таблица = document.createElement("dl");
|
||||
таблица.className = "tip-rows";
|
||||
строки.forEach(function (r) {
|
||||
var dt = document.createElement("dt");
|
||||
dt.textContent = r && r.label != null ? String(r.label) : "";
|
||||
var dd = document.createElement("dd");
|
||||
dd.textContent = r && r.value != null ? String(r.value) : "";
|
||||
таблица.appendChild(dt);
|
||||
таблица.appendChild(dd);
|
||||
});
|
||||
части.push(таблица);
|
||||
}
|
||||
return части.length ? части : текстом(сырое);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ставит подсказку над маркой и разворачивает внутрь у края.
|
||||
* Привязка к МАРКЕ, а не к курсору: иначе подсказка дрожит вместе с мышью и
|
||||
* читать её на ходу нельзя.
|
||||
*/
|
||||
function поставить(марка) {
|
||||
var m = марка.getBoundingClientRect();
|
||||
var t = узел.getBoundingClientRect();
|
||||
var поля = 8;
|
||||
var x = m.left + m.width / 2 - t.width / 2;
|
||||
x = Math.max(поля, Math.min(x, window.innerWidth - t.width - поля));
|
||||
var сверху = m.top - t.height - 8;
|
||||
var снизу = m.bottom + 8;
|
||||
// Не хватило места сверху (самый высокий столбец) — уходим под марку.
|
||||
var y = сверху >= поля ? сверху : снизу;
|
||||
узел.dataset.side = сверху >= поля ? "top" : "bottom";
|
||||
узел.style.left = Math.round(x + window.scrollX) + "px";
|
||||
узел.style.top = Math.round(y + window.scrollY) + "px";
|
||||
}
|
||||
|
||||
function показать(марка) {
|
||||
if (!марка || марка === текущая) return;
|
||||
скрыть();
|
||||
создать();
|
||||
узел.replaceChildren.apply(узел, содержимое(марка));
|
||||
узел.hidden = false;
|
||||
текущая = марка;
|
||||
марка.setAttribute("data-kt-tip-open", "true");
|
||||
марка.setAttribute("aria-describedby", узел.id);
|
||||
поставить(марка);
|
||||
}
|
||||
|
||||
function скрыть() {
|
||||
if (!текущая) return;
|
||||
текущая.removeAttribute("data-kt-tip-open");
|
||||
текущая.removeAttribute("aria-describedby");
|
||||
текущая = null;
|
||||
if (узел) узел.hidden = true;
|
||||
}
|
||||
|
||||
function марка(цель) {
|
||||
return цель && цель.closest ? цель.closest(МЕТКА) : null;
|
||||
}
|
||||
|
||||
document.addEventListener("pointerover", function (e) {
|
||||
// На тач-устройстве pointerover приходит вместе с нажатием — там показом
|
||||
// управляет pointerdown ниже, иначе подсказка мигнёт и исчезнет.
|
||||
if (e.pointerType === "touch") return;
|
||||
var м = марка(e.target);
|
||||
if (м) показать(м); else скрыть();
|
||||
});
|
||||
|
||||
document.addEventListener("pointerdown", function (e) {
|
||||
if (e.pointerType !== "touch") return;
|
||||
var м = марка(e.target);
|
||||
if (м) показать(м); else скрыть();
|
||||
});
|
||||
|
||||
document.addEventListener("focusin", function (e) {
|
||||
var м = марка(e.target);
|
||||
if (м) показать(м); else скрыть();
|
||||
});
|
||||
document.addEventListener("focusout", function (e) {
|
||||
if (марка(e.target)) скрыть();
|
||||
});
|
||||
|
||||
// Esc убирает подсказку, НЕ снимая фокус: человек продолжает идти по маркам
|
||||
// с клавиатуры, просто перестал смотреть на числа.
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape" && текущая) {
|
||||
e.stopPropagation();
|
||||
скрыть();
|
||||
}
|
||||
});
|
||||
|
||||
// Прокрутка и смена размера сдвигают марку — подсказка обязана уехать с ней.
|
||||
window.addEventListener("scroll", function () { if (текущая) поставить(текущая); }, true);
|
||||
window.addEventListener("resize", скрыть);
|
||||
|
||||
window.ktAiChartTip = { show: показать, hide: скрыть };
|
||||
})();
|
||||
1918
design-system/kt-ai-components.css
Normal file
1918
design-system/kt-ai-components.css
Normal file
File diff suppressed because it is too large
Load Diff
187
design-system/kt-ai-composer.js
Normal file
187
design-system/kt-ai-composer.js
Normal file
@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Композер чата: slash-команды, пилюли навыков, вложения.
|
||||
*
|
||||
* Идеи из ai-agent-input (aicss.dev): навык выбирается по «/», становится
|
||||
* объектом, а не текстом, и удаляется целиком. Код оттуда не используется —
|
||||
* там всё построено на contenteditable, а у нас textarea (см. COMPONENTS.md,
|
||||
* «Композер чата»: почему переписывать на contenteditable нельзя).
|
||||
*
|
||||
* Разметка:
|
||||
* <div class="kt-ai-prompt-bar" data-composer="true"
|
||||
* data-skills='[{"id":"sverka","label":"Сверка","hint":"Сравнить счёт"}]'>
|
||||
* <div class="kt-ai-prompt-skills"></div>
|
||||
* <textarea rows="1" placeholder="Спросите… «/» — навыки"></textarea>
|
||||
* <button class="kt-ai-prompt-send">…</button>
|
||||
* </div>
|
||||
* <script src="kt-ai-composer.js"></script>
|
||||
*
|
||||
* События: composer:submit (detail = {text, skills, files}).
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var doc = global.document;
|
||||
|
||||
function экран(s) {
|
||||
return String(s).replace(/[&<>"]/g, function (c) {
|
||||
return { "&": "&", "<": "<", ">": ">", '"': """ }[c];
|
||||
});
|
||||
}
|
||||
|
||||
function mount(bar) {
|
||||
if (!bar || bar.__ktComposer) return;
|
||||
var ta = bar.querySelector("textarea");
|
||||
if (!ta) return;
|
||||
|
||||
var ряд = bar.querySelector(".kt-ai-prompt-skills");
|
||||
if (!ряд) {
|
||||
ряд = doc.createElement("div");
|
||||
ряд.className = "kt-ai-prompt-skills";
|
||||
bar.insertBefore(ряд, bar.firstChild);
|
||||
}
|
||||
|
||||
var навыки = [];
|
||||
try { навыки = JSON.parse(bar.getAttribute("data-skills") || "[]"); } catch (e) { навыки = []; }
|
||||
|
||||
var меню = doc.createElement("div");
|
||||
меню.className = "kt-ai-slash-menu";
|
||||
меню.setAttribute("role", "listbox");
|
||||
меню.hidden = true;
|
||||
bar.appendChild(меню);
|
||||
|
||||
var выбранные = []; // id выбранных навыков
|
||||
var активный = 0; // подсвеченный пункт меню
|
||||
var найденные = [];
|
||||
|
||||
function добавить(навык) {
|
||||
if (выбранные.indexOf(навык.id) >= 0) return;
|
||||
выбранные.push(навык.id);
|
||||
var el = doc.createElement("span");
|
||||
el.className = "kt-ai-prompt-skill";
|
||||
el.setAttribute("data-skill", навык.id);
|
||||
el.innerHTML = экран(навык.label) +
|
||||
'<button type="button" aria-label="Убрать навык «' + экран(навык.label) + '»">' +
|
||||
'<svg class="kt-icon" style="width:10px;height:10px" aria-hidden="true"><use href="#x"></use></svg></button>';
|
||||
el.querySelector("button").addEventListener("click", function () { убрать(навык.id); });
|
||||
ряд.appendChild(el);
|
||||
}
|
||||
|
||||
function убрать(id) {
|
||||
выбранные = выбранные.filter(function (x) { return x !== id; });
|
||||
var el = ряд.querySelector('[data-skill="' + id + '"]');
|
||||
if (el) el.remove();
|
||||
ta.focus();
|
||||
}
|
||||
|
||||
/** Запрос после «/» непосредственно перед кареткой. null — меню не нужно. */
|
||||
function запрос() {
|
||||
var до = ta.value.slice(0, ta.selectionStart);
|
||||
var m = до.match(/(?:^|\s)\/([^\s/]*)$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function показать() {
|
||||
var q = запрос();
|
||||
if (q === null) return скрыть();
|
||||
var qq = q.toLowerCase();
|
||||
найденные = навыки.filter(function (s) {
|
||||
return выбранные.indexOf(s.id) < 0 &&
|
||||
(s.label.toLowerCase().indexOf(qq) >= 0 || (s.hint || "").toLowerCase().indexOf(qq) >= 0);
|
||||
});
|
||||
if (!найденные.length) return скрыть();
|
||||
активный = 0;
|
||||
отрисовать();
|
||||
меню.hidden = false;
|
||||
ta.setAttribute("aria-expanded", "true");
|
||||
}
|
||||
|
||||
function отрисовать() {
|
||||
меню.innerHTML = найденные.map(function (s, i) {
|
||||
return '<button type="button" role="option" class="kt-ai-slash-item"' +
|
||||
(i === активный ? ' data-active="true" aria-selected="true"' : ' aria-selected="false"') +
|
||||
' data-id="' + экран(s.id) + '">' +
|
||||
"<span>" + экран(s.label) + "</span>" +
|
||||
(s.hint ? '<span class="kt-ai-meta">' + экран(s.hint) + "</span>" : "") +
|
||||
"</button>";
|
||||
}).join("");
|
||||
[].forEach.call(меню.querySelectorAll(".kt-ai-slash-item"), function (b) {
|
||||
b.addEventListener("mousedown", function (e) {
|
||||
e.preventDefault(); // не отдаём фокус: каретка нужна на месте
|
||||
выбрать(b.getAttribute("data-id"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function скрыть() {
|
||||
меню.hidden = true;
|
||||
ta.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
function выбрать(id) {
|
||||
var навык = навыки.filter(function (s) { return s.id === id; })[0];
|
||||
if (!навык) return;
|
||||
// Убираем «/запрос» из текста — навык теперь объект, а не строка.
|
||||
var p = ta.selectionStart;
|
||||
var до = ta.value.slice(0, p).replace(/(?:^|\s)\/[^\s/]*$/, function (m) {
|
||||
return m[0] === "/" ? "" : m[0];
|
||||
});
|
||||
ta.value = до + ta.value.slice(p);
|
||||
ta.setSelectionRange(до.length, до.length);
|
||||
добавить(навык);
|
||||
скрыть();
|
||||
ta.focus();
|
||||
}
|
||||
|
||||
ta.addEventListener("input", показать);
|
||||
ta.addEventListener("click", показать);
|
||||
ta.addEventListener("blur", function () { setTimeout(скрыть, 0); });
|
||||
|
||||
ta.addEventListener("keydown", function (e) {
|
||||
if (!меню.hidden) {
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); активный = (активный + 1) % найденные.length; отрисовать(); return; }
|
||||
if (e.key === "ArrowUp") { e.preventDefault(); активный = (активный - 1 + найденные.length) % найденные.length; отрисовать(); return; }
|
||||
if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); выбрать(найденные[активный].id); return; }
|
||||
if (e.key === "Escape") { e.preventDefault(); скрыть(); return; }
|
||||
}
|
||||
// Бэкспейс в пустом поле снимает последний навык — он ведёт себя как
|
||||
// объект в строке, хотя физически лежит рядом.
|
||||
if (e.key === "Backspace" && !ta.value && выбранные.length) {
|
||||
e.preventDefault();
|
||||
убрать(выбранные[выбранные.length - 1]);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); отправить(); }
|
||||
});
|
||||
|
||||
function отправить() {
|
||||
var текст = ta.value.trim();
|
||||
if (!текст && !выбранные.length) return;
|
||||
bar.dispatchEvent(new CustomEvent("composer:submit", {
|
||||
bubbles: true,
|
||||
detail: { text: текст, skills: выбранные.slice() },
|
||||
}));
|
||||
ta.value = "";
|
||||
выбранные.slice().forEach(убрать);
|
||||
}
|
||||
|
||||
var send = bar.querySelector(".kt-ai-prompt-send");
|
||||
if (send) send.addEventListener("click", отправить);
|
||||
|
||||
bar.__ktComposer = {
|
||||
добавитьНавык: добавить,
|
||||
убратьНавык: убрать,
|
||||
выбранные: function () { return выбранные.slice(); },
|
||||
отправить: отправить,
|
||||
меню: меню,
|
||||
};
|
||||
}
|
||||
|
||||
function mountAll(root) {
|
||||
var nodes = (root || doc).querySelectorAll('.kt-ai-prompt-bar[data-composer="true"]');
|
||||
for (var i = 0; i < nodes.length; i++) mount(nodes[i]);
|
||||
}
|
||||
|
||||
global.ktAiComposer = { mount: mount, mountAll: mountAll };
|
||||
if (doc && doc.readyState !== "loading") mountAll();
|
||||
else if (doc) doc.addEventListener("DOMContentLoaded", function () { mountAll(); });
|
||||
})(typeof window !== "undefined" ? window : this);
|
||||
337
design-system/kt-ai-feedback.js
Normal file
337
design-system/kt-ai-feedback.js
Normal file
@ -0,0 +1,337 @@
|
||||
/*
|
||||
* KT AI — режим обратной связи прототипа (drop-in).
|
||||
* Закрывает петлю «показал владельцу → собрал фидбэк → итерировал» в контексте.
|
||||
* Владелец процесса включает режим, кликает по любому элементу, оставляет заметку;
|
||||
* все заметки экспортируются в markdown, который БА берёт в работу.
|
||||
*
|
||||
* Подключение (одна строка, после kt-ai-components.css):
|
||||
* <script src="kt-ai-feedback.js" defer><\/script> (escaped close tag — безопасно и при инлайне)
|
||||
*
|
||||
* Самодостаточен: стили инжектятся на токенах ДС (обе темы), без зависимостей.
|
||||
* Канон: docs/PRODUCT_CONTRACT.md, docs/GOAL.md (DoD #6 — замкнутая петля обратной связи).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
if (window.__ktFeedback) return;
|
||||
window.__ktFeedback = true;
|
||||
|
||||
var KEY = 'kt-ai-feedback:' + location.pathname;
|
||||
var comments = load();
|
||||
var active = false;
|
||||
var visible = true;
|
||||
var seq = comments.reduce(function (m, c) { return Math.max(m, c.n); }, 0);
|
||||
|
||||
// ---------- стили (на semantic-токенах ДС) ----------
|
||||
function injectStyles() {
|
||||
var css = [
|
||||
'.ktfb-ui{font-family:var(--kt-ai-font-sans);box-sizing:border-box}',
|
||||
'.ktfb-ui *{box-sizing:border-box}',
|
||||
'.ktfb-fab{position:fixed;left:20px;bottom:20px;z-index:65;display:inline-flex;align-items:center;gap:var(--kt-ai-space-3);',
|
||||
' height:var(--kt-ai-control-h-lg);padding:0 var(--kt-ai-space-5);border-radius:var(--kt-ai-radius-full);cursor:pointer;',
|
||||
' background:var(--kt-ai-bg-elevated);color:var(--kt-ai-fg);border:1px solid var(--kt-ai-border-strong);',
|
||||
' box-shadow:var(--kt-ai-shadow-md);font-size:var(--kt-ai-text-sm);font-weight:var(--kt-ai-weight-medium)}',
|
||||
'.ktfb-fab[data-on="true"]{background:var(--kt-ai-primary);color:var(--kt-ai-fg-on-fill);border-color:var(--kt-ai-primary)}',
|
||||
'.ktfb-fab .dot{width:8px;height:8px;border-radius:50%;background:var(--kt-ai-primary)}',
|
||||
'.ktfb-fab[data-on="true"] .dot{background:var(--kt-ai-fg-on-fill)}',
|
||||
'.ktfb-fab .cnt{font-family:var(--kt-ai-font-mono);font-size:var(--kt-ai-text-2xs);opacity:.8}',
|
||||
'body.ktfb-pick *{cursor:crosshair!important}',
|
||||
'body.ktfb-pick .ktfb-ui, body.ktfb-pick .ktfb-ui *{cursor:default!important}',
|
||||
'.ktfb-hl{outline:2px solid var(--kt-ai-primary)!important;outline-offset:1px!important;border-radius:var(--kt-ai-radius-sm)}',
|
||||
'.ktfb-layer{position:fixed;inset:0;z-index:64;pointer-events:none}',
|
||||
'.ktfb-pin{position:fixed;z-index:64;width:22px;height:22px;margin:-11px 0 0 -11px;border-radius:50%;',
|
||||
' background:var(--kt-ai-primary);color:var(--kt-ai-fg-on-fill);font-size:var(--kt-ai-text-2xs);',
|
||||
' font-weight:var(--kt-ai-weight-bold);display:flex;align-items:center;justify-content:center;',
|
||||
' pointer-events:auto;cursor:pointer;box-shadow:var(--kt-ai-shadow-sm);border:1.5px solid var(--kt-ai-bg-elevated)}',
|
||||
'.ktfb-panel{position:fixed;left:20px;bottom:72px;z-index:65;width:320px;max-height:60vh;display:flex;flex-direction:column;',
|
||||
' background:var(--kt-ai-bg-elevated);border:1px solid var(--kt-ai-border);border-radius:var(--kt-ai-radius-3xl);',
|
||||
' box-shadow:var(--kt-ai-shadow-lg);overflow:hidden}',
|
||||
'.ktfb-panel[hidden]{display:none}',
|
||||
'.ktfb-head{display:flex;align-items:center;gap:var(--kt-ai-space-4);padding:var(--kt-ai-space-5) var(--kt-ai-space-6);border-bottom:1px solid var(--kt-ai-divider)}',
|
||||
'.ktfb-head .t{font-size:var(--kt-ai-text-sm);font-weight:var(--kt-ai-weight-semibold);color:var(--kt-ai-fg)}',
|
||||
'.ktfb-head .sp{margin-left:auto}',
|
||||
'.ktfb-list{overflow:auto;padding:var(--kt-ai-space-4);display:flex;flex-direction:column;gap:var(--kt-ai-space-3)}',
|
||||
'.ktfb-item{display:flex;gap:var(--kt-ai-space-4);padding:var(--kt-ai-space-4);border-radius:var(--kt-ai-radius-2xl);background:var(--kt-ai-bg-soft)}',
|
||||
'.ktfb-item .n{flex-shrink:0;width:20px;height:20px;border-radius:50%;background:var(--kt-ai-primary);color:var(--kt-ai-fg-on-fill);',
|
||||
' font-size:var(--kt-ai-text-2xs);font-weight:var(--kt-ai-weight-bold);display:flex;align-items:center;justify-content:center}',
|
||||
'.ktfb-item .body{min-width:0;flex:1}',
|
||||
'.ktfb-item .tgt{font-size:var(--kt-ai-text-2xs);color:var(--kt-ai-fg-faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
|
||||
'.ktfb-item .txt{font-size:var(--kt-ai-text-xs);color:var(--kt-ai-fg);margin-top:2px;white-space:pre-wrap;word-break:break-word}',
|
||||
'.ktfb-item .del{flex-shrink:0;background:none;border:none;color:var(--kt-ai-fg-faint);cursor:pointer;font-size:14px;line-height:1;padding:2px}',
|
||||
'.ktfb-item .del:hover{color:var(--kt-ai-danger)}',
|
||||
'.ktfb-empty{padding:var(--kt-ai-space-7);text-align:center;color:var(--kt-ai-fg-muted);font-size:var(--kt-ai-text-xs)}',
|
||||
'.ktfb-foot{display:flex;gap:var(--kt-ai-space-4);padding:var(--kt-ai-space-5);border-top:1px solid var(--kt-ai-divider)}',
|
||||
'.ktfb-foot button{flex:1}',
|
||||
'.ktfb-pop{position:fixed;z-index:66;width:260px;background:var(--kt-ai-bg-elevated);border:1px solid var(--kt-ai-border);',
|
||||
' border-radius:var(--kt-ai-radius-2xl);box-shadow:var(--kt-ai-shadow-lg);padding:var(--kt-ai-space-5)}',
|
||||
'.ktfb-pop[hidden]{display:none}',
|
||||
'.ktfb-pop .tgt{font-size:var(--kt-ai-text-2xs);color:var(--kt-ai-fg-faint);margin-bottom:var(--kt-ai-space-3);',
|
||||
' white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
|
||||
'.ktfb-pop textarea{width:100%;min-height:64px;resize:vertical;font-family:var(--kt-ai-font-sans);font-size:var(--kt-ai-text-sm);',
|
||||
' color:var(--kt-ai-fg);background:var(--kt-ai-bg);border:1px solid var(--kt-ai-border);border-radius:var(--kt-ai-radius-xl);',
|
||||
' padding:var(--kt-ai-space-4)}',
|
||||
'.ktfb-pop textarea:focus{outline:none;border-color:var(--kt-ai-primary);box-shadow:var(--kt-ai-focus-shadow)}',
|
||||
'.ktfb-pop .row{display:flex;gap:var(--kt-ai-space-4);margin-top:var(--kt-ai-space-4)}',
|
||||
'.ktfb-pop .row button{flex:1}',
|
||||
// если в прототипе нет компонентов ДС — минимальный фолбэк для наших кнопок
|
||||
'.ktfb-ui .ktfb-btn{height:var(--kt-ai-control-h-md);padding:0 var(--kt-ai-space-5);border-radius:var(--kt-ai-radius-xl);',
|
||||
' font-size:var(--kt-ai-text-sm);font-family:var(--kt-ai-font-sans);cursor:pointer;border:1px solid var(--kt-ai-border-strong);',
|
||||
' background:var(--kt-ai-bg-elevated);color:var(--kt-ai-fg)}',
|
||||
'.ktfb-ui .ktfb-btn[data-primary]{background:var(--kt-ai-primary);color:var(--kt-ai-fg-on-fill);border-color:var(--kt-ai-primary);',
|
||||
' font-weight:var(--kt-ai-weight-semibold)}',
|
||||
'.ktfb-ui .ktfb-btn:disabled{opacity:.4;cursor:default}',
|
||||
'@media print{.ktfb-ui{display:none!important}}'
|
||||
].join('\n');
|
||||
var s = document.createElement('style');
|
||||
s.id = 'ktfb-styles';
|
||||
s.textContent = css;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
// ---------- хранилище ----------
|
||||
function load() {
|
||||
try { return JSON.parse(localStorage.getItem(KEY)) || []; } catch { return []; }
|
||||
}
|
||||
function save() {
|
||||
try { localStorage.setItem(KEY, JSON.stringify(comments)); } catch {}
|
||||
}
|
||||
|
||||
// ---------- надёжный селектор элемента ----------
|
||||
function selectorFor(el) {
|
||||
if (!el || el === document.body) return 'body';
|
||||
var parts = [];
|
||||
while (el && el.nodeType === 1 && el !== document.body && parts.length < 6) {
|
||||
var tag = el.tagName.toLowerCase();
|
||||
var p = el.parentNode;
|
||||
if (p) {
|
||||
var sib = Array.prototype.filter.call(p.children, function (c) { return c.tagName === el.tagName; });
|
||||
if (sib.length > 1) tag += ':nth-of-type(' + (sib.indexOf(el) + 1) + ')';
|
||||
}
|
||||
parts.unshift(tag);
|
||||
el = p;
|
||||
}
|
||||
return parts.join(' > ');
|
||||
}
|
||||
function labelFor(el) {
|
||||
var t = (el.getAttribute && el.getAttribute('aria-label')) || el.textContent || el.tagName.toLowerCase();
|
||||
t = t.replace(/\s+/g, ' ').trim();
|
||||
return t.length > 60 ? t.slice(0, 60) + '…' : (t || el.tagName.toLowerCase());
|
||||
}
|
||||
|
||||
// ---------- UI ----------
|
||||
var fab, panel, list, layer, pop, popState = null;
|
||||
|
||||
var triggers = [];
|
||||
function build() {
|
||||
fab = el('button', 'ktfb-ui ktfb-fab', { type: 'button', 'aria-label': 'Режим обратной связи' });
|
||||
fab.innerHTML = '<span class="dot"></span><span class="lbl">Отзыв</span> <span class="cnt"></span>';
|
||||
fab.addEventListener('click', toggleMode);
|
||||
|
||||
panel = el('div', 'ktfb-ui ktfb-panel', { hidden: '' });
|
||||
panel.innerHTML =
|
||||
'<div class="ktfb-head"><span class="t">Отзыв по прототипу</span>' +
|
||||
'<span class="sp"></span>' +
|
||||
'<button class="ktfb-btn" data-act="hide" type="button" style="height:auto;padding:4px 10px">Скрыть</button></div>' +
|
||||
'<div class="ktfb-list"></div>' +
|
||||
'<div class="ktfb-foot">' +
|
||||
'<button class="ktfb-btn" data-act="clear" type="button">Очистить</button>' +
|
||||
'<button class="ktfb-btn" data-primary data-act="export" type="button">Экспорт в markdown</button></div>';
|
||||
list = panel.querySelector('.ktfb-list');
|
||||
panel.addEventListener('click', onPanelClick);
|
||||
|
||||
layer = el('div', 'ktfb-ui ktfb-layer');
|
||||
|
||||
pop = el('div', 'ktfb-ui ktfb-pop', { hidden: '' });
|
||||
pop.innerHTML =
|
||||
'<div class="tgt"></div><textarea placeholder="Что не так / что улучшить?"></textarea>' +
|
||||
'<div class="row"><button class="ktfb-btn" data-act="cancel" type="button">Отмена</button>' +
|
||||
'<button class="ktfb-btn" data-primary data-act="add" type="button">Добавить</button></div>';
|
||||
pop.addEventListener('click', onPopClick);
|
||||
|
||||
document.body.appendChild(layer);
|
||||
document.body.appendChild(panel);
|
||||
document.body.appendChild(pop);
|
||||
document.body.appendChild(fab);
|
||||
|
||||
// Внешний триггер (напр. в нижней части сайдбара): прячем плавающий fab, навешиваем toggle.
|
||||
triggers = Array.prototype.slice.call(document.querySelectorAll('[data-kt-feedback-trigger]'));
|
||||
if (triggers.length) {
|
||||
fab.style.display = 'none';
|
||||
triggers.forEach(function (t) {
|
||||
t.classList.add('ktfb-ui');
|
||||
t.addEventListener('click', function (e) { e.preventDefault(); toggleMode(); });
|
||||
});
|
||||
}
|
||||
window.ktFeedback = { toggle: toggleMode, isActive: function () { return active; } };
|
||||
|
||||
window.addEventListener('scroll', positionPins, true);
|
||||
window.addEventListener('resize', positionPins);
|
||||
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { closePop(); if (active) toggleMode(); } });
|
||||
|
||||
renderAll();
|
||||
}
|
||||
|
||||
function el(tag, cls, attrs) {
|
||||
var n = document.createElement(tag);
|
||||
if (cls) n.className = cls;
|
||||
if (attrs) for (var k in attrs) n.setAttribute(k, attrs[k]);
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---------- режим выбора ----------
|
||||
function toggleMode() {
|
||||
active = !active;
|
||||
fab.setAttribute('data-on', active);
|
||||
var ktfbLbl = active ? 'Готово' : 'Отзыв';
|
||||
fab.querySelector('.lbl').textContent = ktfbLbl;
|
||||
triggers.forEach(function (t) { t.setAttribute('data-on', active); var l = t.querySelector('[data-ktfb-label]'); if (l) l.textContent = ktfbLbl; });
|
||||
document.body.classList.toggle('ktfb-pick', active);
|
||||
if (active) {
|
||||
visible = true; panel.hidden = false; renderPanel();
|
||||
document.addEventListener('mouseover', onHover, true);
|
||||
document.addEventListener('mouseout', onHoverOut, true);
|
||||
document.addEventListener('click', onPick, true);
|
||||
} else {
|
||||
closePop();
|
||||
document.removeEventListener('mouseover', onHover, true);
|
||||
document.removeEventListener('mouseout', onHoverOut, true);
|
||||
document.removeEventListener('click', onPick, true);
|
||||
clearHighlight();
|
||||
}
|
||||
}
|
||||
|
||||
var hl = null;
|
||||
function isOurs(t) { return t && t.closest && t.closest('.ktfb-ui, [data-kt-feedback-trigger]'); }
|
||||
function onHover(e) { if (isOurs(e.target)) return; clearHighlight(); hl = e.target; hl.classList.add('ktfb-hl'); }
|
||||
function onHoverOut() { clearHighlight(); }
|
||||
function clearHighlight() { if (hl) { hl.classList.remove('ktfb-hl'); hl = null; } }
|
||||
|
||||
function onPick(e) {
|
||||
if (isOurs(e.target)) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
var t = e.target;
|
||||
clearHighlight();
|
||||
openPop(t, e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
function openPop(target, x, y) {
|
||||
popState = { selector: selectorFor(target), label: labelFor(target), x: x, y: y };
|
||||
pop.querySelector('.tgt').textContent = '▸ ' + popState.label;
|
||||
var ta = pop.querySelector('textarea'); ta.value = '';
|
||||
pop.hidden = false;
|
||||
var px = Math.min(x, window.innerWidth - 280);
|
||||
var py = Math.min(y + 8, window.innerHeight - 180);
|
||||
pop.style.left = Math.max(8, px) + 'px';
|
||||
pop.style.top = Math.max(8, py) + 'px';
|
||||
ta.focus();
|
||||
}
|
||||
function closePop() { pop.hidden = true; popState = null; }
|
||||
|
||||
function onPopClick(e) {
|
||||
var act = e.target.getAttribute('data-act');
|
||||
if (act === 'cancel') return closePop();
|
||||
if (act === 'add') {
|
||||
var txt = pop.querySelector('textarea').value.trim();
|
||||
if (!txt) return;
|
||||
comments.push({ n: ++seq, selector: popState.selector, label: popState.label, text: txt });
|
||||
save(); closePop(); renderAll();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- рендер ----------
|
||||
function renderAll() { renderPanel(); renderPins(); updateCount(); }
|
||||
function updateCount() { fab.querySelector('.cnt').textContent = comments.length ? comments.length : ''; }
|
||||
|
||||
function renderPanel() {
|
||||
list.innerHTML = '';
|
||||
if (!comments.length) {
|
||||
var em = el('div', 'ktfb-empty');
|
||||
em.textContent = active
|
||||
? 'Кликните по любому элементу экрана и опишите, что поправить.'
|
||||
: 'Нажмите «Отзыв», затем кликайте по элементам и оставляйте заметки.';
|
||||
list.appendChild(em);
|
||||
return;
|
||||
}
|
||||
comments.forEach(function (c) {
|
||||
var it = el('div', 'ktfb-item');
|
||||
it.innerHTML =
|
||||
'<div class="n">' + c.n + '</div>' +
|
||||
'<div class="body"><div class="tgt">▸ ' + esc(c.label) + '</div><div class="txt">' + esc(c.text) + '</div></div>' +
|
||||
'<button class="del" data-del="' + c.n + '" type="button" aria-label="Удалить">×</button>';
|
||||
list.appendChild(it);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPins() {
|
||||
layer.innerHTML = '';
|
||||
if (!visible) return;
|
||||
comments.forEach(function (c) {
|
||||
var pin = el('div', 'ktfb-ui ktfb-pin', { 'data-pin': c.n });
|
||||
pin.textContent = c.n;
|
||||
pin.title = c.text;
|
||||
pin.addEventListener('click', function () { panel.hidden = false; });
|
||||
layer.appendChild(pin);
|
||||
c._pin = pin;
|
||||
});
|
||||
positionPins();
|
||||
}
|
||||
function positionPins() {
|
||||
comments.forEach(function (c) {
|
||||
if (!c._pin) return;
|
||||
var t = safeQuery(c.selector);
|
||||
if (!t) { c._pin.style.display = 'none'; return; }
|
||||
var r = t.getBoundingClientRect();
|
||||
c._pin.style.display = '';
|
||||
c._pin.style.left = Math.round(r.left + Math.min(r.width - 6, 10)) + 'px';
|
||||
c._pin.style.top = Math.round(r.top + 10) + 'px';
|
||||
});
|
||||
}
|
||||
function safeQuery(sel) { try { return document.querySelector(sel); } catch { return null; } }
|
||||
|
||||
function onPanelClick(e) {
|
||||
var del = e.target.getAttribute('data-del');
|
||||
var act = e.target.getAttribute('data-act');
|
||||
if (del) {
|
||||
comments = comments.filter(function (c) { return String(c.n) !== del; });
|
||||
save(); renderAll();
|
||||
} else if (act === 'hide') {
|
||||
panel.hidden = true;
|
||||
} else if (act === 'clear') {
|
||||
if (comments.length && confirm('Удалить все ' + comments.length + ' заметок?')) { comments = []; save(); renderAll(); }
|
||||
} else if (act === 'export') {
|
||||
exportMd();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- экспорт ----------
|
||||
function productName() {
|
||||
try {
|
||||
var cfg = JSON.parse(document.getElementById('kt-app-config').textContent);
|
||||
return (cfg.product && cfg.product.name) || document.title;
|
||||
} catch { return document.title || 'Прототип'; }
|
||||
}
|
||||
function exportMd() {
|
||||
if (!comments.length) return;
|
||||
var lines = ['# Отзыв по прототипу: ' + productName(), '', '_' + comments.length + ' заметок · ' + location.pathname + '_', ''];
|
||||
comments.forEach(function (c) {
|
||||
lines.push(c.n + '. **' + c.label + '**');
|
||||
lines.push(' ' + c.text.replace(/\n/g, '\n '));
|
||||
lines.push(' `' + c.selector + '`');
|
||||
lines.push('');
|
||||
});
|
||||
var md = lines.join('\n');
|
||||
if (navigator.clipboard) navigator.clipboard.writeText(md).catch(function () {});
|
||||
var blob = new Blob([md], { type: 'text/markdown' });
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'feedback-' + location.pathname.replace(/\W+/g, '-').replace(/^-|-$/g, '') + '.md';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
function esc(s) { return String(s).replace(/[&<>]/g, function (c) { return { '&': '&', '<': '<', '>': '>' }[c]; }); }
|
||||
|
||||
// ---------- init ----------
|
||||
function init() { injectStyles(); build(); }
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||||
else init();
|
||||
})();
|
||||
51
design-system/kt-ai-fonts.css
Normal file
51
design-system/kt-ai-fonts.css
Normal file
@ -0,0 +1,51 @@
|
||||
/* ============================================================
|
||||
* KT AI Fonts — самохостинг Inter. Подключать ПЕРВЫМ, до токенов.
|
||||
*
|
||||
* Зачем отдельным файлом, а не внутри токенов: `url()` в CSS резолвится
|
||||
* относительно САМОГО CSS-файла, а прототипы ДС инлайнят CSS прямо в HTML —
|
||||
* там относительный путь сломался бы. Поэтому шрифт подключают явно те,
|
||||
* кто строит приложение; самодостаточные прототипы остаются лёгкими
|
||||
* (иначе каждый вырос бы на ~240 КБ base64) и используют системный запасной.
|
||||
*
|
||||
* Обязателен для продакшн-приложения: без него `font-family: Inter` из токенов
|
||||
* молча уходит в системный шрифт, а он на 2–5% уже — плывут ширины колонок
|
||||
* и обрезка текста. То есть «прототип === продакшн» перестаёт выполняться.
|
||||
*
|
||||
* Файл: fonts/InterVariable-subset.woff2 — 178 КБ, ОДИН вариативный на веса
|
||||
* 100–900. Покрытие: латиница, полная кириллица (включая казахские ӘҒҚҢӨҰҮҺІ),
|
||||
* ₸ и типографские знаки. Лицензия SIL OFL — fonts/OFL.txt.
|
||||
*
|
||||
* Подключение:
|
||||
* <link rel="stylesheet" href="design-system/kt-ai-fonts.css">
|
||||
* <link rel="stylesheet" href="design-system/kt-ai-tokens.css">
|
||||
* <link rel="stylesheet" href="design-system/kt-ai-components.css">
|
||||
*
|
||||
* Ускорить первую отрисовку (необязательно, но заметно):
|
||||
* <link rel="preload" as="font" type="font/woff2" crossorigin
|
||||
* href="design-system/fonts/InterVariable-subset.woff2">
|
||||
* ============================================================ */
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
/* Вариативный: один файл покрывает весь диапазон весов ДС (400/500/600/700). */
|
||||
font-weight: 100 900;
|
||||
font-display: swap; /* текст виден сразу системным, затем подменяется — без «мигания пустотой» */
|
||||
src: url("fonts/InterVariable-subset.woff2") format("woff2-variations"),
|
||||
url("fonts/InterVariable-subset.woff2") format("woff2");
|
||||
}
|
||||
|
||||
/* JetBrains Mono — ТОЛЬКО для клавиш (.kt-ai-kbd) и командных групп.
|
||||
Числа на нём больше не набираются: в JetBrains Mono НЕТ глифа ₸, поэтому
|
||||
в каждой сумме «1 240 000 ₸» тенге подставлялся из другого шрифта, что рвало
|
||||
табличное выравнивание. Числа переведены на Inter + tabular-nums — так уже
|
||||
было у значения KPI, и цифры Inter выровнены по ширине не хуже моноширинных.
|
||||
Отсюда и крошечный сабсет: нужны только цифры, латиница и символы клавиш. */
|
||||
@font-face {
|
||||
font-family: "JetBrains Mono";
|
||||
font-style: normal;
|
||||
font-weight: 100 800;
|
||||
font-display: swap;
|
||||
src: url("fonts/JetBrainsMono-subset.woff2") format("woff2-variations"),
|
||||
url("fonts/JetBrainsMono-subset.woff2") format("woff2");
|
||||
}
|
||||
138
design-system/kt-ai-orb.js
Normal file
138
design-system/kt-ai-orb.js
Normal file
@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Орб агента — монохромное точечное облако на canvas.
|
||||
*
|
||||
* ЗАЧЕМ СВОЙ, А НЕ ГОТОВЫЙ. Идея взята у thinking-orbs (MIT, Jakub Antalik &
|
||||
* Alex Brinza), но сам пакет не подошёл по устройству: его сборка импортирует
|
||||
* React на верхнем уровне, а HTML app-shell работает без React. Поставить его
|
||||
* только в кит значило бы развести рантаймы — один и тот же контракт показывал
|
||||
* бы разный индикатор, а это ровно тот разрыв паритета, который запрещает
|
||||
* гейт G7. Плюс у ДС политика «внешних зависимостей нет»: вендоринг
|
||||
* минифицированного бандла положил бы в систему код, который никто в команде
|
||||
* не сможет поддерживать.
|
||||
*
|
||||
* ЦВЕТ. Ни одного значения цвета внутри: точки рисуются currentColor элемента.
|
||||
* Значит орб темизуется теми же токенами, что и всё остальное, и работает в
|
||||
* обеих темах без единой строки про тему.
|
||||
*
|
||||
* Подключение:
|
||||
* <canvas class="kt-ai-orb" data-state="thinking" width="16" height="16"></canvas>
|
||||
* ktAiOrb.mountAll(); // или ktAiOrb.mount(canvas)
|
||||
*
|
||||
* Состояния: idle (медленное дыхание), thinking (вращение), listening (пульс).
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
/* Состояния различаются ДВИЖЕНИЕМ, а не формой. Раньше здесь был параметр
|
||||
spread, сжимавший сферу по вертикали (0.55 у idle) — облако читалось не как
|
||||
шар, а как лужа, и на витрине это было видно сразу. Силуэт должен
|
||||
оставаться круглым во всех состояниях, меняется только характер движения. */
|
||||
var СОСТОЯНИЯ = {
|
||||
idle: { speed: 0.14, pulse: 0.10 }, // медленно дышит
|
||||
thinking: { speed: 0.85, pulse: 0.03 }, // уверенно вращается
|
||||
listening: { speed: 0.30, pulse: 0.30 }, // заметно пульсирует
|
||||
};
|
||||
|
||||
/* Два размера — два отдельных рисунка, а не масштаб одного.
|
||||
На 22px 44 точки радиусом меньше пикселя сливались в серую кашу: точек
|
||||
должно быть меньше, а каждая — крупнее и чётче. */
|
||||
var РАЗМЕРЫ = [
|
||||
{ до: 32, точек: 26, точка: 1.15, радиус: 0.80 },
|
||||
{ до: 1e9, точек: 92, точка: 1.70, радиус: 0.84 },
|
||||
];
|
||||
|
||||
function пресет(size) {
|
||||
for (var i = 0; i < РАЗМЕРЫ.length; i++) if (size <= РАЗМЕРЫ[i].до) return РАЗМЕРЫ[i];
|
||||
return РАЗМЕРЫ[РАЗМЕРЫ.length - 1];
|
||||
}
|
||||
|
||||
/** Точки, равномерно разложенные по сфере (решётка Фибоначчи).
|
||||
* Случайные точки дают комки — на 40 точках это видно сразу. */
|
||||
function точкиСферы(n) {
|
||||
var pts = [], φ = Math.PI * (3 - Math.sqrt(5));
|
||||
for (var i = 0; i < n; i++) {
|
||||
var y = 1 - (i / (n - 1)) * 2;
|
||||
var r = Math.sqrt(Math.max(0, 1 - y * y));
|
||||
var θ = φ * i;
|
||||
pts.push([Math.cos(θ) * r, y, Math.sin(θ) * r]);
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
function mount(canvas) {
|
||||
if (!canvas || canvas.__ktOrb) return;
|
||||
var ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
var reduce = global.matchMedia &&
|
||||
global.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
var size = canvas.clientWidth || parseInt(canvas.getAttribute("width"), 10) || 16;
|
||||
var ps = пресет(size);
|
||||
var pts = точкиСферы(ps.точек);
|
||||
var t0 = null, raf = 0;
|
||||
|
||||
function resize() {
|
||||
var dpr = global.devicePixelRatio || 1;
|
||||
size = canvas.clientWidth || size;
|
||||
canvas.width = Math.round(size * dpr);
|
||||
canvas.height = Math.round(size * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
|
||||
function кадр(ts) {
|
||||
if (t0 === null) t0 = ts;
|
||||
var t = (ts - t0) / 1000;
|
||||
var st = СОСТОЯНИЯ[canvas.getAttribute("data-state")] || СОСТОЯНИЯ.idle;
|
||||
|
||||
// Цвет — только currentColor: ни одного литерала в файле.
|
||||
var ink = global.getComputedStyle(canvas).color;
|
||||
var m = ink.match(/\d+/g) || [26, 26, 26];
|
||||
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
var c = size / 2;
|
||||
var R = c * ps.радиус * (1 + Math.sin(t * 1.6) * st.pulse * 0.14);
|
||||
var a = t * st.speed, ca = Math.cos(a), sa = Math.sin(a);
|
||||
var tilt = 0.42, ct = Math.cos(tilt), stl = Math.sin(tilt);
|
||||
var dot = ps.точка;
|
||||
|
||||
for (var i = 0; i < pts.length; i++) {
|
||||
var x = pts[i][0], y = pts[i][1], z = pts[i][2];
|
||||
var x1 = x * ca - z * sa, z1 = x * sa + z * ca; // поворот вокруг Y
|
||||
var y1 = y * ct - z1 * stl, z2 = y * stl + z1 * ct; // наклон оси
|
||||
// Дальние точки тусклее и мельче — это и создаёт объём.
|
||||
var depth = (z2 + 1) / 2;
|
||||
ctx.globalAlpha = 0.18 + depth * 0.62;
|
||||
ctx.fillStyle = "rgb(" + m[0] + "," + m[1] + "," + m[2] + ")";
|
||||
ctx.beginPath();
|
||||
ctx.arc(c + x1 * R, c + y1 * R, Math.max(0.6, dot * (0.62 + depth * 0.52)), 0, 6.2832);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
if (!reduce) raf = global.requestAnimationFrame(кадр);
|
||||
}
|
||||
|
||||
resize();
|
||||
// ПЕРВЫЙ КАДР — СИНХРОННО, до всякого rAF. Браузер не вызывает
|
||||
// requestAnimationFrame в фоновой вкладке, и без этой строки орб в неактивном
|
||||
// окне не рисовался вообще: не «замирал», а оставался пустым прямоугольником.
|
||||
// То же самое даёт корректное поведение при prefers-reduced-motion — статичный
|
||||
// кадр вместо пустоты.
|
||||
кадр(0); // следующий кадр ставит себе он сам — второй раз планировать нельзя
|
||||
|
||||
canvas.__ktOrb = {
|
||||
stop: function () { global.cancelAnimationFrame(raf); canvas.__ktOrb = null; },
|
||||
resize: resize,
|
||||
frame: кадр, // чтобы поведение можно было проверить без rAF
|
||||
};
|
||||
}
|
||||
|
||||
function mountAll(root) {
|
||||
var nodes = (root || global.document).querySelectorAll("canvas.kt-ai-orb");
|
||||
for (var i = 0; i < nodes.length; i++) mount(nodes[i]);
|
||||
}
|
||||
|
||||
global.ktAiOrb = { mount: mount, mountAll: mountAll };
|
||||
if (global.document && global.document.readyState !== "loading") mountAll();
|
||||
else if (global.document) global.document.addEventListener("DOMContentLoaded", function () { mountAll(); });
|
||||
})(typeof window !== "undefined" ? window : this);
|
||||
144
design-system/kt-ai-page.css
Normal file
144
design-system/kt-ai-page.css
Normal file
@ -0,0 +1,144 @@
|
||||
/* ============================================================
|
||||
* KT AI Page — примитивы СТРАНИЦЫ (лендинг, вход, ошибка, документация).
|
||||
* Требует kt-ai-tokens.css. Подключать ДОПОЛНИТЕЛЬНО к kt-ai-components.css.
|
||||
*
|
||||
* Зачем отдельный файл: kt-ai-components.css покрывает продуктовые экраны
|
||||
* внутри приложения (таблицы, формы, drawer). Всё, что «до входа» — лендинг,
|
||||
* страница входа, 404, публичная документация — приходилось верстать руками,
|
||||
* и каждый делал это по-своему. Здесь только каркас: ритм, контейнер, сетка.
|
||||
* Ничего брендового — цвет и вордмарк живут в слое продукта.
|
||||
*
|
||||
* app-shell этот файл НЕ подключает: внутри приложения он не нужен.
|
||||
* ============================================================ */
|
||||
|
||||
@layer kt-page {
|
||||
|
||||
/* ---------- База страницы ----------
|
||||
Подключение этого файла означает «страницу целиком строю на ДС», поэтому
|
||||
здесь можно задать основу. kt-ai-components.css этого НЕ делает намеренно:
|
||||
его вендорят в чужое приложение, и перекрашивать чужой body нельзя.
|
||||
Без этой базы страница уезжала в браузерный serif — базовый шрифт задавался
|
||||
только в HTML app-shell (тот же перекос, что был с box-sizing). */
|
||||
html { scrollbar-gutter: stable; } /* место под скроллбар зарезервировано всегда: контент не прыгает вбок, когда модал прячет скроллбар */
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--kt-ai-bg);
|
||||
color: var(--kt-ai-fg);
|
||||
font-family: var(--kt-ai-font-sans);
|
||||
font-size: var(--kt-ai-text-md);
|
||||
line-height: var(--kt-ai-leading-normal);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
::selection { background: var(--kt-ai-selection); }
|
||||
|
||||
/* ---------- Контейнер и ритм ---------- */
|
||||
.kt-ai-wrap { width: min(1080px, 92vw); margin-inline: auto; }
|
||||
.kt-ai-wrap[data-width="narrow"] { width: min(720px, 92vw); }
|
||||
.kt-ai-wrap[data-width="wide"] { width: min(1200px, 94vw); }
|
||||
|
||||
/* Вертикальный ритм секций: одна переменная вместо margin вручную у каждой. */
|
||||
.kt-ai-section { padding-block: clamp(48px, 8vh, 88px); }
|
||||
.kt-ai-section[data-divider="true"] { border-top: 1px solid var(--kt-ai-divider); }
|
||||
.kt-ai-section-head { max-width: 640px; }
|
||||
.kt-ai-section-head h2 {
|
||||
margin: 0; font-size: var(--kt-ai-text-2xl); font-weight: var(--kt-ai-weight-semibold);
|
||||
letter-spacing: -0.02em; color: var(--kt-ai-fg);
|
||||
}
|
||||
.kt-ai-section-head p {
|
||||
margin: var(--kt-ai-space-5) 0 0; color: var(--kt-ai-fg-muted);
|
||||
font-size: var(--kt-ai-text-lg); line-height: var(--kt-ai-leading-normal);
|
||||
}
|
||||
.kt-ai-section-head .kt-ai-tagline {
|
||||
display: block; margin-top: var(--kt-ai-space-3);
|
||||
color: var(--kt-ai-fg-muted); font-size: var(--kt-ai-text-md);
|
||||
}
|
||||
|
||||
/* ---------- Шапка страницы (тонкая, контент важнее хрома) ---------- */
|
||||
.kt-ai-pagehead {
|
||||
position: sticky; top: 0; z-index: var(--kt-ai-z-sticky);
|
||||
border-bottom: 1px solid var(--kt-ai-border);
|
||||
background: var(--kt-ai-bg-glass);
|
||||
-webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px);
|
||||
}
|
||||
.kt-ai-pagehead-in { display: flex; align-items: center; gap: var(--kt-ai-space-6); height: 56px; }
|
||||
.kt-ai-pagehead-in > .kt-ai-spacer { margin-left: auto; }
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.kt-ai-pagehead { -webkit-backdrop-filter: none; backdrop-filter: none; background: var(--kt-ai-bg-elevated); }
|
||||
}
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
.kt-ai-hero { padding-block: clamp(56px, 11vh, 104px) clamp(40px, 7vh, 72px); }
|
||||
.kt-ai-hero[data-align="center"] { text-align: center; }
|
||||
.kt-ai-hero h1 {
|
||||
margin: 0; font-size: clamp(32px, 5.5vw, 52px); font-weight: var(--kt-ai-weight-semibold);
|
||||
letter-spacing: -0.035em; line-height: 1.05; color: var(--kt-ai-fg);
|
||||
}
|
||||
.kt-ai-hero-sub {
|
||||
margin: var(--kt-ai-space-6) 0 0; max-width: 620px;
|
||||
color: var(--kt-ai-fg-muted); font-size: var(--kt-ai-text-lg);
|
||||
line-height: var(--kt-ai-leading-normal);
|
||||
}
|
||||
.kt-ai-hero[data-align="center"] .kt-ai-hero-sub { margin-inline: auto; }
|
||||
.kt-ai-hero-actions { display: flex; gap: var(--kt-ai-space-4); margin-top: var(--kt-ai-space-8); flex-wrap: wrap; }
|
||||
.kt-ai-hero[data-align="center"] .kt-ai-hero-actions { justify-content: center; }
|
||||
|
||||
/* ---------- Сетка возможностей (bento) ----------
|
||||
Плитки группируются границей, а не тенью — как карточки в приложении.
|
||||
data-span управляет шириной: следи, чтобы сумма давала целые ряды,
|
||||
иначе в сетке появится дыра (это «гигантская пустота» из красных флагов). */
|
||||
.kt-ai-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--kt-ai-space-6); }
|
||||
.kt-ai-grid[data-cols="2"] { grid-template-columns: repeat(2, 1fr); }
|
||||
.kt-ai-grid[data-cols="4"] { grid-template-columns: repeat(4, 1fr); }
|
||||
.kt-ai-tile {
|
||||
border: 1px solid var(--kt-ai-card-border); border-radius: var(--kt-ai-radius-3xl);
|
||||
background: var(--kt-ai-card-bg); padding: var(--kt-ai-space-8);
|
||||
transition: border-color var(--kt-ai-dur-base) var(--kt-ai-ease-standard);
|
||||
}
|
||||
.kt-ai-tile:hover { border-color: var(--kt-ai-card-border-hover); }
|
||||
.kt-ai-tile[data-span="2"] { grid-column: span 2; }
|
||||
.kt-ai-tile[data-span="full"] { grid-column: 1 / -1; }
|
||||
.kt-ai-tile h3 {
|
||||
margin: 0; font-size: var(--kt-ai-text-lg); font-weight: var(--kt-ai-weight-semibold);
|
||||
letter-spacing: -0.01em; color: var(--kt-ai-fg);
|
||||
}
|
||||
.kt-ai-tile p {
|
||||
margin: var(--kt-ai-space-4) 0 0; color: var(--kt-ai-fg-muted);
|
||||
font-size: var(--kt-ai-text-md); line-height: var(--kt-ai-leading-normal);
|
||||
}
|
||||
.kt-ai-tile-icon { display: inline-flex; margin-bottom: var(--kt-ai-space-5); color: var(--kt-ai-fg-muted); }
|
||||
.kt-ai-tile-icon svg { width: 18px; height: 18px; }
|
||||
|
||||
/* ---------- Призыв к действию ---------- */
|
||||
.kt-ai-cta { text-align: center; padding-block: clamp(64px, 11vh, 108px); }
|
||||
.kt-ai-cta h2 {
|
||||
margin: 0; font-size: var(--kt-ai-text-2xl); font-weight: var(--kt-ai-weight-semibold);
|
||||
letter-spacing: -0.02em; color: var(--kt-ai-fg);
|
||||
}
|
||||
.kt-ai-cta p { margin: var(--kt-ai-space-4) 0 var(--kt-ai-space-8); color: var(--kt-ai-fg-muted); font-size: var(--kt-ai-text-lg); }
|
||||
|
||||
/* ---------- Подвал ---------- */
|
||||
.kt-ai-pagefoot {
|
||||
border-top: 1px solid var(--kt-ai-divider); padding-block: var(--kt-ai-space-8);
|
||||
display: flex; align-items: center; gap: var(--kt-ai-space-6);
|
||||
color: var(--kt-ai-fg-faint); font-size: var(--kt-ai-text-sm); flex-wrap: wrap;
|
||||
}
|
||||
.kt-ai-pagefoot > .kt-ai-spacer { margin-left: auto; }
|
||||
|
||||
/* ---------- Центрированная карточка (вход, 404) ---------- */
|
||||
.kt-ai-centered {
|
||||
min-height: 100dvh; display: grid; place-items: center; padding: var(--kt-ai-space-8);
|
||||
}
|
||||
.kt-ai-centered > * { width: min(400px, 100%); }
|
||||
|
||||
/* ---------- Адаптив ---------- */
|
||||
@media (max-width: 900px) {
|
||||
.kt-ai-grid, .kt-ai-grid[data-cols="4"] { grid-template-columns: repeat(2, 1fr); }
|
||||
.kt-ai-tile[data-span="2"] { grid-column: span 2; }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.kt-ai-grid, .kt-ai-grid[data-cols="2"], .kt-ai-grid[data-cols="4"] { grid-template-columns: 1fr; }
|
||||
.kt-ai-tile[data-span="2"], .kt-ai-tile[data-span="full"] { grid-column: span 1; }
|
||||
.kt-ai-pagehead-in { height: 52px; gap: var(--kt-ai-space-4); }
|
||||
}
|
||||
|
||||
}
|
||||
88
design-system/kt-ai-print.css
Normal file
88
design-system/kt-ai-print.css
Normal file
@ -0,0 +1,88 @@
|
||||
/* AUTOGENERATED из tokens.json v5.4.0 – НЕ ПРАВИТЬ РУКАМИ.
|
||||
* Пересборка: python3 scripts/build_tokens.py
|
||||
* Канон: 00_Система/08_Дизайн-система/docs/DESIGN.md */
|
||||
|
||||
|
||||
@media print {
|
||||
:root, [data-theme] {
|
||||
color-scheme: light;
|
||||
--kt-ai-bg: #fcfcfb;
|
||||
--kt-ai-bg-soft: #f7f7f5;
|
||||
--kt-ai-bg-elevated: #ffffff;
|
||||
--kt-ai-bg-sunken: #f0f0ed;
|
||||
--kt-ai-bg-hover: #0000000a;
|
||||
--kt-ai-bg-active: #00000014;
|
||||
--kt-ai-bg-glass: color-mix(in srgb, #fcfcfb 85%, transparent);
|
||||
--kt-ai-inset-highlight: #ffffff1f;
|
||||
--kt-ai-ai-accent: #7c4fe0;
|
||||
--kt-ai-ai-gradient: linear-gradient(135deg, #0096d7, #7c4fe0 92%);
|
||||
--kt-ai-ai-orb: radial-gradient(circle at 32% 30%, #b69cf0, #0077c8 75%);
|
||||
--kt-ai-orb-bg: radial-gradient(circle at 42% 35%, #1c2940 0%, #0a0c12 72%);
|
||||
--kt-ai-fg-on-ai: #ffffff;
|
||||
--kt-ai-card-bg: #ffffff;
|
||||
--kt-ai-card-bg-glow: #ffffff;
|
||||
--kt-ai-card-border: #00000014;
|
||||
--kt-ai-card-border-hover: #00000029;
|
||||
--kt-ai-card-shadow: 0 1px 2px #0000000d;
|
||||
--kt-ai-card-shadow-hover: 0 4px 14px #00000014;
|
||||
--kt-ai-selection: #1a1a1a16;
|
||||
--kt-ai-fg: #1a1a1a;
|
||||
--kt-ai-fg-muted: #58585c;
|
||||
--kt-ai-fg-faint: #707073;
|
||||
--kt-ai-fg-on-fill: #ffffff;
|
||||
--kt-ai-border: #00000014;
|
||||
--kt-ai-border-strong: #00000029;
|
||||
--kt-ai-divider: #0000000f;
|
||||
--kt-ai-primary: #1a1a1a;
|
||||
--kt-ai-primary-hover: #343437;
|
||||
--kt-ai-primary-subtle: #0000000a;
|
||||
--kt-ai-brand-blue: #0096d7;
|
||||
--kt-ai-link: #0077c8;
|
||||
--kt-ai-focus-ring: #1a1a1a99;
|
||||
--kt-ai-focus-shadow: 0 0 0 3px #1a1a1a1f;
|
||||
--kt-ai-danger: #dc2626;
|
||||
--kt-ai-danger-hover: #b91c1c;
|
||||
--kt-ai-overlay: #00000052;
|
||||
--kt-ai-chip-default-bg: #0000000f;
|
||||
--kt-ai-chip-default-fg: #2d2d2f;
|
||||
--kt-ai-chip-gray-bg: #6b72801a;
|
||||
--kt-ai-chip-gray-fg: #3f3f42;
|
||||
--kt-ai-chip-orange-bg: #f9731624;
|
||||
--kt-ai-chip-orange-fg: #8a3d06;
|
||||
--kt-ai-chip-green-bg: #16a34a20;
|
||||
--kt-ai-chip-green-fg: #166534;
|
||||
--kt-ai-chip-blue-bg: #0284c724;
|
||||
--kt-ai-chip-blue-fg: #075985;
|
||||
--kt-ai-chip-purple-bg: #7c3aed20;
|
||||
--kt-ai-chip-purple-fg: #5b21b6;
|
||||
--kt-ai-chip-red-bg: #dc262620;
|
||||
--kt-ai-chip-red-fg: #991b1b;
|
||||
--kt-ai-status-ok-bg: #16a34a20;
|
||||
--kt-ai-status-ok-fg: #166534;
|
||||
--kt-ai-status-warn-bg: #f9731624;
|
||||
--kt-ai-status-warn-fg: #8a3d06;
|
||||
--kt-ai-status-risk-bg: #dc262620;
|
||||
--kt-ai-status-risk-fg: #991b1b;
|
||||
--kt-ai-status-info-bg: #6b72801a;
|
||||
--kt-ai-status-info-fg: #3f3f42;
|
||||
--kt-ai-chart-blue: #1683f3;
|
||||
--kt-ai-chart-green: #34a853;
|
||||
--kt-ai-chart-orange: #ff6a00;
|
||||
--kt-ai-chart-salmon: #d2785f;
|
||||
--kt-ai-chart-pink: #ea2f6b;
|
||||
--kt-ai-chart-red: #ef4444;
|
||||
--kt-ai-chart-bar-fill: #e4e4e0;
|
||||
--kt-ai-shadow-sm: 0 1px 2px #0000000d;
|
||||
--kt-ai-shadow-md: 0 4px 14px #00000014;
|
||||
--kt-ai-shadow-lg: 0 2px 6px #00000012, 0 12px 32px #0000001f;
|
||||
--kt-ai-shadow-xl: 0 3px 8px #00000014, 0 24px 64px #00000026;
|
||||
--kt-ai-command-shadow: none;
|
||||
}
|
||||
@page { size: A4; margin: 18mm 16mm; }
|
||||
body { background: #ffffff !important; }
|
||||
.kt-ai-sidebar, .kt-ai-toast-stack, .kt-ai-table-toolbar, .kt-ai-drawer,
|
||||
nav, header[role="banner"], button, [data-no-print] { display: none !important; }
|
||||
.kt-ai-shell { display: block; }
|
||||
.kt-ai-card, .kt-ai-table-wrap { box-shadow: none !important; break-inside: avoid; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
}
|
||||
395
design-system/kt-ai-tokens.css
Normal file
395
design-system/kt-ai-tokens.css
Normal file
@ -0,0 +1,395 @@
|
||||
/* AUTOGENERATED из tokens.json v5.4.0 – НЕ ПРАВИТЬ РУКАМИ.
|
||||
* Пересборка: python3 scripts/build_tokens.py
|
||||
* Канон: 00_Система/08_Дизайн-система/docs/DESIGN.md */
|
||||
|
||||
|
||||
@layer kt-tokens {
|
||||
|
||||
/* TIER 1. PRIMITIVES – в продуктовом CSS не использовать */
|
||||
:root {
|
||||
--kt-blue-300: #84c5f0;
|
||||
--kt-blue-400: #4b9ce2;
|
||||
--kt-blue-500: #0096d7;
|
||||
--kt-blue-600: #0077c8;
|
||||
--kt-blue-700: #075985;
|
||||
--kt-ink-950: #101011;
|
||||
--kt-ink-900: #1a1a1b;
|
||||
--kt-ink-850: #222223;
|
||||
--kt-ink-800: #2b2b2d;
|
||||
--kt-gray-950: #0a0a0b;
|
||||
--kt-gray-900: #161617;
|
||||
--kt-gray-850: #1c1c1e;
|
||||
--kt-gray-800: #232325;
|
||||
--kt-gray-750: #2a2a2c;
|
||||
--kt-gray-700: #343437;
|
||||
--kt-gray-25: #fcfcfb;
|
||||
--kt-gray-50: #f7f7f5;
|
||||
--kt-gray-100: #f0f0ed;
|
||||
--kt-gray-200: #e4e4e0;
|
||||
--kt-gray-300: #d4d4d0;
|
||||
--kt-paper: #fcfcfb;
|
||||
--kt-snow: #ffffff;
|
||||
--kt-near-black: #1a1a1a;
|
||||
--kt-near-white: #fafafa;
|
||||
--kt-ink-700: #3f3f42;
|
||||
--kt-ink-600: #58585c;
|
||||
--kt-ink-400: #707073;
|
||||
--kt-white-a04: #ffffff0a;
|
||||
--kt-white-a06: #ffffff0e;
|
||||
--kt-white-a09: #ffffff18;
|
||||
--kt-white-a11: #ffffff1c;
|
||||
--kt-white-a18: #ffffff2d;
|
||||
--kt-white-a92: #ffffffeb;
|
||||
--kt-black-a04: #0000000a;
|
||||
--kt-black-a06: #0000000f;
|
||||
--kt-black-a08: #00000014;
|
||||
--kt-black-a16: #00000029;
|
||||
--kt-black-a32: #00000052;
|
||||
--kt-black-a55: #0000008c;
|
||||
--kt-green-300: #7fc794;
|
||||
--kt-green-a18: #3f9c5e2e;
|
||||
--kt-orange-300: #f5a35f;
|
||||
--kt-orange-a18: #e87b232e;
|
||||
--kt-red-300: #f0928a;
|
||||
--kt-red-a20: #dc5b5234;
|
||||
--kt-blue-a18: #3f93c52e;
|
||||
--kt-purple-300: #bb9ae0;
|
||||
--kt-purple-a18: #8a5fb02e;
|
||||
--kt-grayc-a18: #8a8a8d2e;
|
||||
--kt-green-700: #166534;
|
||||
--kt-green-a13: #16a34a20;
|
||||
--kt-orange-700: #8a3d06;
|
||||
--kt-orange-a14: #f9731624;
|
||||
--kt-red-700: #991b1b;
|
||||
--kt-red-a13: #dc262620;
|
||||
--kt-blue-a14: #0284c724;
|
||||
--kt-purple-700: #5b21b6;
|
||||
--kt-purple-a13: #7c3aed20;
|
||||
--kt-grayc-a10: #6b72801a;
|
||||
--kt-chart-blue: #1683f3;
|
||||
--kt-chart-green: #34a853;
|
||||
--kt-chart-orange: #ff6a00;
|
||||
--kt-chart-salmon: #d2785f;
|
||||
--kt-chart-pink: #ea2f6b;
|
||||
--kt-chart-red: #ef4444;
|
||||
--kt-ai-space-0: 0;
|
||||
--kt-ai-space-1: 2px;
|
||||
--kt-ai-space-2: 4px;
|
||||
--kt-ai-space-3: 6px;
|
||||
--kt-ai-space-4: 8px;
|
||||
--kt-ai-space-5: 12px;
|
||||
--kt-ai-space-6: 16px;
|
||||
--kt-ai-space-7: 20px;
|
||||
--kt-ai-space-8: 24px;
|
||||
--kt-ai-space-9: 32px;
|
||||
--kt-ai-space-10: 40px;
|
||||
--kt-ai-space-11: 48px;
|
||||
--kt-ai-space-12: 64px;
|
||||
--kt-ai-radius-xs: 2px;
|
||||
--kt-ai-radius-sm: 3px;
|
||||
--kt-ai-radius-md: 4px;
|
||||
--kt-ai-radius-tag: 5px;
|
||||
--kt-ai-radius-lg: 6px;
|
||||
--kt-ai-radius-xl: 8px;
|
||||
--kt-ai-radius-2xl: 10px;
|
||||
--kt-ai-radius-3xl: 12px;
|
||||
--kt-ai-radius-full: 9999px;
|
||||
--kt-ai-text-2xs: 11px;
|
||||
--kt-ai-text-xs: 12px;
|
||||
--kt-ai-text-sm: 13px;
|
||||
--kt-ai-text-md: 14px;
|
||||
--kt-ai-text-lg: 15px;
|
||||
--kt-ai-text-xl: 18px;
|
||||
--kt-ai-text-2xl: 24px;
|
||||
--kt-ai-text-3xl: 30px;
|
||||
--kt-ai-leading-tight: 1.25;
|
||||
--kt-ai-leading-normal: 1.5;
|
||||
--kt-ai-weight-regular: 400;
|
||||
--kt-ai-weight-medium: 500;
|
||||
--kt-ai-weight-semibold: 600;
|
||||
--kt-ai-weight-bold: 700;
|
||||
--kt-ai-dur-fast: 100ms;
|
||||
--kt-ai-dur-base: 160ms;
|
||||
--kt-ai-dur-slow: 240ms;
|
||||
--kt-ai-dur-slower: 400ms;
|
||||
--kt-ai-ease-standard: cubic-bezier(.2, 0, 0, 1);
|
||||
--kt-ai-ease-enter: cubic-bezier(0, 0, .2, 1);
|
||||
--kt-ai-ease-exit: cubic-bezier(.4, 0, 1, 1);
|
||||
--kt-ai-z-sticky: 20;
|
||||
--kt-ai-z-dropdown: 30;
|
||||
--kt-ai-z-drawer: 40;
|
||||
--kt-ai-z-modal: 50;
|
||||
--kt-ai-z-toast: 60;
|
||||
--kt-ai-z-tooltip: 70;
|
||||
--kt-ai-control-h-sm: 28px;
|
||||
--kt-ai-control-h-md: 32px;
|
||||
--kt-ai-control-h-lg: 40px;
|
||||
--kt-ai-control-h-touch: 44px;
|
||||
--kt-ai-bp-sm: 640px;
|
||||
--kt-ai-bp-md: 832px;
|
||||
--kt-ai-bp-lg: 1024px;
|
||||
--kt-ai-bp-xl: 1280px;
|
||||
--kt-ai-sidebar-width: 232px;
|
||||
--kt-ai-sidebar-collapsed-width: 52px;
|
||||
--kt-ai-content-max: 1040px;
|
||||
--kt-ai-content-wide: 1200px;
|
||||
--kt-ai-content-table: 1180px;
|
||||
--kt-ai-nav-row: 30px;
|
||||
--kt-ai-table-row: 48px;
|
||||
--kt-ai-table-row-compact: 38px;
|
||||
--kt-ai-table-row-relaxed: 56px;
|
||||
--kt-ai-chart-height: 200px;
|
||||
--kt-ai-stack-tight: 12px;
|
||||
--kt-ai-stack-block: 16px;
|
||||
--kt-ai-stack-group: 24px;
|
||||
--kt-ai-stack-region: 32px;
|
||||
--kt-ai-pad-card: 20px;
|
||||
--kt-ai-font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
--kt-ai-font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* TIER 2. SEMANTIC. Дефолт – light (v5 «Mono», light-first); dark – полноправный паритет */
|
||||
:root,
|
||||
[data-theme="kt-ai-terminal-light"],
|
||||
[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--kt-ai-bg: var(--kt-paper);
|
||||
--kt-ai-bg-soft: var(--kt-gray-50);
|
||||
--kt-ai-bg-elevated: var(--kt-snow);
|
||||
--kt-ai-bg-sunken: var(--kt-gray-100);
|
||||
--kt-ai-bg-hover: var(--kt-black-a04);
|
||||
--kt-ai-bg-active: var(--kt-black-a08);
|
||||
--kt-ai-bg-glass: color-mix(in srgb, #fcfcfb 85%, transparent);
|
||||
--kt-ai-inset-highlight: #ffffff1f;
|
||||
--kt-ai-ai-accent: #7c4fe0;
|
||||
--kt-ai-ai-gradient: linear-gradient(135deg, #0096d7, #7c4fe0 92%);
|
||||
--kt-ai-ai-orb: radial-gradient(circle at 32% 30%, #b69cf0, #0077c8 75%);
|
||||
--kt-ai-orb-bg: radial-gradient(circle at 42% 35%, #1c2940 0%, #0a0c12 72%);
|
||||
--kt-ai-fg-on-ai: #ffffff;
|
||||
--kt-ai-card-bg: var(--kt-snow);
|
||||
--kt-ai-card-bg-glow: var(--kt-snow);
|
||||
--kt-ai-card-border: var(--kt-black-a08);
|
||||
--kt-ai-card-border-hover: var(--kt-black-a16);
|
||||
--kt-ai-card-shadow: 0 1px 2px #0000000d;
|
||||
--kt-ai-card-shadow-hover: 0 4px 14px #00000014;
|
||||
--kt-ai-selection: #1a1a1a16;
|
||||
--kt-ai-fg: var(--kt-near-black);
|
||||
--kt-ai-fg-muted: var(--kt-ink-600);
|
||||
--kt-ai-fg-faint: var(--kt-ink-400);
|
||||
--kt-ai-fg-on-fill: #ffffff;
|
||||
--kt-ai-border: var(--kt-black-a08);
|
||||
--kt-ai-border-strong: var(--kt-black-a16);
|
||||
--kt-ai-divider: var(--kt-black-a06);
|
||||
--kt-ai-primary: var(--kt-near-black);
|
||||
--kt-ai-primary-hover: #343437;
|
||||
--kt-ai-primary-subtle: var(--kt-black-a04);
|
||||
--kt-ai-brand-blue: var(--kt-blue-500);
|
||||
--kt-ai-link: var(--kt-blue-600);
|
||||
--kt-ai-focus-ring: #1a1a1a99;
|
||||
--kt-ai-focus-shadow: 0 0 0 3px #1a1a1a1f;
|
||||
--kt-ai-danger: #dc2626;
|
||||
--kt-ai-danger-hover: #b91c1c;
|
||||
--kt-ai-overlay: var(--kt-black-a32);
|
||||
--kt-ai-chip-default-bg: var(--kt-black-a06);
|
||||
--kt-ai-chip-default-fg: #2d2d2f;
|
||||
--kt-ai-chip-gray-bg: var(--kt-grayc-a10);
|
||||
--kt-ai-chip-gray-fg: var(--kt-ink-700);
|
||||
--kt-ai-chip-orange-bg: var(--kt-orange-a14);
|
||||
--kt-ai-chip-orange-fg: var(--kt-orange-700);
|
||||
--kt-ai-chip-green-bg: var(--kt-green-a13);
|
||||
--kt-ai-chip-green-fg: var(--kt-green-700);
|
||||
--kt-ai-chip-blue-bg: var(--kt-blue-a14);
|
||||
--kt-ai-chip-blue-fg: var(--kt-blue-700);
|
||||
--kt-ai-chip-purple-bg: var(--kt-purple-a13);
|
||||
--kt-ai-chip-purple-fg: var(--kt-purple-700);
|
||||
--kt-ai-chip-red-bg: var(--kt-red-a13);
|
||||
--kt-ai-chip-red-fg: var(--kt-red-700);
|
||||
--kt-ai-status-ok-bg: var(--kt-green-a13);
|
||||
--kt-ai-status-ok-fg: var(--kt-green-700);
|
||||
--kt-ai-status-warn-bg: var(--kt-orange-a14);
|
||||
--kt-ai-status-warn-fg: var(--kt-orange-700);
|
||||
--kt-ai-status-risk-bg: var(--kt-red-a13);
|
||||
--kt-ai-status-risk-fg: var(--kt-red-700);
|
||||
--kt-ai-status-info-bg: var(--kt-grayc-a10);
|
||||
--kt-ai-status-info-fg: var(--kt-ink-700);
|
||||
--kt-ai-chart-blue: var(--kt-chart-blue);
|
||||
--kt-ai-chart-green: var(--kt-chart-green);
|
||||
--kt-ai-chart-orange: var(--kt-chart-orange);
|
||||
--kt-ai-chart-salmon: var(--kt-chart-salmon);
|
||||
--kt-ai-chart-pink: var(--kt-chart-pink);
|
||||
--kt-ai-chart-red: var(--kt-chart-red);
|
||||
--kt-ai-chart-bar-fill: #e4e4e0;
|
||||
--kt-ai-shadow-sm: 0 1px 2px #0000000d;
|
||||
--kt-ai-shadow-md: 0 4px 14px #00000014;
|
||||
--kt-ai-shadow-lg: 0 2px 6px #00000012, 0 12px 32px #0000001f;
|
||||
--kt-ai-shadow-xl: 0 3px 8px #00000014, 0 24px 64px #00000026;
|
||||
--kt-ai-command-shadow: var(--kt-ai-shadow-xl);
|
||||
}
|
||||
|
||||
[data-theme="kt-ai-terminal"],
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--kt-ai-bg: var(--kt-gray-950);
|
||||
--kt-ai-bg-soft: var(--kt-gray-900);
|
||||
--kt-ai-bg-elevated: var(--kt-gray-850);
|
||||
--kt-ai-bg-sunken: #060607;
|
||||
--kt-ai-bg-hover: var(--kt-white-a06);
|
||||
--kt-ai-bg-active: var(--kt-white-a09);
|
||||
--kt-ai-bg-glass: color-mix(in srgb, #0a0a0b 85%, transparent);
|
||||
--kt-ai-inset-highlight: #00000014;
|
||||
--kt-ai-ai-accent: #7c4fe0;
|
||||
--kt-ai-ai-gradient: linear-gradient(135deg, #0096d7, #8b5cf6 92%);
|
||||
--kt-ai-ai-orb: radial-gradient(circle at 32% 30%, #c4b0ff, #0096d7 75%);
|
||||
--kt-ai-orb-bg: radial-gradient(circle at 42% 35%, #1c2940 0%, #0a0c12 72%);
|
||||
--kt-ai-fg-on-ai: #ffffff;
|
||||
--kt-ai-card-bg: var(--kt-gray-900);
|
||||
--kt-ai-card-bg-glow: var(--kt-gray-900);
|
||||
--kt-ai-card-border: var(--kt-white-a09);
|
||||
--kt-ai-card-border-hover: var(--kt-white-a18);
|
||||
--kt-ai-card-shadow: none;
|
||||
--kt-ai-card-shadow-hover: none;
|
||||
--kt-ai-selection: #fafafa1f;
|
||||
--kt-ai-fg: var(--kt-white-a92);
|
||||
--kt-ai-fg-muted: #9b9b9e;
|
||||
--kt-ai-fg-faint: #848487;
|
||||
--kt-ai-fg-on-fill: var(--kt-ink-900);
|
||||
--kt-ai-border: var(--kt-white-a09);
|
||||
--kt-ai-border-strong: var(--kt-white-a18);
|
||||
--kt-ai-divider: var(--kt-white-a06);
|
||||
--kt-ai-primary: var(--kt-near-white);
|
||||
--kt-ai-primary-hover: #e4e4e7;
|
||||
--kt-ai-primary-subtle: var(--kt-white-a09);
|
||||
--kt-ai-brand-blue: var(--kt-blue-500);
|
||||
--kt-ai-link: var(--kt-blue-400);
|
||||
--kt-ai-focus-ring: #fafafa80;
|
||||
--kt-ai-focus-shadow: 0 0 0 3px #fafafa24;
|
||||
--kt-ai-danger: #f0726a;
|
||||
--kt-ai-danger-hover: #f48a83;
|
||||
--kt-ai-overlay: var(--kt-black-a55);
|
||||
--kt-ai-chip-default-bg: var(--kt-white-a09);
|
||||
--kt-ai-chip-default-fg: var(--kt-white-a92);
|
||||
--kt-ai-chip-gray-bg: var(--kt-grayc-a18);
|
||||
--kt-ai-chip-gray-fg: var(--kt-gray-300);
|
||||
--kt-ai-chip-orange-bg: var(--kt-orange-a18);
|
||||
--kt-ai-chip-orange-fg: var(--kt-orange-300);
|
||||
--kt-ai-chip-green-bg: var(--kt-green-a18);
|
||||
--kt-ai-chip-green-fg: var(--kt-green-300);
|
||||
--kt-ai-chip-blue-bg: var(--kt-blue-a18);
|
||||
--kt-ai-chip-blue-fg: var(--kt-blue-300);
|
||||
--kt-ai-chip-purple-bg: var(--kt-purple-a18);
|
||||
--kt-ai-chip-purple-fg: var(--kt-purple-300);
|
||||
--kt-ai-chip-red-bg: var(--kt-red-a20);
|
||||
--kt-ai-chip-red-fg: var(--kt-red-300);
|
||||
--kt-ai-status-ok-bg: var(--kt-green-a18);
|
||||
--kt-ai-status-ok-fg: var(--kt-green-300);
|
||||
--kt-ai-status-warn-bg: var(--kt-orange-a18);
|
||||
--kt-ai-status-warn-fg: var(--kt-orange-300);
|
||||
--kt-ai-status-risk-bg: var(--kt-red-a20);
|
||||
--kt-ai-status-risk-fg: var(--kt-red-300);
|
||||
--kt-ai-status-info-bg: var(--kt-grayc-a18);
|
||||
--kt-ai-status-info-fg: var(--kt-gray-300);
|
||||
--kt-ai-chart-blue: var(--kt-chart-blue);
|
||||
--kt-ai-chart-green: var(--kt-chart-green);
|
||||
--kt-ai-chart-orange: var(--kt-chart-orange);
|
||||
--kt-ai-chart-salmon: var(--kt-chart-salmon);
|
||||
--kt-ai-chart-pink: var(--kt-chart-pink);
|
||||
--kt-ai-chart-red: var(--kt-chart-red);
|
||||
--kt-ai-chart-bar-fill: #2e2e31;
|
||||
--kt-ai-shadow-sm: 0 1px 2px #00000052;
|
||||
--kt-ai-shadow-md: 0 4px 16px #00000066;
|
||||
--kt-ai-shadow-lg: 0 12px 40px #00000080;
|
||||
--kt-ai-shadow-xl: 0 24px 80px #00000099;
|
||||
--kt-ai-command-shadow: var(--kt-ai-shadow-xl);
|
||||
}
|
||||
|
||||
/* data-theme="auto": dark при системной тёмной теме (дефолт light уже задан в :root) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
[data-theme="auto"] {
|
||||
color-scheme: dark;
|
||||
--kt-ai-bg: var(--kt-gray-950);
|
||||
--kt-ai-bg-soft: var(--kt-gray-900);
|
||||
--kt-ai-bg-elevated: var(--kt-gray-850);
|
||||
--kt-ai-bg-sunken: #060607;
|
||||
--kt-ai-bg-hover: var(--kt-white-a06);
|
||||
--kt-ai-bg-active: var(--kt-white-a09);
|
||||
--kt-ai-bg-glass: color-mix(in srgb, #0a0a0b 85%, transparent);
|
||||
--kt-ai-inset-highlight: #00000014;
|
||||
--kt-ai-ai-accent: #7c4fe0;
|
||||
--kt-ai-ai-gradient: linear-gradient(135deg, #0096d7, #8b5cf6 92%);
|
||||
--kt-ai-ai-orb: radial-gradient(circle at 32% 30%, #c4b0ff, #0096d7 75%);
|
||||
--kt-ai-orb-bg: radial-gradient(circle at 42% 35%, #1c2940 0%, #0a0c12 72%);
|
||||
--kt-ai-fg-on-ai: #ffffff;
|
||||
--kt-ai-card-bg: var(--kt-gray-900);
|
||||
--kt-ai-card-bg-glow: var(--kt-gray-900);
|
||||
--kt-ai-card-border: var(--kt-white-a09);
|
||||
--kt-ai-card-border-hover: var(--kt-white-a18);
|
||||
--kt-ai-card-shadow: none;
|
||||
--kt-ai-card-shadow-hover: none;
|
||||
--kt-ai-selection: #fafafa1f;
|
||||
--kt-ai-fg: var(--kt-white-a92);
|
||||
--kt-ai-fg-muted: #9b9b9e;
|
||||
--kt-ai-fg-faint: #848487;
|
||||
--kt-ai-fg-on-fill: var(--kt-ink-900);
|
||||
--kt-ai-border: var(--kt-white-a09);
|
||||
--kt-ai-border-strong: var(--kt-white-a18);
|
||||
--kt-ai-divider: var(--kt-white-a06);
|
||||
--kt-ai-primary: var(--kt-near-white);
|
||||
--kt-ai-primary-hover: #e4e4e7;
|
||||
--kt-ai-primary-subtle: var(--kt-white-a09);
|
||||
--kt-ai-brand-blue: var(--kt-blue-500);
|
||||
--kt-ai-link: var(--kt-blue-400);
|
||||
--kt-ai-focus-ring: #fafafa80;
|
||||
--kt-ai-focus-shadow: 0 0 0 3px #fafafa24;
|
||||
--kt-ai-danger: #f0726a;
|
||||
--kt-ai-danger-hover: #f48a83;
|
||||
--kt-ai-overlay: var(--kt-black-a55);
|
||||
--kt-ai-chip-default-bg: var(--kt-white-a09);
|
||||
--kt-ai-chip-default-fg: var(--kt-white-a92);
|
||||
--kt-ai-chip-gray-bg: var(--kt-grayc-a18);
|
||||
--kt-ai-chip-gray-fg: var(--kt-gray-300);
|
||||
--kt-ai-chip-orange-bg: var(--kt-orange-a18);
|
||||
--kt-ai-chip-orange-fg: var(--kt-orange-300);
|
||||
--kt-ai-chip-green-bg: var(--kt-green-a18);
|
||||
--kt-ai-chip-green-fg: var(--kt-green-300);
|
||||
--kt-ai-chip-blue-bg: var(--kt-blue-a18);
|
||||
--kt-ai-chip-blue-fg: var(--kt-blue-300);
|
||||
--kt-ai-chip-purple-bg: var(--kt-purple-a18);
|
||||
--kt-ai-chip-purple-fg: var(--kt-purple-300);
|
||||
--kt-ai-chip-red-bg: var(--kt-red-a20);
|
||||
--kt-ai-chip-red-fg: var(--kt-red-300);
|
||||
--kt-ai-status-ok-bg: var(--kt-green-a18);
|
||||
--kt-ai-status-ok-fg: var(--kt-green-300);
|
||||
--kt-ai-status-warn-bg: var(--kt-orange-a18);
|
||||
--kt-ai-status-warn-fg: var(--kt-orange-300);
|
||||
--kt-ai-status-risk-bg: var(--kt-red-a20);
|
||||
--kt-ai-status-risk-fg: var(--kt-red-300);
|
||||
--kt-ai-status-info-bg: var(--kt-grayc-a18);
|
||||
--kt-ai-status-info-fg: var(--kt-gray-300);
|
||||
--kt-ai-chart-blue: var(--kt-chart-blue);
|
||||
--kt-ai-chart-green: var(--kt-chart-green);
|
||||
--kt-ai-chart-orange: var(--kt-chart-orange);
|
||||
--kt-ai-chart-salmon: var(--kt-chart-salmon);
|
||||
--kt-ai-chart-pink: var(--kt-chart-pink);
|
||||
--kt-ai-chart-red: var(--kt-chart-red);
|
||||
--kt-ai-chart-bar-fill: #2e2e31;
|
||||
--kt-ai-shadow-sm: 0 1px 2px #00000052;
|
||||
--kt-ai-shadow-md: 0 4px 16px #00000066;
|
||||
--kt-ai-shadow-lg: 0 12px 40px #00000080;
|
||||
--kt-ai-shadow-xl: 0 24px 80px #00000099;
|
||||
--kt-ai-command-shadow: var(--kt-ai-shadow-xl);
|
||||
}
|
||||
}
|
||||
|
||||
/* Системные предпочтения */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root { --kt-ai-dur-fast: 0ms; --kt-ai-dur-base: 0ms; --kt-ai-dur-slow: 0ms; --kt-ai-dur-slower: 0ms; }
|
||||
}
|
||||
|
||||
/* Сенсорная ширина: цель нажатия не меньше control-h-touch (Fitts, 44px).
|
||||
sm и md здесь СОВПАДАЮТ: на сенсорной ширине «маленький» контрол
|
||||
не имеет права быть меньше минимальной цели — это не размер, а порог. */
|
||||
@media (pointer: coarse), (max-width: 767.98px) {
|
||||
:root { --kt-ai-control-h-sm: var(--kt-ai-control-h-touch); --kt-ai-control-h-md: var(--kt-ai-control-h-touch); --kt-ai-control-h-lg: 48px; --kt-ai-nav-row: var(--kt-ai-control-h-touch); --kt-ai-table-row: 52px; --kt-ai-table-row-compact: 48px; }
|
||||
}
|
||||
|
||||
} /* @layer kt-tokens */
|
||||
1552
design-system/product.schema.json
Normal file
1552
design-system/product.schema.json
Normal file
File diff suppressed because it is too large
Load Diff
542
design-system/scripts/validate_product.py
Executable file
542
design-system/scripts/validate_product.py
Executable file
@ -0,0 +1,542 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Валидатор продуктового контракта KT AI (seed для kt-ai-lint).
|
||||
|
||||
Проверяет конфиг прототипа против product.schema.json (структура) И против правил
|
||||
дизайн-системы (честность): целостность статусов, один primary, запрет ROI/FTE на
|
||||
операционном дашборде, debug-язык в копирайте. Тот же контракт выдаёт Төре,
|
||||
потребляет kt-ai-app-shell.html, читает handoff. ОДИН источник истины.
|
||||
|
||||
Запуск:
|
||||
python3 scripts/validate_product.py examples/dogovor-arendy.config.json
|
||||
python3 scripts/validate_product.py <config.json> --strict # warnings → ошибка
|
||||
|
||||
Без внешних зависимостей (stdlib). Код выхода: 0 – ок, 1 – ошибки (или warn при --strict).
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(HERE)
|
||||
|
||||
ERRORS, WARNINGS, NOTES = [], [], []
|
||||
def err(msg): ERRORS.append(msg)
|
||||
def warn(msg): WARNINGS.append(msg)
|
||||
# Заметка — не претензия к конфигу, а факт о системе, который лучше узнать до
|
||||
# сборки («этот архетип есть только в одном рантайме»). Считать её нарушением
|
||||
# честности нельзя: под --strict предупреждения блокируют, и корректный конфиг
|
||||
# падал бы из-за справки. Гейт от заметок не краснеет никогда.
|
||||
def note(msg): NOTES.append(msg)
|
||||
|
||||
_SPRITE_IDS = None
|
||||
def sprite_ids():
|
||||
"""id-набор иконок из канонического спрайта (для проверки nav/onboarding icon)."""
|
||||
global _SPRITE_IDS
|
||||
if _SPRITE_IDS is None:
|
||||
try:
|
||||
with open(os.path.join(ROOT, "icons", "kt-ai-lucide-sprite.svg"), encoding="utf-8") as fh:
|
||||
_SPRITE_IDS = set(re.findall(r'symbol id="([^"]+)"', fh.read()))
|
||||
except OSError:
|
||||
_SPRITE_IDS = set()
|
||||
return _SPRITE_IDS
|
||||
|
||||
|
||||
# ---------- мини-валидатор JSON Schema (подмножество, что используем) ----------
|
||||
def validate_schema(node, schema, path="$"):
|
||||
t = schema.get("type")
|
||||
if t == "object" and not isinstance(node, dict):
|
||||
return err(f"{path}: ожидался object, получено {type(node).__name__}")
|
||||
if t == "array" and not isinstance(node, list):
|
||||
return err(f"{path}: ожидался array, получено {type(node).__name__}")
|
||||
if t == "string" and not isinstance(node, str):
|
||||
return err(f"{path}: ожидалась string")
|
||||
if t == "integer" and not isinstance(node, int):
|
||||
return err(f"{path}: ожидалось integer")
|
||||
if t == "boolean" and not isinstance(node, bool):
|
||||
return err(f"{path}: ожидался boolean")
|
||||
|
||||
if "enum" in schema and node not in schema["enum"]:
|
||||
err(f"{path}: значение {node!r} не из {schema['enum']}")
|
||||
if isinstance(node, str):
|
||||
if "maxLength" in schema and len(node) > schema["maxLength"]:
|
||||
warn(f"{path}: длина {len(node)} > maxLength {schema['maxLength']} (\"{node[:40]}…\")")
|
||||
if "minLength" in schema and len(node) < schema["minLength"]:
|
||||
err(f"{path}: пустое значение (minLength {schema['minLength']})")
|
||||
if "pattern" in schema and not re.match(schema["pattern"], node):
|
||||
err(f"{path}: {node!r} не соответствует pattern {schema['pattern']}")
|
||||
|
||||
if isinstance(node, dict):
|
||||
for r in schema.get("required", []):
|
||||
if r not in node:
|
||||
err(f"{path}: отсутствует обязательное поле «{r}»")
|
||||
props = schema.get("properties", {})
|
||||
if "minProperties" in schema and len(node) < schema["minProperties"]:
|
||||
err(f"{path}: нужно ≥{schema['minProperties']} ключей")
|
||||
ap = schema.get("additionalProperties", True)
|
||||
for k, v in node.items():
|
||||
if k in props:
|
||||
validate_schema(v, props[k], f"{path}.{k}")
|
||||
elif ap is False:
|
||||
err(f"{path}: лишнее поле «{k}» (additionalProperties=false)")
|
||||
elif isinstance(ap, dict):
|
||||
validate_schema(v, ap, f"{path}.{k}")
|
||||
|
||||
if isinstance(node, list):
|
||||
if "minItems" in schema and len(node) < schema["minItems"]:
|
||||
err(f"{path}: нужно ≥{schema['minItems']} элементов")
|
||||
item_schema = schema.get("items")
|
||||
if item_schema:
|
||||
for i, it in enumerate(node):
|
||||
validate_schema(it, item_schema, f"{path}[{i}]")
|
||||
|
||||
|
||||
# ---------- правила дизайн-системы (kt-ai-lint) ----------
|
||||
FORBIDDEN_METRIC = re.compile(r"\b(roi|fte)\b|эконом|эффект|окупаем|time\s*saved|человеко-час", re.I)
|
||||
DEBUG_LANG = re.compile(r"модель вернула|не проходит дизайн|\bfallback\b|\bshell\b|\bjson\b|\bprompt\b", re.I)
|
||||
# «проповеднический»/мета-копирайт: дисклеймеры прототипа и нравоучения про human-in-the-loop.
|
||||
# Продукт показывает решение контролами (рекомендация + кнопки), а НЕ объясняет/оправдывает себя.
|
||||
PREACHY_LANG = re.compile(
|
||||
r"это прототип|прототип на дизайн|данные иллюстративн|контрольная точка|"
|
||||
r"(ии|ai) не действует|решение (подтверждает|принимает) человек|решение[ —-]+за вами", re.I)
|
||||
|
||||
|
||||
def load_registry():
|
||||
path = os.path.join(ROOT, "archetypes", "registry.json")
|
||||
try:
|
||||
return json.load(open(path, encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _present(cfg, block):
|
||||
"""Блок контракта присутствует и не пуст."""
|
||||
v = cfg.get(block)
|
||||
if v is None:
|
||||
return False
|
||||
if isinstance(v, (dict, list, str)):
|
||||
return len(v) > 0
|
||||
return True
|
||||
|
||||
|
||||
def lint_archetype(cfg):
|
||||
"""Реестр-управляемая проверка архетипа. Добавление архетипа = запись в registry.json,
|
||||
код валидатора не меняется."""
|
||||
reg = load_registry()
|
||||
if not reg:
|
||||
return
|
||||
arch_id = cfg.get("archetype") or cfg.get("layout") or reg.get("fallback", "queue")
|
||||
by_id = {a["id"]: a for a in reg.get("archetypes", [])}
|
||||
fallback = reg.get("fallback", "queue")
|
||||
|
||||
spec = by_id.get(arch_id)
|
||||
if spec is None:
|
||||
warn(f"archetype: «{arch_id}» нет в реестре archetypes/registry.json — экран деградирует в «{fallback}». "
|
||||
f"Известные: {sorted(by_id)}.")
|
||||
spec = by_id.get(fallback)
|
||||
if spec is None:
|
||||
return
|
||||
elif spec.get("status") == "planned":
|
||||
warn(f"archetype «{arch_id}» ({spec.get('title','')}): рендерер ещё не реализован (status=planned) — "
|
||||
f"экран показывается как «{fallback}».")
|
||||
|
||||
for block in spec.get("requires", []):
|
||||
if not _present(cfg, block):
|
||||
err(f"archetype «{spec['id']}» требует блок «{block}» — он отсутствует или пуст.")
|
||||
for block in spec.get("forbids", []):
|
||||
if _present(cfg, block):
|
||||
err(f"archetype «{spec['id']}» запрещает блок «{block}».")
|
||||
for block in spec.get("recommends", []):
|
||||
if not _present(cfg, block):
|
||||
warn(f"archetype «{spec['id']}» рекомендует блок «{block}» — без него экран беднее.")
|
||||
|
||||
if spec.get("id") == "compare" and not (cfg.get("compare", {}) or {}).get("pairs"):
|
||||
warn("archetype compare: нет compare.pairs — нечего сверять, экран деградирует в queue.")
|
||||
|
||||
|
||||
def lint_kpi_labels(cfg):
|
||||
"""Подпись KPI начинается с прописной (Michelle v4.0: капитализация не пляшет)."""
|
||||
import re as _re
|
||||
for i, k in enumerate(cfg.get("kpis") or []):
|
||||
lbl = (k or {}).get("label") or ""
|
||||
if lbl and _re.match(r"^[а-яё]", lbl):
|
||||
warn(f"kpis[{i}].label: подпись начинается со строчной («{lbl[:30]}») — начните с прописной.")
|
||||
|
||||
|
||||
def lint_deprecated(cfg):
|
||||
"""Устаревшие поля: рендерятся для старых конфигов, в новых не использовать."""
|
||||
if cfg.get("filterKeys"):
|
||||
warn("filterKeys: устаревшее поле — фильтры выводятся из statuses автоматически; уберите из новых конфигов.")
|
||||
for i, n in enumerate(cfg.get("nav") or []):
|
||||
if isinstance(n, dict) and "count" in n:
|
||||
warn(f"nav[{i}].count: устаревшее поле — счётчики в меню не входят в контракт; уберите из новых конфигов.")
|
||||
|
||||
|
||||
def lint_nav_groups(cfg):
|
||||
"""Группы разделов: заголовок собирает пункты, идущие ПОДРЯД.
|
||||
|
||||
Одинаковое значение group у пунктов, разнесённых по списку, рисует два
|
||||
одинаковых заголовка — читатель видит две группы с одним именем и решает,
|
||||
что это разные вещи. Рантайм здесь честен: он ставит заголовок там, где
|
||||
группа сменилась, и склеивать разорванный список за продукт не будет —
|
||||
порядок разделов принадлежит продукту.
|
||||
"""
|
||||
nav = [n for n in (cfg.get("nav") or []) if isinstance(n, dict)]
|
||||
видел, предыдущая = set(), None
|
||||
for i, n in enumerate(nav):
|
||||
g = n.get("group")
|
||||
if g and g != предыдущая and g in видел:
|
||||
warn(f"nav[{i}].group «{g}»: пункты этой группы идут не подряд — "
|
||||
"заголовок нарисуется дважды. Соберите их рядом.")
|
||||
if g:
|
||||
видел.add(g)
|
||||
предыдущая = g
|
||||
|
||||
# Порог Миллера: списком без устройства читается всё, что длиннее 7 строк.
|
||||
if len(nav) > 7 and not any(n.get("group") for n in nav):
|
||||
warn(f"nav: {len(nav)} пунктов подряд без групп — такой список читается как "
|
||||
"перечень ссылок, а не как устройство продукта. Поле nav[].group "
|
||||
"собирает пункты под общими заголовками (нумерация не меняется).")
|
||||
|
||||
|
||||
def lint_banner_length(cfg):
|
||||
"""Баннер — предупреждение, а не место для оговорок и выводов.
|
||||
|
||||
Баннер стоит НАД числами: всё, что в него уходит, человек читает до первого
|
||||
числа. Продукты складывали туда оговорки к показателям (места у числа не
|
||||
было) — на одном экране это дало 716 знаков сплошного текста перед данными.
|
||||
Теперь места есть: оговорка о числе — kpis[].note, вывод раздела —
|
||||
conclusion. Порог 200 знаков — примерно три строки на широком экране.
|
||||
"""
|
||||
text = ((cfg.get("banner") or {}).get("text") or "").strip()
|
||||
if len(text) > 200:
|
||||
warn(f"banner.text: {len(text)} знаков — это абзац до первого числа. "
|
||||
"Оговорка к показателю живёт в kpis[].note (рядом с числом), "
|
||||
"вывод раздела — в conclusion (после таблицы); в баннере остаётся "
|
||||
"предупреждение о том, из-за чего число соврёт.")
|
||||
|
||||
|
||||
def lint_key_refs(cfg):
|
||||
"""Ключи данных, которые раньше никто не сверял: колонка и разделы inbox.
|
||||
|
||||
Ключ с опечаткой не ломает экран: колонка просто пустая, письмо просто без
|
||||
заголовка. Разбираться в этом продукт идёт глазами по строкам, а машина
|
||||
сверяет за секунду. drawer.*, chart.* и columns[].sub уже проверяет
|
||||
check_data_contract — здесь только то, чего там не было.
|
||||
"""
|
||||
rows = ((cfg.get("table") or {}).get("rows") or [])
|
||||
keys = set()
|
||||
for r in rows[:200]:
|
||||
if isinstance(r, dict):
|
||||
keys |= {k for k in r if not k.startswith("_")}
|
||||
if not keys:
|
||||
return
|
||||
|
||||
# drawer.fields[].key и columns[].sub уже проверяются ниже, в check_data_contract.
|
||||
for i, c in enumerate((cfg.get("table") or {}).get("columns") or []):
|
||||
if isinstance(c, dict) and c.get("key") and c["key"] not in keys:
|
||||
err(f"table.columns[{i}].key: «{c['key']}» нет в данных ряда — колонка будет пустой.")
|
||||
|
||||
inbox = cfg.get("inbox") or {}
|
||||
for поле in ("titleKey", "subKey", "timeKey", "bodyKey"):
|
||||
if inbox.get(поле) and inbox[поле] not in keys:
|
||||
err(f"inbox.{поле}: «{inbox[поле]}» нет в данных ряда {sorted(keys)}")
|
||||
|
||||
|
||||
def lint_dashboard(cfg):
|
||||
"""Ряд графика привязывается к метрике по ИНДЕКСУ — индекс обязан существовать."""
|
||||
db = cfg.get("dashboard") or {}
|
||||
kpis = cfg.get("kpis") or []
|
||||
for i, ряд in enumerate(db.get("series") or []):
|
||||
if not isinstance(ряд, dict):
|
||||
continue
|
||||
ki = ряд.get("kpi", i)
|
||||
if not isinstance(ki, int) or ki >= len(kpis):
|
||||
err(f"dashboard.series[{i}].kpi: индекс {ki} вне диапазона kpis "
|
||||
f"(0..{len(kpis) - 1}) — ряд не привяжется к метрике и график не откроется.")
|
||||
if len(ряд.get("points") or []) < 2:
|
||||
warn(f"dashboard.series[{i}]: меньше двух точек — рантайм такой ряд пропускает.")
|
||||
|
||||
|
||||
def lint_archetype_runtimes(cfg):
|
||||
"""Архетип может жить не в обоих рантаймах — сказать об этом до сборки."""
|
||||
a = cfg.get("archetype") or cfg.get("layout")
|
||||
if not a:
|
||||
return
|
||||
путь = os.path.join(ROOT, "archetypes", "registry.json")
|
||||
if not os.path.exists(путь):
|
||||
return
|
||||
try:
|
||||
реестр = {x["id"]: x for x in json.load(open(путь, encoding="utf-8"))["archetypes"]}
|
||||
except (KeyError, ValueError):
|
||||
return
|
||||
спец = реестр.get(a)
|
||||
if спец and "kit" not in (спец.get("runtimes") or []):
|
||||
note(f"archetype «{a}»: реализован только в HTML-рантайме. В React-ките экран "
|
||||
"деградирует (sections → custom, таблица → queue). Для React выберите "
|
||||
"архетип с runtimes=kit или стройте экран на компонентах.")
|
||||
|
||||
|
||||
def lint_kpi_semantics(cfg):
|
||||
"""DS-016 (Töre, 28.08.2026): значение KPI повторяло число, которое подпись отменяет
|
||||
(«сводка вместо 1,5-2 ч» → значение «1,5 ч»). Значение KPI — всегда НОВОЕ (to-be)
|
||||
состояние; старое число живёт в подписи или hint."""
|
||||
import re as _re
|
||||
def _nums(sv):
|
||||
return {n.replace(",", ".") for n in _re.findall(r"\d+(?:[.,]\d+)?", str(sv or ""))}
|
||||
for i, k in enumerate(cfg.get("kpis") or []):
|
||||
lbl = (k or {}).get("label") or ""
|
||||
m = _re.search(r"вместо\s+(.+)$", lbl, _re.I)
|
||||
if not m:
|
||||
continue
|
||||
old = _nums(m.group(1))
|
||||
val = _nums((k or {}).get("value"))
|
||||
if old and val and val <= old:
|
||||
warn(f"kpis[{i}] «{lbl[:40]}»: подпись говорит «вместо {m.group(1).strip()[:20]}», "
|
||||
"а значение повторяет то же число — значение KPI показывает, сколько СТАЛО; "
|
||||
"старое число живёт в подписи или hint.")
|
||||
|
||||
|
||||
def lint_rules(cfg):
|
||||
lint_archetype(cfg)
|
||||
lint_deprecated(cfg)
|
||||
lint_kpi_labels(cfg)
|
||||
lint_kpi_semantics(cfg)
|
||||
lint_nav_groups(cfg)
|
||||
lint_banner_length(cfg)
|
||||
lint_key_refs(cfg)
|
||||
lint_dashboard(cfg)
|
||||
lint_archetype_runtimes(cfg)
|
||||
statuses = cfg.get("statuses", {})
|
||||
status_keys = set(statuses.keys())
|
||||
table = cfg.get("table", {})
|
||||
cols = table.get("columns", [])
|
||||
col_keys = {c["key"] for c in cols if "key" in c}
|
||||
status_cols = [c for c in cols if c.get("type") == "status"]
|
||||
# данные ряда (колонки — лишь видимая выборка; drawer/sub могут ссылаться на любое поле ряда)
|
||||
row_keys = set()
|
||||
for row in table.get("rows", []):
|
||||
row_keys.update(k for k in row.keys() if not str(k).startswith("_"))
|
||||
data_keys = col_keys | row_keys
|
||||
# col.sub должен ссылаться на реальное поле ряда
|
||||
for c in cols:
|
||||
if c.get("sub") and row_keys and c["sub"] not in data_keys:
|
||||
err(f"table.columns «{c.get('key')}».sub: «{c['sub']}» нет в данных ряда {sorted(data_keys)}")
|
||||
|
||||
# tone уже проверен enum'ом в схеме.
|
||||
if len(status_cols) > 1:
|
||||
warn(f"table: {len(status_cols)} колонок type=status; обычно одна workflow-колонка.")
|
||||
|
||||
# целостность статусов в рядах
|
||||
for i, row in enumerate(table.get("rows", [])):
|
||||
for sc in status_cols:
|
||||
v = row.get(sc["key"])
|
||||
if v is not None and v not in status_keys:
|
||||
err(f"table.rows[{i}].{sc['key']}: статус «{v}» не объявлен в statuses {sorted(status_keys)}")
|
||||
|
||||
# scenario переходы → существующие статусы
|
||||
sc = cfg.get("scenario", {})
|
||||
for fld in ("approveTo", "rejectTo"):
|
||||
v = sc.get(fld)
|
||||
if v and v not in status_keys:
|
||||
err(f"scenario.{fld}: «{v}» не объявлен в statuses {sorted(status_keys)}")
|
||||
if "scenario" in cfg and not sc.get("control"):
|
||||
err("scenario.control: если задан scenario, в нём должно быть одно главное (primary) действие.")
|
||||
|
||||
# drawer ключи → поля ряда (колонки + любые поля строки)
|
||||
dr = cfg.get("drawer", {})
|
||||
if dr.get("titleKey") and data_keys and dr["titleKey"] not in data_keys:
|
||||
err(f"drawer.titleKey: «{dr['titleKey']}» нет в данных ряда {sorted(data_keys)}")
|
||||
for j, f in enumerate(dr.get("fields", [])):
|
||||
if f.get("key") and data_keys and f["key"] not in data_keys:
|
||||
err(f"drawer.fields[{j}].key: «{f['key']}» нет в данных ряда {sorted(data_keys)}")
|
||||
|
||||
# chart ключи → реальные колонки/поля ряда (как column.sub/drawer — единый класс проверок)
|
||||
ch = cfg.get("chart", {}) or {}
|
||||
if ch.get("dateKey") and data_keys and ch["dateKey"] not in data_keys:
|
||||
err(f"chart.dateKey: «{ch['dateKey']}» нет в данных ряда {sorted(data_keys)}")
|
||||
if ch.get("groupBy") and data_keys and ch["groupBy"] not in data_keys:
|
||||
err(f"chart.groupBy: «{ch['groupBy']}» нет в данных ряда {sorted(data_keys)}")
|
||||
if ch.get("bucket") == "day" and not ch.get("dateKey"):
|
||||
err("chart.bucket=\"day\": требуется chart.dateKey (колонка-дата для раскатки по дням).")
|
||||
|
||||
# banner.actionNav → существующий индекс nav
|
||||
bn = cfg.get("banner", {}) or {}
|
||||
nav_len = len(cfg.get("nav", []) or [])
|
||||
if bn.get("actionNav") is not None and nav_len and not (0 <= bn["actionNav"] < nav_len):
|
||||
err(f"banner.actionNav: индекс {bn['actionNav']} вне диапазона nav (0..{nav_len - 1}).")
|
||||
|
||||
# nav/onboarding иконки → существуют в спрайте (опечатка иначе рендерит пустую иконку)
|
||||
ids = sprite_ids()
|
||||
if ids:
|
||||
for k, n in enumerate(cfg.get("nav", []) or []):
|
||||
if n.get("icon") and n["icon"] not in ids:
|
||||
warn(f"nav[{k}].icon: «{n['icon']}» нет в спрайте icons/kt-ai-lucide-sprite.svg")
|
||||
for k, o in enumerate(cfg.get("onboarding", []) or []):
|
||||
if o.get("icon") and o["icon"] not in ids:
|
||||
warn(f"onboarding[{k}].icon: «{o['icon']}» нет в спрайте icons/kt-ai-lucide-sprite.svg")
|
||||
|
||||
# запрет project-метрик на операционном дашборде
|
||||
view = cfg.get("product", {}).get("view", "operational")
|
||||
if view == "operational":
|
||||
for k, kpi in enumerate(cfg.get("kpis", [])):
|
||||
blob = f"{kpi.get('value','')} {kpi.get('label','')}"
|
||||
if FORBIDDEN_METRIC.search(blob):
|
||||
warn(f"kpis[{k}] «{kpi.get('label','')}»: похоже на ROI/FTE/экономию — это метрика проекта. "
|
||||
f"Перенесите в product.view=management или замените на рабочее число (очередь, риск, просрочка).")
|
||||
|
||||
# debug-язык + проповеднический/мета-копирайт в пользовательских строках
|
||||
for p, s in iter_user_strings(cfg):
|
||||
if DEBUG_LANG.search(s):
|
||||
warn(f"{p}: служебная формулировка генерации в UI («{s[:50]}…»). "
|
||||
f"Пользователь видит продуктовый язык, не кухню сборки.")
|
||||
if PREACHY_LANG.search(s):
|
||||
warn(f"{p}: дисклеймер/нравоучение в UI («{s[:50]}…»). "
|
||||
f"Убери — решение показывают контролы (рекомендация + кнопки), а не объяснения про прототип/human-in-the-loop.")
|
||||
|
||||
# объём данных (только если экран вообще табличный)
|
||||
if "table" in cfg:
|
||||
n = len(table.get("rows", []))
|
||||
# дневной time-series график легитимно требует плотности данных → выше потолок
|
||||
daily = (cfg.get("chart", {}) or {}).get("bucket") == "day"
|
||||
cap = 60 if daily else 16
|
||||
if n < 5:
|
||||
warn(f"table.rows: {n} строк — таблица оправдана от 5 записей; добавьте правдоподобных данных (8-12).")
|
||||
elif n > cap:
|
||||
warn(f"table.rows: {n} строк — для прототипа достаточно 8-12" + (" (или ≤60 для дневного графика)." if daily else "."))
|
||||
|
||||
# длина статус-лейблов (до 4 слов) — DoD G1
|
||||
for key, st in statuses.items():
|
||||
if len(st.get("label", "").split()) > 4:
|
||||
warn(f"statuses.{key}.label: > 4 слов; длинное объяснение — в drawer.")
|
||||
|
||||
# DoD G1: заголовки колонок коротко (≤3 слов; деталь — в drawer)
|
||||
for c in (table.get("columns", []) if "table" in cfg else []):
|
||||
if len(str(c.get("label", "")).split()) > 3:
|
||||
warn(f"table.columns «{c.get('key')}».label: > 3 слов; заголовок таблицы — рабочий объект, не описание.")
|
||||
|
||||
# DoD G1: KPI-полоса тихая — 2-4 главные метрики, не стена чисел
|
||||
if len(cfg.get("kpis", []) or []) > 4:
|
||||
warn(f"kpis: {len(cfg['kpis'])} метрик — полоса KPI отвечает на 2-4 главных вопроса; остальное в drawer/аналитику.")
|
||||
|
||||
# DoD G5 / закон Хика: первичные фильтры = workflow-статусы, ≤4 + «Все»
|
||||
if len(statuses) > 5:
|
||||
warn(f"statuses: {len(statuses)} статусов — таб-фильтр держит ≤4+«Все»; confidence/risk-type/системы не идут в первичные фильтры (Хик).")
|
||||
|
||||
|
||||
def iter_user_strings(cfg):
|
||||
"""Пользовательские строки (без ai.fallback — там легитимны слова про прототип)."""
|
||||
def walk(node, path):
|
||||
if isinstance(node, str):
|
||||
yield path, node
|
||||
elif isinstance(node, dict):
|
||||
for k, v in node.items():
|
||||
if path == "$.ai" and k == "fallback":
|
||||
continue
|
||||
yield from walk(v, f"{path}.{k}")
|
||||
elif isinstance(node, list):
|
||||
for i, v in enumerate(node):
|
||||
yield from walk(v, f"{path}[{i}]")
|
||||
yield from walk(cfg, "$")
|
||||
|
||||
|
||||
# Нравоучение про human-in-the-loop — свой же красный флаг из CHECKLIST G4.
|
||||
# Решение показывают КОНТРОЛЫ («Согласовать», «Переписать вручную»), а не абзац
|
||||
# текста: пользователь и так видит кнопку, а абзац читает как недоверие к себе.
|
||||
#
|
||||
# Ловим ФРАЗЫ, а не корень «подтвержд». Разница принципиальна: «Тариф подтверждён
|
||||
# по SAP» и «ждёт подтверждения мастера с фото» — факты о состоянии, они нужны.
|
||||
# Нравоучение — это утверждение ПОЛИТИКИ («ни один договор не уходит без…»),
|
||||
# которое ничего не сообщает о конкретной записи.
|
||||
MORALIZING = re.compile(
|
||||
r"(только после [\w\s]{0,20}подтвержд"
|
||||
r"|без подтвержд[\w\s]{0,25}не\s"
|
||||
r"|ни один[^.]*без подтвержд|ни одна[^.]*без подтвержд"
|
||||
r"|подтверждает только"
|
||||
r"|агент ничего не (отправляет|делает)"
|
||||
r"|не действует сам|решение (за вами|принимаете вы)"
|
||||
r"|проверьте факты|это (лишь )?прототип|данные иллюстративны"
|
||||
r"|контроль человека|подтверждает человек)", re.I)
|
||||
|
||||
|
||||
def lint_moralizing(cfg):
|
||||
for path, val in iter_user_strings(cfg):
|
||||
for sent in re.split(r"(?<=[.!?])\s+", val):
|
||||
m = MORALIZING.search(sent)
|
||||
if m:
|
||||
err(f"{path}: нравоучение про human-in-the-loop — «{sent.strip()[:70]}». "
|
||||
f"Решение показывают контролы, а не абзац текста (CHECKLIST G4).")
|
||||
|
||||
|
||||
def check_one(cfg_path, schema, strict):
|
||||
"""Проверяет один конфиг. Возвращает True, если он ПРОВАЛИЛСЯ."""
|
||||
global ERRORS, WARNINGS, NOTES
|
||||
ERRORS, WARNINGS, NOTES = [], [], [] # глобальные списки — обнуляем на каждый файл
|
||||
|
||||
try:
|
||||
cfg = json.load(open(cfg_path, encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
print(f" ✗ ERROR {os.path.basename(cfg_path)}: не читается — {e}")
|
||||
return True
|
||||
|
||||
validate_schema(cfg, schema)
|
||||
lint_moralizing(cfg)
|
||||
if not ERRORS: # lint только на структурно валидном конфиге
|
||||
lint_rules(cfg)
|
||||
|
||||
name = os.path.basename(cfg_path)
|
||||
for e in ERRORS:
|
||||
print(f" ✗ ERROR {e}")
|
||||
for n in NOTES:
|
||||
print(f" ℹ ЗАМЕТКА {n}")
|
||||
for w in WARNINGS:
|
||||
print(f" ⚠ WARN {w}")
|
||||
|
||||
fail = bool(ERRORS) or (strict and WARNINGS)
|
||||
status = "FAIL" if fail else ("OK с замечаниями" if WARNINGS else "OK")
|
||||
print(f"{name}: {status} — {len(ERRORS)} ошибок, {len(WARNINGS)} предупреждений")
|
||||
if WARNINGS and not strict:
|
||||
print(" → handoff/CI: прогоните с --strict, чтобы предупреждения честности стали блокирующими.")
|
||||
return fail
|
||||
|
||||
|
||||
def main():
|
||||
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||
strict = "--strict" in sys.argv
|
||||
if not args:
|
||||
print("usage: validate_product.py <config.json | 'glob/**/*.config.json'> [--strict]",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# Глоб раскрываем САМИ: гейт из доков — "app/**/*.config.json" в кавычках,
|
||||
# то есть шелл его не раскрывает, и раньше это падало FileNotFoundError
|
||||
# на литеральной строке. Кавычки нужны, иначе шелл подставит только первый файл.
|
||||
targets, schema_arg = [], None
|
||||
for a in args:
|
||||
if any(ch in a for ch in "*?[") or os.path.isdir(a):
|
||||
pattern = os.path.join(a, "**", "*.config.json") if os.path.isdir(a) else a
|
||||
targets.extend(sorted(glob.glob(pattern, recursive=True)))
|
||||
elif a.endswith(".schema.json"):
|
||||
schema_arg = a
|
||||
else:
|
||||
targets.append(a)
|
||||
|
||||
if not targets:
|
||||
print(f"Не найдено ни одного конфига по: {' '.join(args)}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
schema_path = schema_arg or os.path.join(ROOT, "product.schema.json")
|
||||
schema = json.load(open(schema_path, encoding="utf-8"))
|
||||
|
||||
failed = [t for t in targets if check_one(t, schema, strict)]
|
||||
|
||||
if len(targets) > 1:
|
||||
print(f"\nИтого: {len(targets) - len(failed)}/{len(targets)} прошло"
|
||||
+ (f", упало: {', '.join(os.path.basename(f) for f in failed)}" if failed else ""))
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
2090
design-system/templates/kt-ai-app-shell.html
Normal file
2090
design-system/templates/kt-ai-app-shell.html
Normal file
File diff suppressed because one or more lines are too long
320
design-system/tokens.json
Normal file
320
design-system/tokens.json
Normal file
@ -0,0 +1,320 @@
|
||||
{
|
||||
"$meta": {
|
||||
"name": "KT AI Design Tokens",
|
||||
"version": "5.4.0",
|
||||
"comment": "Единый источник истины. Правки ТОЛЬКО здесь, затем: python3 scripts/build_tokens.py. Генерирует: kt-ai-tokens.css, kt-ai-print.css, figma-variables.json, kt-ai-tailwind-preset.cjs. Ссылка на primitive: значение вида $имя. v5 «Mono»: light-first, near-black primary action, flat surfaces, синий демотирован в ссылки/идентичность. v5.2: контраст AA как машинный гейт — пороги проверяются в build_tokens.py, а не глазами. v5.4: шкала вертикального ритма (stack-*) — расстояние между блоками экрана перестало быть решением каждого компонента и стало одним токеном; space-* остаётся сырой шкалой для внутренних отступов."
|
||||
},
|
||||
"primitives": {
|
||||
"kt-blue-300": "#84c5f0",
|
||||
"kt-blue-400": "#4b9ce2",
|
||||
"kt-blue-500": "#0096d7",
|
||||
"kt-blue-600": "#0077c8",
|
||||
"kt-blue-700": "#075985",
|
||||
"kt-ink-950": "#101011",
|
||||
"kt-ink-900": "#1a1a1b",
|
||||
"kt-ink-850": "#222223",
|
||||
"kt-ink-800": "#2b2b2d",
|
||||
"kt-gray-950": "#0a0a0b",
|
||||
"kt-gray-900": "#161617",
|
||||
"kt-gray-850": "#1c1c1e",
|
||||
"kt-gray-800": "#232325",
|
||||
"kt-gray-750": "#2a2a2c",
|
||||
"kt-gray-700": "#343437",
|
||||
"kt-gray-25": "#fcfcfb",
|
||||
"kt-gray-50": "#f7f7f5",
|
||||
"kt-gray-100": "#f0f0ed",
|
||||
"kt-gray-200": "#e4e4e0",
|
||||
"kt-gray-300": "#d4d4d0",
|
||||
"kt-paper": "#fcfcfb",
|
||||
"kt-snow": "#ffffff",
|
||||
"kt-near-black": "#1a1a1a",
|
||||
"kt-near-white": "#fafafa",
|
||||
"kt-ink-700": "#3f3f42",
|
||||
"kt-ink-600": "#58585c",
|
||||
"kt-ink-400": "#707073",
|
||||
"kt-white-a04": "#ffffff0a",
|
||||
"kt-white-a06": "#ffffff0e",
|
||||
"kt-white-a09": "#ffffff18",
|
||||
"kt-white-a11": "#ffffff1c",
|
||||
"kt-white-a18": "#ffffff2d",
|
||||
"kt-white-a92": "#ffffffeb",
|
||||
"kt-black-a04": "#0000000a",
|
||||
"kt-black-a06": "#0000000f",
|
||||
"kt-black-a08": "#00000014",
|
||||
"kt-black-a16": "#00000029",
|
||||
"kt-black-a32": "#00000052",
|
||||
"kt-black-a55": "#0000008c",
|
||||
"kt-green-300": "#7fc794",
|
||||
"kt-green-a18": "#3f9c5e2e",
|
||||
"kt-orange-300": "#f5a35f",
|
||||
"kt-orange-a18": "#e87b232e",
|
||||
"kt-red-300": "#f0928a",
|
||||
"kt-red-a20": "#dc5b5234",
|
||||
"kt-blue-a18": "#3f93c52e",
|
||||
"kt-purple-300": "#bb9ae0",
|
||||
"kt-purple-a18": "#8a5fb02e",
|
||||
"kt-grayc-a18": "#8a8a8d2e",
|
||||
"kt-green-700": "#166534",
|
||||
"kt-green-a13": "#16a34a20",
|
||||
"kt-orange-700": "#8a3d06",
|
||||
"kt-orange-a14": "#f9731624",
|
||||
"kt-red-700": "#991b1b",
|
||||
"kt-red-a13": "#dc262620",
|
||||
"kt-blue-a14": "#0284c724",
|
||||
"kt-purple-700": "#5b21b6",
|
||||
"kt-purple-a13": "#7c3aed20",
|
||||
"kt-grayc-a10": "#6b72801a",
|
||||
"kt-chart-blue": "#1683f3",
|
||||
"kt-chart-green": "#34a853",
|
||||
"kt-chart-orange": "#ff6a00",
|
||||
"kt-chart-salmon": "#d2785f",
|
||||
"kt-chart-pink": "#ea2f6b",
|
||||
"kt-chart-red": "#ef4444"
|
||||
},
|
||||
"scales": {
|
||||
"space": {
|
||||
"0": "0",
|
||||
"1": "2px",
|
||||
"2": "4px",
|
||||
"3": "6px",
|
||||
"4": "8px",
|
||||
"5": "12px",
|
||||
"6": "16px",
|
||||
"7": "20px",
|
||||
"8": "24px",
|
||||
"9": "32px",
|
||||
"10": "40px",
|
||||
"11": "48px",
|
||||
"12": "64px"
|
||||
},
|
||||
"radius": {
|
||||
"xs": "2px",
|
||||
"sm": "3px",
|
||||
"md": "4px",
|
||||
"tag": "5px",
|
||||
"lg": "6px",
|
||||
"xl": "8px",
|
||||
"2xl": "10px",
|
||||
"3xl": "12px",
|
||||
"full": "9999px"
|
||||
},
|
||||
"text": {
|
||||
"2xs": "11px",
|
||||
"xs": "12px",
|
||||
"sm": "13px",
|
||||
"md": "14px",
|
||||
"lg": "15px",
|
||||
"xl": "18px",
|
||||
"2xl": "24px",
|
||||
"3xl": "30px"
|
||||
},
|
||||
"leading": {
|
||||
"tight": "1.25",
|
||||
"normal": "1.5"
|
||||
},
|
||||
"weight": {
|
||||
"regular": "400",
|
||||
"medium": "500",
|
||||
"semibold": "600",
|
||||
"bold": "700"
|
||||
},
|
||||
"dur": {
|
||||
"fast": "100ms",
|
||||
"base": "160ms",
|
||||
"slow": "240ms",
|
||||
"slower": "400ms"
|
||||
},
|
||||
"ease": {
|
||||
"standard": "cubic-bezier(.2, 0, 0, 1)",
|
||||
"enter": "cubic-bezier(0, 0, .2, 1)",
|
||||
"exit": "cubic-bezier(.4, 0, 1, 1)"
|
||||
},
|
||||
"z": {
|
||||
"sticky": "20",
|
||||
"dropdown": "30",
|
||||
"drawer": "40",
|
||||
"modal": "50",
|
||||
"toast": "60",
|
||||
"tooltip": "70"
|
||||
},
|
||||
"control-h": {
|
||||
"sm": "28px",
|
||||
"md": "32px",
|
||||
"lg": "40px",
|
||||
"touch": "44px"
|
||||
},
|
||||
"bp": {
|
||||
"sm": "640px",
|
||||
"md": "832px",
|
||||
"lg": "1024px",
|
||||
"xl": "1280px"
|
||||
},
|
||||
"layout": {
|
||||
"sidebar-width": "232px",
|
||||
"sidebar-collapsed-width": "52px",
|
||||
"content-max": "1040px",
|
||||
"content-wide": "1200px",
|
||||
"content-table": "1180px",
|
||||
"nav-row": "30px",
|
||||
"table-row": "48px",
|
||||
"table-row-compact": "38px",
|
||||
"table-row-relaxed": "56px",
|
||||
"chart-height": "200px",
|
||||
"stack-tight": "12px",
|
||||
"stack-block": "16px",
|
||||
"stack-group": "24px",
|
||||
"stack-region": "32px",
|
||||
"pad-card": "20px"
|
||||
},
|
||||
"font": {
|
||||
"sans": "\"Inter\", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica, Arial, sans-serif",
|
||||
"mono": "\"JetBrains Mono\", ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Monaco, Consolas, monospace"
|
||||
}
|
||||
},
|
||||
"themes": {
|
||||
"light": {
|
||||
"bg": "$kt-paper",
|
||||
"bg-soft": "$kt-gray-50",
|
||||
"bg-elevated": "$kt-snow",
|
||||
"bg-sunken": "$kt-gray-100",
|
||||
"bg-hover": "$kt-black-a04",
|
||||
"bg-active": "$kt-black-a08",
|
||||
"bg-glass": "color-mix(in srgb, #fcfcfb 85%, transparent)",
|
||||
"inset-highlight": "#ffffff1f",
|
||||
"ai-accent": "#7c4fe0",
|
||||
"ai-gradient": "linear-gradient(135deg, #0096d7, #7c4fe0 92%)",
|
||||
"ai-orb": "radial-gradient(circle at 32% 30%, #b69cf0, #0077c8 75%)",
|
||||
"orb-bg": "radial-gradient(circle at 42% 35%, #1c2940 0%, #0a0c12 72%)",
|
||||
"fg-on-ai": "#ffffff",
|
||||
"card-bg": "$kt-snow",
|
||||
"card-bg-glow": "$kt-snow",
|
||||
"card-border": "$kt-black-a08",
|
||||
"card-border-hover": "$kt-black-a16",
|
||||
"card-shadow": "0 1px 2px #0000000d",
|
||||
"card-shadow-hover": "0 4px 14px #00000014",
|
||||
"selection": "#1a1a1a16",
|
||||
"fg": "$kt-near-black",
|
||||
"fg-muted": "$kt-ink-600",
|
||||
"fg-faint": "$kt-ink-400",
|
||||
"fg-on-fill": "#ffffff",
|
||||
"border": "$kt-black-a08",
|
||||
"border-strong": "$kt-black-a16",
|
||||
"divider": "$kt-black-a06",
|
||||
"primary": "$kt-near-black",
|
||||
"primary-hover": "#343437",
|
||||
"primary-subtle": "$kt-black-a04",
|
||||
"brand-blue": "$kt-blue-500",
|
||||
"link": "$kt-blue-600",
|
||||
"focus-ring": "#1a1a1a99",
|
||||
"focus-shadow": "0 0 0 3px #1a1a1a1f",
|
||||
"danger": "#dc2626",
|
||||
"danger-hover": "#b91c1c",
|
||||
"overlay": "$kt-black-a32",
|
||||
"chip-default-bg": "$kt-black-a06",
|
||||
"chip-default-fg": "#2d2d2f",
|
||||
"chip-gray-bg": "$kt-grayc-a10",
|
||||
"chip-gray-fg": "$kt-ink-700",
|
||||
"chip-orange-bg": "$kt-orange-a14",
|
||||
"chip-orange-fg": "$kt-orange-700",
|
||||
"chip-green-bg": "$kt-green-a13",
|
||||
"chip-green-fg": "$kt-green-700",
|
||||
"chip-blue-bg": "$kt-blue-a14",
|
||||
"chip-blue-fg": "$kt-blue-700",
|
||||
"chip-purple-bg": "$kt-purple-a13",
|
||||
"chip-purple-fg": "$kt-purple-700",
|
||||
"chip-red-bg": "$kt-red-a13",
|
||||
"chip-red-fg": "$kt-red-700",
|
||||
"status-ok-bg": "$kt-green-a13",
|
||||
"status-ok-fg": "$kt-green-700",
|
||||
"status-warn-bg": "$kt-orange-a14",
|
||||
"status-warn-fg": "$kt-orange-700",
|
||||
"status-risk-bg": "$kt-red-a13",
|
||||
"status-risk-fg": "$kt-red-700",
|
||||
"status-info-bg": "$kt-grayc-a10",
|
||||
"status-info-fg": "$kt-ink-700",
|
||||
"chart-blue": "$kt-chart-blue",
|
||||
"chart-green": "$kt-chart-green",
|
||||
"chart-orange": "$kt-chart-orange",
|
||||
"chart-salmon": "$kt-chart-salmon",
|
||||
"chart-pink": "$kt-chart-pink",
|
||||
"chart-red": "$kt-chart-red",
|
||||
"chart-bar-fill": "#e4e4e0",
|
||||
"shadow-sm": "0 1px 2px #0000000d",
|
||||
"shadow-md": "0 4px 14px #00000014",
|
||||
"shadow-lg": "0 2px 6px #00000012, 0 12px 32px #0000001f",
|
||||
"shadow-xl": "0 3px 8px #00000014, 0 24px 64px #00000026"
|
||||
},
|
||||
"dark": {
|
||||
"bg": "$kt-gray-950",
|
||||
"bg-soft": "$kt-gray-900",
|
||||
"bg-elevated": "$kt-gray-850",
|
||||
"bg-sunken": "#060607",
|
||||
"bg-hover": "$kt-white-a06",
|
||||
"bg-active": "$kt-white-a09",
|
||||
"bg-glass": "color-mix(in srgb, #0a0a0b 85%, transparent)",
|
||||
"inset-highlight": "#00000014",
|
||||
"ai-accent": "#7c4fe0",
|
||||
"ai-gradient": "linear-gradient(135deg, #0096d7, #8b5cf6 92%)",
|
||||
"ai-orb": "radial-gradient(circle at 32% 30%, #c4b0ff, #0096d7 75%)",
|
||||
"orb-bg": "radial-gradient(circle at 42% 35%, #1c2940 0%, #0a0c12 72%)",
|
||||
"fg-on-ai": "#ffffff",
|
||||
"card-bg": "$kt-gray-900",
|
||||
"card-bg-glow": "$kt-gray-900",
|
||||
"card-border": "$kt-white-a09",
|
||||
"card-border-hover": "$kt-white-a18",
|
||||
"card-shadow": "none",
|
||||
"card-shadow-hover": "none",
|
||||
"selection": "#fafafa1f",
|
||||
"fg": "$kt-white-a92",
|
||||
"fg-muted": "#9b9b9e",
|
||||
"fg-faint": "#848487",
|
||||
"fg-on-fill": "$kt-ink-900",
|
||||
"border": "$kt-white-a09",
|
||||
"border-strong": "$kt-white-a18",
|
||||
"divider": "$kt-white-a06",
|
||||
"primary": "$kt-near-white",
|
||||
"primary-hover": "#e4e4e7",
|
||||
"primary-subtle": "$kt-white-a09",
|
||||
"brand-blue": "$kt-blue-500",
|
||||
"link": "$kt-blue-400",
|
||||
"focus-ring": "#fafafa80",
|
||||
"focus-shadow": "0 0 0 3px #fafafa24",
|
||||
"danger": "#f0726a",
|
||||
"danger-hover": "#f48a83",
|
||||
"overlay": "$kt-black-a55",
|
||||
"chip-default-bg": "$kt-white-a09",
|
||||
"chip-default-fg": "$kt-white-a92",
|
||||
"chip-gray-bg": "$kt-grayc-a18",
|
||||
"chip-gray-fg": "$kt-gray-300",
|
||||
"chip-orange-bg": "$kt-orange-a18",
|
||||
"chip-orange-fg": "$kt-orange-300",
|
||||
"chip-green-bg": "$kt-green-a18",
|
||||
"chip-green-fg": "$kt-green-300",
|
||||
"chip-blue-bg": "$kt-blue-a18",
|
||||
"chip-blue-fg": "$kt-blue-300",
|
||||
"chip-purple-bg": "$kt-purple-a18",
|
||||
"chip-purple-fg": "$kt-purple-300",
|
||||
"chip-red-bg": "$kt-red-a20",
|
||||
"chip-red-fg": "$kt-red-300",
|
||||
"status-ok-bg": "$kt-green-a18",
|
||||
"status-ok-fg": "$kt-green-300",
|
||||
"status-warn-bg": "$kt-orange-a18",
|
||||
"status-warn-fg": "$kt-orange-300",
|
||||
"status-risk-bg": "$kt-red-a20",
|
||||
"status-risk-fg": "$kt-red-300",
|
||||
"status-info-bg": "$kt-grayc-a18",
|
||||
"status-info-fg": "$kt-gray-300",
|
||||
"chart-blue": "$kt-chart-blue",
|
||||
"chart-green": "$kt-chart-green",
|
||||
"chart-orange": "$kt-chart-orange",
|
||||
"chart-salmon": "$kt-chart-salmon",
|
||||
"chart-pink": "$kt-chart-pink",
|
||||
"chart-red": "$kt-chart-red",
|
||||
"chart-bar-fill": "#2e2e31",
|
||||
"shadow-sm": "0 1px 2px #00000052",
|
||||
"shadow-md": "0 4px 16px #00000066",
|
||||
"shadow-lg": "0 12px 40px #00000080",
|
||||
"shadow-xl": "0 24px 80px #00000099"
|
||||
}
|
||||
}
|
||||
}
|
||||
228
design.md
Normal file
228
design.md
Normal file
@ -0,0 +1,228 @@
|
||||
<!-- vibe42-design-version: v4-ds-scaffold-2026-09-11 -->
|
||||
# Дизайн-система KT AI — по ней собирается весь UI
|
||||
|
||||
В папке проекта лежит `design-system/` — это **дизайн-система KT AI**
|
||||
(источник: `github.com/erjan-A/kt-ai-design-system`, MIT, версия в `design-system/VERSION`).
|
||||
Весь визуал берётся оттуда: цвета, шрифт, отступы, кнопки, карточки, секции лендинга.
|
||||
|
||||
**Не выдумывай свой стиль и не тащи чужие фреймворки.** Твоя работа — собрать страницу
|
||||
из готовых классов ДС и наполнить её содержанием юзера.
|
||||
|
||||
Если папки `design-system/` в проекте нет (старый проект) — принеси её один раз:
|
||||
|
||||
```bash
|
||||
cp -r /srv/opencode/design-system/kt-ai design-system
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Три правила, которые нельзя нарушать
|
||||
|
||||
1. **Цвета и размеры — только токенами** `var(--kt-ai-…)`. Сырой hex (`#0F1218`, `#fff`) в вёрстке
|
||||
запрещён: он ломает светлую/тёмную тему.
|
||||
2. **Никакого Bootstrap / Tailwind / Material / Font Awesome / Google Fonts.** Шрифт (Inter) уже
|
||||
внутри ДС и работает офлайн. Всё, что нужно лендингу, в ДС уже есть.
|
||||
3. **Свой `<style>` — только на мелочи**, которых нет в ДС (позиционирование, картинка-фон).
|
||||
Не переопределяй кнопки, карточки и типографику.
|
||||
|
||||
---
|
||||
|
||||
## Старт: `index.html`
|
||||
|
||||
Это рабочий каркас лендинга. Копируй его целиком и меняй содержание.
|
||||
|
||||
**В новом проекте этот каркас уже лежит как `index.html`** — не создавай файл заново: `read index.html` → `edit`.
|
||||
Если проект старый и файла нет — скопируй каркас ниже целиком. Если проект не про веб-страницу — стартовый `index.html` можно удалить.
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" data-theme="auto">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Название проекта</title>
|
||||
<link rel="stylesheet" href="design-system/kt-ai-fonts.css">
|
||||
<link rel="stylesheet" href="design-system/kt-ai-tokens.css">
|
||||
<link rel="stylesheet" href="design-system/kt-ai-components.css">
|
||||
<link rel="stylesheet" href="design-system/kt-ai-page.css">
|
||||
<style>
|
||||
/* ДС рисует кнопки для <button>; у ссылки-кнопки браузер добавляет подчёркивание */
|
||||
a.kt-ai-btn { text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="kt-ai-pagehead">
|
||||
<div class="kt-ai-wrap kt-ai-pagehead-in">
|
||||
<strong>Название</strong>
|
||||
<span class="kt-ai-spacer"></span>
|
||||
<a class="kt-ai-btn" data-variant="ghost" href="#about">О нас</a>
|
||||
<a class="kt-ai-btn" data-variant="primary" href="#contacts">Оставить заявку</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="kt-ai-hero" data-align="center">
|
||||
<div class="kt-ai-wrap">
|
||||
<h1>Главное обещание в одну строку</h1>
|
||||
<p class="kt-ai-hero-sub">Подзаголовок: кому это и почему стоит остаться.</p>
|
||||
<div class="kt-ai-hero-actions">
|
||||
<a class="kt-ai-btn" data-variant="primary" data-size="lg" href="#contacts">Оставить заявку</a>
|
||||
<a class="kt-ai-btn" data-size="lg" href="#about">Подробнее</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="about" class="kt-ai-section" data-divider="true">
|
||||
<div class="kt-ai-wrap">
|
||||
<div class="kt-ai-section-head">
|
||||
<h2>Что вы получите</h2>
|
||||
<p>Одно-два предложения пояснения к секции.</p>
|
||||
</div>
|
||||
<div class="kt-ai-grid" style="margin-top:var(--kt-ai-space-9)">
|
||||
<article class="kt-ai-tile"><h3>Пункт первый</h3><p>Коротко и по делу.</p></article>
|
||||
<article class="kt-ai-tile"><h3>Пункт второй</h3><p>Коротко и по делу.</p></article>
|
||||
<article class="kt-ai-tile"><h3>Пункт третий</h3><p>Коротко и по делу.</p></article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="contacts" class="kt-ai-cta">
|
||||
<div class="kt-ai-wrap">
|
||||
<h2>Призыв к действию</h2>
|
||||
<p>Город, адрес, часы работы — то, что нужно, чтобы дойти.</p>
|
||||
<a class="kt-ai-btn" data-variant="primary" data-size="lg" href="#">Написать</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="kt-ai-wrap kt-ai-pagefoot">
|
||||
<span>© 2026 Название</span>
|
||||
<span class="kt-ai-spacer"></span>
|
||||
<span>Контакты</span>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Блоки страницы (`kt-ai-page.css`)
|
||||
|
||||
| Класс | Что это |
|
||||
|-------|---------|
|
||||
| `.kt-ai-wrap` | контейнер по центру (1080px). `data-width="narrow"` — 720px, `"wide"` — 1200px |
|
||||
| `.kt-ai-pagehead` + `.kt-ai-pagehead-in` | липкая шапка страницы |
|
||||
| `.kt-ai-hero`, `.kt-ai-hero-sub`, `.kt-ai-hero-actions` | первый экран. `data-align="center"` — по центру |
|
||||
| `.kt-ai-section` + `.kt-ai-section-head` (h2 + p) | обычная секция. `data-divider="true"` — линия сверху |
|
||||
| `.kt-ai-grid` + `.kt-ai-tile` (h3 + p) | сетка плиток «возможности». `data-cols="2"/"4"`, у плитки `data-span="2"/"full"` |
|
||||
| `.kt-ai-tile-icon` | иконка над заголовком плитки (спрайт — `design-system/icons/`) |
|
||||
| `.kt-ai-cta` | финальный блок с призывом |
|
||||
| `.kt-ai-pagefoot` | подвал |
|
||||
| `.kt-ai-spacer` | распорка внутри шапки/подвала (толкает остальное вправо) |
|
||||
| `.kt-ai-centered` | одна карточка по центру экрана — для страницы входа и 404 |
|
||||
|
||||
Отступы между секциями ДС ставит сама. Адаптив (3→2→1 колонка) — тоже, `@media` писать не нужно.
|
||||
|
||||
## Элементы (`kt-ai-components.css`)
|
||||
|
||||
| Класс | Как применять |
|
||||
|-------|---------------|
|
||||
| `.kt-ai-btn` | кнопка / ссылка-кнопка. `data-variant="primary"` — главная, `"ghost"` — прозрачная, `"danger"` — опасная. `data-size="sm"/"lg"` |
|
||||
| `.kt-ai-card` | карточка контента |
|
||||
| `.kt-ai-chip` | статус-пилюля / тег |
|
||||
| `.kt-ai-link` | текстовая ссылка |
|
||||
| `.kt-ai-field` + `.kt-ai-input` / `.kt-ai-textarea` / `.kt-ai-select` | поле формы с подписью |
|
||||
|
||||
**Один `primary` на экран.** Остальные кнопки — обычные или `ghost`.
|
||||
|
||||
## Токены (`kt-ai-tokens.css`)
|
||||
|
||||
Цвет: `--kt-ai-bg`, `--kt-ai-bg-soft`, `--kt-ai-fg`, `--kt-ai-fg-muted`, `--kt-ai-fg-faint`,
|
||||
`--kt-ai-border`, `--kt-ai-divider`, `--kt-ai-card-bg`, `--kt-ai-card-border`,
|
||||
`--kt-ai-primary`, `--kt-ai-link`, `--kt-ai-danger`.
|
||||
|
||||
Отступы: `--kt-ai-space-1…12` (2px → 64px). Скругления: `--kt-ai-radius-md/lg/xl/2xl/3xl/full`.
|
||||
Текст: `--kt-ai-text-xs…3xl`. Вес: `--kt-ai-weight-regular/medium/semibold/bold`.
|
||||
Шрифты: `--kt-ai-font-sans` (Inter), `--kt-ai-font-mono`.
|
||||
|
||||
**Тема** — атрибут на `<html>`: `data-theme="auto"` (по системной, ставь его по умолчанию),
|
||||
`"light"`, `"dark"`. Проверь страницу в обеих темах — с токенами это бесплатно.
|
||||
|
||||
---
|
||||
|
||||
## Не лендинг, а приложение (дашборд, таблица, очередь заявок, чат)
|
||||
|
||||
Тогда экран собирается **не вёрсткой руками, а конфигом**:
|
||||
|
||||
1. Возьми `design-system/templates/kt-ai-app-shell.html` — самодостаточный HTML-каркас приложения
|
||||
(без React и npm).
|
||||
2. Опиши экран JSON-конфигом по `design-system/docs/PRODUCT_CONTRACT.md` и положи его в тег
|
||||
`<script id="kt-app-config" type="application/json">…</script>` внутри каркаса — он сам отрисует
|
||||
таблицу, KPI, фильтры, drawer и темы.
|
||||
3. Проверь конфиг: `python3 design-system/scripts/validate_product.py config.json --strict` → нужен `0/0`.
|
||||
|
||||
Полные правила для сложных экранов — `design-system/AGENTS.md`, список компонентов —
|
||||
`design-system/COMPONENTS.md`, приёмка — `design-system/CHECKLIST.md`. Читай их, когда лендинга
|
||||
уже мало.
|
||||
|
||||
---
|
||||
|
||||
## 📦 Готовые рецепты
|
||||
|
||||
### Фото без ключей
|
||||
- Одно и то же слово → одна и та же картинка: `https://picsum.photos/seed/coffee/800/600`
|
||||
- По теме: `https://loremflickr.com/800/600/coffee` (тег прямо в URL)
|
||||
|
||||
Вставляй с рамкой ДС: `<img src="…" alt="…" style="width:100%;border-radius:var(--kt-ai-radius-3xl)">`
|
||||
|
||||
### Кнопка WhatsApp
|
||||
```html
|
||||
<a class="kt-ai-btn" data-variant="primary" data-size="lg"
|
||||
href="https://wa.me/7XXXXXXXXXX?text=Здравствуйте!">Написать в WhatsApp</a>
|
||||
```
|
||||
`7XXXXXXXXXX` — номер в международном формате без `+`.
|
||||
|
||||
### Форма заявки без бэкенда
|
||||
```html
|
||||
<form action="https://formsubmit.co/EMAIL" method="POST" style="max-width:420px">
|
||||
<div class="kt-ai-field"><label>Имя</label><input class="kt-ai-input" name="name" required></div>
|
||||
<div class="kt-ai-field"><label>Телефон</label><input class="kt-ai-input" name="phone" required></div>
|
||||
<button class="kt-ai-btn" data-variant="primary" type="submit">Отправить</button>
|
||||
</form>
|
||||
```
|
||||
Скажи юзеру: замени `EMAIL` на свою почту — заявки будут приходить туда (первая заявка активирует форму).
|
||||
|
||||
### Favicon-эмодзи (без файла)
|
||||
```html
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>☕</text></svg>">
|
||||
```
|
||||
|
||||
### Карта
|
||||
На maps.google.com: место → «Поделиться» → «Встроить карту» → вставь iframe как есть:
|
||||
```html
|
||||
<iframe src="https://www.google.com/maps/embed?pb=..." width="100%" height="320"
|
||||
style="border:0;border-radius:var(--kt-ai-radius-3xl)" loading="lazy"></iframe>
|
||||
```
|
||||
|
||||
### Плавные появления при скролле
|
||||
```html
|
||||
<style>
|
||||
.reveal{opacity:0;transform:translateY(20px);transition:opacity .6s ease,transform .6s ease}
|
||||
.reveal.in{opacity:1;transform:none}
|
||||
</style>
|
||||
<script>
|
||||
var io=new IntersectionObserver(function(entries){
|
||||
entries.forEach(function(e){ if(e.isIntersecting) e.target.classList.add('in'); });
|
||||
},{threshold:.15});
|
||||
document.querySelectorAll('.reveal').forEach(function(el){ io.observe(el); });
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Перед тем как сказать «готово»
|
||||
|
||||
- [ ] Подключены все четыре CSS из `design-system/`, папка лежит в проекте (не забудь закоммитить её).
|
||||
- [ ] В вёрстке нет сырых hex-цветов и чужих шрифтов.
|
||||
- [ ] Страница нормально выглядит в светлой и тёмной теме и на телефоне (узкое окно).
|
||||
- [ ] Один `data-variant="primary"` на экран, тексты — про юзера, а не «Lorem ipsum».
|
||||
493
index.html
Normal file
493
index.html
Normal file
@ -0,0 +1,493 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Разбор похожих позиций — Годовой план закупок</title>
|
||||
<link rel="stylesheet" href="design-system/kt-ai-fonts.css">
|
||||
<link rel="stylesheet" href="design-system/kt-ai-tokens.css">
|
||||
<link rel="stylesheet" href="design-system/kt-ai-components.css">
|
||||
<link rel="stylesheet" href="design-system/kt-ai-page.css">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📋</text></svg>">
|
||||
<style>
|
||||
a.kt-ai-btn { text-decoration: none; }
|
||||
.kt-ai-app { background: var(--kt-ai-bg); color: var(--kt-ai-fg); font-family: var(--kt-ai-font-sans); min-height: 100vh; }
|
||||
.app-header { display: flex; align-items: center; gap: var(--kt-ai-space-4); padding: var(--kt-ai-space-4) 0; }
|
||||
.app-title { font-size: var(--kt-ai-text-xl); font-weight: var(--kt-ai-weight-semibold); }
|
||||
.filters-panel { display: flex; gap: var(--kt-ai-space-3); flex-wrap: wrap; align-items: center; margin-bottom: var(--kt-ai-space-6); }
|
||||
.kpi-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--kt-ai-space-4); margin-bottom: var(--kt-ai-space-6); }
|
||||
.groups-list { display: flex; flex-direction: column; gap: var(--kt-ai-space-4); }
|
||||
.group-card { border: 1px solid var(--kt-ai-border); border-radius: var(--kt-ai-radius-xl); padding: var(--kt-ai-space-5); background: var(--kt-ai-card-bg); }
|
||||
.group-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--kt-ai-space-3); }
|
||||
.group-items { display: flex; flex-direction: column; gap: var(--kt-ai-space-2); margin: var(--kt-ai-space-3) 0; }
|
||||
.group-item { display: grid; grid-template-columns: auto 1fr auto auto auto; gap: var(--kt-ai-space-3); align-items: center; padding: var(--kt-ai-space-3); border: 1px solid var(--kt-ai-border); border-radius: var(--kt-ai-radius-lg); }
|
||||
.group-item.selected { background: var(--kt-ai-bg-soft); border-color: var(--kt-ai-primary); }
|
||||
.item-checkbox { width: 18px; height: 18px; cursor: pointer; }
|
||||
.item-name { font-size: var(--kt-ai-text-sm); }
|
||||
.item-code { font-family: var(--kt-ai-font-mono); font-size: var(--kt-ai-text-xs); color: var(--kt-ai-fg-muted); }
|
||||
.item-amount { text-align: right; font-family: var(--kt-ai-font-mono); font-size: var(--kt-ai-text-sm); tabular-nums: tabular-nums; }
|
||||
.item-category { font-size: var(--kt-ai-text-xs); }
|
||||
.item-actions { display: flex; gap: var(--kt-ai-space-2); }
|
||||
.group-actions { display: flex; gap: var(--kt-ai-space-3); align-items: center; margin-top: var(--kt-ai-space-4); padding-top: var(--kt-ai-space-4); border-top: 1px solid var(--kt-ai-border); }
|
||||
.comment-field { flex: 1; }
|
||||
.empty-state { text-align: center; padding: var(--kt-ai-space-12) var(--kt-ai-space-6); color: var(--kt-ai-fg-muted); }
|
||||
.empty-icon { font-size: 48px; margin-bottom: var(--kt-ai-space-4); }
|
||||
.status-chip { min-width: 100px; justify-self: end; }
|
||||
.selection-panel { position: sticky; bottom: var(--kt-ai-space-4); background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-border); border-radius: var(--kt-ai-radius-xl); padding: var(--kt-ai-space-4); margin-top: var(--kt-ai-space-6); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
|
||||
.selection-info { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--kt-ai-space-3); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="kt-ai-app" data-kt-data-mode="demo">
|
||||
|
||||
<div class="kt-ai-wrap" style="padding-top:var(--kt-ai-space-6);padding-bottom:var(--kt-ai-space-8)">
|
||||
|
||||
<header class="app-header">
|
||||
<img src="https://code.vibe42.kz/tore/aitu/kt-logo.svg" alt="Казахтелеком" height="28">
|
||||
<span class="app-title">Разбор похожих позиций</span>
|
||||
<span class="kt-ai-spacer"></span>
|
||||
<span class="kt-ai-chip" data-tone="orange">Демо-данные</span>
|
||||
</header>
|
||||
|
||||
<div class="kpi-strip" style="margin-top:var(--kt-ai-space-6)">
|
||||
<div class="kt-ai-kpi">
|
||||
<div class="value" id="kpi-total">0</div>
|
||||
<div class="label">Всего групп</div>
|
||||
<div class="hint">Найдено похожих</div>
|
||||
</div>
|
||||
<div class="kt-ai-kpi">
|
||||
<div class="value" id="kpi-processed" style="color:var(--kt-ai-status-ok-fg)">0</div>
|
||||
<div class="label">Обработано</div>
|
||||
<div class="hint">Подтверждено или отклонено</div>
|
||||
</div>
|
||||
<div class="kt-ai-kpi">
|
||||
<div class="value" id="kpi-remaining" style="color:var(--kt-ai-status-warn-fg)">0</div>
|
||||
<div class="label">Осталось</div>
|
||||
<div class="hint">Требует разбора</div>
|
||||
</div>
|
||||
<div class="kt-ai-kpi">
|
||||
<div class="value" id="kpi-sum" style="color:var(--kt-ai-chart-blue)">0 ₸</div>
|
||||
<div class="label">Сумма в работе</div>
|
||||
<div class="hint">По выбранным группам</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters-panel">
|
||||
<div class="kt-ai-field" style="margin:0">
|
||||
<label>Категория</label>
|
||||
<select class="kt-ai-select" id="filter-category">
|
||||
<option value="all">Все категории</option>
|
||||
<option value="it">IT и оборудование</option>
|
||||
<option value="services">Услуги</option>
|
||||
<option value="materials">Материалы</option>
|
||||
<option value="construction">Строительство</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="kt-ai-field" style="margin:0">
|
||||
<label>Статус</label>
|
||||
<select class="kt-ai-select" id="filter-status">
|
||||
<option value="all">Все статусы</option>
|
||||
<option value="new">Новые</option>
|
||||
<option value="review">На проверке</option>
|
||||
<option value="confirmed">Подтверждено</option>
|
||||
<option value="rejected">Отклонено</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="kt-ai-field" style="margin:0">
|
||||
<label>Период</label>
|
||||
<select class="kt-ai-select" id="filter-period">
|
||||
<option value="all">Весь период</option>
|
||||
<option value="2026">2026 год</option>
|
||||
<option value="2025">2025 год</option>
|
||||
<option value="q1">Q1 2026</option>
|
||||
<option value="q2">Q2 2026</option>
|
||||
</select>
|
||||
<span data-kt-filter-note style="font-size:var(--kt-ai-text-xs);color:var(--kt-ai-fg-muted);margin-left:var(--kt-ai-space-2)">в демо не меняет набор</span>
|
||||
</div>
|
||||
<span class="kt-ai-spacer"></span>
|
||||
<button class="kt-ai-btn" data-variant="ghost" id="btn-reset">Сбросить фильтры</button>
|
||||
</div>
|
||||
|
||||
<div id="empty-state" class="empty-state" style="display:none">
|
||||
<div class="empty-icon">✅</div>
|
||||
<h3 style="margin-bottom:var(--kt-ai-space-2)">Все позиции разобраны</h3>
|
||||
<p>Нет групп для обработки с выбранными фильтрами</p>
|
||||
</div>
|
||||
|
||||
<div class="groups-list" id="groups-container"></div>
|
||||
|
||||
<div class="selection-panel" id="selection-panel" style="display:none">
|
||||
<div class="selection-info">
|
||||
<strong>Выбрано позиций: <span id="selected-count">0</span></strong>
|
||||
<span class="kt-ai-chip" id="selection-status">Готово к обработке</span>
|
||||
</div>
|
||||
<div class="kt-ai-field comment-field">
|
||||
<label>Комментарий к решению</label>
|
||||
<textarea class="kt-ai-textarea" id="group-comment" rows="2" placeholder="Укажите причину подтверждения или отклонения..."></textarea>
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--kt-ai-space-3);margin-top:var(--kt-ai-space-3)">
|
||||
<button class="kt-ai-btn" data-variant="danger" id="btn-reject">Отклонить выбранные</button>
|
||||
<span class="kt-ai-spacer"></span>
|
||||
<button class="kt-ai-btn" data-variant="primary" id="btn-confirm">Подтвердить выбранные</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Демо-данные: группы похожих позиций годового плана закупок
|
||||
const DEMO_DATA = [
|
||||
{
|
||||
id: 'group-1',
|
||||
status: 'new',
|
||||
items: [
|
||||
{ code: 'ZKP-2026-00142', name: 'Поставка компьютерного оборудования для нужд департамента', amount: 2450000, category: 'it', year: '2026', quarter: 'q1' },
|
||||
{ code: 'ZKP-2026-00189', name: 'Закупка компьютерной техники для офисов', amount: 2380000, category: 'it', year: '2026', quarter: 'q1' },
|
||||
{ code: 'ZKP-2026-00201', name: 'Поставка ПК и периферийного оборудования', amount: 2510000, category: 'it', year: '2026', quarter: 'q1' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'group-2',
|
||||
status: 'new',
|
||||
items: [
|
||||
{ code: 'ZKP-2026-00312', name: 'Услуги по технической поддержке программного обеспечения', amount: 890000, category: 'services', year: '2026', quarter: 'q2' },
|
||||
{ code: 'ZKP-2026-00345', name: 'Техническая поддержка и сопровождение ПО', amount: 920000, category: 'services', year: '2026', quarter: 'q2' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'group-3',
|
||||
status: 'new',
|
||||
items: [
|
||||
{ code: 'ZKP-2026-00478', name: 'Приобретение кабельной продукции для магистральных линий', amount: 15600000, category: 'materials', year: '2026', quarter: 'q2' },
|
||||
{ code: 'ZKP-2026-00489', name: 'Поставка кабеля и проводников для сетей связи', amount: 15200000, category: 'materials', year: '2026', quarter: 'q2' },
|
||||
{ code: 'ZKP-2026-00501', name: 'Кабельно-проводниковая продукция для строительства ЛЭП', amount: 14900000, category: 'materials', year: '2026', quarter: 'q2' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'group-4',
|
||||
status: 'new',
|
||||
items: [
|
||||
{ code: 'ZKP-2026-00623', name: 'Строительно-монтажные работы по реконструкции здания', amount: 42000000, category: 'construction', year: '2026', quarter: 'q1' },
|
||||
{ code: 'ZKP-2026-00634', name: 'Реконструкция административного здания с капитальным ремонтом', amount: 43500000, category: 'construction', year: '2026', quarter: 'q1' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'group-5',
|
||||
status: 'new',
|
||||
items: [
|
||||
{ code: 'ZKP-2025-01234', name: 'Услуги уборки служебных помещений', amount: 3200000, category: 'services', year: '2025', quarter: 'q4' },
|
||||
{ code: 'ZKP-2025-01256', name: 'Клининговые услуги для офисных площадей', amount: 3100000, category: 'services', year: '2025', quarter: 'q4' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'group-6',
|
||||
status: 'new',
|
||||
items: [
|
||||
{ code: 'ZKP-2026-00789', name: 'Поставка серверного оборудования и систем хранения', amount: 28700000, category: 'it', year: '2026', quarter: 'q3' },
|
||||
{ code: 'ZKP-2026-00801', name: 'Закупка серверов и СХД для дата-центра', amount: 29100000, category: 'it', year: '2026', quarter: 'q3' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const CATEGORY_NAMES = {
|
||||
it: 'IT и оборудование',
|
||||
services: 'Услуги',
|
||||
materials: 'Материалы',
|
||||
construction: 'Строительство'
|
||||
};
|
||||
|
||||
const STATUS_LABELS = {
|
||||
new: { text: 'Новые', tone: 'blue' },
|
||||
review: { text: 'На проверке', tone: 'orange' },
|
||||
confirmed: { text: 'Подтверждено', tone: 'green' },
|
||||
rejected: { text: 'Отклонено', tone: 'red' }
|
||||
};
|
||||
|
||||
// Состояние приложения
|
||||
let appState = {
|
||||
groups: [],
|
||||
selectedItems: new Set(),
|
||||
filters: { category: 'all', status: 'all', period: 'all' }
|
||||
};
|
||||
|
||||
// Загрузка из localStorage
|
||||
function loadState() {
|
||||
const saved = localStorage.getItem('zakupki-review-state');
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
appState.groups = parsed.groups || [];
|
||||
appState.filters = parsed.filters || { category: 'all', status: 'all', period: 'all' };
|
||||
} catch (e) {
|
||||
appState.groups = [];
|
||||
}
|
||||
}
|
||||
if (appState.groups.length === 0) {
|
||||
appState.groups = JSON.parse(JSON.stringify(DEMO_DATA));
|
||||
}
|
||||
}
|
||||
|
||||
// Сохранение в localStorage
|
||||
function saveState() {
|
||||
const toSave = {
|
||||
groups: appState.groups,
|
||||
filters: appState.filters
|
||||
};
|
||||
localStorage.setItem('zakupki-review-state', JSON.stringify(toSave));
|
||||
}
|
||||
|
||||
// Форматирование суммы
|
||||
function formatAmount(amount) {
|
||||
return new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'KZT', maximumFractionDigits: 0 }).format(amount);
|
||||
}
|
||||
|
||||
// Рендер группы
|
||||
function renderGroup(group, index) {
|
||||
const statusInfo = STATUS_LABELS[group.status] || STATUS_LABELS.new;
|
||||
const isProcessed = group.status === 'confirmed' || group.status === 'rejected';
|
||||
|
||||
let itemsHtml = '';
|
||||
group.items.forEach((item, itemIndex) => {
|
||||
const isSelected = appState.selectedItems.has(`${group.id}-${itemIndex}`);
|
||||
itemsHtml += `
|
||||
<div class="group-item ${isSelected ? 'selected' : ''}" data-group="${group.id}" data-item="${itemIndex}">
|
||||
<input type="checkbox" class="item-checkbox" ${isSelected ? 'checked' : ''} ${isProcessed ? 'disabled' : ''}>
|
||||
<div>
|
||||
<div class="item-name">${item.name}</div>
|
||||
<div class="item-code">${item.code}</div>
|
||||
</div>
|
||||
<div class="item-category"><span class="kt-ai-chip" data-tone="gray">${CATEGORY_NAMES[item.category]}</span></div>
|
||||
<div class="item-amount">${formatAmount(item.amount)}</div>
|
||||
<div class="status-chip"><span class="kt-ai-chip" data-tone="${statusInfo.tone}">${statusInfo.text}</span></div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
return `
|
||||
<div class="group-card" data-group-id="${group.id}" style="${isProcessed ? 'opacity:0.6' : ''}">
|
||||
<div class="group-header">
|
||||
<strong>Группа ${index + 1} — ${CATEGORY_NAMES[group.items[0].category]}</strong>
|
||||
<span class="kt-ai-chip" data-tone="${statusInfo.tone}">${group.items.length} позиций</span>
|
||||
</div>
|
||||
<div class="group-items">${itemsHtml}</div>
|
||||
${!isProcessed ? `
|
||||
<div class="group-actions">
|
||||
<span class="kt-ai-chip" data-tone="gray" style="font-size:var(--kt-ai-text-xs)">Похожесть: ${Math.floor(Math.random() * 15 + 85)}%</span>
|
||||
<span class="kt-ai-spacer"></span>
|
||||
<button class="kt-ai-btn kt-ai-btn-ghost btn-reject-group" data-group="${group.id}">Отклонить все</button>
|
||||
<button class="kt-ai-btn kt-ai-btn-primary btn-confirm-group" data-group="${group.id}">Подтвердить все</button>
|
||||
</div>
|
||||
` : `<div style="margin-top:var(--kt-ai-space-3);font-size:var(--kt-ai-text-sm);color:var(--kt-ai-fg-muted)">Обработано: ${group.comment || 'без комментария'}</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Фильтрация групп
|
||||
function filterGroups() {
|
||||
const { category, status, period } = appState.filters;
|
||||
|
||||
return appState.groups.filter(group => {
|
||||
if (group.status === 'confirmed' || group.status === 'rejected') {
|
||||
if (status !== 'all' && status !== 'confirmed' && status !== 'rejected') return false;
|
||||
if (status === 'confirmed' && group.status !== 'confirmed') return false;
|
||||
if (status === 'rejected' && group.status !== 'rejected') return false;
|
||||
} else {
|
||||
if (status === 'confirmed' || status === 'rejected') return false;
|
||||
if (status === 'review') return false;
|
||||
}
|
||||
|
||||
const itemCategory = group.items[0].category;
|
||||
if (category !== 'all' && itemCategory !== category) return false;
|
||||
|
||||
const itemPeriod = group.items[0].quarter;
|
||||
const itemYear = group.items[0].year;
|
||||
if (period !== 'all') {
|
||||
if (period === '2026' && itemYear !== '2026') return false;
|
||||
if (period === '2025' && itemYear !== '2025') return false;
|
||||
if (period === 'q1' && itemPeriod !== 'q1') return false;
|
||||
if (period === 'q2' && itemPeriod !== 'q2') return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Обновление KPI
|
||||
function updateKPI() {
|
||||
const total = appState.groups.length;
|
||||
const processed = appState.groups.filter(g => g.status === 'confirmed' || g.status === 'rejected').length;
|
||||
const remaining = total - processed;
|
||||
|
||||
const remainingGroups = appState.groups.filter(g => g.status !== 'confirmed' && g.status !== 'rejected');
|
||||
const sumInWork = remainingGroups.reduce((sum, g) => {
|
||||
return sum + g.items.reduce((s, i) => s + i.amount, 0);
|
||||
}, 0);
|
||||
|
||||
document.getElementById('kpi-total').textContent = total;
|
||||
document.getElementById('kpi-processed').textContent = processed;
|
||||
document.getElementById('kpi-remaining').textContent = remaining;
|
||||
document.getElementById('kpi-sum').textContent = formatAmount(sumInWork);
|
||||
}
|
||||
|
||||
// Рендер списка групп
|
||||
function renderGroups() {
|
||||
const container = document.getElementById('groups-container');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
const filtered = filterGroups();
|
||||
|
||||
if (filtered.length === 0) {
|
||||
container.style.display = 'none';
|
||||
emptyState.style.display = 'block';
|
||||
document.getElementById('selection-panel').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
container.style.display = 'flex';
|
||||
|
||||
let html = '';
|
||||
filtered.forEach((group, index) => {
|
||||
html += renderGroup(group, index);
|
||||
});
|
||||
container.innerHTML = html;
|
||||
|
||||
updateKPI();
|
||||
attachEventListeners();
|
||||
}
|
||||
|
||||
// Прикрепление обработчиков
|
||||
function attachEventListeners() {
|
||||
document.querySelectorAll('.item-checkbox').forEach(cb => {
|
||||
cb.addEventListener('change', function() {
|
||||
const groupEl = this.closest('.group-item');
|
||||
const groupId = groupEl.dataset.group;
|
||||
const itemIndex = groupEl.dataset.item;
|
||||
const key = `${groupId}-${itemIndex}`;
|
||||
|
||||
if (this.checked) {
|
||||
appState.selectedItems.add(key);
|
||||
groupEl.classList.add('selected');
|
||||
} else {
|
||||
appState.selectedItems.delete(key);
|
||||
groupEl.classList.remove('selected');
|
||||
}
|
||||
updateSelectionPanel();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.btn-reject-group').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const groupId = this.dataset.group;
|
||||
processGroup(groupId, 'rejected', 'Отклонено массово');
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.btn-confirm-group').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const groupId = this.dataset.group;
|
||||
processGroup(groupId, 'confirmed', 'Подтверждено массово');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Обновление панели выбора
|
||||
function updateSelectionPanel() {
|
||||
const panel = document.getElementById('selection-panel');
|
||||
const count = appState.selectedItems.size;
|
||||
|
||||
if (count === 0) {
|
||||
panel.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
panel.style.display = 'block';
|
||||
document.getElementById('selected-count').textContent = count;
|
||||
}
|
||||
|
||||
// Обработка группы
|
||||
function processGroup(groupId, status, defaultComment) {
|
||||
const group = appState.groups.find(g => g.id === groupId);
|
||||
if (!group) return;
|
||||
|
||||
const comment = document.getElementById('group-comment').value || defaultComment;
|
||||
group.status = status;
|
||||
group.comment = comment;
|
||||
|
||||
appState.selectedItems.clear();
|
||||
saveState();
|
||||
renderGroups();
|
||||
document.getElementById('group-comment').value = '';
|
||||
}
|
||||
|
||||
// Сброс фильтров
|
||||
function resetFilters() {
|
||||
appState.filters = { category: 'all', status: 'all', period: 'all' };
|
||||
document.getElementById('filter-category').value = 'all';
|
||||
document.getElementById('filter-status').value = 'all';
|
||||
document.getElementById('filter-period').value = 'all';
|
||||
saveState();
|
||||
renderGroups();
|
||||
}
|
||||
|
||||
// Инициализация
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadState();
|
||||
renderGroups();
|
||||
|
||||
document.getElementById('filter-category').addEventListener('change', function() {
|
||||
appState.filters.category = this.value;
|
||||
saveState();
|
||||
renderGroups();
|
||||
});
|
||||
|
||||
document.getElementById('filter-status').addEventListener('change', function() {
|
||||
appState.filters.status = this.value;
|
||||
saveState();
|
||||
renderGroups();
|
||||
});
|
||||
|
||||
document.getElementById('filter-period').addEventListener('change', function() {
|
||||
appState.filters.period = this.value;
|
||||
saveState();
|
||||
renderGroups();
|
||||
});
|
||||
|
||||
document.getElementById('btn-reset').addEventListener('click', resetFilters);
|
||||
|
||||
document.getElementById('btn-confirm').addEventListener('click', function() {
|
||||
if (appState.selectedItems.size === 0) return;
|
||||
const comment = document.getElementById('group-comment').value || 'Подтверждено пользователем';
|
||||
appState.selectedItems.forEach(key => {
|
||||
const [groupId, itemIndex] = key.split('-');
|
||||
const group = appState.groups.find(g => g.id === groupId);
|
||||
if (group) {
|
||||
group.status = 'confirmed';
|
||||
group.comment = comment;
|
||||
}
|
||||
});
|
||||
appState.selectedItems.clear();
|
||||
saveState();
|
||||
renderGroups();
|
||||
document.getElementById('group-comment').value = '';
|
||||
});
|
||||
|
||||
document.getElementById('btn-reject').addEventListener('click', function() {
|
||||
if (appState.selectedItems.size === 0) return;
|
||||
const comment = document.getElementById('group-comment').value || 'Отклонено пользователем';
|
||||
appState.selectedItems.forEach(key => {
|
||||
const [groupId, itemIndex] = key.split('-');
|
||||
const group = appState.groups.find(g => g.id === groupId);
|
||||
if (group) {
|
||||
group.status = 'rejected';
|
||||
group.comment = comment;
|
||||
}
|
||||
});
|
||||
appState.selectedItems.clear();
|
||||
saveState();
|
||||
renderGroups();
|
||||
document.getElementById('group-comment').value = '';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user