Changelog

GitHub

Hver væsentlig ændring, udgivelse for udgivelse. Samme fil findes i repository'et og er oversat til alle 19 sprog.

[1.57.0] — 2026-05-19

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

🐛 Fixes

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

✨ Features

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

🧪 Tests

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

[1.56.4] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.56.3] — 2026-05-19

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

🐛 Fixes

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

🧪 Tests

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

[1.56.2] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.56.1] — 2026-05-19

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

🐛 Fixes

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

🧪 Tests

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

[1.56.0] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.8] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.7] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.6] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.5] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.4] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.3] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.2] — 2026-05-18

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

🐛 Bug Fixes

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

🧪 Tests

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

[1.55.1] — 2026-05-18

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

🐛 Bug Fixes

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

🧪 Tests

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

[1.55.0] — 2026-05-18

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

✨ Features

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

🧪 Tests

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

[1.54.10] — 2026-05-18

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

🐛 Fixes

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

🧪 Tests

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

[1.54.9] — 2026-05-18

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

🐛 Fixes

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

🧪 Tests

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

[1.54.8] — 2026-05-18

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

✨ Features

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

🧪 Tests

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

[1.54.7] — 2026-05-18

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

🐛 Fixes

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

🧪 Tests

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

[1.54.6] — 2026-05-18

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

🐛 Fixes

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

🧪 Tests

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

[1.54.5] — 2026-05-18

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

🐛 Fixes

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

🧪 Tests

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

[1.54.4] — 2026-05-18

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

🐛 Fixes

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

🧪 Tests

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

[1.54.3] — 2026-05-18

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

✨ Features

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

🧪 Tests

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

[1.54.2] — 2026-05-18

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

✨ Features

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

🧪 Tests

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

[1.54.1] — 2026-05-18

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

🐛 Fixes

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

🧪 Tests

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

Verification

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

[1.54.0] — 2026-05-18

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

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

🐛 Fixes

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

🧪 Tests

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

📝 Documentation

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

Verification

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

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


[1.53.0] — 2026-05-18

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

🧪 Tests

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

📝 Documentation

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

Verification

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

[1.52.0] — 2026-05-18

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

🐛 Fixes

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

🌐 i18n

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

🧪 Tests

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

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

Verification

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

[1.51.0] — 2026-05-18

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

🐛 Fixes

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

🌐 i18n

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

🧪 Tests

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

Verification

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

[1.50.0] — 2026-05-18

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

🐛 Fixes

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

🌐 i18n

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

🧪 Tests

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

Verification

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

[1.49.0] — 2026-05-18

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

🐛 Fixes

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

🌐 i18n

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

🧪 Tests

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

Verification

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

[1.48.0] — 2026-05-18

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

🐛 Fixes

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

🌐 i18n

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

🧪 Tests

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

Verification

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

[1.47.0] — 2026-05-18

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

🐛 Fixes

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

🧪 Tests

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

Verification

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

[1.46.0] — 2026-05-18

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

🐛 Fixes

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

🌐 i18n

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

🧪 Tests

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

Verification

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

[1.45.0] — 2026-05-18

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

🐛 Fixes

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

🌐 i18n

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

🧪 Tests

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

Verification

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

[1.44.0] — 2026-05-18

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

🐛 Fixes

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

🌐 i18n

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

🧪 Tests

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

Verification

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

[1.43.0] — 2026-05-18

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

✨ Features

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

🧪 Tests

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

📝 Documentation

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

Verification

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

[1.42.0] — 2026-05-18

WS2 fix #2 — #/portals dead-route → config deep-link.

