Changelog

GitHub

Todas as mudanças relevantes, versão por versão. O mesmo arquivo é publicado no repositório e traduzido nos 19 idiomas.

[1.58.20] — 2026-05-20

fix(i18n/platform): I-6 — o atalho do rodapé mostra ⌘K no Mac e Ctrl+K nos demais sistemas, com o verbo localizado. Antes era o literal CTRL+K — search em todas as plataformas/idiomas. top.langhint agora usa {hotkey} — buscar; applyFooterHotkey() substitui {hotkey} pela combinação nativa via navigator.platform. 915 → 916 unitários. (I-6)


[1.58.19] — 2026-05-20

fix(i18n): I-4 — #/followup em russo já não vaza cadence / follow-up. Strings RU do modo followup (H1, hints) continham cadence, follow-up, scope, timeline. Substituídas por equivalentes russos nativos. 914 → 915 unitários. (I-4)


[1.58.18] — 2026-05-20

fix(i18n): I-3 — itens 2/5/13/14 do TOC da ajuda sem vazamento de inglês em locales não latinos. Antes da correção, alguns bundles ainda traziam ## 2. App settings & API keys, ## 5. Portals & Sources, ## 13. Mode prompts, ## 14. Apply checklist. Agora totalmente localizados nos 8 idiomas. 913 → 914 unitários. (I-3)


[1.58.17] — 2026-05-20

fix(i18n): I-2 — datas do Saved-research agora usam Intl.RelativeTimeFormat por locale. O helper formatRelative() em public/js/views/deep.js retornava today / 1d ago / Nd ago em inglês em todos os idiomas. Substituído por Intl.RelativeTimeFormat(I18n.getLang(), { numeric: 'auto' }) — string nativa do browser para “hoje/ontem/há N dias”. Datas > 7 dias caem em Intl.DateTimeFormat(locale, { dateStyle: 'medium' }). 912 → 913 unitários. (I-2)


[1.58.16] — 2026-05-20

fix(ui): tremor de hover nos botões da marca (reportado pelo usuário). Causa: o fundo padrão de .btn-primary / .btn-danger era um linear-gradient(...) e o :hover o trocava por uma cor sólida. CSS não interpola gradiente↔sólido, então a transição de 180 ms estalava e o usuário via um flash branco/rosa. Correção em public/css/app.css: manter o gradiente no hover e escurecer com filter: brightness(0.92) — filter interpola limpamente em qualquer browser. A lista de transition de .btn recebe filter var(--transition) para animar o escurecimento. 911 → 912 unitários. (reportado pelo usuário)


[1.58.15] — 2026-05-20

fix(a11y/i18n): I-1 — aria-label e <label> visualmente oculto da busca da barra superior agora localizados. Antes, leitores de tela em qualquer idioma ouviam o aria-label em inglês. Novo hook genérico data-i18n-aria-label em public/js/app.js — applyI18n() atualiza aria-label a cada troca de idioma, simétrico ao tratamento de data-i18n / data-i18n-placeholder. Duas novas chaves i18n (top.search.aria, top.search.label) nos 8 idiomas. O hook é reutilizável por qualquer controle futuro. 910 → 911 unitários. (I-1)


[1.58.14] — 2026-05-20

fix(ux): M-9 — botão Atualizar do banner de conexão agora dá feedback (antes era reload silencioso). Até v1.58.13 o handler chamava location.reload() direto. Agora exibe o toast Atualizando…, marca sessionStorage['refreshedToast'], desativa o botão para evitar dupla clique, e atrasa o reload em 200 ms para o toast aparecer. No próximo boot, app.js detecta a marca e emite o toast de sucesso Atualizado. 2 novas chaves i18n (common.refreshing, common.refreshed) nos 8 idiomas. 909 → 910 unitários. (M-9)


[1.58.13] — 2026-05-20

fix(ux): M-8 — o checklist de #/apply agora é interativo. Antes de v1.58.13, “▶ Gerar checklist” mostrava os itens 0…7 como bloco monoespaçado <pre> — apenas leitura, sem possibilidade de marcar. Agora cada item é um <input type="checkbox"> real, envolvido em <label> (área de clique ≥44 px, WCAG 2.5.5). O estado é persistido por URL em localStorage['applyChecklist:'+slug] — marca 3 itens, recarrega, os 3 continuam marcados. Botões: Copiar não marcados (copia itens em aberto como - markdown) e Redefinir. 5 novas chaves i18n (apply.checklist.copyUnchecked, resetBtn, copied, copyFailed, reset) em todos os 8 idiomas. Fallback defensivo caso o parser não encontre itens. 908 → 909 unitários. (M-8)


[1.58.12] — 2026-05-20

fix(ux): M-7 — a linha de custo agora segue o provedor ativo (OpenRouter não cai mais em um número fabricado). UI.providerCostHint() já consultava /api/status/providers, mas os mapas em public/js/api.js listavam apenas anthropic/gemini/openai/qwen. Após v1.57.0 com OpenRouter como 5º provedor, ele caía no fallback genérico de 0,03 e mostrava o literal openrouter em minúsculas. Agora EST inclui openrouter: null (o router escolhe o modelo — custo variável), e o ramo === null emite cost varies (router picks) localizado em vez do falso ~$0.03/eval. NAME adiciona openrouter: 'OpenRouter'. Nova chave i18n cost.varies nos 8 idiomas. 907 → 908 unitários. (M-7)


[1.58.11] — 2026-05-20

fix(ux): M-4 — espaçamento entre título e data no card de pesquisa salva agora é CSS estrutural (antes margem inline). A regressão MASTER de v1.58.3 confirmou que alguns cards mostravam software-engineer-generaltoday (sem espaço entre título e data) enquanto outros estavam OK — o código anterior dependia de style="margin-left: 8px" entre dois <span> soltos, que colapsava em certas entradas. Correção em public/js/views/deep.js — substitui os dois <span> por .saved-card__title + um <time class="saved-card__date" datetime="…"> semântico, envolvidos por um container flex .saved-card. O espaçamento agora é controlado por gap: var(--space-2, 8px), não colapsa (e ganha semântica a11y/SEO com <time>). 906 → 907 unitários. (M-4)


[1.58.10] — 2026-05-20

fix(ux): M-2 — descartar o toast de progresso antes de abrir qualquer modal de resultado. Clicar em sync-check em #/cv deixava o toast “Running cv-sync-check.mjs…” no canto inferior direito enquanto o modal de resultado abria — ambos disputando atenção e em telas estreitas sobrepostos. Os botões Doctor / verify-pipeline da página Health já chamavam UI.dismissToast() antes de UI.modal(); o sync-check de cv.js era o único ponto que omitia. Correção em public/js/api.js — UI.modal() agora chama dismissToast() como primeira instrução executável (defesa em profundidade no limite). As strings hardcoded em inglês de cv.js foram localizadas via t('cv.syncCheckRunning') / t('cv.syncCheck') (invariante BUG-008: título do modal == rótulo localizado do botão). Duas novas chaves i18n adicionadas nos 8 idiomas. 905 → 906 unitários. (M-2)


[1.58.9] — 2026-05-20

fix(a11y): M-1 — restabelecer um anel :focus-visible visível nos campos de formulário (WCAG 2.4.7 Nível AA). A regressão MASTER de v1.58.3 confirmou que getComputedStyle(focusedInput) retornava outline: rgb(255,255,255) none 1.5px — a palavra-chave none colapsava o anel para 0 px em cada campo. Causa raiz: as regras base .input, .textarea, .select { outline: none } e .searchbar input { outline: none } tinham maior especificidade do que *:focus-visible global e silenciosamente eliminavam o anel de foco do teclado em 88 elementos por página. Correção em public/css/app.css — regras explícitas .input:focus-visible/.textarea:focus-visible/.select:focus-visible e .searchbar input:focus-visible com outline: 2px solid var(--rausch) + sombra translúcida; o foco de mouse (:focus) permanece limpo. 904 → 905 unitários (guarda estática); Playwright 60 → 61 (Tab-traversal). (M-1)


[1.58.8] — 2026-05-20

feat(health): exibir OPENAI_API_KEY / QWEN_API_KEY / OPENROUTER_API_KEY em #/health (análogo a GEMINI_API_KEY). v1.57.0 adicionou OpenRouter como 5º provedor live-eval; v1.55.3 (UX-2) trouxe o onboarding de 4 provedores. A página #/health mostrava apenas GEMINI_API_KEY e ANTHROPIC_API_KEY — os outros três ficavam invisíveis embora /api/status/providers já os roteasse. Pedido do usuário: estender o padrão “set / unset (manual mode)” a todos os provedores headless. server/lib/routes/health.mjs agora adiciona três linhas opcionais conectadas ao mesmo gate isUsableKey. A view Health itera sobre body.checks, então não foi preciso alterar strings nos 8 idiomas. 903 → 904 unitários. (Pedido do usuário)


[1.58.7] — 2026-05-20

