v1: портфолио Полиграф в стиле Midnight

This commit is contained in:
IlyasBAKT 2026-09-23 04:58:28 +00:00
parent c18a2a23ba
commit 89938a34b0
46 changed files with 11739 additions and 0 deletions

1
.vibe42-design.json Normal file
View File

@ -0,0 +1 @@
{"version": 1, "id": "midnight", "name": "Midnight"}

324
AGENTS.md Normal file
View File

@ -0,0 +1,324 @@
<!-- project-design: midnight -->
Выбранный дизайн проекта: **Midnight**. Прочитай design.md до работы над UI; его стиль важнее оформления в примерах навыков.
<!-- vibe42-agents-version: v47-design-presets-2026-09-18:midnight -->
# Vibe42 — учебная песочница: сайты, боты и первые приложения
Workspace юзера `IlyasBAKT`. Это **учебная среда**, где обычные люди (не разработчики) делают свой первый настоящий проект: сайт, telegram-бота или простое приложение.
---
## 🎯 ТВОЯ РОЛЬ
Ты — **гид и помощник**, а не слепой исполнитель. Цель сессии — чтобы юзер вышел с:
1. **работающим воплощением ЕГО идеи**: сайт — опубликован на `https://pages.git.vibe42.kz/IlyasBAKT/<repo>/`, бот/приложение — запущены через `run` с живой ссылкой,
2. ощущением «это было легко» — всю техническую кухню (серверы, зависимости, git) берёшь на себя ты.
Юзер не разработчик. Ему важен **работающий результат**, а не код.
---
## 🧱 СТЕК: ОДИН НА ВСЁ — НЕ ВЫДУМЫВАЙ
Модель у нас не самая мощная, поэтому **не сочиняй архитектуру с нуля** — бери готовый стек и рецепт под тип задачи. Так проект заработает с первого-второго раза, а не будет «не могу заранить / node не стартует».
**Определи тип и возьми стек — без вариантов:**
| Что хочет юзер | Стек (ЖЁСТКО) | Навык / рецепт — загрузить ПЕРЕД работой |
|----------------|---------------|--------|
| Сайт, лендинг, визитка, портфолио, меню, афиша, waitlist (форма на нём — только если ответы никуда сохранять не надо; иначе строка «Простое приложение с сервером») | **Статика:** в проекте УЖЕ лежит стартовый `index.html` в выбранном дизайне из `design.md` — открой его (`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`** (массив плоских объектов — юзер видит записи во вкладке «Данные» и качает Excel; админку не строй без просьбы) | навыки `kt-design-system` (архетип экрана) → `backend-run` → Рецепт B |
| **Полноценное приложение:** кабинет, панель, CRM-lite, несколько экранов, живые данные — юзер явно просит «приложение/систему», а не страницу | **Express + Vite/React (фронт собирается в `dist/`, сервер его раздаёт с одного порта) + `data.json`.** Бюджет контейнера **512 МБ / 1 CPU**: зависимости — только express, react, react-dom, vite, @vitejs/plugin-react; НИКАКИХ next/nuxt/nest/prisma/mongoose/pg/tailwind. Сборку и установку делает `run` (scripts.build + scripts.start), сам в шелле не собирай | навыки `kt-design-system` (архетип экрана) → `backend-run` → Рецепт A |
| Нужен ИИ внутри (умный бот, генерация текста, ответы) | тот же 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, но несколько сущностей и статусы | навыки `kt-design-system` → `accounting-system` → `backend-run` (Рецепт B) |
| Читать корпоративную почту юзера (Лотус) | Node-скелет + `process.env.LOTUS_*` | навык `backend-run` → «бот, читающий почту (Lotus)» |
| **Данные из ClickHouse:** отчёт, аналитика, дашборд, «посчитай по базе», выгрузка, динамика | ИИ считает командой `ch` (только чтение) и отвечает цифрами; дашборд — Рецепт B, сервер ходит в `process.env.CH_BASE_URL` | навык `clickhouse` (сначала `ch schema`) → для экрана `kt-design-system` (архетип dashboard/analytics) |
**Сначала таблица, потом код.** Прежде чем писать хоть строку — найди в таблице строку под запрос юзера и загрузи указанный навык вызовом `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` в шелле.
4. **Пути во фронтенде — ТОЛЬКО относительные, без ведущего `/`:** `fetch("api/register")`, `src="script.js"`, `href="list.html"`. Приложение юзера открывается под под-путём (`…/proxy/3000/`), и `fetch("/api/…")` уходит мимо сервера — форма «не отправляется». В сервере наоборот сравнивай `url` с `/api/...` как обычно.
---
## 🍳 Готовые рецепты кода → навык `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/IlyasBAKT/.mimo/memory.md` (dot-папка `.mimo` не видна в Предпросмотре — так и задумано). Папка `.mimo/` уже создана, тебе нужно только читать/писать `memory.md`.
**В НАЧАЛЕ каждой сессии** — прочитай `/srv/opencode/workspaces/users/IlyasBAKT/.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.
---
## 🚀 ПУБЛИКАЦИЯ В ИНТЕРНЕТ — ТОЛЬКО ПО КНОПКЕ «Опубликовать»
**Проект с сервером (server.js / `run`) публикуется той же кнопкой, но НЕ в `pages`:** платформа сама запускает сервер и даёт публичную ссылку `https://code.vibe42.kz/apps/IlyasBAKT/<repo>/` (открывается без входа, формы и `api/…` работают, уснувшее приложение просыпается по первому заходу). Для таких проектов **ничего не пушь в `pages`** — там статика, формы получают 405. Просто скажи юзеру нажать «Опубликовать» и что ссылка появится в этом окне.
**Публикуй (push в ветку `pages`) ТОЛЬКО когда юзер явно просит опубликовать.** Он нажимает кнопку **«Опубликовать»** вверху — тебе приходит сообщение вида «Опубликуй текущий проект…». САМ, без такой просьбы, в `pages` НИКОГДА не пушь — как бы хорошо сайт ни выглядел.
Когда юзер попросил опубликовать:
```bash
git add -A
git commit -m "publish"
git push origin HEAD:pages
```
Затем **ОБЯЗАТЕЛЬНО** дай ссылку **жирно**:
> 🎉 Готово! Твой сайт в интернете: **https://pages.git.vibe42.kz/IlyasBAKT/<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/IlyasBAKT`)** — это папка-контейнер юзера, не репозиторий.
---
## ⚙️ Бэкенд, боты, 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/IlyasBAKT
- Pages (живые лендинги): https://pages.git.vibe42.kz/IlyasBAKT/<repo>/
- Креды уже в `/srv/opencode/workspaces/users/IlyasBAKT/.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` там НЕ работает. Поэтому проект с сервером публикуется кнопкой как **приложение** (`/apps/…`), а не в `pages`. В Превью запросы формы автоматически уходят к запущенному через `run` серверу, если пути относительные.
- Прежде чем переделывать работающий статический 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`
- **Публикация — только кнопкой «Опубликовать» в интерфейсе; API git-хостинга (Gitea/GitLab Pages) не вызывай** — публикацией управляет Pages-шлюз платформы, из проекта достаточно push в ветку `pages`
- Запуталось — сделай новый чистый проект через `./new-project NAME-v2`, перенеси туда работающий index.html
---
## ❌ Чего НЕ делать НИКОГДА
- ❌ `git init` в workspace root
- ❌ `npm install` руками в шелле и тяжёлые зависимости (mongoose/pg/prisma/next/nuxt/nest) — контейнер юзера 512 МБ
- ❌ Поднимать бэкенд там, где хватает статики (лендинг, визитка, презентация) — сверься с таблицей стека
- ❌ Ставить тяжёлые фреймворки (nest / next / nuxt / django) — сервер на `node:http`, а для полноценного приложения (Рецепт A) — express
- ❌ Использовать `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
Стиль проекта указан в начале AGENTS.md и в `.vibe42-design.json`. Его палитра, шрифты и скругления важнее оформления в примерах навыков. Для стилей Стандартный дизайн / Apple / Midnight / Studio сохраняй `design-system/vibe-theme.css` последним после четырёх базовых CSS. Логотипы КТ обязательны только для стиля Казахтелеком; в других стилях используй бренд пользователя.
Рядом лежит `design.md` — **прочитай его перед первой строкой вёрстки**. Иконки — только спрайт ДС (`design-system/icons/kt-ai-icons.js` + `<use href="#имя">`), логотипы — файлы бренда проекта; эмодзи и самодельные SVG вместо иконок запрещены. `design.md` — про сайты и лендинги. Для **приложений** (панель, кабинет, учёт, заявки, дашборд, канбан, мастер) сначала вызови навык `kt-design-system`: там архетипы экрана, app-shell и 16 эталонных прототипов — без него получается лендинг с карточками вместо продукта. В нём каркас `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 страница останется без стилей.

60
design-system/AGENTS.md Normal file
View 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
View 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
View 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
View 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
View 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 |

View 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
View 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) — платформа собирает ванильную статику

View 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 — оргструктура/иерархия"
]
}

View File

@ -0,0 +1,7 @@
# Бренд-файлы (наложение платформы Vibe AI поверх ДС KT AI)
- `kt-ai-logo.png` — логотип KT AI (80px, из React-кита ДС `kt-ai-logo.ts`). Для brand-ряда сайдбара, шапки, favicon.
- `kazakhtelecom-logo.png` — логотип АО «Казахтелеком», тёмный (для светлого фона — по умолчанию). Шапка/подвал корпоративных страниц.
- `kazakhtelecom-logo-white.png` — тот же логотип белым: для тёмных подвалов и тёмной темы.
Правило для ИИ: логотипы не рисовать текстом, эмодзи или самодельным SVG — только эти файлы.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View 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`.
Прототип никогда не падает из-за архетипа — в худшем случае показывает надёжную таблицу-очередь.

View 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.

View 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/селект там, где хватает прямой кнопки.

View 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 без ручного запуска.
## Решающий критерий
Перед любой работой по системе спросить: **приближает ли это уравнение «прототип === фронтенд продакшна»?** Если да — делаем. Если нет — это не приоритет, как бы красиво ни выглядело.

View 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]`), и какие гейты прогонять для нового продукта / фичи / правки. Не дублируй чек-лист здесь — правь его в одном месте.

View 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/`. Не плодить пятую копию контракта.

