Changelog
GitHubOgni cambiamento rilevante, release dopo release. Lo stesso file è distribuito nel repository ed è tradotto in tutte le 19 lingue.
[1.58.22] — 2026-05-20
fix(ux): U-2 — #/auto H1 no longer wraps to 2 lines because of the leading ✨. Pre-fix the auto.title i18n value was ✨ Auto-pipeline a URL; the emoji participated in line-wrap and pushed the title to a second row at 1280-1600 px widths. Split into separate elements in public/js/views/auto.js: .page-header.page-header--icon (CSS grid, auto 1fr columns) + <span class="page-icon" aria-hidden="true">✨</span> + <h1 class="page-title">{t(auto.title)}</h1> + <p class="page-subtitle">. The emoji now sits on its own column and never participates in line wrap; the H1 wraps freely if needed. auto.title i18n value stripped of its leading ✨ in all 8 locales. 914 → 915 unit. (U-2)
[1.58.21] — 2026-05-20
fix(ux): U-1 — #/cv H1 + subtitle now match every other page (supersedes v1.56.0 UX-9 chip by design). v1.56.0 UX-9 demoted the page title to a .cv-breadcrumb chip (lowercase grey cv) so the user’s name in the preview “owned” visual space. v1.58.3 QA confirmed it just reads as a layout bug — the page header looks broken next to #/dashboard/#/help/etc. U-1 promotes public/js/views/cv.js back to <h1 class="page-title"> + visible <p class="page-subtitle"> like every other page; .cv-breadcrumb CSS rule removed. Single-<h1> invariant unchanged (F-V54-A still shifts user # Name h1→h2 in the preview, so this stays the page’s only H1). Lock test tests/cv-breadcrumb.test.mjs (which encoded the now-reversed UX-9 contract) is removed; tests/cv-single-h1.test.mjs updated to assert the new .page-title-only shape. 913 → 914 unit. (U-1)
[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) —
#/followupnow 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). Genericpatternsupport added to the mode-page form factory. - BUG-003 (Major, every help page) —
**bold**,`code`, italic and[links]now render inside block-quotes inUI.md()(the> **Audience:**cards showed literal**). Safe-by-construction (content is already HTML-escaped beforeinline()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
#/reportsempty 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’sconfig/profile.yml/cv.mdare 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”), anddash.quick.deepCtaare 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#/deepand 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-safeUI.md(), so the result is a properly formatted document. #/outreachalias →#/contacto(BUG-004) for a consistent, bookmarkable English URL; canonical slug stayscontacto(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 validationdetailsare 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) andtests/llm-output.test.mjs(5, incl. idempotency + “don’t eat real code blocks”).checkProfileCustomizedexercised directly (real name containing “test” is not false-flagged). Updatedurl-validation+config-validation-detailassertions for the new messages;e2e-comprehensivenow date-aware. 881 → 896 unit; Playwright 58/58; e2e 20/20 + 23/23.
Triaged, not changed (with rationale)
- BUG-009 (
#/cvH1 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.ymlcontent) — 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
/#/configfrom the browser always returnedvalidation failed.public/js/api.jsauto-attaches alangfield to every JSON POST body (so LLM routes pick up the UI locale)./api/configis not an LLM route andlangis not a config key, sovalidateConfig’s (correct, security-relevant) unknown-key rejection 400’d every Save withvalidation failed — lang: not a known config key. This was browser-only: curl/in-process repros never sentlang, 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-onlylangbefore validating; theKNOWN_KEYSwrite-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 intonpm 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 withlangsucceeds (andlangnever lands in.env), while a real unknown key withlangpresent 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’scatch (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.jsnow folds the per-fielddetailsinto 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.detailsis also exposed for inline rendering. - Input-error messages are now maximally descriptive.
validateConfigexplains 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
/#/configPORT 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
/#/configno 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$-anchoredANTHROPIC_API_KEYcharset regex (/^sk-ant-[A-Za-z0-9_-]{20,}$/) false-rejected genuine Anthropic keys whose base64url tail / future prefix didn’t fit the class.validateConfignow normalizes (trims) every value before validating, the route persists the trimmed value (so it authenticates at runtime — no stray-newline.envquoting), and the Anthropic check is a resilientsk-ant-prefix + length sanity check (the sharedisUsableKey()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_KEYon/#/configand one key fronts 300+ models (Anthropic, OpenAI, Google, Meta, Qwen, DeepSeek …). It’s the last entry of theautoorder (Anthropic → Gemini → OpenAI → Qwen → OpenRouter), so an existing setup is never silently re-routed;LLM_PROVIDER=openrouterpins 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 — directfetch,AbortControllertimeout, key never logged) with the recommendedHTTP-Referer/X-Titleattribution headers. - The OpenRouter model dropdown is live.
OPENROUTER_MODELis a dynamic select fed byGET /api/openrouter/models— a server-side proxy of OpenRouter’s public catalogue (keeps the CSPconnect-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/#/configvisits from re-hitting OpenRouter. New i18n keys (config.openrouter*) across all 8 locales;config.llmProviderHintupdated.
🧪 Tests
- New CI-isolated suites:
tests/openrouter-route.test.mjs(proxy answers 200 with a non-empty namespaced list even offline; leaks no secret) andtests/openrouter-model-selector.test.mjs(env-config contract + config.js wiring + 8-locale i18n parity). Extendedenv-config,openai, andprovider-selectorsuites: trim/relax validation,runOpenRouter/hasOpenRouterKey/fetchOpenRouterModels, the appendedautotail, and the OpenRouter gate. Two previously-weakvalidateConfigcases (used the no-longer-knownHH_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 fromdata-mac/data-otherby a platform check (navigator.platform/userAgent): ⌘K on macOS/iOS, Ctrl K elsewhere. It isaria-hidden="true"(the existingaria-labelalready announces the shortcut to screen readers — the badge must not double-announce) andpointer-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 alreadyflex:1).
🧪 Tests
- New CI-isolated source-static suite
tests/cmdk-hint-visible.test.mjs(5): the.kbd-shortcut<kbd>lives inside the.searchbarpill; it isaria-hidden="true"with bothdata-mac/data-othervariants;app.jsfills it via anavigatorplatform check from the dataset; the existing(e.ctrlKey||e.metaKey)&&e.key==='k'→search.focus()keybinding is intact (regression guard);app.cssstyles.kbd-shortcutand neverdisplay: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_KEYin a parent.envwas reported “✓ set” AND mis-selected as the active provider over a validANTHROPIC_API_KEY.effectiveEnv()only rejectedundefined/'', so a 10-char placeholder counted as a real key: the onboarding banner showed GEMINI ✓ set,GET /api/status/providersreturnedactiveProvider: "gemini", and every live ⚡ eval would have silently failed against a dead key while a working 108-char Anthropic key was ignored. New pureisUsableKey()(env-config.mjs) treats a secret as configured only when it is ≥ 20 chars (no supported provider’s key is shorter — GeminiAIza…≈ 39, Anthropicsk-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 tohasAnthropicKey()/hasGeminiKey()(anthropic.mjs),hasOpenAIKey()/hasQwenKey()(openai.mjs), and theGEMINI_API_KEY/ANTHROPIC_API_KEYrows ofGET /api/health— which also moved off rawprocess.envonto 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 correctkeysConfigured.
🧪 Tests
- New CI-isolated suite
tests/key-detection-rejects-placeholder.test.mjs(5):isUsableKeyunit cases (empty/non-string, too-short incl. the 10-char repro, long-but-placeholder, realistic keys accepted) + an in-processcreateApp()reproduction of the exact reported scenario (10-charGEMINI_API_KEY+ realANTHROPIC_API_KEYin a temp parent.env, the 4 keys stripped fromprocess.env) assertinggeminiis NOT inkeysConfigured,activeProvider === "anthropic", and the/api/healthrows agree. Four existinganthropic/openaieffective-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 staticindex.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.jsfocusNewView()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-12tabindexset follows). Falls back tocareer-ops — command centerwhen a view has no heading.
🧪 Tests
- New CI-isolated source-static suite
tests/document-title-per-route.test.mjs(4):focusNewViewassignsdocument.title; the title is derived from the view<h1>textContent (per-route + localized, not one hardcoded literal); the assignment precedes the!firstPaintDoneearly-return; a sane product default is present. router.js is browser-only → asserted statically (same approach asdashboard-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.jsgives each destination view’s headingtabindex="-1"and.focus()’s it on every client-side navigation so screen readers announce the new page. Atabindex="-1"element is never keyboard-reachable, yet Chromium’s:focus-visiblemodality 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 theimages/dashboard-*.pnghero 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-visiblering (WCAG 2.4.7 intact); the skip-link’s ring is unaffected (it is an<a>, nottabindex="-1", with a higher-specificity rule). - All 8
images/dashboard-*.pngregenerated 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-visiblebrand ring is still defined (WCAG 2.4.7 not regressed);[tabindex="-1"]:focus, :focus-visibleresolves tooutline: none; the suppression rule follows the global rule (cascade safety); the fix stays scoped (no blanket*:focus { outline: none }). Pairs with the existingtests/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 —
#/cvbreadcrumb (visual hierarchy): the page title is demoted to a quiet uppercase.cv-breadcrumbchip and the loud page-subtitle paragraph moves to the<h1>titletooltip, 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,#/deepand every#/<mode>. It reusesGET /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 —
#/helpTOC 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 —
#/dashboardfirst-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#contentstaysaria-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-hintCSS.
🧪 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 +#contentaria-live. The pre-existingcv-single-h1andhelp-nav-a11ylocks 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/trackergains optional?page/?pageSize/?statusquery 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 }—pageSizeclamped to[1,500],pageclamped to≥1,statusfiltersrows+total, andfunnelis 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 isaria-pressedand 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--activeCSS.
🧪 Tests
test: tests/tracker-server-paged.test.mjs(new, 7 cases, CI-isolated, in-process Express on an ephemeral port + tempCAREER_OPS_ROOTapplications.md fixture — CLAUDE.md #2/#8): back-compat (no params ⇒ exactly{rows}, no envelope leak);?page&pageSizeslice +total+page+pageSize+fullfunnelsumming 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;pageSizecap; plus a source-static lock on the.tracker-funnelchip 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
#/pipelinerendered 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): aboveVIRTUALIZE_THRESHOLD = 1000,#/pipelinebecomes 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 purecomputeWindow()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 simpleforEach→appendChild; the >threshold branch rendersslice(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
#/scanstacked 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.advancedFiltersacross all 8 locales; new token-based.scan-advancedsummary 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-advancedhook andscan.advancedFilterslabel; free-text + remote stay in the always-visible group; scope + source live inside the disclosure;chipsContaineris a<details>;.scan-advanced summaryis 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
#/dashboardopened with ~30 equal-weight nodes — no clear “what next”. A new.dash-heroblock now sits directly under the page header: the two P0 journeys — ✨ Auto-pipeline a URL and 🌐 Scan now — are promoted to large.btn-herobuttons, and a single focal recent-activity hint (“Last evaluation:<score>—<title>”, linked to the report; a guiding empty-state on cold start viadash.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-chippills so they no longer compete with the hero (Information-scent / cognitive-load lens). - New i18n keys
dash.lastEval,dash.heroNoEvalacross all 8 locales; new token-based.dash-hero/.btn-hero/.dash-chipCSS.
🧪 Tests
test: tests/dashboard-hero.test.mjs(new, 5 cases, CI-isolated, source-static): a.dash-heroblock exists and precedes the Quick-actions grid; both P0 CTAs are.btn-herowith the/auto+/scanroutes; a focaldash.lastEvalhint +dash.heroNoEvalempty-state; status buckets use.dash-chip;.dash-hero/.btn-hero/.dash-chipCSS 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-etahint — “⏱ ~1–2 min” (keyauto.eta,titleviaauto.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)flipsscan-stop-btnbetweenbtn-danger(running) andbtn-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.etaTitleacross all 8 locales; new token-based.btn-danger/.auto-etaCSS.
🧪 Tests
test: tests/auto-eta-stop.test.mjs(new, 4 cases, CI-isolated, source-static):#/autorenderst('auto.eta')with the.auto-etaclass adjacent torunBtn;auto.eta×8 locales;setScanRunning(running)promotes Stop tobtn-danger;.btn-dangerexists 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 }.keysConfigureduses the same effective-env view as thellm.mjsgate sites (process.env ∨ parent.env, viahasAnthropicKey/hasGeminiKey/hasOpenAIKey/hasQwenKey);activeProvideris what the OR-router would actually pick —selectActiveProvider(), a new pure helper inenv-config.mjsthat walksproviderOrder()(so anLLM_PROVIDERpin with no matching key correctly yieldsnull). No secrets are returned — only provider names + the model id. - The SPA shell now renders a global onboarding region (
#onboarding-banner, populated byapp.jsfrom 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.activeProvideracross all 8 locales; new.onboarding-warn/.onboarding-okCSS (token-based, mirrors.conn-banner).
🧪 Tests
test: tests/onboarding-key-banner.test.mjs(new, 9 cases, CI-isolated):selectActiveProviderauto-order / none /LLM_PROVIDER-pin semantics;GET /api/status/providersin-process (ephemeral port + tempCAREER_OPS_ROOT.envso 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-keysCTA) +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
#/cvprimary editor<textarea id="cv-editor">now carries a descriptivearia-labelvia the newcv.editorAriakey — “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 inspectedaria-label/labels), the field was not nameless — v1.47.0 (WS2 #16) had already bound it viaaria-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 redundantaria-labelledbyis removed (a leftover would be dead markup —aria-labelwins 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 likeauto-stepper-prerender.test.mjs):#cv-editornames itself viat('cv.editorAria', …)with a non-empty fallback;cv.editorAriais present and non-empty in all 8 locales; no redundantaria-labelledbyremains 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
#/autonow 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 createddisplay:noneandrenderStepper()was only reached fromsetStep()/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 thependingstate, and carries anaria-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 likerouter.test.mjs): theSTEPSarray is exactly the 5 canonical stages in order;stepperElis notdisplay:noneon mount and carriesauto.stepperAria; a mount-scoperenderStepper()call precedesfunction setStep(;auto.stepperAriais 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_PROVIDERgainsopenaiandqwen;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 asanthropic.mjs:AbortControllertimeout, key never logged,effectiveEnv()key resolution so a parent-.envkey works without a restart). One core (runOpenAICompatible) backsrunOpenAI(api.openai.com) andrunQwen(Alibaba DashScope OpenAI-compatible mode; override the endpoint withQWEN_BASE_URLin the raw.envfor 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-pipelineSSE — 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 toKNOWN_KEYS/KEY_GROUPS.core;LLM_PROVIDERSandproviderOrder()extended;OPENAI_API_KEYis now a first-class headless provider key (was stored-only).#/configAPI-keys tab:LLM_PROVIDERselect gainsopenai/qwen; newQWEN_API_KEY+QWEN_MODELfields (curatedqwen-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_tokensclamp, timeout,effectiveEnvkey detection, no-key-leak canary.tests/provider-selector.test.mjsupdated for the v1.55.0providerOrder/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#/autoSSE stream was mid-flight made the server’s nextres.write()reject withEPIPE/"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()inauto-pipeline.mjsnow registers a no-opres.on('error')and guardssend()onres.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, soclosePage()aborted the server’s in-flightsafeGet()after the test ended. It now waits for the pipeline to reach a terminal state (so the fetch resolves normally before close). A sharedclosePage()helper (window.stop()then close) and theafter-hookserver.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 theopenSsedisconnect-hygiene contract (res.on('error')listener +writableEnded||destroyedguard + 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_KEYwas the configured provider. Root cause:hasAnthropicKey()/hasGeminiKey()(andrunAnthropic’s key/model lookup) read only the boot-timeprocess.envsnapshot. If the Anthropic key was added to the parent.envafter the server started, the running process never saw it → Anthropic detection was false, and evaluation fell through to whatever stale key was inprocess.env(often an old, invalidGEMINI_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)inenv-config.mjs: a non-emptyprocess.envvalue wins (covers shell exports and the live-apply inPOST /api/config); otherwise the current parent.envfile is consulted.anthropic.mjsnow resolvesANTHROPIC_API_KEY,ANTHROPIC_MODEL, and the Gemini-key check through it, so a key set in the parent.envis 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.mjsrewritten to be CI-isolated (tempCAREER_OPS_ROOT, dynamic import) with 2 new cases reproducing the exact bug (key only in parent.env→ detected;runAnthropicsends the parent-.envkey + model whenprocess.envis unset).test: tests/env-config.test.mjs+3effectiveEnvcases (process.env precedence,.envfallback 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-schemamodes/_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-describedbyfor 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 inconfig.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.mjsnow setsCache-Control: no-storeon.js/.mjs/.css/.htmlvia theexpress.staticsetHeadershook, and explicitly on the SPA-shell catch-all (which usessendFileand bypassessetHeaders), so the browser always revalidates the code that drives routing. Non-code static assets keepexpress.static’s default caching. Security headers (CSP / nosniff / frame-deny / referrer-policy) are unchanged — verified by the existingsecurity-headerssuite (8 cases) running green alongside the new test.
🧪 Tests
test: tests/asset-cache-control.test.mjs— 4 cases (JS assetsno-store, CSSno-store, staticindex.htmlno-store, SPA catch-all deep-route shellno-store), booting the real app against an isolatedCAREER_OPS_ROOT. Plus a flaky-teardown fix intests/playwright-smoke.mjs(separatetest(e2e)commit): the auto-pipeline SSE smoke test now cancels the reader + aborts the fetch in afinallyand theafterhook 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
#/helpfloating back-to-top button worked correctly (verified live) but its class list (btn btn-primary help-back-top) sat outside the.back-to-topselector 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 canonicalback-to-topclass. Purely additive and a CSS-no-op:help-back-top(the existing CSS hook) is unchanged andback-to-tophas no CSS rule — it’s a stable test/automation handle only. Verified live:document.querySelector('.back-to-top')resolves the button,aria-labelintact, 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 canonicalback-to-topselector (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
#/batchTSV textarea had a hint wired viaaria-describedbybut no accessible name — no<label htmlFor>, noaria-label/aria-labelledby(regression run F-V54-C; WCAG 1.3.1 Info & Relationships / 4.1.2 Name, Role, Value).aria-describedbysupplies a description, not a name, so a screen reader announced an unlabelled “edit text”. The textarea now carries anaria-labelvia the new i18n keybatch.tsvAria, consistent with the sibling run-control inputs that already use*Ariakeys; the existing describedby hint is preserved. Verified live:aria-labelpresent + localized,aria-describedbyintact, 0 console errors. - New i18n key
batch.tsvAriaacross all 8 locales.
🧪 Tests
test: tests/batch-tsv-accessible-name.test.mjs— 2 cases (thebatch-tsvblock has anaria-labelviat(batch.tsvAria)while keeping its describedby hint;batch.tsvAriadefined 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#/pipelinewere icon-only with only atitleattribute (regression run F-V54-B; WCAG 4.1.2 Name, Role, Value).titleis 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 explicitaria-labeldisambiguated by a compact URL via a newshortUrl()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 — reusescommon.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 withshortUrl(url)+ exactly two such labels,shortUrldeclared 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.mdas 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✕witharia-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 existingPUT /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 wiresModesForm.build/collect+ drops the stalemodesSectionInputsmap, 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 isolatedCAREER_OPS_ROOTfixture: 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
#/confighad no way to pick the OpenAI / Codex model — onlyANTHROPIC_MODELandGEMINI_MODELhad dropdowns, even thoughOPENAI_API_KEYwas already exposed for the parent multi-CLI (Codex / OpenCode) flow.OPENAI_MODELis now a first-class env key: added toenv-config.mjsKNOWN_KEYS(ordered right afterOPENAI_API_KEY) and thecorekey group, and deliberately not inSECRET_KEYS— it’s a model id, not a credential, so it’s never masked.config.jsgains a curatedOPENAI_MODELSlist (gpt-5-codexdefault, thengpt-5/gpt-5-mini/gpt-4.1/o4-mini/o3) and anOPENAI_MODEL<select>field rendered immediately after the OpenAI key, mirroring the Anthropic/Gemini model fields exactly (samekind: 'select',aria-describedbyhint 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_MODELselect with 6 options, defaultgpt-5-codex, label-bound, 0 console errors.- New i18n keys
config.openaiModel+config.openaiModelHintacross all 8 locales.
🧪 Tests
test: tests/openai-model-selector.test.mjs— 4 cases (env-config core/non-secret contract + key ordering,OPENAI_MODELSlist withgpt-5-codexdefault, theOPENAI_MODELselect field wired afterOPENAI_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
# Namerendered 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.jsnow feeds every preview-injection point (initial render, file-import refresh, live editor sync) through a scopedcvMd()that shifts headings down one level (h1→h2 … h5→h6, h6→role="heading" aria-level="7"), so the page keeps exactly one<h1>. Scoped tocv.json purpose —UI.mdis 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# Nameis 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, sodocs/help/en.mdhad 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_companieskept byte-identical). All 8 bundles: 17 H2 / 70 H3.
🧪 Tests
test(help): H3-parity gate—help-ru-config-section.test.mjsnow 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.mdrefreshed to v1.54.0 reality: test totals (716node --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-trappedUI.confirm, WAI-ARIA tabs, SSE live-regions, label binding, sortable-tablearia-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 4bin/*.shscripts and the.githooks/pre-commithook had zero coverage. 10 cases now lock:bash -n/sh -nsyntax, shebang + executable bit, and the behavioural contracts other workstreams depend on:career-ops-ui.sh—helpexits 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 fragilesed-scrape), all dispatcher case-labels present.start.sh—NO_OPENhonored, Node ≥ 18 gate, browser-raise delegated toscripts/open-dashboard.mjs(v1.43.0 guard), no bareopen "$URL".setup.sh— strict mode,SKIP_START, clones both repos,need git.run_all.sh—--quick/--no-e2eparsing, runs all 4 suites viarun_suite..githooks/pre-commit— execs the WS7 reviewer; no shell file invokesgit --no-verify(CLAUDE.md hard rule #7 guard);install-hooks.mjswirescore.hooksPathand is the npmpreparestep.
- 706 → 716.
📝 Documentation
docs/architecture/TESTING.md— added the shell-surface base layer to the pyramid diagram + a v1.53.0 totals note (716node --testcases / 90 files + 4 E2E surfaces); documents thatscripts/*.mjslogic is covered bycli-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— archetypefit/levelrendered as two bare ambiguous chips; now prefixed (Fit: …/Level: …) with matchingaria-label. - #35
#/health— Run-doctor / verify toasts showed rawdoctor.mjs/verify-pipeline.mjsstrings; 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 arole="list"<ul>/<li>and the status badge carriesaria-label="<check>: <status>". - #37
#/reports— report cards were mouse-only<div onClick>; nowrole="link"+tabindex="0"+ Enter/Space handler +aria-label. - #38
#/activity— the paginator comment said “200” while the code requested 500; reconciled to aCAPconstant and arole="note"notice now surfaces when the 500-cap truncates older history (activity.truncated). - #39
#/batch— prose placeholders were hardcoded English while theiraria-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).
- #33
🌐 i18n
- 10 new keys × 8 locales (
set.fit/level,health.runningDoctor/Verify,activity.truncated({n}preserved),batch.minScorePh/maxRetriesPh/modelPh/startFromPh,mode.liveReadyAnnounce).i18n-coveragegate green.
🧪 Tests
test: tests/low-sweep.test.mjs— 9 cases (one per finding + the 10-key ×8 i18n check). 697 → 706. Playwright-verified: healthul[role=list](17li, 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
#/autoRun 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 infinally. - #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 toexecCommand, and toasts a real failure (auto.copyFail) instead of a false success. - #19
#/evaluate#eval-outhad no live role — the long LLM call was silent for screen readers. Nowrole="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). NowUI.withSpinner-wrapped (disables + busy state).
- #13
🌐 i18n
- 3 new keys × 8 locales —
auto.running,auto.httpFail({n}preserved),auto.copyFail.i18n-coveragegate 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.mjsdeleted the test row via the pre-v1.48 native-confirm path; v1.48.0’s focus-trappedUI.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#/helpfindings 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 hasaria-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 onhashchangeaway from#/help(no leak).
- #28 the doc markdown opened with its own
🌐 i18n
- 2 new keys × 8 locales —
help.tocFilter,help.backToTop.i18n-coveragegate 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, TOCaria-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#/trackerfindings:- #10 the action column header was an empty string and the per-row Report button had no context. Every
<th>now hasscope="col"; the action header ist('track.col.actions');Score/PDFheaders are i18n-keyed (were hardcoded English); the Report button gains anaria-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>witharia-sort(none/ascending/descending); asorted()comparator (numeric for score, locale-compare for date/status) runs before pagination; clicking toggles direction and resets the pager. - #25
track.normalize/dedup/mergewere identical English in all 8 locales despite being the highest-stakes destructive controls — now properly localized, plus atitletooltip (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 === 0now renders a distinct empty state (title + body + “Open pipeline” CTA); the filter-excluded-everything case keepstrack.noMatch.
- #10 the action column header was an empty string and the per-row Report button had no context. Every
🌐 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-coveragegate 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
#/pipelineactions used nativeconfirm()(auto-dismissed in embeds, not focus-trapped): the preview-pane Delete, the per-row✕delete, and “Evaluate first”. All now route through the focus-trappedUI.confirm()(v1.44.0 infra) — the two deletesdanger:true(Cancel-default), “Evaluate first”danger:false. - #22
previewPanehad no live role and a fetch failure was stuffed intopreviewBodyso it rendered as a misleading<pre>“preview”. It’s nowrole="region" aria-live="polite"with anaria-label; failures set a separatepreviewErrorand render a distinctrole="alert"block.previewErroris cleared on (re)select and when the active row is deleted.
- #8 all three
- No native
confirm()remains inpipeline.js.
🌐 i18n
- 4 new keys × 8 locales —
pipe.confirmDelTitle,pipe.previewError,pipe.evaluateAllTitle,pipe.previewRegion.i18n-coveragegate 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— thedry-runcheckbox andcompany-selectdropdown had labels with nofor; addedhtmlFor(matching the existingids). - #30
deep.js—company/roleinputs had unbound labels; addedid+htmlFor(deep-company,deep-role). - #31
apply.js—url/jdhad unbound labels; addedid+htmlFor(apply-url,apply-jd). - #16
cv.js— the primary markdown<textarea>had no accessible name; bound it viaaria-labelledbyto the visible “Markdown”<h3>(id="cv-md-heading") — SR name == on-screen heading, no new i18n key.
- #7
- Uses the explicit
label[for]↔control[id]pattern already standard inbatch.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 newhtmlFor/aria-labelledbyhas a matchingidin-file. 667 → 672. Playwright-verified: on #/scan, #/deep, #/apply everylabel[for]resolves to a control; #/cvaria-labelledbyresolves 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. Nowrole="log" aria-live="polite" aria-relevant="additions"+ anaria-label+tabindex="0"(keyboard-scrollable). A separate visually-hiddenrole="status" aria-live="assertive"region announces terminal events (complete / failed / stopped). - #6 an in-flight
EventSourcescan 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 + setsaria-busyand toggles the Stop button across both stream paths (single-phasestreamToand multi-phaserunScanAll— the latter only ends the run on the terminaldone,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.
- #5 the streaming console (
- 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-coveragegate 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: norole, noaria-selected, no keyboard model (UX-audit HIGH #3, WCAG 4.1.2 / 2.1.1). Now: arole="tablist"container with anaria-label; each tabrole="tab"+id+aria-controls+aria-selected(synced inactivate()) + rovingtabindex(0 active / -1 rest); the panelrole="tabpanel"+tabindex="0"+aria-labelledbytracking the active tab. Full keyboard nav: ←/→/↑/↓ (wrapping) + Home/End move focus AND activate. The legacy.tab-btn.is-activeCSS 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-coveragegate 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.jssaveProfileRaw/saveModesRawreplaced the ENTIRE parentconfig/profile.yml/modes/_profile.mdwith no confirmation; (#9)tracker.jsNormalize/Dedup/Merge rewrote parentdata/applications.mdin place with no confirmation. AddedUI.confirm(title, message, opts)topublic/js/api.js— a focus-trapped dialog reusing the existing WAI-ARIA modal infra (focus-return, Tab-trap), returningPromise<boolean>. A new_onClosehook fires fromcloseModal()so EVERY dismissal path (Esc / backdrop / × / Cancel) resolvesfalse; only the explicit confirm button resolvestrue. Focus defaults to Cancel (safe choice for a destructive op). NOT nativeconfirm()(auto-dismissed in embeds, not focus-trapped). All three call sites are now gated before theAPI.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 intrack.fixConfirmBodyis preserved verbatim across all locales (runtime-substituted).i18n-coveragegate green.
🧪 Tests
test: tests/confirm-gate.test.mjs— 8 cases:UI.confirmexported,_onCloseresolves false on every dismissal path, Cancel-default focus,modal()back-compat onClose arg, all 3 destructive sites gated before their write, no nativeconfirm()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
career-ops-ui v1.267.1 · page 7/10 · mirror of the project changelog for cvstart.org