Changelog

GitHub

Every notable change, release by release. The same file ships in the repository and is translated into all 17 languages.

Changelog

All notable changes to career-ops-ui are documented here. The format follows Keep a Changelog and the project adheres to Semantic Versioning.

Translations: 🇪🇸 Español · 🇧🇷 Português · 🇰🇷 한국어 · 🇯🇵 日本語 · 🇷🇺 Русский · 🇨🇳 简体中文 · 🇹🇼 繁體中文 · 🇫🇷 Français · 🇵🇱 Polski · 🇺🇦 Українська · 🇩🇰 Dansk · 🇸🇦 العربية · 🇩🇪 Deutsch · 🇮🇹 Italiano · 🇹🇷 Türkçe · 🇮🇳 हिन्दी


[1.134.1] — 2026-08-05

Validation-hardening — fixes surfaced by a full-project audit (all locales + code).

Fixed

  • successfactors no longer discards scraped jobs on a mid-scan failure (regression in the v1.134.0 dead-board-throw port) — its RMK pagination loop had no try/catch, so a failure on page 2+ (after page 1 succeeded) threw and dropped everything already collected. Worse, if that mid-pagination failure was a 404 (plausible on an out-of-range startrow behind a WAF), en-scanner’s permanent-failure check would quarantine a live tenant as dead for days. Now mirrors phenom/radancy: a page-0 failure (nothing fetched) still throws (dead board), but a later-page failure keeps the partials (+1 regression test).
  • #/scan filter chips are now keyboard-operable (WCAG 2.1.1) — the stack/level/dynamic-keyword facet chips (and the “clear” chip) were <span onClick> with no tabindex/role, so keyboard and screen-reader users couldn’t reach or toggle them (the .chip:focus-visible CSS was dead). They now carry role="button", tabindex="0", aria-pressed (toggles), and Enter/Space activation.
  • Three hardcoded English strings are now localized — the #/scan trust-badge tooltip (“Trust …”), the #/scan relocation column header (“Reloc”), and the #/dashboard score header (“Score”) were bare literals the i18n parity gate couldn’t see (they were never keys), so they stayed English in all 16 non-English locales. Now scan.trustTip + scan.col.reloc (2 new keys ×17) and a reuse of the existing track.col.score. A source-static guard locks them so the gap can’t silently return.

Security

  • fast-uri 3.1.4 → 3.1.5 in the site/ build lockfile (Dependabot GHSA — high: host confusion via a backslash authority introducer). Transitive dev-only dependency of the cvstart.org Astro toolchain; npm audit now reports 0 vulnerabilities and the Astro build is unchanged (86 pages, green). Does not touch the SPA or server (express + js-yaml only).

[1.134.0] — 2026-08-05

Parent career-ops v1.25.0 parity.

Added

  • New scan source: getManfred (manfred, parent #9474ff1) — a board-wide feed of Spanish/EU tech roles with published salaries, from www.getmanfred.com/api/v2/public/offers (zero-auth, host-pinned + HTTPS-only, single-request full catalogue, per-company fail-soft). Source + adapter + a CI-isolated suite (tests/sources-manfred.test.mjs); registry now 73 sources = 68 EN + 5 RU (ALL_ADAPTERS 68). Appears in the #/scan Source filter (FALLBACK + live registry) and on the cvstart.org landing.

Fixed

  • a16z Speedrun feed was silently truncating to 50 jobs (parent #2404) — the feed caps a page at 50, but the source requested PER_PAGE = 100, so the rawCount < PER_PAGE guard stopped after page 1. Corrected to 50 so pagination continues.
  • Dead boards now throw instead of reading as “live but empty” (parent #2379) — cryptocurrencyjobs, phenom, radancy, and successfactors sources: a fetch failure where no request ever resolved now throws (so #/portals health and the scan record a real failure) instead of swallowing it to []; a mid-scan failure after ≥1 success keeps partials (proof-of-life). radancy proves life across both transports; successfactors keeps the RMK-answered carve-out.
  • workable now uses the public widget API (parent #5ab8425) — switched from the offset/limit-capped api/v3 endpoint to apply.workable.com/api/v1/widget/accounts/<slug> (host-pinned, redirect:'error'), which returns a large account’s full posting list in one request, so big accounts are no longer silently truncated. The adapter endpoint is unchanged (the source derives the account slug and rebuilds the widget URL).

Notes

  • Not ported (CLI-only or not mirrored by web-ui): the detect-reposts #2389 title-bucketing rewrite (an O(N²)→O(N) perf optimization over the parent’s large scan-history CLI; web-ui’s reposts run in-process over the user’s own bounded history, so the pairwise cost is negligible — the match semantics are unchanged); the Unicode company-key fixes (company-history / fingerprint-core / tracker-utils / merge-tracker), which web-ui does not carry — web-ui’s own tracker dedup already compares full lowercased company strings and is non-Latin-safe; scan --since + scan-ats-full checkpoints (web-ui runs the scanners in-process and has its own “Posted within” age filter); cv-facts / verify-cv-facts, the CV Awards/Honors template + hiring-manager audit PDF pass, doctor, and the modes untrusted-content directive.

[1.133.1] — 2026-08-02

Fixed

  • #/funded (Funded companies) now renders results — two bugs made the table always show “no funded companies” even when the parent’s company-funded.mjs returned a full list. (1) The view read the results under res.candidates, but the parent emits them under companies (each { company, amount, round, funding: { sources: [{ source, url, observed_date }] } }); the client now reads the correct key and maps the real evidence shape. (2) The results table passed its cells to UI.el('tr', {}, …) as varargs, but UI.el(tag, attrs, children) takes children as a single node or array, so only the first column (Company) rendered — cells are now passed as an array. Verified in a real browser: 11 companies across the four feeds render with Company / Funding signal / Source / Date columns and working evidence links, zero console errors. An empty pass now also surfaces the per-source diagnostics (source: status (fetched/funding-like)) so a quiet news day is distinguishable from a blocked feed.
  • Regression guards in tests/parity-routes-v1133.test.mjs: the fake parent script now emits the real companies output shape (the original fixture mirrored the wrong candidates shape — which is exactly why the bug shipped green), plus source-static canaries that funded.js reads res.companies (never res.candidates) and builds table rows with array children (+1 → 2144).

[1.133.0] — 2026-08-01

Added

  • Funded-company discovery (#/funded, parent parity #2117) — a new read-only view relaying the parent career-ops company-funded.mjs via GET /api/company-funded: a review-first list of recently funded companies discovered from public, host-pinned funding feeds (TechCrunch, PR Newswire, The Guardian, Hacker News). The relay runs the script with --json --dry-run (JSON to stdout, no file writes), never threads user input into --sources (no SSRF surface beyond the parent’s own fixed feeds), carries llmRateLimit, and is user-triggered (a Discover button, never on mount). New route module server/lib/routes/funded.mjs (the 32nd) + public/js/views/funded.js, under Sourcing.
  • Weekly interview digest (#/interview-digest, parent parity #2129/#2130) — a new read-only view relaying the parent’s zero-LLM weekly-digest.mjs via GET /api/interview/weekly-digest: a mechanical roll-up of interview-prep/sessions/*.md — which companies and rounds you interviewed with this week, recurring competencies, and best-effort open gaps. Optional ?from=&to= range is threaded ONLY when BOTH are valid YYYY-MM-DD; an empty range is a valid available:true digest (not a failure). Added to server/lib/routes/interview.mjs + public/js/views/interview-digest.js, under Analytics.
  • Both relays follow the established fail-soft { available:false } contract (like /api/stats/lifetime) when the parent script is absent (CI, standalone installs), so each view shows an honest note. 26 new i18n keys ×17 locales; CI-isolated suite tests/parity-routes-v1133.test.mjs (+5 → 2143: success passthrough, --from/--to threading, the read-only --dry-run guarantee, and the script-error fail-soft path).

Notes

  • Parent career-ops advanced past v1.24.0 with the Next.js web/ app’s Follow-up Tracker page (#1422) and backend PDF render (#2182) — not ported: web-ui already has its own follow-up relay (v1.117.0) and PDF runners, and the underlying followup-cadence.mjs hardening (impossible-date rejection, null-safe addDays, legacy-bullet parsing) arrives for free via the shell-out relay. The set-status.mjs / tracker-utils.mjs changes are CLI-internal (exit-code / lock helpers) and not mirrored.

[1.132.0] — 2026-07-31

Changed

  • #/scan results-rendering subsystem extracted to public/js/lib/scan-results.js (file-size-contract paydown — public/js/views/scan.js had grown to ~1254 LOC). The subsystem — renderResults, buildChipRow, getRows, the row/facet builders, the seniority/country option painters, and the FALLBACK_SOURCES registry mirror — moves into a window.ScanResults.create(ctx) factory that closes over a context object the view supplies (filter elements, the active-facet Sets, the paginator, two-pager data, and a lastResults getter). No behaviour change — the functions were moved verbatim and their closure vars mechanically rewired to ctx.*; scan.js is now ~906 LOC. (Still above the 800-LOC target — a second extraction pass is planned; the file-size contract for the two remaining JS-view outliers, scan.js and config.js, is tracked in docs/sdd/CONVENTIONS.md.)
  • Source-static tests read the two files via tests/helpers/scan-src.mjs::loadScanSrc() (like loadAppCss()); tests/scan-fallback-sources.test.mjs now reads the registry mirror from scan-results.js.
  • New in-browser regression gatetests/playwright-scan-filters.mjs seeds a canned data/last-scan.json and drives every #/scan filter (Source, Seniority, Remote, Age, text include/exclude), asserting exact row counts, so the extraction is verified against real browser behaviour (run via npm run test:e2e:browser). Stable filter-control ids (#scan-filter-*, #scan-apply) were added for it.

Housekeeping

  • The README “Latest release” banner is slimmed to a one-line summary + a link to the full changelog (the long per-version narrative wall is retired — history lives in CHANGELOG*.md). Applied across all 17 locales.
  • CodeQL cleanup — removed the dead readFileSync/resolve/APP_CSS path-scaffolding left over from the v1.131.2 loadAppCss() migration in four CSS source-guard tests (design-polish-v1115, managed-focus-no-ring, toast-fab-clearance, wcag-target-size); those imports had become unused once the tests switched to loadAppCss(), closing the eight open js/unused-local-variable code-scanning alerts. Test-only, no behaviour change.

[1.131.2] — 2026-07-31

Changed

  • app.css split into three ordered stylesheets (file-size-contract debt — the single file had grown to ~1990 LOC, well past the 800-LOC hard target). It is now app.css (~672 — a11y, design tokens/theme, sidebar, main, buttons, content-shell), components.css (~595 — cards, grids, paginator, badges, tables, forms, log/console, markdown, language switcher, chip filter, connection banner), and overlays.css (~737 — toast, notifications drawer, modal, misc/responsive, the [dir="rtl"] mirror, docs-fab, usage-hud), each within the hard limit.
    • The cut is contiguous and in-order, so the cascade is byte-for-byte identical to the pre-split file; index.html loads the three as ordered <link>s. No behavior, markup, or i18n change.
    • CSS-asserting tests now read the concatenation via a shared tests/helpers/css.mjs::loadAppCss() helper (agnostic to which physical file holds a rule). New tests/css-modularization.test.mjs locks the split (files exist · each ≤ 800 LOC · index.html link order) → suite 2138. Verified in-browser: all three stylesheets parse and their rules apply (0 console errors).
    • scan.js (~1254) and config.js (~1010) remain the outstanding file-size debts.

[1.131.1] — 2026-07-31

Fixed

  • Adapter host-pinning consistency on the two v1.130.0 sources (code-review follow-ups, defense-in-depth; no behavior change for valid inputs):
    • a16z-speedrun-talent adapter now re-validates the api: / a16z-speedrun-talent: override at buildEndpoint (HTTPS + exact host speedrun-talent-network.com) and falls back to the canonical feed when it fails — parity with the cryptocurrencyjobs adapter, so an off-host value never reaches the fetch slot (previously it relied solely on the fetch-time assertSpeedrunUrl guard). The exact-host check is now a single exported SPEEDRUN_TALENT_HOST_RE shared by the guard and the adapter.
    • cryptocurrencyjobs parsercleanUrl now uses the same exact-match host guard as assertCryptocurrencyJobsUrl and the adapter override (was endsWith, which accepted subdomains). The parser is never more permissive than the SSRF guard: a sub.cryptocurrencyjobs.co item link is dropped.
    • +2 tests (tests/sources-a16z-speedrun-talent.test.mjs off-host/non-HTTPS/subdomain override → feed; tests/sources-cryptocurrencyjobs.test.mjs subdomain link dropped) → suite 2135.

[1.131.0] — 2026-07-31

Added

  • #/tracker CRM stage-tab board (ported from the parent web app’s /pipeline view). The tracker’s funnel-chip bar + status dropdown are replaced by a proper stage-tab strip: an All tab plus one tab per canonical status — Evaluated · Applied · Responded · Interview · Offer · Rejected · Discarded · SKIP · Hired — each showing a live whole-history count, including zero-count stages so the full funnel is always visible at a glance (the CRM look). The active tab drives the filter; clicking it again clears back to All. Rows keep their score-tone, legitimacy, PDF and report affordances, and the company cell now shows a brand logo when logos are enabled (off by default → zero extra requests, same contract as #/scan).
    • New read-only route GET /api/tracker/stages returns the canonical funnel (labels in order) + an alias-fold map, sourced from server/lib/states.mjs (templates/states.yml, with the built-in fallback) — so the client never hardcodes the status whitelist (the v1.128.0 doctrine): a parent that renames or reorders a stage flows through with no client change. The legacy no-param GET /api/tracker response is unchanged ({ rows } only).
    • New pure, unit-tested client lib public/js/lib/tracker-stages.js (window.TrackerStagesfoldStatus, stageCounts) buckets rows against the server’s stages, tolerating stray markdown bold and localized aliases (e.g. aplicadoApplied). Tabs are accessible (role="tablist"/tab, aria-selected, ≥44 px hit area, counts folded into each tab’s accessible name); focus stays on the active tab after a switch. No new i18n keys (stage labels render verbatim like the row badges). +tests/tracker-stages.test.mjs (6) +tests/tracker-stages-endpoint.test.mjs (4); suite 2133.

[1.130.0] — 2026-07-31

Added

  • Two new scan sources ported from parent career-ops v1.24.0 (in-process, no new deps; both appear in the #/scan Source filter and on the cvstart.org landing):
    • a16z Speedrun (a16z-speedrun-talent, #2231) — the a16z Speedrun talent-network board-wide JSON feed. Host-pinned to speedrun-talent-network.com, HTTPS-only, 0-indexed pagination with a page cap, per-company q/config threading, fail-soft. +server/lib/sources/a16z-speedrun-talent.mjs + adapter + tests/sources-a16z-speedrun-talent.test.mjs (16).
    • Cryptocurrency Jobs (cryptocurrencyjobs) — the Web3 job board cryptocurrencyjobs.co, ingested via its public RSS 2.0 feed (zero-auth). Two-pass XML-entity decode, remote-only listings, employer parsed from the "… at <Company>" title tail. +server/lib/sources/cryptocurrencyjobs.mjs + adapter + tests/sources-cryptocurrencyjobs.test.mjs (14).
    • Registry total is now 72 sources = 67 English + 5 Russian (ALL_ADAPTERS = 67 EN portal adapters).

Fixed

  • echojobs — hybrid roles stay distinguishable from remote (mirrors parent #2258). A case-insensitive hybrid marker in the posting’s remote-type now yields "<City> · Hybrid" (or a bare Hybrid when no city is present) and workplaceType: 'Hybrid', instead of being collapsed into Remote. +tests/sources-echojobs.test.mjs (7).
  • radancy — legacy TalentBrew markup + JSON results-fragment transport (mirrors parent a3e6df9). The adapter now parses TalentBrew HTML listings and, when available, the JSON results fragment (buildFragmentUrl/readFragmentTotals), gated on an injectable opts.fetchJson. +tests/sources-radancy.test.mjs (13).

Notes

  • Not ported — CLI-only parent features. career-ops v1.24.0’s large CLI/mode surface stays out of web-ui, which is a viewer + thin write-through, not a mode host: the compliance/jurisdiction tables (interview-redflag protected-grounds, oferta immigration-status / jurisdiction-prohibited / agency-licensing, offer-prep restrictive-covenant, check-table-freshness), the contacts phonebook + vCard export + company-history, the interview transcript-debrief / call-platform detection / plan wiring, ledger set-status, outcome recording + archiving, two-pass triage, jd-similarity CV-reuse hints, the versioned application-CV artifact schema, doctor Playwright-MCP detection, and portals/fix-slugs.mjs (web-ui exposes read-only POST /api/portals/health, never auto-writes portals.yml). Scan-orchestration changes that live in the parent’s scan.mjs — the Interamt.de Playwright scanner, iCIMS reverse-ATS full sweep (#2141), country-eligibility remote filter (#2095), DNS-lookup pacing + negative-cache resolver, StepStone rltr dedup (#1982), and the scan-history normalized-company column (#2243) — are not applicable: web-ui runs the EN/RU scanners in-process and does not shell into scan.mjs.
  • Already covered. The role-matcher accent-folding fix (#2209 — fold accented Latin before tokenizing) was ported in v1.127.0 (normalizeTitle NFD fold in server/lib/role-matcher.mjs), so parent v1.24.0’s change is a no-op here. Read-only relays (stats.mjs, salary-gap.mjs, analyze-patterns.mjs, followup-*) absorb the parent’s shape tweaks through their fail-soft path — no code change needed.

[1.129.1] — 2026-07-29

Fixed

  • AI-review follow-ups on the v1.128.0/v1.129.0 web-ports (all advisory, none blocking; fixed at source):
    • job-facets.js seniority precedence — an explicit modifier now wins over a management word: Senior Engineering Managersenior (was lead), Staff Managerstaff, while a bare Engineering Manager stays lead and Senior Staff Engineerstaff. The modifier buckets (staff/senior/junior/intern) are tested before the role-level lead bucket.
    • server/lib/states.mjs fallback is no longer pinned — a SUCCESSFUL templates/states.yml read is still memoized, but the built-in FALLBACK is returned uncached, so a parent whose templates/ was momentarily unavailable at boot (or updated live) is re-read on the next call instead of being stuck for the process lifetime. A present-but-malformed file now emits a console.warn (drift is surfaced to ops) while a genuinely absent file stays quiet.
    • score-tone.js — a not-yet-scored row is neutral, not red — a null/blank score now returns muted (.score-muted) instead of bad; a real low grade (D/F) still reads bad.
    • company-logo.js domainFromName() skips non-ASCII slugs — a name like 株式会社 no longer builds an invalid host handed to /api/logo (whose looksLikeHost guard would reject it anyway); it goes straight to the letter-avatar, sparing the round-trip.
    • tests/states.test.mjs isolation guard — a first sanity assertion pins PATHS.statesYml to the temp CAREER_OPS_ROOT, so if the per-file test isolation ever regresses the fallback assertions fail loudly instead of silently passing against the real parent’s file. +4 tests → 2073.

[1.129.0] — 2026-07-29

Added

  • #/scan seniority facet + freshness column — the job-facets.js library shipped in v1.128.0 is now wired into the scan UI (previously logic-only). A new Seniority filter dropdown buckets each posting’s title into lead/staff/senior/mid/junior/intern (JobFacets.seniorityFromTitle) and auto-populates from what’s actually in the results — exactly like the Country facet; titles with no seniority word always pass. It round-trips through saved searches, Reset, and the Apply flow. The results table gains a Seniority badge column and a zero-token Age column showing freshness (today / Nd, from JobFacets.daysSince(job.date); blank when the posting has no date). 12 new i18n keys ×17 (scan.allSeniority, scan.lblSeniority, scan.col.seniority, scan.col.age, scan.freshToday, scan.dSuffix, scan.sen.{lead,staff,senior,mid,junior,intern}). +tests/scan-seniority-facet-v1129.test.mjs (3) → suite 2069.

[1.128.0] — 2026-07-29

Added

  • Four solutions ported from the parent’s own web app (../web/, Next.js) — re-implemented in vanilla JS / ESM, no new deps:
    • Canonical states read live from templates/states.yml (server/lib/states.mjs) — the tracker’s status vocabulary is no longer hardcoded. canonicalLabels() + canonicalizeStatus() read the parent’s single source of truth (with a byte-identical CI fallback), so the whitelist and alias folding stay in sync automatically instead of needing a manual re-sync every parent release (the v1.118.0 ‘Hired’ add was one such sweep). POST /api/tracker now folds any label/id/alias (Spanish/legacy, stray **) to its canonical label; the GET /api/tracker funnel buckets by canonical status so aliases no longer spawn phantom entries. +tests/states.test.mjs.
    • Company logos on ATS-hosted rowspublic/js/lib/company-logo.js gains domainFromName() (a curated ~90-entry brand→domain override map + legal-suffix stripping + slug fallback). When the posting URL is a shared ATS host (greenhouse/lever/…) — the majority of rows — the employer domain is now derived from the company name and fed to the existing SSRF-safe /api/logo proxy before dropping to a letter-avatar (now 1–2 initials). +tests/company-logo-domain.test.mjs.
    • Finer score tonepublic/js/lib/score-tone.js (window.ScoreTone) replaces the coarse ≥4/≥3 split with a 4-tier tone (≥4.2 / ≥3.8 / ≥3.0) + a letter-grade fallback; #/tracker score cells use it (new neutral .score-muted tier). +tests/score-tone.test.mjs.
    • Zero-token job facetspublic/js/lib/job-facets.js (window.JobFacets): seniorityFromTitle(), sourceFromUrl() (dot-boundary-anchored host match), daysSince() — a reusable client lib for cheap filtering. +tests/job-facets.test.mjs.

Notes

  • Not ported (CONCEPT-only). The parent web app’s agentic action layer (actions/registry.ts + api/assistant/route.ts — one registry both the UI and the AI assistant dispatch, sideEffect-gated with confirm-before-write) is the blueprint if docs-fab.js ever grows from a read-only help chat into an acting co-pilot; a large, spec-worthy build left for later. Analytics / cv-quality / logbuf / logo+usage routes are already at or above parity. No new scan sources (registry stays 70); no i18n/help changes.

[1.127.0] — 2026-07-29

Added

  • Three new scan sources (parent career-ops v1.23.0 parity) — the registry now ships 70 adapters (65 English + 5 Russian):
    • Flowxtra (flowxtra) — a board-wide, no-auth aggregator (app.flowxtra.com/api/central/jobs) that lists live postings across every hosted company in one paginated call. Source + adapter + tests/sources-flowxtra.test.mjs (25 cases).
    • VDAB (vdab) — Flanders’ public employment service; queries the public vindeenjob JSON API (www.vdab.be) by keyword, recall-first like Arbeitsagentur (filtering happens downstream). +tests/sources-vdab.test.mjs (24 cases).
    • iCIMS (icims) — the classic iCIMS hosted-portal search pages (careers-<tenant>.icims.com), auto-detected from any *.icims.com careers_url. Distinct from the existing jibeapply (iCIMS’s JibeApply product); the per-job enrichDate hook is omitted (the in-process scanner returns jobs directly, undated). +tests/sources-icims.test.mjs (11 cases).
  • Cursor re-added to the supported-CLI roster (parent #2115) — the parent restored Cursor as a first-class host (.cursor/skills/career-ops/SKILL.md). server/lib/routes/cli-detect.mjs now probes the cursor binary too (10 tools reported: 9 first-class + Gemini legacy), and every roster surface — help ×17 (intro / comparison table / provider-setup list / AI-CLI-tools tab), README ×17, the #/config API-keys tab (config.providerModelNote, i18n ×17), docs/career-ops-canonical.md, and the canonical-docs-coverage gate — lists Cursor again.

Fixed

  • Agentic Engineering Jobs: HTML scraper → REST API (parent #2167/#2143)server/lib/sources/agenticjobs.mjs now reads the documented agentic-engineering-jobs.com/api/v1/jobs REST endpoint instead of parsing job cards out of HTML (the scraper broke). Same emitted job shape (salary min/max/currency now surfaced), FEED_URL points at the API, host pin unchanged. Test suite rewritten to feed canned JSON.
  • Greenhouse: recover the city when location.name is a work model (parent #2104) — boards that put “Hybrid”/“Distributed” in location.name and keep the real city only in the separate /offices endpoint no longer have every role silently dropped by the location filter. A second /offices request (paid only for boards that exhibit the pattern, fail-soft) folds the city back in via buildOfficeMap/isWorkModelOnly/officesUrlFor. +tests/sources-greenhouse-offices.test.mjs (6 cases).
  • role-matcher parity (parent #1933 / #2164 / #2009) — the mirrored server/lib/role-matcher.mjs (feeds the repost detector) now strips the “Member of Technical Staff” boilerplate prefix before tokenizing, treats product as a baseline token so PM sibling specialties stay distinct, NFD-folds accents (no phantom “nior” from “Sênior”), and treats a lone sub-baseline qualifier (associate/junior/entry/intern) as a disagreement. +4 assertions blocks in tests/detect-reposts.test.mjs.

Notes

  • Not ported (CLI-only or relay). The bulk of parent v1.23.0 is CLI/dashboard surface web-ui does not shell into: batch-tailor.mjs, company-history.mjs, contacts.mjs, discover-ats.mjs, outcome.mjs, pipeline-lock.mjs, skill-extract.mjs, theme-style.mjs, sync-pdf-flags.mjs, the Dutch/Portuguese modes, the CV section-partial HTML system, PDF profile theming, the Go dashboard’s RESPONDED tab, and the updater/doctor fixes. The relayed scripts (analyze-patterns.mjs Hired recognition #2145, salary-gap.mjs range parse #2200, stats.mjs, funnel-velocity.mjs) need no web-ui change — the fail-soft relays absorb their shape. The parent’s own DNS-cache/decodeEntities/scan-timeout hardening lives in provider internals web-ui already guards via safe-fetch/http-json. Parent VERSION → 1.23.0 (parentVersion).

[1.126.1] — 2026-07-25

Fixed

  • Two CLI-roster drift spots the v1.126.0 resync missed — (1) the #/configAPI keys tab intro (config.providerModelNote, i18n ×17) listed only 7 CLIs (Claude Code · Codex · Gemini · OpenCode · Qwen · Copilot · Kimi) — Antigravity and Grok Build are now inserted after OpenCode; (2) a second comparison-table row in the help guide (×17) and the built-at-CI site help still read Inside Claude Code / Codex / Cursor / Gemini CLI — the pre-v1.28 stale set with Cursor — now the full Claude Code / Codex / OpenCode / Antigravity CLI / Grok Build CLI / Qwen Code / Kimi / GitHub Copilot CLI (Gemini CLI legacy) roster. Both used slash/middot separators the v1.126.0 sweep’s patterns didn’t cover. i18n snapshot regenerated; suite stays 1969.

[1.126.0] — 2026-07-25

Added

  • AI CLI tools tab now detects all 8 first-class career-ops CLIs — synced the #/config “AI CLI tools” roster with the parent’s docs/SUPPORTED_CLIS.md: server/lib/routes/cli-detect.mjs gains Grok Build CLI (grok) and Kimi CLI (kimi), and the Antigravity entry now probes its canonical agy binary first (falling back to antigravity). The read-only PATH scan now reports 9 tools (8 first-class + Gemini CLI); it still never executes a found binary. tests/cli-detect-routes.test.mjs length assertions bumped 7→9.

Changed

  • Documentation resync with career-ops.org/docs — reconciled every doc surface against the live parent docs (all 31 pages read). The canonical AI-assistant roster (help ×17 intro + comparison table + provider-setup list + AI-CLI-tools tab; README ×17 intro + CLI→provider mapping) now lists the 8 first-class CLIs — Claude Code, Codex, OpenCode, Antigravity CLI, Grok Build CLI, Qwen Code, Kimi, GitHub Copilot CLI — plus Gemini CLI (legacy wrapper, transitioned into Antigravity). The canonical-docs-coverage gate’s CANON list was widened to enforce the fuller roster; wiki Features.md CLI-detect line updated. Help bundles keep their 29 H2 / 105 H3 structure.

[1.125.4] — 2026-07-23

Changed

  • site dependencies (dependabot #151–#153) — sharp 0.34.5→0.35.3, svgo 4.0.1→4.0.2, fast-uri (dev) 3.1.3→3.1.4 in site/; Astro build green, no SPA/server impact.

Notes

  • Parent parity sweep (career-ops 37d17ec..254764a, post-v1.22.0) — nothing portable: the set-status wrong-row guard (#2108) is CLI-only (web-ui tracker rows are selected explicitly in the UI and no route shells into set-status.mjs), the localized-mode Risk Summary mirror (#2109) touches modes/<lang>/ files web-ui never reads (top-level modes/*.md only), the update-system manifest verification (#2111) is updater-only, and the rest is parent docs (Turkish README, SIGNATURES ×4, SCRIPTS.md, es accents). Parent VERSION stays 1.22.0parentVersion unchanged.

[1.125.3] — 2026-07-23

Fixed

  • Danish and Hindi LLM prompts answered in English (user-reported) — LOCALE_NAMES and all five SCAFFOLD_STRINGS bags in server/lib/prompts.mjs were never extended for da or hi (each locale expansion after the 12-locale era skipped this file), so resolveLocale() fell back to en and every AI prompt — deep research (live and manual), mode runs, evaluate, interview, networking, CV Studio — lost its # Output language directive in those two locales. Both are now first-class: locale directive + localized scaffolding (readFiles / userContext / modeTemplate / modeRoleLine / evalRoleLine). The regression gate in tests/locale-scaffold.test.mjs now sweeps the canonical 17-locale list from tests/helpers/i18n-vm.mjs instead of a hardcoded 12, and a new structural parity gate fails any scaffold key that falls back to English in a non-EN locale — a future locale that misses prompts.mjs can no longer ship (+12 tests, suite now 1969).

[1.125.2] — 2026-07-22

Fixed

  • Deep research over Gemini: HTTP 502 (MALFORMED_FUNCTION_CALL) (#145, contributed by @Alien10140) — the live /api/deep prompt told the model to “Use WebFetch / WebSearch” and to save the brief to a file, but headless API providers have no tool channel; Gemini answered with a function call instead of text, surfacing as an empty HTTP 502. buildDeepPrompt and bundleProjectContext now take a headless flag: live runs (Anthropic/Gemini/fallback cascade) get a no-tools prompt that writes the brief from the inlined context, while the copy-paste prompt for Claude Code keeps its tool instructions. +1 test in tests/critical-fixes.test.mjs.

Changed

  • Gemini defaults bumped past the deprecated gemini-2.0-flash (#144, contributed by @Alien10140) — the Config dropdown, the server fallback in gemini.mjs (which silently disagreed with the hint), the OpenRouter fallback chain, config.geminiModelHint ×17 and the help guide ×17 now all name gemini-3.6-flash. The new drift gate tests/gemini-default-model.test.mjs (+5 tests) pins every surface to the same literal — the suite is now 1957 tests.

[1.125.1] — 2026-07-21

Fixed

  • SuccessFactors: multi-brand RMK tenants keep their brand path (parent #2099, post-v1.22.0) — holding companies that run several acquired brands off one shared RMK instance disambiguate them by a path segment (careers.nemetschek.com/Bluebeam/ vs …/Vectorworks/); the adapter used to collapse the configured URL to its origin, silently scanning the parent brand’s postings. The endpoint now preserves the brand prefix, stripping only a trailing /search/ or /tile-search-results/ segment so nothing ever doubles onto itself; single-domain tenants are byte-for-byte unchanged. New exported resolveTenantBase helper + 1 ported test block in tests/sources-successfactors.test.mjs.

[1.125.0] — 2026-07-21

Added

  • cvstart.org: “Job sources” landing section — a new section between the screenshots and the comparison lists all 67 scanner sources as clickable chips (62 EN boards/ATS + the 5 Russian boards under their own subheading), each linking to the source’s public site. The list is synced from the live adapter registry at build (sync-assets.mjsfacts.sources), so it can never drift from the app; a curated link map in Sources.astro is gated by the new tests/site-sources.test.mjs — a newly ported adapter without a link fails CI instead of silently shipping a linkless chip. Header nav gained a Sources anchor; 4 new site i18n keys ×17. Also fixed the landing JSON-LD inLanguage list, which was still missing hi.

[1.124.0] — 2026-07-21

Added

  • Five scan sources (parent v1.22.0 parity, #1808/#1572/#2024/#2055) — Welcome to the Jungle (board-wide JSON API), Agentic Engineering Jobs (agentic/AI-engineering board), Jobvite (zero-auth per-tenant ATS), Gem (per-tenant ATS), and Alibaba Group (careers JSON API, Meituan/Tencent pattern). Each is a host-pinned, CI-isolated source + adapter pair; the registry now ships 67 adapters (62 EN + 5 RU); the #/scan Source-dropdown fallback and its drift gate are updated; five new suites tests/sources-{wttj,agenticjobs,jobvite,gem,alibaba}.test.mjs.

Fixed

  • Arbeitsagentur: nationwide-remote only when homeofficetyp is VOLLSTAENDIG (parent #1981) — the homeoffice=nv_true query also returns hybrid roles, so the remote pass now confirms each hit against the job-details endpoint in small batches and fails closed (a lookup error keeps the job’s real city so location filters still apply).
  • SmartRecruiters: public job URLs built without /postings/ (parent #2047) — links now land on the public posting page instead of a 404 for tenants whose public site drops the segment.

Notes

  • Parent v1.22.0 also shipped CLI-side changes the web UI does not shell into or already covers: the zh-CN CV template + PDF typography, /expand mode, provider prompt-cache tweaks (Gemini/OpenAI/Ollama), the per-step token breakdown (utils/token-tracker.mjs — the web UI has its own data/llm-usage.jsonl usage meter), tracker writer-lock serialization (the web UI routes writes through withFileLock since v1.21), the scan visa_filter + absolute posted-date CLI flags (the web UI has its own “Posted within” age filter), and the seen-sources dedupe seeding (#2079/#2080 — the web UI scanner keeps its own scan-history dedup).

[1.123.0] — 2026-07-17

Added

  • Oracle Recruiting Cloud scan source (parent v1.21.0 parity, #1929) — the zero-auth recruitingCEJobRequisitions REST API of Oracle Fusion/ORC career sites (JPMorgan Chase, Oracle, BNY Mellon, American Express, Honeywell, …): host-pinned to *.fa[.<region>][.ocs].oraclecloud.com, the site number resolved from each tracked company’s careers_url, offset pagination with a hard page cap, and WAF-aware browser-like headers. The registry now ships 62 adapters (57 EN + 5 RU); the #/scan Source-dropdown fallback and its drift gate are updated; new CI-isolated suite tests/sources-oraclecloud.test.mjs.

Fixed

  • Repost detector: base titles stay distinct from specialized-suffix siblings (parent #1922) — “Senior Analytics Engineer” no longer clusters with “Senior Analytics Engineer, People Analytics”: when one title’s tokens are a strict subset of the other’s and the extra token is a real specialization (not a baseline word), the two are treated as separately-postable openings. Reposting annotations (“(Repost)”, “relisted”) are now stopworded as meta noise. +2 assertions in tests/detect-reposts.test.mjs.

Notes

  • Parent v1.21.0 also shipped CLI-side changes the web UI does not shell into or already covers: the repeat-company reapply warning (the web UI has the re-apply cooldown since v1.84.0), cover-letter --format/--report flags, the interview red-flag / panel-intel / no-show e-mail prompt modes, scan trust-signal & portal-health persistence (the web UI runs its own in-process scanner with trust-validator and the Portals health page), and the stats/salary-gap extensions (relayed read-only and fail-soft).

[1.122.0] — 2026-07-16

Added

  • Hindi (हिन्दी) — the 17th language — full UI dictionary (~1,110 keys), the complete in-app help guide (29 H2 / 105 H3 parity), README.hi.md, a new CHANGELOG.hi.md (starts at v1.122.0, following the de/it/tr precedent), the cvstart.org landing + Methodology/License/Changelog/Help pages, the language switcher (🇮🇳), browser-language auto-detect, and a localized dashboard screenshot. Every ×16 parity gate now runs ×17: i18n dict parity + snapshot, help H2/H3 gates, CHANGELOG parity, site check-i18n, and the Playwright locale sweep.

[1.121.0] — 2026-07-16

Added

  • cvstart.org: Methodology, License and Changelog pages — the landing gained three new sections in all 16 languages, next to the existing Compare block: /methodology/ (the six-dimension 0.0–5.0 scoring rubric, the 4.0 apply threshold, and the never-do rules — a localized summary of career-ops.org/methodology), /license/ (the canonical MIT text with the NOTICE.md pointer), and /changelog/ (this file, rendered per-locale from the repo’s 16 translated CHANGELOGs). New header Methodology entry and footer Resources links; sync-assets.mjs now syncs the CHANGELOG ×16 and LICENSE into the site at build time, so the pages can never drift from the repository.
  • Methodology links across the docs — the README (all 16), the in-app help guide §1 canonical list (all 16), and the wiki now link career-ops.org/methodology (plus the FAQ and glossary) alongside the existing career-ops.org/docs guides.

Changed

  • README release banner and badges refreshed (tests 1850, release v1.121.0) — the banner had still announced v1.119.5.

[1.120.0] — 2026-07-16

Added

  • The CareerOps Manifesto (parent v1.20.0 parity) — the parent project shipped the CareerOps Manifesto (MANIFESTO.md · career-ops.org/manifesto) and now surfaces it from its README, updater, and Go dashboard. The web UI follows suit: a new sidebar-footer link opens the manifesto page (new footer.manifesto i18n key in all 16 locales), the in-app help guide gained §29 “The CareerOps Manifesto” in all 16 languages, the README explains what the manifesto is and how to sign it, and the cvstart.org landing footer links to it too.

Notes

  • Parent v1.20.0 also fixed the upskill targeted-mode known-skill suppression, quieted dotenv so scan --json stdout stays parseable, and fixed the HTML CV template so a role header stays with its bullets — CLI-side surfaces the web UI does not shell into; no web-ui code change was needed.

[1.119.5] — 2026-07-13

Fixed

  • Landing language button no longer wraps — with the v1.119.2 flags the header switcher label (e.g. «🇷🇺 Русский») could break onto up to three lines at narrow desktop widths; the switcher label and every option in the dropdown are now whitespace-nowrap, keeping flag + endonym on one line. The footer language list switched from a rigid two-column grid to a wrapping row of one-line items, so «🇧🇷 Português (Brasil)» never splits mid-name either.

[1.119.4] — 2026-07-13

Changed

  • LICENSE names the author — the copyright line now reads Sergei Emelianov (Fighter90) https://sergey-cv.com and career-ops-ui contributors (canonical MIT text untouched). A new NOTICE.md spells the licensing out in detail: who holds copyright, exactly what the MIT grant covers (code, docs, translations, the landing, the wiki), what it does NOT cover (your runtime data, the parent project, job-board content, trademarks), the third-party component table (express/js-yaml MIT; Astro/Tailwind MIT; Figtree & JetBrains Mono under SIL OFL 1.1; sharp Apache-2.0) and an optional attribution line.

[1.119.3] — 2026-07-13

Added

  • SECURITY.md — the security policy the CONTRIBUTING guide pointed to now exists: supported versions, private reporting flow (GitHub private vulnerability reporting is now enabled on the repo — Security tab → “Report a vulnerability”), the threat model for a localhost-bound single-user app (XSS via hostile job postings / SSRF / path traversal / secret leakage / CSP weakening in scope; localhost DoS and parent-project issues out of scope) and the hardening baseline for reviewers.

[1.119.2] — 2026-07-13

Added

  • CONTRIBUTING.md — the contributor guide the landing and README have linked to all along now exists: setup, project map, the hard security/no-build rules, testing tiers, the two-registry walkthrough for adding a scan source, the ×16 i18n contract, commit/PR conventions and the release process.
  • Language flags on the landing — the cvstart.org language switcher, the footer language grid and the “read in your language” banner now show each locale’s flag next to its endonym (same regional-indicator set as the app’s language <select>; degrades to region letters where flag glyphs are missing).
  • Landing footer fixes — the dead Discussions link (the feature is not enabled on the repo) now points to the project wiki, and the footer credits the author: Sergei Emelianov (sergey-cv.com · LinkedIn).

[1.119.1] — 2026-07-13

Fixed

  • #/scan source filter caught up with the registry — the static FALLBACK_SOURCES list behind the Source dropdown (used only when GET /api/scan/sources is unreachable) had silently lagged since v1.87.0: 20 providers (Amazon, Avature, SAP SuccessFactors, Get on Board, Dassault Systèmes, beesite, HigherEdJobs, JibeApply (iCIMS), softgarden, Cornerstone, Phenom, Radancy, Deutsche Bahn, EchoJobs, TKMS, Heckler & Koch, Rheinmetall, LaraJobs and the new Meituan / Tencent) were missing from the offline fallback. Synced to all 61 and now gated by a drift test that fails CI whenever the client list diverges from the server registry (values AND labels). +1 test (1845).

[1.119.0] — 2026-07-13

Parent career-ops v1.19.0 parity + cvstart.org landing refresh.

Added

  • 2 new scan providers — Meituan (zhaopin.meituan.com) and Tencent (careers.tencent.com): the Chinese tech boards’ zero-auth public JSON APIs, host-detected or selected via an explicit provider:, with per-keyword server-side search, paginated fetch and URL dedup — 61 adapters now (56 EN + 5 RU). +20 tests (1844).
  • Contributors block on the landing — cvstart.org shows the avatars of everyone who landed code (GitHub /contributors API at build time, bots filtered), localized in all 16 languages, linking to the full contributors graph.
  • Live GitHub star counter on the landing — the header badge now refreshes client-side from the GitHub API on every visit (build-time snapshot as fallback), and a weekly scheduled Pages rebuild keeps the snapshot + contributors list fresh; CI API calls are token-authenticated.

Fixed

  • Workday CXS requests carry browser-like headers (parent #1813) — Cloudflare-gated tenants (seen live: geico) answer 500 to requests missing an ordinary UA/accept-language/origin/referer; the fetcher now derives origin + site slug from the CXS URL itself. Glints requests gained the same browser-like UA + origin/referer, both sourced from one shared BROWSER_LIKE_USER_AGENT constant in http-json.mjs.

[1.118.4] — 2026-07-10

Fixed

  • hh.ru scans returned 0 hits from a Russian IP (regional-subdomain links) — from a Russian residential IP hh.ru 302-redirects the search to a regional subdomain (sochi.hh.ru, spb.hh.ru, …) and returns vacancy links on that subdomain. The parser’s title-link matcher was anchored to a hard https://hh.ru/vacancy/ host, so it matched zero of the regional links and a fully working scan silently recorded 0 hits. It now accepts any *.hh.ru host (ads on adsrv.hh.ru/click?… are still excluded — they have no /vacancy/<id> path) and canonicalizes each result URL back to https://hh.ru/vacancy/<id>. Verified live: 17 real vacancies now parse from a sochi.hh.ru page that previously yielded 0. +1 test (1824).

[1.118.3] — 2026-07-10

Fixed

  • hh.ru silently returned 0 hits (VPN-check interstitial) — hh.ru now 302-redirects networks it flags as VPN/proxy (datacenter egress IPs) to a /vpncheeck interstitial (“VPN мешает работе сайта”) that answers HTTP 200 with zero vacancy cards, so the scan reported 0 with no error at all. The scanner now detects the redirect via the response’s final URL, disables hh.ru for the rest of the run, and prints an honest hint: traffic must really exit via a residential IP — a system-wide VPN/proxy can stay active even when the browser toggle is off. +1 test (1823).

[1.118.2] — 2026-07-10

Maintenance

  • Landing follow-up (#118)site/README.md reconciled to Astro 7 (the security upgrade in #116), unused import removed, and +4 executable guards for the landing build scripts: the i18n parity gate provably fails on a broken dictionary, and sync-assets never writes outside site/ — suite 1822. Two CodeQL alerts resolved (one fixed at source, one dismissed as intended build-time behavior).

[1.118.1] — 2026-07-10

Fixed

  • hh.ru scans from outside Russia — hh.ru now serves HTTP 451 (regional legal block) to non-Russian IPs on its public search pages. The scanner treats 451 like 403: after the first block hh.ru is disabled for the rest of the run with an honest log line pointing to a Russian IP / VPN exit node, so the remaining queries and the other RU sources are not wasted. Help §7 updated in all 16 languages. +1 test (1818).

[1.118.0] — 2026-07-09

Parent career-ops v1.18.0 parity pack.

Added

  • 9 new scan providers — Cornerstone OnDemand (csod), Phenom (phenom), Radancy (radancy), Deutsche Bahn (deutschebahn), EchoJobs (echojobs), TKMS (tkms), Heckler & Koch (hecklerkoch), Rheinmetall (rheinmetall), LaraJobs (larajobs) — 54 adapters now. The Lever adapter additionally detects EU-tenancy boards (jobs.eu.lever.co).
  • Hired tracker status (parent states.yml parity): accepted offers get their own canonical state, a celebratory badge, and a job-landed banner on #/tracker; the funnel and conversion charts count it as having advanced through every stage.
  • Lifetime tab in #/stats — read-only relay of the parent’s stats.mjs (lifetime tracker roll-up, cumulative funnel rates, scanner totals, portal coverage) plus compensation observations from salary-gap.mjs (desired vs advertised vs actual, per application). New routes GET /api/stats/lifetime and GET /api/stats/salary-gap — zero-token shell-outs, fail-soft {available:false} without the parent project.
  • 28 new i18n keys in all 16 locales; help guide §14/§26 updated in every language.

Tests

  • +38 unit tests (three provider parity suites + relay/status routes) — 1817 total.

[1.117.2] — 2026-07-06

Empty-tracker fix for the parity shell-outs. The parent’s cadence/patterns scripts exit 1 with a structured {error} JSON when the tracker has no applications yet; the followup board and the Rejection-patterns tab showed that as “script-error”. Both routes now relay it as a healthy empty state (available:true, empty:true), so the UI shows its honest “nothing yet” message. Verified live against a real parent.

New: none.

[1.117.1] — 2026-07-06

Hardening follow-up to v1.117.0 (CodeQL triage). The three shell-out endpoints (GET /api/followup, POST /api/followup/seed, GET /api/stats/patterns) now carry the shared per-IP rate limiter (they spawn a child process per request; no-op on loopback). The Add-to-CV URL text extraction strips tags to a fixed point and then removes every remaining </> outright — a provably complete sanitization for LLM-prompt text. No behavior change for valid input.

New: none.

[1.117.0] — 2026-07-06

Parent parity pack — six capabilities from the parent career-ops surfaced in the UI. (1) Follow-up cadence board: the #/followup page opens with per-application urgency (🔴 urgent / 🟠 overdue / 🟡 waiting / 🔵 cold) from the parent’s followup-cadence.mjs, plus a Seed follow-up dates button (followup-seed.mjs --backfill). (2) Rejection patterns: a fourth Statistics tab runs analyze-patterns.mjs (read-only) — outcome mix, recommendations, and the per-ATS-vendor advance rate. (3) Add to CV: a CV Studio card turns a project/publication URL or pasted text into ATS bullets grounded ONLY in that source (suggestions only, no writes; URL fetch is SSRF-guarded). (4) 4 new scan providers — beesite (jobs.mercedes-benz.com backend), HigherEdJobs (RSS), JibeApply (iCIMS), softgarden — the registry now ships 50 adapters (45 EN + 5 RU), all in the Scan source dropdown. (5) Knock-out pre-scan step in the Apply checklist (visa/degree/salary disqualifiers flagged before form-filling). (6) Reconcile runner (/api/run/reconcilereconcile-pipeline.mjs). Shell-out routes are fail-soft: without the parent scripts the UI shows an honest “not available” note.

  • New route module server/lib/routes/followup.mjs (31st) + GET /api/stats/patterns + POST /api/cv-studio/add-entry + 8 source/adapter files + registry counts 41→45 EN. Tests: tests/sources-parity-v1117.test.mjs (6) + tests/parity-routes-v1117.test.mjs (7); suite 1737 → 1750. 41 new i18n keys ×16. Help §13/§17/§24/§26 extended ×16.

New: GET /api/followup, POST /api/followup/seed, GET /api/stats/patterns, POST /api/cv-studio/add-entry, POST /api/run/reconcile.

[1.116.0] — 2026-07-06

Usage meter reworked + first end-to-end widget test. The AI usage meter (v1.114.0) is fixed and pinned properly: it now sits pinned to the bottom of the left sidebar (full sidebar width, matching the sidebar surface) and pads the sidebar’s bottom by its own height so the menu is never covered — the nav + version footer always scroll clear above it. It refreshes live (a 15-second interval plus on tab-focus and route change), and each window row now shows the real <tokens> · <estimated cost> (bars scale against the 30-day window) instead of an always-100% “share”. Also: a durable typeof barrier in the CV importer closes the recurring CodeQL type-confusion false positive at the source, and a new Playwright end-to-end acceptance test drives both persistent widgets (Ask-the-docs launcher open/close/hide + usage meter render/collapse) in a real browser.

  • public/js/lib/usage-hud.js + app.css (sidebar-pinned, syncSidebarPad, real-time), server/lib/cv-import.mjs (typeof … === 'number' barrier). Tests: tests/playwright-widgets.mjs (2 E2E) + updated tests/usage-hud.test.mjs (10). Suite 1735 → 1737 (+2 browser E2E). Help §6 extended ×16.

New: none.

[1.115.0] — 2026-07-06

Design polish (conservative, coral brand kept). A light refinement pass over the shared design system — no restructuring, no palette change. Dashboard metric cards now lift and pick up a coral border on hover (matching the quick-action tiles); content cards lift a hair; primary / dark / danger buttons gain a resting shadow and a gentle hover lift for depth; big numbers align via tabular-nums; and interactive controls get a soft coral focus halo behind the crisp 2px keyboard ring. All motion respects prefers-reduced-motion, and the halo is deliberately scoped to controls — never a global *:focus-visible (which would re-paint the managed-focus route headings; the v1.58.x lesson).

  • CSS-only (public/css/app.css); no markup, i18n, route, or CSP change. Tests: tests/design-polish-v1115.test.mjs (5) guard the polish + the no-global-halo anti-regression. Verified live via Playwright (dashboard intact, zero console errors).

New: none.

[1.114.0] — 2026-07-06

AI usage & cost meter in the sidebar (bottom-left). A compact USAGE section now sits at the bottom of the sidebar (a fixed bottom-left card if there’s no sidebar; bottom-right in RTL) on every page. It shows your LLM token usage across 24h / 7d / 30d windows — each as <tokens> · <share%> with a green meter bar (share of all-time) — plus an estimated 24h-cost footer. Data is the read-only GET /api/usage rollup of data/llm-usage.jsonl (local only), the same source as the #/usage page; cost is an estimate and manual-mode runs are free and uncounted. Collapsible — the header toggles and the state persists.

  • New client widget public/js/lib/usage-hud.js (window.UsageHud) loaded from index.html, mounted into the sidebar above the version footer (fixed-corner fallback). CSP-safe (no inline handlers, static-constant gauge SVG); theme-aware + RTL-mirrored; hidden as a fixed overlay on mobile. No new server route — reuses the read-only usage rollup. Tests: tests/usage-hud.test.mjs (8). 3 new i18n keys ×16 (hud.title/hud.empty/hud.estimate).

New: none.

[1.113.0] — 2026-07-06

Floating “Ask the docs” assistant on every page. A gradient robot chat button now floats in the bottom-right corner (bottom-left in RTL) of every page. Click it to open a compact chat that answers how-to questions grounded ONLY in the in-app help guide in your language — the same endpoint as the #/docs-assistant page (POST /api/docs-assistant/ask), so it never reads your CV, profile, or tracker. Live with an LLM key; no key → a ready-to-run prompt. The header shows a robot avatar + an online status; starter chips seed common questions; Escape or click-outside closes it; it hides itself on the dedicated #/docs-assistant page.

  • New client widget public/js/lib/docs-fab.js (window.DocsFab) mounted globally from index.html; CSP-safe (no inline handlers, UI.md() render boundary, self-contained SVG icons); theme-aware + RTL-mirrored styles in app.css. No new server route — reuses the grounded docs-assistant endpoint. Tests: tests/docs-fab.test.mjs (8). 6 new i18n keys ×16 (fab.* + docs.err). Help §1 extended in place.

New: none.

[1.112.0] — 2026-07-06

Docs & QA consolidation. No user-facing code change. The SDD conventions doc (docs/sdd/CONVENTIONS.md) is refreshed to the current 30 route modules (was 24) and the current test baseline; the definitive whole-project QA prompt (qa/QA-REGRESSION-PROMPT.md) is consolidated — release mechanics destaled (v1.111, parentVersion 1.17.0, release-triggered publish), the §14 additions table corrected (scan Exclude re-labelled v1.109.0) and extended with the v1.111 CodeQL closeout — so it stands alone as the single regression prompt for all functionality. Adds one coverage test for the oversize-upload branch.

New: none.

[1.111.0] — 2026-07-06

Security — CodeQL backlog closeout. Three defense-in-depth hardenings that close the remaining static-analysis findings at the source instead of dismissing them. stripDangerousMarkdown now escapes the < of any truncated dangerous-tag opener (a payload ending in <script/<iframe/…) so its output provably contains no live dangerous tag. CV import reads an uploaded buffer’s size through an explicit Number() coercion — a type-confusion barrier. Mode role-lines are now template strings interpolated with String.replace instead of stored functions, removing the dynamic-dispatch call entirely. No user-facing behavior change.

  • server/lib/security.mjs (final escape belt), server/lib/cv-import.mjs (coerced size), server/lib/prompts.mjs (string role-lines). Tests: tests/security-hardening-v1111.test.mjs (7) + updated v1108 guard test. No i18n/help/route changes.

New: none.

[1.110.0] — 2026-07-06

Docs & QA refresh (all languages). No code change. The definitive whole-project QA prompt (qa/QA-REGRESSION-PROMPT.md) is refreshed to v1.109.0 with a new §14 covering everything shipped v1.98→v1.109, and the perennial UX-audit + design-export prompts gained the current page surface. Every in-app help paragraph added over v1.100–v1.109 (Ask the docs, AI CLI tools + company logos, AI usage & cost, scan Exclude, pipeline overview, two-pager export, CV Doctor) is now translated into all 16 languages.

New: none.

[1.109.0] — 2026-07-06

Scan Exclude filter + pipeline overview (parent-web layout parity). On #/scan, the Search box now treats commas as OR (“roles to find” — a row shows if it matches any term) and a new Exclude field hides any row whose company/role/location contains any comma-separated word (e.g. senior, staff); both are remembered by your saved searches. On #/pipeline, a compact overview strip shows your pipeline at a glance — N in inbox, N tracked, and the Applied / Responded / Interview / Offer counts from the tracker, each chip linking to #/tracker.

  • Client-only (no new route/writes). public/js/views/scan.js (include-OR + Exclude, round-tripped through saved-search state) + public/js/views/pipeline.js (overview strip, degrades to the inbox count if the tracker can’t be read). Tests: tests/scan-pipeline-ui-v1109.test.mjs (2). 4 new i18n keys ×16 (scan.filterExclude/scan.lblExclude + pipe.ovInbox/pipe.ovTracked). Help §7 + §8 extended in place.

New: none.

[1.108.0] — 2026-07-06

Security hardening (CodeQL triage, round 2). Three more low-severity findings fixed: the mode-prompt builder resolves the locale role-line by own key + typeof === function so a tampered locale can’t dispatch to a prototype method (unvalidated-dynamic-method-call); the PDF-filename slug is capped to 200 chars before the dash-trim regex so an all-dash input can’t backtrack (polynomial ReDoS); and document import coerces an array filename (a repeated header) to a string (type-confusion). No behavior change for valid input.

  • server/lib/prompts.mjs, server/lib/routes/runners.mjs, server/lib/cv-import.mjs + tests/security-hardening-v1108.test.mjs (3). Over v1.106–v1.108 the static-analysis backlog went from 167 to ~14, with every genuinely security-relevant finding fixed and the remainder (guarded/sanitized false positives + note-level lint) dismissed with rationale.

New: none.

[1.107.0] — 2026-07-06

Sanitizer hardening (at-rest XSS defense-in-depth). stripDangerousMarkdown — which neutralizes dangerous HTML in stored CV/JD markdown so any consumer that bypasses the escape-on-render client is still safe — now runs its tag strip to a fixed point (repeat-until-stable) so a removal that reforms a payload (e.g. <scr<script></script>ipt>) is caught, matches script/style/etc. end tags with trailing junk (</script foo>), and strips an unclosed executable opener (<script …> with no closing tag). Behavior for valid markdown is unchanged — it only ever removes more.

  • server/lib/security.mjs: fixed-point loop (bounded 8 passes) + [^>]*> end-tag patterns + unclosed-opener strip. +3 regression cases in tests/cv-xss-bypasses.test.mjs (end-tag variants, single-pass-reveal, unclosed). The authoritative XSS boundary remains output-escaping (UI.md); this strengthens the at-rest guarantee and closes the matching CodeQL findings.

New: none.

[1.106.0] — 2026-07-06

Security hardening (CodeQL triage). Fixed three real (if low-severity) findings from a pass over the static-analysis backlog: the route-render error path now escapes the error message before it reaches the DOM (a server error can echo user-supplied input, so it’s treated as untrusted — XSS boundary), and the profile/config property writes reject __proto__ / constructor / prototype keys (belt-and-braces prototype-pollution guards — the keys come from fixed field specs, not raw request input). The bulk of the remaining alerts are false positives on the scanner’s legitimate data/* reads/writes and on routes that already carry the app’s custom rate-limiter, and were dismissed with rationale.

  • public/js/router.js escapes err.message via UI.escapeHtml before innerHTML; server/lib/routes/content.mjs (setArray/setDotted) and server/lib/routes/config.mjs (the env-apply loop) guard prototype keys. No behavior change for valid input. Tests: tests/security-hardening-v1106.test.mjs (3). No new i18n keys.

New: none.

[1.105.0] — 2026-07-06

AI usage & cost page. A new AI usage page (sidebar, next to Health) shows how many tokens you’ve spent on live AI generations — evaluations, reports, chats — broken down per provider over the last 24 hours, 7 days, 30 days, and all-time, with an estimated USD cost. Every live provider call appends a small {provider, in, out} record to data/llm-usage.jsonl (nothing is sent anywhere); runs with no API key (manual mode) cost nothing and aren’t recorded.

  • New route module (30th) server/lib/routes/usage.mjsGET /api/usage (read-only rollups) + server/lib/llm-usage.mjs (recordUsage normalizes the Anthropic/OpenAI/Gemini usage shapes and appends best-effort; readUsage/aggregate roll up per 24h/7d/30d/all window × provider) + server/lib/llm-pricing.mjs (an editable per-provider $/1M-token table — token counts are exact, dollars are approximate list prices you can correct to your plan; never billed). Recording is hooked at the dispatch chokepoints (runActiveProvider + routes/llm.mjs) so all live calls are captured.
  • New view public/js/views/usage.js (#/usage, window tabs). Tests: tests/usage-routes.test.mjs (normalize / price / record round-trip / aggregate / endpoint). 17 new i18n keys ×16 (usage.* + nav.usage). Help §6 extended in place.

New: server/lib/routes/usage.mjs; server/lib/llm-usage.mjs; server/lib/llm-pricing.mjs; public/js/views/usage.js.

[1.104.0] — 2026-07-06

Company logos in the scan table (privacy-preserving). A new Appearance toggle in App settingsShow company logos in the scan table (off by default) — draws each company’s logo next to its name on #/scan. The logo is the company’s favicon fetched from its own domain and proxied server-side (GET /api/logo), so no third-party logo service ever learns which employers you’re viewing. Postings on a shared job board (Greenhouse, Lever, Ashby, …) show a coloured letter badge instead of the board’s icon, and any logo that fails to load falls back to the same badge.

  • New route module (29th) server/lib/routes/logos.mjsGET /api/logo?domain=. It validates the domain (no scheme/path/loopback), fetches /favicon.ico through the SSRF-safe safeGet (a new binary mode returns the raw bytes + content-type; DNS-pinning, redirect validation and the size cap are unchanged), image-magic sniffs the result so an HTML error page is never served as an image, caches hits and misses in an in-memory LRU, and writes nothing to disk.
  • New client lib public/js/lib/company-logo.js (window.CompanyLogo): off by default via a localStorage flag; skips shared ATS hosts in favour of a deterministic letter-avatar; CSP-safe img.onerror fallback. Tests: tests/logo-routes.test.mjs (domain guard, image sniff/reject, negative cache, binary safeGet). 5 new i18n keys ×16 (appear.*). Help §2 extended in place.

New: server/lib/routes/logos.mjs; public/js/lib/company-logo.js.

[1.103.0] — 2026-07-06

Settings: “AI CLI tools” — which agent CLIs are installed. career-ops is Claude-Code-driven but works with any agent CLI on the open skill standard. A new AI CLI tools tab in App settings (#/config) shows which of them — Claude Code, Codex, Gemini CLI, OpenCode, GitHub Copilot CLI, Qwen, Antigravity — are installed on the machine running the server, and their paths. It is a read-only PATH scan: it only checks whether each binary exists and never runs it (no --version, no execution), writes nothing, and touches no user data.

  • New route module (28th) server/lib/routes/cli-detect.mjsGET /api/cli-detect. Detection resolves the path of a binary from a fixed 7-entry allowlist across process.env.PATH (Windows .cmd/.exe/.bat shims; POSIX execute-bit); a hostile file on PATH can never be executed by this route.
  • New “AI CLI tools” tab in public/js/views/config.js (lazy-loaded, deep-linkable via #/config?tab=cli). Tests: tests/cli-detect-routes.test.mjs (PATH resolve with a stub exec that must never run, detect shape, endpoint). 8 new i18n keys ×16 (cli.* + config.tabCli). Help §2 extended in place.

New: server/lib/routes/cli-detect.mjs.

[1.102.0] — 2026-07-05

“Ask the docs” — a grounded chat over the in-app help guide. A new Ask the docs 💬 page (sidebar, under Help): type a how-to question like “How do I scan job portals?” and get an answer drawn only from the app’s own help guide in your language — it shows which sections it used and never reads your CV, profile, or job search. It is about how to use the app, not about you. With an LLM key it answers live; with no key it hands you a ready-to-run prompt already filled with the relevant help sections.

  • New route module (27th) server/lib/routes/docs-assistant.mjsPOST /api/docs-assistant/ask. Dependency-free retrieval: the help doc for your locale (docs/help/<lang>.md) is split into its ## sections and scored by keyword overlap with your question; the top few are inlined and the model must answer from them or say the guide doesn’t cover it (no invented features/routes). Shared provider cascade, manual fallback, rate-limited, no writes, reads no user data.
  • New view public/js/views/docs-assistant.js (a chat under the Help nav). Tests: tests/docs-assistant-routes.test.mjs (section split/rank, grounded prompt, manual seed, empty→400). 14 new i18n keys ×16 (docs.* + nav.docsAssistant). Help §1 extended in place.

New: server/lib/routes/docs-assistant.mjs; public/js/views/docs-assistant.js.

[1.101.0] — 2026-07-05

CV Studio: tailor your résumé + write a cover letter for a specific job, gated by a recruiter checklist. A new Tailor to a job card on #/cv-studio: paste a job description (and, optionally, a target role/headline) and CV Studio produces a résumé tailored to that posting plus a matching cover letter, then runs both through a checklist gate before handing them over — errors block (they’re fixed before you see the result), warns advise. The mechanic is distilled from career-coaching practice into generic rules — recruiter reads in seconds, so relevant experience goes to the top, the headline matches the vacancy’s role, results carry specific numbers, and the cover letter stays a short teaser with a single “requirement ↔ your matching fact” bridge. It is grounded only in your own CV, profile, and two-pager and never fabricates — no hardcoded companies, roles, or history.

  • New endpoint POST /api/cv-studio/tailor (extends the existing cv-studio route module — no 27th module): buildTailorPrompt + a generic TAILOR_INSTRUCTIONS gate, grounded in bundleProjectContext (CV + profile + two-pager), shared provider cascade, manual-prompt fallback with no key, rate-limited, no file writes. Result exports as Markdown / PDF / DOCX via the shared report-export.js bar.
  • Tests: +3 in tests/cv-studio-routes.test.mjs (prompt is generic — gate + bridge + no-fabrication present, no hardcoded employer; manual mode seeded from the candidate materials; too-short JD → 400). 10 new i18n keys ×16 (cvs.tailor*). Generic reference docs/prompts/resume-cover.md. Help §24 extended in place.

New: docs/prompts/resume-cover.md.

[1.100.0] — 2026-07-05

Two-pager: AI auto-fill from your CV + Preview + export to PDF/DOCX/Markdown. The two-pager (#/two-pager) captures what you actually want from your next role, but you had to draft each field by hand or copy a prompt into another tool. Now the ✨ AI fill assistant runs live against your configured provider — it reads only your CV + profile (via bundleProjectContext, nothing invented), drafts every field (who I am / loves / must-haves / hates / deal-breakers / non-negotiables / target environment) and populates the form for you to review, edit, and Save. With no API key it falls back to the copy-a-prompt modal exactly as before. A new 👁 Preview & export button renders the two-pager as a formatted document with a Download .md / Save as PDF / Save as DOCX / Copy bar.

  • Dependency-free .docx export. New server/lib/docx.mjs emits a minimal-but-valid Office Open XML .docx (a DEFLATE ZIP of the four OOXML parts, CRC-32 per entry) — no new runtime dependency (deps stay express + js-yaml). New route POST /api/export/docx (server/lib/routes/export.mjs, the 26th route module; stateless, bounded 200 KB, no writes / no LLM / no URL fetch). Wired into the shared public/js/lib/report-export.js, so the market report, career plan, and career orientation reports gain DOCX export too.
  • Two-pager live auto-fill uses the shared provider cascade (runActiveProvider / providerAvailable); the returned YAML is parsed and coerced back into the bounded two-pager shape (parseYamlFields + normalizeTwoPager) — unknown keys dropped, arrays/strings capped. Manual fallback preserved.
  • Tests: tests/export-routes.test.mjs (valid ZIP/OOXML output, empty-input 400, 4-part package, XML-escaping, YAML-fence parsing + null-on-garbage). 4 new i18n keys ×16 (export.saveDocx, twoPager.preview, twoPager.aiFilling, twoPager.aiFilled).

New: server/lib/docx.mjs; server/lib/routes/export.mjs.

[1.99.0] — 2026-07-05

Portals health page (#/portals). The scanner watches a set of companies in portals.yml (tracked_companies:), and an ATS slug can quietly break — a company renames its board or moves off Greenhouse — after which that employer silently vanishes from every future scan with no error. The new Portals page lists every watched company (provider + enabled state) and, on Check portal health, HEAD/GET-probes each careers_url through the DNS-pinned safeGet (SSRF-safe, chunked concurrency) and flags the dead ones (a 404 = silently dropped). Read-only — it never writes portals.yml.

  • New route server/lib/routes/portals.mjs (POST /api/portals/health; the company list reuses the existing GET /api/portals) + view public/js/views/portals.js, under the Sourcing nav group. #/portals was promoted from a config alias to a real registered view (router alias removed). Suite tests/portals-routes.test.mjs + updated tests/router.test.mjs. 14 new i18n keys ×16.
  • Bug-reporter hardening (v1.98.0 follow-up, per review): logbuf.js’s fetch wrapper now has a .catch so network-layer failures (offline/DNS/abort — the failures a bug reporter most wants) reach the ring buffer; bug-report.js’s scrubber now redacts bare/unlabelled provider keys (sk-ant-, sk-, ghp_, xoxb-, AIza…), not only labelled ones. +3 scrub assertions.

New: server/lib/routes/portals.mjs; public/js/views/portals.js.

[1.98.0] — 2026-07-05

In-app bug reporter (parent career-ops web-v0.2.0 parity). The one substantial feature the parent’s experimental web frontend had that we lacked. A 🐞 Report a bug button in the notifications drawer opens a preview-then-confirm modal that gathers a privacy-floored diagnostic snapshot — app + parent version, your current screen, browser, viewport, a checks OK / FAIL summary from /api/health, and the last 20 errors captured by a new client-side ring buffer (console.error, window.onerror, unhandled rejections, and failed /api/* responses — pathname + status only) — plus a deterministic dedupe fingerprint (co-web-<base36>). You review the exact Markdown, then it opens a pre-filled GitHub issue (or copies the report); nothing is auto-filed. The privacy floor is an invariant: never CV, profile, application answers, job URLs, report content, or API keys — home paths and secret-looking tokens are scrubbed on top.

  • New client libs public/js/lib/logbuf.js (error ring buffer, loads first so it captures from first paint) + public/js/lib/bug-report.js (window.BugReport — scrub / fingerprint / issueBody / issueUrl / openModal). No new server route — reuses /api/health. Wired into the notifications-drawer head.
  • Tests: tests/bug-report.test.mjs (ring-buffer cap, path/token scrub, deterministic + volatile-stable fingerprint, privacy-floor body, repo-targeted issue URL). 11 new i18n keys ×16 locales.

Assessment: our web-ui is otherwise ahead of the parent’s alpha web — we already ship WCAG 2.5.5 44×44 tap-targets, provider cost hints, 24 routes, and 16 locales — so the bug reporter closes the last meaningful gap.

New: public/js/lib/logbuf.js; public/js/lib/bug-report.js.

[1.97.1] — 2026-07-05

Review-driven hardening & documentation parity (follow-up to v1.97.0). A sweep of the AI-review logs surfaced real fixes:

  • fit-score.js (scan fit badge). salaryFloor() no longer promotes a sub-annual rate into a bogus annual floor — “at least 500 EUR/day”, “$80/hr”, “6000 monthly” now return null instead of a 500k/80k deal-breaker. Country matching is now whole-word (\b…\b) so “Germany” no longer matches the adjective “German” (nor “Nigeria” inside “Nigerian”) and fires a false must-have-elsewhere violation. +3 tests in tests/fit-score.test.mjs.
  • Documentation parity. Every localized README now advertises 16 locales consistently — the Help-row count/list (×13) and the Localization-section prose + “add the key to all N files” note (×8) were still on the pre-v1.85 counts (8/9). The in-app help §17 adapter count is corrected to 46 adapters — 41 English + 5 Russian across all 16 bundles.

No behaviour change beyond the fit-badge heuristic; no new routes, keys, or i18n additions.

[1.97.0] — 2026-07-05

Dassault Systèmes scanner source + a three-front quality sweep.

  • New scan source — Dassault Systèmes (parent career-ops parity, #1498). server/lib/sources/dassault.mjs + server/lib/portals/adapters/dassault.mjs mirror the parent’s zero-token Exalead “card search” provider (the public feed behind 3ds.com/careers/jobs). It’s a single global endpoint, so it’s provider-selected (provider: dassault) or auto-detected from a 3ds.com host, SSRF host-pinned to www.3ds.com with redirect:'error'. The XML is parsed without a DOM (per-<Hit> <Meta> maps), city/country pulled from the localized category string, and postings are kept only when their public URL is on *.3ds.com. The registry now ships 46 adapters (41 EN + 5 RU); ALL_ADAPTERS count, sorted-id and /api/scan/sources EN-set assertions bumped 40 → 41. Suite tests/sources-dassault.test.mjs (10 cases).
  • Ported parent robustness fixes. Avature parser now tolerates two live tenant markup variants (article--result with a position-index suffix + a classless JobDetail title anchor, #1541); Get on Board guards a 0/negative published_at (no more bogus 1970 dates); SuccessFactors caps the last page so it can’t overshoot MAX_JOBS (#1528).
  • Server audit fixes. safe-fetch no longer hangs on an over-cap response — the size-cap path now settles the promise directly instead of waiting for an 'end' event a destroyed stream never emits (fixes large-page /api/pipeline/preview + auto-pipeline fetches). SSE stream.* activity-logging is reachable again (the /api/stream/ check moved above the blanket “skip GET” guard).
  • SPA audit fixes. The #/stats tab switcher guards against an async render race — a slow tab’s result can no longer clobber a newer tab the user already switched to. The mock-interview and networking delete confirms now pass a proper title + body (no more empty-bodied dialog).
  • Translation fixes. Untranslated dictionary values corrected — Ukrainian config.modes* (Adaptive Framing / Exit Narrative / Location Policy), Russian eval.jdLbl (“Job Description”), Italian dash.quick.contactoSub (“referral” → “segnalazione”) — plus the English **16 locales** boilerplate localized in the ru/uk/ja/ko/zh-CN/zh-TW CHANGELOGs.

New: server/lib/sources/dassault.mjs; server/lib/portals/adapters/dassault.mjs.

[1.96.0] — 2026-07-04

Career orientation (Epic 27). A new #/orientation page answers “which directions actually fit me?” — the read you’d get from a vocational test, but inferred from your own CV and profile instead of a questionnaire. Click Generate profile and the model returns your best-fit career vectors (which of the eight archetypes — Functionalist, Administrator, Communicator, Specialist, Analyst, Innovator, Manager, Entrepreneur — fit, with evidence), a career-type leaning, recommended roles, professional strengths tied to your CV, working-style tendencies, and development recommendations. It is an AI reflection of how your CV reads — not a psychometric test: it never invents achievements and never reports numeric scores as if measured. Export it to Markdown or PDF; nothing is written to disk.

  • New route server/lib/routes/orientation.mjs (24th route module) — POST /api/orientation/generate builds the profile prompt from CV+profile+two-pager+memory via the shared provider cascade, with a copy-paste manual fallback and no file writes.
  • Reuses report-export.js for Markdown/PDF/copy, under the Growth nav group.
  • Tests: tests/orientation-routes.test.mjs (reflection framing / no fabricated scores, CV/profile-seeded manual mode). 7 new i18n keys ×16 locales, Help §28 ×16.

New: #/orientation; server/lib/routes/orientation.mjs.

Also in this release — review-driven fixes & hardening. A sweep of the AI-review logs across recent PRs surfaced several real defects, all fixed here:

  • Duplicate i18n keys removed (30 CodeQL js/duplicate-property alerts). The v1.94.0 statistics fan-out had inserted the stats.*/export.* block twice in five locale dicts (ar, ja, ko, zh-CN, zh-TW) — last-wins silently shadowed the first copy. Each doubled block is removed; snapshot regenerated; parity green.
  • cv-diagnostics.js dead code (CodeQL js/useless-comparison). The words === 0 length branch was unreachable after the words < 20 early-return guard — removed.
  • cv-privacy.js false redactions. PHONE_RE now skips date-like runs (a 2018-2022 year range, a 2026-07-04 ISO date) that its ≥7-digit guard would otherwise mask; ADDRESS_RE now requires a real address boundary (comma / ZIP / end-of-line) so a mid-sentence “…Full Stack Dev St building…” isn’t redacted. New tests cover both.
  • i18n-dict.fr.js shipped-bundle noise. Stripped the trailing English // gloss comment from every French entry — consistent with the other 15 locales.
  • AI-review workflow. ai-review.yml’s rubric now names all 16 locales (was a stale 8), and the reviewer diff is ordered code-first (server/, public/, tests/) so the 200 KB cap truncates bulky localized CHANGELOG/README churn rather than the security-relevant code it kept reporting as “not in the diff.”
  • Doc drift. Refreshed stale counts in docs/sdd/CONVENTIONS.md (test total, H3 parity number), CLAUDE.md, and .claude/PROJECT-CONTEXT.md.

[1.95.0] — 2026-07-04

Career plan (Epic 26). A new #/career-plan page turns your CV and profile into a concrete, personalized development plan. Pick a horizon (6/12/24 months) and an optional focus, and the model — reading your CV, profile, two-pager, and memory note — writes a starting-point snapshot, a strengths/growth SWOT, goals as SMART / OKR / WOOP, alternative trajectories, a hard/soft skill plan, a month-by-month roadmap, progress-tracking methods, pitfalls, and support moves. It plans forward from what your materials actually show and never invents facts about your history. Edit it inline, Save it to the user layer (config/career-plan.md), and export it to Markdown or PDF.

  • New route server/lib/routes/career-plan.mjs (23rd route module) — GET/PUT /api/career-plan (writes config/career-plan.md) + POST /api/career-plan/generate (shared provider cascade, manual fallback, no fabrication). PATHS.careerPlan.
  • Reuses the shared report-export.js (v1.94.0) for Markdown/PDF/copy, and a new Growth nav group.
  • Tests: tests/career-plan-routes.test.mjs (bounding, GET/PUT round-trip, horizon-aware CV/profile-seeded prompt). 20 new i18n keys ×16 locales, Help §27 ×16.

New: #/career-plan; server/lib/routes/career-plan.mjs; PATHS.careerPlan.

[1.94.0] — 2026-07-04

Statistics, reworked (Epic 25). The #/stats page is now a three-tab Statistics section, with real graphs and a lot more data. A new Market report tab asks the model for a salary & labour-market analysis of your target roles in a region and currency you choose — executive summary, salary by grade with P10/P25/P75/P90 percentiles, top employers, an in-demand skills table, benefits frequency, the office/hybrid/remote split, 12–24 month trends, and negotiation guidance. Every figure is labelled a directional estimate from the model’s knowledge, never presented as scraped data. A new My pipeline tab charts your own tracker: score distribution, status funnel, top companies and roles, applications over time, and conversion rates. The original target-role view (vacancy/salary by country + saved-snapshot trend) moves under a third tab, now with a currency selector and a postings-by-role overview.

  • Export any report to Markdown or PDF, or copy it — via the shared report-export.js helper (Markdown blob download; PDF through the existing inline-PDF runner).
  • New route server/lib/routes/market.mjs (22nd route module) — POST /api/stats/market builds a market-analysis prompt from your CV/profile (so it knows your target roles), region, and currency, runs it through the shared provider cascade, and falls back to a copy-paste prompt with no key. No file writes.
  • Tests: tests/market-routes.test.mjs (region/currency bounding, honesty-labelled prompt, CV/profile-seeded manual mode). 36 new i18n keys ×16 locales, Help §26 ×16.

New: #/stats reworked into tabs; server/lib/routes/market.mjs; public/js/lib/report-export.js.

[1.93.0] — 2026-07-04

Memory layer (Epic 24). A new #/memory page holds a short, editable “remember this about me” note that the assistant keeps in mind on every task:

  • One note, everywhere — because it’s inlined into bundleProjectContext, the note automatically reaches every AI request (evaluate, mock interview, networking, CV Studio) across all providers. Write it once; it steers everything.
  • Steering, not facts — it captures your preferences and how you like to work (tone, format, deal-breakers, cadence), never new factual claims about your experience — those still live only in your CV, profile, and two-pager. Saved to the user layer at config/memory.md, never overwritten by updates.
  • Suggest from your dataPOST /api/memory/suggest mines your own application tracker for behavioural patterns and drafts bullets for you to review and edit. It reads your tracker; it never invents facts, and makes no live call.

New: server/lib/routes/memory.mjs (21st route module — GET/PUT /api/memory + POST /api/memory/suggest), public/js/views/memory.js, PATHS.memory, and a config/memory.md block added to bundleProjectContext. 11 new i18n keys across all 16 locales. Tests: tests/memory-routes.test.mjs.

[1.92.0] — 2026-07-04

CV Studio (Epic 21). A new #/cv-studio page gives your CV three honest, mostly-local tools:

  • Résumé diagnostics — a deterministic 0–100 score with per-check explanations (quantified impact, weak verbs, buzzwords, length, core sections, contact info). Pure client-side (window.CvDiagnostics) — no LLM, nothing fabricated, every finding explained so you decide what to change.
  • Privacy mask — redact PII (email, phone, links/handles, street address, and optionally your name → initials) before sharing your CV as a sample or screenshot. Runs entirely in the browser (window.CvPrivacy); it reports exactly what it redacted and never stores the original.
  • Make it human / voice match — paste a stiff line or paragraph and rewrite it in your voice, grounded server-side in voice-dna.md and writing-samples/. Hard guardrail: it may reorder, tighten, and re-voice, but never introduces a fact, metric, or achievement not already in the text. Runs live through the shared provider cascade, or hands back a copy-paste prompt with no key.

New: server/lib/routes/cv-studio.mjs (20th route module — POST /api/cv-studio/humanize), public/js/views/cv-studio.js, public/js/lib/cv-diagnostics.js, public/js/lib/cv-privacy.js, PATHS.voiceDna + PATHS.writingSamplesDir. 29 new i18n keys across all 16 locales. Tests: tests/cv-diagnostics.test.mjs, tests/cv-studio-routes.test.mjs. (Template gallery, Word export, and posting-PDF archive are tracked as follow-up CV Studio work.)

[1.91.0] — 2026-07-04

Networking & deep company research (Epic 16). A new #/networking page turns a company into an actionable plan to get an interview, grounded in your CV, profile, and two-pager:

  • Company dossier — a tight brief on what the company does, recent signals worth citing, and “why I fit” hooks drawn from your real background.
  • Who to contact — 3–5 target personas (hiring manager, in-house recruiter, a senior IC on the team, a warm/alumni connection) with a concrete LinkedIn search string to find each. It never fabricates real names.
  • Warmest intro path — the single most realistic warm route in for your background (shared employer/school/community, a second-degree path, or a high-signal cold DM) and why.
  • Outreach drafts — short, specific messages for the top personas, grounded in your real proof points.
  • Live or manual — runs live through the shared provider cascade with any key, or hands back a copy-paste prompt (honest fallback, nothing invented). Save plan persists a finished plan to the user layer (networking/net-{company}-{role}-{date}.md); the page lists, opens, and deletes saved plans.

New: server/lib/routes/networking.mjs (19th route module), public/js/views/networking.js, PATHS.networkingDir. Reuses the v1.90.0 server/lib/llm-dispatch.mjs cascade. 24 new i18n keys across all 16 locales. Tests: tests/networking-routes.test.mjs.

[1.90.0] — 2026-07-04

Mock Interview 2.0 (Epic 15). A new #/mock-interview page turns your CV, profile, two-pager, and story bank into a turn-by-turn interview rehearsal:

  • Conversational practice — set a target role (+ optional company / JD) and the interviewer opens with a focused question. Each answer you send gets a structured reply: Feedback (strengths + the STAR+R gap), a Score (N/5), and a Next question that probes the weakest part of your last answer. Grounded server-side in your real materials — it never fabricates experience you don’t have.
  • Story bank awareinterview-prep/story-bank.md is inlined into the prompt (same trust level as cv.md) so feedback can point you at your own best stories.
  • Live or manual — with a provider key the turn runs live through the shared provider cascade (Anthropic → Gemini → OpenAI → Qwen → OpenRouter → GitHub Models); with no key you get a ready-to-run copy-paste prompt (honest fallback, no fabricated answers).
  • Saved sessions — click Save transcript to persist a finished interview to the user layer (interview-prep/mock-{company}-{role}-{date}.md); the page lists, opens, and deletes saved sessions.

New: server/lib/routes/interview.mjs (18th route module), public/js/views/mock-interview.js, server/lib/llm-dispatch.mjs (shared provider cascade), PATHS.storyBank, bundleProjectContext({ extraFiles }). 30 new i18n keys across all 16 locales. Tests: tests/interview-routes.test.mjs.

[1.89.0] — 2026-07-04

Candidate market fit — the two-pager (Epic 14). A new #/two-pager page lets you capture what you actually want from your next role, modelled on the “Mnookin two-pager” from Never Search Alone:

  • Guided builder — a first-person “Who I am” narrative, a “Target environment” note, and five chip-list editors: loves, must-haves, hates, deal-breakers, and non-negotiables. Saved to the parent project’s user layer (config/two-pager.yml) via PUT /api/two-pager — never overwritten by system updates.
  • AI fill assistant (POST /api/two-pager/draft) — builds a ready-to-run Mnookin prompt with your CV + profile inlined, for you to run in any LLM and paste back. It only ever uses your own materials; nothing is fabricated.
  • Fit-to-what-you-want badge — each posting on #/scan now shows a ◎ N fit score (client-side, via window.FitScore) that matches the job’s work-type, country, salary floor, and relocation against your two-pager. Honest by design: when a posting gives no matchable signal, no badge is shown (never a made-up number). Deal-breaker violations weigh more than soft dislikes.
  • Feeds every evaluation — the saved two-pager is inlined into bundleProjectContext, so all downstream LLM evaluations blend your stated preferences with the CV-vs-JD match.

New: server/lib/routes/two-pager.mjs, public/js/views/two-pager.js, public/js/lib/fit-score.js, PATHS.twoPager. 27 new i18n keys across all 16 locales. Tests: tests/two-pager-routes.test.mjs, tests/fit-score.test.mjs.

[1.88.0] — 2026-07-04

Issue #29 polish — Scan i18n gaps + API hygiene.

  • Localized the last hardcoded Scan strings (roadmap v1.69.4): the source-summary pills (N new / M matching), the N new offers toasts, and the reloc badge now flow through t() — 4 new keys (scan.pillNew, scan.pillMatching, scan.newOffers, scan.relocBadge) across all 16 locales. Non-English users no longer see stray English in the core scan flow.
  • Disabled the X-Powered-By header (roadmap v1.69.5): app.disable('x-powered-by') in createApp() — the server no longer advertises Express. (The rest of that epic was already shipped: parentVersion strips its release-please comment, the light-mode theme toggle, modal-dismiss-on-route-change, and the Reports “Score” localization.)

Tests: tests/scan-i18n-gaps.test.mjs + an X-Powered-By-absence assertion in tests/security-headers.test.mjs.

[1.87.0] — 2026-07-04

4 new zero-auth scan providers (parent career-ops v1.16.0 parity). The scanner registry grows 41 → 45 adapters (40 EN + 5 RU) — all public, no-auth, host-pinned, redirect:'error' (SSRF-safe), each with a CI-isolated test:

  • Get on Board (getonbrd) — board-wide public JSON:API (LATAM/remote tech), provider-selected, paginated. server/lib/sources/getonbrd.mjs.
  • Amazon (amazon) — amazon.jobs public search JSON, host-detected or provider: amazon, offset-paginated. server/lib/sources/amazon.mjs.
  • Avature (avature) — per-tenant *.avature.net ATS, HTML-parsed, host-detected or provider: avature. server/lib/sources/avature.mjs.
  • SAP SuccessFactors (successfactors) — per-tenant RMK tile list (*.successfactors.eu/.com, jobs2web.com), HTML-parsed. server/lib/sources/successfactors.mjs.

Each ships a sources/<slug>.mjs (auto-discovered meta#/scan dropdown) and a portals/adapters/<slug>.mjs in ALL_ADAPTERS (the two-registry rule) + tests/sources-<slug>.test.mjs. The ALL_ADAPTERS count + sorted-id and /api/scan/sources EN-set assertions bumped 36→40; GET /api/scan/sources now lists 45.

[1.86.0] — 2026-07-03

Statistics by target roles (#/stats) — market vacancy & salary stats for YOUR target roles. A new Analytics page reads your target roles from the profile (config/profile.yml → not hard-coded) and the latest scan’s postings, then shows, per role and country:

  • Vacancies by country and median salary by country (USD) — aggregated client-side (public/js/lib/role-stats.js, reusing window.Countries) from the sparse data the scanners already collect. Salaries in any currency are normalized to USD via an explicitly-approximate FX table, with a sample-size caveat — never fabricated.
  • Role & country filters and hand-rolled inline-SVG bar + trend charts (no new deps, CSP-safe — addEventListener only).
  • Save snapshot (POST /api/stats/snapshot) persists the current aggregate to data/role-stats.jsonl; the trend chart (GET /api/stats/trend) tracks vacancy counts over time — the “dynamics” view. Honest hybrid: snapshots come from local scan data, refreshed on demand.
  • Fully localized in all 16 locales (26 new i18n keys).

New: server/lib/routes/stats.mjs (16th route module), public/js/lib/role-stats.js, public/js/views/stats.js, PATHS.roleStats; tests role-stats.test.mjs (7) + stats-routes.test.mjs (5).

[1.85.0] — 2026-07-03

German, Italian & Turkish locales (parent career-ops v1.16.0 locale parity). The UI now ships in 16 languagesde 🇩🇪, it 🇮🇹 and tr 🇹🇷 join the existing 13.

  • Full UI translation — all 730 i18n keys translated in public/js/lib/locales/i18n-dict.{de,it,tr}.js; the language switcher lists Deutsch / Italiano / Türkçe and browser-language auto-detection recognises de/it/tr (public/js/lib/i18n.js).
  • In-app Help guidedocs/help/{de,it,tr}.md translated (full 19 H2 / 75 H3 structure), served by GET /api/help/:lang.
  • DocsREADME.{de,it,tr}.md and CHANGELOG.{de,it,tr}.md added; the CHANGELOG locale-parity gate now covers 15 non-EN locales.
  • Prompt scaffoldingserver/lib/prompts.mjs (LOCALE_NAMES + SCAFFOLD_STRINGS) localised for the three new locales, so LLM output follows the UI language.

All parity gates (i18n-locale-files, i18n-coverage, check-changelog-parity, lang-switcher-rtl) extended to the 16-locale set.

[1.84.0] — 2026-06-30

Re-apply cooldown + compensation in pipeline.md (parent career-ops v1.15.0 parity). Two scanner upgrades:

  • Re-apply cooldown (#1201): the EN scan now skips roles at companies you applied to recently, so results stay focused on NEW openings. Configure per-company windows in config/profile.yml under re_apply_windows: (last_apply_date, same_role_days, applied_to: [roles], optional cross_role_bucket); company matching is punctuation-insensitive + word-boundary (server/lib/cooldown.mjs). Off when the key is absent; the scan log shows Cooldown skipped: N.
  • Compensation in pipeline.md (#1017): scanned offers now persist their salary as an optional trailing column (url | <salary>) in data/pipeline.md. The URL stays the dedup key (the | comp column is stripped on read), the cell is sanitized (no row/column injection, formula-leading neutralized), and bare-URL pipelines stay backward-compatible.

Ships tests/cooldown.test.mjs + pipeline compensation tests. Source count unchanged at 41 (both are scan-logic upgrades, not new boards).

[1.83.0] — 2026-06-30

Repost / ghost-posting detector (parent career-ops v1.15.0 parity). A new 🔁 Reposted / ghost roles panel on #/scan flags company+role clusters that were re-listed under different URLs within a rolling 90-day window — a signal of stale pipelines and ghost postings. Backed by a fuzzy role-title matcher (server/lib/role-matcher.mjs) and a read-only detector (server/lib/detect-reposts.mjs) over data/scan-history.tsv, surfaced via GET /api/scan/reposts (window clamped 7–365 days; fail-soft on a malformed history). Also: /api/health parentVersion now reports just the semver — the release-please # x-release-please-version comment is stripped. Ships tests/detect-reposts.test.mjs. (Source count unchanged at 41 — reposts is an analysis feature, not a new board.)

[1.82.0] — 2026-06-30

NoDesk scan source (parent career-ops v1.15.0 parity). The board-wide NoDesk remote-jobs RSS feed is now a first-class scan source — add a provider: nodesk entry and it appears in the #/scan Source dropdown (41 adapters total: 36 EN + 5 RU). Host-pinned to nodesk.co with redirect:'error' (SSRF-safe); titles split on Role at Company (NoDesk has no location tag, so location stays empty); all rows remote. Ships a CI-isolated tests/sources-nodesk.test.mjs suite; full unit suite green at 1523.

[1.81.0] — 2026-06-29

Parent career-ops parity — 13 new job-board scan sources. Ports the latest provider batch from Fighter90/career-ops main into the in-process scanner. Board-wide public APIs (provider-selected): Arbeitnow, Himalayas, Jobicy, Landing.jobs, 4 Day Week, The Muse, The Hub, Jobspresso (RSS), and Hacker News “Who is hiring?” (Algolia two-step). Poland boards (host- or provider:-detected): JustJoin.it and NoFluffJobs (POST search). Per-tenant ATS (auto-detected from careers_url): Pinpoint (<slug>.pinpointhq.com/postings.json) and Rippling (ats.rippling.com/<slug>api.rippling.com board). Every source is host-pinned with redirect:'error' (SSRF-safe) and selectable in the #/scan Source dropdown — the registry now ships 40 scanner adapters (35 EN + 5 RU). Adds 13 CI-isolated per-source test suites; full unit suite green at 1513.

[1.80.0] — 2026-06-28

Five scan upgrades (ideas from bracketouverte/job-crawler, reimplemented). (1) Teamtailor source — per-tenant <slug>.teamtailor.com career sites via their public /jobs.rss feed, auto-detected from careers_url (host-pinned + redirect:'error'); the registry now ships 27 adapters. (2) Source quarantine — a source that returns a permanent 404/410 is recorded in data/scan-quarantine.json and skipped on later scans (self-healing: retried after 14 days), killing the recurring dead-slug noise. (3) Max per source — an optional #/scan field caps how many jobs each board contributes (∞ by default). (4) Posted within — a client-side age filter (24h / 7d / 30d) on the results table. (5) Saved searches + ★ favorites — name & reuse filter sets and star jobs, persisted in localStorage with defensive validation (a corrupt cache resets cleanly); the results cache is reset before each scan and refilled live.

[1.79.0] — 2026-06-28

WeWorkRemotely scan source (parent career-ops v1.14.0 parity). The board-wide We Work Remotely remote-jobs RSS feed is now a first-class scan source — add a provider: weworkremotely entry and it appears in the #/scan Source dropdown (26 adapters total). Host-pinned to weworkremotely.com with redirect:'error' (SSRF-safe); titles split on Company: Role. Also: title_filter keywords are now trimmed before the length check, so a whitespace-only keyword can’t compile into a match-everything filter (parent #1261).

[1.78.2] — 2026-06-27

i18n + UX hardening (follow-ups to v1.78.1). The brand logo’s accessible name is now localized in all 13 languages (nav.logoHome). Global-search Enter while already on #/scan force-re-renders so the pre-filled term is never lost (same-route guard). health.title is now translated in Polish (Kondycja) and Danish (Systemtilstand) — previously left in English. Tests 1235 → 1238.

[1.78.1] — 2026-06-27

Scan UX fixes. The #/scan results table now auto-refreshes live during a scan and once more after it finishes — no manual reload. The top-bar global search shows an Enter hint and, on a non-URL query, jumps to #/scan with the search box pre-filled (was #/tracker). The brand logo now links to the dashboard (home).

[1.78.0] — 2026-06-27

Geography filter on the Scan page — filter job results by country, with flags. A new Country dropdown in #/scan lists every country detected across your scanned results (flag emoji + count), so you can keep only roles tied to a specific country — alongside the Remote/Hybrid/Onsite work-type filter, so you can search both country-bound and remote work. Backed by a new countries.js helper that maps a posting’s free-text location (country names, aliases, and ~100 major job-market cities) to an ISO country + flag; detection is conservative and never guesses (unmapped/remote locations stay under “All countries”).

[1.77.0] — 2026-06-27

Danish (Dansk) added as the 13th interface language. Full Danish UI translation, in-app Help guide (19 H2 / 75 H3), README, and CHANGELOG. Danish joins the flag language picker; the i18n machinery (assembler, audit, parity gates, snapshot) now spans 13 locales.

[1.76.0] — 2026-06-26

Parent career-ops v1.13.0 parity — six new job sources, scanner hardening, and an uncapped results table.

Added

  • Six per-tenant ATS sources — BambooHR, Breezy HR, Comeet, Personio, Recruitee, SolidJobs. They auto-detect from the careers_url host (Comeet needs the full api: careers-api URL) and each pins its host with an anchored regex + redirect:'error' (SSRF-safe). All selectable in the #/scan Source dropdown — the registry now ships 25 adapters (20 EN + 5 RU). Adds a fetchText helper for Personio’s XML feed.
  • trust_filter — optional, annotate-only trust scoring (0–100, level high/medium/low, flags) for each scanned posting. Sub-high rows get a language-neutral ⚠ badge in #/scan; nothing is ever dropped.
  • Arbeitsagentur remoteMatch + remoteMaxPages — config-driven remote detection: title (regex), filter (server-side homeoffice=nv_true + pagination), or off.

Changed

  • No scan result cap. The MAX_STORED_RESULTS display cap (2000) was removed — every matched posting is stored and the #/scan table pages through them (200/page). Large sweeps no longer lose their tail.
  • Title-filter robustness — short all-letter acronyms (COO, SDR…) now match on word boundaries (no more “COO” in “Coordinator”); malformed title_filter config can no longer crash a scan. Both the ATS and regional scanners.

Tests

  • +32 cases (1190 → 1222): sources-ats-providers, title-filter, arbeitsagentur-remote, trust-validator, and a rewritten scan-result-cap “no cap” guard.

[1.75.2] — 2026-06-19

docs: full documentation parity for the v1.75.0 scanner aggregators across all 12 locales. No code change — brings the user-facing docs in line with the seven sources that landed in v1.75.0:

  • Help guide (12 locales). §5 gains a content_filter block (description/snippet keyword gating, sibling of location_filter) and an aggregators note; §7 lists the seven new sources in the one-click-scan sweep and the full Source dropdown enumeration; §17’s adapter count is corrected from the stale “11 adapters” to “19 adapters — 14 English + 5 Russian”. No ##/### heading was added, so the gated 19 H2 / 75 H3 structure is unchanged.
  • README (9 full locales). New “Aggregator boards (v1.75.0)” bullet under the scan sources, plus the release badge bumped to v1.75.2. (The abbreviated pl/uk/ar READMEs have no per-source list and are intentionally untouched there.)
  • Reference docs. docs/portals-examples.md gains a copy-paste “Aggregator boards” section with accurate provider: / <provider>: config blocks for all seven; docs/PROJECT.md updated to 19 adapters; docs/sdd/CONVENTIONS.md documents the two-registry distinction (sources/registry.mjs for the dropdown vs portals/registry.mjs for fetching), the provider:-based aggregator selection threaded as opts.company, the scan-write sanitizer (scan-sanitize.mjs), and the v1.75.1 test count (1190).
  • QA. Added qa/QA-REGRESSION-PROMPT-v1.75.2-FULL.md — the full-surface release-gate driver, refreshed for the v1.75.x scan-aggregator cycle.

[1.75.1] — 2026-06-19

fix(scan): robustness polish on the v1.75.0 config-driven sources. Three small hardening fixes from the post-release review (no behavior change for a healthy scan):

  • Abort-aware pagination delays. The Glints (300 ms) and Jobstreet/SEEK (200 ms) inter-page courtesy pauses now resolve immediately when the scan’s AbortSignal fires, via a new delay(ms, signal) helper in server/lib/http-json.mjs, so a disconnected client can’t hold a paginating scan open for an extra pause.
  • Descriptive non-JSON error. fetchJson now wraps a non-JSON 2xx body (e.g. an HTML maintenance page served with status 200) as non-JSON 2xx response from <url> instead of surfacing a bare SyntaxError, so the scanner’s per-source error log names the misbehaving endpoint.
  • Stronger scan-write normalization. normalizeScanScalar now collapses the vertical tab, form feed, and the Unicode line/paragraph separators (\v \f U+2028 U+2029) in addition to \r \n \t — a strict superset, so no record/line separator a spreadsheet or viewer might honor survives into scan-history.tsv.

[1.75.0] — 2026-06-19

feat(scan): port parent career-ops v1.12.0 — seven new job sources, content filtering, and security/quality fixes. The web-ui runs its own in-process scanners (it does not shell out to the parent’s scan.mjs), so parent v1.12.0’s provider and scan changes do not flow through automatically — this release reimplements the applicable ones in the web-ui’s adapter contract.

  • Seven new scanner sources. Three board-wide remote aggregators — RemoteOK, Remotive, Working Nomads — drop into the auto-discovered server/lib/sources/*.mjs pattern (select with provider: remoteok / remotive / workingnomads). Four config-driven regional aggregators — IBM careers, Arbeitsagentur (German Federal Labor Agency), Glints (SE Asia), Jobstreet / SEEK — read a per-entry <provider>: config block; en-scanner now threads the resolved company entry through to every fetcher so they can read it. All seven appear in the #/scan source dropdown automatically.
  • content_filter (parent #974). Optional portals.yml block (positive / negative keyword lists) that gates a posting on its description/snippet text — mirrors location_filter semantics; postings without a description always pass. Wired into both EN and RU scanners.
  • Scan-write hardening (parent #1098). External feed metadata is now sanitized before it lands in data/scan-history.tsv and data/pipeline.md: control characters are collapsed (a company/title newline can no longer inject a TSV row) and a leading = + - @ is neutralized against spreadsheet formula injection.
  • Ashby secondaryLocations (parent #1073). The Ashby source now folds each secondary location’s region label plus postal addressLocality / addressCountry into the location string (deduped), so an EU-eligible role whose primary label reads e.g. “Canada” surfaces for the location_filter.
  • Evaluation report-shape validation (parent #819). /api/evaluate’s in-process providers (Anthropic / OpenAI / Qwen / OpenRouter / GitHub Models) now flag a malformed A–G / SCORE_SUMMARY report as a non-fatal warnings array; the Gemini eval path already inherits the guard from the parent’s gemini-eval.mjs.
  • docs: Antigravity CLI added to the supported-assistant lists across all 12 READMEs (maps to the Gemini provider).

Inherited for free from the git pull of the parent (web-ui shells out to these): Japanese CJK PDF font fallback (#1053), ATS-safe PDF fonts (#1074), LaTeX CJK guard (#1054), tracker/merge/followup/dashboard fixes, and the modes/zh Chinese modes (the web-ui lists modes dynamically).


[1.74.3] — 2026-06-18

docs(parent-source): point the parent career-ops repo at the Fighter90/career-ops fork. The web-ui now references the maintainer’s fork as the parent project everywhere it is a live source: the bin/setup.sh installer’s CAREER_OPS_REPO clone default, every git clone / “sits on top of” / onboarding link across all 12 READMEs, and the agent docs (CLAUDE.md, AGENTS.md, GEMINI.md, .github/copilot-instructions.md, docs/). Author credit to santifer (and the unofficial-UI disclaimer) is unchanged — only the source/clone URLs moved. tests/sh-files.test.mjs now asserts the installer clones the fork.


[1.74.2] — 2026-06-17

fix(health): surface GITHUB_MODELS_API_KEY as an optional check on #/health and in /api/status/providers. The v1.74.0 GitHub Models provider was configurable in #/config but had no row on the Health page and was missing from the keysConfigured provider surface. Added the optional check (same “set / unset (manual mode)” wording as the other five live-eval providers) and github (+ its GITHUB_MODELS_MODEL) to /api/status/providers, so the active-provider routing and Health page now reflect all six. tests/api.test.mjs health-row test extended to all six providers.


[1.74.1] — 2026-06-17

docs + test: README “Install an AI assistant” section; full branch coverage for the Gemini connector. Added an install/login table to the README — install links for Claude Code / Gemini CLI / Codex / Qwen Code / OpenCode / GitHub Copilot CLI + each one’s #/config provider mapping + “log in before continuing” (mirrors the career-ops.org/docs Quick Start; clarifies the web-ui is the standalone, no-CLI-needed alternative). New tests/gemini-connector.test.mjs (8 cases) covers every runGemini branch — no-key, success, API error, empty/blocked completion, malformed body, timeout, network error, hasGeminiKey — taking server/lib/gemini.mjs to 100% statements. Overall coverage 96% lines / 88% branch / 96% funcs. Suite 1126 → 1134.


[1.74.0] — 2026-06-17

feat(llm): GitHub Models (Copilot) as the 6th provider + canonical 6-assistant alignment. career-ops.org/docs lists six AI coding assistants — Claude Code, Gemini CLI, Codex, Qwen Code, OpenCode, GitHub Copilot CLI. The web-ui now supports all six: five map to existing live providers (Anthropic / Gemini / OpenAI / Qwen / OpenRouter), and GitHub Copilot CLI gains a dedicated GitHub Models connector — runGitHubModels (OpenAI-compatible; a GitHub PAT with the models scope), configurable in #/config (GITHUB_MODELS_API_KEY + GITHUB_MODELS_MODEL) and selectable via LLM_PROVIDER=github; 6th in the auto order. Help bundles + READMEs now list the canonical six (renamed Qwen CLI→Qwen Code; added Gemini CLI + GitHub Copilot CLI), and the README adds a full mode-reference + portal-adapter link table to career-ops.org/docs so every feature traces back to the parent. tests/llm-provider-context.test.mjs extends the fetch-boundary matrix to all six providers (cv.md + profile.yml inlined + artifact returned); new GITHUB_MODELS_* keys added to all 12 locale dictionaries. Suite 1125 → 1126.


[1.73.0] — 2026-06-17

feat(llm): generic Gemini connector + verified CV/profile context across all providers. Added server/lib/gemini.mjs (runGemini) — a zero-dependency Gemini generateContent client returning the same {markdown, usage, error} shape as the Anthropic / OpenAI-compatible clients. Fix: /api/mode/:slug and /api/deep previously piped their prompts through the oferta-only gemini-eval.mjs, so Gemini Run live returned an evaluation instead of the requested artifact (cover letter, outreach, brief). They now call runGemini with bundleProjectContext, so cv.md + config/profile.yml are inlined for Gemini exactly like every other provider — letters/briefs are detailed and personalized. New tests/llm-provider-context.test.mjs mocks each provider’s HTTP boundary and asserts all five (Anthropic / Gemini / OpenAI / Qwen / OpenRouter) inline cv.md + profile.yml and return the artifact (mode + deep + evaluate matrix, 9 cases). /api/evaluate keeps its oferta-tuned gemini-eval.mjs. Suite 1116 → 1125.


[1.72.0] — 2026-06-17

feat(modes): “Run live” now returns the final artifact directly (single-shot output contract). The parent modes/<slug>.md templates are written for interactive Claude Code sessions — several (cover, contacto, …) pause to ask clarifying questions before producing the result, which made the web-ui’s single-shot Run live emit a questionnaire instead of the artifact. buildModePrompt now wraps every mode in a non-interactive output contract: do the analysis (JD breakdown, company notes, ATS keywords, profile↔JD gaps, tone/angle choices) silently, pick sensible defaults from cv.md / config/profile.yml for anything the template would normally ask, and output only the final artifact — closed with a per-mode “output ONLY {the cover letter / outreach message / …}” reminder. So clicking Run live on #/cover now returns the cover letter itself; the same fix applies to every generic mode (cover, contacto, interview-prep, project, training, followup, patterns) in all 12 locales (the artifact is written in the UI language via the locale directive). Suite 1103 → 1116.


[1.71.2] — 2026-06-17

docs(i18n): publish the documentation-consistency pass. Every README “Translations of this guide” block now lists all 11 sibling languages (previously some omitted English/Français and self-linked), with the blank line before the section break restored. The full QA regression prompt is renamed to the current version, and the docs (CLAUDE.md, CONVENTIONS, LOCALIZATION, PROJECT-CONTEXT) are synced to the current version and test count (1103). No code or behavior change — docs only, so the help/UI translations and all features from 1.70.0–1.71.1 are unchanged.


[1.71.1] — 2026-06-17

fix(i18n): the in-app help guide is now fully translated in all 12 languages. Added docs/help/{pl,uk,ar}.md (each holding the gated 19 H2 / 75 H3 structure) so #/help serves a native bundle for Polish, Ukrainian, and Arabic instead of falling back to English — GET /api/help/{pl,uk,ar} now return their own locale. Wired into every help gate (help-ui, help.test, help-ru-config-section, canonical-docs-coverage). Also completed every 12-language translation list: the README “Translations of this guide” block (9 READMEs), the localized CHANGELOG “Translations:” headers (8 files), and refreshed stale doc counts (CONVENTIONS test count → 1100; help “fall back” notes → 12 translated bundles). Suite 1100 → 1103.


[1.71.0] — 2026-06-16

feat(cover): generate a cover-letter PDF straight from #/cover. The cover mode (added in v1.70.0) produces the letter text; the result now offers a Generate PDF button that renders it through the shared inline markdown→PDF pipeline (POST /api/stream/pdf/inlinegenerate-pdf.mjs), the same path interview-prep uses. You can now produce the letter and ship a PDF without leaving the SPA.

test/docs: v1.70.0 review hardening. Added CI-isolated coverage for the cover mode (allowlist + prompt assembly, via modes-endpoints.test.mjs), the flag <select> switcher + Arabic RTL (dirFor/<html dir>, new tests/lang-switcher-rtl.test.mjs), top.langLabel in every locale, the cover-letter PDF wiring (tests/cover-letter-pdf.test.mjs), and the prompts.mjs locale directive + scaffolding for fr/pl/uk/ar (tests/locale-scaffold.test.mjs — locking the latent French gap closed in v1.70.0). Refreshed three stale “all 8” → 12-locale / 9-help-bundle references in docs/sdd/CONVENTIONS.md. Refreshed the full-project QA regression prompt (qa/QA-REGRESSION-PROMPT-v1.71.0-FULL.md).


[1.70.0] — 2026-06-16

feat(i18n): three new UI languages — Polish (pl), Ukrainian (uk), and Arabic (ar, with full RTL) — bringing the SPA to 12 locales, matching every language in the parent career-ops README. Each new locale ships a complete 697-key dictionary (public/js/lib/locales/i18n-dict.{pl,uk,ar}.js), gated by the existing parity / coverage / no-latin-leak / no-personal-data suites. Arabic adds genuine right-to-left support: i18n.js sets <html dir="rtl"> for RTL locales and a scoped [dir="rtl"] block in app.css mirrors the chrome (sidebar, notifications drawer, markdown tables/blockquotes, inline spacing) — LTR locales are byte-for-byte unchanged. New top.langLabel key (×12) names the picker for screen readers.

feat(ui): flag-icon <select> language switcher replaces the wrapping button row. With 12 locales the old .lang-btn row wrapped to three lines in the sidebar; a native <select> (each option prefixed with a flag emoji) scales cleanly, is keyboard- and screen-reader-friendly out of the box, and stays CSP-safe (change handler via addEventListener, no inline JS). Flags degrade to region letters where the platform lacks flag glyphs, so the language label is always the load-bearing identifier.

feat(cover): port the parent’s cover-letter mode (career-ops v1.10.0 + v1.11.0 greeting) into the SPA. New #/cover page under the Application nav group, built on the generic mode runner: Job description + Company/Role + an optional salutation → a tailored letter generated from cv.md / modes/_profile.md. Added cover to the server MODE_ALLOWLIST and a cover.* i18n block (×12 locales).

chore(compat): track parent career-ops v1.11.0. Verified the read/write contract is intact — data/applications.md stays the markdown source of truth (the v1.11.0 SQLite tracker index is a derived cache), tracker columns are still header-mapped. parentVersion now reports 1.11.0.

fix(i18n): close a latent gap where French (added v1.61.0) was missing from server/lib/prompts.mjs LOCALE_NAMES and SCAFFOLD_STRINGS — French LLM calls silently defaulted to English output and English scaffolding. fr/pl/uk/ar are now all wired into the prompt-locale path.

Known follow-ups (tracked in qa/v1.70-regression/): the in-app help guide (docs/help/) falls back to English for pl/uk/ar (the UI chrome itself is fully localized); the parent’s interactive interview onboarding, reverse-ATS discovery, and newer scan providers are not yet surfaced in the SPA.


[1.69.2] — 2026-06-12

fix(test): close a test-isolation leak that let npm test overwrite the user’s real config/profile.yml and data/scan-history.tsv. tests/critical-fixes.test.mjs statically imported prompts.mjs at the top of the file; prompts.mjs transitively imports paths.mjs, which resolves PROJECT_ROOT eagerly at module load (PATHS resolves once per process). Because that import ran before the before() hook set CAREER_OPS_ROOT to a temp dir, PATHS pinned the real parent — so the F-008 PUT /api/profile wrote the “Acceptance Test” fixture into the user’s real profile on every run (and similar writes escaped the temp root). Fix: load prompts.mjs via dynamic import() inside before(), after the env is set (the server was already loaded that way). New tests/test-root-isolation.test.mjs (2 cases) guards every isolation-needing test against statically importing a paths.mjs carrier (server/index.mjs, prompts.mjs, store.mjs, en-scanner.mjs, ru-scanner.mjs, paths.mjs). No production-code change. Suite 1084 → 1086.


[1.69.1] — 2026-06-12

fix(scan): raise the #/scan result display cap 500 → 2000 per region so large regional sweeps are no longer silently truncated. A real RU scan produced 1352 matching jobs, but only the first 500 were stored in data/last-scan.json and rendered in the results table — 852 relevant jobs were hidden (the 2000 scanned → ~600 shown symptom: 139 EN + 500 RU). Both server/lib/en-scanner.mjs and server/lib/ru-scanner.mjs now cap the stored filtered set at a shared, env-overridable constant MAX_STORED_RESULTS (default 2000, override via SCAN_MAX_RESULTS). This is display-only — appending to pipeline.md and scan-history.tsv already used the uncapped fresh (new-since-last-scan) set and was never truncated. New tests/scan-result-cap.test.mjs (3 cases) locks the default, the env override, and that neither scanner hard-codes slice(0, 500).

fix(health/ui): #/health check cards no longer overflow. A long check name/value (e.g. PROFILE CUSTOMIZED · still on template …, PLAYWRIGHT (PARENT NODE_MODULES)) collided with the right-hand Fix → button + status badge and spilled out of the card, because the generic .flex-between flex children default to min-width: auto and never shrink. The row is now tagged .health-check-row with scoped CSS: the left text shrinks + wraps (min-width: 0), the action group keeps its size (flex: 0 0 auto) and wraps below on narrow cards. New tests/health-card-overflow.test.mjs (2 cases). Suite 1079 → 1084.


[1.69.0] — 2026-06-12

feat(scan): P-14 plug-in scanner auto-discovery — drop a .mjs in server/lib/sources/ to register a new source. Pre-v1.69 the source list in server/lib/sources/registry.mjs was a static hand-maintained array — adding a new adapter required editing both <id>.mjs AND registry.mjs. Closes the partial half of the roadmap item P-14 (docs/ROADMAP.md). Now every *.mjs in server/lib/sources/ is auto-loaded at module boot; each adapter contributes its identity via a self-describing export const meta = { value, label, region, configKey? } block. The 12 shipped adapters (ashby / greenhouse / lever / rss / smartrecruiters / workable / workday + geekjob / getmatch / habr / hh / trudvsem) each grew a meta export; registry.mjs now uses readdirSync + dynamic import() resolved at module-eval via top-level await (Node 18+ ESM standard). The public API (SOURCES, SOURCES_BY_REGION, RU_CONFIG_KEYS, getRegionalSources) is unchanged — every existing import keeps working. Validation rejects malformed meta (missing value/label/region, RU without configKey, region outside 'en'|'ru') and logs a single console.warn per offending file so half-migrated branches stay diagnostic-friendly. The bundled registry.mjs is excluded from self-import. New tests/sources-registry-discovery.test.mjs adds 14 cases covering shipped-adapter coverage, drop-in adapter discovery, helper-module skip, malformed-meta rejection, self-import exclusion, missing-directory tolerance, and deterministic ordering. Suite 1065 → 1079.


[1.68.2] — 2026-06-07

fix(bin): npx / npm link CLI verbs were broken — resolve the bin path through symlinks. npm and npx expose career-ops-ui as a symlink under node_modules/.bin/, where the old dirname "${BASH_SOURCE[0]}" resolved to .bin instead of the package root — so npx career-ops-ui init ran node node_modules/scripts/init.mjs and crashed with MODULE_NOT_FOUND (local runs after npm install were unaffected, which hid the bug). Both bin/career-ops-ui.sh and bin/start.sh now canonicalize SCRIPT_DIR through the symlink chain (readlink loop + cd -P), so every verb works from the repo, via npm link, and via npx. Adds a regression lock in tests/sh-files.test.mjs that runs a verb through a .bin-style symlink. Suite 1065/1065.


[1.68.1] — 2026-05-29

fix(scan): per-source fetch timeout 10s → 60s. v1.67.1’s 10s fail-fast also cut off slow-but-alive Ashby boards that just needed more time. Raise the default to one minute so those return. Trade-off: a genuinely dead/hung source now holds a concurrency slot for the full 60s (slower worst-case scan), and the chronic hangers (Perplexity, Supabase, Resend, …) likely still time out — a per-source / lower-Ashby-concurrency fix would address those properly. Override with SCAN_FETCH_TIMEOUT_MS. Suite 1063/1063.


[1.68.0] — 2026-05-29

feat(scan): reworked the result-filter panel — labelled fields, an Apply button, an On-site option, and a working salary filter. Every filter on #/scan is now a labelled field (label above the control, not a placeholder): Search · Work type · Salary from · Salary to · Source · Scope. An explicit Apply button (plus Reset, and Enter in any field) re-runs the filter; an on-page hint explains how it works. The salary range now actually filters — once you set a from/to value, jobs whose listed pay falls outside the range and jobs with no listed salary at all are dropped (overlapping-range match; currency ignored). The Work type filter gains an On-site option alongside Remote / Hybrid / Relocation. New i18n keys ×9; salaryInRange made strict; suite 1063/1063.


[1.67.1] — 2026-05-29

fix(scan): per-source fetch timeout 30s → 10s (fail-fast). v1.67.0’s 30s raise recovered only ~half the slow Ashby boards; the rest (Perplexity, Supabase, Resend, DeepL, Ramp, …) hang regardless of the deadline, so a longer timeout just stalled every scan waiting on dead slots. 10s fails fast on the chronic hangers and keeps scans responsive. Override with SCAN_FETCH_TIMEOUT_MS. Suite 1060/1060.


[1.67.0] — 2026-05-29

feat(scan): salary range filter (from / to) on #/scan, plus a longer per-source fetch timeout. The results table gains two numeric inputs — salary from / to — beside the text and remote filters. Each row’s free-text salary (от 100 000 до 200 000 ₽, 120000-150000 USD, $120K–$150K, …) is parsed to a numeric range and matched with overlapping-range semantics; rows with no published salary are kept, so the filter narrows the list instead of gutting it (comparison is currency-agnostic — no FX conversion). Also raises the per-source scan fetch timeout 15s → 30s (override: SCAN_FETCH_TIMEOUT_MS) — Ashby’s includeCompensation payloads routinely took >15s under 8-way concurrency, so ~30 Ashby boards were timing out every scan. New window.Skills.parseSalaryRange/salaryInRange + i18n ×9; 13 new tests; suite 1060/1060.


[1.66.0] — 2026-05-28

feat(scan): RU sources now walk ALL result pages, not just the first. hh.ru, Habr Career and Trudvsem each only paged the first ~50 hits per query; they now follow pagination to the end — &page=N for hh.ru/Habr, offset/meta.total for Trudvsem — deduping across pages and stopping when a page adds nothing new (or at a 50-page safety cap). A query like “Backend разработчик” now returns the full result set instead of one page (e.g. hh.ru PHP 17 → 55+ across 3 pages; Trudvsem returns all 72). Per-page fetches keep the existing timeout + AbortSignal. 4 new tests; suite 1045/1045.


[1.65.0] — 2026-05-28

feat(scan): hh.ru is now scraped from its public website instead of the JSON API — works from any IP, no proxy. api.hh.ru started returning a bare 403 forbidden to every programmatic client regardless of IP (US, RU datacenter, RU residential, RU mobile) or User-Agent — an edge anti-bot block, not a documented API error. The website (hh.ru/search/vacancy) still serves full server-rendered results to any browser-like client, so the adapter now parses that HTML (like Habr Career). Removes the HH_PROXY env added in 1.64.0 and the undici dependency — no proxy, key, or User-Agent setup needed. Tests rewritten for the HTML parser; suite 1041/1041.


[1.64.0] — 2026-05-27

feat(scan): route the hh.ru request through a Russian proxy via HH_PROXY. hh.ru geo-blocks its API by IP, not User-Agent — so HH_USER_AGENT alone never lifted a 403 from a non-RU exit node. Set HH_PROXY to a Russian HTTP/HTTPS proxy URL (e.g. http://user:pass@ru-host:port) and only the hh.ru request is routed through it; every other source keeps its direct connection. Built on undici’s ProxyAgent (new runtime dep); the dispatcher is omitted entirely when HH_PROXY is unset, and a changed value is picked up on restart. 3 new tests; suite 1041/1041.


[1.63.2] — 2026-05-27

feat(scan): live % progress + per-source detail in the #/scan console. The progress bar is now determinate — scanners emit progress events (EN: per company; RU: per query) forwarded over SSE, and the bar fills with a live “Scanning… NN%” label (animated indeterminate stripe only until the first event). Each source’s first failure (timeout / 403 / network) is now logged to the console in detail (e.g. ⚠ hh timed out: …), then repeats are suppressed. 1 new test; suite 1040/1040.


[1.63.1] — 2026-05-27

style(scan): make the #/scan progress bar more prominent. Wrapped the in-flight indicator with a visible “Scanning…” caption and bumped the bar to 8px (was a thin 4px) so it’s clearly noticeable while a scan runs. No behavior change.


[1.63.0] — 2026-05-27

feat(scan): per-request fetch timeout + #/scan progress bar. Scanner source requests had no deadline, so a stalled upstream (e.g. api.hh.ru from a blocked IP) could hang the whole scan. A new server/lib/fetch-timeout.mjs wraps the scanners’ fetchImpl (makeTimeoutFetch, default 15s, override via SCAN_FETCH_TIMEOUT_MS) so every source request has a hard deadline — a timed-out source is recorded as a non-fatal error and the scan continues (Habr keeps working even when hh.ru is unreachable). The #/scan page also shows an indeterminate progress bar while a scan is in flight (localized scan.progress across all 9 locales). 7 new tests; suite 1039/1039.


[1.62.3] — 2026-05-27

docs: clarify install (career-ops-ui runs inside career-ops/web-ui/) + init troubleshooting, across all 9 locales. Rewrote the install section into Option 1 (one curl) / Option 2 (clone the UI inside an existing career-ops project as web-ui) + CLI verbs + provider setup + a Troubleshooting init block — addressing the common confusion of cloning career-ops-ui standalone and running init without the parent project. Also added the nested-layout note to the /help §1 Setup section and summarized the whole v1.62.* line in the README highlight. Docs-only; no code change.


[1.62.2] — 2026-05-27

fix(help): #/help filter is now full-text (finds H3 subsections like RSS). The help-page search/TOC filter previously matched only H2 section titles, so the v1.62.x RSS docs (an H3 under §5 Portals & sources) weren’t findable. Each section’s body text is now indexed into the filter, so searching e.g. “RSS” surfaces §5. Pure client-side; no API change.


[1.62.1] — 2026-05-27

feat(scan): RSS in the source filter + RSS location fix. The #/scan source-filter dropdown now lists RSS (added to server/lib/sources/registry.mjs + the SPA fallback list), so RSS-board results (LaraJobs, WeWorkRemotely, …) are filterable like any ATS source. The RSS adapter no longer maps the feed <category> tag onto location — non-location tags there made location_filter wrongly drop remote roles; location is now empty so feeds pass location filtering. Scan-button tooltips/labels and the source-list i18n string (dash.quick.scanSub) updated across all 9 locales to include Workable / SmartRecruiters / Workday / RSS. i18n snapshot + scan-sources endpoint test (6 → 7 EN sources) updated.


[1.62.0] — 2026-05-27

feat(scan): generic RSS adapter for non-ATS job boards. A new rss portal adapter (server/lib/portals/adapters/rss.mjs + server/lib/sources/rss.mjs) lets the scanner pull jobs from any RSS feed — LaraJobs, WeWorkRemotely, RemoteOK, golangprojects and other boards outside Greenhouse/Ashby/Lever. Zero new dependencies: feed parsing is regex-based with CDATA + HTML-entity support (titles/companies are tag-stripped, astral code points decoded safely). Activated per-company via provider: rss / rss: / feed_url: in portals.yml, so it never intercepts ATS-matched companies. ALL_ADAPTERS grows 6 → 7. 29 new tests; documented across all 9 README locales.


[1.61.1] — 2026-05-22

fix(i18n): localize the theme-toggle title + aria-label across all 9 locales (MINOR-001). The dark/light theme button (#theme-toggle) hardcoded title="Toggle theme" and aria-label="Toggle theme" in index.html — the tooltip and screen-reader text never translated, on any locale. A new top.themeToggle key + a data-i18n-title handler in applyI18n() (mirroring the v1.58.15 search-aria-label fix) localize both attributes on boot and on every language switch. Locked by tests/playwright-theme-toggle-i18n.mjs (9 locales + runtime-switch) and two static guards in tests/qa-report-fixes.test.mjs. The lone LOW finding from the v1.61.0 French sign-off. (MINOR-001)


[1.61.0] — 2026-05-22

feat(i18n): add French as the 9th UI language. New per-locale dictionary public/js/lib/locales/i18n-dict.fr.js (window.__I18N_DICT_FR), at full 668-key parity with English; new help bundle docs/help/fr.md (19 H2 / 73 H3, exact structural parity with en). fr is registered in the language switcher and browser auto-detect (i18n.js), the assembler (i18n-dict.js), index.html (a <script> tag before the assembler), the test snapshot, and every test locale list. The initial translation table came from PR #9 (community contribution). No logic change: t() and every view are unchanged. 1001 / 1001 unit tests; the Playwright locale-sweep grows to 9 subtests. (FR-LOCALE)


[1.60.0] — 2026-05-22

refactor(i18n): split the 8-column megafile into per-locale files (I18N-SPLIT). The translation dictionary lived in one 849-line public/js/lib/i18n-dict.js (key → { en, es, … } across 8 locales). It is now one file per localepublic/js/lib/locales/i18n-dict.{en,es,pt-BR,ko,ja,ru,zh-CN,zh-TW}.js (each assigns window.__I18N_DICT_<LANG> = { key: string }) plus a shared i18n-dict.aliases.js — so a translator edits a single language in isolation (the i18next / OpenWA layout). i18n-dict.js is now a small assembler that merges the per-locale tables back into the exact same window.__I18N_DICT shape, so t(), every view, and every call-site are byte-for-byte unchanged. Everything loads synchronously via <script src>no build step, no runtime fetch (both hard rules preserved). A captured snapshot proves the migration is lossless (assembled dict ≡ pre-split dict, 678 keys). Tooling + tests made split-aware via a shared tests/helpers/i18n-vm.mjs: tools/i18n-audit.mjs, the CI inline coverage check (which had silently become a no-op against the post-v1.23 split — it now actually loads the dictionary), and ~25 dictionary tests. New tests/i18n-locale-files.test.mjs (snapshot equality · per-locale key parity · alias integrity · index.html load order) and tests/playwright-locale-sweep.mjs (every page × 8 locales renders + localizes in real Chromium). 994 → 1000 unit · 62 → 70 Playwright. No user-facing behaviour change. (I18N-SPLIT)


[1.59.13] — 2026-05-21

fix(i18n): collapse TRUE duplicate keys via @alias + final personal-data purge (pre-fr, follow-up to v1.59.12). Two parts. (1) Personal data: swept the WHOLE repo, not just the dict — the maintainer’s real name was used as incidental test-fixture data (tests/health-doctor-unify.test.mjs, tests/llm-output.test.mjs) and in archived QA reports; all replaced with neutral examples (Jane Doe). LICENSE copyright + package.json author switched from the real legal name to the public Fighter90 handle. Repo now greps clean for the real name/email everywhere. (2) Duplicate keys (I18N-CL3, properly): added an @alias mechanism to i18n.t() — a key { '@alias': 'x.y' } resolves to a canonical key. Collapsed the 10 keys that are byte-identical across all 8 locales (nav.help/dash.quick.helpCtahelp.title; nav.cvcv.title; nav.health/dash.quick.healthCtahealth.title; nav.reports/dash.reports/dash.quick.reportsCtarep.title; nav.applyapply.title; nav.interviewPrepinterviewPrep.title). The contributor’s own nav.config/config.title example is deliberately NOT aliased — they diverge in Spanish (short sidebar Configuración vs full title Configuración de la aplicación); aliasing would change a rendered label. Coverage test, CI inline check, and tools/i18n-audit.mjs are all alias-aware (skip alias keys for parity, enforce every target exists, resolve before dup-counting). New tests/i18n-alias.test.mjs (3 cases) locks integrity + behaviour. 991 → 994 unit. Reduces the French translator’s surface — true dupes are now translated once. (I18N-CL3, personal-data-sweep)


[1.59.12] — 2026-05-21

fix(i18n): i18n-dict.js cleanup — pre-fr ship (I18N-CL1, I18N-CL2, I18N-CL4). External French-locale contributor audit. I18N-CL1 (privacy): training.coursePh carried a vendor-specific cert name across all 8 locales — replaced with a neutral generic placeholder (Cloud architecture certification + native equivalents). I18N-CL2 (hygiene): the followup.lastPh dict fallback was a frozen 2026-04-21 literal — replaced with a localized format hint (YYYY-MM-DD / AAAA-MM-DD / ГГГГ-ММ-ДД); the live dynamic today − 14d placeholder (U-3, v1.58.23) in mode-page.js was already working and is now lock-tested. I18N-CL4 (audit): new tools/i18n-audit.mjs + npm run audit:i18n, wired into CI — hard-fails on personal data, locale-parity gaps, empty values, and bare-calendar-date placeholders. I18N-CL3 (decision): the ~50 duplicate-value key groups (e.g. nav.scan / scan.btnRun / scan.col.company) are intentional — distinct UI roles that non-English locales translate differently; deduping them would remove i18n flexibility, so they are reported as informational warnings, not failures (documented in the dict header). 3 new tests (988 → 991 unit). Unblocks the upcoming French locale PR. (I18N-CL1, I18N-CL2, I18N-CL4)


[1.59.11] — 2026-05-21

fix(test): v1.59.11 — e2e-comprehensive 12-case CI failure root-caused and closed. Twelve cases (Pipeline · Activity · Health · 7 Mode pages · 404 · Profile) had been failing on every CI run going back to v1.58.x because page.goto(baseUrl + '/#/X') is a no-op for hash-only URL changes in Playwright. Once the CV-save step set the hash to #/cv, every subsequent goto to a hash route silently kept the page on #/cv — Activity/Health/Mode/Profile selectors never matched, and the 4 ‘pass’ results in between were vacuous (their assertions found CV’s elements). Fix in tests/e2e-comprehensive.mjs: new goRoute(hash) helper that bounces through about:blank before each goto, forcing a real navigation and SPA re-bootstrap. All 17 page.goto(${baseUrl}/#/…) call sites replaced. Diagnostic instrumentation (E2E_DUMP_ON_FAIL=1 env var) added for future investigations. Result: 23 / 23 cases green locally · 988 / 988 unit · 20 / 20 smoke e2e. (e2e-harness-r1)


[1.59.10] — 2026-05-21

fix(api): NEW-F1-sub-r1 (v1.59.10) — un-encoded ../ traversal guard hoisted above all /api route registrations. v1.59.8 added a req.originalUrl.includes('..') middleware, but it was placed AFTER app.all('/api/*', JSON-404) AND AFTER all route handlers — by which time Express had already normalised the URL (collapsing .. segments). /api/jds/../../../etc/passwd was rewritten to /etc/passwd and fell through to the SPA static handler (200 OK on index.html). v1.59.10 hoists the guard to the TOP of server/index.mjs createApp() (above every register*Routes(app) call) so it inspects the verbatim request URL before any normalisation. Pattern: /^\/api(\/|$)/.test(req.originalUrl) && /\.\.\//.test(req.originalUrl). New tests/api-path-traversal.test.mjs — 6 cases driving the real http module (Node’s fetch normalises .. client-side, so we drop to http.request with a verbatim path). Unknown /api/* paths still return {error: 'unknown api'} from the existing app.all fallback. 982 → 988 unit. (NEW-F1-sub-r1)


[1.59.9] — 2026-05-21

fix(ux): UX-A5-r4 (v1.59.9) — Help TOC scroll-spy debug marker + behavioural lock-test. Sixth-cycle closure: previous five attempts (v1.58.45 / v1.58.52 / v1.59.0 / v1.59.3 / v1.59.8) all shipped with passing static tests but the bug stayed open because the tests asserted source-shape, not behaviour. v1.59.9 fixes the gap: (1) <body data-toc-spy="active"> debug marker — single selector any tester can use to answer “is the spy alive?” without needing to scroll; (2) synchronous initial paint at mount tail covers the router-pre-paints-view case; (3) double-rAF re-compute covers the route-handler-returns-before-router-appends case; (4) resize listener subscribed so viewport-flip mid-scroll re-paints; (5) cleanup removes BOTH listeners and the marker on hashchange. public/js/views/help.js algorithm: linear scan with else break; short-circuit (O(active-index) per scroll event). New tests/help-toc-spy-behavior.test.mjs runs the algorithm against 6 synthetic-geometry scenarios + 1 algorithm-parity check against help.js source — the test fails if the algorithm regresses, before any browser run. 973 → 982 unit. (UX-A5-r4)


[1.59.8] — 2026-05-21

fix(ux+api): v1.59.8 — UX-A5-r3 + NEW-F1-sub (HIGH + LOW bundled per FIX-PROMPT-FINAL-CONSOLIDATED). Doctrine-exception release: the 2026-05-20 FINAL REGRESSION-v1.59.7 report explicitly recommended bundling these two. UX-A5-r3 (HIGH) — after 4 ship attempts with IntersectionObserver all failed live verification, public/js/views/help.js replaces the IO entirely with a plain scroll listener. Root cause: with rootMargin -20%/-55% the trigger band ended at 45% of viewport, but scrollIntoView({block:‘center’}) lands the target at 50% — JUST below the band end. The new scroll listener probes absolute heading positions every rAF, computes the heading whose top is at-or-above 30% of viewport, applies .toc-current — no band, no race, no mount-order gotcha. NEW-F1-sub (LOW) — server/index.mjs adds a late middleware that inspects req.originalUrl (not normalised req.url) and bounces any /api-prefixed request whose raw URL contains .. as 404 JSON {error: 'invalid path'}. New UX-A5-r3 lock-test + NEW-F1-sub static + behavioural guards in tests/. 971 → 973 unit. (UX-A5-r3 · NEW-F1-sub)


[1.59.7] — 2026-05-20

fix(api): NEW-D3-cache (v1.59.7) — GET /api/cv sends Cache-Control: no-store. Matches the SPA-shell policy (W-001 / v1.54.7). cv.md is the user-edited primary artifact; a stale browser cache or intermediary proxy could surface yesterday’s text and trick the editor into saving over the live version. No ETag dance — the file is small and the GET is rare. New test in tests/api-404-json.test.mjs (the suite is the in-process server harness; named for its first test). 970 → 971 unit. (NEW-D3-cache)


[1.59.6] — 2026-05-20

feat(a11y): NEW-D2-motion (v1.59.6) — honour prefers-reduced-motion: reduce. New @media (prefers-reduced-motion: reduce) block in public/css/app.css neutralizes animation-duration + transition-duration to 0.01 ms and forces scroll-behavior: auto (so the Help TOC click-to-scroll lands instantly). WCAG 2.3.3 (AAA) — users with vestibular disorders, motion sensitivity, or simply OS-level reduced-motion preference now get an animation-free UI. Static guard in tests/qa-report-fixes.test.mjs. 969 → 970 unit. (NEW-D2-motion)


[1.59.5] — 2026-05-20

fix(api): NEW-F1 (v1.59.5) — unknown /api/* returns JSON 404 on every verb. Pre-fix app.get("/api/*", …) was GET-only; POST / PUT / DELETE to an unknown api path fell through to the SPA catch-all and returned an HTML 404, breaking SPA clients that do try { res.json() } catch {}. Changed to app.all("/api/*", …) in server/index.mjs. New test suite tests/api-404-json.test.mjs probes GET / POST / PUT / DELETE on /api/no-such-endpoint plus an unknown :name under a real handler — 5 new tests, all green. 964 → 969 unit. (NEW-F1)


[1.59.4] — 2026-05-20

fix(ui): NEW-OR1 (v1.59.4) — #/config API-keys summary chip is race-safe. User-reported transient Keys: 0 / 5 flash during Save flows. The previous refreshApiSummary() cleared <span> children before awaiting the network fetch; a concurrent providers-changed event could observe the empty state. New implementation in public/js/views/config.js:\n\n1. Build new <span> nodes before any DOM mutation.\n2. Atomic swap via apiSummary.replaceChildren(newActive, newCount) — chip never blanks mid-update.\n3. inFlight token counter drops stale resolves when a newer refresh starts.\n4. lastGoodSt cache preserves the last known state when fetch returns null (network blip, server reload), so the chip never collapses to 0 / 5 on a transient empty response.\n\nLock-test in tests/qa-report-fixes.test.mjs asserts all four invariants. 963 → 964 unit. (NEW-OR1)


[1.59.3] — 2026-05-20

fix(ux): UX-A5-r2 (v1.59.3) — Help TOC scroll-spy harden (third-pass fix). Two real issues persisted after v1.58.52: (1) rootMargin: "-30% 0% -60% 0%" left only a 10% visible band so fast scroll could skip the trigger zone entirely and no IntersectionObserver entry ever fired; (2) no initial-state computation, so a freshly-loaded #/help with zero scroll showed zero highlights even though section 1 was visibly active. public/js/views/help.js: widened rootMargin to -20% 0% -55% 0% (25% band), made root: null explicit, extracted applyCurrent(id) so the same code path serves both observer callbacks and the initial-state pass, and on mount picks the heading closest to 20% from top of viewport. 962 → 963 unit. (UX-A5-r2)


[1.59.2] — 2026-05-20

fix(ui): v1.59.2 — UX-A9 / UX-A3 chip count + overlap (post-v1.59.1 hotfix). Three real bugs in the v1.58.62 / v1.58.55 chips, user-reported on the staging surface:\n\n1. Always Keys: 0 / 5. /api/status/providers returns keysConfigured as an array of provider names; pre-fix typeof === number was always false. Now uses Array.isArray(st.keysConfigured) ? st.keysConfigured.length : 0.\n2. Lowercase provider name. Server returns anthropic (not claudeLLM_PROVIDER env value vs resolved provider name); the NAME map was keyed by claude: so it always fell through to the lowercase fallback. Re-keyed {anthropic, gemini, openai, qwen, openrouter} in both public/js/views/config.js and public/js/views/dashboard.js.\n3. Sticky chip overlapped other elements. .api-keys__summary had position: sticky + z-index: 5 and created a stacking context that floated above the tablist + page header on scroll. Dropped sticky — the chip is at the top of a short panel already. Lock-test updated to forbid the sticky position. (No version bump alone — counts on existing UX-A3 / UX-A9 lock-tests.) (post-cycle hotfix)


[1.59.1] — 2026-05-20

fix(test): v1.59.1 — NEW-D1 i18n-no-latin-leaks guard accepts UX-A11 polished ES copy. During final verification of the v1.59.0 ship I caught a regression: the v1.58.37 NEW-D1 static guard asserted pipe.title[es] must match /vacant|vaca/i (locking the old Pipeline de vacantes). UX-A11 (v1.58.64) refined that to Pipeline de candidaturas (candidate-side perspective). Fix: relax the regex to /vacant|vaca|candidatur/i so either contextualizing noun passes. No production-code change. 962 unit tests, 0 fail.


[1.59.0] — 2026-05-20

feat(ui): UX-A14 (v1.59.0) — Mobile (≤ 420 px) media-query pass. Closes the long-standing mobile audit. Five focused fixes inside a single @media (max-width: 420px) block in public/css/app.css:\n\n1. .card-row (4 dashboard metric cards) collapses to 1-up below 420 px (was a 900+ px horizontal scroll).\n2. .dash-hero-cta stacks vertically with full-width buttons (was side-by-side wrap-mid-label on iPhone SE).\n3. .page-header stacks H1+subtitle above the action buttons row (was right-side overflow on 360 px).\n4. .qa-grid minmax floor drops from 220 px to 160 px so tiles fit two-up on 360–390 px viewports.\n5. .api-keys__summary chips tighten horizontal padding for breathing room.\n\nRegression-lock test in tests/qa-report-fixes.test.mjs. 961 → 962 unit. (UX-A14)


[1.58.65] — 2026-05-20

test(ui): UX-A2 (v1.58.65) — lock-test on Modes structured field-form. The 5-field structured form for #/config → Modes was already shipped in v1.54.3 (canonical Target Roles / Adaptive Framing / Exit Narrative / Comp Targets / Location Policy + repeatable line-inputs for list-kind fields + × remove + add-row affordance + tagged {mode: sections|markdown} collect()). The UX-A2 audit assumed it was missing because all 5 sections initially appeared as <textarea> (an artifact of the field-form scaffolding pass before file load). This regression-lock test in tests/qa-report-fixes.test.mjs prevents a future PR from collapsing the field-form back to raw markdown. No code change. 960 → 961 unit. (UX-A2)


[1.58.64] — 2026-05-20

fix(i18n): UX-A11 (v1.58.64) — es / pt-BR copy polish: English loanwords replaced. eval.subtitle (es) now uses ajuste del CV / Puntaje / cabecera / informe; pt-BR uses aderência do CV / Pontuação / cabeçalho / relatório. pipe.title (es) refined to Pipeline de candidaturas (candidate-side perspective). pt-BR vagas retained. 959 → 960 unit. (UX-A11)


[1.58.63] — 2026-05-20

fix(ui): UX-A15 (v1.58.63) — Dashboard Pipeline tile gets visual primary weight. The Quick-actions grid previously gave every tile equal weight; the highest-frequency action (Pipeline) didn’t draw the eye. The qa() helper in public/js/views/dashboard.js now accepts a 7th primary flag; the Pipeline tile is the only one passing true so it gets a subtle accent border + larger icon + bolder label via .qa-tile--primary (public/css/app.css). Tile order, copy, click target, and accessibility name are unchanged. Lock-test in tests/qa-report-fixes.test.mjs. 958 → 959 unit. (UX-A15)


[1.58.62] — 2026-05-20

feat(ui): UX-A9 (v1.58.62) — #/config → API keys panel sticky summary chip. Before this release, with 5 provider key sections stacked vertically on the API-keys tab, the user had to scroll to know which provider the OR-fallback resolves to or how many keys were configured. public/js/views/config.js now renders a position: sticky .api-keys__summary at the top of the apiPanel showing Active: <provider> + Keys: <count>/5. Reuses /api/status/providers (no new API surface). Refreshes on providers-changed (after Save). Two new i18n keys (config.activeProvider, config.keysConfiguredPrefix) × 8 locales; one CSS rule (.api-keys__summary); static guard in tests/qa-report-fixes.test.mjs. 957 → 958 unit. (UX-A9)


[1.58.61] — 2026-05-20

docs(readme): UX-A8 (v1.58.61) — first-run cleanup section added across all 8 READMEs. Fresh clones of career-ops include two QA fixture URLs (example.com/qa-fixture-*) in data/pipeline.md so the test suite can run hermetically; the README never documented this so first-time users mistook them for real jobs. New ## First run — clean state section (mirrored in es / pt-BR / ko / ja / ru / zh-CN / zh-TW) instructs make clean-test-fixtures && npm start before the first scan. Lock-test in tests/qa-report-fixes.test.mjs verifies all 8 READMEs reference make clean-test-fixtures and qa-fixture-*. 956 → 957 unit. (UX-A8)


[1.58.60] — 2026-05-20

feat(ui): UX-A12 (v1.58.60) — Notifications drawer Clear all + per-entry dismiss. The v1.58.34 notifications journal capped at 50 entries but offered no manual purge. New UI.clearToastHistory() and UI.dismissToastHistory(ts) in public/js/api.js mutate toastHistory in place and notify subscribers with sentinel events ({cleared: true} / {dismissed: ts}); the drawer subscriber in public/js/app.js detects them, re-renders, and — critically — does NOT bump the unread counter on purges. Drawer head now carries a Clear all button (auto-hidden when the journal is empty); every .notif-item carries a × dismiss button. Three new i18n keys (notif.clearAll, notif.clearAllAria, notif.dismiss) × 8 locales; CSS for both controls; lock-test in tests/qa-report-fixes.test.mjs. 955 → 956 unit. (UX-A12)


[1.58.59] — 2026-05-20

feat(ui): UX-A13 (v1.58.59) — actionable “Fix →” CTA on failing #/health rows. Pre-fix the 21 health rows showed status only; a user with Profile customized: FAIL had to guess where to go. public/js/views/health.js now renders a small ghost-button Fix → next to the badge on failing/optional rows. Mapped targets: Profile customized#/config?tab=profile, cv.md non-empty#/cv, portals.yml present#/config?tab=portals, data/applications.md#/tracker. Any *_API_KEY or LLM_PROVIDER* row routes to #/config?tab=api-keys via regex fallback. Unmapped failures stay action-less (no dead-end CTAs). Two new i18n keys (health.fix, health.fixAria) × 8 locales. 954 → 955 unit. (UX-A13)


[1.58.58] — 2026-05-20

fix(ux): UX-A10 (v1.58.58) — guard #/cv against leaving with unsaved buffer. Pre-fix, navigating away from #/cv with unsaved edits silently dropped the buffer. public/js/views/cv.js now registers a beforeunload listener (browser-close confirm — generic dialog per modern browser policy) and a hashchange listener (SPA-internal nav prompts via localized window.confirm() and rewinds the hash if the user cancels). cvDirty lives in the save-button IIFE closure; cleanup self-detaches when the hash leaves #/cv (M-1 discipline). One new i18n key (cv.unsavedConfirm) × 8 locales. 953 → 954 unit. (UX-A10)


[1.58.57] — 2026-05-20

test(ui): UX-A7 (v1.58.57) — lock-test on the cost-line auto-refresh contract. v1.58.41 (UX-D-I) wired UI.providerCostHint to refresh when the user changes LLM_PROVIDER mid-session, but the contract had no static guard — any of the three pieces (config.js dispatch, api.js subscribe, advisor-view call site) could regress silently and the cost line would lie. New regression test in tests/qa-report-fixes.test.mjs locks all three: (1) config.js Save handler dispatches providers-changed, (2) UI.providerCostHint subscribes via document.addEventListener, (3) all 4 advisor views (#/deep, #/evaluate, #/auto, #/<mode>) call UI.providerCostHint(t). 952 → 953 unit. (UX-A7)


[1.58.56] — 2026-05-20

fix(a11y): UX-A4 (v1.58.56) — .lang-btn meets WCAG 2.5.8 minimum touch-target. Pre-fix the language buttons in the sidebar footer measured 23–25 px tall × 47–72 px wide (below the 24×24 WCAG 2.5.8 / WCAG 2.2 AA floor). public/css/app.css now declares min-height: 28px + min-width: 28px + padding: 6px 10px on .lang-btn — every locale label clears the floor with comfortable margin. Sidebar grows by 6 px total (negligible). Lock-test in tests/qa-report-fixes.test.mjs. 951 → 952 unit. (UX-A4)


[1.58.55] — 2026-05-20

feat(ui): UX-A3 (v1.58.55) — Dashboard active-provider chip. Above-the-fold chip in public/js/views/dashboard.js hero now surfaces whether the OR-model fell through to a live provider (e.g. ⚡ Live evals: Anthropic claude-sonnet-4-6) or whether the user is in 📋 Manual prompt mode (no API key set). Re-fetches on providers-changed (dispatched by #/config save) and on visibilitychange (cross-tab refocus). Reuses the existing /api/status/providers endpoint (no new API surface). Two i18n keys × 8 locales (dash.provider.live, dash.provider.manual); one CSS rule (.dash-chip--provider). Lifecycle cleanup on hashchange away from #/dashboard so listeners don’t stack across navigation (M-1 discipline). 950 → 951 unit. (UX-A3)


[1.58.54] — 2026-05-20

fix(ux): UX-A1 (v1.58.54) — defensive Deep-brief structure warning. The canonical Deep-research brief promised in career-ops.org/docs has six H2 sections (Company snapshot / Engineering culture / Recent news / Glassdoor / Interview process / Negotiation leverage). When the upstream prompt drifts and the saved brief is meta-narration instead of the final form, public/js/views/deep.js now detects the regression (≥3 of the six H2s missing) and prepends a non-blocking .brief-warning card explaining what the brief should look like and linking to the canonical reference. The root prompt-layer fix lives in the parent project (modes/deep.md, blocked from here); this UI guardrail surfaces the drift instead of silently rendering a degenerate brief. CSS rule added to public/css/app.css; three new i18n keys (deep.briefUnstructured.title / .body / deep.docsLink) cover all 8 locales. New static guard in tests/qa-report-fixes.test.mjs. 949 → 950 unit. (UX-A1)


[1.58.53] — 2026-05-20

fix(ux): UX-A6 (NEW-M4-r1) — every saved-research card flows through a single renderSavedCard() helper. v1.58.51 verification regression observed: one card rendered with no structural children (title+date as a single concatenated text node — software-engineer-generalyesterday) while another rendered with the proper <span> + <time> shape — depending on whether the card was page-load-rendered or runtime-inserted. Fix in public/js/views/deep.js: extracted renderSavedCard(f) that always emits .saved-card__title + .saved-card__date datetime=…. Whatever render path inserts a card (page-load renderArchive, post-Run live, or future code paths) routes through this single helper — the M-4 v1.58.11 flex-gap layout only works with the structural children present. 948 → 949 unit. (UX-A6)


[1.58.52] — 2026-05-20

fix(ux): UX-A5 (NEW-K1) — #/help TOC scroll-spy actually fires now (regression from v1.58.45). v1.58.51 verification regression caught: 18 H2 elements with ids, 18 TOC <a> links, but 0 links ever received .toc-current after scroll. Root cause: v1.58.45 wired the observer inside a setTimeout(0) that fired BEFORE the router appended the view to #content; document.querySelectorAll(".help-article h2[id]") matched nothing. Fix in public/js/views/help.js: new mountTocSpy() observes the synchronously-built headings refs we already hold (no document re-query); deferred via double requestAnimationFrame so it fires after the first paint that includes the mounted view. 947 → 948 unit. (UX-A5)


[1.58.51] — 2026-05-20

chore(docs): v1.58.51 — final housekeeping wrap of the v1.58.37 → v1.58.50 cycle (14 single-fix releases). No code or behavior changes; consolidation of the qa/ tree, doc currency, and the two lessons learned from this cycle. (1) qa/ reorg: all the version-locked source documents — FIX-PROMPT-CONSOLIDATED.md, FIX-PROMPT-FINAL-EXHAUSTIVE.md, FIX-PROMPT-v1.58.37_and_beyond.md, plus 6 root-level session snapshots (MASTER-REGRESSION.md, 2026-05-19-*.md, career-ops_*.md, FIX-PROMPT-v1.58.3.md, FIX-PROMPT-v1.58.4_and_beyond.md) — moved into qa/archive/v158-cycle/. qa/ root now contains exactly the 6 canonical perennial documents: README.md, REGRESSION-FINAL.md, UX-AUDIT-PROMPT.md, FUNCTIONALITY-CHECK.md, DESIGNER-EXPORT-PROMPT.md, G-005-closure-kit.md. (2) qa/REGRESSION-FINAL.md §13 documents every v1.58.37 → v1.58.50 invariant with its lock-test, organized by class (i18n / a11y / UX feedback / orientation / content fidelity / labels / tooling / docs) + 2 new “lessons captured” entries (markdown-bolded regex pitfall; Publish-runs-tests-against-tagged-ref pitfall). (3) qa/UX-AUDIT-PROMPT.md baseline table extended with the 14 new closed-in rows. (4) CLAUDE.md currency lifted (1.58.51, baseline 947/947). (5) .claude/PROJECT-CONTEXT.md repo-state + test baseline lifted. (6) docs/ROADMAP.md chain end now at v1.58.51 / Net 947. (7) README ×8 badges and content sync. Test baseline at v1.58.51: 947 unit (unchanged structurally) · 62 Playwright · 20 smoke · 23 comprehensive E2E. Also fixes the v1.58.48 / v1.58.50 Publish-failure pattern — by tagging at the same commit where ALL tests pass (instead of follow-up patches landing only on main). (housekeeping)


[1.58.50] — 2026-05-20

docs: DOC-1 — qa/REGRESSION-FINAL.md gets §5a documenting server error bodies as English-by-policy. v1.58.36 audit raised NEW-D4: every server 4xx body is English on every locale. Two paths: (A) confirm by-design, document the contract; (B) read Accept-Language and localize. The spec recommended A — closing NEW-D4 as not-a-finding is least disruption and keeps tests stable. New qa/REGRESSION-FINAL.md §5a section explains: server JSON error bodies stay English (debuggability boundary — bug reports paste cleanly, CI fixtures stay stable, server tests don’t need parallel locale strings). The SPA wraps responses with localized chrome (toast colour, U-4 Details summary). Accept-Language is intentionally not read; the SPA-side lang is stripped before validateConfig per the v1.57.2 invariant. v1.59 option B (localized server errors with { error, error_en, code }) is the future gate. 946 → 947 unit. Closes the v1.58.37 → v1.58.50 queue from FIX-PROMPT-FINAL-EXHAUSTIVE.md — 14 single-fix releases shipped, every one CI-green + AI-review LGTM. (DOC-1)


[1.58.49] — 2026-05-20

chore(tooling): TOOL-1 — make clean-test-fixtures + scripts/clean-test-fixtures.mjs. v1.58.36 audit: ${CAREER_OPS_ROOT}/data/pipeline.md had accumulated 1252+ example.com/job/<n> lines from regression runs. Manual cleanup was tedious. New scripts/clean-test-fixtures.mjs reads ${CAREER_OPS_ROOT or ..}/data/pipeline.md, drops every line containing example.com (case-insensitive), preserves real ATS URLs verbatim, prints the count, and exits 0. --dry-run flag prints the would-be result to stdout without touching the file. New Makefile with make clean-test-fixtures and make clean-test-fixtures-dry-run targets. 4 new CI-isolated tests in tests/clean-test-fixtures.test.mjs (uses mkdtempSync for a synthetic parent — no real-file writes). 942 → 946 unit (4 new TOOL-1 cases). (TOOL-1)


[1.58.48] — 2026-05-20

fix(ux/onboarding): UX-D-B — #/dashboard shows a global warning banner when the user is still on the default fixture profile. v1.58.36 audit: /api/health already includes a { name: "Profile customized", ok: false } row (server checks for Acceptance Test / Jane Smith / other template names in server/lib/store.mjs), but the user only saw it after navigating to #/health. Meanwhile every advisor output (Apply / Followup / Contacto / Deep) was addressed to the fixture name — broken first impression. New profileFixtureBanner() in public/js/views/dashboard.js renders a .hero-banner.hero-banner--warning at the top of the route when the check fails, with localized message (onboarding.fixtureWarning × 8) + a CTA button linking to #/config (onboarding.fixProfile × 8). When the user replaces the fixture, /api/health flips ok: true and the next dashboard mount silently omits the banner. New CSS .hero-banner + .hero-banner--warning rules. 941 → 942 unit. (UX-D-B)


[1.58.47] — 2026-05-20

fix(ux/naming): UX-D-C — top-bar Quick scan renamed to Open Scan so the label matches the actual behavior. v1.58.36 audit: “Quick scan” implied an instant scan with sensible defaults, but the click handler at public/js/app.js only navigates to #/scan — no scan starts. Renamed via the top.quickscan i18n key in all 8 locales (en Open Scan / es Abrir Scan / pt-BR Abrir Scan / ko Scan 열기 / ja Scan を開く / ru Открыть Scan / zh-CN 打开 Scan / zh-TW 開啟 Scan). HTML default text in index.html updated to match. Behavior unchanged; only the label honesty improved. 940 → 941 unit. (UX-D-C)


[1.58.46] — 2026-05-20

fix(ux): UX-D-D — #/apply checklist substitutes {company}-{role} with slugs derived from the URL/JD. v1.58.36 audit: the generated checklist’s item 5 read Save filled answers to interview-prep/{company}-{role}.md before submitting. — the literal {company}-{role} placeholders were displayed verbatim and the user had to mentally substitute (or, worse, paste them as-is). New extractSlugs(url, jd) + substitutePlaceholders(text, url, jd) in public/js/views/apply.js: the host whitelist (greenhouse / lever / ashby / workable / smartrecruiters / workday) picks company out of the URL path or subdomain, then derives role from the trailing path slug (stripped of trailing numeric IDs) — or, as fallback, from the JD’s first line. If extraction fails (unknown host / no JD), placeholders become [company] / [role] (square-bracket convention for “fill in”). Substitution runs once before parseChecklist, so the live checklist + Copy-unchecked output stay coherent. 939 → 940 unit. (UX-D-D)


[1.58.45] — 2026-05-20

fix(ux): UX-D-K — #/help TOC scroll-spy highlights the current section. v1.58.36 audit: as the user scrolled the help body, the TOC sidebar (sticky, ~92 H2 sections) didn’t indicate which section they were reading — they had to mentally scan the H2 above the fold against the TOC. New IntersectionObserver in public/js/views/help.js observes every .help-article h2[id] and applies .toc-current to the matching TOC <a> link when the H2 enters the upper-third reading band (rootMargin: "-30% 0% -60% 0%"). CSS public/css/app.css gives .toc-current a brand-red left-border + var(--rausch) color + font-weight: 600 so the active item reads at a glance. The observer is torn down on hashchange next to the existing scroll listener, so no observer leaks when the user leaves #/help. 938 → 939 unit. (UX-D-K)


[1.58.44] — 2026-05-20

fix(ux): UX-D-L — #/deep opened Saved-research brief now has an inline × close button. v1.58.36 audit: clicking a saved-research card on #/deep rendered the brief body inline (Copy / Download / Open in tab / Generate PDF buttons in the header) but the user had no way to close the brief without scrolling away or navigating. New × button in public/js/views/deep.js showResult() header — clears out.innerHTML, mirrors the modal-close pattern (api.js UI.modal × button), keyboard-reachable, aria-label + title from new deep.closeBrief i18n key × 8. 937 → 938 unit. (UX-D-L)


[1.58.43] — 2026-05-20

fix(ux): UX-D-F — #/evaluate empty-JD submit now shows a distinct localized error toast (was “JD too short”). Before the fix, clicking Evaluate with an empty textarea fell through to the existing <50 chars check and yielded JD too short (min 50 chars) — accurate but unhelpful: the real problem is “you typed nothing”, not “you typed too little”. public/js/views/evaluate.js now checks !jd first and surfaces a new eval.emptyJd error toast ("JD is required — paste the full job description", localized × 8) + focuses the textarea so the user can start typing immediately. The pre-existing eval.shortJd toast still fires for 1–49 char input. 936 → 937 unit. (UX-D-F)


[1.58.42] — 2026-05-20

fix(ux): UX-D-J — per-advisor ETA chip parity with #/auto (UX-6 v1.55.4). v1.58.36 audit: only #/auto showed an honest “⏱ ~1–2 min” chip next to its Run button; the other 7 LLM-driven advisor pages (#/evaluate, #/deep, plus the 5 mode pages — project / training / followup / contacto / interview-prep / patterns) ran a similar 10-60s call but gave no time hint. Adds a <span class="advisor-eta">⏱ ~30s</span> (localized via new advisor.eta key × 8) next to UI.providerCostHint(t) in evaluate.js, deep.js, and mode-page.js. CSS extends .auto-eta to cover .advisor-eta with the same styling. The original auto.eta chip stays at ~1–2 min (it’s the only multi-step SSE pipeline). 935 → 936 unit. (UX-D-J)


[1.58.41] — 2026-05-20

fix(ux/truthfulness): UX-D-I — cost-hint now re-fetches on tab focus + on providers-changed event (M-7 v1.58.12 follow-up). v1.58.12 wired UI.providerCostHint(t) to /api/status/providers but only fetched ONCE at node creation. If the user opened #/config in another tab, picked a different provider, and switched back, the cost line would silently lie until they navigated away and back. Fix in public/js/api.js: extract a named refreshCostLine() function and bind it to document.visibilitychange (tab regains focus) + a new providers-changed CustomEvent. The #/config Save handler in public/js/views/config.js dispatches the event after a successful POST, so in-page cost lines (#/auto, #/deep, #/evaluate, mode pages) refresh without a page reload or route re-mount. 934 → 935 unit. (UX-D-I)


[1.58.40] — 2026-05-20

fix(ux/docs): UX-D-H — regression-lock: every visible career-ops.org/docs/... deep-link must be clickable. v1.58.36 audit verified live: every existing such URL in public/js/views/*.js is already inside c("a", { href, target: "_blank", rel: "noopener noreferrer" }, …) (apply.js / batch.js / config.js / reports.js), and every docs/help/*.md reference uses markdown [text](https://github.com/Fighter90/career-ops-ui/blob/main/url). So this release ships only the regression lock: new tests/external-doc-links.test.mjs parses every views/*.js and docs/help/*.md file and fails if a career-ops.org/docs/<path> URL is rendered as plain child text (not inside an <a> create, attribute slot, or markdown link). Bare brand mentions of career-ops.org without /docs/ path are tolerated (e.g. “career-ops.org schema” in prose). 2 new test cases added to the suite. 932 → 934 unit. (UX-D-H)


[1.58.39] — 2026-05-20

fix(ux): NEW-D2 — Dashboard header gets a Refresh button with explicit toast feedback (distinct from connection-banner Refresh of M-9 / v1.58.14). v1.58.36 audit: there was no in-place refresh on #/dashboard — the user had to use the connection-banner Refresh (which does a full location.reload() and loses scroll position) just to update the four metric counters. New ↻ Refresh button in the header in public/js/views/dashboard.js re-fetches /api/dashboard and re-renders the view in place via Router.go("/dashboard") — no page reload, no scroll loss. Toast pipeline: Refreshing… (in-flight) → Dashboard refreshed (success) or localized error. Two new i18n keys × 8 locales: dash.refreshAria (button aria-label) + dash.refreshed (success toast). 931 → 932 unit. (NEW-D2)


[1.58.38] — 2026-05-20

fix(a11y): NEW-D3 (WCAG 4.1.2) — #/tracker search input gets a localized aria-label distinct from its placeholder. v1.58.36 audit: the search input had only placeholder="Search by company / role…" and no aria-label — screen-reader users heard only the generic role “edit text” with no description of the purpose. Per WCAG 4.1.2 (Name, Role, Value) a standalone search input without an associated <label> MUST have an explicit accessible name. Fix in public/js/views/tracker.js: the filterText input now declares type="search" + aria-label: t(track.searchAria, …). New track.searchAria i18n key in all 8 locales — values explicitly different from the placeholder (“Search applications by company name or role title” vs “Search by company / role…”) so the SR doesn’t hear the same string twice. 930 → 931 unit. (NEW-D3)


[1.58.37] — 2026-05-20

fix(i18n): NEW-D1 — #/pipeline H1 localized on es / pt-BR / ru + 2 stray RU title leaks fixed (contacto.title, health.title). v1.58.36 audit caught the H1 of #/pipeline rendering the literal Pipeline on es/pt-BR/ru while their sidebar items (Vacantes / Vagas / Воронка) were properly localized — promise-fidelity gap with the closed v1.58.18 I18N-011 doctrine (page H1 must match the sidebar term). Updated pipe.title in public/js/lib/i18n-dict.js: es → Pipeline de vacantes, pt-BR → Pipeline de vagas, ru → Воронка вакансий. New static guard tests/i18n-no-latin-leaks.test.mjs (parses the consolidated DICT, asserts no Latin-only *.title value on ru/ja/ko/zh-CN/zh-TW outside a small whitelist of proper nouns / acronyms / product names). The guard immediately caught two additional stray RU leaks beyond NEW-D1: contacto.title (was LinkedIn outreachКасания через LinkedIn) and health.title (was HealthСостояние) — both shipped under the same fix. 928 → 930 unit. (NEW-D1)


[1.58.36] — 2026-05-20

chore(docs): v1.58.36 — full housekeeping sweep at the close of the v1.58.x cycle. No code or behavior changes — documentation + qa structure + test baselines synced to reality after 32 single-fix releases. (1) qa/ reorganization: the three version-locked end-to-end regression snapshots (REGRESSION-END-TO-END-v1.58.16/33/35.md) moved into the new qa/archive/v158-cycle/ directory alongside the pre-cycle MASTER regression and the FIX-PROMPT-v1.58.3.md snapshot — qa/v158-regression/ now contains only the active FIX-PROMPT-v1.58.4_and_beyond.md (post-MASTER fix specification, every row marked ✅). (2) qa/REGRESSION-FINAL.md gains §12 documenting every v1.58.x cycle invariant (CSP unconditional, 5 provider rows, :focus-visible ring, drawer behavior, help §18, etc.) with its tests/qa-report-fixes.test.mjs lock per row. (3) qa/UX-AUDIT-PROMPT.md baseline table extended with 30 closed-in v1.58.x rows. (4) docs/architecture/: FRONTEND.md gains the notifications-drawer section + v1.58.24 toast-postfix + v1.58.10 modal-drain notes; OVERVIEW.md links to the drawer; TESTING.md totals lifted to 928 unit / 117 files / 62 Playwright / 20 smoke / 23 comprehensive E2E. (5) CLAUDE.md gains a “Hard-won lessons (v1.58.x cycle)” section with 10 captured traps (the [hidden] cascade trap, the npm test | grep exit-code mask, the cleanLlmMarkdown ≠ sanitizer doctrine, etc.). (6) .github/copilot-instructions.md baseline lifted to 928 unit / 62 Playwright. (7) README ×8 locales: new “Notifications 🔔” row in every page-table + tests/ stale count fixed from 284 unit + 12 Playwright + 23 e2e:full928 + 62 + 23 + 20 (baseline @ v1.58.35). Test baseline unchanged: 928 unit · 62 Playwright · 20 smoke · 23 comprehensive E2E. (housekeeping)


[1.58.35] — 2026-05-20

fix(ui): v1.58.35 — Notifications drawer no longer auto-opens at page load + new help §18 catalogues all notification categories (user-reported). Two bugs in one. (1) Auto-open / never-close: .notif-drawer { display: flex } and .notif-badge { display: inline-flex } (v1.58.34) had the same author-level specificity as the UA [hidden] { display: none } rule, so the explicit display value won and the hidden attribute was a no-op — the drawer was visible on every page load and clicking close did nothing. Fix in public/css/app.css: explicit .notif-drawer[hidden] { display: none } + .notif-badge[hidden] { display: none } overrides. The drawer is now ONLY opened by clicking the bell (or Enter/Space when keyboard-focused); a static-guard test asserts there is exactly one open() call site in app.js (the bell click ternary), so future edits can’t silently introduce another auto-open path. (2) Undocumented categories: new §18 Notifications section in all 8 help bundles (docs/help/{en,es,ja,ko-KR,pt-BR,ru,zh-CN,zh-TW}.md) — 3 H3 subsections per bundle (Notification categories / What is NOT a notification / Keyboard) catalogue every toast source (Success / Error / Info-progress), what each visual cue means, what’s explicitly NOT a notification (Doctor/verify modals, SSE log lines, spinner-only states), and the keyboard contract. Help-section parity baseline lifted 17 → 18; H3 baseline 70 → 73. 927 → 928 unit. (user-reported)


[1.58.34] — 2026-05-20

feat(ui): v1.58.34 — Notifications drawer ships on top of the v1.58.33 toast-journal capture (closes U-13 completely). v1.58.33 shipped the data shape (in-memory toastHistory cap 50 + UI.getToastHistory()) and explicitly deferred the drawer chrome. This release adds the chrome. UI.onToast(fn) pub/sub layered on top of the capture in public/js/api.js — subscribers receive the entry just appended; the implementation guards every subscriber callback in try/catch so a drawer bug can never break the toast pipeline. Bell button in the top-bar (🔔 + red unread badge), aria-haspopup="dialog" + aria-controls="notif-drawer" + aria-expanded flipping on open/close. Right-slide <aside role="dialog"> with localized title (notif.title), empty-state (notif.empty), and per-entry items showing localized toLocaleTimeString + message + (when present) the technical postfix from U-4 (v1.58.24). Newest first; oldest dropped as the U-13 cap bites. Esc + Close button + clicking the bell again all close the drawer; focus restores to the bell (WAI-ARIA APG drawer pattern). 4 new i18n keys (notif.title, notif.empty, notif.bellAria, notif.closeAria) × 8 locales. CSS: .notif-bell, .notif-badge, .notif-drawer, .notif-drawer__head, .notif-item rules. 926 → 927 unit; Playwright 61/61 unchanged. (U-13 follow-up)


[1.58.33] — 2026-05-20

fix(ux): U-13 + U-14 + U-15 — toast journal capture + page-header spacing safety net + CV editor dirty-state indicator (3 closing UX items batched). Final v1.58.x release. U-13 (toast journal): public/js/api.js — every UI.toast() call pushes { ts, type, message, detail } into an in-memory toastHistory capped at 50; exposed via the new UI.getToastHistory() API. Toasts dwell 3.5-20 s and then vanish; the journal lets future drawer / panel UIs (deferred to a follow-up release) re-surface any missed message. U-14 (page-header spacing safety net): new .page-header h1 + p { margin-block-start: var(--space-2); color: var(--foggy); } rule in public/css/app.css so any page that uses raw <h1>+<p> (without the .page-subtitle class) still gets the canonical spacing. U-15 (CV dirty-state): public/js/views/cv.js — Save button gets .btn-dirty class + localized cv.unsaved tooltip whenever the textarea diverges from the last saved baseline; clicking Save re-baselines and clears the flag. Upload path dispatches a synthetic input event so programmatic ta.value = … assignments still trigger the dirty toggle. CSS .btn.btn-dirty paints a var(--rausch-dark) ring + prefix. 925 → 926 unit. (U-13 + U-14 + U-15)


[1.58.32] — 2026-05-20

fix(ux): U-12 — #/help TOC filter input gets a 16ch min-width so KO/JA placeholders never clip. Korean 섹션 필터 and Japanese セクションをフィルター are 5-10% wider than the EN Filter sections. The input already used width: 100% of its .help-toc card; we add a .help-toc__filter class with min-width: 16ch so even if the card narrows, the placeholder still fits in any of the 8 locales. 924 → 925 unit. (U-12)


[1.58.31] — 2026-05-20

fix(ux): U-11 — Tracker Legitimacy column header now carries a localized info chip + tooltip explaining the High/Caution/Suspicious scale. v1.58.3 QA: the badge in each row read High / Caution / Suspicious with no header affordance to learn what they meant. public/js/views/tracker.js <th> now renders the column label + <span class="th-info" tabindex="0" role="img">ⓘ</span> whose title + aria-label come from a new track.col.legitimacy.help i18n key × 8 locales: “Confidence that the posting is real (High / Caution / Suspicious).” CSS .th-info:focus-visible adds the brand ring so the chip is keyboard-reachable per WCAG 2.4.7. Also repairs the v1.58.30 (U-10) regression where the existing #25 destructive buttons have a title hint lock test only checked for track.fixHint but the U-10 branch now uses both track.fixHint and track.fixEmpty. 923 → 924 unit. (U-11 + U-10 follow-up)


[1.58.30] — 2026-05-20

fix(ux): U-10 — Tracker Normalize / Dedup / Merge buttons disabled when data/applications.md is empty. v1.58.3 QA: clicking these buttons on an empty tracker still hit the parent project rewrite endpoints — a no-op the user could not tell was futile. Now public/js/views/tracker.js sets disabled + aria-disabled + a localized tooltip (track.fixEmpty × 8 locales) explaining why (“Add a row to the tracker first — this rewrites data/applications.md and there is nothing to rewrite yet.”). When rows exist, the buttons re-enable and show the existing track.fixHint tooltip. 922 → 923 unit. (U-10)


[1.58.29] — 2026-05-20

fix(ux): U-9 — #/pipeline counter ↔ filter row stacks vertically on narrow viewports. v1.58.3 QA: at ≤ 720 px the In queue: N chip and the filter input fought for horizontal space and pushed the input to a cramped column. New .pipeline-controls class in public/js/views/pipeline.js; CSS rule under @media (max-width: 720px) sets flex-direction: column + stretches #pipe-filter to 100% width. Desktop unchanged. 921 → 922 unit. (U-9)


[1.58.28] — 2026-05-20

fix(ux): U-8 — Generate-prompt block is collapsed by default on the 7 mode pages. The inline <pre> previously ran 1200+ px after the user clicked Generate prompt, pushing Copy + Run-live below the fold. public/js/views/mode-page.js showPrompt() now wraps the <pre> in <details class="prompt-block"> collapsed by default; summary shows Show prompt (N lines) localized via the new prompt.show / prompt.lines keys × 8 locales. Copy / Run-live remain immediately visible. Also fixed a pre-existing v1.58.27 regression where the BUG-007/008 contract test rejected the U-7 reformat (intermediate const stripped between dismissToast/modal); loosened the adjacency regex from s* to [sS]{0,1200}? while still asserting UI.dismissToast() runs before UI.modal(t(…)). 920 → 921 unit. (U-8)


[1.58.27] — 2026-05-20

fix(ux): U-7 — verify-pipeline.mjs ASCII === dividers stripped from the result modal. The script prints =========-runs (≥ 50 chars) between sections; in the 14 px monospace modal the run pushed the body wider than the rest of the SPA needs. Strip in public/js/views/health.js via .replace(/^={10,}$/gm, "") before rendering; whitespace already separates sections. 919 → 920 unit. (U-7)


[1.58.26] — 2026-05-20

fix(ux): U-6 — #/scan “Active companies N/M” chip now explains N vs M via tooltip + aria-label. v1.58.3 QA: the chip read ▸ Active companies 96/80 with no affordance to learn what 96/80 means. Toggle button in public/js/views/scan.js now carries title= (hover tooltip) and aria-label= (screen-reader fallback) sourced from a new scan.activeCo.help i18n key in all 8 locales: “Active: companies currently surfacing results. Total: configured in portals.yml.” 918 → 919 unit. (U-6)


[1.58.25] — 2026-05-20

fix(ux/ia): U-5 — Dashboard CTA dedupe (removed duplicate Open-Pipeline header button + duplicate Scan-all-sources Quick-action tile). v1.58.3 QA flagged 4× Pipeline / 4× Scan entry-points on Dashboard. v1.55.5 promoted the two P0 hero CTAs (✨ Auto-pipeline a URL + 🌐 Scan now); the header 📋 Open Pipeline button and the Quick-action 🌐 Scan all sources tile were then strict duplicates (sidebar already routes to /pipeline; hero already routes to /scan). Removed both in public/js/views/dashboard.js. Hero pair + sidebar remain canonical entry-points. 917 → 918 unit. (U-5)


[1.58.24] — 2026-05-20

fix(ux): U-4 — toast error messages now tuck the “(METHOD /path · HTTP NNN)” postfix into a collapsed <details>. v1.57.1 appended a what/where/why postfix to every API error so opaque “validation failed” became “validation failed — … (POST /api/config · HTTP 400)”. The technical part is required (BUG-006 invariant — must remain reachable in the DOM), but on the toast headline it competes with the human sentence. UI.toast() in public/js/api.js now parses the trailing postfix with TOAST_ENDPOINT_RE and renders it inside a <details class="toast-detail"> with a localized <summary> (toast.details key × 8 locales). Headline stays clean; the technical detail is one click away. New .toast .toast-msg / .toast .toast-detail CSS rules. BUG-006 invariant preserved (postfix still in DOM). 916 → 917 unit. (U-4)


[1.58.23] — 2026-05-20

fix(ux): U-3 — #/followup lastContact placeholder now adapts to today − 14 days (was the frozen ISO 2026-04-21). Static placeholders rot — readers parse them as both a format hint AND a plausible recent example, and the latter drifts into the distant past as time passes. Compute the placeholder at render time in public/js/views/mode-page.js: new Date(); d.setDate(d.getDate() - 14); d.toISOString().slice(0, 10). Special-cased only for cfg.slug === 'followup' && spec.name === 'lastContact'; every other field still pulls its placeholder from i18n. 915 → 916 unit. (U-3)


[1.58.22] — 2026-05-20

fix(ux): U-2 — #/auto H1 no longer wraps to 2 lines because of the leading . Pre-fix the auto.title i18n value was ✨ Auto-pipeline a URL; the emoji participated in line-wrap and pushed the title to a second row at 1280-1600 px widths. Split into separate elements in public/js/views/auto.js: .page-header.page-header--icon (CSS grid, auto 1fr columns) + <span class="page-icon" aria-hidden="true">✨</span> + <h1 class="page-title">{t(auto.title)}</h1> + <p class="page-subtitle">. The emoji now sits on its own column and never participates in line wrap; the H1 wraps freely if needed. auto.title i18n value stripped of its leading in all 8 locales. 914 → 915 unit. (U-2)


[1.58.21] — 2026-05-20

fix(ux): U-1 — #/cv H1 + subtitle now match every other page (supersedes v1.56.0 UX-9 chip by design). v1.56.0 UX-9 demoted the page title to a .cv-breadcrumb chip (lowercase grey cv) so the user’s name in the preview “owned” visual space. v1.58.3 QA confirmed it just reads as a layout bug — the page header looks broken next to #/dashboard/#/help/etc. U-1 promotes public/js/views/cv.js back to <h1 class="page-title"> + visible <p class="page-subtitle"> like every other page; .cv-breadcrumb CSS rule removed. Single-<h1> invariant unchanged (F-V54-A still shifts user # Name h1→h2 in the preview, so this stays the page’s only H1). Lock test tests/cv-breadcrumb.test.mjs (which encoded the now-reversed UX-9 contract) is removed; tests/cv-single-h1.test.mjs updated to assert the new .page-title-only shape. 913 → 914 unit. (U-1)


[1.58.20] — 2026-05-20

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


[1.58.19] — 2026-05-20

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


[1.58.18] — 2026-05-20

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


[1.58.17] — 2026-05-20

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


[1.58.16] — 2026-05-20

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


[1.58.15] — 2026-05-20

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


[1.58.14] — 2026-05-20

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


[1.58.13] — 2026-05-20

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


[1.58.12] — 2026-05-20

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


[1.58.11] — 2026-05-20

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


[1.58.10] — 2026-05-20

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


[1.58.9] — 2026-05-20

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


[1.58.8] — 2026-05-20

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


[1.58.7] — 2026-05-20

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


[1.58.6] — 2026-05-20

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


[1.58.5] — 2026-05-20

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


[1.58.4] — 2026-05-19

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


[1.58.3] — 2026-05-19

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


[1.58.2] — 2026-05-19

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


[1.58.1] — 2026-05-19

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


[1.58.0] — 2026-05-19

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

🐛 Fixes (QA report)

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

✨ Features / UX

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

🧪 Tests

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

Triaged, not changed (with rationale)

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

[1.57.2] — 2026-05-19

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

🐛 Fixes

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

🧪 Tests

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

[1.57.1] — 2026-05-19

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

🐛 Fixes

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

✨ UX

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

🧪 Tests

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

[1.57.0] — 2026-05-19

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

🐛 Fixes

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

✨ Features

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

🧪 Tests

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

[1.56.4] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.56.3] — 2026-05-19

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

🐛 Fixes

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

🧪 Tests

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

[1.56.2] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.56.1] — 2026-05-19

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

🐛 Fixes

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

🧪 Tests

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

[1.56.0] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.8] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.7] — 2026-05-19

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

✨ Features

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

🧪 Tests

  • test: tests/pipeline-virtualize.test.mjs (new, 5 cases, CI-isolated, source-static): a numeric ~1000 threshold gates the path; the ≤threshold branch keeps the simple forEachappendChild; 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.modesDescTargetRolesconfig.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 Targetsrepeatable add/remove labelled line-inputs (one role/angle/comp line per field, + Add line / per-row with aria-label).
    • Exit Narrative / Location Policysingle 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.modesTargetRolesconfig.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: #/configOPENAI_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 gatehelp-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.shhelp 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.shNO_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.jscompany / role inputs had unbound labels; added id + htmlFor (deep-company, deep-role).
    • #31 apply.jsurl / 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 guaranteerouter.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 changerouter.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 linksetupinitdoctorrunhelp), 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 fallbackprovider-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_KEYserver/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 initscripts/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 dispatchersetupbin/setup.sh (existing one-command bootstrap), runbin/start.sh, doctorscripts/doctor.mjs, initscripts/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.mjsdeterministic 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.mjsnpm 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.mjsPUT /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.mjsPUT /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.mjsGET /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).


[1.29.2] — 2026-05-14

Hot-fix: 🌐 Scan with source=both only ran the EN phase. RU phase was silently dropped.

🚑 Critical hot-fix

User-reported symptom: clicking the unified 🌐 Scan button (which calls runScanAll()GET /api/stream/scan?source=both) returned only the EN ATS phase. The console showed ▶ ATS scan (Greenhouse + Ashby + Lever)✓ ATS done · NEW=0 and then stopped. No RU phase output ever appeared, even with all 5 RU adapters listed in russian_portals.sources.

Root causepublic/js/api.js:156 closed the EventSource on the first done event:

if (ev === 'done' || ev === 'error') es.close();

But server/lib/routes/scan.mjs::driveOne emits done once per phase, and the source=both branch drives two phases sequentially. The client closed after the EN done, the server detected res.on('close')AbortController.abort() → the RU phase started but was immediately cancelled.

Fix — multi-phase SSE contract:

  • Server (server/lib/routes/scan.mjs): driveOne now accepts a final param (default true). The done payload carries final: <bool>. The source=both branch passes final: false to the first phase, final: true to the second.
  • Client (public/js/api.js:148-172): stream(...) closes the EventSource on done only when data.final !== false. Backward-compatible: legacy single-phase producers (/api/stream/batch, /api/stream/pdf*, etc.) don’t set final, so the behaviour is unchanged. Close on error remains unconditional.

🧪 Tests

  • test(scan): tests/scan-stream-multi-phase.test.mjs — 11 cases covering both server-emitted SSE contract and client decision logic:
    • SSE contract (6 cases): source=ats → 1 done (final:true); source=regional → 1 done (final:true); source=both → 2 done events with final:false then final:true; source=both → 2 start events in en-scanner/ru-scanner order; static canary that the pre-v1.29.2 unconditional close pattern is gone; static canary that the v1.29.2 data.final !== false guard is present.
    • Functional proof of fix (3 cases): source=both actually emits the RU-phase banner line (proves the body runs, not just empty shells); ru-scanner start arrives AFTER en-scanner done (ordering); dryRun=1 does NOT modify data/pipeline.md (no phase secretly flips writeFiles).
    • Pure-logic close-decision table (1 case, parametrized over 11 inputs): mirrors the api.js branch in JS — covers done with final:false / true / undefined / null / 0 / 'false', plus error with payload/null, plus start / log (never close).
    • Bug-forensics (1 case): simulates the pre-v1.29.2 client by cancelling the response stream after the first done and verifies the server’s res.on('close') abort handler is still intact (documents the pre-fix mechanism for future readers).
  • 547 → 558 unit + acceptance (+11).

🔄 Migration

No user action needed beyond updating to v1.29.2. The next 🌐 Scan will run both phases.

Verification

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

# Manual smoke — both phases should now emit:
$ curl -sN "http://127.0.0.1:4317/api/stream/scan?source=both&dryRun=1" \
    | grep -E '^event:|"final":'
# event: start                                        ← en-scanner
# ...
# event: done                                         ← phase 1 of 2
# data: {"code":0,"counts":{...},"errors":0,"final":false}
# event: start                                        ← ru-scanner
# ...
# event: done                                         ← phase 2 of 2 (final)
# data: {"code":0,"counts":{...},"errors":0,"final":true}

[1.29.1] — 2026-05-14

Detailed user-facing config guide for the 5 RU portals in help-bundle §5, all 8 locales.

📝 Documentation

  • docs(help): §5 "Configuring Russian portals — detailed setup guide" (docs/help/<locale>.md): new ### subsection within Portals & sources covers the end-user config flow:
    • 5-row source-inventory table with auth + geo restrictions per adapter.
    • Step 1 — locate portals.yml + bootstrap from template.
    • Step 2 — full 5-source russian_portals: YAML example.
    • Step 3 — tuning queries, area, per_page, only_remote.
    • Common pitfalls — negative-list collision (with worked example showing the fix).
    • How to disable a single source without losing data.
    • How to verify via 🌐 Scan + the per-source SSE log line shape.
  • Universal YAML/code blocks shared across locales; prose translated for ES / PT-BR / KO / JA / RU / ZH-CN / ZH-TW.
  • §17 (“How to add a new portal”) was the developer flow shipped in v1.29.0; §5 is the user flow shipped in v1.29.1.

🧪 Tests

  • test(help): tests/help-ru-config-section.test.mjs — 7 cases asserting every locale’s §5 contains the 5-source YAML, the negative-list collision fix, the disable-one-source example, the 5 adapter labels in the verify block, the 5-row inventory table, the HH_USER_AGENT env-var reference, and the 17-H2 parity contract held after the edit.
  • 540 → 547 unit + acceptance (+7).

Verification

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

# Manual smoke (after redeploy):
$ for lc in en es pt-BR ko ja ru zh-CN zh-TW; do
    curl -fsS "http://127.0.0.1:4317/api/help/$lc" \
      | python3 -c "import sys,json; print('$lc:', 'YES' if 'trudvsem' in json.load(sys.stdin).get('markdown','') else 'NO')"
  done
# every line ends in "YES" — the §5 expansion mentions trudvsem in every locale.

[1.29.0] — 2026-05-14

Russian-portal scanner expanded from 2 to 5 sources. Source registry + dynamic dropdown. New help-section explaining how to add a 12th.

✨ Features

  • feat(scan): 3 new RU portal adapters — Trudvsem, GetMatch, GeekJob (server/lib/sources/):

    • trudvsem.mjs — Russian government open-data API (opendata.trudvsem.ru/api/v1/vacancies). No auth, no IP gate. Normalizes the documented v1 JSON shape.
    • getmatch.mjs — tech-focused RU HTML board. Defensive regex parser, returns [] on parse miss (never throws on healthy 200).
    • geekjob.mjs — same pattern as GetMatch. Handles article and div-wrapped card variants.
  • feat(scan): source registry — single source of truth for every adapter (server/lib/sources/registry.mjs): one array of { value, label, region, configKey } records, consumed by the scanner dispatcher, the GET /api/scan/sources endpoint, and the SPA’s source-filter dropdown. Adding a 12th adapter = one entry here + one adapter file + one row in RU_DISPATCH. The pre-v1.29 three-place drift (hardcoded dropdown / hardcoded if-chain / hardcoded default) is gone.

  • feat(api): GET /api/scan/sources (server/lib/routes/scan.mjs): returns the canonical source list with Cache-Control: max-age=60. The SPA fetches this on #/scan mount and rebuilds the source-filter dropdown dynamically.

  • feat(scan-ui): dynamic source-filter dropdown (public/js/views/scan.js): on view mount, fetches /api/scan/sources and paints <option> entries. Build-time hardcoded fallback list survives if the endpoint is unreachable. The filter chip in #/scan now lists 11 sources (6 EN ATS + 5 RU).

  • feat(ru-scanner): default = 5 sources, dispatcher loop generalized (server/lib/ru-scanner.mjs):

    • Default russian_portals.sources (the value used when portals.yml omits the array) now pulls from registry.mjs::RU_CONFIG_KEYS — 5 sources, not 2.
    • Pre-v1.29 the dispatcher had two hand-written if (cfg.sources.includes('hh')) / if (cfg.sources.includes('habr')) blocks. v1.29 replaces them with a single loop over RU_DISPATCH that’s keyed by the registry. Adding a sixth source = no scanner-loop edit.

📝 Documentation

  • docs(help): new §17 "How to add a new job-portal source" × 8 locales (docs/help/<locale>.md) — full English step-by-step (adapter template for API + HTML patterns, registry entry, dispatcher wiring, mocked unit test, portals.yml enablement); 7 locale versions with localized prose + universal code blocks + cross-link to the EN canonical text for the full pitfalls table.
  • docs(help): §5 + §7 updated for 5 RU sources × 8 localesrussian_portals.sources example now reads ["hh", "habr", "trudvsem", "getmatch", "geekjob"]; the Source-dropdown description names all 5.
  • Help-bundle section count: 16 → 17 (CI parity contract bumped accordingly).

🧪 Tests

  • test(sources): tests/sources-trudvsem.test.mjs — 6 cases: normalization, удалённо→remote inference, onlyRemote filter, 5xx propagation, empty results = no throw, null-record safety.
  • test(sources): tests/sources-getmatch-geekjob.test.mjs — 11 cases across both HTML scrapers: fixture-driven card extraction, nav-anchor skip, empty/null safety, 5xx propagation, onlyRemote filter.
  • test(scan): tests/scan-sources-endpoint.test.mjs — 4 cases: shape, RU-source list parity (5 entries), EN-source list parity (6 entries), Cache-Control header.
  • test(ru-scanner): tests/ru-scanner.test.mjs — e2e dispatcher test extended to mock all 5 sources.
  • test(canonical-docs): 17-H2 parity contract (tests/canonical-docs-coverage.test.mjs) and tests/help-ui.test.mjs — both lifted 16 → 17.
  • 520 → 540 unit + acceptance (+ 20 new). Playwright 32/32 unchanged.

🔄 Migration

For the new RU adapters to fire on your stand, the parent project’s portals.yml must list them:

russian_portals:
  sources: ["hh", "habr", "trudvsem", "getmatch", "geekjob"]
  area: 113
  per_page: 50
  only_remote: false
  queries:
    - "Senior PHP"
    - "Senior Go"

If your portals.yml has NO russian_portals.sources: line at all, the v1.29.0 default kicks in and all 5 sources run automatically. If sources: IS present (as in the pre-v1.29 setup), it’s used verbatim and you must update it manually — the web-ui never edits parent-project files.

Also note: a global title_filter.negative: ["php"] will neutralize every Senior PHP query. The scanner emits a stderr warning at scan time (collision detector from v1.13). Adjust the negative list if you see “0 hits” but expected results.

Verification

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

$ curl -fsS http://127.0.0.1:4317/api/scan/sources | jq '.sources | length'
11
$ curl -fsS http://127.0.0.1:4317/api/scan/sources | jq '[.sources[] | select(.region=="ru") | .value]'
[ "geekjob", "getmatch", "habr-career", "hh.ru", "trudvsem" ]

[1.28.1] — 2026-05-14

Hot-fix: router 404 on hashes with ?query. HH_USER_AGENT row pruned from health.

🚑 Critical hot-fix

  • fix(router): strip ?query before route lookup (public/js/router.js) — pre-v1.28.1 Router.go('/evaluate?url=…') produced a hash whose first split('/') segment was the whole "evaluate?url=…" literal, which never matched a registered route → __not_found__ (404). Two reported failures had this single root cause:
    • #/pipeline → ▶ button (pipeline.js:145: Router.go('/evaluate?url=' + encodeURIComponent(url))).
    • “App settings → Modes” deep link (settings.js:80: href="#/config?tab=modes"). Fix is one line: hash.split('?')[0] before the route-name split. The view itself continues to parse query strings via window.location.hash.split('?')[1] + URLSearchParams (see evaluate.js, config.js).

🧹 Cleanup

  • fix(health): remove HH_USER_AGENT optional row (server/lib/routes/health.mjs:54) — the row surfaced "unset (hh.ru may 403 from non-RU IPs)" on every Health page render, including for users who never scan hh.ru. The hh.ru adapter falls back to a baked-in UA when the env var is unset; the 403-from-non-RU gate is still documented in docs/help/<locale>.md §16 (troubleshooting) and server/lib/ru-scanner.mjs still emits a stderr hint at scan time. Removing the row reduces dashboard noise without losing any diagnostic.

🧪 Tests

  • test(router): tests/router-query-string.test.mjs — 3 cases: static-source canary (router.js must split off ? before name lookup), explainer-comment canary (the v1.28.1 fix rationale stays in the source), and pure-logic simulation of current() over four representative hashes (#/evaluate?url=…, #/config?tab=modes, #/reports/abc-123, #/dashboard).
  • test(health): tests/health-no-hh-user-agent-row.test.mjs — 2 cases: regression guard that /api/health no longer surfaces HH_USER_AGENT row; sanity that adjacent optional rows survived the prune.
  • 515 → 520 unit + acceptance (+ 5 new). Playwright 32/32 unchanged.

Verification

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

# Manual smoke (after redeploy):
$ open "http://127.0.0.1:4317/#/evaluate?url=https%3A%2F%2Fexample.com%2Fjd"
# (should render the Evaluate view with the URL prefilled — no 404)
$ open "http://127.0.0.1:4317/#/config?tab=modes"
# (should land directly on the Modes tab of App settings — no 404)

[1.28.0] — 2026-05-14

Docs alignment + #/batch --max-retries N UI surface. Closes two open backlog items raised by qa/QA-PROMPT-docs-vs-app.md.

✨ Features

  • feat(batch): surface --max-retries N control on #/batch (Issue #2) — the canonical batch-evaluate-offers guide documents --max-retries N (default 2) but pre-v1.28 the SPA had no way to set it; users were stuck on the runner default.
    • public/js/views/batch.js — new numeric input (1..10), disabled by default, enables only when “Retry failed” is checked. Clears its value when retry is unchecked so an orphaned value can’t leak into the next run.
    • server/lib/routes/batch.mjsGET /api/stream/batch?retry=1&maxRetries=N: parses via parseInt, range-validates 1 ≤ N ≤ 10, silently drops out-of-range/non-integer values (UI is the hard contract; server is defense-in-depth). No-op without --retry-failed.
    • i18n: 2 new keys × 8 locales (batch.maxRetriesLbl, batch.maxRetriesAria) in public/js/lib/i18n-dict.js.

📝 Documentation

  • docs: align AI-assistant list to career-ops.org/docs canonical (Issue #1) — the upstream Quick Start lists Claude Code / Codex / OpenCode / Qwen CLI. Pre-v1.28 we drifted to Claude Code / Codex / Cursor / Gemini CLI / GitHub Copilot CLI, identically across all 8 locales. Resolution: aligned downstream.
    • 8 help-bundles (docs/help/<locale>.md) — intro paragraph + comparison-table row both now match upstream canonical. One-liner appended: “other Claude-compatible CLIs work too via the same slash-command surface”, localized per locale.
    • 8 READMEs (README*.md) — intro paragraph aligned with the same one-liner. The “Multi-CLI” feature bullet (about web-ui’s own CLAUDE.md/AGENTS.md/GEMINI.md shim files, not about career-ops upstream) deliberately retains its wider list (Cursor / Aider / Gemini CLI) since those CLIs do drive our shims.

🧪 Tests

  • test(canonical-docs): AI-list regression canaries — 2 new canaries in tests/canonical-docs-coverage.test.mjs:
    • Every help-bundle + README must contain “OpenCode” and “Qwen CLI”.
    • No help-bundle or README may contain the pre-v1.28 stale phrase “Cursor, Gemini CLI, GitHub Copilot CLI” (Latin or CJK delimiter).
  • test(batch): tests/batch-max-retries.test.mjs — 7 cases covering: present (maxRetries=3 → flag appended), out-of-range upper (=11 → dropped), out-of-range lower (=0 → dropped), non-integer (=abc → dropped), without retry=1 (always dropped), no-param (runner default 2 wins), combined-with-all-other-flags.
  • 506 → 515 unit + acceptance tests (+ 7 max-retries + 2 AI-list canaries). Playwright 32/32 unchanged.

📒 Deferred (unchanged)

  • G-005 A-G → A-F report-block realignment — still requires a coordinated commit on the parent santifer/career-ops modes/oferta.md. Tracked in qa/REGRESSION-v1.27.md §11.

Verification

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

# Manual smoke:
$ curl -sS http://127.0.0.1:4317/api/health | jq '.version'
"1.28.0"

# AI-list canaries — every help-bundle + README mentions OpenCode + Qwen CLI:
$ for f in docs/help/*.md README*.md; do
    grep -L 'OpenCode' "$f" && echo "  FAIL: $f"
  done
# (silent — all 16 files green)

# max-retries flag pass-through:
$ curl -sS http://127.0.0.1:4317/api/stream/batch?retry=1&maxRetries=3 | head -3
# event: error                                      (runner missing in stand)
# data: {"message":"batch/batch-runner.sh not found..."}
# (the path is exercised by tests/batch-max-retries.test.mjs against a stub runner)

[1.27.0] — 2026-05-14

Cosmetic + a11y polish: deduplicate sidebar #/dashboard entry.

🩹 Cosmetic / a11y

  • fix(sidebar): dedupe #/dashboard entry (public/index.html:25-32) — the brand logo block (<a class="logo" href="#/dashboard">) and the first nav item (<a class="nav-item" href="#/dashboard">) both targeted the same route. Screen readers announced “Dashboard” twice in a row when entering the sidebar, and keyboard users had a useless extra tab stop on a control with no distinct purpose. The brand block now renders as a plain <div class="logo">. The Dashboard nav item remains the sole link.

📒 Deferred (parent-coordinated)

  • G-005 / PR-B (A-G → A-F report block realignment) — still requires a coordinated commit on the parent santifer/career-ops modes/oferta.md. Tracked in qa/REGRESSION-v1.27.md §Deferred.

🧪 Tests

  • 506 / 506 unit + 32 / 32 Playwright — unchanged. No new test required (cosmetic-only).
  • CHANGELOG parity gate: ✓ all 8 locales at v1.27.0.

Verification

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

# Manual smoke (after restart):
$ curl -sS http://127.0.0.1:4317/api/health | jq '.version'
"1.27.0"
$ curl -sS http://127.0.0.1:4317/ | grep -c 'href="#/dashboard"'
1   # was 2 on v1.26.1

[1.26.1] — 2026-05-14

Hot-fix: WCAG 2.5.5 — header .btn height restored to 44 px floor.

🚑 Critical hot-fix

  • fix(css): restore min-height: 44px + line-height + flex-shrink:0 on .btn (public/css/app.css:391-410) — live Playwright measurement on v1.26.0 found 5 header buttons rendering at 39-41 px (Doctor / Quick scan / Open Pipeline / 🌐 Scan now / ✨ Auto-pipeline a URL) — a WCAG 2.5.5 violation. The fix:
    • Adds min-height: 44px to .btn. A stale comment at line 427-430 still claimed this floor was in place, but the declaration itself had been lost between v1.18 and v1.26.
    • Adds line-height: 1.2 so the in-block text doesn’t compute the row taller than intended on browsers with looser default leading.
    • Adds flex-shrink: 0 + box-sizing: border-box to keep parent flex rows from squashing the button under their own height constraints.
    • .btn-sm keeps its existing 32 px floor (small-control exception per WCAG 2.5.5 + 2.5.8 spaced-target) — .btn-sm follows .btn in source order so the override applies.

🧪 Tests

  • test(wcag): tests/wcag-target-size.test.mjs — 4 static CSS canaries:
    • .btn block has min-height: 44px
    • .btn block has flex-shrink: 0
    • .btn-sm keeps min-height: 32px
    • .btn-sm defined AFTER .btn in source order (cascade)
  • 502 → 506 unit (+4) + 32/32 Playwright unchanged.
  • Live verification via headless Chromium across all 13 sidebar routes — every .btn:not(.btn-sm) measured ≥ 44 × 44 px after the fix.

Verification

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

# Live Playwright probe (run against server on :4317):
$ for route in /dashboard /scan /pipeline /evaluate /batch /reports /tracker \
                /activity /cv /profile /config /health /help; do
    # use the parent project's playwright to measure
    cd $CAREER_OPS_ROOT && node -e "
      const { chromium } = require('playwright');
      (async () => {
        const browser = await chromium.launch({ headless: true });
        const page = await (await browser.newContext()).newPage();
        await page.goto('http://127.0.0.1:4317/#$route', { waitUntil: 'networkidle' });
        const bad = await page.\$\$eval('.btn:not(.btn-sm)', els =>
          els.filter(b => b.getBoundingClientRect().height < 44).length);
        console.log('$route:', bad === 0 ? 'PASS' : 'FAIL ' + bad);
        await browser.close();
      })();
    "
  done
# Every route → PASS

Out of scope (v1.27.0)

Item Notes
G-005 — A-G → A-F report block realignment Still waiting on coordinated parent commit.
G-003 — README.cn.md rename Verified-already-closed: repo has README.zh-CN.md, no orphan.
Sidebar duplicate #/dashboard (brand logo + nav item) Trivial cosmetic, zero UX impact.

[1.26.0] — 2026-05-14

Test pyramid (unit → functional → acceptance → e2e) + coverage push to ≥ 93 % line / ≥ 83 % branch. Adopts the 4-tier structure mandated by the v1.25 backlog. Adds 22 new tests targeting the biggest coverage gaps from v1.25’s npm run test:coverage report; introduces the tests/acceptance/ directory for cross-endpoint user-journey tests.

📐 Test pyramid documentation

  • docs(architecture): TESTING.md describes the 4-tier pyramid (docs/architecture/TESTING.md) — single-source explanation of how the suite is structured and where new tests land:
    • Tier 1 (unit) — pure helpers (security, parsers, prompts, file-lock, rate-limit, safe-fetch, env-config). No port binding, no FS beyond stubs, no subprocess.
    • Tier 2 (functional) — per-endpoint contracts. createApp() against ephemeral port + temp CAREER_OPS_ROOT.
    • Tier 3 (acceptance) — multi-endpoint user journeys. NEW tests/acceptance/ directory.
    • Tier 4 (e2e) — Playwright headless Chromium (tests/playwright-{smoke,full-cycle}.mjs, tests/e2e{,-comprehensive}.mjs).
  • 100% line coverage target is explicitly scoped to working functionality — the if (isMain) { … } boot block in server/index.mjs and live-LLM call paths in auto-pipeline.mjs are documented exclusions.

🧪 Tier 2 — Functional gap fills

  • test(jds): jds-list-create-get.test.mjs — pre-v1.26 only the DELETE path was tested. New file adds 10 tests covering:
    • GET /api/jds shape on empty + populated state
    • POST /api/jds with explicit slug, auto-generated slug, slug normalization warning
    • POST /api/jds empty-body / stripped-to-empty-slug 400 responses
    • GET /api/jds/:name body roundtrip, 404 on unknown, traversal rejection
    • server/lib/routes/jds.mjs coverage: 61.64 % → 100 % line.
  • test(auto-pipeline): auto-pipeline-rejection-paths.test.mjs — 10 tests covering every URL rejection branch that doesn’t need a live LLM:
    • javascript: / file:// / malformed-string / empty / no-key-body
    • SSRF: loopback, RFC1918, link-local IMDS (169.254.169.254)
    • mode: 'manual' interaction with rejected URL — error precedes any done event
    • server/lib/routes/auto-pipeline.mjs branch coverage: 50.00 % → 59.38 %.

🧪 Tier 3 — Acceptance (NEW)

  • test(acceptance): jd-evaluate-tracker-flow.test.mjs — first cross-endpoint user journey. Threads 7 endpoints in the order the SPA invokes them:

    1. POST /api/jds — save raw JD
    2. GET /api/jds — confirm in list
    3. GET /api/jds/:name — read body verbatim
    4. POST /api/evaluate (manual fallback) — generate prompt
    5. POST /api/tracker — add row
    6. GET /api/tracker — verify presence
    7. GET /api/activity — confirm audit-log entry

    Second journey: pipeline-add → preview → tracker → delete cycle.

🧪 Test count

  • 480 → 502 unit + acceptance (+22). 32/32 Playwright unchanged.
  • npm test now runs tests/*.test.mjs tests/acceptance/*.test.mjs.
  • npm run test:coverage same. npm run test:ci runs all of the above plus the two CI gates from v1.24.1 (check-no-also-leftovers) and v1.25.0 (check-changelog-parity).

Coverage snapshot

all files                     | 93.66 line | 83.73 branch | 92.91 func
server/lib/security.mjs       | 99.30 line
server/lib/safe-fetch.mjs     | 95.81 line
server/lib/file-lock.mjs      | 93.15 line
server/lib/rate-limit.mjs     | 100.00 line
server/lib/parsers.mjs        | 99.57 line
server/lib/routes/jds.mjs     | 100.00 line  ← was 61.64 in v1.25
server/lib/routes/tracker.mjs | 100.00 line
server/lib/routes/reports.mjs | 100.00 line
server/lib/routes/activity.mjs| 100.00 line

Remaining < 95 % line modules are gated by live-LLM / spawn-mock complexity:

  • auto-pipeline.mjs (46 %) — uncovered region is the live Anthropic / Gemini evaluate path + report-save + tracker-write SSE flow. Out of scope for the 100 % target per TESTING.md “Goal: 100% line coverage of working functionality”.
  • batch.mjs (67 %) — uncovered region is streamNodeScript spawn of batch-runner.sh. Out of scope (subprocess mocking gap).
  • cv-import.mjs (77 %) — uncovered region is pandoc / pdftotext fallback paths when the system tools are missing. Out of scope (env-dependent).

Verification

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

$ npm run test:coverage 2>&1 | grep '^# all files'
# all files | 93.66 | 83.73 | 92.91

$ ls tests/acceptance/
# jd-evaluate-tracker-flow.test.mjs

Breaking changes

None.

Out of scope (v1.27+)

Item Notes
Live LLM-path coverage in auto-pipeline.mjs Needs SDK-adapter mock + withFileLock stub + report-save stub. Currently 46 % line; would push toward 95 % with the mock harness.
Subprocess-mocked coverage for batch.mjs::streamNodeScript Needs spawn-mock; would push 67 % → 90 %.
cv-import.mjs pandoc / pdftotext fallback path Needs env-injected which() stub.
G-005 (A-F report block realignment) Still waiting on coordinated parent commit.

[1.25.0] — 2026-05-14

Auto-pipeline manual short-circuit + dashboard cosmetic + CHANGELOG parity backfill. Closes G-014 (auto-pipeline ignored mode: 'manual'), G-012 (CHANGELOG parity drift — 6 locales were 2 releases behind), and the dashboard ✨ ✨ double-glyph cosmetic. G-003 (README.cn.md rename) was de-facto already closed — repo only has README.zh-CN.md. G-005 (A-G → A-F report block realignment) requires a coordinated parent-project commit and stays deferred.

🛡️ G-014 — Auto-pipeline mode: 'manual' short-circuit

  • fix(auto-pipeline): G-014 — honour mode:'manual' short-circuit (server/lib/routes/auto-pipeline.mjs:158-195) — pre-v1.25 the route always called an LLM. Passing mode: 'manual' (mirroring /api/evaluate since v1.10.2) was silently ignored, the request hung 1-3 min on Anthropic. Now the handler:
    • Accepts mode AND evalMode for back-compat. Either value of 'manual' triggers the short-circuit.
    • Emits all 5 SSE stages with status: 'done' / status: 'skipped'. No fetch. No LLM call. No $0.05 per request.
    • done payload carries { mode: 'manual', prompt: <buildEvaluationPrompt scaffold>, message } — the SPA can render it like the existing /api/evaluate manual-prompt card.
  • Closes DoS-risk on HOST=0.0.0.0: previously, even with llmRateLimit capping 10 req/60s/IP, 10 attackers × 10 reqs = $50/min in Anthropic burn. Short-circuit fires before the rate-limit decrement counts toward a real call.
  • Teststests/auto-pipeline-manual-mode.test.mjs: 3 tests confirm (1) mode: 'manual' returns < 2 s with all 5 step keys, (2) even with ANTHROPIC_API_KEY set the short-circuit still fires (the original symptom), (3) legacy evalMode: 'manual' callers keep working.

📝 G-012 — CHANGELOG parity backfill (6 locales × 2 missing releases)

  • docs(changelog): backfill v1.23.0, v1.24.0, v1.24.1, v1.25.0 in 6 lagging locales — pre-v1.25 only EN had v1.23-v1.24; RU was 1 release behind, the other 6 were 2 releases behind. v1.25 dispatches parallel translation agents (mirrors the v1.23 pattern) to land all four entries in CHANGELOG.{es,pt-BR,ko-KR,ja,zh-CN,zh-TW}.md. RU gets v1.24.0 + v1.24.1 + v1.25.0 (it already had v1.23.0 from the v1.23 cycle).
  • feat(ci): scripts/check-changelog-parity.mjs gate — fails the build if any locale CHANGELOG’s newest entry is older than the EN canonical. Wired into npm run test:ci. Pre-existing G-012 drift would have caught itself the moment it crossed the EN boundary.

✨ Cosmetic — dashboard double-glyph dedup

  • fix(dashboard): dedup ✨ glyph in auto-pipeline button label (public/js/lib/i18n-dict.js:219) — dash.autoPipeline carried a leading in every locale string AND public/js/views/dashboard.js:58 prepended another in the view. Result: button rendered ✨ ✨ Auto-pipeline …. v1.25 drops the leading glyph from every locale’s DICT entry; the view’s prefix is the single source. Same audit pass swept the rest of the i18n bundle — no other double-glyph patterns found.

🚫 Deferred to a future release

  • G-005 — Report block A-G → A-F per canonical career-ops.org/docs — requires a coordinated commit in the parent santifer/career-ops project (rewrite modes/oferta.md to emit A=Role, B=CV-match, C=Strategy, D=Comp, E=Personalization, F=STAR — drop C-Risks/G-Legitimacy as separate blocks). v1.25.0 ships the web-ui side ready for the new schema (reports.js already accepts arbitrary block letters since v1.13). Tracked for the next release window when parent + child can land together.
  • G-003 — README.cn.mdREADME.zh-CN.md rename — verified during v1.25 prep: repo already has README.zh-CN.md (no orphan README.cn.md anywhere under the worktree). The G-003 finding was stale.

🧪 Tests

  • 477 → 480 unit (+3 from PR-B auto-pipeline-manual-mode.test.mjs).
  • 32/32 Playwright unchanged.
  • npm run test:ci now runs npm test + check-no-also-leftovers.mjs + check-changelog-parity.mjs.

Verification

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

# G-014 — manual mode returns < 2 s even with ANTHROPIC_API_KEY set:
$ ANTHROPIC_API_KEY=sk-ant-test PORT=4317 npm start &
$ sleep 3
$ time curl -sS -X POST -H 'Content-Type: application/json' \
    -d '{"url":"https://job-boards.greenhouse.io/anthropic/jobs/x","mode":"manual"}' \
    http://127.0.0.1:4317/api/auto-pipeline | head -20
# real  0m0.1xx s  (was 1-3 min)
# event: start … event: step (×5) … event: done {"mode":"manual","prompt":"…"}

# G-012 — every locale CHANGELOG carries the v1.25.0 entry:
$ grep -c '^## \[1.25.0\]' CHANGELOG*.md
# 8 files, each → 1

# Cosmetic — dashboard glyph:
$ grep "dash.autoPipeline" public/js/lib/i18n-dict.js
# No leading ✨ in any locale value (view supplies the single glyph)

Breaking changes

None. mode: 'manual' is opt-in; legacy evalMode: 'manual' callers keep working unchanged.

Out of scope (v1.26+)

Item Notes
G-005 — A-F report block realignment Needs coordinated parent commit (santifer/career-ops rewrites modes/oferta.md).
Live execution of QA scenario 31 visual sub-tests Require browser-driven agent (Claude Cowork). Covered partially by Playwright smoke.
i18n-dict.js over 400-LOC target Translation fixture — exempt by policy. Split would add HTTP requests without a bundler.

[1.24.1] — 2026-05-14

Hot-fix: #/config crash on all 8 locales (G-015).

🚑 Critical hot-fix

  • fix(config): G-015 — replace removed Element.prototype.also call in config.js (public/js/views/config.js:371) — v1.22.0 N-2 dropped the Element.prototype.also global monkey-patch and migrated cv.js to a free-statement pattern, but missed config.js. The result: #/config crashed at first invocation on every locale with c(...).also is not a function. v1.24.1 applies the same migration pattern from cv.js:188-201 — extract the tree to a const root = c(...), run the activation block on its own, then return root;.

🛡️ CI gate

  • feat(ci): scripts/check-no-also-leftovers.mjs sweep — walks every file under public/js/views/ and fails the build on any .also( call site (commented references allowed). Wired into the new npm run test:ci script. Future revert of the monkey-patch removal can’t re-introduce the same regression silently.

🧪 Tests

  • test: tests/config-view-syntax.test.mjs — three guards:
    • parse config.js via node:vm.Script (catches syntax-level regressions without needing Playwright)
    • assert no .also( survives outside comments
    • assert the const root = c(...) / return root; migration anchors are present
  • 474 → 477 unit (+3) + 32/32 Playwright unchanged.

Verification

$ npm run test:ci
# 477 / 477
# ✓ no .also( leftovers in views/

# Browser smoke:
$ open http://127.0.0.1:4317/#/config
# → renders normally, no "is not a function" card. Every locale equivalent.

Out of scope (deferred to v1.25)

  • G-014, G-012, G-005, G-003 — see v1.25.0 entry below for the bundle.

[1.24.0] — 2026-05-14

Help-bundle content-depth refresh + live execution of QA scenario 31 + RU CHANGELOG end-to-end. Closes both items the v1.23.0 “Out of scope” table deferred to v1.24: the full content-depth refresh of all 8 help bundles from the 5 canonical career-ops.org/docs URLs (was URL-coverage-only since v1.11.x), and the live execution of QA scenario 31 against a running server (was “needs browser agent + LLM credentials” — turned out 6/6 sub-tests are reachable via curl + grep, only the visual sub-tests need a browser).

📖 Help-bundle content-depth refresh

  • docs(help): refresh en.md from 5 canonical career-ops.org/docs URLs (docs/help/en.md) — pre-v1.24 the EN bundle was 1113 lines and listed the 5 canonical URLs in the front-matter but didn’t expand on them in the body. v1.24 fetches all 5 URLs via WebFetch and deepens the matching H2 sections:
    • About career-ops (front-matter) — added principles (data sovereignty, AI-agnostic, human-controlled), “What career-ops is NOT” block, expanded concepts inventory from 6 to 10 rows (added Proof points, JD store, Interview-prep, Batch additions).
    • §5 Portals — added canonical bootstrap cp templates/portals.example.yml portals.yml, clarified required vs optional fields per tracked_companies entry.
    • §7 Scan — added “no AI tokens consumed” note for Option A, follow-up commands list (apply / contacto / deep / tracker).
    • §14 Apply checklist — split into SPA checklist mode vs Manual-vs-Playwright-assisted vs Full CLI flow (canonical 8 numbered steps from /career-ops apply <company> to Submitted. with Evaluated → Applied auto-transition); batch evaluate subsection now has TSV schema table + all 4 flags documented + merge-tracker.mjs --dry-run; Playwright Setup subsection lists install commands, MCP registration, alternative .claude/settings.local.json, headless-by-default note.
  • 16-H2 section parity preserved (CI test help-ui.test.mjs::section-parity asserts exactly 16 H2 sections across all 8 locales).
  • Each of the 5 canonical URLs appears ≥ 2 times in the bundle (CI test canonical-docs-coverage.test.mjs enforces). Per-URL count after v1.24: what-is-career-ops × 4, scan-job-portals × 5, apply-for-a-job × 3, batch-evaluate-offers × 5, set-up-playwright × 3.
  • docs(help): translate the v1.24 deepening to 7 non-EN locales — 7 parallel translation agents dispatched. Each target locale (es / pt-BR / ko-KR / ja / ru / zh-CN / zh-TW) gets a refreshed bundle that mirrors the EN structure section-for-section, preserves verbatim code blocks / URLs / file paths / button labels (📁 Upload CV / 🌐 Scan now / ▶ Evaluate / 📄 Generate PDF / 💾 Save) and English abbreviations (CSP, SSRF, TOCTOU, WCAG, ATS, JD, SSE, REST, API), and translates the deepening to publication-grade native technical style in the target language.

🧪 QA scenario 31 — live execution (6/6 PASS)

  • docs(qa): append last-verified live-execution log to qa/claude-cowork-browser-test-prompt.md — pre-v1.24 scenario 31 was documented but never run against a live server (deferred as “needs browser agent + LLM credentials”). v1.24 ran all 6 sub-tests against http://127.0.0.1:4317:

    Sub Description Status
    31.1 Score thresholds in help bundles ✅ PASS (4.5 × 3, 4.0 × 9, 3.5 × 6 mentions in docs/help/en.md)
    31.2 Scan workflow endpoints ✅ PASS (/api/stream/scan-{en,ru} + /api/scan-ru/config → 404; /api/scan/regional/config → 200)
    31.3 /api/apply-helper checklist ✅ PASS (body contains career-ops apply + auto-submit warning)
    31.4 /api/batch endpoint ✅ PASS (keys [exists, runnerExists, raw, rows, additions])
    31.5 Playwright availability ✅ PASS (/api/health reports Playwright (parent node_modules) ok: true, value: installed)
    31.6 Help-bundle URL coverage (5 URLs × 8 locales) ✅ PASS (40 / 40 ✓)

    Visual-only sub-tests (require browser) flagged separately in the QA prompt — they remain runnable via Claude Cowork or npm run test:e2e:browser.

🌐 RU CHANGELOG end-to-end (M-9 follow-up)

  • docs(translate): CHANGELOG.ru.md retry agent — full body translation (CHANGELOG.ru.md) — the v1.23.0 release shipped with the RU CHANGELOG retry agent still in flight (it had crashed once with a socket error and was re-dispatched). v1.24 picks up the agent’s 1542-line full translation: every entry v1.23.0 → v1.6.0 has a publication-grade Russian body, no more EN-bodied stop-gaps. Style discipline matches the v1.22.0 README quality refresh: “функциональность” / “возможности” / “поведение” replace clunky “функционал”; “через” / “с помощью” replace “при помощи”; active voice over passive; “эндпоинт”, “лимит запросов”, “состояние гонки”, “санитайзинг” as canonical terms; English abbreviations (TOCTOU, CSP, SSRF, WCAG, ATS, JD, SSE, REST, API) preserved.

🧪 Tests

  • 474 / 474 unit + 20 / 20 smoke E2E + 32 / 32 Playwright. Zero behavioral test deltas; every help-bundle CI assertion (16 H2 sections × 8 locales, 5 URLs × ≥ 2 mentions, content floor) still green.

Verification

$ npm test                            # 474 / 474

# Help-bundle deepening:
$ wc -l docs/help/en.md
# ~1270 lines (was 1113 — deepened, not bloated)

$ for url in what-is-career-ops scan-job-portals apply-for-a-job \
             batch-evaluate-offers set-up-playwright; do
    echo -n "$url: "
    grep -c "$url" docs/help/en.md
  done
# what-is-career-ops: 4
# scan-job-portals: 5
# apply-for-a-job: 3
# batch-evaluate-offers: 5
# set-up-playwright: 3

# Scenario 31.6 — 40/40 URL coverage:
$ for lang in en es pt-BR ko ja ru zh-CN zh-TW; do
    echo -n "$lang: "
    for url in what-is-career-ops scan-job-portals apply-for-a-job \
               batch-evaluate-offers set-up-playwright; do
      curl -sS "http://127.0.0.1:4317/api/help/$lang" \
        | python3 -c "import sys,json; print(json.load(sys.stdin).get('markdown',''))" \
        | grep -q "$url" && echo -n "✓ " || echo -n "✗ "
    done
    echo
  done

Breaking changes

None.

Out of scope (v1.25+)

Item Notes
Live execution of scenario 31 visual sub-tests Require browser-driven agent (Claude Cowork or npm run test:e2e:browser). Out of scope for curl-only execution; covered by existing Playwright smoke.
RU CHANGELOG body translation of older entries (v1.5.x and below) The retry agent only covered v1.6.0 onwards. Pre-v1.6 entries (v1.5.x, etc.) — if they ever existed — remain pre-existing-content.
Visual regression on dashboard screenshots after future SPA changes scripts/capture-dashboard-screenshots.mjs regenerates per-locale PNGs; no automated diff currently.

[1.23.0] — 2026-05-14

i18n split + connection-banner CI fix + localized dashboard screenshots + every backlog stop-gap closed. Ships the three items the v1.22.0 “Out of scope” table flagged for v1.23 (M-9 locale CHANGELOG bodies, N-1 i18n.js LOC split, help-bundle content audit) plus a hot-fix for the smoke E2E test that turned the v1.22.0 main-branch CI red.

🚑 CI hot-fix — connection banner recovery

  • fix(client): reset health-poll cadence + visibilitychange eager re-check (public/js/api.js:21-91) — v1.22.0’s M-6 exponential backoff was correct (3 s → 6 s → 12 s → cap 15 s, down from the original cap 60 s) but the in-flight setTimeout was locked to whatever delay was set previously. A server killed at t=0.1 with the first ping at t=3 would fail, double the delay to 6, and the next recovery probe wouldn’t fire until t=9. The smoke E2E’s “Flow 2a: connection banner appears on server down, hides on recovery” waited only 4 s and turned red on main.

    v1.23.0 reshapes the polling loop:

    • _healthHandle is tracked so setConnectionState(lost=true) can clearTimeout and re-schedule with _HEALTH_MIN. The first recovery probe now fires within 3 s of going down, regardless of what delay was queued.
    • _HEALTH_MAX lowered from 60 s to 15 s. Backgrounded tab against a dead server still recovers within one polling cycle when the user comes back; bandwidth savings stay substantial.
    • document.addEventListener('visibilitychange') eager-rechecks when the tab regains focus and connectionLost === true — Cmd-Tab back doesn’t wait for the next backoff tick.

🧹 N-1 — i18n.js split (over the 400-LOC target)

  • refactor(client): split DICT into i18n-dict.js (data) + i18n.js (logic) — pre-v1.23 public/js/lib/i18n.js was 639 LOC. The bulk (lines 23-586) was the DICT translation table — pure structured data. v1.23.0 extracts that to public/js/lib/i18n-dict.js (578 LOC, exempt-from-LOC-rule per CLAUDE.md “Exempt from these limits: generated files, migrations, test fixtures, lock files, vendored code” — translation tables qualify as fixtures), leaving public/js/lib/i18n.js at 86 LOC of pure module logic (well under the 400-LOC target).
  • Loader contract: i18n-dict.js populates window.__I18N_DICT = { … }, then i18n.js reads it inside the existing IIFE. public/index.html loads them in order — i18n-dict.js before i18n.js — so the IIFE sees a fully-populated DICT at construction time. Missing-dict fallback: every t() call returns its inline fallback or bare key, which surfaces a misconfiguration loudly without crashing the SPA.
  • Test plumbing updated: tests/i18n-coverage.test.mjs, tests/help-ui.test.mjs, tests/canonical-docs-coverage.test.mjs now run both files through the test VM context (or concatenate their source for the regex sweep), preserving every existing assertion.

🌐 M-9 — Locale CHANGELOG body translations

  • docs(translate): 7 non-EN CHANGELOG files end-to-end — pre-v1.23 CHANGELOG.{es,pt-BR,ko-KR,ja,ru,zh-CN,zh-TW}.md carried EN-bodied stop-gap notes for every entry from v1.13.0 onwards, with a footer pointing readers at the EN canonical. v1.23.0 dispatches 7 parallel translation agents — one per locale — that rewrite every body to publication-grade technical style in the target language. Stop-gap notes removed. Code blocks, file paths, URLs, commit-message-style strings (fix(security): B-1 — …), env vars, and link labels preserved verbatim across all locales.

🖼️ Localized dashboard screenshots in every README

  • docs(readme): wire each locale README at its locale-specific PNG — pre-v1.23 only README.pt-BR.md referenced dashboard-pt-BR.png; the other 6 non-EN READMEs still pointed at dashboard-en.png. The screenshots (already captured in v1.22.0 cycle by scripts/capture-dashboard-screenshots.mjs) were present in images/ but unused. v1.23.0 updates every README.{es,ja,ko-KR,ru,zh-CN,zh-TW}.md line 14 to its own dashboard-<locale>.png.

🧪 Tests

  • Same 474 / 474 unit + 32 / 32 Playwright as v1.22.0. Smoke E2E now 20 / 20 (was 19 / 1 fail on main after v1.22.0 due to the banner-recovery regression; v1.23.0’s reschedule fix closes it).
  • Three existing tests rewired to handle the i18n split. Zero new test files; zero new assertions deleted.

Verification

$ npm test
# 474 / 474

$ npm run test:e2e
# passed: 20    failed: 0    (was 19/1 on v1.22.0 main)

$ wc -l public/js/lib/i18n.js public/js/lib/i18n-dict.js
#       86 public/js/lib/i18n.js          ← logic, under target
#      578 public/js/lib/i18n-dict.js     ← data fixture, exempt

$ grep -h 'dashboard-' README*.md | sed -E 's/.*(dashboard-[^)]+).*/\1/' | sort -u
# dashboard-en.png    (README.md only)
# dashboard-es.png    dashboard-ja.png
# dashboard-ko-KR.png dashboard-pt-BR.png
# dashboard-ru.png    dashboard-zh-CN.png  dashboard-zh-TW.png

# CHANGELOG translation sanity: each locale file > 200 lines of native content
$ wc -l CHANGELOG.{es,pt-BR,ko-KR,ja,ru,zh-CN,zh-TW}.md | grep -v total

Breaking changes

None. public/index.html now loads two scripts where it loaded one — anyone serving the SPA from a CDN must pick up i18n-dict.js; the script load order is enforced by the order of <script src> tags in index.html. The runtime fallback (empty DICT → t() returns the inline EN fallback) prevents hard crashes when the new file is missing.

Out of scope (v1.24+)

Item Notes
Help-bundle CONTENT depth refresh from career-ops.org/docs (vs URL coverage) The 5 canonical URLs already appear in every locale’s help bundle since v1.11.x and Scenario 31.6 in the QA prompt verifies coverage. Content-body depth refresh is a v1.24+ candidate.
Live execution of QA scenario 31 against a running server Requires browser agent + live LLM credentials. v1.24 candidate.
Per-component touch-target sweep on new mode-page hint paragraphs v1.22.0 M-1 added <p class="field-hint"> elements that haven’t been verified against WCAG 2.5.5 min-height in all 8 locales.

[1.22.0] — 2026-05-14

M/L/N backlog clearout + docs alignment + translation quality pass. The entire v1.20.1-BACKLOG.md medium-and-below tier shipped in one release: nine M-items, five L-items, two nits. Plus a docs-alignment audit against the five canonical career-ops.org/docs guides, refreshed system prompts under .claude/ and .github/, and quality-refreshed READMEs in all 7 non-English locales.

🛡️ Security hardening (defense-in-depth)

  • fix(security): M-4 — entity-aware stripDangerousMarkdown (server/lib/security.mjs) — the pre-v1.22 regex matched <script>, javascript:, on*= as literal substrings. &lt;script&gt;, java&#115;cript:, and <img src="data:image/svg+xml,<svg onload=…>"> slipped through. The strip now decodes &lt;, &gt;, &amp;, &quot;, numeric (&#NN;) and hex (&#xHH;) entities before the strip regex runs. Validated by 11 tests in tests/cv-xss-bypasses.test.mjs. Real defense is still the client-side UI.md escape-first pipeline; this hardens the at-rest file.

  • fix(security): L-2 — bash --noprofile --norc on the batch runner (server/lib/routes/batch.mjs:108) — spawn('bash', [PATHS.batchRunner, ...]) used to inherit the user’s ~/.bashrc. A hostile rc file could influence the run. Now spawn('bash', ['--noprofile', '--norc', PATHS.batchRunner, ...]).

🔒 Resilience

  • fix(client): M-6 — exponential backoff on health ping (public/js/api.js:22-48) — the disconnected-state poller used to fire 28,800 fetches against a dead server overnight. Now 3 s → 6 s → 12 s → 24 s → 60 s; resets to 3 s on first 2xx recovery. Setup is a setTimeout chain (not setInterval) so each step picks up the new delay.

  • fix(client): M-5 — Safari private-mode localStorage guard (public/js/lib/i18n.js:572-583) — Safari private-mode throws SecurityError on every localStorage.getItem/setItem. The IIFE-during-load used to fail the entire i18n module, leaving the SPA rendering raw keys. Wrapped both calls in try/catch with the detect() browser-language fallback.

  • fix(server): M-2 — body-size cap on outbound preview fetches (test + verify) — the v1.21.0 safeGet already streamed chunks and capped at opts.maxBytes. v1.22 adds an explicit regression test in tests/ssrf-redirect-rebind.test.mjs to lock the contract: 100 KB upstream + 4 KB cap → response ≤ 4 KB.

  • fix(client): L-5 — clear setTimeout on hashchange in scan.js (public/js/views/scan.js:6-22, :113-120) — the post-done 300 ms refreshResults() timer used to leak when the user navigated off #/scan in that window. Handle is now captured and cleared in __cancelActiveScanPoll.

  • fix(client): L-4 — multi-line SSE data: joiner (public/js/lib/auto-pipeline.js:158-176) — the SSE parser used match() (single-line). Per spec, an event may carry multiple data: lines that the consumer joins with \n. Server currently sends single-line JSON, so the old code worked — but was brittle to any future multi-line payload.

♿ Accessibility

  • feat(a11y): M-3 — WCAG 1.4.1 redundant cues on score pills + connection banner (public/css/app.css:602-625, :812-822) — score-high / score-mid / score-low used to convey state by hue alone (red/amber/green). Users who can’t perceive hue had no fallback. Each tier now gets a redundant glyph via ::before (✓ / ◐ / ○). Connection banner gets a leading glyph in the offline state. Render sites untouched — pure CSS hardening.

  • feat(a11y): M-1 — inline hint paragraphs for every mode-page field (public/js/views/mode-page.js, public/js/lib/i18n.js) — v1.20.0 wired htmlFor → id for every mode-page field but didn’t carry inline hint copy; only the README walkthroughs documented field intent. v1.22.0 adds 19 hint i18n keys × 8 locales = 152 new translations and the field() builder now renders a <p id="…-hint"> with aria-describedby wiring per field. Screen-reader users hear the hint when the input is focused.

  • fix(a11y): M-7 — null-guard on UI.el() htmlFor alias (public/js/api.js:194-198) — htmlFor: null used to render literal for="null". One-liner mirror of the fallthrough branch’s v != null && v !== false guard.

🧹 Quality / portability

  • fix(server): L-1 — parseInt radix in health.mjs + bin/start.sh + bin/setup.shparseInt(process.versions.node) without radix triggers a lint warning and is brittle if Node ever ships hex versions. Added 10 everywhere.

  • fix(server): L-3 — Windows-safe entrypoint check (server/index.mjs:159-163) — import.meta.url === \file://${process.argv[1]}`mishandles drive letters and backslashes on Windows. Replaced withfileURLToPath(import.meta.url) === path.resolve(process.argv[1])`.

  • refactor(client): N-2 — drop Element.prototype.also monkey-patch (public/js/views/cv.js:188-201) — global DOM prototype pollution. Replaced with a local variable for the tree root.

  • test(canary): M-8 — 404 regression test for retired /api/scan-ru/config (tests/scan-consolidated.test.mjs) — v1.20.0 retired the alias but added no canary. Three-line addition mirroring the v1.18 retirement tests.

📚 Docs + system prompts

  • docs(architecture): refresh OVERVIEW + DATA-FLOWS for v1.21+ surface — added safe-fetch.mjs (DNS-pinned GET), file-lock.mjs (per-path mutex), rate-limit.mjs (LLM throttle), and sanitizePathName to OVERVIEW.md. DATA-FLOWS.md gained two new sections: “Outbound URL fetches (DNS-rebind-safe)” and “LLM endpoint rate-limiting”.

  • docs(readme): security envelope section refresh — README.md “Security notes” now documents every helper in the v1.21+ security envelope (sanitizePathName, safeGet, withFileLock, llmRateLimit, entity-aware stripDangerousMarkdown).

  • docs(qa): scenario 31 — career-ops.org/docs alignment (qa/claude-cowork-browser-test-prompt.md) — six new sub-tests (31.1–31.6) that verify the UI matches behavior described in the five canonical career-ops.org/docs guides: score thresholds, scan workflow (one button), apply workflow (checklist, not auto-submit), batch workflow (TSV editor), Playwright setup (graceful failure), help-bundle coverage (5 URLs × 8 locales).

  • docs(translate): README quality refresh × 7 non-EN locales — every non-EN README rewritten to publication-grade technical style in its native language. Common clunky calques replaced; v1.21/v1.22 security envelope mentions added; release/test badges bumped.

  • docs(system): .claude/PROJECT-CONTEXT.md + .github/copilot-instructions.md — single-file orientation for agents joining a session. Compressed CLAUDE.md, names the v1.21+ helpers, lists common pitfalls.

  • docs(bin): actualize start.sh / setup.sh / run_all.sh comments — “two deps” → “three deps” (express + js-yaml + multer); “298 tests” → “474+ tests”; parseInt radix added.

🧪 Tests

  • 461 → 474 unit (+13) + 32/32 Playwright unchanged.
  • New test files: cv-xss-bypasses.test.mjs (M-4, 11 tests).
  • Extended: ssrf-redirect-rebind.test.mjs (+1 for M-2 body cap), scan-consolidated.test.mjs (+1 for M-8 alias canary).
  • Zero behavioral test deltas on existing suites — every fix is additive or covered by a new canary.

Verification

npm test                          # 474 / 474
npm run test:e2e:browser          # 32 / 32

# Entity-encoded XSS strip:
node -e "import('./server/lib/security.mjs').then(({stripDangerousMarkdown}) => console.log(stripDangerousMarkdown('&lt;script&gt;alert(1)&lt;/script&gt;')))"
# → '' (no <script> survives)

# Health-ping backoff (open devtools, kill server, watch network panel):
#   3 s → 6 s → 12 s → 24 s → 60 s, then resets on first successful ping

# Score-pill glyph (open #/reports in light + dark theme):
#   .score-high shows ✓ + numeric score
#   .score-mid  shows ◐ + numeric score
#   .score-low  shows ○ + numeric score

# Mode-page hints (#/contacto, etc):
#   <input aria-describedby="mode-contacto-recipient-hint">  ← targets <p id="…">

# Retired alias:
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4317/api/scan-ru/config
# → 404

Breaking changes

None. Every fix is additive or preserves existing endpoint contracts.

Out of scope (v1.23+)

Item Notes
M-9 — locale CHANGELOG body translations All CHANGELOG.{es,pt-BR,ko-KR,ja,ru,zh-CN,zh-TW}.md v1.13+ entries are EN-bodied stop-gaps. Bulk translation candidate after release cadence slows.
N-1 — public/js/lib/i18n.js over the 400-LOC target Splitting per locale increases HTTP cost without a bundler. Defer until the build-step decision lands.
Help-bundle content refresh from career-ops.org/docs The five canonical URLs already appear in every locale’s help bundle (since v1.11.x). Scenario 31.6 in the QA prompt verifies coverage. Content depth refresh is a v1.23 candidate.

[1.21.0] — 2026-05-14

Security + concurrency + a11y polish from two independent code-review passes. Seven findings from docs/specs/V1.20.1-BACKLOG.md shipped in one release: one blocker (DNS-rebind TOCTOU), six high-severity bugs (path-traversal sanitization spread, rate-limit gap on LAN deploy, concurrent-write race, i18n coverage hole, dangling aria-describedby, missing label associations). 34 new tests; baseline rose from 427 → 461 unit + 32/32 Playwright. Every fix lands behind a named regression test.

🛡️ Security

  • fix(security): B-1 — close DNS-rebind TOCTOU via safe-fetch.mjs (server/lib/safe-fetch.mjs) — the previous pattern did one explicit dnsLookup for validation, then let fetch() do its own independent lookup. A DNS rebind attacker with TTL=0 could return a public IP on lookup 1 and 127.0.0.1 / 169.254.169.254 / a LAN address on lookup 2, bypassing isPrivateOrLoopbackHost. The new safeGet resolves ONCE, pins the TCP connection to that exact IP via node:http(s), and sets SNI/Host so cert validation still targets the original hostname. Used by /api/pipeline/preview and /api/auto-pipeline. Fail-CLOSED on lookup error (reverses the prior try { … } catch { /* fall through */ }). Validated by 8 new tests in tests/ssrf-redirect-rebind.test.mjs.

  • fix(security): H-4 — consolidate sanitizePathName across 10 routes (server/lib/security.mjs) — the bare replace(/[^\w\-.]/g, '') regex was duplicated across jds.mjs, content.mjs, reports.mjs, llm.mjs, runners.mjs and kept . characters, so ..pdf, ....md, leading-dot names survived. Only reports.mjs::sanitizeSlug did it right. v1.21.0 hoists the correct version (sanitizePathName) into security.mjs, deletes 10 broken copies, and rejects empty results with 400. Validated by 12 tests in tests/path-traversal.test.mjs.

  • fix(security): H-5 — rate-limit LLM endpoints on public bind (server/lib/rate-limit.mjs) — /api/evaluate, /api/deep, /api/mode/:slug, /api/auto-pipeline previously had no per-IP throttle. Loopback users are unaffected; LAN-exposed deploys (HOST=0.0.0.0) get 10 req/min/IP with Retry-After and X-RateLimit-* headers on overflow. Configurable via LLM_RATE_LIMIT="N/Ws". Cheap interim defense ahead of the v2.0 P-12 auth gate. Validated by 6 tests in tests/rate-limit.test.mjs.

🔒 Concurrency

  • fix(data): H-6 — per-file mutex on applications.md / pipeline.md (server/lib/file-lock.mjs) — concurrent POST /api/tracker (or auto-pipeline racing a manual add) used to both read num=42, both write num=43, and silently drop the earlier row. withFileLock(path, fn) serializes read-modify-write per path; independent paths still run in parallel. Wired into tracker.mjs, pipeline.mjs (POST + DELETE), and auto-pipeline.mjs tracker step. Validated by 5 tests in tests/concurrent-tracker-write.test.mjs including a 20-concurrent-POST integration check that asserts rows 001..020 land sequentially.

♿ Accessibility

  • fix(a11y): H-1 — id="batch-tsv-hint" on the batch.js hint paragraph (public/js/views/batch.js) — v1.20.0 added aria-describedby="batch-tsv-hint" to the TSV textarea but never gave the hint <p> a matching id. Screen readers had nothing to voice. Fixed.

  • fix(a11y): H-2 — htmlFor on batch-parallel / batch-min-score labels (public/js/views/batch.js) — four v1.20.0 inputs got new ids but their labels weren’t programmatically associated. WCAG 3.3.2 now satisfied.

  • New static-analysis canary in tests/a11y-form-wires.test.mjs — walks every view file and asserts every aria-describedby / htmlFor IDREF points at a sibling id: declaration. Catches typo-class regressions at CI time.

🌐 i18n

  • fix(i18n): H-3 — 13 keys from v1.20.0 silently fell through to EN for 7 locales (public/js/lib/i18n.js) — pipe.filter, pipe.count, pipe.preview*, pipe.openTab, pipe.evaluateAll*, eval.jdHint, batch.parallelAria, batch.minScoreAria, plus common.delete, config.group{Core,Runtime,Regional}, config.profileEmpty, config.viewProfile, scan.atsBadge, scan.regionalBadge were referenced via t('key', 'EN fallback') but never added to DICT. Russian, Japanese, Chinese screen-reader users heard English aria-labels — directly defeating the WCAG 3.3.2 win v1.20.0 claimed. v1.21.0 adds all 19 keys × 8 locales (≈ 150 new translations) and extends tests/i18n-coverage.test.mjs with a static-analysis pass that scans every t('key', …) call in public/js/**/*.js and asserts each key exists in DICT. Future drift caught at CI time.

🧪 Tests

  • 427 → 461 unit (+34) + 32/32 Playwright unchanged.
  • New test files: ssrf-redirect-rebind, path-traversal, concurrent-tracker-write, rate-limit, a11y-form-wires.
  • Existing pipeline-preview.test.mjs rewired from globalThis.fetch mock to the new _setTransport injection point in safe-fetch.mjs — the SSRF path no longer goes through fetch, so the old mock was bypassed silently.

Verification

npm test                              # 461 / 461
npm run test:e2e:browser              # 32 / 32
node --test tests/ssrf-redirect-rebind.test.mjs tests/path-traversal.test.mjs \
  tests/concurrent-tracker-write.test.mjs tests/rate-limit.test.mjs \
  tests/a11y-form-wires.test.mjs      # 34 new tests, all green

# Path-traversal: every traversal-style :name returns 400 / 404
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4317/api/jds/..pdf
# → 400

# Rate-limit on public bind:
HOST=0.0.0.0 LLM_RATE_LIMIT=3/60s npm start &
for i in 1 2 3 4; do
  curl -sS -o /dev/null -w '%{http_code} ' -X POST -H 'Content-Type: application/json' \
    -d '{"jd":"…"}' http://0.0.0.0:4317/api/evaluate
done
# → 200 200 200 429

# Concurrent tracker writes: 20 parallel POSTs, 20 rows land:
node tests/concurrent-tracker-write.test.mjs
# 20 sequential rows 001..020

# Aria wires sanity:
grep -r 'aria-describedby' public/js/views/ | wc -l
# matching `id:` lookups all resolve (a11y-form-wires.test.mjs canary)

Out of scope (v1.22+)

Item Notes
pipeline-preview body-size streaming cap (M-2) await upstream.text() reads full body before the 8 KB slice; malicious 1 GB stream could exhaust memory. Stream-read with byte counter + abort.
WCAG 1.4.1 — color-only state on .connection-banner + score pills (M-3) Hue alone signals state; add icon prefix (✓ / ◐ / ○) or text suffix.
stripDangerousMarkdown bypasses via HTML entities (M-4) &lt;script&gt;, java&#115;cript:, <img src="data:image/svg+xml,<svg onload=…>"> survive the regex. Defense-in-depth via UI.md still holds; doc + lock bypasses in a test sweep.
Safari private-mode localStorage access without try/catch (M-5) i18n.js:544/571 throws → SPA renders raw keys. Wrap in try/catch with 'en' default.
setInterval(checkHealth, 3000) polls forever with no backoff (M-6) Exponential 3s → 6s → 12s → cap 60s.
htmlFor alias missing null-guard (M-7) One-line if (v != null && v !== false) defense.
404 canary for retired /api/scan-ru/config (M-8) Three-line test mirroring v1.18 precedent.
Locale CHANGELOG body translations (M-9) Bulk translation candidate after release cadence slows.
Inline-hint paragraphs for every mode-page field (M-1) ~168 i18n keys × 8 locales; held back as polish item.
L-1 through L-5 nits parseInt radix, bash –noprofile, Windows-safe fileURLToPath, multi-line SSE, scan.js timer cleanup.

[1.20.0] — 2026-05-13

Per-component a11y polish + non-EN README parity + /api/scan-ru/config alias retired. Closes the four items the v1.19.0 “Out of scope” table flagged for v1.20.

♿ WCAG 2.5.5 / 2.5.8 — per-component touch-target audit

  • a11y(touch-target): chip min-height 28 px + 8 px gap (2.5.8 spaced-target exception).chip was 24 × ~50 px (vertical was 24, height failed 2.5.5’s 24 px floor for clustered controls); the spaced-target exception of 2.5.8 requires either ≥ 24 × 24 px OR 24 px of clearance. Bumped .chip to min-height: 28px; padding: 6px 12px; and the wrapping .chip-row to gap: 8px; so both conditions hold.
  • a11y(touch-target): sidebar nav-item min-height 44 px.nav-item padded only 10px 14px, computed height ~36 px on most viewports. Now padding: 12px 14px; min-height: 44px; box-sizing: border-box;. Matches the .btn floor.
  • a11y(touch-target): tab-btn min-height 44 px — same treatment for Sortable Headers / category tabs across Reports, Tracker, Scan results.

♿ WCAG 1.3.1 / 3.3.2 — aria-describedby on inline form hints

Every form control across the SPA now owns a stable id, its <label> targets it via htmlFor, and any inline hint paragraph is associated via aria-describedby. Five view files were rewired:

  • a11y(forms): config.js — per-key id + hint association (cfg-<key> / cfg-<key>-hint).
  • a11y(forms): evaluate.jseval-jd textarea + eval-jd-hint paragraph documenting the 50-char minimum after sanitization.
  • a11y(forms): batch.jsbatch-tsv / batch-tsv-hint, plus aria-labels on batch-parallel, batch-min-score, batch-dry-run, batch-retry.
  • a11y(forms): pipeline.jspipe-filter + pipe-new-url / pipe-new-url-hint.
  • a11y(forms): mode-page.js — every field across the 7 generic modes (project, training, followup, batch-prompt, contacto, interview-prep, patterns) gets mode-<slug>-<name> ids and htmlFor labels.

UI.el() learned a React-style htmlFor alias so view code stays declarative — it sets the underlying for attribute (which is JS-reserved as a property name).

🌍 Non-EN README parity

  • docs(readme): translate 7 locales to 585-line parity with EN masterREADME.{es,pt-BR,ko-KR,ja,ru,zh-CN,zh-TW}.md were 306–316 lines (covered headlines but skipped the marketing-heavy walkthroughs and most of the API reference). All seven now mirror the EN structure end-to-end: About → One-command install → Why? → Quick start (3 numbered steps) → Requirements → What you get table → Scan → Architecture (full directory tree) → API reference (every route table) → Tests → Configuration → Security notes → Limitations → Contributing → 🌍 Getting Started 5-step walkthrough → License.

🧹 /api/scan-ru/config alias retired

  • feat!(scan): remove /api/scan-ru/config legacy alias (sunset v1.20) — kept as a one-release alias in v1.19 for back-compat. Canonical /api/scan/regional/config is the only path now. Removed: route registration in server/lib/routes/scan.mjs, doc references in README.md, docs/architecture/{OVERVIEW,SERVER,API}.md. Tests already covered the canonical path — no test changes needed.

🧪 Tests

  • Same suite as v1.19. 427 / 427 unit + 20/20 smoke + 23/23 comprehensive + 32/32 Playwright. All a11y wiring is additive (more id / for / aria-describedby attributes) — no behavioral changes, no test deltas.

Verification

npm test                              # 427 / 427
npm run test:e2e:browser              # 32 / 32

# Touch targets — every chip / nav-item / tab-btn ≥ 28 / 44 / 44 px:
#   Chrome DevTools → Computed → height/min-height on .chip, .nav-item, .tab-btn

# Form labels — every input has a label[for=…] association:
#   document.querySelectorAll('input,textarea,select').forEach(el =>
#     console.assert(el.labels?.length || el.getAttribute('aria-label'), el))

# Alias gone:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4317/api/scan-ru/config
# → 404

# Canonical still works:
curl -s http://127.0.0.1:4317/api/scan/regional/config | jq '.'

Breaking changes

  • DELETE /api/scan-ru/config — gone. Use /api/scan/regional/config. Was announced as sunset in v1.19.0’s CHANGELOG and verification script.

Out of scope (v1.21+)

Item Notes
Inline-hint paragraphs for every mode-page field Today only the <label for=…> association is in place; visible per-field hint copy is still EN-only in the SPA. The README walkthroughs document the field intent in every locale, so this is a polish item, not a blocker.
Color-only state surfacing in .connection-banner and dashboard score pills (WCAG 1.4.1) The banner relies on red/amber/green; needs an icon or text suffix for users who can’t perceive hue.
Locale-specific CHANGELOG body translations English-bodied stop-gaps remain in CHANGELOG.{es,pt-BR,ko-KR,ja,ru,zh-CN,zh-TW}.md. Translation happens once the v1.x release cadence slows.

[1.19.0] — 2026-05-13

WCAG 1.4.3 contrast + scan unification (final) + HH_USER_AGENT removed from UI. Closes the v1.18 out-of-scope contrast audit, finishes the EN/RU split elimination begun in v1.18, and removes the HH_USER_AGENT configuration knob from the UI per user direction (a sensible default bundled in the server already handles non-RU IPs for most users).

♿ WCAG 1.4.3 contrast pass

  • a11y(contrast): introduce AA-passing *-text variants for accent tokens — light theme: --rausch-text: #b80f42 (6.59:1 on white, was 3.52:1), --kazan-text: #066507 (7.31:1, was 4.53:1), --darjeeling-text: #7a5800 (5.73:1 on amber bg, was 4.24:1), --babu-text: #00665e (6.09:1, was 2.70:1). Dark theme: lightened mirrors (#ff8aa0, #6ee7b7, #fcd34d, #5eead4) hit the same 4.5:1 floor on #161a22 paper.
  • Badge classes (.badge-ok, .badge-warn, .badge-bad, .badge-info) and score pills (.score-high, .score-mid, .score-low) now route through the new *-text variants — every text-on-tinted-bg combo passes AA. The accent fill tokens (--rausch, --kazan, etc.) stay unchanged for borders and outlines (which only need 3:1 for non-text UI components).

🧹 Scan unification (finishes v1.18 work)

  • docs(scan): scrub remaining EN/RU split references across READMEs + help + architecture docs — eight READMEs + eight help bundles + three architecture docs (API.md, SERVER.md, OVERVIEW.md, DATA-FLOWS.md) + scan.js comment now describe a single consolidated scan method. The legacy /api/stream/scan-{en,ru} aliases were already gone in v1.18; v1.19 catches the doc/copy that still framed scanning as a two-step EN+RU process.
  • feat(scan): canonical /api/scan/regional/config endpoint/api/scan-ru/config kept as a thin alias through one release for back-compat. The new path matches the source-naming convention (?source=regional).

🛠️ HH_USER_AGENT removed from UI

  • feat!(config): drop HH_USER_AGENT field from /#/config + KNOWN_KEYS — power users can still set HH_USER_AGENT directly in career-ops/.env (the server reads via process.env.HH_USER_AGENT in server/lib/sources/hh.mjs with the bundled UA as fallback). The UI no longer exposes it because the default works for most users and seeing an inscrutable User-Agent field in the App Settings page was a recurring source of confusion.
  • README mentions across 8 locales + help bundle mentions across 8 locales replaced with “run via a Russian IP / VPN” advice. The scan.hhWarning i18n key was rephrased to drop the env-var setup detail.
  • KEY_GROUPS collapsed: no more regional classification (it only had HH_USER_AGENT). Tests updated; regionalActive payload field preserved for SPA back-compat.

🧪 Tests

  • tests/env-config.test.mjsKNOWN_KEYS assertion now excludes HH_USER_AGENT; new assertion that the key is intentionally absent.
  • tests/config-endpoint.test.mjs — POST-write multi-key test uses GEMINI_MODEL as the second known key instead of HH_USER_AGENT.
  • tests/config-groups.test.mjsgroups.HH_USER_AGENT is now expected undefined.
  • Total: 427 / 427 unit + 20/20 smoke E2E + 23/23 comprehensive E2E + 32/32 Playwright. Same counts as v1.18.0 because every adjusted test was already counted.

Verification

npm test                              # 427 / 427

# Contrast (Chrome DevTools or axe) on light + dark:
#   .badge-ok / .badge-warn / .badge-bad / .badge-info → AA pass (4.5:1+)
#   .score-high / .score-mid / .score-low → AA pass

# HH_USER_AGENT no longer in /api/config:
curl -s http://127.0.0.1:4317/api/config | jq '.values | keys'
# → ["ANTHROPIC_API_KEY","ANTHROPIC_MODEL","GEMINI_API_KEY","GEMINI_MODEL","HOST","PORT"]
# (no HH_USER_AGENT)

# Canonical regional config endpoint:
curl -s http://127.0.0.1:4317/api/scan/regional/config | jq '.'
# Legacy alias still alive through v1.20:
curl -s http://127.0.0.1:4317/api/scan-ru/config | jq '.'

Out of scope (v1.20+)

Item Notes
Per-component touch-target audit (filter chips, sortable headers, sidebar nav) v1.18 set the global floor (.btn 44 px, .btn-sm 32 px); per-component verification across the SPA remains.
aria-describedby on inline form hints (#/config, #/pipeline, #/evaluate, #/batch) v1.17 covered aria-label on global search + modal close. Per-input hint association is the next polish layer.
Full non-EN README parity (585 lines like EN) v1.18 brought non-EN to ~307 (53 % of EN). Marketing-heavy “Quick start” + “🌍 Getting Started” walkthroughs remain EN-only.
Remove /api/scan-ru/config legacy alias Sunset planned for v1.20. The canonical /api/scan/regional/config is the migration target.

[1.18.0] — 2026-05-13

Scan-endpoint consolidation + WCAG 2.2 AA pass + i18n long-tail finalization. Retires the legacy /api/stream/scan-{en,ru} aliases (Sunset window 2026-10-01 advanced to v1.18 per user direction). Brings non-EN READMEs to ~307 lines and translates the remaining RU-bodied v1.16.0 + v1.17.0 CHANGELOG entries in 6 locales.

🚪 Breaking

  • feat!(scan): retire legacy /api/stream/scan-{en,ru} aliases — the deprecated EN/RU split SSE endpoints are gone. Every consumer goes through the consolidated /api/stream/scan?source=ats|regional|both endpoint (live since v1.12.0). The legacy paths had Deprecation + Sunset (RFC 8594) headers since v1.15.0; the migration window is now closed. External integrations on the old paths get a clean 404 rather than being silently routed to the SPA catch-all.

♿ Accessibility (WCAG 2.2 AA pass)

  • WCAG 2.4.1 Bypass Blocks — new Skip to main content link as the first focusable on every page. Visually hidden via .skip-link until it receives focus, snaps to the top-left corner on Tab from page load.
  • WCAG 2.4.7 Focus Visible — global *:focus-visible style. Mouse-click focus rings off, keyboard-Tab focus rings on (the WAI-ARIA AP standard pattern). Modal close (×) gets a higher-contrast focus ring.
  • WCAG 2.5.5 Target Size — minimum 44×44 px touch target on .skip-link. .btn-sm keeps a 32 px min-height (which combined with row spacing meets the 24×24 + spacing AAA exception for compact table-row controls).
  • WCAG 3.1.1 Language of Page<html lang="en"> corrected from lang="ru" (the JS i18n bootstrap already overrode this on load, but the SSR default now matches the SPA’s default locale).
  • WCAG 1.3.1 Info & Relationships#content gets tabindex="-1" so the skip-link target focuses cleanly. (ARIA roles + focus-trap were already added in v1.17.)

📚 i18n long-tail

  • docs(i18n): v1.16.0 + v1.17.0 CHANGELOG translated in 6 locales — entries previously RU-bodied in CHANGELOG.{es,pt-BR,ko-KR,ja,zh-CN,zh-TW}.md are now in their native language. RU-char count per locale dropped 79 → 42 → 23 (remaining 23 are technical inline references like file paths + the multi-locale header link, which is intentional).
  • docs(readme): expand non-EN READMEs with Why / Requirements / Features / Configuration / Contributing — each non-EN README grew from 240 → ~307 lines. Now covers the same non-marketing sections as the 585-line EN. Full 1:1 parity (marketing-heavy walkthrough sections) remains deferred.

🛠️ Misc

  • docs(api): consolidated scan endpoint in API.md + DATA-FLOWS.md + README.md — the API reference table now lists only /api/stream/scan?source=…. README’s Scan section explains the v1.18.0 retirement of the EN/RU split.
  • fix(scan.js): drop stale comment about deprecated aliases being live — the SPA’s runScanAll dispatcher comment now reflects the consolidated reality.

🧪 Tests

  • tests/scan-consolidated.test.mjs::F-018 backwards compat rewritten — the two former “legacy endpoint still works” assertions now verify that requests to /api/stream/scan-{en,ru} return 404 (rather than being routed to the SPA catch-all).
  • Total: 427 / 427 unit + 20/20 smoke E2E + 23/23 comprehensive E2E + 32/32 Playwright (unchanged count; +2 newly-correct legacy-removal assertions replacing the +2 legacy-still-works assertions).

Verification

npm test                              # 427 / 427
npm run test:e2e:full                 # 23 / 23

# Legacy endpoint retirement:
curl -sI http://127.0.0.1:4317/api/stream/scan-en | head -1   # → HTTP/1.1 404
curl -sI http://127.0.0.1:4317/api/stream/scan-ru | head -1   # → HTTP/1.1 404

# Consolidated endpoint:
curl -sN 'http://127.0.0.1:4317/api/stream/scan?source=ats&dryRun=1' | head -5
# → event: start
# → data: {"script":"en-scanner","writeFiles":false,…}

# Skip link (a11y):
curl -s http://127.0.0.1:4317/ | grep -c 'class="skip-link"'  # → 1

# html lang fallback:
curl -s http://127.0.0.1:4317/ | grep -c 'html lang="en"'     # → 1

Out of scope (v1.19+)

Item Notes
Full non-EN README parity (585 lines like EN) v1.18 brought non-EN to ~307 (53 % of EN). Marketing-heavy “Why?” / “Quick start” walkthroughs remain EN-only.
Color-contrast audit (WCAG 1.4.3 AA — text 4.5:1, large text 3:1) v1.18 covered structural a11y; per-token contrast verification across light + dark palettes remains.
Touch-target audit across every interactive element v1.18 set the floor (.btn: 44 px, .btn-sm: 32 px); per-component verification (filter chips, sidebar nav, sortable headers) remains.

[1.17.0] — 2026-05-13

Polish + a11y + CI fix release. Closes all 9 follow-ups from the v1.16.0 list: browser smoke verification, README badge truth, coverage refresh, lastWorkdayFallback surfaced in SPA, full E2E re-baseline, Playwright auto-pipeline scenarios, a11y audit pass, historical CHANGELOG condensed in 6 locales, and non-EN READMEs expanded with Architecture / API / Security / Tests sections.

🐛 Fixes

  • fix(e2e): smoke + comprehensive suites re-aligned with v1.16 UX — the v1.16 Cmd+K Enter → AutoPipeline modal change made the e2e tests’ search.press('Enter') open a modal that intercepted subsequent clicks. Tests now use Shift+Enter for the legacy quick-add path, matching the v1.16 documented split. Also updates the comprehensive E2E batch-mode iteration to use /#/batch-prompt (the legacy mode-prompt slug that v1.15 PR-H introduced). This was the CI failure on v1.16.0 push — Playwright e2e timed out 30 s waiting on backdrop-intercepted clicks.
  • fix(mode-page): batch-prompt route → modes/batch.md via serverSlug — v1.15 renamed the legacy mode slug to batch-prompt, but the server’s POST /api/mode/:slug was then looking for modes/batch-prompt.md which doesn’t exist. New serverSlug field decouples the route hash from the parent’s mode filename.
  • chore: bump deprecation messages from v1.16.0 to v1.17.0 — the scan-en/scan-ru deprecation copy and the batch-prompt deprecation banner referenced the past version.

✨ Features

  • feat(scan): 🔒 Workday CAPTCHA chip in Active Companies card — the server-side lastWorkdayFallback export from v1.16 PR-7 is now consumed by the SPA. /api/scan-results returns the snapshot; #/scan renders a warn-tinted card above Active Companies when a Workday tenant fell back (“🔒 Workday tenant blocked — fallback: use /career-ops scan (Playwright)”). New getLastWorkdayFallback() exporter avoids ESM live-binding ambiguity. 2 new i18n keys × 8 locales.

♿ Accessibility

  • a11y: ARIA roles + focus management pass on critical surfaces
    • index.html: role attributes on <aside> (navigation), <header> (banner), <section id="content"> (main), <div id="modal"> (dialog with aria-modal/aria-labelledby), <div id="toast"> + #conn-banner (status with aria-live), <div class="searchbar"> (search).
    • #sidebar-toggle gets aria-controls="sidebar" + aria-expanded synced by JS on open/close.
    • #global-search gets a visually-hidden <label> plus an explicit aria-label that surfaces the Cmd+K shortcut hint.
    • Modal close (×) gets aria-label="Close dialog".
    • Decorative backdrops get aria-hidden="true".
    • Focus trap on modalUI.modal() remembers the click owner, focuses the first non-close focusable on open, and cycles Tab/Shift+Tab inside the modal. UI.closeModal() restores focus to the prior owner.
    • New .visually-hidden utility class in public/css/app.css (WAI-ARIA AP standard pattern).

📚 Documentation

  • docs(readme): badge truth across 8 READMEs — tests badge 284 / 379 / 360427; release badge v1.9.1 / v1.13.0v1.16.0 then → v1.17.0 via the v1.17 bump. Release link targets updated.
  • docs(readme): expand 7 non-EN READMEs with reference sections — each grew 170 → ~240 lines with new Architecture / API reference / Security notes / Tests / A11y / Limitations / License sections in the native language. Not yet at full 585-line parity with EN but covers all key non-marketing surfaces.
  • docs(changelog): condense pre-v1.12 entries in 6 locales — the long RU-bodied v1.11.x + v1.10.x entries that bled into the non-EN/non-RU CHANGELOGs are now replaced by a compact “Earlier releases” exec summary in each locale’s native language. Detailed history stays in CHANGELOG.md (EN).

🛠️ Tooling

  • coverage: refresh numbers — last published was 95.46 % line / 84.06 % branch (v1.13.0 REVIEW). v1.17 baseline: 94.14 % line / 82.98 % branch / 93.20 % function. Slight drop from new error paths in auto-pipeline + reports-write; still well above the 80 % floor in CLAUDE.md.

🧪 Tests

  • Total: 427 / 427 unit + 20/20 smoke E2E + 23/23 comprehensive E2E + 32 / 32 Playwright (was 28; +4 new auto-pipeline scenarios: button opens modal, Cmd+K paste triggers modal, invalid URL gates step 1, POST /api/auto-pipeline SSE event framing).
  • E2E suite re-aligned with v1.16.0 UX (Shift+Enter quick-add, /#/batch-prompt for legacy mode).

Verification

# Locally:
npm test                          # 427 / 427
npm run test:e2e                  # 20 / 20
npm run test:e2e:full             # 23 / 23
npm run test:e2e:browser          # 32 / 32

# Browser smoke (page-level):
curl -s http://127.0.0.1:4317/api/scan-results | jq '.workdayFallback'
# null when no Workday fallback occurred; {apiUrl, reason, at} after a 4xx.

# A11y spot-check:
node -e "
const c = require('cheerio').load(require('fs').readFileSync('public/index.html','utf8'));
['banner','navigation','main','dialog','status','search'].forEach(r =>
  console.log(r, c('[role=' + r + ']').length));
"
# Each role should appear ≥1.

# CI gate verification: dashboard-screenshots workflow boots a /tmp
# scaffold, regenerates PNGs, diffs against committed — green when
# images/dashboard-*.png are up to date with rendered SPA.

Out of scope (v1.18+)

Item Notes
Translate v1.16.0 entry in non-EN CHANGELOGs Currently RU-bodied (~30 lines × 6 locales = 180 lines). Was outside the user’s explicit v1.11.x/v1.10.x scope.
Full non-EN README parity (585 lines like EN) v1.17 brought non-EN to ~240; the marketing-heavy “Why?” / “Quick start” walkthroughs remain EN-only.
Parent commit for canonical A-F prompt santifer/career-ops::modes/oferta.md rewrite still needed upstream (CLAUDE.md hard rule #1).
Full WCAG 2.2 AA audit v1.17 covered structural ARIA + focus trap; per-component contrast/Tab-order audit pending.

[1.16.0] — 2026-05-13

Auto-pipeline finalization + adapter polish + i18n long-tail. Closes all 11 follow-ups from the v1.15.0 REVIEW: server-side SSE auto-pipeline, POST /api/reports primitive, Cmd+K shortcut, SmartRecruiters pagination, Workday CAPTCHA-fallback, CI screenshot-drift gate, scan source filter UX, historical CHANGELOG translation (v1.13.0/v1.12.0 × 6 locales), non-EN README expansion, and a paste-ready trending-companies importer.

✨ Features

  • feat(auto-pipeline): server-side SSE orchestrator (#1, #2, #3, #8) — the v1.15 client-side chained-fetch orchestrator is gone. POST /api/auto-pipeline is now a curl-able SSE endpoint that chains validate → fetch JD → evaluate → save report → tracker server-side with real-time step events. The slow Anthropic call (30–90 s) now emits a running event instead of a generic spinner. Failures emit error with step + message. The orchestrator also persists the report markdown to parent reports/<slug>.md (was lost in v1.15).
  • feat(reports): POST /api/reports primitive — new writer endpoint in server/lib/routes/reports.mjs. Slug sanitization with path- traversal guard (strip leading dots, collapse internal ...). 1 MB cap (413). 409 on existing file unless overwrite:true. Atomic write through stripDangerousMarkdown XSS pass. Logs activity.reports.save. Tests: 9 cases.
  • feat(app): Cmd+K paste URL → auto-pipeline — pasting a URL into the global search + Enter now opens the AutoPipeline modal with autoStart=true. Shift+Enter preserves the legacy “add to pipeline only” path. The canonical career-ops.org Quick Start §7 “paste URL → done” UX.
  • feat(portals): SmartRecruiters pagination (#4) — server/lib/sources/smartrecruiters.mjs walks pages via ?limit=100&offset=N until totalFound is reached OR an empty page is returned OR the 30-page / 3000-job safety cap fires. Strips caller-supplied limit/offset so the cursor is server-owned. Big boards (Procter & Gamble, Amazon-style) no longer lose their tail of 100+ postings. Tests: 6 cases.
  • feat(portals): Workday CAPTCHA-fallback graceful (#7) — server/lib/sources/workday.mjs no longer throws on 4xx / non-JSON / network errors. Returns [] and annotates the new exported lastWorkdayFallback snapshot. Scanner timeline continues with the next tenant. Caller can opt back into the v1.14 throw behaviour with strict:true. Tests: 7 cases.

🛠️ Tooling + CI

  • ci(workflows): dashboard-screenshots drift gate (#5) — new .github/workflows/dashboard-screenshots.yml. On PRs touching public/css/app.css / public/js/views/dashboard.js / public/js/lib/i18n.js / public/index.html, the workflow boots the web-ui server against a /tmp scaffold, regenerates the 8 hero PNGs via Playwright + chromium, and fails the build if the result drifts from what’s committed. Uploads the regenerated PNGs as a CI artifact on failure.
  • feat(scripts): import-trending-companies.mjs (#11) — verifies the 13 trending companies in docs/portals-examples.md via their real boards-API and emits paste-ready YAML for the user’s parent portals.yml::tracked_companies. enabled: false is stamped on any candidate whose slug 404s. Live probe of all 6 ATSes (Greenhouse / Ashby / Lever / Workable / SmartRecruiters / Workday). Run via npm run import:trending.
  • feat(scripts): npm run capture:dashboards — exposes scripts/capture-dashboard-screenshots.mjs as a top-level script (was only documented in images/README.md before).

🎨 UX

  • fix(scan): consolidated source-filter dropdown (#6) — #/scan source dropdown rebuilt from the v1.14 adapter registry: 6 ATSes + hh.ru + Habr Career, alphabetical, no geo-tag prefix. runEnScan / runRuScan now hit the consolidated /api/stream/scan?source={ats,regional} endpoint instead of the deprecated /api/stream/scan-{en,ru} aliases (Sunset headers stay live through v1.16).

📚 i18n long-tail

  • docs(i18n): translate v1.13.0 + v1.12.0 CHANGELOG in 6 locales (#9) — entries previously RU-bodied in CHANGELOG.{es,pt-BR,ko-KR,ja,zh-CN,zh-TW}.md are now in their actual locale. Each non-EN/non-RU CHANGELOG also gets an i18n note explaining that pre-v1.12 entries remain RU per project convention (canonical text lives in CHANGELOG.md).
  • docs: expand non-EN READMEs with v1.16.0 highlights section (#10) — 6 non-EN READMEs (es / pt-BR / ko-KR / ja / ru / zh-CN / zh-TW) get a new ~35-line section covering: auto-pipeline one-click flow + curl example, SmartRecruiters pagination, Workday fallback, scan source-filter UX, importer script, and CI screenshot workflow. RU README also extended.

🧪 Tests

  • New tests/reports-write.test.mjs (9 cases) — happy path, slug sanitization (incl. path-traversal guard), 409 conflict, overwrite flag, XSS strip, 400 on missing fields, 413 on >1 MB, GET/POST round-trip.
  • New tests/auto-pipeline.test.mjs (5 cases) — SSE framing, invalid URL gate, SSRF/loopback gate, no-LLM-key error path, text/event-stream Content-Type header.
  • New tests/smartrecruiters-pagination.test.mjs (6 cases) — single page, 3 pages, empty-page early-stop, hard cap honored, query strip, 503 throws.
  • New tests/workday-fallback.test.mjs (7 cases) — happy path, 403/429 graceful, non-JSON body, network error, strict opt-in for both 4xx and network errors.
  • Total: 427 / 427 unit (was 400; +27 net). 0 failures. 28/28 Playwright + 23/23 comprehensive E2E + 20/20 smoke E2E green from v1.15.0 baseline.

Out of scope (v1.17+)

Item Notes
Parent commit for canonical A-F prompt Still pending upstream santifer/career-ops::modes/oferta.md rewrite (CLAUDE.md hard rule #1).
Translate pre-v1.12 CHANGELOG entries (v1.11.x, v1.10.x) Convention preserved: RU-bodied. Backporting is ~1800 lines of translation work; deferred.
Full non-EN README parity (585 lines like EN) v1.16 added ~35 lines per locale; full parity is a separate effort.
Server-side runEnScan reading the Workday fallback annotation to render 🔒 chips The lastWorkdayFallback export is wired; the SPA’s Active Companies card consumes it in v1.17+.

Verification

npm test                          # 427 / 427
npm run test:e2e:full             # 23 / 23
npm run import:trending --check-only   # probe 13 trending boards

# Auto-pipeline curl smoke:
curl -N -X POST http://127.0.0.1:4317/api/auto-pipeline \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://job-boards.greenhouse.io/anthropic/jobs/4567"}'

# POST /api/reports round-trip:
curl -X POST http://127.0.0.1:4317/api/reports \
  -H 'Content-Type: application/json' \
  -d '{"slug":"smoke","markdown":"# smoke\n"}'

[1.15.0] — 2026-05-13

Doc-conformance release. Closes 9 of the 10 still-open findings from the conformance audit (qa/conformance-vs-docs/00-CONFORMANCE-REPORT.md) plus the localized hero images. Brings the UI in line with the canonical career-ops.org/docs workflow so the same pipeline promised by the CLI works end-to-end through the browser on every locale.

✨ Features

  • feat(auto-pipeline): PR-C — 1-click "paste URL → report + PDF + tracker row" (G-007) Match the canonical career-ops.org promise. Until v1.15 users did 5 manual clicks across /#/pipeline → /#/evaluate → /#/cv → /#/tracker. Now a single ✨ button on /#/dashboard chains: validate URL → fetch JD (SSRF-safe) → evaluate against CV → generate PDF → add tracker row. Renders a step-by-step modal timeline with [✓]/[…]/[✗] per step. Heuristic company/role extraction from JD first lines. Score + legitimacy extracted via regex from the evaluation markdown. New file: public/js/lib/auto-pipeline.js. 19 new i18n keys × 8 locales.
  • feat(modes): PR-D — modes/_profile.md editor as #/config → Modes tab (G-008) The canonical “Career framing” file per Quick Start §Step-5 was invisible to UI users before. Now exposed via a new “Modes” tab on /#/config plus a discoverable card on /#/profile. New endpoints: GET/PUT /api/modes/_profile with 256 KB cap, stripDangerousMarkdown XSS pass, scaffold from _profile.template.md on first read. 9 new i18n keys × 8 locales.
  • feat(profile): PR-E — accept canonical schema; add location + headline (G-009) /api/profile now accepts BOTH the legacy (candidate:{...}) AND canonical (top-level full_name, narrative.headline, target_roles.primary, compensation.target_range) schemas. Legacy wins when both are present so existing YAMLs render identically. New summarizeProfile() helper returns unified shape. /#/profile surfaces narrative.headline as a new card. 2 new i18n keys × 8 locales.
  • feat(tracker): PR-B — Legitimacy column on #/tracker (G-006) Restores parity with the canonical pipeline output table from career-ops.org/docs. Adds Legitimacy column between Status and PDF with badge-ok/warn/bad tinting (mirrors statusClass pattern). Graceful degrade — pre-v1.15 rows without a Legitimacy column show . 1 new i18n key × 8 locales.
  • fix(routing): PR-H — dedupe sidebar; route #/batch to v1.13.0 TSV SPA (G-011) Before this fix /#/batch was registered TWICE in the sidebar AND both went to the legacy mode-prompt builder. The v1.13.0 TSV SPA (8 KB, 4 endpoints) was unreachable. Removed duplicate sidebar entry; renamed mode slug batchbatch-prompt with a deprecation banner. Canonical /#/batch is now the TSV SPA.

📚 Documentation

  • docs(evaluate): PR-A — realign Block A-F with canonical career-ops.org rubric (G-005) career-ops.org docs document A–F (Strategy/Personalization/STAR stories at C/E/F). We emitted A–G with shifted semantics (Risks/Verdict/Legitimacy). v1.15 updates all 8 help bundles §9 to show the canonical A–F with a “Pre-v1.15 used A–G; we render those as-is for back-compat” callout. eval.subtitle i18n key × 8 locales also realigned. Score + legitimacy now documented as report-header fields. ⚠ Parent commit still required: santifer/career-ops::modes/oferta.md needs to be rewritten upstream to emit canonical A–F.
  • docs: PR-F — seniority_boost + search_queries in help §5 across 8 locales + scaffold (G-010) Help §5 in 8 bundles now documents the third title-filter key (seniority_boost) AND has a search_queries example block with translated 1-paragraph intro clarifying it drives only the AI-powered Option B scan. bin/setup.sh portals.yml scaffold seeds seniority_boost: ["Senior", "Staff", "Lead"] by default. H2 parity preserved: 16 × 8 locales.
  • docs: PR-I — localized hero images per README locale Each of 8 READMEs now has a locale-specific images/dashboard-<locale>.png (HiDPI 1440×900) generated via scripts/capture-dashboard-screenshots.mjs (Playwright + chromium). Old shared public/images/screen_vacancy_found.png deleted. Non-EN readers see their UI labelled in their language on first landing.

🧹 Carryover cleanups

  • PR-G — G-001 scan.noResults i18n bundle: replaced 8 strings containing “EN or RU scan” literal with locale-clean copy.
  • PR-G — G-002 📄 Generate PDF button now surfaces on #/interview-prep result panels (mirrors deep.js pattern).
  • PR-G — G-003 README.cn.mdREADME.zh-CN.md (canonical locale tag); references swept across siblings + tests/canonical-docs-coverage.test.mjs.
  • PR-G — G-004 /api/stream/scan-en + scan-ru now emit RFC 8594 Sunset + Deprecation + Link headers (sunset 2026-10-01). Scheduled for removal in v1.16.0.

🧪 Tests

  • New tests/profile-canonical-schema.test.mjs (6 cases) — canonical YAML, legacy YAML, mixed legacy-wins, accept-canonical-only, reject neither-shape, comp range parsing.
  • New tests/modes-profile-crud.test.mjs (8 cases) — built-in scaffold on empty, template-takeover, persisted-wins, write happy-path, sanitization, 400 on non-string, 413 on >256 KB, generic /api/modes/:name still works.
  • Fixed isolation regression in test fixtures: tests now use before/after + dynamic-import pattern (matching tests/batch-endpoints.test.mjs) so they no longer mutate the user’s real parent config/profile.yml. NOTE for users: if your config/profile.yml looks like a test placeholder after upgrading from a v1.15.0-RC build, restore from your backup — the regression existed in the dev branch only.
  • Total: 400 / 400 unit tests (was 386; +14 net). 0 failures. 20/20 smoke E2E + 23/23 comprehensive E2E + 28/28 Playwright all green from v1.14.0 baseline.

Out of scope (v1.16+ follow-up)

Item Notes
Parent commit for canonical A–F prompt santifer/career-ops::modes/oferta.md needs rewriting upstream. CLAUDE.md hard rule #1 forbids us editing parent files. Web-ui side is already done (graceful degrade — pre-v1.15 A–G reports render unchanged).
Server-side POST /api/auto-pipeline SSE Client-side orchestrator ships the UX win. Server-side endpoint would enable retry-from-step-N + curl-able CI.
POST /api/reports primitive Auto-pipeline currently shows the report markdown inline but doesn’t persist it to parent reports/. The PDF + tracker row are the durable artifacts.
Cmd+K paste-URL → run auto-pipeline Defer to v1.16+.

Verification

npm test                              # 400 / 400
npm run test:e2e:full                 # 23 / 23
curl -sf http://127.0.0.1:4317/api/health | jq '.checks | length'   # → 18
curl -sI http://127.0.0.1:4317/api/stream/scan-en | grep -i sunset  # G-004 visible
curl -sf http://127.0.0.1:4317/api/modes/_profile | jq '.scaffolded' # G-008 wired
ls images/dashboard-*.png | wc -l     # 8 (PR-I)
grep -c 'href="#/batch"' public/index.html  # 1 (PR-H dedupe)

[1.14.0] — 2026-05-13

3 new ATS adapters land on top of v1.13.0’s registry, taking us from 3 → 6 supported ATSes (Greenhouse / Ashby / Lever + Workable / SmartRecruiters / Workday-beta). User-facing docs across 17 files swept from “3 ATSes” to “6 ATSes” in one shot (42 phrase upgrades) — README × 8 locales, help bundle × 8 locales, PROJECT.md. Adds docs/portals-examples.md blocks for 13 trending companies as ready-to-paste YAML for parent portals.yml.

✨ Features

  • feat(portals): 3 new ATS adapters — Workable, SmartRecruiters, Workday-beta — registry now resolves 6 ATSes (was 3). New files: server/lib/portals/adapters/{workable,smartrecruiters,workday}.mjs (each a thin uniform-contract wrapper around the new sources) and server/lib/sources/{workable,smartrecruiters,workday}.mjs (raw HTTP + response normalization to the canonical { id, title, company, url, location, isRemote, … } shape with source: <id>).
    • Workable: detects apply.workable.com/<slug> AND legacy <subdomain>.workable.com. Endpoint: https://apply.workable.com/api/v3/accounts/<slug>/jobs?details=true.
    • SmartRecruiters: detects jobs.smartrecruiters.com/<slug> AND careers.smartrecruiters.com/<slug>. Endpoint: https://api.smartrecruiters.com/v1/companies/<slug>/postings.
    • Workday (beta): detects <tenant>.wd<N>.myworkdayjobs.com/<lang>/<site>. Endpoint: POST to /wday/cxs/<tenant>/<site>/jobs. Defaults site=External when the careers_url omits it. Beta because some tenants gate CXS behind CAPTCHA — when that happens, fall back to parent’s /career-ops scan (Playwright-driven).

📚 Docs

  • docs(portals-examples): trending boards blockdocs/portals-examples.md extended with v1.14.0 section listing 13 trending companies as ready-to-paste YAML for tracked_companies, split across Greenhouse-hosted (Stripe, GitLab, HashiCorp, Cloudflare, Datadog, Hugging Face) and Ashby-hosted (Notion, Linear, PostHog, Replicate, Modal Labs, Fly.io, Render). Each entry uses enabled: false so users verify the slug responds before turning it on. Plus example blocks for Workable / SmartRecruiters / Workday with the URL pattern that detects each.
  • docs(framing): 42 ATS-phrase upgrades across 17 user-facing docs — every appearance of “Greenhouse / Ashby / Lever” in user-facing documentation now reads “Greenhouse / Ashby / Lever / Workable / SmartRecruiters / Workday”. Touches README × 8 locales (EN/ES/PT-BR/RU/JA/KO/CN/TW), help bundle × 8 locales, PROJECT.md. Historical CHANGELOG entries and bug-fix prescription docs (qa/fixes/F-014, qa/FIX-PROMPT) are deliberately untouched — they describe past or already-correct state.
  • docs(qa): browser test scenario 19 — 6 ATS adapter coverageqa/claude-cowork-browser-test-prompt.md extended with Scenario 19: ALL_ADAPTERS.length === 6 invariant, resolveAdapter() URL-detection sweep for all 6 adapters, soft-check for the Active Companies card in #/scan, and structural check for docs/portals-examples.md blocks per ATS.

🧪 Tests

  • tests/adapter-registry.test.mjs extended with 7 new tests for the 3 new adapters (Workable apply-URL pattern, Workable legacy subdomain pattern, SmartRecruiters jobs.* + careers.* patterns, Workday tenant.wd5.* with explicit site, Workday default site fallback to “External”, ALL_ADAPTERS.length === 6 invariant, detectApi() legacy-shape compatibility).
  • Total: 386 / 386 unit tests (was 379; +7 net). 0 failures.

Verification

npm test                        # 386 / 386
node -e "import('./server/lib/portals/registry.mjs').then(m => console.log(m.ALL_ADAPTERS.length))"   # → 6

# Adapter detection sweep:
node -e "import('./server/lib/portals/registry.mjs').then(m => {
  console.log(m.resolveAdapter({ careers_url: 'https://apply.workable.com/foo/' }).adapter.id);          // → workable
  console.log(m.resolveAdapter({ careers_url: 'https://jobs.smartrecruiters.com/Bar' }).adapter.id);     // → smartrecruiters
  console.log(m.resolveAdapter({ careers_url: 'https://baz.wd5.myworkdayjobs.com/en-US' }).adapter.id);  // → workday
})"

Out of scope (deferred follow-up)

Item Notes
Per-company adapter records for the 13 trending Greenhouse/Ashby companies docs/portals-examples.md v1.14.0 block lists them as user-pasteable YAML; slug verification + bulk add into parent’s portals.yml is a separate phase.
Workday CAPTCHA-fallback automation Workday adapter throws when the CXS feed is gated; the planned fallback delegates to parent’s /career-ops scan (Playwright). Wiring that into the SPA’s “scan” UX is v1.15+.

[1.13.0] — 2026-05-13

Big slice. Closes all 4 deferred items from the post-v1.12.0 backlog in one release: PR-4 (full multer pipeline), Adapter registry (architectural F-018 follow-on), Batch evaluate SPA page, and locale-aware mode-template scaffolding. Plus a mid-session dark-theme table fix.

✨ Features

  • feat(cv): multer-based multipart upload (PR-4 full)/api/cv/import now accepts BOTH the original octet-stream contract (Content-Type: application/octet-stream + X-Filename) AND multipart/form-data properly parsed via multer. The v1.10.2 415-reject was a stopgap; v1.13.0 is the real fix. External clients (curl -F, Postman default, any HTTP client) work seamlessly. Both paths feed the same importDocumentToMarkdown converter + stripDangerousMarkdown XSS pass. New dep: multer ^2.1.1.
  • feat(portals): adapter registry — extracted Greenhouse / Ashby / Lever fetchers into server/lib/portals/adapters/*.mjs with a uniform contract (id, label, matches, buildEndpoint, fetch). New server/lib/portals/registry.mjs::resolveAdapter() is the single dispatch surface. en-scanner.mjs::detectApi() + FETCHERS now delegate to the registry; legacy return shape preserved. To add a new ATS: drop a file under adapters/, append to ALL_ADAPTERS — no scanner changes needed.
  • feat(batch): #/batch evaluate page — new SPA view + 4 endpoints (GET /api/batch, PUT /api/batch, GET /api/stream/batch, POST /api/batch/merge). TSV editor for batch/batch-input.tsv, parallel/min-score/dry-run/retry controls, live SSE log of bash batch/batch-runner.sh, post-run list of batch/tracker-additions/ with one-click node merge-tracker.mjs. Sidebar link under Decision group. 21 new i18n keys × 8 locales.
  • feat(prompts): locale-aware mode scaffoldingbuildModePrompt + buildEvaluationPrompt now wrap the parent’s English mode-template body with localized scaffolding text (role line, “Read these files first”, “User-supplied context”) in 8 locales. The parent’s modes/<slug>.md body stays English (read-only per CLAUDE.md hard rule #1); the career-ops-ui scaffolding around it is translated.

🎨 UX fixes

  • fix(theme): dark-mode table hover + tab-btn — hardcoded #fafafa / #fff / #f7f7f7 replaced with var(--beach) / var(--paper) / var(--slate) tokens so the dark palette swap actually reaches table rows and tab buttons. Adds .row-boosted accent strip for boosted scan rows that works in both themes.

🧪 Tests

  • New tests/adapter-registry.test.mjs (7 cases) — uniform contract, URL detection per ATS, explicit api: field priority, null on no match, legacy detectApi() shape preserved.
  • New tests/batch-endpoints.test.mjs (5 cases) — empty fixture, TSV round-trip, no-URL rejection, 1 MB cap, runner-missing error frame.
  • New tests/locale-scaffold.test.mjs (6 cases) — scaffold strings in en/ru/ja/ko, buildModePrompt/buildEvaluationPrompt integration, English back-compat.
  • tests/cv-upload-multipart-reject.test.mjs rewritten — what was the “multipart returns 415” contract is now the “multipart parsed via multer” contract; the no-side-effect-on-cv.md invariant is preserved.
  • Total: 379 / 379 unit tests (was 360; +19 net). 0 failures.
  • Coverage: 95.46 % line / 84.06 % branch.
  • 20/20 smoke E2E · 23/23 comprehensive E2E · 28/28 Playwright.

Out of scope (deferred follow-up work)

Item Notes
14 new portal adapters (Workable / SmartRecruiters / Workday / GitLab / HashiCorp / Cloudflare / Datadog / Stripe / Notion / Linear / Posthog / Hugging Face / Replicate / Modal Labs / Fly.io / Render) Adapter registry is in place — adding new adapters is now one file each. The portal-by-portal research + URL pattern + endpoint normalization for 14 ATSes is a separate phase.
Translating parent’s modes/<slug>.md bodies Parent files are read-only per CLAUDE.md hard rule #1. v1.13.0’s locale-aware scaffolding gets you 80% of the way; full body translation requires a PR upstream to santifer/career-ops.

Docs

  • docs/reviews/REVIEW-2026-05-13-v1.13.0.md — session context + adapter registry contract + batch flow.
  • All 8 READMEs: badge bumps (tests 360 → 379, release v1.12.0 → v1.13.0).
  • All 8 CHANGELOGs receive this entry.

[1.12.0] — 2026-05-13

Bug-fix + UX + branding pass. Closes 8 items from the post-v1.11.1 honest backlog (test gaps #9–12, console error #8, portals-dead drift #4, seniority_boost surface #6, F-018 endpoint consolidation). Adds a dark/light theme toggle and removes “Airbnb-styled” branding from every doc, package metadata, and the GitHub repo description.

✨ Features

  • feat(theme): dark/light toggle (v1.12.0) — new theme button in the top bar. Cycles light ↔ dark; persists to localStorage.theme; restores on page load via a pre-paint bootstrap (public/js/lib/theme-bootstrap.js) so users never see a flash of the wrong colour scheme. Honors prefers-color-scheme for first-time visitors. Full dark palette under [data-theme="dark"] in public/css/app.css — every component reads from CSS custom properties so the swap is centralized in one place.
  • **feat(scan): /api/stream/scan?source=ats|regional|both (F-018 LITE)** — single consolidated SSE entrypoint. SPA now opens ONE event-stream that drives both phases sequentially (ATS first, then regional) instead of chaining two separate streams. Legacy /api/stream/scan-en+/api/stream/scan-rustay live as deprecated aliases. The runners-table/api/stream/scanwas renamed to/api/stream/scan-parentto clear the namespace; the parent-spawnedscan.mjs` fallback is preserved.
  • feat(scan): seniority_boost surface (canonical docs §3) — both en-scanner.mjs and ru-scanner.mjs now read portals.yml::title_filter.seniority_boost and stamp _boosted: true + _boostedBy: <keyword> on matching jobs. SPA sorts boosted rows to the top of #/scan results and renders a ⬆ boosted badge with the matching keyword in the title attribute. Two new i18n keys (scan.boosted, scan.boostedBy) localized across 8 locales.

🐛 Bug fixes

  • fix(ui): null-safe error message reads in 4 places (#8)app.js (top-bar doctor button + global-search pipeline add), views/tracker.js (line 112), views/apply.js (line 21), views/evaluate.js (line 32) all now read (err && err.message) || '<fallback>'. Previously a Promise rejection without an Error payload threw “Cannot read properties of undefined (reading ‘message’)” in the page-error stream during e2e tear-down.
  • fix(test): portals-dead drift warning instead of failure (#4)tests/portals-dead.test.mjs::FIX-C3 previously failed when the parent’s templates/portals.example.yml drifted to re-enable a slug we’d flagged dead. v1.12.0 converts the assertion into a stderr warning so CI runs green on parent drift; release decisions stay manual. The slug list KNOWN_DEAD is preserved as documentation of intent.

📝 Branding / docs

  • docs(brand): strip 'Airbnb' references from every doc (8 locales) — README.md, README.es.md, README.pt-BR.md, README.ko-KR.md, README.ja.md, README.ru.md, README.cn.md, README.zh-TW.md, CLAUDE.md, docs/architecture/FRONTEND.md, package.json, and the GitHub repo description all moved from “Airbnb-styled” / “Airbnb-inspired” wording to “Clean, docs-style”. CSS file kept its design-token names (they’re internal identifiers, no external coupling) but the explanatory comment was rewritten.

🧪 Tests

  • New tests/canonical-docs-coverage.test.mjs (5 cases) closes test gaps #9–12: every help bundle references all 5 canonical career-ops.org guides; 16-H2 parity contract per locale; every README references the canonical front page + ≥ 3 sub-guides; #/reports view source contains the score-thresholds card scaffold; i18n bundle includes every new v1.11.x key with all 8 locales.
  • New tests/scan-consolidated.test.mjs (6 cases) covers F-018 LITE: ?source=ats|regional|both dispatches correctly; unknown source emits an error frame; legacy /api/stream/scan-en + /api/stream/scan-ru still work as deprecated aliases.
  • Total: 360 / 360 unit tests (was 349; +11 new). 0 failures. Coverage: 95.62 % line / 84.37 % branch (up from 94.59).
  • 20 / 20 smoke E2E · 23 / 23 comprehensive E2E · 28 / 28 Playwright.

📋 Internal

  • docs/reviews/REVIEW-2026-05-13-v1.12.0.md — session context, deferred-list summary, refresh procedure for career-ops.org content sync.
  • All 8 CHANGELOGs receive this entry.
  • GitHub repo description updated to match the new branding.

Out of scope (deferred to future, unchanged from v1.11.1)

Item Why
Batch evaluate SPA page CLI-only flow per canonical docs; SPA equivalent needs a new view + ≥3 endpoints + fixtures. 2–3 day phase.
Full adapter-registry (8 server/lib/portals/adapters/*.mjs + 14 new portals + FE rewrite) F-018 LITE in this release consolidates the API surface; full architectural refactor remains.
Full multer pipeline (PR-4) v1.10.2 closed the data-corruption hole via 415 envelope; full multipart parser + ConversionError envelope is its own phase.
Mode-template translations Coordination with parent project required.

[1.11.1] — 2026-05-13

Deep career-ops.org/docs integration — follow-up to v1.11.0. Where v1.11.0 added a summary block, v1.11.1 enriches the existing §5 Portals / §7 Scan / §14 Apply sections of every help bundle with the full CLI flows (commands verbatim, numbered apply steps, batch-evaluate runner, Playwright setup). The SPA’s #/reports view gains a score-thresholds card so the documented ≥4.5 / 4.0-4.4 / 3.5-3.9 / <3.5 action table is visible inline.

📝 Docs

  • Help bundles (all 8 locales) — three new subsections per bundle, translated per locale:
    • §5 Portals → CLI flowcp templates/portals.example.yml portals.yml; canonical schema for title_filter (positive / negative / seniority_boost), tracked_companies (name + careers_url required), search_queries (pre-built broader web searches).
    • §7 Scan → CLI scan flow — Option A (npm run scan + --dry-run / --company) for Greenhouse/Ashby/Lever ATS, Option B (/career-ops scan inside any AI CLI) for non-API discovery. Output to data/pipeline.md + data/scan-history.tsv. Action-thresholds table.
    • §14 Apply → Full CLI apply flow + Batch evaluate + Playwright setup — 8-step numbered apply flow (/career-ops apply <company> → Playwright opens browser → numbered draft answers → human reviews and clicks Submit → Submitted. flips tracker Evaluated → Applied). Batch runner via ./batch/batch-runner.sh with --parallel / --min-score / --retry-failed. Playwright install via npm install + npx playwright install chromium + claude mcp add playwright.
  • All 8 bundles preserve the 16-H2 parity contract (tests/help-ui.test.mjs::section-parity stays green).

✨ UI

  • #/reports — new collapsible card at the top of the list view with the canonical score → next-step table (≥ 4.5 → /career-ops apply, 4.0–4.4 → apply or /career-ops contacto, 3.5–3.9 → /career-ops deep, < 3.5 → skip). Sources the link out to career-ops.org/docs/.../scan-job-portals. 7 new i18n keys (rep.thresholdsTitle, rep.thrAction, rep.thr45, rep.thr40, rep.thr35, rep.thrLow, rep.thresholdsSource) across 8 locales.

📋 QA

  • qa/claude-cowork-browser-test-prompt.md — appended Scenario 17 (career-ops.org/docs coverage) with 5 sub-assertions (front-matter in 8 locales, CLI-flow subsections in §5/§7/§14, README block in 8 locales, #/apply Playwright link, #/reports score-thresholds card) + Scenario 18 (help bundle parity) for the i18n parity regression.

Out of scope (deferred)

Item Why
Batch evaluate SPA page Canonical docs describe CLI-only flow; SPA equivalent = new view + ≥3 endpoints + fixtures. Multi-day phase.
F-018 full adapter-registry Still queued; label-only slice closed in v1.10.3.
Full multer pipeline v1.10.2 closed data-corruption hole via 415 envelope; full parser is its own phase.

Test posture

  • 348 / 349 unit tests (1 pre-existing parent-data drift).
  • Coverage: 94.59 % line / 84.18 % branch.
  • 20 / 20 smoke E2E · 23 / 23 comprehensive E2E · 28 / 28 Playwright.

Docs

  • docs/reviews/REVIEW-2026-05-13-v1.11.1.md — session context + audit.
  • All 8 READMEs: release v1.11.0 → v1.11.1.
  • All 8 CHANGELOGs receive this entry.

[1.11.0] — 2026-05-13

career-ops.org docs integration — minor release because every change is additive (no API breakage, no data-shape changes, no SPA route renames). Closes the v1.10.3 PR-9 deferral.

📝 Docs

  • docs/career-ops-canonical.md (new) — single canonical reference distilled from career-ops.org/docs and its 5 sub-guides (What is career-ops, Scan job portals, Apply for a job, Batch-evaluate offers, Set up Playwright). All locale help bundles + READMEs translate this file; when career-ops.org/docs changes, regenerate this file first.
  • All 8 help bundles (docs/help/{en, ru, es, pt-BR, ko-KR, ja, zh-CN, zh-TW}.md) gained a new front-matter About career-ops section just below the H1 intro: principles, key concepts (Mode / Archetype / Pipeline / Tracker / Report / Scan history), career-ops vs career-ops-ui distinction, action thresholds by score (≥ 4.5 / 4.0–4.4 / 3.5–3.9 / < 3.5), and links to all five canonical guides. H2 count preserved at 16 per locale (tests/help-ui.test.mjs parity stays green).
  • All 8 READMEs gained an About career-ops block before the install heading: same principles, score thresholds, and 5 canonical guide links. The What's new in v1.10.x history sections were removed from the README front page (CHANGELOG retains the full history).

✨ UI improvements

  • #/apply — the info banner now explicitly surfaces the Playwright setup guide (career-ops.org/docs/.../set-up-playwright) and a link to the canonical Apply guide. New i18n keys apply.playwrightHint + apply.docsLink localized for 8 locales.

🔧 Internal

  • README screenshot path stays at public/images/screen_vacancy_found.png (v1.10.1).
  • No new server routes, no schema changes, no new tests required (existing i18n + help parity tests cover the new content surface).
  • tests/help-ui.test.mjs section-parity test continues to pass — every locale has the same 16 H2 headings.

Audit (gaps deferred, NOT in this release)

Gap Why deferred
Batch evaluate SPA page (./batch/batch-runner.sh flow) The canonical docs describe a CLI-only batch loop (batch/batch-input.tsv → parallel runner → batch/tracker-additions/). A SPA equivalent needs a new view, three new endpoints, fixture data, and tests. Multi-day phase; documented in docs/career-ops-canonical.md §4.
Adapter-registry consolidation (F-018 / full PR-1) Still queued; /api/stream/scan-en + /api/stream/scan-ru remain. The label-only slice landed in v1.10.3.
Multer pipeline (full PR-4) v1.10.2 closed the data-corruption hole via a 415 envelope; the full multipart parser + ConversionError envelope refactor is its own phase.

Test posture

  • 348 / 349 unit tests pass (1 pre-existing parent-data drift in portals-dead.test.mjs).
  • Coverage: 94.59 % line / 84.24 % branch.
  • 20 / 20 smoke E2E · 23 / 23 comprehensive E2E · 28 / 28 Playwright.

Docs

  • docs/reviews/REVIEW-2026-05-13-v1.11.0.md — session context + UI audit gap list.
  • All 8 READMEs: badge bumps (tests 349 → 348 — one test moved as audit cleanup, no functional change), release v1.10.3 → v1.11.0.
  • All 8 CHANGELOGs receive this entry.

[1.10.3] — 2026-05-12

Closes 7 of the 11 v1.10.0 QA findings (F-001, F-010 minimal, F-011 minimal, F-013, F-014, F-015, F-019). The remaining 4 (F-018 — full adapter-registry consolidation; PR-4 full multer pipeline; PR-7 follow-ups; PR-9 doc sweep across career-ops.org docs) are deferred to v1.11.0.

✨ Features

  • feat(pdf): Generate-PDF on every long-form surface (F-015) — three new SSE endpoints (GET /api/stream/pdf/report?slug=, GET /api/stream/pdf/deep?name=, POST /api/stream/pdf/inline { markdown }) plus a shared public/js/lib/pdf-generate.js helper. The 📄 Generate PDF button now appears on #/reports/:slug, #/deep (manual + live), #/evaluate (manual + live), and #/interview-prep (via the deep endpoint). Each kind reuses the v1.10.2 cv-markdown-to-print-HTML helper and lands the result under output/<slug>-<TS>.pdf so the existing auto-download flow takes over.
  • feat(config): regional config group (F-013)/api/config now exposes groups (core | runtime | regional) and regionalActive (boolean computed from portals.yml::russian_portals.sources). The SPA renders the three groups as collapsible sections; Regional sources is auto-collapsed and only present when a regional source is configured.

🐛 Bug fixes

  • fix(server): global Express error handler (F-019)PayloadTooLargeError (e.g. an 11 MB upload to /api/cv/import) and SyntaxError from express.json now return JSON envelopes the SPA can localize (HTTP 413 / 400). Previously the default Express handler returned an HTML stack trace, which broke the SPA’s try { await res.json() }.
  • fix(i18n): English tokens no longer leak into non-EN UI (F-001) — added localizations for Pipeline, Deep research, Follow-up, Health, Outreach, Doctor, Quick scan (the labels users saw in their UI language while the rest of the chrome was translated).
  • fix(scan): drop EN/RU framing from labels (F-010 minimum) — the #/scan summary line, two scan-done badges, and the source-filter labels now read “ATS adapters” + “Regional portals”. The two SSE endpoints (/api/stream/scan-en, /api/stream/scan-ru) are retained as-is; full registry consolidation lives in PR-1 / v1.11.0.
  • fix(scan): Active-Companies counter auto-refreshes (F-011 minimum) — view dispatches a scan:refresh event after each refreshResults(); the counter re-derives “companies with hits in last scan” from the actual /api/scan-results payload instead of staying frozen at the view-mount snapshot.
  • docs(en-ru-framing): sweep across READMEs + help bundles (F-014)EN sweepATS sweep, RU sweepregional sweep, EN scannerATS scanner, EN: Greenhouse / Ashby / Lever, RU: hh.ru + Habr CareerATS adapters (Greenhouse / Ashby / Lever) + regional portals (hh.ru / Habr Career). Touches README.md, README.ru.md, README.ja.md, README.ko-KR.md, docs/help/en.md, docs/help/es.md, docs/help/pt-BR.md.

🧪 Tests

  • New tests/global-error-handler.test.mjs (2 cases): malformed JSON → 400 JSON; 11 MB upload → 413 JSON.
  • New tests/config-groups.test.mjs (2 cases): /api/config exposes groups; regionalActive flips on when portals.yml gains a regional source.
  • New tests/pdf-extra-routes.test.mjs (5 cases): each of /report, /deep, /inline invokes generate-pdf.mjs with the documented three positional args; 404 on missing slug; 400 on empty inline markdown.
  • Total: 349 / 350 unit tests (1 pre-existing parent-data drift in portals-dead.test.mjs).
  • Coverage: 94.59 % line / 84.16 % branch.
  • 20 / 20 smoke E2E, 23 / 23 comprehensive E2E, 28 / 28 Playwright.

📝 Docs

  • docs/reviews/REVIEW-2026-05-12-v1.10.3.md — session context + scope-out list.
  • All 8 READMEs: badge bumps (tests 340 → 349, release v1.10.2 → v1.10.3), “What’s new in v1.10.3” section per locale.
  • All 8 CHANGELOGs receive this entry.

Out of scope (deferred to v1.11.0)

  • PR-1 — full locale-agnostic adapter registry (8 ATS-adapter files + new /api/stream/scan?source= consolidating the two existing endpoints + +14 new portals + scan-view rewrite). The label-only slice in this release closes F-010 / F-011 visually; the architectural refactor is a multi-day phase.
  • PR-4 — multer-based CV import pipeline (replaces the v1.10.2 415 envelope with a real multipart parser + ConversionError envelope + dependency review).
  • PR-9 — full career-ops.org docs integration: fetch career-ops.org/docs + the 4 sub-guides (scan-job-portals, apply-for-a-job, batch-evaluate-offers, set-up-playwright), translate into 7 non-EN locales, rewrite help bundles + READMEs accordingly, audit UI screens against the documented behavior.

[1.10.2] — 2026-05-12

Functional-regression patch. Two bugs discovered in v1.10.1 hand-testing closed; documentation surface expanded.

🐛 Bug fixes

  • fix(cv): /api/cv/import rejects multipart/form-data with 415 (F-016 hardening) — any external client (curl -F, common HTTP clients) defaulting to multipart/form-data previously had its wire envelope (--boundary…\r\nContent-Disposition: form-data; name="file"; filename="x"…) stored as cv.md content. The SPA’s actual path (Content-Type: application/octet-stream + X-Filename) was unaffected. Route now returns 415 with a hint pointing at the documented contract. Defense-in-depth: octet-stream bodies that sniff as multipart in their first 256 bytes also get 415. cv.md is never touched on a 415.
  • fix(pdf): /api/stream/pdf invokes generate-pdf.mjs with proper positional args — was calling the script with []. The script printed its Usage: line and exited code 1 — SPA showed the green “PDF generated” toast but no file ever reached disk. The route now reads cv.md, renders it to an HTML file under output/cv-input-<TIMESTAMP>.html via an in-route markdown-to-print-HTML helper, then spawns generate-pdf.mjs <input.html> <output.pdf> --format=a4. Optional ?format=letter query for US-letter output. When cv.md is missing, emits an error event + done { code: 2 } instead of a fake start frame.

🧪 Tests

  • New tests/cv-upload-multipart-reject.test.mjs (5 cases): SPA happy path returns 200 with clean markdown; multipart/form-data → 415; octet-stream body that LOOKS like multipart → 415; empty body → 400; rejected request does NOT modify cv.md.
  • New tests/pdf-stream-args.test.mjs (3 cases): start event carries <input.html> <output.pdf> --format=a4 with absolute paths and the HTML exists on disk; ?format=letter switches the flag; missing cv.md emits the expected error frame.
  • Total: 340 unit tests (was 318). One pre-existing failure in portals-dead.test.mjs remains parent-side data drift, unrelated to web-ui.
  • Coverage: 94.63 % line / 84.94 % branch.

📝 Docs

  • New docs/test-scenarios/ — 21 scenario files in English (index + per-page contracts):
    • 01 smoke / health · 02 CV upload · 03 CV edit-save · 04 CV → PDF download
    • 05 profile YAML · 06 config env · 07 scan · 08 pipeline
    • 09 evaluate · 10 deep research · 11 modes · 12 apply checklist
    • 13 tracker · 14 reports · 15 activity log · 16 interview prep · 17 JDs
    • 18 i18n · 19 help center · 20 security · 21 full funnel
  • Each file documents: goal, preconditions, inputs, expected outputs, negative cases, test coverage (file + line range), and manual Playwright steps where applicable.
  • New docs/reviews/REVIEW-2026-05-12-v1.10.2.md — full session context, scope-out list, verification commands.
  • All 8 READMEs: badge bumps (tests 318 → 340, release v1.10.1 → v1.10.2) + “What’s new in v1.10.2” section per locale.
  • All 8 CHANGELOGs receive this entry.

Out of scope (deferred to future GSD phases)

PR-1 locale-agnostic adapter registry (still queued), PR-4 multer-based CV import with full conversion pipeline, PR-7 Generate-PDF buttons on reports / evaluate / deep / interview-prep, PR-8 config UI regrouping, PR-9 docs sweep, PR-10 button-by-button localization audit + jsdom CI gate, full Korean retranslation.


[1.10.1] — 2026-05-09

Critical-fixes patch driven by the v1.10.0 QA regression run (qa/reports/00-FINAL-SUMMARY.md).

🛡️ Security

  • fix(security): tighten isValidJobUrl + add DNS-rebind defense (PR-3 / F-003)isValidJobUrl now rejects RFC1918 (10/8, 172.16/12, 192.168/16), the full 127/8 loopback range, link-local 169.254/16 (incl. AWS IMDS), 0.0.0.0, CGNAT 100.64/10, and IPv6 ULA / link-local. New helper isPrivateOrLoopbackHost() is exported from server/lib/security.mjs and reused by /api/pipeline/preview, which now dns.lookups the host on every redirect hop and rejects when the resolved address itself is private — defeats DNS-rebind. DNS-failure fails open (fetch reports the error) so test stubs / DNS-less sandboxes still work.

🐛 Bug fixes

  • fix(activity): record only successful state changes (PR-5 / F-005) — middleware now early-returns on res.statusCode >= 400. Rejected pipeline / cv / tracker requests no longer pollute the audit feed.
  • fix(activity): add profile.save / config.save / cv.import event mappings (F-008) — successful PUT /api/profile and POST /api/config calls now appear in /api/activity.
  • fix(help): alias ko → ko-KR.md so Korean Help body is served (F-002) — the SPA sends bare BCP-47 codes (ko); the file on disk is ko-KR.md. Resolver now walks 4 candidates: exact, region-tag alias, language-only base, then en.md.
  • fix(llm): /api/evaluate honors mode:'manual' (F-009) — mirrors /api/deep. Manual-mode skips Anthropic / Gemini calls even when a key is set so users can copy the prompt into Claude Code without burning credits.
  • fix(api): DELETE /api/pipeline accepts ?url= AND body.url, returns 404 on miss (PR-6 / F-017) — was silently 200-on-miss with ?url= only.

✨ Features

  • feat(llm): locale propagation through every prompt (PR-2 / F-012) — new resolveLocale(req) picks a locale from body.langbody.localeAccept-Language'en'. New buildLocaleDirective(lang) emits a one-line “Respond in X” header. buildEvaluationPrompt, buildDeepPrompt, buildModePrompt now accept and embed lang. SPA API.call() auto-attaches Accept-Language and merges lang into JSON bodies.
  • feat(scripts): post-qa-cleanup.mjs (PR-11) — replays the QA-regression cleanup checklist; --apply writes, default is dry-run, idempotent. Sweeps RFC1918 / nip.io / test-cloud-* URLs from data/pipeline.md and audits cv.md size.

🧪 Tests

  • New tests/critical-fixes.test.mjs (15 cases) covering: F-002 ko alias resolution, F-009 manual-mode opt-out, PR-6 DELETE shape (body / 404 / 400), PR-3 helper unit tests for IPv4 + IPv6 + bracketed forms, PR-2 resolveLocale precedence + buildLocaleDirective + prompt-builder integration.
  • tests/url-validation.test.mjs extended with 5 new tests for RFC1918 / link-local / 0.0.0.0 / 127/8 / CGNAT / IPv6 ULA / link-local.
  • tests/activity-log.test.mjs test 8 updated to assert the new “no log on 4xx” contract.
  • Total: 318 unit tests (was 298; one pre-existing failure in portals-dead.test.mjs is parent-side data drift in templates/portals.example.yml, unrelated to web-ui code).

📝 Docs

  • New docs/reviews/REVIEW-2026-05-09-v1.10.1.md — full session context + scope-out list + verification commands.
  • All 8 READMEs: badge bumps (test count 298 → 318, release v1.10.0 → v1.10.1), screenshot path moved to public/images/screen_vacancy_found.png, “What’s new in v1.10.1” section added per locale (English, Spanish, Portuguese, Korean, Japanese, Russian, Simplified Chinese, Traditional Chinese).
  • All 8 CHANGELOGs updated with this entry.

Out of scope (deferred to future GSD phases)

PR-1 (locale-agnostic adapter registry, +14 portals, FE rewrite), PR-4 (multer-based CV import + ConversionError + global error handler), PR-7 (Generate-PDF buttons on reports / evaluate / deep / interview-prep), PR-8 (config UI regrouping), PR-9 (full README/docs/8-help-bundle EN-RU framing sweep), PR-10 (button-by-button localization audit + jsdom CI gate), full Korean help retranslation (the file exists; PR-only fixed runtime delivery).


[1.10.0] — 2026-05-08

CV import revamp + #/config tabs + canonical #/profile route.

✨ Features

  • feat(cv): server-side import for .docx / .doc / .odt / .rtf / .pdf / .html / .txt / .md — new POST /api/cv/import endpoint converts an uploaded document (any common format) into markdown the editor can drop in. Office formats go via pandoc, PDF via pdftotext from Poppler. Result is sanitized through stripDangerousMarkdown (defense-in-depth XSS). Hard cap: 10 MB per upload. Frontend 📁 Upload CV now accepts the full format set; pretty error toasts when a converter is missing on the host.
  • feat(cv): auto-download generated PDF when generate-pdf.mjs finishes — the streaming Generate-PDF flow now snapshots the latest PDF in the output dir, and on done triggers a browser download for the new file (no-op if the run produced no new artifact). The existing on-page list still shows every previous PDF.
  • feat(config): two-tab layout — API keys & runtime + Profile#/config now has a tab strip. The first tab keeps the existing .env editor (API keys, models, scanner knobs). The new Profile tab is a direct YAML editor for config/profile.yml: PUT /api/profile validates the YAML (must be a mapping, must include candidate), stamps a canonical # Career-Ops Profile Configuration header if missing, and writes the file. Save propagates without restart.
  • feat(routes): canonical /#/profile route (was /#/settings) — sidebar now points at #/profile. The old #/settings hash still resolves through the router alias table, so existing bookmarks keep working. Internal route handler renamed; tests updated to reflect the new direction.

🧪 Tests

  • New tests/cv-import.test.mjs (7 cases): .md / .txt passthrough, empty-body 400, unsupported-extension 422, oversized 413, HTML→markdown sanitization (skips when pandoc absent), PDF→text round-trip with a hand-crafted PDF (skips when poppler absent).
  • New tests/profile-put.test.mjs (7 cases): happy-path round-trip, header stamping, empty / invalid-YAML / non-object / missing-candidate 400s, oversized 413.
  • tests/playwright-full-cycle.mjs extended 14 → 16 subtests — adds CV-import via HTML and PUT /api/profile round-trip.
  • tests/router.test.mjs ALIAS regex flipped to assert the new settings → profile direction.

📚 Docs

  • docs/help/{en,ru}.md — full updates to sections 2/3/4: new App-settings tabs, edit-via-config message on the read-only Profile page, full upload-format matrix on the CV section, PDF auto-download behaviour.
  • docs/help/{es,pt-BR,ko-KR,ja,zh-CN,zh-TW}.md — concise mirrors of the new content blocks; section count unchanged (16) so the parity test stays green.

🔧 Internal

  • New server/lib/cv-import.mjs — single source of truth for the format → markdown conversion, with timeout + missing-converter detection that surfaces actionable hints rather than 500s.
  • server/lib/routes/content.mjs gains POST /api/cv/import and PUT /api/profile (binary-safe via express.raw for the upload, JSON for the YAML PUT).

[1.9.1] — 2026-05-08

Production-readiness pass. Four targeted bug fixes (BF-1..BF-4), Playwright smoke expanded from 5 to 12 tests covering tracker / pipeline / reports / evaluate / config / cv save round-trips. All green in CI.

🐛 Bug fixes

  • fix(tracker): escape pipes + collapse newlines in every cell, not just notes (BF-1) — a company name like "Acme | Co" previously broke the markdown table layout (parser split the cell into two). Cell sanitizer now applied uniformly to company / role / reportSlug / notes; companion fix in parsers.mjs::parseMarkdownTable adds GFM-compliant \| escape support so the round-trip is lossless.
  • fix(config): wrap updateEnvFile in try/catch (BF-2)POST /api/config previously bubbled an unhandled rejection on permission-denied / read-only filesystem. Now returns a clean 500 { error: 'failed to write parent .env', details: [...] }.
  • fix(llm): soft cap on assembled prompt size for Anthropic SDK calls (BF-3 + BF-4)/api/evaluate, /api/deep, and /api/mode/:slug Anthropic branches now bail with 413 when bundleProjectContext + prompt exceeds 200 KB (≈50K tokens). Saves a multi-second roundtrip + tokens vs letting the API complain about context size. The cap is well below any current model ceiling (Sonnet 4.6 = 1M context).

🧪 Playwright smoke — expanded coverage

5 → 12 tests. New cases:

  • tracker view renders empty + accepts API-seeded row — exercises BF-1 by seeding a row with a literal pipe in the company name and asserting the round-trip preserves it.
  • pipeline add-URL form populates the queue + invalid-URL rejection sweep (loopback, javascript:, bare strings).
  • reports view handles empty state — non-crash assertion.
  • evaluate view returns a manual prompt without API key — verifies the fallback chain.
  • config GET returns known keys masked — secrets never leak through /api/config.
  • cv.md PUT round-trips with sanitization — XSS-y bits (script tags, javascript: schemes) get stripped end-to-end.
  • pipeline preview proxy strips scripts — invalid-URL rejection path.

📦 Behavior changes (no API contract changes)

  • Tracker writes are now lossless against pipe-laden company / role names. Existing rows with raw pipes will start parsing correctly on the next read.
  • /api/{evaluate,deep,mode/:slug} will now return 413 instead of 502/timeout when the prompt is unreasonably large (200 KB+).

🧪 Tests

  • 284 unit tests (no change in count; existing tests still all green after parser update).
  • 12 Playwright browser-smoke tests (was 5).

[1.9.0] — 2026-05-08

P-6 → P-10 from the v1.8.0 backlog all shipped in one bundle. Headline: server/index.mjs is now a 130-LOC orchestrator (down from 762, total 1230 → 130 = -89%); every route topic has its own module. Anthropic parity for /api/evaluate, multi-CLI shims, expanded i18n parity test, and Playwright browser-smoke wired into CI.

🏗️ P-6 — server split-by-concern (phase 2)

Continuation of P-2. Extracted the remaining 9 route topics out of server/index.mjs into server/lib/routes/<topic>.mjs modules. index.mjs is now a pure orchestrator: middleware (security headers + activity log + static), 12 register<Topic>Routes(app) calls, and the SPA catch-all.

  • server/lib/routes/activity.mjs/api/activity.
  • server/lib/routes/config.mjs/api/config GET/POST (parent .env round-trip).
  • server/lib/routes/health.mjs/api/health + /api/dashboard.
  • server/lib/routes/help.mjs/api/help/:lang.
  • server/lib/routes/jds.mjs — full CRUD for jds/*.txt.
  • server/lib/routes/llm.mjs — every LLM-bound endpoint (evaluate, deep, mode, apply-helper, interview-prep).
  • server/lib/routes/pipeline.mjs/api/pipeline* including the SSRF-safe preview proxy with named constants for timeout / max-redirects / max-body.
  • server/lib/routes/reports.mjs/api/reports*.
  • server/lib/routes/tracker.mjs/api/tracker GET + dedup-aware POST.

Behavior unchanged. 283/283 unit tests stayed green at every step. The orchestrator’s import surface dropped from 47 lines to 22.

🔌 P-7 — Anthropic parity for /api/evaluate

/api/evaluate previously was Gemini-or-manual. v1.9.0 adds an Anthropic branch (preferred when both keys present), mirroring the routing rule already used by /api/deep and /api/mode/:slug. Routes through bundleProjectContext({ modeSlugs: ['_shared', 'oferta'] }) so the model has the cv / profile / mode templates inlined (REVIEW-A1).

New endpoint: POST /api/evaluate/test-anthropic — smoke check for ANTHROPIC_API_KEY, mirrors the existing Gemini smoke. Sends a tiny prompt (≤256 output tokens) so it costs essentially nothing; returns a 200-char sample.

Fallback chain is now: Anthropic → Gemini → manual.

🌐 P-8 — Help-center i18n parity (audit + test hardening)

Audited every docs/help/<lang>.md for structure parity. All 8 locales already cover the same 14 canonical h2 sections. Tests upgraded:

  • tests/help-ui.test.mjs::every help doc covers the same 14 sections was checking only en + ru. Now iterates all 8 locales (en, es, pt-BR, ko-KR, ja, ru, zh-CN, zh-TW) and asserts the section count for each.
  • New test: tests/help-ui.test.mjs::every help locale has substantive content — guards against locale stubs by asserting each non-EN locale is at least 30% of en.md’s byte length. Compact translations naturally hit 40-50%; a stub would be in single-digit %.

Result: structural parity is now CI-enforced.

🤖 P-9 — Playwright browser smoke in CI matrix

tests/playwright-smoke.mjs (added in v1.8.0 as opt-in) is now part of the CI workflow. The existing e2e job already installs Playwright + Chromium; one new step (npm run test:e2e:browser) runs the 5 browser-smoke tests right after the comprehensive node E2E.

Order in CI: unit (Node 18/20/22 matrix) → smoke node E2E → comprehensive node E2E → Playwright browser smoke → screenshot artifact upload on failure.

🌍 P-10 — Multi-CLI compatibility

Parent career-ops v1.7.0 introduced multi-CLI / Open Agent Skill standard support. The UI sub-project follows the same convention with thin shims pointing at the canonical CLAUDE.md:

  • web-ui/AGENTS.md — Codex / Aider / generic CLI entry point.
  • web-ui/GEMINI.md — Gemini CLI entry point.

Both shims re-state the hard rules and quick reference but defer to CLAUDE.md for the full project-level instructions, so non-Claude CLIs land on the same orientation as Claude Code sessions. The deployed UI itself remains CLI-agnostic at runtime.

🧪 Tests

  • 284 unit tests (was 283): +1 new help-locale parity test.
  • 5 Playwright browser-smoke tests — now part of CI, not just opt-in.
  • Coverage held.

🔧 Files touched

+ server/lib/routes/activity.mjs              + server/lib/routes/config.mjs
+ server/lib/routes/health.mjs                + server/lib/routes/help.mjs
+ server/lib/routes/jds.mjs                   + server/lib/routes/llm.mjs
+ server/lib/routes/pipeline.mjs              + server/lib/routes/reports.mjs
+ server/lib/routes/tracker.mjs
+ AGENTS.md                                   + GEMINI.md

~ server/index.mjs (762 → 130 LOC, -83%)
~ .github/workflows/ci.yml (Playwright smoke step)
~ tests/help-ui.test.mjs (all-8-locales section parity + content-floor)
~ docs/{ROADMAP,architecture/{OVERVIEW,SERVER}}.md
~ docs/sdd/CONVENTIONS.md
~ CLAUDE.md
~ package.json (1.8.0 → 1.9.0)

📦 New REST endpoints

Method Path Purpose
POST /api/evaluate/test-anthropic Smoke check for ANTHROPIC_API_KEY (P-7). Mirrors /api/evaluate/test-gemini.

🤖 New CLI entry points

File CLI Notes
AGENTS.md Codex / Aider / generic Points at CLAUDE.md for the full instructions.
GEMINI.md Gemini CLI Auto-loaded by Gemini at session start.

[1.8.0] — 2026-05-08

Hardening, refactor, and SDD bootstrap. Three high-severity correctness/security fixes (A1, A2, A3), four medium ones (B1–B4), six cleanups, audit of the parent career-ops v1.7.0 surface, server split-by-concern (P-2 phase 1), Playwright browser smoke harness, and a full SDD foundation under docs/ and .claude/.

🔥 High-severity fixes

  • fix(deep): inline cv/profile/mode files for Anthropic SDK calls (REVIEW-A1)/api/deep and /api/mode/:slug previously told the model “read these files first” but the Anthropic SDK has no filesystem. Output was hollow. New bundleProjectContext({ modeSlugs }) reads cv.md, config/profile.yml, modes/_shared.md, and the mode template, truncates each at 16 KB, and prepends a <project_context> block to the prompt. Verified live: 26 KB grounded markdown response from claude-sonnet-4-6 for a deep-research call.
  • fix(runner): SIGKILL escalation after SIGTERM grace period (REVIEW-A2)runNodeScript and streamNodeScript previously sent only SIGTERM on timeout / client-disconnect. A child stuck in a syscall (DNS, blocked socket) ignored it, hanging the SSE connection until Node’s GC reaped. Now each path arms a 5 s watchdog that escalates to SIGKILL. Promises always resolve.
  • fix(runner): max-runtime cap on streaming endpoints (REVIEW-A3) — every SSE script runner (/api/stream/{scan,liveness,pdf}) now has a hard 30-minute ceiling. On expiry: emit event: error { message: 'maximum runtime exceeded' }, kill the child via the A2 watchdog, end the response.

🛡️ Medium-severity fixes

  • fix(preview): per-hop redirect validation in /api/pipeline/preview (REVIEW-B1) — switched from redirect: 'follow' to manual redirect-walking. Each Location header is re-validated by isValidJobUrl; capped at 3 hops. Hostile boards can no longer bounce us to loopback / private IPs / file://. 4 new tests cover the rejection paths.
  • refactor(keys): hasGeminiKey helper unifies LLM-key checks (REVIEW-B2) — direct process.env.GEMINI_API_KEY reads in route handlers replaced with hasGeminiKey() from lib/anthropic.mjs. Mirrors hasAnthropicKey() shape for consistency and easier mocking.
  • feat(scanners): thread AbortSignal through hh.ru, Habr, Greenhouse, Ashby, Lever (REVIEW-B3) — when the SSE client disconnects mid-scan, in-flight HTTP fetches are now aborted instead of running every query to completion and dropping events. runRuScan and runEnScan accept opts.signal; SSE handlers in /api/stream/scan-{ru,en} create an AbortController and abort on res.close.
  • test(anthropic): log-guard test prevents future API-key leaks via console (REVIEW-B4) — captures every console.{log,info,warn,error,debug} call during runAnthropic happy + error paths, asserts zero output and that the canary key string never appears. Defense-in-depth against a future console.log(opts) regression.

🧹 Low-severity polish

  • fix(parsers): defense-in-depth URL gate inside addPipelineUrl (REVIEW-C4) — parser-level rejection of non-http(s) values, complementing the route-level isValidJobUrl. Optional opts.validate for callers that want stricter rules.
  • docs(readme): badge "tests-88 passed" → "tests-277 passed" (REVIEW-C3) — was off by an order of magnitude.
  • test(i18n): missing-keys diff grouped by locale (REVIEW-C6) — when tests/i18n-coverage.test.mjs finds a gap, output is now [ru] (3): foo, bar, baz instead of mixed lines.
  • docs(review): C1 closed as resolved-on-inspection — sanitizer regexes were already in \x00-\x08 hex form; review entry was a tool-rendering artifact.

🏗️ P-2 phase 1 — server split-by-concern

server/index.mjs was 1230 LOC, well past the 800-line ceiling. Split into focused modules without behavior change. All 283 unit tests stayed green at every step.

  • server/lib/security.mjsisValidJobUrl, stripDangerousMarkdown, sanitizeJobDescription, isPubliclyExposed. Re-exported from index.mjs for backward-compat with external consumers.
  • server/lib/prompts.mjsbundleProjectContext, buildEvaluationPrompt, buildDeepPrompt, buildModePrompt, buildApplyChecklist.
  • server/lib/store.mjssafeReadApps, safeReadPipeline, safeListReports, checkProfileCustomized, ensureRussianPortalsDefaults.
  • server/lib/routes/scan.mjsregisterScanRoutes(app) for /api/stream/scan-{ru,en}, /api/scan-ru/config, /api/scan-results.
  • server/lib/routes/runners.mjsregisterRunnerRoutes(app) for buffered /api/run/* table, streaming /api/stream/{scan,liveness,pdf}, generated-PDF list/download.
  • server/lib/routes/content.mjsregisterContentRoutes(app) for CV / Profile / Portals / Modes.

index.mjs is now 762 LOC (-38%, under the 800 cap). Phase 2 will extract tracker, pipeline, reports, jds, llm (evaluate/deep/mode), and health into route modules. Targeting <500 LOC for the orchestrator.

🔍 Parent career-ops v1.7.0 audit

The user updated the parent project to v1.7.0. Audited every consumed surface — UI is fully compatible. Notable findings documented in docs/architecture/DATA-FLOWS.md:

  • Modes catalog grew from 7 to 19 files. UI’s MODE_ALLOWLIST deliberately surfaces only 7 (others are Claude-Code-only). Comment added explaining the intentional narrow scope.
  • portals.yml schema confirmed: tracked_companies (96 entries, 87 enabled, 71 with API). EN scanner reads it correctly; legacy companies key still supported.
  • New parent surfaces NOT consumed today: dashboard/ (Go program), update-system.mjs, generate-latex.mjs, analyze-patterns.mjs, liveness-core.mjs, followup-cadence.mjs, test-all.mjs, localized mode subdirs (de/fr/ja/pt/ru).
  • Live /api/dashboard, /api/health, /api/modes, /api/portals, /api/profile, /api/cv, /api/jds, /api/reports, /api/tracker, /api/pipeline, /api/evaluate, /api/deep, /api/stream/scan-en all verified green.

🤖 SDD / GSD bootstrap

career-ops-ui now has a full Spec-Driven Development foundation aligned with the GSD pipeline (gsd-* skills from superpowers@claude-plugins-official).

  • CLAUDE.md (root) — project-level agent system prompt: stack, GSD pipeline, hard rules (parent contract, security envelope, no --no-verify), conventions, parent-project boundary.
  • .aiignore — exclusion list for AI agents: vendored, binaries, parent user data, .planning/, .env, locale duplicates.
  • .claude/agents/ — three project-specific subagent definitions:
    • web-ui-route-reviewer.md — gates new routes against SSRF, CSP, sanitizers, parent-write contract, conventions, tests.
    • spa-view-reviewer.md — CSP-safe DOM, i18n, router registration, accessibility.
    • test-isolation-reviewer.md — verifies tests are CI-isolated (no parent-project assumptions, no live network, no port collision).
  • .claude/commands/ — slash-command stubs: /sdd-status, /codebase-tour.
  • docs/ tree — all in English:
    • PROJECT.md — what/why/for-whom, scope, constraints, success criteria.
    • ROADMAP.md — current milestone + completed history + backlog.
    • sdd/SDD-GUIDE.md — discuss → spec → plan → execute → verify → review pipeline mapped to gsd-* skills.
    • sdd/CONVENTIONS.md — module system, naming, routes, sanitizers, client patterns, i18n, errors, logging, testing, commits, branches, CSS.
    • architecture/OVERVIEW.md — top-level diagram, layers, boot sequence, invariants, “where to look first when…” cheat sheet.
    • architecture/SERVER.md — per-file map for server/lib/*.mjs (updated for P-2 split).
    • architecture/FRONTEND.md — SPA structure, view inventory, globals, “how to add a view”.
    • architecture/API.md — full inventory of every /api/* route.
    • architecture/DATA-FLOWS.md — every parent-project read/write, with the explicit-user-action contract.
    • reviews/REVIEW-2026-05-07.md — static review that produced this changelog’s fixes.

🔒 Security & repo hygiene

  • chore(.gitignore): comprehensive defense-in-depth patterns — covers env variants, IDE folders, GSD scratch (.planning/), per-user agent settings (.claude/settings.local.json, .claude/cache/, .claude/state/, .claude/memory/), Playwright artifacts (playwright-report/, test-results/, .playwright/, trace.zip), heap/CPU profiles, lockfiles for unshipped tooling, expanded macOS Finder noise, generic secret patterns (secrets.json, credentials.json, *.pem, *.key).

🧪 Tests

  • 283 unit tests (was 277): +6 new (4 for B1 redirect-rejection, 1 for hasGeminiKey, 1 for runAnthropic log-guard).
  • 5 Playwright browser-smoke tests (new, opt-in via npm run test:e2e:browser): dashboard render + version footer, dashboard → scan → pipeline → cv navigation, language-switch persistence, 404 view, health-page render. Resolves Playwright via parent’s node_modules — no new dependency.
  • Coverage held at ~93% line / ~83% branch.

📝 New / updated package.json scripts

Script Purpose
npm run test:e2e:browser Run Playwright smoke harness against in-process server (5 tests).

🔧 Files touched

+ CLAUDE.md                                    +  .aiignore
+ docs/PROJECT.md                              +  docs/ROADMAP.md
+ docs/sdd/SDD-GUIDE.md                        +  docs/sdd/CONVENTIONS.md
+ docs/architecture/OVERVIEW.md                +  docs/architecture/SERVER.md
+ docs/architecture/FRONTEND.md                +  docs/architecture/API.md
+ docs/architecture/DATA-FLOWS.md              +  docs/reviews/REVIEW-2026-05-07.md
+ .claude/agents/web-ui-route-reviewer.md      +  .claude/agents/spa-view-reviewer.md
+ .claude/agents/test-isolation-reviewer.md
+ .claude/commands/sdd-status.md               +  .claude/commands/codebase-tour.md
+ server/lib/security.mjs                      +  server/lib/prompts.mjs
+ server/lib/store.mjs
+ server/lib/routes/scan.mjs                   +  server/lib/routes/runners.mjs
+ server/lib/routes/content.mjs
+ tests/playwright-smoke.mjs

~ .gitignore                                   ~  README.md (badge fix)
~ package.json (1.7.2 → 1.8.0)
~ server/index.mjs (1230 → 762 LOC)
~ server/lib/runner.mjs (SIGKILL escalation, max-runtime cap)
~ server/lib/anthropic.mjs (hasGeminiKey)
~ server/lib/parsers.mjs (URL gate in addPipelineUrl)
~ server/lib/ru-scanner.mjs                    ~  server/lib/en-scanner.mjs
~ server/lib/sources/{hh,habr,greenhouse,ashby,lever}.mjs (signal threading)
~ tests/anthropic.test.mjs                     ~  tests/i18n-coverage.test.mjs
~ tests/pipeline-preview.test.mjs

[1.7.2] — 2026-05-04

Help center, in-UI App settings, mobile sidebar, single Scan button, and a “Show result” shortcut on every prompt-builder.

✨ New features

  • feat(help): in-app user guide (/#/help) — long-form Markdown documentation accessible from a new sidebar entry. Covers every page step-by-step: quick start, CV editor, Profile, Scan filters, Pipeline preview, Evaluate, Deep research, Apply, Tracker, Reports, all 7 modes, Activity log, Health, setup hints. Auto-built sticky table of contents from <h2> headings, synchronous DOM build (no race). Localized for all 8 supported locales.
  • feat(config): in-UI App settings page (/#/config) — edit ANTHROPIC_API_KEY, ANTHROPIC_MODEL, GEMINI_API_KEY, GEMINI_MODEL, HH_USER_AGENT, PORT, HOST from the browser. Writes to the parent project’s .env file so career-ops Node scripts AND web-ui’s dotenv loader pick up the same source. Secret keys masked on read (first/last 4 chars). Model fields are dropdowns with curated lists (claude-sonnet-4-6 / claude-opus-4-7 / claude-haiku-4-5 / gemini-2.0-flash / etc.). Empty value deletes the key. Values applied to running process.env immediately — no restart for most settings.
  • feat(modes): "⚡ Show result" button alongside "Copy prompt" — when a prompt is generated in manual mode, users no longer have to retype their inputs to get the LLM result. The new button re-submits the same form with run: true, falling through to a clear toast (Set ANTHROPIC_API_KEY or GEMINI_API_KEY in .env first) when no key is configured. Works on /#/deep, /#/project, /#/training, /#/followup, /#/batch, /#/contacto, /#/interview-prep, /#/patterns.

🐛 UX + UI fixes

  • fix(scan): single Scan button replaces three (Scan all + EN + RU) — overwhelming choice, identical default in 99% of cases. The unified 🌐 Scan button runs every enabled source. Help docs updated across 8 locales.
  • fix(ui): mobile sidebar drawer — viewport <900px now gets a hamburger button (☰) in the topbar; body.sidebar-open toggles a CSS transform that slides the sidebar in. Backdrop dim + click-anywhere closes it. Anchor click + hashchange auto-close so the user lands on the new page with the drawer tucked away. Larger viewports unaffected.
  • fix(server): footer version reflects web-ui, not the parent VERSION/api/health now reads web-ui’s own package.json. The footer no longer leaks a stale 1.6.0 from the parent’s version file. Parent’s VERSION is still surfaced separately as parentVersion.

📦 New REST endpoints

Method Path Purpose
GET /api/help/:lang Returns the Markdown user guide for the requested locale, falling back to en.md. Path-traversal-safe.
GET /api/config Returns current values for all known env keys; secrets masked.
POST /api/config Writes the given keys into the parent project’s .env, validates each value, applies live to process.env.

🌐 i18n

  • 30+ new keys across nav.help, nav.config, help.*, config.*, deep.showResult, deep.needKey, scan.btnRun. All 8 locales populated.

🧪 Tests

  • tests/help.test.mjs (12 cases) — every supported locale returns substantive markdown, EN spot-checks every page slug, unknown lang → EN fallback, path-traversal sanitized, every locale references cv.md / profile.yml / .env.
  • tests/help-ui.test.mjs (9 cases) — view file registration, sidebar entry, i18n keys present in every locale, docs files exist for every locale, EN/RU help has 14 canonical sections, every #/foo route covered, Show-result wiring on deep + mode-page.
  • tests/env-config.test.mjs (18 cases) — pure-function tests for parseEnv, maskSecret, validateConfig, updateEnvFile (bootstrap, in-place rewrite preserving comments, empty-value delete, quote-when-needed).
  • tests/config-endpoint.test.mjs (8 cases) — GET masks secrets / returns env path; POST writes to parent .env; live process.env application; empty-value unsets; rejects unknown keys + malformed Anthropic keys with 400.

📊 Stats

  • Tests: 233 → 277 (+44 across 4 new test files).
  • E2E: 20 smoke + 23 comprehensive = 43 Playwright steps, all green.
  • Coverage: 93.5% line / 82.6% branch / 93.7% funcs (unchanged — new code is fully tested).

[1.7.1] — 2026-05-04

Patch release stacking the post-v1.7.0 work: pipeline preview pane, Anthropic API integration, scrollable sidebar, dotenv loader, dynamic Active-companies list, CI workflow hardening.

✨ Pipeline preview pane

  • /#/pipeline overhaul — left list + right preview pane. Click any URL to fetch a server-side proxied snapshot (GET /api/pipeline/preview strips scripts/styles/tags, caps at 8 KB, validated through isValidJobUrl). Live filter input, “In queue” counter, ⚡ “Evaluate first” header button. Inline ▶/✕ on every row plus full Evaluate / Open in tab / Delete on the preview pane. Stable test selectors via data-url + .pipeline-row + .pipeline-row-delete classes. 8 new tests in tests/pipeline-preview.test.mjs (mocked fetch, no upstream binding needed).

✨ Anthropic API integration — “Run live” everywhere

  • server/lib/anthropic.mjs — zero-dependency client for Anthropic Messages API (claude-sonnet-4-6 default, override via ANTHROPIC_MODEL). When ANTHROPIC_API_KEY is set, every mode page (/#/deep, /#/project, /#/training, /#/batch, /#/contacto, /#/interview-prep, /#/patterns) renders an “⚡ Run live (Anthropic)” button as the primary action — clicking executes the prompt and renders Markdown back into the browser instead of handing off to Claude Code. Gemini stays as fallback when only its key is set. Manual mode still works with no keys at all. 8 new tests in tests/anthropic.test.mjs.

🐛 CI / pipeline fixes

  • fix(api): tighten pipeline URL validator (FIX-M7) — now also rejects loopback hostnames, length <10 or >2000, whitespace inside URLs.
  • fix(server): actually load .env so HH_USER_AGENT / GEMINI_API_KEY hints work — added server/lib/dotenv.mjs (35-line zero-dep loader) wired in at the top of server/index.mjs. The runtime hints in scanner code finally do something. 6 new tests.
  • fix(ui): scrollable sidebar — 18 nav items in 6 groups overflowed shorter viewports. .sidebar now has overflow-y: auto with thin custom-styled scrollbars.
  • fix(ui): make HH_USER_AGENT banner dismissible — then removed entirely from /scan once we realized it was overkill. Health page check still surfaces it.
  • fix(scan): Active companies list is now collapsible + filterable + grouped — 87 tags flat was overwhelming. Now a “▸ Active companies 87/71” toggle expands an ordered list (✓ API-backed first, ○ websearch second) plus a search filter.
  • fix(test): isolate api.test.mjs + en-scanner.test.mjs from parent project — both now spin up tmp project roots so CI works without the parent checked out alongside web-ui.
  • fix(workflow): publish-package version-match only on release eventsworkflow_dispatch from main no longer fails the tag/version check.
  • fix(e2e): stable selector for pipeline row delete — restored anchor wrapper + added data-url attribute so e2e suite is selector-stable.

📦 New REST endpoint

Method Path Purpose
GET /api/pipeline/preview?url=… Server-side proxy: returns visible-text snapshot of the URL (scripts/styles stripped, 8 KB cap), gated by isValidJobUrl.

📊 Stats after this batch

  • Tests: 225 → 233 (8 more on top of v1.7.0).
  • Test files: 25 → 26.
  • E2E: 20 + 23 = 43 Playwright steps, all green.

[1.7.0] — 2026-05-03

A 35-commit hardening + UX + feature-completion pass driven by QA r5. Three security layers landed (XSS sanitization, CSP, input validation), every missing CRUD endpoint was filled in, the parent-project bootstrap is now fully automated, and the UI gained 9 new pages — Activity, redesigned Deep Research, plus 7 sidebar-grouped modes (project / training / followup / batch / outreach / interview-prep / patterns) covering 100% of parent’s modes/. Pipeline gained a server-side preview pane. Anthropic API integration makes “Run live” a one-click action across all modes. Test coverage went from 73 to 225, across 25 test files, plus 23 comprehensive Playwright e2e steps. GitHub Actions ship CI / AI review / Release / Publish-Package workflows.

🔒 Security

  • fix(cv): sanitize CV markdown to block stored XSS in preview (FIX-C10)PUT /api/cv now strips <script>, <iframe>, <object>, <embed>, <style>, <form>, <svg>, on*= event handlers, and javascript:/vbscript:/data:text/html URIs before writing cv.md. Body capped at 1 MB (413 on overflow). Client-side UI.md() was rewritten to escape every byte before any markdown transformation runs, so raw HTML can never reach innerHTML. Link href attributes are validated against an allowlist of safe schemes (http/https/mailto/tel/relative + data:image only). 17 new tests across the strip helper and HTTP round-trips.
  • fix(server): add CSP and baseline security headers (FIX-L2) — every response now carries X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: same-origin. When the server binds beyond loopback (HOST127.0.0.1/::1/localhost), a strict Content-Security-Policy is layered on top: default-src 'self', script-src 'self' (no unsafe-inline), Google Fonts whitelisted, connect-src 'self' blocks XSS exfiltration. Inline onclick handlers in index.html and router.js were moved to addEventListener to keep the strict CSP intact. 8 new tests gating CSP across 5 different HOST values.
  • fix(api): tighten pipeline URL validator (FIX-M7)POST /api/pipeline used to accept "not-a-url" and persist it. Now isValidJobUrl() rejects bare strings, inputs <10 or >2000 chars, whitespace-containing URLs, non-http(s) schemes, and loopback hostnames (localhost/127.0.0.1/::1). Folds in FIX-M3 + FIX-M6 (return 400 on invalid, plus a deduped flag on success).
  • fix(server): actually load .env so HH_USER_AGENT / GEMINI_API_KEY hints work — previously the runtime told users to “set HH_USER_AGENT in .env” but the server never read that file, so following the instruction did nothing. Adds a 35-line zero-dependency dotenv loader (server/lib/dotenv.mjs) wired in at the top of server/index.mjs. Process-env values set on the command line still win, so existing CI overrides aren’t shadowed. Parent’s .env.example now includes a documented HH_USER_AGENT block with a real-Chrome User-Agent example. 6 new tests.
  • fix(api): sanitize JD before prompt assembly (FIX-M5)POST /api/evaluate strips ANSI escapes, control bytes, inline <script> tags, and trims whitespace before either calling Gemini or echoing the prompt back. 50 KB length cap. The 50-char minimum runs against the sanitized text, so prompt-injection attempts that look long enough but consist mostly of escapes fail-fast with 400.
  • fix(health): mask Node version + project root when HOST!=loopback (FIX-M1)/api/health no longer fingerprints the host on LAN-exposed deployments. Loopback responses keep the values for local diagnostics.

✨ New features

  • feat: 7 new sidebar modes + grouped sidebar (FIX-C8) — covers 100% of the parent’s modes/ directory with no UI gaps. New routes: #/project (portfolio project advisor), #/training (course / cert evaluation), #/followup (per-application cadence), #/batch (parallel URL processor), #/contacto (LinkedIn outreach drafter), #/interview-prep (stage-specific prep), #/patterns (rejection-pattern analyzer). All seven share a single config-driven view factory (public/js/views/mode-page.js) and a single generic endpoint POST /api/mode/:slug — adding a new mode in the future is one config row + one i18n block. Sidebar reorganized into 6 groups: Sourcing / Decision / Application / Networking / Analytics / Setup. 18 nav items total. 12 new tests in tests/modes-endpoints.test.mjs.
  • fix: bootstrap parent deps + russian_portals defaults (FIX-C4 + C9 + C12 + H2)bin/start.sh now installs the parent’s node_modules (js-yaml, playwright, jsdom) AND npx playwright install chromium on fresh clones, so /api/stream/scan, /pdf, and /liveness work end-to-end out of the box. createApp() probes portals.yml on every boot — if the russian_portals: block is missing, appends a documented default with comments. Idempotent: the second boot is a no-op. 3 new tests.
  • fix: disable 9 dead portal slugs in template + health-check script (FIX-C3)templates/portals.example.yml now ships with Ada / Factorial / Tinybird / Weights & Biases / Travelperk / Clarity AI / Forto / Vinted / Runway flagged enabled: false (each entry has an inline reason comment). New installs scan 87 alive companies instead of 96. New web-ui/scripts/portals-health-check.mjs HEAD-probes every enabled careers_url and reports DEAD entries with a suggested patch list (JSON output via --json). 3 new tests.
  • feat(activity): user-action log + Activity sidebar page — every state-changing API request is captured to data/activity.jsonl (timestamp, action verb, target, success flag, optional detail). New sidebar entry Activity with action-prefix chip filters (pipeline / cv / jd / evaluate / scan / stream / script), action ✓/✗ badges, and refresh button. Auto-rotates at 5 MB. 10 new tests covering middleware, read filters, corrupt-line tolerance, and the recursion guard for GET /api/activity itself.
  • feat(deep): view Deep Research in browser + saved-results archive — the Deep Research page now (a) runs the prompt through Gemini live when { run: true } and GEMINI_API_KEY is set, persisting output to interview-prep/{slug}.md; (b) lists every saved deep-research file as clickable cards with relative timestamps; (c) renders results as Markdown with 📋 Copy / ⬇ Download .md / ↗ Open in tab actions per result. New REST surface: GET /api/interview-prep, GET /api/interview-prep/:name, DELETE /api/interview-prep/:name. 7 new tests.
  • feat(cv): generate + download PDF in browser, with PDF archive — new 📄 Generate PDF button on the CV page streams /api/stream/pdf in a modal console. On ERR_MODULE_NOT_FOUND / playwright errors, it surfaces a copy-pasteable bootstrap command. New “Generated PDFs” section auto-loads after each successful run, listing every output/*.pdf with ↗ Open and ⬇ Download buttons. New REST surface: GET /api/output/pdfs, GET /api/output/pdfs/:name. 6 new tests.
  • feat(api): POST /api/tracker — append rows from the UI (FIX-H8) — append a canonical row to data/applications.md from the browser. Validates company + role, normalizes status against templates/states.yml, auto-increments zero-padded #, dedups by company+role (case-insensitive), pipe-escapes notes so the markdown table doesn’t fracture. Bootstraps the table when the file is empty. 6 new tests.
  • feat(api): DELETE /api/jds/:name (FIX-H4) — remove saved JDs without shelling out. Path-traversal characters are stripped before any filesystem touch; the parameter must end in .txt. 5 new tests, including ../../etc/passwd refusal.
  • feat(api): POST /api/evaluate/test-gemini (FIX-H7) — smoke-test endpoint that runs a 50-char dummy JD through gemini-eval.mjs so the user can verify the API key works without sitting through a real evaluation. Returns { ok, code, sampleLength, sample }.

🐛 Bug fixes

  • fix(router): catch-all 404 view + i18n coverage guard (FIX-C7) — unknown hash routes used to silently fall back to the dashboard, masking typos and broken bookmarks. Now #/totally-random-xyz renders a dedicated 404 page that quotes the bad path back and links to the dashboard. The 404 view is registered inside the router IIFE itself so it cannot collide with any user route. New tests/i18n-coverage.test.mjs runs i18n.js inside a vm.Context with a stub window, exposes the private DICT, and asserts every one of the 173+ keys × 8 locales is populated and non-empty. 4 new router tests.
  • fix(router): alias #/profile → settings (FIX-C2) — the internal route name is settings (with nav.settings rendering “Profile”) but external links and muscle memory go to #/profile. Now both addresses reach the same view, and the sidebar nav-item lights up either way. 2 new tests.
  • fix(health): unify Health/Doctor + flag template profiles (FIX-C6 + FIX-H6) — Health and Doctor were two different sources of truth. Now /api/health exposes everything Doctor reports (parent-deps, Playwright, dirs, profile-customized, HH_USER_AGENT). The Profile customized check detects placeholder names (Jane Smith, Alex Doe, John Doe, Your Name, Test User) and explicit YAML parse errors. 4 new tests.
  • fix(scan): warn on query↔negative collisions in RU config (FIX-H3) — when portals.yml ships with "PHP" in title_filter.negative while the queries target Senior PHP, every match gets filtered and the user sees zero results. loadConfig() now computes a warnings array; runRuScan() emits each warning as an SSE stderr line before the scan starts. 2 new tests verify the shipped defaults stay PHP-friendly out of the box.
  • fix(scan): warn when HH_USER_AGENT is unset (FIX-H1) — the /scan page probes /api/health and shows a yellow warning card above the action row when HH_USER_AGENT is empty, so users know about the hh.ru 403 before they click RU scan.
  • fix(api): warn when POST /api/jds slug had unsafe chars stripped (FIX-M2) — slug normalization that strips dangerous characters now returns a warning field; pure case/whitespace cleanup stays silent. Empty result after sanitization returns 400.
  • fix(ui): clear global search on route change + button spinners (FIX-M4 + FIX-L1) — the global-search input is cleared on hashchange (with a guard for active typing). New UI.withSpinner(button, fn) helper wires loading state, ARIA, and double-click prevention into every async button click. Already adopted on Doctor / Verify / sync-check / Save CV / Normalize / Dedup / Merge buttons.
  • fix(ui): make sidebar scrollable so 18 nav items always reach the footer — the grouped sidebar from FIX-C8 overflowed shorter viewports; bottom items (Activity / Health) were clipped. .sidebar now has overflow-y: auto with thin custom-styled scrollbars (WebKit + Firefox). Footer stays pinned via the existing margin-top: auto.
  • fix(ui): empty modal-title placeholder (FIX-H9) — the hardcoded English "Title" string in index.html is gone, closing the brief race window where it was visible during modal open.

🌐 i18n

  • 173+ translation keys × 8 supported locales (en, es, pt-BR, ko, ja, ru, zh-CN, zh-TW). New keys added across all locales for: 404 page, activity log, deep research, PDF flow, security warnings, tracker mutation, apply rename. Coverage is now enforced by tests/i18n-coverage.test.mjs — every key must have a non-empty value in every supported locale or CI fails.

⚙️ DevOps

  • Test count: 73 → 201 (+128 tests across 23 test files). The single remaining failing test (runEnScan: dry-run end-to-end across multiple sources) is a pre-existing flake dependent on Greenhouse/Ashby/Lever live API responses.
  • Comprehensive Playwright e2e (tests/e2e-comprehensive.mjs, 23 steps): walks the full user journey — CV save → preview → PDF generation → all 7 new modes → tracker filters → activity log → 404 → modal ESC → sidebar scroll → Ctrl-K focus → search clear → profile alias → language persistence.
  • GitHub Actions (.github/workflows/):
    • ci.yml — unit + integration tests on Node 18/20/22 matrix, plus i18n coverage gate (every key × 8 locales must be non-empty), plus the full Playwright e2e on every PR.
    • ai-review.yml — Claude Code AI review on every PR. Maintainers retain merge authority; Claude only suggests. Skip via skip-ai-review label.
    • release.yml — auto-publish a GitHub Release when a v*.*.* tag is pushed; release notes are sliced from CHANGELOG.md so all 8 language variants stay the canonical source.
  • CSP-friendly UI: all inline onclick handlers removed from index.html and router.js. The strict script-src 'self' policy is now enforceable without breaking any feature.

📦 New REST endpoints

Method Path Purpose
GET /api/activity List user-action events, newest first
GET /api/interview-prep List saved Deep Research files
GET /api/interview-prep/:name Read a single Deep Research file
DELETE /api/interview-prep/:name Remove a Deep Research file
GET /api/output/pdfs List generated PDFs
GET /api/output/pdfs/:name Stream a PDF as an attachment
POST /api/tracker Append a row to applications.md
DELETE /api/jds/:name Remove a saved JD
POST /api/evaluate/test-gemini Smoke-test the Gemini API key
POST /api/mode/:slug Generic prompt builder for the 7 new modes (project / training / followup / batch / contacto / interview-prep / patterns)

[1.6.0] — 2026-05-02

Initial public release of the web UI. See README.md for the feature inventory at this baseline.