🐛 Fixes

  • fix(router): #/portals 404 → alias to config + Regional-sources deep-link — #/portals was an unregistered route that rendered the 404 view, even though it is a plausible bookmarked/typed URL for portal-source management (UX-audit HIGH #2). Added portals: 'config' to router.js ALIASES (same bookmark-stability pattern as settings→profile), so it now resolves to the config view with the config nav item active. When a Regional-sources group exists, the view (config.js) detects the #/portals hash, force-opens that <details> group, scrolls it into view, and moves focus to its summary (overriding the default h1 focus) so the user lands exactly on the portal-source controls. Never renders an empty regional group from the alias alone. Verified live: #/portals → config view, is404:false, active nav = config, 0 console errors.

🧪 Tests

  • test(router): portals→config alias guarantee — router.test.mjs static assertion guarding the new ALIASES entry against future-refactor regression. 635 → 636.

📝 Documentation

  • help-bundle §5 × 8 — a “Shortcut” blockquote noting #/portals now resolves to App settings / Regional sources instead of 404ing. H2-section parity preserved (17 each).

Verification

$ npm test
# 636 / 636
# Playwright: #/portals → {h1:"App settings", is404:false, activeNav:"config"} · 0 console errors

[1.41.0] — 2026-05-18

WS2 — senior UX/usability audit + cross-cutting focus-management fix.

A 10+ yr heuristic audit (Nielsen × WCAG 2.2 AA × project conventions) of all 17 routes produced a 40-finding, severity-ranked queue (.planning/.../UX-AUDIT.md); HIGH→MEDIUM→LOW are now shipped one fix per release. This release lands the #1 cross-cutting HIGH.

🐛 Fixes

  • fix(a11y): move focus to the new view on every route change — router.js render() replaced #content on each hashchange but never moved focus, so keyboard / screen-reader users stayed on the destroyed node and lost their place (WCAG 2.4.3 Focus Order / 4.1.3 Status Messages — cross-cutting, affected all 17 screens). New focusNewView(content) focuses the new view’s first h1/.page-title (concise SR announcement + correct focus order), making the heading focusable (tabindex=-1) if needed and falling back to #content. The very first paint is skipped so it never fights the skip-link. Wired on both the success and error render paths. Verified live: after nav, document.activeElement is the new view’s H1.page-title.

🧪 Tests

  • test(router): focus-management static guarantees — 4 cases in router.test.mjs (helper defined, heading-target + content fallback, first-paint skip guard, ≥2 call sites). 631 → 635.

📝 Documentation

  • .planning/.../UX-AUDIT.md — full 40-finding audit + prioritized fix queue + per-release ship grouping (v1.42 → v1.51). Drives the remaining WS2 iterations.

Verification

$ npm run test:ci
# 635 / 635 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.41.0
# Playwright: #/dashboard → #/config → #/tracker · activeElement = new H1.page-title · 0 console errors

[1.40.0] — 2026-05-18

WS8.3 — docs-actualization sweep + career-ops-ui help fix + askSecret hardening.

🐛 Fixes

  • fix(cli): career-ops-ui help no longer leaks shell source — the dispatcher printed its header comment with sed -n '2,12p', but line 12 (set -euo pipefail) is code, not a comment, so career-ops-ui help (and the unknown-verb usage text) ended with a stray set -euo pipefail line. Narrowed to 2,11p (the comment block) in both the help and *) cases. help exits 0, unknown verb exits 2 — verified.
  • fix(cli): scripts/init.mjs key entry never echoes — the v1.39.0 follow-up replaced the cosmetic readline-overwrite mask with a real raw-mode reader: setRawMode(true) + a buffered line so typed/pasted key bytes never reach the terminal at all (no scrollback / tmux / screen-share leak). A full VT escape FSM consumes every CSI/SS3/OSC/DCS/SOS/PM/APC sequence so arrow & function keys can’t corrupt the secret; stdin is dependency-injected so the non-TTY fallback is unit-tested without poking the global. Iterated to a clean AI-review LGTM.

