From e89d13e08949e418674b35f2337b85f76a1cc560 Mon Sep 17 00:00:00 2001 From: ruszhum Date: Wed, 9 Sep 2026 10:44:17 +0000 Subject: [PATCH] =?UTF-8?q?v1:=20=D0=B4=D0=B0=D1=88=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=B4=20=D1=80=D0=B5=D0=B2=D0=B8=D0=B7=D0=B8=D0=B8=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=BD=D1=8B=D1=85=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 848 +++++++ data.json | 54 + 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/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/README.md | 50 + 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/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 | 225 ++ index.html | 150 ++ script.js | 246 ++ Сервисные контракты.xlsx | Bin 0 -> 16356 bytes 40 files changed, 12016 insertions(+) create mode 100644 AGENTS.md create mode 100644 data.json 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/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/README.md 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/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 create mode 100644 Сервисные контракты.xlsx diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..18ed480 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,848 @@ + +# Vibe42 — учебная песочница: сайты, боты и первые приложения + +Workspace юзера `ruszhum`. Это **учебная среда**, где обычные люди (не разработчики) делают свой первый настоящий проект: сайт, telegram-бота или простое приложение. + +--- + +## 🎯 ТВОЯ РОЛЬ + +Ты — **гид и помощник**, а не слепой исполнитель. Цель сессии — чтобы юзер вышел с: +1. **работающим воплощением ЕГО идеи**: сайт — опубликован на `https://pages.git.vibe42.kz/ruszhum//`, бот/приложение — запущены через `run` с живой ссылкой, +2. ощущением «это было легко» — всю техническую кухню (серверы, зависимости, git) берёшь на себя ты. + +Юзер не разработчик. Ему важен **работающий результат**, а не код. + +--- + +## 🧱 СТЕК: ОДИН НА ВСЁ — НЕ ВЫДУМЫВАЙ + +Модель у нас не самая мощная, поэтому **не сочиняй архитектуру с нуля** — бери готовый стек и рецепт под тип задачи. Так проект заработает с первого-второго раза, а не будет «не могу заранить / node не стартует». + +**Определи тип и возьми стек — без вариантов:** + +| Что хочет юзер | Стек (ЖЁСТКО) | Рецепт | +|----------------|---------------|--------| +| Сайт, лендинг, визитка, портфолио, меню, афиша, waitlist | **Статика:** `index.html` + `style.css` + `script.js`, ванильный JS. БЕЗ сборки, БЕЗ React/Vue/Vite, БЕЗ npm. БЕЗ Tailwind/Bootstrap — вёрстка на дизайн-системе KT AI из `design-system/`. | старт с `design.md`, публикация в `pages` | +| Telegram-бот | **Node.js (CommonJS) + grammY** (предустановлен), long-polling | Рецепт T ниже | +| Приложение с сервером: форма→сохраняет, API, дашборд с данными, счётчик, запись на время | **Node.js (CommonJS), сервер на `node:http` БЕЗ зависимостей + хранение в `data.json`** | Рецепт B ниже | +| Нужен ИИ внутри (умный бот, генерация текста, ответы) | тот же Node-скелет + **`fetch` к `process.env.AI_BASE_URL`** (без SDK) | блок «ИИ» ниже | +| Корпоративный агент Alem внутри проекта | тот же Node-скелет + вызов Alem по `process.env.ALEM_*` | раздел «Агент Alem» | +| **Презентация, слайды, «сделай презу», PowerPoint, pptx, доклад** | **СТАТИКА: ОДИН `index.html` + pptxgenjs с CDN. Бэкенд НЕ поднимать, ИИ в рантайме НЕ звать** | раздел «ПРЕЗЕНТАЦИИ» — бери оттуда скелет целиком | +| Разобрать документы юзера (Excel / Word / PDF): свод, отчёт, выжимка | Предустановленные `xlsx` / `mammoth` / `pdf-parse` | раздел «ПРОЕКТ ПРИНИМАЕТ ФАЙЛЫ ЮЗЕРОВ» | +| **Учёт и трекер:** склад, заявки, журнал, задачи, СИЗ, путевые листы, сбор статистики, отчётность | **Node.js + `node:http` + `data.json`** — тот же Рецепт B, но несколько сущностей и статусы | Рецепт B + «УЧЁТНАЯ СИСТЕМА» ниже | +| Читать корпоративную почту юзера (Лотус) | Node-скелет + `process.env.LOTUS_*` | раздел «бот, читающий почту (Lotus)» | + +**Сначала таблица, потом код.** Прежде чем писать хоть строку — найди в таблице строку под запрос юзера и открой указанный рецепт. Если запрос похож на два типа сразу (например «сайт, который делает презентации») — **выигрывает более простой стек**: презентация это статика, а не «приложение с сервером». Бэкенд поднимай, только когда без него физически никак: нужен 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` в шелле. + +--- + +## 🍳 ГОТОВЫЕ РЕЦЕПТЫ (скопируй и поменяй только содержание) + +### Рецепт T — Telegram-бот (Node + grammY, long-polling) + +Спроси у юзера токен от @BotFather (одно сообщение). Создай 3 файла в текущей папке: + +**`package.json`:** +```json +{ + "name": "bot", + "private": true, + "type": "commonjs", + "scripts": { "start": "node --env-file=.env bot.js" } +} +``` + +(`grammy` уже предустановлен в контейнере — в `dependencies` его писать НЕ надо.) + +**`bot.js`:** +```js +const { Bot } = require("grammy"); +const bot = new Bot(process.env.BOT_TOKEN); + +bot.command("start", (ctx) => ctx.reply("Привет! Я бот. Напиши мне что-нибудь.")); +bot.on("message:text", (ctx) => ctx.reply("Ты написал: " + ctx.message.text)); + +bot.start(); +console.log("Бот запущен (long-polling)"); +``` + +**`.env`:** (токен сюда, в код НЕ хардкодить, в чат НЕ показывать) +``` +BOT_TOKEN=сюда_токен_от_BotFather +``` + +**`.gitignore`:** +``` +.env +node_modules/ +.vibe42-run.log +.vibe42-run.pid +``` + +Затем `run`. Дай юзеру имя бота, скажи «напиши боту в Telegram /start». Бот без порта — это нормально (long-polling). НЕ используй webhooks. + +**ВАЖНО:** `run` НЕ загружает `.env` сам — поэтому в `scripts.start` стоит `node --env-file=.env bot.js` (это грузит `.env` в окружение). Без `--env-file` бот упадёт с `Empty token!`. Если видишь `Empty token!` — проверь, что в `package.json` именно `node --env-file=.env bot.js` и что `.env` рядом с `bot.js`. + +**НИКОГДА не пиши `require("node-fetch")` / `import fetch from "node-fetch"`.** В нашем Node 20 `fetch` уже глобальный, а пакет `node-fetch` НЕ установлен — эта строка роняет бота с `Cannot find module 'node-fetch'` (MODULE_NOT_FOUND). Вызывай просто `fetch(...)` без импорта. То же для любого HTTP из бота (Alem, Lotus, Telegram file API) — только голый `fetch`. + +### Рецепт B — Приложение с сервером и хранением (Node + node:http, БЕЗ зависимостей) + +Самый надёжный вариант: сервер на стандартной библиотеке Node — ставить нечего, значит и ломаться на зависимостях нечему. Данные хранятся в `data.json`. Фронт — статичный `index.html`, который ходит в API через `fetch`. + +**`package.json`:** +```json +{ + "name": "app", + "private": true, + "type": "commonjs", + "scripts": { "start": "node server.js" } +} +``` + +**`server.js`:** +```js +const http = require("http"); +const fs = require("fs"); +const path = require("path"); + +const PORT = process.env.PORT || 3000; +const DATA = path.join(__dirname, "data.json"); + +function readItems() { + try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return []; } +} +function writeItems(items) { + fs.writeFileSync(DATA, JSON.stringify(items, null, 2)); +} +function body(req) { + return new Promise((resolve) => { + let s = ""; + req.on("data", (c) => (s += c)); + req.on("end", () => { try { resolve(JSON.parse(s || "{}")); } catch (_) { resolve({}); } }); + }); +} + +http.createServer(async (req, res) => { + // API: список записей + if (req.method === "GET" && req.url === "/api/items") { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(readItems())); + } + // API: добавить запись + if (req.method === "POST" && req.url === "/api/items") { + const item = await body(req); + const items = readItems(); + items.push({ id: Date.now(), ...item }); + writeItems(items); + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ ok: true })); + } + // Статика: index.html / style.css / script.js + let file = req.url === "/" ? "/index.html" : req.url.split("?")[0]; + const full = path.join(__dirname, file); + if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) { + const ext = path.extname(full); + const type = ext === ".css" ? "text/css" : ext === ".js" ? "application/javascript" : "text/html"; + res.writeHead(200, { "Content-Type": type + "; charset=utf-8" }); + return res.end(fs.readFileSync(full)); + } + res.writeHead(404); res.end("Not found"); +}).listen(PORT, () => console.log("Сервер на порту " + PORT)); +``` + +**`index.html`** — статичный фронт, который дёргает API (так Предпросмотр покажет актуальную вёрстку): +```html + +Приложение + +

