From 484b9793c48cc6e3621dd3adf0f45d8411fa8f89 Mon Sep 17 00:00:00 2001 From: olzhasus Date: Thu, 17 Sep 2026 12:46:06 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=BE=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 319 +++ 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 | 7 + .../brand/kazakhtelecom-logo-white.png | Bin 0 -> 3803 bytes design-system/brand/kazakhtelecom-logo.png | Bin 0 -> 3756 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 | 41 + 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 | 258 ++ index.html | 263 +++ js/aiAdapter.js | 217 ++ js/app.js | 434 ++++ js/data.js | 167 ++ js/logic.js | 165 ++ js/storage.js | 92 + js/ui.js | 323 +++ style.css | 933 ++++++++ 50 files changed, 13713 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-white.png 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/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 js/aiAdapter.js create mode 100644 js/app.js create mode 100644 js/data.js create mode 100644 js/logic.js create mode 100644 js/storage.js create mode 100644 js/ui.js create mode 100644 style.css diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fd98802 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,319 @@ + +# 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)» | +| **Данные из ClickHouse:** отчёт, аналитика, дашборд, «посчитай по базе», выгрузка, динамика | ИИ считает командой `ch` (только чтение) и отвечает цифрами; дашборд — Рецепт B, сервер ходит в `process.env.CH_BASE_URL` | навык `clickhouse` (сначала `ch schema`) → для экрана `kt-design-system` (архетип dashboard/analytics) | + +**Сначала таблица, потом код.** Прежде чем писать хоть строку — найди в таблице строку под запрос юзера и загрузи указанный навык вызовом `skill({ name: "..." })`: рецепты и скелеты лежат там, а не здесь. Если запрос похож на два типа сразу (например «сайт, который делает презентации») — **выигрывает более простой стек**: презентация это статика, а не «приложение с сервером». Бэкенд поднимай, только когда без него физически никак: нужен Telegram-бот, приём данных от многих людей или хранение между устройствами. + +**Язык бэкенда — ВСЕГДА Node.js в стиле CommonJS: `require(...)`, а не `import`.** Не смешивай ESM и CJS — это главная причина «node не запускается». Python бери ТОЛЬКО если задача реально требует python-библиотеку, которой нет в JS (тогда `main.py` + `requirements.txt`). По умолчанию — Node. + +**3 правила, которые ломаются чаще всего:** +1. Зависимости — ТОЛЬКО в `package.json` → `dependencies`. Их поставит `run`. НИКОГДА не пиши `npm install` в шелле. +2. Порт — всегда `process.env.PORT || 3000`. Не хардкодь другой. +3. Запуск бэкенда — ТОЛЬКО командой `run`. Никогда сам `node ...` / `npm start` в шелле. +4. **Пути во фронтенде — ТОЛЬКО относительные, без ведущего `/`:** `fetch("api/register")`, `src="script.js"`, `href="list.html"`. Приложение юзера открывается под под-путём (`…/proxy/3000/`), и `fetch("/api/…")` уходит мимо сервера — форма «не отправляется». В сервере наоборот сравнивай `url` с `/api/...` как обычно. + +--- +## 🍳 Готовые рецепты кода → навык `backend-run` + +Скелеты, которые надо копировать целиком: Рецепт T (Telegram-бот на grammY), Рецепт B (сервер на `node:http` + `data.json`), блок «ИИ» без ключей — лежат в навыке `backend-run`. Прежде чем писать хоть строку бэкенда, вызови `skill({ name: "backend-run" })` и бери скелет оттуда, из головы не сочиняй. + +--- + +## 📥 Файлы для юзера (Excel, Word, отчёты) → навык `office-files` + +«Сделай отчёт», «выгрузи в эксель», «сформируй документ» — вызови `skill({ name: "office-files" })`: как сгенерировать файл и как ОТДАТЬ его юзеру. Создать файл мало — без выдачи юзер его не получит. + +--- + +## 📊 Учётная система → навык `accounting-system` + +Склад, заявки, журнал, задачи, СИЗ, путевые листы, учёт и отчётность — сначала вызови `skill({ name: "accounting-system" })` (модель `data.json`, поля, статусы, экраны), потом строй по Рецепту B из навыка `backend-run`. Схему не изобретай. + +--- + +## 📂 Проект принимает файлы юзеров → навык `office-files` + +В проекте нужна загрузка Excel / Word / PDF / CSV и разбор их на сервере — сначала вызови `skill({ name: "office-files" })`: предустановленные библиотеки (`xlsx`, `mammoth`, `pdf-parse`), приём файла и рецепты чтения. Свои парсеры не пиши. + +--- + +## 🚑 Не запускается? → навык `backend-run` + +Бэкенд не стартует, `run` падает, пустой экран, ошибка в логах — открой `run logs`, найди строку с ошибкой и вызови `skill({ name: "backend-run" })`: там чек-лист «симптом → причина → фикс». Не гадай без него. + +--- + +## 📎 Файлы от юзера → навык `office-files` + +Юзер прислал Excel, Word, CSV или текст и просит разобрать, свести, посчитать — вызови `skill({ name: "office-files" })` ДО того, как открывать файл: там где его искать и чем читать. + +--- + +## 🗺 СЦЕНАРИЙ ПЕРВОГО ЗАХОДА (юзер только зашёл, ещё ничего нет) + +1. Поздоровайся коротко: «Привет! Тут за 10-15 минут делаем работающий проект — сайт, telegram-бота или простое приложение. Что хочешь сделать?» +2. Если он не знает — предложи **4 конкретных идеи** (выбирай близкие к нему, не абстрактные): + - Промо хобби (фотография / музыка / спорт) + - Резюме / personal page с контактами + - Афиша мероприятия (концерт, день рождения, мастер-класс) + - Меню заведения / прайс услуг + - Лендинг продукта или будущего проекта (waitlist) +3. Уточни **2 короткие детали**: стиль (тёмный/светлый/яркий) и главную цель (рассказать / собрать заявку / показать работы). +4. Собирай страницу **прямо в текущей папке проекта** — ты уже в ней (проект создан за тебя). Создавай `index.html`/`style.css`/`script.js` тут же. **НЕ запускай `./new-project`** и не делай `cd` в другие папки. Не спрашивай разрешения на каждый шаг. + +--- +## 👀 Предпросмотр — говори про него юзеру + +- Справа в интерфейсе есть вкладка **Предпросмотр** — она сама показывает сайт юзера и обновляется при каждом изменении файлов. Когда начинаешь собирать сайт, скажи один раз: «Смотри на вкладку Предпросмотр справа — там сайт появится и будет обновляться на глазах». +- Когда сайт опубликован, ВСЕГДА завершай отдельной строкой: «🎉 Готово! Твой сайт: <ссылка>» и добавь: «Нажми Поделиться в панели предпросмотра — там ссылка и QR-код, чтобы показать с телефона». + +--- +## 🧠 Память о юзере — веди её сам + +Файл памяти: `/srv/opencode/workspaces/users/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/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..b246d46 --- /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` — логотип АО «Казахтелеком», тёмный — для светлого фона (шапка, секции). Белый вариант `kazakhtelecom-logo-white.png` — для тёмного подвала (`.kt-ai-pagefoot` с тёмным фоном) и тёмной темы. + +```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..36989e8 --- /dev/null +++ b/index.html @@ -0,0 +1,263 @@ + + + + + + +HR Candidate Matcher + + + +
+ + +
+
+
+
+

HR Candidate Matcher

+

Сопоставьте резюме с требованиями вакансии по прозрачным критериям. Подготовьте вопросы для интервью. Все данные остаются в вашем браузере.

+
+ + +
+

+ + Персональные данные не сохраняются без явного согласия и не покидают браузер. +