📝 Documentation

  • README ×8 — the old “one-command install” section is replaced by a prominent “Launch & initialize in one command” section: the curl one-liner plus the explicit career-ops-ui CLI chain (clone → npm link → setup → init → doctor → run → help), the provider-wizard explanation, the CI --provider --anthropic-key --yes form, and the LLM_PROVIDER note. All 8 README badges actualized from the stale v1.22–v1.24 / tests-461–474 to v1.40.0 / tests-631 (e2e badge made non-numeric to avoid an invented count).
  • help-bundle ×8 §1 — a “One-command launch & init” blockquote callout added at the top of the Quick-start playbook (before “A. Setup”) in all 8 locales. H2-section parity preserved (17 each — CI gate green).

🧪 Tests

  • test(init): non-TTY askSecret fallback — provider-selector.test.mjs gains a DI-stdin case asserting askSecret delegates to plain ask() (trim-parity) off a TTY without mutating the shared global. 629 → 631.

Verification

$ npm run test:ci
# 631 / 631 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.40.0
$ career-ops-ui help     # clean, exit 0 (no `set -euo pipefail` leak)
$ career-ops-ui doctor   # all required green, exit 0

[1.39.0] — 2026-05-18

WS8.2 — LLM provider selector + OpenAI/Codex key + interactive init wizard.

✨ Features

  • feat(config): LLM_PROVIDER selector + OPENAI_API_KEY — server/lib/env-config.mjs adds LLM_PROVIDER (auto|claude|gemini) + OPENAI_API_KEY to KNOWN_KEYS/KEY_GROUPS; OPENAI_API_KEY is secret-masked. New providerOrder(env) helper: auto→[anthropic,gemini] (legacy), claude→[anthropic], gemini→[gemini]. All 6 provider-gate sites in server/lib/routes/llm.mjs (evaluate/deep/mode × Anthropic/Gemini) consult it via _provGate() — a forced provider with no key falls through to the manual-prompt path exactly like the pre-v1.39 no-key behaviour (zero behaviour change for auto/default). #/config API-keys tab gains an LLM_PROVIDER select + OPENAI_API_KEY input. Provider-key set matches what santifer/career-ops actually implements (Gemini = parent gemini-eval, Anthropic = web-ui SDK + Claude Code, OpenAI = Codex/OpenCode CLI side); Mistral/Qwen are model names with no parent env key — not invented.
  • feat(cli): interactive career-ops-ui init — scripts/init.mjs is now a real wizard (was a WS8.1 stub): pick provider 1-4, enter key(s), writes parent .env via the validated env-config.updateEnvFile path (explicit user action, same as POST /api/config). Flag-driven too: --provider --anthropic-key --gemini-key --openai-key --yes.

📝 Documentation

  • help-bundle §2 × 8 + docs/sdd/CONVENTIONS.md — provider selector + the 3 parent-implemented provider keys. (Full README ×8 + canonical-docs fold = WS8.3/WS10, the user-mandated final steps.)

🧪 Tests

  • test: tests/provider-selector.test.mjs — 7 cases: providerOrder (auto/unknown/whitespace → legacy, claude/gemini forced), env-config surface (KNOWN/SECRET/LLM_PROVIDERS), init parseArgs+buildUpdates (clamp, non-empty-only), and a static canary that all 6 llm.mjs gate-sites use _provGate(). 622 → 629.

Verification

$ npm run test:ci
# 629 / 629 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.39.0
$ career-ops-ui init --provider gemini --gemini-key … --yes   # writes parent .env
$ career-ops-ui doctor                                          # verifies

[1.38.0] — 2026-05-17

WS8.1 — unified CLI dispatcher + doctor verb.

AutoResearchClaw-style one-command workflow. bin/career-ops-ui.sh dispatches setup / run / doctor / init / help; package.json bin.career-ops-ui points at it.

