Dziennik zmian

GitHub

Każda istotna zmiana, wydanie po wydaniu. Ten sam plik jest dostępny w repozytorium i przetłumaczony na wszystkie 19 języków.

[1.58.20] — 2026-05-20

fix(i18n/platform): I-6 — footer hotkey hint now shows ⌘K on Mac, Ctrl+K elsewhere (localized verb). Pre-fix the sidebar footer hint was the literal CTRL+K — search on every platform and locale. The top-bar <kbd> badge already adapted via data-mac/data-other (v1.56.4 UX-N2); now the footer hint does too. top.langhint i18n values embed a {hotkey} placeholder (e.g., {hotkey} — поиск); new applyFooterHotkey() in public/js/app.js runs on boot + every I18n.onChange and replaces {hotkey} with ⌘K or Ctrl+K based on navigator.platform. The localized verb (search / buscar / поиск / 搜索 / …) stays. 915 → 916 unit (static guard locks the {hotkey} shape in all 8 locales, the platform branch, and the I18n.onChange re-apply). (I-6)


[1.58.19] — 2026-05-20

fix(i18n): I-4 — RU #/followup H1 + hints no longer leak Latin cadence / follow-up / scope / timeline. v1.58.3 RU regression: H1 Советник по cadence follow-up; hint ISO-дата (YYYY-MM-DD) — основа для cadence.; notesHint timeline, internal hold, …. All replaced with native Russian: Советник по ритму касаний, ISO-дата (ГГГГ-ММ-ДД) — основа для расчёта ритма касаний., сроки, внутренняя пауза, праздники. companyHint scope → область. 914 → 915 unit (negative-match guard: no cadence / follow-up in any RU followup.* string). (I-4)


[1.58.18] — 2026-05-20

fix(i18n): I-3 — help TOC items 2/5/13/14 free of English bleed in non-Latin locales. Pre-fix several locale help bundles still showed ## 2. App settings & API keys, ## 5. Portals & Sources, ## 13. Mode prompts, ## 14. Apply checklist (ru/ja/ko/zh-CN/zh-TW). Now fully localized in all 8 locales (RU Подсказки режимов / Чек-лист отклика, JA 応募チェックリスト, KO 앱 설정 및 API 키 / 포털 및 소스 / 지원 체크리스트, zh-CN 应用设置与 API 密钥 / 模式提示 / 申请清单, zh-TW 應用設定與 API 金鑰 / 招聘版面與來源 / 模式提示 / 申請清單). 913 → 914 unit (negative-match guard: items 2/5/13/14 contain none of App|settings|Apply|checklist|Portals|Sources|Mode|prompts for the 5 non-Latin locales). (I-3)


[1.58.17] — 2026-05-20

fix(i18n): I-2 — saved-research dates now use Intl.RelativeTimeFormat per locale. The formatRelative() helper in public/js/views/deep.js returned hardcoded English today / 1d ago / Nd ago regardless of UI language. Replaced with Intl.RelativeTimeFormat(I18n.getLang(), { numeric: 'auto' }) — the browser-native localized “today/yesterday/N days ago” string (сегодня/вчера, 今日/昨日, etc.). Dates older than 7 days fall back to Intl.DateTimeFormat(locale, { dateStyle: 'medium' }). Defensive try/catch keeps the old English fallback for ancient browsers without Intl. 912 → 913 unit. (I-2)


[1.58.16] — 2026-05-20

fix(ui): brand-button hover-flicker — pink/red primary & danger buttons no longer flash on hover (user-reported). Root cause in public/css/app.css: the default .btn-primary / .btn-danger background was a linear-gradient(135deg, var(--rausch) 0%, var(--rausch-dark) 100%); the :hover rule replaced it with a solid var(--rausch-dark). CSS cannot interpolate between a gradient and a solid colour, so the 180 ms transition: background on .btn snapped — the gradient briefly resolved to one of its stops on hover entry/exit, producing the visible white/pink flicker the user reported. Fix: keep the gradient on hover and dim via filter: brightness(0.92) instead — filter interpolates cleanly in every browser, so the dim/un-dim now animates symmetrically over the existing 180 ms duration. The .btn transition list is extended with filter var(--transition) so the hover dim actually animates. Mouse pointer-focus state stays clean (uses :focus, not :focus-visible, per the v1.58.9 M-1 contract). 911 → 912 unit (tests/qa-report-fixes.test.mjs asserts the gradient-stays/filter-dims contract on both .btn-primary:hover and .btn-danger:hover, plus that the pre-fix background: var(--rausch-dark) is gone and that filter is in the .btn transition list). (user-reported)


[1.58.15] — 2026-05-20

fix(a11y/i18n): I-1 — top-bar search aria-label and visually-hidden label now localized. v1.58.3 verified the global search input shipped aria-label="Global search — Cmd+K to focus, paste a URL and Enter for auto-pipeline" regardless of UI language. Screen-reader users on RU/JA/zh-CN/etc. were stuck with English. New generic data-i18n-aria-label hook in public/js/app.js mirrors the existing data-i18n / data-i18n-placeholder pattern — applyI18n() iterates every [data-i18n-aria-label] element and calls el.setAttribute('aria-label', I18n.t(key, …)) on each language change. The top-bar input now declares data-i18n-aria-label="top.search.aria"; the visually-hidden <label> declares data-i18n="top.search.label". Two new i18n keys (top.search.aria, top.search.label) added across all 8 locales. The hook is reusable — any future control just adds the attribute. 910 → 911 unit (tests/qa-report-fixes.test.mjs asserts the HTML markup wiring, the app.js handler shape, the 8-locale i18n parity, and a sanity check that RU ≠ EN so a copy-paste-English regression can’t slip through). (I-1)


[1.58.14] — 2026-05-20

fix(ux): M-9 — connection-banner Refresh now gives feedback (was silent reload). v1.58.3 verified the global Refresh button called location.reload() synchronously — user sees a brief flash but no explicit signal that anything happened. Fix in public/js/app.js: the click handler now (1) emits a transient Refreshing… toast, (2) sets sessionStorage['refreshedToast'] so the next page boot can surface a success toast (the in-flight one is destroyed by navigation), (3) disables the button to swallow rapid double-clicks (no stacking), and (4) defers location.reload() by 200 ms so the in-flight toast paints first. On boot, app.js checks the sessionStorage flag, clears it, and emits a success Refreshed toast (deferred so it lands after the SPA settles). Two new i18n keys (common.refreshing, common.refreshed) added across all 8 locales. 909 → 910 unit (tests/qa-report-fixes.test.mjs asserts the synchronous progress toast, deferred reload, disabled-guard, sessionStorage handoff, success toast on next boot, and 8-locale i18n parity). (M-9)


[1.58.13] — 2026-05-20

fix(ux): M-8 — #/apply checklist becomes interactive (delivers the page’s promise). v1.58.3 verified: ▶ Generate checklist rendered a monospace <pre> block listing items 0…7 — read-only text the user couldn’t tick. The page is titled “Apply checklist” but didn’t actually behave like one. Fix in public/js/views/apply.js — after the API returns, the raw r.checklist text is parsed into items (lines, trimmed, leading -/*/1./[ ] markers stripped) and rendered as a <ul class="apply-checklist"> of real <input type="checkbox"> rows. Each row is wrapped in <label> so the full row is the click target (WCAG 2.5.5; works with v1.58.9 M-1 focus rings). State is persisted in localStorage['applyChecklist:' + slug] (slug = URL stripped of protocol + query, alphanum/._/:- only, ≤240 char) — tick three items, reload, three still ticked. Two action buttons: Copy unchecked (clipboard the still-open items as - markdown bullets) and Reset (clear all ticks for this URL). Defensive fallback: if the parser finds zero items, render the raw text as before so the user isn’t shown an empty card. New CSS in public/css/app.css (.apply-checklist, .apply-checklist__actions, line-through on checked spans). Five new i18n keys (apply.checklist.copyUnchecked, resetBtn, copied, copyFailed, reset) across all 8 locales. 908 → 909 unit (tests/qa-report-fixes.test.mjs asserts the checkbox + data-item-index render, the <label> wrap, the applyChecklist: localStorage prefix, the load/save state functions, the two action buttons, the no-raw-<pre> regression-lock, the CSS row sizing, and 8-locale i18n parity for all five new keys). (M-8)


[1.58.12] — 2026-05-20

fix(ux): M-7 — cost hint now tracks the active provider (OpenRouter no longer falls through to a fabricated number). UI.providerCostHint() already routed via /api/status/providers so the line was provider-aware in spirit, but the per-provider maps in public/js/api.js only listed anthropic/gemini/openai/qwen. With v1.57.0’s 5th provider live, OpenRouter fell through to the generic 0.03 fallback and rendered the literal openrouter (lowercase) as the provider name — both wrong. Per the fix-prompt’s intent (“be honest, never quote a fixed number that might be wrong”), the EST map now lists openrouter: null (router picks the underlying model — cost varies per request), and the render path branches on null to emit a localized cost varies (router picks) instead of a fabricated ~$0.03/eval. NAME map adds openrouter: 'OpenRouter' so the user-visible name matches the docs. New i18n key cost.varies added across all 8 locales (cost varies (router picks) / coste variable (lo elige el router) / coste variable / etc.). 907 → 908 unit (tests/qa-report-fixes.test.mjs asserts EST openrouter: null, NAME openrouter: 'OpenRouter', the === null branch + t('cost.varies', …) call site, and 8-locale i18n parity). (M-7)


[1.58.11] — 2026-05-20

fix(ux): M-4 — saved-research card title↔date gap now structural CSS (was inline margin). v1.58.3 verified: some saved-research card entries displayed software-engineer-generaltoday (no space between title and date), while others were fine — the pre-fix relied on style="margin-left: 8px" between two raw <span> children, which collapsed on certain entries. The fix in public/js/views/deep.js:34-55 replaces the two anonymous <span>s with .saved-card__title + a semantic <time class="saved-card__date" datetime="…">, wrapped in a .saved-card flex container. Spacing is now driven by CSS gap: var(--space-2, 8px) so it can’t collapse (and you get a11y/SEO <time> semantics for free). New CSS in public/css/app.css defines .saved-card { display: inline-flex; align-items: baseline; gap: var(--space-2, 8px); white-space: nowrap }, .saved-card__title { font-weight: 500 }, .saved-card__date { color: var(--foggy); font-size: 0.85em; white-space: nowrap }. 906 → 907 unit (tests/qa-report-fixes.test.mjs asserts the new classes, the semantic <time datetime=…>, the no-inline-marginLeft: '8px' regression-lock, and the CSS gap declaration). (M-4)


[1.58.10] — 2026-05-20

fix(ux): M-2 — drain the progress toast before any result modal opens. Clicking sync-check on #/cv left the “Running cv-sync-check.mjs…” toast bottom-right while the result modal opened — both fighting for attention, and on narrow screens visually overlapping. The Health-page Doctor / verify-pipeline buttons already called UI.dismissToast() explicitly before UI.modal(); cv.js’s sync-check (and any future call site) was the odd one out. Fix in public/js/api.js — UI.modal() now calls dismissToast() as its first executable statement so every present and future call site gets the drain for free (defence-in-depth at the boundary). Also localized public/js/views/cv.js — the previously-hardcoded English UI.toast('sync-check…') and UI.modal('sync-check', …) now use t('cv.syncCheckRunning') and t('cv.syncCheck') respectively, satisfying the BUG-008 invariant (modal title == localized button label) for the cv-view path too. Two new i18n keys (cv.syncCheck, cv.syncCheckRunning) added across all 8 locales. 905 → 906 unit (tests/qa-report-fixes.test.mjs static guard for the dismissToast() boundary, the localized cv.js call site, the no-hardcoded-'sync-check…'-literal, and 8-locale i18n parity). (M-2)


[1.58.9] — 2026-05-20