+
+
+
+ + + + + + + + + + +
+ +
+ + +
+ + + + + + + + + diff --git a/js/aiAdapter.js b/js/aiAdapter.js new file mode 100644 index 0000000..62e9336 --- /dev/null +++ b/js/aiAdapter.js @@ -0,0 +1,217 @@ +/* HR Candidate Matcher — AI Adapter (Demo Mode) */ +/* global window */ +(function () { + 'use strict'; + + var APP = window.HRMatcher = window.HRMatcher || {}; + + var SKILLS = APP.SKILL_DICTIONARY; + var TEMPLATES = APP.INTERVIEW_TEMPLATES; + + var EXPERIENCE_PATTERN = /(?:опыт(?:\s+работы)?[\s:]*(?:от\s+)?(\d+)\s*(?:год|лет|года))/i; + var EXPERIENCE_PATTERN2 = /(?:от\s+(\d+)\s*(?:год|лет|года)\s*опыта)/i; + var EXPERIENCE_PATTERN3 = /(?:стаж\s+(?:от\s+)?(\d+)\s*(?:год|лет|года))/i; + var EXPERIENCE_PATTERN4 = /(\d+)\s*(?:год|лет|года)\s*(?:опыта|стажа)/i; + + var REQUIREMENT_PATTERNS = [ + /(?:знания?\s+|владение\s+|умени[ея]\s+(?:работать\s+)?(?:с\s+)?|опыт\s+(?:работы\s+)?(?:с\s+)?(?:в\s+)?)((?:[A-Za-zА-Яа-яЁё0-9.]+\s*){1,4})/gi, + /(?:требуется|необходимо|обязательно|нужно)[\s:]+((?:[A-Za-zА-Яа-яЁё0-9.]+\s*){1,4})/gi + ]; + + var NEGATION_PATTERN = /(?:без\s+|нет\s+|не\s+(?:имеет?\s+)?|отсутств(?:ие|ует|уют)\s+|не\s+требует(?:ся)?)\s*/i; + + function normalizeText(text) { + return (text || '').toLowerCase().replace(/\s+/g, ' ').trim(); + } + + function detectSkillType(label) { + var lower = normalizeText(label); + if (/\b(опыт|стаж|лет\s+работ)\b/.test(lower)) return 'experience'; + if (/\b(analytics|analyt|метрик|аналитик|отчёт|отчет|тестирован|a\/b)\b/.test(lower)) return 'tool'; + if (/\b(ads|реклам|campaign|кампани|target|таргет)\b/.test(lower)) return 'tool'; + return 'skill'; + } + + APP.aiAdapter = { + isDemoMode: true, + + extractRequirements: function (vacancyText) { + var text = vacancyText || ''; + var criteria = []; + var seen = {}; + + Object.keys(SKILLS).forEach(function (key) { + var skill = SKILLS[key]; + var allTerms = [key].concat(skill.synonyms); + var found = false; + for (var i = 0; i < allTerms.length; i++) { + var term = allTerms[i]; + var regex = new RegExp('\\b' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i'); + if (regex.test(text)) { + found = true; + break; + } + } + if (found && !seen[key]) { + seen[key] = true; + var isDesired = /(?:плюс|желательно|будет\s+плюсом|приветствуется|желател)/i.test( + text.substring(Math.max(0, text.toLowerCase().indexOf(key) - 50), text.toLowerCase().indexOf(key) + key.length + 50) + ); + criteria.push(APP.createCriterion( + skill.label, + !isDesired, + isDesired ? 2 : 3, + skill.synonyms.slice(0, 3) + )); + } + }); + + var expMatch = text.match(EXPERIENCE_PATTERN) || text.match(EXPERIENCE_PATTERN2) || + text.match(EXPERIENCE_PATTERN3) || text.match(EXPERIENCE_PATTERN4); + if (expMatch) { + var years = parseInt(expMatch[1], 10); + if (years > 0 && years <= 20 && !seen['experience_years']) { + seen['experience_years'] = true; + criteria.push(APP.createCriterion( + 'Опыт работы от ' + years + ' лет', + true, + 4, + ['стаж от ' + years, years + ' лет опыта', 'опыт ' + years + ' года'] + )); + } + } + + var lines = text.split('\n'); + for (var li = 0; li < lines.length; li++) { + var line = lines[li].trim(); + if (/^[-–—•]\s+/.test(line)) { + var content = line.replace(/^[-–—•]\s+/, '').trim(); + if (content.length > 5 && content.length < 100) { + var lowerContent = normalizeText(content); + var alreadyMatched = false; + Object.keys(SKILLS).forEach(function (key) { + var allTerms = [key].concat(SKILLS[key].synonyms); + for (var ti = 0; ti < allTerms.length; ti++) { + if (lowerContent.indexOf(normalizeText(allTerms[ti])) !== -1) { + alreadyMatched = true; + break; + } + } + }); + if (!alreadyMatched && /(?:знани|владе|уме|опыт|способн|знаком)/i.test(content)) { + var cleanLabel = content.replace(/^(?:знание|владение|умение|опыт|знакомство)\s+(?:с\s+)?/i, '').trim(); + if (cleanLabel.length >= 3 && !seen[normalizeText(cleanLabel)]) { + seen[normalizeText(cleanLabel)] = true; + criteria.push(APP.createCriterion(cleanLabel, true, 3, [])); + } + } + } + } + } + + return criteria; + }, + + matchCriterion: function (criterion, resumeText) { + var label = criterion.label || ''; + var synonyms = criterion.synonyms || []; + var allTerms = [label].concat(synonyms).map(function (t) { return t.trim(); }).filter(Boolean); + var resumeLower = normalizeText(resumeText); + var evidence = []; + + for (var i = 0; i < allTerms.length; i++) { + var term = allTerms[i]; + var termLower = normalizeText(term); + var idx = resumeLower.indexOf(termLower); + if (idx !== -1) { + var contextStart = Math.max(0, idx - 40); + var contextEnd = Math.min(resumeText.length, idx + term.length + 40); + var fragment = resumeText.substring(contextStart, contextEnd).trim(); + if (contextStart > 0) fragment = '…' + fragment; + if (contextEnd < resumeText.length) fragment = fragment + '…'; + + var beforeText = resumeText.substring(Math.max(0, idx - 30), idx); + if (NEGATION_PATTERN.test(beforeText)) { + continue; + } + + evidence.push(APP.createEvidence(fragment, idx)); + } + } + + if (evidence.length > 0) { + return APP.createMatchResult(criterion.id, APP.STATUS.CONFIRMED, evidence, ''); + } + + var resumeSentences = resumeText.split(/[.!?\n]+/).map(function (s) { return s.trim(); }).filter(Boolean); + var unclear = false; + for (var si = 0; si < resumeSentences.length; si++) { + var sentence = normalizeText(resumeSentences[si]); + for (var ti = 0; ti < allTerms.length; ti++) { + var tLower = normalizeText(allTerms[ti]); + var words = tLower.split(/\s+/); + var matchedWords = 0; + for (var wi = 0; wi < words.length; wi++) { + if (words[wi].length >= 3 && sentence.indexOf(words[wi]) !== -1) { + matchedWords++; + } + } + if (words.length > 1 && matchedWords >= words.length * 0.5) { + var sentIdx = resumeText.toLowerCase().indexOf(resumeSentences[si].toLowerCase()); + if (sentIdx !== -1) { + evidence.push(APP.createEvidence(resumeSentences[si], sentIdx)); + } + unclear = true; + break; + } + } + if (unclear) break; + } + + if (unclear) { + return APP.createMatchResult(criterion.id, APP.STATUS.UNCLEAR, evidence, 'Требуется уточнение'); + } + + return APP.createMatchResult(criterion.id, APP.STATUS.NOT_FOUND, [], ''); + }, + + generateQuestions: function (criterion, matchResult) { + var type = detectSkillType(criterion.label); + var templates = TEMPLATES[type] || TEMPLATES.skill; + var questions = []; + var label = criterion.label; + + for (var i = 0; i < templates.length; i++) { + questions.push(templates[i].replace(/\{skill\}/g, label)); + } + + if (matchResult && matchResult.status === APP.STATUS.UNCLEAR && matchResult.evidence.length > 0) { + questions.unshift('В резюме упоминается «' + label + '», но контекст неясен. Можете уточнить ваш уровень и примеры использования?'); + } + + return questions; + }, + + detectSkillType: detectSkillType, + + computeCoverage: function (criteria, matchResults) { + if (!criteria || criteria.length === 0) return 0; + var totalWeight = 0; + var confirmedWeight = 0; + var resultMap = {}; + (matchResults || []).forEach(function (r) { resultMap[r.criterionId] = r; }); + + for (var i = 0; i < criteria.length; i++) { + var c = criteria[i]; + totalWeight += c.weight; + var result = resultMap[c.id]; + if (result && result.status === APP.STATUS.CONFIRMED) { + confirmedWeight += c.weight; + } + } + + if (totalWeight === 0) return 0; + return Math.round((confirmedWeight / totalWeight) * 100); + } + }; +})(); diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..57c4727 --- /dev/null +++ b/js/app.js @@ -0,0 +1,434 @@ +/* HR Candidate Matcher — Application Entry Point */ +/* global window, document, HRMatcher */ +(function () { + 'use strict'; + + var APP = window.HRMatcher; + var $ = function (sel) { return document.querySelector(sel); }; + var $$ = function (sel) { return Array.prototype.slice.call(document.querySelectorAll(sel)); }; + + var state = APP.createAppState(); + + function saveState() { + var data = { + currentScreen: state.currentScreen, + vacancy: state.vacancy, + resumeText: state.isDemo ? '' : state.resumeText, + matchResults: state.matchResults, + isDemo: state.isDemo + }; + APP.storage.save(data); + } + + function loadState() { + var data = APP.storage.load(); + if (!data) return false; + state.currentScreen = data.currentScreen || 'welcome'; + state.vacancy = data.vacancy || null; + state.resumeText = data.resumeText || ''; + state.matchResults = data.matchResults || []; + state.isDemo = data.isDemo || false; + return true; + } + + function navigateTo(screen) { + state.currentScreen = screen; + APP.ui.showScreen(screen); + + if (screen === 'input') { + var vacInput = $('#input-vacancy'); + var resInput = $('#input-resume'); + if (vacInput && state.vacancy) vacInput.value = state.vacancy.rawText || ''; + if (resInput) resInput.value = state.resumeText || ''; + } else if (screen === 'criteria') { + if (state.vacancy) { + APP.ui.renderCriteriaTable(state.vacancy.criteria || []); + } + } else if (screen === 'match') { + if (state.vacancy) { + currentFilter = 'all'; + APP.ui.updateFilterChips('all'); + APP.ui.renderMatchTable(state.vacancy.criteria, state.matchResults, 'all'); + APP.ui.renderCoverage(APP.aiAdapter.computeCoverage(state.vacancy.criteria, state.matchResults)); + } + } else if (screen === 'interview') { + if (state.vacancy) { + var interviewData = APP.logic.generateInterviewData(state.vacancy.criteria, state.matchResults); + APP.ui.renderInterview(interviewData); + } + } + + saveState(); + } + + var currentFilter = 'all'; + + function bindEvents() { + var btnStartEmpty = $('#btn-start-empty'); + var btnStartDemo = $('#btn-start-demo'); + var btnBackInput = $('#btn-back-from-input'); + var btnBackCriteria = $('#btn-back-from-criteria'); + var btnBackMatch = $('#btn-back-from-match'); + var btnBackInterview = $('#btn-back-from-interview'); + var formInput = $('#form-input'); + var btnAddCriterion = $('#btn-add-criterion'); + var btnToMatch = $('#btn-to-match'); + var btnToInterview = $('#btn-to-interview'); + var btnViewSource = $('#btn-view-source'); + var btnCloseSource = $('#btn-close-source'); + var btnCopyQuestions = $('#btn-copy-questions'); + var btnExportSummary = $('#btn-export-summary'); + var btnStartOver = $('#btn-start-over'); + var btnClearAll = $('#btn-clear-all'); + var btnConfirmOk = $('#btn-confirm-ok'); + var btnConfirmCancel = $('#btn-confirm-cancel'); + var btnResetFilter = $('#btn-reset-filter'); + + if (btnStartEmpty) { + btnStartEmpty.addEventListener('click', function () { + state = APP.createAppState(); + state.currentScreen = 'input'; + navigateTo('input'); + }); + } + + if (btnStartDemo) { + btnStartDemo.addEventListener('click', function () { + state = APP.createAppState(); + state.isDemo = true; + state.vacancy = APP.createVacancy('Таргетолог', APP.DEMO_VACANCY_TEXT, []); + state.resumeText = APP.DEMO_RESUME_TEXT; + state.vacancy.criteria = APP.aiAdapter.extractRequirements(APP.DEMO_VACANCY_TEXT); + state.matchResults = APP.logic.runMatch(state.vacancy.criteria, state.resumeText); + state.currentScreen = 'criteria'; + navigateTo('criteria'); + APP.ui.showToast('Загружен пример. Проверьте критерии.', 'success'); + }); + } + + if (btnBackInput) btnBackInput.addEventListener('click', function () { navigateTo('welcome'); }); + if (btnBackCriteria) btnBackCriteria.addEventListener('click', function () { navigateTo('input'); }); + if (btnBackMatch) btnBackMatch.addEventListener('click', function () { navigateTo('criteria'); }); + if (btnBackInterview) btnBackInterview.addEventListener('click', function () { navigateTo('match'); }); + + if (formInput) { + formInput.addEventListener('submit', function (e) { + e.preventDefault(); + APP.ui.clearAllErrors(); + + var vacancyText = $('#input-vacancy').value; + var resumeText = $('#input-resume').value; + var validation = APP.logic.validateInput(vacancyText, resumeText); + + if (!validation.valid) { + if (validation.errors.vacancy) { + APP.ui.showError('vacancy', validation.errors.vacancy); + $('#input-vacancy').focus(); + } else if (validation.errors.resume) { + APP.ui.showError('resume', validation.errors.resume); + $('#input-resume').focus(); + } + return; + } + + var title = vacancyText.split('\n')[0].substring(0, 80).trim(); + state.vacancy = APP.createVacancy(title, vacancyText, []); + state.resumeText = resumeText; + state.isDemo = false; + state.vacancy.criteria = APP.aiAdapter.extractRequirements(vacancyText); + state.matchResults = APP.logic.runMatch(state.vacancy.criteria, resumeText); + + navigateTo('criteria'); + APP.ui.showToast('Извлечено требований: ' + state.vacancy.criteria.length, 'success'); + }); + } + + if (btnAddCriterion) { + btnAddCriterion.addEventListener('click', function () { + var criteria = APP.ui.readCriteriaFromTable(); + criteria.push(APP.createCriterion('', true, 3, [])); + state.vacancy.criteria = criteria; + APP.ui.renderCriteriaTable(criteria); + var rows = $$('#criteria-tbody tr'); + var lastRow = rows[rows.length - 1]; + if (lastRow) { + var input = lastRow.querySelector('[data-field="label"]'); + if (input) input.focus(); + } + }); + } + + if (btnToMatch) { + btnToMatch.addEventListener('click', function () { + var criteria = APP.ui.readCriteriaFromTable(); + var validation = APP.logic.validateCriteria(criteria); + if (!validation.valid) { + APP.ui.showToast(validation.message, 'error'); + return; + } + state.vacancy.criteria = criteria; + state.matchResults = APP.logic.runMatch(criteria, state.resumeText); + navigateTo('match'); + }); + } + + var criteriaTbody = $('#criteria-tbody'); + if (criteriaTbody) { + criteriaTbody.addEventListener('click', function (e) { + var btn = e.target.closest('[data-action="delete-criterion"]'); + if (!btn) return; + var row = btn.closest('tr'); + if (!row) return; + var id = row.getAttribute('data-id'); + var criteria = APP.ui.readCriteriaFromTable(); + var label = ''; + for (var i = 0; i < criteria.length; i++) { + if (criteria[i].id === id) { label = criteria[i].label; break; } + } + APP.ui.showConfirm('Удалить требование' + (label ? ' «' + label + '»' : '') + '?', function () { + var updated = criteria.filter(function (c) { return c.id !== id; }); + state.vacancy.criteria = updated; + APP.ui.renderCriteriaTable(updated); + APP.ui.hideConfirm(); + }); + }); + } + + var matchTbody = $('#match-tbody'); + if (matchTbody) { + matchTbody.addEventListener('change', function (e) { + var target = e.target; + var row = target.closest('tr'); + if (!row) return; + var criterionId = row.getAttribute('data-criterion-id'); + + if (target.getAttribute('data-action') === 'change-status') { + for (var i = 0; i < state.matchResults.length; i++) { + if (state.matchResults[i].criterionId === criterionId) { + state.matchResults[i].status = target.value; + break; + } + } + APP.ui.renderCoverage(APP.aiAdapter.computeCoverage(state.vacancy.criteria, state.matchResults)); + saveState(); + } + }); + + matchTbody.addEventListener('input', function (e) { + var target = e.target; + if (target.getAttribute('data-action') !== 'change-note') return; + var row = target.closest('tr'); + if (!row) return; + var criterionId = row.getAttribute('data-criterion-id'); + for (var i = 0; i < state.matchResults.length; i++) { + if (state.matchResults[i].criterionId === criterionId) { + state.matchResults[i].note = target.value; + break; + } + } + saveState(); + }); + } + + var filterBar = $('#match-filter-bar'); + if (filterBar) { + filterBar.addEventListener('click', function (e) { + var chip = e.target.closest('.chip'); + if (!chip) return; + currentFilter = chip.getAttribute('data-filter') || 'all'; + APP.ui.updateFilterChips(currentFilter); + APP.ui.renderMatchTable(state.vacancy.criteria, state.matchResults, currentFilter); + }); + } + + if (btnResetFilter) { + btnResetFilter.addEventListener('click', function () { + currentFilter = 'all'; + APP.ui.updateFilterChips('all'); + APP.ui.renderMatchTable(state.vacancy.criteria, state.matchResults, 'all'); + }); + } + + if (btnToInterview) { + btnToInterview.addEventListener('click', function () { + navigateTo('interview'); + }); + } + + if (btnViewSource) { + btnViewSource.addEventListener('click', function () { + var modal = $('#modal-source'); + var vacSource = $('#source-vacancy'); + var resSource = $('#source-resume'); + if (vacSource) vacSource.textContent = state.vacancy ? state.vacancy.rawText : ''; + if (resSource) resSource.textContent = state.resumeText || ''; + if (modal) modal.hidden = false; + }); + } + + if (btnCloseSource) { + btnCloseSource.addEventListener('click', function () { + var modal = $('#modal-source'); + if (modal) modal.hidden = true; + }); + } + + if (btnCopyQuestions) { + btnCopyQuestions.addEventListener('click', function () { + var interviewData = APP.logic.generateInterviewData(state.vacancy.criteria, state.matchResults); + if (interviewData.length === 0) { + APP.ui.showToast('Нет вопросов для копирования', 'error'); + return; + } + var text = ''; + var num = 1; + interviewData.forEach(function (section) { + text += section.criterion.label + ':\n'; + section.questions.forEach(function (q) { + text += num + '. ' + q + '\n'; + num++; + }); + text += '\n'; + }); + copyToClipboard(text.trim()); + }); + } + + if (btnExportSummary) { + btnExportSummary.addEventListener('click', function () { + var summary = APP.logic.exportSummary(state.vacancy, state.vacancy.criteria, state.matchResults); + downloadFile('candidate-summary.txt', summary, 'text/plain;charset=utf-8'); + APP.ui.showToast('Сводка экспортирована', 'success'); + }); + } + + if (btnStartOver) { + btnStartOver.addEventListener('click', function () { + APP.ui.showConfirm('Начать заново? Текущие данные будут очищены.', function () { + state = APP.createAppState(); + APP.storage.clear(); + navigateTo('welcome'); + APP.ui.hideConfirm(); + }); + }); + } + + if (btnClearAll) { + btnClearAll.addEventListener('click', function () { + APP.ui.showConfirm('Очистить все данные? Это действие нельзя отменить.', function () { + state = APP.createAppState(); + APP.storage.clear(); + navigateTo('welcome'); + APP.ui.hideConfirm(); + APP.ui.showToast('Все данные очищены', 'success'); + }); + }); + } + + if (btnConfirmOk) { + btnConfirmOk.addEventListener('click', function () { + APP.ui.resolveConfirm(); + }); + } + + if (btnConfirmCancel) { + btnConfirmCancel.addEventListener('click', function () { + APP.ui.hideConfirm(); + }); + } + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { + var sourceModal = $('#modal-source'); + var confirmModal = $('#modal-confirm'); + if (sourceModal && !sourceModal.hidden) { + sourceModal.hidden = true; + } else if (confirmModal && !confirmModal.hidden) { + APP.ui.hideConfirm(); + } + } + }); + + var sourceModal = $('#modal-source'); + if (sourceModal) { + sourceModal.addEventListener('click', function (e) { + if (e.target === sourceModal) sourceModal.hidden = true; + }); + } + var confirmModal = $('#modal-confirm'); + if (confirmModal) { + confirmModal.addEventListener('click', function (e) { + if (e.target === confirmModal) APP.ui.hideConfirm(); + }); + } + + window.addEventListener('beforeunload', function () { + if (state.currentScreen !== 'welcome' && !state.isDemo) { + saveState(); + } + }); + } + + function copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(function () { + APP.ui.showToast('Скопировано в буфер обмена', 'success'); + }).catch(function () { + fallbackCopy(text); + }); + } else { + fallbackCopy(text); + } + } + + function fallbackCopy(text) { + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + try { + document.execCommand('copy'); + APP.ui.showToast('Скопировано в буфер обмена', 'success'); + } catch (e) { + APP.ui.showToast('Не удалось скопировать. Выделите текст вручную.', 'error'); + } + document.body.removeChild(ta); + } + + function downloadFile(filename, content, mimeType) { + var blob = new Blob([content], { type: mimeType }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = filename; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + setTimeout(function () { + URL.revokeObjectURL(url); + document.body.removeChild(a); + }, 100); + } + + function init() { + bindEvents(); + + var hasSaved = loadState(); + if (hasSaved && state.currentScreen !== 'welcome') { + navigateTo(state.currentScreen); + if (state.isDemo) { + APP.ui.showToast('Загружены данные примера', 'success'); + } + } else { + APP.ui.showScreen('welcome'); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/js/data.js b/js/data.js new file mode 100644 index 0000000..1a4411a --- /dev/null +++ b/js/data.js @@ -0,0 +1,167 @@ +/* HR Candidate Matcher — Data Layer */ +/* global window */ +(function () { + 'use strict'; + + var APP = window.HRMatcher = window.HRMatcher || {}; + + APP.STATUS = { + CONFIRMED: 'confirmed', + NOT_FOUND: 'not_found', + UNCLEAR: 'unclear' + }; + + APP.CRITERION_TYPE = { + REQUIRED: 'required', + DESIRED: 'desired' + }; + + var idCounter = Date.now(); + APP.generateId = function () { + return 'id_' + (idCounter++) + '_' + Math.random().toString(36).slice(2, 8); + }; + + APP.createVacancy = function (title, rawText, criteria) { + return { + id: APP.generateId(), + title: title || '', + rawText: rawText || '', + criteria: criteria || [] + }; + }; + + APP.createCriterion = function (label, required, weight, synonyms) { + return { + id: APP.generateId(), + label: label || '', + required: required !== false, + weight: Math.max(1, Math.min(5, weight || 3)), + synonyms: synonyms || [] + }; + }; + + APP.createMatchResult = function (criterionId, status, evidence, note) { + return { + criterionId: criterionId, + status: status || APP.STATUS.NOT_FOUND, + evidence: evidence || [], + note: note || '' + }; + }; + + APP.createEvidence = function (fragment, position) { + return { + fragment: fragment || '', + position: position || 0 + }; + }; + + APP.createAppState = function () { + return { + currentScreen: 'welcome', + vacancy: null, + resumeText: '', + matchResults: [], + isDemo: false, + hasUnsavedChanges: false + }; + }; + + APP.DEMO_VACANCY_TEXT = 'Таргетолог (Digital Marketing Specialist)\n\n' + + 'Компания: ТОО «DigitalKZ»\n' + + 'Локация: Алматы, Казахстан\n' + + 'Зарплата: от 350 000 ₸ до 500 000 ₸\n\n' + + 'Обязанности:\n' + + '— Настройка и оптимизация рекламных кампаний в Facebook Ads, Google Ads, TikTok Ads\n' + + '— Анализ эффективности кампаний, подготовка отчётов\n' + + '— A/B тестирование креативов и посадочных страниц\n' + + '— Работа с аналитическими инструментами: Google Analytics, Яндекс.Метрика\n' + + '— Управление бюджетом рекламных кампаний\n' + + '— Ведение отчётности в Excel / Google Sheets\n\n' + + 'Требования:\n' + + '— Опыт работы таргетологом от 2 лет\n' + + '— Знание Facebook Ads Manager и Google Ads\n' + + '— Умение работать с Google Analytics и Яндекс.Метрикой\n' + + '— Опыт A/B тестирования\n' + + '— Знание Excel / Google Sheets на уровне продвинутого пользователя\n' + + '— Английский язык — Intermediate и выше\n' + + '— Понимание основ SEO будет плюсом\n' + + '— Опыт работы с TikTok Ads — желательно\n\n' + + 'Условия:\n' + + '— Полный рабочий день, офис в центре Алматы\n' + + '— Оформление по ТК РК\n' + + '— ДМС после испытательного срока'; + + APP.DEMO_RESUME_TEXT = 'Кандидат на позицию Таргетолог\n\n' + + 'Опыт работы:\n' + + 'Таргетолог, ТОО «МедиаГрупп» — 2 года 6 месяцев (2023 – настоящее время)\n' + + '— Настройка и ведение рекламных кампаний в Facebook Ads и Google Ads\n' + + '— Ежемесячный бюджет: от 500 000 ₸ до 2 000 000 ₸\n' + + '— A/B тестирование креативов: повышение CTR на 35%\n' + + '— Подготовка еженедельных отчётов в Google Sheets\n' + + '— Работа с Google Analytics для анализа воронки\n\n' + + 'Младший таргетолог, ИП «РекламаПлюс» — 1 год (2022 – 2023)\n' + + '— Настройка рекламы в Instagram и VK Ads\n' + + '— Ведение отчётности в Excel\n\n' + + 'Навыки:\n' + + '— Facebook Ads Manager — продвинутый\n' + + '— Google Ads — продвинутый\n' + + '— Google Analytics — уверенный\n' + + '— Яндекс.Метрика — базовый\n' + + '— Excel / Google Sheets — продвинутый (сводные таблицы, VLOOKUP)\n' + + '— A/B тестирование — опыт 2 года\n' + + '— TikTok Ads — нет опыта, но готов освоить\n' + + '— SEO — знаком с основами\n\n' + + 'Образование:\n' + + '— КазНУ им. аль-Фараби, Маркетинг, бакалавр (2018 – 2022)\n\n' + + 'Языки:\n' + + '— Казахский — родной\n' + + '— Русский — свободный\n' + + '— Английский — Intermediate (B1)'; + + APP.SKILL_DICTIONARY = { + 'facebook ads': { label: 'Facebook Ads', synonyms: ['facebook ads manager', 'fb ads', 'meta ads', 'реклама в facebook'] }, + 'google ads': { label: 'Google Ads', synonyms: ['google adwords', 'adwords', 'реклама в google'] }, + 'tiktok ads': { label: 'TikTok Ads', synonyms: ['tiktok advertising', 'реклама в tiktok'] }, + 'google analytics': { label: 'Google Analytics', synonyms: ['ga4', 'ga', 'аналитика google'] }, + 'яндекс.метрика': { label: 'Яндекс.Метрика', synonyms: ['метрика', 'yandex metrika', 'яндекс метрика'] }, + 'excel': { label: 'Excel', synonyms: ['google sheets', 'google таблицы', 'эксель', '电子表格'] }, + 'a/b тестирование': { label: 'A/B тестирование', synonyms: ['ab тестирование', 'a/b тесты', 'ab тесты', 'a/b testing'] }, + 'seo': { label: 'SEO', synonyms: ['поисковая оптимизация', 'search engine optimization'] }, + 'sql': { label: 'SQL', synonyms: ['mysql', 'postgresql', 'запросы к базе данных'] }, + 'python': { label: 'Python', synonyms: ['питон'] }, + 'javascript': { label: 'JavaScript', synonyms: ['js'] }, + 'react': { label: 'React', synonyms: ['reactjs', 'react.js'] }, + 'figma': { label: 'Figma', synonyms: ['фигма'] }, + 'photoshop': { label: 'Photoshop', synonyms: ['фотошоп', 'ps'] }, + 'css': { label: 'CSS', synonyms: ['css3'] }, + 'html': { label: 'HTML', synonyms: ['html5'] }, + 'project management': { label: 'Управление проектами', synonyms: ['project manager', 'pm', 'управление проектом'] }, + 'agile': { label: 'Agile', synonyms: ['scrum', 'канбан', 'kanban'] }, + 'power bi': { label: 'Power BI', synonyms: ['powerbi'] }, + 'tableau': { label: 'Tableau', synonyms: ['табло'] } + }; + + APP.INTERVIEW_TEMPLATES = { + skill: [ + 'Расскажите, как вы применяли «{skill}» в рабочих проектах?', + 'Какие сложности возникали при работе с «{skill}» и как вы их решали?', + 'На каком уровне вы оцениваете своё владение «{skill}»? Приведите пример.' + ], + experience: [ + 'Опишите конкретный проект, где требовался опыт в «{skill}».', + 'Как вы отслеживаете результаты своей работы в области «{skill}»?', + 'Что бы вы изменили в подходе к «{skill}», зная то, что знаете сейчас?' + ], + tool: [ + 'Какие инструменты / платформы вы использовали для «{skill}»?', + 'Как вы оцениваете свою скорость работы с «{skill}»? Приведите пример.', + 'Есть ли альтернативные инструменты, которые вы рассматривали для «{skill}»?' + ], + development: [ + 'Какие шаги вы предпринимаете для развития навыка «{skill}»?', + 'Есть ли ресурсы (курсы, книги), которые вы используете для изучения «{skill}»?', + 'Какой результат вы ожидаете достичь в «{skill}» через полгода?' + ] + }; +})(); diff --git a/js/logic.js b/js/logic.js new file mode 100644 index 0000000..651a8e2 --- /dev/null +++ b/js/logic.js @@ -0,0 +1,165 @@ +/* HR Candidate Matcher — Business Logic */ +/* global window */ +(function () { + 'use strict'; + + var APP = window.HRMatcher = window.HRMatcher || {}; + + APP.logic = { + validateInput: function (vacancyText, resumeText) { + var errors = {}; + var valid = true; + + if (!vacancyText || vacancyText.trim().length < 20) { + errors.vacancy = 'Введите текст вакансии (минимум 20 символов)'; + valid = false; + } + if (!resumeText || resumeText.trim().length < 20) { + errors.resume = 'Введите текст резюме (минимум 20 символов)'; + valid = false; + } + + return { valid: valid, errors: errors }; + }, + + validateCriteria: function (criteria) { + if (!criteria || criteria.length === 0) { + return { valid: false, message: 'Добавьте хотя бы один критерий' }; + } + for (var i = 0; i < criteria.length; i++) { + var c = criteria[i]; + if (!c.label || c.label.trim().length < 2) { + return { valid: false, message: 'Укажите название для критерия №' + (i + 1) }; + } + if (c.weight < 1 || c.weight > 5 || isNaN(c.weight)) { + return { valid: false, message: 'Вес критерия «' + c.label + '» должен быть от 1 до 5' }; + } + } + return { valid: true }; + }, + + runMatch: function (criteria, resumeText) { + var results = []; + for (var i = 0; i < criteria.length; i++) { + var result = APP.aiAdapter.matchCriterion(criteria[i], resumeText); + results.push(result); + } + return results; + }, + + getFilteredResults: function (matchResults, filter) { + if (!filter || filter === 'all') return matchResults; + return matchResults.filter(function (r) { return r.status === filter; }); + }, + + getCriterionById: function (criteria, id) { + for (var i = 0; i < criteria.length; i++) { + if (criteria[i].id === id) return criteria[i]; + } + return null; + }, + + getUnconfirmedCriteria: function (criteria, matchResults) { + var resultMap = {}; + matchResults.forEach(function (r) { resultMap[r.criterionId] = r; }); + return criteria.filter(function (c) { + var r = resultMap[c.id]; + return !r || r.status !== APP.STATUS.CONFIRMED; + }); + }, + + generateInterviewData: function (criteria, matchResults) { + var resultMap = {}; + matchResults.forEach(function (r) { resultMap[r.criterionId] = r; }); + + var sections = []; + var unconfirmed = this.getUnconfirmedCriteria(criteria, matchResults); + + unconfirmed.forEach(function (c) { + var result = resultMap[c.id]; + var status = result ? result.status : APP.STATUS.NOT_FOUND; + var questions = APP.aiAdapter.generateQuestions(c, result); + sections.push({ + criterion: c, + status: status, + questions: questions + }); + }); + + return sections; + }, + + exportSummary: function (vacancy, criteria, matchResults) { + var lines = []; + var now = new Date(); + lines.push('=== Сводка по кандидату ==='); + lines.push('Дата: ' + now.toLocaleDateString('ru-KZ')); + lines.push('Позиция: ' + (vacancy.title || 'Не указана')); + lines.push(''); + + var coverage = APP.aiAdapter.computeCoverage(criteria, matchResults); + lines.push('Покрытие указанных требований: ' + coverage + '%'); + lines.push(''); + + var resultMap = {}; + matchResults.forEach(function (r) { resultMap[r.criterionId] = r; }); + + var statusLabels = {}; + statusLabels[APP.STATUS.CONFIRMED] = 'Подтверждено'; + statusLabels[APP.STATUS.NOT_FOUND] = 'Не найдено'; + statusLabels[APP.STATUS.UNCLEAR] = 'Уточнить'; + + lines.push('--- Критерии ---'); + criteria.forEach(function (c) { + var result = resultMap[c.id]; + var statusText = result ? statusLabels[result.status] || '—' : '—'; + var typeText = c.required ? 'Обязательное' : 'Желательное'; + lines.push('• ' + c.label + ' [' + typeText + ', вес ' + c.weight + '] — ' + statusText); + if (result && result.note) { + lines.push(' Примечание: ' + result.note); + } + }); + + lines.push(''); + lines.push('--- Вопросы для интервью ---'); + var interviewData = this.generateInterviewData(criteria, matchResults); + var qNum = 1; + interviewData.forEach(function (section) { + lines.push(''); + lines.push('▸ ' + section.criterion.label + ' (' + (statusLabels[section.status] || '—') + ')'); + section.questions.forEach(function (q) { + lines.push(' ' + qNum + '. ' + q); + qNum++; + }); + }); + + lines.push(''); + lines.push('---'); + lines.push('Данные обезличены. Сводка подготовлена в демонстрационном режиме.'); + lines.push('Персональные данные кандидата не включены.'); + + return lines.join('\n'); + }, + + formatNumber: function (num) { + try { + return new Intl.NumberFormat('ru-KZ').format(num); + } catch (e) { + return String(num); + } + }, + + formatCurrency: function (amount) { + try { + return new Intl.NumberFormat('ru-KZ', { + style: 'currency', + currency: 'KZT', + minimumFractionDigits: 0, + maximumFractionDigits: 0 + }).format(amount); + } catch (e) { + return amount + ' ₸'; + } + } + }; +})(); diff --git a/js/storage.js b/js/storage.js new file mode 100644 index 0000000..aa966ad --- /dev/null +++ b/js/storage.js @@ -0,0 +1,92 @@ +/* HR Candidate Matcher — Storage Layer */ +/* global window */ +(function () { + 'use strict'; + + var APP = window.HRMatcher = window.HRMatcher || {}; + + var NAMESPACE = 'hr_matcher'; + var SCHEMA_VERSION = 1; + var STORAGE_KEY = NAMESPACE + '_v' + SCHEMA_VERSION; + + var storageAvailable = false; + var memoryFallback = {}; + + function testStorage() { + try { + var test = '__storage_test__'; + localStorage.setItem(test, test); + localStorage.removeItem(test); + return true; + } catch (e) { + return false; + } + } + + storageAvailable = testStorage(); + + APP.storage = { + isAvailable: function () { return storageAvailable; }, + + save: function (data) { + var payload = { + version: SCHEMA_VERSION, + timestamp: Date.now(), + data: data + }; + if (storageAvailable) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); + return true; + } catch (e) { + if (e.name === 'QuotaExceededError' || e.code === 22) { + APP.ui && APP.ui.showToast && APP.ui.showToast('Хранилище переполнено. Изменения не сохраняются.', 'error'); + } + memoryFallback = payload; + return false; + } + } else { + memoryFallback = payload; + return false; + } + }, + + load: function () { + if (storageAvailable) { + try { + var raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + var parsed = JSON.parse(raw); + if (parsed && parsed.version === SCHEMA_VERSION && parsed.data) { + return parsed.data; + } + return null; + } catch (e) { + return null; + } + } + return memoryFallback && memoryFallback.data ? memoryFallback.data : null; + }, + + clear: function () { + if (storageAvailable) { + try { + localStorage.removeItem(STORAGE_KEY); + } catch (e) { /* ignore */ } + } + memoryFallback = {}; + }, + + savePartial: function (key, value) { + var current = this.load() || {}; + current[key] = value; + return this.save(current); + }, + + removePartial: function (key) { + var current = this.load() || {}; + delete current[key]; + return this.save(current); + } + }; +})(); diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..7d01903 --- /dev/null +++ b/js/ui.js @@ -0,0 +1,323 @@ +/* HR Candidate Matcher — UI Layer */ +/* global window, document */ +(function () { + 'use strict'; + + var APP = window.HRMatcher = window.HRMatcher || {}; + var $ = function (sel) { return document.querySelector(sel); }; + var $$ = function (sel) { return Array.prototype.slice.call(document.querySelectorAll(sel)); }; + + var toastTimer = null; + var confirmCallback = null; + var currentFilter = 'all'; + var criteriaEditState = []; + + APP.ui = { + showToast: function (message, type) { + var container = $('#toast-container'); + if (!container) return; + var toast = document.createElement('div'); + toast.className = 'toast' + (type ? ' toast-' + type : ''); + toast.textContent = message; + container.appendChild(toast); + clearTimeout(toastTimer); + toastTimer = setTimeout(function () { + toast.classList.add('toast-out'); + setTimeout(function () { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 200); + }, 3000); + }, + + showConfirm: function (message, onConfirm) { + var modal = $('#modal-confirm'); + var desc = $('#confirm-desc'); + if (!modal || !desc) return; + desc.textContent = message; + modal.hidden = false; + confirmCallback = onConfirm; + $('#btn-confirm-ok').focus(); + }, + + hideConfirm: function () { + var modal = $('#modal-confirm'); + if (modal) modal.hidden = true; + confirmCallback = null; + }, + + resolveConfirm: function () { + if (confirmCallback) confirmCallback(); + }, + + showError: function (fieldId, message) { + var field = $('#field-' + fieldId); + var error = $('#' + fieldId + '-error'); + if (field) field.classList.add('has-error'); + if (error) { + error.textContent = message; + error.hidden = false; + } + }, + + clearError: function (fieldId) { + var field = $('#field-' + fieldId); + var error = $('#' + fieldId + '-error'); + if (field) field.classList.remove('has-error'); + if (error) { + error.textContent = ''; + error.hidden = true; + } + }, + + clearAllErrors: function () { + $$('.field').forEach(function (f) { f.classList.remove('has-error'); }); + $$('.field-error').forEach(function (e) { e.textContent = ''; e.hidden = true; }); + }, + + showScreen: function (screenName) { + $$('.screen').forEach(function (s) { s.hidden = true; }); + var screen = $('#screen-' + screenName); + if (screen) { + screen.hidden = false; + screen.removeAttribute('hidden'); + } + + var nav = $('#app-nav'); + var clearBtn = $('#btn-clear-all'); + if (screenName === 'welcome') { + if (nav) nav.hidden = true; + if (clearBtn) clearBtn.hidden = true; + } else { + if (nav) nav.hidden = false; + if (clearBtn) clearBtn.hidden = false; + } + + this.updateStepsIndicator(screenName); + window.scrollTo(0, 0); + }, + + updateStepsIndicator: function (current) { + var steps = ['input', 'criteria', 'match', 'interview']; + var currentIdx = steps.indexOf(current); + $$('.step').forEach(function (el) { + var stepName = el.getAttribute('data-step'); + var stepIdx = steps.indexOf(stepName); + el.classList.remove('step-active', 'step-done'); + if (stepIdx === currentIdx) el.classList.add('step-active'); + else if (stepIdx < currentIdx) el.classList.add('step-done'); + }); + }, + + renderCriteriaTable: function (criteria) { + var tbody = $('#criteria-tbody'); + var emptyMsg = $('#criteria-empty'); + var tableWrap = $('.criteria-table-wrap'); + if (!tbody) return; + + criteriaEditState = criteria.map(function (c) { + return { + id: c.id, + label: c.label, + required: c.required, + weight: c.weight, + synonyms: (c.synonyms || []).join(', ') + }; + }); + + if (criteria.length === 0) { + tbody.innerHTML = ''; + if (tableWrap) tableWrap.hidden = true; + if (emptyMsg) emptyMsg.hidden = false; + return; + } + + if (tableWrap) tableWrap.hidden = false; + if (emptyMsg) emptyMsg.hidden = true; + + var html = ''; + for (var i = 0; i < criteriaEditState.length; i++) { + var c = criteriaEditState[i]; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + } + tbody.innerHTML = html; + }, + + readCriteriaFromTable: function () { + var rows = $$('#criteria-tbody tr'); + var criteria = []; + for (var i = 0; i < rows.length; i++) { + var row = rows[i]; + var id = row.getAttribute('data-id'); + var label = row.querySelector('[data-field="label"]').value.trim(); + var required = row.querySelector('[data-field="required"]').value === 'true'; + var weight = parseInt(row.querySelector('[data-field="weight"]').value, 10); + var synonymsStr = row.querySelector('[data-field="synonyms"]').value; + var synonyms = synonymsStr.split(',').map(function (s) { return s.trim(); }).filter(Boolean); + criteria.push({ id: id, label: label, required: required, weight: weight, synonyms: synonyms }); + } + return criteria; + }, + + renderMatchTable: function (criteria, matchResults, filter) { + var tbody = $('#match-tbody'); + var emptyMsg = $('#match-empty'); + var tableWrap = $('.match-table-wrap'); + if (!tbody) return; + + var filtered = APP.logic.getFilteredResults(matchResults, filter); + + if (filtered.length === 0) { + tbody.innerHTML = ''; + if (tableWrap) tableWrap.hidden = true; + if (emptyMsg) emptyMsg.hidden = false; + return; + } + + if (tableWrap) tableWrap.hidden = false; + if (emptyMsg) emptyMsg.hidden = true; + + var html = ''; + for (var i = 0; i < filtered.length; i++) { + var r = filtered[i]; + var c = APP.logic.getCriterionById(criteria, r.criterionId); + if (!c) continue; + + var statusClass = 'status-' + r.status.replace('_', '-'); + var statusIcon = ''; + var statusText = ''; + if (r.status === APP.STATUS.CONFIRMED) { + statusIcon = ''; + statusText = 'Подтверждено'; + } else if (r.status === APP.STATUS.NOT_FOUND) { + statusIcon = ''; + statusText = 'Не найдено'; + } else { + statusIcon = ''; + statusText = 'Уточнить'; + } + + var evidenceHtml = ''; + if (r.evidence && r.evidence.length > 0) { + evidenceHtml = '
    '; + for (var e = 0; e < r.evidence.length; e++) { + evidenceHtml += '
  • ' + escapeHtml(r.evidence[e].fragment) + '
  • '; + } + evidenceHtml += '
'; + } else { + evidenceHtml = 'Фрагменты не найдены'; + } + + html += ''; + html += '' + escapeHtml(c.label) + ''; + html += '' + evidenceHtml + ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + } + tbody.innerHTML = html; + }, + + renderCoverage: function (coverage) { + var bar = $('#coverage-bar-fill'); + var value = $('#coverage-value'); + if (bar) { + bar.style.width = coverage + '%'; + bar.setAttribute('aria-valuenow', coverage); + } + if (value) { + value.textContent = coverage + '%'; + } + }, + + renderInterview: function (interviewData) { + var container = $('#interview-questions'); + var emptyMsg = $('#interview-empty'); + if (!container) return; + + if (interviewData.length === 0) { + container.innerHTML = ''; + if (emptyMsg) emptyMsg.hidden = false; + return; + } + + if (emptyMsg) emptyMsg.hidden = true; + + var statusLabels = {}; + statusLabels[APP.STATUS.NOT_FOUND] = 'Не найдено'; + statusLabels[APP.STATUS.UNCLEAR] = 'Требует уточнения'; + + var html = ''; + var globalNum = 1; + for (var i = 0; i < interviewData.length; i++) { + var section = interviewData[i]; + html += '
'; + html += '
' + escapeHtml(section.criterion.label); + html += ' ' + (statusLabels[section.status] || section.status) + ''; + html += '
'; + html += '
'; + html += '
'; + if (section.criterion.required) html += 'Обязательное'; + else html += 'Желательное'; + html += ' · Вес ' + section.criterion.weight; + html += '
'; + html += '
    '; + for (var q = 0; q < section.questions.length; q++) { + html += '
  1. ' + globalNum + '. ' + escapeHtml(section.questions[q]) + '
  2. '; + globalNum++; + } + html += '
'; + html += '
'; + html += '
'; + } + container.innerHTML = html; + }, + + updateFilterChips: function (activeFilter) { + $$('.chip').forEach(function (chip) { + var f = chip.getAttribute('data-filter'); + if (f === activeFilter) { + chip.classList.add('chip-active'); + } else { + chip.classList.remove('chip-active'); + } + }); + } + }; + + function escapeHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function escapeAttr(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + } +})(); diff --git a/style.css b/style.css new file mode 100644 index 0000000..5aad674 --- /dev/null +++ b/style.css @@ -0,0 +1,933 @@ +/* HR Candidate Matcher — Custom Design System */ +/* no-design-system */ + +:root { + --color-bg: #F5F4EF; + --color-surface: #FFFFFF; + --color-text: #202922; + --color-text-secondary: #59645C; + --color-accent: #365F48; + --color-accent-hover: #2A4D39; + --color-border: #D9DFD7; + --color-success: #2F7A4F; + --color-warning: #A8641A; + --color-error: #B23B3B; + --color-error-bg: #FEF2F2; + --color-success-bg: #F0FDF4; + --color-warning-bg: #FFFBEB; + + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 24px; + --space-6: 32px; + --space-7: 48px; + --space-8: 64px; + + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + --font-mono: "SF Mono", "Cascadia Code", "Fira Code", "JetBrains Mono", Menlo, Monaco, Consolas, "Liberation Mono", monospace; + + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.25rem; + --text-2xl: 1.5rem; + --text-3xl: 2rem; + + --shadow-sm: 0 1px 2px rgba(0,0,0,0.05); + --shadow-md: 0 4px 6px -1px rgba(0,0,0,0.07), 0 2px 4px -2px rgba(0,0,0,0.05); + + --transition-fast: 150ms ease; + --transition-normal: 200ms ease; +} + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + -webkit-text-size-adjust: 100%; +} + +body { + font-family: var(--font-sans); + font-size: var(--text-base); + line-height: 1.5; + color: var(--color-text); + background: var(--color-bg); + min-height: 100vh; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border: 0; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 var(--space-6); +} + +@media (max-width: 768px) { + .container { padding: 0 var(--space-5); } +} +@media (max-width: 390px) { + .container { padding: 0 var(--space-4); } +} + +/* Header */ +.app-header { + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); + position: sticky; + top: 0; + z-index: 100; +} +.app-header-inner { + display: flex; + align-items: center; + gap: var(--space-4); + height: 56px; +} +.app-logo { + display: flex; + align-items: center; + gap: var(--space-2); + color: var(--color-accent); + font-weight: 600; + font-size: var(--text-sm); + white-space: nowrap; + flex-shrink: 0; +} +.app-logo svg { flex-shrink: 0; } +.app-logo-text { display: none; } +@media (min-width: 768px) { + .app-logo-text { display: inline; } +} +.app-nav { flex: 1; display: flex; justify-content: center; } +.app-nav[hidden] { display: none; } +#btn-clear-all { margin-left: auto; flex-shrink: 0; } +#btn-clear-all[hidden] { display: none; } + +/* Steps indicator */ +.steps { + list-style: none; + display: flex; + gap: var(--space-2); + align-items: center; +} +.step { + display: flex; + align-items: center; + gap: var(--space-1); + font-size: var(--text-xs); + color: var(--color-text-secondary); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + white-space: nowrap; +} +.step-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + font-size: 11px; + font-weight: 600; + background: var(--color-border); + color: var(--color-text-secondary); +} +.step-active { + color: var(--color-accent); + font-weight: 600; +} +.step-active .step-num { + background: var(--color-accent); + color: #fff; +} +.step-done .step-num { + background: var(--color-success); + color: #fff; +} +@media (max-width: 600px) { + .step span:not(.step-num) { display: none; } +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + font-family: inherit; + font-size: var(--text-sm); + font-weight: 500; + line-height: 1.5; + border: 1px solid transparent; + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-fast); + text-decoration: none; + white-space: nowrap; + min-height: 44px; +} +.btn:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.btn-primary { + background: var(--color-accent); + color: #fff; + border-color: var(--color-accent); +} +.btn-primary:hover:not(:disabled) { + background: var(--color-accent-hover); + border-color: var(--color-accent-hover); +} +.btn-outline { + background: transparent; + color: var(--color-accent); + border-color: var(--color-border); +} +.btn-outline:hover:not(:disabled) { + background: var(--color-accent); + color: #fff; + border-color: var(--color-accent); +} +.btn-ghost { + background: transparent; + color: var(--color-text-secondary); + border-color: transparent; +} +.btn-ghost:hover:not(:disabled) { + background: rgba(0,0,0,0.04); + color: var(--color-text); +} +.btn-danger { + background: var(--color-error); + color: #fff; + border-color: var(--color-error); +} +.btn-danger:hover:not(:disabled) { + opacity: 0.9; +} +.btn-sm { + padding: var(--space-1) var(--space-2); + font-size: var(--text-xs); + min-height: 36px; +} +.btn-lg { + padding: var(--space-3) var(--space-5); + font-size: var(--text-base); + min-height: 48px; +} +.btn-back { + color: var(--color-text-secondary); +} + +/* Main Content */ +.main-content { + min-height: calc(100vh - 56px); +} +.screen[hidden] { display: none; } +.screen { + padding: var(--space-7) 0; + animation: fadeIn var(--transition-normal) ease; +} +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: none; } +} + +/* Welcome */ +.welcome-hero { + max-width: 640px; + margin: var(--space-8) auto; + text-align: center; +} +.welcome-title { + font-size: var(--text-3xl); + font-weight: 700; + color: var(--color-text); + margin-bottom: var(--space-4); + letter-spacing: -0.02em; +} +@media (max-width: 768px) { + .welcome-title { font-size: var(--text-2xl); } +} +.welcome-desc { + font-size: var(--text-lg); + color: var(--color-text-secondary); + margin-bottom: var(--space-6); + line-height: 1.6; +} +@media (max-width: 768px) { + .welcome-desc { font-size: var(--text-base); } +} +.welcome-actions { + display: flex; + gap: var(--space-3); + justify-content: center; + flex-wrap: wrap; + margin-bottom: var(--space-5); +} +.welcome-privacy { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + font-size: var(--text-sm); + color: var(--color-text-secondary); +} + +/* Screen Header */ +.screen-header { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-3); +} +.screen-title { + font-size: var(--text-2xl); + font-weight: 700; + letter-spacing: -0.01em; +} +@media (max-width: 768px) { + .screen-title { font-size: var(--text-xl); } +} +.screen-subtitle { + color: var(--color-text-secondary); + margin-bottom: var(--space-5); + font-size: var(--text-sm); +} + +/* Input Grid */ +.input-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-5); +} +@media (max-width: 768px) { + .input-grid { + grid-template-columns: 1fr; + } +} +.input-actions { + margin-top: var(--space-5); + display: flex; + justify-content: center; +} + +/* Fields */ +.field { margin-bottom: var(--space-4); } +.field-label { + display: block; + font-size: var(--text-sm); + font-weight: 500; + margin-bottom: var(--space-1); + color: var(--color-text); +} +.field-required { color: var(--color-error); } +.textarea { + width: 100%; + padding: var(--space-3); + font-family: inherit; + font-size: var(--text-sm); + line-height: 1.6; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + color: var(--color-text); + resize: vertical; + transition: border-color var(--transition-fast); +} +.textarea:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(54,95,72,0.1); +} +.textarea::placeholder { + color: var(--color-text-secondary); + opacity: 0.6; +} +.field-error { + margin-top: var(--space-1); + font-size: var(--text-xs); + color: var(--color-error); +} +.field-error[hidden] { display: none; } +.field.has-error .textarea { + border-color: var(--color-error); +} + +/* Select & Input */ +.select, .input { + width: 100%; + padding: var(--space-2) var(--space-3); + font-family: inherit; + font-size: var(--text-sm); + line-height: 1.5; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + color: var(--color-text); + min-height: 36px; +} +.select:focus, .input:focus { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(54,95,72,0.1); +} + +/* Criteria Table */ +.criteria-table-wrap { + overflow-x: auto; + margin-bottom: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); +} +.criteria-table-wrap[hidden] { display: none; } +.criteria-table { + width: 100%; + border-collapse: collapse; + font-size: var(--text-sm); +} +.criteria-table th { + text-align: left; + padding: var(--space-3) var(--space-4); + font-weight: 600; + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-secondary); + border-bottom: 1px solid var(--color-border); + background: var(--color-bg); + white-space: nowrap; +} +.criteria-table td { + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border); + vertical-align: middle; +} +.criteria-table tr:last-child td { border-bottom: none; } +.criteria-table .col-label { min-width: 200px; } +.criteria-table .col-type { width: 140px; } +.criteria-table .col-weight { width: 80px; } +.criteria-table .col-synonyms { min-width: 180px; } +.criteria-table .col-actions { width: 48px; text-align: center; } + +.criteria-empty { + text-align: center; + padding: var(--space-7) var(--space-4); + color: var(--color-text-secondary); +} +.criteria-empty[hidden] { display: none; } +.criteria-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; +} + +/* Inline edit inputs in criteria table */ +.criteria-table .input-inline { + width: 100%; + padding: var(--space-1) var(--space-2); + font-family: inherit; + font-size: var(--text-sm); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: transparent; + min-height: 32px; +} +.criteria-table .input-inline:focus { + outline: none; + border-color: var(--color-accent); + background: var(--color-surface); +} +.criteria-table .select-inline { + padding: var(--space-1) var(--space-2); + font-size: var(--text-sm); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: transparent; + min-height: 32px; + cursor: pointer; +} +.criteria-table .select-inline:focus { + outline: none; + border-color: var(--color-accent); +} + +/* Weight display */ +.weight-display { + display: inline-flex; + align-items: center; + gap: var(--space-1); + font-variant-numeric: tabular-nums; + font-weight: 500; +} + +/* Delete button in table */ +.btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + background: transparent; + color: var(--color-text-secondary); + border-radius: var(--radius-sm); + cursor: pointer; + transition: all var(--transition-fast); +} +.btn-icon:hover { background: var(--color-error-bg); color: var(--color-error); } +.btn-icon:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +/* Match Screen */ +.match-filter-bar { + display: flex; + gap: var(--space-2); + margin-bottom: var(--space-4); + flex-wrap: wrap; +} +.chip { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-3); + font-family: inherit; + font-size: var(--text-xs); + font-weight: 500; + border: 1px solid var(--color-border); + border-radius: 20px; + background: var(--color-surface); + color: var(--color-text-secondary); + cursor: pointer; + transition: all var(--transition-fast); + min-height: 32px; +} +.chip:hover { border-color: var(--color-accent); color: var(--color-accent); } +.chip:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; } +.chip-active { + background: var(--color-accent); + color: #fff; + border-color: var(--color-accent); +} +.chip-active:hover { background: var(--color-accent-hover); color: #fff; } + +.match-table-wrap { + overflow-x: auto; + margin-bottom: var(--space-5); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); +} +.match-table { + width: 100%; + border-collapse: collapse; + font-size: var(--text-sm); +} +.match-table th { + text-align: left; + padding: var(--space-3) var(--space-4); + font-weight: 600; + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-secondary); + border-bottom: 1px solid var(--color-border); + background: var(--color-bg); + white-space: nowrap; +} +.match-table td { + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border); + vertical-align: top; +} +.match-table tr:last-child td { border-bottom: none; } +.match-table .col-criterion { min-width: 160px; font-weight: 500; } +.match-table .col-evidence { min-width: 280px; } +.match-table .col-status { width: 140px; white-space: nowrap; } +.match-table .col-note { min-width: 180px; } + +/* Status badges */ +.status-badge { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + font-size: var(--text-xs); + font-weight: 500; +} +.status-confirmed { + background: var(--color-success-bg); + color: var(--color-success); +} +.status-not-found { + background: #F5F0EB; + color: var(--color-warning); +} +.status-unclear { + background: #F0F4FF; + color: #4A6FA5; +} + +/* Evidence fragments */ +.evidence-list { + list-style: none; + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.evidence-fragment { + font-family: var(--font-mono); + font-size: var(--text-xs); + line-height: 1.5; + background: var(--color-bg); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + border-left: 3px solid var(--color-border); + color: var(--color-text); + word-break: break-word; +} +.evidence-fragment mark { + background: #FEF3C7; + color: inherit; + padding: 0 2px; + border-radius: 2px; +} + +/* Coverage bar */ +.match-coverage { + margin-bottom: var(--space-5); + padding: var(--space-4); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} +.coverage-label { + font-size: var(--text-sm); + color: var(--color-text-secondary); + margin-bottom: var(--space-2); +} +.coverage-bar-track { + width: 100%; + height: 8px; + background: var(--color-bg); + border-radius: 4px; + overflow: hidden; + margin-bottom: var(--space-1); +} +.coverage-bar-fill { + height: 100%; + background: var(--color-accent); + border-radius: 4px; + transition: width 400ms ease; + min-width: 0; +} +.coverage-value { + font-size: var(--text-2xl); + font-weight: 700; + color: var(--color-accent); + font-variant-numeric: tabular-nums; +} + +.match-empty { + text-align: center; + padding: var(--space-7) var(--space-4); + color: var(--color-text-secondary); +} +.match-empty[hidden] { display: none; } +.match-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; +} + +/* Note input in match table */ +.note-input { + width: 100%; + padding: var(--space-1) var(--space-2); + font-family: inherit; + font-size: var(--text-xs); + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-secondary); + min-height: 28px; + transition: all var(--transition-fast); +} +.note-input:hover { border-color: var(--color-border); } +.note-input:focus { + outline: none; + border-color: var(--color-accent); + background: var(--color-surface); +} + +/* Status select in match table */ +.status-select { + padding: var(--space-1) var(--space-2); + font-family: inherit; + font-size: var(--text-xs); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + cursor: pointer; + min-height: 32px; +} +.status-select:focus { + outline: none; + border-color: var(--color-accent); +} + +/* Modal */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + padding: var(--space-4); + animation: fadeIn var(--transition-fast); +} +.modal-overlay[hidden] { display: none; } +.modal-panel { + background: var(--color-surface); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + max-width: 900px; + width: 100%; + max-height: 85vh; + overflow-y: auto; + padding: var(--space-5); +} +.modal-sm { max-width: 420px; } +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-4); +} +.modal-header h3 { + font-size: var(--text-lg); + font-weight: 600; +} +.modal-body { margin-bottom: var(--space-4); } +.modal-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + margin-top: var(--space-4); +} + +/* Source texts modal */ +.source-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-5); +} +@media (max-width: 768px) { + .source-grid { grid-template-columns: 1fr; } +} +.source-col h4 { + font-size: var(--text-sm); + font-weight: 600; + margin-bottom: var(--space-2); + color: var(--color-text-secondary); +} +.source-text { + font-family: var(--font-mono); + font-size: var(--text-xs); + line-height: 1.6; + background: var(--color-bg); + padding: var(--space-3); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + white-space: pre-wrap; + word-break: break-word; + max-height: 400px; + overflow-y: auto; + color: var(--color-text); +} + +/* Interview Screen */ +.demo-badge { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background: var(--color-warning-bg); + color: var(--color-warning); + border-radius: var(--radius-md); + font-size: var(--text-xs); + font-weight: 500; + margin-bottom: var(--space-5); +} + +.interview-section { + margin-bottom: var(--space-5); +} +.interview-section-title { + font-size: var(--text-base); + font-weight: 600; + margin-bottom: var(--space-3); + display: flex; + align-items: center; + gap: var(--space-2); +} +.interview-card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-4); + margin-bottom: var(--space-3); +} +.interview-card-criterion { + font-size: var(--text-xs); + font-weight: 600; + color: var(--color-accent); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--space-2); +} +.interview-card-status { + margin-bottom: var(--space-2); +} +.interview-questions-list { + list-style: none; + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.interview-question { + padding: var(--space-2) var(--space-3); + background: var(--color-bg); + border-radius: var(--radius-sm); + font-size: var(--text-sm); + line-height: 1.5; + display: flex; + gap: var(--space-2); +} +.interview-question-num { + color: var(--color-accent); + font-weight: 600; + flex-shrink: 0; +} +.interview-empty { + text-align: center; + padding: var(--space-7) var(--space-4); + color: var(--color-text-secondary); +} +.interview-empty[hidden] { display: none; } +.interview-actions { + display: flex; + gap: var(--space-3); + flex-wrap: wrap; + margin-top: var(--space-5); +} + +/* Toast */ +.toast-container { + position: fixed; + bottom: var(--space-5); + right: var(--space-5); + z-index: 300; + display: flex; + flex-direction: column; + gap: var(--space-2); + pointer-events: none; +} +.toast { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + background: var(--color-text); + color: #fff; + border-radius: var(--radius-md); + font-size: var(--text-sm); + box-shadow: var(--shadow-md); + pointer-events: auto; + animation: toastIn 200ms ease; + max-width: 360px; +} +.toast-success { background: var(--color-success); } +.toast-error { background: var(--color-error); } +@keyframes toastIn { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: none; } +} +.toast-out { + animation: toastOut 150ms ease forwards; +} +@keyframes toastOut { + to { opacity: 0; transform: translateY(12px); } +} + +/* Confirm dialog */ +#modal-confirm h3 { + font-size: var(--text-lg); + font-weight: 600; + margin-bottom: var(--space-3); +} +#modal-confirm p { + color: var(--color-text-secondary); + font-size: var(--text-sm); + line-height: 1.5; +} + +/* Tabular numbers */ +.tabular { font-variant-numeric: tabular-nums; } + +/* Print / export helpers */ +@media print { + .app-header, .btn-back, .btn, .toast-container, .modal-overlay { display: none !important; } + .screen { padding: 0; } + .match-table-wrap, .criteria-table-wrap { border: 1px solid #ccc; } +}