Журнал змін
GitHubКожна значуща зміна, реліз за релізом. Той самий файл постачається в репозиторії та перекладений усіма 19 мовами.
[1.70.0] — 2026-06-16
feat(i18n): три нові мови інтерфейсу — польська (pl), українська (uk) та арабська (ar, з повною підтримкою RTL) — розширюють SPA до 12 локалей, що відповідає всім мовам README батьківського проекту career-ops. Кожна нова локаль постачається з повним словником із 697 ключів (public/js/lib/locales/i18n-dict.{pl,uk,ar}.js), перевіреним наявними наборами тестів паритету / покриття / відсутності витоків латиниці / відсутності персональних даних. Арабська додає справжню підтримку письма справа наліво: i18n.js встановлює <html dir="rtl"> для RTL-локалей, а блок [dir="rtl"] в app.css відображає хром (бічна панель, ящик сповіщень, таблиці та блок-цитати markdown, вбудовані відступи) — LTR-локалі залишаються незмінними побайтово. Новий ключ top.langLabel (×12) іменує перемикач для програм читання з екрана.
feat(ui): випадаючий список <select> із піктограмами прапорів замінює ряд кнопок, що переносилися. При 12 локалях старий ряд .lang-btn переносився на три рядки в бічній панелі; нативний <select> (кожен пункт з префіксом у вигляді емодзі прапора) масштабується акуратно, підтримує клавіатуру та програми читання з екрана без додаткових налаштувань, а також є безпечним для CSP (обробник зміни через addEventListener, без вбудованого JS). Там, де платформа не має гліфів прапорів, вони замінюються літерами регіону, тому мовна мітка завжди є основним ідентифікатором.
feat(cover): перенесення режиму супровідного листа з батьківського проекту (career-ops v1.10.0 + вітання v1.11.0) до SPA. Нова сторінка #/cover у групі навігації «Заявки», побудована на базовому виконавці режимів: опис вакансії + компанія/роль + необов’язкове вітання → персоналізований лист, згенерований із cv.md / modes/_profile.md. Додано cover до серверного MODE_ALLOWLIST та блок i18n cover.* (×12 локалей).
chore(compat): відстеження батьківського career-ops v1.11.0. Підтверджено, що контракт читання/запису залишається чинним — data/applications.md й надалі є джерелом істини у форматі markdown (SQLite-індекс трекера у v1.11.0 є похідним кешем), стовпці трекера й надалі зіставляються за заголовком. parentVersion тепер повідомляє 1.11.0.
fix(i18n): усунення прихованої прогалини, де французька мова (додана у v1.61.0) була відсутня в LOCALE_NAMES та SCAFFOLD_STRINGS у server/lib/prompts.mjs — LLM-виклики французькою мовою мовчки переходили на англійський вивід та англійські шаблони. fr/pl/uk/ar тепер усі підключені до шляху локалі підказок.
Відомі подальші дії: вбудований довідковий посібник (
docs/help/) повертається до англійської для pl/uk/ar (сам UI-хром повністю локалізований); інтерактивне введення для співбесід батьківського проекту, зворотне виявлення ATS та новіші постачальники сканування ще не представлені в 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 locale — public/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.helpCta → help.title; nav.cv → cv.title; nav.health/dash.quick.healthCta → health.title; nav.reports/dash.reports/dash.quick.reportsCta → rep.title; nav.apply → apply.title; nav.interviewPrep → interviewPrep.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 claude — LLM_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:full → 928 + 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)
career-ops-ui v1.267.1 · page 8/12 · mirror of the project changelog for cvstart.org