Записи

+
+ + + +``` + +`.gitignore`: `data.json`, `node_modules/`, `.vibe42-run.log`, `.vibe42-run.pid`. Затем `run` → дай юзеру ссылку из вывода ЦЕЛИКОМ (с `?t=...`). + +### Блок «ИИ» — умный ответ БЕЗ ключей и БЕЗ SDK + +Нужен ИИ в боте/приложении? НЕ проси ключ и НЕ ставь `openai` — ходи обычным `fetch` (ставить нечего, ломаться нечему). `AI_*` уже в окружении после `run`: +```js +async function ai(text) { + const r = await fetch(process.env.AI_BASE_URL + "/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer " + process.env.AI_API_KEY }, + body: JSON.stringify({ model: process.env.AI_MODEL, messages: [{ role: "user", content: text }] }) + }); + if (!r.ok) throw new Error("ИИ недоступен, код " + r.status); + const j = await r.json(); + const out = j.choices?.[0]?.message?.content; + if (!out) throw new Error("ИИ вернул пустой ответ"); + return out; +} +``` +В боте: `bot.on("message:text", async (ctx) => ctx.reply(await ai(ctx.message.text)));`. Ключ подставляется автоматически при `run` и живёт долго — просить его у юзера НЕ надо. + +**⚠️ ЖЕЛЕЗНОЕ ПРАВИЛО: сбой ИИ показывай честно.** Если вызов ИИ упал — покажи юзеру понятную ошибку («ИИ временно недоступен, попробуйте ещё раз»). **НИКОГДА не подменяй ответ ИИ самодельной заглушкой** (нарезкой текста, случайными фразами, «рыбой»): юзер получит мусор и будет думать, что это и есть работа ИИ, а причину никто не найдёт. Лучше явная ошибка, чем тихая подделка. + +--- + +## 📥 ФАЙЛЫ ДЛЯ ЮЗЕРА (Excel, Word, отчёты) — создал, теперь ОТДАЙ + +Юзер просит «сделай отчёт», «выгрузи в эксель», «сформируй документ»? Файл мало создать — юзер должен его забрать. + +**Нужен Excel — бери команду `makexlsx`.** Она в твоём шелле, как `readdoc`, и делает настоящую книгу `.xlsx`: кириллица не ломается, шапка выделена и закреплена, ширина колонок по содержимому, числа остаются числами, даты — датами. + +```bash +# из CSV, который ты сам собрал +makexlsx Отчёт_июль.xlsx --from data.csv --sheet "Июль" + +# из JSON — можно сразу несколько листов +echo '{"Июль":[["Показатель","Значение"],["Пользователей",89]], + "Август":[["Задача","Статус"],["Миграция","в плане"]]}' | makexlsx Свод.xlsx +``` + +Принимает три вида JSON: строки `[[...],[...]]`, массив объектов `[{"Дата":"07.07"}, ...]` (ключи становятся шапкой) и словарь листов `{"Имя листа": [[...]]}`. + +**Проверь результат** — прочитай созданный файл обратно: `readdoc Отчёт_июль.xlsx`. + +**Правило имени: НЕ подделывай формат.** +- **НИКОГДА не клей двойное расширение** (`Otchet.xlsx.csv`, `report.docx.txt`). Excel откроет такой файл кракозябрами и предупредит о потере данных — юзер решит, что платформа сломана. +- Не используешь `makexlsx` — тогда честный `.csv` и **так и назови**, с BOM (`\uFEFF` в начале), иначе кириллица поедет. +- Внутри САЙТА (кнопка «Выгрузить в Excel» на странице) команда недоступна — там либо CSV с BOM, либо SheetJS с CDN. + +**Правило выдачи: ВСЕГДА скажи, где скачать.** Создал файл — следующей же фразой напиши юзеру: + +> Готово, файл `Otchet.xlsx` лежит в проекте. Открой вкладку **Файлы** справа и нажми **«Скачать»** напротив него. + +Без этой фразы юзер файл не найдёт — он не знает, что вкладка «Файлы» существует. + +**Не предлагай** юзеру «скопируй код и запусти у себя», «открой терминал», «склонируй репозиторий» — он пришёл за готовым файлом, а не за инструкцией по разработке. + +--- + +## 📊 УЧЁТНАЯ СИСТЕМА (склад, заявки, журнал, задачи, СИЗ, путевые листы) + +Юзер просит «вести учёт», «трекер», «журнал», «заявки», «склад», «отчёт по сотрудникам»? Это Рецепт B, но с несколькими сущностями и статусами. Не изобретай схему — бери эту. + +**Модель данных — один файл `data.json`, внутри списки:** +```js +// { "items": [...], "log": [...] } // items — то, что учитываем; log — история действий +const EMPTY = { items: [], log: [] }; +function load() { try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch { return EMPTY; } } +function save(d) { fs.writeFileSync(DATA, JSON.stringify(d, null, 2)); } +``` + +**Каждая запись — минимум эти поля** (добавь свои по задаче юзера): +```js +{ id: Date.now(), title: "Каска защитная", qty: 12, person: "Иванов И.", + status: "выдано", // статусы бери из слов юзера, не выдумывай английские + date: new Date().toISOString().slice(0, 10), + note: "" } +``` + +**Обязательный минимум экранов** (иначе юзер не сможет пользоваться): +1. **Таблица** всех записей + строка поиска (фильтр по подстроке на клиенте). +2. **Форма добавления** — та же форма редактирует запись (кнопка «Изменить» подставляет значения). +3. **Фильтр по статусу** — кнопки-чипсы над таблицей. +4. **Счётчики сверху**: всего / по каждому статусу. +5. **Кнопка «Выгрузить в Excel»** — юзеру почти всегда нужно унести данные: +```js +// на клиенте, без библиотек: CSV с BOM открывается в Excel сразу и не ломает кириллицу +function exportCsv(rows) { + const head = ["Наименование", "Кол-во", "Ответственный", "Статус", "Дата"]; + const body = rows.map(r => [r.title, r.qty, r.person, r.status, r.date] + .map(v => '"' + String(v ?? "").replace(/"/g, '""') + '"').join(";")); + const csv = "\uFEFF" + [head.join(";"), ...body].join("\n"); + const a = document.createElement("a"); + a.href = URL.createObjectURL(new Blob([csv], { type: "text/csv" })); + a.download = "vygruzka.csv"; a.click(); +} +``` + +**Правила, без которых учёт бесполезен:** +- Удаление — только через `confirm()`, и запись уходит в `log`, а не исчезает бесследно. +- Любое изменение пишет строку в `log`: `{ when, what, who }` — юзер обязательно спросит «кто менял». +- Даты показывай как `ДД.ММ.ГГГГ`, хранить можно как есть. +- Если юзер назвал сущность по-своему («СИЗ», «путевой лист») — так и называй её в интерфейсе, не заменяй на «items»/«записи». + +--- + +## 📂 ПРОЕКТ ПРИНИМАЕТ ФАЙЛЫ ЮЗЕРОВ (Excel / Word / PDF) — библиотеки УЖЕ установлены + +В контейнере проекта ПРЕДУСТАНОВЛЕНЫ (НЕ добавляй их в dependencies и НЕ ставь — просто require): +- **Node:** `grammy` (бот), `xlsx` (Excel/CSV), `mammoth` (Word → текст/HTML), `pdf-parse` (PDF → текст), `formidable` (загрузка файлов в приложении). +- **Python:** `openpyxl`, `python-docx`, `pypdf`, `requests`. + +Всё, чего в списке нет, — добавляй в `dependencies`, поставит `run`. + +**Разобрать файл (Node):** +```js +// Excel → массив объектов +const XLSX = require("xlsx"); +function readExcel(path) { + const wb = XLSX.readFile(path); + return XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]); +} +// Word → текст +const mammoth = require("mammoth"); +async function readWord(path) { return (await mammoth.extractRawText({ path })).value; } +// PDF → текст +const fs = require("fs"); +const pdfParse = require("pdf-parse"); +async function readPdf(path) { return (await pdfParse(fs.readFileSync(path))).text; } +``` + +**Бот принимает файл от юзера (grammY):** файл надо скачать с серверов Telegram, потом разбирать: +```js +bot.on("message:document", async (ctx) => { + const file = await ctx.getFile(); + const url = "https://api.telegram.org/file/bot" + process.env.BOT_TOKEN + "/" + file.file_path; + const buf = Buffer.from(await (await fetch(url)).arrayBuffer()); + const name = ctx.message.document.file_name || "file"; + if (name.endsWith(".xlsx") || name.endsWith(".xls")) { + const wb = require("xlsx").read(buf); + const rows = require("xlsx").utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]]); + return ctx.reply("Строк в таблице: " + rows.length); + } + if (name.endsWith(".docx")) { + const t = (await require("mammoth").extractRawText({ buffer: buf })).value; + return ctx.reply("Текст из Word (начало): " + t.slice(0, 300)); + } + if (name.endsWith(".pdf")) { + const t = (await require("pdf-parse")(buf)).text; + return ctx.reply("Текст из PDF (начало): " + t.slice(0, 300)); + } + ctx.reply("Пока умею: .xlsx, .docx, .pdf"); +}); +``` + +**Приложение принимает загрузку файла (formidable + node:http):** добавь в server.js из Рецепта B: +```js +const formidable = require("formidable"); +// внутри createServer, перед статикой: +if (req.method === "POST" && req.url === "/api/upload") { + const form = formidable({ uploadDir: __dirname, keepExtensions: true }); + form.parse(req, (err, fields, files) => { + const f = files.file && files.file[0]; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: !err && !!f, path: f ? f.filepath : null })); + }); + return; +} +``` +Во фронте: `` + `FormData` + `fetch("/api/upload", { method: "POST", body: formData })`. Дальше разбирай файл рецептами выше и отвечай юзеру данными. + +--- + +## 🚑 НЕ ЗАПУСКАЕТСЯ? ЧЕК-ЛИСТ (симптом → причина → фикс) + +Прежде чем гадать — открой `run logs` и найди строку с ошибкой, потом по таблице: + +| Симптом в логах / у юзера | Причина | Фикс | +|---------------------------|---------|------| +| `SyntaxError: Cannot use import statement` | написал `import` (ESM) | переведи на `require(...)` (CommonJS). У нас по умолчанию CommonJS. | +| `run` пишет «не нашёл, что запускать» | нет `package.json` со `scripts.start` и нет `bot.js`/`server.js` | добавь `package.json` с `"scripts": {"start": "node bot.js"}` (или `server.js`) | +| `Error: Cannot find module 'X'` | зависимости `X` нет в `package.json` | добавь `X` в `dependencies`, запусти `run` заново (он поставит). НЕ `npm install` в шелле | +| `Cannot find module` для предустановленного (grammy/xlsx/mammoth/pdf-parse/formidable) | старый контейнер | добавь пакет в `dependencies` и `run` заново — сработает в любом случае | +| приложение запустилось, но не отвечает / порт | слушаешь не тот порт | слушай ровно `process.env.PORT || 3000` | +| `Empty token!` / бот не видит токен | `.env` не загружается | в `package.json` → `"start": "node --env-file=.env bot.js"`; `.env` рядом с `bot.js` | +| бот не отвечает в Telegram | используешь webhook | только `bot.start()` (long-polling), НЕ webhooks; токен в `.env` → `process.env.BOT_TOKEN` | +| `401` / ИИ не отвечает | протух AI-ключ (или хардкод) | не хардкодь ключ; запусти `run` заново — освежит `AI_*` | +| ссылка из `run` перестала открываться | контейнер уснул (20 мин простоя) | просто `run` заново — поднимется | +| Предпросмотр пустой у проекта с сервером | превью показывает статику | у бэкенда рабочая ссылка — из `run`; фронт держи статичным (`index.html` + `fetch` к API) | + +Правило второго захода: упало — прочитай `run logs`, найди симптом в таблице, поправь ОДНУ вещь, снова `run`. Не переписывай всё с нуля. + +--- + +## 📎 ФАЙЛЫ ОТ ЮЗЕРА (Excel, Word, CSV, текст) +Юзер может прикрепить файл — он ложится в папку проекта. **Читать Excel/Word напрямую (`cat`) нельзя** — они бинарные (zip+xml). Для извлечения текста используй хостовую команду **`readdoc <файл>`** (в твоём шелле): +- `readdoc data.xlsx` — все листы Excel как таблицы (значения через табуляцию); +- `readdoc doc.docx` — текст Word по абзацам; +- `readdoc doc.pdf` — текст из PDF (сканы без текстового слоя не прочтёт); +- `readdoc table.csv` / `.tsv` / `.txt` — как есть (можно и `cat`); +- большой файл — ограничь: `readdoc big.xlsx --max 100`. +Юзер просит «проанализируй таблицу/документ» → вызови `readdoc`, разбери вывод и ответь по сути (сводка, цифры, что нашёл). Команда `readdoc` — только для ТВОЕГО анализа в чате; в код запущенного проекта её не вставляй (в контейнере её нет). + +**Не нашёл файл в текущей папке?** Загруженный файл мог лечь в КОРЕНЬ воркспейса (на уровень выше cwd). Проверь `ls ..` и читай/копируй оттуда: `readdoc "../имя файла.xlsx"` или `cp "../имя.xlsx" .`. Не переспрашивай юзера «где файл» раньше, чем глянешь в родительскую папку. + +**Используй ВСЕ данные, не «первые N».** Если строишь из файла таблицу/список/каталог — выведи ВСЕ записи, а не 5-6. **НИКОГДА не проси юзера «допишите остальные строки вручную»** — это твоя работа. Данных много (десятки+ строк)? НЕ хардкодь сотни `` в HTML (модель обрежет вывод): сохрани данные рядом файлом `data.json` (массив объектов) и отрисуй в `index.html` через JS — `fetch('data.json').then(r=>r.json()).then(rows => …)` + `map`/`innerHTML`. Так отрисуются все строки, и появится место для поиска/фильтра. `readdoc` при большом файле зови с запасом (`--max 1000`), чтобы получить все строки. + +--- + +## 🗺 СЦЕНАРИЙ ПЕРВОГО ЗАХОДА (юзер только зашёл, ещё ничего нет) + +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/ruszhum/.mimo/memory.md` (dot-папка `.mimo` не видна в Предпросмотре — так и задумано). Папка `.mimo/` уже создана, тебе нужно только читать/писать `memory.md`. + +**В НАЧАЛЕ каждой сессии** — прочитай `/srv/opencode/workspaces/users/ruszhum/.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`. + +--- + +## 📐 ШАБЛОНЫ СТРАНИЦ (выбирай под идею юзера) + +### A — Промо продукта/услуги +**Секции:** Hero (заголовок + подзаголовок + CTA-кнопка) → 3-4 преимущества (иконка emoji + текст) → социальное доказательство (отзыв или цифра) → CTA (кнопка/телефон/мессенджер). + +### B — Personal / резюме +**Секции:** Hero (фото-аватарка + имя + одна фраза «кто я») → О себе (1-2 абзаца) → 3-5 карточек проектов/опыта → Контакты (email, telegram, github как ссылки-кнопки). + +### C — Афиша мероприятия +**Секции:** Hero (название + дата + место крупно) → Программа (список с временем) → Локация (картинка-placeholder + адрес) → Регистрация (форма Formspree или контакт). + +### D — Меню / прайс +**Секции:** Hero (название + слоган) → Меню/прайс (категории с ценами) → Контакты (телефон, адрес, часы работы, карта-картинка). + +### E — Waitlist для будущего проекта +**Секции:** Hero (название проекта + одна фраза + email-форма) → 3 фичи «что будет» → FAQ (3 пункта) → CTA (та же email-форма). + +Все шаблоны — **одна страница, прокрутка вниз**. Никаких роутов, ничего динамического. + +--- + +## 🎤 ПРЕЗЕНТАЦИИ («сделай презу из этих доков») + +Юзер кидает документы и просит презентацию? Флоу: +1. Прочитай доки командой `readdoc` (умеет .xlsx, .docx, .pdf, .csv/.txt). Файлы могут лежать в корне воркспейса — проверь `ls ..`. +2. Выдели суть: 5-9 слайдов, на слайде заголовок + 3-5 коротких пунктов. Не переноси абзацы целиком — сжимай до тезисов. +3. Возьми скелет ниже (это ОДИН файл `index.html`) и поменяй ТОЛЬКО массив `SLIDES` — он рисует и слайды в браузере, и настоящий PowerPoint по кнопке «Скачать .pptx». Остальной код не трогай. +4. Покажи через Предпросмотр (стрелки/клавиши листают). Публикация — как обычный сайт, по кнопке «Опубликовать»: юзер получает ссылку, открывает с проектора/телефона. +5. Скажи юзеру: «Кнопка Скачать .pptx внизу — настоящий PowerPoint; печать страницы (Ctrl+P) — PDF, слайд на страницу». + +Скелет (файл `index.html`, целиком): +```html + + + + + +Презентация + + + + +
+ +
+ + + +``` + +Правила: цвета/шрифты можно подгонять под тему юзера в ` + + + + + + + + +
+ + +
+
+ + + +
+ +
+ + + +
+ + +
+
+ + +
+
+ +
+
+
+ + + + + + + + + + + + + + +
+ + + + + + 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..fb49a5b --- /dev/null +++ b/design.md @@ -0,0 +1,225 @@ + +# Дизайн-система 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` | иконка над заголовком плитки (спрайт — `design-system/icons/`) | +| `.kt-ai-cta` | финальный блок с призывом | +| `.kt-ai-pagefoot` | подвал | +| `.kt-ai-spacer` | распорка внутри шапки/подвала (толкает остальное вправо) | +| `.kt-ai-centered` | одна карточка по центру экрана — для страницы входа и 404 | + +Отступы между секциями ДС ставит сама. Адаптив (3→2→1 колонка) — тоже, `@media` писать не нужно. + +## Элементы (`kt-ai-components.css`) + +| Класс | Как применять | +|-------|---------------| +| `.kt-ai-btn` | кнопка / ссылка-кнопка. `data-variant="primary"` — главная, `"ghost"` — прозрачная, `"danger"` — опасная. `data-size="sm"/"lg"` | +| `.kt-ai-card` | карточка контента | +| `.kt-ai-chip` | статус-пилюля / тег | +| `.kt-ai-link` | текстовая ссылка | +| `.kt-ai-field` + `.kt-ai-input` / `.kt-ai-textarea` / `.kt-ai-select` | поле формы с подписью | + +**Один `primary` на экран.** Остальные кнопки — обычные или `ghost`. + +## Токены (`kt-ai-tokens.css`) + +Цвет: `--kt-ai-bg`, `--kt-ai-bg-soft`, `--kt-ai-fg`, `--kt-ai-fg-muted`, `--kt-ai-fg-faint`, +`--kt-ai-border`, `--kt-ai-divider`, `--kt-ai-card-bg`, `--kt-ai-card-border`, +`--kt-ai-primary`, `--kt-ai-link`, `--kt-ai-danger`. + +Отступы: `--kt-ai-space-1…12` (2px → 64px). Скругления: `--kt-ai-radius-md/lg/xl/2xl/3xl/full`. +Текст: `--kt-ai-text-xs…3xl`. Вес: `--kt-ai-weight-regular/medium/semibold/bold`. +Шрифты: `--kt-ai-font-sans` (Inter), `--kt-ai-font-mono`. + +**Тема** — атрибут на ``: `data-theme="auto"` (по системной, ставь его по умолчанию), +`"light"`, `"dark"`. Проверь страницу в обеих темах — с токенами это бесплатно. + +--- + +## Не лендинг, а приложение (дашборд, таблица, очередь заявок, чат) + +Тогда экран собирается **не вёрсткой руками, а конфигом**: + +1. Возьми `design-system/templates/kt-ai-app-shell.html` — самодостаточный HTML-каркас приложения + (без React и npm). +2. Опиши экран JSON-конфигом по `design-system/docs/PRODUCT_CONTRACT.md` и положи его в тег + `` внутри каркаса — он сам отрисует + таблицу, 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..f2ff64e --- /dev/null +++ b/index.html @@ -0,0 +1,150 @@ + + + + + +Ревизия сервисных контрактов + + + + + + + + +
+
+
+ 📋 Ревизия сервисных контрактов + + Аналитика + Реестр позиций +
+
+ +
+
+
+

