Changelog

GitHub

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

Ændringslog

Alle væsentlige ændringer i career-ops-ui er dokumenteret her. Formatet følger Keep a Changelog, og projektet følger Semantic Versioning.

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

Oversættelsesnote. Filen er oversat til dansk: de seneste poster (fra v1.77.0 og bagud, inkl. v1.76.0 / v1.75.2 / v1.75.1 / v1.75.0) er fuldt oversat til teknisk dansk. Ældre poster bevares på engelsk; versionsoverskrifter og datoer er identiske med den engelske kildefil.


[1.134.1] — 2026-08-05

Validering-hærdning — rettelser afdækket af en fuldstændig projektgennemgang.

Rettet

  • successfactors kasserer ikke længere hentede job ved en fejl midt i scanningen (regression i v1.134.0’s port af “dødt-opslagstavle-kaster-fejl”) — dens pagineringsloop havde ingen try/catch, så en fejl på side 2 eller senere (efter at side 1 var lykkedes) kastede en fejl og kasserede alt, hvad der allerede var indsamlet; og hvis denne fejl var en 404 (en startrow uden for området), ville en-scanner sætte en live tenant i karantæne som “død” i flere dage. Spejler nu phenom/radancy: en fejl på side 0 kaster stadig en fejl (død opslagstavle), men en fejl på en senere side bevarer de delvise resultater.
  • #/scan-facetfiltrenes chips er nu tastaturbetjenbare (WCAG 2.1.1) — facet-chippene (og “ryd”-chippen) var spans med en klik-handler, men uden tabindex/rolle, så tastatur- og skærmlæserbrugere ikke kunne nå eller aktivere dem. De bærer nu role="button", tabindex="0", aria-pressed og Enter/Space-aktivering.
  • Tre hardkodede engelske strenge er nu lokaliseret#/scan-tillidsbadgets tooltip, #/scan-relokeringskolonnens overskrift og #/dashboard-scoreoverskriften var bare literaler, som i18n-paritetstjekket ikke kunne se (de var aldrig nøgler), så de blev stående på engelsk i alle ikke-engelske lokaliteter. Nu scan.trustTip + scan.col.reloc (2 nye nøgler) + genbrug af track.col.score, med en kildestatisk vagt.

[1.134.0] — 2026-08-05

Paritet med forælder-projektet career-ops v1.25.0.

Tilføjet

  • Ny scan-kilde: getManfred (manfred) — et boardbredt feed af spanske/EU-teknologijob med offentliggjorte lønninger, fra www.getmanfred.com/api/v2/public/offers (ingen auth, host-pinned + kun HTTPS, fuldt katalog i én enkelt forespørgsel). Kilde + adapter + en CI-isoleret suite (tests/sources-manfred.test.mjs); registret rummer nu 73 kilder = 68 engelske + 5 russiske (ALL_ADAPTERS 68). Optræder i #/scans Kilde-filter og på cvstart.org-landingssiden.