✨ Features

  • feat(cli): bin/career-ops-ui.sh dispatcher — setup → bin/setup.sh (existing one-command bootstrap), run → bin/start.sh, doctor → scripts/doctor.mjs, init → scripts/init.mjs (WS8.1 stub; interactive provider wizard = WS8.2), help. Backward-compat career-ops-ui-start bin alias kept.
  • feat(cli): scripts/doctor.mjs — standalone health check that reuses the exact /api/health engine (spins createApp() in-process on an ephemeral port → renders the JSON to a colorized terminal report). Single source of truth — doctor can never drift from the Health page. Exit 0 iff every REQUIRED check is green, exit 1 otherwise, so setup / CI can gate on it. No new deps; read-only.

📝 Documentation

  • docs/sdd/CONVENTIONS.md — “CLI dispatcher” section. help-bundle §1 × 8 — CLI quickstart note. (Full README ×8 quickstart block lands in WS8.3, the user-requested final verification step.)

🧪 Tests

  • test(cli): tests/cli-doctor.test.mjs — 6 cases: formatReport pure logic (all-pass / required-fail / optional-only-fail / empty-tolerant), dispatcher verb-routing canary, package.json bin wiring. 616 → 622.

Verification

$ npm run test:ci
# 622 / 622 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.38.0
$ node scripts/doctor.mjs   # ✓ all required checks pass · exit 0

[1.37.0] — 2026-05-17

WS7 — pre-commit AI review in the git workflow.

Per user request: an AI review before every commit. Two layers — a fast deterministic floor that fails-HARD, and an advisory AI pass that fails-SOFT.