fix(a11y): M-1 — re-establish a visible :focus-visible ring on form fields (WCAG 2.4.7 Level AA). The v1.58.3 MASTER regression confirmed getComputedStyle(focusedInput) returned outline: rgb(255,255,255) none 1.5px — i.e. the none keyword collapsed the ring to 0 px on every form field. Root cause: the base .input, .textarea, .select { outline: none } rule (intentional, to suppress the mouse-focus halo) had higher specificity than the global *:focus-visible rule and silently nuked the keyboard-focus ring on 88 focusable elements per page. Same issue on .searchbar input { outline: none } for the global ⌘K/Ctrl K search. Fix in public/css/app.css — add explicit .input:focus-visible, .textarea:focus-visible, .select:focus-visible and .searchbar input:focus-visible rules with outline: 2px solid var(--rausch) + a translucent box-shadow so keyboard-only focus paints a visible ring while mouse focus stays clean (uses :focus, not :focus-visible). 904 → 905 unit (tests/qa-report-fixes.test.mjs static CSS contract guard for all three new rules + the regression-lock for the pre-existing global *:focus-visible); Playwright 60 → 61 (tests/playwright-smoke.mjs Tab-traversal test asserts the computed outline on a #/config form field is ≥1.5 px wide and not none). (M-1)


[1.58.8] — 2026-05-20

feat(health): surface OPENAI_API_KEY, QWEN_API_KEY, OPENROUTER_API_KEY on #/health (analogous to GEMINI_API_KEY). v1.57.0 added OpenRouter as the 5th headless live-eval provider; v1.55.3 (UX-2) added on-screen onboarding for the 4-provider mix. The #/health page however only reported GEMINI_API_KEY and ANTHROPIC_API_KEY — the other three providers’ key state was invisible there even though /api/status/providers already routed evals to them. User request: extend the same “set / unset (manual mode)” row pattern to every headless provider. server/lib/routes/health.mjs now pushes three additional optional check rows (OPENAI_API_KEY, QWEN_API_KEY, OPENROUTER_API_KEY) wired to the same isUsableKey gate as /api/status/providers (hasOpenAIKey(), hasQwenKey(), hasOpenRouterKey() were already imported but unused). “manual mode” wording is identical to the GEMINI row across the SPA’s 8 locales — the Health view iterates over body.checks so no per-locale string change was needed. 903 → 904 unit (tests/api.test.mjs extended to assert all three new optional rows; ok=true still holds since they’re not required). (NEW user-requested feature)


[1.58.7] — 2026-05-20

fix(security): NEW-2 — isValidJobUrl now rejects paired template-placeholder syntaxes (${…}, {{…}}) to match the error message. The route-level 400 returned by POST /api/pipeline advertises “contain no script or template characters”, but the v1.58.3 MASTER regression confirmed only ASP/EJS-style <%…%> was actually blocked (free side effect of the [<>"'\\s] bracket-char gate). JS template literals (${TEST}) and Mustache/Handlebars ({{TEST}}) both passed validation — a regex↔message semantic gap. Option A from the fix-prompt (tighten regex to match message; slight hardening against URL-templating injection): a new TEMPLATE_PATTERNS array in [server/lib/security.mjs](https://github.com/Fighter90/career-ops-ui/blob/main/server/lib/security.mjs) (/${[^}]}/, /{{[^}]}}/) is consulted via hasTemplatePlaceholder(url)beforenew URL(…). Only **paired** placeholders are rejected — a bare {normal} ATS-style path token survives (https://boards.greenhouse.io/anthropic/jobs/4567` and https://example.com/job/{normal} both still accepted). 901 → 903 unit (tests/url-validation.test.mjs extended with 2 NEW-2 tests covering both rejected forms and the no-false-positive ATS case). The <%…%> regression-lock is also explicit now. (NEW-2)


[1.58.6] — 2026-05-20

fix(a11y/i18n): BUG-008-tb — top-bar Doctor modal title now matches the localized button label. The ledger row BUG-008 fixed in v1.58.0 (“modal title == localized button label”) closed the Health page entry-point. The v1.58.3 MASTER regression then found the top-bar entry-point still violated the invariant: regardless of UI locale, clicking the top-bar Doctor button opened a modal titled doctor (hardcoded English, lowercase). Repro: EN top-bar Doctor → modal title doctor; RU top-bar Диагностика → modal title doctor. Two entry paths, one invariant, one passing. Fix in public/js/app.js:118 — replace the literal 'doctor' with I18n.t('top.doctor', 'Doctor'). The top.doctor i18n key (already present in all 8 locales: EN Doctor · ES/pt-BR Diagnóstico · KO 진단 · JA 診断 · RU Диагностика · zh-CN 诊断 · zh-TW 診斷) is the same key the button declares via data-i18n="top.doctor", so the modal title is now textually identical to the visible button label across all locales. Static contract guard added in tests/qa-report-fixes.test.mjs (BUG-008-tb): asserts the I18n.t('top.doctor', 'Doctor') form in app.js, the no-hardcoded-UI.modal('doctor',-literal, the 8-locale presence of top.doctor in i18n-dict.js, and the button↔key wiring in index.html. 900 → 901 unit; Playwright 60/60. (BUG-008-tb)


[1.58.5] — 2026-05-20

fix(ui): NEW-3 — #/followup Run-live double-POST triaged not-reproducible; locked with Playwright regression guard. The v1.58.3 MASTER regression observed (via monkey-patched window.fetch) two identical POSTs to /api/mode/followup within ~2 s after a single click on #/followup Run live (company/role/notes filled, date intentionally empty). Per the fix-prompt’s “repro first” doctrine, source inspection of public/js/views/mode-page.js::submit() shows: (a) the Run-live and Generate-prompt buttons are plain <button> elements with a single onClick each — there is no parent <form> and no addEventListener('submit') to double-fire, and (b) UI.withSpinner() (FIX-L1) sets button.disabled = true for the duration of the in-flight request, so a second physical click during the request is blocked at the source. A new Playwright test in tests/playwright-smoke.mjs (NEW-3 — single click on #/followup submits exactly one POST) walks the exact regression recipe — fills company/role/notes, leaves the date blank, clicks the manual-prompt button (which shares the submit() function with Run live), and asserts exactly one POST /api/mode/followup over a 3 s window. Locale-stable selector (the ▶ glyph is the same in all 8 locales), and addInitScript seeds career-ops-ui:lang=en so a prior language test in the same browser context can’t perturb the field selectors. Playwright 59 → 60. Original QA observation is filed as recipe-only; no shipped code change required. (NEW-3)


[1.58.4] — 2026-05-19

fix(security): NEW-1 — emit Content-Security-Policy on every response (was loopback-gated). Before v1.58.4 the CSP header was layered on only when isPubliclyExposed() was true (HOST bound beyond loopback); over 127.0.0.1 both / and /api/health returned no CSP, leaving UI.md()’s escape-first contract as the only XSS defence. The v1.58.3 MASTER regression (§5) flagged this as a stop-ship invariant gap. CSP is now unconditional and identical on every response regardless of bind address: default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'. script-src never allows 'unsafe-inline'/'unsafe-eval'. The directive set is unchanged from the prior exposed-only policy (already SPA-correct — Google Fonts allowlisted for Inter), so no visual or functional regression. tests/security-headers.test.mjs was rewritten to lock CSP-present-on-loopback; a Playwright route-walk (en/ru/ja/zh-TW × 7 routes) asserts 0 CSP violations. 900 unit · Playwright 58→59 · e2e 20/20+23/23. Next fix-prompt items (NEW-3, BUG-008-tb, NEW-2, M-1…) ship as subsequent one-fix releases per project doctrine. See qa/v158-regression/FIX-PROMPT-v1.58.4_and_beyond.md. (NEW-1)


[1.58.3] — 2026-05-19

fix(deep): R-2 / FIX-C1 — strip ORPHAN / unbalanced agent-scaffolding tags from research output. v1.58.0 added cleanLlmMarkdown but it only removed paired <tool_call>…</tool_call> blocks and a dangling open tag. A deep regression of v1.58.2 found a model that emitted a lopsided trace — an orphan closing </tool_response> (and </thinking>) with no opener — which survived and rendered literally in the saved #/deep brief. A final conservative sweep now removes any standalone scaffold token (open or close, balanced or not): tool_call|tool_response|tool_use|function_call|function_results|thinking, the Anthropic tool XML (<invoke …>/</invoke>/<parameter …>/<function_calls> and antml:-namespaced forms), and fenced ```tool_* blocks. Pure + idempotent; real <https://…> autolinks and `<code>` spans are preserved (only the named agent tokens match). FIX-C2 (<html lang> not updating) was triaged not-reproducible — i18n.js already sets document.documentElement.lang on setLang AND at boot and detects navigator.language; the QA repro was a stale localStorage/pre-redeploy artifact. Locked both with regression guards. 896 → 900 unit · Playwright 58/58 · e2e 20/20+23/23. The remaining v1.58.3 fix-prompt items (M-1 focus-ring, M-4..M-9, I-1..I-6, U-1..U-15) are queued as subsequent one-fix ships per project doctrine (never batched). See qa/v158-regression/.


[1.58.2] — 2026-05-19

fix(i18n): I18N-011 — localize the #/help table-of-contents in all 7 non-EN locales. The #/help “On this page” TOC is built from the ## section headings of docs/help/<lang>.md. Sections 3/4/6/7/8/9/10/11/12 (Profile, CV, Health, Scan, Pipeline, Evaluate, Reports, Tracker, Deep research) still carried their English titles in es/pt-BR/ko/ja/ru/zh-CN/zh-TW, so the TOC showed English while the sidebar nav was translated. Each affected heading is now localized to the exact same term as the sidebar nav.* key (single source of truth — TOC ↔ sidebar match), preserving the section number and the (#/route …) parenthetical verbatim. EN unchanged (canonical). 7–9 headings per locale. Closes the sole open i18n backlog item from the v1.58 QA sweep. Docs-only; 896/896 unit · 33/33 help tests · Playwright 58/58. See qa/v158-regression/.


[1.58.1] — 2026-05-19

fix(test): CI-isolated checkProfileCustomized guard (patch over v1.58.0). v1.58.0 shipped green on the advisory pre-commit but red on ci.yml (Node 18/20/22): the new BUG-002/UX-032 test used a cache-bust dynamic import + PATHS rewrite, but server/lib/paths.mjs resolves the project root once per process, so the temp root never took effect under the shared npm test runner (it coincidentally passed locally). Replaced with a robust static guard that asserts the store.mjs allow-list contains the fixture names AND that the regex is ^(…)$/i exact-anchored (so a real name merely containing “test” — e.g. María Testanova — is provably never false-flagged). No production code changed; this also unblocks publish-package.yml (it runs the suite before publishing). 896/896 unit · Playwright 58/58. See qa/v158-regression/.


[1.58.0] — 2026-05-19

fix(qa): external QA-report bug sweep + clean, formatted research output.

🐛 Fixes (QA report)

  • BUG-001 (Critical) — #/followup now validates the optional Last contact field client-side as an ISO date (YYYY-MM-DD); junk no longer reaches the LLM (and burns a credit). Generic pattern support added to the mode-page form factory.
  • BUG-003 (Major, every help page) — **bold**, `code`, italic and [links] now render inside block-quotes in UI.md() (the > **Audience:** cards showed literal **). Safe-by-construction (content is already HTML-escaped before inline() runs).
  • BUG-005 (Major) — adding a URL already in the pipeline now shows an honest “Already in the queue — skipped” info toast instead of a misleading green “Added”.
  • BUG-006 (Major) — the invalid-URL message is humanized/sentence-cased (“That doesn’t look like a valid job posting URL — …”). The (POST /api/pipeline · HTTP 400) where/why context stays by design.
  • BUG-007/008 (Major/Minor) — the “Running doctor.mjs…” progress toast is dismissed before the result modal opens (new UI.dismissToast()), and the modal title reuses the localized button label so its casing always matches.
  • BUG-010 — the #/reports empty state now has the descriptive page-subtitle every other page has.
  • BUG-002 / UX-032 (code-side) — checkProfileCustomized() now flags QA/test-fixture names (Acceptance Test, Real Person, QA, …) as not customized, so the Health card and prompts stop treating a test profile as the real candidate. The parent project’s config/profile.yml / cv.md are not touched (hard rule #1) — the data cleanup is the user’s; this is the heuristic fix.
  • I18N-012/013 — Russian deep.title, deep.subtitle (“smart questions”), and dash.quick.deepCta are now actually translated.

✨ Features / UX

  • Clean, formatted research output. New server/lib/llm-output.mjs::cleanLlmMarkdown() strips agent/tool scaffolding the model sometimes echoes (<tool_call>{…}</tool_call>, <tool_response>, <tool_use>, <function_call>, <thinking>, [TOOL_CALL]…) from #/deep and Saved research. Applied at every chokepoint — Anthropic / OpenAI / Qwen / OpenRouter clients, the Gemini subprocess path, and on serving an already-saved brief (older files render clean too). The SPA already renders these via the XSS-safe UI.md(), so the result is a properly formatted document.
  • #/outreach alias → #/contacto (BUG-004) for a consistent, bookmarkable English URL; canonical slug stays contacto (parent mode filename).
  • AI-review rule 3 — the client-owned network-error sentence is localized via I18n.t() (api.netError / api.netHint, 8 locales). Documented decision: server-emitted validation details are intentionally English diagnostics (consistent with every other server error; the SPA surfaces them verbatim and localizes its own chrome).

🧪 Tests

  • New tests/qa-report-fixes.test.mjs (10) and tests/llm-output.test.mjs (5, incl. idempotency + “don’t eat real code blocks”). checkProfileCustomized exercised directly (real name containing “test” is not false-flagged). Updated url-validation + config-validation-detail assertions for the new messages; e2e-comprehensive now date-aware. 881 → 896 unit; Playwright 58/58; e2e 20/20 + 23/23.

Triaged, not changed (with rationale)

  • BUG-009 (#/cv H1 is a quiet breadcrumb chip) — by design: the single-<h1> WCAG 1.3.1 decision (F-V54-A); changing it risks an a11y regression.
  • BUG-002 data, UX-022 (parent profile.yml/cv.md/portals.yml content) — parent-owned; hard rule #1 forbids the repo editing parent files. Code-side detection hardened instead.
  • Long-tail minor i18n/UX (I18N-011 help-TOC localization, I18N-014..019, UX-020/021/023/024/025/026/027/028/029/030/031) — tracked backlog; deferred to avoid a large unreviewed change set in a patch line.

[1.57.2] — 2026-05-19

fix(config): the ACTUAL root cause of /#/config “validation failed” — the SPA-injected lang field.

🐛 Fixes

  • Saving anything on /#/config from the browser always returned validation failed. public/js/api.js auto-attaches a lang field to every JSON POST body (so LLM routes pick up the UI locale). /api/config is not an LLM route and lang is not a config key, so validateConfig’s (correct, security-relevant) unknown-key rejection 400’d every Save with validation failed — lang: not a known config key. This was browser-only: curl/in-process repros never sent lang, which is why v1.57.0/.1 (whitespace-trim, descriptive errors) improved the message but didn’t fix the cause. The config route now strips the transport-only lang before validating; the KNOWN_KEYS write-filter still drops any genuinely-unknown key, so the attacker-injection guard is unchanged.
  • Found by a new Playwright form sweep that drives the real save button — not the synthetic fetch repros that masked it.

🧪 Tests

  • New tests/playwright-forms.mjs (26, wired into npm run test:e2e:browser): drives a real Chromium over every form-bearing route — asserts no console errors site-wide, the invalid-save toast shows field+reason+request-context, secret fields never echo the typed value, valid saves succeed, and PORT/HOST show their prefilled defaults.
  • tests/config-endpoint.test.mjs: browser-parity cases — POST with lang succeeds (and lang never lands in .env), while a real unknown key with lang present is still rejected. 879 → 881 unit; Playwright 32 → 58.

[1.57.1] — 2026-05-19

fix(ux): every API error now says WHAT failed, WHERE, and WHY — and the input-error text is maximally descriptive.

🐛 Fixes

  • “validation failed” was opaque. The server already returned { error, details: ["FIELD: reason", …] }, but every form’s catch (e) { UI.toast(e.message) } showed only the top line, so on /#/config (and everywhere) the user couldn’t tell which field was wrong. api.js now folds the per-field details into the thrown message site-wide (one change, every form benefits), appends the request context (METHOD /path · HTTP NNN) so the toast says where it failed, falls back to a trimmed raw-body snippet for non-JSON errors, and includes the verb+path on network errors. err.details is also exposed for inline rendering.
  • Input-error messages are now maximally descriptive. validateConfig explains what’s wrong and how to fix it — e.g. PORT: must be 1-65535 — a whole number, digits only (the default is 4317); you entered "abc"; HOST: invalid hostname/ip — only letters, digits and . : - _ are allowed (e.g. 127.0.0.1 …); the Anthropic message now points to console.anthropic.com and tells you to use the right provider’s field. Secret keys never echo the entered value (only its character length), so a mistyped real key can’t leak into a toast or log.
  • PORT range is now actually enforced — a 5-digit value above 65535 (e.g. 99999) was previously accepted; it’s now rejected with a clear reason.

✨ UX

  • /#/config PORT and HOST are pre-filled with their real defaults (4317 / 127.0.0.1) instead of looking empty/unconfigured.
  • Error toasts stay on screen long enough to read — dwell time scales with message length (9–20 s for errors; success/info keep the snappy 3.5 s) and the toast wraps + scrolls instead of clipping a detailed message to one line.

🧪 Tests

  • New tests/config-validation-detail.test.mjs (12): api.js detail-folding + WHERE/WHY context + toast dwell (static guards); config.js PORT/HOST defaults; server contract that every error is a field-prefixed string; descriptive-message assertions; secret-no-echo leak guard; the 1–65535 range fix. 874 → 879.

[1.57.0] — 2026-05-19

feat(provider): OpenRouter as a 5th headless live-eval provider + fix(config): “validation failed” when saving any API key.

🐛 Fixes

  • /#/config no longer rejects valid API keys with “validation failed”. Pasted keys routinely arrive with a trailing newline or surrounding spaces (OS clipboard, the “copy” buttons on provider consoles). Pre-1.57 that tripped the newline guard for every provider, and the $-anchored ANTHROPIC_API_KEY charset regex (/^sk-ant-[A-Za-z0-9_-]{20,}$/) false-rejected genuine Anthropic keys whose base64url tail / future prefix didn’t fit the class. validateConfig now normalizes (trims) every value before validating, the route persists the trimmed value (so it authenticates at runtime — no stray-newline .env quoting), and the Anthropic check is a resilient sk-ant- prefix + length sanity check (the shared isUsableKey() 20-char floor remains the real “is it real?” gate). Internal newlines are still rejected — that .env-injection guard is intact.

✨ Features

  • OpenRouter is now a first-class provider. Add OPENROUTER_API_KEY on /#/config and one key fronts 300+ models (Anthropic, OpenAI, Google, Meta, Qwen, DeepSeek …). It’s the last entry of the auto order (Anthropic → Gemini → OpenAI → Qwen → OpenRouter), so an existing setup is never silently re-routed; LLM_PROVIDER=openrouter pins it. Wired into the same _tailProvider() path as OpenAI/Qwen across /api/evaluate, /api/deep, /api/mode/:slug, and surfaced by /api/status/providers + the Health dashboard. OpenAI-compatible client (zero new deps — direct fetch, AbortController timeout, key never logged) with the recommended HTTP-Referer/X-Title attribution headers.
  • The OpenRouter model dropdown is live. OPENROUTER_MODEL is a dynamic select fed by GET /api/openrouter/models — a server-side proxy of OpenRouter’s public catalogue (keeps the CSP connect-src 'self' envelope intact; no browser→third-party fetch). It degrades to a curated namespaced fallback list when the catalogue is unreachable (offline / rate-limited / 5xx), so the dropdown is never empty, and a 10-minute in-memory cache keeps repeat /#/config visits from re-hitting OpenRouter. New i18n keys (config.openrouter*) across all 8 locales; config.llmProviderHint updated.

🧪 Tests

  • New CI-isolated suites: tests/openrouter-route.test.mjs (proxy answers 200 with a non-empty namespaced list even offline; leaks no secret) and tests/openrouter-model-selector.test.mjs (env-config contract + config.js wiring + 8-locale i18n parity). Extended env-config, openai, and provider-selector suites: trim/relax validation, runOpenRouter/hasOpenRouterKey/fetchOpenRouterModels, the appended auto tail, and the OpenRouter gate. Two previously-weak validateConfig cases (used the no-longer-known HH_USER_AGENT, so they passed for the wrong reason) now assert against a real key. 831 → 855.

[1.56.4] — 2026-05-19

feat(ui): UX-N2 — visible, platform-aware ⌘K / Ctrl K hint on the global search input.

✨ Features

  • The Cmd/Ctrl+K “focus search” shortcut is now discoverable on screen. It lived only in the input’s aria-label / source, so sighted users never found it and the app felt slower than it is. A muted <kbd class="kbd-shortcut"> now sits at the end of the search pill, filled at boot from data-mac / data-other by a platform check (navigator.platform/userAgent): ⌘K on macOS/iOS, Ctrl K elsewhere. It is aria-hidden="true" (the existing aria-label already announces the shortcut to screen readers — the badge must not double-announce) and pointer-events:none (decorative). The existing Cmd/Ctrl+K keybinding is unchanged. No new i18n keys (the glyphs are universal); the badge is a flex child of the existing .searchbar (no wrapper / absolute positioning — the input is already flex:1).

🧪 Tests

  • New CI-isolated source-static suite tests/cmdk-hint-visible.test.mjs (5): the .kbd-shortcut <kbd> lives inside the .searchbar pill; it is aria-hidden="true" with both data-mac/data-other variants; app.js fills it via a navigator platform check from the dataset; the existing (e.ctrlKey||e.metaKey)&&e.key==='k' → search.focus() keybinding is intact (regression guard); app.css styles .kbd-shortcut and never display:none. index.html/app.js/app.css are browser-only → asserted statically. 826 → 831.

[1.56.3] — 2026-05-19

fix(reliability): provider key detection rejects placeholder / too-short values, not only the empty string.

🐛 Fixes

  • A placeholder GEMINI_API_KEY in a parent .env was reported “✓ set” AND mis-selected as the active provider over a valid ANTHROPIC_API_KEY. effectiveEnv() only rejected undefined/'', so a 10-char placeholder counted as a real key: the onboarding banner showed GEMINI ✓ set, GET /api/status/providers returned activeProvider: "gemini", and every live ⚡ eval would have silently failed against a dead key while a working 108-char Anthropic key was ignored. New pure isUsableKey() (env-config.mjs) treats a secret as configured only when it is ≥ 20 chars (no supported provider’s key is shorter — Gemini AIza… ≈ 39, Anthropic sk-ant-… ≈ 100+, OpenAI ≥ 40, Qwen ≈ 35 — so a real key is never false-negatived) and not a known placeholder (your_*_here, changeme, placeholder, <…>, all-one-char, …). Applied uniformly to hasAnthropicKey()/hasGeminiKey() (anthropic.mjs), hasOpenAIKey()/hasQwenKey() (openai.mjs), and the GEMINI_API_KEY/ANTHROPIC_API_KEY rows of GET /api/health — which also moved off raw process.env onto the same effective+plausible view, so the health page, the providers endpoint, and the OR-router now always agree. selectActiveProvider() is unchanged; it just receives a correct keysConfigured.

🧪 Tests

  • New CI-isolated suite tests/key-detection-rejects-placeholder.test.mjs (5): isUsableKey unit cases (empty/non-string, too-short incl. the 10-char repro, long-but-placeholder, realistic keys accepted) + an in-process createApp() reproduction of the exact reported scenario (10-char GEMINI_API_KEY + real ANTHROPIC_API_KEY in a temp parent .env, the 4 keys stripped from process.env) asserting gemini is NOT in keysConfigured, activeProvider === "anthropic", and the /api/health rows agree. Four existing anthropic/openai effective-env layering tests had trivially-short stub keys (sk-x, AIzaTEST, sk-o, …) lengthened to realistic values — the layering contract they assert is unchanged; only the stubs had to clear the new floor. 821 → 826.

[1.56.2] — 2026-05-19

feat(a11y): UX-N1 — per-route, locale-aware document.title (multi-tab orientation + screen-reader page-change announcement).

✨ Features

  • Every SPA route now sets a distinct, localized document.title. Pre-fix all 24 routes kept the static index.html <title> (“career-ops — command center”), so multi-tab browsers showed identical tab labels, bookmarks were generic, and the screen-reader “page changed” announcement read the same on every navigation. public/js/router.js focusNewView() now derives the title from the view’s own localized <h1 class="page-title"> — "<View> — career-ops" — so titles are automatically translated (no new i18n keys) and distinct per route. Set before the first-paint guard so the initial tab is titled too (the same ordering rule the v1.56.0 UX-12 tabindex set follows). Falls back to career-ops — command center when a view has no heading.

🧪 Tests

  • New CI-isolated source-static suite tests/document-title-per-route.test.mjs (4): focusNewView assigns document.title; the title is derived from the view <h1> textContent (per-route + localized, not one hardcoded literal); the assignment precedes the !firstPaintDone early-return; a sane product default is present. router.js is browser-only → asserted statically (same approach as dashboard-initial-focus.test.mjs). 817 → 821.

[1.56.1] — 2026-05-19

fix(a11y): suppress the spurious brand focus ring on router-managed tabindex="-1" heading focus.

🐛 Fixes

  • A red box framed every view’s <h1> (regression surfaced by v1.56.0 UX-12, rooted in the v1.41.0 SPA focus-management pattern). public/js/router.js gives each destination view’s heading tabindex="-1" and .focus()’s it on every client-side navigation so screen readers announce the new page. A tabindex="-1" element is never keyboard-reachable, yet Chromium’s :focus-visible modality heuristic still painted the global brand ring (*:focus-visible { outline: 2px solid var(--rausch) }) around it — a red rectangle around the page heading (e.g. “Command Center” on #/dashboard) on every navigation, which had also been baked into the images/dashboard-*.png hero screenshots. The fix is one scoped rule — [tabindex="-1"]:focus, [tabindex="-1"]:focus-visible { outline: none } — the WAI-ARIA APG managed-focus pattern. Genuine keyboard focus on real interactive controls keeps the global *:focus-visible ring (WCAG 2.4.7 intact); the skip-link’s ring is unaffected (it is an <a>, not tabindex="-1", with a higher-specificity rule).
  • All 8 images/dashboard-*.png regenerated against the fixed UI — the README hero screenshots no longer show the red box.

🧪 Tests

  • New CI-isolated source-static suite tests/managed-focus-no-ring.test.mjs (4): the global *:focus-visible brand ring is still defined (WCAG 2.4.7 not regressed); [tabindex="-1"]:focus, :focus-visible resolves to outline: none; the suppression rule follows the global rule (cascade safety); the fix stays scoped (no blanket *:focus { outline: none }). Pairs with the existing tests/dashboard-initial-focus.test.mjs (router still sets tabindex + focuses on navigation). 813 → 817.

[1.56.0] — 2026-05-19

feat(ux): LOW-priority polish bundle — UX-9 / UX-10 / UX-11 / UX-12 (one grouped minor release).

✨ Features

  • UX-9 — #/cv breadcrumb (visual hierarchy): the page title is demoted to a quiet uppercase .cv-breadcrumb chip and the loud page-subtitle paragraph moves to the <h1> title tooltip, so the user’s CV (their name, rendered in the preview) owns the visual space. The F-V54-A invariant is intact — still exactly one <h1>, still .page-title (the router focus target); only its weight changed.
  • UX-10 — honest cost ballpark (trust/feedback): a new shared UI.providerCostHint(t) helper renders next to ⚡ Run live on #/auto, #/evaluate, #/deep and every #/<mode>. It reuses GET /api/status/providers (v1.55.3): with a key it shows “Estimated cost: OpenAI gpt-5-codex · ~$0.04/eval” (order-of-magnitude, deliberately “~”); with no key it states plainly that ⚡ Run live copies a manual prompt at no API cost. Fail-soft (hidden when offline).
  • UX-11 — #/help TOC auto-jump (aesthetic/flow): when the TOC filter narrows to exactly one section, the page smooth-scrolls there after a 300ms idle (debounced — mid-typing keystrokes never yank the page; never fires for 0 or >1 matches), reusing the same scroll+focus path as a TOC click.
  • UX-12 — #/dashboard first-paint a11y (accessibility): on the very first SPA paint the landing view’s <h1> is now made programmatically focusable (tabindex="-1") so screen-reader / heading navigation lands on it, and #content stays aria-live="polite" so the heading is announced on boot — without stealing focus (that would fight the skip-link, the deliberate v1.41.0 behaviour). Only subsequent route changes move focus. (Note: UX-12 as originally specified — “call focusNewView() on boot” — conflicted with the v1.41.0 skip-link decision; resolved conservatively to satisfy the a11y intent without the regression.)
  • New i18n keys cost.estimate, cost.manual ×8 locales; new token-based .cv-breadcrumb / .cost-hint CSS.

🧪 Tests

  • 4 new CI-isolated source-static suites — tests/cv-breadcrumb.test.mjs (3), tests/run-cost-line.test.mjs (4), tests/help-toc-autoscroll.test.mjs (4), tests/dashboard-initial-focus.test.mjs (3): single-h1 + breadcrumb class; shared helper exported + fail-soft + present in all 4 views + cost.* ×8; debounced exactly-1-match jump gated; tabindex-before-guard ordering + focus-after-guard + #content aria-live. The pre-existing cv-single-h1 and help-nav-a11y locks were updated for the new (invariant-preserving) code. 800 → 813. Live Playwright probe of all four (no-key fixture): single h1 13px breadcrumb, cost hint manual note, TOC→1 narrow, h1 tabindex=-1 + aria-live polite, 0 console errors.

[1.55.8] — 2026-05-19

feat(tracker): server-side pagination + clickable funnel chips (UX-8).

✨ Features

  • Server: GET /api/tracker gains optional ?page / ?pageSize / ?status query params. With none, the response is byte-for-byte the legacy { rows: [...] } (every existing caller/test untouched). With them, it returns { rows: slice, total, page, pageSize, funnel } — pageSize clamped to [1,500], page clamped to ≥1, status filters rows+total, and funnel is the whole-history status→count breakdown (independent of the page or status filter, so the UI chips are always accurate).
  • #/tracker: a new clickable funnel summary chip bar at the top — “all statuses · N · Applied · N · Interview · N · Offer · N · Rejected · N …” (ordered Applied → Responded → Interview → Offer → Rejected → Discarded → Evaluated → SKIP). Clicking a chip sets the Status filter (clicking the active chip clears it); the active chip is aria-pressed and visually highlighted. The pipeline state is now legible at a glance instead of buried behind a dropdown. Feedback/forms lens.
  • New i18n key track.funnelAria ×8 locales; new token-based .tracker-funnel / .tracker-chip / .tracker-chip--active CSS.

🧪 Tests

  • test: tests/tracker-server-paged.test.mjs (new, 7 cases, CI-isolated, in-process Express on an ephemeral port + temp CAREER_OPS_ROOT applications.md fixture — CLAUDE.md #2/#8): back-compat (no params ⇒ exactly {rows}, no envelope leak); ?page&pageSize slice + total+page+pageSize+full funnel summing to N; last partial page with no overlap; out-of-range page ⇒ empty rows + valid total; ?status= filters total/rows while funnel stays whole-history; pageSize cap; plus a source-static lock on the .tracker-funnel chip bar (toggle-on-reclick, aria-pressed, track.funnelAria ×8, CSS). 793 → 800.

[1.55.7] — 2026-05-19

feat(pipeline): vanilla-JS row virtualization at >1000 rows (UX-7).

✨ Features

  • #/pipeline rendered every row (filtered.forEach(list.appendChild(urlRow))) — a real scan fills the queue with thousands of URLs, so thousands of row nodes (each a flex div + <a> + two buttons) were built synchronously on every filter keystroke, flooding the DOM and the accessibility tree. New vanilla-JS virtualization (a react-window equivalent, no deps): above VIRTUALIZE_THRESHOLD = 1000, #/pipeline becomes a fixed-height (70vh) scroll viewport with a non-shrinkable spacer (flex:0 0 auto, height = rows × 56px) that preserves the real scrollbar for the full list, and an rAF-throttled scroll listener renders only the viewport ± a 5-row buffer (~16–19 nodes at a time instead of N). At/below the threshold the original simple full render is kept byte-for-byte, so typical pipelines and all existing pipeline tests/e2e are unaffected. Each virtualized row keeps its URL-disambiguated ▶/✕ aria-label (F-V54-B regression-locked). Window math is a pure computeWindow() helper.

🧪 Tests

  • test: tests/pipeline-virtualize.test.mjs (new, 5 cases, CI-isolated, source-static): a numeric ~1000 threshold gates the path; the ≤threshold branch keeps the simple forEach→appendChild; the >threshold branch renders slice(start,end) with a rAF-throttled scroll listener + a scrollbar-preserving spacer; computeWindow() clamps [0,total] with a ± buffer; rows keep the disambiguated ▶/✕ aria-labels. 788 → 793. Live Playwright probe (1200-URL fixture): scrollHeight≈67248 (full range), only ~16–19 row nodes in the DOM, window tracks scroll end-to-end (row 0 → 595 → 1199), 0 console errors.

[1.55.6] — 2026-05-19

feat(scan): tuck secondary filters behind an “Advanced filters” disclosure (UX-4).

✨ Features

  • #/scan stacked every filter — free-text, remote/hybrid/onsite, scope, source, and the post-scan stack/level/dynamic facet chips — at equal weight, a wall of controls. Now the everyday filters stay visible (free-text + Remote/Hybrid/Onsite; the 🌐 Scan button is already separate in the controls card) and the secondary ones collapse behind a <details class="scan-advanced"><summary>Advanced filters</summary>: the Scope + Source selects, and — separately — the stack/level/dynamic facet-chip cluster (which now leads the fresh result set with the table, not a wall of chips, and only renders when at least one chip row exists). Cognitive-load lens.
  • New i18n key scan.advancedFilters across all 8 locales; new token-based .scan-advanced summary styling (quiet ⚙ affordance, marker-less, bold when open).

🧪 Tests

  • test: tests/scan-advanced-disclosure.test.mjs (new, 6 cases, CI-isolated, source-static): an Advanced-filters <details>/<summary> exists with the .scan-advanced hook and scan.advancedFilters label; free-text + remote stay in the always-visible group; scope + source live inside the disclosure; chipsContainer is a <details>; .scan-advanced summary is styled; scan.advancedFilters ×8 locales. 782 → 788.

[1.55.5] — 2026-05-19

feat(dashboard): hero-promote the 2 P0 CTAs + a focal recent-activity hint (UX-3).

✨ Features

  • #/dashboard opened with ~30 equal-weight nodes — no clear “what next”. A new .dash-hero block now sits directly under the page header: the two P0 journeys — ✨ Auto-pipeline a URL and 🌐 Scan now — are promoted to large .btn-hero buttons, and a single focal recent-activity hint (“Last evaluation: <score> — <title>”, linked to the report; a guiding empty-state on cold start via dash.heroNoEval) tells a returning user where they left off and a new user the one action that matters. The two primary buttons were removed from the header (only the secondary “📋 Open pipeline” stays there) so the action isn’t duplicated.
  • The application-status buckets were demoted from prominent .badges to quiet .dash-chip pills so they no longer compete with the hero (Information-scent / cognitive-load lens).
  • New i18n keys dash.lastEval, dash.heroNoEval across all 8 locales; new token-based .dash-hero / .btn-hero / .dash-chip CSS.

🧪 Tests

  • test: tests/dashboard-hero.test.mjs (new, 5 cases, CI-isolated, source-static): a .dash-hero block exists and precedes the Quick-actions grid; both P0 CTAs are .btn-hero with the /auto + /scan routes; a focal dash.lastEval hint + dash.heroNoEval empty-state; status buckets use .dash-chip; .dash-hero/.btn-hero/.dash-chip CSS exists; dash.lastEval + dash.heroNoEval ×8 locales. 777 → 782.

[1.55.4] — 2026-05-19

feat(ux): honest auto-pipeline ETA next to Run + prominent Stop during a scan (UX-6).

✨ Features

  • #/auto: a new .auto-eta hint — “⏱ ~1–2 min” (key auto.eta, title via auto.etaTitle) — now sits directly next to the Run button, so the one-click promise is honest about duration before the user commits. The wording matches career-ops.org/docs (“paste a URL → full report in 1–2 minutes”). Feedback & system-status lens.
  • #/scan: while the multi-minute crawl is running (aria-busy), the Stop control is promoted from a low-contrast ghost button to a prominent destructive button (new .btn-danger — filled, high-contrast white-on-coral, weight 600). setScanRunning(running) flips scan-stop-btn between btn-danger (running) and btn-ghost (idle, when it is hidden anyway), so the user can find and trust Stop under load. Error-recovery lens.
  • New i18n keys auto.eta, auto.etaTitle across all 8 locales; new token-based .btn-danger / .auto-eta CSS.

🧪 Tests

  • test: tests/auto-eta-stop.test.mjs (new, 4 cases, CI-isolated, source-static): #/auto renders t('auto.eta') with the .auto-eta class adjacent to runBtn; auto.eta ×8 locales; setScanRunning(running) promotes Stop to btn-danger; .btn-danger exists with high-contrast white text. 773 → 777.

[1.55.3] — 2026-05-19

feat(onboarding): on-screen 4-provider OR status — cold-start banner + active-provider chip (UX-2, HIGH).

✨ Features

  • New read-only endpoint GET /api/status/providers → { activeProvider, activeModel, keysConfigured }. keysConfigured uses the same effective-env view as the llm.mjs gate sites (process.env ∨ parent .env, via hasAnthropicKey/hasGeminiKey/hasOpenAIKey/hasQwenKey); activeProvider is what the OR-router would actually pick — selectActiveProvider(), a new pure helper in env-config.mjs that walks providerOrder() (so an LLM_PROVIDER pin with no matching key correctly yields null). No secrets are returned — only provider names + the model id.
  • The SPA shell now renders a global onboarding region (#onboarding-banner, populated by app.js from that endpoint, CSP-safe DOM only): 0 keys → a red banner “No LLM key set — ‘⚡ Run live’ is in manual-prompt mode…” with a CTA deep-linking to #/config?tab=api-keys; ≥1 key → a subtle chip naming the active provider + model (e.g. Live eval: OpenAI (gpt-5-codex)). It re-evaluates on locale change and when the user navigates away from the config tab (keys may have just been saved). This makes the product’s headline differentiator — “one of Anthropic / Gemini / OpenAI / Qwen works, auto-ordered” — discoverable on screen instead of learned by trial.
  • New i18n keys onboarding.noKey.title, onboarding.noKey.cta, onboarding.activeProvider across all 8 locales; new .onboarding-warn / .onboarding-ok CSS (token-based, mirrors .conn-banner).

🧪 Tests

  • test: tests/onboarding-key-banner.test.mjs (new, 9 cases, CI-isolated): selectActiveProvider auto-order / none / LLM_PROVIDER-pin semantics; GET /api/status/providers in-process (ephemeral port + temp CAREER_OPS_ROOT .env so the real parent key is never read — CLAUDE.md #2/#8) for 0-key, 1-key+model, and Anthropic-over-Gemini auto order; static SPA wiring (banner host, endpoint fetch, #/config?tab=api-keys CTA) + onboarding.* ×8 locale coverage. 764 → 773.

[1.55.2] — 2026-05-18

fix(cv): give the #/cv markdown editor a descriptive, self-contained accessible name (F-V55-H / UX-5).

🐛 Bug Fixes

  • The #/cv primary editor <textarea id="cv-editor"> now carries a descriptive aria-label via the new cv.editorAria key — “CV markdown editor — your professional resume in markdown format” — instead of the terse name it inherited from the visible “Markdown” section heading. Note: contrary to the F-V55-H symptom (which only inspected aria-label/labels), the field was not nameless — v1.47.0 (WS2 #16) had already bound it via aria-labelledby → the <h3 id="cv-md-heading">Markdown</h3>, so a screen reader announced “Markdown, edit, multiline”. v1.55.2 upgrades that terse “Markdown” to a self-contained label so a screen-reader user lands and immediately knows what the field is. The redundant aria-labelledby is removed (a leftover would be dead markup — aria-label wins per ARIA precedence); the visible <h3>Markdown</h3> stays on screen for sighted users. WCAG 1.3.1 + 4.1.2; parallels the v1.54.5 batch-tsv fix (F-V54-C).

🧪 Tests

  • test: tests/cv-editor-a11y.test.mjs (new, 3 cases, CI-isolated, source-static like auto-stepper-prerender.test.mjs): #cv-editor names itself via t('cv.editorAria', …) with a non-empty fallback; cv.editorAria is present and non-empty in all 8 locales; no redundant aria-labelledby remains on the element. 761 → 764.

[1.55.1] — 2026-05-18

fix(auto): pre-render the 5-stage pipeline stepper on #/auto mount (F-V55-E / UX-1, senior obs S-4 reopened).

🐛 Bug Fixes

  • #/auto now shows the documented five-stage outline — validate → fetch → evaluate → save report → add tracker — the moment the screen mounts, instead of staying blank until the first SSE event. Previously <ol class="auto-stepper"> was created display:none and renderStepper() was only reached from setStep() / run(), so a cold-start user never saw the pipeline the docs promise before clicking Run. The stepper is now visible on mount with all five steps in the pending state, and carries an aria-label (auto.stepperAria) so assistive tech announces the region. Closes F-V55-E (a11y/static-guarantee lens) and UX-1 (promise-fidelity lens) — same fix, both lenses.

🧪 Tests

  • test: tests/auto-stepper-prerender.test.mjs (new, 4 cases, CI-isolated, source-static like router.test.mjs): the STEPS array is exactly the 5 canonical stages in order; stepperEl is not display:none on mount and carries auto.stepperAria; a mount-scope renderStepper() call precedes function setStep(; auto.stepperAria is present in all 8 locales. 757 → 761.

[1.55.0] — 2026-05-18

feat(llm): headless live-eval runs via “OR” — Anthropic | Gemini | OpenAI | Qwen, auto-selected by whichever key is set.

✨ Features

  • Per user request, the web-ui ⚡ live eval now works with whichever API key is set, not just Anthropic/Gemini. LLM_PROVIDER gains openai and qwen; auto (default) uses the first provider whose key is present, preferring Anthropic → Gemini → OpenAI → Qwen. An explicit value pins one; a forced provider with no key still falls through to the manual-prompt path.
  • New server/lib/openai.mjs — a zero-dependency OpenAI-compatible Chat Completions client (same secure direct-HTTPS pattern as anthropic.mjs: AbortController timeout, key never logged, effectiveEnv() key resolution so a parent-.env key works without a restart). One core (runOpenAICompatible) backs runOpenAI (api.openai.com) and runQwen (Alibaba DashScope OpenAI-compatible mode; override the endpoint with QWEN_BASE_URL in the raw .env for the mainland-CN host). No SDKs, no arbitrary CLI execution — the parent project stays CLI-agnostic (Claude Code · Codex · Gemini · OpenCode · Qwen · Copilot · Kimi); this only extends the headless API-key path.
  • The OpenAI/Qwen tail is wired into all eval surfaces: /api/evaluate, /api/deep, /api/mode/:slug, and the /api/auto-pipeline SSE — consulted after the existing Anthropic (inline) + Gemini (subprocess) branches so the auto preference is preserved, with the same bundled-context inlining Anthropic uses.
  • env-config.mjs: QWEN_API_KEY (secret) + QWEN_MODEL (not secret) added to KNOWN_KEYS/KEY_GROUPS.core; LLM_PROVIDERS and providerOrder() extended; OPENAI_API_KEY is now a first-class headless provider key (was stored-only).
  • #/config API-keys tab: LLM_PROVIDER select gains openai/qwen; new QWEN_API_KEY + QWEN_MODEL fields (curated qwen-max/qwen-plus/qwen-turbo/qwen2.5-* list); a new top-of-tab note explains the CLI-agnostic parent vs the headless web-ui eval and the OR order. Updated OpenAI/provider hint copy. New i18n keys (config.providerModelNote, config.qwen*) + 3 updated hints, across all 8 locales.

🧪 Tests

  • test: tests/openai.test.mjs (new, 9 cases, CI-isolated): OpenAI/Qwen success + block-array content, Bearer auth, default + QWEN_BASE_URL-overridden endpoints, 4xx/5xx/malformed, max_tokens clamp, timeout, effectiveEnv key detection, no-key-leak canary. tests/provider-selector.test.mjs updated for the v1.55.0 providerOrder/LLM_PROVIDERS/SECRET surface + the OpenAI/Qwen tail wiring. 748 → 757.

[1.54.10] — 2026-05-18

fix(auto-pipeline): SSE client-disconnect hygiene — kill the flaky Playwright e2e job.

🐛 Fixes

  • The Playwright e2e job intermittently went red (32/32 individual tests pass, but not ok 2 - tests/playwright-smoke.mjs): closing a page while the #/auto SSE stream was mid-flight made the server’s next res.write() reject with EPIPE/"aborted", and — with no 'error' listener on the response — Node escalated it to an uncaughtException that node:test reported as “asynchronous activity after the test ended”. openSse() in auto-pipeline.mjs now registers a no-op res.on('error') and guards send() on res.writableEnded || res.destroyed (wrapped in try/catch) — a vanished client is expected, not exceptional. This is correct production SSE hygiene, not just a test fix.
  • tests/playwright-smoke.mjs: the Cmd+K test used a real outbound URL (https://example.com/jobs/123) but only waited for the modal to appear, so closePage() aborted the server’s in-flight safeGet() after the test ended. It now waits for the pipeline to reach a terminal state (so the fetch resolves normally before close). A shared closePage() helper (window.stop() then close) and the after-hook server.closeAllConnections() remain as defence-in-depth. Verified: 8/8 consecutive green runs (6× node --test + 2× browser-smoke), previously ~1-in-2 red.

🧪 Tests

  • test: tests/auto-pipeline.test.mjs +1 static case locking the openSse disconnect-hygiene contract (res.on('error') listener + writableEnded||destroyed guard + try-wrapped writes). 747 → 748.

[1.54.9] — 2026-05-18

fix(llm): honour the parent .env LLM keys at request time — stop mis-routing to a stale/invalid provider.

🐛 Fixes

  • Live evaluation could fail with “Gemini API error: API key not valid” even when ANTHROPIC_API_KEY was the configured provider. Root cause: hasAnthropicKey() / hasGeminiKey() (and runAnthropic’s key/model lookup) read only the boot-time process.env snapshot. If the Anthropic key was added to the parent .env after the server started, the running process never saw it → Anthropic detection was false, and evaluation fell through to whatever stale key was in process.env (often an old, invalid GEMINI_API_KEY). The Gemini exec path (a parent Node subprocess) already read the live parent .env, so the two providers resolved keys inconsistently.
  • New effectiveEnv(key, envFilePath) in env-config.mjs: a non-empty process.env value wins (covers shell exports and the live-apply in POST /api/config); otherwise the current parent .env file is consulted. anthropic.mjs now resolves ANTHROPIC_API_KEY, ANTHROPIC_MODEL, and the Gemini-key check through it, so a key set in the parent .env is honoured without a server restart and key DETECTION always matches the key the request actually SENDS. Provider order is unchanged (auto → Anthropic-then-Gemini); this only fixes detection. Keys are never logged or returned (the REVIEW-B4 no-leak test still passes).

🧪 Tests

  • test: tests/anthropic.test.mjs rewritten to be CI-isolated (temp CAREER_OPS_ROOT, dynamic import) with 2 new cases reproducing the exact bug (key only in parent .env → detected; runAnthropic sends the parent-.env key + model when process.env is unset). test: tests/env-config.test.mjs +3 effectiveEnv cases (process.env precedence, .env fallback incl. empty-string-as-unset, missing-file / absent-key / no-path → undefined) — 100% of the new branch. 742 → 747.

[1.54.8] — 2026-05-18

feat(config): Modes field-form always renders the canonical schema (even on an empty/stub file) with career-ops.org field guidance.

✨ Features

  • The v1.54.3 Modes field-form only rendered fields for ## sections that already existed — so on a fresh, empty, or non-schema modes/_profile.md (e.g. the common 1-line stub) it fell back to “No ## sections found — use the raw editor below.” and the user never got fields. Per user request (“разбей по полям … описание полей возьми из career-ops.org/docs”), the form now always renders the 5 canonical fields in documented order (Target Roles, Adaptive Framing, Exit Narrative, Comp Targets, Location Policy), pre-filled from the file when present and empty-but-editable when not — so a brand-new profile can be filled in entirely through the form.
  • Each field shows a description sourced from the canonical career-ops.org Quick Start §Step-5 (what to put in Target Roles / Adaptive Framing / Exit Narrative / Comp Targets / Location Policy), wired via aria-describedby for screen readers.
  • Heading-variant tolerant: the template’s ## Your Target Roles (etc.) maps to the same canonical field as ## Target Roles, so neither the template nor the server-scaffold convention breaks the form.
  • collect() is now a tagged payload: a non-destructive { sections } merge when the rendered headings exactly match the file’s existing ones (preamble + untouched + custom sections survive byte-stable), or a { markdown } full-file rebuild that bootstraps/normalises a schema-conformant document when the file lacked the schema. The rebuild path is confirm-gated in config.js (it replaces the parent file — WS2 #4 destructive-save invariant), preserves the existing preamble (or a documented default), and keeps non-canonical sections verbatim.
  • 6 new i18n keys (config.modesDescTargetRoles … config.modesDescLocationPolicy + config.modesFormRebuildBody) across all 8 locales.

🧪 Tests

  • test: tests/modes-form.test.mjs — rewritten for the v1.54.8 contract: schema + canonical order, config.js payload/confirm wiring, every field’s doc-sourced description present in all 8 locales, canonicalKey “Your X” tolerance, list round-trip stability, the bootstrap-always-renders guarantee, and the tagged sections-vs-markdown collect() with data-safety. Verified live against the real parent stub file (5 fields + descriptions appear, 0 console errors) and an isolated stub fixture (fill → confirm-gated save → all 5 canonical sections persisted). 742 unchanged (7 cases, replaced).

[1.54.7] — 2026-05-18

fix: W-001 — code/style assets + SPA shell served Cache-Control: no-store (deploy-hygiene).

🐛 Fixes

  • The SPA loads api.js / router.js / every view via plain <script src> with no version query string, and there is no build step (no content hashing), so after a deploy a browser could keep serving a cached old bundle for hours → stale-cache 404s on query-string routes (observed live during the v1.29.2 regression; regression run W-001). server/index.mjs now sets Cache-Control: no-store on .js / .mjs / .css / .html via the express.static setHeaders hook, and explicitly on the SPA-shell catch-all (which uses sendFile and bypasses setHeaders), so the browser always revalidates the code that drives routing. Non-code static assets keep express.static’s default caching. Security headers (CSP / nosniff / frame-deny / referrer-policy) are unchanged — verified by the existing security-headers suite (8 cases) running green alongside the new test.

🧪 Tests

  • test: tests/asset-cache-control.test.mjs — 4 cases (JS assets no-store, CSS no-store, static index.html no-store, SPA catch-all deep-route shell no-store), booting the real app against an isolated CAREER_OPS_ROOT. Plus a flaky-teardown fix in tests/playwright-smoke.mjs (separate test(e2e) commit): the auto-pipeline SSE smoke test now cancels the reader + aborts the fetch in a finally and the after hook force-closes lingering sockets, eliminating the post-teardown “Error: aborted” that reddened the v1.54.6 Playwright e2e job. 738 → 742.

[1.54.6] — 2026-05-18

fix(a11y): S-7 — #/help back-to-top button carries the canonical back-to-top selector class.

🐛 Fixes

  • The #/help floating back-to-top button worked correctly (verified live) but its class list (btn btn-primary help-back-top) sat outside the .back-to-top selector convention the spec §2 #28 test targets — a tightened selector would have flaked (regression run S-7, “easy win”). The button now also carries the canonical back-to-top class. Purely additive and a CSS-no-op: help-back-top (the existing CSS hook) is unchanged and back-to-top has no CSS rule — it’s a stable test/automation handle only. Verified live: document.querySelector('.back-to-top') resolves the button, aria-label intact, 0 console errors.

🧪 Tests

  • test: tests/help-nav-a11y.test.mjs — extended the existing #12 case with an assertion that the back-to-top button’s class list includes the canonical back-to-top selector (no new file; 738 unchanged).

[1.54.5] — 2026-05-18

fix(a11y): F-V54-C — #/batch TSV editor has an accessible name.

🐛 Fixes

  • The #/batch TSV textarea had a hint wired via aria-describedby but no accessible name — no <label htmlFor>, no aria-label/aria-labelledby (regression run F-V54-C; WCAG 1.3.1 Info & Relationships / 4.1.2 Name, Role, Value). aria-describedby supplies a description, not a name, so a screen reader announced an unlabelled “edit text”. The textarea now carries an aria-label via the new i18n key batch.tsvAria, consistent with the sibling run-control inputs that already use *Aria keys; the existing describedby hint is preserved. Verified live: aria-label present + localized, aria-describedby intact, 0 console errors.
  • New i18n key batch.tsvAria across all 8 locales.

🧪 Tests

  • test: tests/batch-tsv-accessible-name.test.mjs — 2 cases (the batch-tsv block has an aria-label via t(batch.tsvAria) while keeping its describedby hint; batch.tsvAria defined in all 8 locales). 736 → 738.

[1.54.4] — 2026-05-18

fix(a11y): F-V54-B — #/pipeline row-action buttons have accessible names.

🐛 Fixes

  • The per-row ▶ (evaluate) and ✕ (delete) buttons on #/pipeline were icon-only with only a title attribute (regression run F-V54-B; WCAG 4.1.2 Name, Role, Value). title is not a reliable accessible name, so a screen-reader user heard a long run of indistinct “button”s and could not tell which row a delete would hit. Both buttons now carry an explicit aria-label disambiguated by a compact URL via a new shortUrl() helper (host + …/ + last 2 path segments; trailing-slice fallback for unparseable input), so the a11y tree reads e.g. “Delete: hh.ru/…/vacancy/12345”. No new i18n keys — reuses common.delete / pipe.evaluateBtn + the URL. Verified live: 1385 rows, each button name unique per row, 0 console errors.

🧪 Tests

  • test: tests/pipeline-row-action-names.test.mjs — 4 cases (both buttons wired with shortUrl(url) + exactly two such labels, shortUrl declared before use, same-host different-job URLs don’t collapse, bare-host / unparseable / empty fallbacks). 732 → 736.

[1.54.3] — 2026-05-18

feat(config): structured field-form for the #/config “Modes” tab (no more raw markdown).

✨ Features

  • The “Modes” tab edited modes/_profile.md as one raw <textarea> per ## section (v1.36.0 section-level granularity). Per user request — “собери данные по полям, разбей из документации, определи набор полей и реализуй поля именно, а не сырой” — it now renders a structured field-form derived from the documented schema (career-ops.org Quick Start §Step-5):
    • Target Roles / Adaptive Framing / Comp Targets → repeatable add/remove labelled line-inputs (one role/angle/comp line per field, + Add line / per-row ✕ with aria-label).
    • Exit Narrative / Location Policy → single labelled prose <textarea>.
    • Each field is a real <label htmlFor>-bound control with an i18n section name.
  • New public/js/lib/modes-form.js (window.ModesForm) owns the parse → render → collect() logic; it feeds the existing PUT /api/modes/_profile { sections } merge path, so the preamble, ordering, and any section the form doesn’t touch survive byte-stable (merge-not-replace, server-enforced).
  • Data-safety: a canonical list section whose body isn’t a pure bullet list (user put prose there) and any non-canonical ## section fall back to a labelled verbatim <textarea> with an explanatory note — arbitrary content round-trips untouched, never silently reshaped or lost. Round-trip stability proven: serialise(parse(body)) re-parses identically.
  • The raw full-file markdown editor remains as the confirm-gated Advanced disclosure for add/remove-section and preamble edits (WS2 #4 destructive-save gate unchanged).
  • 10 new i18n keys (config.modesTargetRoles … config.modesUnknownNote) across all 8 locales.

🧪 Tests

  • test: tests/modes-form.test.mjs — 7 cases (documented schema present, config.js wires ModesForm.build/collect + drops the stale modesSectionInputs map, list classification incl. scaffold/prose/mixed, list & prose round-trip stability, empty-list → empty section, custom-section verbatim data-safety). 725 → 732. Verified live against an isolated CAREER_OPS_ROOT fixture: 5 canonical sections rendered as fields + 1 custom section as a labelled fallback, edit-and-save round-trip preserved the preamble + custom section, 0 console errors.

[1.54.2] — 2026-05-18

feat(config): OpenAI / Codex model selector in #/config.

✨ Features

  • #/config had no way to pick the OpenAI / Codex model — only ANTHROPIC_MODEL and GEMINI_MODEL had dropdowns, even though OPENAI_API_KEY was already exposed for the parent multi-CLI (Codex / OpenCode) flow. OPENAI_MODEL is now a first-class env key: added to env-config.mjs KNOWN_KEYS (ordered right after OPENAI_API_KEY) and the core key group, and deliberately not in SECRET_KEYS — it’s a model id, not a credential, so it’s never masked. config.js gains a curated OPENAI_MODELS list (gpt-5-codex default, then gpt-5 / gpt-5-mini / gpt-4.1 / o4-mini / o3) and an OPENAI_MODEL <select> field rendered immediately after the OpenAI key, mirroring the Anthropic/Gemini model fields exactly (same kind: 'select', aria-describedby hint wiring, label = env-var name). Read by the parent Codex / OpenCode CLI flow — web-ui live-eval still uses Anthropic|Gemini. Verified live: #/config → OPENAI_MODEL select with 6 options, default gpt-5-codex, label-bound, 0 console errors.
  • New i18n keys config.openaiModel + config.openaiModelHint across all 8 locales.

🧪 Tests

  • test: tests/openai-model-selector.test.mjs — 4 cases (env-config core/non-secret contract + key ordering, OPENAI_MODELS list with gpt-5-codex default, the OPENAI_MODEL select field wired after OPENAI_API_KEY, both i18n keys across all 8 locales). 721 → 725.

[1.54.1] — 2026-05-18

fix(a11y): F-V54-A — #/cv single <h1>.

🐛 Fixes

  • The CV markdown’s own # Name rendered as a second top-level <h1> next to the page-title <h1>CV</h1> (regression run F-V54-A; WCAG 1.3.1 Info & Relationships / 2.4.6 Headings). cv.js now feeds every preview-injection point (initial render, file-import refresh, live editor sync) through a scoped cvMd() that shifts headings down one level (h1→h2 … h5→h6, h6→role="heading" aria-level="7"), so the page keeps exactly one <h1>. Scoped to cv.js on purpose — UI.md is shared by help/reports/deep/evaluate, which manage headings their own way (help strips article h1s + builds its TOC from h2). Verified live: #/cv → 1 page <h1> (“CV”), the user’s # Name is now <h2>, 0 console errors.

🧪 Tests

  • test: tests/cv-single-h1.test.mjs — 4 cases (cvMd shift chain, every preview site uses cvMd not raw UI.md, single page-title h1, the transform re-derived + proven to map every level down). 717 → 721.

Verification

$ npm run test:ci
# 721 / 721 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.54.1
# Playwright #/cv: 1 <h1> ("CV"), preview h1=0/h2=1, 0 console errors

[1.54.0] — 2026-05-18

WS10 — canonical-docs re-validation + help-bundle H3 parity (final convergence release).

The absolute-last workstream: re-validate the SPA against the 5 canonical career-ops.org/docs guides + the parent project, close the last structural doc-divergence, and refresh the SDD spec — shipped as its own release per the WS10 mandate.

🐛 Fixes

  • fix(docs): help-bundle H3 parity — en=70 vs locales=68 — the CHANGELOG/structure CI gate only checked H2, so docs/help/en.md had silently drifted to 70 ### subsections while all 7 localized bundles stayed at 68. The gap was §17 (“How to add a new job-portal source”): the “Reference adapters” table + “Common pitfalls” list had been added to en only. Both are now translated into es/ja/ko-KR/pt-BR/ru/zh-CN/zh-TW (adapter filenames, relative links, registry.mjs, r.source === fs, fetchImpl/signal, tracked_companies kept byte-identical). All 8 bundles: 17 H2 / 70 H3.

🧪 Tests

  • test(help): H3-parity gate — help-ru-config-section.test.mjs now also asserts every bundle (en + 7) has an identical H3 count (70), not just H2. An en-only ### addition can no longer silently diverge the localized bundles. 716 → 717.

📝 Documentation

  • Canonical re-validation: tests/canonical-docs-coverage.test.mjs (7/7) confirms the help bundles still mirror all 5 canonical guides — what-is-career-ops, scan-job-portals, apply-for-a-job, batch-evaluate-offers, set-up-playwright — and the WS2 UX-audit (40 findings, every screen Playwright-verified across v1.41→v1.52) validated every screen against documented behaviour. No divergence found.
  • docs/sdd/CONVENTIONS.md refreshed to v1.54.0 reality: test totals (716 node --test + 4 E2E + shell-surface tier), the new H3-parity gate, updated file-size outliers (scan/config grew with WS2), and a new Accessibility section codifying the WS2 patterns (focus-trapped UI.confirm, WAI-ARIA tabs, SSE live-regions, label binding, sortable-table aria-sort, async-relabel announce) as standing conventions.

Verification

$ npm run test:ci
# 717 / 717 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.54.0
# help bundles: all 8 → 17 H2 / 70 H3 · canonical-docs-coverage 7/7

WS0–WS10 complete. Only WS11 (qa/ actualization + final QA prompt) remains.


[1.53.0] — 2026-05-18

WS9 — shell-surface test pyramid (the last untested layer).

🧪 Tests

  • test: tests/sh-files.test.mjs — the 4 bin/*.sh scripts and the .githooks/pre-commit hook had zero coverage. 10 cases now lock: bash -n/sh -n syntax, shebang + executable bit, and the behavioural contracts other workstreams depend on:
    • career-ops-ui.sh — help exits 0 + prints usage with no shell-source leak (v1.40.0 regression guard), unknown verb exits 2 with usage on stderr, usage() is a heredoc (not the fragile sed-scrape), all dispatcher case-labels present.
    • start.sh — NO_OPEN honored, Node ≥ 18 gate, browser-raise delegated to scripts/open-dashboard.mjs (v1.43.0 guard), no bare open "$URL".
    • setup.sh — strict mode, SKIP_START, clones both repos, need git.
    • run_all.sh — --quick/--no-e2e parsing, runs all 4 suites via run_suite.
    • .githooks/pre-commit — execs the WS7 reviewer; no shell file invokes git --no-verify (CLAUDE.md hard rule #7 guard); install-hooks.mjs wires core.hooksPath and is the npm prepare step.
  • 706 → 716.

📝 Documentation

  • docs/architecture/TESTING.md — added the shell-surface base layer to the pyramid diagram + a v1.53.0 totals note (716 node --test cases / 90 files + 4 E2E surfaces); documents that scripts/*.mjs logic is covered by cli-doctor/open-dashboard/ai-precommit-review/provider-selector + the CI-gate scripts.

Verification

$ npm run test:ci
# 716 / 716 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.53.0

[1.52.0] — 2026-05-18

WS2 LOWs #33–#40 — batched polish sweep (closes the UX-audit queue).

🐛 Fixes

  • fix(a11y/i18n): WS2 LOW batch — eight low-severity findings:
    • #33 #/dashboard — the 3 header CTAs were inconsistent (only 2 had a leading icon); “Open Pipeline” now gets 📋 so all three match.
    • #34 #/profile — archetype fit/level rendered as two bare ambiguous chips; now prefixed (Fit: … / Level: …) with matching aria-label.
    • #35 #/health — Run-doctor / verify toasts showed raw doctor.mjs / verify-pipeline.mjs strings; now i18n-keyed (health.runningDoctor/Verify).
    • #36 #/health — the check results were a flat run of <div>s with no programmatic name↔status link. Now a role="list" <ul>/<li> and the status badge carries aria-label="<check>: <status>".
    • #37 #/reports — report cards were mouse-only <div onClick>; now role="link" + tabindex="0" + Enter/Space handler + aria-label.
    • #38 #/activity — the paginator comment said “200” while the code requested 500; reconciled to a CAP constant and a role="note" notice now surfaces when the 500-cap truncates older history (activity.truncated).
    • #39 #/batch — prose placeholders were hardcoded English while their aria-labels were localized; the four (minScore/maxRetries/model/startFrom) are now i18n-keyed (the TSV-template placeholder stays — it’s a data-format example).
    • #40 mode pages — the async health probe relabelled/reordered the primary button silently; a visually-hidden role="status" region now announces it (mode.liveReadyAnnounce).

🌐 i18n

  • 10 new keys × 8 locales (set.fit/level, health.runningDoctor/Verify, activity.truncated ({n} preserved), batch.minScorePh/maxRetriesPh/modelPh/startFromPh, mode.liveReadyAnnounce). i18n-coverage gate green.

🧪 Tests

  • test: tests/low-sweep.test.mjs — 9 cases (one per finding + the 10-key ×8 i18n check). 697 → 706. Playwright-verified: health ul[role=list] (17 li, badge aria “Node version: OK”), report card role=link/tabindex/aria, dashboard CTAs all-iconed, 0 console errors.

This closes the WS2 UX-audit queue (#1–#40 across v1.41→v1.52). Next: WS9 (test pyramid) → WS10 (canonical re-validation + separate final release) → WS11 (qa/ finalization).

Verification

$ npm run test:ci
# 706 / 706 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.52.0

[1.51.0] — 2026-05-18

WS2 #13 + #14 + #18 + #19 + #20 — feedback / i18n sweep (auto + evaluate).

🐛 Fixes

  • fix(a11y/ux): pending feedback, actionable errors, real clipboard, status regions:
    • #13 #/auto Run button was disabled with an unchanged label → no pending cue for the multi-second pipeline. It now shows a busy state (is-loading + aria-busy + “⏳ Running…”), restored in finally.
    • #14 the HTTP-failure branch put a bare “HTTP 500” on step 1 with no toast and an empty live region. Now an actionable, i18n’d message (auto.httpFail, {n}-substituted) on the step and a toast.
    • #18 the manual-mode “Copy prompt” used deprecated document.execCommand('copy') and always toasted “Copied” even when it silently no-op’d. Now prefers the async Clipboard API, falls back to execCommand, and toasts a real failure (auto.copyFail) instead of a false success.
    • #19 #/evaluate #eval-out had no live role — the long LLM call was silent for screen readers. Now role="status" aria-live="polite" so “Evaluating…”, the verdict, and errors are announced.
    • #20 the Evaluate button was a plain onClick: run (enabled during the call → duplicate submissions). Now UI.withSpinner-wrapped (disables + busy state).

🌐 i18n

  • 3 new keys × 8 locales — auto.running, auto.httpFail ({n} preserved), auto.copyFail. i18n-coverage gate green.

🧪 Tests

  • test: tests/feedback-i18n-sweep.test.mjs — 6 cases (busy state + restore, actionable+toasted HTTP fail, async-clipboard fallback, eval status region, eval spinner-wrap, 3 i18n keys ×8). 691 → 697.
  • fix(test): e2e pipeline-delete teardown (commit 7f8e250) — e2e.mjs / e2e-comprehensive.mjs deleted the test row via the pre-v1.48 native-confirm path; v1.48.0’s focus-trapped UI.confirm() left the modal open and its backdrop blocked later flows (CI Playwright-e2e red). Teardown is now an API DELETE. Not a product regression — tests predated the confirm gate.

Verification

$ npm run test:ci
# 697 / 697 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.51.0
$ node tests/e2e.mjs               # 20/0
$ node tests/e2e-comprehensive.mjs # 23/23

[1.50.0] — 2026-05-18

WS2 #12 + #27 + #28 — help navigation accessibility.

🐛 Fixes

  • fix(a11y): help — single h1, labelled+filterable TOC, focus-on-anchor, back-to-top — three #/help findings on a 17-section / 90+-heading guide:
    • #28 the doc markdown opened with its own # Title, producing a SECOND <h1> on a page whose header already supplies the canonical h1 (and a h1→h3 jump in some locales). Every article <h1> is now stripped, so there is exactly one h1 and the hierarchy starts cleanly at the <h2> sections.
    • #27 the TOC <nav> was an unnamed landmark (two unlabeled <nav>s on the page); it now has aria-label (help.toc). Clicking a TOC entry no longer just scrolls the viewport — focus moves to the section heading (tabindex=-1 + focus()), so keyboard/SR users land in the section.
    • #12 no way to find anything in a long doc. A type="search" filter above the TOC narrows entries by heading text live; a floating, aria-labelled Back to top button appears after scrolling, returns to top and moves focus back to the page <h1>. Its scroll listener is removed on hashchange away from #/help (no leak).

🌐 i18n

  • 2 new keys × 8 locales — help.tocFilter, help.backToTop. i18n-coverage gate green.

🧪 Tests

  • test: tests/help-nav-a11y.test.mjs — 6 cases (h1 strip, labelled nav, focus-on-anchor, filter narrows, back-to-top + listener cleanup, 2 i18n keys ×8). 685 → 691. Playwright-verified: 1 h1, TOC aria-label, filter narrows 17→1 on “scan”, 0 console errors.

Verification

$ npm run test:ci
# 691 / 691 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.50.0

[1.49.0] — 2026-05-18

WS2 #10 + #11 + #25 + #26 — tracker table accessibility & sort.

🐛 Fixes

  • fix(a11y): tracker headers, sortable table, localized fix labels, empty state — four #/tracker findings:
    • #10 the action column header was an empty string and the per-row Report button had no context. Every <th> now has scope="col"; the action header is t('track.col.actions'); Score/PDF headers are i18n-keyed (were hardcoded English); the Report button gains an aria-label (<report> — <company>).
    • #11 a job tracker with no way to sort. Date / Score / Status headers are now keyboard-operable sort buttons inside the <th> with aria-sort (none/ascending/descending); a sorted() comparator (numeric for score, locale-compare for date/status) runs before pagination; clicking toggles direction and resets the pager.
    • #25 track.normalize/dedup/merge were identical English in all 8 locales despite being the highest-stakes destructive controls — now properly localized, plus a title tooltip (Rewrites data/applications.md in place).
    • #26 a zero-row first run showed the same “no match” message as an over-filtered list. rows.length === 0 now renders a distinct empty state (title + body + “Open pipeline” CTA); the filter-excluded-everything case keeps track.noMatch.

🌐 i18n

  • 7 new keys × 8 locales (track.col.score/pdf/actions, track.fixHint, track.emptyTitle/Body/Cta) + 3 re-localized (track.normalize/dedup/merge). i18n-coverage gate green.

🧪 Tests

  • test: tests/tracker-a11y-sort.test.mjs — 6 cases (scope + i18n headers, Report aria-label, sortable th/aria-sort/comparator, localized destructive labels + title, distinct empty state, 7 i18n keys ×8). 677 → 683. Playwright-verified: 9 th all scope=col, 3 sortable, action header localized, aria-sort none→ascending on click, 0 console errors.

Verification

$ npm run test:ci
# 683 / 683 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.49.0

[1.48.0] — 2026-05-18

WS2 #8 + #22 — pipeline: focus-trapped confirm + preview a11y.

🐛 Fixes

  • fix(a11y): pipeline UI.confirm() + live preview region —
    • #8 all three #/pipeline actions used native confirm() (auto-dismissed in embeds, not focus-trapped): the preview-pane Delete, the per-row ✕ delete, and “Evaluate first”. All now route through the focus-trapped UI.confirm() (v1.44.0 infra) — the two deletes danger:true (Cancel-default), “Evaluate first” danger:false.
    • #22 previewPane had no live role and a fetch failure was stuffed into previewBody so it rendered as a misleading <pre> “preview”. It’s now role="region" aria-live="polite" with an aria-label; failures set a separate previewError and render a distinct role="alert" block. previewError is cleared on (re)select and when the active row is deleted.
  • No native confirm() remains in pipeline.js.

🌐 i18n

  • 4 new keys × 8 locales — pipe.confirmDelTitle, pipe.previewError, pipe.evaluateAllTitle, pipe.previewRegion. i18n-coverage gate green.

🧪 Tests

  • test: tests/pipeline-confirm-preview.test.mjs — 5 cases (no native confirm, ≥3 UI.confirm with correct danger flags, labelled live region, distinct alert + state-clear, 4 i18n keys ×8). 672 → 677. Playwright-verified: preview role=region/aria-live, row-delete opens a focus-trapped modal (focus on Cancel), 0 console errors.

Verification

$ npm run test:ci
# 677 / 677 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.48.0

[1.47.0] — 2026-05-18

WS2 #7 + #30 + #31 + #16 — unbound-label accessibility sweep.

🐛 Fixes

  • fix(a11y): bind every swept form control to an accessible name — four UX-audit findings where inputs had no programmatic label (WCAG 1.3.1 / 3.3.2 / 4.1.2):
    • #7 scan.js — the dry-run checkbox and company-select dropdown had labels with no for; added htmlFor (matching the existing ids).
    • #30 deep.js — company / role inputs had unbound labels; added id + htmlFor (deep-company, deep-role).
    • #31 apply.js — url / jd had unbound labels; added id + htmlFor (apply-url, apply-jd).
    • #16 cv.js — the primary markdown <textarea> had no accessible name; bound it via aria-labelledby to the visible “Markdown” <h3> (id="cv-md-heading") — SR name == on-screen heading, no new i18n key.
  • Uses the explicit label[for]↔control[id] pattern already standard in batch.js / mode-page.js. No new i18n keys; zero behaviour change.

🧪 Tests

  • test: tests/unbound-label-sweep.test.mjs — 5 cases incl. a binding-integrity check that every new htmlFor/aria-labelledby has a matching id in-file. 667 → 672. Playwright-verified: on #/scan, #/deep, #/apply every label[for] resolves to a control; #/cv aria-labelledby resolves to the heading.

Verification

$ npm run test:ci
# 672 / 672 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.47.0
# Playwright: scan/deep/apply 2 bound label[for] each (all resolve) · cv aria-labelledby resolves · 0 errors

[1.46.0] — 2026-05-18

WS2 #5 + #6 + #21 + #24 — scan SSE accessibility.

🐛 Fixes

  • fix(a11y): scan SSE — live-log region, Stop, run-state, error banner — four UX-audit findings on #/scan:
    • #5 the streaming console (<pre id="scan-console">) was a bare element; screen readers got no announcement of scanned lines. Now role="log" aria-live="polite" aria-relevant="additions" + an aria-label + tabindex="0" (keyboard-scrollable). A separate visually-hidden role="status" aria-live="assertive" region announces terminal events (complete / failed / stopped).
    • #6 an in-flight EventSource scan had no abort path. The handle is now captured (activeES) and a Stop button closes it (es.close()), cancels the result poll, and resets state. Stop is shown only while a scan runs.
    • #21 the Scan button stayed enabled with no busy cue during a multi-minute crawl. setScanRunning() now disables it + sets aria-busy and toggles the Stop button across both stream paths (single-phase streamTo and multi-phase runScanAll — the latter only ends the run on the terminal done, final !== false).
    • #24 an SSE failure was a 3.5 s toast only. A persistent role="alert" banner now shows the error with a Retry scan action (re-invokes the last run fn); cleared on the next run.
  • All side-effecting closures keep the existing __cancelActiveScanPoll() hashchange cleanup.

🌐 i18n

  • 8 new keys × 8 locales — scan.consoleLabel/stop/stopped/statusDone/statusFailed/statusStopped/errBannerTitle/errRetry. i18n-coverage gate green.

🧪 Tests

  • test: tests/scan-sse-a11y.test.mjs — 7 cases (log-region roles, assertive status, Stop closes EventSource on both paths, run-state aria-busy, persistent alert + Retry, terminal-done gating, 8 i18n keys ×8). 660 → 667.

Verification

$ npm run test:ci
# 667 / 667 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.46.0
# Playwright #/scan: console role=log aria-live=polite tabindex=0 · status role=status · error role=alert(hidden) · Stop hidden · 0 errors

[1.45.0] — 2026-05-18

WS2 #3 — config tabs: full WAI-ARIA Tabs pattern.

🐛 Fixes

  • fix(a11y): config.js tabs implement role=tablist/tab/tabpanel — the three #/config tabs (API keys / Profile / Modes) were plain <button class="tab-btn"> with click-only activation: no role, no aria-selected, no keyboard model (UX-audit HIGH #3, WCAG 4.1.2 / 2.1.1). Now: a role="tablist" container with an aria-label; each tab role="tab" + id + aria-controls + aria-selected (synced in activate()) + roving tabindex (0 active / -1 rest); the panel role="tabpanel" + tabindex="0" + aria-labelledby tracking the active tab. Full keyboard nav: ←/→/↑/↓ (wrapping) + Home/End move focus AND activate. The legacy .tab-btn.is-active CSS hook is preserved. Verified live: ArrowRight API→Profile syncs aria-selected + panel labelledby; End→Modes; 0 console errors.

🌐 i18n

  • 1 new key × 8 locales — config.tablistLabel (“Settings sections”). i18n-coverage gate green.

🧪 Tests

  • test: tests/config-tabs-aria.test.mjs — 7 cases (tablist/tab/tabpanel roles, aria-controls + roving tabindex, activate() aria-selected sync, keyboard map incl. wrap + preventDefault + focus move, legacy textContent-toggle removed, i18n key ×8). Total 653 → 660.
  • fix(test): retarget 2 stale auto-pipeline smoke tests (commit 5d253ba) — the pre-v1.34 Playwright-e2e smoke tests asserted a transient modal the dashboard “Auto-pipeline” button stopped opening in v1.34.0 (→ Router.go('/auto')); they had been red on the separate Playwright-e2e CI job for 10 releases. Retargeted to the #/auto screen. Local smoke 16/16; CI green.

Verification

$ npm run test:ci
# 660 / 660 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.45.0
# Playwright: #/config ArrowRight/End move + activate tabs, aria-selected synced, 0 errors

[1.44.0] — 2026-05-18

WS2 #4 + #9 — focus-trapped confirmation for destructive parent writes.

🐛 Fixes

  • fix(a11y/safety): UI.confirm() gate before whole-file parent overwrites — two UX-audit HIGHs, both data-loss: (#4) config.js saveProfileRaw/saveModesRaw replaced the ENTIRE parent config/profile.yml / modes/_profile.md with no confirmation; (#9) tracker.js Normalize/Dedup/Merge rewrote parent data/applications.md in place with no confirmation. Added UI.confirm(title, message, opts) to public/js/api.js — a focus-trapped dialog reusing the existing WAI-ARIA modal infra (focus-return, Tab-trap), returning Promise<boolean>. A new _onClose hook fires from closeModal() so EVERY dismissal path (Esc / backdrop / × / Cancel) resolves false; only the explicit confirm button resolves true. Focus defaults to Cancel (safe choice for a destructive op). NOT native confirm() (auto-dismissed in embeds, not focus-trapped). All three call sites are now gated before the API.put/API.post. Verified live: Normalize → focus-trapped “Переписать applications.md?” modal, focus on Cancel, Cancel dismisses with no POST, 0 console errors.

🌐 i18n

  • 8 new keys × 8 locales — common.confirm, config.rawConfirmTitle/Ok, config.profileRawConfirmBody, config.modesRawConfirmBody, track.fixConfirmTitle/Body/Ok. The {op} placeholder in track.fixConfirmBody is preserved verbatim across all locales (runtime-substituted). i18n-coverage gate green.

🧪 Tests

  • test: tests/confirm-gate.test.mjs — 8 cases: UI.confirm exported, _onClose resolves false on every dismissal path, Cancel-default focus, modal() back-compat onClose arg, all 3 destructive sites gated before their write, no native confirm() left, 8 i18n keys present with {op} intact. 644 → 652.

Verification

$ npm run test:ci
# 652 / 652 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.44.0
# Playwright: #/tracker Normalize → focus-trapped modal, focus=Cancel, Cancel→no POST, 0 errors

[1.43.0] — 2026-05-18

User-requested — career-ops-ui open + autostart browser-raise.

✨ Features

  • feat(cli): career-ops-ui open — open AND raise the dashboard tab — after setup/run, bare open/xdg-open left the dashboard tab in the background when the browser was already running, so the user had to hunt for it. New scripts/open-dashboard.mjs builds the URL from HOST/PORT (rewriting a 0.0.0.0 bind to loopback), optionally waits for /api/health, opens the default browser, then force-raises it — macOS osascript activating whichever of Chrome/Brave/Edge/Safari/Arc/Firefox is running, Linux xdg-open+wmctrl, Windows start. Exposed as the career-ops-ui open verb (aliases dash, focus). bin/start.sh autostart now delegates to it so the tab is raised automatically; NO_OPEN=1 disables auto-open for headless/CI starts. Verified live: career-ops-ui open → URL printed, browser raised, exit 0.

🧪 Tests

  • test: tests/open-dashboard.test.mjs — 8 cases: dashboardUrl (defaults / PORT / 0.0.0.0→loopback / explicit HOST), openAndRaise platform routing (darwin/win32/linux, no real browser), waitForHealth bounded-timeout against a dead port, and static guarantees that the dispatcher routes open|dash|focus and start.sh delegates + honors NO_OPEN (old bare-open path gone). 636 → 644.

📝 Documentation

  • README ×8 + help-bundle §1 ×8 — the open verb added to the launch block + a note that setup/run now raises the tab automatically and NO_OPEN=1 disables it. H2-section parity preserved (17).

Verification

$ npm run test:ci
# 644 / 644 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.43.0
$ career-ops-ui open --no-wait    # URL printed, browser raised, exit 0


career-ops-ui v1.267.1 · page 7/10 · mirror of the project changelog for cvstart.org