Changelog
GitHubHver 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
successfactorskasserer 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 ingentry/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 en404(enstartrowuden for området), villeen-scannersætte en live tenant i karantæne som “død” i flere dage. Spejler nuphenom/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 udentabindex/rolle, så tastatur- og skærmlæserbrugere ikke kunne nå eller aktivere dem. De bærer nurole="button",tabindex="0",aria-pressedog 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. Nuscan.trustTip+scan.col.reloc(2 nye nøgler) + genbrug aftrack.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, frawww.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_ADAPTERS68). 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ælderenscompany-funded.mjsreturnerede en fuld liste. (1) Visningen læste resultaterne underres.candidates, men forælderen udsender dem undercompanies(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 tilUI.el('tr', {}, …)som varargs, menUI.el(tag, attrs, children)forventerchildrensom 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 reellecompanies-outputform (den oprindelige fixture spejlede den forkertecandidates-form — hvilket er præcis grunden til, at fejlen slap igennem med grønne tests), plus kildestatiske kanariefugle for, atfunded.jslæserres.companies(aldrigres.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-opscompany-funded.mjsviaGET /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 rutemodulserver/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-LLMweekly-digest.mjsviaGET /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 gyldigeYYYY-MM-DD; et tomt interval er et gyldigtavailable:true-resumé. Tilføjet tilserver/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 suitetests/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 tilpublic/js/lib/scan-results.js(afvikling af file-size-contract-gæld —public/js/views/scan.jsvar vokset til ~1254 LOC). Delsystemet (renderResults,buildChipRow,getRows, række-/facet-bygherrerne, valgmulighedsmalerne ogFALLBACK_SOURCES-registerspejlet) blev flyttet ind i enwindow.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 tilctx.*;scan.jser 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.mjslæser nu registerspejlet frascan-results.js. - Ny regressionsspærring i browseren —
tests/playwright-scan-filters.mjssår en foruddefineretdata/last-scan.jsonog 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.cssopdelt 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 nuapp.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) ogoverlays.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.htmlindlæ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 nyetests/css-modularization.test.mjsfastlå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.
- Snittet er sammenhængende og i rækkefølge, så cascaden er byte-for-byte identisk med filen før opdelingen;
[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 nuapi:/a16z-speedrun-talent:-overskrivningen vedbuildEndpoint(HTTPS + eksakt hostspeedrun-talent-network.com) og falder tilbage til det kanoniske feed, hvis det fejler — paritet medcryptocurrencyjobs-adapteren, så en off-host-værdi aldrig når frem til fetch-slottet (tidligere afhang det udelukkende af fetch-tids-vagtenassertSpeedrunUrl). Det eksakte host-tjek er nu et enkelt eksporteretSPEEDRUN_TALENT_HOST_RE, som deles af vagten og adapteren.cryptocurrencyjobs-parseren —cleanUrlbruger nu den samme eksakte host-vagt somassertCryptocurrencyJobsUrlog adapter-overskrivningen (varendsWith, som accepterede subdomæner). Parseren er aldrig mere tilladende end SSRF-vagten: etsub.cryptocurrencyjobs.co-linkelement kasseres.- +2 tests → suite 2135.
[1.131.0] — 2026-07-31
Tilføjet
#/trackerCRM-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/stagesreturnerer den kanoniske tragt (etiketter i rækkefølge) + et alias-foldningskort, hentet fraserver/lib/states.mjs(templates/states.yml, med den indbyggede fallback) — så klienten aldrig hardkoder statuswhitelisten. Det legacy parameterløseGET /api/tracker-svar er uændret (kun{ rows }). - Nyt rent, enhedstestet klientbibliotek
public/js/lib/tracker-stages.jsgrupperer rækker efter serverens stadier og tolererer forvildet markdown-fed skrift og lokaliserede aliasser (fxaplicado→Applied). 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.
- Ny read-only rute
[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 tilspeedrun-talent-network.com, kun HTTPS, 0-indekseret paginering med et sideloft, per-virksomhedsq/config-threading, fail-soft. - Cryptocurrency Jobs (
cryptocurrencyjobs) — Web3-jobboardetcryptocurrencyjobs.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).
- a16z Speedrun (
Rettet
echojobs— hybride roller forbliver adskillelige fra remote (spejler forælderens #2258). En case-insensitivehybrid-markør giver nu"<By> · Hybrid"(eller et bartHybrid, når der ikke er nogen by) ogworkplaceType: 'Hybrid', i stedet for at blive kollapset tilRemote.radancy— legacy TalentBrew-markup + JSON-resultatfragment-transport (spejler forælderens a3e6df9), gatet af en injicerbaropts.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ælderensscan.mjs— Interamt.de-Playwright-scanneren, iCIMS reverse-ATS-fuldsweep, land-berettigelses-remote-filteret, DNS-lookup-pacing, StepStonesrltr-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 tilscan.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 Manager→senior, førlead); fallback istates.mjsfastlå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.warnved 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 itests/states.test.mjs. +4 tests → 2073.
[1.129.0] — 2026-07-29
Tilføjet
- Niveaufacet + alderskolonne på
#/scan—job-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, fraJobFacets.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.mjslæsertemplates/states.ymllive 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 viadomainFromName()(~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årdocs-fabbliver 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 frajibeapply). Desuden vender Cursor tilbage til CLI-rosteret (parent #2115):cli-detectregistrerer nucursor(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årlocation.namekun 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 stadigInside 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ælderensdocs/SUPPORTED_CLIS.md:server/lib/routes/cli-detect.mjsfår Grok Build CLI (grok) og Kimi CLI (kimi), og Antigravity prober nu sin kanoniske binæragyfø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) —
sharp0.34.5→0.35.3,svgo4.0.1→4.0.2,fast-uri(dev) 3.1.3→3.1.4 isite/; 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 iset-status(#2108) er kun CLI (i web-ui vælges tracker-rækker eksplicit i UI’et, og ingen rute kalderset-status.mjs), Risk Summary i lokaliserede tilstande (#2109) rørermodes/<lang>/-filer, som web-ui aldrig læser (kunmodes/*.mdpå øverste niveau), manifest-verifikationen iupdate-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.0 —parentVersionuændret.
[1.125.3] — 2026-07-23
Rettet
- LLM-prompter på dansk og hindi svarede på engelsk (brugerrapporteret) —
LOCALE_NAMESog alle femSCAFFOLD_STRINGS-blokke iserver/lib/prompts.mjsblev aldrig udvidet meddaoghi, såresolveLocale()faldt tilbage tilen, 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 itests/locale-scaffold.test.mjsgennemgå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 springerprompts.mjsover, 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/deepbad 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.buildDeepPromptogbundleProjectContexttager nu etheadless-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 itests/critical-fixes.test.mjs.
Ændret
- Gemini-standarder løftet forbi den udfasede
gemini-2.0-flash(#144, bidrag fra @Alien10140) — konfigurations-dropdownen, server-fallbacken igemini.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-gatetests/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ælpefunktionresolveTenantBase+ 1 overført testblok itests/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.mjs→facts.sources), så den aldrig kan afvige fra appen; et kurateret linkkort iSources.astroer sikret af den nye testtests/site-sources.test.mjs. Header-navigationen fik et Sources-anker; 4 nye site-i18n-nøgler ×17. Rettede desuden landingens JSON-LDinLanguage-liste, der stadig mangledehi.
[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 suitertests/sources-{wttj,agenticjobs,jobvite,gem,alibaba}.test.mjs.
Rettet
- Arbeitsagentur: landsdækkende remote kun når
homeofficetyperVOLLSTAENDIG(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 gennemwithFileLocksiden 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 virksomhedscareers_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 suitetests/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 medtrust-validatorog 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 nyCHANGELOG.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, sitetscheck-i18nog 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.mjssynkroniserer 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øglefooter.manifestoi 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 frascan --jsonforbliver 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å
#/scanhar indhentet registreringen — den statiskeFALLBACK_SOURCES-liste bag Source-dropdownen (bruges kun nårGET /api/scan/sourceser 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 eksplicitprovider:, 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 konstantBROWSER_LIKE_USER_AGENTihttp-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ærthttps://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 tilhttps://hh.ru/vacancy/<id>. Verificeret live: 17 rigtige jobs parses nu fra ensochi.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.mdbragt 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, ogsync-assetsskriver aldrig uden forsite/— 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ælderensstates.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ælderensstats.mjs(samlet tracker-oversigt, akkumulerede tragtrater, scannertal, portaldækning) plus lønobservationer frasalary-gap.mjs(ønsket vs annonceret vs faktisk, pr. ansøgning). Nye ruterGET /api/stats/lifetimeogGET /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 på #/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.jsindlæst fraindex.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.jsmonteret globalt fraindex.html; CSP-sikker; tema-bevidste + RTL-spejlede stilarter iapp.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). På #/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 itests/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.jsescapererr.messageviaUI.escapeHtmlførinnerHTML;server/lib/routes/content.mjsogserver/lib/routes/config.mjsvæ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.mjs—GET /api/usage(skrivebeskyttede opsummeringer) +server/lib/llm-usage.mjs(recordUsagenormaliserer Anthropic/OpenAI/Gemini-forbrugsformerne og tilføjer best-effort;readUsage/aggregateopsummerer pr. vindue 24t/7d/30d/alt × udbyder) +server/lib/llm-pricing.mjs(en redigerbar pris-tabel pr. udbyder$/1Mtokens — 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-indstillinger — Vis 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.mjs—GET /api/logo?domain=. Det validerer domænet (ingen skema/sti/loopback), henter/favicon.icovia den SSRF-sikresafeGet(en nybinary-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-sikkerimg.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.mjs—GET /api/cli-detect. Registreringen slår en binærs sti op fra en fast 7-punkts allowlist viaprocess.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.mjs—POST /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 generiskTAILOR_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 deltereport-export.js-bjælke. - Tests: +3 i
tests/cv-studio-routes.test.mjs. 10 nye i18n-nøgler ×16 (cvs.tailor*). Generisk referencedocs/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. Nyserver/lib/docx.mjsudsender 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 forbliverexpress+js-yaml). Ny rutePOST /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 deltepublic/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 nunulli 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 itests/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.mjsspejler forælderens zero-token Exalead-“card search”-udbyder (det offentlige feed bag3ds.com/careers/jobs). Det er ét enkelt globalt endpoint, så det vælges via udbyder (provider: dassault) eller autodetekteres fra en3ds.com-vært, SSRF-værtsfastgjort tilwww.3ds.commedredirect:'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/sourcesEN-sæt-assertions hævet 40 → 41. Suitetests/sources-dassault.test.mjs(10 tilfælde). - Overførte robusthedsrettelser fra forælderen. Avature-parseren tolererer nu to live tenant-markup-varianter (
article--resultmed et positionsindeks-suffiks + et klasseløst JobDetail-titelanker, #1541); Get on Board værner mod en0/negativpublished_at(ikke flere fejlagtige 1970-datoer); SuccessFactors begrænser den sidste side, så den ikke kan overskrideMAX_JOBS(#1528). - Serveraudit-rettelser.
safe-fetchhæ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). SSEstream.*-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), russiskeval.jdLbl(“Job Description”), italienskdash.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/generatebygger 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.jstil 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(skriverconfig/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/marketbygger 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.mdog overskrives aldrig af opdateringer. - Foreslå ud fra dine data —
POST /api/memory/suggestudvinder 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.mdogwriting-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-bevidst —
interview-prep/story-bank.mdindlejres i prompten (samme tillidsniveau somcv.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) viaPUT /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å
#/scanviser nu en◎ Nfit-score (klientside, viawindow.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 ogreloc-badget flyder nu gennemt()— 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')icreateApp()— serveren annoncerer ikke længere Express. (Resten af den epic var allerede leveret:parentVersionfjerner 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.jobsoffentlige søge-JSON, host-registreret ellerprovider: amazon, offset-pagineret.server/lib/sources/amazon.mjs. - Avature (
avature) — pr.-lejer*.avature.netATS, HTML-parset, host-registreret ellerprovider: 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 genbrugerwindow.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 idata/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 sprog — de 🇩🇪, 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 genkenderde/it/tr(public/js/lib/i18n.js). - Indbygget hjælpeguide —
docs/help/{de,it,tr}.mdoversat (fuld 19 H2 / 75 H3-struktur), serveret afGET /api/help/:lang. - Dokumentation —
README.{de,it,tr}.mdogCHANGELOG.{de,it,tr}.mdtilføjet; CHANGELOG-locale-paritetsporten dækker nu 15 ikke-EN-locales. - Prompt-stillads —
server/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.ymlunderre_apply_windows:(last_apply_date,same_role_days,applied_to: [roles], valgfricross_role_bucket); virksomhedsmatchning er tegnsætningsuafhængig + ordgrænsebaseret (server/lib/cooldown.mjs). Slået fra, når nøglen mangler; scanloggen viserCooldown skipped: N. - Kompensation i pipeline.md (#1017): scannede opslag gemmer nu deres løn som en valgfri trailingkolonne (
url | <salary>) idata/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ølgertests/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 på #/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 fuldeapi: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 enfetchText-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 underhighfår et sprogneutralt ⚠-badge i#/scan; intet kasseres nogensinde.- Arbeitsagentur
remoteMatch+remoteMaxPages— konfigurationsstyret registrering af fjernarbejde:title(regex),filter(server-sidehomeoffice=nv_true+ paginering) elleroff.
Ændret
- Intet loft på scanresultater. Visningsloftet
MAX_STORED_RESULTS(2000) er fjernet — hvert matchet opslag gemmes, og tabellen på#/scanpagerer 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-validatorog en omskrevetscan-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 tillocation_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.mdfår et “Aggregator boards”-afsnit klar til kopiering med korrekteprovider:/<provider>:-konfigurationsblokke for alle syv;docs/PROJECT.mdopdateret til 19 adapters;docs/sdd/CONVENTIONS.mddokumenterer skellet mellem de to registre (sources/registry.mjstil rullemenuen kontraportals/registry.mjstil hentning), detprovider:-baserede aggregatorvalg, der videreføres somopts.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
AbortSignaludløses, via en nydelay(ms, signal)-hjælper iserver/lib/http-json.mjs, så en frakoblet klient ikke kan holde en paginerende scanning åben en ekstra pause. - Beskrivende fejl ved ikke-JSON.
fetchJsonombryder nu et ikke-JSON2xx-svar (f.eks. en HTML-vedligeholdelsesside leveret med status 200) somnon-JSON 2xx response from <url>i stedet for at vise en barSyntaxError, så scannerens fejllog pr. kilde navngiver det fejlbehæftede endpoint. - Stærkere normalisering ved scan-skrivning.
normalizeScanScalarsammenfolder 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 iscan-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 medprovider: 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). Valgfriportals.yml-blok (nøgleordslisterpositive/negative), der gater et opslag på dets beskrivelses-/uddragstekst — afspejlerlocation_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.tsvogdata/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/addressCountryind i lokationsstrengen (dedupliceret), så en EU-berettiget rolle, hvis primære mærkat f.eks. lyder “Canada”, dukker op forlocation_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-fataltwarnings-array; Gemini-evalueringsstien arver allerede beskyttelsen fra forælderensgemini-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/inline → generate-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 locale — public/js/lib/locales/i18n-dict.{en,es,pt-BR,ko,ja,ru,zh-CN,zh-TW}.js (each assigns window.__I18N_DICT_<LANG> = { key: string }) plus a shared i18n-dict.aliases.js — so a translator edits a single language in isolation (the i18next / OpenWA layout). i18n-dict.js is now a small assembler that merges the per-locale tables back into the exact same window.__I18N_DICT shape, so t(), every view, and every call-site are byte-for-byte unchanged. Everything loads synchronously via <script src> — no build step, no runtime fetch (both hard rules preserved). A captured snapshot proves the migration is lossless (assembled dict ≡ pre-split dict, 678 keys). Tooling + tests made split-aware via a shared tests/helpers/i18n-vm.mjs: tools/i18n-audit.mjs, the CI inline coverage check (which had silently become a no-op against the post-v1.23 split — it now actually loads the dictionary), and ~25 dictionary tests. New tests/i18n-locale-files.test.mjs (snapshot equality · per-locale key parity · alias integrity · index.html load order) and tests/playwright-locale-sweep.mjs (every page × 8 locales renders + localizes in real Chromium). 994 → 1000 unit · 62 → 70 Playwright. No user-facing behaviour change. (I18N-SPLIT)
[1.59.13] — 2026-05-21
fix(i18n): collapse TRUE duplicate keys via @alias + final personal-data purge (pre-fr, follow-up to v1.59.12). Two parts. (1) Personal data: swept the WHOLE repo, not just the dict — the maintainer’s real name was used as incidental test-fixture data (tests/health-doctor-unify.test.mjs, tests/llm-output.test.mjs) and in archived QA reports; all replaced with neutral examples (Jane Doe). LICENSE copyright + package.json author switched from the real legal name to the public Fighter90 handle. Repo now greps clean for the real name/email everywhere. (2) Duplicate keys (I18N-CL3, properly): added an @alias mechanism to i18n.t() — a key { '@alias': 'x.y' } resolves to a canonical key. Collapsed the 10 keys that are byte-identical across all 8 locales (nav.help/dash.quick.helpCta → help.title; nav.cv → cv.title; nav.health/dash.quick.healthCta → health.title; nav.reports/dash.reports/dash.quick.reportsCta → rep.title; nav.apply → apply.title; nav.interviewPrep → interviewPrep.title). The contributor’s own nav.config/config.title example is deliberately NOT aliased — they diverge in Spanish (short sidebar Configuración vs full title Configuración de la aplicación); aliasing would change a rendered label. Coverage test, CI inline check, and tools/i18n-audit.mjs are all alias-aware (skip alias keys for parity, enforce every target exists, resolve before dup-counting). New tests/i18n-alias.test.mjs (3 cases) locks integrity + behaviour. 991 → 994 unit. Reduces the French translator’s surface — true dupes are now translated once. (I18N-CL3, personal-data-sweep)
[1.59.12] — 2026-05-21
fix(i18n): i18n-dict.js cleanup — pre-fr ship (I18N-CL1, I18N-CL2, I18N-CL4). External French-locale contributor audit. I18N-CL1 (privacy): training.coursePh carried a vendor-specific cert name across all 8 locales — replaced with a neutral generic placeholder (Cloud architecture certification + native equivalents). I18N-CL2 (hygiene): the followup.lastPh dict fallback was a frozen 2026-04-21 literal — replaced with a localized format hint (YYYY-MM-DD / AAAA-MM-DD / ГГГГ-ММ-ДД); the live dynamic today − 14d placeholder (U-3, v1.58.23) in mode-page.js was already working and is now lock-tested. I18N-CL4 (audit): new tools/i18n-audit.mjs + npm run audit:i18n, wired into CI — hard-fails on personal data, locale-parity gaps, empty values, and bare-calendar-date placeholders. I18N-CL3 (decision): the ~50 duplicate-value key groups (e.g. nav.scan / scan.btnRun / scan.col.company) are intentional — distinct UI roles that non-English locales translate differently; deduping them would remove i18n flexibility, so they are reported as informational warnings, not failures (documented in the dict header). 3 new tests (988 → 991 unit). Unblocks the upcoming French locale PR. (I18N-CL1, I18N-CL2, I18N-CL4)
[1.59.11] — 2026-05-21
fix(test): v1.59.11 — e2e-comprehensive 12-case CI failure root-caused and closed. Twelve cases (Pipeline · Activity · Health · 7 Mode pages · 404 · Profile) had been failing on every CI run going back to v1.58.x because page.goto(baseUrl + '/#/X') is a no-op for hash-only URL changes in Playwright. Once the CV-save step set the hash to #/cv, every subsequent goto to a hash route silently kept the page on #/cv — Activity/Health/Mode/Profile selectors never matched, and the 4 ‘pass’ results in between were vacuous (their assertions found CV’s elements). Fix in tests/e2e-comprehensive.mjs: new goRoute(hash) helper that bounces through about:blank before each goto, forcing a real navigation and SPA re-bootstrap. All 17 page.goto(${baseUrl}/#/…) call sites replaced. Diagnostic instrumentation (E2E_DUMP_ON_FAIL=1 env var) added for future investigations. Result: 23 / 23 cases green locally · 988 / 988 unit · 20 / 20 smoke e2e. (e2e-harness-r1)
[1.59.10] — 2026-05-21
fix(api): NEW-F1-sub-r1 (v1.59.10) — un-encoded ../ traversal guard hoisted above all /api route registrations. v1.59.8 added a req.originalUrl.includes('..') middleware, but it was placed AFTER app.all('/api/*', JSON-404) AND AFTER all route handlers — by which time Express had already normalised the URL (collapsing .. segments). /api/jds/../../../etc/passwd was rewritten to /etc/passwd and fell through to the SPA static handler (200 OK on index.html). v1.59.10 hoists the guard to the TOP of server/index.mjs createApp() (above every register*Routes(app) call) so it inspects the verbatim request URL before any normalisation. Pattern: /^\/api(\/|$)/.test(req.originalUrl) && /\.\.\//.test(req.originalUrl). New tests/api-path-traversal.test.mjs — 6 cases driving the real http module (Node’s fetch normalises .. client-side, so we drop to http.request with a verbatim path). Unknown /api/* paths still return {error: 'unknown api'} from the existing app.all fallback. 982 → 988 unit. (NEW-F1-sub-r1)
[1.59.9] — 2026-05-21
fix(ux): UX-A5-r4 (v1.59.9) — Help TOC scroll-spy debug marker + behavioural lock-test. Sixth-cycle closure: previous five attempts (v1.58.45 / v1.58.52 / v1.59.0 / v1.59.3 / v1.59.8) all shipped with passing static tests but the bug stayed open because the tests asserted source-shape, not behaviour. v1.59.9 fixes the gap: (1) <body data-toc-spy="active"> debug marker — single selector any tester can use to answer “is the spy alive?” without needing to scroll; (2) synchronous initial paint at mount tail covers the router-pre-paints-view case; (3) double-rAF re-compute covers the route-handler-returns-before-router-appends case; (4) resize listener subscribed so viewport-flip mid-scroll re-paints; (5) cleanup removes BOTH listeners and the marker on hashchange. public/js/views/help.js algorithm: linear scan with else break; short-circuit (O(active-index) per scroll event). New tests/help-toc-spy-behavior.test.mjs runs the algorithm against 6 synthetic-geometry scenarios + 1 algorithm-parity check against help.js source — the test fails if the algorithm regresses, before any browser run. 973 → 982 unit. (UX-A5-r4)
[1.59.8] — 2026-05-21
fix(ux+api): v1.59.8 — UX-A5-r3 + NEW-F1-sub (HIGH + LOW bundled per FIX-PROMPT-FINAL-CONSOLIDATED). Doctrine-exception release: the 2026-05-20 FINAL REGRESSION-v1.59.7 report explicitly recommended bundling these two. UX-A5-r3 (HIGH) — after 4 ship attempts with IntersectionObserver all failed live verification, public/js/views/help.js replaces the IO entirely with a plain scroll listener. Root cause: with rootMargin -20%/-55% the trigger band ended at 45% of viewport, but scrollIntoView({block:‘center’}) lands the target at 50% — JUST below the band end. The new scroll listener probes absolute heading positions every rAF, computes the heading whose top is at-or-above 30% of viewport, applies .toc-current — no band, no race, no mount-order gotcha. NEW-F1-sub (LOW) — server/index.mjs adds a late middleware that inspects req.originalUrl (not normalised req.url) and bounces any /api-prefixed request whose raw URL contains .. as 404 JSON {error: 'invalid path'}. New UX-A5-r3 lock-test + NEW-F1-sub static + behavioural guards in tests/. 971 → 973 unit. (UX-A5-r3 · NEW-F1-sub)
[1.59.7] — 2026-05-20
fix(api): NEW-D3-cache (v1.59.7) — GET /api/cv sends Cache-Control: no-store. Matches the SPA-shell policy (W-001 / v1.54.7). cv.md is the user-edited primary artifact; a stale browser cache or intermediary proxy could surface yesterday’s text and trick the editor into saving over the live version. No ETag dance — the file is small and the GET is rare. New test in tests/api-404-json.test.mjs (the suite is the in-process server harness; named for its first test). 970 → 971 unit. (NEW-D3-cache)
[1.59.6] — 2026-05-20
feat(a11y): NEW-D2-motion (v1.59.6) — honour prefers-reduced-motion: reduce. New @media (prefers-reduced-motion: reduce) block in public/css/app.css neutralizes animation-duration + transition-duration to 0.01 ms and forces scroll-behavior: auto (so the Help TOC click-to-scroll lands instantly). WCAG 2.3.3 (AAA) — users with vestibular disorders, motion sensitivity, or simply OS-level reduced-motion preference now get an animation-free UI. Static guard in tests/qa-report-fixes.test.mjs. 969 → 970 unit. (NEW-D2-motion)
[1.59.5] — 2026-05-20
fix(api): NEW-F1 (v1.59.5) — unknown /api/* returns JSON 404 on every verb. Pre-fix app.get("/api/*", …) was GET-only; POST / PUT / DELETE to an unknown api path fell through to the SPA catch-all and returned an HTML 404, breaking SPA clients that do try { res.json() } catch {}. Changed to app.all("/api/*", …) in server/index.mjs. New test suite tests/api-404-json.test.mjs probes GET / POST / PUT / DELETE on /api/no-such-endpoint plus an unknown :name under a real handler — 5 new tests, all green. 964 → 969 unit. (NEW-F1)
[1.59.4] — 2026-05-20
fix(ui): NEW-OR1 (v1.59.4) — #/config API-keys summary chip is race-safe. User-reported transient Keys: 0 / 5 flash during Save flows. The previous refreshApiSummary() cleared <span> children before awaiting the network fetch; a concurrent providers-changed event could observe the empty state. New implementation in public/js/views/config.js:\n\n1. Build new <span> nodes before any DOM mutation.\n2. Atomic swap via apiSummary.replaceChildren(newActive, newCount) — chip never blanks mid-update.\n3. inFlight token counter drops stale resolves when a newer refresh starts.\n4. lastGoodSt cache preserves the last known state when fetch returns null (network blip, server reload), so the chip never collapses to 0 / 5 on a transient empty response.\n\nLock-test in tests/qa-report-fixes.test.mjs asserts all four invariants. 963 → 964 unit. (NEW-OR1)
[1.59.3] — 2026-05-20
fix(ux): UX-A5-r2 (v1.59.3) — Help TOC scroll-spy harden (third-pass fix). Two real issues persisted after v1.58.52: (1) rootMargin: "-30% 0% -60% 0%" left only a 10% visible band so fast scroll could skip the trigger zone entirely and no IntersectionObserver entry ever fired; (2) no initial-state computation, so a freshly-loaded #/help with zero scroll showed zero highlights even though section 1 was visibly active. public/js/views/help.js: widened rootMargin to -20% 0% -55% 0% (25% band), made root: null explicit, extracted applyCurrent(id) so the same code path serves both observer callbacks and the initial-state pass, and on mount picks the heading closest to 20% from top of viewport. 962 → 963 unit. (UX-A5-r2)
[1.59.2] — 2026-05-20
fix(ui): v1.59.2 — UX-A9 / UX-A3 chip count + overlap (post-v1.59.1 hotfix). Three real bugs in the v1.58.62 / v1.58.55 chips, user-reported on the staging surface:\n\n1. Always Keys: 0 / 5. /api/status/providers returns keysConfigured as an array of provider names; pre-fix typeof === number was always false. Now uses Array.isArray(st.keysConfigured) ? st.keysConfigured.length : 0.\n2. Lowercase provider name. Server returns anthropic (not claude — LLM_PROVIDER env value vs resolved provider name); the NAME map was keyed by claude: so it always fell through to the lowercase fallback. Re-keyed {anthropic, gemini, openai, qwen, openrouter} in both public/js/views/config.js and public/js/views/dashboard.js.\n3. Sticky chip overlapped other elements. .api-keys__summary had position: sticky + z-index: 5 and created a stacking context that floated above the tablist + page header on scroll. Dropped sticky — the chip is at the top of a short panel already. Lock-test updated to forbid the sticky position. (No version bump alone — counts on existing UX-A3 / UX-A9 lock-tests.) (post-cycle hotfix)
[1.59.1] — 2026-05-20
fix(test): v1.59.1 — NEW-D1 i18n-no-latin-leaks guard accepts UX-A11 polished ES copy. During final verification of the v1.59.0 ship I caught a regression: the v1.58.37 NEW-D1 static guard asserted pipe.title[es] must match /vacant|vaca/i (locking the old Pipeline de vacantes). UX-A11 (v1.58.64) refined that to Pipeline de candidaturas (candidate-side perspective). Fix: relax the regex to /vacant|vaca|candidatur/i so either contextualizing noun passes. No production-code change. 962 unit tests, 0 fail.
[1.59.0] — 2026-05-20
feat(ui): UX-A14 (v1.59.0) — Mobile (≤ 420 px) media-query pass. Closes the long-standing mobile audit. Five focused fixes inside a single @media (max-width: 420px) block in public/css/app.css:\n\n1. .card-row (4 dashboard metric cards) collapses to 1-up below 420 px (was a 900+ px horizontal scroll).\n2. .dash-hero-cta stacks vertically with full-width buttons (was side-by-side wrap-mid-label on iPhone SE).\n3. .page-header stacks H1+subtitle above the action buttons row (was right-side overflow on 360 px).\n4. .qa-grid minmax floor drops from 220 px to 160 px so tiles fit two-up on 360–390 px viewports.\n5. .api-keys__summary chips tighten horizontal padding for breathing room.\n\nRegression-lock test in tests/qa-report-fixes.test.mjs. 961 → 962 unit. (UX-A14)
[1.58.65] — 2026-05-20
test(ui): UX-A2 (v1.58.65) — lock-test on Modes structured field-form. The 5-field structured form for #/config → Modes was already shipped in v1.54.3 (canonical Target Roles / Adaptive Framing / Exit Narrative / Comp Targets / Location Policy + repeatable line-inputs for list-kind fields + × remove + add-row affordance + tagged {mode: sections|markdown} collect()). The UX-A2 audit assumed it was missing because all 5 sections initially appeared as <textarea> (an artifact of the field-form scaffolding pass before file load). This regression-lock test in tests/qa-report-fixes.test.mjs prevents a future PR from collapsing the field-form back to raw markdown. No code change. 960 → 961 unit. (UX-A2)
[1.58.64] — 2026-05-20
fix(i18n): UX-A11 (v1.58.64) — es / pt-BR copy polish: English loanwords replaced. eval.subtitle (es) now uses ajuste del CV / Puntaje / cabecera / informe; pt-BR uses aderência do CV / Pontuação / cabeçalho / relatório. pipe.title (es) refined to Pipeline de candidaturas (candidate-side perspective). pt-BR vagas retained. 959 → 960 unit. (UX-A11)
[1.58.63] — 2026-05-20
fix(ui): UX-A15 (v1.58.63) — Dashboard Pipeline tile gets visual primary weight. The Quick-actions grid previously gave every tile equal weight; the highest-frequency action (Pipeline) didn’t draw the eye. The qa() helper in public/js/views/dashboard.js now accepts a 7th primary flag; the Pipeline tile is the only one passing true so it gets a subtle accent border + larger icon + bolder label via .qa-tile--primary (public/css/app.css). Tile order, copy, click target, and accessibility name are unchanged. Lock-test in tests/qa-report-fixes.test.mjs. 958 → 959 unit. (UX-A15)
[1.58.62] — 2026-05-20
feat(ui): UX-A9 (v1.58.62) — #/config → API keys panel sticky summary chip. Before this release, with 5 provider key sections stacked vertically on the API-keys tab, the user had to scroll to know which provider the OR-fallback resolves to or how many keys were configured. public/js/views/config.js now renders a position: sticky .api-keys__summary at the top of the apiPanel showing Active: <provider> + Keys: <count>/5. Reuses /api/status/providers (no new API surface). Refreshes on providers-changed (after Save). Two new i18n keys (config.activeProvider, config.keysConfiguredPrefix) × 8 locales; one CSS rule (.api-keys__summary); static guard in tests/qa-report-fixes.test.mjs. 957 → 958 unit. (UX-A9)
[1.58.61] — 2026-05-20
docs(readme): UX-A8 (v1.58.61) — first-run cleanup section added across all 8 READMEs. Fresh clones of career-ops include two QA fixture URLs (example.com/qa-fixture-*) in data/pipeline.md so the test suite can run hermetically; the README never documented this so first-time users mistook them for real jobs. New ## First run — clean state section (mirrored in es / pt-BR / ko / ja / ru / zh-CN / zh-TW) instructs make clean-test-fixtures && npm start before the first scan. Lock-test in tests/qa-report-fixes.test.mjs verifies all 8 READMEs reference make clean-test-fixtures and qa-fixture-*. 956 → 957 unit. (UX-A8)
[1.58.60] — 2026-05-20
feat(ui): UX-A12 (v1.58.60) — Notifications drawer Clear all + per-entry dismiss. The v1.58.34 notifications journal capped at 50 entries but offered no manual purge. New UI.clearToastHistory() and UI.dismissToastHistory(ts) in public/js/api.js mutate toastHistory in place and notify subscribers with sentinel events ({cleared: true} / {dismissed: ts}); the drawer subscriber in public/js/app.js detects them, re-renders, and — critically — does NOT bump the unread counter on purges. Drawer head now carries a Clear all button (auto-hidden when the journal is empty); every .notif-item carries a × dismiss button. Three new i18n keys (notif.clearAll, notif.clearAllAria, notif.dismiss) × 8 locales; CSS for both controls; lock-test in tests/qa-report-fixes.test.mjs. 955 → 956 unit. (UX-A12)
[1.58.59] — 2026-05-20
feat(ui): UX-A13 (v1.58.59) — actionable “Fix →” CTA on failing #/health rows. Pre-fix the 21 health rows showed status only; a user with Profile customized: FAIL had to guess where to go. public/js/views/health.js now renders a small ghost-button Fix → next to the badge on failing/optional rows. Mapped targets: Profile customized → #/config?tab=profile, cv.md non-empty → #/cv, portals.yml present → #/config?tab=portals, data/applications.md → #/tracker. Any *_API_KEY or LLM_PROVIDER* row routes to #/config?tab=api-keys via regex fallback. Unmapped failures stay action-less (no dead-end CTAs). Two new i18n keys (health.fix, health.fixAria) × 8 locales. 954 → 955 unit. (UX-A13)
[1.58.58] — 2026-05-20
fix(ux): UX-A10 (v1.58.58) — guard #/cv against leaving with unsaved buffer. Pre-fix, navigating away from #/cv with unsaved edits silently dropped the buffer. public/js/views/cv.js now registers a beforeunload listener (browser-close confirm — generic dialog per modern browser policy) and a hashchange listener (SPA-internal nav prompts via localized window.confirm() and rewinds the hash if the user cancels). cvDirty lives in the save-button IIFE closure; cleanup self-detaches when the hash leaves #/cv (M-1 discipline). One new i18n key (cv.unsavedConfirm) × 8 locales. 953 → 954 unit. (UX-A10)
[1.58.57] — 2026-05-20
test(ui): UX-A7 (v1.58.57) — lock-test on the cost-line auto-refresh contract. v1.58.41 (UX-D-I) wired UI.providerCostHint to refresh when the user changes LLM_PROVIDER mid-session, but the contract had no static guard — any of the three pieces (config.js dispatch, api.js subscribe, advisor-view call site) could regress silently and the cost line would lie. New regression test in tests/qa-report-fixes.test.mjs locks all three: (1) config.js Save handler dispatches providers-changed, (2) UI.providerCostHint subscribes via document.addEventListener, (3) all 4 advisor views (#/deep, #/evaluate, #/auto, #/<mode>) call UI.providerCostHint(t). 952 → 953 unit. (UX-A7)
[1.58.56] — 2026-05-20
fix(a11y): UX-A4 (v1.58.56) — .lang-btn meets WCAG 2.5.8 minimum touch-target. Pre-fix the language buttons in the sidebar footer measured 23–25 px tall × 47–72 px wide (below the 24×24 WCAG 2.5.8 / WCAG 2.2 AA floor). public/css/app.css now declares min-height: 28px + min-width: 28px + padding: 6px 10px on .lang-btn — every locale label clears the floor with comfortable margin. Sidebar grows by 6 px total (negligible). Lock-test in tests/qa-report-fixes.test.mjs. 951 → 952 unit. (UX-A4)
[1.58.55] — 2026-05-20
feat(ui): UX-A3 (v1.58.55) — Dashboard active-provider chip. Above-the-fold chip in public/js/views/dashboard.js hero now surfaces whether the OR-model fell through to a live provider (e.g. ⚡ Live evals: Anthropic claude-sonnet-4-6) or whether the user is in 📋 Manual prompt mode (no API key set). Re-fetches on providers-changed (dispatched by #/config save) and on visibilitychange (cross-tab refocus). Reuses the existing /api/status/providers endpoint (no new API surface). Two i18n keys × 8 locales (dash.provider.live, dash.provider.manual); one CSS rule (.dash-chip--provider). Lifecycle cleanup on hashchange away from #/dashboard so listeners don’t stack across navigation (M-1 discipline). 950 → 951 unit. (UX-A3)
[1.58.54] — 2026-05-20
fix(ux): UX-A1 (v1.58.54) — defensive Deep-brief structure warning. The canonical Deep-research brief promised in career-ops.org/docs has six H2 sections (Company snapshot / Engineering culture / Recent news / Glassdoor / Interview process / Negotiation leverage). When the upstream prompt drifts and the saved brief is meta-narration instead of the final form, public/js/views/deep.js now detects the regression (≥3 of the six H2s missing) and prepends a non-blocking .brief-warning card explaining what the brief should look like and linking to the canonical reference. The root prompt-layer fix lives in the parent project (modes/deep.md, blocked from here); this UI guardrail surfaces the drift instead of silently rendering a degenerate brief. CSS rule added to public/css/app.css; three new i18n keys (deep.briefUnstructured.title / .body / deep.docsLink) cover all 8 locales. New static guard in tests/qa-report-fixes.test.mjs. 949 → 950 unit. (UX-A1)
[1.58.53] — 2026-05-20
fix(ux): UX-A6 (NEW-M4-r1) — every saved-research card flows through a single renderSavedCard() helper. v1.58.51 verification regression observed: one card rendered with no structural children (title+date as a single concatenated text node — software-engineer-generalyesterday) while another rendered with the proper <span> + <time> shape — depending on whether the card was page-load-rendered or runtime-inserted. Fix in public/js/views/deep.js: extracted renderSavedCard(f) that always emits .saved-card__title + .saved-card__date datetime=…. Whatever render path inserts a card (page-load renderArchive, post-Run live, or future code paths) routes through this single helper — the M-4 v1.58.11 flex-gap layout only works with the structural children present. 948 → 949 unit. (UX-A6)
[1.58.52] — 2026-05-20
fix(ux): UX-A5 (NEW-K1) — #/help TOC scroll-spy actually fires now (regression from v1.58.45). v1.58.51 verification regression caught: 18 H2 elements with ids, 18 TOC <a> links, but 0 links ever received .toc-current after scroll. Root cause: v1.58.45 wired the observer inside a setTimeout(0) that fired BEFORE the router appended the view to #content; document.querySelectorAll(".help-article h2[id]") matched nothing. Fix in public/js/views/help.js: new mountTocSpy() observes the synchronously-built headings refs we already hold (no document re-query); deferred via double requestAnimationFrame so it fires after the first paint that includes the mounted view. 947 → 948 unit. (UX-A5)
[1.58.51] — 2026-05-20
chore(docs): v1.58.51 — final housekeeping wrap of the v1.58.37 → v1.58.50 cycle (14 single-fix releases). No code or behavior changes; consolidation of the qa/ tree, doc currency, and the two lessons learned from this cycle. (1) qa/ reorg: all the version-locked source documents — FIX-PROMPT-CONSOLIDATED.md, FIX-PROMPT-FINAL-EXHAUSTIVE.md, FIX-PROMPT-v1.58.37_and_beyond.md, plus 6 root-level session snapshots (MASTER-REGRESSION.md, 2026-05-19-*.md, career-ops_*.md, FIX-PROMPT-v1.58.3.md, FIX-PROMPT-v1.58.4_and_beyond.md) — moved into qa/archive/v158-cycle/. qa/ root now contains exactly the 6 canonical perennial documents: README.md, REGRESSION-FINAL.md, UX-AUDIT-PROMPT.md, FUNCTIONALITY-CHECK.md, DESIGNER-EXPORT-PROMPT.md, G-005-closure-kit.md. (2) qa/REGRESSION-FINAL.md §13 documents every v1.58.37 → v1.58.50 invariant with its lock-test, organized by class (i18n / a11y / UX feedback / orientation / content fidelity / labels / tooling / docs) + 2 new “lessons captured” entries (markdown-bolded regex pitfall; Publish-runs-tests-against-tagged-ref pitfall). (3) qa/UX-AUDIT-PROMPT.md baseline table extended with the 14 new closed-in rows. (4) CLAUDE.md currency lifted (1.58.51, baseline 947/947). (5) .claude/PROJECT-CONTEXT.md repo-state + test baseline lifted. (6) docs/ROADMAP.md chain end now at v1.58.51 / Net 947. (7) README ×8 badges and content sync. Test baseline at v1.58.51: 947 unit (unchanged structurally) · 62 Playwright · 20 smoke · 23 comprehensive E2E. Also fixes the v1.58.48 / v1.58.50 Publish-failure pattern — by tagging at the same commit where ALL tests pass (instead of follow-up patches landing only on main). (housekeeping)
[1.58.50] — 2026-05-20
docs: DOC-1 — qa/REGRESSION-FINAL.md gets §5a documenting server error bodies as English-by-policy. v1.58.36 audit raised NEW-D4: every server 4xx body is English on every locale. Two paths: (A) confirm by-design, document the contract; (B) read Accept-Language and localize. The spec recommended A — closing NEW-D4 as not-a-finding is least disruption and keeps tests stable. New qa/REGRESSION-FINAL.md §5a section explains: server JSON error bodies stay English (debuggability boundary — bug reports paste cleanly, CI fixtures stay stable, server tests don’t need parallel locale strings). The SPA wraps responses with localized chrome (toast colour, U-4 Details summary). Accept-Language is intentionally not read; the SPA-side lang is stripped before validateConfig per the v1.57.2 invariant. v1.59 option B (localized server errors with { error, error_en, code }) is the future gate. 946 → 947 unit. Closes the v1.58.37 → v1.58.50 queue from FIX-PROMPT-FINAL-EXHAUSTIVE.md — 14 single-fix releases shipped, every one CI-green + AI-review LGTM. (DOC-1)
[1.58.49] — 2026-05-20
chore(tooling): TOOL-1 — make clean-test-fixtures + scripts/clean-test-fixtures.mjs. v1.58.36 audit: ${CAREER_OPS_ROOT}/data/pipeline.md had accumulated 1252+ example.com/job/<n> lines from regression runs. Manual cleanup was tedious. New scripts/clean-test-fixtures.mjs reads ${CAREER_OPS_ROOT or ..}/data/pipeline.md, drops every line containing example.com (case-insensitive), preserves real ATS URLs verbatim, prints the count, and exits 0. --dry-run flag prints the would-be result to stdout without touching the file. New Makefile with make clean-test-fixtures and make clean-test-fixtures-dry-run targets. 4 new CI-isolated tests in tests/clean-test-fixtures.test.mjs (uses mkdtempSync for a synthetic parent — no real-file writes). 942 → 946 unit (4 new TOOL-1 cases). (TOOL-1)
[1.58.48] — 2026-05-20
fix(ux/onboarding): UX-D-B — #/dashboard shows a global warning banner when the user is still on the default fixture profile. v1.58.36 audit: /api/health already includes a { name: "Profile customized", ok: false } row (server checks for Acceptance Test / Jane Smith / other template names in server/lib/store.mjs), but the user only saw it after navigating to #/health. Meanwhile every advisor output (Apply / Followup / Contacto / Deep) was addressed to the fixture name — broken first impression. New profileFixtureBanner() in public/js/views/dashboard.js renders a .hero-banner.hero-banner--warning at the top of the route when the check fails, with localized message (onboarding.fixtureWarning × 8) + a CTA button linking to #/config (onboarding.fixProfile × 8). When the user replaces the fixture, /api/health flips ok: true and the next dashboard mount silently omits the banner. New CSS .hero-banner + .hero-banner--warning rules. 941 → 942 unit. (UX-D-B)
[1.58.47] — 2026-05-20
fix(ux/naming): UX-D-C — top-bar Quick scan renamed to Open Scan so the label matches the actual behavior. v1.58.36 audit: “Quick scan” implied an instant scan with sensible defaults, but the click handler at public/js/app.js only navigates to #/scan — no scan starts. Renamed via the top.quickscan i18n key in all 8 locales (en Open Scan / es Abrir Scan / pt-BR Abrir Scan / ko Scan 열기 / ja Scan を開く / ru Открыть Scan / zh-CN 打开 Scan / zh-TW 開啟 Scan). HTML default text in index.html updated to match. Behavior unchanged; only the label honesty improved. 940 → 941 unit. (UX-D-C)
[1.58.46] — 2026-05-20
fix(ux): UX-D-D — #/apply checklist substitutes {company}-{role} with slugs derived from the URL/JD. v1.58.36 audit: the generated checklist’s item 5 read Save filled answers to interview-prep/{company}-{role}.md before submitting. — the literal {company}-{role} placeholders were displayed verbatim and the user had to mentally substitute (or, worse, paste them as-is). New extractSlugs(url, jd) + substitutePlaceholders(text, url, jd) in public/js/views/apply.js: the host whitelist (greenhouse / lever / ashby / workable / smartrecruiters / workday) picks company out of the URL path or subdomain, then derives role from the trailing path slug (stripped of trailing numeric IDs) — or, as fallback, from the JD’s first line. If extraction fails (unknown host / no JD), placeholders become [company] / [role] (square-bracket convention for “fill in”). Substitution runs once before parseChecklist, so the live checklist + Copy-unchecked output stay coherent. 939 → 940 unit. (UX-D-D)
[1.58.45] — 2026-05-20
fix(ux): UX-D-K — #/help TOC scroll-spy highlights the current section. v1.58.36 audit: as the user scrolled the help body, the TOC sidebar (sticky, ~92 H2 sections) didn’t indicate which section they were reading — they had to mentally scan the H2 above the fold against the TOC. New IntersectionObserver in public/js/views/help.js observes every .help-article h2[id] and applies .toc-current to the matching TOC <a> link when the H2 enters the upper-third reading band (rootMargin: "-30% 0% -60% 0%"). CSS public/css/app.css gives .toc-current a brand-red left-border + var(--rausch) color + font-weight: 600 so the active item reads at a glance. The observer is torn down on hashchange next to the existing scroll listener, so no observer leaks when the user leaves #/help. 938 → 939 unit. (UX-D-K)
[1.58.44] — 2026-05-20
fix(ux): UX-D-L — #/deep opened Saved-research brief now has an inline × close button. v1.58.36 audit: clicking a saved-research card on #/deep rendered the brief body inline (Copy / Download / Open in tab / Generate PDF buttons in the header) but the user had no way to close the brief without scrolling away or navigating. New × button in public/js/views/deep.js showResult() header — clears out.innerHTML, mirrors the modal-close pattern (api.js UI.modal × button), keyboard-reachable, aria-label + title from new deep.closeBrief i18n key × 8. 937 → 938 unit. (UX-D-L)
[1.58.43] — 2026-05-20
fix(ux): UX-D-F — #/evaluate empty-JD submit now shows a distinct localized error toast (was “JD too short”). Before the fix, clicking Evaluate with an empty textarea fell through to the existing <50 chars check and yielded JD too short (min 50 chars) — accurate but unhelpful: the real problem is “you typed nothing”, not “you typed too little”. public/js/views/evaluate.js now checks !jd first and surfaces a new eval.emptyJd error toast ("JD is required — paste the full job description", localized × 8) + focuses the textarea so the user can start typing immediately. The pre-existing eval.shortJd toast still fires for 1–49 char input. 936 → 937 unit. (UX-D-F)
[1.58.42] — 2026-05-20
fix(ux): UX-D-J — per-advisor ETA chip parity with #/auto (UX-6 v1.55.4). v1.58.36 audit: only #/auto showed an honest “⏱ ~1–2 min” chip next to its Run button; the other 7 LLM-driven advisor pages (#/evaluate, #/deep, plus the 5 mode pages — project / training / followup / contacto / interview-prep / patterns) ran a similar 10-60s call but gave no time hint. Adds a <span class="advisor-eta">⏱ ~30s</span> (localized via new advisor.eta key × 8) next to UI.providerCostHint(t) in evaluate.js, deep.js, and mode-page.js. CSS extends .auto-eta to cover .advisor-eta with the same styling. The original auto.eta chip stays at ~1–2 min (it’s the only multi-step SSE pipeline). 935 → 936 unit. (UX-D-J)
[1.58.41] — 2026-05-20
fix(ux/truthfulness): UX-D-I — cost-hint now re-fetches on tab focus + on providers-changed event (M-7 v1.58.12 follow-up). v1.58.12 wired UI.providerCostHint(t) to /api/status/providers but only fetched ONCE at node creation. If the user opened #/config in another tab, picked a different provider, and switched back, the cost line would silently lie until they navigated away and back. Fix in public/js/api.js: extract a named refreshCostLine() function and bind it to document.visibilitychange (tab regains focus) + a new providers-changed CustomEvent. The #/config Save handler in public/js/views/config.js dispatches the event after a successful POST, so in-page cost lines (#/auto, #/deep, #/evaluate, mode pages) refresh without a page reload or route re-mount. 934 → 935 unit. (UX-D-I)
[1.58.40] — 2026-05-20
fix(ux/docs): UX-D-H — regression-lock: every visible career-ops.org/docs/... deep-link must be clickable. v1.58.36 audit verified live: every existing such URL in public/js/views/*.js is already inside c("a", { href, target: "_blank", rel: "noopener noreferrer" }, …) (apply.js / batch.js / config.js / reports.js), and every docs/help/*.md reference uses markdown [text](https://github.com/Fighter90/career-ops-ui/blob/main/url). So this release ships only the regression lock: new tests/external-doc-links.test.mjs parses every views/*.js and docs/help/*.md file and fails if a career-ops.org/docs/<path> URL is rendered as plain child text (not inside an <a> create, attribute slot, or markdown link). Bare brand mentions of career-ops.org without /docs/ path are tolerated (e.g. “career-ops.org schema” in prose). 2 new test cases added to the suite. 932 → 934 unit. (UX-D-H)
[1.58.39] — 2026-05-20
fix(ux): NEW-D2 — Dashboard header gets a Refresh button with explicit toast feedback (distinct from connection-banner Refresh of M-9 / v1.58.14). v1.58.36 audit: there was no in-place refresh on #/dashboard — the user had to use the connection-banner Refresh (which does a full location.reload() and loses scroll position) just to update the four metric counters. New ↻ Refresh button in the header in public/js/views/dashboard.js re-fetches /api/dashboard and re-renders the view in place via Router.go("/dashboard") — no page reload, no scroll loss. Toast pipeline: Refreshing… (in-flight) → Dashboard refreshed (success) or localized error. Two new i18n keys × 8 locales: dash.refreshAria (button aria-label) + dash.refreshed (success toast). 931 → 932 unit. (NEW-D2)
[1.58.38] — 2026-05-20
fix(a11y): NEW-D3 (WCAG 4.1.2) — #/tracker search input gets a localized aria-label distinct from its placeholder. v1.58.36 audit: the search input had only placeholder="Search by company / role…" and no aria-label — screen-reader users heard only the generic role “edit text” with no description of the purpose. Per WCAG 4.1.2 (Name, Role, Value) a standalone search input without an associated <label> MUST have an explicit accessible name. Fix in public/js/views/tracker.js: the filterText input now declares type="search" + aria-label: t(track.searchAria, …). New track.searchAria i18n key in all 8 locales — values explicitly different from the placeholder (“Search applications by company name or role title” vs “Search by company / role…”) so the SR doesn’t hear the same string twice. 930 → 931 unit. (NEW-D3)
[1.58.37] — 2026-05-20
fix(i18n): NEW-D1 — #/pipeline H1 localized on es / pt-BR / ru + 2 stray RU title leaks fixed (contacto.title, health.title). v1.58.36 audit caught the H1 of #/pipeline rendering the literal Pipeline on es/pt-BR/ru while their sidebar items (Vacantes / Vagas / Воронка) were properly localized — promise-fidelity gap with the closed v1.58.18 I18N-011 doctrine (page H1 must match the sidebar term). Updated pipe.title in public/js/lib/i18n-dict.js: es → Pipeline de vacantes, pt-BR → Pipeline de vagas, ru → Воронка вакансий. New static guard tests/i18n-no-latin-leaks.test.mjs (parses the consolidated DICT, asserts no Latin-only *.title value on ru/ja/ko/zh-CN/zh-TW outside a small whitelist of proper nouns / acronyms / product names). The guard immediately caught two additional stray RU leaks beyond NEW-D1: contacto.title (was LinkedIn outreach → Касания через LinkedIn) and health.title (was Health → Состояние) — both shipped under the same fix. 928 → 930 unit. (NEW-D1)
[1.58.36] — 2026-05-20
chore(docs): v1.58.36 — full housekeeping sweep at the close of the v1.58.x cycle. No code or behavior changes — documentation + qa structure + test baselines synced to reality after 32 single-fix releases. (1) qa/ reorganization: the three version-locked end-to-end regression snapshots (REGRESSION-END-TO-END-v1.58.16/33/35.md) moved into the new qa/archive/v158-cycle/ directory alongside the pre-cycle MASTER regression and the FIX-PROMPT-v1.58.3.md snapshot — qa/v158-regression/ now contains only the active FIX-PROMPT-v1.58.4_and_beyond.md (post-MASTER fix specification, every row marked ✅). (2) qa/REGRESSION-FINAL.md gains §12 documenting every v1.58.x cycle invariant (CSP unconditional, 5 provider rows, :focus-visible ring, drawer behavior, help §18, etc.) with its tests/qa-report-fixes.test.mjs lock per row. (3) qa/UX-AUDIT-PROMPT.md baseline table extended with 30 closed-in v1.58.x rows. (4) docs/architecture/: FRONTEND.md gains the notifications-drawer section + v1.58.24 toast-postfix + v1.58.10 modal-drain notes; OVERVIEW.md links to the drawer; TESTING.md totals lifted to 928 unit / 117 files / 62 Playwright / 20 smoke / 23 comprehensive E2E. (5) CLAUDE.md gains a “Hard-won lessons (v1.58.x cycle)” section with 10 captured traps (the [hidden] cascade trap, the npm test | grep exit-code mask, the cleanLlmMarkdown ≠ sanitizer doctrine, etc.). (6) .github/copilot-instructions.md baseline lifted to 928 unit / 62 Playwright. (7) README ×8 locales: new “Notifications 🔔” row in every page-table + tests/ stale count fixed from 284 unit + 12 Playwright + 23 e2e:full → 928 + 62 + 23 + 20 (baseline @ v1.58.35). Test baseline unchanged: 928 unit · 62 Playwright · 20 smoke · 23 comprehensive E2E. (housekeeping)
[1.58.35] — 2026-05-20
fix(ui): v1.58.35 — Notifications drawer no longer auto-opens at page load + new help §18 catalogues all notification categories (user-reported). Two bugs in one. (1) Auto-open / never-close: .notif-drawer { display: flex } and .notif-badge { display: inline-flex } (v1.58.34) had the same author-level specificity as the UA [hidden] { display: none } rule, so the explicit display value won and the hidden attribute was a no-op — the drawer was visible on every page load and clicking close did nothing. Fix in public/css/app.css: explicit .notif-drawer[hidden] { display: none } + .notif-badge[hidden] { display: none } overrides. The drawer is now ONLY opened by clicking the bell (or Enter/Space when keyboard-focused); a static-guard test asserts there is exactly one open() call site in app.js (the bell click ternary), so future edits can’t silently introduce another auto-open path. (2) Undocumented categories: new §18 Notifications section in all 8 help bundles (docs/help/{en,es,ja,ko-KR,pt-BR,ru,zh-CN,zh-TW}.md) — 3 H3 subsections per bundle (Notification categories / What is NOT a notification / Keyboard) catalogue every toast source (Success / Error / Info-progress), what each visual cue means, what’s explicitly NOT a notification (Doctor/verify modals, SSE log lines, spinner-only states), and the keyboard contract. Help-section parity baseline lifted 17 → 18; H3 baseline 70 → 73. 927 → 928 unit. (user-reported)
[1.58.34] — 2026-05-20
feat(ui): v1.58.34 — Notifications drawer ships on top of the v1.58.33 toast-journal capture (closes U-13 completely). v1.58.33 shipped the data shape (in-memory toastHistory cap 50 + UI.getToastHistory()) and explicitly deferred the drawer chrome. This release adds the chrome. UI.onToast(fn) pub/sub layered on top of the capture in public/js/api.js — subscribers receive the entry just appended; the implementation guards every subscriber callback in try/catch so a drawer bug can never break the toast pipeline. Bell button in the top-bar (🔔 + red unread badge), aria-haspopup="dialog" + aria-controls="notif-drawer" + aria-expanded flipping on open/close. Right-slide <aside role="dialog"> with localized title (notif.title), empty-state (notif.empty), and per-entry items showing localized toLocaleTimeString + message + (when present) the technical postfix from U-4 (v1.58.24). Newest first; oldest dropped as the U-13 cap bites. Esc + Close button + clicking the bell again all close the drawer; focus restores to the bell (WAI-ARIA APG drawer pattern). 4 new i18n keys (notif.title, notif.empty, notif.bellAria, notif.closeAria) × 8 locales. CSS: .notif-bell, .notif-badge, .notif-drawer, .notif-drawer__head, .notif-item rules. 926 → 927 unit; Playwright 61/61 unchanged. (U-13 follow-up)
[1.58.33] — 2026-05-20
fix(ux): U-13 + U-14 + U-15 — toast journal capture + page-header spacing safety net + CV editor dirty-state indicator (3 closing UX items batched). Final v1.58.x release. U-13 (toast journal): public/js/api.js — every UI.toast() call pushes { ts, type, message, detail } into an in-memory toastHistory capped at 50; exposed via the new UI.getToastHistory() API. Toasts dwell 3.5-20 s and then vanish; the journal lets future drawer / panel UIs (deferred to a follow-up release) re-surface any missed message. U-14 (page-header spacing safety net): new .page-header h1 + p { margin-block-start: var(--space-2); color: var(--foggy); } rule in public/css/app.css so any page that uses raw <h1>+<p> (without the .page-subtitle class) still gets the canonical spacing. U-15 (CV dirty-state): public/js/views/cv.js — Save button gets .btn-dirty class + localized cv.unsaved tooltip whenever the textarea diverges from the last saved baseline; clicking Save re-baselines and clears the flag. Upload path dispatches a synthetic input event so programmatic ta.value = … assignments still trigger the dirty toggle. CSS .btn.btn-dirty paints a var(--rausch-dark) ring + ● prefix. 925 → 926 unit. (U-13 + U-14 + U-15)
[1.58.32] — 2026-05-20
fix(ux): U-12 — #/help TOC filter input gets a 16ch min-width so KO/JA placeholders never clip. Korean 섹션 필터 and Japanese セクションをフィルター are 5-10% wider than the EN Filter sections. The input already used width: 100% of its .help-toc card; we add a .help-toc__filter class with min-width: 16ch so even if the card narrows, the placeholder still fits in any of the 8 locales. 924 → 925 unit. (U-12)
[1.58.31] — 2026-05-20
fix(ux): U-11 — Tracker Legitimacy column header now carries a localized info chip + tooltip explaining the High/Caution/Suspicious scale. v1.58.3 QA: the badge in each row read High / Caution / Suspicious with no header affordance to learn what they meant. public/js/views/tracker.js <th> now renders the column label + <span class="th-info" tabindex="0" role="img">ⓘ</span> whose title + aria-label come from a new track.col.legitimacy.help i18n key × 8 locales: “Confidence that the posting is real (High / Caution / Suspicious).” CSS .th-info:focus-visible adds the brand ring so the chip is keyboard-reachable per WCAG 2.4.7. Also repairs the v1.58.30 (U-10) regression where the existing #25 destructive buttons have a title hint lock test only checked for track.fixHint but the U-10 branch now uses both track.fixHint and track.fixEmpty. 923 → 924 unit. (U-11 + U-10 follow-up)
[1.58.30] — 2026-05-20
fix(ux): U-10 — Tracker Normalize / Dedup / Merge buttons disabled when data/applications.md is empty. v1.58.3 QA: clicking these buttons on an empty tracker still hit the parent project rewrite endpoints — a no-op the user could not tell was futile. Now public/js/views/tracker.js sets disabled + aria-disabled + a localized tooltip (track.fixEmpty × 8 locales) explaining why (“Add a row to the tracker first — this rewrites data/applications.md and there is nothing to rewrite yet.”). When rows exist, the buttons re-enable and show the existing track.fixHint tooltip. 922 → 923 unit. (U-10)
[1.58.29] — 2026-05-20
fix(ux): U-9 — #/pipeline counter ↔ filter row stacks vertically on narrow viewports. v1.58.3 QA: at ≤ 720 px the In queue: N chip and the filter input fought for horizontal space and pushed the input to a cramped column. New .pipeline-controls class in public/js/views/pipeline.js; CSS rule under @media (max-width: 720px) sets flex-direction: column + stretches #pipe-filter to 100% width. Desktop unchanged. 921 → 922 unit. (U-9)
[1.58.28] — 2026-05-20
fix(ux): U-8 — Generate-prompt block is collapsed by default on the 7 mode pages. The inline <pre> previously ran 1200+ px after the user clicked Generate prompt, pushing Copy + Run-live below the fold. public/js/views/mode-page.js showPrompt() now wraps the <pre> in <details class="prompt-block"> collapsed by default; summary shows Show prompt (N lines) localized via the new prompt.show / prompt.lines keys × 8 locales. Copy / Run-live remain immediately visible. Also fixed a pre-existing v1.58.27 regression where the BUG-007/008 contract test rejected the U-7 reformat (intermediate const stripped between dismissToast/modal); loosened the adjacency regex from s* to [sS]{0,1200}? while still asserting UI.dismissToast() runs before UI.modal(t(…)). 920 → 921 unit. (U-8)
[1.58.27] — 2026-05-20
fix(ux): U-7 — verify-pipeline.mjs ASCII === dividers stripped from the result modal. The script prints =========-runs (≥ 50 chars) between sections; in the 14 px monospace modal the run pushed the body wider than the rest of the SPA needs. Strip in public/js/views/health.js via .replace(/^={10,}$/gm, "") before rendering; whitespace already separates sections. 919 → 920 unit. (U-7)
[1.58.26] — 2026-05-20
fix(ux): U-6 — #/scan “Active companies N/M” chip now explains N vs M via tooltip + aria-label. v1.58.3 QA: the chip read ▸ Active companies 96/80 with no affordance to learn what 96/80 means. Toggle button in public/js/views/scan.js now carries title= (hover tooltip) and aria-label= (screen-reader fallback) sourced from a new scan.activeCo.help i18n key in all 8 locales: “Active: companies currently surfacing results. Total: configured in portals.yml.” 918 → 919 unit. (U-6)
[1.58.25] — 2026-05-20
fix(ux/ia): U-5 — Dashboard CTA dedupe (removed duplicate Open-Pipeline header button + duplicate Scan-all-sources Quick-action tile). v1.58.3 QA flagged 4× Pipeline / 4× Scan entry-points on Dashboard. v1.55.5 promoted the two P0 hero CTAs (✨ Auto-pipeline a URL + 🌐 Scan now); the header 📋 Open Pipeline button and the Quick-action 🌐 Scan all sources tile were then strict duplicates (sidebar already routes to /pipeline; hero already routes to /scan). Removed both in public/js/views/dashboard.js. Hero pair + sidebar remain canonical entry-points. 917 → 918 unit. (U-5)
[1.58.24] — 2026-05-20
fix(ux): U-4 — toast error messages now tuck the “(METHOD /path · HTTP NNN)” postfix into a collapsed <details>. v1.57.1 appended a what/where/why postfix to every API error so opaque “validation failed” became “validation failed — … (POST /api/config · HTTP 400)”. The technical part is required (BUG-006 invariant — must remain reachable in the DOM), but on the toast headline it competes with the human sentence. UI.toast() in public/js/api.js now parses the trailing postfix with TOAST_ENDPOINT_RE and renders it inside a <details class="toast-detail"> with a localized <summary> (toast.details key × 8 locales). Headline stays clean; the technical detail is one click away. New .toast .toast-msg / .toast .toast-detail CSS rules. BUG-006 invariant preserved (postfix still in DOM). 916 → 917 unit. (U-4)
[1.58.23] — 2026-05-20
fix(ux): U-3 — #/followup lastContact placeholder now adapts to today − 14 days (was the frozen ISO 2026-04-21). Static placeholders rot — readers parse them as both a format hint AND a plausible recent example, and the latter drifts into the distant past as time passes. Compute the placeholder at render time in public/js/views/mode-page.js: new Date(); d.setDate(d.getDate() - 14); d.toISOString().slice(0, 10). Special-cased only for cfg.slug === 'followup' && spec.name === 'lastContact'; every other field still pulls its placeholder from i18n. 915 → 916 unit. (U-3)
[1.58.22] — 2026-05-20
fix(ux): U-2 — #/auto H1 no longer wraps to 2 lines because of the leading ✨. Pre-fix the auto.title i18n value was ✨ Auto-pipeline a URL; the emoji participated in line-wrap and pushed the title to a second row at 1280-1600 px widths. Split into separate elements in public/js/views/auto.js: .page-header.page-header--icon (CSS grid, auto 1fr columns) + <span class="page-icon" aria-hidden="true">✨</span> + <h1 class="page-title">{t(auto.title)}</h1> + <p class="page-subtitle">. The emoji now sits on its own column and never participates in line wrap; the H1 wraps freely if needed. auto.title i18n value stripped of its leading ✨ in all 8 locales. 914 → 915 unit. (U-2)
[1.58.21] — 2026-05-20
fix(ux): U-1 — #/cv H1 + subtitle now match every other page (supersedes v1.56.0 UX-9 chip by design). v1.56.0 UX-9 demoted the page title to a .cv-breadcrumb chip (lowercase grey cv) so the user’s name in the preview “owned” visual space. v1.58.3 QA confirmed it just reads as a layout bug — the page header looks broken next to #/dashboard/#/help/etc. U-1 promotes public/js/views/cv.js back to <h1 class="page-title"> + visible <p class="page-subtitle"> like every other page; .cv-breadcrumb CSS rule removed. Single-<h1> invariant unchanged (F-V54-A still shifts user # Name h1→h2 in the preview, so this stays the page’s only H1). Lock test tests/cv-breadcrumb.test.mjs (which encoded the now-reversed UX-9 contract) is removed; tests/cv-single-h1.test.mjs updated to assert the new .page-title-only shape. 913 → 914 unit. (U-1)
[1.58.20] — 2026-05-20
fix(i18n/platform): I-6 — footer hotkey hint now shows ⌘K on Mac, Ctrl+K elsewhere (localized verb). Pre-fix the sidebar footer hint was the literal CTRL+K — search on every platform and locale. The top-bar <kbd> badge already adapted via data-mac/data-other (v1.56.4 UX-N2); now the footer hint does too. top.langhint i18n values embed a {hotkey} placeholder (e.g., {hotkey} — поиск); new applyFooterHotkey() in public/js/app.js runs on boot + every I18n.onChange and replaces {hotkey} with ⌘K or Ctrl+K based on navigator.platform. The localized verb (search / buscar / поиск / 搜索 / …) stays. 915 → 916 unit (static guard locks the {hotkey} shape in all 8 locales, the platform branch, and the I18n.onChange re-apply). (I-6)
[1.58.19] — 2026-05-20
fix(i18n): I-4 — RU #/followup H1 + hints no longer leak Latin cadence / follow-up / scope / timeline. v1.58.3 RU regression: H1 Советник по cadence follow-up; hint ISO-дата (YYYY-MM-DD) — основа для cadence.; notesHint timeline, internal hold, …. All replaced with native Russian: Советник по ритму касаний, ISO-дата (ГГГГ-ММ-ДД) — основа для расчёта ритма касаний., сроки, внутренняя пауза, праздники. companyHint scope → область. 914 → 915 unit (negative-match guard: no cadence / follow-up in any RU followup.* string). (I-4)
[1.58.18] — 2026-05-20
fix(i18n): I-3 — help TOC items 2/5/13/14 free of English bleed in non-Latin locales. Pre-fix several locale help bundles still showed ## 2. App settings & API keys, ## 5. Portals & Sources, ## 13. Mode prompts, ## 14. Apply checklist (ru/ja/ko/zh-CN/zh-TW). Now fully localized in all 8 locales (RU Подсказки режимов / Чек-лист отклика, JA 応募チェックリスト, KO 앱 설정 및 API 키 / 포털 및 소스 / 지원 체크리스트, zh-CN 应用设置与 API 密钥 / 模式提示 / 申请清单, zh-TW 應用設定與 API 金鑰 / 招聘版面與來源 / 模式提示 / 申請清單). 913 → 914 unit (negative-match guard: items 2/5/13/14 contain none of App|settings|Apply|checklist|Portals|Sources|Mode|prompts for the 5 non-Latin locales). (I-3)
[1.58.17] — 2026-05-20
fix(i18n): I-2 — saved-research dates now use Intl.RelativeTimeFormat per locale. The formatRelative() helper in public/js/views/deep.js returned hardcoded English today / 1d ago / Nd ago regardless of UI language. Replaced with Intl.RelativeTimeFormat(I18n.getLang(), { numeric: 'auto' }) — the browser-native localized “today/yesterday/N days ago” string (сегодня/вчера, 今日/昨日, etc.). Dates older than 7 days fall back to Intl.DateTimeFormat(locale, { dateStyle: 'medium' }). Defensive try/catch keeps the old English fallback for ancient browsers without Intl. 912 → 913 unit. (I-2)
[1.58.16] — 2026-05-20
fix(ui): brand-button hover-flicker — pink/red primary & danger buttons no longer flash on hover (user-reported). Root cause in public/css/app.css: the default .btn-primary / .btn-danger background was a linear-gradient(135deg, var(--rausch) 0%, var(--rausch-dark) 100%); the :hover rule replaced it with a solid var(--rausch-dark). CSS cannot interpolate between a gradient and a solid colour, so the 180 ms transition: background on .btn snapped — the gradient briefly resolved to one of its stops on hover entry/exit, producing the visible white/pink flicker the user reported. Fix: keep the gradient on hover and dim via filter: brightness(0.92) instead — filter interpolates cleanly in every browser, so the dim/un-dim now animates symmetrically over the existing 180 ms duration. The .btn transition list is extended with filter var(--transition) so the hover dim actually animates. Mouse pointer-focus state stays clean (uses :focus, not :focus-visible, per the v1.58.9 M-1 contract). 911 → 912 unit (tests/qa-report-fixes.test.mjs asserts the gradient-stays/filter-dims contract on both .btn-primary:hover and .btn-danger:hover, plus that the pre-fix background: var(--rausch-dark) is gone and that filter is in the .btn transition list). (user-reported)
[1.58.15] — 2026-05-20
fix(a11y/i18n): I-1 — top-bar search aria-label and visually-hidden label now localized. v1.58.3 verified the global search input shipped aria-label="Global search — Cmd+K to focus, paste a URL and Enter for auto-pipeline" regardless of UI language. Screen-reader users on RU/JA/zh-CN/etc. were stuck with English. New generic data-i18n-aria-label hook in public/js/app.js mirrors the existing data-i18n / data-i18n-placeholder pattern — applyI18n() iterates every [data-i18n-aria-label] element and calls el.setAttribute('aria-label', I18n.t(key, …)) on each language change. The top-bar input now declares data-i18n-aria-label="top.search.aria"; the visually-hidden <label> declares data-i18n="top.search.label". Two new i18n keys (top.search.aria, top.search.label) added across all 8 locales. The hook is reusable — any future control just adds the attribute. 910 → 911 unit (tests/qa-report-fixes.test.mjs asserts the HTML markup wiring, the app.js handler shape, the 8-locale i18n parity, and a sanity check that RU ≠ EN so a copy-paste-English regression can’t slip through). (I-1)
[1.58.14] — 2026-05-20
fix(ux): M-9 — connection-banner Refresh now gives feedback (was silent reload). v1.58.3 verified the global Refresh button called location.reload() synchronously — user sees a brief flash but no explicit signal that anything happened. Fix in public/js/app.js: the click handler now (1) emits a transient Refreshing… toast, (2) sets sessionStorage['refreshedToast'] so the next page boot can surface a success toast (the in-flight one is destroyed by navigation), (3) disables the button to swallow rapid double-clicks (no stacking), and (4) defers location.reload() by 200 ms so the in-flight toast paints first. On boot, app.js checks the sessionStorage flag, clears it, and emits a success Refreshed toast (deferred so it lands after the SPA settles). Two new i18n keys (common.refreshing, common.refreshed) added across all 8 locales. 909 → 910 unit (tests/qa-report-fixes.test.mjs asserts the synchronous progress toast, deferred reload, disabled-guard, sessionStorage handoff, success toast on next boot, and 8-locale i18n parity). (M-9)
[1.58.13] — 2026-05-20
fix(ux): M-8 — #/apply checklist becomes interactive (delivers the page’s promise). v1.58.3 verified: ▶ Generate checklist rendered a monospace <pre> block listing items 0…7 — read-only text the user couldn’t tick. The page is titled “Apply checklist” but didn’t actually behave like one. Fix in public/js/views/apply.js — after the API returns, the raw r.checklist text is parsed into items (lines, trimmed, leading -/*/1./[ ] markers stripped) and rendered as a <ul class="apply-checklist"> of real <input type="checkbox"> rows. Each row is wrapped in <label> so the full row is the click target (WCAG 2.5.5; works with v1.58.9 M-1 focus rings). State is persisted in localStorage['applyChecklist:' + slug] (slug = URL stripped of protocol + query, alphanum/._/:- only, ≤240 char) — tick three items, reload, three still ticked. Two action buttons: Copy unchecked (clipboard the still-open items as - markdown bullets) and Reset (clear all ticks for this URL). Defensive fallback: if the parser finds zero items, render the raw text as before so the user isn’t shown an empty card. New CSS in public/css/app.css (.apply-checklist, .apply-checklist__actions, line-through on checked spans). Five new i18n keys (apply.checklist.copyUnchecked, resetBtn, copied, copyFailed, reset) across all 8 locales. 908 → 909 unit (tests/qa-report-fixes.test.mjs asserts the checkbox + data-item-index render, the <label> wrap, the applyChecklist: localStorage prefix, the load/save state functions, the two action buttons, the no-raw-<pre> regression-lock, the CSS row sizing, and 8-locale i18n parity for all five new keys). (M-8)
[1.58.12] — 2026-05-20
fix(ux): M-7 — cost hint now tracks the active provider (OpenRouter no longer falls through to a fabricated number). UI.providerCostHint() already routed via /api/status/providers so the line was provider-aware in spirit, but the per-provider maps in public/js/api.js only listed anthropic/gemini/openai/qwen. With v1.57.0’s 5th provider live, OpenRouter fell through to the generic 0.03 fallback and rendered the literal openrouter (lowercase) as the provider name — both wrong. Per the fix-prompt’s intent (“be honest, never quote a fixed number that might be wrong”), the EST map now lists openrouter: null (router picks the underlying model — cost varies per request), and the render path branches on null to emit a localized cost varies (router picks) instead of a fabricated ~$0.03/eval. NAME map adds openrouter: 'OpenRouter' so the user-visible name matches the docs. New i18n key cost.varies added across all 8 locales (cost varies (router picks) / coste variable (lo elige el router) / coste variable / etc.). 907 → 908 unit (tests/qa-report-fixes.test.mjs asserts EST openrouter: null, NAME openrouter: 'OpenRouter', the === null branch + t('cost.varies', …) call site, and 8-locale i18n parity). (M-7)
[1.58.11] — 2026-05-20
fix(ux): M-4 — saved-research card title↔date gap now structural CSS (was inline margin). v1.58.3 verified: some saved-research card entries displayed software-engineer-generaltoday (no space between title and date), while others were fine — the pre-fix relied on style="margin-left: 8px" between two raw <span> children, which collapsed on certain entries. The fix in public/js/views/deep.js:34-55 replaces the two anonymous <span>s with .saved-card__title + a semantic <time class="saved-card__date" datetime="…">, wrapped in a .saved-card flex container. Spacing is now driven by CSS gap: var(--space-2, 8px) so it can’t collapse (and you get a11y/SEO <time> semantics for free). New CSS in public/css/app.css defines .saved-card { display: inline-flex; align-items: baseline; gap: var(--space-2, 8px); white-space: nowrap }, .saved-card__title { font-weight: 500 }, .saved-card__date { color: var(--foggy); font-size: 0.85em; white-space: nowrap }. 906 → 907 unit (tests/qa-report-fixes.test.mjs asserts the new classes, the semantic <time datetime=…>, the no-inline-marginLeft: '8px' regression-lock, and the CSS gap declaration). (M-4)
[1.58.10] — 2026-05-20
fix(ux): M-2 — drain the progress toast before any result modal opens. Clicking sync-check on #/cv left the “Running cv-sync-check.mjs…” toast bottom-right while the result modal opened — both fighting for attention, and on narrow screens visually overlapping. The Health-page Doctor / verify-pipeline buttons already called UI.dismissToast() explicitly before UI.modal(); cv.js’s sync-check (and any future call site) was the odd one out. Fix in public/js/api.js — UI.modal() now calls dismissToast() as its first executable statement so every present and future call site gets the drain for free (defence-in-depth at the boundary). Also localized public/js/views/cv.js — the previously-hardcoded English UI.toast('sync-check…') and UI.modal('sync-check', …) now use t('cv.syncCheckRunning') and t('cv.syncCheck') respectively, satisfying the BUG-008 invariant (modal title == localized button label) for the cv-view path too. Two new i18n keys (cv.syncCheck, cv.syncCheckRunning) added across all 8 locales. 905 → 906 unit (tests/qa-report-fixes.test.mjs static guard for the dismissToast() boundary, the localized cv.js call site, the no-hardcoded-'sync-check…'-literal, and 8-locale i18n parity). (M-2)
[1.58.9] — 2026-05-20
fix(a11y): M-1 — re-establish a visible :focus-visible ring on form fields (WCAG 2.4.7 Level AA). The v1.58.3 MASTER regression confirmed getComputedStyle(focusedInput) returned outline: rgb(255,255,255) none 1.5px — i.e. the none keyword collapsed the ring to 0 px on every form field. Root cause: the base .input, .textarea, .select { outline: none } rule (intentional, to suppress the mouse-focus halo) had higher specificity than the global *:focus-visible rule and silently nuked the keyboard-focus ring on 88 focusable elements per page. Same issue on .searchbar input { outline: none } for the global ⌘K/Ctrl K search. Fix in public/css/app.css — add explicit .input:focus-visible, .textarea:focus-visible, .select:focus-visible and .searchbar input:focus-visible rules with outline: 2px solid var(--rausch) + a translucent box-shadow so keyboard-only focus paints a visible ring while mouse focus stays clean (uses :focus, not :focus-visible). 904 → 905 unit (tests/qa-report-fixes.test.mjs static CSS contract guard for all three new rules + the regression-lock for the pre-existing global *:focus-visible); Playwright 60 → 61 (tests/playwright-smoke.mjs Tab-traversal test asserts the computed outline on a #/config form field is ≥1.5 px wide and not none). (M-1)
[1.58.8] — 2026-05-20
feat(health): surface OPENAI_API_KEY, QWEN_API_KEY, OPENROUTER_API_KEY on #/health (analogous to GEMINI_API_KEY). v1.57.0 added OpenRouter as the 5th headless live-eval provider; v1.55.3 (UX-2) added on-screen onboarding for the 4-provider mix. The #/health page however only reported GEMINI_API_KEY and ANTHROPIC_API_KEY — the other three providers’ key state was invisible there even though /api/status/providers already routed evals to them. User request: extend the same “set / unset (manual mode)” row pattern to every headless provider. server/lib/routes/health.mjs now pushes three additional optional check rows (OPENAI_API_KEY, QWEN_API_KEY, OPENROUTER_API_KEY) wired to the same isUsableKey gate as /api/status/providers (hasOpenAIKey(), hasQwenKey(), hasOpenRouterKey() were already imported but unused). “manual mode” wording is identical to the GEMINI row across the SPA’s 8 locales — the Health view iterates over body.checks so no per-locale string change was needed. 903 → 904 unit (tests/api.test.mjs extended to assert all three new optional rows; ok=true still holds since they’re not required). (NEW user-requested feature)
[1.58.7] — 2026-05-20
fix(security): NEW-2 — isValidJobUrl now rejects paired template-placeholder syntaxes (${…}, {{…}}) to match the error message. The route-level 400 returned by POST /api/pipeline advertises “contain no script or template characters”, but the v1.58.3 MASTER regression confirmed only ASP/EJS-style <%…%> was actually blocked (free side effect of the [<>"'\\s] bracket-char gate). JS template literals (${TEST}) and Mustache/Handlebars ({{TEST}}) both passed validation — a regex↔message semantic gap. Option A from the fix-prompt (tighten regex to match message; slight hardening against URL-templating injection): a new TEMPLATE_PATTERNS array in [server/lib/security.mjs](https://github.com/Fighter90/career-ops-ui/blob/main/server/lib/security.mjs) (/${[^}]}/, /{{[^}]}}/) is consulted via hasTemplatePlaceholder(url)beforenew URL(…). Only **paired** placeholders are rejected — a bare {normal} ATS-style path token survives (https://boards.greenhouse.io/anthropic/jobs/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-reproducible — i18n.js already sets document.documentElement.lang on setLang AND at boot and detects navigator.language; the QA repro was a stale localStorage/pre-redeploy artifact. Locked both with regression guards. 896 → 900 unit · Playwright 58/58 · e2e 20/20+23/23. The remaining v1.58.3 fix-prompt items (M-1 focus-ring, M-4..M-9, I-1..I-6, U-1..U-15) are queued as subsequent one-fix ships per project doctrine (never batched). See qa/v158-regression/.
[1.58.2] — 2026-05-19
fix(i18n): I18N-011 — localize the #/help table-of-contents in all 7 non-EN locales. The #/help “On this page” TOC is built from the ## section headings of docs/help/<lang>.md. Sections 3/4/6/7/8/9/10/11/12 (Profile, CV, Health, Scan, Pipeline, Evaluate, Reports, Tracker, Deep research) still carried their English titles in es/pt-BR/ko/ja/ru/zh-CN/zh-TW, so the TOC showed English while the sidebar nav was translated. Each affected heading is now localized to the exact same term as the sidebar nav.* key (single source of truth — TOC ↔ sidebar match), preserving the section number and the (#/route …) parenthetical verbatim. EN unchanged (canonical). 7–9 headings per locale. Closes the sole open i18n backlog item from the v1.58 QA sweep. Docs-only; 896/896 unit · 33/33 help tests · Playwright 58/58. See qa/v158-regression/.
[1.58.1] — 2026-05-19
fix(test): CI-isolated checkProfileCustomized guard (patch over v1.58.0). v1.58.0 shipped green on the advisory pre-commit but red on ci.yml (Node 18/20/22): the new BUG-002/UX-032 test used a cache-bust dynamic import + PATHS rewrite, but server/lib/paths.mjs resolves the project root once per process, so the temp root never took effect under the shared npm test runner (it coincidentally passed locally). Replaced with a robust static guard that asserts the store.mjs allow-list contains the fixture names AND that the regex is ^(…)$/i exact-anchored (so a real name merely containing “test” — e.g. María Testanova — is provably never false-flagged). No production code changed; this also unblocks publish-package.yml (it runs the suite before publishing). 896/896 unit · Playwright 58/58. See qa/v158-regression/.
[1.58.0] — 2026-05-19
fix(qa): external QA-report bug sweep + clean, formatted research output.
🐛 Fixes (QA report)
- BUG-001 (Critical) —
#/followupnow 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). Genericpatternsupport added to the mode-page form factory. - BUG-003 (Major, every help page) —
**bold**,`code`, italic and[links]now render inside block-quotes inUI.md()(the> **Audience:**cards showed literal**). Safe-by-construction (content is already HTML-escaped beforeinline()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
#/reportsempty 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’sconfig/profile.yml/cv.mdare 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”), anddash.quick.deepCtaare 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#/deepand 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-safeUI.md(), so the result is a properly formatted document. #/outreachalias →#/contacto(BUG-004) for a consistent, bookmarkable English URL; canonical slug stayscontacto(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 validationdetailsare 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) andtests/llm-output.test.mjs(5, incl. idempotency + “don’t eat real code blocks”).checkProfileCustomizedexercised directly (real name containing “test” is not false-flagged). Updatedurl-validation+config-validation-detailassertions for the new messages;e2e-comprehensivenow date-aware. 881 → 896 unit; Playwright 58/58; e2e 20/20 + 23/23.
Triaged, not changed (with rationale)
- BUG-009 (
#/cvH1 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.ymlcontent) — 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
/#/configfrom the browser always returnedvalidation failed.public/js/api.jsauto-attaches alangfield to every JSON POST body (so LLM routes pick up the UI locale)./api/configis not an LLM route andlangis not a config key, sovalidateConfig’s (correct, security-relevant) unknown-key rejection 400’d every Save withvalidation failed — lang: not a known config key. This was browser-only: curl/in-process repros never sentlang, 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-onlylangbefore validating; theKNOWN_KEYSwrite-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 intonpm 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 withlangsucceeds (andlangnever lands in.env), while a real unknown key withlangpresent 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’scatch (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.jsnow folds the per-fielddetailsinto 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.detailsis also exposed for inline rendering. - Input-error messages are now maximally descriptive.
validateConfigexplains 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
/#/configPORT 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
/#/configno 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$-anchoredANTHROPIC_API_KEYcharset regex (/^sk-ant-[A-Za-z0-9_-]{20,}$/) false-rejected genuine Anthropic keys whose base64url tail / future prefix didn’t fit the class.validateConfignow normalizes (trims) every value before validating, the route persists the trimmed value (so it authenticates at runtime — no stray-newline.envquoting), and the Anthropic check is a resilientsk-ant-prefix + length sanity check (the sharedisUsableKey()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_KEYon/#/configand one key fronts 300+ models (Anthropic, OpenAI, Google, Meta, Qwen, DeepSeek …). It’s the last entry of theautoorder (Anthropic → Gemini → OpenAI → Qwen → OpenRouter), so an existing setup is never silently re-routed;LLM_PROVIDER=openrouterpins 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 — directfetch,AbortControllertimeout, key never logged) with the recommendedHTTP-Referer/X-Titleattribution headers. - The OpenRouter model dropdown is live.
OPENROUTER_MODELis a dynamic select fed byGET /api/openrouter/models— a server-side proxy of OpenRouter’s public catalogue (keeps the CSPconnect-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/#/configvisits from re-hitting OpenRouter. New i18n keys (config.openrouter*) across all 8 locales;config.llmProviderHintupdated.
🧪 Tests
- New CI-isolated suites:
tests/openrouter-route.test.mjs(proxy answers 200 with a non-empty namespaced list even offline; leaks no secret) andtests/openrouter-model-selector.test.mjs(env-config contract + config.js wiring + 8-locale i18n parity). Extendedenv-config,openai, andprovider-selectorsuites: trim/relax validation,runOpenRouter/hasOpenRouterKey/fetchOpenRouterModels, the appendedautotail, and the OpenRouter gate. Two previously-weakvalidateConfigcases (used the no-longer-knownHH_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 fromdata-mac/data-otherby a platform check (navigator.platform/userAgent): ⌘K on macOS/iOS, Ctrl K elsewhere. It isaria-hidden="true"(the existingaria-labelalready announces the shortcut to screen readers — the badge must not double-announce) andpointer-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 alreadyflex:1).
🧪 Tests
- New CI-isolated source-static suite
tests/cmdk-hint-visible.test.mjs(5): the.kbd-shortcut<kbd>lives inside the.searchbarpill; it isaria-hidden="true"with bothdata-mac/data-othervariants;app.jsfills it via anavigatorplatform check from the dataset; the existing(e.ctrlKey||e.metaKey)&&e.key==='k'→search.focus()keybinding is intact (regression guard);app.cssstyles.kbd-shortcutand neverdisplay: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_KEYin a parent.envwas reported “✓ set” AND mis-selected as the active provider over a validANTHROPIC_API_KEY.effectiveEnv()only rejectedundefined/'', so a 10-char placeholder counted as a real key: the onboarding banner showed GEMINI ✓ set,GET /api/status/providersreturnedactiveProvider: "gemini", and every live ⚡ eval would have silently failed against a dead key while a working 108-char Anthropic key was ignored. New pureisUsableKey()(env-config.mjs) treats a secret as configured only when it is ≥ 20 chars (no supported provider’s key is shorter — GeminiAIza…≈ 39, Anthropicsk-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 tohasAnthropicKey()/hasGeminiKey()(anthropic.mjs),hasOpenAIKey()/hasQwenKey()(openai.mjs), and theGEMINI_API_KEY/ANTHROPIC_API_KEYrows ofGET /api/health— which also moved off rawprocess.envonto 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 correctkeysConfigured.
🧪 Tests
- New CI-isolated suite
tests/key-detection-rejects-placeholder.test.mjs(5):isUsableKeyunit cases (empty/non-string, too-short incl. the 10-char repro, long-but-placeholder, realistic keys accepted) + an in-processcreateApp()reproduction of the exact reported scenario (10-charGEMINI_API_KEY+ realANTHROPIC_API_KEYin a temp parent.env, the 4 keys stripped fromprocess.env) assertinggeminiis NOT inkeysConfigured,activeProvider === "anthropic", and the/api/healthrows agree. Four existinganthropic/openaieffective-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 staticindex.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.jsfocusNewView()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-12tabindexset follows). Falls back tocareer-ops — command centerwhen a view has no heading.
🧪 Tests
- New CI-isolated source-static suite
tests/document-title-per-route.test.mjs(4):focusNewViewassignsdocument.title; the title is derived from the view<h1>textContent (per-route + localized, not one hardcoded literal); the assignment precedes the!firstPaintDoneearly-return; a sane product default is present. router.js is browser-only → asserted statically (same approach asdashboard-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.jsgives each destination view’s headingtabindex="-1"and.focus()’s it on every client-side navigation so screen readers announce the new page. Atabindex="-1"element is never keyboard-reachable, yet Chromium’s:focus-visiblemodality 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 theimages/dashboard-*.pnghero 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-visiblering (WCAG 2.4.7 intact); the skip-link’s ring is unaffected (it is an<a>, nottabindex="-1", with a higher-specificity rule). - All 8
images/dashboard-*.pngregenerated 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-visiblebrand ring is still defined (WCAG 2.4.7 not regressed);[tabindex="-1"]:focus, :focus-visibleresolves tooutline: none; the suppression rule follows the global rule (cascade safety); the fix stays scoped (no blanket*:focus { outline: none }). Pairs with the existingtests/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 —
#/cvbreadcrumb (visual hierarchy): the page title is demoted to a quiet uppercase.cv-breadcrumbchip and the loud page-subtitle paragraph moves to the<h1>titletooltip, 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,#/deepand every#/<mode>. It reusesGET /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 —
#/helpTOC 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 —
#/dashboardfirst-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#contentstaysaria-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-hintCSS.
🧪 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 +#contentaria-live. The pre-existingcv-single-h1andhelp-nav-a11ylocks 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/trackergains optional?page/?pageSize/?statusquery 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 }—pageSizeclamped to[1,500],pageclamped to≥1,statusfiltersrows+total, andfunnelis 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 isaria-pressedand 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--activeCSS.
🧪 Tests
test: tests/tracker-server-paged.test.mjs(new, 7 cases, CI-isolated, in-process Express on an ephemeral port + tempCAREER_OPS_ROOTapplications.md fixture — CLAUDE.md #2/#8): back-compat (no params ⇒ exactly{rows}, no envelope leak);?page&pageSizeslice +total+page+pageSize+fullfunnelsumming 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;pageSizecap; plus a source-static lock on the.tracker-funnelchip 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
#/pipelinerendered 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): aboveVIRTUALIZE_THRESHOLD = 1000,#/pipelinebecomes 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 purecomputeWindow()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 simpleforEach→appendChild; the >threshold branch rendersslice(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
#/scanstacked 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.advancedFiltersacross all 8 locales; new token-based.scan-advancedsummary 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-advancedhook andscan.advancedFilterslabel; free-text + remote stay in the always-visible group; scope + source live inside the disclosure;chipsContaineris a<details>;.scan-advanced summaryis 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
#/dashboardopened with ~30 equal-weight nodes — no clear “what next”. A new.dash-heroblock now sits directly under the page header: the two P0 journeys — ✨ Auto-pipeline a URL and 🌐 Scan now — are promoted to large.btn-herobuttons, and a single focal recent-activity hint (“Last evaluation:<score>—<title>”, linked to the report; a guiding empty-state on cold start viadash.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-chippills so they no longer compete with the hero (Information-scent / cognitive-load lens). - New i18n keys
dash.lastEval,dash.heroNoEvalacross all 8 locales; new token-based.dash-hero/.btn-hero/.dash-chipCSS.
🧪 Tests
test: tests/dashboard-hero.test.mjs(new, 5 cases, CI-isolated, source-static): a.dash-heroblock exists and precedes the Quick-actions grid; both P0 CTAs are.btn-herowith the/auto+/scanroutes; a focaldash.lastEvalhint +dash.heroNoEvalempty-state; status buckets use.dash-chip;.dash-hero/.btn-hero/.dash-chipCSS 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-etahint — “⏱ ~1–2 min” (keyauto.eta,titleviaauto.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)flipsscan-stop-btnbetweenbtn-danger(running) andbtn-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.etaTitleacross all 8 locales; new token-based.btn-danger/.auto-etaCSS.
🧪 Tests
test: tests/auto-eta-stop.test.mjs(new, 4 cases, CI-isolated, source-static):#/autorenderst('auto.eta')with the.auto-etaclass adjacent torunBtn;auto.eta×8 locales;setScanRunning(running)promotes Stop tobtn-danger;.btn-dangerexists 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 }.keysConfigureduses the same effective-env view as thellm.mjsgate sites (process.env ∨ parent.env, viahasAnthropicKey/hasGeminiKey/hasOpenAIKey/hasQwenKey);activeProvideris what the OR-router would actually pick —selectActiveProvider(), a new pure helper inenv-config.mjsthat walksproviderOrder()(so anLLM_PROVIDERpin with no matching key correctly yieldsnull). No secrets are returned — only provider names + the model id. - The SPA shell now renders a global onboarding region (
#onboarding-banner, populated byapp.jsfrom 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.activeProvideracross all 8 locales; new.onboarding-warn/.onboarding-okCSS (token-based, mirrors.conn-banner).
🧪 Tests
test: tests/onboarding-key-banner.test.mjs(new, 9 cases, CI-isolated):selectActiveProviderauto-order / none /LLM_PROVIDER-pin semantics;GET /api/status/providersin-process (ephemeral port + tempCAREER_OPS_ROOT.envso 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-keysCTA) +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
#/cvprimary editor<textarea id="cv-editor">now carries a descriptivearia-labelvia the newcv.editorAriakey — “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 inspectedaria-label/labels), the field was not nameless — v1.47.0 (WS2 #16) had already bound it viaaria-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 redundantaria-labelledbyis removed (a leftover would be dead markup —aria-labelwins 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 likeauto-stepper-prerender.test.mjs):#cv-editornames itself viat('cv.editorAria', …)with a non-empty fallback;cv.editorAriais present and non-empty in all 8 locales; no redundantaria-labelledbyremains 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
#/autonow 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 createddisplay:noneandrenderStepper()was only reached fromsetStep()/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 thependingstate, and carries anaria-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 likerouter.test.mjs): theSTEPSarray is exactly the 5 canonical stages in order;stepperElis notdisplay:noneon mount and carriesauto.stepperAria; a mount-scoperenderStepper()call precedesfunction setStep(;auto.stepperAriais 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_PROVIDERgainsopenaiandqwen;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 asanthropic.mjs:AbortControllertimeout, key never logged,effectiveEnv()key resolution so a parent-.envkey works without a restart). One core (runOpenAICompatible) backsrunOpenAI(api.openai.com) andrunQwen(Alibaba DashScope OpenAI-compatible mode; override the endpoint withQWEN_BASE_URLin the raw.envfor 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-pipelineSSE — 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 toKNOWN_KEYS/KEY_GROUPS.core;LLM_PROVIDERSandproviderOrder()extended;OPENAI_API_KEYis now a first-class headless provider key (was stored-only).#/configAPI-keys tab:LLM_PROVIDERselect gainsopenai/qwen; newQWEN_API_KEY+QWEN_MODELfields (curatedqwen-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_tokensclamp, timeout,effectiveEnvkey detection, no-key-leak canary.tests/provider-selector.test.mjsupdated for the v1.55.0providerOrder/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#/autoSSE stream was mid-flight made the server’s nextres.write()reject withEPIPE/"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()inauto-pipeline.mjsnow registers a no-opres.on('error')and guardssend()onres.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, soclosePage()aborted the server’s in-flightsafeGet()after the test ended. It now waits for the pipeline to reach a terminal state (so the fetch resolves normally before close). A sharedclosePage()helper (window.stop()then close) and theafter-hookserver.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 theopenSsedisconnect-hygiene contract (res.on('error')listener +writableEnded||destroyedguard + 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_KEYwas the configured provider. Root cause:hasAnthropicKey()/hasGeminiKey()(andrunAnthropic’s key/model lookup) read only the boot-timeprocess.envsnapshot. If the Anthropic key was added to the parent.envafter the server started, the running process never saw it → Anthropic detection was false, and evaluation fell through to whatever stale key was inprocess.env(often an old, invalidGEMINI_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)inenv-config.mjs: a non-emptyprocess.envvalue wins (covers shell exports and the live-apply inPOST /api/config); otherwise the current parent.envfile is consulted.anthropic.mjsnow resolvesANTHROPIC_API_KEY,ANTHROPIC_MODEL, and the Gemini-key check through it, so a key set in the parent.envis 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.mjsrewritten to be CI-isolated (tempCAREER_OPS_ROOT, dynamic import) with 2 new cases reproducing the exact bug (key only in parent.env→ detected;runAnthropicsends the parent-.envkey + model whenprocess.envis unset).test: tests/env-config.test.mjs+3effectiveEnvcases (process.env precedence,.envfallback 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-schemamodes/_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-describedbyfor 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 inconfig.js(it replaces the parent file — WS2 #4 destructive-save invariant), preserves the existing preamble (or a documented default), and keeps non-canonical sections verbatim.- 6 new i18n keys (
config.modesDescTargetRoles…config.modesDescLocationPolicy+config.modesFormRebuildBody) across all 8 locales.
🧪 Tests
test: tests/modes-form.test.mjs— rewritten for the v1.54.8 contract: schema + canonical order, config.js payload/confirm wiring, every field’s doc-sourced description present in all 8 locales,canonicalKey“Your X” tolerance, list round-trip stability, the bootstrap-always-renders guarantee, and the tagged sections-vs-markdown collect() with data-safety. Verified live against the real parent stub file (5 fields + descriptions appear, 0 console errors) and an isolated stub fixture (fill → confirm-gated save → all 5 canonical sections persisted). 742 unchanged (7 cases, replaced).
[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.mjsnow setsCache-Control: no-storeon.js/.mjs/.css/.htmlvia theexpress.staticsetHeadershook, and explicitly on the SPA-shell catch-all (which usessendFileand bypassessetHeaders), so the browser always revalidates the code that drives routing. Non-code static assets keepexpress.static’s default caching. Security headers (CSP / nosniff / frame-deny / referrer-policy) are unchanged — verified by the existingsecurity-headerssuite (8 cases) running green alongside the new test.
🧪 Tests
test: tests/asset-cache-control.test.mjs— 4 cases (JS assetsno-store, CSSno-store, staticindex.htmlno-store, SPA catch-all deep-route shellno-store), booting the real app against an isolatedCAREER_OPS_ROOT. Plus a flaky-teardown fix intests/playwright-smoke.mjs(separatetest(e2e)commit): the auto-pipeline SSE smoke test now cancels the reader + aborts the fetch in afinallyand theafterhook 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
#/helpfloating back-to-top button worked correctly (verified live) but its class list (btn btn-primary help-back-top) sat outside the.back-to-topselector 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 canonicalback-to-topclass. Purely additive and a CSS-no-op:help-back-top(the existing CSS hook) is unchanged andback-to-tophas no CSS rule — it’s a stable test/automation handle only. Verified live:document.querySelector('.back-to-top')resolves the button,aria-labelintact, 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 canonicalback-to-topselector (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
#/batchTSV textarea had a hint wired viaaria-describedbybut no accessible name — no<label htmlFor>, noaria-label/aria-labelledby(regression run F-V54-C; WCAG 1.3.1 Info & Relationships / 4.1.2 Name, Role, Value).aria-describedbysupplies a description, not a name, so a screen reader announced an unlabelled “edit text”. The textarea now carries anaria-labelvia the new i18n keybatch.tsvAria, consistent with the sibling run-control inputs that already use*Ariakeys; the existing describedby hint is preserved. Verified live:aria-labelpresent + localized,aria-describedbyintact, 0 console errors. - New i18n key
batch.tsvAriaacross all 8 locales.
🧪 Tests
test: tests/batch-tsv-accessible-name.test.mjs— 2 cases (thebatch-tsvblock has anaria-labelviat(batch.tsvAria)while keeping its describedby hint;batch.tsvAriadefined 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#/pipelinewere icon-only with only atitleattribute (regression run F-V54-B; WCAG 4.1.2 Name, Role, Value).titleis 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 explicitaria-labeldisambiguated by a compact URL via a newshortUrl()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 — reusescommon.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 withshortUrl(url)+ exactly two such labels,shortUrldeclared 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.mdas 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 Targets→ repeatable add/remove labelled line-inputs (one role/angle/comp line per field,+ Add line/ per-row✕witharia-label).Exit Narrative/Location Policy→ single 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 existingPUT /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.modesTargetRoles…config.modesUnknownNote) across all 8 locales.
🧪 Tests
test: tests/modes-form.test.mjs— 7 cases (documented schema present, config.js wiresModesForm.build/collect+ drops the stalemodesSectionInputsmap, 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 isolatedCAREER_OPS_ROOTfixture: 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
#/confighad no way to pick the OpenAI / Codex model — onlyANTHROPIC_MODELandGEMINI_MODELhad dropdowns, even thoughOPENAI_API_KEYwas already exposed for the parent multi-CLI (Codex / OpenCode) flow.OPENAI_MODELis now a first-class env key: added toenv-config.mjsKNOWN_KEYS(ordered right afterOPENAI_API_KEY) and thecorekey group, and deliberately not inSECRET_KEYS— it’s a model id, not a credential, so it’s never masked.config.jsgains a curatedOPENAI_MODELSlist (gpt-5-codexdefault, thengpt-5/gpt-5-mini/gpt-4.1/o4-mini/o3) and anOPENAI_MODEL<select>field rendered immediately after the OpenAI key, mirroring the Anthropic/Gemini model fields exactly (samekind: 'select',aria-describedbyhint wiring, label = env-var name). Read by the parent Codex / OpenCode CLI flow — web-ui live-eval still uses Anthropic|Gemini. Verified live:#/config→OPENAI_MODELselect with 6 options, defaultgpt-5-codex, label-bound, 0 console errors.- New i18n keys
config.openaiModel+config.openaiModelHintacross all 8 locales.
🧪 Tests
test: tests/openai-model-selector.test.mjs— 4 cases (env-config core/non-secret contract + key ordering,OPENAI_MODELSlist withgpt-5-codexdefault, theOPENAI_MODELselect field wired afterOPENAI_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
# Namerendered 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.jsnow feeds every preview-injection point (initial render, file-import refresh, live editor sync) through a scopedcvMd()that shifts headings down one level (h1→h2 … h5→h6, h6→role="heading" aria-level="7"), so the page keeps exactly one<h1>. Scoped tocv.json purpose —UI.mdis 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# Nameis 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, sodocs/help/en.mdhad 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_companieskept byte-identical). All 8 bundles: 17 H2 / 70 H3.
🧪 Tests
test(help): H3-parity gate—help-ru-config-section.test.mjsnow 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.mdrefreshed to v1.54.0 reality: test totals (716node --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-trappedUI.confirm, WAI-ARIA tabs, SSE live-regions, label binding, sortable-tablearia-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 4bin/*.shscripts and the.githooks/pre-commithook had zero coverage. 10 cases now lock:bash -n/sh -nsyntax, shebang + executable bit, and the behavioural contracts other workstreams depend on:career-ops-ui.sh—helpexits 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 fragilesed-scrape), all dispatcher case-labels present.start.sh—NO_OPENhonored, Node ≥ 18 gate, browser-raise delegated toscripts/open-dashboard.mjs(v1.43.0 guard), no bareopen "$URL".setup.sh— strict mode,SKIP_START, clones both repos,need git.run_all.sh—--quick/--no-e2eparsing, runs all 4 suites viarun_suite..githooks/pre-commit— execs the WS7 reviewer; no shell file invokesgit --no-verify(CLAUDE.md hard rule #7 guard);install-hooks.mjswirescore.hooksPathand is the npmpreparestep.
- 706 → 716.
📝 Documentation
docs/architecture/TESTING.md— added the shell-surface base layer to the pyramid diagram + a v1.53.0 totals note (716node --testcases / 90 files + 4 E2E surfaces); documents thatscripts/*.mjslogic is covered bycli-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— archetypefit/levelrendered as two bare ambiguous chips; now prefixed (Fit: …/Level: …) with matchingaria-label. - #35
#/health— Run-doctor / verify toasts showed rawdoctor.mjs/verify-pipeline.mjsstrings; 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 arole="list"<ul>/<li>and the status badge carriesaria-label="<check>: <status>". - #37
#/reports— report cards were mouse-only<div onClick>; nowrole="link"+tabindex="0"+ Enter/Space handler +aria-label. - #38
#/activity— the paginator comment said “200” while the code requested 500; reconciled to aCAPconstant and arole="note"notice now surfaces when the 500-cap truncates older history (activity.truncated). - #39
#/batch— prose placeholders were hardcoded English while theiraria-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).
- #33
🌐 i18n
- 10 new keys × 8 locales (
set.fit/level,health.runningDoctor/Verify,activity.truncated({n}preserved),batch.minScorePh/maxRetriesPh/modelPh/startFromPh,mode.liveReadyAnnounce).i18n-coveragegate green.
🧪 Tests
test: tests/low-sweep.test.mjs— 9 cases (one per finding + the 10-key ×8 i18n check). 697 → 706. Playwright-verified: healthul[role=list](17li, 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
#/autoRun 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 infinally. - #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 toexecCommand, and toasts a real failure (auto.copyFail) instead of a false success. - #19
#/evaluate#eval-outhad no live role — the long LLM call was silent for screen readers. Nowrole="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). NowUI.withSpinner-wrapped (disables + busy state).
- #13
🌐 i18n
- 3 new keys × 8 locales —
auto.running,auto.httpFail({n}preserved),auto.copyFail.i18n-coveragegate 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.mjsdeleted the test row via the pre-v1.48 native-confirm path; v1.48.0’s focus-trappedUI.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#/helpfindings 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 hasaria-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 onhashchangeaway from#/help(no leak).
- #28 the doc markdown opened with its own
🌐 i18n
- 2 new keys × 8 locales —
help.tocFilter,help.backToTop.i18n-coveragegate 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, TOCaria-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#/trackerfindings:- #10 the action column header was an empty string and the per-row Report button had no context. Every
<th>now hasscope="col"; the action header ist('track.col.actions');Score/PDFheaders are i18n-keyed (were hardcoded English); the Report button gains anaria-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>witharia-sort(none/ascending/descending); asorted()comparator (numeric for score, locale-compare for date/status) runs before pagination; clicking toggles direction and resets the pager. - #25
track.normalize/dedup/mergewere identical English in all 8 locales despite being the highest-stakes destructive controls — now properly localized, plus atitletooltip (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 === 0now renders a distinct empty state (title + body + “Open pipeline” CTA); the filter-excluded-everything case keepstrack.noMatch.
- #10 the action column header was an empty string and the per-row Report button had no context. Every
🌐 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-coveragegate 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
#/pipelineactions used nativeconfirm()(auto-dismissed in embeds, not focus-trapped): the preview-pane Delete, the per-row✕delete, and “Evaluate first”. All now route through the focus-trappedUI.confirm()(v1.44.0 infra) — the two deletesdanger:true(Cancel-default), “Evaluate first”danger:false. - #22
previewPanehad no live role and a fetch failure was stuffed intopreviewBodyso it rendered as a misleading<pre>“preview”. It’s nowrole="region" aria-live="polite"with anaria-label; failures set a separatepreviewErrorand render a distinctrole="alert"block.previewErroris cleared on (re)select and when the active row is deleted.
- #8 all three
- No native
confirm()remains inpipeline.js.
🌐 i18n
- 4 new keys × 8 locales —
pipe.confirmDelTitle,pipe.previewError,pipe.evaluateAllTitle,pipe.previewRegion.i18n-coveragegate 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— thedry-runcheckbox andcompany-selectdropdown had labels with nofor; addedhtmlFor(matching the existingids). - #30
deep.js—company/roleinputs had unbound labels; addedid+htmlFor(deep-company,deep-role). - #31
apply.js—url/jdhad unbound labels; addedid+htmlFor(apply-url,apply-jd). - #16
cv.js— the primary markdown<textarea>had no accessible name; bound it viaaria-labelledbyto the visible “Markdown”<h3>(id="cv-md-heading") — SR name == on-screen heading, no new i18n key.
- #7
- Uses the explicit
label[for]↔control[id]pattern already standard inbatch.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 newhtmlFor/aria-labelledbyhas a matchingidin-file. 667 → 672. Playwright-verified: on #/scan, #/deep, #/apply everylabel[for]resolves to a control; #/cvaria-labelledbyresolves 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. Nowrole="log" aria-live="polite" aria-relevant="additions"+ anaria-label+tabindex="0"(keyboard-scrollable). A separate visually-hiddenrole="status" aria-live="assertive"region announces terminal events (complete / failed / stopped). - #6 an in-flight
EventSourcescan 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 + setsaria-busyand toggles the Stop button across both stream paths (single-phasestreamToand multi-phaserunScanAll— the latter only ends the run on the terminaldone,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.
- #5 the streaming console (
- 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-coveragegate 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: norole, noaria-selected, no keyboard model (UX-audit HIGH #3, WCAG 4.1.2 / 2.1.1). Now: arole="tablist"container with anaria-label; each tabrole="tab"+id+aria-controls+aria-selected(synced inactivate()) + rovingtabindex(0 active / -1 rest); the panelrole="tabpanel"+tabindex="0"+aria-labelledbytracking the active tab. Full keyboard nav: ←/→/↑/↓ (wrapping) + Home/End move focus AND activate. The legacy.tab-btn.is-activeCSS 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-coveragegate 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.jssaveProfileRaw/saveModesRawreplaced the ENTIRE parentconfig/profile.yml/modes/_profile.mdwith no confirmation; (#9)tracker.jsNormalize/Dedup/Merge rewrote parentdata/applications.mdin place with no confirmation. AddedUI.confirm(title, message, opts)topublic/js/api.js— a focus-trapped dialog reusing the existing WAI-ARIA modal infra (focus-return, Tab-trap), returningPromise<boolean>. A new_onClosehook fires fromcloseModal()so EVERY dismissal path (Esc / backdrop / × / Cancel) resolvesfalse; only the explicit confirm button resolvestrue. Focus defaults to Cancel (safe choice for a destructive op). NOT nativeconfirm()(auto-dismissed in embeds, not focus-trapped). All three call sites are now gated before theAPI.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 intrack.fixConfirmBodyis preserved verbatim across all locales (runtime-substituted).i18n-coveragegate green.
🧪 Tests
test: tests/confirm-gate.test.mjs— 8 cases:UI.confirmexported,_onCloseresolves false on every dismissal path, Cancel-default focus,modal()back-compat onClose arg, all 3 destructive sites gated before their write, no nativeconfirm()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— aftersetup/run, bareopen/xdg-openleft the dashboard tab in the background when the browser was already running, so the user had to hunt for it. Newscripts/open-dashboard.mjsbuilds the URL from HOST/PORT (rewriting a0.0.0.0bind to loopback), optionally waits for/api/health, opens the default browser, then force-raises it — macOSosascriptactivating whichever of Chrome/Brave/Edge/Safari/Arc/Firefox is running, Linuxxdg-open+wmctrl, Windowsstart. Exposed as thecareer-ops-ui openverb (aliasesdash,focus).bin/start.shautostart now delegates to it so the tab is raised automatically;NO_OPEN=1disables 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),openAndRaiseplatform routing (darwin/win32/linux, no real browser),waitForHealthbounded-timeout against a dead port, and static guarantees that the dispatcher routesopen|dash|focusandstart.shdelegates + honorsNO_OPEN(old bare-openpath gone). 636 → 644.
📝 Documentation
- README ×8 + help-bundle §1 ×8 — the
openverb added to the launch block + a note thatsetup/runnow raises the tab automatically andNO_OPEN=1disables 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—#/portalswas 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). Addedportals: 'config'torouter.jsALIASES(same bookmark-stability pattern assettings→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#/portalshash, 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 guarantee—router.test.mjsstatic assertion guarding the new ALIASES entry against future-refactor regression. 635 → 636.
📝 Documentation
- help-bundle §5 × 8 — a “Shortcut” blockquote noting
#/portalsnow 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 change—router.js render()replaced#contenton 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). NewfocusNewView(content)focuses the new view’s firsth1/.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.activeElementis the new view’sH1.page-title.
🧪 Tests
test(router): focus-management static guarantees— 4 cases inrouter.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 withsed -n '2,12p', but line 12 (set -euo pipefail) is code, not a comment, socareer-ops-ui help(and the unknown-verb usage text) ended with a strayset -euo pipefailline. Narrowed to2,11p(the comment block) in both thehelpand*)cases.helpexits 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;stdinis 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-uiCLI chain (clone →npm link→setup→init→doctor→run→help), the provider-wizard explanation, the CI--provider --anthropic-key --yesform, and theLLM_PROVIDERnote. 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 fallback—provider-selector.test.mjsgains a DI-stdin case assertingaskSecretdelegates to plainask()(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_KEY—server/lib/env-config.mjsaddsLLM_PROVIDER(auto|claude|gemini) +OPENAI_API_KEYtoKNOWN_KEYS/KEY_GROUPS;OPENAI_API_KEYis secret-masked. NewproviderOrder(env)helper:auto→[anthropic,gemini](legacy),claude→[anthropic],gemini→[gemini]. All 6 provider-gate sites inserver/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 forauto/default).#/configAPI-keys tab gains anLLM_PROVIDERselect +OPENAI_API_KEYinput. 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 init—scripts/init.mjsis now a real wizard (was a WS8.1 stub): pick provider 1-4, enter key(s), writes parent.envvia the validatedenv-config.updateEnvFilepath (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),initparseArgs+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 dispatcher—setup→bin/setup.sh(existing one-command bootstrap),run→bin/start.sh,doctor→scripts/doctor.mjs,init→scripts/init.mjs(WS8.1 stub; interactive provider wizard = WS8.2),help. Backward-compatcareer-ops-ui-startbin alias kept.feat(cli): scripts/doctor.mjs— standalone health check that reuses the exact/api/healthengine (spinscreateApp()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, sosetup/ 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:formatReportpure logic (all-pass / required-fail / optional-only-fail / empty-tolerant), dispatcher verb-routing canary,package.jsonbin 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 reviewscripts/ai-precommit-review.mjs— deterministic 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.exampleplaceholders exempt), leaves.also(in a staged view (mirrors the CI gate), or stages a.mjs/.jsthat failsnode --check. AI layer (fail-SOFT): runsclaude -pover the staged diff when the CLI is on PATH andAI_REVIEW !== 'off'; missing CLI / offline / timeout → notice, never blocks..githooks/pre-commit+scripts/install-hooks.mjs—npm installwirescore.hooksPath=.githooksvia the newpreparescript. 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.exampleexempt), 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 editorserver/lib/routes/content.mjs— byte-exactsplitProfileSections(preamble + per-##{ heading, headingLine, body }via string slices, not line-join — round-trip is byte-identical) +joinProfileSections.GET /api/modes/_profilenow also returns{ preamble, sections }.PUTaccepts{ sections: { "<heading>": "<body>" } }: replaces only named sections, preamble + unknown sections + ordering survive byte-for-byte (merge-not-replace). Unknown heading → 400. ExistingstripDangerousMarkdownsanitization 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 editorsserver/lib/routes/content.mjs—PUT /api/profilenow 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 serverKNOWN_KEYS(ANTHROPIC_API_KEY/MODEL, GEMINI_API_KEY/MODEL, PORT, HOST) ≡ clientFIELDS. Every recognized.envkey 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 existingPOST /api/auto-pipelineSSE 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 politerole="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=1opens + auto-starts. - Sidebar entry (✨ Auto-pipeline, after Pipeline); dashboard ✨ button now routes here (single coherent flow; the
window.AutoPipelinemodal 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’sscan.mjsgained an optionallocation_filterblock; web-ui runs its OWN in-processen-scanner/ru-scanner(they do NOT shell out to parentscan.mjs), so it did not flow through. Newserver/lib/location-filter.mjsmirrors the parentbuildLocationFiltersemantics verbatim (no key → pass-all; empty location → pass;blockprecedence overallow;allowempty → pass;allownon-empty → match ≥ 1; case-insensitive substring). Wired into both scanners’ post-fetch filter step (after title/negative, before dedup/persist). Config-driven viaportals.ymltop-levellocation_filter:— no UI needed.
📝 Documentation
- help-bundle §5 (Portals) × 8 locales — new
location_filtersubsection 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’sportals.example.ymlworked 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 savepublic/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.mjs—PUT /api/profilegains a{ fields: { "candidate.full_name": … } }payload. Server reads the existingconfig/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 (nophone: ""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-frompublic/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.mjs—GET /api/stream/batch?model=…&startFrom=…. Defense-in-depth (same posture as v1.28.0--max-retries):modelaccepted only if it matches^[A-Za-z0-9.\-]{1,60}$(rejects shell-meta even thoughspawn()is arg-array — belt-and-suspenders);startFromaccepted 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-fromdocumented in the flag list with the#/batchfield-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
bundleProjectContextautomatically;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);parentVersionauto-reports 1.8.0 viahealth.mjsruntime 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-tunetitle_filter.positiveinportals.ymlto narrow the set if they wanted to inspect later rows. v1.30.0 swaps the cap forUI.paginate(the same helper that drives#/tracker,#/reports,#/activity).PAGE_SIZE = 200preserves 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. Whentotal ≤ pageSize, the controls show only the item count (clean for small datasets).
- Stale
scan.shownTopi18n key removed frompublic/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; wiresUI.paginate({ pageSize: PAGE_SIZE, onChange: …renderResults… }); resets pager on filter input; sorts the FULLrows.slice()intosortedAllBEFORE paginating; usespager.slice(sortedAll); appendspager.controls(sorted.length, rows.length)after the table; no longer contains the pre-v1.30rows.slice(0, 200)truncation.i18n-dict.jsdoes NOT carry the stalescan.shownTopkey.api.jsstill exports thepaginate()helper with.slice / .controls / .resetsurface. - 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.
- Static-source canaries (7): scan.js declares
- 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 cause — public/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):driveOnenow accepts afinalparam (defaulttrue). Thedonepayload carriesfinal: <bool>. Thesource=bothbranch passesfinal: falseto the first phase,final: trueto the second. - Client (
public/js/api.js:148-172):stream(...)closes theEventSourceondoneonly whendata.final !== false. Backward-compatible: legacy single-phase producers (/api/stream/batch,/api/stream/pdf*, etc.) don’t setfinal, so the behaviour is unchanged. Close onerrorremains 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→ 1done(final:true);source=regional→ 1done(final:true);source=both→ 2doneevents withfinal:falsethenfinal:true;source=both→ 2startevents inen-scanner/ru-scannerorder; static canary that the pre-v1.29.2 unconditional close pattern is gone; static canary that the v1.29.2data.final !== falseguard is present. - Functional proof of fix (3 cases):
source=bothactually emits the RU-phase banner line (proves the body runs, not just empty shells);ru-scannerstart arrives AFTERen-scannerdone (ordering);dryRun=1does NOT modifydata/pipeline.md(no phase secretly flipswriteFiles). - Pure-logic close-decision table (1 case, parametrized over 11 inputs): mirrors the api.js branch in JS — covers
donewithfinal:false / true / undefined / null / 0 / 'false', pluserrorwith payload/null, plusstart/log(never close). - Bug-forensics (1 case): simulates the pre-v1.29.2 client by cancelling the response stream after the first
doneand verifies the server’sres.on('close')abort handler is still intact (documents the pre-fix mechanism for future readers).
- SSE contract (6 cases):
- 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, theHH_USER_AGENTenv-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. Handlesarticleanddiv-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, theGET /api/scan/sourcesendpoint, and the SPA’s source-filter dropdown. Adding a 12th adapter = one entry here + one adapter file + one row inRU_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 withCache-Control: max-age=60. The SPA fetches this on#/scanmount 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/sourcesand paints<option>entries. Build-time hardcoded fallback list survives if the endpoint is unreachable. The filter chip in#/scannow 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 whenportals.ymlomits the array) now pulls fromregistry.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 overRU_DISPATCHthat’s keyed by the registry. Adding a sixth source = no scanner-loop edit.
- Default
📝 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.ymlenablement); 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 locales—russian_portals.sourcesexample 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,onlyRemotefilter, 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,onlyRemotefilter.test(scan): tests/scan-sources-endpoint.test.mjs— 4 cases: shape, RU-source list parity (5 entries), EN-source list parity (6 entries),Cache-Controlheader.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) andtests/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.1Router.go('/evaluate?url=…')produced a hash whose firstsplit('/')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 viawindow.location.hash.split('?')[1]+URLSearchParams(seeevaluate.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 indocs/help/<locale>.md §16(troubleshooting) andserver/lib/ru-scanner.mjsstill 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.jsmust split off?before name lookup), explainer-comment canary (the v1.28.1 fix rationale stays in the source), and pure-logic simulation ofcurrent()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/healthno longer surfacesHH_USER_AGENTrow; 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.mjs—GET /api/stream/batch?retry=1&maxRetries=N: parses viaparseInt, range-validates1 ≤ 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) inpublic/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 ownCLAUDE.md/AGENTS.md/GEMINI.mdshim files, not about career-ops upstream) deliberately retains its wider list (Cursor / Aider / Gemini CLI) since those CLIs do drive our shims.
- 8 help-bundles (
🧪 Tests
test(canonical-docs): AI-list regression canaries— 2 new canaries intests/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), withoutretry=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-opsmodes/oferta.md. Tracked inqa/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-opsmodes/oferta.md. Tracked inqa/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: 44pxto.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.2so 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-boxto keep parent flex rows from squashing the button under their own height constraints. .btn-smkeeps its existing 32 px floor (small-control exception per WCAG 2.5.5 + 2.5.8 spaced-target) —.btn-smfollows.btnin source order so the override applies.
- Adds
🧪 Tests
test(wcag): tests/wcag-target-size.test.mjs— 4 static CSS canaries:.btnblock hasmin-height: 44px.btnblock hasflex-shrink: 0.btn-smkeepsmin-height: 32px.btn-smdefined AFTER.btnin 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 + tempCAREER_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).
- Tier 1 (unit) — pure helpers (
- 100% line coverage target is explicitly scoped to working functionality — the
if (isMain) { … }boot block inserver/index.mjsand live-LLM call paths inauto-pipeline.mjsare 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/jdsshape on empty + populated statePOST /api/jdswith explicit slug, auto-generated slug, slug normalization warningPOST /api/jdsempty-body / stripped-to-empty-slug 400 responsesGET /api/jds/:namebody roundtrip, 404 on unknown, traversal rejectionserver/lib/routes/jds.mjscoverage: 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 eventserver/lib/routes/auto-pipeline.mjsbranch 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:POST /api/jds— save raw JDGET /api/jds— confirm in listGET /api/jds/:name— read body verbatimPOST /api/evaluate(manual fallback) — generate promptPOST /api/tracker— add rowGET /api/tracker— verify presenceGET /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 testnow runstests/*.test.mjs tests/acceptance/*.test.mjs.npm run test:coveragesame.npm run test:ciruns 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 isstreamNodeScriptspawn ofbatch-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. Passingmode: 'manual'(mirroring/api/evaluatesince v1.10.2) was silently ignored, the request hung 1-3 min on Anthropic. Now the handler:- Accepts
modeANDevalModefor 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. donepayload carries{ mode: 'manual', prompt: <buildEvaluationPrompt scaffold>, message }— the SPA can render it like the existing/api/evaluatemanual-prompt card.
- Accepts
- Closes DoS-risk on
HOST=0.0.0.0: previously, even withllmRateLimitcapping 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. - Tests —
tests/auto-pipeline-manual-mode.test.mjs: 3 tests confirm (1)mode: 'manual'returns < 2 s with all 5 step keys, (2) even withANTHROPIC_API_KEYset the short-circuit still fires (the original symptom), (3) legacyevalMode: '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 inCHANGELOG.{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 intonpm 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.autoPipelinecarried a leading✨in every locale string ANDpublic/js/views/dashboard.js:58prepended 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-opsproject (rewritemodes/oferta.mdto 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.jsalready accepts arbitrary block letters since v1.13). Tracked for the next release window when parent + child can land together. - G-003 —
README.cn.md→README.zh-CN.mdrename — verified during v1.25 prep: repo already hasREADME.zh-CN.md(no orphanREADME.cn.mdanywhere 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:cinow runsnpm 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 theElement.prototype.alsoglobal monkey-patch and migratedcv.jsto a free-statement pattern, but missedconfig.js. The result:#/configcrashed at first invocation on every locale withc(...).also is not a function. v1.24.1 applies the same migration pattern fromcv.js:188-201— extract the tree to aconst root = c(...), run the activation block on its own, thenreturn root;.
🛡️ CI gate
feat(ci): scripts/check-no-also-leftovers.mjs sweep— walks every file underpublic/js/views/and fails the build on any.also(call site (commented references allowed). Wired into the newnpm run test:ciscript. 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.jsvianode: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
- parse
- 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 pertracked_companiesentry. - §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>toSubmitted.withEvaluated → Appliedauto-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-parityasserts 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.mjsenforces). 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 againsthttp://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-helperchecklist✅ PASS (body contains career-ops apply+auto-submitwarning)31.4 /api/batchendpoint✅ PASS (keys [exists, runnerExists, raw, rows, additions])31.5 Playwright availability ✅ PASS ( /api/healthreportsPlaywright (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-flightsetTimeoutwas 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 onmain.v1.23.0 reshapes the polling loop:
_healthHandleis tracked sosetConnectionState(lost=true)canclearTimeoutand re-schedule with_HEALTH_MIN. The first recovery probe now fires within 3 s of going down, regardless of what delay was queued._HEALTH_MAXlowered 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 andconnectionLost === 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.23public/js/lib/i18n.jswas 639 LOC. The bulk (lines 23-586) was theDICTtranslation table — pure structured data. v1.23.0 extracts that topublic/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), leavingpublic/js/lib/i18n.jsat 86 LOC of pure module logic (well under the 400-LOC target).- Loader contract:
i18n-dict.jspopulateswindow.__I18N_DICT = { … }, theni18n.jsreads it inside the existing IIFE.public/index.htmlloads them in order —i18n-dict.jsbeforei18n.js— so the IIFE sees a fully-populated DICT at construction time. Missing-dict fallback: everyt()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.mjsnow 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.23CHANGELOG.{es,pt-BR,ko-KR,ja,ru,zh-CN,zh-TW}.mdcarried 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 onlyREADME.pt-BR.mdreferenceddashboard-pt-BR.png; the other 6 non-EN READMEs still pointed atdashboard-en.png. The screenshots (already captured in v1.22.0 cycle byscripts/capture-dashboard-screenshots.mjs) were present inimages/but unused. v1.23.0 updates everyREADME.{es,ja,ko-KR,ru,zh-CN,zh-TW}.mdline 14 to its owndashboard-<locale>.png.
🧪 Tests
- Same 474 / 474 unit + 32 / 32 Playwright as v1.22.0. Smoke E2E now 20 / 20 (was 19 / 1 fail on
mainafter 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.<script>,javascript:, and<img src="data:image/svg+xml,<svg onload=…>">slipped through. The strip now decodes<,>,&,", numeric (&#NN;) and hex (&#xHH;) entities before the strip regex runs. Validated by 11 tests intests/cv-xss-bypasses.test.mjs. Real defense is still the client-sideUI.mdescape-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. Nowspawn('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 asetTimeoutchain (notsetInterval) 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 throwsSecurityErroron everylocalStorage.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 thedetect()browser-language fallback. -
fix(server): M-2 — body-size cap on outbound preview fetches (test + verify)— the v1.21.0safeGetalready streamed chunks and capped atopts.maxBytes. v1.22 adds an explicit regression test intests/ssrf-redirect-rebind.test.mjsto 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 msrefreshResults()timer used to leak when the user navigated off#/scanin 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 usedmatch()(single-line). Per spec, an event may carry multipledata: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 wiredhtmlFor → idfor 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 thefield()builder now renders a<p id="…-hint">witharia-describedbywiring 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: nullused to render literalfor="null". One-liner mirror of the fallthrough branch’sv != null && v !== falseguard.
🧹 Quality / portability
-
fix(server): L-1 — parseInt radix in health.mjs + bin/start.sh + bin/setup.sh—parseInt(process.versions.node)without radix triggers a lint warning and is brittle if Node ever ships hex versions. Added10everywhere. -
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— addedsafe-fetch.mjs(DNS-pinned GET),file-lock.mjs(per-path mutex),rate-limit.mjs(LLM throttle), andsanitizePathNameto 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”;parseIntradix 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('<script>alert(1)</script>')))"
# → '' (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 explicitdnsLookupfor validation, then letfetch()do its own independent lookup. A DNS rebind attacker with TTL=0 could return a public IP on lookup 1 and127.0.0.1/169.254.169.254/ a LAN address on lookup 2, bypassingisPrivateOrLoopbackHost. The newsafeGetresolves 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/previewand/api/auto-pipeline. Fail-CLOSED on lookup error (reverses the priortry { … } catch { /* fall through */ }). Validated by 8 new tests intests/ssrf-redirect-rebind.test.mjs. -
fix(security): H-4 — consolidate sanitizePathName across 10 routes(server/lib/security.mjs) — the barereplace(/[^\w\-.]/g, '')regex was duplicated acrossjds.mjs,content.mjs,reports.mjs,llm.mjs,runners.mjsand kept.characters, so..pdf,....md, leading-dot names survived. Onlyreports.mjs::sanitizeSlugdid it right. v1.21.0 hoists the correct version (sanitizePathName) intosecurity.mjs, deletes 10 broken copies, and rejects empty results with 400. Validated by 12 tests intests/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-pipelinepreviously had no per-IP throttle. Loopback users are unaffected; LAN-exposed deploys (HOST=0.0.0.0) get 10 req/min/IP withRetry-AfterandX-RateLimit-*headers on overflow. Configurable viaLLM_RATE_LIMIT="N/Ws". Cheap interim defense ahead of the v2.0 P-12 auth gate. Validated by 6 tests intests/rate-limit.test.mjs.
🔒 Concurrency
fix(data): H-6 — per-file mutex on applications.md / pipeline.md(server/lib/file-lock.mjs) — concurrentPOST /api/tracker(or auto-pipeline racing a manual add) used to both readnum=42, both writenum=43, and silently drop the earlier row.withFileLock(path, fn)serializes read-modify-write per path; independent paths still run in parallel. Wired intotracker.mjs,pipeline.mjs(POST + DELETE), andauto-pipeline.mjstracker step. Validated by 5 tests intests/concurrent-tracker-write.test.mjsincluding 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 addedaria-describedby="batch-tsv-hint"to the TSV textarea but never gave the hint<p>a matchingid. 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 everyaria-describedby/htmlForIDREF points at a siblingid: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, pluscommon.delete,config.group{Core,Runtime,Regional},config.profileEmpty,config.viewProfile,scan.atsBadge,scan.regionalBadgewere referenced viat('key', 'EN fallback')but never added to DICT. Russian, Japanese, Chinese screen-reader users heard Englisharia-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 extendstests/i18n-coverage.test.mjswith a static-analysis pass that scans everyt('key', …)call inpublic/js/**/*.jsand 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.mjsrewired fromglobalThis.fetchmock to the new_setTransportinjection point insafe-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) |
<script>, javascript:, <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)—.chipwas 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.chiptomin-height: 28px; padding: 6px 12px;and the wrapping.chip-rowtogap: 8px;so both conditions hold.a11y(touch-target): sidebar nav-item min-height 44 px—.nav-itempadded only10px 14px, computed height ~36 px on most viewports. Nowpadding: 12px 14px; min-height: 44px; box-sizing: border-box;. Matches the.btnfloor.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-keyid+ hint association (cfg-<key>/cfg-<key>-hint).a11y(forms): evaluate.js—eval-jdtextarea +eval-jd-hintparagraph documenting the 50-char minimum after sanitization.a11y(forms): batch.js—batch-tsv/batch-tsv-hint, plusaria-labels onbatch-parallel,batch-min-score,batch-dry-run,batch-retry.a11y(forms): pipeline.js—pipe-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) getsmode-<slug>-<name>ids andhtmlForlabels.
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 master—README.{es,pt-BR,ko-KR,ja,ru,zh-CN,zh-TW}.mdwere 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/configis the only path now. Removed: route registration inserver/lib/routes/scan.mjs, doc references inREADME.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-describedbyattributes) — 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#161a22paper.- Badge classes (
.badge-ok,.badge-warn,.badge-bad,.badge-info) and score pills (.score-high,.score-mid,.score-low) now route through the new*-textvariants — 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/configkept 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 setHH_USER_AGENTdirectly incareer-ops/.env(the server reads viaprocess.env.HH_USER_AGENTinserver/lib/sources/hh.mjswith 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.hhWarningi18n key was rephrased to drop the env-var setup detail. KEY_GROUPScollapsed: no moreregionalclassification (it only had HH_USER_AGENT). Tests updated;regionalActivepayload field preserved for SPA back-compat.
🧪 Tests
tests/env-config.test.mjs—KNOWN_KEYSassertion now excludes HH_USER_AGENT; new assertion that the key is intentionally absent.tests/config-endpoint.test.mjs— POST-write multi-key test usesGEMINI_MODELas the second known key instead of HH_USER_AGENT.tests/config-groups.test.mjs—groups.HH_USER_AGENTis now expectedundefined.- 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|bothendpoint (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-linkuntil it receives focus, snaps to the top-left corner on Tab from page load. - WCAG 2.4.7 Focus Visible — global
*:focus-visiblestyle. 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-smkeeps 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 fromlang="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 —
#contentgetstabindex="-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 inCHANGELOG.{es,pt-BR,ko-KR,ja,zh-CN,zh-TW}.mdare 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 compatrewritten — 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 useShift+Enterfor 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 tobatch-prompt, but the server’sPOST /api/mode/:slugwas then looking formodes/batch-prompt.mdwhich doesn’t exist. NewserverSlugfield 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-sidelastWorkdayFallbackexport from v1.16 PR-7 is now consumed by the SPA./api/scan-resultsreturns the snapshot;#/scanrenders a warn-tinted card above Active Companies when a Workday tenant fell back (“🔒 Workday tenant blocked — fallback: use /career-ops scan (Playwright)”). NewgetLastWorkdayFallback()exporter avoids ESM live-binding ambiguity. 2 new i18n keys × 8 locales.
♿ Accessibility
a11y: ARIA roles + focus management pass on critical surfaces—index.html:roleattributes 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-togglegetsaria-controls="sidebar"+aria-expandedsynced by JS on open/close.#global-searchgets a visually-hidden<label>plus an explicitaria-labelthat surfaces the Cmd+K shortcut hint.- Modal close (×) gets
aria-label="Close dialog". - Decorative backdrops get
aria-hidden="true". - Focus trap on modal —
UI.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-hiddenutility class inpublic/css/app.css(WAI-ARIA AP standard pattern).
📚 Documentation
docs(readme): badge truth across 8 READMEs— tests badge284 / 379 / 360→ 427; release badgev1.9.1 / v1.13.0→ v1.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 inCHANGELOG.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-pipelineSSE 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-pipelineis 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 arunningevent instead of a generic spinner. Failures emiterrorwithstep+message. The orchestrator also persists the report markdown to parentreports/<slug>.md(was lost in v1.15).feat(reports): POST /api/reports primitive— new writer endpoint inserver/lib/routes/reports.mjs. Slug sanitization with path- traversal guard (strip leading dots, collapse internal...). 1 MB cap (413). 409 on existing file unlessoverwrite:true. Atomic write throughstripDangerousMarkdownXSS 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 withautoStart=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.mjswalks pages via?limit=100&offset=NuntiltotalFoundis 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.mjsno longer throws on 4xx / non-JSON / network errors. Returns[]and annotates the new exportedlastWorkdayFallbacksnapshot. Scanner timeline continues with the next tenant. Caller can opt back into the v1.14 throw behaviour withstrict:true. Tests: 7 cases.
🛠️ Tooling + CI
ci(workflows): dashboard-screenshots drift gate(#5) — new.github/workflows/dashboard-screenshots.yml. On PRs touchingpublic/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 indocs/portals-examples.mdvia their real boards-API and emits paste-ready YAML for the user’s parentportals.yml::tracked_companies.enabled: falseis stamped on any candidate whose slug 404s. Live probe of all 6 ATSes (Greenhouse / Ashby / Lever / Workable / SmartRecruiters / Workday). Run vianpm run import:trending.feat(scripts): npm run capture:dashboards— exposesscripts/capture-dashboard-screenshots.mjsas a top-level script (was only documented inimages/README.mdbefore).
🎨 UX
fix(scan): consolidated source-filter dropdown(#6) —#/scansource dropdown rebuilt from the v1.14 adapter registry: 6 ATSes + hh.ru + Habr Career, alphabetical, no geo-tag prefix.runEnScan/runRuScannow 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 inCHANGELOG.{es,pt-BR,ko-KR,ja,zh-CN,zh-TW}.mdare 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 inCHANGELOG.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-streamContent-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/_profilewith 256 KB cap,stripDangerousMarkdownXSS pass, scaffold from_profile.template.mdon first read. 9 new i18n keys × 8 locales.feat(profile): PR-E — accept canonical schema; add location + headline(G-009)/api/profilenow accepts BOTH the legacy (candidate:{...}) AND canonical (top-levelfull_name,narrative.headline,target_roles.primary,compensation.target_range) schemas. Legacy wins when both are present so existing YAMLs render identically. NewsummarizeProfile()helper returns unified shape./#/profilesurfacesnarrative.headlineas 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 slugbatch→batch-promptwith 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.subtitlei18n key × 8 locales also realigned. Score + legitimacy now documented as report-header fields. ⚠ Parent commit still required:santifer/career-ops::modes/oferta.mdneeds 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 asearch_queriesexample block with translated 1-paragraph intro clarifying it drives only the AI-powered Option B scan.bin/setup.shportals.yml scaffold seedsseniority_boost: ["Senior", "Staff", "Lead"]by default. H2 parity preserved: 16 × 8 locales.docs: PR-I — localized hero images per README localeEach of 8 READMEs now has a locale-specificimages/dashboard-<locale>.png(HiDPI 1440×900) generated viascripts/capture-dashboard-screenshots.mjs(Playwright + chromium). Old sharedpublic/images/screen_vacancy_found.pngdeleted. Non-EN readers see their UI labelled in their language on first landing.
🧹 Carryover cleanups
PR-G — G-001scan.noResultsi18n 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-003README.cn.md→README.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-runow 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-importpattern (matchingtests/batch-endpoints.test.mjs) so they no longer mutate the user’s real parentconfig/profile.yml. NOTE for users: if yourconfig/profile.ymllooks 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) andserver/lib/sources/{workable,smartrecruiters,workday}.mjs(raw HTTP + response normalization to the canonical{ id, title, company, url, location, isRemote, … }shape withsource: <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>ANDcareers.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. Defaultssite=Externalwhen 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).
- Workable: detects
📚 Docs
docs(portals-examples): trending boards block—docs/portals-examples.mdextended with v1.14.0 section listing 13 trending companies as ready-to-paste YAML fortracked_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 usesenabled: falseso 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 coverage—qa/claude-cowork-browser-test-prompt.mdextended with Scenario 19:ALL_ADAPTERS.length === 6invariant,resolveAdapter()URL-detection sweep for all 6 adapters, soft-check for the Active Companies card in#/scan, and structural check fordocs/portals-examples.mdblocks per ATS.
🧪 Tests
tests/adapter-registry.test.mjsextended 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 === 6invariant,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/importnow accepts BOTH the original octet-stream contract (Content-Type: application/octet-stream+X-Filename) ANDmultipart/form-dataproperly 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 sameimportDocumentToMarkdownconverter +stripDangerousMarkdownXSS pass. New dep:multer ^2.1.1.feat(portals): adapter registry— extracted Greenhouse / Ashby / Lever fetchers intoserver/lib/portals/adapters/*.mjswith a uniform contract (id,label,matches,buildEndpoint,fetch). Newserver/lib/portals/registry.mjs::resolveAdapter()is the single dispatch surface.en-scanner.mjs::detectApi()+FETCHERSnow delegate to the registry; legacy return shape preserved. To add a new ATS: drop a file underadapters/, append toALL_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 forbatch/batch-input.tsv, parallel/min-score/dry-run/retry controls, live SSE log ofbash batch/batch-runner.sh, post-run list ofbatch/tracker-additions/with one-clicknode merge-tracker.mjs. Sidebar link under Decision group. 21 new i18n keys × 8 locales.feat(prompts): locale-aware mode scaffolding—buildModePrompt+buildEvaluationPromptnow 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’smodes/<slug>.mdbody 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/#f7f7f7replaced withvar(--beach)/var(--paper)/var(--slate)tokens so the dark palette swap actually reaches table rows and tab buttons. Adds.row-boostedaccent 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, explicitapi:field priority, null on no match, legacydetectApi()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/buildEvaluationPromptintegration, English back-compat. tests/cv-upload-multipart-reject.test.mjsrewritten — 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 tolocalStorage.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. Honorsprefers-color-schemefor first-time visitors. Full dark palette under[data-theme="dark"]inpublic/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)— bothen-scanner.mjsandru-scanner.mjsnow readportals.yml::title_filter.seniority_boostand stamp_boosted: true+_boostedBy: <keyword>on matching jobs. SPA sorts boosted rows to the top of#/scanresults and renders a⬆ boostedbadge 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-C3previously failed when the parent’stemplates/portals.example.ymldrifted 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 listKNOWN_DEADis 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;#/reportsview 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|bothdispatches correctly; unknown source emits an error frame; legacy/api/stream/scan-en+/api/stream/scan-rustill 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 flow—cp templates/portals.example.yml portals.yml; canonical schema fortitle_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 scaninside any AI CLI) for non-API discovery. Output todata/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 trackerEvaluated → Applied). Batch runner via./batch/batch-runner.shwith--parallel/--min-score/--retry-failed. Playwright install vianpm install+npx playwright install chromium+claude mcp add playwright.
- §5 Portals →
- All 8 bundles preserve the 16-H2 parity contract (
tests/help-ui.test.mjs::section-paritystays 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 tocareer-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,#/applyPlaywright link,#/reportsscore-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-matterAbout career-opssection 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.mjsparity stays green). - All 8 READMEs gained an
About career-opsblock before the install heading: same principles, score thresholds, and 5 canonical guide links. TheWhat's new in v1.10.xhistory 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 keysapply.playwrightHint+apply.docsLinklocalized 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.mjssection-paritytest 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 sharedpublic/js/lib/pdf-generate.jshelper. 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 underoutput/<slug>-<TS>.pdfso the existing auto-download flow takes over.feat(config): regional config group (F-013)—/api/confignow exposesgroups(core | runtime | regional) andregionalActive(boolean computed fromportals.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) andSyntaxErrorfromexpress.jsonnow return JSON envelopes the SPA can localize (HTTP 413 / 400). Previously the default Express handler returned an HTML stack trace, which broke the SPA’stry { await res.json() }.fix(i18n): English tokens no longer leak into non-EN UI (F-001)— added localizations forPipeline,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#/scansummary 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 ascan:refreshevent after eachrefreshResults(); the counter re-derives “companies with hits in last scan” from the actual/api/scan-resultspayload instead of staying frozen at the view-mount snapshot.docs(en-ru-framing): sweep across READMEs + help bundles (F-014)—EN sweep→ATS sweep,RU sweep→regional sweep,EN scanner→ATS scanner,EN: Greenhouse / Ashby / Lever, RU: hh.ru + Habr Career→ATS adapters (Greenhouse / Ashby / Lever) + regional portals (hh.ru / Habr Career). TouchesREADME.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/configexposesgroups;regionalActiveflips on when portals.yml gains a regional source. - New
tests/pdf-extra-routes.test.mjs(5 cases): each of/report,/deep,/inlineinvokesgenerate-pdf.mjswith 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 tomultipart/form-datapreviously had its wire envelope (--boundary…\r\nContent-Disposition: form-data; name="file"; filename="x"…) stored ascv.mdcontent. 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.mdis 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 itsUsage:line and exited code 1 — SPA showed the green “PDF generated” toast but no file ever reached disk. The route now readscv.md, renders it to an HTML file underoutput/cv-input-<TIMESTAMP>.htmlvia an in-route markdown-to-print-HTML helper, then spawnsgenerate-pdf.mjs <input.html> <output.pdf> --format=a4. Optional?format=letterquery for US-letter output. Whencv.mdis missing, emits anerrorevent +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 modifycv.md. - New
tests/pdf-stream-args.test.mjs(3 cases):startevent carries<input.html> <output.pdf> --format=a4with absolute paths and the HTML exists on disk;?format=letterswitches the flag; missingcv.mdemits the expected error frame. - Total: 340 unit tests (was 318). One pre-existing failure in
portals-dead.test.mjsremains 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)—isValidJobUrlnow rejects RFC1918 (10/8,172.16/12,192.168/16), the full 127/8 loopback range, link-local169.254/16(incl. AWS IMDS),0.0.0.0, CGNAT100.64/10, and IPv6 ULA / link-local. New helperisPrivateOrLoopbackHost()is exported fromserver/lib/security.mjsand reused by/api/pipeline/preview, which nowdns.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 onres.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)— successfulPUT /api/profileandPOST /api/configcalls 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 isko-KR.md. Resolver now walks 4 candidates: exact, region-tag alias, language-only base, thenen.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)— newresolveLocale(req)picks a locale frombody.lang→body.locale→Accept-Language→'en'. NewbuildLocaleDirective(lang)emits a one-line “Respond in X” header.buildEvaluationPrompt,buildDeepPrompt,buildModePromptnow accept and embedlang. SPAAPI.call()auto-attachesAccept-Languageand mergeslanginto JSON bodies.feat(scripts): post-qa-cleanup.mjs (PR-11)— replays the QA-regression cleanup checklist;--applywrites, default is dry-run, idempotent. Sweeps RFC1918 /nip.io/test-cloud-*URLs fromdata/pipeline.mdand auditscv.mdsize.
🧪 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-2resolveLocaleprecedence +buildLocaleDirective+ prompt-builder integration. tests/url-validation.test.mjsextended with 5 new tests for RFC1918 / link-local / 0.0.0.0 / 127/8 / CGNAT / IPv6 ULA / link-local.tests/activity-log.test.mjstest 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.mjsis parent-side data drift intemplates/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— newPOST /api/cv/importendpoint 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 throughstripDangerousMarkdown(defense-in-depth XSS). Hard cap: 10 MB per upload. Frontend📁 Upload CVnow 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 ondonetriggers 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—#/confignow has a tab strip. The first tab keeps the existing.enveditor (API keys, models, scanner knobs). The new Profile tab is a direct YAML editor forconfig/profile.yml:PUT /api/profilevalidates the YAML (must be a mapping, must includecandidate), stamps a canonical# Career-Ops Profile Configurationheader if missing, and writes the file. Save propagates without restart.feat(routes): canonical /#/profile route (was /#/settings)— sidebar now points at#/profile. The old#/settingshash 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/.txtpassthrough, 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.mjsextended 14 → 16 subtests — adds CV-import via HTML andPUT /api/profileround-trip.tests/router.test.mjsALIAS regex flipped to assert the newsettings → profiledirection.
📚 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.mjsgainsPOST /api/cv/importandPUT /api/profile(binary-safe viaexpress.rawfor 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 inparsers.mjs::parseMarkdownTableadds GFM-compliant\|escape support so the round-trip is lossless.fix(config): wrap updateEnvFile in try/catch (BF-2)—POST /api/configpreviously 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/:slugAnthropic branches now bail with 413 whenbundleProjectContext + promptexceeds 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/configGET/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 forjds/*.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/trackerGET + 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 sectionswas 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% ofen.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/deepand/api/mode/:slugpreviously told the model “read these files first” but the Anthropic SDK has no filesystem. Output was hollow. NewbundleProjectContext({ modeSlugs })readscv.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 fromclaude-sonnet-4-6for a deep-research call.fix(runner): SIGKILL escalation after SIGTERM grace period (REVIEW-A2)—runNodeScriptandstreamNodeScriptpreviously sent onlySIGTERMon 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 toSIGKILL. 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: emitevent: 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 fromredirect: 'follow'to manual redirect-walking. EachLocationheader is re-validated byisValidJobUrl; 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)— directprocess.env.GEMINI_API_KEYreads in route handlers replaced withhasGeminiKey()fromlib/anthropic.mjs. MirrorshasAnthropicKey()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.runRuScanandrunEnScanacceptopts.signal; SSE handlers in/api/stream/scan-{ru,en}create anAbortControllerand abort onres.close.test(anthropic): log-guard test prevents future API-key leaks via console (REVIEW-B4)— captures everyconsole.{log,info,warn,error,debug}call duringrunAnthropichappy + error paths, asserts zero output and that the canary key string never appears. Defense-in-depth against a futureconsole.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-levelisValidJobUrl. Optionalopts.validatefor 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)— whentests/i18n-coverage.test.mjsfinds a gap, output is now[ru] (3): foo, bar, bazinstead of mixed lines.docs(review): C1 closed as resolved-on-inspection— sanitizer regexes were already in\x00-\x08hex 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.mjs—isValidJobUrl,stripDangerousMarkdown,sanitizeJobDescription,isPubliclyExposed. Re-exported fromindex.mjsfor backward-compat with external consumers.server/lib/prompts.mjs—bundleProjectContext,buildEvaluationPrompt,buildDeepPrompt,buildModePrompt,buildApplyChecklist.server/lib/store.mjs—safeReadApps,safeReadPipeline,safeListReports,checkProfileCustomized,ensureRussianPortalsDefaults.server/lib/routes/scan.mjs—registerScanRoutes(app)for/api/stream/scan-{ru,en},/api/scan-ru/config,/api/scan-results.server/lib/routes/runners.mjs—registerRunnerRoutes(app)for buffered/api/run/*table, streaming/api/stream/{scan,liveness,pdf}, generated-PDF list/download.server/lib/routes/content.mjs—registerContentRoutes(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_ALLOWLISTdeliberately surfaces only 7 (others are Claude-Code-only). Comment added explaining the intentional narrow scope. portals.ymlschema confirmed:tracked_companies(96 entries, 87 enabled, 71 with API). EN scanner reads it correctly; legacycompanieskey 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-enall 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 togsd-*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 forserver/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 forrunAnthropiclog-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’snode_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) — editANTHROPIC_API_KEY,ANTHROPIC_MODEL,GEMINI_API_KEY,GEMINI_MODEL,HH_USER_AGENT,PORT,HOSTfrom the browser. Writes to the parent project’s.envfile 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 withrun: 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🌐 Scanbutton 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-opentoggles 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/healthnow reads web-ui’s ownpackage.json. The footer no longer leaks a stale1.6.0from the parent’s version file. Parent’s VERSION is still surfaced separately asparentVersion.
📦 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 referencescv.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 forparseEnv,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
/#/pipelineoverhaul — left list + right preview pane. Click any URL to fetch a server-side proxied snapshot (GET /api/pipeline/previewstrips scripts/styles/tags, caps at 8 KB, validated throughisValidJobUrl). 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 viadata-url+.pipeline-row+.pipeline-row-deleteclasses. 8 new tests intests/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 viaANTHROPIC_MODEL). WhenANTHROPIC_API_KEYis 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 intests/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— addedserver/lib/dotenv.mjs(35-line zero-dep loader) wired in at the top ofserver/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..sidebarnow hasoverflow-y: autowith thin custom-styled scrollbars.fix(ui): make HH_USER_AGENT banner dismissible— then removed entirely from/scanonce 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 events—workflow_dispatchfrom main no longer fails the tag/version check.fix(e2e): stable selector for pipeline row delete— restored anchor wrapper + addeddata-urlattribute 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/cvnow strips<script>,<iframe>,<object>,<embed>,<style>,<form>,<svg>,on*=event handlers, andjavascript:/vbscript:/data:text/htmlURIs before writingcv.md. Body capped at 1 MB (413 on overflow). Client-sideUI.md()was rewritten to escape every byte before any markdown transformation runs, so raw HTML can never reachinnerHTML. Linkhrefattributes are validated against an allowlist of safe schemes (http/https/mailto/tel/relative +data:imageonly). 17 new tests across the strip helper and HTTP round-trips.fix(server): add CSP and baseline security headers(FIX-L2) — every response now carriesX-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: same-origin. When the server binds beyond loopback (HOST≠127.0.0.1/::1/localhost), a strictContent-Security-Policyis layered on top:default-src 'self',script-src 'self'(nounsafe-inline), Google Fonts whitelisted,connect-src 'self'blocks XSS exfiltration. Inlineonclickhandlers inindex.htmlandrouter.jswere moved toaddEventListenerto keep the strict CSP intact. 8 new tests gating CSP across 5 differentHOSTvalues.fix(api): tighten pipeline URL validator(FIX-M7) —POST /api/pipelineused to accept"not-a-url"and persist it. NowisValidJobUrl()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 adedupedflag 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 ofserver/index.mjs. Process-env values set on the command line still win, so existing CI overrides aren’t shadowed. Parent’s.env.examplenow includes a documentedHH_USER_AGENTblock with a real-Chrome User-Agent example. 6 new tests.fix(api): sanitize JD before prompt assembly(FIX-M5) —POST /api/evaluatestrips 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/healthno 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’smodes/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 endpointPOST /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 intests/modes-endpoints.test.mjs.fix: bootstrap parent deps + russian_portals defaults(FIX-C4 + C9 + C12 + H2) —bin/start.shnow installs the parent’snode_modules(js-yaml, playwright, jsdom) ANDnpx playwright install chromiumon fresh clones, so/api/stream/scan,/pdf, and/livenesswork end-to-end out of the box.createApp()probesportals.ymlon every boot — if therussian_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.ymlnow ships with Ada / Factorial / Tinybird / Weights & Biases / Travelperk / Clarity AI / Forto / Vinted / Runway flaggedenabled: false(each entry has an inline reason comment). New installs scan 87 alive companies instead of 96. Newweb-ui/scripts/portals-health-check.mjsHEAD-probes every enabledcareers_urland 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 todata/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 forGET /api/activityitself.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 }andGEMINI_API_KEYis set, persisting output tointerview-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/pdfin a modal console. OnERR_MODULE_NOT_FOUND/playwrighterrors, it surfaces a copy-pasteable bootstrap command. New “Generated PDFs” section auto-loads after each successful run, listing everyoutput/*.pdfwith ↗ 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 todata/applications.mdfrom the browser. Validates company + role, normalizes status againsttemplates/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/passwdrefusal.feat(api): POST /api/evaluate/test-gemini(FIX-H7) — smoke-test endpoint that runs a 50-char dummy JD throughgemini-eval.mjsso 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-xyzrenders 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. Newtests/i18n-coverage.test.mjsrunsi18n.jsinside avm.Contextwith a stubwindow, exposes the privateDICT, 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 issettings(withnav.settingsrendering “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/healthexposes everything Doctor reports (parent-deps, Playwright, dirs, profile-customized,HH_USER_AGENT). TheProfile customizedcheck 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) — whenportals.ymlships with"PHP"intitle_filter.negativewhile the queries target Senior PHP, every match gets filtered and the user sees zero results.loadConfig()now computes awarningsarray;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/scanpage probes/api/healthand shows a yellow warning card above the action row whenHH_USER_AGENTis 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 awarningfield; 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 onhashchange(with a guard for active typing). NewUI.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..sidebarnow hasoverflow-y: autowith thin custom-styled scrollbars (WebKit + Firefox). Footer stays pinned via the existingmargin-top: auto.fix(ui): empty modal-title placeholder(FIX-H9) — the hardcoded English"Title"string inindex.htmlis 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 bytests/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 viaskip-ai-reviewlabel.release.yml— auto-publish a GitHub Release when av*.*.*tag is pushed; release notes are sliced fromCHANGELOG.mdso all 8 language variants stay the canonical source.
- CSP-friendly UI: all inline
onclickhandlers removed fromindex.htmlandrouter.js. The strictscript-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.