✨ Features

  • feat(workflow): pre-commit AI review
    • scripts/ai-precommit-review.mjs — deterministic floor (fail-HARD): rejects a commit that stages a .env/secret-bearing file, contains a high-confidence secret pattern in the added diff lines (.env.example placeholders exempt), leaves .also( in a staged view (mirrors the CI gate), or stages a .mjs/.js that fails node --check. AI layer (fail-SOFT): runs claude -p over the staged diff when the CLI is on PATH and AI_REVIEW !== 'off'; missing CLI / offline / timeout → notice, never blocks.
    • .githooks/pre-commit + scripts/install-hooks.mjs — npm install wires core.hooksPath=.githooks via the new prepare script. Idempotent; no-op outside a git checkout.
    • AI_REVIEW=off git commit … skips only the AI layer. Never --no-verify (CLAUDE.md hard rule #7); CI runs the full gate regardless.

📝 Documentation

  • docs/sdd/CONVENTIONS.md — new “Pre-commit AI review” section (floor vs AI layer, env switch, never-bypass rule).

🧪 Tests

  • test(workflow): tests/ai-precommit-review.test.mjs — 6 cases over the exported pure floor functions: blocked-path detection (.env.example exempt), secret-pattern hits (added-lines only, placeholders ignored), .also( view leftover, aggregate floor, clean-diff pass. 610 → 616.

Verification

$ npm run test:ci
# 616 / 616 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.37.0
# This very commit was gated by the new hook (live dogfood).

[1.36.0] — 2026-05-17

WS6.3 — #/config Modes tab: raw blob → per-section editor. WS6 complete.

modes/_profile.md is a prompt-engineering doc (markdown tables + prose), not key→value settings — so section-level editing is the right granularity (not field decomposition). This finishes WS6: every settings surface is now structured.

✨ Features

  • feat(config): per-section _profile.md editor
    • server/lib/routes/content.mjs — byte-exact splitProfileSections (preamble + per-## { heading, headingLine, body } via string slices, not line-join — round-trip is byte-identical) + joinProfileSections. GET /api/modes/_profile now also returns { preamble, sections }. PUT accepts { sections: { "<heading>": "<body>" } }: replaces only named sections, preamble + unknown sections + ordering survive byte-for-byte (merge-not-replace). Unknown heading → 400. Existing stripDangerousMarkdown sanitization retained. Legacy { markdown } raw path unchanged.
    • public/js/views/config.js — one collapsible textarea per ## section (label = ## heading); Save sends { sections }. Collapsed Advanced: raw markdown disclosure retained for add/remove-section + preamble edits.
    • i18n: 5 keys × 8 (config.modesSectionHint/modesNoSections/modesRawToggle/modesRawHint/modesRawSave).

📝 Documentation

  • help-bundle §2 × 8 locales — Modes tab documented as per-section editor (merge-by-section, byte-exact preservation, Advanced raw disclosure).

🧪 Tests

  • test(config): tests/modes-section-form.test.mjs — 6 cases: GET exposes preamble+sections, section-merge preserves preamble/others byte-exact, unknown-heading 400, ordering preserved, legacy raw path, sanitization. 604 → 610.

WS6 outcome

Every settings surface is now field/section-structured: API-keys (already field-based — WS6.2 audit confirmed KNOWN_KEYS ≡ FIELDS, no gap), Profile scalars (WS1 v1.32.0), Profile arrays (WS6.4 v1.35.0), Modes sections (WS6.3 v1.36.0). The raw editors remain as documented escape hatches.

Verification

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

[1.35.0] — 2026-05-17

WS6.4 — #/config Profile structured array editors + WS6.2 API-keys audit.

WS1 (v1.32.0) gave the 14 scalar profile fields a form. WS6.4 finishes the job: the list-shaped fields get add/remove-row editors, so the raw-YAML hatch is now truly last-resort.

✨ Features

  • feat(config): profile array editors
    • server/lib/routes/content.mjs — PUT /api/profile now also accepts an { arrays: { … } } payload (alongside / combinable with { fields }). Allow-listed paths: target_roles.primary + narrative.superpowers (string lists), target_roles.archetypes (name/level/fit), narrative.proof_points (name/url/hero_metric). Object rows keep ONLY allow-listed sub-keys (injected keys dropped); empty rows dropped; an emptied list deletes the leaf. Same merge-not-replace invariant — scalars, unknown keys, and untouched arrays survive.
    • public/js/views/config.js — 4 collapsible add/remove-row editors (string-list rows; object rows with per-sub-key inputs). Save sends { fields, arrays } in one request.
    • i18n: 6 new keys × 8 (config.pfPrimaryRoles/Superpowers/Archetypes/ProofPoints/AddRow/RemoveRow).
  • audit(config): WS6.2 API-keys tab — verified server KNOWN_KEYS (ANTHROPIC_API_KEY/MODEL, GEMINI_API_KEY/MODEL, PORT, HOST) ≡ client FIELDS. Every recognized .env key already has its own labeled input. No gap — no code change.

📝 Documentation

  • help-bundle §2 × 8 locales — Profile-tab section documents the array editors (add/remove rows, drop-empty, merge-not-replace).

🧪 Tests

  • test(config): tests/profile-array-editors.test.mjs — 7 cases: string-array merge preserves scalars+unknown-keys, object-array allow-list+drop-empty, empty→leaf-removed, proof_points round-trip, unknown-array-path 400, combined fields+arrays, arrays-only request. 597 → 604.

Verification

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

[1.34.0] — 2026-05-17

WS5 — one-click Auto-pipeline screen (#/auto).

The v1.15 dashboard auto-pipeline was a transient modal. Promoted to a dedicated, linkable page with senior-UX scaffolding.

✨ Features

  • feat(auto): dedicated #/auto screen (public/js/views/auto.js) — paste one job URL, one click runs the full chain (validate → fetch → evaluate → save report → append tracker) via the existing POST /api/auto-pipeline SSE contract. Senior-UX:
    • Single primary CTA; Enter in the URL field also runs it.
    • Live vertical stepper as an ordered list with aria-current="step" on the running row + a polite role="status" live-region announcing every transition (comprehensible without sight).
    • On success the result card deep-links to the saved report (#/reports/:slug · N/5) and the tracker — next action one click away.
    • Failed step marked red with message; CTA re-enables for fix-and-retry without reload.
    • No API key → manual mode: steps collapse to a copy-the-prompt card (no spend).
    • Linkable: #/auto?url=<encoded>&go=1 opens + auto-starts.
    • Sidebar entry (✨ Auto-pipeline, after Pipeline); dashboard ✨ button now routes here (single coherent flow; the window.AutoPipeline modal helper stays for backward-compat).
    • i18n: 14 new keys × 8 locales.

📝 Documentation

  • help-bundle §1 × 8 locales — new “One-click Auto-pipeline (#/auto) — the 21-step shortcut” subsection (full step list + a11y + manual-mode + deep-link behaviour). H2 count unchanged (17) — added as a ### subsection, no parity-gate churn.
  • README × 8 — Auto-pipeline headline feature bullet.

🧪 Tests

  • test(auto): tests/auto-screen.test.mjs — 8 cases: route registration, POST+SSE-drain transport, a11y scaffolding (aria-live + aria-current), manual-mode card, success deep-links, index.html script+nav wiring, dashboard→#/auto routing, 14 i18n keys × 8 locales parity.
  • 589 → 597 unit + acceptance (+8).

Verification

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

[1.33.0] — 2026-05-17

WS4 — full parent career-ops 1.8.0 feature-parity audit + location_filter.

Audited every user-facing parent commit v1.7.0→v1.8.0 (see .planning/.../PARENT-PARITY.md). One real GAP found and closed; everything else is FLOW (parent script we shell out to / inline via bundleProjectContext), CLI-ONLY (parent Go TUI), or N/A.

✨ Features

  • feat(scan): portals.yml location_filter parity (parent #570) — parent’s scan.mjs gained an optional location_filter block; web-ui runs its OWN in-process en-scanner/ru-scanner (they do NOT shell out to parent scan.mjs), so it did not flow through. New server/lib/location-filter.mjs mirrors the parent buildLocationFilter semantics verbatim (no key → pass-all; empty location → pass; block precedence over allow; allow empty → pass; allow non-empty → match ≥ 1; case-insensitive substring). Wired into both scanners’ post-fetch filter step (after title/negative, before dedup/persist). Config-driven via portals.yml top-level location_filter: — no UI needed.

📝 Documentation

  • help-bundle §5 (Portals) × 8 locales — new location_filter subsection with the worked allow/block example + exact semantics + the “top-level key, sibling of title_filter” note.
  • .planning/.../PARENT-PARITY.md — full classification table of the 20+ parent commits (GAP / FLOW / CLI-ONLY / DOCS / N/A) with rationale per row.

🧪 Tests

  • test(scan): tests/location-filter.test.mjs — 8 cases: no-filter pass-all, empty-location pass, block precedence, allow-empty pass, allow-match-required, case-insensitivity, malformed-config safe-pass, exact parity with parent’s portals.example.yml worked example. Scanner-integration regression: 51 en/ru/scan tests green (wiring didn’t regress).
  • 581 → 589 unit + acceptance (+8).

Parity outcome

career-ops v1.8.0 feature-parity complete as of web-ui v1.33.0. Deferred (PLAN R4 scope-guard): #341 Turkish as a 9th UI locale — parent added TR mode templates (template-side); a 9th web-ui locale is a dedicated future phase. Optional/LOW backlog: #602 explicit Greenhouse hostname allowlist (already covered by web-ui’s safe-fetch + isValidJobUrl envelope; slug-based URL construction, not raw hostname).

Verification

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

[1.32.0] — 2026-05-17

#/config Profile tab — raw-YAML blob → field-by-field form (WS1).

Before v1.32.0 the Profile tab was a single monospace textarea where name, email, location, archetypes, compensation and every custom key lived in one undifferentiated YAML blob — the exact pain the user called out (“всё в одной куче”). It is now a structured form.

✨ Features

  • feat(config): Profile field-form with merge-not-replace save
    • public/js/views/config.js — 14 modeled scalar paths grouped into 3 collapsible sections: Candidate (full_name/email/phone/location/linkedin/github/portfolio_url/twitter), Narrative (headline/exit_story), Compensation (target_range/currency/minimum/location_flexibility). Full-name client-side required-check before save.
    • server/lib/routes/content.mjs — PUT /api/profile gains a { fields: { "candidate.full_name": … } } payload. Server reads the existing config/profile.yml, sets/clears only the allow-listed leaves, re-serializes the whole object. Arrays the form doesn’t model (target_roles.archetypes, narrative.proof_points, narrative.superpowers) and any custom keys survive the round-trip untouched — the load-bearing invariant (PLAN R1). Empty field → leaf deleted (no phone: "" residue). Allow-list rejects unknown dotted paths; identity gate (full name required) preserved; corrupt existing YAML → 409 (refuses to clobber, routes user to raw editor).
    • Raw-YAML escape hatch retained as a collapsed Advanced <details> — the pre-1.32 full-file editor, unchanged ({ yaml } path, replaces whole file, preserves comments). For nested-array edits or comment preservation.
    • i18n: 23 new config.pf* / config.profile* keys × 8 locales.

📝 Documentation

  • help-bundle §2 (App settings) + §3 (Profile) × 8 locales — rewritten for the 3-tab layout + field-form description + the comment-loss tradeoff + Advanced raw-YAML disclosure.

🧪 Tests

  • test(config): tests/profile-field-form.test.mjs — 7 cases: arrays + unknown-key survival across a field-merge round-trip (the R1 gate), empty-field leaf-deletion, unknown-path rejection, no-full-name rejection, corrupt-YAML 409 guard, legacy raw { yaml } path still works, value trimming.
  • 574 → 581 unit + acceptance (+7).

🔄 Migration

No user action. First Profile-tab save after upgrade merges into the existing file; comments are dropped on a field save (use the Advanced raw editor to keep them). All data keys preserved.

Verification

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

[1.31.0] — 2026-05-17

Parent career-ops 1.8.0 sync — #/batch exposes --model + --start-from.

The parent project bumped 1.7.1 → 1.8.0. The user-relevant code delta: batch-runner.sh gained --model NAME (#504) and --start-from N. web-ui surfaces both on #/batch, completing flag parity (the runner’s full surface — --parallel --dry-run --retry-failed --start-from --max-retries --min-score --model — is now reachable from the browser).

✨ Features

  • feat(batch): surface --model + --start-from
    • public/js/views/batch.js — new Model text input (optional; placeholder “Claude Max default”) and Start from # numeric input (optional; min 1). Appended to the run-control row next to Max retries.
    • server/lib/routes/batch.mjs — GET /api/stream/batch?model=…&startFrom=…. Defense-in-depth (same posture as v1.28.0 --max-retries): model accepted only if it matches ^[A-Za-z0-9.\-]{1,60}$ (rejects shell-meta even though spawn() is arg-array — belt-and-suspenders); startFrom accepted only as an integer 1..100000. Bad values silently dropped, not 400’d — the UI input is the soft contract.
    • i18n: batch.modelLbl, batch.modelAria, batch.startFromLbl, batch.startFromAria × 8 locales.

📝 Documentation

  • help-bundle §14 (Batch evaluate) × 8 locales — --model + --start-from documented in the flag list with the #/batch field-name mapping.
  • Parent career-ops 1.8.0 analysis recorded. Other parent deltas assessed as no web-ui code change required: interview-prep audience split (#489) flows through bundleProjectContext automatically; dashboard/ is the parent’s Go/Bubble-Tea terminal TUI (browser equivalent is our SPA — not integrated by design); modes/tr/ Turkish templates are parent-side (a 9th web-ui UI locale is a separate deferred decision); parentVersion auto-reports 1.8.0 via health.mjs runtime read.

🧪 Tests

  • test(batch): tests/batch-model-startfrom.test.mjs — 7 cases: model pass-through, shell-meta charset rejection (injection guard), empty-model drop, startFrom pass-through, out-of-range drop (< 1), non-integer drop, combined-with-other-flags coexistence.
  • 567 → 574 unit + acceptance (+7).

🔄 Migration

No user action. The next 🌐 Batch run with the Model / Start-from fields blank behaves exactly as before (Claude Max default, start at offer #1).

Verification

$ npm run test:ci
# 574 / 574 · ✓ no .also( leftovers · ✓ CHANGELOG parity: all 8 locales at v1.31.0
$ curl -sN 'http://127.0.0.1:4317/api/stream/batch?model=claude-sonnet-4-6&startFrom=5' | grep -m1 '"args"'
# data: {"script":"batch-runner.sh","args":["--model","claude-sonnet-4-6","--start-from","5"]}

[1.30.0] — 2026-05-14

#/scan results paginator — replaces the v1.12.0 “first 200 of N” truncation.

✨ Features

  • feat(scan): paginate over full filtered result set (public/js/views/scan.js) — pre-v1.30 the scan results table was hard-capped at the first 200 filtered rows with a footnote saying “Showing first 200 of N”. Rows 201..N were unreachable from the UI; the user had to re-tune title_filter.positive in portals.yml to narrow the set if they wanted to inspect later rows. v1.30.0 swaps the cap for UI.paginate (the same helper that drives #/tracker, #/reports, #/activity).
    • PAGE_SIZE = 200 preserves the prior visual density per page.
    • The FULL filtered set is sorted first (boost-to-top is stable across pages), then page-sliced — so a boosted row that lands on page 2 still appears at the top of page 2, not buried.
    • Filter input (text / source / remote / scope / chips) calls pager.reset() so the user lands on page 1 of the new filter result.
    • pager.controls(visible, total) renders « ‹ N-M of K › » with disabled-state buttons when on first / last page. When total ≤ pageSize, the controls show only the item count (clean for small datasets).
  • Stale scan.shownTop i18n key removed from public/js/lib/i18n-dict.js (× 8 locales — no longer referenced).

🧪 Tests

  • test(scan): tests/scan-paginator.test.mjs — 9 cases across three layers:
    • Static-source canaries (7): scan.js declares PAGE_SIZE = 200; wires UI.paginate({ pageSize: PAGE_SIZE, onChange: …renderResults… }); resets pager on filter input; sorts the FULL rows.slice() into sortedAll BEFORE paginating; uses pager.slice(sortedAll); appends pager.controls(sorted.length, rows.length) after the table; no longer contains the pre-v1.30 rows.slice(0, 200) truncation. i18n-dict.js does NOT carry the stale scan.shownTop key. api.js still exports the paginate() helper with .slice / .controls / .reset surface.
    • Pure-logic paginator table (1): replicates clamp+slice rules and exercises 6 boundary cases — page 0 of 550 → 200 rows starting at 0; page 1 → 200 rows starting at 200; page 2 → 150 rows starting at 400; overflow page=99 → clamp to last valid page (2); filter-narrow to 5 rows while on page 2 → clamp to page 0 returning the 5 rows; empty set → page=0, empty slice.
    • Summary computation (1): mirrors start = page * pageSize + 1; end = min(total, start + visible - 1) from api.js paginate().controls(). Verifies the displayed range across all 3 pages of a 550-row dataset.
  • 558 → 567 unit + acceptance (+9 new).

🔄 Migration

No user action needed beyond updating to v1.30.0. The next scan that produces > 200 filtered rows will surface the paginator below the results table. Smaller scans see only an “N items” hint (unchanged from UI.paginate’s established behaviour in tracker / reports / activity).

Verification

$ npm run test:ci
# 567 / 567
# ✓ no .also( leftovers in views/
# ✓ CHANGELOG parity: all 8 locales at v1.30.0

Manual smoke after redeploy: run a scan that produces > 200 filtered rows, navigate #/scan, scroll past the results table, click › to advance to page 2 — should show rows 201..400 instantly (no server round-trip; pure client-side slicing of the already-fetched result set).



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