Binary file not shown.

Binary file not shown.

View 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.

View 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.

View 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.

View File

@ -0,0 +1 @@
home chat send feed lots source catalog decisions risk recommender compare collections search settings chart table sort filter download chevronRight external help history info users file tool model dashboard columns more check x alert clock arrowRight panelLeftClose panelLeft trophy pin sun moon menu book rocket sparkles play lightbulb plus minus chevronDown chevronLeft arrowLeft arrowUpDown eye calendar link copy trash pencil user bell refresh logout lock mail megaphone archive arrow-right building chevron-right alert-circle check-circle clipboard sync database spreadsheet merge inbox layers checklist package receipt scale split warning upload circleNumber0 circleNumber1 circleNumber2 circleNumber3 circleNumber4 circleNumber5 circleNumber6 circleNumber7 circleNumber8 circleNumber9

View 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="#имя">`.

View File

@ -0,0 +1,41 @@
// Загрузчик спрайта иконок ДС KT AI (наложение платформы Vibe AI).
// Внешний <use href="sprite.svg#home"> браузеры блокируют, поэтому спрайт вставляется в документ:
// <script src="design-system/icons/kt-ai-icons.js"></script>
// <svg class="kt-icon" aria-hidden="true"><use href="#home"></use></svg>
// Путь к спрайту берётся рядом со скриптом; переопределить: data-sprite="/icons.svg".
(function () {
var s = document.currentScript;
var url = (s && s.getAttribute("data-sprite")) || (s ? s.src.replace(/[^/]*$/, "kt-ai-lucide-sprite.svg") : "design-system/icons/kt-ai-lucide-sprite.svg");
// Модель иногда берёт «обычные» имена Lucide, которых в спрайте ДС нет: подменяем ближайшими, чтобы иконка не была пустой.
var ALIAS = { shield: "lock", "shield-check": "check-circle", headset: "help", headphones: "help", phone: "megaphone", "phone-call": "megaphone",
star: "trophy", award: "trophy", medal: "trophy", heart: "check-circle", "thumbs-up": "check-circle", globe: "external", wifi: "sync", signal: "chart",
server: "database", cloud: "layers", cpu: "tool", zap: "sparkles", bolt: "sparkles", target: "lightbulb", briefcase: "building", "map-pin": "pin",
"shopping-cart": "package", "credit-card": "receipt", wallet: "receipt", truck: "package", "bar-chart": "chart", "bar-chart-2": "chart", "pie-chart": "chart",
activity: "chart", "trending-up": "arrowUpDown", "line-chart": "chart", "message-circle": "chat", "message-square": "chat", smartphone: "tool",
monitor: "dashboard", laptop: "dashboard", "check-square": "checklist", "file-text": "file", "folder": "archive", image: "eye", camera: "eye",
"user-check": "user", "user-plus": "users", "users-2": "users", "log-out": "logout", "log-in": "arrowRight", "arrow-up-right": "external",
"chevron-up": "chevronDown", key: "lock", "alert-triangle": "warning", "x-circle": "x", "help-circle": "help", "info-circle": "info", "circle-check": "check-circle" };
function fixUses(root) {
var uses = (root || document).querySelectorAll('use[href^="#"]');
for (var i = 0; i < uses.length; i++) {
var id = uses[i].getAttribute("href").slice(1);
if (!id || document.getElementById(id)) continue;
var alt = ALIAS[id] || ALIAS[id.toLowerCase()] || "layers";
if (document.getElementById(alt)) { uses[i].setAttribute("href", "#" + alt); console.warn("[kt-ai-icons] иконки «" + id + "» нет в спрайте ДС, показана «" + alt + "» — поправь имя по списку в design.md"); }
}
}
function inject(svgText) {
if (document.getElementById("kt-ai-sprite")) { fixUses(); return; }
var box = document.createElement("div");
box.id = "kt-ai-sprite"; box.setAttribute("aria-hidden", "true");
box.style.cssText = "position:absolute;width:0;height:0;overflow:hidden";
box.innerHTML = svgText;
(document.body || document.documentElement).insertBefore(box, (document.body || document.documentElement).firstChild);
fixUses();
if (window.MutationObserver) new MutationObserver(function () { fixUses(); }).observe(document.documentElement, { childList: true, subtree: true });
}
var ready = fetch(url, { credentials: "same-origin" }).then(function (r) { if (!r.ok) throw new Error("sprite " + r.status); return r.text(); })
.then(function (t) { if (document.body) inject(t); else document.addEventListener("DOMContentLoaded", function () { inject(t); }); })
.catch(function (e) { console.warn("[kt-ai-icons] спрайт не загрузился:", e && e.message); });
window.KTIcons = { ready: ready, url: url };
})();

View 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

View 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: скрыть };
})();

File diff suppressed because it is too large Load Diff

View 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 { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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);

View 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 { '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]; }); }
// ---------- init ----------
function init() { injectStyles(); build(); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();

View 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
View 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);

View 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); }
}
}

View 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; }
}

View 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 */

File diff suppressed because it is too large Load Diff

View 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())

View File

@ -0,0 +1,483 @@
# Origin Financial — Style Reference
> midnight gallery of quiet wealth. A hushed, near-black room where oversized serif whispers and a few luminous color panels make finance feel like curated art.
**Theme:** dark
Origin presents personal finance as a nocturnal gallery: near-black canvases, whisper-weight serif display headlines at 80-96px (Lyon Display at weight 300), and chromatic feature cards that hover like illuminated color panels. Most surfaces stay quiet and monochromatic, with saturated color appearing only as full-bleed category tiles for spending, investing, and forecasting modules, and as data signals (chart lines, sparklines). Typography splits into three distinct voices: a high-contrast serif for emotive statements, a neo-grotesque sans for UI, and a monospace for uppercase technical labels and data, producing an editorial-meets-precision feel. White-on-black is the only primary action; the rest of the interface recedes into the dark.
## Tokens — Colors
| Name | Value | Token | Role |
|------|-------|-------|------|
| Iris Gleam | `#847dff` | `--color-iris-gleam` | Primary chromatic accent for feature category cards — the signature violet that appears most often across module tiles |
| Cyan Signal | `#00b3dd` | `--color-cyan-signal` | Data accent — chart lines, sparkline strokes, and forecasting indicators on dark surfaces |
| Pale Iris | `#d1c9ff` | `--color-pale-iris` | Light variant of the brand violet for softer category panels and background washes |
| Deep Iris | `#4b49aa` | `--color-deep-iris` | Dark variant of the brand violet for shaded category panels and hover states |
| Orchid Bloom | `#dd90d8` | `--color-orchid-bloom` | Pink category card for lifestyle/spending modules — warm contrast against the cool palette |
| Periwinkle | `#90b8f0` | `--color-periwinkle` | Soft blue category card for advisory or couples modules |
| Obsidian | `#0f1011` | `--color-obsidian` | Page canvas — the dominant dark surface that everything floats on |
| Abyss | `#090a0b` | `--color-abyss` | Slightly deeper than Obsidian for subtle stacked sections and shadow tone |
| Graphite | `#2e2e2e` | `--color-graphite` | Mid-dark card surface for elevated modules and data panels |
| Steel | `#3f4041` | `--color-steel` | Hover/pressed surface for interactive elements and secondary raised panels |
| Silver | `#cacaca` | `--color-silver` | Light card surface for inverted panels, stat blocks, and testimonial cards |
| Fog | `#6a6b6b` | `--color-fog` | Muted text and disabled-link color |
| Ash | `#9f9fa0` | `--color-ash` | Body text, descriptions, and secondary links — never full white |
| Cloud | `#f5f5f7` | `--color-cloud` | Heading text variant for softer white display where #ffffff feels too sterile |
| Pure | `#ffffff` | `--color-pure` | Primary text, primary action fill, and nav icon strokes — the brightest element in the system |
| Void | `#000000` | `--color-void` | Icon fills, button text on white fills, and input backgrounds |
## Tokens — Typography
### Suisseintltrial — Suisseintltrial — detected in extracted data but not described by AI · `--font-suisseintltrial`
- **Weights:** 400
- **Sizes:** 11px, 14px, 16px
- **Line height:** 1, 1.5, 1.67, 2.18
- **Letter spacing:** 0.182
- **Role:** Suisseintltrial — detected in extracted data but not described by AI
### Lyon Display — Display headlines and section titles — the signature voice. Used only at weight 300 (light) to create whisper-soft authority; the extreme size range (38-96px) with tight 0.9 line-height is a defining tension · `--font-lyon-display`
- **Substitute:** DM Serif Display or Playfair Display (Google Fonts) at weight 400 with manual letter-spacing tightening
- **Weights:** 300
- **Sizes:** 38px, 80px, 96px
- **Line height:** 0.9-1.0
- **Letter spacing:** normal (0)
- **Role:** Display headlines and section titles — the signature voice. Used only at weight 300 (light) to create whisper-soft authority; the extreme size range (38-96px) with tight 0.9 line-height is a defining tension
### Suisse Int'l — Body copy, card descriptions, link text, and input fields. Weight 300 appears in 18px card subheads for a subtle echo of the display voice; weight 400 is the workhorse UI text · `--font-suisse-intl`
- **Substitute:** Inter or Geist Sans at matching weights
- **Weights:** 300, 400
- **Sizes:**
- **Line height:** 1.41-2.0
- **Letter spacing:** normal (0)
- **Role:** Body copy, card descriptions, link text, and input fields. Weight 300 appears in 18px card subheads for a subtle echo of the display voice; weight 400 is the workhorse UI text
### Suisse Int'l Trial — Extended UI text at 16px (buttons, nav, links) and uppercase tracked labels at 11px. Shares lineage with Suisse Int'l · `--font-suisse-intl-trial`
- **Substitute:** Inter or Geist Sans
- **Weights:** 400
- **Sizes:**
- **Line height:** 1.0-2.18
- **Letter spacing:** 0.1820em on 11px uppercase (≈2px tracking) — the widest tracking in the system
- **Role:** Extended UI text at 16px (buttons, nav, links) and uppercase tracked labels at 11px. Shares lineage with Suisse Int'l
### Roboto Mono — Monospaced labels, data readouts, badge text, and uppercase micro-copy (≤12px). Always set in uppercase. Weight 500 for 12px button labels; weight 400 for 10-11px annotations · `--font-roboto-mono`
- **Substitute:** Roboto Mono (Google Fonts) — already free
- **Weights:** 400, 500
- **Sizes:** 10px, 11px, 12px, 16px
- **Line height:** 1.35-2.0
- **Letter spacing:** 0.0160em to 0.0210em for small uppercase (≈0.16-0.25px at 12px), normal for 16px
- **Role:** Monospaced labels, data readouts, badge text, and uppercase micro-copy (≤12px). Always set in uppercase. Weight 500 for 12px button labels; weight 400 for 10-11px annotations
### Suisseintl — Suisseintl — detected in extracted data but not described by AI · `--font-suisseintl`
- **Weights:** 300, 400
- **Sizes:** 12px, 14px, 16px, 18px
- **Line height:** 1, 1.41, 1.43, 1.5, 2
- **Role:** Suisseintl — detected in extracted data but not described by AI
### Type Scale
| Role | Size | Line Height | Letter Spacing | Token |
|------|------|-------------|----------------|-------|
| mono-label | 12px | 2 | — | `--text-mono-label` |
| body-sm | 14px | 1.67 | — | `--text-body-sm` |
| body | 16px | 1.5 | — | `--text-body` |
| subheading | 18px | 1.5 | — | `--text-subheading` |
| heading-lg | 38px | 0.9 | — | `--text-heading-lg` |
| display-sm | 80px | 1 | — | `--text-display-sm` |
| display | 96px | 0.9 | — | `--text-display` |
## Tokens — Spacing & Shapes
**Base unit:** 4px
**Density:** comfortable
### Spacing Scale
| Name | Value | Token |
|------|-------|-------|
| 4 | 4px | `--spacing-4` |
| 8 | 8px | `--spacing-8` |
| 12 | 12px | `--spacing-12` |
| 16 | 16px | `--spacing-16` |
| 20 | 20px | `--spacing-20` |
| 24 | 24px | `--spacing-24` |
| 32 | 32px | `--spacing-32` |
| 40 | 40px | `--spacing-40` |
| 48 | 48px | `--spacing-48` |
| 60 | 60px | `--spacing-60` |
| 68 | 68px | `--spacing-68` |
| 100 | 100px | `--spacing-100` |
| 120 | 120px | `--spacing-120` |
| 140 | 140px | `--spacing-140` |
### Border Radius
| Element | Value |
|---------|-------|
| cards | 16px |
| inputs | 8px |
| buttons | 8px |
| navItems | 8px |
| statBlocks | 16px |
| pillButtons | 9999px |
| featureCards | 30px |
| categoryTiles | 30px |
### Shadows
| Name | Value | Token |
|------|-------|-------|
| lg | `rgba(0, 0, 0, 0.2) 0px 18px 20px 0px` | `--shadow-lg` |
### Layout
- **Page max-width:** 1200px
- **Section gap:** 80px
- **Card padding:** 32px
- **Element gap:** 12px
## Components
### Primary CTA Button
**Role:** High-contrast action trigger on dark backgrounds
White fill (#ffffff), black text (#000000), 8px border-radius, 12px vertical / 18px horizontal padding, Suisse Int'l 16px weight 400. Always paired with a right-arrow icon (→). The brightest element on screen — uses the highest contrast pair in the system (21:1 AAA).
### Ghost Outline Button
**Role:** Secondary navigation and tertiary actions
Transparent background with 1px white border (rgb(255,255,255)), white text, 8px radius, 12px/18px padding, Suisse Int'l 16px. Used in nav bar for 'PRODUCTS', 'FOR EMPLOYERS', 'RESOURCES'.
### Nav Glass Button
**Role:** Sticky navigation items with glassmorphic treatment
Background rgba(255,255,255,0.1), 1px white border, 8px radius, 9px/12px padding. Sits inside a nav bar with backdrop-filter blur(24px) over the Obsidian canvas — frosted glass over dark.
### Pill Badge Button
**Role:** Compact tag-style trigger for chat or messaging entry
White 20% fill (rgba(255,255,255,0.2)), 1px white border, 100% radius (pill shape), 1px/6px padding. Icon-only or short text. Used for floating action affordances.
### Pill Chip Label
**Role:** Uppercase category or section eyebrow labels
Background rgba(255,255,255,0.12), 1px border at rgba(255,255,255,0.15), 1440px border-radius (full pill), 10px/32px padding, white text #fafafa at 10-11px. Promotional messaging like the $1 promo.
### Feature Category Card
**Role:** Full-bleed colored tile for spending/investing/forecasting modules
One of six chromatic backgrounds (Iris Gleam, Cyan Signal, Pale Iris, Deep Iris, Orchid Bloom, or Periwinkle), 30px border-radius, 32px padding on all sides, white or near-white text. Holds an icon, title (Lyon Display 38px/300), and short Suisse Int'l description. Color is the only differentiator between categories — no icons or labels needed for grouping.
### Stat / Inverted Card
**Role:** Light surface card for testimonials, stats, or feature callouts
Background #cacaca (Silver), 30px border-radius, 32px padding. Dark text #000000 inside. Breaks the dark-only rhythm — used sparingly to spotlight a single piece of content.
### Phone Mockup Module
**Role:** Product showcase for app features
Graphite background #2e2e2, 16px border-radius, 90px padding on all sides (dramatic breathing room). Contains a tilted iPhone render of the Origin app on Obsidian canvas. The largest padding value in the system — signals premium product framing.
### AI Prompt Input
**Role:** Natural-language query field for the AI financial advisor
Black background (#000000), near-white text #fafafa, 8px border-radius, 8px vertical / 22px left padding (asymmetric: generous left padding for the cursor). Suisse Int'l 14-16px. Paired with a circular submit button (100% radius, white 20% fill, arrow icon). Placeholder: 'Where am I overspending this month?'
### Promo Eyebrow Badge
**Role:** Pill-shaped promotional or pricing label above headlines
White 12% background, 1px white 15% border, 1440px radius, 10px/32px padding. Roboto Mono 12px weight 500, uppercase, white. Text examples follow the pattern: '$1 FOR 1 YEAR — LIMITED TIME'.
### Award Laurel Badge
**Role:** Third-party press or industry recognition marker
Transparent background, white SVG laurel-wreath icon flanking publication name (Forbes, Fast Company) in Lyon Display 18px weight 300, with Roboto Mono 10-12px uppercase sub-text. Sits centered as social proof.
### Display Headline
**Role:** Hero and section-opening statements
Lyon Display at weight 300, sizes 80-96px, line-height 0.9, color #ffffff or #f5f5f7. Italic word ('Own', 'Simplify') is mixed with roman to create editorial tension within a single headline. The whisper-weight at 96px is anti-convention — most fintech sites use 600-700, this achieves authority through restraint.
### Section Subhead
**Role:** Supporting text under headlines and card titles
Suisse Int'l 18px weight 300 (echoing the display voice), line-height 1.5, color #ffffff for card titles or #9f9fa0 for body descriptions. The 300 weight on body is deliberate — creates vertical harmony with the 300 display.
## Do's and Don'ts
### Do
- Set all display headlines in Lyon Display (or DM Serif Display substitute) at weight 300 — never bold the display
- Use 8px radius for all buttons, inputs, and nav items; 16-30px for cards; 9999px only for true pill chips
- Reserve chromatic colors (Iris Gleam, Cyan Signal, Orchid Bloom, etc.) exclusively for full-bleed feature category tiles — never as inline accents or borders
- Use white-on-black (#ffffff fill, #000000 text) as the only primary action; contrast ratio is 21:1
- Apply Roboto Mono uppercase at 10-12px with tracking 0.016-0.182em for all labels, badges, and data annotations
- Maintain line-height 0.9 on Lyon Display 96px headlines — tight leading is a signature
- Differentiate surfaces by color step (#0f1011 → #2e2e2 → #cacaca), not by shadow
### Don't
- Do not bold Lyon Display — weight 300 is the system's defining tension
- Do not apply drop shadows to cards; the system is intentionally flat
- Do not use chromatic accent colors on text smaller than 18px — they vibrate against dark backgrounds
- Do not set body text at full #ffffff white — use #9f9fa0 (Ash) for descriptions and #f5f5f7 (Cloud) for headings instead
- Do not break the three-voice typography system: serif display for emotion, sans for UI, mono for data
- Do not add decorative gradients to UI surfaces — the only legitimate gradient is the dark chrome one (rgb(43,43,44) → rgb(19,19,19))
- Do not use #00b3dd (Cyan Signal) for body color or large fills — it is reserved for data signals and chart lines
## Surfaces
| Level | Name | Value | Purpose |
|-------|------|-------|---------|
| 0 | Obsidian Canvas | `#0f1011` | Page background — the base everything sits on |
| 1 | Abyss | `#090a0b` | Full-bleed dark sections alternating with canvas for subtle banding |
| 2 | Graphite Card | `#2e2e2` | Elevated product cards and data modules |
| 3 | Steel Hover | `#3f4041` | Interactive hover/pressed states on dark surfaces |
| 4 | Silver Inverted | `#cacaca` | Light inverted cards for stat blocks and testimonials |
## Elevation
Elevation is expressed through surface color shifts, not drop shadows. Only one shadow token exists in the system (rgba(0,0,0,0.2) 0px 18px 20px — used on a single button), meaning the design deliberately trades shadow depth for a flat, gallery-wall aesthetic. Cards differentiate through #0f1011 → #2e2e2 → #cacaca stepping, and glassmorphism (backdrop-filter blur(24px)) on the nav bar provides the only true depth cue.
## Imagery
Atmospheric photography anchors the hero (a full-bleed cloud-and-sky photograph with a desaturated cool treatment that reads as aspirational open sky rather than literal weather). Product visualization is delivered through tilted iPhone renders of the Origin app on near-black backgrounds — the device is the hero, with a subtle volumetric glow rather than screen-glare effects. There are no illustrations, no abstract graphics, and no lifestyle photography. Icons are minimal, line-weight monoline SVGs rendered in white or as silhouette marks. The overall ratio is heavily text-dominant: typography carries ~80% of the visual weight, imagery is reserved for hero atmosphere and product proof.
## Layout
Full-bleed dark canvas (max-width 1200px content container, but sections break to edge). The page rhythm alternates: (1) atmospheric photo hero with centered headline and inline AI prompt, (2) full-bleed #0f1011 module sections, (3) light #cacaca inverted stat blocks for contrast, (4) product mockup bands with 90px internal padding. Headlines are always center-aligned; feature category cards are arranged in a 3-column grid with 12-15px gaps. Vertical section spacing ranges 24-80px, creating generous breathing room between modules. The navigation is a sticky top bar with glassmorphism (backdrop-filter blur(24px)) containing a logo mark, 3 nav items as ghost buttons, 'Log In' as text link, and a white 'Get Started' primary CTA flush right.
## Agent Prompt Guide
**Quick Color Reference**
- Text primary: #ffffff
- Text body: #9f9fa0
- Text heading variant: #f5f5f7
- Background canvas: #0f1011
- Surface elevated: #2e2e2e
- Inverted surface: #cacaca
- primary action: #ffffff (filled action)
- Accent (data/charts): #00b3dd
**Example Component Prompts**
1. **Hero Section**: Obsidian canvas #0f1011 full-bleed. Centered Lyon Display 80px weight 300, #ffffff, line-height 1.0, with the first word in italic. Subhead in Suisse Int'l 18px weight 300, #9f9fa0, line-height 1.5, max-width 560px. Primary CTA below: white #ffffff fill, #000000 text, 8px radius, 12px/18px padding, arrow icon trailing.
2. **Feature Category Card**: Full-bleed Iris Gleam #847dff background, 30px border-radius, 32px padding. Lyon Display 38px weight 300 white headline. Suisse Int'l 16px weight 400 white description below at 1.5 line-height. No border, no shadow — color carries the entire card identity.
3. **AI Prompt Input**: Obsidian background #0f1011, 8px radius, 8px top/bottom and 22px left padding, 1px solid rgba(255,255,255,0.1) border. Placeholder text 'Ask anything…' in Ash #9f9fa0, Suisse Int'l 16px weight 400. Circular submit button: 100% radius, rgba(255,255,255,0.2) fill, white arrow icon.
4. Create a Primary Action Button: #ffffff background, #000000 text, 9999px radius, compact pill padding. Use this filled treatment for the main CTA.
5. **Stat Inverted Card**: Silver #cacaca background, 30px border-radius, 32px padding. Lyon Display 38px weight 300 black #000000 headline, Suisse Int'l 16px weight 400 #000000 body. Used to break the dark rhythm — maximum 1-2 per page.
## Gradient System
The system uses exactly two gradients, both subtle and structural — never decorative:
1. **Dark Chrome** — linear-gradient(135deg, rgb(43,43,44), rgb(19,19,19)): applied to product device frames and elevated chrome surfaces. Reads as machined metal in low light.
2. **Sky Atmosphere** — linear-gradient(rgb(15,16,17), rgb(19,29,39) 18%, rgb(26,71,136) 37%, rgb(64,138,193) 69%, rgb(64,138,193) 102%): used behind the hero photograph to deepen the horizon and separate the cloud layer from the page canvas.
Never apply gradients to text, buttons, or feature cards. Never use radial or conic gradients.
## Motion Philosophy
Motion is restrained and confident, not decorative. Three patterns define the system:
- **Quick state transitions**: 0.2s ease on background-color and opacity for all hover/focus states
- **Long atmospheric reveals**: 2.5s with cubic-bezier(0.455, 0.03, 0.515, 0.955) — used for hero text fade-ins and product mockup entrance
- **Border trace**: named animation 'borderTurn' that animates a 1px stroke around hexagonal or circular frames, typically for feature highlights
The 800× prevalence of plain 'ease' timing for short transitions confirms the system values predictability over spectacle. No bouncy springs, no overshoots, no parallax.
## Similar Brands
- **Wealthfront** — Same dark-canvas approach with whisper-quiet serif headlines and full-bleed feature category cards distinguished by color alone
- **Mercury** — Identical near-black surface palette, neo-grotesque sans + monospace label system, and white-on-black single primary action
- **Linear** — Same ultralight display weight anti-convention, flat elevation philosophy (color steps over shadows), and monospace micro-labels
- **Fidelity Spire** — Editorial-meets-precision typography split (serif display + sans body + mono data) applied to a dark financial interface
- **Copilot Money** — App-first dark product UI with full-bleed phone mockup bands and chromatic accent tiles per feature module
## Quick Start
### CSS Custom Properties
```css
:root {
/* Colors */
--color-iris-gleam: #847dff;
--color-cyan-signal: #00b3dd;
--color-pale-iris: #d1c9ff;
--color-deep-iris: #4b49aa;
--color-orchid-bloom: #dd90d8;
--color-periwinkle: #90b8f0;
--color-obsidian: #0f1011;
--color-abyss: #090a0b;
--color-graphite: #2e2e2e;
--color-steel: #3f4041;
--color-silver: #cacaca;
--color-fog: #6a6b6b;
--color-ash: #9f9fa0;
--color-cloud: #f5f5f7;
--color-pure: #ffffff;
--color-void: #000000;
/* Typography — Font Families */
--font-suisseintltrial: 'Suisseintltrial', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-lyon-display: 'Lyon Display', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-suisse-intl: 'Suisse Int'l', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-suisse-intl-trial: 'Suisse Int'l Trial', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-roboto-mono: 'Roboto Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-suisseintl: 'Suisseintl', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
/* Typography — Scale */
--text-mono-label: 12px;
--leading-mono-label: 2;
--text-body-sm: 14px;
--leading-body-sm: 1.67;
--text-body: 16px;
--leading-body: 1.5;
--text-subheading: 18px;
--leading-subheading: 1.5;
--text-heading-lg: 38px;
--leading-heading-lg: 0.9;
--text-display-sm: 80px;
--leading-display-sm: 1;
--text-display: 96px;
--leading-display: 0.9;
/* Typography — Weights */
--font-weight-light: 300;
--font-weight-regular: 400;
--font-weight-medium: 500;
/* Spacing */
--spacing-unit: 4px;
--spacing-4: 4px;
--spacing-8: 8px;
--spacing-12: 12px;
--spacing-16: 16px;
--spacing-20: 20px;
--spacing-24: 24px;
--spacing-32: 32px;
--spacing-40: 40px;
--spacing-48: 48px;
--spacing-60: 60px;
--spacing-68: 68px;
--spacing-100: 100px;
--spacing-120: 120px;
--spacing-140: 140px;
/* Layout */
--page-max-width: 1200px;
--section-gap: 80px;
--card-padding: 32px;
--element-gap: 12px;
/* Border Radius */
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-3xl-2: 30px;
--radius-full: 1440px;
--radius-full-2: 9999px;
--radius-full-3: 14385.6px;
/* Named Radii */
--radius-cards: 16px;
--radius-inputs: 8px;
--radius-buttons: 8px;
--radius-navitems: 8px;
--radius-statblocks: 16px;
--radius-pillbuttons: 9999px;
--radius-featurecards: 30px;
--radius-categorytiles: 30px;
/* Shadows */
--shadow-lg: rgba(0, 0, 0, 0.2) 0px 18px 20px 0px;
/* Surfaces */
--surface-obsidian-canvas: #0f1011;
--surface-abyss: #090a0b;
--surface-graphite-card: #2e2e2;
--surface-steel-hover: #3f4041;
--surface-silver-inverted: #cacaca;
}
```
### Tailwind v4
```css
@theme {
/* Colors */
--color-iris-gleam: #847dff;
--color-cyan-signal: #00b3dd;
--color-pale-iris: #d1c9ff;
--color-deep-iris: #4b49aa;
--color-orchid-bloom: #dd90d8;
--color-periwinkle: #90b8f0;
--color-obsidian: #0f1011;
--color-abyss: #090a0b;
--color-graphite: #2e2e2e;
--color-steel: #3f4041;
--color-silver: #cacaca;
--color-fog: #6a6b6b;
--color-ash: #9f9fa0;
--color-cloud: #f5f5f7;
--color-pure: #ffffff;
--color-void: #000000;
/* Typography */
--font-suisseintltrial: 'Suisseintltrial', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-lyon-display: 'Lyon Display', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-suisse-intl: 'Suisse Int'l', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-suisse-intl-trial: 'Suisse Int'l Trial', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-roboto-mono: 'Roboto Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--font-suisseintl: 'Suisseintl', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
/* Typography — Scale */
--text-mono-label: 12px;
--leading-mono-label: 2;
--text-body-sm: 14px;
--leading-body-sm: 1.67;
--text-body: 16px;
--leading-body: 1.5;
--text-subheading: 18px;
--leading-subheading: 1.5;
--text-heading-lg: 38px;
--leading-heading-lg: 0.9;
--text-display-sm: 80px;
--leading-display-sm: 1;
--text-display: 96px;
--leading-display: 0.9;
/* Spacing */
--spacing-4: 4px;
--spacing-8: 8px;
--spacing-12: 12px;
--spacing-16: 16px;
--spacing-20: 20px;
--spacing-24: 24px;
--spacing-32: 32px;
--spacing-40: 40px;
--spacing-48: 48px;
--spacing-60: 60px;
--spacing-68: 68px;
--spacing-100: 100px;
--spacing-120: 120px;
--spacing-140: 140px;
/* Border Radius */
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-3xl-2: 30px;
--radius-full: 1440px;
--radius-full-2: 9999px;
--radius-full-3: 14385.6px;
/* Shadows */
--shadow-lg: rgba(0, 0, 0, 0.2) 0px 18px 20px 0px;
}
```

File diff suppressed because one or more lines are too long

320
design-system/tokens.json Normal file
View 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"
}
}
}

View File

@ -0,0 +1,53 @@
/* Vibe AI: Midnight. Load AFTER the four base stylesheets. */
:root, [data-theme] {
color-scheme: dark;
--kt-ai-bg: #0f1011;
--kt-ai-bg-soft: #0f1011;
--kt-ai-bg-elevated: #2e2e2e;
--kt-ai-bg-sunken: #0f1011;
--kt-ai-bg-glass: #2e2e2e;
--kt-ai-bg-hover: #38334f;
--kt-ai-bg-active: #38334f;
--kt-ai-fg: #ffffff;
--kt-ai-fg-muted: #aaa9ae;
--kt-ai-fg-faint: #aaa9ae;
--kt-ai-fg-on-fill: #090a0b;
--kt-ai-border: #444448;
--kt-ai-border-strong: #aaa9ae;
--kt-ai-divider: #444448;
--kt-ai-card-bg: #2e2e2e;
--kt-ai-card-bg-glow: #2e2e2e;
--kt-ai-card-border: #444448;
--kt-ai-card-border-hover: #aaa9ae;
--kt-ai-primary: #ffffff;
--kt-ai-primary-hover: #ffffff;
--kt-ai-primary-subtle: #38334f;
--kt-ai-link: #c4bfff;
--kt-ai-brand-blue: #847dff;
--kt-ai-selection: #38334f;
--kt-ai-focus-ring: #c4bfff;
--kt-ai-focus-shadow: 0 0 0 3px #38334f;
--kt-ai-chip-default-bg: #38334f;
--kt-ai-chip-default-fg: #ffffff;
--kt-ai-font-sans: Inter, system-ui, sans-serif;
--kt-ai-card-shadow: none;
--kt-ai-card-shadow-hover: none;
--vibe-accent: #847dff;
--vibe-tint: #38334f;
--vibe-heading-font: Georgia, "Times New Roman", serif;
--vibe-heading-weight: 400;
--vibe-card-radius: 16px;
--vibe-button-radius: 8px;
--vibe-field-radius: 8px;
}
body { background: var(--kt-ai-bg); color: var(--kt-ai-fg); font-family: var(--kt-ai-font-sans); }
h1, h2, .kt-ai-h1, .kt-ai-h2 { font-family: var(--vibe-heading-font); font-weight: var(--vibe-heading-weight); letter-spacing: -.035em; }
.kt-ai-hero h1 { font-size: clamp(36px, 5.5vw, 64px); line-height: 1.08; }
.kt-ai-section { padding-block: clamp(32px, 6vw, 64px); }
.kt-ai-pagehead, .kt-ai-cta, .kt-ai-pagefoot { background: var(--kt-ai-bg); color: var(--kt-ai-fg); }
.kt-ai-tile, .kt-ai-card { background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-card-border); border-radius: var(--vibe-card-radius); box-shadow: var(--kt-ai-card-shadow); }
.kt-ai-btn, .kt-ai-btn[data-size] { border-radius: var(--vibe-button-radius); }
.kt-ai-input, .kt-ai-select, .kt-ai-textarea { border-radius: var(--vibe-field-radius); }
.vibe-highlight { background: var(--vibe-tint); color: var(--kt-ai-fg); border-radius: 4px; padding-inline: .12em; }
.vibe-accent { color: var(--vibe-accent); }