fix(security): NEW-2 — isValidJobUrl agora rejeita sintaxes pareadas de placeholders de template (${…}, {{…}}) para coincidir com a mensagem de erro. O 400 do POST /api/pipeline anuncia “contain no script or template characters”, mas a regressão MASTER de v1.58.3 confirmou que apenas <%…%> estilo ASP/EJS era de fato bloqueado (como efeito colateral do filtro [<>"'\\s]). Template literals JS (${TEST}) e Mustache/Handlebars ({{TEST}}) passavam — um descompasso regex↔mensagem. Opção A do fix-prompt (apertar a regex): novo TEMPLATE_PATTERNSem [server/lib/security.mjs](https://github.com/Fighter90/career-ops-ui/blob/main/server/lib/security.mjs) consultado viahasTemplatePlaceholder(url)antes denew URL(…). Apenas placeholders **pareados** são rejeitados ({normal}` ATS-style continua aceito). 901 → 903 unitários. (NEW-2)


[1.58.6] — 2026-05-20

fix(a11y/i18n): BUG-008-tb — o título do modal do botão Doctor na barra superior agora coincide com o rótulo localizado. A regra BUG-008 (fechada em v1.58.0) exige “título do modal == rótulo localizado do botão”. A regressão MASTER de v1.58.3 detectou que o ponto de entrada da barra superior ainda violava a invariante: clicar em Doctor abria um modal com título doctor (inglês minúsculo), independentemente do idioma. Correção em public/js/app.js:118 — substituir o literal 'doctor' por I18n.t('top.doctor', 'Doctor'). A chave top.doctor já existe nos 8 idiomas (EN Doctor · ES/pt-BR Diagnóstico · KO 진단 · JA 診断 · RU Диагностика · zh-CN 诊断 · zh-TW 診斷) e é a mesma que o botão declara via data-i18n="top.doctor". Guarda estática adicionada em tests/qa-report-fixes.test.mjs. 900 → 901 unitários; Playwright 60/60. (BUG-008-tb)


[1.58.5] — 2026-05-20

fix(ui): NEW-3 — duplo-POST do Run-live em #/followup triado como não reproduzível; bloqueado com guarda Playwright. A regressão MASTER de v1.58.3 observou (via window.fetch com monkey-patch) dois POSTs idênticos para /api/mode/followup em ~2 s após um único clique no Run live em #/followup (empresa/cargo/notas preenchidos, data deixada em branco). Seguindo a doutrina “reproduzir primeiro” do fix-prompt, a inspeção de public/js/views/mode-page.js::submit() mostra: (a) Run live e Generate prompt são <button> simples com apenas um onClick cada — não há <form> pai nem addEventListener('submit') que possa disparar duas vezes; (b) UI.withSpinner() (FIX-L1) define button.disabled = true durante a requisição, bloqueando um segundo clique físico na origem. Um novo teste Playwright em tests/playwright-smoke.mjs segue a receita exata da regressão — preenche empresa/cargo/notas, deixa a data em branco, clica no botão manual (que compartilha o submit() com o Run live) e verifica exatamente um POST /api/mode/followup em uma janela de 3 s. Seletor estável entre locais (o glifo ▶ é idêntico nos 8 idiomas) e addInitScript semeia career-ops-ui:lang=en para que um teste anterior de idioma no mesmo contexto do navegador não perturbe os seletores. Playwright 59 → 60. A observação original do QA fica como receita; nenhuma alteração de código de produção é necessária. (NEW-3)


[1.58.4] — 2026-05-19

fix(security): NEW-1 — enviar Content-Security-Policy em toda resposta (antes restrito a não-loopback). Antes da v1.58.4 o cabeçalho CSP só era adicionado quando isPubliclyExposed() era verdadeiro (HOST fora do loopback); em 127.0.0.1 tanto / quanto /api/health respondiam sem CSP, deixando o contrato escape-first de UI.md() como única defesa contra XSS. A regressão MASTER da v1.58.3 (§5) sinalizou isso como invariante stop-ship. Agora a CSP é incondicional e idêntica em toda resposta, independentemente do endereço de bind: 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 nunca permite 'unsafe-inline'/'unsafe-eval'. O conjunto de diretivas não mudou em relação à política anterior (já correta para a SPA — Google Fonts na lista de permissões para o Inter), sem regressão visual ou funcional. tests/security-headers.test.mjs foi reescrito; um percurso Playwright (en/ru/ja/zh-TW × 7 rotas) verifica 0 violações de CSP. 900 unitários · Playwright 58→59 · e2e 20/20+23/23. Os próximos itens do fix-prompt são publicados como releases one-fix subsequentes. (NEW-1)


[1.58.3] — 2026-05-19

fix(deep): R-2 / FIX-C1 — remove tags de andaime ÓRFÃS / desbalanceadas do output de pesquisa. O cleanLlmMarkdown (v1.58.0) só removia blocos pareados e uma tag aberta pendente. Uma regressão profunda da v1.58.2 achou um </tool_response> órfão (e </thinking>) sem abertura que sobrevivia e renderizava literal no brief salvo de #/deep. Uma varredura conservadora final remove qualquer token de andaime solto, o XML de ferramentas Anthropic (<invoke>/<parameter>/antml:*) e blocos tool_*. Puro + idempotente; autolinks <https://…> e código preservados. FIX-C2 triado não-reproduzível (i18n.js já define <html lang> e detecta navigator.language). Ambos travados com guards. 896 → 900 unit · Playwright 58/58. Restante do fix-prompt v1.58.3 na fila como one-fix ships (doutrina: nunca em lote).


[1.58.2] — 2026-05-19

fix(i18n): I18N-011 — localiza o índice do #/help nos 7 idiomas não-EN. O TOC é gerado dos títulos ## de docs/help/<lang>.md. As seções 3/4/6/7/8/9/10/11/12 ainda tinham títulos em inglês em es/pt-BR/ko/ja/ru/zh-CN/zh-TW, então o TOC saía em inglês enquanto a sidebar estava traduzida. Cada título afetado agora é localizado com o mesmo termo da chave nav.* da sidebar (fonte única — TOC ↔ sidebar batem), preservando o número da seção e o parêntese (#/route …). EN inalterado. Fecha o único pendente i18n do QA v1.58. Somente docs; 896/896 unit · 33/33 help · Playwright 58/58.


[1.58.1] — 2026-05-19

fix(test): guard checkProfileCustomized isolado de CI (patch sobre v1.58.0). v1.58.0 passou no pre-commit (consultivo) mas falhou no ci.yml (Node 18/20/22): o teste usava import dinâmico cache-bust + reescrita de PATHS, mas paths.mjs resolve a raiz uma vez por processo. Substituído por um guard estático robusto (allow-list + regex ^(…)$/i ancorado; nome real com “test” nunca é marcado). Sem mudança de código de produção; desbloqueia publish-package.yml. 896/896 unit · Playwright 58/58. Ver qa/v158-regression/.


[1.58.0] — 2026-05-19

fix(qa): varredura de bugs do relatório QA externo + saída de pesquisa limpa e formatada. Corrigido: BUG-001 #/followup valida a data opcional como ISO YYYY-MM-DD no cliente; BUG-003 **negrito**/`código`/links agora renderizam dentro de citações em UI.md() (todas as páginas de Ajuda); BUG-005 URL duplicada no pipeline mostra «Já está na fila — ignorado»; BUG-006 mensagem de URL inválida humanizada (contexto (POST /api/pipeline · HTTP 400) mantido de propósito); BUG-007/008 o toast «Running doctor.mjs…» é descartado antes do modal (novo UI.dismissToast()), título do modal = rótulo localizado do botão; BUG-010 subtítulo no estado vazio de #/reports; BUG-002/UX-032 checkProfileCustomized() marca fixtures de teste como «não personalizado» (profile.yml/cv.md do pai intocados — regra #1); I18N-012/013 Deep research em russo realmente traduzido. Novo: cleanLlmMarkdown() remove andaime de agente (<tool_call>{…}</tool_call>, <tool_response>, <thinking> …) de #/deep e da Pesquisa salva, em todos os provedores e ao servir arquivos já salvos; alias #/outreach→#/contacto (BUG-004); erro de rede do cliente localizado via I18n.t() (8 locales; os details do servidor são diagnósticos em inglês de propósito). Testes: novos tests/qa-report-fixes.test.mjs (10) e tests/llm-output.test.mjs (5); 881 → 896 unit; Playwright 58/58. Não alterado (com justificativa): BUG-009 (H1 #/cv por design, WCAG single-h1), dados do pai (parent-owned), cauda longa de i18n/UX menor no backlog. Detalhe completo em CHANGELOG.md.


[1.57.2] — 2026-05-19

fix(config): a causa REAL do «validation failed» em /#/config — o campo lang injetado pela SPA. public/js/api.js anexa automaticamente um campo lang a todos os corpos POST JSON (para as rotas LLM pegarem o idioma da UI). /api/config não é rota LLM e lang não é uma chave de configuração, então a rejeição de chaves desconhecidas do validateConfig (correta e relevante para segurança) retornava 400 em cada Salvar: validation failed — lang: not a known config key. Era só no navegador: repros com curl/in-process nunca enviavam lang, por isso v1.57.0/.1 melhoraram a mensagem mas não a causa. A rota agora remove o lang de transporte antes de validar; o filtro de escrita por KNOWN_KEYS continua descartando qualquer chave realmente desconhecida — a proteção anti-injeção é inalterada. Detectado por uma nova varredura Playwright que clica no botão Salvar real. Testes: novo tests/playwright-forms.mjs (26, integrado em npm run test:e2e:browser) sobre todos os formulários; config-endpoint com paridade de navegador. 879 → 881 unit; Playwright 32 → 58. Detalhe completo em CHANGELOG.md.


[1.57.1] — 2026-05-19

fix(ux): todo erro de API agora diz O QUE falhou, ONDE e POR QUÊ; o texto do erro de entrada é o mais descritivo possível. O servidor já retornava { error, details: ["CAMPO: motivo", …] }, mas os formulários só mostravam a linha de topo («validation failed»), então em /#/config (e em todo lugar) não dava para saber qual campo estava errado. O api.js agora incorpora os details por campo na mensagem em todo o site (uma mudança, todos os formulários se beneficiam), acrescenta o contexto da requisição (MÉTODO /caminho · HTTP NNN) (ONDE), recorre a um trecho do corpo cru em erros não-JSON e os erros de rede levam método+caminho; err.details fica exposto. As mensagens do validateConfig agora são máximamente descritivas (o que está errado e como corrigir). Chaves secretas nunca ecoam o valor digitado (só o tamanho) — uma chave real digitada errada não vaza em toast/log. A faixa de PORT agora é validada de fato (99999 é rejeitado). Em /#/config PORT e HOST vêm pré-preenchidos com os padrões reais (4317 / 127.0.0.1). Os toasts de erro ficam mais tempo (9–20 s) e quebram/rolam em vez de cortar. Testes: novo tests/config-validation-detail.test.mjs (12), 874 → 879. Detalhe completo em CHANGELOG.md.


[1.57.0] — 2026-05-19

feat(provider): OpenRouter como 5º provedor de avaliação ao vivo headless + fix(config): «validation failed» ao salvar qualquer chave API. Chaves coladas costumam chegar com uma quebra de linha final ou espaços (área de transferência do SO, botões «copiar» dos consoles dos provedores) — antes da 1.57 isso disparava o guard de quebra de linha para todos os provedores, e a regex ancorada em $ de ANTHROPIC_API_KEY rejeitava por engano chaves Anthropic reais. Agora validateConfig normaliza (faz trim) cada valor antes de validar, a rota persiste o valor já aparado (autentica em runtime, sem corromper o .env), e a checagem da Anthropic é um prefixo sk-ant- + comprimento resiliente (o piso compartilhado isUsableKey() ≥ 20 continua sendo o real «é uma chave válida?»). Quebras de linha internas continuam rejeitadas (guard de injeção no .env). OpenRouter agora é provedor de primeira classe: OPENROUTER_API_KEY em /#/config — uma chave dá acesso a mais de 300 modelos. É o último da ordem auto (Anthropic → Gemini → OpenAI → Qwen → OpenRouter), então uma configuração existente nunca é redirecionada silenciosamente; LLM_PROVIDER=openrouter o fixa. Conectado ao mesmo caminho _tailProvider() de OpenAI/Qwen em /api/evaluate, /api/deep, /api/mode/:slug; exposto em /api/status/providers + no painel de Health. Cliente compatível com OpenAI (sem novas dependências — fetch direto, timeout AbortController, a chave nunca é registrada) com os headers HTTP-Referer/X-Title recomendados. O dropdown de modelos é ao vivo: OPENROUTER_MODEL é preenchido por GET /api/openrouter/models (proxy no servidor do catálogo público da OpenRouter — mantém CSP connect-src 'self'), com lista curada de fallback e cache em memória de 10 min. Novas chaves i18n (config.openrouter*) nos 8 idiomas. Testes: novos tests/openrouter-route.test.mjs e tests/openrouter-model-selector.test.mjs; suites env-config/openai/provider-selector ampliados. 831 → 855. Detalhe completo em CHANGELOG.md.


[1.56.4] — 2026-05-19

feat(ui): UX-N2 — dica visível e ciente de plataforma de ⌘K / Ctrl K na busca global. O atalho Cmd/Ctrl+K (focar a busca) só existia no aria-label/código, então usuários videntes não o descobriam e o app parecia mais lento do que é. Agora um <kbd class="kbd-shortcut"> discreto fica no fim da pílula de busca, preenchido na inicialização a partir de data-mac/data-other por uma checagem de plataforma (navigator.platform/userAgent): ⌘K no macOS/iOS, Ctrl K no resto. É aria-hidden="true" (o aria-label existente já anuncia aos leitores de tela — o selo não deve duplicar) e pointer-events:none (decorativo). O atalho Cmd/Ctrl+K existente não muda. Sem novas chaves i18n (os glifos são universais); o selo é filho flex do .searchbar existente (sem wrapper/posição absoluta — o input já é flex:1). Testes: nova suíte estática por código isolada de CI tests/cmdk-hint-visible.test.mjs (5): o <kbd class="kbd-shortcut"> está dentro do .searchbar; é aria-hidden="true" com ambas variantes data-mac/data-other; app.js o preenche via checagem navigator; o binding (e.ctrlKey||e.metaKey)&&e.key==='k' → search.focus() intacto (guard de regressão); app.css estiliza .kbd-shortcut e nunca display:none. 826 → 831. feat(ui) · test: tests/cmdk-hint-visible.test.mjs. Detalhes — CHANGELOG.md.


[1.56.3] — 2026-05-19

fix(reliability): a detecção de chaves de provedor rejeita placeholders / valores curtos demais, não apenas a string vazia. Um GEMINI_API_KEY placeholder em um .env pai era reportado como “✓ set” E selecionado como provedor ativo em vez de um ANTHROPIC_API_KEY válido. effectiveEnv() só rejeitava undefined/'', então 10 caracteres de lixo contavam como chave real: o banner de onboarding mostrava GEMINI ✓ set, GET /api/status/providers retornava activeProvider: "gemini", e toda avaliação ⚡ ao vivo teria falhado em silêncio contra uma chave morta, ignorando uma chave Anthropic válida de 108 caracteres. A nova função pura isUsableKey() (env-config.mjs) considera um segredo configurado apenas se tiver ≥ 20 caracteres (nenhuma chave suportada é mais curta — Gemini AIza… ≈ 39, Anthropic sk-ant-… ≈ 100+, OpenAI ≥ 40, Qwen ≈ 35) e não for um placeholder conhecido (your_*_here, changeme, placeholder, <…>, um único caractere repetido…). Aplicada uniformemente a hasAnthropicKey()/hasGeminiKey() (anthropic.mjs), hasOpenAIKey()/hasQwenKey() (openai.mjs) e às linhas GEMINI_API_KEY/ANTHROPIC_API_KEY de GET /api/health (movidas de process.env cru para a mesma visão effective+plausible) — a página de saúde, o endpoint de provedores e o roteador OR agora sempre concordam. selectActiveProvider() não muda; recebe um keysConfigured correto. Testes: nova suíte isolada de CI tests/key-detection-rejects-placeholder.test.mjs (5): casos unitários de isUsableKey + reprodução in-process com createApp() do cenário relatado (.env temporário com GEMINI_API_KEY de 10 caracteres + ANTHROPIC_API_KEY real) — gemini NÃO está em keysConfigured, activeProvider === "anthropic", linhas de /api/health coerentes. Quatro testes existentes de camadas effective-env tiveram stubs curtos demais alongados (o contrato não muda). 821 → 826. fix(reliability) · test: tests/key-detection-rejects-placeholder.test.mjs. Detalhes — CHANGELOG.md.


[1.56.2] — 2026-05-19

feat(a11y): UX-N1 — document.title por rota e ciente de idioma (orientação multi-aba + anúncio de mudança de página do leitor de tela). Antes as 24 rotas mantinham o <title> estático do index.html (“career-ops — command center”): abas com o mesmo nome, favoritos genéricos e o mesmo anúncio “página alterada”. O focusNewView() em public/js/router.js agora deriva o título do próprio <h1 class="page-title"> localizado da view — “View — career-ops” — então os títulos são traduzidos automaticamente (sem novas chaves i18n) e únicos por rota. Definido antes do guard do primeiro paint para a aba inicial também ter título (mesma ordem do tabindex da v1.56.0 UX-12). Recorre a career-ops — command center se a view não tiver cabeçalho. Testes: nova suíte estática por código isolada de CI tests/document-title-per-route.test.mjs (4): focusNewView atribui document.title; o título vem do <h1> (por rota + localizado, não um literal único); a atribuição precede !firstPaintDone; há um padrão de produto. 817 → 821. feat(a11y) · test: tests/document-title-per-route.test.mjs. Detalhes — CHANGELOG.md.


[1.56.1] — 2026-05-19

fix(a11y): suprime o anel de foco de marca espúrio no foco do título com tabindex="-1" gerenciado pelo router. public/js/router.js define tabindex="-1" no título da view de destino e chama .focus() a cada navegação de cliente (para o leitor de tela anunciar a nova página). Um elemento tabindex="-1" nunca é alcançável por teclado, mas a heurística :focus-visible do Chromium ainda desenhava o anel de marca global (*:focus-visible { outline: 2px solid var(--rausch) }) — um retângulo vermelho ao redor do título (ex.: “Command Center” em #/dashboard) a cada navegação, que também ficou gravado nas capturas hero images/dashboard-*.png. A correção é uma única regra com escopo [tabindex="-1"]:focus, [tabindex="-1"]:focus-visible { outline: none } (padrão de foco gerenciado do WAI-ARIA APG). O foco real de teclado em controles interativos mantém o anel global *:focus-visible (WCAG 2.4.7 intacto); o anel do skip-link não é afetado (é um <a>, não tabindex="-1", com maior especificidade). As 8 images/dashboard-*.png foram regeneradas — sem o retângulo vermelho. Testes: nova suíte estática por código isolada de CI tests/managed-focus-no-ring.test.mjs (4): o anel global *:focus-visible continua definido (WCAG 2.4.7 sem regressão); [tabindex="-1"]:focus,:focus-visible ⇒ outline:none; a regra de supressão vem após a global (segurança de cascata); a correção tem escopo (sem *:focus{outline:none} geral). Junto com tests/dashboard-initial-focus.test.mjs. 813 → 817. fix(a11y) · test: tests/managed-focus-no-ring.test.mjs. Detalhes — CHANGELOG.md.


[1.56.0] — 2026-05-19

feat(ux): pacote de polimento LOW — UX-9 / UX-10 / UX-11 / UX-12 (uma release menor agrupada). UX-9 #/cv: o título da página é rebaixado a um chip-breadcrumb .cv-breadcrumb discreto e o subtítulo barulhento vai para o title do <h1>, para que o CV do usuário (seu nome, na pré-visualização) tenha a hierarquia visual. Invariante F-V54-A intacto — ainda exatamente um <h1>, ainda .page-title. UX-10 novo helper compartilhado UI.providerCostHint(t) ao lado de ⚡ Executar ao vivo em #/auto, #/evaluate, #/deep e cada #/<mode>; reusa GET /api/status/providers (v1.55.3): com chave mostra “Custo estimado: OpenAI gpt-5-codex · ~$0.04/eval” (ordem de grandeza, “~”); sem chave indica que ⚡ copia um prompt manual sem custo de API; fail-soft. UX-11 #/help: quando o filtro do TOC reduz a exatamente uma seção, a página rola até lá após 300ms de inatividade (debounced; nunca com 0 ou >1). UX-12 #/dashboard: no primeiro paint o <h1> fica focável (tabindex="-1") e #content permanece aria-live="polite" (anunciado no boot) sem roubar o foco (evita brigar com o skip-link, decisão v1.41.0). Novas chaves i18n cost.estimate, cost.manual ×8; novo CSS .cv-breadcrumb/.cost-hint. Testes: 4 novas suites estático-de-fonte CI-isoladas (cv-breadcrumb 3, run-cost-line 4, help-toc-autoscroll 4, dashboard-initial-focus 3); locks pré-existentes cv-single-h1/help-nav-a11y atualizados (invariantes preservados). 800 → 813. Sonda Playwright ao vivo dos 4, 0 erros de console. feat(ux) · 4 test suites. Detalhe em CHANGELOG.md.


[1.55.8] — 2026-05-19

feat(tracker): paginação do lado servidor + chips de funil clicáveis (UX-8). Servidor: GET /api/tracker ganha params opcionais ?page / ?pageSize / ?status. Sem eles, a resposta é byte a byte o { rows: [...] } legado (todos os chamadores/testes existentes intactos). Com eles retorna { rows: slice, total, page, pageSize, funnel } — pageSize clampado a [1,500], page a ≥1, status filtra rows+total, e funnel é o detalhamento status→contagem de todo o histórico (independente de página ou filtro, para os chips serem sempre exatos). #/tracker: nova barra de chips de funil clicável no topo — “todos status · N · Applied · N · Interview · N …” (ordem Applied → Responded → Interview → Offer → Rejected → Discarded → Evaluated → SKIP). Clicar um chip define o filtro de Status (clicar o ativo limpa); o chip ativo é aria-pressed e destacado. Nova chave i18n track.funnelAria ×8; novo CSS .tracker-funnel/.tracker-chip/.tracker-chip--active. test: tests/tracker-server-paged.test.mjs (novo, 7 casos, CI-isolado, Express in-process em porta efêmera + applications.md temporário em CAREER_OPS_ROOT — CLAUDE.md #2/#8): back-compat (sem params ⇒ exatamente {rows}); ?page&pageSize slice + total/page/pageSize/funnel somando N; última página parcial sem sobreposição; página fora de alcance ⇒ rows vazio + total válido; ?status= filtra total/rows com funnel de todo o histórico; cap de pageSize; + lock estático-de-fonte da barra de chips. 793 → 800. feat(tracker) · test: tests/tracker-server-paged.test.mjs. Detalhe em CHANGELOG.md.


[1.55.7] — 2026-05-19

feat(pipeline): virtualização de linhas em vanilla-JS para >1000 linhas (UX-7). #/pipeline renderizava todas as linhas (filtered.forEach(list.appendChild(urlRow))) — um scan enche a fila com milhares de URLs, então milhares de nós de linha (cada um um div flex + <a> + dois botões) eram construídos sincronamente a cada tecla do filtro, saturando o DOM e a árvore de acessibilidade. Nova virtualização vanilla-JS (equivalente a react-window, sem deps): acima de VIRTUALIZE_THRESHOLD = 1000, #/pipeline vira um viewport com scroll de altura fixa (70vh) com um espaçador não encolhível (flex:0 0 auto, height = linhas × 56px) que preserva a barra de rolagem real da lista inteira, e um listener de scroll com rAF renderiza só o viewport ± um buffer de 5 linhas (~16–19 nós por vez em vez de N). Em/abaixo do limiar o render simples original é mantido byte a byte, então pipelines típicos e todos os tests/e2e existentes não são afetados. Cada linha virtualizada mantém seu aria-label ▶/✕ desambiguado por URL (F-V54-B travado por regressão). O cálculo de janela é um helper puro computeWindow(). test: tests/pipeline-virtualize.test.mjs (novo, 5 casos, CI-isolado, estático-de-fonte): limiar numérico ~1000; ramo ≤limiar mantém forEach→appendChild; ramo >limiar renderiza slice(start,end) com listener de scroll rAF + espaçador; computeWindow() clampa [0,total] com ± buffer; linhas mantêm aria-labels ▶/✕. 788 → 793. Sonda Playwright ao vivo (fixture de 1200 URLs): scrollHeight≈67248, só ~16–19 nós no DOM, a janela acompanha o scroll de ponta a ponta, 0 erros de console. feat(pipeline) · test: tests/pipeline-virtualize.test.mjs. Detalhe em CHANGELOG.md.


[1.55.6] — 2026-05-19

feat(scan): recolher filtros secundários atrás de um disclosure “Filtros avançados” (UX-4). #/scan empilhava todos os filtros — texto livre, remoto/híbrido/presencial, escopo, fonte e os chips de facetas stack/nível/dinâmicos pós-scan — com peso igual, um muro de controles. Agora os filtros do dia a dia ficam visíveis (texto livre + Remoto/Híbrido/Presencial; o botão 🌐 Buscar já está separado no card de controles) e os secundários colapsam atrás de um <details class="scan-advanced"><summary>Filtros avançados</summary>: os selects Escopo + Fonte e — separadamente — o cluster de chips de facetas (que agora lidera o resultado com a tabela, não um muro de chips, e só renderiza se houver ao menos uma linha de chips). Nova chave i18n scan.advancedFilters nos 8 locais; novo estilo .scan-advanced (affordance ⚙ discreta, sem marcador, negrito ao abrir). test: tests/scan-advanced-disclosure.test.mjs (novo, 6 casos, CI-isolado, estático-de-fonte): existe <details>/<summary> com hook .scan-advanced e rótulo scan.advancedFilters; texto livre + remoto seguem visíveis; escopo + fonte dentro do disclosure; chipsContainer é <details>; .scan-advanced summary estilizado; scan.advancedFilters ×8. 782 → 788. feat(scan) · test: tests/scan-advanced-disclosure.test.mjs. Detalhe em CHANGELOG.md.


[1.55.5] — 2026-05-19

feat(dashboard): hero com os 2 CTAs P0 + dica focal de atividade recente (UX-3). #/dashboard abria com ~30 nós de peso igual — sem um “o que vem a seguir” claro. Um novo bloco .dash-hero agora fica logo abaixo do cabeçalho: as duas jornadas P0 — ✨ Auto-pipeline para uma URL e 🌐 Buscar agora — são promovidas a botões grandes .btn-hero, e uma única dica focal de atividade recente (“Última avaliação: <score> — <título>”, com link para o relatório; estado vazio guia no arranque a frio via dash.heroNoEval) diz ao usuário recorrente onde parou e ao novo a única ação que importa. Os dois botões primários foram removidos do cabeçalho (só fica o secundário “📋 Abrir pipeline”) para não duplicar a ação. Os contadores de status foram rebaixados de .badge proeminentes para pílulas .dash-chip discretas. Novas chaves i18n dash.lastEval, dash.heroNoEval nos 8 locais; novo CSS .dash-hero/.btn-hero/.dash-chip. test: tests/dashboard-hero.test.mjs (novo, 5 casos, CI-isolado, estático-de-fonte): .dash-hero existe e precede a grade Quick-actions; ambos CTAs P0 são .btn-hero com rotas /auto+/scan; dica focal dash.lastEval + estado vazio dash.heroNoEval; buckets usam .dash-chip; CSS existe; dash.lastEval+dash.heroNoEval ×8. 777 → 782. feat(dashboard) · test: tests/dashboard-hero.test.mjs. Detalhe em CHANGELOG.md.


[1.55.4] — 2026-05-19

feat(ux): ETA honesta do auto-pipeline ao lado de Run + Stop proeminente durante um scan (UX-6). #/auto: nova dica .auto-eta — “⏱ ~1–2 min” (chave auto.eta, title via auto.etaTitle) — agora ao lado do botão Run, para que a promessa de um clique seja honesta sobre a duração antes de o usuário se comprometer; o texto casa com career-ops.org/docs (“cole uma URL → relatório completo em 1–2 minutos”). #/scan: enquanto o rastreamento de vários minutos está ativo (aria-busy), o Stop é promovido de botão fantasma de baixo contraste a botão destrutivo proeminente (novo .btn-danger — preenchido, branco sobre coral de alto contraste, peso 600). setScanRunning(running) alterna scan-stop-btn entre btn-danger (rodando) e btn-ghost (ocioso, oculto de qualquer forma), para que o usuário encontre e confie no Stop sob carga. Novas chaves i18n auto.eta, auto.etaTitle nos 8 locais; novo CSS .btn-danger/.auto-eta. test: tests/auto-eta-stop.test.mjs (novo, 4 casos, CI-isolado, estático-de-fonte): #/auto renderiza t('auto.eta') com classe .auto-eta ao lado de runBtn; auto.eta ×8; setScanRunning(running) promove Stop a btn-danger; .btn-danger existe com texto branco de alto contraste. 773 → 777. feat(ux) · test: tests/auto-eta-stop.test.mjs. Detalhe em CHANGELOG.md.


[1.55.3] — 2026-05-19

feat(onboarding): status OR de 4 provedores na tela — banner de arranque a frio + chip de provedor ativo (UX-2, ALTA). Novo endpoint somente-leitura GET /api/status/providers → { activeProvider, activeModel, keysConfigured }. keysConfigured usa a mesma visão de env efetiva dos gate sites de llm.mjs (process.env ∨ .env do pai); activeProvider é o que o OR-router escolheria — selectActiveProvider(), novo helper puro em env-config.mjs que percorre providerOrder() (um pin LLM_PROVIDER sem chave correspondente dá null). Nenhum segredo é retornado — só nomes de provedor + o id do modelo. O shell da SPA agora renderiza uma região de onboarding global (#onboarding-banner, populada por app.js, só DOM seguro para CSP): 0 chaves → banner vermelho com CTA para #/config?tab=api-keys; ≥1 chave → chip discreto com o provedor + modelo ativo. Torna o diferencial principal (“um de Anthropic / Gemini / OpenAI / Qwen, auto-ordenado”) descobrível na tela em vez de aprendido por tentativa. Novas chaves i18n onboarding.* nos 8 locais; novo CSS .onboarding-warn/.onboarding-ok. test: tests/onboarding-key-banner.test.mjs (novo, 9 casos, CI-isolado): semântica de selectActiveProvider; GET /api/status/providers em processo (porta efêmera + .env em CAREER_OPS_ROOT temporário para nunca ler a chave real do pai — CLAUDE.md #2/#8); cabeamento SPA estático + cobertura onboarding.* ×8. 764 → 773. feat(onboarding) · test: tests/onboarding-key-banner.test.mjs. Detalhe em CHANGELOG.md.


[1.55.2] — 2026-05-18

fix(cv): dar ao editor markdown de #/cv um nome acessível descritivo e autônomo (F-V55-H / UX-5). O <textarea id="cv-editor"> do editor principal de #/cv agora carrega um aria-label descritivo via a nova chave cv.editorAria — “Editor markdown do CV — seu currículo profissional em formato markdown” — em vez do nome enxuto que herdava do cabeçalho visível “Markdown”. Nota: ao contrário do sintoma de F-V55-H (que só inspecionou aria-label/labels), o campo não estava sem nome — v1.47.0 (WS2 #16) já o havia vinculado via aria-labelledby → o <h3 id="cv-md-heading">Markdown</h3>, então um leitor de tela anunciava “Markdown, edição, multilinha”. v1.55.2 melhora esse enxuto “Markdown” para um rótulo autônomo. O aria-labelledby redundante é removido (seria markup morto — aria-label vence por precedência ARIA); o <h3>Markdown</h3> visível permanece para usuários videntes. WCAG 1.3.1 + 4.1.2; paralelo ao conserto batch-tsv de v1.54.5 (F-V54-C). test: tests/cv-editor-a11y.test.mjs (novo, 3 casos, CI-isolado, estático-de-fonte como auto-stepper-prerender.test.mjs): #cv-editor se nomeia via t('cv.editorAria', …) com fallback não vazio; cv.editorAria presente e não vazio nos 8 locais; sem aria-labelledby redundante no elemento. 761 → 764. fix(cv) · test: tests/cv-editor-a11y.test.mjs. Detalhe em CHANGELOG.md.


[1.55.1] — 2026-05-18

fix(auto): pré-renderizar o stepper de 5 etapas do pipeline ao montar #/auto (F-V55-E / UX-1, obs. sênior S-4 reaberta). #/auto agora mostra o esquema documentado de cinco etapas — validar → buscar → avaliar → salvar relatório → adicionar ao tracker — no instante em que a tela monta, em vez de ficar em branco até o primeiro evento SSE. Antes <ol class="auto-stepper"> era criado display:none e renderStepper() só era alcançado a partir de setStep() / run(), então um usuário em arranque a frio nunca via o pipeline que os docs prometem antes de clicar Run. O stepper agora é visível ao montar com as cinco etapas no estado pending e carrega um aria-label (auto.stepperAria) para que a tecnologia assistiva anuncie a região. Fecha F-V55-E (lente a11y/garantia estática) e UX-1 (lente fidelidade de promessa) — mesma correção, ambas as lentes. test: tests/auto-stepper-prerender.test.mjs (novo, 4 casos, CI-isolado, estático-de-fonte como router.test.mjs): o array STEPS são exatamente as 5 etapas canônicas em ordem; stepperEl não é display:none ao montar e carrega auto.stepperAria; uma chamada renderStepper() de escopo de montagem precede function setStep(; auto.stepperAria presente nos 8 locais. 757 → 761. fix(auto) · test: tests/auto-stepper-prerender.test.mjs. Detalhe em CHANGELOG.md.


[1.55.0] — 2026-05-18

feat(llm): a live-eval headless funciona via “OR” — Anthropic | Gemini | OpenAI | Qwen, autoselecionada conforme qual chave estiver definida. A pedido do usuário, a ⚡ live-eval do web-ui agora funciona com qualquer chave de API que estiver definida, não só Anthropic/Gemini. LLM_PROVIDER ganha openai e qwen; auto (padrão) usa o primeiro provedor cuja chave esteja presente, preferindo Anthropic → Gemini → OpenAI → Qwen. Um valor explícito fixa um; um provedor forçado sem chave ainda cai no caminho do prompt manual. Novo server/lib/openai.mjs — um cliente Chat Completions compatível com OpenAI e sem dependências (mesmo padrão HTTPS direto seguro de anthropic.mjs: timeout AbortController, chave nunca logada, resolução de chave effectiveEnv() para que uma chave do .env do pai funcione sem reinício). Um núcleo (runOpenAICompatible) sustenta runOpenAI (api.openai.com) e runQwen (modo compatível com OpenAI da Alibaba DashScope; sobrescreva o endpoint com QWEN_BASE_URL no .env cru para o host da China continental). Sem SDKs, sem execução arbitrária de CLI — o projeto pai continua agnóstico de CLI (Claude Code · Codex · Gemini · OpenCode · Qwen · Copilot · Kimi); isto só estende o caminho headless por chave de API. A cauda OpenAI/Qwen está cabeada em todas as superfícies de eval: /api/evaluate, /api/deep, /api/mode/:slug e o SSE de /api/auto-pipeline — consultada após as ramificações Anthropic (inline) + Gemini (subprocesso) para preservar a preferência auto, com o mesmo inlining de contexto empacotado que a Anthropic usa. env-config.mjs: QWEN_API_KEY (secreto) + QWEN_MODEL (não secreto) adicionados a KNOWN_KEYS/KEY_GROUPS.core; LLM_PROVIDERS e providerOrder() estendidos; OPENAI_API_KEY agora é uma chave de provedor headless de primeira classe (antes só armazenada). Aba de chaves do #/config: o select LLM_PROVIDER ganha openai/qwen; novos campos QWEN_API_KEY + QWEN_MODEL (lista curada qwen-max/qwen-plus/qwen-turbo/qwen2.5-*); uma nova nota no topo da aba explica o pai agnóstico de CLI vs a eval headless do web-ui e a ordem OR. Novas chaves i18n nos 8 locais. test: tests/openai.test.mjs (novo, 9 casos, CI-isolado): sucesso OpenAI/Qwen + conteúdo em array de blocos, auth Bearer, endpoints padrão e sobrescrito por QWEN_BASE_URL, 4xx/5xx/malformado, clamp de max_tokens, timeout, detecção de chave effectiveEnv, canário de não-vazamento de chave. tests/provider-selector.test.mjs atualizado para a superfície providerOrder/LLM_PROVIDERS/SECRET de v1.55.0 + o cabeamento da cauda OpenAI/Qwen. 748 → 757. feat(llm) · test: tests/openai.test.mjs · test: tests/provider-selector.test.mjs. Detalhe em CHANGELOG.md.


[1.54.10] — 2026-05-18

fix(auto-pipeline): higiene de desconexão do cliente SSE — eliminar o job e2e do Playwright instável. O job e2e do Playwright ficava vermelho de forma intermitente (32/32 testes individuais passam, mas not ok 2 - tests/playwright-smoke.mjs): fechar uma página enquanto o stream SSE de #/auto estava em andamento fazia o próximo res.write() do servidor ser rejeitado com EPIPE/"aborted", e —sem um listener 'error' na resposta— o Node escalava isso para uma uncaughtException que o node:test reportava como “asynchronous activity after the test ended”. openSse() em auto-pipeline.mjs agora registra um res.on('error') no-op e protege send() com res.writableEnded || res.destroyed (envolvido em try/catch) — um cliente que sumiu é esperado, não excepcional. Isto é higiene SSE de produção correta, não apenas uma correção de teste. tests/playwright-smoke.mjs: o teste do Cmd+K usava uma URL externa real (https://example.com/jobs/123) mas só esperava o modal aparecer, então closePage() abortava o safeGet() em andamento do servidor depois que o teste terminava. Agora ele espera o pipeline atingir um estado terminal (para que o fetch resolva normalmente antes do fechamento). Um helper compartilhado closePage() (window.stop() e então fechar) e o hook after com server.closeAllConnections() permanecem como defesa em profundidade. Verificado: 8/8 execuções verdes consecutivas (6× node --test + 2× browser-smoke), antes ~1-em-2 vermelho. tests/auto-pipeline.test.mjs +1 caso estático fixando o contrato de higiene de desconexão de openSse (listener res.on('error') + guarda writableEnded||destroyed + escritas envolvidas em try). 747 → 748. fix(auto-pipeline) · test: tests/auto-pipeline.test.mjs. Detalhe em CHANGELOG.md.


[1.54.9] — 2026-05-18

fix(llm): honrar as chaves LLM do .env do projeto pai em tempo de requisição — parar de rotear erroneamente para um provedor obsoleto/inválido. A avaliação ao vivo podia falhar com “Gemini API error: API key not valid” mesmo quando ANTHROPIC_API_KEY era o provedor configurado. Causa raiz: hasAnthropicKey() / hasGeminiKey() (e a busca de chave/modelo de runAnthropic) liam apenas o snapshot de process.env do boot. Se a chave Anthropic fosse adicionada ao .env do pai depois que o servidor iniciou, o processo em execução nunca a via → a detecção de Anthropic era falsa, e a avaliação caía para qualquer chave obsoleta que de fato estivesse em process.env (frequentemente um GEMINI_API_KEY antigo e inválido). O caminho de execução do Gemini (um subprocesso Node do pai) já lia o .env vivo do pai, então os dois provedores resolviam chaves de forma inconsistente. Novo effectiveEnv(key, envFilePath) em env-config.mjs: um valor não vazio de process.env vence (cobre exports de shell e o live-apply em POST /api/config); caso contrário consulta-se o .env atual do pai. anthropic.mjs agora resolve ANTHROPIC_API_KEY, ANTHROPIC_MODEL e a verificação da chave Gemini através dele, de modo que uma chave definida no .env do pai é honrada sem reiniciar o servidor e a DETECÇÃO de chave sempre coincide com a chave que a requisição realmente ENVIA. A ordem dos provedores não muda (auto → Anthropic-depois-Gemini); isto só corrige a detecção. As chaves nunca são logadas nem retornadas (o teste de não-vazamento REVIEW-B4 continua passando). tests/anthropic.test.mjs reescrito para ser CI-isolado (temp CAREER_OPS_ROOT, import dinâmico) com 2 casos novos que reproduzem o bug exato (chave só no .env do pai → detectada; runAnthropic envia a chave + modelo do .env do pai quando process.env está indefinido). tests/env-config.test.mjs +3 casos effectiveEnv (precedência de process.env, fallback ao .env incl. string-vazia-como-indefinida, arquivo-ausente / chave-ausente / sem-caminho → undefined) — 100% do novo ramo. 742 → 747. fix(llm) · test: tests/anthropic.test.mjs · test: tests/env-config.test.mjs. Detalhe em CHANGELOG.md.


[1.54.8] — 2026-05-18

feat(config): o formulário por campos de Modes sempre renderiza o esquema canônico (mesmo em um arquivo vazio/stub) com a orientação de campos do career-ops.org. O formulário por campos de Modes da v1.54.3 só renderizava campos para seções ## já existentes — então em um modes/_profile.md recém-criado, vazio ou fora do esquema (p. ex. o stub comum de 1 linha) ele caía em “No ## sections found — use the raw editor below.” e o usuário não recebia campos. A pedido do usuário (“разбей по полям … описание полей возьми из career-ops.org/docs”), o formulário agora sempre renderiza os 5 campos canônicos na ordem documentada (Target Roles, Adaptive Framing, Exit Narrative, Comp Targets, Location Policy), pré-preenchidos a partir do arquivo quando presentes e vazios-mas-editáveis quando não — de modo que um perfil totalmente novo pode ser preenchido inteiramente pelo formulário. Cada campo exibe uma descrição obtida do §Step-5 do Quick Start canônico do career-ops.org (o que colocar em Target Roles / Adaptive Framing / Exit Narrative / Comp Targets / Location Policy), conectada via aria-describedby para leitores de tela. Tolerante a variantes de cabeçalho: o ## Your Target Roles (etc.) do template mapeia para o mesmo campo canônico que ## Target Roles, de modo que nem o template nem a convenção do scaffold do servidor quebram o formulário. collect() agora é um payload etiquetado: uma mesclagem { sections } não destrutiva quando os cabeçalhos renderizados batem exatamente com os do arquivo (preâmbulo + seções intocadas + personalizadas sobrevivem byte-estáveis), ou uma reconstrução de arquivo completo { markdown } que inicializa/normaliza um documento conforme ao esquema quando o arquivo não o tinha. O caminho de reconstrução é protegido por confirmação em config.js (substitui o arquivo pai — invariante de salvamento destrutivo WS2 #4), preserva o preâmbulo existente (ou um padrão documentado) e mantém as seções não canônicas verbatim. 6 novas chaves i18n (config.modesDescTargetRoles … config.modesDescLocationPolicy + config.modesFormRebuildBody) nos 8 locales. tests/modes-form.test.mjs reescrito para o contrato v1.54.8: esquema + ordem canônica, fiação de payload/confirmação do config.js, presença da descrição de cada campo proveniente da documentação nos 8 locales, tolerância canonicalKey “Your X”, estabilidade do round-trip de listas, a garantia de bootstrap-sempre-renderiza, e o collect() etiquetado seções-vs-markdown com segurança de dados. Verificado ao vivo contra o arquivo stub real do pai (5 campos + descrições aparecem, 0 erros de console) e um fixture stub isolado (preencher → salvamento protegido por confirmação → as 5 seções canônicas persistidas). feat(config) · test: tests/modes-form.test.mjs. Detalhe em CHANGELOG.md.


[1.54.7] — 2026-05-18

fix: W-001 — assets de código/estilo + o shell da SPA eram servidos com Cache-Control: no-store (higiene de deploy). A SPA carrega api.js / router.js / cada view via <script src> simples sem query string de versão, e não há etapa de build (sem content hashing), então após um deploy um navegador podia continuar servindo um bundle antigo cacheado por horas → 404 de cache obsoleto em rotas com query string (observado ao vivo durante a regressão v1.29.2; corrida de regressão W-001). server/index.mjs agora define Cache-Control: no-store em .js / .mjs / .css / .html via o hook setHeaders do express.static, e explicitamente no catch-all do shell da SPA (que usa sendFile e contorna setHeaders), de modo que o navegador sempre revalida o código que conduz o roteamento. Assets estáticos não-código mantêm o caching padrão do express.static. As cabeçalhos de segurança (CSP / nosniff / frame-deny / referrer-policy) não mudam — verificado pela suíte security-headers existente (8 casos) rodando em verde ao lado do novo teste. +1 arquivo de testes tests/asset-cache-control.test.mjs — 4 casos (assets JS no-store, CSS no-store, index.html estático no-store, shell de rota profunda do catch-all da SPA no-store), inicializando o app real contra um CAREER_OPS_ROOT isolado. Mais um conserto de teardown flaky em tests/playwright-smoke.mjs (commit test(e2e) à parte): o teste de fumaça SSE do auto-pipeline agora cancela o reader + aborta o fetch num finally e o hook after força o fechamento de sockets persistentes, eliminando o “Error: aborted” pós-teardown que avermelhava o job Playwright e2e de v1.54.6. 738 → 742. fix · test: tests/asset-cache-control.test.mjs. Detalhe em CHANGELOG.md.


[1.54.6] — 2026-05-18

fix(a11y): S-7 — o botão back-to-top de #/help carrega a classe seletora canônica back-to-top. O botão flutuante back-to-top de #/help funcionava corretamente (verificado ao vivo) mas sua lista de classes (btn btn-primary help-back-top) ficava fora da convenção do seletor .back-to-top que o teste da spec §2 #28 mira — um seletor mais estrito teria flutuado (corrida de regressão S-7, “vitória fácil”). O botão agora carrega também a classe canônica back-to-top. Puramente aditivo e um no-op de CSS: help-back-top (o hook CSS existente) não muda e back-to-top não tem regra CSS — é apenas uma alça estável de teste/automação. Verificado ao vivo: document.querySelector('.back-to-top') resolve o botão, aria-label intacto, 0 erros de console. O caso #12 existente em tests/help-nav-a11y.test.mjs foi estendido com uma asserção de que a lista de classes do botão back-to-top inclui o seletor canônico back-to-top (sem arquivo novo). fix(a11y) · test: tests/help-nav-a11y.test.mjs. Detalhe em CHANGELOG.md.


[1.54.5] — 2026-05-18

fix(a11y): F-V54-C — o editor TSV de #/batch tem um nome acessível. O <textarea> TSV de #/batch tinha uma dica cabeada via aria-describedby mas nenhum nome acessível — sem <label htmlFor>, sem aria-label/aria-labelledby (corrida de regressão F-V54-C; WCAG 1.3.1 Info & Relationships / 4.1.2 Name, Role, Value). aria-describedby fornece uma descrição, não um nome, então um leitor de tela anunciava um “edit text” sem rótulo. O textarea agora carrega um aria-label via a nova chave i18n batch.tsvAria, consistente com as entradas irmãs de controle de corrida que já usam chaves *Aria; a dica describedby existente é preservada. Verificado ao vivo: aria-label presente + localizado, aria-describedby intacto, 0 erros de console. Nova chave i18n batch.tsvAria nos 8 locales. +1 arquivo de testes tests/batch-tsv-accessible-name.test.mjs (2 casos: o bloco batch-tsv tem um aria-label via t(batch.tsvAria) mantendo sua dica describedby; batch.tsvAria definida nos 8 locales); 736 → 738. fix(a11y) · test: tests/batch-tsv-accessible-name.test.mjs. Detalhe em CHANGELOG.md.


[1.54.4] — 2026-05-18

fix(a11y): F-V54-B — os botões de ação por linha de #/pipeline têm nomes acessíveis. Os botões ▶ (avaliar) e ✕ (excluir) por linha em #/pipeline eram somente ícones com apenas um atributo title (corrida de regressão F-V54-B; WCAG 4.1.2 Name, Role, Value). title não é um nome acessível confiável, então um usuário de leitor de tela ouvia uma longa sucessão de “button” indistintos e não conseguia saber qual linha uma exclusão atingiria. Ambos os botões agora carregam um aria-label explícito desambiguado por uma URL compacta via um novo helper shortUrl() (host + …/ + os 2 últimos segmentos de caminho; fallback de recorte final para entradas não parseáveis), de modo que a árvore de a11y lê p. ex. “Delete: hh.ru/…/vacancy/12345”. Sem novas chaves i18n — reutiliza common.delete / pipe.evaluateBtn + a URL. Verificado ao vivo: 1385 linhas, cada nome de botão único por linha, 0 erros de console. +1 arquivo de testes tests/pipeline-row-action-names.test.mjs (4 casos: ambos os botões cabeados com shortUrl(url) + exatamente dois desses rótulos, shortUrl declarado antes do uso, URLs de mesmo host mas emprego diferente não colapsam, fallbacks de host puro / não parseável / vazio); 732 → 736. fix(a11y) · test: tests/pipeline-row-action-names.test.mjs. Detalhe em CHANGELOG.md.


[1.54.3] — 2026-05-18

feat(config): formulário de campos estruturado para a aba “Modes” de #/config (sem mais markdown cru). A aba “Modes” editava modes/_profile.md como um único <textarea> cru por seção ## (granularidade em nível de seção da v1.36.0). A pedido do usuário, agora renderiza um formulário de campos estruturado derivado do esquema documentado (career-ops.org Quick Start §Step-5): Target Roles / Adaptive Framing / Comp Targets → entradas de linha rotuladas repetíveis para adicionar/remover (uma linha de cargo/ângulo/comp por campo, + Add line / ✕ por linha com aria-label); Exit Narrative / Location Policy → um único <textarea> de prosa rotulado. Cada campo é um controle real vinculado por <label htmlFor> com um nome de seção i18n. O novo public/js/lib/modes-form.js (window.ModesForm) detém a lógica parse → render → collect(); ele alimenta o caminho de merge existente PUT /api/modes/_profile { sections }, de modo que o preâmbulo, a ordenação e qualquer seção que o formulário não toque sobrevivem byte-estáveis (merge-não-substituição, imposto pelo servidor). Segurança de dados: uma seção de lista canônica cujo corpo não seja uma lista de marcadores pura (o usuário colocou prosa ali) e qualquer seção ## não canônica recorrem a um <textarea> literal rotulado com uma nota explicativa — o conteúdo arbitrário faz round-trip intacto, nunca é reescrito nem perdido silenciosamente. Estabilidade de round-trip comprovada: serialise(parse(body)) re-parseia identicamente. O editor de markdown cru do arquivo completo permanece como a divulgação Advanced com confirmação para adicionar/remover seção e editar o preâmbulo (portão de salvamento destrutivo do WS2 #4 inalterado). 10 novas chaves i18n (config.modesTargetRoles … config.modesUnknownNote) nos 8 locales. +1 arquivo de testes tests/modes-form.test.mjs (7 casos); 725 → 732. Verificado ao vivo contra uma fixture isolada CAREER_OPS_ROOT: 5 seções canônicas renderizadas como campos + 1 seção personalizada como fallback rotulado, round-trip de editar-e-salvar preservou o preâmbulo + a seção personalizada, 0 erros de console. feat(config) · test: tests/modes-form.test.mjs. Detalhe em CHANGELOG.md.


[1.54.2] — 2026-05-18

feat(config): seletor de modelo OpenAI / Codex em #/config. #/config não tinha forma de escolher o modelo OpenAI / Codex — apenas ANTHROPIC_MODEL e GEMINI_MODEL tinham menus suspensos, embora OPENAI_API_KEY já estivesse exposta para o fluxo multi-CLI do projeto pai (Codex / OpenCode). Agora OPENAI_MODEL é uma chave de ambiente de primeira classe: adicionada ao KNOWN_KEYS de env-config.mjs (ordenada logo após OPENAI_API_KEY) e ao grupo de chaves core, e deliberadamente não em SECRET_KEYS — é um id de modelo, não uma credencial, então nunca é mascarada. config.js ganha uma lista curada OPENAI_MODELS (gpt-5-codex por padrão, depois gpt-5 / gpt-5-mini / gpt-4.1 / o4-mini / o3) e um campo <select> OPENAI_MODEL renderizado logo após a chave OpenAI, espelhando exatamente os campos de modelo Anthropic/Gemini. Novas chaves i18n config.openaiModel + config.openaiModelHint nos 8 locales. +1 arquivo de testes tests/openai-model-selector.test.mjs (4 casos); 721 → 725. Verificado ao vivo: #/config → select OPENAI_MODEL com 6 opções, padrão gpt-5-codex, vinculado ao rótulo, 0 erros de console. feat(config) · test: tests/openai-model-selector.test.mjs. Detalhe em CHANGELOG.md.


[1.54.1] — 2026-05-18

fix(a11y): F-V54-A — #/cv com um único <h1>. O próprio # Name do markdown do CV era renderizado como um segundo <h1> de nível superior ao lado do <h1>CV</h1> do título da página (corrida de regressão F-V54-A; WCAG 1.3.1 Informações e relações / 2.4.6 Cabeçalhos). Agora cv.js canaliza cada ponto de injeção da pré-visualização do CV (render inicial, atualização ao importar arquivo, sincronização ao vivo do editor) por um cvMd() de escopo restrito que rebaixa os cabeçalhos um nível (h1→h2 … h6→role="heading" aria-level="7"), de modo que a página mantém exatamente um <h1>. Restrito a cv.js de propósito — UI.md é compartilhado por help/reports/deep/evaluate, que gerenciam seus cabeçalhos à sua maneira. +1 arquivo de testes tests/cv-single-h1.test.mjs (4 casos); 717 → 721. Verificado ao vivo: #/cv → 1 <h1>, o # Name do usuário agora é <h2>, 0 erros de console. fix(a11y): F-V54-A · test: tests/cv-single-h1.test.mjs. Detalhe em CHANGELOG.md.


[1.54.0] — 2026-05-18

WS10 — re-validação de docs canônicos + paridade H3 do pacote de ajuda (a versão final de convergência). O gate de CI de CHANGELOG/estrutura só verificava H2, então docs/help/en.md havia derivado em silêncio até 70 subseções H3 enquanto os 7 pacotes localizados ficavam em 68 — a lacuna era §17 (a tabela «Reference adapters» + a lista «Common pitfalls», só em inglês). Ambas estão agora traduzidas para os 7 idiomas (nomes de arquivo / links / identificadores de adaptadores mantidos byte a byte idênticos); os 8 pacotes agora têm 17 H2 / 70 H3. Um novo gate de paridade H3 em help-ru-config-section.test.mjs o trava (716 → 717). canonical-docs-coverage.test.mjs 7/7 confirma que a ajuda ainda reflete as 5 guias de career-ops.org/docs; a auditoria UX do WS2 (40 achados v1.41→v1.52) validou cada tela frente aos docs — sem divergência. docs/sdd/CONVENTIONS.md atualizado para v1.54.0 (totais de testes, gate de paridade H3, arquivos atípicos por tamanho, nova seção de convenções de Acessibilidade). WS0–WS10 completos; só resta WS11. fix(docs): WS10 canonical re-validation + H3 parity · test(help): H3-parity gate. Detalhe em CHANGELOG.md.


[1.53.0] — 2026-05-18

WS9 — pirâmide de testes da superfície shell (a última camada sem testes). Os 4 scripts bin/*.sh e o hook .githooks/pre-commit tinham cobertura zero; o novo tests/sh-files.test.mjs adiciona 10 casos que fixam a sintaxe bash -n/sh -n, o shebang + bit executável e os contratos de comportamento dos quais outros workstreams dependem: career-ops-ui.sh — help sai com 0 sem vazamento de shell-source (guarda de regressão v1.40.0), um verbo desconhecido sai com 2, e usage() é um heredoc; start.sh — respeita NO_OPEN, exige Node ≥ 18 e delega o levantamento do navegador a scripts/open-dashboard.mjs (guarda v1.43.0); setup.sh — modo estrito, SKIP_START, clona ambos os repos; run_all.sh — parsing de --quick/--no-e2e e as 4 suítes; .githooks/pre-commit executa o revisor do WS7 e nenhum arquivo shell invoca git --no-verify (guarda da regra dura #7 do CLAUDE.md); install-hooks.mjs conecta core.hooksPath. docs/architecture/TESTING.md — adicionada a camada base de superfície shell ao diagrama da pirâmide + uma nota de totais v1.53.0 (716 casos node --test / 90 arquivos + 4 superfícies E2E). 706 → 716. Detalhe em CHANGELOG.md.


[1.52.0] — 2026-05-18

WS2 LOWs #33–#40 — varredura de polimento em lote (fecha a fila da auditoria de UX). Oito achados de baixa severidade. fix(a11y/i18n): WS2 LOW batch — #33: #/dashboard — os 3 CTAs de cabeçalho eram inconsistentes (só 2 tinham ícone inicial); «Open Pipeline» agora leva 📋 e os três coincidem. #34: #/profile — os fit/level do arquétipo eram dois chips ambíguos; agora prefixados (Fit: / Level:) com aria-label correspondente. #35: #/health — os toasts de Run-doctor / verify mostravam strings cruas de doctor.mjs; agora com chave i18n. #36: #/health — os resultados das verificações eram <div>s planos; agora um role=list <ul>/<li> e o badge de status leva aria-label="<check>: <status>". #37: #/reports — os cards eram <div onClick> só de mouse; agora role=link + tabindex + handler Enter/Espaço + aria-label. #38: #/activity — o comentário do paginador dizia «200» enquanto o código pedia 500; reconciliado a uma constante CAP e um aviso role=note aflora quando o limite de 500 trunca o histórico antigo. #39: #/batch — os placeholders em prosa estavam codificados em inglês enquanto seus aria-labels estavam localizados; os quatro agora com chave i18n. #40: as páginas de modo relabelavam o botão primário em silêncio após a sonda assíncrona; agora uma região role=status cortês o anuncia. 10 novas chaves i18n × 8 idiomas ({n} preservado); +9 testes: test: tests/low-sweep.test.mjs. 697 → 706. Fecha a fila da auditoria de UX de WS2 (#1–#40 de v1.41→v1.52); próximo WS9 → WS10 → WS11. Detalhe em CHANGELOG.md.


[1.51.0] — 2026-05-18

WS2 #13 + #14 + #18 + #19 + #20 — varredura de feedback/i18n em #/auto e #/evaluate. Cinco achados da auditoria de UX. fix(a11y/ux): auto+evaluate — busy state, actionable HTTP errors, clipboard fallback, aria-live result, spinner-guarded submit — #13: o botão Run de #/auto agora mostra um estado ocupado (is-loading + aria-busy + “Running…”) em vez de apenas se desabilitar. #14: uma requisição HTTP falha agora aflora uma mensagem i18n acionável sobre o passo E um toast (auto.httpFail com {n}), em vez de um seco “HTTP 500”. #18: o “Copy prompt” do modo manual agora usa a Clipboard API assíncrona com fallback execCommand, e emite um toast de falha real em vez de um falso “Copied”. #19: o contêiner do resultado de evaluate é agora role=status aria-live=polite, de modo que a longa chamada ao LLM é anunciada aos leitores de tela. #20: o botão Evaluate vai envolvido em UI.withSpinner (era um onClick: run simples que permitia envios duplicados). 3 novas chaves i18n × 8 idiomas; +6 testes: 691 → 697. Também uma correção apenas de testes (commit 7f8e250): o teardown de e2e pipeline-delete estava no caminho do confirm nativo anterior à v1.48; migrado para DELETE por API (fix(test): … — o Playwright-e2e de CI estava vermelho; não é uma regressão do produto). Detalhe em CHANGELOG.md.


[1.50.0] — 2026-05-18

WS2 #12 + #27 + #28 — acessibilidade da navegação da ajuda. Três achados da auditoria de UX em #/help sobre um guia de 17 seções e 90+ cabeçalhos, corrigidos em help.js. fix(a11y): help — single h1, labelled+filterable TOC, focus-on-anchor, back-to-top — #28: o markdown do documento abria com seu próprio # Title, produzindo um SEGUNDO <h1> numa página cujo cabeçalho já fornece o h1 canônico; agora todo <h1> do artigo é removido, de modo que há exatamente um h1 e a hierarquia começa limpa nas seções <h2>. #27: o <nav> do TOC era um ponto de referência sem nome (dois <nav> sem rótulo na página); agora tem aria-label (help.toc), e ao clicar numa entrada do TOC o foco se move para o cabeçalho da seção (tabindex=-1 + focus()), não apenas a rolagem do viewport. #12: não havia como encontrar nada num documento longo; um filtro type=search acima do TOC reduz as entradas por texto de cabeçalho ao vivo, e um botão flutuante com aria-label “Back to top” aparece após rolar, volta ao início e devolve o foco ao <h1> da página; seu listener de scroll é removido no hashchange ao sair de #/help. 2 novas chaves i18n × 8 idiomas — help.tocFilter, help.backToTop; +6 testes: test: tests/help-nav-a11y.test.mjs. 685 → 691. Detalhe em CHANGELOG.md.


[1.49.0] — 2026-05-18

WS2 #10 + #11 + #25 + #26 — acessibilidade e ordenação da tabela do tracker. Quatro achados da auditoria de UX em #/tracker, corrigidos em tracker.js. fix(a11y): tracker headers, sortable table, localized fix labels, empty state — #10: o cabeçalho da coluna de ação era uma string vazia e o botão Report por linha não tinha contexto; agora todo <th> tem scope=col, o cabeçalho de ação e os de Score/PDF estão com chave i18n (estavam vazios ou em inglês codificado), e o botão Report ganha um aria-label com a empresa (<report> — <company>). #11: um tracker sem como ordenar; os cabeçalhos Date / Score / Status são agora botões de ordenação operáveis por teclado dentro do <th> com aria-sort (none/ascending/descending); um comparador sorted() (numérico para score, comparação de locale para date/status) roda antes da paginação, e o clique alterna a direção e reinicia o paginador. #25: track.normalize/dedup/merge eram inglês idêntico nos 8 idiomas apesar de serem os controles destrutivos de maior risco (reescrevem data/applications.md no lugar) — agora corretamente localizados, mais um title de dica. #26: a primeira execução com zero linhas mostrava a mesma mensagem “no match” que uma lista superfiltrada; rows.length === 0 agora renderiza um estado vazio distinto (título + corpo + CTA “Open pipeline”). 7 novas chaves i18n × 8 idiomas + 3 relocalizadas; +6 testes: test: tests/tracker-a11y-sort.test.mjs. 677 → 683. Detalhe em CHANGELOG.md.


[1.48.0] — 2026-05-18

WS2 #8 + #22 — pipeline: confirmação com foco preso + acessibilidade da pré-visualização. Dois achados da auditoria de UX em #/pipeline, corrigidos em pipeline.js. fix(a11y): pipeline UI.confirm() + live preview region — #8: as três ações de #/pipeline usavam confirm() nativo (sem foco preso): o Delete do painel de pré-visualização, o ✕ de cada linha e “Evaluate first”; agora todas passam pelo UI.confirm() com foco preso (infra v1.44.0) — as duas exclusões danger:true (Cancelar por padrão), “Evaluate first” danger:false; não resta nenhum confirm() nativo em pipeline.js. #22: previewPane não tinha papel ao vivo e uma falha de fetch era enfiada em previewBody, renderizada como uma <pre> “preview” enganosa; agora é role=region aria-live=polite com um aria-label, e as falhas definem um previewError à parte renderizado como um bloco role=alert distinto (limpo ao (re)selecionar e ao excluir a linha ativa). 4 novas chaves i18n × 8 idiomas; +5 testes: test: tests/pipeline-confirm-preview.test.mjs. 672 → 677. Detalhe em CHANGELOG.md.


[1.47.0] — 2026-05-18

WS2 #7 + #30 + #31 + #16 — varredura de acessibilidade de rótulos não vinculados. Quatro achados da auditoria de UX em que controles de formulário não tinham rótulo programático (WCAG 1.3.1 / 3.3.2 / 4.1.2), agora vinculados. fix(a11y): bind every swept form control to an accessible name — #7 scan.js: o checkbox dry-run e o dropdown company-select tinham rótulos sem for; adicionado htmlFor (com os id existentes). #30 deep.js: os inputs company / role tinham rótulos não vinculados; adicionado id + htmlFor (deep-company, deep-role). #31 apply.js: url / jd tinham rótulos não vinculados; adicionado id + htmlFor (apply-url, apply-jd). #16 cv.js: o <textarea> principal de markdown não tinha nome acessível; vinculado via aria-labelledby ao título visível “Markdown” — nome para leitores de tela idêntico ao título na tela, sem nova chave i18n. Usa o padrão explícito label[for]↔control[id] já padrão em batch.js / mode-page.js; sem novas chaves i18n; zero mudança de comportamento. +5 testes: test: 667 → 672. Detalhe em CHANGELOG.md.


[1.46.0] — 2026-05-18

WS2 #5 + #6 + #21 + #24 — acessibilidade do SSE de scan. Quatro achados da auditoria de UX em #/scan, corrigidos em scan.js. fix(a11y): scan SSE — live-log region, Stop, run-state, error banner — #5: o console de streaming agora é role=log aria-live=polite (+ aria-label, tabindex=0, rolável por teclado), com uma região oculta visualmente à parte role=status assertiva que anuncia os eventos terminais (concluído / falhou / parado). #6: um botão Stop fecha o EventSource em voo (es.close()), cancela o polling de resultados e reinicia o estado; só é exibido enquanto um scan corre. #21: o botão Scan é desativado + recebe aria-busy enquanto corre e Stop aparece, em ambos os caminhos de stream (streamTo de uma fase e runScanAll multifase — este último só encerra a execução no done terminal, final !== false). #24: uma falha do SSE não é mais só um toast de 3,5 s; agora um banner persistente role=alert mostra o erro com uma ação de repetição (reinvoca a última função de execução), limpo na próxima execução. 8 novas chaves i18n × 8 idiomas; +7 testes: test: tests/scan-sse-a11y.test.mjs. 660 → 667. Detalhe em CHANGELOG.md.


[1.45.0] — 2026-05-18

WS2 #3 — abas de #/config: padrão WAI-ARIA Tabs completo. As três abas de #/config (API keys / Profile / Modes) eram <button class="tab-btn"> simples com ativação apenas por clique: sem role, sem aria-selected, sem modelo de teclado (UX-audit HIGH #3, WCAG 4.1.2 / 2.1.1). fix(a11y): config.js tabs implement role=tablist/tab/tabpanel — agora um contêiner role=tablist com aria-label; cada aba role=tab + id + aria-controls + aria-selected (sincronizado em activate()) + tabindex itinerante (0 ativa / -1 demais); o painel role=tabpanel + tabindex=0 + aria-labelledby acompanhando a aba ativa. Navegação de teclado completa: ←/→/↑/↓ (com envolvimento) + Home/End movem o foco E ativam. O gancho CSS legado .tab-btn.is-active é preservado. +1 chave i18n × 8 idiomas (config.tablistLabel); +7 testes: test: tests/config-tabs-aria.test.mjs. Além disso, uma correção apenas de testes: fix(test): retarget 2 stale auto-pipeline smoke tests — dois smoke tests de Playwright-e2e anteriores ao v1.34 afirmavam um modal transitório que o botão “Auto-pipeline” do dashboard deixou de abrir no v1.34.0 (→ Router.go('/auto')); estavam vermelhos no job de CI Playwright-e2e separado. Reapontados para a tela #/auto. 653 → 660. Detalhe em CHANGELOG.md.


[1.44.0] — 2026-05-18

WS2 #4 + #9 — confirmação com foco preso antes de sobrescritas destrutivas de arquivos do projeto pai. Dois HIGH da auditoria de UX, ambos com perda de dados: (#4) config.js saveProfileRaw/saveModesRaw substituía o config/profile.yml / _profile.md inteiro do pai sem confirmação; (#9) tracker.js Normalize/Dedup/Merge reescrevia o data/applications.md do pai in loco sem confirmação. fix(a11y/safety): UI.confirm() gate before whole-file parent overwrites — novo UI.confirm() em public/js/api.js, um diálogo com foco preso que reutiliza a infraestrutura modal WAI-ARIA existente (um hook _onClose faz Esc / backdrop / × / Cancel resolverem todos false; o foco recai por padrão em Cancel; retorna Promise<boolean>; NÃO o confirm() nativo). As três chamadas destrutivas agora ficam protegidas antes da escrita. 8 novas chaves i18n × 8 locais (o marcador {op} é preservado verbatim); +8 testes: test: tests/confirm-gate.test.mjs, 644 → 652. Detalhe em CHANGELOG.md.


[1.43.0] — 2026-05-18

Solicitado pelo usuário — career-ops-ui open + autostart trazendo o navegador para frente. Após setup/run, um open/xdg-open pelado deixava a aba do dashboard em segundo plano quando o navegador já estava aberto, obrigando o usuário a procurá-la. feat(cli): career-ops-ui open — open AND raise the dashboard tab — o novo scripts/open-dashboard.mjs constrói a URL a partir de HOST/PORT (reescrevendo um bind 0.0.0.0 para loopback), opcionalmente espera por /api/health, abre o navegador padrão e então o força para frente — osascript no macOS ativando o que estiver rodando entre Chrome/Brave/Edge/Safari/Arc/Firefox, xdg-open+wmctrl no Linux, start no Windows. Exposto como o verbo career-ops-ui open (aliases dash, focus). O autostart de bin/start.sh agora delega a ele para que a aba seja trazida para frente automaticamente; NO_OPEN=1 desativa o auto-open em inicializações headless/CI. README ×8 + help §1 ×8 atualizados; +8 testes: test: tests/open-dashboard.test.mjs, 636 → 644. Detalhe em CHANGELOG.md.


[1.42.0] — 2026-05-18

WS2 correção nº 2 — rota morta #/portals → deep-link para config. #/portals era uma rota não registrada que renderizava a view 404, embora seja uma URL plausível de favorito/digitação para gerenciar fontes de portais (HIGH nº 2 da auditoria de UX). fix(router): #/portals 404 → alias to config + Regional-sources deep-link — adicionado portals: 'config' ao ALIASES do router.js (mesmo padrão de estabilidade de favoritos que settings→profile), então agora resolve para a view config com o item de navegação config ativo. Quando existe um grupo Regional-sources, a view (config.js) detecta o hash #/portals, força a abertura desse grupo <details>, rola-o até a vista e move o foco para seu summary (sobrepondo o foco h1 padrão), de modo que o usuário aterrissa exatamente nos controles de fontes de portais; nunca renderiza um grupo regional vazio só pelo alias. help-bundle §5 × 8 ganhou uma nota de atalho; +1 teste de router: test(router): portals→config alias guarantee em router.test.mjs, 635 → 636. Detalhe em CHANGELOG.md.


[1.41.0] — 2026-05-18

WS2 — auditoria sênior de UX/usabilidade + correção transversal de gestão de foco. Uma auditoria heurística de mais de 10 anos (Nielsen × WCAG 2.2 AA × convenções do projeto) das 17 rotas produziu uma fila de 40 achados ordenada por severidade (.planning/.../UX-AUDIT.md); HIGH→MEDIUM→LOW são entregues agora uma correção por release. Esta release aterrissa o HIGH transversal nº 1. Correções: fix(a11y): move focus to the new view on every route change — router.js render() substituía #content a cada hashchange mas nunca movia o foco, então usuários de teclado / leitor de tela ficavam no nó destruído e perdiam o lugar (WCAG 2.4.3 Focus Order / 4.1.3 Status Messages — transversal, afetava as 17 telas); o novo focusNewView(content) foca o primeiro h1/.page-title da nova view (anúncio SR conciso + ordem de foco correta), tornando o cabeçalho focável (tabindex=-1) se preciso e recorrendo a #content; o primeiro paint é pulado para não brigar com o skip-link; ligado nas rotas de render de sucesso e erro; verificado ao vivo: após navegar, document.activeElement é o H1.page-title da nova view. Testes: test(router): focus-management static guarantees — 4 casos em router.test.mjs (helper definido, alvo-cabeçalho + fallback para content, guarda de pulo no primeiro paint, ≥2 pontos de chamada); 631 → 635. Detalhe em CHANGELOG.md.


[1.40.0] — 2026-05-18

WS8.3 — varredura de atualização de docs + correção de career-ops-ui help + endurecimento de askSecret. Correções: fix(cli): career-ops-ui help no longer leaks shell source — o dispatcher imprimia seu comentário de cabeçalho com sed -n '2,12p', mas a linha 12 (set -euo pipefail) é código, não comentário, então career-ops-ui help (e o texto de uso de verbo desconhecido) terminava com uma linha set -euo pipefail perdida; restringido a 2,11p (o bloco de comentário) nos casos help e *); help sai 0, verbo desconhecido sai 2 — verificado. fix(cli): scripts/init.mjs key entry never echoes — o follow-up de v1.39.0 substituiu a máscara cosmética de readline por um leitor real em modo raw: setRawMode(true) + linha com buffer para que os bytes de chave digitados/colados nunca cheguem ao terminal (sem vazamento em scrollback / tmux / compartilhamento de tela); um FSM completo de escape VT consome cada sequência CSI/SS3/OSC/DCS/SOS/PM/APC para que as teclas de seta e função não corrompam o segredo; stdin é injetado por dependência, então o fallback não-TTY é testado unitariamente sem mexer no global; iterado até um LGTM limpo da revisão IA. Documentação: README ×8 — a antiga seção “instalação em um comando” é substituída por uma seção destacada “Iniciar e inicializar em um comando” (o one-liner de curl mais a cadeia explícita do CLI career-ops-ui: clone → npm link → setup → init → doctor → run → help, a explicação do assistente de provedor, a forma CI --provider --anthropic-key --yes e a nota de LLM_PROVIDER); os 8 badges de README atualizados de v1.22–v1.24 / tests-461–474 para v1.40.0 / tests-631 (badge e2e tornado não numérico para evitar uma contagem inventada); help-bundle ×8 §1 — um callout “Lançamento e init em um comando” adicionado ao topo do manual de início rápido (antes de “A. Setup”) nos 8 idiomas; paridade de seções H2 preservada (17 cada — gate de CI verde). Testes: test(init): non-TTY askSecret fallback — provider-selector.test.mjs ganha um caso de stdin por DI verificando que askSecret delega ao ask() simples (paridade de trim) fora de um TTY sem mutar o global compartilhado; 629 → 631. Detalhe em CHANGELOG.md.


[1.39.0] — 2026-05-18

WS8.2 — seletor de provedor LLM + chave OpenAI/Codex + assistente init interativo. LLM_PROVIDER (auto|claude|gemini) + OPENAI_API_KEY no env-config (secreto). providerOrder() consultado pelos 6 gate-sites do llm.mjs via _provGate(); sem mudança para auto. Select + campo em #/config. scripts/init.mjs agora é assistente real (grava parent .env pela rota validada). 7 testes. 622 → 629. README ×8 / fold canônico = WS8.3/WS10. Detalhe em CHANGELOG.md.


[1.38.0] — 2026-05-17

WS8.1 — dispatcher CLI unificado + verbo doctor. bin/career-ops-ui.sh despacha setup/run/doctor/init/help. scripts/doctor.mjs reutiliza o motor /api/health exato (createApp in-process → relatório terminal); exit 0 só se todos os checks OBRIGATÓRIOS passarem. docs/sdd + help §1 ×8. 6 testes. 616 → 622. README quickstart ×8 = WS8.3. Detalhe em CHANGELOG.md.


[1.37.0] — 2026-05-17

WS7 — revisão AI pré-commit no workflow git. Floor determinístico (fail-HARD): bloqueia .env/segredos staged, padrões de chave no diff, .also( em views staged, falha node --check. Camada AI (fail-SOFT): claude -p sobre o diff se o CLI existir e AI_REVIEW != off. .githooks/pre-commit + prepare. Nunca --no-verify. docs/sdd. 6 testes. 610 → 616. Detalhe em CHANGELOG.md.


[1.36.0] — 2026-05-17

WS6.3 — aba Modes: blob bruto → editor por seção. WS6 completo. modes/_profile.md editado por seção ## (um textarea recolhível por cabeçalho). splitProfileSections byte-exato; PUT { sections } faz merge só das seções nomeadas — preâmbulo + seções alheias + ordem preservados byte a byte. Cabeçalho desconhecido → 400. Rota raw intacta. i18n 5 chaves ×8. help §2 ×8. 6 testes novos. 604 → 610. WS6 concluído. Detalhe em CHANGELOG.md.


[1.35.0] — 2026-05-17

WS6.4 — editores de arrays do Profile + auditoria WS6.2 de API-keys. PUT /api/profile aceita { arrays } (combinável com { fields }): Target roles/Superpowers (listas), Archetypes (name/level/fit), Proof points (name/url/hero-metric). Mesma garantia merge-not-replace; linhas vazias descartadas; lista vazia remove a chave. 4 editores add/remove em #/config. i18n 6 chaves ×8. Auditoria: KNOWN_KEYS ≡ FIELDS, sem gap. 7 testes novos. 597 → 604. Detalhe em CHANGELOG.md.


[1.34.0] — 2026-05-17

WS5 — tela Auto-pipeline de um clique (#/auto). O modal virou página dedicada e linkável. Um clique roda validar→buscar→avaliar→salvar relatório→tracker via SSE. Stepper acessível, deep-links, modo manual sem key, linkável #/auto?url=…&go=1. Entrada no sidebar; botão ✨ do dashboard agora vem aqui. i18n 14 chaves ×8. help §1 ×8 + README ×8. 8 testes novos. 589 → 597. Detalhe em CHANGELOG.md.


[1.33.0] — 2026-05-17

WS4 — auditoria de paridade com career-ops 1.8.0 + location_filter. O scan.mjs do projeto pai ganhou location_filter (#570); os scanners in-process do web-ui não delegam a ele. Novo server/lib/location-filter.mjs replica a semântica verbatim; integrado nos dois scanners. Doc help §5 ×8. 8 testes novos. 581 → 589. Detalhe em CHANGELOG.md.


[1.32.0] — 2026-05-17

Aba Profile de #/config — blob YAML bruto → formulário por campos (WS1). 3 seções recolhíveis (Candidato / Narrativa / Remuneração), 14 caminhos escalares. Save por campos faz merge em config/profile.yml: arquétipos, proof points e chaves próprias preservados intactos. Escape-hatch raw-YAML mantido em Advanced (preserva comentários). 23 chaves i18n ×8. 7 testes novos. 574 → 581. Detalhe em CHANGELOG.md.


[1.31.0] — 2026-05-17

Sync com career-ops 1.8.0 — #/batch expõe --model + --start-from. O projeto pai subiu 1.7.1 → 1.8.0; batch-runner.sh ganhou --model NAME (#504) e --start-from N. web-ui os expõe em #/batch (campos Modelo e A partir de #) com validação defense-in-depth no servidor. i18n ×8. 7 testes novos. 567 → 574. Detalhe completo em CHANGELOG.md.


[1.30.0] — 2026-05-14

Paginador em #/scan — substitui o truncamento «primeiros 200 de N» de v1.12.

Pré-v1.30 a tabela de resultados de scan era truncada nas primeiras 200 linhas filtradas com a nota «Showing first 200 of N» no rodapé. As linhas 201..N eram inacessíveis pela UI. v1.30.0 troca o cap por UI.paginate (mesmo helper de #/tracker / #/reports / #/activity). PAGE_SIZE = 200 preserva a densidade visual anterior; ordenação boosted-to-top estável entre páginas (ordena o conjunto COMPLETO antes de paginar); reset automático para página 1 ao mudar filtros. Chave i18n obsoleta scan.shownTop removida (×8 locales). 9 novos casos em tests/scan-paginator.test.mjs (7 canários estáticos + 1 tabela lógica com 6 casos limite + 1 cálculo do resumo). 558 → 567 unit + acceptance (+9). Detalhes completos em CHANGELOG.md.


[1.29.2] — 2026-05-14

Hot-fix: 🌐 Scan com source=both rodava apenas a fase EN. A fase RU era descartada silenciosamente.

O cliente SSE (public/js/api.js:156) fechava o EventSource no PRIMEIRO evento done, mas o servidor emite um por fase em source=both. A fase RU iniciava e era imediatamente cancelada. Fix: servidor marca cada done com final: true|false; cliente fecha apenas quando final !== false. Retrocompatível — produtores de fase única sem final continuam fechando como antes. 547 → 558 unit + acceptance (+11 novos). Detalhes completos em CHANGELOG.md.


[1.29.1] — 2026-05-14

Guia detalhado do usuário para configurar os 5 portais RU no help-bundle §5, em todas as 8 locales.

Nova subseção “Configurar os portais russos — guia detalhado” dentro de §5 (Portals & sources): tabela inventário das 5 fontes com auth e restrições geográficas, passo-a-passo para localizar e editar portals.yml, exemplo YAML completo das 5 fontes, colisão com lista negativa com correção, como desabilitar uma fonte, como verificar via 🌐 Scan + log SSE. §17 (shipped em v1.29.0) cobre o fluxo do desenvolvedor; §5 v1.29.1 cobre o fluxo do usuário final. 540 → 547 unit + acceptance (+7 novos). Detalhes completos em CHANGELOG.md.


[1.29.0] — 2026-05-14

Scanner de portais russos passa de 2 a 5 fontes; registry + dropdown dinâmico; nova seção §17 “Como adicionar um novo portal”.

  • 3 novos adapters RU: Trudvsem (API open-data do governo, sem auth nem geo-gate), GetMatch e GeekJob (HTML scrape com parser defensivo — [] se não parsear, nunca throw em 200 saudável).
  • Source registry em server/lib/sources/registry.mjs — única fonte da verdade consumida por dispatcher + endpoint + dropdown. Pré-v1.29 a lista vivia hardcoded em TRÊS lugares.
  • Novo endpoint GET /api/scan/sources com Cache-Control: max-age=60 — o SPA reconstrói o dropdown ao montar #/scan.
  • Help-bundle §17 nova nas 8 locales: “Como adicionar um novo portal” (template de adapter, entry do registry, dispatcher, teste mockado, portals.yml).
  • russian_portals.sources default muda de ["hh", "habr"] para as 5 fontes; se o seu portals.yml já lista sources: explicitamente, você deve adicionar as 3 novas manualmente.
  • Testes: 520 → 540 (+20). Detalhes completos em CHANGELOG.md.

[1.28.1] — 2026-05-14

Hot-fix: router 404 com hashes que levam ?query. Linha HH_USER_AGENT removida do health.

Pré-v1.28.1 Router.go('/evaluate?url=…') produzia um hash cujo primeiro split('/') era o literal "evaluate?url=…", que nunca coincidia com uma rota registrada → __not_found__ (404). Fix de uma linha: hash.split('?')[0] antes do split do nome. Cobre dois cliques reportados: #/pipeline → ▶ e “App settings → Modes”. A linha opcional HH_USER_AGENT foi removida de /api/health (a dica 403-fora-da-Rússia continua no help-bundle §16 e é emitida em stderr durante o scan). 515 → 520 unit + acceptance (+ 5 novos). Detalhes completos em CHANGELOG.md.


[1.28.0] — 2026-05-14

Alinhamento de docs + novo controle --max-retries N em #/batch. Fecha as duas issues abertas levantadas por qa/QA-PROMPT-docs-vs-app.md.

  • Issue #2 — #/batch agora expõe um campo numérico “Max retries” (1–10), habilitado apenas quando “Retry failed” está marcado. O servidor faz parse + valida 1≤N≤10 (valores fora de faixa são descartados silenciosamente) e omite --max-retries sem --retry-failed. 7 casos de teste em tests/batch-max-retries.test.mjs. 2 chaves i18n novas × 8 locales.
  • Issue #1 — a lista de CLIs de IA nos 8 help-bundles e 8 READMEs alinha com o cânon de career-ops.org/docs (Claude Code · Codex · OpenCode · Qwen CLI), com uma frase localizada: «outras CLIs compatíveis com Claude também funcionam pela mesma superfície de slash-commands». O bullet “Multi-CLI” do README sobre os arquivos shim do web-ui é mantido (descreve outra superfície). 2 novos canários em tests/canonical-docs-coverage.test.mjs.
  • 506 → 515 unit + acceptance (+ 9 novos). Playwright 32/32 sem alterações. Detalhes completos em CHANGELOG.md.

[1.27.0] — 2026-05-14

Polimento cosmético + a11y: desduplicar a entrada #/dashboard da barra lateral.

Na barra lateral, o logo da marca (<a class="logo" href="#/dashboard">) e o primeiro item de navegação apontavam para a mesma rota. Leitores de tela anunciavam «Dashboard» duas vezes e usuários de teclado tinham um tab-stop redundante. O bloco da marca agora é um <div class="logo"> simples; o item de navegação continua sendo o único link para #/dashboard. 506 / 506 unit + 32 / 32 Playwright — sem alterações. Detalhes completos em CHANGELOG.md.


[1.26.1] — 2026-05-14

Hot-fix WCAG 2.5.5 — altura mínima 44 px de .btn restaurada.

v1.26.0 perdeu a declaração min-height: 44px em .btn; botões do header renderizavam a 39-41 px (violação WCAG 2.5.5). v1.26.1 restaura o piso de 44 px + flex-shrink: 0 + line-height: 1.2. 502 → 506 unit, 32/32 Playwright inalterados. Detalhe em CHANGELOG.md.



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