Rettet

  • a16z Speedrun-feedet blev stille afkortet til 50 job (#2404) — feedet loftbelægger en side ved 50, men kilden anmodede om PER_PAGE = 100, så pagineringen stoppede efter side 1. Rettet til 50.
  • Døde jobopslagstavler kaster nu en fejl i stedet for at blive læst som “live, men tom” (#2379) — cryptocurrencyjobs, phenom, radancy, successfactors: en hentningsfejl, hvor ingen forespørgsel nogensinde blev afsluttet, kaster nu en fejl (så #/portals-sundhedstjek og scanningen registrerer en reel fejl), i stedet for at sluge den til en tom liste; en fejl midt i scanningen efter mindst én succes bevarer de delvise resultater.
  • workable bruger nu den offentlige widget-API (#5ab8425) — skiftet til apply.workable.com/api/v1/widget/accounts/<slug>, som returnerer en stor kontos fulde opslagsliste i én enkelt forespørgsel, så store konti ikke længere afkortes.

Noter

  • Ikke porteret (kun CLI eller ikke spejlet af web-ui): detect-reposts #2389’s titel-bucketing-ydelsesomskrivning; Unicode-firmanøgle-rettelserne (web-ui’s eget tracker-dedup er allerede sikkert for ikke-latinske tegn); scan --since; cv-facts; CV-skabelon-/PDF-revisionsgennemgangen; doctor; tilstandenes direktiv for utroværdigt indhold.

[1.133.1] — 2026-08-02

Rettet

  • #/funded (Finansierede virksomheder) viser nu resultater — to fejl fik tabellen til altid at vise “ingen finansierede virksomheder”, selv når forælderens company-funded.mjs returnerede en fuld liste. (1) Visningen læste resultaterne under res.candidates, men forælderen udsender dem under companies (hver { company, amount, round, funding: { sources: [{ source, url, observed_date }] } }); klienten læser nu den korrekte nøgle og mapper den reelle evidensform. (2) Resultattabellen sendte sine celler til UI.el('tr', {}, …) som varargs, men UI.el(tag, attrs, children) forventer children som en enkelt node eller et array, så kun den første kolonne (Virksomhed) blev gengivet — cellerne sendes nu som et array. Verificeret i en rigtig browser: 11 virksomheder på tværs af de fire feeds gengives med kolonnerne Virksomhed / Finansieringssignal / Kilde / Dato og fungerende evidenslinks, ingen konsolfejl. Et tomt resultat viser nu også diagnostik pr. kilde, så en stille nyhedsdag kan skelnes fra et blokeret feed.
  • Regressionsspærringer i tests/parity-routes-v1133.test.mjs: det simulerede forælderscript udsender nu den reelle companies-outputform (den oprindelige fixture spejlede den forkerte candidates-form — hvilket er præcis grunden til, at fejlen slap igennem med grønne tests), plus kildestatiske kanariefugle for, at funded.js læser res.companies (aldrig res.candidates) og bygger tabelrækker med børn som array (+1 → 2144).

[1.133.0] — 2026-08-01

Tilføjet

  • Opdagelse af finansierede virksomheder (#/funded, forælder-paritet #2117) — en ny skrivebeskyttet visning, der videreformidler forælderens career-ops company-funded.mjs via GET /api/company-funded: en liste med fokus på gennemgang over nyligt finansierede virksomheder, fundet fra offentlige, host-pinnede finansieringsfeeds (TechCrunch, PR Newswire, The Guardian, Hacker News). Relæet kører scriptet med --json --dry-run (JSON til stdout, ingen filskrivninger), sender aldrig brugerinput videre til --sources, har rate limiting og udløses af brugeren (en Opdag-knap, aldrig ved indlæsning). Nyt rutemodul server/lib/routes/funded.mjs + public/js/views/funded.js, under Sourcing.
  • Ugentligt interviewresumé (#/interview-digest, forælder-paritet #2129/#2130) — en ny skrivebeskyttet visning, der videreformidler forælderens nul-LLM weekly-digest.mjs via GET /api/interview/weekly-digest: en mekanisk sammenfatning af interviewsessionsnoterne — hvilke virksomheder og runder du har interviewet med i denne uge, tilbagevendende kompetencer og bedst-mulige åbne huller. Det valgfrie ?from=&to=-interval sendes kun med, når BEGGE er gyldige YYYY-MM-DD; et tomt interval er et gyldigt available:true-resumé. Tilføjet til server/lib/routes/interview.mjs + public/js/views/interview-digest.js, under Analyse.
  • Begge relæer følger den etablerede fail-soft available:false-kontrakt, når forælderens script ikke er til stede (CI, standalone-installationer). 26 nye i18n-nøgler ×17 lokaliteter; CI-isoleret suite tests/parity-routes-v1133.test.mjs (+5 → 2143).

Noter

  • Forælderens career-ops er avanceret forbi v1.24.0 med Next.js web/-appens Follow-up Tracker-side (#1422) og backend-PDF-rendering (#2182) — ikke porteret: web-ui har allerede sit eget follow-up-relæ og PDF-runnere, og den underliggende followup-cadence.mjs-hærdning kommer gratis med via shell-out-relæet. set-status.mjs/tracker-utils.mjs-ændringerne er CLI-interne og spejles ikke.

[1.132.0] — 2026-07-31

Ændret

  • #/scan-resultatgengivelses-delsystemet blev udtrukket til public/js/lib/scan-results.js (afvikling af file-size-contract-gæld — public/js/views/scan.js var vokset til ~1254 LOC). Delsystemet (renderResults, buildChipRow, getRows, række-/facet-bygherrerne, valgmulighedsmalerne og FALLBACK_SOURCES-registerspejlet) blev flyttet ind i en window.ScanResults.create(ctx)-fabrik, der lukker over et kontekstobjekt, som visningen leverer. Ingen adfærdsændring — funktionerne blev flyttet ordret, og closure-variablerne blev omkoblet til ctx.*; scan.js er nu ~906 LOC (en anden udtrækningsrunde mod 800-LOC-målet er planlagt).
  • Kildestatiske tests læser begge filer via tests/helpers/scan-src.mjs::loadScanSrc(); tests/scan-fallback-sources.test.mjs læser nu registerspejlet fra scan-results.js.
  • Ny regressionsspærring i browserentests/playwright-scan-filters.mjs sår en foruddefineret data/last-scan.json og gennemkører hvert #/scan-filter, idet den verificerer nøjagtige rækketal (npm run test:e2e:browser); stabile filter-id’er (#scan-filter-*, #scan-apply) blev tilføjet.
  • README’s “Seneste udgivelse”-banner er skåret ned til et enkeltlinjeresumé + et link til den fulde ændringslog (den lange fortællemur på tværs af flere versioner er udfaset). Anvendt på tværs af alle 17 lokaliteter.

[1.131.2] — 2026-07-31

Ændret

  • app.css opdelt i tre ordnede stylesheets (file-size-contract-gæld — den enkelte fil var vokset til ca. 1990 LOC, langt over den hårde grænse på 800 LOC). Den er nu app.css (~672 — a11y, design-tokens/tema, sidebar, main, knapper, content-shell), components.css (~595 — kort, grids, paginator, badges, tabeller, formularer, log/konsol, markdown, sprogskifter, chip-filter, forbindelsesbanner) og overlays.css (~737 — toast, notifikationsskuffe, modal, diverse/responsivt, [dir="rtl"]-spejlingen, docs-fab, usage-hud), hver inden for den hårde grænse.
    • Snittet er sammenhængende og i rækkefølge, så cascaden er byte-for-byte identisk med filen før opdelingen; index.html indlæser de tre som ordnede <link>s. Ingen ændring af adfærd, markup eller i18n.
    • CSS-testene læser nu konkatenationen via en delt hjælper, tests/helpers/css.mjs::loadAppCss(). Den nye tests/css-modularization.test.mjs fastlåser opdelingen (filerne findes · hver ≤ 800 LOC · index.html-linkrækkefølge) → suite 2138. Verificeret i browseren: alle tre stylesheets parses, og deres regler anvendes.

[1.131.1] — 2026-07-31

Rettet

  • Host-pinning-konsistens for adapterne i de to v1.130.0-kilder (code-review-opfølgninger, forsvar-i-dybden; ingen adfærdsændring for gyldige input):
    • a16z-speedrun-talent-adapteren genvaliderer nu api:/a16z-speedrun-talent:-overskrivningen ved buildEndpoint (HTTPS + eksakt host speedrun-talent-network.com) og falder tilbage til det kanoniske feed, hvis det fejler — paritet med cryptocurrencyjobs-adapteren, så en off-host-værdi aldrig når frem til fetch-slottet (tidligere afhang det udelukkende af fetch-tids-vagten assertSpeedrunUrl). Det eksakte host-tjek er nu et enkelt eksporteret SPEEDRUN_TALENT_HOST_RE, som deles af vagten og adapteren.
    • cryptocurrencyjobs-parserencleanUrl bruger nu den samme eksakte host-vagt som assertCryptocurrencyJobsUrl og adapter-overskrivningen (var endsWith, som accepterede subdomæner). Parseren er aldrig mere tilladende end SSRF-vagten: et sub.cryptocurrencyjobs.co-linkelement kasseres.
    • +2 tests → suite 2135.

[1.131.0] — 2026-07-31

Tilføjet

  • #/tracker CRM-fanebladstavle (porteret fra forælderens web-apps /pipeline-visning). Trackerens funnel-chip-bar + status-dropdown erstattes af en fanebladsstrimmel: en Alle-fane plus én fane pr. kanonisk status — Evaluated · Applied · Responded · Interview · Offer · Rejected · Discarded · SKIP · Hired — hver med et live optalt tal for hele historikken, inklusive nul-tælling-stadier, så hele tragten altid er synlig (CRM-udseendet). Den aktive fane styrer filtret; et nyt klik på den rydder tilbage til Alle. Rækkerne beholder deres score-tone, legitimitet, PDF- og rapport-muligheder, og firmacellen viser nu et brandlogo, når logoer er aktiveret (fra som standard → nul ekstra forespørgsler).
    • Ny read-only rute GET /api/tracker/stages returnerer den kanoniske tragt (etiketter i rækkefølge) + et alias-foldningskort, hentet fra server/lib/states.mjs (templates/states.yml, med den indbyggede fallback) — så klienten aldrig hardkoder statuswhitelisten. Det legacy parameterløse GET /api/tracker-svar er uændret (kun { rows }).
    • Nyt rent, enhedstestet klientbibliotek public/js/lib/tracker-stages.js grupperer rækker efter serverens stadier og tolererer forvildet markdown-fed skrift og lokaliserede aliasser (fx aplicadoApplied). Fanerne er tilgængelige (role tablist/tab, aria-selected, ≥44 px hit-areal, tællinger i hver fanes tilgængelighedsnavn). Ingen nye i18n-nøgler. Suite 2133.

[1.130.0] — 2026-07-31

Tilføjet

  • To nye scan-kilder porteret fra forælderens career-ops v1.24.0 (in-process, ingen nye afhængigheder; begge optræder i #/scans Kilde-filter og på cvstart.org-landingssiden):
    • a16z Speedrun (a16z-speedrun-talent, #2231) — det boardbrede JSON-feed for a16z Speedruns talent-network. Host-pinned til speedrun-talent-network.com, kun HTTPS, 0-indekseret paginering med et sideloft, per-virksomheds q/config-threading, fail-soft.
    • Cryptocurrency Jobs (cryptocurrencyjobs) — Web3-jobboardet cryptocurrencyjobs.co, hentet via dets offentlige RSS 2.0-feed (ingen auth). To-pas XML-entitetsdekodning, kun fjernopslag (remote), arbejdsgiver parset fra titlens hale "… at <Company>".
    • Registret rummer nu i alt 72 kilder = 67 engelske + 5 russiske (ALL_ADAPTERS = 67 engelske portal-adaptere).

Rettet

  • echojobs — hybride roller forbliver adskillelige fra remote (spejler forælderens #2258). En case-insensitive hybrid-markør giver nu "<By> · Hybrid" (eller et bart Hybrid, når der ikke er nogen by) og workplaceType: 'Hybrid', i stedet for at blive kollapset til Remote.
  • radancy — legacy TalentBrew-markup + JSON-resultatfragment-transport (spejler forælderens a3e6df9), gatet af en injicerbar opts.fetchJson.

Noter

  • Ikke porteret — kun CLI-funktioner i forælderen. career-ops v1.24.0’s store CLI-/tilstandsflade forbliver uden for web-ui, som er en fremviser + tynd write-through, ikke en tilstandsvært: compliance-/jurisdiktionstabellerne, kontakt-telefonbogen + vCard, interview-transskript-debriefing/opkaldsplatform-detektion, ledger-statusskift, resultatregistrering, to-pas-triage, jd-similarity, det versionerede ansøgnings-CV-artefaktskema, doctors Playwright-MCP-detektion og portals/fix-slugs.mjs. Scan-orkestreringsændringer, der findes i forælderens scan.mjs — Interamt.de-Playwright-scanneren, iCIMS reverse-ATS-fuldsweep, land-berettigelses-remote-filteret, DNS-lookup-pacing, StepStones rltr-deduplikering og den normaliserede firmakolonne i scan-historikken — gælder ikke: web-ui kører EN/RU-scannerne in-process og shell’er ikke ud til scan.mjs.
  • Allerede dækket. role-matchers accent-foldningsrettelse (#2209) blev porteret i v1.127.0, så det er en no-op her.

[1.129.1] — 2026-07-29

Rettet

  • AI-review-opfølgninger på web-portene fra v1.128/v1.129 (alle rådgivende, rettet ved kilden): niveau-præcedens i job-facets.js (en eksplicit modifikator vinder nu over et ledelsesord — Senior Engineering Managersenior, før lead); fallback i states.mjs fastlåses ikke længere (en vellykket læsning memoiseres, fallback returneres ucachet — en forælder der er midlertidigt utilgængelig ved boot genlæses ved næste kald) + console.warn ved en tilstedeværende men misdannet fil; score-tone.js — en række uden score er neutral (muted), ikke rød; domainFromName() springer ikke-ASCII-slugs over før /api/logo; +en isolationsvagt i tests/states.test.mjs. +4 tests → 2073.

[1.129.0] — 2026-07-29

Tilføjet

  • Niveaufacet + alderskolonne på #/scanjob-facets.js-biblioteket fra v1.128.0 er nu koblet til scan-UI’et (før kun logik). En ny Niveau-dropdown inddeler hver opslags titel i lead/staff/senior/mid/junior/praktikant (JobFacets.seniorityFromTitle) og udfyldes ud fra det, der faktisk er i resultaterne (som Land-facetten); titler uden niveauord passerer altid. Bevares i gemte søgninger, Nulstil og Anvend. Tabellen får en Niveau-badgekolonne og en token-fri Alder-kolonne (i dag / Nd, fra JobFacets.daysSince). 12 i18n-nøgler ×17, +3 tests → 2069.

[1.128.0] — 2026-07-29

Tilføjet

  • Fire løsninger porteret fra forælderens egen web-app (../web/, Next.js), genimplementeret i vanilla JS/ESM, uden nye afhængigheder: (1) server/lib/states.mjs læser templates/states.yml live som trackerens kanoniske statusordforråd (CI-fallback) — fjerner den manuelle whitelist-resync hver udgivelse; POST folder aliasser (spansk/legacy) til den kanoniske etiket, GET-funnel grupperer efter kanonisk status; (2) firmalogoer på ATS-hostede rækker via domainFromName() (~90 brand→domæne); (3) score-tone.js — 4-trins score-tone (≥4.2/3.8/3.0 + bogstav-fallback); (4) job-facets.js — seniority/source/days-facetter. +21 tests.

Noter

  • Ikke porteret (kun koncept): forælderens agent-handlingslag (actions/registry.ts + api/assistant/route.ts) — plan for når docs-fab bliver en copilot. Ingen nye kilder (register 70), ingen i18n/help-ændringer.

[1.127.0] — 2026-07-29

Tilføjet

  • Tre nye scan-kilder (career-ops v1.23.0-paritet) — registret rummer nu 70 adaptere (65 EN + 5 RU): Flowxtra (board-wide aggregator uden auth), VDAB (den flamske jobservices keyword-API) og iCIMS (careers-<tenant>.icims.com-portaler, adskilt fra jibeapply). Desuden vender Cursor tilbage til CLI-rosteret (parent #2115): cli-detect registrerer nu cursor (10 værktøjer), roster genoprettet i help/README/config ×17.

Rettet

  • agenticjobs skiftede fra HTML-scraping til REST-API (#2167); Greenhouse genfinder byen fra /offices, når location.name kun er en arbejdsmodel (#2104); role-matcher-paritet (#1933/#2164/#2009: MTS-præfiks, product-baseline, accent-foldning, sub-baseline-uenighed).

Noter

  • Ikke porteret. Størstedelen af v1.23.0 er CLI/dashboard-flade som web-ui ikke bruger (batch-tailor, discover-ats, NL/PT-tilstande, PDF-temaer, Go-dashboard, updater/doctor); relay-scripts uændret. Forælderens VERSION → 1.23.0.

[1.126.1] — 2026-07-25

Rettet

  • To CLI-roster-drift-steder som v1.126.0-resyncen missede — (1) introen på API keys-fanen i #/config (config.providerModelNote, i18n ×17) listede kun 7 CLIer — nu indsættes Antigravity og Grok Build efter OpenCode; (2) en anden sammenligningstabel-række i hjælpeguiden (×17) og det CI-byggede site-help stod stadig Inside Claude Code / Codex / Cursor / Gemini CLI — det forældede sæt med Cursor — nu det fulde roster. Begge brugte skråstreg/midtpunkt-separatorer som v1.126.0-fejningens mønstre ikke dækkede. i18n-snapshot regenereret; suiten forbliver 1969.

[1.126.0] — 2026-07-25

Tilføjet

  • AI CLI-værktøjsfanen registrerer nu alle 8 førsteklasses career-ops-CLIer#/config-rosteret er synkroniseret med forælderens docs/SUPPORTED_CLIS.md: server/lib/routes/cli-detect.mjs får Grok Build CLI (grok) og Kimi CLI (kimi), og Antigravity prober nu sin kanoniske binær agy først. Den skrivebeskyttede PATH-scanning rapporterer nu 9 værktøjer; den kører stadig aldrig en fundet binær.

Ændret

  • Dokumentations-resync med career-ops.org/docs — hver dokumentationsflade blev afstemt med forælderens live-sider (alle 31 læst). Det kanoniske AI-assistent-roster (help ×17 + README ×17) lister nu de 8 førsteklasses CLIer — Claude Code, Codex, OpenCode, Antigravity CLI, Grok Build CLI, Qwen Code, Kimi, GitHub Copilot CLI — plus Gemini CLI (legacy-wrapper). Hjælpebundterne bevarer deres 29 H2 / 105 H3-struktur.

[1.125.4] — 2026-07-23

Ændret

  • site-afhængigheder (dependabot #151–#153) — sharp 0.34.5→0.35.3, svgo 4.0.1→4.0.2, fast-uri (dev) 3.1.3→3.1.4 i site/; Astro-build grønt, ingen påvirkning af SPA/server.

Noter

  • Paritetsgennemgang af forælderen (career-ops 37d17ec..254764a, efter v1.22.0) — intet at portere: forkert-række-garden i set-status (#2108) er kun CLI (i web-ui vælges tracker-rækker eksplicit i UI’et, og ingen rute kalder set-status.mjs), Risk Summary i lokaliserede tilstande (#2109) rører modes/<lang>/-filer, som web-ui aldrig læser (kun modes/*.md på øverste niveau), manifest-verifikationen i update-system (#2111) gælder kun opdateringsværktøjet, og resten er forælderens dokumentation (tyrkisk README, SIGNATURES ×4, SCRIPTS.md, es-accenter). Forælderens VERSION forbliver 1.22.0parentVersion uændret.

[1.125.3] — 2026-07-23

Rettet

  • LLM-prompter på dansk og hindi svarede på engelsk (brugerrapporteret) — LOCALE_NAMES og alle fem SCAFFOLD_STRINGS-blokke i server/lib/prompts.mjs blev aldrig udvidet med da og hi, så resolveLocale() faldt tilbage til en, og hver AI-prompt — deep research (live og manuel), tilstande, evaluering, interview, netværk, CV Studio — mistede sit # Output language-direktiv på disse to sprog. Begge er nu fuldgyldige: sprogdirektiv + lokaliseret stillads. Regressionsporten i tests/locale-scaffold.test.mjs gennemgår nu den kanoniske liste med 17 sprog i stedet for 12 hårdkodede, og en ny strukturel paritetsport fejler enhver stilladsnøgle, der falder tilbage til engelsk — et fremtidigt sprog, der springer prompts.mjs over, kan ikke længere udgives (+12 tests, suiten er nu på 1969).

[1.125.2] — 2026-07-22

Rettet

  • Deep research via Gemini: HTTP 502 (MALFORMED_FUNCTION_CALL) (#145, bidrag fra @Alien10140) — live-prompten i /api/deep bad modellen om at »Use WebFetch / WebSearch« og gemme briefet i en fil, men headless API-udbydere har ingen værktøjskanal; Gemini svarede med et funktionskald i stedet for tekst, hvilket viste sig som en tom HTTP 502. buildDeepPrompt og bundleProjectContext tager nu et headless-flag: live-kørsler (Anthropic/Gemini/fallback-kaskaden) får en prompt uden værktøjer, der skriver briefet ud fra den indlejrede kontekst, mens copy-paste-prompten til Claude Code beholder sine værktøjsinstruktioner. +1 test i tests/critical-fixes.test.mjs.

Ændret

  • Gemini-standarder løftet forbi den udfasede gemini-2.0-flash (#144, bidrag fra @Alien10140) — konfigurations-dropdownen, server-fallbacken i gemini.mjs (som i stilhed var uenig med hintet), OpenRouter-fallbackkæden, config.geminiModelHint ×17 og hjælpeguiden ×17 peger nu alle på gemini-3.6-flash. Den nye drift-gate tests/gemini-default-model.test.mjs (+5 tests) fastgør alle overflader til samme literal — suiten er nu på 1957 tests.

[1.125.1] — 2026-07-21

Rettet

  • SuccessFactors: multi-brand RMK-tenants beholder deres brand-sti (forælder #2099, post-v1.22.0) — holdingselskaber, der driver flere opkøbte brands fra én fælles RMK-instans, adskiller dem via et sti-segment (careers.nemetschek.com/Bluebeam/ versus …/Vectorworks/); adapteren kollapsede tidligere den konfigurerede URL til dens origin og scannede derved stille og roligt moderbrandets opslag. Endpointet bevarer nu brand-præfikset og fjerner kun et afsluttende /search/- eller /tile-search-results/-segment, så intet nogensinde dubleres oven i sig selv; enkelt-domæne-tenants er byte-for-byte uændrede. Ny eksporteret hjælpefunktion resolveTenantBase + 1 overført testblok i tests/sources-successfactors.test.mjs.

[1.125.0] — 2026-07-21

Tilføjet

  • cvstart.org: landingssektionen “Jobkilder” — en ny sektion mellem screenshots og sammenligningen, der lister alle 67 scanner-kilder som klikbare chips (62 engelsksprogede boards/ATS’er + de 5 russiske boards under egen underoverskrift), hver med link til kildens offentlige side. Listen synkroniseres med det aktive adapter-register ved build (sync-assets.mjsfacts.sources), så den aldrig kan afvige fra appen; et kurateret linkkort i Sources.astro er sikret af den nye test tests/site-sources.test.mjs. Header-navigationen fik et Sources-anker; 4 nye site-i18n-nøgler ×17. Rettede desuden landingens JSON-LD inLanguage-liste, der stadig manglede hi.

[1.124.0] — 2026-07-21

Tilføjet

  • Fem scan-kilder (paritet med forælder v1.22.0, #1808/#1572/#2024/#2055) — Welcome to the Jungle (board-bred JSON-API), Agentic Engineering Jobs (agentic-/AI-engineering-board), Jobvite (zero-auth per-tenant-ATS), Gem (per-tenant-ATS) og Alibaba Group (careers-JSON-API, Meituan/Tencent-mønster). Hver er et vært-fastgjort, CI-isoleret kilde- og adapter-par; registreringen leverer nu 67 adaptere (62 EN + 5 RU); #/scan-Source-rullelistens fallback og dens drift-gate er opdateret; fem nye suiter tests/sources-{wttj,agenticjobs,jobvite,gem,alibaba}.test.mjs.

Rettet

  • Arbeitsagentur: landsdækkende remote kun når homeofficetyp er VOLLSTAENDIG (forælder #1981) — homeoffice=nv_true-forespørgslen returnerer også hybrid-stillinger, så remote-passet bekræfter nu hvert hit mod job-detalje-endpointet i små batches og fejler lukket (en opslagsfejl beholder jobbets rigtige by, så lokationsfiltre stadig gælder).
  • SmartRecruiters: offentlige job-URL’er bygget uden /postings/ (forælder #2047) — links lander nu på den offentlige opslagsside i stedet for en 404 for tenants, hvis offentlige site dropper segmentet.

Noter

  • Forælder v1.22.0 leverede også CLI-side ændringer, som web-ui’en ikke shell’er ind i eller allerede dækker: zh-CN-CV-skabelonen + PDF-typografi, /expand-tilstanden, provider-prompt-cache-finjusteringer (Gemini/OpenAI/Ollama), den trin-for-trin-token-opdeling (web-ui’en har sin egen forbrugsmåler), tracker-writer-lock-serialisering (web-ui’en har rutet skrivninger gennem withFileLock siden v1.21), scan-visa_filter- og absolut-opslagsdato-CLI-flagene (web-ui’en har sit eget “Opslået inden for”-alders-filter), samt seen-sources-dedup-seedingen (web-ui-scanneren beholder sin egen scan-historik-dedup).

[1.123.0] — 2026-07-17

Tilføjet

  • Oracle Recruiting Cloud-scan-kilde (paritet med forælder v1.21.0, #1929) — den zero-auth recruitingCEJobRequisitions-REST-API’en på Oracle Fusion/ORC-karrieresider (JPMorgan Chase, Oracle, BNY Mellon, American Express, Honeywell, …): vært-fastgjort til *.fa[.<region>][.ocs].oraclecloud.com, sitenummeret udledes af hver sporet virksomheds careers_url, offset-paginering med et hårdt sidetag, og WAF-bevidste browser-lignende headers. Registreringen leverer nu 62 adaptere (57 EN + 5 RU); #/scan-Source-rullelistens fallback og dens drift-gate er opdateret; ny CI-isoleret suite tests/sources-oraclecloud.test.mjs.

Rettet

  • Repost-detektor: grundtitler forbliver adskilt fra søskende med specialiserings-suffiks (forælder #1922) — “Senior Analytics Engineer” klynger ikke længere sammen med “Senior Analytics Engineer, People Analytics”: når den ene titels tokens er en streng delmængde af den andens, og det ekstra token er en reel specialisering (ikke et basisord), behandles de to som separat-opslåelige stillinger. Repost-annoteringer (“(Repost)”, “relisted”) stopordsfiltreres nu som meta-støj. +2 assertions i tests/detect-reposts.test.mjs.

Noter

  • Forælder v1.21.0 leverede også CLI-side ændringer, som web-ui’en ikke shell’er ind i eller allerede dækker: gentag-virksomheds-genansøgningsadvarslen (web-ui’en har haft genansøgnings-cooldown siden v1.84.0), cover letter-flagene --format/--report, samtale-tilstandene for røde flag / panel-indsigt / no-show-e-mail-prompts, scan-tillidssignaler & portal-sundheds-persistens (web-ui’en kører sin egen in-process-scanner med trust-validator og Portalsundhed-siden) samt stats-/løngab-udvidelserne (relæet er skrivebeskyttet og fejler blødt).

[1.122.0] — 2026-07-16

Tilføjet

  • Hindi (हिन्दी) — det 17. sprog — komplet UI-ordbog (ca. 1.110 nøgler), den fuldstændige indbyggede hjælpevejledning (29 H2 / 105 H3 paritet), README.hi.md, en ny CHANGELOG.hi.md (starter ved v1.122.0, efter samme model som de/it/tr), cvstart.org-landingen + Metodologi-/Licens-/Changelog-/Hjælp-siderne, sprogvælgeren (🇮🇳), automatisk browsersprog-detektion og et lokaliseret dashboard-screenshot. Hver ×16-paritetsgate kører nu ×17: i18n-ordbogsparitet + snapshot, hjælp-gates for H2/H3, changelog-paritet, sitets check-i18n og Playwright-lokalitetsgennemgangen.

[1.121.0] — 2026-07-16

Tilføjet

  • cvstart.org: Metodologi-, licens- og changelog-sider — landingen fik tre nye sektioner på alle 16 sprog, ved siden af den eksisterende Sammenligning-blok: /methodology/ (den seks-dimensioners 0,0–5,0-scoringsrubrik, 4,0-ansøgningstærsklen og reglerne for, hvad systemet aldrig gør — et lokaliseret resumé af career-ops.org/methodology), /license/ (den kanoniske MIT-tekst med henvisning til NOTICE.md) og /changelog/ (denne fil, gengivet pr. sprog ud fra repository’ets 16 oversatte changelogs). Ny Metodologi-post i headeren og links til Ressourcer i footeren; sync-assets.mjs synkroniserer nu CHANGELOG ×16 og LICENSE ind i sitet ved build-tid, så siderne aldrig kan drifte væk fra repository’et.
  • Metodologi-links på tværs af dokumentationen — README’en (alle 16), den indbyggede hjælpeguides §1-kanoniske liste (alle 16) og wikien linker nu til career-ops.org/methodology (plus FAQ’en og ordlisten) sammen med de eksisterende career-ops.org/docs-vejledninger.

Ændret

  • README-udgivelsesbanner og badges opdateret (1850 tests, udgivelse v1.121.0) — banneret annoncerede stadig v1.119.5.

[1.120.0] — 2026-07-16

Tilføjet

  • CareerOps-manifestet (paritet med forælder v1.20.0) — moderprojektet udgav CareerOps-manifestet (MANIFESTO.md · career-ops.org/manifesto) og fremhæver det nu i sin README, sin updater og sit Go-dashboard. Web-ui’en følger trop: et nyt link i sidebjælke-footeren åbner manifest-siden (ny i18n-nøgle footer.manifesto i alle 16 lokaliseringer), den indbyggede hjælpeguide fik §29 “CareerOps-manifestet” på alle 16 sprog, README’en forklarer, hvad manifestet er, og hvordan man underskriver det, og cvstart.org-landingens footer linker også til det.

Noter

  • Forælder v1.20.0 rettede også undertrykkelsen af allerede kendte kompetencer i upskill-tilstandens målrettede tilstand, gjorde dotenv tavs, så stdout fra scan --json forbliver parsebar, og rettede HTML-CV-skabelonen, så en rolleoverskrift bliver hos sine punkter — CLI-side-flader, som web-ui’en ikke shell’er ind i; ingen ændring af web-ui-kode var nødvendig.

[1.119.5] — 2026-07-13

Rettet

  • Sprogknappen på landingen ombrydes ikke længere — med flagene fra v1.119.2 kunne switcher-etiketten i headeren (f.eks. «🇷🇺 Русский») knække på op til tre linjer ved smalle desktop-bredder; switcher-etiketten og alle dropdown-valg er nu whitespace-nowrap — flag + endonym altid på én linje. Sproglisten i footeren gik fra et stift to-kolonne-gitter til en ombrydende række af enkeltlinjede punkter — «🇧🇷 Português (Brasil)» knækker heller ikke længere midt i navnet.

[1.119.4] — 2026-07-13

Ændret

  • LICENSE nævner forfatteren — copyright-linjen lyder nu: Sergei Emelianov (Fighter90) https://sergey-cv.com and career-ops-ui contributors (kanonisk MIT-tekst urørt). En ny NOTICE.md udfolder licenseringen i detaljer: hvem der holder copyright, præcis hvad MIT-tilladelsen dækker (kode, docs, oversættelser, landingen, wikien), hvad den IKKE dækker (dine runtime-data, forældreprojektet, jobboard-indhold, varemærker), tabellen over tredjepartskomponenter (express/js-yaml — MIT; Astro/Tailwind — MIT; skrifttyperne Figtree og JetBrains Mono — SIL OFL 1.1; sharp — Apache-2.0) og en valgfri krediteringslinje.

[1.119.3] — 2026-07-13

Tilføjet

  • SECURITY.md — sikkerhedspolitikken, som CONTRIBUTING pegede på, findes nu: understøttede versioner, privat rapporteringsflow (GitHubs private vulnerability reporting er nu aktiveret på repoet — Security-fanen → „Report a vulnerability“), trusselsmodellen for en localhost-bundet enkeltbruger-app (i scope: XSS via fjendtlige jobopslag / SSRF / path traversal / lækage af hemmeligheder / CSP-svækkelse; uden for scope: DoS mod egen localhost og problemer i forældreprojektet) og hardening-baseline for reviewere.

[1.119.2] — 2026-07-13

Tilføjet

  • CONTRIBUTING.md — bidragyderguiden, som landingen og README altid har linket til, findes nu: opsætning, projektkort, de hårde sikkerheds-/no-build-regler, testniveauer, to-registre-gennemgangen til at tilføje en scanningskilde, ×16-i18n-kontrakten, commit-/PR-konventioner og release-processen.
  • Sprogflag på landingen — cvstart.orgs sprogvælger, footerens sproggitter og “læs på dit sprog”-banneret viser nu hver lokalitets flag ved siden af dens endonym (samme sæt regionale indikatorer som appens sprog-<select>; degraderer til regionsbogstaver, hvor flagglyffer mangler).
  • Landing-footer-rettelser — det døde Discussions-link (funktionen er ikke aktiveret på repoet) peger nu på projektets wiki, og footeren krediterer forfatteren: Sergei Emelianov (sergey-cv.com · LinkedIn).

[1.119.1] — 2026-07-13

Rettet

  • Kildefilteret på #/scan har indhentet registreringen — den statiske FALLBACK_SOURCES-liste bag Source-dropdownen (bruges kun når GET /api/scan/sources er utilgængelig) haltede stille bagefter siden v1.87.0: 20 udbydere manglede i offline-fallbacken (Amazon, Avature, SAP SuccessFactors, Get on Board, Dassault Systèmes, beesite, HigherEdJobs, JibeApply (iCIMS), softgarden, Cornerstone, Phenom, Radancy, Deutsche Bahn, EchoJobs, TKMS, Heckler & Koch, Rheinmetall, LaraJobs og de nye Meituan / Tencent). Synkroniseret med alle 61 og nu sikret af en drift-test, der fejler CI, når klientlisten afviger fra server-registreringen (værdier OG labels). +1 test (1845).

[1.119.0] — 2026-07-13

Paritet med forældre-career-ops v1.19.0 + refresh af cvstart.org-landingen.

Tilføjet

  • 2 nye scanningsudbydere — Meituan (zhaopin.meituan.com) og Tencent (careers.tencent.com): de kinesiske tech-boards’ offentlige JSON-API’er uden auth, host-detekteret eller valgt via eksplicit provider:, med server-side søgning pr. nøgleord, paginering og URL-deduplikering — nu 61 adaptere (56 EN + 5 RU). +20 tests (1844).
  • Bidragyder-blok på landingen — cvstart.org viser avatarer for alle, der har bidraget med kode (GitHubs /contributors-API på build-tid, bots filtreret), lokaliseret på alle 16 sprog, med link til den fulde bidragyder-graf.
  • Live GitHub-stjernetæller på landingen — header-badget opdateres nu klient-side fra GitHub-API’et ved hvert besøg (build-øjebliksbilledet er fallback), og en ugentlig planlagt Pages-genopbygning holder øjebliksbilledet + bidragyderlisten frisk; API-kald i CI er token-autentificerede.

Rettet

  • Workday CXS-forespørgsler bærer browser-lignende headers (forælder #1813) — Cloudflare-beskyttede tenants (set live: geico) svarer 500 på forespørgsler uden almindelige UA/accept-language/origin/referer; fetcheren udleder nu origin + site-slug fra selve CXS-URL’en. Glints-forespørgsler fik samme browser-UA + origin/referer, begge fra den fælles konstant BROWSER_LIKE_USER_AGENT i http-json.mjs.

[1.118.4] — 2026-07-10

Rettet

  • hh.ru-scanninger returnerede 0 resultater fra en russisk IP (links på regionalt subdomæne) — fra en russisk residential-IP omdirigerer hh.ru søgningen (302) til et regionalt subdomæne (sochi.hh.ru, spb.hh.ru, …) og returnerer joblinks på det subdomæne. Parseren søgte titel-linket på den fast indkodede vært https://hh.ru/vacancy/ og ramte ingen af de regionale, så en fuldt fungerende scanning registrerede lydløst 0. Den accepterer nu enhver *.hh.ru-vært (annoncer på adsrv.hh.ru/click?… udelukkes fortsat — de har ingen /vacancy/<id>-sti) og kanoniserer hver resultat-URL tilbage til https://hh.ru/vacancy/<id>. Verificeret live: 17 rigtige jobs parses nu fra en sochi.hh.ru-side, der før gav 0. +1 test (1824).

[1.118.3] — 2026-07-10

Rettet

  • hh.ru returnerede lydløst 0 resultater (VPN-tjek-interstitial) — hh.ru omdirigerer nu (302) netværk, det anser for VPN/proxy (datacenter-IP’er), til en interstitial /vpncheeck (“VPN мешает работе сайта”), der svarer HTTP 200 uden et eneste vakance-kort, så scanningen rapporterede 0 helt uden fejl. Scanneren opdager nu omdirigeringen via svarets endelige URL, deaktiverer hh.ru for resten af kørslen og skriver et ærligt hint: trafikken skal reelt gå ud via en residential IP — en systemdækkende VPN/proxy kan stadig være aktiv, selv når browser-kontakten er slået fra. +1 test (1823).

[1.118.2] — 2026-07-10

Vedligeholdelse

  • Landing-opfølgning (#118)site/README.md bragt i overensstemmelse med Astro 7 (sikkerhedsopgraderingen fra #116), ubrugt import fjernet og +4 eksekverbare vagter for landingens build-scripts: i18n-paritetsporten fejler bevisligt på en ødelagt ordbog, og sync-assets skriver aldrig uden for site/ — suite 1822. To CodeQL-advarsler løst (én rettet i kilden, én afvist som tilsigtet build-adfærd).

[1.118.1] — 2026-07-10

Rettet

  • hh.ru-scanning uden for Rusland — hh.ru svarer nu med HTTP 451 (regional juridisk blokering) til ikke-russiske IP’er på de offentlige søgesider. Scanneren behandler 451 som 403: efter den første blokering deaktiveres hh.ru for resten af kørslen med en ærlig loglinje, der peger på russisk IP / VPN-exit, så de resterende forespørgsler og de øvrige RU-kilder ikke spildes. Hjælp §7 opdateret på alle 16 sprog. +1 test (1818).

[1.118.0] — 2026-07-09

Paritetspakke med forældre-career-ops v1.18.0.

Tilføjet

  • 9 nye scanningsudbydere — Cornerstone OnDemand (csod), Phenom (phenom), Radancy (radancy), Deutsche Bahn (deutschebahn), EchoJobs (echojobs), TKMS (tkms), Heckler & Koch (hecklerkoch), Rheinmetall (rheinmetall), LaraJobs (larajobs) — nu 54 adaptere. Lever-adapteren genkender desuden EU-tenant-boards (jobs.eu.lever.co).
  • Hired-status i trackeren (paritet med forælderens states.yml): accepterede tilbud får deres egen kanoniske status, et festligt badge og et «jobbet er i hus»-banner på #/tracker; tragten og konverteringerne tæller den som gennemført alle trin.
  • Samlet-fane i #/stats — skrivebeskyttet relæ af forælderens stats.mjs (samlet tracker-oversigt, akkumulerede tragtrater, scannertal, portaldækning) plus lønobservationer fra salary-gap.mjs (ønsket vs annonceret vs faktisk, pr. ansøgning). Nye ruter GET /api/stats/lifetime og GET /api/stats/salary-gap — shell-outs uden tokens, sikker degradering {available:false} uden forældre-projektet.
  • 28 nye i18n-nøgler i alle 16 sprog; hjælpeguiden §14/§26 opdateret på alle sprog.

Tests

  • +38 enhedstests (tre udbyder-paritetssuiter + relæ-/statusruter) — 1817 i alt.

[1.117.2] — 2026-07-06

Tom-tracker-rettelse for paritets-shell-outs. Forælderens scripts afslutter med kode 1 og et struktureret {error}-JSON, når trackeren endnu ingen ansøgninger har; kadencetavlen og fanen med afvisningsmønstre viste det som “script-error”. Begge ruter videregiver det nu som en sund tom tilstand (available:true, empty:true), så UI’et viser sin ærlige “intet endnu”-besked. Verificeret live mod en rigtig forælder.

Nyt: intet.

[1.117.1] — 2026-07-06

Hærdning af v1.117.0 (CodeQL-triage). De tre shell-out-endpoints (GET /api/followup, POST /api/followup/seed, GET /api/stats/patterns) bærer nu den delte per-IP-begrænser (de starter en børneproces pr. forespørgsel; no-op på loopback). Føj-til-CV’s URL-tekstudtræk fjerner tags til et fikspunkt og sletter derefter alle resterende </> — en bevisligt komplet sanering for LLM-prompttekst. Ingen adfærdsændring for gyldigt input.

Nyt: intet.

[1.117.0] — 2026-07-06

Forælder-paritetspakke — seks evner fra det overordnede career-ops bragt ind i UI’et. (1) Kadencetavle#/followup: hastighed pr. ansøgning (🔴/🟠/🟡/🔵) fra followup-cadence.mjs, plus knappen Så opfølgningsdatoer (followup-seed.mjs --backfill). (2) Afvisningsmønstre: en fjerde statistik-fane kører analyze-patterns.mjs (skrivebeskyttet) — udfaldsmiks, anbefalinger, avanceringsrate pr. ATS-leverandør. (3) Føj til CV: et CV Studio-kort forvandler en URL eller indsat tekst til ATS-punkter, der KUN bygger på den kilde (kun forslag, ingen skrivninger; URL-hentning er SSRF-beskyttet). (4) 4 nye scan-udbydere — beesite, HigherEdJobs (RSS), JibeApply (iCIMS), softgarden — registret har nu 50 adaptere (45 EN + 5 RU), alle i Scan-rullelisten. (5) Præ-scan af diskvalifikatorer i Apply-tjeklisten. (6) Reconcile-runner (/api/run/reconcile). Shell-out-ruter degraderer ærligt uden forælderens scripts.

  • Nyt rutemodul server/lib/routes/followup.mjs (31.) + nye ruter + 8 source/adapter-filer. Tests: 6 + 7 nye; suite 1737 → 1750. 41 nye i18n-nøgler ×16. Hjælp §13/§17/§24/§26 udvidet ×16.

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

[1.116.0] — 2026-07-06

Forbrugsmåler omarbejdet + første ende-til-ende widget-test. AI-forbrugsmåleren (v1.114.0) er rettet og fastgjort korrekt: den sidder nu fastgjort nederst i venstre sidebjælke (fuld sidebjælkebredde, samme overflade) og reserverer nederst plads svarende til sin egen højde, så menuen aldrig dækkes — nav + versionsfod ruller altid frit ovenover. Den opdateres live (hvert 15. sekund, ved fanefokus og ruteskift), og hver vinduesrække viser nu de rigtige <tokens> · <estimeret omkostning> (bjælker skaleres mod 30-dages-vinduet) i stedet for en altid-100% “andel”. Desuden: en holdbar typeof-barriere i CV-importøren lukker den tilbagevendende CodeQL type-confusion-falske positiv ved kilden, og en ny Playwright ende-til-ende-test kører begge vedvarende widgets i en rigtig browser.

  • public/js/lib/usage-hud.js + app.css, server/lib/cv-import.mjs. Tests: tests/playwright-widgets.mjs (2 E2E) + tests/usage-hud.test.mjs (10). Hjælp §6 udvidet ×16.

Nyt: intet.

[1.115.0] — 2026-07-06

Designfinpudsning (konservativ, koralbrand bevaret). En let forfinelsesrunde over det delte designsystem — ingen omstrukturering, ingen paletændring. Dashboardets metrikkort løfter sig nu og får en koralkant ved hover (som hurtighandlingsfliserne); indholdskort løfter sig en anelse; primary / dark / danger-knapper får en hvileskygge og et blidt hover-løft for dybde; store tal justeres via tabular-nums; og interaktive kontroller får en blød koral-fokusglorie bag den skarpe 2px-tastaturring. Al bevægelse respekterer prefers-reduced-motion, og glorien er afgrænset til kontroller — aldrig en global *:focus-visible.

  • Kun CSS (public/css/app.css); ingen ændringer i markup, i18n, ruter eller CSP. Tests: tests/design-polish-v1115.test.mjs (5). Verificeret live med Playwright.

Nyt: intet.

[1.114.0] — 2026-07-06

AI-forbrugs- og omkostningsmåler i sidebjælken (nederst til venstre). En kompakt FORBRUG-sektion sidder nu nederst i sidebjælken (et fast kort nederst til venstre hvis der ingen sidebjælke er; nederst til højre i RTL) på hver side. Den viser dit LLM-tokenforbrug over 24t / 7d / 30d vinduer — hvert som <tokens> · <andel%> med en grøn måler (andel af hele perioden) — plus en fod med den estimerede 24t-omkostning. Data er den skrivebeskyttede GET /api/usage-opsummering af data/llm-usage.jsonl (kun lokalt), samme kilde som #/usage-siden; omkostningen er et estimat, og manuelle kørsler er gratis og tælles ikke. Sammenfoldelig — overskriften skifter, og tilstanden bevares.

  • Ny klient-widget public/js/lib/usage-hud.js indlæst fra index.html, monteret i sidebjælken over versionsfoden (fast-hjørne-fallback). CSP-sikker; tema-bevidst + RTL-spejlet. Ingen ny serverrute. Tests: tests/usage-hud.test.mjs (8). 3 nye i18n-nøgler ×16.

Nyt: intet.

[1.113.0] — 2026-07-06

Flydende “Spørg hjælpen”-assistent på hver side. En gradient-robot-chatknap svæver nu i nederste højre hjørne (nederste venstre i RTL) på hver side. Tryk for at åbne en kompakt chat, der svarer på brugsspørgsmål udelukkende ud fra den indbyggede hjælpeguide på dit sprog — samme endpoint som #/docs-assistant-siden (POST /api/docs-assistant/ask), så den læser aldrig dit CV, din profil eller din tracker. Live med en LLM-nøgle; uden nøgle → en klar-til-brug prompt. Overskriften viser en robot-avatar + onlinestatus; chips foreslår almindelige spørgsmål; Esc eller klik udenfor lukker; den skjuler sig på #/docs-assistant-siden.

  • Ny klient-widget public/js/lib/docs-fab.js monteret globalt fra index.html; CSP-sikker; tema-bevidste + RTL-spejlede stilarter i app.css. Ingen ny serverrute. Tests: tests/docs-fab.test.mjs (8). 6 nye i18n-nøgler ×16. Hjælp §1 udvidet på stedet.

Nyt: intet.

[1.112.0] — 2026-07-06

Docs- og QA-konsolidering. Ingen brugersynlig kodeændring. SDD-konventionsdokumentet (docs/sdd/CONVENTIONS.md) er opdateret til de nuværende 30 rute-moduler (var 24) og den nuværende testbaseline; den definitive projektomfattende QA-prompt (qa/QA-REGRESSION-PROMPT.md) er konsolideret — release-mekanik afstøvet (v1.111, parentVersion 1.17.0, release-udløst udgivelse), §14-tilføjelsestabellen rettet (Scan Udeluk om-mærket v1.109.0) og udvidet med v1.111-CodeQL-lukningen — så den står alene som den ene regressionsprompt for al funktionalitet. Tilføjer én dækningstest for den overstore upload-gren.

Nyt: intet.

[1.111.0] — 2026-07-06

Sikkerhed — lukning af CodeQL-backlog. Tre defense-in-depth-hærdninger, der lukker de resterende fund fra statisk analyse ved kilden i stedet for at afvise dem. stripDangerousMarkdown escaper nu < i enhver afkortet farlig tag-åbning (en payload, der ender på <script/<iframe/…), så dens output beviseligt ikke indeholder nogen levende farlig tag. CV-import læser den uploadede buffers størrelse via en eksplicit Number()-tvang — en barriere mod typeforvirring. Modus-rollelinjer er nu skabelon-strenge interpoleret med String.replace i stedet for gemte funktioner, hvilket helt fjerner det dynamiske dispatch-kald. Ingen brugersynlig adfærdsændring.

  • server/lib/security.mjs, server/lib/cv-import.mjs, server/lib/prompts.mjs. Tests: tests/security-hardening-v1111.test.mjs (7) + opdateret v1108-vagttest. Ingen i18n-/hjælpe-/rute-ændringer.

Nyt: intet.

[1.110.0] — 2026-07-06

Docs- & QA-opdatering (alle sprog). Ingen kodeændring. Hele-projekt-QA-prompten er opdateret til v1.109.0 med et nyt §14 (v1.98→v1.109), og de vedvarende UX-audit- og design-export-prompts fik den aktuelle sideflade. Hvert hjælpeafsnit tilføjet i v1.100–v1.109 er nu oversat til alle 16 sprog.

Nyt: intet.

[1.109.0] — 2026-07-06

Scan Udeluk-filter + pipeline-overblik (web-layout-paritet).#/scan behandler Søg-feltet nu kommaer som ELLER (“roller at finde”), og et nyt Udeluk-felt skjuler enhver række, hvis firma/rolle/lokation indeholder et af de kommaseparerede ord (f.eks. senior, staff); begge huskes af dine gemte søgninger. På #/pipeline viser en kompakt overbliksstribe din pipeline med ét blik — N i indbakke, N sporet og antallene Applied / Responded / Interview / Offer fra trackeren, hver chip linker til #/tracker.

  • Kun klient (ingen ny rute/skrivninger). public/js/views/scan.js + public/js/views/pipeline.js. Tests: tests/scan-pipeline-ui-v1109.test.mjs (2). 4 nye i18n-nøgler ×16. Hjælp §7 + §8 udvidet på stedet.

Nyt: intet.

[1.108.0] — 2026-07-06

Sikkerhedshærdning (CodeQL-triage, runde 2). Tre yderligere lavseverititets-fund rettet: prompt-byggeren opslår locale-rollelinjen via egen nøgle + typeof === function, så en manipuleret locale ikke kan dispatche til en prototype-metode (unvalidated-dynamic-method-call); PDF-filnavns-sluggen begrænses til 200 tegn før regex’en, så et input med kun bindestreger ikke backtracker (polynomiel ReDoS); og dokumentimport tvinger et array-filename (gentaget header) til en streng (type-confusion). Ingen adfærdsændring for gyldigt input.

  • server/lib/prompts.mjs, server/lib/routes/runners.mjs, server/lib/cv-import.mjs + tests/security-hardening-v1108.test.mjs (3). Over v1.106–v1.108 gik den statiske analyses efterslæb fra 167 til ~14, hvor hvert reelt sikkerhedsrelevant fund blev rettet og resten (værnede/rensede falske positiver + note-niveau-lint) afvist med begrundelse.

Nyt: intet.

[1.107.0] — 2026-07-06

Sanitizer-hærdning (XSS-forsvar i dybden i hviletilstand). stripDangerousMarkdown — som neutraliserer farlig HTML i gemt CV/job-markdown, så enhver forbruger, der omgår escape-ved-rendering-klienten, stadig er sikker — kører nu sin tag-strip til et fikspunkt (gentag til stabil), så en fjernelse, der gendanner en payload (f.eks. <scr<script></script>ipt>), fanges, matcher script/style-slutmærker med efterfølgende skrald (</script foo>) og fjerner en uafsluttet eksekverbar åbner (<script …>). Adfærd for gyldig markdown er uændret — den fjerner kun mere.

  • server/lib/security.mjs: fikspunkt-løkke (begrænset til 8 gennemløb) + [^>]*>-slutmærke-mønstre + fjernelse af uafsluttet åbner. +3 regressionstilfælde i tests/cv-xss-bypasses.test.mjs. Den autoritative XSS-grænse er fortsat output-escaping (UI.md); dette styrker hvile-garantien og lukker de tilsvarende CodeQL-fund.

Nyt: intet.

[1.106.0] — 2026-07-06

Sikkerhedshærdning (CodeQL-triage). Tre reelle (om end lavseverititets) fund rettet efter en gennemgang af den statiske analyses efterslæb: rute-renderingens fejlsti escaper nu fejlmeddelelsen, før den når DOM’en (en serverfejl kan gengive brugerinput, så den behandles som utroværdig — XSS-grænse), og profil/config-egenskabsskrivninger afviser __proto__ / constructor / prototype-nøgler (prototype-forureningsværn for en sikkerheds skyld — nøglerne kommer fra faste feltspecifikationer, ikke rå request-input). Størstedelen af de resterende advarsler er falske positiver på scannerens legitime data/*-læsninger/-skrivninger og på ruter, der allerede har den egne rate-limiter; afvist med begrundelse.

  • public/js/router.js escaper err.message via UI.escapeHtml før innerHTML; server/lib/routes/content.mjs og server/lib/routes/config.mjs værner prototype-nøgler. Ingen adfærdsændring for gyldigt input. Tests: tests/security-hardening-v1106.test.mjs (3). Ingen nye i18n-nøgler.

Nyt: intet.

[1.105.0] — 2026-07-06

AI-forbrug og -omkostningsside. En ny AI-forbrug-side (sidebjælke, ved siden af Helbred) viser, hvor mange tokens du har brugt på live AI-genereringer — evalueringer, rapporter, chats — opdelt pr. udbyder over de sidste 24 timer, 7 dage, 30 dage og al tid, med en estimeret USD-omkostning. Hvert live-kald tilføjer en lille {provider, in, out}-post til data/llm-usage.jsonl (intet sendes nogen steder hen); kørsler uden nøgle (manuel tilstand) koster intet og registreres ikke.

  • Nyt rutemodul (det 30.) server/lib/routes/usage.mjsGET /api/usage (skrivebeskyttede opsummeringer) + server/lib/llm-usage.mjs (recordUsage normaliserer Anthropic/OpenAI/Gemini-forbrugsformerne og tilføjer best-effort; readUsage/aggregate opsummerer pr. vindue 24t/7d/30d/alt × udbyder) + server/lib/llm-pricing.mjs (en redigerbar pris-tabel pr. udbyder $/1M tokens — tokens er præcise, dollars er omtrentlige listepriser, du kan rette; faktureres aldrig). Registreringen er koblet på dispatch-punkterne (runActiveProvider + routes/llm.mjs).
  • Ny visning public/js/views/usage.js (#/usage, vinduesfaner). Tests: tests/usage-routes.test.mjs. 17 nye i18n-nøgler ×16 (usage.* + nav.usage). Hjælp §6 udvidet på stedet.

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

[1.104.0] — 2026-07-06

Firmalogoer i scanningstabellen (privatlivsbevarende). En ny Udseende-kontakt i App-indstillingerVis firmalogoer i scanningstabellen (fra som standard) — tegner hvert firmas logo ved siden af navnet på #/scan. Logoet er firmaets favicon hentet fra dets eget domæne og proxyet på serveren (GET /api/logo), så ingen tredjeparts logotjeneste får at vide, hvilke arbejdsgivere du kigger på. Opslag på en delt jobportal (Greenhouse, Lever, Ashby, …) viser et farvet bogstavmærke i stedet for portalens ikon, og ethvert logo, der ikke kan indlæses, falder tilbage til samme mærke.

  • Nyt rutemodul (det 29.) server/lib/routes/logos.mjsGET /api/logo?domain=. Det validerer domænet (ingen skema/sti/loopback), henter /favicon.ico via den SSRF-sikre safeGet (en ny binary-tilstand returnerer de rå bytes + content-type; DNS-pinning, redirect-validering og størrelsesloftet er uændrede), udfører billed-magic-sniffing, så en HTML-fejlside aldrig serveres som et billede, cacher hits og misses i en in-memory LRU og skriver intet til disk.
  • Nyt klientbibliotek public/js/lib/company-logo.js (window.CompanyLogo): fra som standard via et localStorage-flag; springer delte ATS-værter over til fordel for en deterministisk bogstav-avatar; CSP-sikker img.onerror-fallback. Tests: tests/logo-routes.test.mjs. 5 nye i18n-nøgler ×16 (appear.*). Hjælp §2 udvidet på stedet.

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

[1.103.0] — 2026-07-06

Indstillinger: “AI CLI-værktøjer” — hvilke er installeret. career-ops er Claude Code-drevet, men virker med enhver agent-CLI på den åbne skill-standard. En ny AI CLI-værktøjer-fane i App-indstillinger (#/config) viser hvilke — Claude Code, Codex, Gemini CLI, OpenCode, GitHub Copilot CLI, Qwen, Antigravity — der er installeret på maskinen, der kører serveren, og deres stier. Det er en skrivebeskyttet PATH-scanning: den tjekker kun, om hver binær findes, og kører den aldrig (ingen --version, ingen kørsel), skriver intet og rører ingen brugerdata.

  • Nyt rutemodul (det 28.) server/lib/routes/cli-detect.mjsGET /api/cli-detect. Registreringen slår en binærs sti op fra en fast 7-punkts allowlist via process.env.PATH (.cmd/.exe/.bat-shims på Windows; execute-bit på POSIX); en fjendtlig fil på PATH kan aldrig køres af denne rute.
  • Ny “AI CLI-værktøjer”-fane i public/js/views/config.js (lazy-loaded, dyb-linkbar via #/config?tab=cli). Tests: tests/cli-detect-routes.test.mjs. 8 nye i18n-nøgler ×16 (cli.* + config.tabCli). Hjælp §2 udvidet på stedet.

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

[1.102.0] — 2026-07-05

“Spørg guiden” — en forankret chat over den indbyggede hjælpeguide. En ny Spørg guiden 💬-side (sidebjælke, under Hjælp): skriv et spørgsmål som “Hvordan scanner jeg jobportaler?” og få et svar hentet kun fra appens egen hjælpeguide på dit sprog — den viser hvilke afsnit den brugte og læser aldrig dit CV, din profil eller din jobsøgning. Det handler om at bruge appen, ikke om dig. Med en LLM-nøgle svarer den live; uden nøgle giver den dig en klar-til-brug prompt, allerede udfyldt med de relevante hjælpeafsnit.

  • Nyt rutemodul (det 27.) server/lib/routes/docs-assistant.mjsPOST /api/docs-assistant/ask. Afhængighedsfri hentning: hjælpedokumentet på dit sprog opdeles i sine ##-afsnit og scores efter nøgleordsoverlap med dit spørgsmål; de bedste indlejres, og modellen skal svare ud fra dem eller sige, at guiden ikke dækker det (ingen opdigtede funktioner/ruter). Delt udbyderkaskade, manuel fallback, rate-begrænset, ingen skrivninger, læser ingen brugerdata.
  • Ny visning public/js/views/docs-assistant.js. Tests: tests/docs-assistant-routes.test.mjs. 14 nye i18n-nøgler ×16 (docs.* + nav.docsAssistant). Hjælp §1 udvidet på stedet.

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

[1.101.0] — 2026-07-05

CV Studio: tilpas dit CV + skriv en ansøgning til et bestemt job, gennem en rekrutterings-checkliste. Nyt Tilpas til et job-kort på #/cv-studio: indsæt en jobbeskrivelse (og evt. en mål-rolle/overskrift), og CV Studio laver et CV tilpasset det opslag plus en matchende ansøgning, og kører dem derefter gennem en checkliste-gate, før de afleveres — error blokerer (rettes, før du ser resultatet), warn rådgiver. Mekanikken er destilleret fra karrierecoaching-praksis til generiske regler — en rekrutter læser på sekunder, så relevant erfaring kommer øverst, overskriften matcher stillingens rolle, resultater bærer konkrete tal, og ansøgningen forbliver en kort teaser med én “krav ↔ din matchende kendsgerning”-bro. Den bygger kun på dit eget CV, din profil og din two-pager og opdigter aldrig — ingen hardkodede virksomheder, roller eller historik.

  • Nyt endpoint POST /api/cv-studio/tailor (udvider det eksisterende cv-studio-modul — intet 27. modul): buildTailorPrompt + en generisk TAILOR_INSTRUCTIONS-gate, bygget på bundleProjectContext, delt udbyderkaskade, manuel fallback uden nøgle, rate-begrænset, ingen skrivninger. Resultatet eksporteres til Markdown / PDF / DOCX via den delte report-export.js-bjælke.
  • Tests: +3 i tests/cv-studio-routes.test.mjs. 10 nye i18n-nøgler ×16 (cvs.tailor*). Generisk reference docs/prompts/resume-cover.md. Hjælp §24 udvidet på stedet.

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

[1.100.0] — 2026-07-05

Two-pager: AI-autoudfyldning fra dit CV + Forhåndsvisning + eksport til PDF/DOCX/Markdown. Two-pageren (#/two-pager) fanger, hvad du faktisk vil have af din næste rolle, men før skulle hvert felt skrives i hånden eller en prompt kopieres til et andet værktøj. Nu kører ✨ AI-udfyldningsassistenten live mod din konfigurerede udbyder — den læser kun dit CV + profil (via bundleProjectContext, intet opdigtet), udarbejder alle felter (hvem jeg er / kan lide / skal-haves / hader / dealbreakers / ufravigelige / målmiljø) og udfylder formularen, så du kan gennemgå, redigere og gemme. Uden API-nøgle falder den tilbage til kopiér-prompt-modalen som før. En ny 👁 Forhåndsvis og eksportér-knap gengiver two-pageren som et formateret dokument med en Download .md / Gem som PDF / Gem som DOCX / Kopiér-bjælke.

  • Afhængighedsfri .docx-eksport. Ny server/lib/docx.mjs udsender en minimal, men gyldig Office Open XML-.docx (en DEFLATE-ZIP af de fire OOXML-dele, CRC-32 pr. post) — ingen ny runtime-afhængighed (deps forbliver express + js-yaml). Ny rute POST /api/export/docx (server/lib/routes/export.mjs, det 26. rutemodul; tilstandsløs, begrænset til 200 KB, ingen skrivninger / ingen LLM / ingen URL-fetch). Koblet ind i den delte public/js/lib/report-export.js, så markedsrapporten, karriereplanen og karriereorienteringen også får DOCX-eksport.
  • Live-autoudfyldning bruger den delte udbyderkaskade (runActiveProvider / providerAvailable); den returnerede YAML parses og tvinges tilbage til den afgrænsede two-pager-form (parseYamlFields + normalizeTwoPager) — ukendte nøgler droppes, arrays/strenge begrænses. Manuel tilstand bevaret.
  • Tests: tests/export-routes.test.mjs. 4 nye i18n-nøgler ×16 (export.saveDocx, twoPager.preview, twoPager.aiFilling, twoPager.aiFilled).

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

[1.99.0] — 2026-07-05

Portalsundhedsside (#/portals). Scanneren holder øje med en række virksomheder i portals.yml; et ATS-slug kan gå i stykker i stilhed, og den arbejdsgiver forsvinder fra alle fremtidige scanninger. Den nye Portals-side viser hver overvåget virksomhed og prober ved Check portal health hver careers_url gennem det DNS-fastgjorte safeGet (SSRF-sikkert) og markerer de døde (et 404 = droppet i stilhed) — skrivebeskyttet. Hærder også v1.98.0-fejlrapportøren efter review: fejl-ringbufferen fanger nu netværks-fetch-fejl, og scrubberen skjuler umærkede udbydernøgler.

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

[1.98.0] — 2026-07-05

Indbygget fejlrapportør (paritet med moderprojektets web-v0.2.0-web). En 🐞 Report a bug-knap i notifikationsskuffen samler et privatlivsbegrænset diagnostisk øjebliksbillede — versioner, din skærm, browser, et /api/health-tjekresumé og de sidste 20 fejl fra en ny klientside-ringbuffer — plus et deterministisk dedupe-fingeraftryk (co-web-<base36>), lader dig gennemse den præcise Markdown og åbner derefter en forududfyldt GitHub-issue. Intet indsendes automatisk; det bærer aldrig dit CV, din profil, svar, job-URLer eller nøgler. Nye libs logbuf.js + bug-report.js; 11 i18n-nøgler ×16; tests/bug-report.test.mjs.

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

[1.97.1] — 2026-07-05

Gennemgangsdrevet hærdning & dokumentationsparitet (opfølgning på v1.97.0). En gennemgang af AI-review-loggene afdækkede reelle rettelser:

  • fit-score.js (scan--fit-badge). salaryFloor() ophøjer ikke længere en sats under årsniveau til et falsk årligt gulv — “at least 500 EUR/day”, “$80/hr”, “6000 monthly” returnerer nu null i stedet for en 500k/80k-dealbreaker. Landematchning sker nu på hele ord (\b…\b), så “Germany” ikke længere matcher tillægsordet “German” (ej heller “Nigeria” inde i “Nigerian”) og udløser en falsk skal-haves-andetsteds-overtrædelse. +3 tests i tests/fit-score.test.mjs.
  • Dokumentationsparitet. Hver lokaliseret README averterer nu konsekvent 16 lokaliteter — Help-rækkens tælling/liste (×13) og Lokaliserings-sektionens tekst plus “tilføj nøglen til alle N filer”-noten (×8) sad stadig på tallene fra før v1.85 (8/9). Den in-app hjælps §17-adaptertælling er rettet til 46 adaptere — 41 engelske + 5 russiske på tværs af alle 16 bundter.

Ingen adfærdsændring ud over fit-badge-heuristikken; ingen nye ruter, nøgler eller i18n-tilføjelser.

[1.97.0] — 2026-07-05

Dassault Systèmes-scannerkilde + en trefronts kvalitetsgennemgang.

  • Ny scannerkilde — Dassault Systèmes (paritet med forælderens career-ops, #1498). server/lib/sources/dassault.mjs + server/lib/portals/adapters/dassault.mjs spejler forælderens zero-token Exalead-“card search”-udbyder (det offentlige feed bag 3ds.com/careers/jobs). Det er ét enkelt globalt endpoint, så det vælges via udbyder (provider: dassault) eller autodetekteres fra en 3ds.com-vært, SSRF-værtsfastgjort til www.3ds.com med redirect:'error'. XML’en parses uden en DOM (<Meta>-maps pr. <Hit>), by/land trækkes fra den lokaliserede kategoristreng, og opslag beholdes kun, når deres offentlige URL er på *.3ds.com. Registret leverer nu 46 adaptere (41 EN + 5 RU); ALL_ADAPTERS-tælling, sorteret-id- og /api/scan/sources EN-sæt-assertions hævet 40 → 41. Suite tests/sources-dassault.test.mjs (10 tilfælde).
  • Overførte robusthedsrettelser fra forælderen. Avature-parseren tolererer nu to live tenant-markup-varianter (article--result med et positionsindeks-suffiks + et klasseløst JobDetail-titelanker, #1541); Get on Board værner mod en 0/negativ published_at (ikke flere fejlagtige 1970-datoer); SuccessFactors begrænser den sidste side, så den ikke kan overskride MAX_JOBS (#1528).
  • Serveraudit-rettelser. safe-fetch hænger ikke længere på et svar over grænsen — størrelsesgrænse-stien afgør nu løftet direkte i stedet for at vente på en 'end'-hændelse, som en ødelagt stream aldrig udsender (retter store-side /api/pipeline/preview + auto-pipeline-fetches). SSE stream.*-aktivitetslogning er nåbar igen (/api/stream/-tjekket blev flyttet over den generelle “spring GET over”-værn).
  • SPA-audit-rettelser. #/stats-fanevælgeren værner mod et asynkront render-kapløb — en langsom fanes resultat kan ikke længere overskrive en nyere fane, som brugeren allerede har skiftet til. Sletbekræftelserne i mock interview og networking sender nu en korrekt titel + brødtekst (ikke flere dialoger med tom brødtekst).
  • Oversættelsesrettelser. Uoversatte ordbogsværdier rettet — ukrainsk config.modes* (Adaptive Framing / Exit Narrative / Location Policy), russisk eval.jdLbl (“Job Description”), italiensk dash.quick.contactoSub (“referral” → “segnalazione”) — plus den engelske 16 lokaliteter-standardtekst lokaliseret i ru/uk/ja/ko/zh-CN/zh-TW-CHANGELOG’erne.

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

[1.96.0] — 2026-07-04

Karriereorientering (Epic 27). En ny side #/orientation besvarer “hvilke retninger passer faktisk til mig?” — den læsning, du ville få af en erhvervstest, men udledt af dit eget CV og din profil frem for et spørgeskema. Klik på Generér profil, og modellen returnerer dine bedst passende karrierevektorer (hvilke af de otte arketyper — Funktionalist, Administrator, Kommunikator, Specialist, Analytiker, Innovator, Leder, Iværksætter — der passer, med belæg), en karrieretype-tendens, anbefalede roller, professionelle styrker knyttet til dit CV, arbejdsstilstendenser og udviklingsanbefalinger. Den er en AI-refleksion af, hvordan dit CV læses — ikke en psykometrisk test: den opfinder aldrig resultater og rapporterer aldrig numeriske scorer, som om de var målt. Eksportér den til Markdown eller PDF; intet skrives til disk.

  • Ny rute server/lib/routes/orientation.mjs (24. rutemodul) — POST /api/orientation/generate bygger profil-prompten ud fra CV+profil+two-pager+hukommelse via den delte udbyderkaskade, med en kopiér-indsæt-manuel-fallback og ingen filskrivninger.
  • Genbruger report-export.js til Markdown/PDF/kopiér, under Vækst-navigationsgruppen.
  • Tests: tests/orientation-routes.test.mjs (refleksionsramme / ingen fabrikerede scorer, CV/profil-seedet manuel tilstand). 7 nye i18n-nøgler ×16 locales, Hjælp §28 ×16.

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

[1.95.0] — 2026-07-04

Karriereplan (Epic 26). En ny side #/career-plan forvandler dit CV og din profil til en konkret, personlig udviklingsplan. Vælg en horisont (6/12/24 måneder) og et valgfrit fokus, og modellen — der læser dit CV, din profil, din to-pager og din hukommelsesnote — skriver et udgangspunkts-snapshot, en styrke/vækst-SWOT, mål som SMART / OKR / WOOP, alternative udviklingsspor, en plan for hårde/bløde kompetencer, et måned-for-måned-vejkort, metoder til at følge fremskridt, faldgruber og støttende tiltag. Den planlægger fremad ud fra det, dine materialer faktisk viser, og opfinder aldrig fakta om din historik. Redigér den inline, Gem den til brugerlaget (config/career-plan.md), og eksportér den til Markdown eller PDF.

  • Ny rute server/lib/routes/career-plan.mjs (23. rutemodul) — GET/PUT /api/career-plan (skriver config/career-plan.md) + POST /api/career-plan/generate (den delte udbyderkaskade, manuel fallback, ingen fabrikation). PATHS.careerPlan.
  • Genbruger den delte report-export.js (v1.94.0) til Markdown/PDF/kopiér, og en ny Vækst-navigationsgruppe.
  • Tests: tests/career-plan-routes.test.mjs (afgrænsning, GET/PUT-rundtur, horisont-bevidst CV/profil-seedet prompt). 20 nye i18n-nøgler ×16 locales, Hjælp §27 ×16.

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

[1.94.0] — 2026-07-04

Statistik, omarbejdet (Epic 25). Siden #/stats er nu en Statistik-sektion med tre faner, med rigtige grafer og langt flere data. En ny fane Markedsrapport beder modellen om en løn- og arbejdsmarkedsanalyse af dine målroller i en region og valuta, du vælger — ledelsesresumé, løn efter niveau med P10/P25/P75/P90-percentiler, de største arbejdsgivere, en tabel over efterspurgte kompetencer, frekvens af personalegoder, fordelingen kontor/hybrid/fjernarbejde, tendenser over 12–24 måneder og vejledning til forhandling. Hvert tal er mærket som et retningsgivende estimat fra modellens viden, aldrig præsenteret som skrabede data. En ny fane Min pipeline grafer din egen tracker: scorefordeling, statustragt, de største virksomheder og roller, ansøgninger over tid og konverteringsrater. Den oprindelige målrollevisning (vakance/løn efter land + gemt snapshot-tendens) flytter ind under en tredje fane, nu med en valgvælger til valuta og et overblik over opslag-efter-rolle.

  • Eksportér enhver rapport til Markdown eller PDF, eller kopiér den — via den delte hjælper report-export.js (Markdown-blob-download; PDF gennem den eksisterende inline-PDF-runner).
  • Ny rute server/lib/routes/market.mjs (22. rutemodul) — POST /api/stats/market bygger en markedsanalyse-prompt ud fra dit CV/din profil (så den kender dine målroller), region og valuta, kører den gennem den delte udbyderkaskade og falder tilbage til en kopiér-og-indsæt-prompt uden nøgle. Ingen filskrivninger.
  • Tests: tests/market-routes.test.mjs (region/valuta-afgrænsning, ærligheds-mærket prompt, CV/profil-seedet manuel tilstand). 36 nye i18n-nøgler ×16 locales, Hjælp §26 ×16.

Nyt: #/stats omarbejdet til faner; server/lib/routes/market.mjs; public/js/lib/report-export.js.

[1.93.0] — 2026-07-04

Hukommelseslag (Epic 24). En ny #/memory-side rummer en kort, redigerbar “husk dette om mig”-note, som assistenten holder sig for øje ved hver opgave:

  • Én note, overalt — fordi den indlejres i bundleProjectContext, når noten automatisk frem til hver eneste AI-forespørgsel (evaluering, jobsamtale-øvelse, networking, CV Studio) på tværs af alle udbydere. Skriv den én gang; den styrer alt.
  • Styring, ikke fakta — den indfanger dine præferencer og hvordan du kan lide at arbejde (tone, format, deal-breakers, kadence), aldrig nye faktuelle påstande om din erfaring — dem lever der stadig kun i dit CV, din profil og din two-pager. Gemmes i brugerlaget i config/memory.md og overskrives aldrig af opdateringer.
  • Foreslå ud fra dine dataPOST /api/memory/suggest udvinder adfærdsmønstre fra din egen ansøgnings-tracker og udkaster punkter, du kan gennemse og redigere. Den læser din tracker; den opdigter aldrig fakta og foretager intet live-kald.

Nyt: server/lib/routes/memory.mjs (21. rutemodul — GET/PUT /api/memory + POST /api/memory/suggest), public/js/views/memory.js, PATHS.memory og en config/memory.md-blok tilføjet til bundleProjectContext. 11 nye i18n-nøgler på tværs af alle 16 locales. Tests: tests/memory-routes.test.mjs.

[1.92.0] — 2026-07-04

CV Studio (Epic 21). En ny #/cv-studio-side giver dit CV tre ærlige, overvejende lokale værktøjer:

  • CV-diagnostik — en deterministisk 0–100-score med forklaringer pr. tjek (kvantificeret effekt, svage verber, buzzwords, længde, kerneafsnit, kontaktoplysninger). Rent på klientsiden (window.CvDiagnostics) — ingen LLM, intet opdigtet, hvert fund forklaret, så du afgør, hvad der skal ændres.
  • Privatlivsmaske — sløring af PII (e-mail, telefon, links/handles, gadeadresse og valgfrit dit navn → initialer), før du deler dit CV som en prøve eller et skærmbillede. Kører fuldstændigt i browseren (window.CvPrivacy); den rapporterer nøjagtigt, hvad den slørede, og gemmer aldrig originalen.
  • Make it human / stemmematch — indsæt en stiv linje eller et afsnit, og skriv den om i din stemme, forankret på serversiden i voice-dna.md og writing-samples/. Hårdt værn: den må omrokere, stramme og genstemme, men aldrig introducere et faktum, en metrik eller en præstation, der ikke allerede står i teksten. Kører live gennem den delte udbyder-kaskade eller giver et copy-paste-prompt tilbage uden nøgle.

Nyt: server/lib/routes/cv-studio.mjs (20. rutemodul — POST /api/cv-studio/humanize), public/js/views/cv-studio.js, public/js/lib/cv-diagnostics.js, public/js/lib/cv-privacy.js, PATHS.voiceDna + PATHS.writingSamplesDir. 29 nye i18n-nøgler på tværs af alle 16 locales. Tests: tests/cv-diagnostics.test.mjs, tests/cv-studio-routes.test.mjs. (Skabelongalleri, Word-eksport og opslags-PDF-arkiv spores som opfølgende CV Studio-arbejde.)

[1.91.0] — 2026-07-04

Networking og dybdegående virksomhedsresearch (Epic 16). En ny #/networking-side forvandler en virksomhed til en handlingsorienteret plan for at få en samtale, forankret i dit CV, din profil og din two-pager:

  • Virksomhedsdossier — et stramt brief om, hvad virksomheden laver, nylige signaler værd at citere, og “hvorfor jeg passer”-kroge trukket fra din reelle baggrund.
  • Hvem du skal kontakte — 3–5 målpersonaer (hiring manager, intern rekrutterer, en senior IC på teamet, en varm/alumne-forbindelse) med en konkret LinkedIn-søgestreng til at finde hver enkelt. Den opdigter aldrig rigtige navne.
  • Den varmeste introvej — den mest realistiske varme indgang for din baggrund (fælles arbejdsgiver/skole/fællesskab, en andengrads-vej eller en kold DM med højt signal) og hvorfor.
  • Kontaktudkast — korte, specifikke beskeder til de vigtigste personaer, forankret i dine reelle bevispunkter.
  • Live eller manuel — kører live gennem den delte udbyder-kaskade med en hvilken som helst nøgle, eller returnerer et copy-paste-prompt (ærlig fallback, intet opdigtet). Gem plan bevarer en færdig plan i brugerlaget (networking/net-{company}-{role}-{date}.md); siden viser, åbner og sletter gemte planer.

Nyt: server/lib/routes/networking.mjs (19. rutemodul), public/js/views/networking.js, PATHS.networkingDir. Genbruger server/lib/llm-dispatch.mjs-kaskaden fra v1.90.0. 24 nye i18n-nøgler på tværs af alle 16 locales. Tests: tests/networking-routes.test.mjs.

[1.90.0] — 2026-07-04

Mock Interview 2.0 (Epic 15). En ny #/mock-interview-side forvandler dit CV, din profil, din two-pager og din historiebank til en tur-for-tur jobsamtaleøvelse:

  • Samtalebaseret øvelse — angiv en målrolle (+ valgfri virksomhed / JD), og intervieweren åbner med et fokuseret spørgsmål. Hvert svar, du sender, får et struktureret svar tilbage: Feedback (styrker + STAR+R-hullet), en Score (N/5) og et Next question, der undersøger den svageste del af dit sidste svar. Forankret på serversiden i dine faktiske materialer — det opdigter aldrig erfaring, du ikke har.
  • Historiebank-bevidstinterview-prep/story-bank.md indlejres i prompten (samme tillidsniveau som cv.md), så feedbacken kan pege dig hen på dine egne bedste historier.
  • Live eller manuel — med en udbydernøgle kører turen live gennem den delte udbyderkaskade (Anthropic → Gemini → OpenAI → Qwen → OpenRouter → GitHub Models); uden nøgle får du en klar-til-brug prompt til at kopiere og indsætte (ærlig fallback, ingen opdigtede svar).
  • Gemte sessioner — klik på Save transcript for at gemme en færdig samtale i brugerlaget (interview-prep/mock-{company}-{role}-{date}.md); siden viser, åbner og sletter gemte sessioner.

Nyt: server/lib/routes/interview.mjs (18. rutemodul), public/js/views/mock-interview.js, server/lib/llm-dispatch.mjs (delt udbyderkaskade), PATHS.storyBank, bundleProjectContext({ extraFiles }). 30 nye i18n-nøgler på tværs af alle 16 locales. Tests: tests/interview-routes.test.mjs.

[1.89.0] — 2026-07-04

Kandidat-marked-fit — the two-pager (Epic 14). En ny #/two-pager-side lader dig indfange, hvad du faktisk vil have af din næste rolle, modelleret efter “Mnookin two-pager” fra Never Search Alone:

  • Guidet builder — en førstepersons “Hvem jeg er”-fortælling, en “Målmiljø”-note og fem chip-liste-editorer: loves, must-haves, hates, deal-breakers og non-negotiables. Gemmes i forældreprojektets brugerlag (config/two-pager.yml) via PUT /api/two-pager — overskrives aldrig af systemopdateringer.
  • AI-udfyldningsassistent (POST /api/two-pager/draft) — bygger en klar-til-kørsel Mnookin-prompt med dit CV + profil indlejret, som du kan køre i en hvilken som helst LLM og indsætte resultatet tilbage. Den bruger kun dit eget materiale; intet opdigtes.
  • Fit-til-det-du-vil-have-badge — hvert opslag på #/scan viser nu en ◎ N fit-score (klientside, via window.FitScore), der holder opslagets arbejdstype, land, lønbund og relokation op mod din two-pager. Ærlig af design: når et opslag ikke giver noget matchbart signal, vises intet badge (aldrig et opdigtet tal). Deal-breaker-overtrædelser vejer tungere end bløde uenigheder.
  • Nærer hver evaluering — den gemte two-pager indlejres i bundleProjectContext, så alle efterfølgende LLM-evalueringer blander dine erklærede præferencer med CV-vs-JD-matchet.

Nyt: server/lib/routes/two-pager.mjs, public/js/views/two-pager.js, public/js/lib/fit-score.js, PATHS.twoPager. 27 nye i18n-nøgler på tværs af alle 16 locales. Tests: tests/two-pager-routes.test.mjs, tests/fit-score.test.mjs.

[1.88.0] — 2026-07-04

Finpudsning af issue #29 — i18n-huller i Scan + API-hygiejne.

  • Lokaliserede de sidste hardkodede Scan-strenge (køreplan v1.69.4): kilde-opsummeringspillerne (N nye / M matchende), N nye stillinger-toasterne og reloc-badget flyder nu gennem t() — 4 nye nøgler (scan.pillNew, scan.pillMatching, scan.newOffers, scan.relocBadge) på tværs af alle 16 locales. Ikke-engelske brugere ser ikke længere strøet engelsk i kerne-scan-flowet.
  • Deaktiverede X-Powered-By-headeren (køreplan v1.69.5): app.disable('x-powered-by') i createApp() — serveren annoncerer ikke længere Express. (Resten af den epic var allerede leveret: parentVersion fjerner sin release-please-kommentar, lystilstands-temaskifteren, luk-modal-ved-ruteskift og lokaliseringen af “Score” (rep.score) i Rapporter.)

Tests: tests/scan-i18n-gaps.test.mjs + en assertion om fravær af X-Powered-By i tests/security-headers.test.mjs.

[1.87.0] — 2026-07-04

4 nye scan-udbydere uden godkendelse (paritet med forælder-career-ops v1.16.0). Scannerregistret vokser fra 41 → 45 adaptere (40 EN + 5 RU) — alle offentlige, uden godkendelse, host-fastlåst, redirect:'error' (SSRF-sikre), hver med en CI-isoleret test:

  • Get on Board (getonbrd) — portaldækkende offentligt JSON:API (LATAM/fjern-tech), udbyder-valgt, pagineret. server/lib/sources/getonbrd.mjs.
  • Amazon (amazon) — amazon.jobs offentlige søge-JSON, host-registreret eller provider: amazon, offset-pagineret. server/lib/sources/amazon.mjs.
  • Avature (avature) — pr.-lejer *.avature.net ATS, HTML-parset, host-registreret eller provider: avature. server/lib/sources/avature.mjs.
  • SAP SuccessFactors (successfactors) — pr.-lejer RMK-fliseliste (*.successfactors.eu/.com, jobs2web.com), HTML-parset. server/lib/sources/successfactors.mjs.

Hver leverer et sources/<slug>.mjs (auto-opdaget meta#/scan-rullemenu) og et portals/adapters/<slug>.mjs i ALL_ADAPTERS (reglen om de to registre) + tests/sources-<slug>.test.mjs. ALL_ADAPTERS-antallet samt assertions for sorteret id og EN-sættet i /api/scan/sources steg fra 36→40; GET /api/scan/sources viser nu 45.

[1.86.0] — 2026-07-03

Statistik efter målroller (#/stats) — markedsstatistik for stillinger og løn for DINE målroller. En ny Analyse-side læser dine målroller fra profilen (config/profile.yml → ikke hardkodet) og opslagene fra den seneste scanning og viser derefter, pr. rolle og land:

  • Stillinger pr. land og medianløn pr. land (USD) — aggregeret på klientsiden (public/js/lib/role-stats.js, som genbruger window.Countries) ud fra de sparsomme data, scannerne allerede indsamler. Lønninger i enhver valuta normaliseres til USD via en eksplicit tilnærmet FX-tabel, med et forbehold om stikprøvestørrelse — aldrig opdigtet.
  • Rolle- og landefiltre samt håndlavede inline-SVG-søjle- og trenddiagrammer (ingen nye afhængigheder, CSP-sikre — kun addEventListener).
  • Gem øjebliksbillede (POST /api/stats/snapshot) bevarer det aktuelle aggregat i data/role-stats.jsonl; trenddiagrammet (GET /api/stats/trend) følger antallet af stillinger over tid — “dynamik”-visningen. Ærlig hybrid: øjebliksbilleder kommer fra lokale scanningsdata og opdateres efter behov.
  • Fuldt lokaliseret i alle 16 locales (26 nye i18n-nøgler).

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

[1.85.0] — 2026-07-03

Tysk, italiensk og tyrkisk locale (locale-paritet med forælderens career-ops v1.16.0). UI’et leveres nu på 16 sprogde 🇩🇪, it 🇮🇹 og tr 🇹🇷 slutter sig til de eksisterende 13.

  • Fuld UI-oversættelse — alle 730 i18n-nøgler oversat i public/js/lib/locales/i18n-dict.{de,it,tr}.js; sprogvælgeren viser Deutsch / Italiano / Türkçe, og automatisk registrering af browsersprog genkender de/it/tr (public/js/lib/i18n.js).
  • Indbygget hjælpeguidedocs/help/{de,it,tr}.md oversat (fuld 19 H2 / 75 H3-struktur), serveret af GET /api/help/:lang.
  • DokumentationREADME.{de,it,tr}.md og CHANGELOG.{de,it,tr}.md tilføjet; CHANGELOG-locale-paritetsporten dækker nu 15 ikke-EN-locales.
  • Prompt-stilladsserver/lib/prompts.mjs (LOCALE_NAMES + SCAFFOLD_STRINGS) lokaliseret til de tre nye locales, så LLM-outputtet følger UI-sproget.

Alle paritetsporte (i18n-locale-files, i18n-coverage, check-changelog-parity, lang-switcher-rtl) udvidet til sættet på 16 locales.

[1.84.0] — 2026-06-30

Genansøgnings-cooldown + kompensation i pipeline.md (paritet med forælderens career-ops v1.15.0). To scan-opgraderinger:

  • Genansøgnings-cooldown (#1201): EN-scanningen springer nu over roller hos virksomheder, du har søgt hos for nylig, så resultaterne forbliver fokuserede på NYE opslag. Konfigurér vinduer pr. virksomhed i config/profile.yml under re_apply_windows: (last_apply_date, same_role_days, applied_to: [roles], valgfri cross_role_bucket); virksomhedsmatchning er tegnsætningsuafhængig + ordgrænsebaseret (server/lib/cooldown.mjs). Slået fra, når nøglen mangler; scanloggen viser Cooldown skipped: N.
  • Kompensation i pipeline.md (#1017): scannede opslag gemmer nu deres løn som en valgfri trailingkolonne (url | <salary>) i data/pipeline.md. URL’en forbliver dedup-nøgle (| comp-kolonnen strippes ved læsning), cellen saniteres (ingen række-/kolonneindsprøjtning, formel-led neutraliseres), og bare-URL-pipelines bevarer bagudkompatibiliteten. Medfølger tests/cooldown.test.mjs + pipeline-kompensationstests. Kildeantallet uændret på 41 (begge er scan-logik-opgraderinger, ikke nye boards).

[1.83.0] — 2026-06-30

Genopslags-/spøgelsesstillings-detektor (paritet med forælderens career-ops v1.15.0). Et nyt panel 🔁 Genopslåede / spøgelsesstillinger#/scan markerer firma+stilling-klynger, der er genopslået under forskellige URL’er inden for et rullende 90-dages vindue — et signal om forældede pipelines og spøgelsesstillinger. Understøttet af en fuzzy stillingstitel-matcher (server/lib/role-matcher.mjs) og en skrivebeskyttet detektor (server/lib/detect-reposts.mjs) over data/scan-history.tsv, eksponeret via GET /api/scan/reposts. Desuden: parentVersion i /api/health rapporterer nu blot semver-nummeret (release-please-kommentaren # x-release-please-version fjernes). Medfølger tests/detect-reposts.test.mjs. Kildeantallet uændret på 41 — genopslag er en analysefunktion, ikke et nyt board.

[1.82.0] — 2026-06-30

NoDesk-scanningskilde (paritet med forælderens career-ops v1.15.0). Det boards-dækkende NoDesk RSS-feed med fjernjob er nu en førsteklasses scanningskilde — tilføj en provider: nodesk-post, og den vises i Source-dropdownen på #/scan (41 adaptere i alt: 36 EN + 5 RU). Host-låst til nodesk.co med redirect:'error' (SSRF-sikker); titler opdeles på Role at Company (NoDesk har ingen lokationstag, så lokation forbliver tom); alle rækker er fjernjob. Medfølger en CI-isoleret tests/sources-nodesk.test.mjs-suite; den fulde unittest-suite er grøn ved 1523.

[1.81.0] — 2026-06-29

Paritet med forælderens career-ops — 13 nye jobscanningskilder. Overfører den seneste providerbatch fra Fighter90/career-ops main til in-process-scanneren. Boards-dækkende offentlige API’er (provider-valgt): Arbeitnow, Himalayas, Jobicy, Landing.jobs, 4 Day Week, The Muse, The Hub, Jobspresso (RSS) og Hacker News “Who is hiring?” (Algolia to-trins). Polske boards (host- eller provider:-detekteret): JustJoin.it og NoFluffJobs (POST-søgning). Per-tenant ATS (auto-detekteret fra careers_url): Pinpoint (<slug>.pinpointhq.com/postings.json) og Rippling (ats.rippling.com/<slug>api.rippling.com board). Alle kilder er host-låste med redirect:'error' (SSRF-sikrede) og kan vælges i Source-dropdownen på #/scan — registret leverer nu 40 scanneradaptere (35 EN + 5 RU). Tilføjer 13 CI-isolerede testsuiter pr. kilde; den fulde unittest-suite er grøn ved 1513.

[1.80.0] — 2026-06-28

Fem scan-forbedringer (idéer fra job-crawler, genimplementeret). (1) Teamtailor-kilde — per-tenant <slug>.teamtailor.com via det offentlige /jobs.rss-feed, auto-detekteret fra careers_url (host-låst + redirect:'error'); registret har nu 27 adaptere. (2) Kilde-karantæne — en kilde med permanent 404/410 gemmes i data/scan-quarantine.json og springes over ved senere scanninger (selvhelende: forsøges igen efter 14 dage). (3) Maks. pr. kilde — et valgfrit #/scan-felt der begrænser antallet af jobs pr. board (∞ som standard). (4) Slået op inden for — et klient-side aldersfilter (24t / 7d / 30d). (5) Gemte søgninger + ★ favoritter — navngiv og genbrug filtersæt og marker job, gemt i localStorage med defensiv validering (en korrupt cache nulstilles rent); resultat-cachen nulstilles før hver scan og fyldes live.

[1.79.0] — 2026-06-28

WeWorkRemotely-scanningskilde (paritet med career-ops v1.14.0). Det boards-dækkende RSS-feed fra We Work Remotely er nu en førsteklasses scanningskilde — tilføj en provider: weworkremotely-post, og den vises i Source-dropdownen på #/scan (26 adaptere i alt). Host-låst til weworkremotely.com med redirect:'error' (SSRF-sikker); titler opdeles på Company: Role. Desuden: title_filter-nøgleord trimmes nu før længdetjekket (parent #1261).

[1.78.2] — 2026-06-27

i18n- og UX-hærdning (opfølgning på v1.78.1). Logoets tilgængelige navn er nu lokaliseret på alle 13 sprog (nav.logoHome). Enter i den globale søgning, mens du allerede er på #/scan, fremtvinger en gen-rendering, så den udfyldte søgeterm aldrig mistes (same-route guard). health.title er nu oversat til polsk (Kondycja) og dansk (Systemtilstand) — tidligere på engelsk. Tests 1235 → 1238.

[1.78.1] — 2026-06-27

Scan-UX-rettelser. Resultattabellen på #/scan opdateres nu automatisk under en scanning og igen, når den er færdig — ingen manuel genindlæsning. Den globale søgning i topbjælken viser et Enter-hint og hopper ved en ikke-URL-forespørgsel til #/scan med søgefeltet udfyldt (før #/tracker). Logoet linker nu til dashboardet (hjem).

[1.78.0] — 2026-06-27

Geografifilter på Scan-siden — filtrér jobresultater efter land, med flag. En ny Land-dropdown i #/scan viser hvert land, der er fundet i dine scanningsresultater (flag-emoji + antal), så du kan beholde kun roller knyttet til et bestemt land — sammen med Remote/Hybrid/Onsite-filteret, så du kan søge både landebundet og fjernarbejde. Drevet af en ny countries.js-hjælper, der mapper en annonces fritekst-lokation (landenavne, aliasser og ~100 store jobmarkedsbyer) til et ISO-land + flag; detektionen er konservativ og gætter aldrig.

[1.77.0] — 2026-06-27

Dansk tilføjet som det 13. sprog i grænsefladen. Fuld dansk oversættelse af brugerfladen, den indbyggede hjælpeguide (19 H2 / 75 H3), README og CHANGELOG. Dansk er nu med i sprogvælgeren med flag; i18n-maskineriet (assembler, audit, paritetsporte, snapshot) dækker nu 13 sprog.

[1.76.0] — 2026-06-26

Paritet med forælderens career-ops v1.13.0 — seks nye jobkilder, hærdning af scanneren og en resultattabel uden loft.

Tilføjet

  • Seks ATS-kilder pr. tenant — BambooHR, Breezy HR, Comeet, Personio, Recruitee, SolidJobs. De registreres automatisk ud fra careers_url-værten (Comeet kræver den fulde api: careers-api-URL), og hver enkelt fastlåser sin vært med et forankret regex + redirect:'error' (SSRF-sikret). Alle kan vælges i Source-rullemenuen på #/scan — registret leverer nu 25 adaptere (20 EN + 5 RU). Tilføjer en fetchText-hjælper til Personios XML-feed.
  • trust_filter — valgfri tillidsscoring, der kun annoterer (0–100, niveau high/medium/low, flag), for hver scannet opslag. Rækker under high får et sprogneutralt ⚠-badge i #/scan; intet kasseres nogensinde.
  • Arbeitsagentur remoteMatch + remoteMaxPages — konfigurationsstyret registrering af fjernarbejde: title (regex), filter (server-side homeoffice=nv_true + paginering) eller off.

Ændret

  • Intet loft på scanresultater. Visningsloftet MAX_STORED_RESULTS (2000) er fjernet — hvert matchet opslag gemmes, og tabellen på #/scan pagerer gennem dem (200/side). Store gennemløb mister ikke længere deres hale.
  • Robusthed i titelfilteret — korte akronymer kun med bogstaver (COO, SDR …) matcher nu på ordgrænser (ikke længere “COO” i “Coordinator”); fejlbehæftet title_filter-konfiguration kan ikke længere få en scanning til at gå ned. Gælder både ATS- og den regionale scanner.

Tests

  • +32 cases (1190 → 1222): sources-ats-providers, title-filter, arbeitsagentur-remote, trust-validator og en omskrevet scan-result-cap-vagt (“intet loft”).

[1.75.2] — 2026-06-19

docs: fuld dokumentationsparitet for v1.75.0-scanneraggregatorerne på tværs af alle 12 lokaliteter. Ingen kodeændring — bringer den brugervendte dokumentation på linje med de syv kilder, der kom i v1.75.0:

  • Hjælpevejledning (12 lokaliteter). §5 får en content_filter-blok (gating på nøgleord i beskrivelse/uddrag, søskende til location_filter) og en note om aggregatorer; §7 oplister de syv nye kilder i ét-klik-scangennemløbet og i den fulde Source-rullemenu; adaptertallet i §17 er rettet fra de forældede “11 adapters” til “19 adapters — 14 English + 5 Russian”. Ingen ##/###-overskrift blev tilføjet, så den fastlåste struktur med 19 H2 / 75 H3 er uændret.
  • README (9 fulde lokaliteter). Nyt punkt “Aggregator boards (v1.75.0)” under scankilderne, plus release-badget hævet til v1.75.2. (De forkortede README’er for pl/uk/ar har ingen liste pr. kilde og er bevidst urørte der.)
  • Referencedokumenter. docs/portals-examples.md får et “Aggregator boards”-afsnit klar til kopiering med korrekte provider: / <provider>:-konfigurationsblokke for alle syv; docs/PROJECT.md opdateret til 19 adapters; docs/sdd/CONVENTIONS.md dokumenterer skellet mellem de to registre (sources/registry.mjs til rullemenuen kontra portals/registry.mjs til hentning), det provider:-baserede aggregatorvalg, der videreføres som opts.company, scan-skrivesanitizeren (scan-sanitize.mjs) og v1.75.1-testtallet (1190).
  • QA. Tilføjet qa/QA-REGRESSION-PROMPT-v1.75.2-FULL.md — release-gate-driveren for hele fladen, opdateret til v1.75.x-scanaggregatorcyklussen.

[1.75.1] — 2026-06-19

fix(scan): robusthedsforbedring på de konfigurationsstyrede kilder fra v1.75.0. Tre små hærdningsrettelser fra gennemgangen efter udgivelsen (ingen adfærdsændring for en sund scanning):

  • Pagineringspauser med afbrydelseshensyn. Glints’ (300 ms) og Jobstreet/SEEK’s (200 ms) høflighedspauser mellem sider afsluttes nu øjeblikkeligt, når scanningens AbortSignal udløses, via en ny delay(ms, signal)-hjælper i server/lib/http-json.mjs, så en frakoblet klient ikke kan holde en paginerende scanning åben en ekstra pause.
  • Beskrivende fejl ved ikke-JSON. fetchJson ombryder nu et ikke-JSON 2xx-svar (f.eks. en HTML-vedligeholdelsesside leveret med status 200) som non-JSON 2xx response from <url> i stedet for at vise en bar SyntaxError, så scannerens fejllog pr. kilde navngiver det fejlbehæftede endpoint.
  • Stærkere normalisering ved scan-skrivning. normalizeScanScalar sammenfolder nu lodret tabulator, sideskift og Unicode-linje-/afsnitsseparatorerne (\v \f U+2028 U+2029) ud over \r \n \t — en streng overmængde, så ingen post-/linjeseparator, som et regneark eller en fremviser måtte respektere, overlever ind i scan-history.tsv.

[1.75.0] — 2026-06-19

feat(scan): overfør forælderens career-ops v1.12.0 — syv nye jobkilder, indholdsfiltrering og sikkerheds-/kvalitetsrettelser. web-ui kører sine egne in-process-scannere (den kalder ikke forælderens scan.mjs), så forælderens provider- og scanændringer fra v1.12.0 forplanter sig ikke automatisk — denne udgivelse genimplementerer de relevante i web-ui’ens adapterkontrakt.

  • Syv nye scannerkilder. Tre board-brede aggregatorer for fjernarbejde — RemoteOK, Remotive, Working Nomads — passer ind i det automatisk opdagede server/lib/sources/*.mjs-mønster (vælges med provider: remoteok / remotive / workingnomads). Fire konfigurationsstyrede regionale aggregatorer — IBM careers, Arbeitsagentur (det tyske føderale arbejdsformidlingsagentur), Glints (Sydøstasien), Jobstreet / SEEK — læser en <provider>:-konfigurationsblok pr. post; en-scanner videregiver nu den løste virksomhedspost til hver fetcher, så de kan læse den. Alle syv vises automatisk i kilde-rullemenuen på #/scan.
  • content_filter (forælder #974). Valgfri portals.yml-blok (nøgleordslister positive / negative), der gater et opslag på dets beskrivelses-/uddragstekst — afspejler location_filter-semantikken; opslag uden en beskrivelse passerer altid. Tilkoblet både EN- og RU-scanneren.
  • Hærdning ved scan-skrivning (forælder #1098). Metadata fra eksterne feeds saniteres nu, før de havner i data/scan-history.tsv og data/pipeline.md: kontroltegn sammenfoldes (et linjeskift i virksomhed/titel kan ikke længere indsætte en TSV-række), og et indledende = + - @ neutraliseres mod formelinjektion i regneark.
  • Ashby secondaryLocations (forælder #1073). Ashby-kilden folder nu hver sekundær lokations regionsmærkat plus postnummer-addressLocality / addressCountry ind i lokationsstrengen (dedupliceret), så en EU-berettiget rolle, hvis primære mærkat f.eks. lyder “Canada”, dukker op for location_filter.
  • Validering af evalueringsrapportens form (forælder #819). In-process-providerne i /api/evaluate (Anthropic / OpenAI / Qwen / OpenRouter / GitHub Models) markerer nu en fejlbehæftet A–G- / SCORE_SUMMARY-rapport som et ikke-fatalt warnings-array; Gemini-evalueringsstien arver allerede beskyttelsen fra forælderens gemini-eval.mjs.
  • docs: Antigravity CLI tilføjet til listerne over understøttede assistenter på tværs af alle 12 README’er (afbildes til Gemini-provideren).

Arvet gratis fra git pull af forælderen (web-ui kalder ud til disse): japansk CJK-PDF-fontfallback (#1053), ATS-sikre PDF-fonte (#1074), LaTeX-CJK-vagt (#1054), rettelser i tracker/merge/followup/dashboard og de kinesiske modes/zh-modes (web-ui oplister modes dynamisk).


[1.74.3] — 2026-06-18

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


[1.74.2] — 2026-06-17

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


[1.74.1] — 2026-06-17

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


[1.74.0] — 2026-06-17

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


[1.73.0] — 2026-06-17

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


[1.72.0] — 2026-06-17

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


[1.71.2] — 2026-06-17

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


[1.71.1] — 2026-06-17

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


[1.71.0] — 2026-06-16

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

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


[1.70.0] — 2026-06-16

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

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

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

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

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

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


[1.69.2] — 2026-06-12

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


[1.69.1] — 2026-06-12

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

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


[1.69.0] — 2026-06-12

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


[1.68.2] — 2026-06-07

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


[1.68.1] — 2026-05-29

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


[1.68.0] — 2026-05-29

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


[1.67.1] — 2026-05-29

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


[1.67.0] — 2026-05-29

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


[1.66.0] — 2026-05-28

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


[1.65.0] — 2026-05-28

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


[1.64.0] — 2026-05-27

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


[1.63.2] — 2026-05-27

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


[1.63.1] — 2026-05-27

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


[1.63.0] — 2026-05-27

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


[1.62.3] — 2026-05-27

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


[1.62.2] — 2026-05-27

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


[1.62.1] — 2026-05-27

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


[1.62.0] — 2026-05-27

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


[1.61.1] — 2026-05-22

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


[1.61.0] — 2026-05-22

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


[1.60.0] — 2026-05-22

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


[1.59.13] — 2026-05-21

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


[1.59.12] — 2026-05-21

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


[1.59.11] — 2026-05-21

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


[1.59.10] — 2026-05-21

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


[1.59.9] — 2026-05-21

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


[1.59.8] — 2026-05-21

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


[1.59.7] — 2026-05-20

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


[1.59.6] — 2026-05-20

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


[1.59.5] — 2026-05-20

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


[1.59.4] — 2026-05-20

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


[1.59.3] — 2026-05-20

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


[1.59.2] — 2026-05-20

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


[1.59.1] — 2026-05-20

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


[1.59.0] — 2026-05-20

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


[1.58.65] — 2026-05-20

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


[1.58.64] — 2026-05-20

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


[1.58.63] — 2026-05-20

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


[1.58.62] — 2026-05-20

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


[1.58.61] — 2026-05-20

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


[1.58.60] — 2026-05-20

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


[1.58.59] — 2026-05-20

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


[1.58.58] — 2026-05-20

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


[1.58.57] — 2026-05-20

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


[1.58.56] — 2026-05-20

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


[1.58.55] — 2026-05-20

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


[1.58.54] — 2026-05-20

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


[1.58.53] — 2026-05-20

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


[1.58.52] — 2026-05-20

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


[1.58.51] — 2026-05-20

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


[1.58.50] — 2026-05-20

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


[1.58.49] — 2026-05-20

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


[1.58.48] — 2026-05-20

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


[1.58.47] — 2026-05-20

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


[1.58.46] — 2026-05-20

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


[1.58.45] — 2026-05-20

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


[1.58.44] — 2026-05-20

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


[1.58.43] — 2026-05-20

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


[1.58.42] — 2026-05-20

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


[1.58.41] — 2026-05-20

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


[1.58.40] — 2026-05-20

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


[1.58.39] — 2026-05-20

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


[1.58.38] — 2026-05-20

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


[1.58.37] — 2026-05-20

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


[1.58.36] — 2026-05-20

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


[1.58.35] — 2026-05-20

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


[1.58.34] — 2026-05-20

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


[1.58.33] — 2026-05-20

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


[1.58.32] — 2026-05-20

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


[1.58.31] — 2026-05-20

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


[1.58.30] — 2026-05-20

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


[1.58.29] — 2026-05-20

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


[1.58.28] — 2026-05-20

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


[1.58.27] — 2026-05-20

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


[1.58.26] — 2026-05-20

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


[1.58.25] — 2026-05-20

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


[1.58.24] — 2026-05-20

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


[1.58.23] — 2026-05-20

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


[1.58.22] — 2026-05-20

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


[1.58.21] — 2026-05-20

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


[1.58.20] — 2026-05-20

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


[1.58.19] — 2026-05-20

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


[1.58.18] — 2026-05-20

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


[1.58.17] — 2026-05-20

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


[1.58.16] — 2026-05-20

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


[1.58.15] — 2026-05-20

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


[1.58.14] — 2026-05-20

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


[1.58.13] — 2026-05-20

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


[1.58.12] — 2026-05-20

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


[1.58.11] — 2026-05-20

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


[1.58.10] — 2026-05-20

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


[1.58.9] — 2026-05-20

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


[1.58.8] — 2026-05-20

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


[1.58.7] — 2026-05-20

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


[1.58.6] — 2026-05-20

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


[1.58.5] — 2026-05-20

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


[1.58.4] — 2026-05-19

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


[1.58.3] — 2026-05-19

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


[1.58.2] — 2026-05-19

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


[1.58.1] — 2026-05-19

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


[1.58.0] — 2026-05-19

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

🐛 Fixes (QA report)

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

✨ Features / UX

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

🧪 Tests

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

Triaged, not changed (with rationale)

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

[1.57.2] — 2026-05-19

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

🐛 Fixes

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

🧪 Tests

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

[1.57.1] — 2026-05-19

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

🐛 Fixes

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

✨ UX

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

🧪 Tests

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

[1.57.0] — 2026-05-19

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

🐛 Fixes

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

✨ Features

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

🧪 Tests

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

[1.56.4] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.56.3] — 2026-05-19

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

🐛 Fixes

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

🧪 Tests

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

[1.56.2] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.56.1] — 2026-05-19

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

🐛 Fixes

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

🧪 Tests

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

[1.56.0] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.8] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.7] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.6] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.5] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.4] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.3] — 2026-05-19

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

✨ Features

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

🧪 Tests

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

[1.55.2] — 2026-05-18

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

🐛 Bug Fixes

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

🧪 Tests

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

[1.55.1] — 2026-05-18

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

🐛 Bug Fixes

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

🧪 Tests

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

[1.55.0] — 2026-05-18

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

✨ Features

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

🧪 Tests

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

[1.54.10] — 2026-05-18

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

🐛 Fixes

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

🧪 Tests

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

[1.54.9] — 2026-05-18

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

🐛 Fixes

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

🧪 Tests

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

[1.54.8] — 2026-05-18

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

✨ Features

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

🧪 Tests

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

[1.54.7] — 2026-05-18

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

🐛 Fixes

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

🧪 Tests

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

[1.54.6] — 2026-05-18

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

🐛 Fixes

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

🧪 Tests

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

[1.54.5] — 2026-05-18

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

🐛 Fixes

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

🧪 Tests

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

[1.54.4] — 2026-05-18

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

🐛 Fixes

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

🧪 Tests

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

[1.54.3] — 2026-05-18

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

✨ Features

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

🧪 Tests

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

[1.54.2] — 2026-05-18

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

✨ Features

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

🧪 Tests

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

[1.54.1] — 2026-05-18

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

🐛 Fixes

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

🧪 Tests

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

Verification

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

[1.54.0] — 2026-05-18

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

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

🐛 Fixes

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

🧪 Tests

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

📝 Documentation

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

Verification

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

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


[1.53.0] — 2026-05-18

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

🧪 Tests

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

📝 Documentation

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

Verification

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

[1.52.0] — 2026-05-18

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

🐛 Fixes

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

🌐 i18n

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

🧪 Tests

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

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

Verification

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

[1.51.0] — 2026-05-18

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

🐛 Fixes

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

🌐 i18n

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

🧪 Tests

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

Verification

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

[1.50.0] — 2026-05-18

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

🐛 Fixes

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

🌐 i18n

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

🧪 Tests

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

Verification

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

[1.49.0] — 2026-05-18

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

🐛 Fixes

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

🌐 i18n

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

🧪 Tests

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

Verification

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

[1.48.0] — 2026-05-18

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

🐛 Fixes

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

🌐 i18n

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

🧪 Tests

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

Verification

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

[1.47.0] — 2026-05-18

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

🐛 Fixes

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

🧪 Tests

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

Verification

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

[1.46.0] — 2026-05-18

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

🐛 Fixes

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

🌐 i18n

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

🧪 Tests

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

Verification

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

[1.45.0] — 2026-05-18

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

🐛 Fixes

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

🌐 i18n

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

🧪 Tests

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

Verification

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

[1.44.0] — 2026-05-18

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

🐛 Fixes

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

🌐 i18n

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

🧪 Tests

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

Verification

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

[1.43.0] — 2026-05-18

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

✨ Features

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

🧪 Tests

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

📝 Documentation

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

Verification

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

[1.42.0] — 2026-05-18

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

🐛 Fixes

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

🧪 Tests

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

📝 Documentation

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

Verification

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

[1.41.0] — 2026-05-18

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

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

🐛 Fixes

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

🧪 Tests

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

📝 Documentation

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

Verification

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

[1.40.0] — 2026-05-18

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

🐛 Fixes

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

📝 Documentation

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

🧪 Tests

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

Verification

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

[1.39.0] — 2026-05-18

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

✨ Features

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

📝 Documentation

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

🧪 Tests

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

Verification

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

[1.38.0] — 2026-05-17

WS8.1 — unified CLI dispatcher + doctor verb.

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

✨ Features

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

📝 Documentation

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

🧪 Tests

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

Verification

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

[1.37.0] — 2026-05-17

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

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

✨ Features

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

📝 Documentation

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

🧪 Tests

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

Verification

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

[1.36.0] — 2026-05-17

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

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

✨ Features

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

📝 Documentation

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

🧪 Tests

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

WS6 outcome

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

Verification

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

[1.35.0] — 2026-05-17

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

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

✨ Features

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

📝 Documentation

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

🧪 Tests

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

Verification

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

[1.34.0] — 2026-05-17

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

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

✨ Features

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

📝 Documentation

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

🧪 Tests

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

Verification

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

[1.33.0] — 2026-05-17

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

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

✨ Features

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

📝 Documentation

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

🧪 Tests

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

Parity outcome

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

Verification

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

[1.32.0] — 2026-05-17

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

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

✨ Features

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

📝 Documentation

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

🧪 Tests

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

🔄 Migration

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

Verification

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

[1.31.0] — 2026-05-17

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

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

✨ Features

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

📝 Documentation

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

🧪 Tests

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

🔄 Migration

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

Verification

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

[1.30.0] — 2026-05-14

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

✨ Features

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

🧪 Tests

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

🔄 Migration

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

Verification

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

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


[1.29.2] — 2026-05-14

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

🚑 Critical hot-fix

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

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

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

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

Fix — multi-phase SSE contract:

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

🧪 Tests

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

🔄 Migration

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

Verification

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

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

[1.29.1] — 2026-05-14

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

📝 Documentation

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

🧪 Tests

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

Verification

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

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

[1.29.0] — 2026-05-14

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

✨ Features

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

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

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

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

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

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

📝 Documentation

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

🧪 Tests

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

🔄 Migration

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

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

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

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

Verification

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

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

[1.28.1] — 2026-05-14

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

🚑 Critical hot-fix

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

🧹 Cleanup

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

🧪 Tests

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

Verification

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

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

[1.28.0] — 2026-05-14

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

✨ Features

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

📝 Documentation

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

🧪 Tests

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

📒 Deferred (unchanged)

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

Verification

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

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

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

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

[1.27.0] — 2026-05-14

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

🩹 Cosmetic / a11y

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

📒 Deferred (parent-coordinated)

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

🧪 Tests

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

Verification

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

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

[1.26.1] — 2026-05-14

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

🚑 Critical hot-fix

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

🧪 Tests

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

Verification

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

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

Out of scope (v1.27.0)

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

[1.26.0] — 2026-05-14

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

📐 Test pyramid documentation

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

🧪 Tier 2 — Functional gap fills

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

🧪 Tier 3 — Acceptance (NEW)

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

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

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

🧪 Test count

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

Coverage snapshot

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

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

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

Verification

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

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

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

Breaking changes

None.

Out of scope (v1.27+)

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

[1.25.0] — 2026-05-14

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

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

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

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

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

✨ Cosmetic — dashboard double-glyph dedup

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

🚫 Deferred to a future release

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

🧪 Tests

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

Verification

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

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

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

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

Breaking changes

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

Out of scope (v1.26+)

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

[1.24.1] — 2026-05-14

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

🚑 Critical hot-fix

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

🛡️ CI gate

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

🧪 Tests

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

Verification

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

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

Out of scope (deferred to v1.25)

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

[1.24.0] — 2026-05-14

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

📖 Help-bundle content-depth refresh

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

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

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

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

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

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

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

🧪 Tests

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

Verification

$ npm test                            # 474 / 474

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

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

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

Breaking changes

None.

Out of scope (v1.25+)

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

[1.23.0] — 2026-05-14

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

🚑 CI hot-fix — connection banner recovery

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

    v1.23.0 reshapes the polling loop:

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

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

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

🌐 M-9 — Locale CHANGELOG body translations

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

🖼️ Localized dashboard screenshots in every README

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

🧪 Tests

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

Verification

$ npm test
# 474 / 474

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

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

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

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

Breaking changes

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

Out of scope (v1.24+)

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

[1.22.0] — 2026-05-14

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

🛡️ Security hardening (defense-in-depth)

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

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

🔒 Resilience

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

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

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

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

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

♿ Accessibility

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

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

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

🧹 Quality / portability

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

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

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

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

📚 Docs + system prompts

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

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

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

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

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

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

🧪 Tests

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

Verification

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

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

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

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

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

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

Breaking changes

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

Out of scope (v1.23+)

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

[1.21.0] — 2026-05-14

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

🛡️ Security

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

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

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

🔒 Concurrency

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

♿ Accessibility

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

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

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

🌐 i18n

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

🧪 Tests

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

Verification

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

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

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

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

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

Out of scope (v1.22+)

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

[1.20.0] — 2026-05-13

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

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

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

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

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

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

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

🌍 Non-EN README parity

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

🧹 /api/scan-ru/config alias retired

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

🧪 Tests

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

Verification

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

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

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

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

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

Breaking changes

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

Out of scope (v1.21+)

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

[1.19.0] — 2026-05-13

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

♿ WCAG 1.4.3 contrast pass

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

🧹 Scan unification (finishes v1.18 work)

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

🛠️ HH_USER_AGENT removed from UI

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

🧪 Tests

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

Verification

npm test                              # 427 / 427

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

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

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

Out of scope (v1.20+)

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

[1.18.0] — 2026-05-13

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

🚪 Breaking

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

♿ Accessibility (WCAG 2.2 AA pass)

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

📚 i18n long-tail

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

🛠️ Misc

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

🧪 Tests

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

Verification

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

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

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

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

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

Out of scope (v1.19+)

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

[1.17.0] — 2026-05-13

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

🐛 Fixes

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

✨ Features

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

♿ Accessibility

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

📚 Documentation

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

🛠️ Tooling

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

🧪 Tests

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

Verification

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

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

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

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

Out of scope (v1.18+)

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

[1.16.0] — 2026-05-13

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

✨ Features

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

🛠️ Tooling + CI

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

🎨 UX

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

📚 i18n long-tail

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

🧪 Tests

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

Out of scope (v1.17+)

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

Verification

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

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

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

[1.15.0] — 2026-05-13

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

✨ Features

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

📚 Documentation

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

🧹 Carryover cleanups

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

🧪 Tests

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

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

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

Verification

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

[1.14.0] — 2026-05-13

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

✨ Features

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

📚 Docs

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

🧪 Tests

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

Verification

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

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

Out of scope (deferred follow-up)

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

[1.13.0] — 2026-05-13

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

✨ Features

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

🎨 UX fixes

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

🧪 Tests

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

Out of scope (deferred follow-up work)

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

Docs

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

[1.12.0] — 2026-05-13

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

✨ Features

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

🐛 Bug fixes

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

📝 Branding / docs

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

🧪 Tests

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

📋 Internal

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

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

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

[1.11.1] — 2026-05-13

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

📝 Docs

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

✨ UI

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

📋 QA

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

Out of scope (deferred)

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

Test posture

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

Docs

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

[1.11.0] — 2026-05-13

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

📝 Docs

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

✨ UI improvements

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

🔧 Internal

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

Audit (gaps deferred, NOT in this release)

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

Test posture

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

Docs

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

[1.10.3] — 2026-05-12

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

✨ Features

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

🐛 Bug fixes

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

🧪 Tests

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

📝 Docs

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

Out of scope (deferred to v1.11.0)

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

[1.10.2] — 2026-05-12

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

🐛 Bug fixes

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

🧪 Tests

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

📝 Docs

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

Out of scope (deferred to future GSD phases)

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


[1.10.1] — 2026-05-09

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

🛡️ Security

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

🐛 Bug fixes

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

✨ Features

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

🧪 Tests

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

📝 Docs

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

Out of scope (deferred to future GSD phases)

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


[1.10.0] — 2026-05-08

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

✨ Features

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

🧪 Tests

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

📚 Docs

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

🔧 Internal

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

[1.9.1] — 2026-05-08

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

🐛 Bug fixes

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

🧪 Playwright smoke — expanded coverage

5 → 12 tests. New cases:

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

📦 Behavior changes (no API contract changes)

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

🧪 Tests

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

[1.9.0] — 2026-05-08

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

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

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

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

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

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

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

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

Fallback chain is now: Anthropic → Gemini → manual.

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

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

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

Result: structural parity is now CI-enforced.

🤖 P-9 — Playwright browser smoke in CI matrix

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

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

🌍 P-10 — Multi-CLI compatibility

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

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

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

🧪 Tests

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

🔧 Files touched

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

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

📦 New REST endpoints

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

🤖 New CLI entry points

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

[1.8.0] — 2026-05-08

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

🔥 High-severity fixes

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

🛡️ Medium-severity fixes

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

🧹 Low-severity polish

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

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

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

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

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

🔍 Parent career-ops v1.7.0 audit

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

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

🤖 SDD / GSD bootstrap

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

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

🔒 Security & repo hygiene

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

🧪 Tests

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

📝 New / updated package.json scripts

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

🔧 Files touched

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

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

[1.7.2] — 2026-05-04

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

✨ New features

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

🐛 UX + UI fixes

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

📦 New REST endpoints

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

🌐 i18n

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

🧪 Tests

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

📊 Stats

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

[1.7.1] — 2026-05-04

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

✨ Pipeline preview pane

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

✨ Anthropic API integration — “Run live” everywhere

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

🐛 CI / pipeline fixes

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

📦 New REST endpoint

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

📊 Stats after this batch

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

[1.7.0] — 2026-05-03

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

🔒 Security

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

✨ New features

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

🐛 Bug fixes

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

🌐 i18n

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

⚙️ DevOps

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

📦 New REST endpoints

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

[1.6.0] — 2026-05-02

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