123
design.md Normal file
View File

@ -0,0 +1,123 @@
<!-- vibe42-design-version: v8-design-presets-2026-09-18:midnight -->
# Дизайн проекта: Midnight
Тёмная редакционная эстетика по DESIGN-2.md (Origin). Фон #0f1011, карточки #2e2e2e, белый текст, вторичный текст приглушённый. Акцент #847dff, светлый фиолетовый #d1c9ff; розовый и голубой допустимы только как вторичные сигналы. Основная кнопка белая с чёрным текстом. Крупные лёгкие заголовки с засечками (Lyon Display заменён Georgia), тело — локальный Inter; цифры можно моноширинные. Кнопки и поля 8px, карточки 16px, большие блоки 30px. Воздух, плоские поверхности, тонкие линии, отсутствие тяжёлых теней. В таблицах сохраняй компактный sans-serif; огромные serif-заголовки только в hero. Не копируй бренд Origin. Опечатка #2e2e2 в исходнике исправлена на #2e2e2e.
## Как применять
Стиль выбран пользователем при создании проекта и сохранён в `.vibe42-design.json`. Сохраняй его при дальнейших правках. Цвета, типографика и форма компонентов из этого документа имеют приоритет над оформлением в примерах KT AI и навыках. Оттуда бери структуру и поведение.
В проекте уже есть локальные компоненты `kt-ai-*`, шрифт Inter и иконки. Их токены переопределены в `design-system/vibe-theme.css` под выбранный стиль. Подключай этот файл ПОСЛЕДНИМ после четырёх базовых CSS на каждой странице, включая приложения. Не удаляй его при переходе на React: скопируй design-system в public/ и сохрани все пять подключений. Используй `var(--kt-ai-…)` и `var(--vibe-…)`; не подключай сторонние CSS-фреймворки или удалённые шрифты. Логотип и название — проекта пользователя. Корпоративные логотипы из библиотеки не обязательны. Иконки бери из `design-system/icons/kt-ai-icons.js` и спрайта.
Базовая тема — `dark`. Не включай автоматическое переключение светлой/тёмной темы. Используй доступные системные/локальные замены шрифтов, указанные выше. Полный визуальный референс: `design-system/style-reference.md`; он описывает стиль, его бренд и примеры текста переносить не надо. При расхождениях (опечатки, контраст текста, коммерческие шрифты) следуй этому design.md.
## Старт: `index.html`
Правь существующий каркас под идею пользователя.
```html
<!DOCTYPE html>
<!-- Дизайн: Midnight. Сохраняй все пять CSS; подробности в design.md. -->
<html lang="ru" data-theme="dark">
<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="stylesheet" href="design-system/vibe-theme.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` | иконка над заголовком плитки — из спрайта ДС (см. «Иконки и логотипы» ниже) |
| `.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`.
## Проверка
Проверь мобильный экран, читаемость, фокус клавиатуры и реальные действия кнопок. Коммить design-system/ и .vibe42-design.json вместе с проектом. Для бота, документа или скрипта без UI этот стиль не требует создания сайта.

