From 8aa420a5d2b6a1ef1e30a494956026bc65276a39 Mon Sep 17 00:00:00 2001 From: olzhasus Date: Tue, 15 Sep 2026 15:19:23 +0000 Subject: [PATCH] =?UTF-8?q?AI=20Trip=20Planner:=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B0,=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82=D0=BE=D0=B2?= =?UTF-8?q?,=20=D0=B1=D1=8E=D0=B4=D0=B6=D0=B5=D1=82,=20=D1=8D=D0=BA=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 318 +++ design-system/AGENTS.md | 60 + design-system/CHECKLIST.md | 115 + design-system/COMPONENTS.md | 593 +++++ design-system/LICENSE | 21 + design-system/README.md | 77 + design-system/THIRD-PARTY-NOTICES.md | 18 + design-system/VERSION | 5 + design-system/archetypes/registry.json | 301 +++ design-system/brand/README.md | 6 + design-system/brand/kazakhtelecom-logo.png | Bin 0 -> 4285 bytes design-system/brand/kt-ai-logo.png | Bin 0 -> 5620 bytes design-system/docs/ARCHETYPES.md | 94 + design-system/docs/BUILD_PROTOTYPE.md | 98 + design-system/docs/DESIGN.md | 308 +++ design-system/docs/GOAL.md | 57 + design-system/docs/PRINCIPLES.md | 60 + design-system/docs/PRODUCT_CONTRACT.md | 93 + .../fonts/InterVariable-subset.woff2 | Bin 0 -> 105716 bytes .../fonts/JetBrainsMono-subset.woff2 | Bin 0 -> 9844 bytes design-system/fonts/OFL-JetBrainsMono.txt | 93 + design-system/fonts/OFL.txt | 92 + design-system/icons/LUCIDE_LICENSE.txt | 43 + design-system/icons/NAMES.txt | 1 + design-system/icons/README.md | 50 + design-system/icons/kt-ai-icons.js | 21 + design-system/icons/kt-ai-lucide-sprite.svg | 385 +++ design-system/kt-ai-chart-tip.js | 168 ++ design-system/kt-ai-components.css | 1918 +++++++++++++++ design-system/kt-ai-composer.js | 187 ++ design-system/kt-ai-feedback.js | 337 +++ design-system/kt-ai-fonts.css | 51 + design-system/kt-ai-orb.js | 138 ++ design-system/kt-ai-page.css | 144 ++ design-system/kt-ai-print.css | 88 + design-system/kt-ai-tokens.css | 395 ++++ design-system/kt-ai/AGENTS.md | 60 + design-system/kt-ai/CHECKLIST.md | 115 + design-system/kt-ai/COMPONENTS.md | 593 +++++ design-system/kt-ai/LICENSE | 21 + design-system/kt-ai/README.md | 77 + design-system/kt-ai/THIRD-PARTY-NOTICES.md | 18 + design-system/kt-ai/VERSION | 5 + design-system/kt-ai/archetypes/registry.json | 301 +++ design-system/kt-ai/brand/README.md | 6 + .../kt-ai/brand/kazakhtelecom-logo.png | Bin 0 -> 4285 bytes design-system/kt-ai/brand/kt-ai-logo.png | Bin 0 -> 5620 bytes design-system/kt-ai/docs/ARCHETYPES.md | 94 + design-system/kt-ai/docs/BUILD_PROTOTYPE.md | 98 + design-system/kt-ai/docs/DESIGN.md | 308 +++ design-system/kt-ai/docs/GOAL.md | 57 + design-system/kt-ai/docs/PRINCIPLES.md | 60 + design-system/kt-ai/docs/PRODUCT_CONTRACT.md | 93 + .../kt-ai/fonts/InterVariable-subset.woff2 | Bin 0 -> 105716 bytes .../kt-ai/fonts/JetBrainsMono-subset.woff2 | Bin 0 -> 9844 bytes .../kt-ai/fonts/OFL-JetBrainsMono.txt | 93 + design-system/kt-ai/fonts/OFL.txt | 92 + design-system/kt-ai/icons/LUCIDE_LICENSE.txt | 43 + design-system/kt-ai/icons/NAMES.txt | 1 + design-system/kt-ai/icons/README.md | 50 + design-system/kt-ai/icons/kt-ai-icons.js | 21 + .../kt-ai/icons/kt-ai-lucide-sprite.svg | 385 +++ design-system/kt-ai/kt-ai-chart-tip.js | 168 ++ design-system/kt-ai/kt-ai-components.css | 1918 +++++++++++++++ design-system/kt-ai/kt-ai-composer.js | 187 ++ design-system/kt-ai/kt-ai-feedback.js | 337 +++ design-system/kt-ai/kt-ai-fonts.css | 51 + design-system/kt-ai/kt-ai-orb.js | 138 ++ design-system/kt-ai/kt-ai-page.css | 144 ++ design-system/kt-ai/kt-ai-print.css | 88 + design-system/kt-ai/kt-ai-tokens.css | 395 ++++ design-system/kt-ai/product.schema.json | 1552 ++++++++++++ .../kt-ai/scripts/validate_product.py | 542 +++++ .../kt-ai/templates/kt-ai-app-shell.html | 2090 +++++++++++++++++ design-system/kt-ai/tokens.json | 320 +++ design-system/product.schema.json | 1552 ++++++++++++ design-system/scripts/validate_product.py | 542 +++++ design-system/templates/kt-ai-app-shell.html | 2090 +++++++++++++++++ design-system/tokens.json | 320 +++ design.md | 258 ++ index.html | 214 ++ script.js | 423 ++++ 82 files changed, 22255 insertions(+) create mode 100644 AGENTS.md create mode 100644 design-system/AGENTS.md create mode 100644 design-system/CHECKLIST.md create mode 100644 design-system/COMPONENTS.md create mode 100644 design-system/LICENSE create mode 100644 design-system/README.md create mode 100644 design-system/THIRD-PARTY-NOTICES.md create mode 100644 design-system/VERSION create mode 100644 design-system/archetypes/registry.json create mode 100644 design-system/brand/README.md create mode 100644 design-system/brand/kazakhtelecom-logo.png create mode 100644 design-system/brand/kt-ai-logo.png create mode 100644 design-system/docs/ARCHETYPES.md create mode 100644 design-system/docs/BUILD_PROTOTYPE.md create mode 100644 design-system/docs/DESIGN.md create mode 100644 design-system/docs/GOAL.md create mode 100644 design-system/docs/PRINCIPLES.md create mode 100644 design-system/docs/PRODUCT_CONTRACT.md create mode 100644 design-system/fonts/InterVariable-subset.woff2 create mode 100644 design-system/fonts/JetBrainsMono-subset.woff2 create mode 100644 design-system/fonts/OFL-JetBrainsMono.txt create mode 100644 design-system/fonts/OFL.txt create mode 100644 design-system/icons/LUCIDE_LICENSE.txt create mode 100644 design-system/icons/NAMES.txt create mode 100644 design-system/icons/README.md create mode 100644 design-system/icons/kt-ai-icons.js create mode 100644 design-system/icons/kt-ai-lucide-sprite.svg create mode 100644 design-system/kt-ai-chart-tip.js create mode 100644 design-system/kt-ai-components.css create mode 100644 design-system/kt-ai-composer.js create mode 100644 design-system/kt-ai-feedback.js create mode 100644 design-system/kt-ai-fonts.css create mode 100644 design-system/kt-ai-orb.js create mode 100644 design-system/kt-ai-page.css create mode 100644 design-system/kt-ai-print.css create mode 100644 design-system/kt-ai-tokens.css create mode 100644 design-system/kt-ai/AGENTS.md create mode 100644 design-system/kt-ai/CHECKLIST.md create mode 100644 design-system/kt-ai/COMPONENTS.md create mode 100644 design-system/kt-ai/LICENSE create mode 100644 design-system/kt-ai/README.md create mode 100644 design-system/kt-ai/THIRD-PARTY-NOTICES.md create mode 100644 design-system/kt-ai/VERSION create mode 100644 design-system/kt-ai/archetypes/registry.json create mode 100644 design-system/kt-ai/brand/README.md create mode 100644 design-system/kt-ai/brand/kazakhtelecom-logo.png create mode 100644 design-system/kt-ai/brand/kt-ai-logo.png create mode 100644 design-system/kt-ai/docs/ARCHETYPES.md create mode 100644 design-system/kt-ai/docs/BUILD_PROTOTYPE.md create mode 100644 design-system/kt-ai/docs/DESIGN.md create mode 100644 design-system/kt-ai/docs/GOAL.md create mode 100644 design-system/kt-ai/docs/PRINCIPLES.md create mode 100644 design-system/kt-ai/docs/PRODUCT_CONTRACT.md create mode 100644 design-system/kt-ai/fonts/InterVariable-subset.woff2 create mode 100644 design-system/kt-ai/fonts/JetBrainsMono-subset.woff2 create mode 100644 design-system/kt-ai/fonts/OFL-JetBrainsMono.txt create mode 100644 design-system/kt-ai/fonts/OFL.txt create mode 100644 design-system/kt-ai/icons/LUCIDE_LICENSE.txt create mode 100644 design-system/kt-ai/icons/NAMES.txt create mode 100644 design-system/kt-ai/icons/README.md create mode 100644 design-system/kt-ai/icons/kt-ai-icons.js create mode 100644 design-system/kt-ai/icons/kt-ai-lucide-sprite.svg create mode 100644 design-system/kt-ai/kt-ai-chart-tip.js create mode 100644 design-system/kt-ai/kt-ai-components.css create mode 100644 design-system/kt-ai/kt-ai-composer.js create mode 100644 design-system/kt-ai/kt-ai-feedback.js create mode 100644 design-system/kt-ai/kt-ai-fonts.css create mode 100644 design-system/kt-ai/kt-ai-orb.js create mode 100644 design-system/kt-ai/kt-ai-page.css create mode 100644 design-system/kt-ai/kt-ai-print.css create mode 100644 design-system/kt-ai/kt-ai-tokens.css create mode 100644 design-system/kt-ai/product.schema.json create mode 100755 design-system/kt-ai/scripts/validate_product.py create mode 100644 design-system/kt-ai/templates/kt-ai-app-shell.html create mode 100644 design-system/kt-ai/tokens.json create mode 100644 design-system/product.schema.json create mode 100755 design-system/scripts/validate_product.py create mode 100644 design-system/templates/kt-ai-app-shell.html create mode 100644 design-system/tokens.json create mode 100644 design.md create mode 100644 index.html create mode 100644 script.js diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2ddacb4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,318 @@ + +# Vibe42 — учебная песочница: сайты, боты и первые приложения + +Workspace юзера `olzhasus`. Это **учебная среда**, где обычные люди (не разработчики) делают свой первый настоящий проект: сайт, telegram-бота или простое приложение. + +--- +## 🎯 ТВОЯ РОЛЬ + +Ты — **гид и помощник**, а не слепой исполнитель. Цель сессии — чтобы юзер вышел с: +1. **работающим воплощением ЕГО идеи**: сайт — опубликован на `https://pages.git.vibe42.kz/olzhasus//`, бот/приложение — запущены через `run` с живой ссылкой, +2. ощущением «это было легко» — всю техническую кухню (серверы, зависимости, git) берёшь на себя ты. + +Юзер не разработчик. Ему важен **работающий результат**, а не код. + +--- +## 🧱 СТЕК: ОДИН НА ВСЁ — НЕ ВЫДУМЫВАЙ + +Модель у нас не самая мощная, поэтому **не сочиняй архитектуру с нуля** — бери готовый стек и рецепт под тип задачи. Так проект заработает с первого-второго раза, а не будет «не могу заранить / node не стартует». + +**Определи тип и возьми стек — без вариантов:** + +| Что хочет юзер | Стек (ЖЁСТКО) | Навык / рецепт — загрузить ПЕРЕД работой | +|----------------|---------------|--------| +| Сайт, лендинг, визитка, портфолио, меню, афиша, waitlist (форма на нём — только если ответы никуда сохранять не надо; иначе строка «Простое приложение с сервером») | **Статика:** в проекте УЖЕ лежит стартовый `index.html` на дизайн-системе KT AI — открой его (`read`) и правь (`edit`), не пиши с нуля; при необходимости + `script.js`. Ванильный JS, БЕЗ сборки, БЕЗ React/Vue/Vite, БЕЗ npm. БЕЗ Tailwind/Bootstrap/Google Fonts и без своих hex-цветов — только классы `kt-ai-*` и токены `var(--kt-ai-…)`. | навык `landing-templates` (шаблоны секций A–E) + `design.md` → правка готового `index.html`, публикация в `pages` | +| Telegram-бот | **Node.js (CommonJS) + grammY** (предустановлен), long-polling | навык `backend-run` → Рецепт T | +| Простое приложение с сервером: форма регистрации/заявки/опроса, ответы которой надо **сохранять**, API, счётчик, запись на время (лендинг с рабочей формой — тоже сюда) | **Node.js (CommonJS), сервер на `node:http` БЕЗ зависимостей + хранение в `data.json`** (массив плоских объектов — юзер видит записи во вкладке «Данные» и качает 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)» | + +**Сначала таблица, потом код.** Прежде чем писать хоть строку — найди в таблице строку под запрос юзера и загрузи указанный навык вызовом `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/olzhasus/.mimo/memory.md` (dot-папка `.mimo` не видна в Предпросмотре — так и задумано). Папка `.mimo/` уже создана, тебе нужно только читать/писать `memory.md`. + +**В НАЧАЛЕ каждой сессии** — прочитай `/srv/opencode/workspaces/users/olzhasus/.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/olzhasus//` (открывается без входа, формы и `api/…` работают, уснувшее приложение просыпается по первому заходу). Для таких проектов **ничего не пушь в `pages`** — там статика, формы получают 405. Просто скажи юзеру нажать «Опубликовать» и что ссылка появится в этом окне. + +**Публикуй (push в ветку `pages`) ТОЛЬКО когда юзер явно просит опубликовать.** Он нажимает кнопку **«Опубликовать»** вверху — тебе приходит сообщение вида «Опубликуй текущий проект…». САМ, без такой просьбы, в `pages` НИКОГДА не пушь — как бы хорошо сайт ни выглядел. + +Когда юзер попросил опубликовать: +```bash +git add -A +git commit -m "publish" +git push origin HEAD:pages +``` +Затем **ОБЯЗАТЕЛЬНО** дай ссылку **жирно**: +> 🎉 Готово! Твой сайт в интернете: **https://pages.git.vibe42.kz/olzhasus//** + +и добавь: «Нажми Поделиться в панели предпросмотра — там ссылка и 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/olzhasus`)** — это папка-контейнер юзера, не репозиторий. + +--- +## ⚙️ Бэкенд, боты, 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///`). Проект уже создан за тебя в тот момент, когда юзер написал идею на главной. Проверь: `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/olzhasus +- Pages (живые лендинги): https://pages.git.vibe42.kz/olzhasus// +- Креды уже в `/srv/opencode/workspaces/users/olzhasus/.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 + дизайн-система KT AI + +Рядом лежит `design.md` — **прочитай его перед первой строкой вёрстки**. Иконки — только спрайт ДС (`design-system/icons/kt-ai-icons.js` + ``), логотипы — файлы из `design-system/brand/`; эмодзи и самодельные 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` со своей палитрой не нужен: ДС уже стилизует кнопки, карточки и типографику; свой ` + + + + + + + + +
+ + +
+
+ + + +
+ +
+ + + +
+ + +
+
+ + +
+
+ +
+
+
+ + + + + + + + + + + + + + +
+ + + + + + diff --git a/design-system/kt-ai/tokens.json b/design-system/kt-ai/tokens.json new file mode 100644 index 0000000..6e61427 --- /dev/null +++ b/design-system/kt-ai/tokens.json @@ -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" + } + } +} diff --git a/design-system/product.schema.json b/design-system/product.schema.json new file mode 100644 index 0000000..bbea793 --- /dev/null +++ b/design-system/product.schema.json @@ -0,0 +1,1552 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://kt.kz/ai/product.schema.json", + "title": "KT AI Product Contract", + "description": "Единый продуктовый контракт KT AI. ОДИН источник, ОДИН рантайм: этот контракт выдаёт дизайнер Төре ([[APP]]), потребляет templates/kt-ai-app-shell.html (кликабельный прототип), читает handoff-док и проверяет scripts/validate_product.py. Раньше форма контракта дублировалась в 4 местах (atomic_product_system.md / app-shell JSON / shadcn input-contract / Төре). Это — каноническая форма. Канон правил вёрстки: docs/DESIGN.md, docs/PRINCIPLES.md.", + "type": "object", + "required": [ + "product" + ], + "additionalProperties": false, + "properties": { + "archetype": { + "type": "string", + "default": "queue", + "description": "Архетип экрана из открытого реестра archetypes/registry.json (id, lowercase). Stable: queue, cockpit, dashboard, compare. Неизвестный или ещё не реализованный (status=planned) архетип безопасно деградирует в queue-таблицу. Набор открыт — новые добавляются записью в реестр + рендерером + примером, без правки ядра. См. docs/ARCHETYPES.md." + }, + "layout": { + "type": "string", + "description": "Deprecated-алиас archetype. Рантайм читает archetype || layout. Для новых конфигов использовать archetype." + }, + "product": { + "type": "object", + "required": [ + "name", + "entity" + ], + "additionalProperties": false, + "description": "Кто и о чём. name – короткое имя рабочего объекта (H1, 1-3 слова), не описание процесса.", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "purpose": { + "type": "string", + "maxLength": 90, + "description": "Одна scope-фраза (subtitle)." + }, + "entity": { + "type": "string", + "minLength": 1, + "description": "Главная сущность: договор, заявка, лот, документ, инцидент, клиент, задача." + }, + "createLabel": { + "type": "string", + "maxLength": 24, + "description": "Явная подпись кнопки создания (морфология). Если не задано — «Новый {entity}». Пример: «Новое предложение»." + }, + "primary_user": { + "type": "string", + "description": "Метаданные: роль основного пользователя (для авторинга/handoff; в UI не выводится)." + }, + "language": { + "type": "string", + "default": "ru", + "enum": [ + "ru", + "kk", + "en" + ], + "description": "Метаданные: основной язык продукта (v1 — ru; не переключает локаль рантайма)." + }, + "view": { + "type": "string", + "enum": [ + "operational", + "management" + ], + "default": "operational", + "description": "operational – рабочая очередь владельца (по умолчанию). management – управленческий/reporting view, где допустимы ROI/FTE/эффект." + } + } + }, + "user": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "role": { + "type": "string" + } + } + }, + "notifications": { + "type": "array", + "items": { + "type": "object", + "required": [ + "text" + ], + "additionalProperties": false, + "properties": { + "text": { + "type": "string" + }, + "time": { + "type": "string" + }, + "unread": { + "type": "boolean", + "default": false + } + } + } + }, + "steps": { + "type": "object", + "additionalProperties": false, + "description": "Этапы процесса в мастере создания. НЕ выносятся на главный список (LAYOUT_RULES 9a).", + "properties": { + "items": { + "type": "array", + "items": { + "type": "string" + } + }, + "current": { + "type": "integer", + "minimum": 1 + } + } + }, + "nav": { + "type": "array", + "description": "Разделы sidebar. 3-5 пунктов на группу (закон Миллера).", + "items": { + "type": "object", + "required": [ + "label" + ], + "additionalProperties": false, + "properties": { + "label": { + "type": "string", + "maxLength": 24 + }, + "icon": { + "type": "string", + "description": "Имя иконки из спрайта icons/kt-ai-lucide-sprite.svg (home/feed/lots/risk/chart/tool/file/users/source/decisions/...). Монохром, Lucide." + }, + "view": { + "type": "string", + "enum": [ + "documents", + "registry" + ], + "description": "Тип секции. «documents» → коллекция артефактов (скачиваемых) из `_docs` строк; «registry» → полный рабочий реестр (таблица с поиском/фильтрами/пагинацией) — используйте для dashboard, чтобы главная оставалась обзором." + }, + "docType": { + "type": "string", + "description": "Для view=documents: какой тип документов показать (совпадает с `_docs[].type`, напр. ts/report)." + }, + "group": { + "type": "string", + "maxLength": 24, + "description": "Заголовок группы разделов. Пункты с одинаковым значением идут подряд под общим заголовком; пункты без группы — просто списком, как раньше. Заголовок НЕ пункт: он не кликается и в нумерации не участвует, поэтому banner.actionNav и S.nav(i) считают только пункты. В полосе значков (832-1023px) заголовок становится разделителем." + }, + "count": { + "$comment": "DEPRECATED: счётчик в пункте меню; рендерится для старых конфигов, в новых не использовать", + "type": [ + "integer", + "number" + ] + } + } + } + }, + "kpis": { + "type": "array", + "description": "Тихая метрик-полоса. Каждая метрика отвечает на вопрос пользователя в этом экране (очередь, риск, просрочка), НЕ доказывает ценность продукта. ROI/FTE/экономия часов/тенге эффекта запрещены при product.view=operational (валидатор предупреждает).", + "items": { + "type": "object", + "required": [ + "value", + "label" + ], + "additionalProperties": false, + "properties": { + "value": { + "type": "string" + }, + "label": { + "type": "string", + "maxLength": 32 + }, + "hint": { + "type": "string", + "maxLength": 40, + "$comment": "уточнение под значением: знаменатель, доля, период — «из 66», «за месяц»" + }, + "note": { + "type": "string", + "description": "Сноска: чем это число НЕ является. Поле-источник иногда меряет не ровно то, чем показатель назван («ожидание в очереди» не отделяет автоприветствие бота от первого ответа оператора) — оговорка обязана стоять рядом с числом, а не в баннере над ним. Потолка длины нет: hint остаётся в 40 знаках, чтобы полоса плиток осталась полосой, а оговорка живёт отдельным полем и показывается мельче подписи. Не прячется под клик — спрятанное читают не все." + } + } + } + }, + "scenario": { + "type": "object", + "required": [ + "headline", + "control" + ], + "additionalProperties": false, + "description": "Один сквозной сценарий владельца: список → деталь → контрольное действие. control – ровно одно главное действие (глагол), один primary на экран.", + "properties": { + "headline": { + "type": "string", + "maxLength": 40, + "description": "Заголовок рабочей области, 1-3 слова." + }, + "control": { + "type": "string", + "description": "Главное (primary) действие, глаголом: «Подтвердить и отправить»." + }, + "reject": { + "type": "string", + "description": "Вторичное/обратное действие." + }, + "confirmText": { + "type": "string", + "description": "Текст подтверждения в modal для control." + }, + "aiSummary": { + "type": "string", + "maxLength": 280, + "description": "Что сделал агент: 1-2 фразы для шапки карточки (drawer). Конкретно про этот продукт — НЕ переиспользовать чужой текст. Без него блок «Что сделал агент» не показывается." + }, + "approveTo": { + "type": "string", + "description": "Ключ статуса после control." + }, + "rejectTo": { + "type": "string", + "description": "Ключ статуса после reject." + }, + "actions": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Action Layer: варианты действия, которые человек может выбрать/изменить в карточке (human-in-the-loop). AI рекомендует, человек подтверждает или меняет." + } + } + }, + "statuses": { + "type": "object", + "minProperties": 1, + "description": "Словарь workflow-статусов главного объекта. Ключ → { label, tone }. Confidence и тип риска моделируются отдельно, не смешиваются со статусом.", + "additionalProperties": { + "type": "object", + "required": [ + "label", + "tone" + ], + "additionalProperties": false, + "properties": { + "label": { + "type": "string", + "maxLength": 24, + "description": "До 4 слов." + }, + "tone": { + "type": "string", + "enum": [ + "ok", + "warn", + "risk", + "info" + ] + }, + "dim": { + "type": "boolean", + "description": "Гасит (приглушает) строки с этим статусом — терминальные/неактивные уходят на второй план, активные выделяются (паттерн «dim inactive rows»)." + } + } + } + }, + "chart": { + "type": "object", + "additionalProperties": false, + "description": "Сводный bar-chart над таблицей: раскатывает данные во времени, чтобы не искать по timestamp-колонке («data drives the form»). Напр.: обращения, разобранные AI, по дням.", + "properties": { + "title": { + "type": "string", + "maxLength": 48 + }, + "total": { + "type": "string", + "description": "Крупное итоговое число (например «1 240»)." + }, + "unit": { + "type": "string", + "maxLength": 24, + "description": "Подпись у числа («за месяц», «всего»)." + }, + "color": { + "type": "string", + "enum": [ + "blue", + "green", + "orange", + "salmon", + "pink", + "primary" + ], + "default": "blue" + }, + "link": { + "type": "string", + "maxLength": 16, + "description": "Опц. подпись ссылки справа («Все»)." + }, + "groupBy": { + "type": "string", + "description": "Ключ колонки/поля строки: бары считаются из ФИЛЬТРОВАННЫХ строк (counts по значениям), график адаптируется к выбранному табу. Если задан — заменяет статичный bars." + }, + "bucket": { + "type": "string", + "enum": [ + "day" + ], + "description": "Временная раскатка: counts по дням за выбранный диапазон. Требует dateKey. Включает универсальный фильтр диапазона дат (7/30/90/всё), который фильтрует и таблицу, и график." + }, + "dateKey": { + "type": "string", + "description": "Ключ колонки-даты (ISO YYYY-MM-DD) для bucket:day и фильтра диапазона." + }, + "bars": { + "type": "array", + "description": "Столбцы. 7–31 шт.", + "items": { + "type": "object", + "required": [ + "value" + ], + "additionalProperties": false, + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "number", + "minimum": 0 + }, + "hint": { + "type": "object", + "additionalProperties": false, + "description": "Подсказка марки. Строки приходят готовыми от продукта — рантайм их не форматирует и не пересчитывает.", + "properties": { + "title": { + "type": "string", + "maxLength": 80 + }, + "rows": { + "type": "array", + "maxItems": 3, + "items": { + "type": "object", + "required": [ + "label", + "value" + ], + "additionalProperties": false, + "properties": { + "label": { + "type": "string", + "maxLength": 40 + }, + "value": { + "type": "string", + "maxLength": 40 + } + } + } + } + } + } + } + } + } + } + }, + "activity": { + "type": "array", + "description": "Лента активности (правая панель, триггер-иконка в топбаре): действия агента и история решений во времени. Референс: activity-timeline drawer.", + "items": { + "type": "object", + "required": [ + "title" + ], + "additionalProperties": false, + "properties": { + "title": { + "type": "string" + }, + "time": { + "type": "string", + "description": "Метка времени («Сегодня, 14:13»)." + }, + "ago": { + "type": "string", + "description": "Относительно («4 часа назад»)." + }, + "tone": { + "type": "string", + "enum": [ + "ok", + "warn", + "risk", + "info" + ], + "description": "Цвет точки на таймлайне." + }, + "who": { + "type": "string", + "description": "Кто/что инициировал." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Чипы (датасеты/объекты)." + }, + "summary": { + "type": "string", + "maxLength": 220 + }, + "link": { + "type": "string", + "maxLength": 32, + "description": "Подпись действия-ссылки." + } + } + } + }, + "table": { + "type": "object", + "required": [ + "columns", + "rows" + ], + "additionalProperties": false, + "description": "Главный рабочий объект экрана для 5+ записей.", + "properties": { + "pageSize": { + "type": "integer", + "minimum": 1, + "description": "Размер страницы таблицы. Если строк больше — появляется пагинация. По умолчанию 12." + }, + "columns": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "key", + "label" + ], + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$" + }, + "label": { + "type": "string", + "maxLength": 24, + "description": "1-2 слова." + }, + "type": { + "type": "string", + "enum": [ + "text", + "num", + "date", + "status" + ], + "default": "text" + }, + "sub": { + "type": "string", + "description": "Ключ поля для muted-подзаголовка под значением (двухстрочная entity-ячейка в духе ElevenLabs)." + }, + "values": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Field-level data contract: допустимые значения для status/enum-колонок. Заполняется из паспорта/интервью, чтобы владелец видел ПРАВДУ, а не выдумку." + }, + "source": { + "type": "string", + "description": "Метаданные авторинга: откуда значение (SAP, Лотус, KTWorks, агент, человек). Для трассировки/handoff; в рантайме v1 не рендерится автоматически." + }, + "align": { + "type": "string", + "enum": [ + "left", + "right" + ], + "description": "Выравнивание ячеек. По умолчанию num — справа, остальное — слева; задаёт явный override (рендерят оба рантайма)." + }, + "sortable": { + "type": "boolean", + "default": false + }, + "width": { + "type": "string", + "description": "Явный override ширины колонки (напр. «140px»). По умолчанию ширина считается по содержимому колонки (из всех строк, с клампом) — не шире и не уже, чем нужно." + } + } + } + }, + "rows": { + "type": "array", + "description": "8-12 правдоподобных строк. Данные – из материалов проекта/интервью, НЕ выдуманные. _flags – список проверок агента для drawer; _docs – артефакты, которые продукт производит по записи (ТС, отчёт, СЗ…).", + "items": { + "type": "object", + "properties": { + "_flags": { + "type": "array", + "items": { + "type": "string" + } + }, + "_docs": { + "type": "array", + "description": "Выходные документы записи. Показываются в drawer (секция «Документы») и в nav-секциях view=documents; каждый со статусом и кнопкой скачать.", + "items": { + "type": "object", + "required": [ + "label" + ], + "additionalProperties": false, + "properties": { + "label": { + "type": "string", + "description": "Имя документа: «Проект ТС», «Отчёт о консолидации», «Служебная записка»." + }, + "type": { + "type": "string", + "description": "Тип для группировки в nav-секции (docType): ts/report/…" + }, + "status": { + "type": "string", + "description": "Статус по жизненному циклу: «Черновик v1», «Сформирован», «После принятия»." + }, + "format": { + "type": "string", + "description": "Формат для иконки/подписи: doc/pdf/xlsx." + } + } + } + } + } + } + } + } + }, + "drawer": { + "type": "object", + "required": [ + "titleKey" + ], + "additionalProperties": false, + "description": "Правый drawer деталей объекта (детали в drawer, не на новой странице).", + "properties": { + "titleKey": { + "type": "string", + "description": "Ключ колонки для заголовка drawer." + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "required": [ + "key", + "label" + ], + "additionalProperties": false, + "properties": { + "key": { + "type": "string" + }, + "label": { + "type": "string" + } + } + } + }, + "flagsLabel": { + "type": "string" + } + } + }, + "ai": { + "type": "object", + "additionalProperties": false, + "description": "Точка входа AI-помощи (орб Төре). LLM-вызовы только через backend (app/api/ai/*), не из клиента. AI-контент атрибутируется «Сгенерировано ИИ».", + "properties": { + "intro": { + "type": "string" + }, + "suggestions": { + "type": "array", + "items": { + "type": "string" + } + }, + "answers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "fallback": { + "type": "string" + } + } + }, + "banner": { + "type": "object", + "additionalProperties": false, + "description": "Системный баннер в контенте по схеме «что-почему-что делать».", + "properties": { + "text": { + "type": "string" + }, + "tone": { + "type": "string", + "enum": [ + "info", + "warn", + "risk", + "ok" + ], + "default": "info" + }, + "action": { + "type": "string", + "description": "Подпись действия баннера (делает баннер полезным, не статичным)." + }, + "actionNav": { + "type": "integer", + "description": "Индекс раздела nav, куда ведёт действие баннера." + } + } + }, + "search": { + "type": "object", + "additionalProperties": false, + "description": "Поиск в шапке. Настраивает поле HTML-рантайма; в React-ките те же три поля живут в пропе `search` у KTAIShell (там же items палитры). Само поле не включается и не выключается этим блоком — в HTML-рантайме оно фильтрует таблицу и есть всегда.", + "properties": { + "placeholder": { + "type": "string", + "maxLength": 40, + "description": "Подсказка в поле. По умолчанию «Поиск…». Сочетание сюда писать НЕ надо — у него свой бейдж." + }, + "compact": { + "type": "boolean", + "default": false, + "description": "Узкое поле (176px) вместо полного: шапка отдаёт место остальному. Бейдж сочетания в нём не показывается — это ширина запроса, а не запроса с подписью; само сочетание продолжает работать и названо в title." + }, + "shortcut": { + "type": "boolean", + "default": true, + "description": "Сочетание ⌘K/Ctrl+K и его бейдж. Выключается ОДНИМ полем: подпись без обработчика — обещание, обработчик без подписи — незаявленная горячая клавиша. Подпись берётся по клавиатуре машины: ⌘K на Apple, Ctrl K на остальных." + } + } + }, + "conclusion": { + "type": "object", + "additionalProperties": false, + "required": [ + "text" + ], + "description": "Вывод раздела — последний блок экрана, после таблицы. Противовес баннера: баннер стоит НАД числами и предупреждает, из-за чего число соврёт; вывод стоит ПОСЛЕ данных и говорит, что из них следует (разбор смещения выборки, оговорка о методике, ответ на вопрос раздела). Без этого слота вывод уходил в баннер, и абзац текста вставал до первого числа.", + "properties": { + "title": { + "type": "string", + "maxLength": 60 + }, + "text": { + "type": "string", + "description": "Текст вывода. Пустая строка разделяет абзацы." + } + } + }, + "compare": { + "type": "object", + "additionalProperties": false, + "description": "Данные архетипа compare: попарная сверка двух источников, расхождения подсвечены (аудит/финансы/договоры). Требуется при archetype=compare, иначе экран деградирует в queue.", + "properties": { + "aLabel": { + "type": "string" + }, + "bLabel": { + "type": "string" + }, + "pairs": { + "type": "array", + "items": { + "type": "object", + "required": [ + "label", + "a", + "b" + ], + "additionalProperties": false, + "properties": { + "label": { + "type": "string", + "description": "Имя сверяемого поля." + }, + "a": { + "type": "string", + "description": "Ключ колонки источника A." + }, + "b": { + "type": "string", + "description": "Ключ колонки источника B." + } + } + } + } + } + }, + "sections": { + "type": "array", + "description": "Свободная композиция (archetype custom): секции-блоки из примитивов системы в любом порядке. Архетипы — отправные точки; когда процесс не ложится в один архетип, экран собирается отсюда.", + "items": { + "type": "object", + "required": [ + "title" + ], + "additionalProperties": false, + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "body": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "required": [ + "label", + "value" + ], + "additionalProperties": false, + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + }, + "type": { + "enum": [ + "text", + "cards", + "chart", + "kpis", + "table", + "chat" + ], + "$comment": "text (по умолчанию) | cards — сетка карточек (хаб инструментов, референс ElevenLabs Home) | chart — чарт по контракту analytics | kpis — глобальная KPI-строка | table — глобальная таблица | chat — глобальный ai-блок" + }, + "width": { + "enum": [ + "full", + "half" + ], + "$comment": "half — секция занимает полколонки (двухколоночная композиция)" + }, + "items": { + "type": "array", + "maxItems": 12, + "$comment": "для type=cards", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "title" + ], + "properties": { + "title": { + "type": "string", + "maxLength": 60 + }, + "text": { + "type": "string", + "maxLength": 160 + }, + "badge": { + "type": "string", + "maxLength": 24 + }, + "tone": { + "enum": [ + "ok", + "warn", + "risk", + "info" + ] + } + } + } + }, + "chart": { + "$ref": "#/properties/analytics/properties/charts/items", + "$comment": "для type=chart" + } + } + } + }, + "onboarding": { + "type": "array", + "description": "Слайды онбординг-карусели при первом входе (2-3). Если не задано — берутся дефолтные слайды на основе product. Иконка из спрайта icons/kt-ai-lucide-sprite.svg.", + "items": { + "type": "object", + "required": [ + "title", + "text" + ], + "additionalProperties": false, + "properties": { + "icon": { + "type": "string", + "description": "Имя иконки из спрайта (sparkles/eye/megaphone/book/play/...)." + }, + "title": { + "type": "string", + "maxLength": 48 + }, + "text": { + "type": "string", + "maxLength": 220 + } + } + } + }, + "filterKeys": { + "$comment": "DEPRECATED: фильтры выводятся из statuses автоматически; поле рендерится для старых конфигов, в новых не использовать", + "type": "array", + "items": { + "type": "string" + } + }, + "document": { + "$comment": "Архетип document: документ с правками агента по фрагментам (договор, письмо, ТЗ)", + "type": "object", + "additionalProperties": false, + "required": [ + "title", + "fragments" + ], + "properties": { + "title": { + "type": "string", + "maxLength": 120 + }, + "source": { + "type": "string", + "maxLength": 160, + "$comment": "откуда собран документ (шаблон + заявка)" + }, + "fragments": { + "type": "array", + "minItems": 2, + "maxItems": 14, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "text" + ], + "properties": { + "heading": { + "type": "string", + "maxLength": 90 + }, + "text": { + "type": "string", + "maxLength": 700 + }, + "was": { + "type": "string", + "maxLength": 700, + "$comment": "прежний текст для op=edit" + }, + "op": { + "enum": [ + "keep", + "edit", + "add", + "del" + ] + }, + "flag": { + "type": "string", + "maxLength": 220, + "$comment": "почему агент пометил фрагмент" + } + } + } + } + } + }, + "artifact": { + "$comment": "Архетип copilot: результат-артефакт рядом с чатом", + "type": "object", + "additionalProperties": false, + "required": [ + "title", + "preview" + ], + "properties": { + "type": { + "enum": [ + "document", + "slides", + "report", + "table", + "letter" + ] + }, + "title": { + "type": "string", + "maxLength": 120 + }, + "version": { + "type": "string", + "maxLength": 24 + }, + "preview": { + "type": "string", + "maxLength": 4000, + "$comment": "текст/markdown-превью артефакта" + }, + "actions": { + "type": "array", + "maxItems": 3, + "items": { + "type": "string", + "maxLength": 32 + } + } + } + }, + "wizard": { + "$comment": "Архетип wizard: пошаговая заявка с автозаполнением агентом", + "type": "object", + "additionalProperties": false, + "required": [ + "steps" + ], + "properties": { + "current": { + "type": "integer", + "minimum": 0 + }, + "steps": { + "type": "array", + "minItems": 2, + "maxItems": 7, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "title", + "fields" + ], + "properties": { + "title": { + "type": "string", + "maxLength": 60 + }, + "fields": { + "type": "array", + "minItems": 1, + "maxItems": 10, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "label" + ], + "properties": { + "label": { + "type": "string", + "maxLength": 60 + }, + "value": { + "type": [ + "string", + "number" + ], + "$comment": "предзаполнено" + }, + "prefill": { + "type": "boolean", + "$comment": "true = заполнил агент" + }, + "hint": { + "type": "string", + "maxLength": 120, + "$comment": "источник/подсказка" + } + } + } + }, + "desc": { + "type": "string", + "maxLength": 160, + "$comment": "описание шага в вертикальном степпере (референс ElevenLabs)" + } + } + } + } + } + }, + "inbox": { + "$comment": "Архетип inbox: список + постоянная панель чтения; ключи указывают на поля table.rows", + "type": "object", + "additionalProperties": false, + "required": [ + "bodyKey" + ], + "properties": { + "titleKey": { + "type": "string" + }, + "subKey": { + "type": "string" + }, + "timeKey": { + "type": "string" + }, + "bodyKey": { + "type": "string", + "$comment": "поле строки с полным текстом (письмо, обращение)" + } + } + }, + "analytics": { + "$comment": "Архетип analytics: аналитический разбор – сводка агента, чарты, выводы", + "type": "object", + "additionalProperties": false, + "required": [ + "charts", + "findings" + ], + "properties": { + "summary": { + "type": "string", + "maxLength": 500, + "$comment": "что агент увидел в данных, 1-3 предложения" + }, + "period": { + "type": "string", + "maxLength": 60 + }, + "charts": { + "type": "array", + "minItems": 1, + "maxItems": 4, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "title", + "points" + ], + "properties": { + "type": { + "enum": [ + "bar", + "line", + "donut" + ] + }, + "title": { + "type": "string", + "maxLength": 90 + }, + "unit": { + "type": "string", + "maxLength": 20 + }, + "total": { + "type": [ + "string", + "number" + ] + }, + "note": { + "type": "string", + "maxLength": 160, + "$comment": "чтение чарта в одну фразу" + }, + "points": { + "type": "array", + "minItems": 2, + "maxItems": 14, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "label", + "value" + ], + "properties": { + "label": { + "type": "string", + "maxLength": 40 + }, + "value": { + "type": [ + "number", + "string" + ] + }, + "tone": { + "enum": [ + "ok", + "warn", + "risk", + "info" + ] + } + } + } + } + } + } + }, + "findings": { + "type": "array", + "minItems": 1, + "maxItems": 6, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "title" + ], + "properties": { + "title": { + "type": "string", + "maxLength": 120 + }, + "detail": { + "type": "string", + "maxLength": 300 + }, + "value": { + "type": [ + "string", + "number" + ] + }, + "tone": { + "enum": [ + "ok", + "warn", + "risk", + "info" + ] + } + } + } + } + } + }, + "calendar": { + "$comment": "Архетип calendar: расписание по датам (окна работ, дежурства, сроки)", + "type": "object", + "additionalProperties": false, + "required": [ + "events" + ], + "properties": { + "title": { + "type": "string", + "maxLength": 90 + }, + "range": { + "type": "string", + "maxLength": 60 + }, + "events": { + "type": "array", + "minItems": 2, + "maxItems": 30, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "date", + "title" + ], + "properties": { + "date": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "time": { + "type": "string", + "maxLength": 20 + }, + "title": { + "type": "string", + "maxLength": 120 + }, + "note": { + "type": "string", + "maxLength": 160 + }, + "badge": { + "type": "string", + "maxLength": 30 + }, + "tone": { + "enum": [ + "ok", + "warn", + "risk", + "info" + ] + } + } + } + } + } + }, + "map": { + "$comment": "Архетип map: схема-карта участка (точки 0-100 по осям, линии-связи); НЕ геокарта с тайлами", + "type": "object", + "additionalProperties": false, + "required": [ + "points" + ], + "properties": { + "title": { + "type": "string", + "maxLength": 90 + }, + "points": { + "type": "array", + "minItems": 2, + "maxItems": 24, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "label", + "x", + "y" + ], + "properties": { + "label": { + "type": "string", + "maxLength": 40 + }, + "x": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "y": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "status": { + "type": "string", + "$comment": "ключ из statuses" + }, + "tone": { + "enum": [ + "ok", + "warn", + "risk", + "info" + ] + }, + "note": { + "type": "string", + "maxLength": 200 + } + } + } + }, + "lines": { + "type": "array", + "maxItems": 40, + "items": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "integer", + "minimum": 0 + } + }, + "$comment": "связи как пары индексов points" + } + } + }, + "timeline": { + "$comment": "Архетип timeline: хронология дела/инцидента", + "type": "object", + "additionalProperties": false, + "required": [ + "events" + ], + "properties": { + "title": { + "type": "string", + "maxLength": 120 + }, + "events": { + "type": "array", + "minItems": 3, + "maxItems": 20, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "time", + "title" + ], + "properties": { + "time": { + "type": "string", + "maxLength": 30 + }, + "title": { + "type": "string", + "maxLength": 140 + }, + "text": { + "type": "string", + "maxLength": 400 + }, + "actor": { + "type": "string", + "maxLength": 50 + }, + "tone": { + "enum": [ + "ok", + "warn", + "risk", + "info" + ] + }, + "current": { + "type": "boolean" + } + } + } + } + } + }, + "dashboard": { + "$comment": "Архетип dashboard (референс ElevenLabs): KPI-карточки как табы, выбранная метрика управляет чартом; внизу разборы", + "type": "object", + "additionalProperties": false, + "required": [ + "series" + ], + "properties": { + "range": { + "type": "string", + "maxLength": 60, + "$comment": "период данных, напр. «январь — июль 2026»" + }, + "series": { + "type": "array", + "minItems": 1, + "maxItems": 4, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "points" + ], + "properties": { + "kpi": { + "type": "integer", + "minimum": 0, + "$comment": "индекс метрики в kpis, к которой привязан ряд (по умолчанию — порядковый)" + }, + "title": { + "type": "string", + "maxLength": 90 + }, + "unit": { + "type": "string", + "maxLength": 20 + }, + "total": { + "type": [ + "string", + "number" + ] + }, + "note": { + "type": "string", + "maxLength": 160, + "$comment": "чтение чарта одной фразой" + }, + "points": { + "type": "array", + "minItems": 2, + "maxItems": 32, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "label", + "value" + ], + "properties": { + "label": { + "type": "string", + "maxLength": 20 + }, + "value": { + "type": [ + "number", + "string" + ] + } + } + } + } + } + } + }, + "breakdowns": { + "type": "array", + "maxItems": 4, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "title", + "type", + "items" + ], + "properties": { + "title": { + "type": "string", + "maxLength": 90 + }, + "type": { + "enum": [ + "cards", + "share", + "top" + ] + }, + "unit": { + "type": "string", + "maxLength": 20 + }, + "note": { + "type": "string", + "maxLength": 160 + }, + "items": { + "type": "array", + "minItems": 2, + "maxItems": 10, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "label": { + "type": "string", + "maxLength": 60 + }, + "value": { + "type": [ + "number", + "string" + ] + }, + "title": { + "type": "string", + "maxLength": 60, + "$comment": "для type=cards" + }, + "stats": { + "type": "array", + "maxItems": 5, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "label", + "value" + ], + "properties": { + "label": { + "type": "string", + "maxLength": 40 + }, + "value": { + "type": [ + "string", + "number" + ] + } + } + } + } + } + } + } + } + } + } + } + }, + "width": { + "type": "string", + "enum": [ + "full", + "reading" + ], + "description": "Ширина рабочей области свободного экрана. «reading» — мера чтения сплошного текста (~720px), «full» — общая ширина продукта. Без поля: экран только из текстовых секций читается как «reading», любой другой — «full». Секция fields считается НЕ текстом: пары «подпись — значение» на 720px читаются хуже." + } + } +} diff --git a/design-system/scripts/validate_product.py b/design-system/scripts/validate_product.py new file mode 100755 index 0000000..d20b236 --- /dev/null +++ b/design-system/scripts/validate_product.py @@ -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 --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 [--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()) diff --git a/design-system/templates/kt-ai-app-shell.html b/design-system/templates/kt-ai-app-shell.html new file mode 100644 index 0000000..2345da4 --- /dev/null +++ b/design-system/templates/kt-ai-app-shell.html @@ -0,0 +1,2090 @@ + + + + + +KT AI – прототип + + + + + + + + + + + + + +
+ + +
+
+ + + +
+ +
+ + + +
+ + +
+
+ + +
+
+ +
+
+
+ + + + + + + + + + + + + + +
+ + + + + + diff --git a/design-system/tokens.json b/design-system/tokens.json new file mode 100644 index 0000000..6e61427 --- /dev/null +++ b/design-system/tokens.json @@ -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" + } + } +} diff --git a/design.md b/design.md new file mode 100644 index 0000000..ddb0016 --- /dev/null +++ b/design.md @@ -0,0 +1,258 @@ + +# Дизайн-система KT AI — по ней собирается весь UI + +В папке проекта лежит `design-system/` — это **дизайн-система KT AI** +(источник: `github.com/erjan-A/kt-ai-design-system`, MIT, версия в `design-system/VERSION`). +Весь визуал берётся оттуда: цвета, шрифт, отступы, кнопки, карточки, секции лендинга. + +**Не выдумывай свой стиль и не тащи чужие фреймворки.** Твоя работа — собрать страницу +из готовых классов ДС и наполнить её содержанием юзера. + +Если папки `design-system/` в проекте нет (старый проект) — принеси её один раз: + +```bash +cp -r /srv/opencode/design-system/kt-ai design-system +``` + +--- + +## 🔒 Три правила, которые нельзя нарушать + +1. **Цвета и размеры — только токенами** `var(--kt-ai-…)`. Сырой hex (`#0F1218`, `#fff`) в вёрстке + запрещён: он ломает светлую/тёмную тему. +2. **Никакого Bootstrap / Tailwind / Material / Font Awesome / Google Fonts.** Шрифт (Inter) уже + внутри ДС и работает офлайн. Всё, что нужно лендингу, в ДС уже есть. +3. **Свой ` + + + +
+
+ Название + + О нас + Оставить заявку +
+
+ +
+
+

Главное обещание в одну строку

+

Подзаголовок: кому это и почему стоит остаться.

+ +
+
+ +
+
+
+

Что вы получите

+

Одно-два предложения пояснения к секции.

+
+
+

Пункт первый

Коротко и по делу.

+

Пункт второй

Коротко и по делу.

+

Пункт третий

Коротко и по делу.

+
+
+
+ +
+
+

Призыв к действию

+

Город, адрес, часы работы — то, что нужно, чтобы дойти.

+ Написать +
+
+ +
+ © 2026 Название + + Контакты +
+ + + +``` + +--- + +## Блоки страницы (`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`. + +## Токены (`kt-ai-tokens.css`) + +Цвет: `--kt-ai-bg`, `--kt-ai-bg-soft`, `--kt-ai-fg`, `--kt-ai-fg-muted`, `--kt-ai-fg-faint`, +`--kt-ai-border`, `--kt-ai-divider`, `--kt-ai-card-bg`, `--kt-ai-card-border`, +`--kt-ai-primary`, `--kt-ai-link`, `--kt-ai-danger`. + +Отступы: `--kt-ai-space-1…12` (2px → 64px). Скругления: `--kt-ai-radius-md/lg/xl/2xl/3xl/full`. +Текст: `--kt-ai-text-xs…3xl`. Вес: `--kt-ai-weight-regular/medium/semibold/bold`. +Шрифты: `--kt-ai-font-sans` (Inter), `--kt-ai-font-mono`. + +**Тема** — атрибут на ``: `data-theme="auto"` (по системной, ставь его по умолчанию), +`"light"`, `"dark"`. Проверь страницу в обеих темах — с токенами это бесплатно. + +--- + +## Иконки и логотипы — только из дизайн-системы + +**Иконки не рисуй и не подменяй эмодзи.** В ДС есть спрайт из 97 иконок Lucide (`design-system/icons/kt-ai-lucide-sprite.svg`), штрих 1.75, монохром через `currentColor`. Подключение — две строки: + +```html + + +``` + +Внешняя ссылка `` **не работает** (браузер блокирует), поэтому именно скрипт. В React/Vite-проекте скопируй спрайт в `public/icons.svg` и подключи скрипт с `data-sprite="icons.svg"`. + +Имена иконок (других нет — выбирай ближайшую по смыслу): +`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` + +Запрещено: эмодзи вместо иконок (🚀 ✅ 📊 и т.п. в кнопках, плитках, списках, заголовках), самодельные ``, иконочные шрифты и CDN-наборы (Font Awesome, Material Icons), CSS-псевдоиконки. Эмодзи допустимы только внутри пользовательского текста, если юзер сам их прислал. + +**Логотипы — файлы, не текст и не SVG-подделка:** +- `design-system/brand/kt-ai-logo.png` — логотип KT AI: brand-ряд сайдбара/шапки (18–22px), favicon. +- `design-system/brand/kazakhtelecom-logo.png` — логотип АО «Казахтелеком»: шапка/подвал корпоративных страниц. + +```html +Казахтелеком Название продукта +``` +Если в проекте нет папки `design-system/brand/` (старый проект) — скопируй: `cp -r /srv/opencode/design-system/kt-ai/brand design-system/ && cp /srv/opencode/design-system/kt-ai/icons/kt-ai-icons.js design-system/icons/`. + +## Не лендинг, а приложение (дашборд, таблица, очередь заявок, чат) + +> Для приложений есть отдельный слой дизайн-системы: архетипы экрана, app-shell, KPI, таблицы, drawer и 16 эталонных прототипов. +> Вызови навык `skill({ name: "kt-design-system" })` — он важнее этого раздела. + + +Тогда экран собирается **не вёрсткой руками, а конфигом**: + +1. Возьми `design-system/templates/kt-ai-app-shell.html` — самодостаточный HTML-каркас приложения + (без React и npm). +2. Опиши экран JSON-конфигом по `design-system/docs/PRODUCT_CONTRACT.md` и положи его в тег + `` внутри каркаса — он сам отрисует + таблицу, KPI, фильтры, drawer и темы. +3. Проверь конфиг: `python3 design-system/scripts/validate_product.py config.json --strict` → нужен `0/0`. + +Полные правила для сложных экранов — `design-system/AGENTS.md`, список компонентов — +`design-system/COMPONENTS.md`, приёмка — `design-system/CHECKLIST.md`. Читай их, когда лендинга +уже мало. + +--- + +## 📦 Готовые рецепты + +### Фото без ключей +- Одно и то же слово → одна и та же картинка: `https://picsum.photos/seed/coffee/800/600` +- По теме: `https://loremflickr.com/800/600/coffee` (тег прямо в URL) + +Вставляй с рамкой ДС: `…` + +### Кнопка WhatsApp +```html +Написать в WhatsApp +``` +`7XXXXXXXXXX` — номер в международном формате без `+`. + +### Форма заявки без бэкенда +```html +
+
+
+ +
+``` +Скажи юзеру: замени `EMAIL` на свою почту — заявки будут приходить туда (первая заявка активирует форму). + +### Favicon +```html + +``` +Эмодзи-фавикон не делай: у продукта есть логотип. + +### Карта +На maps.google.com: место → «Поделиться» → «Встроить карту» → вставь iframe как есть: +```html + +``` + +### Плавные появления при скролле +```html + + +``` + +--- + +## Перед тем как сказать «готово» + +- [ ] Подключены все четыре CSS из `design-system/`, папка лежит в проекте (не забудь закоммитить её). +- [ ] В вёрстке нет сырых hex-цветов и чужих шрифтов. +- [ ] Страница нормально выглядит в светлой и тёмной теме и на телефоне (узкое окно). +- [ ] Один `data-variant="primary"` на экран, тексты — про юзера, а не «Lorem ipsum». diff --git a/index.html b/index.html new file mode 100644 index 0000000..ef0d516 --- /dev/null +++ b/index.html @@ -0,0 +1,214 @@ + + + + + +AI Trip Planner — Персональный планировщик путешествий + + + + + + + + + +
+
+
+ + AI Trip Planner +
+ + + +
+
+ +
+
+

Планируй путешествия с умом

+

AI подберёт идеальный маршрут под твой бюджет и интересы за секунды

+
+ + +
+
+
+ +
+
+
+

Параметры поездки

+

Расскажи, что ты хочешь — AI составит детальный план

+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ 🍜 Еда + 📸 Фото + 🏛 История + 🎨 Искусство + 🌃 Ночная жизнь + 🛍 Шопинг + ⚽ Спорт + 🧘 Релакс + 🎯 Приключения + 🏡 Местный колорит +
+
+ +
+ + +
+
+
+
+ + + +
+
+
+

Сохранённые поездки

+

Твои маршруты хранятся локально в браузере

+
+
+
+
+ +
+ © 2026 AI Trip Planner + + Создано с ❤️ для путешественников +
+ + + + + + diff --git a/script.js b/script.js new file mode 100644 index 0000000..eeb1b4b --- /dev/null +++ b/script.js @@ -0,0 +1,423 @@ +// AI Trip Planner - Логика приложения + +// Моковые данные для генерации маршрутов +const destinations = { + beach: { + name: 'Пляжный отдых', + locations: ['Пляж Золотой', 'Прибрежный бульвар', 'Морской порт', 'Аквапарк', 'Дайвинг-центр'], + activities: { + food: ['Рыбный ресторан', 'Кафе у моря', 'Ужин на закате'], + photo: ['Фотосессия на пляже', 'Маяк для фото', 'Закатная точка'], + history: ['Морской музей', 'Старый порт'], + art: ['Галерея маринистики'], + nightlife: ['Пляжный бар', 'Ночной клуб у моря'], + shopping: ['Рынок сувениров', 'ТЦ прибрежный'], + sports: ['Водные лыжи', 'Пляжный волейбол', 'Сёрфинг'], + relax: ['SPA-центр', 'Массаж на пляже'], + adventure: ['Прогулка на катере', 'Снорклинг'], + local: ['Рыбацкая деревня', 'Местный рынок'] + } + }, + active: { + name: 'Активный отдых', + locations: ['Горнолыжный курорт', 'Скалодром', 'Велотропа', 'Парк приключений', 'Река для рафтинга'], + activities: { + food: ['Горное кафе', 'Ресторан местной кухни', 'Эко-кафе'], + photo: ['Панорамная площадка', 'Водопад для фото'], + history: ['Краеведческий музей'], + art: ['Галерея современного искусства'], + nightlife: ['Бар после спорта', 'Лаунж-зона'], + shopping: ['Спортивный магазин', 'Сувениры'], + sports: ['Скалолазание', 'Рафтинг', 'Велопрогулка', 'Зиплайн', 'Лыжи'], + relax: ['Баня после спорта', 'Массаж'], + adventure: ['Прыжок с тарзанки', 'Каньонинг', 'Полёт на параплане'], + local: ['Горное село', 'Местные традиции'] + } + }, + culture: { + name: 'Культурный отдых', + locations: ['Исторический центр', 'Музей искусств', 'Театр оперы', 'Старый город', 'Храм'], + activities: { + food: ['Ресторан высокой кухни', 'Кафе в старом городе', 'Гастро-тур'], + photo: ['Архитектурные шедевры', 'Исторические улочки'], + history: ['Краеведческий музей', 'Экскурсия по храмам', 'Древние руины'], + art: ['Художественный музей', 'Арт-галерея', 'Мастер-класс по искусству'], + nightlife: ['Театральная премьера', 'Джаз-клуб'], + shopping: ['Антикварный магазин', 'Книжный', 'Галерея сувениров'], + sports: ['Прогулка по городу'], + relax: ['Чайная церемония', 'Медитация в саду'], + adventure: ['Квест по городу', 'Ночная экскурсия'], + local: ['Встреча с местными', 'Традиционный ужин'] + } + }, + nature: { + name: 'Отдых на природе', + locations: ['Национальный парк', 'Озеро', 'Лесной массив', 'Горная река', 'Заповедник'], + activities: { + food: ['Пикник на природе', 'Кафе у озера', 'Эко-ресторан'], + photo: ['Животные в заповеднике', 'Пейзажи', 'Рассвет в горах'], + history: ['Музей природы', 'Эко-тропа с информацией'], + art: ['Пленэр для рисования'], + nightlife: ['Наблюдение за звёздами', 'Костёр под звёздами'], + shopping: ['Эко-лавка', 'Местные продукты'], + sports: ['Хайкинг', 'Каякинг', 'Наблюдение за птицами', 'Рыбалка'], + relax: ['Йога на природе', 'Медитация у озера'], + adventure: ['Поход с палатками', 'Сплавление по реке'], + local: ['Встреча с егерем', 'Местные легенды'] + } + } +}; + +const weatherTips = { + beach: '☀️ Лучшее время: май-сентябрь. Не забудь солнцезащитный крем и головной убор.', + active: '🏔 Погода переменчива в горах. Возьми ветровку и удобную обувь.', + culture: '🏛 Идеально в любое время года. Многие музеи работают до 20:00.', + nature: '🌲 Бери репелленты и удобную обувь. Весной и осенью особенно красиво.' +}; + +const budgetTips = { + low: '💡 Бюджет ограничен? Сосредоточься на бесплатных активностях: прогулки, пляжи, парки.', + medium: '💡 Средний бюджет позволяет добавить платные экскурсии и хорошие рестораны.', + high: '💡 С таким бюджетом можно позволить VIP-услуги и эксклюзивные впечатления.' +}; + +let currentTrip = null; +let selectedInterests = []; + +// Инициализация +document.addEventListener('DOMContentLoaded', () => { + initInterestTags(); + loadSavedTrips(); +}); + +function initInterestTags() { + const tags = document.querySelectorAll('.interest-tag'); + tags.forEach(tag => { + tag.addEventListener('click', () => { + tag.classList.toggle('selected'); + const tagName = tag.dataset.tag; + if (selectedInterests.includes(tagName)) { + selectedInterests = selectedInterests.filter(i => i !== tagName); + } else { + selectedInterests.push(tagName); + } + }); + }); +} + +function scrollToSection(id) { + document.getElementById(id).scrollIntoView({ behavior: 'smooth' }); +} + +function generateTrip() { + const budget = parseInt(document.getElementById('budget').value); + const days = parseInt(document.getElementById('days').value); + const restType = document.getElementById('restType').value; + + if (!budget || !days) { + alert('Пожалуйста, заполните бюджет и количество дней'); + return; + } + + if (selectedInterests.length === 0) { + selectedInterests = ['food', 'photo', 'relax']; + } + + currentTrip = createRoute(budget, days, restType, selectedInterests); + displayRoute(currentTrip); + document.getElementById('results').classList.remove('hidden'); + scrollToSection('results'); +} + +function createRoute(budget, days, restType, interests) { + const dest = destinations[restType]; + const trip = { + id: Date.now(), + date: new Date().toISOString(), + budget: budget, + days: days, + restType: restType, + interests: interests, + totalSpent: 0, + routeDays: [] + }; + + const budgetPerDay = Math.floor(budget / days); + const locationBudget = Math.floor(budgetPerDay * 0.4); + const foodBudget = Math.floor(budgetPerDay * 0.3); + const activityBudget = Math.floor(budgetPerDay * 0.3); + + for (let day = 1; day <= days; day++) { + const dayPlan = { + day: day, + title: `День ${day}: ${dest.locations[(day - 1) % dest.locations.length]}`, + activities: [] + }; + + const morningTime = 9 + Math.floor(Math.random() * 2); + const afternoonTime = morningTime + 3; + const eveningTime = afternoonTime + 4; + + const interest1 = interests[day % interests.length]; + const interest2 = interests[(day + 1) % interests.length]; + const interest3 = interests[(day + 2) % interests.length]; + + const morningActivity = dest.activities[interest1]?.[0] || 'Прогулка'; + const afternoonActivity = dest.activities[interest2]?.[0] || 'Обед'; + const eveningActivity = dest.activities[interest3]?.[0] || 'Отдых'; + + const morningCost = Math.floor(locationBudget * (0.8 + Math.random() * 0.4)); + const afternoonCost = Math.floor(foodBudget * (0.8 + Math.random() * 0.4)); + const eveningCost = Math.floor(activityBudget * (0.8 + Math.random() * 0.4)); + + dayPlan.activities.push({ + time: `${morningTime}:00`, + name: morningActivity, + cost: morningCost + }); + + dayPlan.activities.push({ + time: `${afternoonTime}:00`, + name: afternoonActivity, + cost: afternoonCost + }); + + dayPlan.activities.push({ + time: `${eveningTime}:00`, + name: eveningActivity, + cost: eveningCost + }); + + trip.routeDays.push(dayPlan); + trip.totalSpent += morningCost + afternoonCost + eveningCost; + } + + trip.totalSpent = Math.min(trip.totalSpent, budget); + + return trip; +} + +function displayRoute(trip) { + const dest = destinations[trip.restType]; + + document.getElementById('spentAmount').textContent = trip.totalSpent.toLocaleString(); + const remaining = trip.budget - trip.totalSpent; + document.getElementById('remainingAmount').textContent = remaining.toLocaleString(); + + const percent = Math.round((trip.totalSpent / trip.budget) * 100); + document.getElementById('budgetFill').style.width = percent + '%'; + document.getElementById('budgetPercent').textContent = percent + '% использовано'; + + let tips = '

🌤 По сезону: ' + weatherTips[trip.restType] + '

'; + tips += '

💰 По бюджету: ' + getBudgetTip(trip.budget) + '

'; + + if (trip.interests.length > 0) { + const interestNames = trip.interests.map(i => getInterestName(i)).join(', '); + tips += '

🎯 По интересам: Маршрут оптимизирован под: ' + interestNames + '

'; + } + + document.getElementById('aiTipsContent').innerHTML = tips; + + let daysHtml = '
'; + daysHtml += '

📅 Программа по дням

'; + + trip.routeDays.forEach(day => { + daysHtml += '
'; + daysHtml += '

' + day.title + '

'; + + day.activities.forEach(activity => { + daysHtml += '
'; + daysHtml += '' + activity.time + ''; + daysHtml += '' + activity.name + ''; + daysHtml += '' + activity.cost.toLocaleString() + ' ₸'; + daysHtml += '
'; + }); + + daysHtml += '
'; + }); + + daysHtml += '
'; + document.getElementById('routeDays').innerHTML = daysHtml; + + let mapPointsHtml = ''; + trip.routeDays.slice(0, 5).forEach((day, index) => { + const location = day.title.split(': ')[1] || day.title; + mapPointsHtml += ''; + mapPointsHtml += ''; + mapPointsHtml += 'День ' + day.day + ': ' + location + ''; + mapPointsHtml += ''; + }); + document.getElementById('mapPoints').innerHTML = mapPointsHtml; +} + +function getInterestName(tag) { + const names = { + food: 'Еда', photo: 'Фото', history: 'История', art: 'Искусство', + nightlife: 'Ночная жизнь', shopping: 'Шопинг', sports: 'Спорт', + relax: 'Релакс', adventure: 'Приключения', local: 'Местный колорит' + }; + return names[tag] || tag; +} + +function getBudgetTip(budget) { + if (budget < 200000) return budgetTips.low; + if (budget < 500000) return budgetTips.medium; + return budgetTips.high; +} + +function saveTrip() { + if (!currentTrip) return; + + const saved = JSON.parse(localStorage.getItem('savedTrips') || '[]'); + saved.push(currentTrip); + localStorage.setItem('savedTrips', JSON.stringify(saved)); + + alert('Маршрут сохранён!'); + loadSavedTrips(); +} + +function loadSavedTrips() { + const saved = JSON.parse(localStorage.getItem('savedTrips') || '[]'); + const container = document.getElementById('savedTripsList'); + + if (saved.length === 0) { + container.innerHTML = '

Пока нет сохранённых поездок

'; + return; + } + + let html = ''; + saved.forEach(trip => { + const date = new Date(trip.date).toLocaleDateString('ru-RU'); + const destName = destinations[trip.restType]?.name || trip.restType; + + html += '
'; + html += '
'; + html += '
'; + html += '

' + destName + '

'; + html += '

' + trip.days + ' дней • ' + trip.budget.toLocaleString() + ' ₸ • ' + date + '

'; + html += '
'; + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + html += '
'; + }); + + container.innerHTML = html; +} + +function showSavedTrips() { + scrollToSection('saved'); +} + +function loadTrip(id) { + const saved = JSON.parse(localStorage.getItem('savedTrips') || '[]'); + const trip = saved.find(t => t.id === id); + + if (trip) { + currentTrip = trip; + displayRoute(trip); + document.getElementById('results').classList.remove('hidden'); + scrollToSection('results'); + } +} + +function deleteTrip(id) { + if (!confirm('Удалить этот маршрут?')) return; + + let saved = JSON.parse(localStorage.getItem('savedTrips') || '[]'); + saved = saved.filter(t => t.id !== id); + localStorage.setItem('savedTrips', JSON.stringify(saved)); + loadSavedTrips(); +} + +function clearForm() { + document.getElementById('budget').value = ''; + document.getElementById('days').value = ''; + document.getElementById('restType').value = 'beach'; + selectedInterests = []; + document.querySelectorAll('.interest-tag').forEach(tag => tag.classList.remove('selected')); + document.getElementById('results').classList.add('hidden'); + currentTrip = null; +} + +function exportToPDF() { + if (!currentTrip) return; + + const printWindow = window.open('', '_blank'); + const content = generateExportContent('text'); + + printWindow.document.write(` + + + + AI Trip Planner - Маршрут + + + + ${content} + + + `); + + printWindow.document.close(); + printWindow.print(); +} + +function exportToText() { + if (!currentTrip) return; + + const content = generateExportContent('text'); + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = 'ai-trip-route-' + new Date().toISOString().slice(0, 10) + '.txt'; + a.click(); + + URL.revokeObjectURL(url); +} + +function generateExportContent(format) { + const dest = destinations[currentTrip.restType]; + const date = new Date(currentTrip.date).toLocaleDateString('ru-RU'); + + let content = 'AI TRIP PLANNER\n'; + content += '='.repeat(50) + '\n\n'; + content += 'Тип отдыха: ' + dest.name + '\n'; + content += 'Длительность: ' + currentTrip.days + ' дней(я)\n'; + content += 'Бюджет: ' + currentTrip.budget.toLocaleString() + ' ₸\n'; + content += 'Дата создания: ' + date + '\n'; + content += 'Интересы: ' + currentTrip.interests.map(i => getInterestName(i)).join(', ') + '\n\n'; + + content += 'БЮДЖЕТ\n'; + content += '-'.repeat(30) + '\n'; + content += 'Потрачено: ' + currentTrip.totalSpent.toLocaleString() + ' ₸\n'; + content += 'Осталось: ' + (currentTrip.budget - currentTrip.totalSpent).toLocaleString() + ' ₸\n\n'; + + content += 'МАРШРУТ\n'; + content += '-'.repeat(30) + '\n'; + + currentTrip.routeDays.forEach(day => { + content += '\n' + day.title + '\n'; + day.activities.forEach(activity => { + content += ' ' + activity.time + ' - ' + activity.name + ' (' + activity.cost.toLocaleString() + ' ₸)\n'; + }); + }); + + return content; +}