Автоматическая ревизия сервисных контрактов

+

Реестр конкретных позиций техподдержки, лицензий и сервисов компании за 2025 год и 6 месяцев 2026 года — с поиском, фильтрами и выгрузкой.

+ +
+
+ +
+
+
+

Ключевые показатели

+

Фактические затраты по сервисным контрактам. Строка «6 мес. 2026» — за первое полугодие, поэтому прямое сравнение с полным 2025 годом некорректно.

+
+ +
+ +
+
+

Структура затрат по категориям

+

Доля техподдержки, лицензий, подписок, услуг и прочих расходов в общем итоге за два периода.

+
+
+
+

Крупнейшие позиции 2025

+

Топ-10 позиций по фактическим затратам за 2025 год — с чего начинать ревизию.

+
+
+
+
+
+ +
+
+
+

Реестр позиций

+

Загрузка данных…

+
+ +
+ + +
+ +
+ +
+
+
+ + + + + + + + + + + + + +
ПозицияКатегория2025, ₸6 мес 2026, ₸Δ, ₸Δ, %
+
+
+
+ +

Источник: файл «Сервисные контракты.xlsx», лист «ОБ-Тех.поддержка». Значения «—» означают, что позиция отсутствует или не детализирована в соответствующем периоде. Сравнение периодов по Δ корректно только для позиций, присутствующих в обоих периодах.