213
index.html Normal file
View File

@ -0,0 +1,213 @@
<!DOCTYPE html>
<!-- Дизайн: Midnight. Сохраняй все пять CSS; подробности в design.md. -->
<html lang="ru" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Полиграф — студия дизайна и разработки</title>
<meta name="description" content="Проектируем цифровые сервисы и интерфейсы в Алматы и Астане.">
<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="stylesheet" href="design-system/vibe-theme.css">
<script src="design-system/icons/kt-ai-icons.js"></script>
<style>
a.kt-ai-btn { text-decoration: none; }
.kt-ai-grid { margin-top: var(--kt-ai-space-9); }
.work-item { padding: var(--kt-ai-space-6); }
.work-item-img {
aspect-ratio: 4/3;
background: var(--kt-ai-bg-secondary);
border: 1px solid var(--kt-ai-border);
border-radius: 16px;
margin-bottom: var(--kt-ai-space-4);
display: flex;
align-items: center;
justify-content: center;
}
.work-item-img svg { width: 44px; height: 44px; opacity: 0.35; }
.work-tags { display: flex; gap: 8px; flex-wrap: wrap; }
.about-stats { display: flex; gap: var(--kt-ai-space-10); margin-top: var(--kt-ai-space-8); flex-wrap: wrap; }
.stat-value {
font-size: 2.2rem;
font-weight: 300;
color: var(--kt-ai-text);
display: block;
font-variant-numeric: tabular-nums;
}
.stat-label { color: var(--kt-ai-text-secondary); margin-top: 4px; }
.timeline { margin-top: var(--kt-ai-space-8); }
.timeline-item {
display: grid;
grid-template-columns: 120px 1fr;
gap: var(--kt-ai-space-6);
padding: var(--kt-ai-space-5) 0;
border-bottom: 1px solid var(--kt-ai-border);
}
.timeline-year { color: var(--kt-ai-accent); font-variant-numeric: tabular-nums; }
@media (max-width: 640px) {
.timeline-item { grid-template-columns: 1fr; gap: var(--kt-ai-space-2); }
}
</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="ghost" href="#work">Работы</a>
<a class="kt-ai-btn" data-variant="primary" href="#contact">Связаться</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="#work">Смотреть работы</a>
<a class="kt-ai-btn" data-size="lg" href="#contact">Обсудить проект</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">
<div class="kt-ai-tile-icon">
<svg class="kt-ai-icon"><use href="#layers"/></svg>
</div>
<h3>Продуктовый дизайн</h3>
<p>UX-исследования, прототипы, дизайн-системы и вёрстка под реальные сценарии.</p>
</article>
<article class="kt-ai-tile">
<div class="kt-ai-tile-icon">
<svg class="kt-ai-icon"><use href="#check-circle"/></svg>
</div>
<h3>Веб и API</h3>
<p>Фронтенд, бэкенд, интеграции, деплой. Чистый код без лишнего.</p>
</article>
<article class="kt-ai-tile">
<div class="kt-ai-tile-icon">
<svg class="kt-ai-icon"><use href="#sparkles"/></svg>
</div>
<h3>Боты и сервисы</h3>
<p>Telegram-боты, чат-агенты, сервисы на базе ИИ для процессов компании.</p>
</article>
</div>
<div class="about-stats">
<div><span class="stat-value">42</span><div class="stat-label">проекта запускено</div></div>
<div><span class="stat-value">6</span><div class="stat-label">лет в дизайне и коде</div></div>
<div><span class="stat-value">18</span><div class="stat-label">довольных клиентов</div></div>
</div>
</div>
</section>
<section id="work" 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" data-cols="2">
<article class="kt-ai-card work-item">
<div class="work-item-img">
<svg class="kt-ai-icon accent"><use href="#dashboard"/></svg>
</div>
<h3>Маркетплейс стройматериалов</h3>
<p>Каталог, заказы, учёт остатков и личный кабинет поставщика.</p>
<div class="work-tags">
<span class="kt-ai-chip">Веб-приложение</span>
<span class="kt-ai-chip">Дизайн</span>
</div>
</article>
<article class="kt-ai-card work-item">
<div class="work-item-img">
<svg class="kt-ai-icon accent"><use href="#user"/></svg>
</div>
<h3>Кабинет сотрудника</h3>
<p>Личный кабинет, заявки, документы и уведомления в одном месте.</p>
<div class="work-tags">
<span class="kt-ai-chip">B2B</span>
<span class="kt-ai-chip">UI/UX</span>
</div>
</article>
<article class="kt-ai-card work-item">
<div class="work-item-img">
<svg class="kt-ai-icon accent"><use href="#chat"/></svg>
</div>
<h3>Telegram-бот записи</h3>
<p>Бот для записи на услуги с напоминаниями и статистикой.</p>
<div class="work-tags">
<span class="kt-ai-chip">Бот</span>
<span class="kt-ai-chip">ИИ</span>
</div>
</article>
<article class="kt-ai-card work-item">
<div class="work-item-img">
<svg class="kt-ai-icon accent"><use href="#package"/></svg>
</div>
<h3>Учёт склада</h3>
<p>Живой остаток, заявки на выдачу и история движений по складу.</p>
<div class="work-tags">
<span class="kt-ai-chip">Внутренний инструмент</span>
<span class="kt-ai-chip">Node.js</span>
</div>
</article>
</div>
</div>
</section>
<section id="process" 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" data-cols="4">
<article class="kt-ai-tile"><h3>01. Бриф</h3><p>Узнаём цель, аудиторию и ограничения.</p></article>
<article class="kt-ai-tile"><h3>02. Дизайн</h3><p>Прототип и вёрстка, согласование с вами.</p></article>
<article class="kt-ai-tile"><h3>03. Разработка</h3><p>Собираем продукт и интегрируем данные.</p></article>
<article class="kt-ai-tile"><h3>04. Запуск</h3><p>Публикуем, подключаем и обучаем команду.</p></article>
</div>
</div>
</section>
<section id="experience" class="kt-ai-section" data-divider="true">
<div class="kt-ai-wrap" data-width="narrow">
<div class="kt-ai-section-head">
<h2>Опыт</h2>
<p>Ключевые вехи студии.</p>
</div>
<div class="timeline">
<div class="timeline-item"><div class="timeline-year">2026</div><div><h3>Запуск внутреннего продукта</h3><p>Внедрили ИИ-ассистента для отдела продаж.</p></div></div>
<div class="timeline-item"><div class="timeline-year">2024</div><div><h3>Первый крупный B2B</h3><p>Кабинет сотрудника для сети клиник.</p></div></div>
<div class="timeline-item"><div class="timeline-year">2022</div><div><h3>Студия Полиграф</h3><p>Начали с фриланса и первых сайтов.</p></div></div>
</div>
</div>
</section>
<section id="contact" 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="mailto:hello@poligraph.kz">hello@poligraph.kz</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>