Changelog

GitHub

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

[1.201.0] — 2026-08-15

Fixed — a tracker with localized or variant column headers no longer renders blank.

Fixed

  • If your data/applications.md uses non-English or variant column headers — Spanish empresa / puesto / estado / fecha / enlace, or position / stage / link — the tracker read them under the wrong keys and showed blank Company / Role / Status / Date / Link columns. Those headers now fold onto the canonical field names, so the tracker renders correctly. An all-English tracker parses exactly as before.

Notes

  • New HEADER_ALIASES map + a normalization fold in parseApplications (server/lib/parsers.mjs); unknown or already-canonical headers pass through unchanged. tests/tracker-header-aliases.test.mjs. Suite: 2563.

[1.200.0] — 2026-08-15

Added — a one-click “still live?” check for ATS-hosted jobs in your tracker.

Added

  • On #/tracker, an application whose URL is a Greenhouse / Lever / Ashby / Workday / SmartRecruiters posting now shows a “Still live?” button. One click asks the ATS’s own public JSON — zero tokens, no browser — and shows Live / Expired / Unknown, so you can spot dead postings without opening each one. Conservative by design: only a definitive 404/410 reads as Expired; anything ambiguous stays Unknown (never a false Expired).

Notes

  • New server/lib/liveness-core.mjs + liveness-api.mjs and a read-only GET /api/liveness?url= (no writes, no LLM). SSRF-safe: the URL is isValidJobUrl-gated, then the ATS API is reached only through the DNS-pinned safeGet with a fixed host + charset-validated path segments. tests/liveness-core.test.mjs + tests/liveness-route.test.mjs. Suite: 2557.

[1.199.0] — 2026-08-15

Fixed — wide tables now scroll sideways instead of being cut off.

Fixed

  • On the Scan page (and every other table — Tracker, Statistics, Usage, Dashboard) a table wider than the window was clipped with no scrollbar, leaving the rightmost columns unreachable. Wide tables now show a horizontal scrollbar on demand, so every column stays reachable at any width.

Notes

  • .table-wrap in public/css/components.css switched from overflow: hidden to overflow-x: auto (mirrors the existing .reports-scroll container); the rounded border is preserved. tests/table-wrap-scroll.test.mjs. Suite: 2540.

[1.198.0] — 2026-08-15

Added — scan retries now use exponential backoff, jitter, and honour a rate-limiter’s Retry-After.

Added

  • When a job board briefly rate-limits or errors (HTTP 429 / 5xx) mid-scan, the retry now waits with exponential backoff + jitter instead of a fixed short delay — so a busy board isn’t re-hammered at a fixed cadence and concurrent retries don’t re-collide in lockstep. A Retry-After from the board is honoured (but clamped, so a hostile Retry-After: 86400 can’t stall a whole sweep). Permanent errors (404, refused redirects) still fail fast — unchanged.

Notes

  • New parseRetryAfterMs() + the pure computeRetryDelayMs() in server/lib/http-json.mjs; fetchJson now captures .retryAfter on a non-ok response and fetchJsonWithRetry takes an optional maxDelayMs (default 8000). tests/http-json.test.mjs (+9). Suite: 2536.

[1.197.0] — 2026-08-14

Added — track a Getro VC job board by its careers_url alone; the collection id auto-resolves.

Added

  • A tracked Getro board (b2venture, Earlybird, Point Nine, …) no longer needs a hand-looked-up numeric getro_collection. Give it the board’s own careers_url and the id auto-resolves from that page on the first scan — a single SSRF-safe GET reads the numeric network.id straight out of the board’s embedded page data. An explicit getro_collection still wins and skips the fetch entirely.

Notes

  • New httpsCareersUrl(), extractCollectionId(), and async resolveCollectionId() in server/lib/sources/getro.mjs; the board page is fetched through the DNS-pinned, size-capped safeGet, and the resolved id is still host-pinned to api.getro.com by assertGetroUrl. The adapter now matches a provider: getro entry carrying an https careers_url even without an id. tests/sources-getro.test.mjs (+8). Suite: 2527.

[1.196.0] — 2026-08-14

Fixed (security) — the Workday adapter validates an api endpoint by its hostname, not a substring.

Fixed

  • A portals.yml Workday api: value is now accepted only when its hostname is myworkdayjobs.com (or a .myworkdayjobs.com subdomain). The old check was a substring match, so any URL that merely contained the string — e.g. https://example.com/?x=myworkdayjobs.com or https://myworkdayjobs.com.example.com/… — passed and would have been handed back as the fetchable endpoint. Real Workday endpoints are unaffected. (Reported by CodeQL, #443.)

Notes

  • New isWorkdayApi() parses the URL and checks the host in server/lib/portals/adapters/workday.mjs; used by both matches() and buildEndpoint(). tests/workday-adapter-endpoint.test.mjs (+1). Suite: 2522.

[1.195.0] — 2026-08-14

Performance (scanner) — repost detection stays fast on large scan histories.

Performance

  • Duplicate-posting detection no longer degrades to O(N²) on a big scan-history.tsv. The per-company title grouping was a nested loop that paid a full roleFuzzyMatch (re-tokenizing both titles) on every pair; it is now an inverted index — bucket rows by exact title in one pass, then fuzzy-match only over DISTINCT buckets that share a discriminating (non-baseline) token. Output is identical — the exact same repost clusters — proven by a differential test against the old algorithm across 200+ randomised histories.

Notes

  • groupRowsByTitle in server/lib/detect-reposts.mjs (exported for the differential test). tests/detect-reposts-grouping.test.mjs (+2). Suite: 2521.

[1.194.0] — 2026-08-14

Fixed (scanner) — Workday career pages with a single-segment URL now scan correctly.

Fixed

  • The Workday adapter now parses careers URLs whose path is a single segment — e.g. https://parsons.wd5.myworkdayjobs.com/Search, .../KBR_Careers, .../Careers. Before, the site fell back to External, so the adapter hit the wrong CXS endpoint (/wday/cxs/<tenant>/External/jobs) and a probe could look healthy while returning nothing. It now takes the first non-empty path segment (after an optional locale) as the site (dropping a locale prefix like en-US); the documented /en-US/External case is unchanged. (Reported in #255.)

Notes

  • Structural path parse in server/lib/portals/adapters/workday.mjs (replaces the two-segment regex). tests/workday-adapter-endpoint.test.mjs (+7: single-segment, two-segment, deep-link, locale-only default, uppercase host, api pass-through, matches). Suite: 2519.

[1.193.0] — 2026-08-14

Added (stats) — a “Silent after interview” tab that surfaces interviews worth a nudge.

Added

  • A Silent after interview tab in #/stats: interviews that have gone quiet past a courtesy window (default 30 days), joining your active interviews and tracker — with how long each has been silent, the last interview date, and the reason. A gentle nudge / closure list; suggestion-only, never a rejection claim. Zero-token.

Notes

  • New GET /api/stats/rejection-latency relay (fail-soft {available:false} when the parent script is absent). tests/stats-rejection-latency-route.test.mjs (+2). +10 i18n keys ×17; #/stats help-hint bumped 7→8 tabs. Suite: 2510.

[1.192.0] — 2026-08-14

Added (cv-studio) — a “Fact-check your CV” gate that catches numbers you never actually had.

Added

  • A Fact-check your CV card in #/cv-studio: paste a tailored CV or cover letter and check every asserted metric and fact against your real CV, profile, and two-pager. You get a pass / warn / block verdict plus the exact invented metrics, unsupported facts, and forbidden / advisory phrases — so a generated résumé can’t quietly claim a number that isn’t yours. Zero-LLM; nothing is written.

Notes

  • New POST /api/cv-studio/verify-facts relay: writes the text to a throwaway temp file (never your files) and runs verify-cv-facts.mjs against it, trusting the JSON verdict even though the script exits 1 on a block. tests/cv-studio-verify-facts-route.test.mjs (+4). +15 i18n keys ×17. Suite: 2508.

[1.191.0] — 2026-08-14

Added (stats) — a “What to learn next” tab that ranks the skills worth learning first.

Added

  • A What to learn next tab in #/stats: a tracker-wide skill-gap roll-up — the missing skills that most often sank a low-fit match, weighted (by 5−fit-score across every evaluated report) and tiered Critical / High / Medium — plus the skills already covered by your CV/profile. Read-only, suggestions only; zero-token.

Notes

  • New GET /api/stats/upskill relay (carries an { error } field when there is too little data; fail-soft {available:false} when the parent script is absent). tests/stats-upskill-route.test.mjs (+3). +15 i18n keys ×17. Suite: 2504.

[1.190.0] — 2026-08-14

Added (tracker) — a “Company history” panel that tells you which companies actually answer you.

Added

  • A Company history card on #/tracker: pick a company and get read-only evidence — how responsive it has been to you (silent on you / mixed / responded before) and whether the same role keeps getting reposted — joined from your tracker, follow-ups, and scan history. Zero-token; the parent scanner is never called.

Notes

  • New GET /api/stats/company-history[?company=] relay (fail-soft {available:false} when the parent script is absent). tests/stats-company-history-route.test.mjs (+3). +18 i18n keys ×17. Suite: 2501.

[1.189.0] — 2026-08-14

Fixed (scanner) — seniority levels written as roman numerals now count on non-Latin titles too.

Fixed

  • The tier classifier behind skip_tiers now reads a roman-numeral level suffix (I / II / III / IV / V) after a role word in any script — “Инженер III”, “エンジニア I”, “Ingénieur IV” — not only after ASCII words. Before, a level numeral following a non-Latin word was ignored and the posting fell back to mid, so skip_tiers: [senior] or [entry] missed those listings. The match now also lands on the numeral itself, keeping leftmost-marker classification honest.

Notes

  • Script-agnostic lookbehind in server/lib/classify-tier.mjs; removed a dead duplicate Sr. matcher. tests/classify-tier.test.mjs (+1: non-Latin numerals). Suite: 2498.

[1.188.0] — 2026-08-14

Fixed (UI) — lead action buttons no longer sit flush against the page subtitle.

Fixed

  • The primary action / control row on Weekly interview digest, Funded companies, Portals, Career plan, and Career orientation now has a proper top margin, so the button breathes below the page subtitle instead of butting against it.

Notes

  • Regression guard tests/lead-row-top-margin.test.mjs (+5). Suite: 2497.

[1.187.0] — 2026-08-14

Fixed (scanner) — the skip_tiers setting works again: postings you asked to skip by seniority are dropped.