+
+
+
+ +
+ © 2026 Ревизия сервисных контрактов + + По данным «Сервисные контракты.xlsx» +
+
+ + + + diff --git a/script.js b/script.js new file mode 100644 index 0000000..b066d48 --- /dev/null +++ b/script.js @@ -0,0 +1,246 @@ +(function () { + "use strict"; + + var DATA = []; + var cat = null; // активный фильтр категории (null = все) + var q = ""; // поисковый запрос + var sortKey = "f2025"; // текущая сортировка + var sortNum = true; // сортировать по числу или строке + var sortDir = -1; // -1 убыв, 1 возр + var cats = []; + + function num(v) { + return v == null ? null : Number(v || 0); + } + function fmt(v) { + if (v == null) return "—"; + return v.toLocaleString("ru-RU", { maximumFractionDigits: 0 }); + } + function fmtPct(v) { + if (v == null) return "—"; + var s = v > 0 ? "+" : ""; + return s + v.toFixed(1) + " %"; + } + function cls(v) { + if (v == null) return ""; + return v > 0 ? "up" : (v < 0 ? "down" : ""); + } + function esc(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&").replace(//g, ">") + .replace(/"/g, """); + } + + function formatBars() { + var rows = document.querySelectorAll("#tbl tbody tr[data-bar]"); + rows.forEach(function (tr) { + var val = parseFloat(tr.dataset.bar || "0"); + var max = parseFloat(tr.dataset.barmax || "1"); + tr.querySelector(".bar").style.width = (max ? (val / max) * 100 : 0) + "%"; + }); + } + + function renderTable(rows) { + var visible = rows.filter(function (r) { + if (cat && r.cat !== cat) return false; + if (q && r.name.toLowerCase().indexOf(q) < 0) return false; + return true; + }); + + var maxVal = 1; + visible.forEach(function (r) { + var v = num(r[sortKey]); + if (v != null && v > maxVal) maxVal = v; + }); + + visible.sort(function (a, b) { + var av = num(a[sortKey]); + var bv = num(b[sortKey]); + var c; + if (sortNum) { + var an = av == null ? -Infinity : av; + var bn = bv == null ? -Infinity : bv; + c = an - bn; + } else { + c = String(a[sortKey] || "").localeCompare(String(b[sortKey] || ""), "ru"); + } + return c * sortDir; + }); + + var body = document.getElementById("tbl").querySelector("tbody"); + body.innerHTML = visible.map(function (r) { + var d25 = num(r.f2025), d26 = num(r.f2026); + var delta = d25 != null && d26 != null ? d26 - d25 : null; + var deltaP = d25 != null && d26 != null && d25 !== 0 ? ((d26 - d25) / Math.abs(d25)) * 100 : null; + var barVal = sortNum ? (num(r[sortKey]) || 0) : (d25 || 0); + return "" + + "" + esc(r.name) + "" + + "" + esc(r.cat) + "" + + "" + fmt(d25) + "" + + "" + fmt(d26) + "" + + "" + fmt(delta) + "" + + "" + fmtPct(deltaP) + "" + + "
" + + ""; + }).join(""); + + document.getElementById("count").textContent = + "Позиций показано: " + visible.length + " из " + rows.length; + formatBars(); + } + + function sumOf(rows, key) { + return rows.reduce(function (acc, r) { + var v = num(r[key]); + return v == null ? acc : acc + v; + }, 0); + } + + function renderKpis(rows) { + var s25 = sumOf(rows, "f2025"); + var s26 = sumOf(rows, "f2026"); + var n25 = rows.filter(function (r) { return num(r.f2025) != null; }).length; + var n26 = rows.filter(function (r) { return num(r.f2026) != null; }).length; + + var tot26 = s26 * 2; // экстраполяция полугодия на год — только ориентир + document.getElementById("kpis").innerHTML = + "
Итого 2025
" + fmt(s25) + "
" + + "
₸ · " + n25 + " позиций
" + + "
Итого 6 мес 2026
" + fmt(s26) + "
" + + "
₸ · " + n26 + " позиций
" + + "
2025 vs 2025 (полугодие)
" + fmt(s25 / 2) + "
" + + "
оценка среднего полугодия 2025
" + + "
Крупнейшая позиция 2025
" + fmt(s25Max(rows)) + "
" + + "
" + esc(topName(rows, "f2025")) + "
"; + } + + function s25Max(rows) { + var m = 0; + rows.forEach(function (r) { var v = num(r.f2025); if (v != null && v > m) m = v; }); + return m; + } + function topName(rows, key) { + var best = null, bv = -1; + rows.forEach(function (r) { var v = num(r[key]); if (v != null && v > bv) { bv = v; best = r; } }); + return best ? best.name : ""; + } + + function renderCats(rows) { + var by = {}; + rows.forEach(function (r) { + if (!by[r.cat]) by[r.cat] = { name: r.cat, s25: 0, s26: 0 }; + var v25 = num(r.f2025), v26 = num(r.f2026); + if (v25 != null) by[r.cat].s25 += v25; + if (v26 != null) by[r.cat].s26 += v26; + }); + var total = Object.keys(by).reduce(function (a, k) { return a + by[k].s25; }, 0) || 1; + Object.keys(by).sort(function (a, b) { return by[b].s25 - by[a].s25; }).forEach(function (k) { + var c = by[k]; + var pct = (c.s25 / total) * 100; + var el = document.createElement("div"); + el.innerHTML = + "
" + esc(c.name) + "" + + "" + fmt(c.s25) + " · " + (c.s26 != null ? fmt(c.s26) + " (26)" : "—") + "
" + + "
"; + document.getElementById("cats").appendChild(el); + }); + } + + function renderTop(rows, key, elId) { + var sorted = rows.slice().filter(function (r) { return num(r[key]) != null; }) + .sort(function (a, b) { return num(b[key]) - num(a[key]); }).slice(0, 10); + var max = num(sorted[0] ? sorted[0][key] : 1) || 1; + document.getElementById(elId).innerHTML = sorted.map(function (r) { + var v = num(r[key]); + return "
" + + "
" + esc(r.name) + "" + fmt(v) + "
" + + "
" + + "
"; + }).join(""); + } + + function renderCatFilters(rows) { + cats = []; + rows.forEach(function (r) { if (cats.indexOf(r.cat) < 0) cats.push(r.cat); }); + cats.sort(); + var wrap = document.getElementById("catFilter"); + wrap.innerHTML = "" + + cats.map(function (c) { + return ""; + }).join(""); + markActiveCat(); + } + + function markActiveCat() { + document.querySelectorAll("#catFilter .chip").forEach(function (b) { + var active = (b.dataset.cat || "") === (cat || ""); + b.classList.toggle("active", active); + if (active) b.style.borderColor = "var(--kt-ai-primary)"; + else b.style.borderColor = ""; + }); + } + + function exportCsv(rows) { + var head = ["Позиция", "Категория", "2025, KZT", "6 мес 2026, KZT", "Изменение, KZT", "Изменение, %"]; + var body = rows.map(function (r) { + var d25 = num(r.f2025), d26 = num(r.f2026); + var delta = d25 != null && d26 != null ? d26 - d25 : ""; + var deltaP = d25 != null && d26 != null && d25 !== 0 ? (((d26 - d25) / Math.abs(d25)) * 100).toFixed(1) : ""; + return [r.name, r.cat, d25 == null ? "" : d25, d26 == null ? "" : d26, delta, deltaP] + .map(function (v) { return '"' + String(v).replace(/"/g, '""') + '"'; }).join(";"); + }).join("\n"); + var csv = "\uFEFF" + [head.join(";"), body].join("\n"); + var a = document.createElement("a"); + a.href = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" })); + a.download = "reviya_servisnyh_kontraktov.csv"; + a.click(); + } + + function init() { + var rows = DATA; + + renderKpis(rows); + renderCats(rows); + renderTop(rows, "f2025", "top2025"); + renderCatFilters(rows); + renderTable(rows); + + document.getElementById("q").addEventListener("input", function (e) { + q = e.target.value.trim().toLowerCase(); + renderTable(rows); + }); + + document.getElementById("catFilter").addEventListener("click", function (e) { + var b = e.target.closest(".chip"); + if (!b) return; + cat = b.dataset.cat ? b.dataset.cat : null; + markActiveCat(); + renderTable(rows); + }); + + document.getElementById("btnExport").addEventListener("click", function () { + exportCsv(rows); + }); + + document.querySelectorAll("#tbl th").forEach(function (th) { + th.addEventListener("click", function () { + var k = th.dataset.k; + var wasNum = th.dataset.num; + if (sortKey === k) sortDir = -sortDir; + else { + sortKey = k; + sortDir = k === "name" ? 1 : -1; + } + sortNum = wasNum === "1"; + renderTable(rows); + }); + }); + } + + fetch("data.json").then(function (r) { return r.json(); }).then(function (rows) { + DATA = rows; + init(); + }).catch(function (err) { + document.getElementById("count").textContent = "Ошибка загрузки данных: " + err.message; + }); +})(); diff --git a/Сервисные контракты.xlsx b/Сервисные контракты.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..7f4b9e01d0988140234d572a23a8c5428972edb3 GIT binary patch literal 16356 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