Журнал изменений
GitHubКаждое значимое изменение, релиз за релизом. Тот же файл публикуется в репозитории и переведён на все 19 языков.
[1.58.0] — 2026-05-19
fix(qa): прогон багов из внешнего QA-отчёта + чистый форматированный вывод исследований. Исправлено: BUG-001 — #/followup валидирует необязательное поле даты как ISO YYYY-MM-DD на клиенте (мусор больше не уходит в LLM); BUG-003 — **bold**/`code`/ссылки теперь рендерятся внутри блок-цитат в UI.md() (на всех страницах Help); BUG-005 — повторный URL в pipeline даёт честный тост «Уже в очереди — пропущено»; BUG-006 — человеческое сообщение о неверном URL (контекст (POST /api/pipeline · HTTP 400) сохранён намеренно); BUG-007/008 — прогресс-тост «Running doctor.mjs…» убирается перед модалкой (новый UI.dismissToast()), заголовок модалки = локализованная подпись кнопки; BUG-010 — у пустого состояния #/reports появился подзаголовок; BUG-002/UX-032 — checkProfileCustomized() помечает тест-фикстуры (Acceptance Test и др.) как «не настроено» (родительские profile.yml/cv.md не трогаем — hard rule #1); I18N-012/013 — русский Deep research реально переведён. Новое: server/lib/llm-output.mjs::cleanLlmMarkdown() убирает echo агентного скаффолдинга (<tool_call>{…}</tool_call>, <tool_response>, <tool_use>, <function_call>, <thinking>, [TOOL_CALL]…) из #/deep и Сохранённых исследований — на всех провайдерах и при отдаче уже сохранённых файлов; алиас #/outreach→#/contacto (BUG-004); сетевая ошибка клиента локализована через I18n.t() (api.netError/api.netHint, 8 локалей; серверные details намеренно английские — диагностические). Тесты: новые tests/qa-report-fixes.test.mjs (10) и tests/llm-output.test.mjs (5); 881 → 896 юнит; Playwright 58/58; e2e 20/20 + 23/23. Не менялось (с обоснованием): BUG-009 (H1 #/cv — by-design, WCAG single-h1), родительские данные (parent-owned), длинный хвост minor i18n/UX — в бэклоге. Подробно — CHANGELOG.md.
[1.57.2] — 2026-05-19
fix(config): НАСТОЯЩАЯ причина «validation failed» на /#/config — внедряемое SPA поле lang. public/js/api.js авто-добавляет поле lang в каждое JSON-POST тело (чтобы LLM-маршруты подхватывали локаль UI). /api/config — не LLM-маршрут, и lang не является ключом конфигурации, поэтому (корректная, важная для безопасности) проверка неизвестных ключей в validateConfig отвечала 400 на каждое Сохранение: validation failed — lang: not a known config key. Это проявлялось только в браузере: репро через curl/in-process не слали lang, поэтому v1.57.0/.1 (триминг пробелов, описательные ошибки) улучшили сообщение, но не устранили причину. Маршрут конфигурации теперь убирает транспортное поле lang до валидации; фильтр записи по KNOWN_KEYS по-прежнему отбрасывает любой действительно неизвестный ключ — защита от инъекции не изменилась. Найдено новым Playwright-обходом форм, который кликает реальную кнопку Save (а не синтетические fetch-репро, маскировавшие баг). Тесты: новый tests/playwright-forms.mjs (26, включён в npm run test:e2e:browser) — реальный Chromium по всем формам: нет console-ошибок по всему сайту, тост невалидного сохранения показывает поле+причину+контекст запроса, секретные поля не эхо-выводят введённое, валидные сохранения проходят, PORT/HOST показывают предзаполненные значения по умолчанию; config-endpoint — паритет с браузером (POST с lang проходит и lang не попадает в .env; реальный неизвестный ключ при наличии lang по-прежнему отклоняется). 879 → 881 юнит; Playwright 32 → 58. Подробно — CHANGELOG.md.
[1.57.1] — 2026-05-19
fix(ux): каждая ошибка API теперь говорит ЧТО упало, ГДЕ и ПОЧЕМУ; текст ошибки ввода — максимально подробный. Сервер и раньше возвращал { error, details: ["ПОЛЕ: причина", …] }, но все формы показывали в тосте только верхнюю строку («validation failed»), и на /#/config (и везде) было непонятно, какое поле не так. api.js теперь подмешивает пофайловые details в сообщение по всему сайту (одна правка — выигрывают все формы), добавляет контекст запроса (МЕТОД /путь · HTTP NNN) (ГДЕ), при не-JSON ответе показывает обрезанный сырой фрагмент, а сетевые ошибки несут метод+путь; err.details доступен для inline-рендеринга. Сообщения validateConfig стали максимально описательными (что не так и как исправить), напр. PORT: must be 1-65535 — целое число, только цифры (по умолчанию 4317); вы ввели "abc". Секретные ключи никогда не эхо-выводят введённое значение (только длину в символах) — опечатка реального ключа не утечёт в тост/лог. Диапазон PORT теперь действительно проверяется (5-значное >65535, напр. 99999, раньше принималось — теперь отклоняется с понятной причиной). На /#/config поля PORT и HOST предзаполнены реальными значениями по умолчанию (4317 / 127.0.0.1). Тосты ошибок держатся на экране дольше (9–20 с в зависимости от длины) и переносятся/скроллятся, а не обрезаются. Тесты: новый tests/config-validation-detail.test.mjs (12); 874 → 879. Подробно — CHANGELOG.md.
[1.57.0] — 2026-05-19
feat(provider): OpenRouter — 5-й headless-провайдер живой оценки + fix(config): «validation failed» при сохранении любого API-ключа. Вставленные ключи часто приходят с хвостовым переносом строки или пробелами (буфер ОС, кнопки «копировать» в консолях провайдеров) — до 1.57 это роняло проверку переноса строки для каждого провайдера, а $-якорная регулярка ANTHROPIC_API_KEY (/^sk-ant-[A-Za-z0-9_-]{20,}$/) ложно отвергала настоящие ключи Anthropic. Теперь validateConfig нормализует (тримит) каждое значение до проверки, маршрут сохраняет уже обрезанное значение (аутентифицируется в рантайме, без порчи .env кавычками из-за \n), а проверка Anthropic — устойчивый префикс sk-ant- + длина (общий порог isUsableKey() ≥ 20 остаётся реальным «настоящий ли это ключ»). Внутренние переносы строки по-прежнему отвергаются (защита от инъекции в .env). OpenRouter теперь полноценный провайдер: OPENROUTER_API_KEY на /#/config — один ключ открывает 300+ моделей (Anthropic, OpenAI, Google, Meta, Qwen, DeepSeek …). Он последний в порядке auto (Anthropic → Gemini → OpenAI → Qwen → OpenRouter), поэтому существующая настройка никогда молча не перенаправляется; LLM_PROVIDER=openrouter фиксирует его. Подключён в тот же путь _tailProvider(), что OpenAI/Qwen, в /api/evaluate, /api/deep, /api/mode/:slug; виден в /api/status/providers + на дашборде Health. Клиент OpenAI-совместимый (без новых зависимостей — прямой fetch, таймаут AbortController, ключ не логируется) с рекомендованными заголовками HTTP-Referer/X-Title. Выпадающий список моделей — живой: OPENROUTER_MODEL заполняется из GET /api/openrouter/models (серверный прокси публичного каталога OpenRouter — сохраняет CSP connect-src 'self'; без запроса браузер→третья сторона), с кураторским fallback при недоступности каталога и 10-минутным in-memory кэшем. Новые i18n-ключи (config.openrouter*) во всех 8 локалях; config.llmProviderHint обновлён. Тесты: новые CI-изолированные наборы tests/openrouter-route.test.mjs и tests/openrouter-model-selector.test.mjs; расширены наборы env-config/openai/provider-selector; два прежде слабых кейса validateConfig (использовали более не известный HH_USER_AGENT) теперь проверяют настоящий ключ. 831 → 855. Подробно — CHANGELOG.md.
[1.56.4] — 2026-05-19
feat(ui): UX-N2 — видимая платформозависимая подсказка ⌘K / Ctrl K на поле глобального поиска. Шорткат Cmd/Ctrl+K («фокус на поиск») жил только в aria-label/исходнике — зрячие пользователи его не находили, и приложение казалось медленнее, чем есть. Теперь приглушённый <kbd class="kbd-shortcut"> стоит в конце строки поиска и заполняется при загрузке из data-mac/data-other по проверке платформы (navigator.platform/userAgent): ⌘K на macOS/iOS, Ctrl K в остальных. Он aria-hidden="true" (существующий aria-label уже озвучивает шорткат скринридеру — бейдж не должен дублировать) и pointer-events:none (декоративный). Существующая привязка Cmd/Ctrl+K не изменена. Новых i18n-ключей нет (глифы универсальны); бейдж — flex-потомок существующего .searchbar (без обёртки/абсолютного позиционирования — input уже flex:1). Тесты: новый CI-изолированный статичный-по-исходнику набор tests/cmdk-hint-visible.test.mjs (5): <kbd class="kbd-shortcut"> внутри .searchbar; aria-hidden="true" + оба варианта data-mac/data-other; app.js заполняет его по проверке navigator; привязка (e.ctrlKey||e.metaKey)&&e.key==='k' → search.focus() цела (регресс-гард); app.css стилизует .kbd-shortcut и не ставит display:none. 826 → 831. feat(ui) · test: tests/cmdk-hint-visible.test.mjs. Подробно — CHANGELOG.md.
[1.56.3] — 2026-05-19
fix(reliability): определение ключей провайдеров отвергает плейсхолдеры / слишком короткие значения, а не только пустую строку. Плейсхолдер GEMINI_API_KEY в родительском .env показывался как «✓ set» И выбирался активным провайдером вместо валидного ANTHROPIC_API_KEY. effectiveEnv() отвергал лишь undefined/'', поэтому 10-символьный мусор считался настоящим ключом: баннер онбординга показывал GEMINI ✓ set, GET /api/status/providers возвращал activeProvider: "gemini", и каждый живой ⚡-прогон молча падал бы на мёртвом ключе, игнорируя рабочий 108-символьный Anthropic. Новая чистая isUsableKey() (env-config.mjs) считает секрет настроенным только если он ≥ 20 символов (ни один реальный ключ не короче — Gemini AIza… ≈ 39, Anthropic sk-ant-… ≈ 100+, OpenAI ≥ 40, Qwen ≈ 35) и не известный плейсхолдер (your_*_here, changeme, placeholder, <…>, один повторённый символ…). Применено единообразно к hasAnthropicKey()/hasGeminiKey() (anthropic.mjs), hasOpenAIKey()/hasQwenKey() (openai.mjs) и строкам GEMINI_API_KEY/ANTHROPIC_API_KEY в GET /api/health (переведены с сырого process.env на тот же effective+plausible-взгляд) — теперь health-страница, эндпоинт провайдеров и OR-роутер всегда согласованы. selectActiveProvider() не менялся — получает корректный keysConfigured. Тесты: новый CI-изолированный набор tests/key-detection-rejects-placeholder.test.mjs (5): юнит-кейсы isUsableKey + in-process createApp() воспроизведение точного сценария (10-символьный GEMINI_API_KEY + реальный ANTHROPIC_API_KEY во временном .env) — gemini НЕ в keysConfigured, activeProvider === "anthropic", строки /api/health согласованы. Четырём существующим тестам слоёв effective-env удлинили слишком короткие заглушки (контракт не изменился). 821 → 826. fix(reliability) · test: tests/key-detection-rejects-placeholder.test.mjs. Подробно — CHANGELOG.md.
[1.56.2] — 2026-05-19
feat(a11y): UX-N1 — по-маршрутный локализованный document.title (ориентация в мультитабах + объявление смены страницы скринридером). До фикса все 24 маршрута сохраняли статический <title> из index.html («career-ops — command center») — одинаковые подписи вкладок, общие закладки, одинаковое объявление «страница изменилась». public/js/router.js focusNewView() теперь берёт заголовок из собственного локализованного <h1 class="page-title"> вида — «Вид — career-ops» — поэтому заголовки автоматически переведены (без новых i18n-ключей) и различны для каждого маршрута. Устанавливается до guard’а первого пейнта, чтобы стартовая вкладка тоже была озаглавлена (тот же порядок, что и установка tabindex в v1.56.0 UX-12). Фолбэк — career-ops — command center, если у вида нет заголовка. Тесты: новый CI-изолированный статичный-по-исходнику набор tests/document-title-per-route.test.mjs (4): focusNewView присваивает document.title; заголовок берётся из <h1> (по-маршрутный + локализованный, не один литерал); присваивание идёт до !firstPaintDone; присутствует продуктовый дефолт. 817 → 821. feat(a11y) · test: tests/document-title-per-route.test.mjs. Подробно — CHANGELOG.md.
[1.56.1] — 2026-05-19
fix(a11y): убрана ложная фирменная рамка фокуса вокруг заголовка при программном фокусе роутера на tabindex="-1". public/js/router.js ставит каждому заголовку целевого вида tabindex="-1" и вызывает .focus() при каждой клиентской навигации (озвучивание новой страницы скринридером). Элемент tabindex="-1" недостижим с клавиатуры, но эвристика :focus-visible в Chromium всё равно рисовала глобальную фирменную рамку (*:focus-visible { outline: 2px solid var(--rausch) }) — красный прямоугольник вокруг заголовка страницы (например, «Command Center» на #/dashboard) при каждой навигации; та же рамка попала в hero-скриншоты images/dashboard-*.png. Исправление — одно точечное правило [tabindex="-1"]:focus, [tabindex="-1"]:focus-visible { outline: none } (паттерн управляемого фокуса WAI-ARIA APG). Настоящий клавиатурный фокус на интерактивных элементах сохраняет глобальную рамку *:focus-visible (WCAG 2.4.7 цел); рамка skip-link не затронута (это <a>, не tabindex="-1", с более высокой специфичностью). Все 8 images/dashboard-*.png перегенерированы — красной рамки больше нет. Тесты: новый CI-изолированный статичный-по-исходнику набор tests/managed-focus-no-ring.test.mjs (4): глобальная рамка *:focus-visible на месте (WCAG 2.4.7 не нарушен); [tabindex="-1"]:focus,:focus-visible ⇒ outline:none; правило-подавление идёт после глобального (безопасность каскада); фикс точечный (нет общего *:focus{outline:none}). В паре с tests/dashboard-initial-focus.test.mjs. 813 → 817. fix(a11y) · test: tests/managed-focus-no-ring.test.mjs. Подробно — CHANGELOG.md.
[1.56.0] — 2026-05-19
feat(ux): пакет LOW-полировки — UX-9 / UX-10 / UX-11 / UX-12 (один сгруппированный минорный релиз). UX-9 #/cv: заголовок страницы понижен до тихого чипа-хлебной-крошки .cv-breadcrumb, шумный подзаголовок убран в title-тултип <h1> — резюме пользователя (его имя в превью) получает визуальный приоритет. Инвариант F-V54-A цел — по-прежнему ровно один <h1>, по-прежнему .page-title. UX-10 новый общий хелпер UI.providerCostHint(t) рядом с ⚡ Запуск вживую на #/auto, #/evaluate, #/deep и каждом #/<mode>; переиспользует GET /api/status/providers (v1.55.3): с ключом — «Оценочная стоимость: OpenAI gpt-5-codex · ~$0.04/eval» (порядок величины, “~”); без ключа — что ⚡ копирует ручной промпт без затрат API; fail-soft. UX-11 #/help: когда фильтр TOC сужается ровно до одной секции, страница прокручивается туда после 300мс простоя (debounce; никогда при 0 или >1). UX-12 #/dashboard: на первом пейнте <h1> делается фокусируемым (tabindex="-1"), #content остаётся aria-live="polite" (озвучивается при загрузке) — без кражи фокуса (не воюет со skip-link, решение v1.41.0). Новые i18n-ключи cost.estimate, cost.manual ×8; новый CSS .cv-breadcrumb/.cost-hint. Тесты: 4 новых статичных-по-исходнику CI-изолированных набора (cv-breadcrumb 3, run-cost-line 4, help-toc-autoscroll 4, dashboard-initial-focus 3); обновлены прежние локи cv-single-h1/help-nav-a11y (инварианты сохранены). 800 → 813. Живой Playwright-зонд всех 4, 0 ошибок консоли. feat(ux) · 4 test suites. Подробно — CHANGELOG.md.
[1.55.8] — 2026-05-19
feat(tracker): серверная пагинация + кликабельные чипы воронки (UX-8). Сервер: GET /api/tracker получает необязательные query-параметры ?page / ?pageSize / ?status. Без них ответ байт-в-байт равен прежнему { rows: [...] } (все существующие вызовы/тесты не затронуты). С ними возвращает { rows: slice, total, page, pageSize, funnel } — pageSize ограничен [1,500], page ≥1, status фильтрует rows+total, а funnel — разбивка статус→количество по всей истории (независимо от страницы/фильтра, чтобы чипы всегда были точны). #/tracker: новая кликабельная панель чипов воронки сверху — «все статусы · N · Applied · N · Interview · N …» (порядок Applied → Responded → Interview → Offer → Rejected → Discarded → Evaluated → SKIP). Клик по чипу задаёт фильтр Status (повторный клик по активному — сброс); активный чип aria-pressed и подсвечен. Новый i18n-ключ track.funnelAria ×8; новый CSS .tracker-funnel/.tracker-chip/.tracker-chip--active. test: tests/tracker-server-paged.test.mjs (новый, 7 кейсов, CI-изолирован, in-process Express на эфемерном порту + временный CAREER_OPS_ROOT applications.md — CLAUDE.md #2/#8): back-compat (без параметров ⇒ ровно {rows}); ?page&pageSize срез + total/page/pageSize/funnel с суммой N; последняя неполная страница без перекрытия; страница вне диапазона ⇒ пустые rows + валидный total; ?status= фильтрует total/rows, funnel по всей истории; кап pageSize; + статичный-по-исходнику лок панели чипов. 793 → 800. feat(tracker) · test: tests/tracker-server-paged.test.mjs. Подробно — CHANGELOG.md.
[1.55.7] — 2026-05-19
feat(pipeline): vanilla-JS виртуализация строк при >1000 строках (UX-7). #/pipeline рендерил каждую строку (filtered.forEach(list.appendChild(urlRow))) — скан наполняет очередь тысячами URL, поэтому тысячи узлов строк (каждый — flex-div + <a> + две кнопки) строились синхронно на каждое нажатие в фильтре, переполняя DOM и дерево доступности. Новая vanilla-JS виртуализация (эквивалент react-window, без зависимостей): выше VIRTUALIZE_THRESHOLD = 1000 #/pipeline становится скролл-вьюпортом фиксированной высоты (70vh) с несжимаемым спейсером (flex:0 0 auto, height = строк × 56px), который сохраняет реальный скроллбар для всего списка, а rAF-троттлинговый scroll-листенер рендерит только вьюпорт ± буфер в 5 строк (~16–19 узлов вместо N). На/ниже порога исходный простой рендер сохранён байт в байт, поэтому типичные пайплайны и все существующие тесты/e2e не затронуты. Каждая виртуализированная строка сохраняет URL-различающий ▶/✕ aria-label (F-V54-B зафиксирован регрессией). Расчёт окна — чистый хелпер computeWindow(). test: tests/pipeline-virtualize.test.mjs (новый, 5 кейсов, CI-изолирован, статичный-по-исходнику): числовой порог ~1000; ветка ≤порога сохраняет forEach→appendChild; ветка >порога рендерит slice(start,end) с rAF scroll-листенером + спейсером; computeWindow() клампит [0,total] с ± буфером; строки сохраняют ▶/✕ aria-label. 788 → 793. Живой Playwright-зонд (фикстура 1200 URL): scrollHeight≈67248, в DOM только ~16–19 узлов, окно следует за скроллом из конца в конец, 0 ошибок консоли. feat(pipeline) · test: tests/pipeline-virtualize.test.mjs. Подробно — CHANGELOG.md.
[1.55.6] — 2026-05-19
feat(scan): убрать вторичные фильтры за раскрывашку «Расширенные фильтры» (UX-4). #/scan складывал все фильтры — свободный текст, удалёнка/гибрид/офис, охват, источник и пост-сканные фасетные чипы stack/level/dynamic — равным весом, стена контролов. Теперь повседневные фильтры остаются видимыми (свободный текст + Удалёнка/Гибрид/Офис; кнопка 🌐 Скан уже отдельно в карточке контролов), а вторичные сворачиваются за <details class="scan-advanced"><summary>Расширенные фильтры</summary>: селекты Охват + Источник и — отдельно — кластер фасетных чипов (теперь свежий набор начинается с таблицы, а не стены чипов, и рендерится только при ≥1 строке чипов). Новый i18n-ключ scan.advancedFilters во всех 8 локалях; новый стиль .scan-advanced (тихая ⚙-аффорданс, без маркера, жирный при раскрытии). test: tests/scan-advanced-disclosure.test.mjs (новый, 6 кейсов, CI-изолирован, статичный-по-исходнику): <details>/<summary> с хуком .scan-advanced и меткой scan.advancedFilters; свободный текст + удалёнка остаются видимыми; охват + источник внутри раскрывашки; chipsContainer — <details>; .scan-advanced summary стилизован; scan.advancedFilters ×8. 782 → 788. feat(scan) · test: tests/scan-advanced-disclosure.test.mjs. Подробно — CHANGELOG.md.
[1.55.5] — 2026-05-19
feat(dashboard): hero с 2 P0-CTA + фокусной подсказкой недавней активности (UX-3). #/dashboard открывался ~30 узлами равного веса — без ясного «что дальше». Новый блок .dash-hero теперь сразу под шапкой страницы: два P0-сценария — ✨ Auto-pipeline по URL и 🌐 Запустить скан — повышены до крупных кнопок .btn-hero, а единственная фокусная подсказка недавней активности («Последняя оценка: <балл> — <заголовок>», со ссылкой на отчёт; на холодном старте — направляющее пустое состояние через dash.heroNoEval) показывает вернувшемуся пользователю, где он остановился, а новому — единственное важное действие. Две основные кнопки убраны из шапки (остаётся только вторичная «📋 Открыть pipeline»), чтобы не дублировать действие. Счётчики статусов понижены с заметных .badge до тихих пилюль .dash-chip. Новые i18n-ключи dash.lastEval, dash.heroNoEval во всех 8 локалях; новый CSS .dash-hero/.btn-hero/.dash-chip. test: tests/dashboard-hero.test.mjs (новый, 5 кейсов, CI-изолирован, статичный-по-исходнику): .dash-hero существует и предшествует сетке Quick-actions; оба P0-CTA — .btn-hero с маршрутами /auto+/scan; фокусная dash.lastEval + пустое состояние dash.heroNoEval; бакеты используют .dash-chip; CSS существует; dash.lastEval+dash.heroNoEval ×8. 777 → 782. feat(dashboard) · test: tests/dashboard-hero.test.mjs. Подробно — CHANGELOG.md.
[1.55.4] — 2026-05-19
feat(ux): честный ETA auto-pipeline рядом с Run + заметный Stop во время сканирования (UX-6). #/auto: новая подсказка .auto-eta — “⏱ ~1–2 мин” (ключ auto.eta, title через auto.etaTitle) — теперь рядом с кнопкой Run, чтобы обещание «в один клик» было честным о длительности до того, как пользователь решится; формулировка совпадает с career-ops.org/docs («вставьте URL → полный отчёт за 1–2 минуты»). #/scan: пока многоминутный обход выполняется (aria-busy), Stop повышается из малоконтрастной призрачной кнопки в заметную деструктивную (новый .btn-danger — заливка, высококонтрастный белый на коралловом, насыщенность 600). setScanRunning(running) переключает scan-stop-btn между btn-danger (выполняется) и btn-ghost (простой, всё равно скрыт), чтобы пользователь находил и доверял Stop под нагрузкой. Новые i18n-ключи auto.eta, auto.etaTitle во всех 8 локалях; новый CSS .btn-danger/.auto-eta. test: tests/auto-eta-stop.test.mjs (новый, 4 кейса, CI-изолирован, статичный-по-исходнику): #/auto рендерит t('auto.eta') с классом .auto-eta рядом с runBtn; auto.eta ×8; setScanRunning(running) повышает Stop до btn-danger; .btn-danger существует с высококонтрастным белым текстом. 773 → 777. feat(ux) · test: tests/auto-eta-stop.test.mjs. Подробно — CHANGELOG.md.
[1.55.3] — 2026-05-19
feat(onboarding): экранный статус OR из 4 провайдеров — баннер холодного старта + чип активного провайдера (UX-2, HIGH). Новый read-only эндпоинт GET /api/status/providers → { activeProvider, activeModel, keysConfigured }. keysConfigured использует тот же effective-env обзор, что и gate sites llm.mjs (process.env ∨ родительский .env); activeProvider — то, что реально выберет OR-роутер: selectActiveProvider(), новый чистый хелпер в env-config.mjs, обходит providerOrder() (пин LLM_PROVIDER без подходящего ключа → null). Секреты не возвращаются — только имена провайдеров + id модели. Оболочка SPA рендерит глобальную область онбординга (#onboarding-banner, заполняет app.js, только CSP-безопасный DOM): 0 ключей → красный баннер + CTA на #/config?tab=api-keys; ≥1 ключ → ненавязчивый чип с активным провайдером+моделью. Делает ключевое отличие («один из Anthropic / Gemini / OpenAI / Qwen, авто-порядок») обнаружимым на экране, а не методом проб. Новые i18n-ключи onboarding.* во всех 8 локалях; новый CSS .onboarding-warn/.onboarding-ok. test: tests/onboarding-key-banner.test.mjs (новый, 9 кейсов, CI-изолирован): семантика selectActiveProvider; GET /api/status/providers in-process (эфемерный порт + временный .env в CAREER_OPS_ROOT, чтобы никогда не читать реальный родительский ключ — CLAUDE.md #2/#8); статичная SPA-обвязка + покрытие onboarding.* ×8. 764 → 773. feat(onboarding) · test: tests/onboarding-key-banner.test.mjs. Подробно — CHANGELOG.md.
[1.55.2] — 2026-05-18
fix(cv): дать markdown-редактору #/cv описательное, самодостаточное доступное имя (F-V55-H / UX-5). <textarea id="cv-editor"> основного редактора #/cv теперь несёт описательный aria-label через новый ключ cv.editorAria — “Редактор markdown резюме — ваше профессиональное резюме в формате markdown” — вместо краткого имени, унаследованного от видимого заголовка раздела “Markdown”. Примечание: вопреки симптому F-V55-H (проверявшему лишь aria-label/labels), поле не было безымянным — v1.47.0 (WS2 #16) уже связал его через aria-labelledby → <h3 id="cv-md-heading">Markdown</h3>, так что скринридер озвучивал “Markdown, редактирование, многострочное”. v1.55.2 улучшает это краткое “Markdown” до самодостаточной метки. Избыточный aria-labelledby удалён (иначе мёртвая разметка — aria-label побеждает по приоритету ARIA); видимый <h3>Markdown</h3> остаётся для зрячих пользователей. WCAG 1.3.1 + 4.1.2; параллель к правке batch-tsv v1.54.5 (F-V54-C). test: tests/cv-editor-a11y.test.mjs (новый, 3 кейса, CI-изолирован, статичный-по-исходнику как auto-stepper-prerender.test.mjs): #cv-editor именует себя через t('cv.editorAria', …) с непустым фолбэком; cv.editorAria присутствует и непуст во всех 8 локалях; на элементе нет избыточного aria-labelledby. 761 → 764. fix(cv) · test: tests/cv-editor-a11y.test.mjs. Подробно — CHANGELOG.md.
[1.55.1] — 2026-05-18
fix(auto): пререндер 5-этапного степпера пайплайна при монтировании #/auto (F-V55-E / UX-1, senior-наблюдение S-4 переоткрыто). #/auto теперь показывает документированную схему из пяти этапов — валидация → загрузка → оценка → сохранение отчёта → добавление в трекер — в момент монтирования экрана, а не остаётся пустым до первого события SSE. Раньше <ol class="auto-stepper"> создавался с display:none, а renderStepper() достигался только из setStep() / run(), поэтому пользователь при холодном старте никогда не видел обещанный документацией пайплайн до нажатия Run. Степпер теперь виден при монтировании со всеми пятью этапами в состоянии pending и несёт aria-label (auto.stepperAria), чтобы вспомогательные технологии озвучивали регион. Закрывает F-V55-E (линза a11y/статической гарантии) и UX-1 (линза верности обещанию) — одна правка, обе линзы. test: tests/auto-stepper-prerender.test.mjs (новый, 4 кейса, CI-изолирован, статичный-по-исходнику как router.test.mjs): массив STEPS — ровно 5 канонических этапов по порядку; stepperEl не display:none при монтировании и несёт auto.stepperAria; вызов renderStepper() уровня монтирования предшествует function setStep(; auto.stepperAria присутствует во всех 8 локалях. 757 → 761. fix(auto) · test: tests/auto-stepper-prerender.test.mjs. Подробно — CHANGELOG.md.
[1.55.0] — 2026-05-18
feat(llm): headless live-eval работает через “OR” — Anthropic | Gemini | OpenAI | Qwen, автовыбор по тому, какой ключ задан. По запросу пользователя ⚡ live-eval веб-UI теперь работает с любым заданным ключом API, а не только Anthropic/Gemini. LLM_PROVIDER получает openai и qwen; auto (по умолчанию) использует первого провайдера, чей ключ присутствует, предпочитая Anthropic → Gemini → OpenAI → Qwen. Явное значение фиксирует одного; форсированный провайдер без ключа всё равно откатывается к пути ручного промпта. Новый server/lib/openai.mjs — клиент Chat Completions, совместимый с OpenAI, без зависимостей (тот же безопасный паттерн прямого HTTPS, что у anthropic.mjs: таймаут AbortController, ключ никогда не логируется, разрешение ключа через effectiveEnv(), чтобы ключ из родительского .env работал без перезапуска). Одно ядро (runOpenAICompatible) обслуживает runOpenAI (api.openai.com) и runQwen (OpenAI-совместимый режим Alibaba DashScope; переопределите endpoint через QWEN_BASE_URL в сыром .env для хоста материкового Китая). Без SDK, без произвольного исполнения CLI — родительский проект остаётся CLI-агностичным (Claude Code · Codex · Gemini · OpenCode · Qwen · Copilot · Kimi); это лишь расширяет headless путь по ключу API. Хвост OpenAI/Qwen подключён ко всем поверхностям eval: /api/evaluate, /api/deep, /api/mode/:slug и SSE /api/auto-pipeline — консультируется после ветвей Anthropic (inline) + Gemini (подпроцесс), чтобы сохранить auto-предпочтение, с тем же инлайнингом упакованного контекста, что использует Anthropic. env-config.mjs: QWEN_API_KEY (секрет) + QWEN_MODEL (не секрет) добавлены в KNOWN_KEYS/KEY_GROUPS.core; LLM_PROVIDERS и providerOrder() расширены; OPENAI_API_KEY теперь полноправный headless-ключ провайдера (раньше только хранимый). Вкладка ключей #/config: селект LLM_PROVIDER получает openai/qwen; новые поля QWEN_API_KEY + QWEN_MODEL (курируемый список qwen-max/qwen-plus/qwen-turbo/qwen2.5-*); новая заметка вверху вкладки объясняет CLI-агностичного родителя vs headless-eval веб-UI и порядок OR. Новые i18n-ключи во всех 8 локалях. test: tests/openai.test.mjs (новый, 9 кейсов, CI-изолирован): успех OpenAI/Qwen + контент-массив блоков, Bearer-аутентификация, дефолтный и переопределённый QWEN_BASE_URL endpoint’ы, 4xx/5xx/некорректный, кламп max_tokens, таймаут, детекция ключа effectiveEnv, канарейка на отсутствие утечки ключа. tests/provider-selector.test.mjs обновлён под поверхность providerOrder/LLM_PROVIDERS/SECRET v1.55.0 + подключение хвоста OpenAI/Qwen. 748 → 757. feat(llm) · test: tests/openai.test.mjs · test: tests/provider-selector.test.mjs. Подробно — CHANGELOG.md.
[1.54.10] — 2026-05-18
fix(auto-pipeline): гигиена отключения SSE-клиента — устранить нестабильный Playwright e2e-job. Playwright e2e-job периодически краснел (32/32 индивидуальных теста проходят, но not ok 2 - tests/playwright-smoke.mjs): закрытие страницы, пока SSE-стрим #/auto был в процессе, приводило к тому, что следующий res.write() сервера отклонялся с EPIPE/"aborted", и — без слушателя 'error' на ответе — Node эскалировал это в uncaughtException, который node:test сообщал как “asynchronous activity after the test ended”. openSse() в auto-pipeline.mjs теперь регистрирует no-op res.on('error') и защищает send() через res.writableEnded || res.destroyed (обёрнуто в try/catch) — исчезнувший клиент ожидаем, а не исключителен. Это корректная продакшен-гигиена SSE, а не просто починка теста. tests/playwright-smoke.mjs: тест Cmd+K использовал реальный исходящий URL (https://example.com/jobs/123), но только ждал появления модалки, поэтому closePage() обрывал выполняющийся safeGet() сервера после завершения теста. Теперь он ждёт, пока пайплайн достигнет терминального состояния (чтобы fetch разрешился нормально до закрытия). Общий хелпер closePage() (window.stop(), затем закрыть) и хук after с server.closeAllConnections() остаются как защита в глубину. Проверено: 8/8 подряд зелёных прогонов (6× node --test + 2× browser-smoke), ранее ~1 из 2 красных. tests/auto-pipeline.test.mjs +1 статический кейс, фиксирующий контракт гигиены отключения openSse (слушатель res.on('error') + гард writableEnded||destroyed + обёрнутые в try записи). 747 → 748. fix(auto-pipeline) · test: tests/auto-pipeline.test.mjs. Подробно — CHANGELOG.md.
[1.54.9] — 2026-05-18
fix(llm): уважать LLM-ключи из родительского .env во время запроса — прекратить ошибочную маршрутизацию на устаревшего/невалидного провайдера. Живая оценка могла падать с “Gemini API error: API key not valid” даже когда настроенным провайдером был ANTHROPIC_API_KEY. Первопричина: hasAnthropicKey() / hasGeminiKey() (и поиск ключа/модели в runAnthropic) читали только снимок process.env на момент старта. Если ключ Anthropic добавляли в родительский .env после запуска сервера, работающий процесс его никогда не видел → детекция Anthropic была ложной, и оценка откатывалась к любому устаревшему ключу, который был в process.env (часто старый, невалидный GEMINI_API_KEY). Путь исполнения Gemini (родительский Node-подпроцесс) уже читал живой родительский .env, поэтому оба провайдера разрешали ключи несогласованно. Новый effectiveEnv(key, envFilePath) в env-config.mjs: непустое значение process.env побеждает (покрывает shell-экспорты и live-apply в POST /api/config); иначе консультируется текущий родительский файл .env. anthropic.mjs теперь разрешает ANTHROPIC_API_KEY, ANTHROPIC_MODEL и проверку Gemini-ключа через него, так что ключ, заданный в родительском .env, уважается без перезапуска сервера и ДЕТЕКЦИЯ ключа всегда совпадает с ключом, который запрос реально ОТПРАВЛЯЕТ. Порядок провайдеров не изменён (auto → Anthropic-потом-Gemini); это чинит только детекцию. Ключи никогда не логируются и не возвращаются (тест на отсутствие утечки REVIEW-B4 по-прежнему проходит). tests/anthropic.test.mjs переписан CI-изолированным (temp CAREER_OPS_ROOT, динамический import) с 2 новыми кейсами, воспроизводящими точный баг (ключ только в родительском .env → детектируется; runAnthropic отправляет ключ + модель из родительского .env, когда process.env не задан). tests/env-config.test.mjs +3 кейса effectiveEnv (приоритет process.env, фолбэк на .env вкл. пустую-строку-как-незаданную, отсутствующий-файл / отсутствующий-ключ / без-пути → undefined) — 100% новой ветки. 742 → 747. fix(llm) · test: tests/anthropic.test.mjs · test: tests/env-config.test.mjs. Подробно — CHANGELOG.md.
[1.54.8] — 2026-05-18
feat(config): форма по полям вкладки Modes всегда рендерит каноническую схему (даже на пустом/заглушечном файле) с подсказками по полям из career-ops.org. Форма по полям Modes из v1.54.3 рендерила поля только для уже существующих секций ## — поэтому на свежем, пустом или несхемном modes/_profile.md (например, обычная однострочная заглушка) она откатывалась к “No ## sections found — use the raw editor below.” и пользователь не получал полей. По запросу пользователя (“разбей по полям … описание полей возьми из career-ops.org/docs”) форма теперь всегда рендерит 5 канонических полей в документированном порядке (Target Roles, Adaptive Framing, Exit Narrative, Comp Targets, Location Policy), предзаполненных из файла при наличии и пустых-но-редактируемых при отсутствии — так что совершенно новый профиль можно заполнить целиком через форму. Каждое поле показывает описание из канонического Quick Start §Step-5 на career-ops.org (что класть в Target Roles / Adaptive Framing / Exit Narrative / Comp Targets / Location Policy), привязанное через aria-describedby для скринридеров. Толерантность к вариантам заголовка: ## Your Target Roles (и т. п.) из шаблона маппится в то же каноническое поле, что и ## Target Roles, так что ни шаблон, ни конвенция серверного скаффолда не ломают форму. collect() теперь — тегированный payload: неразрушающее слияние { sections }, когда отрендеренные заголовки точно совпадают с имеющимися в файле (преамбула + нетронутые + кастомные секции переживают байт-стабильно), либо полная пересборка файла { markdown }, которая бутстрапит/нормализует схемно-согласованный документ, когда в файле не было схемы. Путь пересборки защищён подтверждением в config.js (он заменяет родительский файл — инвариант разрушающего сохранения WS2 #4), сохраняет имеющуюся преамбулу (или документированный дефолт) и держит неканонические секции verbatim. 6 новых i18n-ключей (config.modesDescTargetRoles … config.modesDescLocationPolicy + config.modesFormRebuildBody) во всех 8 локалях. tests/modes-form.test.mjs переписан под контракт v1.54.8: схема + канонический порядок, проводка payload/подтверждения в config.js, наличие описания каждого поля из документации во всех 8 локалях, толерантность canonicalKey “Your X”, стабильность round-trip списков, гарантия bootstrap-всегда-рендерит, и тегированный sections-vs-markdown collect() с безопасностью данных. Проверено вживую против реального родительского заглушечного файла (5 полей + описания появляются, 0 ошибок консоли) и изолированного заглушечного фикстура (заполнить → защищённое подтверждением сохранение → все 5 канонических секций сохранены). feat(config) · test: tests/modes-form.test.mjs. Подробно — CHANGELOG.md.
[1.54.7] — 2026-05-18
fix: W-001 — ассеты кода/стилей + оболочка SPA отдавались с Cache-Control: no-store (гигиена деплоя). SPA загружает api.js / router.js / каждое представление через обычный <script src> без версионной query-строки, и шага сборки нет (без content hashing), поэтому после деплоя браузер мог часами отдавать кэшированный старый бандл → stale-cache 404 на маршрутах с query-строкой (наблюдалось вживую во время регрессии v1.29.2; прогон регрессии W-001). server/index.mjs теперь ставит Cache-Control: no-store на .js / .mjs / .css / .html через хук setHeaders у express.static, и явно на catch-all оболочки SPA (который использует sendFile и обходит setHeaders), так что браузер всегда перепроверяет код, управляющий маршрутизацией. Не-кодовые статические ассеты сохраняют кэширование express.static по умолчанию. Заголовки безопасности (CSP / nosniff / frame-deny / referrer-policy) не изменены — проверено существующей сюитой security-headers (8 случаев), идущей зелёной рядом с новым тестом. +1 файл тестов tests/asset-cache-control.test.mjs — 4 случая (JS-ассеты no-store, CSS no-store, статический index.html no-store, оболочка глубокого маршрута catch-all SPA no-store), с загрузкой реального приложения против изолированного CAREER_OPS_ROOT. Плюс правка флакающего teardown в tests/playwright-smoke.mjs (отдельный коммит test(e2e)): дымовой SSE-тест авто-пайплайна теперь отменяет reader + прерывает fetch в finally, а хук after принудительно закрывает зависшие сокеты, устраняя пост-teardown «Error: aborted», который краснил Playwright e2e-джоб v1.54.6. 738 → 742. fix · test: tests/asset-cache-control.test.mjs. Подробно — CHANGELOG.md.
[1.54.6] — 2026-05-18
fix(a11y): S-7 — кнопка back-to-top на #/help несёт канонический класс-селектор back-to-top. Плавающая кнопка back-to-top на #/help работала корректно (проверено вживую), но её список классов (btn btn-primary help-back-top) находился вне конвенции селектора .back-to-top, на который нацелен тест spec §2 #28 — ужесточённый селектор флакнул бы (прогон регрессии S-7, «лёгкая победа»). Теперь кнопка несёт также канонический класс back-to-top. Чисто аддитивно и CSS-no-op: help-back-top (существующий CSS-хук) не изменён, а у back-to-top нет CSS-правила — это лишь стабильная ручка для тестов/автоматизации. Проверено вживую: document.querySelector('.back-to-top') разрешает кнопку, aria-label цел, 0 ошибок консоли. Существующий случай #12 в tests/help-nav-a11y.test.mjs расширен утверждением, что список классов кнопки back-to-top включает канонический селектор back-to-top (без нового файла). fix(a11y) · test: tests/help-nav-a11y.test.mjs. Подробно — CHANGELOG.md.
[1.54.5] — 2026-05-18
fix(a11y): F-V54-C — редактор TSV на #/batch имеет доступное имя. TSV <textarea> на #/batch имел подсказку, подключённую через aria-describedby, но никакого доступного имени — ни <label htmlFor>, ни aria-label/aria-labelledby (прогон регрессии F-V54-C; WCAG 1.3.1 Info & Relationships / 4.1.2 Name, Role, Value). aria-describedby поставляет описание, а не имя, поэтому скринридер озвучивал «edit text» без метки. Теперь textarea несёт aria-label через новый ключ i18n batch.tsvAria, согласованно с соседними полями управления прогоном, которые уже используют ключи *Aria; существующая подсказка describedby сохранена. Проверено вживую: aria-label присутствует + локализован, aria-describedby цел, 0 ошибок консоли. Новый ключ i18n batch.tsvAria во всех 8 локалях. +1 файл тестов tests/batch-tsv-accessible-name.test.mjs (2 случая: блок batch-tsv имеет aria-label через t(batch.tsvAria), сохраняя подсказку describedby; batch.tsvAria определён во всех 8 локалях); 736 → 738. fix(a11y) · test: tests/batch-tsv-accessible-name.test.mjs. Подробно — CHANGELOG.md.
[1.54.4] — 2026-05-18
fix(a11y): F-V54-B — кнопки действий строк на #/pipeline имеют доступные имена. Кнопки ▶ (оценить) и ✕ (удалить) для каждой строки на #/pipeline были только иконками с одним лишь атрибутом title (прогон регрессии F-V54-B; WCAG 4.1.2 Name, Role, Value). title не является надёжным доступным именем, поэтому пользователь скринридера слышал длинную череду неразличимых «button» и не мог понять, к какой строке относится удаление. Обе кнопки теперь несут явный aria-label, разрешённый по неоднозначности компактным URL через новый хелпер shortUrl() (host + …/ + последние 2 сегмента пути; фолбэк с обрезкой хвоста для непарсируемого ввода), так что дерево a11y читается как, например, «Delete: hh.ru/…/vacancy/12345». Без новых ключей i18n — переиспользует common.delete / pipe.evaluateBtn + URL. Проверено вживую: 1385 строк, имя каждой кнопки уникально для строки, 0 ошибок консоли. +1 файл тестов tests/pipeline-row-action-names.test.mjs (4 случая: обе кнопки подключены через shortUrl(url) + ровно две такие метки, shortUrl объявлен до использования, URL одного хоста, но разных вакансий не схлопываются, фолбэки голого хоста / непарсируемого / пустого ввода); 732 → 736. fix(a11y) · test: tests/pipeline-row-action-names.test.mjs. Подробно — CHANGELOG.md.
[1.54.3] — 2026-05-18
feat(config): структурированная форма полей для вкладки “Modes” в #/config (больше никакого сырого markdown). Вкладка “Modes” редактировала modes/_profile.md как один сырой <textarea> на каждую секцию ## (гранулярность на уровне секции из v1.36.0). По запросу пользователя — «собери данные по полям, разбей из документации, определи набор полей и реализуй поля именно, а не сырой» — теперь она отрисовывает структурированную форму полей, выведенную из задокументированной схемы (career-ops.org Quick Start §Step-5): Target Roles / Adaptive Framing / Comp Targets → повторяемые поля строк с метками и кнопками добавить/удалить (одна строка роли/угла/comp на поле, + Add line / ✕ на каждую строку с aria-label); Exit Narrative / Location Policy → один <textarea> с меткой для прозы. Каждое поле — реальный контрол, привязанный через <label htmlFor> с i18n-именем секции. Новый public/js/lib/modes-form.js (window.ModesForm) владеет логикой parse → render → collect(); он питает существующий путь слияния PUT /api/modes/_profile { sections }, поэтому преамбула, порядок и любая секция, которую форма не трогает, сохраняются байт-стабильно (слияние-а-не-замена, обеспечивается сервером). Безопасность данных: каноническая списковая секция, тело которой не является чистым маркированным списком (пользователь поместил туда прозу), и любая неканоническая секция ## откатываются к дословному <textarea> с меткой и пояснительной заметкой — произвольное содержимое проходит round-trip нетронутым, никогда не переформируется и не теряется молча. Стабильность round-trip доказана: serialise(parse(body)) ре-парсится идентично. Редактор сырого markdown всего файла остаётся в виде раскрытия Advanced с подтверждением для добавления/удаления секции и правки преамбулы (деструктивный гейт сохранения WS2 #4 без изменений). 10 новых ключей i18n (config.modesTargetRoles … config.modesUnknownNote) во всех 8 локалях. +1 файл тестов tests/modes-form.test.mjs (7 случаев); 725 → 732. Проверено вживую на изолированной фикстуре CAREER_OPS_ROOT: 5 канонических секций отрисованы как поля + 1 пользовательская секция как фолбэк с меткой, round-trip «отредактировать-и-сохранить» сохранил преамбулу + пользовательскую секцию, 0 ошибок консоли. feat(config) · test: tests/modes-form.test.mjs. Подробно — CHANGELOG.md.
[1.54.2] — 2026-05-18
feat(config): селектор модели OpenAI / Codex в #/config. В #/config не было способа выбрать модель OpenAI / Codex — выпадающие списки были только у ANTHROPIC_MODEL и GEMINI_MODEL, хотя OPENAI_API_KEY уже была доступна для мульти-CLI потока родительского проекта (Codex / OpenCode). Теперь OPENAI_MODEL — полноценный ключ окружения: добавлен в KNOWN_KEYS в env-config.mjs (упорядочен сразу после OPENAI_API_KEY) и в группу ключей core, и намеренно не в SECRET_KEYS — это id модели, а не учётные данные, поэтому он никогда не маскируется. config.js получает курируемый список OPENAI_MODELS (gpt-5-codex по умолчанию, далее gpt-5 / gpt-5-mini / gpt-4.1 / o4-mini / o3) и поле <select> OPENAI_MODEL, отрисовываемое сразу после ключа OpenAI, в точности повторяя поля моделей Anthropic/Gemini. Новые ключи i18n config.openaiModel + config.openaiModelHint во всех 8 локалях. +1 файл тестов tests/openai-model-selector.test.mjs (4 случая); 721 → 725. Проверено вживую: #/config → select OPENAI_MODEL с 6 вариантами, по умолчанию gpt-5-codex, привязан к метке, 0 ошибок консоли. feat(config) · test: tests/openai-model-selector.test.mjs. Подробно — CHANGELOG.md.
[1.54.1] — 2026-05-18
fix(a11y): F-V54-A — единственный <h1> на #/cv. Собственный # Name из markdown резюме рендерился как второй <h1> верхнего уровня рядом с <h1>CV</h1> заголовка страницы (прогон регрессии F-V54-A; WCAG 1.3.1 Информация и взаимосвязи / 2.4.6 Заголовки). Теперь cv.js пропускает каждую точку внедрения превью резюме (первичный рендер, обновление при импорте файла, живую синхронизацию редактора) через cvMd() с ограниченной областью действия, который сдвигает заголовки на уровень ниже (h1→h2 … h6→role="heading" aria-level="7"), так что страница сохраняет ровно один <h1>. Ограничено cv.js намеренно — UI.md используется совместно help/reports/deep/evaluate, которые управляют своими заголовками по-своему. +1 файл тестов tests/cv-single-h1.test.mjs (4 случая); 717 → 721. Проверено вживую: #/cv → 1 <h1>, пользовательский # Name теперь <h2>, 0 ошибок консоли. fix(a11y): F-V54-A · test: tests/cv-single-h1.test.mjs. Подробно — CHANGELOG.md.
[1.54.0] — 2026-05-18
WS10 — повторная валидация canonical-docs + паритет H3 help-бандла (финальный релиз конвергенции). CI-гейт CHANGELOG/структуры проверял только H2, поэтому docs/help/en.md молча сполз до 70 H3-подразделов, тогда как 7 локализованных бандлов остались на 68 — разрыв был в §17 (таблица «Reference adapters» + список «Common pitfalls», только по-английски). Оба теперь переведены на все 7 языков (имена файлов / ссылки / идентификаторы адаптеров сохранены байт в байт идентичными); все 8 бандлов теперь 17 H2 / 70 H3. Новый гейт паритета H3 в help-ru-config-section.test.mjs фиксирует это (716 → 717). canonical-docs-coverage.test.mjs 7/7 подтверждает, что help по-прежнему отражает все 5 гайдов career-ops.org/docs; UX-аудит WS2 (40 замечаний v1.41→v1.52) сверил каждый экран с docs — расхождений нет. docs/sdd/CONVENTIONS.md обновлён до v1.54.0 (итоги тестов, гейт паритета H3, файлы-выбросы по размеру, новый раздел соглашений по Доступности). WS0–WS10 завершены; остаётся только WS11. fix(docs): WS10 canonical re-validation + H3 parity · test(help): H3-parity gate. Подробно — CHANGELOG.md.
[1.53.0] — 2026-05-18
WS9 — пирамида тестов shell-поверхности (последний непокрытый слой). 4 скрипта bin/*.sh и хук .githooks/pre-commit имели нулевое покрытие; новый tests/sh-files.test.mjs добавляет 10 кейсов, фиксирующих синтаксис bash -n/sh -n, shebang + бит исполнения и поведенческие контракты, от которых зависят другие workstream’ы: career-ops-ui.sh — help завершается с 0 без утечки shell-source (страж регрессии v1.40.0), неизвестный verb завершается с 2, а usage() — это heredoc; start.sh — уважает NO_OPEN, требует Node ≥ 18 и делегирует подъём браузера в scripts/open-dashboard.mjs (страж v1.43.0); setup.sh — строгий режим, SKIP_START, клонирует оба репозитория; run_all.sh — разбор --quick/--no-e2e и 4 сьюта; .githooks/pre-commit исполняет ревьюера WS7, и ни один shell-файл не вызывает git --no-verify (страж жёсткого правила #7 CLAUDE.md); install-hooks.mjs прокладывает core.hooksPath. docs/architecture/TESTING.md — в диаграмму пирамиды добавлен базовый слой shell-поверхности + заметка с итогами v1.53.0 (716 кейсов node --test / 90 файлов + 4 E2E-поверхности). 706 → 716. Подробно — CHANGELOG.md.
[1.52.0] — 2026-05-18
WS2 LOWs #33–#40 — пакетная зачистка-полировка (закрывает очередь UX-аудита). Восемь замечаний низкой важности. fix(a11y/i18n): WS2 LOW batch — #33: #/dashboard — 3 CTA в шапке были несогласованы (лишь у 2 был ведущий значок); «Open Pipeline» теперь несёт 📋, и все три совпадают. #34: #/profile — fit/level архетипа рисовались двумя неоднозначными чипами; теперь с префиксом (Fit: / Level:) и соответствующим aria-label. #35: #/health — тосты Run-doctor / verify показывали сырые строки из doctor.mjs; теперь с i18n-ключом. #36: #/health — результаты проверок были плоским рядом <div>; теперь role=list <ul>/<li>, а бейдж статуса несёт aria-label="<check>: <status>". #37: #/reports — карточки отчётов были <div onClick> только под мышь; теперь role=link + tabindex + обработчик Enter/Space + aria-label. #38: #/activity — комментарий пагинатора гласил «200», тогда как код запрашивал 500; согласовано с константой CAP, и уведомление role=note всплывает, когда лимит 500 усекает старую историю. #39: #/batch — текстовые плейсхолдеры были захардкожены по-английски, тогда как их aria-label были localized; четыре теперь с i18n-ключом. #40: страницы режимов молча переименовывали главную кнопку после асинхронного зонда; теперь вежливая область role=status объявляет это. 10 новых ключей i18n × 8 локалей ({n} сохранён); +9 тестов: test: tests/low-sweep.test.mjs. 697 → 706. Закрывает очередь UX-аудита WS2 (#1–#40 от v1.41→v1.52); далее WS9 → WS10 → WS11. Подробно — CHANGELOG.md.
[1.51.0] — 2026-05-18
WS2 #13 + #14 + #18 + #19 + #20 — зачистка feedback/i18n на #/auto и #/evaluate. Пять замечаний UX-аудита. fix(a11y/ux): auto+evaluate — busy state, actionable HTTP errors, clipboard fallback, aria-live result, spinner-guarded submit — #13: кнопка Run на #/auto теперь показывает состояние занятости (is-loading + aria-busy + «Running…») вместо простого отключения. #14: неуспешный HTTP-запрос теперь выдаёт действенное i18n-сообщение на шаге И тост (auto.httpFail с {n}), вместо голого «HTTP 500». #18: «Copy prompt» ручного режима теперь использует асинхронный Clipboard API с запасным execCommand и тостит реальную ошибку вместо ложного «Copied». #19: контейнер результата evaluate теперь role=status aria-live=polite, так что долгий вызов LLM объявляется программам чтения с экрана. #20: кнопка Evaluate обёрнута в UI.withSpinner (был простой onClick: run, допускавший повторные отправки). 3 новых ключа i18n × 8 локалей; +6 тестов: 691 → 697. Также правка только тестов (коммит 7f8e250): teardown e2e pipeline-delete был на пути нативного confirm до v1.48; переведён на DELETE по API (fix(test): … — CI Playwright-e2e был красным; не регрессия продукта). Подробно — CHANGELOG.md.
[1.50.0] — 2026-05-18
WS2 #12 + #27 + #28 — доступность навигации справки. Три замечания UX-аудита на #/help для руководства из 17 секций и 90+ заголовков, исправлены в help.js. fix(a11y): help — single h1, labelled+filterable TOC, focus-on-anchor, back-to-top — #28: markdown документа открывался собственным # Title, порождая ВТОРОЙ <h1> на странице, чей хедер уже даёт канонический h1; теперь каждый <h1> статьи удаляется, так что h1 ровно один, а иерархия чисто начинается с секций <h2>. #27: <nav> оглавления был безымянным ориентиром (два <nav> без метки на странице); теперь у него есть aria-label (help.toc), и при клике по записи оглавления фокус перемещается на заголовок секции (tabindex=-1 + focus()), а не только прокрутка вьюпорта. #12: не было способа что-либо найти в длинном документе; фильтр type=search над оглавлением сужает записи по тексту заголовка вживую, а плавающая кнопка с aria-label «Back to top» появляется после прокрутки, возвращает к началу и возвращает фокус на <h1> страницы; её scroll-слушатель снимается на hashchange при уходе с #/help. 2 новых ключа i18n × 8 локалей — help.tocFilter, help.backToTop; +6 тестов: test: tests/help-nav-a11y.test.mjs. 685 → 691. Подробно — CHANGELOG.md.
[1.49.0] — 2026-05-18
WS2 #10 + #11 + #25 + #26 — доступность и сортировка таблицы tracker. Четыре замечания UX-аудита на #/tracker, исправлены в tracker.js. fix(a11y): tracker headers, sortable table, localized fix labels, empty state — #10: заголовок столбца действия был пустой строкой, а построчная кнопка Report не имела контекста; теперь у каждого <th> есть scope=col, заголовок действия и заголовки Score/PDF переведены на ключи i18n (были пустыми или захардкоженным английским), а кнопка Report получает aria-label с названием компании (<report> — <company>). #11: tracker без способа сортировки; заголовки Date / Score / Status теперь — управляемые с клавиатуры кнопки сортировки внутри <th> с aria-sort (none/ascending/descending); компаратор sorted() (числовой для score, locale-сравнение для date/status) выполняется до пагинации, а клик переключает направление и сбрасывает пагинатор. #25: track.normalize/dedup/merge были идентичным английским во всех 8 локалях, несмотря на то что это самые рискованные деструктивные элементы (переписывают data/applications.md на месте) — теперь корректно локализованы, плюс подсказка title. #26: первый запуск с нулём строк показывал то же сообщение «no match», что и переотфильтрованный список; rows.length === 0 теперь рендерит отдельное пустое состояние (заголовок + тело + CTA «Open pipeline»). 7 новых ключей i18n × 8 локалей + 3 перелокализованы; +6 тестов: test: tests/tracker-a11y-sort.test.mjs. 677 → 683. Подробно — CHANGELOG.md.
[1.48.0] — 2026-05-18
WS2 #8 + #22 — pipeline: подтверждение с захватом фокуса + доступность превью. Два замечания UX-аудита на #/pipeline, исправлены в pipeline.js. fix(a11y): pipeline UI.confirm() + live preview region — #8: три действия #/pipeline использовали нативный confirm() (фокус не захватывался): Delete в панели превью, построчное удаление ✕ и «Evaluate first»; теперь все проходят через UI.confirm() с захватом фокуса (инфраструктура v1.44.0) — два удаления danger:true (Cancel по умолчанию), «Evaluate first» danger:false; нативного confirm() в pipeline.js больше не осталось. #22: у previewPane не было live-роли, а сбой fetch запихивался в previewBody и рендерился как вводящий в заблуждение <pre> «preview»; теперь это role=region aria-live=polite с aria-label, а сбои устанавливают отдельный previewError, рендерящийся как отдельный блок role=alert (очищается при (пере)выборе и при удалении активной строки). 4 новых ключа i18n × 8 локалей; +5 тестов: test: tests/pipeline-confirm-preview.test.mjs. 672 → 677. Подробно — CHANGELOG.md.
[1.47.0] — 2026-05-18
WS2 #7 + #30 + #31 + #16 — зачистка доступности непривязанных меток. Четыре замечания UX-аудита, где у элементов формы не было программной метки (WCAG 1.3.1 / 3.3.2 / 4.1.2), теперь привязаны. fix(a11y): bind every swept form control to an accessible name — #7 scan.js: у чекбокса dry-run и выпадающего списка company-select были метки без for; добавлен htmlFor (с существующими id). #30 deep.js: у инпутов company / role были непривязанные метки; добавлены id + htmlFor (deep-company, deep-role). #31 apply.js: у url / jd были непривязанные метки; добавлены id + htmlFor (apply-url, apply-jd). #16 cv.js: у основного markdown-<textarea> не было доступного имени; привязан через aria-labelledby к видимому заголовку «Markdown» — имя для скринридера совпадает с заголовком на экране, без нового ключа i18n. Использует явный паттерн label[for]↔control[id], уже стандартный в batch.js / mode-page.js; без новых ключей i18n; ноль изменений поведения. +5 тестов: test: 667 → 672. Подробно — CHANGELOG.md.
[1.46.0] — 2026-05-18
WS2 #5 + #6 + #21 + #24 — доступность scan SSE. Четыре замечания UX-аудита на #/scan, исправлены в scan.js. fix(a11y): scan SSE — live-log region, Stop, run-state, error banner — #5: потоковая консоль теперь role=log aria-live=polite (+ aria-label, tabindex=0, прокрутка с клавиатуры), с отдельной визуально скрытой assertive-областью role=status, объявляющей терминальные события (завершено / провалено / остановлено). #6: кнопка Stop закрывает выполняющийся EventSource (es.close()), отменяет опрос результата и сбрасывает состояние; показывается только пока идёт scan. #21: кнопка Scan отключается + получает aria-busy во время выполнения, а Stop показывается — на обоих путях стрима (однофазном streamTo и многофазном runScanAll — последний завершает прогон только на терминальном done, final !== false). #24: сбой SSE больше не только тост на 3,5 с; теперь постоянный баннер role=alert показывает ошибку с действием повтора (повторно вызывает последнюю функцию прогона), очищается при следующем прогоне. 8 новых ключей i18n × 8 локалей; +7 тестов: test: tests/scan-sse-a11y.test.mjs. 660 → 667. Подробно — CHANGELOG.md.
[1.45.0] — 2026-05-18
WS2 #3 — вкладки #/config: полный паттерн WAI-ARIA Tabs. Три вкладки #/config (API keys / Profile / Modes) были обычными <button class="tab-btn"> с активацией только по клику: ни role, ни aria-selected, ни клавиатурной модели (UX-аудит HIGH #3, WCAG 4.1.2 / 2.1.1). fix(a11y): config.js tabs implement role=tablist/tab/tabpanel — теперь контейнер role=tablist с aria-label; каждая вкладка role=tab + id + aria-controls + aria-selected (синхронизируется в activate()) + блуждающий tabindex (0 у активной / -1 у остальных); панель role=tabpanel + tabindex=0 + aria-labelledby, отслеживающий активную вкладку. Полная клавиатурная навигация: ←/→/↑/↓ (с зацикливанием) + Home/End перемещают фокус И активируют. Легаси CSS-хук .tab-btn.is-active сохранён. +1 ключ i18n × 8 локалей (config.tablistLabel); +7 тестов: test: tests/config-tabs-aria.test.mjs. Также правка только для тестов: fix(test): retarget 2 stale auto-pipeline smoke tests — два smoke-теста Playwright-e2e эпохи до v1.34 проверяли переходную модалку, которую кнопка «Auto-pipeline» на дашборде перестала открывать в v1.34.0 (→ Router.go('/auto')); они оставались красными в отдельном CI-джобе Playwright-e2e. Перенацелены на экран #/auto. 653 → 660. Подробно — CHANGELOG.md.
[1.44.0] — 2026-05-18
WS2 #4 + #9 — подтверждение с захватом фокуса перед деструктивной перезаписью файлов родительского проекта. Два HIGH из UX-аудита, оба с потерей данных: (#4) config.js saveProfileRaw/saveModesRaw заменял весь родительский config/profile.yml / _profile.md без подтверждения; (#9) tracker.js Normalize/Dedup/Merge переписывал родительский data/applications.md на месте без подтверждения. fix(a11y/safety): UI.confirm() gate before whole-file parent overwrites — новый UI.confirm() в public/js/api.js, диалог с захватом фокуса, переиспользующий существующую WAI-ARIA-инфраструктуру модалок (хук _onClose приводит к тому, что Esc / backdrop / × / Cancel — все пути закрытия резолвят false; фокус по умолчанию на Cancel; возвращает Promise<boolean>; НЕ нативный confirm()). Все три деструктивных вызова теперь защищены перед записью. 8 новых ключей i18n × 8 локалей (плейсхолдер {op} сохранён дословно); +8 тестов: test: tests/confirm-gate.test.mjs, 644 → 652. Подробно — CHANGELOG.md.
[1.43.0] — 2026-05-18
По запросу пользователя — career-ops-ui open + autostart с подъёмом окна браузера. После setup/run голый open/xdg-open оставлял вкладку дашборда на заднем плане, когда браузер уже был запущен, и пользователю приходилось её искать. feat(cli): career-ops-ui open — open AND raise the dashboard tab — новый scripts/open-dashboard.mjs строит URL из HOST/PORT (переписывая бинд 0.0.0.0 на loopback), при необходимости ждёт /api/health, открывает браузер по умолчанию и затем принудительно поднимает его на передний план — macOS через osascript активирует тот из Chrome/Brave/Edge/Safari/Arc/Firefox, что запущен, Linux через xdg-open+wmctrl, Windows через start. Представлено как глагол career-ops-ui open (алиасы dash, focus). Autostart в bin/start.sh теперь делегирует ему, так что вкладка поднимается автоматически; NO_OPEN=1 отключает auto-open для headless/CI-запусков. README ×8 + help §1 ×8 обновлены; +8 тестов: test: tests/open-dashboard.test.mjs, 636 → 644. Подробно — CHANGELOG.md.
[1.42.0] — 2026-05-18
WS2 исправление №2 — мёртвый маршрут #/portals → глубокая ссылка на config. #/portals был незарегистрированным маршрутом и рендерил 404-вьюшку, хотя это вполне правдоподобный URL для закладки/ручного ввода при управлении источниками порталов (HIGH №2 из UX-аудита). fix(router): #/portals 404 → alias to config + Regional-sources deep-link — в ALIASES файла router.js добавлено portals: 'config' (тот же паттерн стабильности закладок, что и settings→profile), так что теперь это резолвится во вьюшку config с активным пунктом навигации config. Когда существует группа Regional-sources, вьюшка (config.js) обнаруживает хеш #/portals, принудительно раскрывает эту <details>-группу, прокручивает её в видимую область и переводит фокус на её summary (переопределяя фокус по умолчанию на h1), так что пользователь попадает ровно на элементы управления источниками порталов; никогда не рендерит пустую региональную группу из-за одного лишь алиаса. help-bundle §5 × 8 получил заметку о шорткате; +1 тест роутера: test(router): portals→config alias guarantee в router.test.mjs, 635 → 636. Подробно — CHANGELOG.md.
[1.41.0] — 2026-05-18
WS2 — старший аудит UX/юзабилити + сквозное исправление управления фокусом. Эвристический аудит с опытом 10+ лет (Nielsen × WCAG 2.2 AA × конвенции проекта) всех 17 маршрутов дал очередь из 40 находок, ранжированную по серьёзности (.planning/.../UX-AUDIT.md); HIGH→MEDIUM→LOW теперь поставляются по одному исправлению на релиз. Этот релиз закрывает сквозной HIGH №1. Исправления: fix(a11y): move focus to the new view on every route change — router.js render() заменял #content на каждом hashchange, но никогда не перемещал фокус, поэтому пользователи клавиатуры / скринридера оставались на уничтоженном узле и теряли своё место (WCAG 2.4.3 Focus Order / 4.1.3 Status Messages — сквозное, затрагивало все 17 экранов); новый focusNewView(content) фокусирует первый h1/.page-title новой вьюшки (лаконичный анонс для SR + правильный порядок фокуса), делая заголовок фокусируемым (tabindex=-1) при необходимости и откатываясь на #content; самая первая отрисовка пропускается, чтобы не конфликтовать со skip-link; подключено и на успешном, и на ошибочном путях рендера; проверено вживую: после навигации document.activeElement — это H1.page-title новой вьюшки. Тесты: test(router): focus-management static guarantees — 4 кейса в router.test.mjs (хелпер определён, цель-заголовок + фолбэк на content, страж пропуска первой отрисовки, ≥2 места вызова); 631 → 635. Подробно — CHANGELOG.md.
[1.40.0] — 2026-05-18
WS8.3 — проход актуализации docs + исправление career-ops-ui help + усиление askSecret. Исправления: fix(cli): career-ops-ui help no longer leaks shell source — диспетчер печатал свой заголовочный комментарий через sed -n '2,12p', но строка 12 (set -euo pipefail) — это код, а не комментарий, поэтому career-ops-ui help (и текст использования при неизвестном глаголе) заканчивался лишней строкой set -euo pipefail; сужено до 2,11p (блок комментария) в обоих случаях — help и *); help завершается с кодом 0, неизвестный глагол — с кодом 2 — проверено. fix(cli): scripts/init.mjs key entry never echoes — доработка v1.39.0 заменила косметическую readline-маску реальным ридером в raw-режиме: setRawMode(true) + буферизованная строка, так что байты введённого/вставленного ключа вообще не доходят до терминала (нет утечки в scrollback / tmux / при демонстрации экрана); полный FSM VT-escape поглощает каждую последовательность CSI/SS3/OSC/DCS/SOS/PM/APC, так что стрелки и функциональные клавиши не портят секрет; stdin внедряется как зависимость, поэтому не-TTY fallback покрыт юнит-тестом без правки глобала; итерации до чистого LGTM от AI-ревью. Документация: README ×8 — старый раздел «установка одной командой» заменён заметным разделом «Запуск и инициализация одной командой» (curl-однострочник плюс явная цепочка CLI career-ops-ui: clone → npm link → setup → init → doctor → run → help, объяснение мастера провайдера, CI-форма --provider --anthropic-key --yes и примечание о LLM_PROVIDER); все 8 README-бейджей актуализированы с v1.22–v1.24 / tests-461–474 до v1.40.0 / tests-631 (e2e-бейдж сделан нечисловым, чтобы не выдумывать счётчик); help-bundle ×8 §1 — врезка-callout «Запуск и init одной командой» добавлена в начало руководства быстрого старта (перед «A. Setup») во всех 8 локалях; паритет H2-секций сохранён (по 17 — CI-гейт зелёный). Тесты: test(init): non-TTY askSecret fallback — provider-selector.test.mjs получает DI-stdin кейс, проверяющий, что askSecret делегирует обычному ask() (паритет trim) вне TTY без мутации общего глобала; 629 → 631. Подробно — CHANGELOG.md.
[1.39.0] — 2026-05-18
WS8.2 — селектор LLM-провайдера + ключ OpenAI/Codex + интерактивный мастер init. В env-config LLM_PROVIDER (auto|claude|gemini) + OPENAI_API_KEY (секрет). providerOrder() используется всеми 6 gate-site llm.mjs через _provGate(); для auto поведение неизменно. select + поле в #/config. scripts/init.mjs теперь реальный мастер (пишет parent .env через validated-path). 7 тестов. 622 → 629. README ×8 / fold канон-доков = WS8.3/WS10. Подробно — CHANGELOG.md.
[1.38.0] — 2026-05-17
WS8.1 — единый CLI-диспетчер + verb doctor. bin/career-ops-ui.sh маршрутизирует setup/run/doctor/init/help. scripts/doctor.mjs переиспользует точный движок /api/health (createApp in-process → терминальный отчёт); exit 0 только если все ОБЯЗАТЕЛЬНЫЕ проверки зелёные. docs/sdd + help §1 ×8. 6 тестов. 616 → 622. README quickstart ×8 = WS8.3 (финальный шаг). Подробно — CHANGELOG.md.
[1.37.0] — 2026-05-17
WS7 — pre-commit AI-ревью в git-workflow. Детерминированный floor (fail-HARD): блокирует staged .env/секреты, паттерны ключей в diff, .also( в staged-вьюхах, провал node --check. AI-слой (fail-SOFT): claude -p по diff, если CLI есть и AI_REVIEW != off. .githooks/pre-commit + prepare прописывает core.hooksPath. Никогда --no-verify. docs/sdd. 6 тестов. 610 → 616. Подробно — CHANGELOG.md.
[1.36.0] — 2026-05-17
WS6.3 — вкладка Modes: сырой блоб → посекционный редактор. WS6 завершён. modes/_profile.md правится по ##-секциям (свой сворачиваемый textarea на заголовок). Серверный splitProfileSections байт-точен; PUT { sections } мёржит только названные секции — преамбула + чужие секции + порядок сохраняются байт-в-байт. Неизвестный заголовок → 400. Raw-путь без изменений. i18n 5 ключей ×8. help §2 ×8. +6 тестов. 604 → 610. WS6 закрыт (API-keys/Profile-скаляры/Profile-массивы/Modes-секции — всё структурировано). Подробно — CHANGELOG.md.
[1.35.0] — 2026-05-17
WS6.4 — редакторы массивов Profile + аудит WS6.2 API-keys. PUT /api/profile принимает { arrays } (комбинируется с { fields }): Target roles/Superpowers (списки), Archetypes (name/level/fit), Proof points (name/url/hero-metric). Та же гарантия merge-not-replace; пустые строки отброшены; пустой список удаляет ключ. 4 add/remove-редактора в #/config. i18n 6 ключей ×8. Аудит: KNOWN_KEYS ≡ FIELDS, гэпа нет. +7 тестов. 597 → 604. Подробно — CHANGELOG.md.
career-ops-ui v1.267.1 · page 10/13 · mirror of the project changelog for cvstart.org