Nhật ký thay đổi

GitHub

Mọi thay đổi đáng chú ý, theo từng bản phát hành. Cùng một tệp đi kèm repository và được dịch sang cả 19 ngôn ngữ.

[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)


[1.58.21] — 2026-05-20

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


[1.58.20] — 2026-05-20

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


[1.58.19] — 2026-05-20

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


[1.58.18] — 2026-05-20

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


[1.58.17] — 2026-05-20

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


[1.58.16] — 2026-05-20

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


[1.58.15] — 2026-05-20

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


[1.58.14] — 2026-05-20

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


[1.58.13] — 2026-05-20

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


[1.58.12] — 2026-05-20

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


[1.58.11] — 2026-05-20

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


[1.58.10] — 2026-05-20

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


[1.58.9] — 2026-05-20

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


[1.58.8] — 2026-05-20

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


[1.58.7] — 2026-05-20

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


[1.58.6] — 2026-05-20

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


[1.58.5] — 2026-05-20

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


[1.58.4] — 2026-05-19

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


[1.58.3] — 2026-05-19

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


[1.58.2] — 2026-05-19

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


[1.58.1] — 2026-05-19

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


[1.58.0] — 2026-05-19

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

🐛 Fixes (QA report)

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

✨ Features / UX

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

🧪 Tests

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

Triaged, not changed (with rationale)

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

[1.57.2] — 2026-05-19

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

🐛 Fixes

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

🧪 Tests

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

[1.57.1] — 2026-05-19

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

🐛 Fixes

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

✨ UX

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

🧪 Tests

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

[1.57.0] — 2026-05-19

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

🐛 Fixes

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

✨ Features

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

🧪 Tests

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

[1.56.4] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.56.3] — 2026-05-19

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

🐛 Fixes

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

🧪 Tests

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

[1.56.2] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.56.1] — 2026-05-19

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

🐛 Fixes

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

🧪 Tests

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

[1.56.0] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.8] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.7] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.6] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.5] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.4] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.3] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.2] — 2026-05-18

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

🐛 Bug Fixes

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

🧪 Tests

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

[1.55.1] — 2026-05-18

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

🐛 Bug Fixes

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

🧪 Tests

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

[1.55.0] — 2026-05-18

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

✨ Features

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

🧪 Tests

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

[1.54.10] — 2026-05-18

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

🐛 Fixes

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

🧪 Tests

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

[1.54.9] — 2026-05-18

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

🐛 Fixes

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

🧪 Tests

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

[1.54.8] — 2026-05-18

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

✨ Features

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

🧪 Tests

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


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