Fixed

  • A skip_tiers: list in portals.yml (e.g. skip_tiers: [intern, entry]) is now honoured by the scan. Each posting’s title is classified into a seniority tier (intern / entry / mid / senior) and dropped if its tier is in your list. Previously the scan ran the title / location / content / trust filters but had no tier filter, so skip_tiers was silently ignored. Titles with no explicit level word fall back to mid (so skip_tiers: [mid] also drops most plain listings), and the classifier reads the LEFTMOST level word — “Summer Intern, Director of Product” is correctly an internship, not a directorship.

Notes

  • New pure server/lib/classify-tier.mjs (classifyTier + buildTierFilter), wired into both the EN and RU scanner filter chains. tests/classify-tier.test.mjs (+7). Suite: 2492.

[1.186.0] — 2026-08-14

Added (CV Studio) — a “Skill gap” panel: which of a job’s required skills your CV names, implies, or is missing.

Added

  • A new Skill gap panel in CV Studio. Pick a saved job description and it sorts each required skill into named in your CV, implied in your CV, or missing — zero-LLM word matching, nothing written. A low-confidence note appears when the job posting had no clear requirements section.

Notes

  • New GET /api/jds/:name/skill-gap (the JD name is path-sanitized and confirmed under jds/ before it becomes a script arg; fail-soft {available:false} without the script). +13 i18n keys ×17. Tests: tests/jds-skill-gap-route.test.mjs (+4, incl. path-traversal rejection). Suite: 2485.

[1.185.0] — 2026-08-14

Added (stats) — a “Funnel & velocity” tab: how your funnel compares to the market and how fast you move between stages.

Added

  • A new Funnel & velocity tab in Statistics shows your response and interview rates next to candidate-side market benchmark ranges (with the small-sample and selection-bias caveats kept intact), a waiting list of in-flight applications past the typical first-response window, and median days per stage (Applied → Responded → Interview → Offer) — with slow-moving rows right-censored so they don’t bias the medians. Read-only and zero-token; it reads only your own tracker.

Notes

  • New GET /api/stats/funnel (fail-soft {available:false} when the script isn’t present). +18 i18n keys ×17. Tests: tests/stats-funnel-route.test.mjs (+2). Suite: 2481.

[1.184.0] — 2026-08-14

Fixed (UI) — the Dashboard quick-action tiles now line up in an even grid.

Fixed

  • On the Dashboard (Command Center), a group of 3 action tiles used to render wider than a group of 4, so the sections stacked with a ragged right edge. Every group now uses equal-width columns (4 on a wide screen, stepping down to 3 / 2 / 1 as the window narrows), so all tiles are the same size and their right edges align.

Notes

  • CSS only (.qa-grid: fixed repeat(N, minmax(0,1fr)) instead of auto-fill). Guarded by tests/dashboard-grid-align.test.mjs (+2). Suite: 2479.

[1.183.0] — 2026-08-14

Added (scanner) — smarter duplicate detection: the same job re-listed with a tracking link no longer shows up twice.

Added

  • The scanner now recognises a posting by a canonical URL key, so the same role re-listed with a tracking parameter (?utm_…, gclid, …), over http vs https, or with a trailing slash / #fragment is treated as the one posting it is — no duplicate row in your scan results or pipeline, and no wasted evaluation on a job you have already seen. Genuinely different postings (a kept functional id like gh_jid) still count separately.

Notes

  • New server/lib/url-key.mjs, wired into both scanners’ dedup and the pipeline writer. It deliberately under-strips — it never merges two distinct postings. Tests: tests/url-key.test.mjs (+5), tests/parsers.test.mjs (+1). Suite: 2477 (+6).

[1.182.0] — 2026-08-14

Fixed (scanner) — salary ranges now read the same in every language.

Fixed

  • Salary figures in scan and tracker rows use the locale-neutral symbols ≥ and ≤ (e.g. ≥ 120000 EUR, ≤ 90000) instead of the English words “from” / “up to”, which leaked untranslated into non-English UIs. Applies to every board that reports a one-sided pay range (Getro, Remotli, Manfred, Agentic Jobs, JustJoin, Jobicy); two-sided ranges (100000–150000 USD) were already language-neutral.

Notes

  • Display only — the client salary filter parses the numbers regardless of prefix, so filtering is unchanged. Suite: 2471.

[1.181.0] — 2026-08-14

Added (scanner) — Getro job boards now show salary, every location, and remote roles.

Added

  • The Getro scanner (VC talent-network boards) now surfaces a salary figure on each role (annual pay range + currency), lists all of a role’s locations instead of just the first, and tags remote roles. A Getro posting in your scan and tracker now carries the same salary + location detail as every other board.

Notes

  • Scanner-only; no new dependency, no route / CSP / SSRF change. Tests: tests/sources-getro.test.mjs (+5). Suite: 2470 (+5).

[1.180.0] — 2026-08-14

Fixed (MEDIUM, reports) — the #/reports list is now a table, and a real score a Machine Summary placeholder was hiding is recovered.

Fixed

  • The #/reports list is a table (Report · Date · Legitimacy · Score), not a 4-card grid. A long “Score not detected” chip used to squeeze the title column to near-zero, and the card title’s overflow-wrap: anywhere then broke the report name one character per line. Every field now has its own column, the report-name cell wraps at word boundaries, and the table scrolls horizontally on a narrow viewport (new .reports-scroll container). New i18n key rep.colReport ×17.
  • A real body score (**Итоговый балл:** 1.8 / 5) is no longer hidden by a Machine Summary placeholder (score: —). When the ## Machine Summary block carried a non-numeric or out-of-range score it filled the parsed score slot and blocked the bold-value-form fallback, so the report showed “Score not detected” despite a real X / 5 in the body. parseReportHeader now recovers the body value form whenever no usable number survived (step 4.5).

Notes

  • Client + parser only; no route / CSP / SSRF / parent-write change. Tests: tests/reports-table.test.mjs (+5), tests/report-header-locale.test.mjs (+2). Suite: 2465 (+7).

[1.179.0] — 2026-08-13

Changed (LOW, scanner) — consolidated 20 duplicate HTML-entity decoders onto the shared module (parent-sync follow-up, closes the worklist).

Changed

  • 20 scraping scan sources each carried their own decodeEntities/decodeXmlEntities (+ a fromCodePoint helper) — copies that had drifted (three could throw a RangeError, fixed in v1.172.0; others admitted NUL/C0 or mis-parsed &#1a2;). All now route through the single server/lib/html-entities.mjs (the XML-1.0-Char-safe decoder), removing ~237 lines of duplication. The 8 RSS-style sources gained &nbsp; decoding (they handled only 5 named entities before); cryptocurrencyjobs’s deliberate double-decode is preserved via an alias. hh keeps its own decoder (it handles &mdash;/&ndash;, outside the shared 6). A new guard test fails if any source re-grows a local decoder.

Notes

  • Behaviour-preserving refactor; no route / CSP / SSRF / parent-write change. Tests: tests/decoder-consolidation.test.mjs (+2). Suite: 2458 (+2).

[1.178.0] — 2026-08-13

Fixed (LOW, parent-parity) — refreshed two stale constants to match the parent (PARENT-SYNC GAP #4 + #5).

Fixed

  • Browser User-Agent (GAP #4) — BROWSER_LIKE_USER_AGENT (sent by workable/workday/oraclecloud/a16z/eightfold to clear WAF/bot gates) bumped Chrome 131 → 151, matching the parent’s user-agent.mjs; a stale build is likelier to be challenged. Guarded by a Chrome major ≥ 151 test.
  • Tracker states FALLBACK (GAP #5) — states.mjs’s last-resort FALLBACK (used only when the live templates/states.yml is unreadable — a fresh clone / CI-isolated root) gained the parent’s Turkish status aliases (#2615): değerlendirildi, başvuruldu, yanıt verildi, mülakat, teklif, reddedildi, iptal edildi, uygun değil, kabul edildi/işe alındı. In production the live file already provided these.

Notes

  • Two constants only; no route / CSP / SSRF / parent-write change. Tests: tests/http-json.test.mjs (+1) + tests/states.test.mjs (+1). Suite: 2456 (+2).

[1.177.0] — 2026-08-13

Fixed (MEDIUM, scanner) — csod (Cornerstone) returned 0 jobs on tenants that gate the search API behind session cookies (parent #2769, PARENT-SYNC GAP #1).

Fixed

  • Some Cornerstone tenants set session cookies on the bootstrap career-site home page and answer 401 CSOD Unauthorized on the search API unless those cookies come back alongside the anonymous bearer token. sources/csod.mjs now reads the bootstrap through a new fetchResponse helper, builds a Cookie header from its Set-Cookie values (cookieHeaderFrom — name=value only, jar semantics), and replays it on the search POST. Same-origin only (host-pinned + redirect:'error'), so session cookies can never reach a third party; a tenant that sets no cookies behaves exactly as before.

Notes

  • New server/lib/http-json.mjs::fetchResponse (additive; existing sources unaffected). No route / CSP / SSRF / parent-write change. Tests: tests/sources-parity-v1118a.test.mjs (+1). Suite: 2454 (+1).

[1.176.0] — 2026-08-13

Fixed (MEDIUM, reports) — a score under a bold label the RU table doesn’t list still read “Score not detected” (FIND-5).

Fixed

  • Two RU reports wrote the score as **Итоговый балл:** 1.8 / 5 / **Скор:** 1.8 / 5 — bold labels REPORT_LABELS.ru doesn’t enumerate (it knows only “Оценка”/“Балл”), so the score stayed unparsed. Rather than grow a synonym list, parseReportHeader now falls back to the value form: a fraction over the /5 rubric under ANY bold label. It’s language-independent, immune to a heading (no **, no /5 value), and rejects a date like 5/5/2026 (negative lookahead on the denominator).

Notes

  • Server parser only; no route / CSP / SSRF / parent-write change. Tests: tests/report-header-locale.test.mjs (+2: the two RU labels + a date-not-a-score guard). Suite: 2453 (+2).

[1.175.0] — 2026-08-13

Fixed (LOW, hardening) — a regression guard for the FIND-3 SEO description + a nullish-safe legitimacy strip (AI-review follow-up).

Fixed

  • SEO description parity guard — the v1.174.0 fix that swapped a hard-coded “~55” in every locale’s meta.desc for a registry-derived {adapters} placeholder had no test, so it could silently regress on the next locale edit. New CI-isolated tests/site-meta-desc-parity.test.mjs fails if any of the 17 site/src/i18n/*.json drops the placeholder or re-hard-codes a count, or if Landing.astro stops interpolating it into all three description metas.
  • Nullish-safe legitimacy strip — stripEmphasis returns '' for a nullish input instead of the string “undefined” (fields are string-initialized, so this is defense-in-depth).

Notes

  • Test + a one-line parser guard; no route / CSP / SSRF / parent-write change. Tests: tests/site-meta-desc-parity.test.mjs (+3). Suite: 2451 (+3).

[1.174.0] — 2026-08-13

Fixed (HIGH, reports) — localized reports read “Score not detected”; the SEO description was stale.

Fixed

  • Score parsing (FIND-1) — a non-English report whose H1 contains the score-label word (# Оценка вакансии: <title>) no longer has that title mistaken for the score. parseReportHeader now anchors on the localized bold label (**Оценка:** 1.5 / 5), skips heading lines, and requires the label adjacent to its colon — so RU reports that showed “Score not detected” render their real score.
  • Legitimacy chip (FIND-2) — markdown emphasis is stripped from the value, so a chip reads “High Confidence”, not “** High Confidence”.
  • Score overflow — a score line with trailing status text (“1.8, Status: Evaluated, …”) is compacted to just the score; .score-pill gains a no-wrap/overflow cap and the card’s title column can shrink, so a coloured chip never spills past the card edge.
  • SEO description (FIND-3) — the cvstart.org meta / OG / Twitter descriptions (all 17 locales) hard-coded “Scan ~55 job boards” while the body counted the live registry (“~75”). The description now interpolates the registry-derived count, so it can’t drift again.

Notes

  • Server parser + client render/CSS + site i18n; no route / CSP / SSRF / parent-write change. Tests: tests/report-header-locale.test.mjs (+4). Suite: 2448 (+4).

[1.173.0] — 2026-08-13

Added (LOW, config) — Hermes joins the detected AI-CLI roster (career-ops parity).

Added

  • The #/config → “AI CLI tools” tab now probes for Hermes (Nous Research), the parent’s newly supported agent runtime (binary hermes). server/lib/routes/cli-detect.mjs grows its fixed allowlist from 10 to 11 tools; detection stays a read-only PATH scan (no binary is ever executed).

Notes

  • No i18n / route / CSP / SSRF / parent-write change; the roster is a fixed allowlist, never input. Suite: 2444 (the cli-detect canary updated 10 → 11).

[1.172.0] — 2026-08-13

Fixed (MEDIUM, scanner) — a malformed HTML entity could crash a scan source (career-ops #2150 parity).

Fixed

  • The oraclecloud, gem and dassault sources decoded numeric HTML entities with a bare Number.isFinite guard before String.fromCodePoint — a reference above 0x10FFFF (e.g. &#99999999; from a malformed or adversarial feed) threw an uncaught RangeError and aborted that source’s entire parse. A shared server/lib/html-entities.mjs (mirroring the parent’s _html-entities.mjs) now restricts numeric references to the XML 1.0 §2.2 Char set so String.fromCodePoint can never throw, and matches hex vs decimal separately so &#1a2; no longer mis-parses. The three sources import it.

Notes

  • No change for valid feeds; no JS / i18n / route / CSP / SSRF / parent-write change. Consolidating the ~20 remaining in-source decoder copies is tracked in qa/PARENT-SYNC-WORKLIST-v1.26.0.md.
  • Tests: tests/html-entities.test.mjs (+7). Suite: 2444 (+7).

[1.171.0] — 2026-08-13

Changed (LOW, design-system) — type-scale + z-index layer tokens (D-4, first step). Sizes and stacking were literal per component; the system couldn’t be reproduced from tokens alone.

Changed

  • z-index layers — introduced named --z-* tokens (--z-topbar … --z-skiplink) and migrated every z-index literal to them. Values are preserved, so stacking is byte-identical; a new canary forbids fresh bare z-index magic numbers.
  • Type scale — introduced a --font-size-* ramp (xs 11 / sm 12 / md 13 / base 15 / lg 18 / xl 22 / 2xl 28, base = Inter 15px) and migrated the core sizes the components already used (value-preserving, zero visual change). Off-ramp one-offs (14 / 16 / 20 / 24 …) and weight/line-height tokens migrate incrementally as components are touched — tracked in docs/UX-ROADMAP.md.

Notes

  • CSS-token only; no behaviour, JS, i18n, route, CSP, SSRF, or parent-write change. No pixel changes (all migrations preserve the exact value).
  • Tests: tests/design-tokens-scale.test.mjs (+3: tokens defined, no bare z-index, base font uses the ramp). Suite: 2437 (+3).

[1.170.0] — 2026-08-13

Added (LOW) — honest ETA hints on long AI generations (P4-ETA). Heavy generations (career-plan ~40 s observed, orientation / market / networking ~30 s, two-pager AI-fill ~20 s) showed a bare “Generating…” with no sense of how long to wait.

Added

  • Each long-generation button now carries a muted ⏱ ~Ns hint next to it, mirroring the #/auto ETA (set expectations before the click). Shared .eta-hint style + two generic i18n keys — common.eta (~{n}s, substituted per page) and common.etaTitle (“Typical generation time”) — so a page passes its typical seconds without a new key each time.

Notes

  • Client-only; no route, CSP, SSRF, or parent-write change. +2 i18n keys ×17 (common.eta, common.etaTitle; snapshot 1219 → 1221).
  • Tests: tests/generation-eta-hint.test.mjs (+2). Suite: 2434 (+2).

[1.169.0] — 2026-08-13

Added (LOW) — inline PDF preview (D-5). GET /api/output/pdfs/:name forced Content-Disposition: attachment, so even the #/cv “Open” link downloaded the file instead of showing it — the docs stress “review it before sending it anywhere”.

Added

  • ?inline=1 on GET /api/output/pdfs/:name serves the SAME sanitized file with Content-Disposition: inline, so the browser renders it in a new tab for a review-before-send 👁 Preview. The default (no param) stays a download (attachment). No new route/surface; the same path-name guards apply.
  • The #/cv generated-PDF list’s first button is now 👁 Preview (opens ?inline=1) next to the unchanged ⬇ Download. cv.openPdf reworded “Open” → “Preview” ×17.

Notes

  • No CSP/SSRF change — same sanitizePathName gate; inline only affects the disposition header on the user’s own generated PDF (served application/pdf). One existing i18n key reworded ×17 (no new keys; snapshot 1219).
  • Tests: tests/output-pdfs.test.mjs (+3: inline header, default-attachment, path guards under ?inline=1). Suite: 2432 (+3).

[1.168.0] — 2026-08-13

Fixed (LOW, a11y) — checkbox rows now meet the WCAG 2.5.8 24×24 target-size floor (D-2). Checkbox/radio labels on #/scan, #/config, #/evaluate and #/cv-studio sat in a ~22 px band — 2 px under the minimum.

Fixed

  • A scoped label:has(> input[type="checkbox"/"radio"]) { min-height: 24px } rule guarantees a ≥24 px clickable band. min-height only — every such label is already flex/inline-flex, so nothing shifts; .apply-checklist (32 px) was already compliant. Completes the design-export a11y target-size items alongside the v1.162.0 ? fix.

Notes

  • CSS-only; no behaviour, JS, i18n, route, CSP, SSRF, or parent-write change.
  • Tests: tests/checkbox-target-size.test.mjs (+1). Suite: 2429 (+1).

[1.167.0] — 2026-08-13

Fixed (LOW, design-system) — elevated surfaces now separate from hairlines (D-3). The raised-surface tokens --panel-2 / --surface-elev1 resolved to --slate — the same value as the --line / --border hairlines — so an elevated panel or chip inside a bordered card had no visual separation.

Fixed

  • A dedicated theme-aware --elev token now backs the raised surfaces (#eef1f6 light / #1e232e dark, distinct from --slate in both themes); the hairlines stay on --slate. Elevated chips (pipeline, two-pager, docs-assistant / mock-interview bubbles, funded bars) and panels now read as raised.
  • The remaining design-export findings (D-2 checkbox target size, D-4 type-scale / z-index tokens, D-5 inline PDF preview, P4 generation-ETA) are restated as tracked backlog in docs/UX-ROADMAP.md — each its own future release (behaviour changes are never bundled into a token ship).

Notes

  • CSS-token only; no behaviour, JS, i18n, route, CSP, SSRF, or parent-write change. Dark-mode contrast guard (tests/dark-theme-tokens.test.mjs) green.
  • Tests: tests/elevation-token.test.mjs (+2). Suite: 2428 (+2).

[1.166.0] — 2026-08-13

Fixed (LOW) — rubric terminology now mirrors the canonical docs. career-ops.org/docs describes the scoring rubric as “five scoring dimensions plus a holistic global score”, but the web-ui, cvstart.org and the wiki all said “six-dimension rubric” — the numbers reconcile (5 + 1 = 6) but the vocabulary didn’t.

Fixed

  • Adopted the docs’ phrasing — “five dimensions plus a holistic global score” — consistently across README ×17, the cvstart.org site copy ×17 (method.* / how.3 / features.2 / meta.methodologyDesc), the in-app help guide ×17, docs/career-ops-canonical.md, and the wiki (Home ×17 + Features). The 6th factor (global fit) is now framed as the holistic global score, matching the docs.

Notes

  • Docs/marketing copy only; no code, i18n-dict key, route, CSP, SSRF, or parent-write change (the SPA UI never used the phrase).
  • Tests: tests/rubric-terminology.test.mjs (+2, guards the English source surfaces against the “six-dimension” drift). Suite: 2426 (+2).

[1.165.0] — 2026-08-13

Fixed (LOW) — the “Two-pager” term is now consistent within every locale. In Arabic the sidebar showed the Latin “Two-pager” while the page <h1> was fully localized (“الصفحتان الخاصتان بك”) — the only Latin string in an otherwise mirrored RTL nav.

Fixed

  • Decision enforced: per locale, nav.twoPager and twoPager.title agree on the term — both keep the Latin product noun, or both localize it. Only Arabic was split; its nav label is now localized (“الصفحتان”) to match its title. A new canary fails if any locale splits them again.

Notes

  • Copy-only; no route, CSP, SSRF, or parent-write change. One i18n value changed (ar); no new keys (snapshot 1219).
  • Tests: tests/two-pager-term-consistency.test.mjs (+2). Suite: 2424 (+2).

[1.164.0] — 2026-08-13

Fixed (LOW) — the top-bar search placeholder no longer overflows in any locale. “Find a company, role or URL…” was clipped (nowrap, scrollWidth > clientWidth) when the searchbar flex-shrinks on a busy top bar — and the “…or URL” half, which teaches the paste-a-URL auto-pipeline flow, was never visible.

Fixed

  • top.search (×17) is now the short “Search or paste a URL” (≤24 chars in every locale), so it fits even in a narrow searchbar while keeping the URL affordance. The hardcoded index.html placeholder fallback matches. The input’s aria-label still conveys the full “Cmd+K … paste a URL and Enter” detail.

Notes

  • Copy-only; no route, CSP, SSRF, or parent-write change. One existing i18n key reworded ×17 (no new keys; snapshot 1219).
  • Tests: tests/search-placeholder-fit.test.mjs (+2). Suite: 2422 (+2).

[1.163.0] — 2026-08-13

Fixed (LOW) — the in-app “Ask the docs” assistant now covers exporting a report to PDF. Asked “How do I export a report to PDF?”, the assistant answered that the guide didn’t cover it — although #/reports/:slug has a working 📄 Generate PDF control.

Fixed

  • Added an “Export a report to PDF” H3 under §10 Reports in all 17 help bundles (where the button is, that the file lands in output/*.pdf, needs Playwright, review-before-send). The docs-assistant retrieval (splitSections/topSections) now surfaces the Reports section for a PDF-export question.

Notes

  • Docs/help only; no code, route, CSP, SSRF, or parent-write change. Help gate moves 112 → 113 H3 (31 H2 unchanged); updated in help-ru-config-section + locales-de-it-tr.
  • Tests: tests/help-reports-pdf-section.test.mjs (+2: guidance present ×17, retrieval surfaces Reports). Suite: 2420 (+2).

[1.162.0] — 2026-08-13

Fixed (MEDIUM) — the “?” help affordance is now a ≥24×24 pointer target (WCAG 2.5.8). .help-hint measured 18×18 px with padding:0, below the Target Size (Minimum) floor, on every page heading.

Fixed

  • .help-hint box is now 24×24 (the measurable pointer target) while the visible ring stays 18px, drawn by a centered ::before — so the glyph size and the <h1> baseline/line-height are unchanged (page-title lines are taller than 24px). Hover / active / focus-visible states move with the ring; the margin trims 6→3px so the gap to the title is identical. The v1.158.0 document.title clone-strip (which excludes .help-hint) is untouched.

Notes

  • CSS-only; no JS, i18n, route, CSP, SSRF, or parent-write change.
  • Tests: tests/help-hint-target-size.test.mjs (+2). Suite: 2418 (+2).

[1.161.0] — 2026-08-13

Fixed (MEDIUM) — #/reports shows a “score not detected” chip instead of blank space. After the v1.159.0 locale-aware parser, a report that still has no parseable score rendered an empty metadata area — indistinguishable from a failed evaluation, with no recovery affordance.

Fixed

  • The score cell now branches: a parsed score → the tone pill; no score → a muted .score-muted chip reading “Score not detected” (localized ×17) with an “Open the report to see the score” tooltip. The card stays a keyboard-operable role="link" that opens the report (where the score lives in the body), and the date still renders (v1.159.0 mtime fallback), so cards never lose their anchor.
  • Reuses the existing neutral tone token — no new colour (score-tone.js already specifies a no-score row as muted).

Notes

  • Client-only; no route/CSP/SSRF/parent-write change. +2 i18n keys ×17 (rep.scoreUnparsed, rep.scoreUnparsedHint; snapshot 1217 → 1219).
  • Tests: tests/reports-unparsed-chip.test.mjs (+3). Suite: 2416 (+3).

[1.160.0] — 2026-08-13

Fixed (HIGH) — provider copy no longer contradicts the 7-provider promise. #/config said the web-ui live eval “uses your Anthropic or Gemini API key” and that the OpenAI key is “not used by the web UI itself”; #/dashboard’s Evaluate card said “Anthropic-first scoring” — all false since the OpenRouter / 7-provider cascade shipped (v1.157.0), and self-contradicted on the same screen (the header chip read Active: OpenRouter).

Fixed

  • config.providerModelNote (×17) now states the truth: the headless ⚡ live eval runs on any one of your seven provider keys — Anthropic · Gemini · OpenAI · Qwen · OpenRouter · GitHub Models · Hermes — auto-ordered, with fallback when a pinned provider has no key. The false “OpenAI … not used by the web UI itself” sentence is removed.
  • dash.quick.evaluateSub (×17) is now vendor-neutral (“0–5 fit scoring”), not “Anthropic-first scoring”; the dashboard.js fallback matches.
  • Keys: N / 5 → N / 7 — the denominator now matches the seven configurable provider key slots (SECRET_KEYS), the same second-order drift.

Notes

  • Copy-only; no route, CSP, SSRF, or parent-write change. Two existing i18n keys reworded ×17 (no new keys; snapshot stays 1217).
  • Tests: tests/provider-copy-honesty.test.mjs (+3: ≥5 provider names ×17, EN drops the exclusivity claims, Evaluate subtitle vendor-neutral ×17). Suite: 2413 (+3).

[1.159.0] — 2026-08-13

Fixed (HIGH) — report metadata is no longer language-coupled. Reports generated in a non-English locale rendered a blank metadata strip on #/reports (no score pill, date, or legitimacy chip), because parseReportHeader matched only English **Score:** / **Legitimacy:** / **Date:** bold labels — so the docs’ “Score → next step” table sat above cards that showed no score.

Fixed

  • parseReportHeader now parses the language-invariant ## Machine Summary YAML block. Precedence: English bold labels (keeps EN reports byte-identical) → the ## Machine Summary block’s score: / legitimacy: / date: keys (the same locale-free source auto-pipeline already reads) → locale-aware prose labels (REPORT_LABELS, all 17 locales) as a last resort. A Russian/Japanese/Arabic report now yields a real scoreNum, date and legitimacy.
  • Locale-tolerant score parsing — 1.5/5, 1.5 / 5, 1,5/5 (comma decimal), 1.5 из 5, 4.5 out of 5 and bare 4.5 all resolve; out-of-range (0–5) values reject.
  • Date never null — falls back to the report file’s mtime (already in the list payload) when the body has no parseable date, so cards keep their chronological anchor.

Notes

  • Read/parse only — no route, CSP, SSRF, or parent-write change; parent files stay read-only. No i18n-dict key change.
  • Tests: tests/report-header-locale.test.mjs (+8: EN byte-identity, RU Machine-Summary parse, mtime fallback, unparsed shape, numeric tolerance, 17-locale label coverage, localized-prose fallback) + a live GET /api/reports RU-fixture integration case. Suite: 2410 (+8).

[1.158.0] — 2026-08-12

Fixed — two cosmetic display bugs (a leaked “?” in tab titles, a wrong provider count on the landing). Display-only; no behaviour, security, or data-flow change.

Fixed

  • The HelpHint ? no longer leaks into document.title. Views built via HelpHint.title(text, key) render their <h1> as [<span>text</span>, <button class="help-hint">?</button>]; the router derived the per-route tab title from the raw h1.textContent, so the tab (and the screen-reader “page changed” announcement) read “Vacancy search?” instead of “Vacancy search”. router.js::focusNewView now deep-clones the heading, strips .help-hint, then reads the text — the live heading’s visible “?” is untouched.
  • cvstart.org showed “17 AI providers” instead of “7”. The landing’s Features.astro sub() helper eagerly rewrote every {n} to the locale count (17) before the per-card override could set the providers card to facts.providers (7), so the feature card and the stats banner disagreed on the same page. {n} is now resolved per-card (providers → 7, languages → 17).

Notes

  • No server, route, CSP, SSRF, or i18n-key change; facts.json shape unchanged (providers: 7, locales: 17).
  • Tests: tests/document-title-per-route.test.mjs (+1, source-static guard that the title is computed from a .help-hint-stripped clone). Suite: 2402 tests (+1); Playwright smoke/full-cycle/forms 62/62.

[1.157.0] — 2026-08-12

Fixed — live evals now run on ANY configured provider, not just Anthropic/Gemini. A user with only OPENROUTER_API_KEY set was wrongly forced into manual mode (“set ANTHROPIC_API_KEY or GEMINI_API_KEY…”). Two independent causes: a stale server-side pin, and stale client-side gating.

Fixed

  • Root cause — a keyless LLM_PROVIDER pin dead-ended. Running init with Claude Code writes LLM_PROVIDER=claude; if you later add only, say, an OpenRouter key, the forced-claude routing found no Anthropic key and fell through to a manual prompt — even though OpenRouter was configured and fully supported. Now a forced provider whose key isn’t set falls back to the auto order among the configured providers (a pin that DOES have its key stays forced). Applied consistently in env-config.mjs::selectActiveProvider and both dispatch cascades (routes/llm.mjs::_provGate + llm-dispatch.mjs::gate), so /api/status/providers and the actual run always agree.
  • Client gating was stale. #/deep and the mode-page views (#/contacto, #/interview-prep, #/project, …) decided “Run live vs manual” by probing /api/health for only ANTHROPIC_API_KEY / GEMINI_API_KEY. They now use the new window.ProviderStatus helper, which reads /api/status/providers (activeProvider, honoring all seven + the pin). No more ⚡-button that promises a live run the server would refuse.
  • Misleading copy. deep.tipManual, deep.needKey, and eval.manualMode (× 17) no longer name only Anthropic/Gemini — they point at “any provider key … in App settings”. config.llmProviderHint (× 17) explains the new pin-fallback. #/dashboard’s system card now shows a single honest Live evals · ready/manual badge (derived from all seven provider rows) instead of Anthropic/Gemini-only badges.

Added

  • public/js/lib/provider-status.js — window.ProviderStatus (.live() → { available, engine, activeProvider, keysConfigured }, plus a 7-provider label map): the single client source of truth for live-eval availability. Loaded after api.js.

Notes

  • No security-surface change — provider endpoints are still trusted config; no route, CSP, or SSRF change. The fallback only picks among keys the operator already configured.
  • Tests: tests/live-provider-gating.test.mjs (+4, source-static guard against the stale 2-provider probe) + a selectActiveProvider keyless-pin-fallback case; the old “pin with no key → null” assertion updated to the new fallback. 3 new dash.system.* keys × 17 (snapshot 1214 → 1217).
  • Suite: 2401 tests (+5); Playwright smoke/full-cycle/forms 62/62.

[1.156.0] — 2026-08-12

Refactor — split scan.js under the file-size limit (P-16), + a CodeQL fix. public/js/views/scan.js was 906 lines, over the 800-line hard limit. Two cohesive, behavior-preserving factories were extracted, bringing it to 648 — completing the view-split pair started with config.js in v1.155.0.

Changed

  • scan/runner.js (new, 276 lines) — window.createScanRunner(ctx) → { runScanAll, stopScan }: the scan-execution engine (Scan/Stop run-state, the indeterminate/determinate progress bar, the persistent error banner + Retry, the SSE console stream, and the per-source runners for ATS / regional / both).
  • scan/filters.js (new, 76 lines) — window.createScanFilters(refs, deps) → { applyFilters, resetFilters, getFilterState, setFilterState }: the result-filter state machine that backs saved searches.
  • scan.js (648) wires both via ctx/refs bags (the same pattern as lib/scan-results.js); the live-poll teardown timers stay at scan.js top level (shared classic-<script> scope). Both new files load before scan.js in index.html.

Fixed

  • CodeQL js/useless-assignment-to-local (#428) in config/tab-controller.js — let n = i; where n is reassigned on every non-returning branch → let n;. Harmless dead-store introduced by the v1.155.0 extraction (moved verbatim from config.js), now cleared.

Notes

  • Pure refactor, zero behavior change — moved code is byte-identical (de-indent only); no route, server, i18n, or CSS change. Four source-reading tests were repointed to the new files (the loadScanSrc() helper now concatenates scan.js + scan/runner.js + scan/filters.js + lib/scan-results.js); assertions otherwise identical.
  • Both oversized views (config.js, scan.js) are now under the 800-line limit; P-15/P-16 complete.
  • Suite: 2396 tests (unchanged — tests repointed, not added); Playwright scan+smoke 26/26 in-browser.

[1.155.0] — 2026-08-12

Refactor — split config.js under the file-size limit (P-15). public/js/views/config.js was 1030 lines, over the project’s 800-line hard limit. Two cohesive, behavior-preserving modules were extracted, bringing it to 783.

Changed

  • config/field-specs.js (new) — the pure, read-only field-spec data: the curated per-provider model lists + the FIELDS descriptor table (API keys / runtime / regional). window.ConfigFieldSpecs, loaded before the view.
  • config/tab-controller.js (new) — the #/config tab-bar controller (ARIA tablist + keyboard nav + panel swapping) as a createConfigTabController(c, panelHost) factory returning { tabBtn, activate }.
  • config.js now references both; the render logic (fieldRow, profile/section editors, save flow) is unchanged. Both new files are wired into index.html before config.js.

Notes

  • Pure refactor, zero behavior change — no route, no server, no i18n key, no CSS. Six source-reading tests were repointed to the new files (they assert on the moved field/model/tab markup); the assertions are otherwise identical.
  • scan.js (906) is intentionally left as-is. Its results-rendering was already extracted to lib/scan-results.js (v1.132.0); the remaining core is a tightly-coupled event-wiring closure bound to ~18 filter/DOM refs, where a mechanical factory extraction would need an 18-argument signature and worsen coupling — the opposite of the rule’s intent. A proper split needs a filter-state model refactor, deferred rather than force-fit.
  • Suite: 2396 tests (unchanged — tests repointed, not added).

[1.154.0] — 2026-08-12

New guide — “Running the whole stack in the cloud.” career-ops has no cloud/server story of its own, so this adds one: a step-by-step recipe for putting the parent career-ops pipeline, this career-ops-ui viewer, and the AI engine (a Claude subscription via the Claude Code CLI, a local Hermes gateway, or provider API keys) on a small always-on server — provision, pick your engine, and expose it safely. It ships as in-app Help §31 in all 17 languages, a README section, a wiki page, and (via the help mirror) the site.

Added

  • In-app Help §31 “Running the whole stack in the cloud” (× 17 locales) — the three moving parts (pipeline / viewer / engine), provision + install (VPS, Node ≥ 18), pick your engine (Claude subscription / Hermes / API keys, mixable), and expose it safely (HTTPS reverse proxy + auth + the CSP/SSRF/XSS/no-secrets invariants that must survive the move off 127.0.0.1). Help bundle grows to 31 H2 / 112 H3 (was 30/108).
  • README — a ## Run the whole stack in the cloud section (× 17) pointing to Help §31, docs/integrations/HERMES.md, and the new wiki page.
  • Wiki — a dedicated Cloud-Deployment page (the three-parts table, provision, engine choice, and the “must survive the move” invariants), linked from Home.

Notes

  • Docs-only — no route, no server, no client-code change; no new i18n key (help is Markdown, not the dict). The four help-bundle gate tests move to the 31 H2 / 112 H3 contract (canonical-docs-coverage, help-ui, help-ru-config-section, locales-de-it-tr).
  • Grounded in the existing docs/integrations/HERMES.md §2 cloud-deployment checklist and the parent’s own docs (which recommend Node 22.5+ and the eight first-class agent CLIs).
  • Suite: 2396 tests (unchanged — the gate assertions were updated, not added).

[1.153.0] — 2026-08-12

Jobvite scanner migrated to the public XML feed (parent-sync). The parent career-ops retired the Jobvite JSON API (it now 302-redirects and returns zero jobs); web-ui’s jobvite source used that same dead endpoint, so any tracked Jobvite company silently scanned empty. This ports the parent’s fix (#2623) into the web-ui source contract: the source now reads the public per-tenant XML feed on a different host, keyed by an opaque companyEId.

Fixed

  • Jobvite returned zero jobs — the source fetched https://jobs.jobvite.com/api/company/{slug}/jobs (retired). It now fetches https://app.jobvite.com/CompanyJobs/Xml.aspx?c={companyEId} and parses the XML <result><job>… payload (CDATA + entity-decode, detail-url preferred over apply-url, http:→https: on display-only per-job URLs).

Changed

  • companyEId resolution — the tenant key changed from the vanity slug (tylertech) to an opaque companyEId (q6NaVfwI) not present in the careers URL. Resolution order: (1) company_eid: on the portal entry, (2) the c= param of an explicit api: URL, (3) board-page discovery (scrape companyEId from the inline JS). Prefer (1) in portals.yml — one line, survives board redesigns, skips a request.
  • server/lib/http-json.mjs — fetchText now attaches .location / .retryAfter to the thrown non-ok error (read-only; lets jobvite tell an empty board — a NoJobs.htm redirect — from a retired tenant, without ever following the redirect). Backward-compatible: both fields are null for a plain error, so existing redirect:'error' callers are unaffected.

CI

  • Pages deploy no longer flips the status badge on a superseded build — deploy-pages.yml used concurrency.cancel-in-progress: true, so when a merge touching docs/help/** auto-fired a Pages build and a manual dispatch raced it, the older run was cancelled and surfaced as a red “check” (“Some checks were not successful”) even though the site deployed fine. Switched to cancel-in-progress: false (GitHub’s recommended Pages pattern) so a second run queues instead of cancelling — the status stays green.

Notes

  • Security — the source pins two hosts (jobs.jobvite.com for discovery, app.jobvite.com for the feed) via assertJobviteUrl before every fetch: https-only, strict-hostname allowlist, no redirect ever followed (redirect:'error' for discovery, redirect:'manual' for the feed — the 3xx is read but never chased). The companyEId is only ever a ?c= query value; feed/board URLs are rebuilt from the resolved slug/eId, never fetched verbatim from user input. Registry source count unchanged (meta.value='jobvite' preserved).
  • Parent-sync: this was the only web-ui-relevant change in the 17 parent commits since parentVersion 1.26.0 (the others — an LLM re-ranker, verify-cv-facts, liveness-core, and two web/ fixes — have no web-ui mirror).
  • Suite: 2396 tests (+4: tests/sources-jobvite.test.mjs rewritten for the XML contract — config/c=/discovery eId resolution, XML parse, the two-host guard, empty-feed handling).

[1.152.0] — 2026-08-12

Hermes provider — completed wiring + docs actualization. A detailed code review of the v1.151.0 Hermes integration surfaced two real gaps and four completeness items; all are fixed here, and the whole app’s LLM-provider roster is brought up to the full seven (Anthropic → Gemini → OpenAI → Qwen → OpenRouter → GitHub Models → Hermes) across every doc surface and all 17 locales.

Fixed

  • #/config could not force Hermes — the LLM_PROVIDER dropdown listed only six providers (GitHub was the last), so a user with both a cloud key and a local Hermes could set HERMES_API_KEY but had no way to force Hermes from the UI (only by hand-editing .env). hermes is now the 8th option (auto + 7 providers), and the field hint (config.llmProviderHint × 17 + the JS fallback) names the full seven-provider order. New provider-selector.test.mjs guard asserts the dropdown can never drift from LLM_PROVIDERS again.
  • Short self-hosted keys were silently rejected — isUsableKey’s 20-char floor was calibrated for cloud keys, but a hermes gateway‘s API_SERVER_KEY is user-chosen and may be short (the Hermes docs’ own example change-me-local-dev is 19 chars). hasHermesKey now uses a relaxed 8-char floor (still rejects empty/placeholder junk), so a legitimate short local key is no longer dropped to manual-mode without a diagnostic.

Changed

  • hermesChatUrl completes a bare host — HERMES_BASE_URL=http://127.0.0.1:8642 (a mis-paste that drops the /v1) now resolves to …/v1/chat/completions instead of a /v1-less 404.
  • Manual-fallback copy in routes/llm.mjs now names Hermes in the “execute via …” provider list.
  • Provider-roster actualization — the six-provider chain/force-list strings were normalized to the full seven across README (× 17), the in-app help (× 17), the config.llmProviderHint dict (× 17), and docs/sdd. CONVENTIONS.md corrected to “all 16 non-EN locales”. Assembled-dict snapshot regenerated (1214 keys).

Notes

  • Security unchanged — no new route, no SSRF/CSP/sanitizer change. The relaxed key floor only affects the local-loopback Hermes gateway; the provider endpoint stays trusted config (not a scanned job URL). HERMES_API_KEY remains a SECRET_KEY.
  • Health/doctor now carries a HERMES_API_KEY row (was omitted in v1.151.0), so #/health and career-ops-ui doctor list all seven providers.
  • Suite: 2392 tests (+2: the isUsableKey minLen guard + the dropdown-vs-LLM_PROVIDERS parity guard).

[1.151.0] — 2026-08-12

Hermes is now a wired LLM provider (Phase 5) — the Phase 5 scoping spike confirmed that Nous Research’s Hermes ships an OpenAI-compatible API Server (hermes gateway → POST /v1/chat/completions), so career-ops-ui now runs live evaluations through a local Hermes exactly like OpenAI/Qwen. Set HERMES_API_KEY in App settings and it joins the auto provider order (last). This closes the roadmap’s final open item — Phase 5, Shape A.

Added

  • Hermes LLM provider (Shape A) — runHermes on the shared runOpenAICompatible client (server/lib/openai.mjs), gated in both cascades (llm-dispatch.mjs + routes/llm.mjs), added to the auto provider-order tail + the LLM_PROVIDER=hermes pin, /api/status/providers, and server/lib/llm-pricing.mjs. It reaches a user-configured local base URL (default http://127.0.0.1:8642/v1) with Bearer auth — a configured provider endpoint (like OpenRouter/Qwen), not a user-supplied job URL, so it does not touch the isValidJobUrl SSRF guard.
  • #/config fields — HERMES_API_KEY (secret) + HERMES_BASE_URL + HERMES_MODEL (default hermes-agent), with 6 new i18n keys × 17 locales (assembled-dict snapshot 1208 → 1214).

Changed

  • The scoping spike is resolved: docs/integrations/HERMES.md, the in-app help §30 (× 17), the README teaser (× 14), the hermes-bridge skill, and docs/UX-ROADMAP.md all move from “planned / not-yet-wired” to wired (Shape A). Shape B (a bespoke agent-runtime relay) was not needed.

Notes

  • Security: the provider fetch is a configured endpoint, identical in category to the existing OpenAI-compatible providers — no new SSRF surface, no CSP/sanitizer change. HERMES_API_KEY is a SECRET_KEY (never echoed).
  • Tests (CI-isolated, stubbed transport): tests/hermes-provider.test.mjs (+5); the v1.146.0 “no Hermes branch” canary is inverted to assert it IS wired; the provider-surface tests updated to the 7-provider order.
  • Suite: 2390 tests (+5: tests/hermes-provider.test.mjs).

[1.150.0] — 2026-08-12

Consistent empty states (Phase 4 polish) — every “nothing here yet” panel now renders through the one shared .empty style instead of a few views re-declaring the look inline with a magic 40px. Small visual-consistency fix; the empty states on #/activity, #/cv-studio, #/stats, and #/usage now match every other one (tokenized 48px padding + the dashed border).

Changed

  • #/activity, #/cv-studio, #/stats, #/usage dropped their redundant inline style: { padding: '40px', textAlign: 'center', color: 'var(--foggy)' } on empty-state panels — all three properties are already provided by the shared .empty class (tokenized --space-7 = 48px, centered, muted, dashed border). So those four now render pixel-identical to the ~25 other .empty panels instead of drifting a few px on a hardcoded number.
  • Genuine per-view overrides (#/dashboard width:100%, #/pipeline border:none) are untouched — only the pure-redundant re-declarations were removed.

Notes

  • Client CSS-usage cleanup only — no route, no server, no i18n key, no CSS-rule change (the .empty class itself is unchanged); app dict snapshot stays 1208. Browser-verified (the #/usage empty panel computes to 48px padding + dashed border, no inline style, 0 console errors).
  • New canary tests/empty-state-consistency.test.mjs keeps .empty the single source of truth — a view may still add a layout-specific override (width/border) but not re-state padding/centering/colour.
  • Completes the concrete slice of Phase 4’s “overall visual polish”; broader taste-driven refinement across all pages remains open to direction. Phase 5 (Hermes provider) stays blocked on the API-contract spike.
  • Suite: 2385 tests (+2: tests/empty-state-consistency.test.mjs).

[1.149.0] — 2026-08-12

Portals moved into Settings (Phase 4) — #/portals now lives in the Setup nav group next to App settings, instead of under Sourcing. Since v1.144.0 it’s a settings surface (enable/disable tracked companies + an ATS health probe), not a sourcing action — so this is where it belongs. Nav-only change; the page and its route are untouched.

Changed

  • #/portals nav item → Setup group (in public/index.html), placed right after App settings. Removed from the Sourcing group (which keeps Scan / Pipeline / Auto-pipeline / Funded companies). The #/portals route, the view, and the nav.portals label are unchanged — only the sidebar position moved.

Notes

  • Nav markup only — no route, no view, no i18n key, no server change. Browser-verified (the Portals item now renders under the Setup header, 0 console errors); guarded by tests/portals-nav-placement.test.mjs.
  • Completes the Phase 4 “Portals → Settings” item. The remaining Phase 4 line — a whole-app visual-polish pass — is tracked separately.
  • Suite: 2383 tests (+2: tests/portals-nav-placement.test.mjs).

[1.148.0] — 2026-08-12

Cleaner scan filters (Phase 4) — the result-filter panel is now a tidy grid — the #/scan filter panel moved from a ragged flex-wrap of rigid, variable-width boxes to a responsive grid, and the Apply/Reset actions now sit on their own separated, right-aligned row. Same filters, same behaviour — just easier to read. A senior-designer polish pass (no parent-sync).

Changed

  • #/scan result-filter panel → responsive grid — .scan-filters is now display: grid with repeat(auto-fill, minmax(180px, 1fr)) columns and even gutters, so the 11 labelled filters align into tidy columns at every width instead of wrapping into a ragged row of 160–240px boxes.
  • Apply / Reset actions span the full grid on their own row, separated by a hairline and right-aligned, so they read as the panel’s primary action. Dropped the old hidden-label alignment hack + inner flex wrapper in scan.js (buttons are now direct children of .scan-filters__actions).

Notes

  • CSS + one small DOM cleanup only — every filter input id (#scan-filter-*, #scan-apply) and the SR.render() wiring are unchanged, so the Playwright scan-filter flow is untouched. No new i18n keys.
  • Browser-verified (grid renders 4 columns at 1100px, actions row separated + right-aligned, 0 console errors); guarded by tests/scan-filters-grid.test.mjs.
  • Remaining Phase 4: the broader “overall visual polish across all pages” pass is a subjective, whole-app design task left for explicit direction; the #/portals → settings-nav move is still open.
  • Suite: 2381 tests (+3: tests/scan-filters-grid.test.mjs).

[1.147.0] — 2026-08-12

Hermes & Telegram — the in-app help section + cvstart.org surface (Phase 5b, part 2) — the second and final slice of the Hermes docs work: the how-to now lives inside the app’s own help guide, in all 17 languages, and the in-app docs assistant answers Hermes questions from it. Still docs-only — the Hermes LLM-provider path remains planned / not-yet-wired (Phase 5).

Added

  • In-app help §30 “Hermes & Telegram” × 17 locales — a new help-guide section (what Hermes is + the two integration shapes; running on a cloud server; Telegram via Hermes + the “what NOT to expose” rule), reachable from #/help. The docs-assistant / floating “Ask the docs” DocsFab grounding picks it up automatically — no wiring needed, since both read docs/help/<lang>.md.
  • cvstart.org — a short Hermes explainer mirroring the README teaser and deep-linking to the GitHub guide.

Changed

  • Help-bundle gate lifted 29 → 30 H2 / 105 → 108 H3 (canonical-docs-coverage, help-ui, help-ru-config-section); §30 adds 3 H3s.

Notes

  • Still nothing calls Hermes. New canary tests/help-hermes-section.test.mjs asserts every locale carries the §30 section with its language-independent anchors (docs/integrations/HERMES.md, hermes-bridge, #/help, 127.0.0.1, Telegram). The provider stays blocked on the Phase 5 API-contract spike.
  • This closes the Phase 5b docs + skill deliverable; the provider integration (Phase 5) remains a separate, blocked item.
  • Suite: 2378 tests (+2: tests/help-hermes-section.test.mjs).

[1.146.0] — 2026-08-12

Hermes agent + Telegram — the integration guide + a skill (Phase 5b, part 1) — you can run career-ops-ui on a cloud server and bridge its events (a finished scan, a new report, an urgent follow-up) to Telegram through a Nous Research Hermes agent. This release ships the design + deployment docs and a hermes-bridge skill; the Hermes LLM-provider path stays planned / not-yet-wired (blocked on the Phase 5 API-contract spike). Docs-ahead-of-code by design.

Added

  • docs/integrations/HERMES.md — the deep-dive: the two integration shapes (OpenAI-compatible endpoint vs. agent runtime), cloud-server deployment (reverse proxy + HTTPS + systemd, the read-only parent contract on a headless box), Telegram-via-Hermes, and a threat-model “what NOT to expose” list (no CV / salary / report bodies / keys to the channel).
  • ## Hermes agent + Telegram README teaser — a short pointer + link, in the EN README and mirrored across the fully-translated locale READMEs.
  • A hermes-bridge skill (.claude/skills/hermes-bridge/) that operationalizes the guide — prerequisite + scoping-gate checks (Node ≥ 18, keys present, endpoint reachability via the SSRF-safe path), never writes secrets to disk/logs, and refuses to invent a Hermes endpoint or claim the provider is wired.
  • An Integrations section in docs/architecture/OVERVIEW.md links the guide.

Notes

  • Nothing calls Hermes yet. A canary (tests/hermes-docs.test.mjs) asserts the “planned / not-yet-wired” honesty markers and that llm-dispatch.mjs has no Hermes/Nous branch — so wiring the provider later must update the docs + roadmap in the same change.
  • Deferred to v1.147.0 (Phase 5b, part 2): the in-app help “Hermes & Telegram” H2 × 17 and the cvstart.org marketing surface.
  • Suite: 2376 tests (+4: tests/hermes-docs.test.mjs).

[1.145.0] — 2026-08-12

Insightful stats (cont.): a rebuildable chart — the #/stats “Target-role trend” tab now has a Build a chart widget: pick a metric × dimension and it re-renders live. First of the Phase-3 interactive-chart work. A user-reported UX request (no parent-sync).

Added

  • Rebuildable metric × dimension chart — choose a metric (Vacancies / Median salary / Average salary) and a dimension (By country / By role), and the bar chart re-renders instantly. Salary metrics honor the existing currency + per-year ⇄ per-month controls; vacancies are a plain count. Built from the same aggregation (RoleStats.aggregate) already computed for the tab — no new data or endpoint.
  • 8 new i18n keys × 17 locales (stats.customChart / metric / dimension / metricVacancies / metricMedian / metricAvg / dimCountry / dimRole); assembled-dict snapshot 1200 → 1208.

Notes

  • The widget lives on the Target-role trend tab (which owns the scan-derived role/country/salary aggregation). v1.140.0’s changelog mislabeled this same section as “My pipeline” — it was always renderTrend.
  • Browser-verified: the metric + dimension selects render and the chart re-renders on change, 0 console errors.
  • Suite: 2372 tests (+2: tests/stats-custom-chart.test.mjs — the wiring + EN-keys guard).

[1.144.0] — 2026-08-12

Settings & filters (Phase 4, part 1): enable/disable tracked portals — you can now turn a watched company on or off from #/portals, and the scanner honors it. A user-reported UX request (no parent-sync).

Added

  • POST /api/portals/toggle — an explicit user write that flips a watched company’s enabled flag in portals.yml, keyed by careers_url. The edit is surgical + parse-validated: only the target company’s enabled: line is inserted/flipped (comments, ordering, and every other field preserved byte-for-byte, matching the store.mjs write pattern), and the result must still parse as YAML or the write is refused. Same write-through contract as POST /api/tracker / PUT /api/cv.
  • Enable / Disable toggle per company on #/portals — one click turns a portal off (the EN scanner already skips enabled: false companies, so a disabled portal drops out of every future scan) or back on, with an optimistic toast.
  • 5 new i18n keys × 17 locales (portals.disable / enable / enabledToast / disabledToast / toggleFailed); assembled-dict snapshot 1195 → 1200.

Notes

  • The scanner change was zero — en-scanner.mjs already filters companies.filter((c) => c.enabled !== false); this release adds the UI + a safe write path to set it.
  • Deferred to a later Phase-4 slice (subjective / cosmetic — they want your eye): the scan-filter visual redesign and moving #/portals fully into a settings nav section. #/portals remains the portal-settings surface for now.
  • Suite: 2370 tests (+3: setEnabledInRaw insert/flip/not-found + the toggle round-trip + unknown-url 404 in tests/portals-routes.test.mjs).

[1.143.0] — 2026-08-12

Understandable (cont.): ? hints on the core workflow views — the ? help affordance now covers the nine main workflow/decision pages, in every language. A user-reported UX pass (no parent-sync).

Added

  • ? help hint on 9 more view titles — #/scan, #/evaluate, #/cv-studio, #/tracker, #/config, #/deep, #/batch, #/auto, #/apply each get an inline title ? (via HelpHint.title) that opens a localized “what this does / how to use it / what result to expect” popover — the same CSP-safe, theme-aware, RTL-mirrored primitive shipped in v1.139.0.
  • 9 new i18n keys × 17 locales (help.hint.scan / evaluate / cvStudio / tracker / config / deep / batch / auto / apply); assembled-dict snapshot 1186 → 1195.

Notes

  • Browser-verified in EN (scan / evaluate / config / tracker / batch render the ? + popover, 0 console errors). #/config and #/batch each have two page-title <h1>s (main render + error state) — both wired.
  • The remaining lighter views (#/dashboard, #/cv, #/reports, #/usage, #/pipeline, #/portals, #/activity, #/docs-assistant) are a later wave.
  • Suite: 2365 tests (+1: the 9-workflow-view wiring guard in tests/help-hint.test.mjs).

[1.142.0] — 2026-08-12

Correctness: no more “Unknown” career archetype — #/orientation now always ranks from the eight named career vectors instead of occasionally answering “Unknown” and recommending you “double down” on it. A user-reported fix (no parent-sync).

Fixed

  • #/orientation — the AI prompt now forbids an out-of-set archetype. The model MUST rank the top three from exactly the eight named vectors (Functionalist / Administrator / Communicator / Specialist / Analyst / Innovator / Manager / Entrepreneur) and may never answer “Unknown” / “N/A” / “insufficient data” or invent a new label. When the CV is thin it still names the three closest fits at lower confidence and says what evidence is missing — instead of declining and producing nonsensical “double down on Unknown” advice.

Notes

  • Server-side prompt change only (buildOrientationPrompt); no i18n / schema change. The generated profile stays localized via the existing output-language directive.
  • Suite: 2364 tests (+1: tests/orientation-routes.test.mjs — the prompt names the eight vectors and forbids “Unknown”).

[1.141.0] — 2026-08-12

Insightful stats (cont.): funded-company enrichment — #/funded becomes a richer, more visual view: company logos, a funding-amount chart, and per-company round / amount / discovery-score / suggested-action cards. A user-reported UX pass (no parent-sync).

Changed

  • #/funded — flat table → enriched card grid. Each recently-funded company is now a card with a logo (derived from the company name via CompanyLogo, letter-avatar fallback), round + amount chips, the parent’s discovery score and suggested action (previously discarded from the relay payload), and the funding-news source link + date.
  • Funding-amount visualization — a horizontal bar chart of the top companies by disclosed amount; free-text amounts (“$120M” / “€1.5B” / “500K”) are parsed to a magnitude via a new parseAmount.
  • 3 new i18n keys × 17 locales (funded.byAmount / funded.score / funded.action); assembled-dict snapshot 1183 → 1186.

Notes

  • Still read-only over GET /api/company-funded (host-pinned public feeds — TechCrunch / PR Newswire / The Guardian / Hacker News); no writes, no LLM, no new route. The “verify a company independently” caveat stays. Description and salary range aren’t in the funding feed, so they’re out of scope for this source.
  • Remaining Phase 3 — interactive/rebuildable charts + the “Unknown” archetype fix (orientation prompt-quality) — continues later (docs/UX-ROADMAP.md).
  • Suite: 2363 tests (+2: tests/funded-view.test.mjs — parseAmount + card-render wiring; the v1.133.1 regression guard updated table→cards).

[1.140.0] — 2026-08-12

Insightful stats: richer salary figures — the #/stats “My pipeline” salary breakdown now shows the average (not just the median), a per-year ⇄ per-month toggle, and a min · avg · median · max table per country. First slice of roadmap Phase 3. A user-reported UX pass (no parent-sync).

Added

  • Average (mean) salary — RoleStats.salaryStats now returns avgUsd alongside minUsd/medianUsd/maxUsd. The median resists outliers; the average exposes right-skew (a few very-high postings pull it above the median), so the two together read as a distribution rather than a single point.
  • Per-year ⇄ per-month toggle on the salary section — every figure divides by 12 for the monthly view, respecting the currency selector.
  • Min · avg · median · max table per country under the “salary by country” chart, driven by the same currency + period controls. 8 new i18n keys × 17 locales.

Notes

  • Salary figures are still derived only from postings/applications with a parseable salary and normalized to USD via approximate FX — indicative, not authoritative (the existing caveat stays on the section).
  • Remaining Phase 3 — interactive/rebuildable charts, the “Unknown” archetype fix (orientation), and funded-company enrichment — continues in later releases (docs/UX-ROADMAP.md).
  • Suite: 2361 tests (+1: an average-right-skew case in tests/role-stats.test.mjs).

[1.139.0] — 2026-08-12

Understandable: ? help hints — a reusable, CSP-safe ? affordance that explains “what this does / how it works / what to expect” on demand, in your language. A user-reported UX pass (no parent-sync).

Added

  • ? help-hint popover (public/js/lib/help-hint.js → window.HelpHint): a small round ? button next to a heading opens a lightweight, theme-aware, RTL-mirrored popover that renders a localized explanation through UI.md() (the escape-first XSS boundary). Accessible — a real <button> with aria-expanded, the popover is role="tooltip", and Escape / outside-click close it with focus returning to the button; CSP-safe (handlers via addEventListener, static ? glyph); singleton (one popover open at a time), positioned under the icon and clamped to the viewport.
  • ? on the 5 #/stats tabs — the active-tab caption (“Rejection patterns (?)”) now carries a hint explaining each analytics view (Market report, My pipeline, Target-role trend, Rejection patterns, Lifetime) — directly answering “what is this tab for”.
  • ? on 8 AI/analytics view titles — career-plan, orientation, two-pager, networking, mock-interview, memory, funded, and the weekly interview digest each get an inline title ? (HelpHint.title) so “what is this / how do I use it / what result to expect” is one click away even when the page is empty.
  • 14 new i18n keys × 17 locales (help.hint.* + stats.hint.*); assembled-dict snapshot regenerated (1161 → 1175 keys). Guarded by tests/help-hint.test.mjs (CSP-safe, wired, EN-present) + the existing i18n parity/latin-leak/personal-data gates.

Notes

  • All 30 views already carried a one-line page-subtitle; the ? hints add the deeper on-demand explanation on top, which also makes empty states (#/career-plan, the weekly digest, #/funded) self-explanatory instead of looking broken.
  • docs/UX-ROADMAP.md: Phase 2b “Understandable” is now shipping — the ?-hint system landed here; extending ? to the remaining views continues in a later release.
  • Suite: 2360 tests (+4: tests/help-hint.test.mjs).

[1.138.0] — 2026-08-12

Generation in your interface language — every AI generation now answers in the language you’ve picked in the UI, plus review-driven test hardening. A user-reported UX pass (no parent-sync).

Changed

  • AI generations now respect the UI language. With the interface set to Russian, Spanish, Japanese, … the generated text now comes back in that language instead of always English. The output-language directive is threaded through every generation endpoint — career plan, orientation, market report, mock interview, networking plan, “ask the docs”, the memory-note suggestion, and the two-pager draft. Code and identifiers stay English (e.g. the two-pager YAML keys); only prose, headings, and bullets are localized. Server-side, resolveLocale(req) + buildLocaleDirective(lang) now also cover POST /api/memory/suggest and POST /api/two-pager/draft; the client sends the active lang on all eight generate calls.

Fixed

  • CSS colour-role guard (tests/css-role-tokens.test.mjs) — a source-static canary asserting the v1.137.0 dark-mode alias tokens never invert role: text-role tokens (--fg/--danger/--ok/…) are never used as a background, and surface-role tokens (--card/--panel/--line/…) never as a text color, across all hand-written CSS and the SPA inline styles. Makes the “0 WCAG-AA failures across 29 views” claim machine-checkable (both AI reviews asked for it).
  • UI.md() XSS-loader self-probe — the test-only extractor that vm-loads md() from api.js now probes md('<script>…') immediately after extraction and throws if the escape is missing, so a future mis-slice of the brace-matched source fails loudly instead of turning the security suite green against a truncated function.
  • #/career-plan scroll guard — the post-generate scrollIntoView now runs only when the preview is still connected to the document.

Notes

  • docs/UX-ROADMAP.md updated: the ?-help-hints + page-descriptions + empty-state pass is now v1.139.0; a Nous Research / Hermes provider — plus a cloud-server + Telegram deployment guide and a Hermes skill — is tracked as Phase 5 / 5b.
  • Suite: 2356 tests (+5: two generation-language canaries, three colour-role guards).

[1.137.0] — 2026-08-11

Readability & rendering fixes — dark-mode contrast, chart labels, and the career plan. A user-reported UX pass (no parent-sync).

Fixed

  • Dark-mode white-on-white / black-on-black across many screens — fifteen CSS custom properties several views referenced (--fg, --panel, --panel-2, --surface-elev1, --line, --ok, --go, --err, --error, --danger, --warn, --muted, --ink, --card, --border) were never declared in the palette, so var(--fg, #111) / var(--panel-2, #eef1f6) / var(--ok, #008a05) silently fell back to hardcoded light/black literals: fine in light mode, unreadable in dark (the #/pipeline overview chips, #/stats active tab, #/config “Active / Keys” + “✓ set”, #/two-pager sections, #/mock-interview question bubble, error text, and more). They’re now aliased to the real theme-aware tokens (--hof/--paper/--slate/--kazan-text/--rausch-text/…) on :root, so they follow the theme automatically. The #/config active tab (a solid pink pill with white text, ~2.94:1) became the readable tinted-badge pattern. Verified with an automated alpha-composited contrast auditor: 0 WCAG-AA failures across all 29 views in dark mode (was: pipeline chip ~1→12.2, stats tab ~1→15.8). A regression guard (tests/dark-theme-tokens.test.mjs) keeps every alias declared and mapped to a theme-aware token.
  • #/stats chart labels were hard-cut mid-word (“Senior Backend Engineer” → “…Enginee”) — the SVG bar chart now ellipsizes (…) with the full label kept as a hover <title>, and the label column is wider.
  • #/career-plan showed the generated plan as raw Markdown (##, **, table pipes) — it now auto-renders the plan as formatted, readable text the moment it’s generated (the editable Markdown stays in the textarea below; Preview toggles the rendered view).

Notes

  • #/career-plan, #/two-pager, #/memory, #/stats, and the weekly interview digest are not broken — every endpoint returns 200; they render empty states (no plan generated yet / no interview sessions logged in the selected week). Clearer on-page “what this does / how to use it” guidance and ? help hints land in the next release.
  • The generated career plan renders through UI.md(), the app’s escape-first client XSS boundary (api.js HTML-escapes every byte before any tag transform) — same path reports/orientation/cv-studio use. A new tests/ui-md-xss.test.mjs feeds <img onerror>/<script>/javascript: payloads through it and asserts no live tag/handler survives.
  • Repo tidy: docs/UX-ROADMAP.md records the Phase 2–4 UX plan; the two perennial QA prompts (qa/UX-AUDIT-PROMPT.md, qa/DESIGNER-EXPORT-PROMPT.md) were actualized to the v1.137.0 baseline (32 route modules · 79 sources · 17 locales); and 59 superseded per-release QA prompts moved to qa/archive/superseded-prompts/.

[1.136.0] — 2026-08-11

Parent career-ops v1.26.x parity (post-v1.26.0 mainline) — one new zero-auth source plus a wave of quality & robustness ports to code web-ui mirrors. Registry now 79 sources = 74 EN + 5 RU (ALL_ADAPTERS 74).

Added

  • eightfold (Eightfold AI, parent #2684) — talent-acquisition boards via the zero-auth https://<tenant>.eightfold.ai/api/apply/v2/jobs API, host-pinned to *.eightfold.ai (the branded careers.<company>.com CNAME is deliberately rejected — the entry must point at the tenant host); paginated with a safety cap, dead-board-throw, url-dedup. Source + adapter + CI-isolated suite; appears in the #/scan Source filter and on the landing.

Fixed

  • Unicode-aware dedup & role keys (parent #2569 / #2587 / #2667) — a new shared server/lib/text-key.mjs::normalizeTextKey (NFKC + lowercase, keeps letters/marks/digits of any script) replaces the ASCII-only keys web-ui mirrored from the core:
    • detect-reposts now keys the company on it, so width/punctuation/spacing variants ("Acme, Inc." ≡ "Acme Inc") cluster and a genuine repost is no longer missed, while distinct non-Latin employers («Тинькофф» vs «Яндекс») never collapse to one key.
    • role-matcher folds full-width titles (SENIOR … ≡ Senior …) and keeps non-Latin role tokens instead of erasing them to an empty set that could never match.
  • fetchJsonWithRetry no longer retries a refused redirect (parent #2657) — our redirect:'error' SSRF guard meeting a 3xx surfaces as a no-status TypeError that looks transient but is deterministic; it’s now classified non-retryable (via undici’s err.cause.message), so it fails fast instead of burning the whole retry budget.
  • title_filter.positive AND-groups (parent #2552) — a whitespace-delimited + inside a positive entry ("staff + platform") now requires every term to appear in the title, in any order; plain entries and c++-style terms are unaffected (the separator requires surrounding whitespace).
  • oraclecloud accepts the numbered tenant apexes oraclecloud1.com … oraclecloud99.com (parent #2683) — some tenants live only on a numbered apex (<t>.fa.ocs.oraclecloud26.com); the host pin now enumerates the bounded family (no leading zero, ≤ 2 digits — never a wildcard apex).
  • workable hardened (parent #2675) — the widget request now goes through the shared retry helper with browser-like headers + a per-account referer and process-wide request serialization against the Cloudflare-fronted host.
  • personio falls back to an HTML scrape when the XML jobs feed is disabled (404) instead of returning nothing — same host-pinning, dead-board contract preserved.
  • states FALLBACK aliases resynced with the parent’s states.yml (#2615) — evaluated gains condicional/hold/evaluar/verificar, skip gains geo blocker/geo_blocker (the live parent file is still read first; this only backstops a fresh clone).

Notes

  • Not ported (not mirrored by web-ui, or CLI-only): reply-matcher corroboration (#2672), jd-similarity seniority gate (#2661), jd-skill-gap heading recognition (#2686) — web-ui has no email-reply / JD-analysis surface; scan env-path (#2568) / --flag=value (#2589) parsing and the per-run dedup-read perf (#… ) — web-ui runs the scanners in-process; cover-letter / CV-template / doctor / ollama / generate-pdf changes — CLI-only. The web js-yaml/nanoid HIGH advisories were already patched in web-ui v1.135.0.

[1.135.0] — 2026-08-11

Parent career-ops v1.26.0 parity — five new zero-auth scan sources plus correctness fixes to four boards web-ui already carries. Registry now 78 sources = 73 EN + 5 RU (ALL_ADAPTERS 73).

Added

  • Five new scan sources (parent #825 / #2527 / #2464), each a self-registering source + adapter + CI-isolated suite, appearing in the #/scan Source filter (FALLBACK + live registry) and on the cvstart.org landing:
    • join (JOIN, parent #2527) — reads a company’s JOIN board from the Next.js __NEXT_DATA__ embedded in join.com/companies/<slug> (host-pinned, page-capped, redirect:'error'); detected from a join.com careers URL.
    • getro (Getro, parent #825) — VC “talent-network” portfolio boards (b2venture, Point Nine, Speedinvest, …) via the public api.getro.com/api/v2/collections/{id}/search/jobs POST API, paginated newest-first with an age-bounded early stop; the numeric board id is set explicitly (getro_collection:), and each job is attributed to the portfolio employer, not the fund.
    • consider (Consider, parent #825) — getconsider.com VC portfolio boards (Founderful, Creandum, Balderton, …) via the same-origin /api-boards/search-jobs POST; the POST host is config-driven from the board’s careers URL and pinned by a structural SSRF guard (public HTTPS host only — rejects IP-literals, loopback, *.internal).
    • joinup (JOINUP, parent #825) — the Swiss startup board joinup.ch, reading the SSR’d newest page of joinup.ch/browse/jobs (__NEXT_DATA__); fail-closed on a scraper break.
    • remotli (Remotli, parent #2464) — remotli.ch, a curated board of remote roles at Swiss companies (salaries in CHF), via the public remotli.ch/api/jobs?remote=all JSON API; emits the employer’s own ATS applyUrl as the canonical link (so cross-listings dedup) and the real employer as the company.

Fixed

  • a16z Speedrun no longer aborts the whole board on a transient blip (parent #2506) — the feed paginates into the hundreds of pages, so a single mid-sweep 429/5xx/timeout used to abort the provider and return nothing. Page fetches now go through a new shared fetchJsonWithRetry (bounded retries on transient failures only — a permanent 4xx is never retried), and the page budget is re-sized for the 50-job page (DEFAULT_MAX_PAGES 3→6, MAX_PAGES_CAP 120→1000, parent #36d0c44). web-ui’s page-0-throw / later-page-keep-partials dead-board contract is preserved.
  • arbeitsagentur moved to the v6 Jobsuche API (parent #2494) — the old /pc/v4/jobs endpoint 404s; v6 (/pc/v6/jobs) renames the response shape (ergebnisliste, referenznummer, stellenangebotsTitel, stellenlokationen[]) and drops the detail-endpoint remote verification (v6’s detail endpoint 403s to the public key), so remoteMatch:'filter' now narrows server-side with homeoffice=nv_true + the same title check.
  • The Hub moved to the v2 jobsandfeatured API (parent #6b33fc4) — from api/jobs to api/v2/jobsandfeatured (json.jobs.docs/.pages envelope), posting URLs rebuilt from the job id, countryCode=EU sent by default; The Hub rows carry no posted date and are exempt from the age filter (emit date: '').
  • hackernews finds the monthly “Who is hiring?” thread reliably (parent #3aa5e15) — the Algolia lookup filters by the whoishiring account tag (tags=story,author_whoishiring) instead of a free-text query that could rank an unrelated recent story above the real thread once enough time had passed since it posted.

Notes

  • Not ported (web-ui already safe, relay-absorbed, or CLI-only):
    • The Unicode-aware role-dedup / company-matching keys (parent #2569 / #2587 / #2429 family): web-ui’s repost grouping already keys company on a plain lowercase (detect-reposts.mjs), so distinct non-Latin employers never collapse to one key — the exact bug those fixed. trust-validator’s ASCII company/hostname heuristic degrades gracefully (returns “no flag” for a non-Latin name, never a silent merge), and role-token matching mirrors the parent’s deliberate “non-Latin role distinction out of scope” decision.
    • The followup rejection-latency signal (parent #2014) + new rejection-latency.mjs, and the company-funded touch-ups: web-ui relays these read-only, and the fail-soft relay absorbs the shape change with no code edit.
    • scan env-overridable pipeline/history paths (#2568) and --flag=value parsing (#2589): web-ui runs the scanners in-process with its own paths.mjs and has no CLI arg surface.
    • The UA-consolidation refactor (oraclecloud/vdab/jobspresso → shared _http.mjs, #2536): a no-op for web-ui, which already centralizes BROWSER_LIKE_USER_AGENT in http-json.mjs.
    • CLI-only / non-mirrored: the untrusted-content coverage roster (#2521), oferta/offer-prep lawyer routing, doctor subscription warnings, cover-letter signature block + unresolved-token guard, CV empty-section trimming, YC-seed paging, .gitattributes/.npmignore, and the parent’s own Next.js web/ changes.

[1.134.1] — 2026-08-05

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

Fixed

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

Security

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

[1.134.0] — 2026-08-05

Parent career-ops v1.25.0 parity.

Added

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

Fixed

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

Notes

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

[1.133.1] — 2026-08-02

Fixed

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

[1.133.0] — 2026-08-01

Added

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

Notes

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

[1.132.0] — 2026-07-31

Changed

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

Housekeeping

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

[1.131.2] — 2026-07-31

Changed

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

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