Historial de cambios
GitHubCada cambio relevante, versión a versión. El mismo archivo se publica en el repositorio y está traducido a los 19 idiomas.
[1.58.34] — 2026-05-20
feat(ui): v1.58.34 — Drawer de notificaciones (cierra U-13 por completo). Sobre la captura de v1.58.33: nueva API UI.onToast(fn) (pub/sub), campana 🔔 en la top-bar con badge de no leídos, drawer derecho con título/vacío/items localizados (notif.* × 8). Esc + cerrar + click en la campana cierran. 926 → 927 unitarios. (U-13 follow-up)
[1.58.33] — 2026-05-20
fix(ux): U-13 + U-14 + U-15 — diario de toasts (cap 50 + UI.getToastHistory()) + selector de seguridad para .page-header h1 + p + indicador de cambios sin guardar en #/cv. Cierra el ciclo v1.58.x. Nueva clave i18n cv.unsaved × 8 idiomas. 925 → 926 unitarios. (U-13/U-14/U-15)
[1.58.32] — 2026-05-20
fix(ux): U-12 — la barra de filtro del TOC de ayuda gana min-width: 16ch para que los placeholders KO/JA no se recorten. Clase .help-toc__filter añadida. 924 → 925 unitarios. (U-12)
[1.58.31] — 2026-05-20
fix(ux): U-11 — el encabezado Legitimacy del Tracker ahora muestra un chip de info ⓘ con tooltip explicando la escala (Alta/Precaución/Sospechosa). Nueva clave i18n track.col.legitimacy.help × 8 idiomas. 923 → 924 unitarios. (U-11)
[1.58.30] — 2026-05-20
fix(ux): U-10 — los botones Normalizar / Dedup / Merge del Tracker se desactivan cuando data/applications.md está vacío. Tooltip localizado (track.fixEmpty × 8 idiomas) explica que hay que añadir filas primero. 922 → 923 unitarios. (U-10)
[1.58.29] — 2026-05-20
fix(ux): U-9 — la fila contador ↔ filtro de #/pipeline se apila verticalmente en ventanas estrechas. Nueva clase .pipeline-controls + @media (max-width: 720px) que estira el filtro a 100% del ancho. 921 → 922 unitarios. (U-9)
[1.58.28] — 2026-05-20
fix(ux): U-8 — el bloque del prompt generado ahora está colapsado por defecto en las 7 páginas de modo. Envuelto en <details class="prompt-block">; el resumen muestra “Show prompt (N lines)” localizado (prompt.show / prompt.lines × 8). Copy + Run-live se mantienen visibles. 920 → 921 unitarios. (U-8)
[1.58.27] — 2026-05-20
fix(ux): U-7 — los divisores ASCII === de verify-pipeline.mjs se eliminan del modal de resultado. Regex ^={10,}$ aplicada en el handler antes de renderizar. 919 → 920 unitarios. (U-7)
[1.58.26] — 2026-05-20
fix(ux): U-6 — el chip de #/scan “Active companies N/M” ahora explica N vs M con tooltip + aria-label. Nueva clave scan.activeCo.help × 8 idiomas. 918 → 919 unitarios. (U-6)
[1.58.25] — 2026-05-20
fix(ux/ia): U-5 — Dashboard deduplica CTAs (eliminados el botón Open Pipeline del header y la tarjeta Scan all sources). El sidebar y el héroe ya cubren ambas rutas; las 4× Pipeline / 4× Scan que detectó la QA v1.58.3 quedan reducidas a 2× cada una. 917 → 918 unitarios. (U-5)
[1.58.24] — 2026-05-20
fix(ux): U-4 — los toasts de error tucan el postfijo “(MÉTODO /ruta · HTTP NNN)” dentro de un <details> colapsado. El postfijo técnico sigue presente en el DOM (invariante de BUG-006), pero el titular humano queda limpio. Nueva clave i18n toast.details × 8 idiomas. 916 → 917 unitarios. (U-4)
[1.58.23] — 2026-05-20
fix(ux): U-3 — el placeholder de lastContact en #/followup ahora se calcula como hoy − 14 días. Antes el placeholder era el ISO congelado 2026-04-21 y envejecía mal; ahora se computa en render via new Date() con setDate(getDate() - 14). 915 → 916 unitarios. (U-3)
[1.58.22] — 2026-05-20
fix(ux): U-2 — el H1 de #/auto ya no se rompe a 2 líneas por el ✨ inicial. Antes el auto.title contenía ✨ Auto-pipeline para una URL; el emoji participaba en el wrap. Se separa el emoji en un <span class="page-icon" aria-hidden="true"> y .page-header--icon usa display: grid. 914 → 915 unitarios. (U-2)
[1.58.21] — 2026-05-20
fix(ux): U-1 — H1 + subtítulo de #/cv ahora coinciden con el resto de páginas (supersede UX-9 v1.56.0 por diseño). El chip .cv-breadcrumb se eliminó; el header de #/cv ahora usa <h1 class="page-title"> + <p class="page-subtitle">. El invariante de UN solo <h1> se mantiene. 913 → 914 unitarios. (U-1)
[1.58.20] — 2026-05-20
fix(i18n/platform): I-6 — el atajo en el pie de barra muestra ⌘K en Mac y Ctrl+K en otros sistemas, con el verbo localizado. Antes era el literal CTRL+K — search en todas las plataformas/idiomas. top.langhint ahora usa {hotkey} — buscar; applyFooterHotkey() sustituye {hotkey} por la combinación nativa según navigator.platform. 915 → 916 unitarios. (I-6)
[1.58.19] — 2026-05-20
fix(i18n): I-4 — #/followup ruso ya no filtra cadence / follow-up. Las cadenas RU del modo followup (H1, hints) usaban cadence, follow-up, scope, timeline. Reemplazadas por equivalentes nativos rusos. 914 → 915 unitarios. (I-4)
[1.58.18] — 2026-05-20
fix(i18n): I-3 — ítems 2/5/13/14 del TOC de ayuda sin restos de inglés en locales no latinos. Algunos bundles de ayuda aún mostraban ## 2. App settings & API keys, ## 5. Portals & Sources, ## 13. Mode prompts, ## 14. Apply checklist (ru/ja/ko/zh-CN/zh-TW). Ahora totalmente localizados en los 8 idiomas. 913 → 914 unitarios. (I-3)
[1.58.17] — 2026-05-20
fix(i18n): I-2 — fechas de Saved-research ahora usan Intl.RelativeTimeFormat por locale. El helper formatRelative() en public/js/views/deep.js devolvía today / 1d ago / Nd ago en inglés en todos los idiomas. Sustituido por Intl.RelativeTimeFormat(I18n.getLang(), { numeric: 'auto' }) — la cadena nativa del navegador (hoy/ayer, сегодня/вчера, 今日/昨日, etc.). Fechas > 7 días caen a Intl.DateTimeFormat(locale, { dateStyle: 'medium' }). 912 → 913 unitarios. (I-2)
[1.58.16] — 2026-05-20
fix(ui): parpadeo de hover en botones de marca (reportado por usuario). Causa: el fondo por defecto de .btn-primary / .btn-danger era un linear-gradient(...) y el :hover lo reemplazaba por un color sólido — CSS no puede interpolar gradiente↔sólido, así que la transición de 180 ms chasqueaba y se veía un parpadeo blanco/rosa. Solución en public/css/app.css: mantener el gradiente en hover y oscurecer con filter: brightness(0.92) — filter se interpola limpiamente. La lista de transition de .btn añade filter var(--transition) para que el oscurecimiento se anime. 911 → 912 unitarios. (reporte de usuario)
[1.58.15] — 2026-05-20
fix(a11y/i18n): I-1 — aria-label y <label> oculto del buscador superior ahora se localizan. Antes los lectores de pantalla en cualquier idioma escuchaban el aria-label inglés. Nuevo hook genérico data-i18n-aria-label en public/js/app.js — applyI18n() actualiza aria-label en cada cambio de idioma como ya lo hace para data-i18n y data-i18n-placeholder. Dos nuevas claves i18n (top.search.aria, top.search.label) en los 8 idiomas. El hook es reutilizable para cualquier control futuro. 910 → 911 unitarios. (I-1)
[1.58.14] — 2026-05-20
fix(ux): M-9 — el botón Actualizar del banner de conexión ahora da feedback (antes era una recarga silenciosa). Hasta v1.58.13 el handler llamaba directamente a location.reload(). Ahora muestra un toast Actualizando…, marca sessionStorage['refreshedToast'], desactiva el botón para evitar dobles clics, y difiere la recarga 200 ms para que el toast pinte. En el arranque siguiente, app.js detecta la marca y emite un toast de éxito Actualizado. 2 nuevas claves i18n (common.refreshing, common.refreshed) en los 8 idiomas. 909 → 910 unitarios. (M-9)
[1.58.13] — 2026-05-20
fix(ux): M-8 — el checklist de #/apply ahora es interactivo. Antes de v1.58.13, ▶ Generar checklist mostraba los ítems 0…7 como un bloque monoespaciado <pre> no editable. Ahora cada ítem se renderiza como <input type="checkbox"> real, envuelto en <label> (área de clic ≥44 px, WCAG 2.5.5). El estado se persiste por URL en localStorage['applyChecklist:'+slug] — marca 3 ítems, recarga, los 3 siguen marcados. Botones: Copiar sin marcar (copia los ítems abiertos como bullets - markdown) y Restablecer. 5 nuevas claves i18n (apply.checklist.copyUnchecked, resetBtn, copied, copyFailed, reset) en los 8 idiomas. Fallback defensivo si el parser no encuentra ítems. 908 → 909 unitarios. (M-8)
[1.58.12] — 2026-05-20
fix(ux): M-7 — la línea de coste ahora sigue al proveedor activo (OpenRouter ya no cae en un número fabricado). UI.providerCostHint() ya consultaba /api/status/providers, pero los mapas por proveedor en public/js/api.js sólo listaban anthropic/gemini/openai/qwen. Tras v1.57.0 con OpenRouter como 5º proveedor, éste caía al genérico 0,03 y mostraba el literal openrouter en minúsculas. Ahora EST incluye openrouter: null (el router elige el modelo — el coste varía), y la rama === null emite cost varies (router picks) localizado en lugar del falso ~$0.03/eval. NAME añade openrouter: 'OpenRouter'. Nueva clave i18n cost.varies en los 8 idiomas. 907 → 908 unitarios. (M-7)
[1.58.11] — 2026-05-20
fix(ux): M-4 — el espaciado entre título y fecha en la tarjeta de investigación guardada ahora es CSS estructural (antes margen inline). La regresión MASTER de v1.58.3 verificó que algunas tarjetas mostraban software-engineer-generaltoday (sin espacio entre título y fecha), mientras otras estaban bien — el código previo dependía de style="margin-left: 8px" entre dos <span> sueltos, que colapsaba en ciertos casos. Corrección en public/js/views/deep.js — sustituye los dos <span> por .saved-card__title + un <time class="saved-card__date" datetime="…"> semántico, envueltos en un contenedor flex .saved-card. El espaciado ahora lo controla gap: var(--space-2, 8px), no puede colapsar (y se gana semántica a11y/SEO con <time>). 906 → 907 unitarios. (M-4)
[1.58.10] — 2026-05-20
fix(ux): M-2 — descartar el toast de progreso antes de abrir cualquier modal de resultado. Hacer clic en sync-check en #/cv dejaba el toast “Running cv-sync-check.mjs…” abajo a la derecha mientras se abría el modal de resultado — ambos compitiendo por la atención y, en pantallas estrechas, solapándose visualmente. Los botones Doctor / verify-pipeline de la página Health ya llamaban a UI.dismissToast() explícitamente antes de UI.modal(); el sync-check de cv.js era el único punto de entrada que lo omitía. Corrección en public/js/api.js — UI.modal() ahora invoca dismissToast() como primera sentencia, cubriendo cualquier futuro punto de entrada (defensa en profundidad). Además, las cadenas de cv.js se localizaron mediante t('cv.syncCheckRunning') y t('cv.syncCheck') (invariante BUG-008: título del modal == etiqueta localizada del botón). Dos nuevas claves i18n añadidas en los 8 idiomas. 905 → 906 unitarios. (M-2)
[1.58.9] — 2026-05-20
fix(a11y): M-1 — restablecer un anillo visible de :focus-visible en los campos de formulario (WCAG 2.4.7 Nivel AA). La regresión MASTER de v1.58.3 confirmó que getComputedStyle(focusedInput) devolvía outline: rgb(255,255,255) none 1.5px — la palabra clave none colapsaba el anillo a 0 px en cada campo. Causa raíz: las reglas base .input, .textarea, .select { outline: none } y .searchbar input { outline: none } tenían mayor especificidad que el *:focus-visible global y anulaban silenciosamente el anillo de teclado en 88 elementos por página. Corrección en public/css/app.css — añadidas reglas explícitas .input:focus-visible/.textarea:focus-visible/.select:focus-visible y .searchbar input:focus-visible con outline: 2px solid var(--rausch) + sombra translúcida; el foco de ratón sigue limpio (usa :focus, no :focus-visible). 904 → 905 unitarios (tests/qa-report-fixes.test.mjs guarda estática); Playwright 60 → 61 (tests/playwright-smoke.mjs Tab-traversal). (M-1)
[1.58.8] — 2026-05-20
feat(health): mostrar OPENAI_API_KEY, QWEN_API_KEY, OPENROUTER_API_KEY en #/health (igual que GEMINI_API_KEY). v1.57.0 sumó OpenRouter como 5º proveedor live-eval; v1.55.3 (UX-2) añadió el onboarding de 4 proveedores. La página #/health solo reportaba GEMINI_API_KEY y ANTHROPIC_API_KEY — los otros tres quedaban invisibles aunque /api/status/providers ya los enrutaba. Petición del usuario: extender el mismo patrón “set / unset (manual mode)” a cada proveedor headless. server/lib/routes/health.mjs ahora añade tres filas adicionales de checks opcionales, conectadas al mismo isUsableKey (hasOpenAIKey(), hasQwenKey(), hasOpenRouterKey() ya estaban importadas pero sin usar). El texto “manual mode” coincide con la fila GEMINI en los 8 idiomas — la vista Health itera sobre body.checks por lo que no se requiere cadena por locale. 903 → 904 unitarios. (Solicitud del usuario)
[1.58.7] — 2026-05-20
fix(security): NEW-2 — isValidJobUrl ahora rechaza las sintaxis emparejadas de plantillas (${…}, {{…}}) para coincidir con el mensaje de error. El 400 a nivel de ruta de POST /api/pipeline anuncia “contain no script or template characters”, pero la regresión MASTER de v1.58.3 confirmó que solo <%…%> estilo ASP/EJS estaba realmente bloqueado (efecto colateral del filtro [<>"'\\s]). Las plantillas JS (${TEST}) y Mustache/Handlebars ({{TEST}}) pasaban — un desfase semántico regex↔mensaje. Opción A del fix-prompt (endurecer la regex para coincidir con el mensaje; ligero refuerzo contra inyección por templating en URL): nuevo TEMPLATE_PATTERNSen [server/lib/security.mjs](https://github.com/Fighter90/career-ops-ui/blob/main/server/lib/security.mjs) consultado víahasTemplatePlaceholder(url)antes denew URL(…). Solo se rechazan placeholders **emparejados** — {normal}` (token ATS legítimo) sigue aceptándose. 901 → 903 unitarios. (NEW-2)
[1.58.6] — 2026-05-20
fix(a11y/i18n): BUG-008-tb — el título del modal del botón Doctor en la barra superior ahora coincide con la etiqueta localizada. La regla BUG-008 (cerrada en v1.58.0) exige “título del modal == etiqueta localizada del botón”. La regresión MASTER de v1.58.3 detectó que el punto de entrada de la barra superior seguía violando la invariante: hacer clic en Doctor abría un modal con título doctor (inglés en minúsculas), independientemente del idioma. Corrección en public/js/app.js:118 — sustituir el literal 'doctor' por I18n.t('top.doctor', 'Doctor'). La clave top.doctor ya existe en los 8 idiomas (EN Doctor · ES/pt-BR Diagnóstico · KO 진단 · JA 診断 · RU Диагностика · zh-CN 诊断 · zh-TW 診斷) y es la misma que declara el botón vía data-i18n="top.doctor". Guardia estática añadida en tests/qa-report-fixes.test.mjs. 900 → 901 unitarios; Playwright 60/60. (BUG-008-tb)
[1.58.5] — 2026-05-20
fix(ui): NEW-3 — #/followup Run-live doble-POST triado no-reproducible; bloqueado con guardia de regresión Playwright. La regresión MASTER de v1.58.3 observó (mediante window.fetch parcheado) dos POSTs idénticos a /api/mode/followup en ~2 s tras un único clic en Run live en #/followup (con empresa/rol/notas rellenos y fecha vacía). Siguiendo la doctrina “reproducir primero” del fix-prompt, la inspección de public/js/views/mode-page.js::submit() muestra: (a) Run live y Generate prompt son <button> planos con un único onClick cada uno — no hay <form> padre ni addEventListener('submit') que dispare dos veces, y (b) UI.withSpinner() (FIX-L1) marca button.disabled = true mientras la petición está en vuelo, bloqueando un segundo clic físico en origen. Una nueva prueba Playwright en tests/playwright-smoke.mjs recorre la receta exacta de la regresión — rellena empresa/rol/notas, deja la fecha vacía, hace clic en el botón manual (que comparte la función submit() con Run live) y verifica exactamente un POST /api/mode/followup en una ventana de 3 s. Selector estable entre locales (el glifo ▶ es idéntico en los 8 idiomas) y addInitScript siembra career-ops-ui:lang=en para que una prueba previa de idioma en el mismo contexto del navegador no perturbe los selectores. Playwright 59 → 60. La observación QA original queda como receta; no requiere cambio de código en producción. (NEW-3)
[1.58.4] — 2026-05-19
fix(security): NEW-1 — enviar Content-Security-Policy en cada respuesta (antes limitado a no-loopback). Antes de v1.58.4 la cabecera CSP solo se añadía cuando isPubliclyExposed() era verdadero (HOST fuera de loopback); sobre 127.0.0.1 tanto / como /api/health devolvían sin CSP, dejando el contrato escape-first de UI.md() como única defensa XSS. La regresión MASTER de v1.58.3 (§5) lo marcó como invariante crítica. Ahora la CSP es incondicional e idéntica en cada respuesta: 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'. El conjunto de directivas no cambia respecto a la política anterior (ya correcta para la SPA — Google Fonts en lista blanca para Inter), sin regresión visual ni funcional. Se reescribió tests/security-headers.test.mjs; un recorrido Playwright (en/ru/ja/zh-TW × 7 rutas) verifica 0 violaciones de CSP. 900 unitarios · Playwright 58→59 · e2e 20/20+23/23. Los siguientes elementos del fix-prompt se publican como versiones one-fix posteriores. (NEW-1)
[1.58.3] — 2026-05-19
fix(deep): R-2 / FIX-C1 — elimina etiquetas de andamiaje HUÉRFANAS / desbalanceadas del output de investigación. cleanLlmMarkdown (v1.58.0) solo quitaba bloques emparejados y una etiqueta abierta colgante. Una regresión profunda de v1.58.2 halló un modelo con traza desbalanceada — un </tool_response> huérfano (y </thinking>) sin apertura — que sobrevivía y se renderizaba literal en el brief guardado de #/deep. Un barrido conservador final elimina ahora cualquier token de andamiaje suelto (abierto o cerrado), el XML de herramientas de Anthropic (<invoke>/<parameter>/antml:*) y bloques tool_*. Puro + idempotente; autoenlaces <https://…> y spans de código se preservan. FIX-C2 triado no-reproducible (i18n.js ya fija <html lang> y detecta navigator.language). Ambos bloqueados con guards. 896 → 900 unit · Playwright 58/58. Resto del fix-prompt v1.58.3 en cola como one-fix ships (doctrina: nunca en lote).
[1.58.2] — 2026-05-19
fix(i18n): I18N-011 — localiza el índice de #/help en los 7 idiomas no-EN. El TOC se construye desde los encabezados ## de docs/help/<lang>.md. Las secciones 3/4/6/7/8/9/10/11/12 aún tenían títulos en inglés en es/pt-BR/ko/ja/ru/zh-CN/zh-TW, así que el TOC salía en inglés mientras el sidebar estaba traducido. Cada encabezado afectado se localiza ahora al mismo término que la clave nav.* del sidebar (fuente única — TOC ↔ sidebar coinciden), conservando el número de sección y el paréntesis (#/route …) literal. EN sin cambios. Cierra el único pendiente i18n del barrido QA v1.58. Solo docs; 896/896 unit · 33/33 help · Playwright 58/58.
[1.58.1] — 2026-05-19
fix(test): guard checkProfileCustomized CI-aislado (parche sobre v1.58.0). v1.58.0 pasó el pre-commit (consultivo) pero falló en ci.yml (Node 18/20/22): el test usaba import dinámico cache-bust + reescritura de PATHS, pero paths.mjs resuelve la raíz una vez por proceso. Reemplazado por un guard estático robusto (allow-list + regex ^(…)$/i anclado; un nombre real con “test” nunca se marca). Sin cambios de código de producción; desbloquea publish-package.yml. 896/896 unit · Playwright 58/58. Ver qa/v158-regression/.
[1.58.0] — 2026-05-19
fix(qa): barrido de bugs del informe QA externo + salida de investigación limpia y formateada. Corregido: BUG-001 #/followup valida la fecha opcional como ISO YYYY-MM-DD en el cliente; BUG-003 **negrita**/`código`/enlaces ahora se renderizan dentro de citas en UI.md() (todas las páginas de Ayuda); BUG-005 URL duplicada en pipeline muestra «Ya está en la cola — omitido»; BUG-006 mensaje de URL inválida humanizado (contexto (POST /api/pipeline · HTTP 400) se mantiene a propósito); BUG-007/008 el toast «Running doctor.mjs…» se descarta antes del modal (nuevo UI.dismissToast()), título del modal = etiqueta localizada del botón; BUG-010 subtítulo en el estado vacío de #/reports; BUG-002/UX-032 checkProfileCustomized() marca fixtures de prueba como «no personalizado» (no se tocan profile.yml/cv.md del padre — regla #1); I18N-012/013 Deep research en ruso realmente traducido. Nuevo: cleanLlmMarkdown() elimina el andamiaje de agente (<tool_call>{…}</tool_call>, <tool_response>, <thinking>, …) de #/deep y de Investigación guardada, en todos los proveedores y al servir archivos ya guardados; alias #/outreach→#/contacto (BUG-004); error de red del cliente localizado vía I18n.t() (8 locales; los details del servidor son diagnósticos en inglés a propósito). Tests: nuevos tests/qa-report-fixes.test.mjs (10) y tests/llm-output.test.mjs (5); 881 → 896 unit; Playwright 58/58. No cambiado (con justificación): BUG-009 (H1 #/cv por diseño, WCAG single-h1), datos del padre (parent-owned), cola larga de i18n/UX menor en backlog. Detalle completo en CHANGELOG.md.
[1.57.2] — 2026-05-19
fix(config): la causa REAL del «validation failed» en /#/config — el campo lang inyectado por la SPA. public/js/api.js adjunta automáticamente un campo lang a todos los cuerpos POST JSON (para que las rutas LLM tomen el idioma de la UI). /api/config no es una ruta LLM y lang no es una clave de configuración, así que el rechazo de claves desconocidas de validateConfig (correcto y relevante para seguridad) devolvía 400 en cada Guardado: validation failed — lang: not a known config key. Era solo del navegador: los repros con curl/in-process nunca enviaban lang, por eso v1.57.0/.1 mejoraron el mensaje pero no la causa. La ruta ahora elimina el lang de transporte antes de validar; el filtro de escritura por KNOWN_KEYS sigue descartando cualquier clave realmente desconocida — la protección anti-inyección no cambia. Detectado por un nuevo barrido Playwright que pulsa el botón Guardar real. Tests: nuevo tests/playwright-forms.mjs (26, integrado en npm run test:e2e:browser) sobre todos los formularios; config-endpoint con paridad de navegador. 879 → 881 unit; Playwright 32 → 58. Detalle completo en CHANGELOG.md.
[1.57.1] — 2026-05-19
fix(ux): cada error de API ahora dice QUÉ falló, DÓNDE y POR QUÉ; el texto del error de entrada es lo más descriptivo posible. El servidor ya devolvía { error, details: ["CAMPO: motivo", …] }, pero los formularios solo mostraban la línea superior («validation failed»), así que en /#/config (y en todas partes) no se sabía qué campo estaba mal. api.js ahora incorpora los details por campo en el mensaje en todo el sitio (un cambio, todos los formularios se benefician), añade el contexto de la petición (MÉTODO /ruta · HTTP NNN) (DÓNDE), recurre a un fragmento del cuerpo crudo en errores no-JSON, y los errores de red llevan método+ruta; err.details queda expuesto. Los mensajes de validateConfig son ahora máximamente descriptivos (qué falla y cómo arreglarlo). Las claves secretas nunca muestran el valor introducido (solo su longitud) — una clave real mal tecleada no se filtra a un toast/log. El rango de PORT ahora sí se valida (99999 se rechaza). En /#/config PORT y HOST se rellenan con sus valores por defecto reales (4317 / 127.0.0.1). Los toasts de error permanecen más tiempo (9–20 s) y se ajustan/desplazan en vez de recortarse. Tests: nuevo tests/config-validation-detail.test.mjs (12); 874 → 879. Detalle completo en CHANGELOG.md.
[1.57.0] — 2026-05-19
feat(provider): OpenRouter como 5º proveedor de evaluación en vivo headless + fix(config): «validation failed» al guardar cualquier clave API. Las claves pegadas suelen llegar con un salto de línea final o espacios (portapapeles del SO, botones «copiar» de las consolas de proveedores) — antes de 1.57 eso disparaba el guard de saltos de línea para todos los proveedores, y la regex anclada en $ de ANTHROPIC_API_KEY rechazaba por error claves Anthropic reales. Ahora validateConfig normaliza (recorta) cada valor antes de validar, la ruta persiste el valor recortado (autentica en runtime, sin romper .env), y la comprobación de Anthropic es un prefijo sk-ant- + longitud resiliente (el umbral compartido isUsableKey() ≥ 20 sigue siendo la verdadera comprobación). Los saltos de línea internos se siguen rechazando (guard de inyección en .env). OpenRouter es ahora un proveedor de primera clase: OPENROUTER_API_KEY en /#/config — una clave da acceso a más de 300 modelos. Es el último del orden auto (Anthropic → Gemini → OpenAI → Qwen → OpenRouter), así que una configuración existente nunca se redirige en silencio; LLM_PROVIDER=openrouter lo fija. Conectado al mismo camino _tailProvider() que OpenAI/Qwen en /api/evaluate, /api/deep, /api/mode/:slug; expuesto en /api/status/providers + el panel de Health. Cliente compatible con OpenAI (sin dependencias nuevas — fetch directo, timeout AbortController, la clave nunca se registra) con los headers HTTP-Referer/X-Title recomendados. El desplegable de modelos es en vivo: OPENROUTER_MODEL se llena desde GET /api/openrouter/models (proxy del servidor del catálogo público de OpenRouter — mantiene CSP connect-src 'self'), con lista curada de respaldo y caché en memoria de 10 min. Nuevas claves i18n (config.openrouter*) en los 8 idiomas. Tests: nuevos suites tests/openrouter-route.test.mjs y tests/openrouter-model-selector.test.mjs; suites env-config/openai/provider-selector ampliados. 831 → 855. Detalle completo en CHANGELOG.md.
[1.56.4] — 2026-05-19
feat(ui): UX-N2 — pista visible y según plataforma de ⌘K / Ctrl K en la búsqueda global. El atajo Cmd/Ctrl+K (enfocar la búsqueda) solo estaba en el aria-label/código, así que los usuarios videntes no lo descubrían y la app parecía más lenta de lo que es. Ahora un <kbd class="kbd-shortcut"> atenuado va al final de la píldora de búsqueda, rellenado al arrancar desde data-mac/data-other según una comprobación de plataforma (navigator.platform/userAgent): ⌘K en macOS/iOS, Ctrl K en el resto. Es aria-hidden="true" (el aria-label existente ya lo anuncia a los lectores de pantalla — la insignia no debe duplicar) y pointer-events:none (decorativa). El atajo Cmd/Ctrl+K existente no cambia. Sin nuevas claves i18n (los glifos son universales); la insignia es hijo flex del .searchbar existente (sin envoltorio/posición absoluta — el input ya es flex:1). Pruebas: nueva suite estática por código aislada de CI tests/cmdk-hint-visible.test.mjs (5): el <kbd class="kbd-shortcut"> está dentro de .searchbar; es aria-hidden="true" con ambas variantes data-mac/data-other; app.js lo rellena vía comprobación navigator; el binding (e.ctrlKey||e.metaKey)&&e.key==='k' → search.focus() intacto (guard de regresión); app.css estiliza .kbd-shortcut y nunca display:none. 826 → 831. feat(ui) · test: tests/cmdk-hint-visible.test.mjs. Detalles — CHANGELOG.md.
[1.56.3] — 2026-05-19
fix(reliability): la detección de claves de proveedor rechaza marcadores de posición / valores demasiado cortos, no solo la cadena vacía. Un GEMINI_API_KEY de marcador en un .env padre se reportaba como “✓ set” Y se elegía como proveedor activo por encima de un ANTHROPIC_API_KEY válido. effectiveEnv() solo rechazaba undefined/'', así que 10 caracteres de basura contaban como clave real: el banner de onboarding mostraba GEMINI ✓ set, GET /api/status/providers devolvía activeProvider: "gemini", y toda evaluación ⚡ en vivo habría fallado en silencio contra una clave muerta ignorando una clave Anthropic válida de 108 caracteres. La nueva función pura isUsableKey() (env-config.mjs) considera un secreto configurado solo si tiene ≥ 20 caracteres (ninguna clave soportada es más corta — Gemini AIza… ≈ 39, Anthropic sk-ant-… ≈ 100+, OpenAI ≥ 40, Qwen ≈ 35) y no es un marcador conocido (your_*_here, changeme, placeholder, <…>, un solo carácter repetido…). Aplicada uniformemente a hasAnthropicKey()/hasGeminiKey() (anthropic.mjs), hasOpenAIKey()/hasQwenKey() (openai.mjs) y las filas GEMINI_API_KEY/ANTHROPIC_API_KEY de GET /api/health (movidas de process.env crudo a la misma vista effective+plausible) — la página de salud, el endpoint de proveedores y el router OR ahora siempre coinciden. selectActiveProvider() no cambia; recibe un keysConfigured correcto. Pruebas: nueva suite aislada de CI tests/key-detection-rejects-placeholder.test.mjs (5): casos unitarios de isUsableKey + reproducción in-process con createApp() del escenario reportado (.env temporal con GEMINI_API_KEY de 10 caracteres + ANTHROPIC_API_KEY real) — gemini NO está en keysConfigured, activeProvider === "anthropic", filas de /api/health coherentes. Cuatro pruebas existentes de capas effective-env alargaron sus stubs demasiado cortos (el contrato no cambia). 821 → 826. fix(reliability) · test: tests/key-detection-rejects-placeholder.test.mjs. Detalles — CHANGELOG.md.
[1.56.2] — 2026-05-19
feat(a11y): UX-N1 — document.title por ruta y con idioma (orientación multipestaña + anuncio de cambio de página del lector de pantalla). Antes las 24 rutas mantenían el <title> estático de index.html (“career-ops — command center”): pestañas con el mismo nombre, marcadores genéricos y el mismo anuncio “página cambiada”. focusNewView() en public/js/router.js ahora deriva el título del propio <h1 class="page-title"> localizado de la vista — “Vista — career-ops” — así que los títulos se traducen automáticamente (sin nuevas claves i18n) y son únicos por ruta. Se fija antes del guard de primer pintado para que la pestaña inicial también tenga título (mismo orden que el tabindex de v1.56.0 UX-12). Recurre a career-ops — command center si una vista no tiene encabezado. Pruebas: nueva suite estática por código aislada de CI tests/document-title-per-route.test.mjs (4): focusNewView asigna document.title; el título proviene del <h1> (por ruta + localizado, no un literal único); la asignación precede a !firstPaintDone; hay un valor por defecto de producto. 817 → 821. feat(a11y) · test: tests/document-title-per-route.test.mjs. Detalles — CHANGELOG.md.
[1.56.1] — 2026-05-19
fix(a11y): se suprime el anillo de foco de marca espurio en el foco del encabezado con tabindex="-1" gestionado por el router. public/js/router.js asigna tabindex="-1" al encabezado de la vista destino y le hace .focus() en cada navegación de cliente (para que el lector de pantalla anuncie la nueva página). Un elemento tabindex="-1" nunca es alcanzable por teclado, pero la heurística :focus-visible de Chromium seguía pintando el anillo de marca global (*:focus-visible { outline: 2px solid var(--rausch) }) — un rectángulo rojo alrededor del encabezado (p. ej. “Command Center” en #/dashboard) en cada navegación, que también quedó grabado en las capturas hero images/dashboard-*.png. La corrección es una sola regla acotada [tabindex="-1"]:focus, [tabindex="-1"]:focus-visible { outline: none } (patrón de foco gestionado de WAI-ARIA APG). El foco real de teclado en controles interactivos conserva el anillo global *:focus-visible (WCAG 2.4.7 intacto); el anillo del skip-link no se ve afectado (es un <a>, no tabindex="-1", con mayor especificidad). Las 8 images/dashboard-*.png se regeneraron — sin recuadro rojo. Pruebas: nueva suite estática por código aislada de CI tests/managed-focus-no-ring.test.mjs (4): el anillo global *:focus-visible sigue definido (WCAG 2.4.7 sin regresión); [tabindex="-1"]:focus,:focus-visible ⇒ outline:none; la regla de supresión va después de la global (seguridad de cascada); la corrección es acotada (sin *:focus{outline:none} general). Junto con tests/dashboard-initial-focus.test.mjs. 813 → 817. fix(a11y) · test: tests/managed-focus-no-ring.test.mjs. Detalles — CHANGELOG.md.
[1.56.0] — 2026-05-19
feat(ux): paquete de pulido LOW — UX-9 / UX-10 / UX-11 / UX-12 (una release menor agrupada). UX-9 #/cv: el título de página se degrada a un chip-breadcrumb .cv-breadcrumb discreto y el subtítulo ruidoso pasa al title del <h1>, para que el CV del usuario (su nombre, en la vista previa) tenga la jerarquía visual. Invariante F-V54-A intacto — sigue siendo exactamente un <h1>, sigue .page-title. UX-10 nuevo helper compartido UI.providerCostHint(t) junto a ⚡ Ejecutar en vivo en #/auto, #/evaluate, #/deep y cada #/<mode>; reusa GET /api/status/providers (v1.55.3): con clave muestra “Coste estimado: OpenAI gpt-5-codex · ~$0.04/eval” (orden de magnitud, “~”); sin clave indica que ⚡ copia un prompt manual sin coste de API; fail-soft. UX-11 #/help: cuando el filtro del TOC reduce a exactamente una sección, la página se desplaza allí tras 300ms de inactividad (debounced; nunca con 0 o >1). UX-12 #/dashboard: en el primer pintado el <h1> se hace enfocable (tabindex="-1") y #content sigue aria-live="polite" (anunciado al arrancar) sin robar el foco (evita pelear con el skip-link, decisión v1.41.0). Nuevas claves i18n cost.estimate, cost.manual ×8; nuevo CSS .cv-breadcrumb/.cost-hint. Tests: 4 nuevas suites estático-de-fuente CI-aisladas (cv-breadcrumb 3, run-cost-line 4, help-toc-autoscroll 4, dashboard-initial-focus 3); locks pre-existentes cv-single-h1/help-nav-a11y actualizados (invariantes preservados). 800 → 813. Sonda Playwright en vivo de los 4, 0 errores de consola. feat(ux) · 4 test suites. Detalle en CHANGELOG.md.
[1.55.8] — 2026-05-19
feat(tracker): paginación del lado servidor + chips de embudo clicables (UX-8). Servidor: GET /api/tracker gana params opcionales ?page / ?pageSize / ?status. Sin ellos, la respuesta es byte a byte el { rows: [...] } heredado (todos los llamadores/tests existentes intactos). Con ellos devuelve { rows: slice, total, page, pageSize, funnel } — pageSize clampado a [1,500], page a ≥1, status filtra rows+total, y funnel es el desglose estado→conteo de todo el historial (independiente de página o filtro, para que los chips sean siempre exactos). #/tracker: nueva barra de chips de embudo clicable arriba — “todos los estados · N · Applied · N · Interview · N …” (orden Applied → Responded → Interview → Offer → Rejected → Discarded → Evaluated → SKIP). Clicar un chip fija el filtro de Estado (clicar el activo lo limpia); el chip activo es aria-pressed y resaltado. Nueva clave i18n track.funnelAria ×8; nuevo CSS .tracker-funnel/.tracker-chip/.tracker-chip--active. test: tests/tracker-server-paged.test.mjs (nuevo, 7 casos, CI-aislado, Express in-process en puerto efímero + applications.md temporal en CAREER_OPS_ROOT — CLAUDE.md #2/#8): back-compat (sin params ⇒ exactamente {rows}); ?page&pageSize slice + total/page/pageSize/funnel sumando N; última página parcial sin solape; página fuera de rango ⇒ rows vacío + total válido; ?status= filtra total/rows con funnel de todo el historial; cap de pageSize; + lock estático-de-fuente de la barra de chips. 793 → 800. feat(tracker) · test: tests/tracker-server-paged.test.mjs. Detalle en CHANGELOG.md.
[1.55.7] — 2026-05-19
feat(pipeline): virtualización de filas en vanilla-JS para >1000 filas (UX-7). #/pipeline renderizaba todas las filas (filtered.forEach(list.appendChild(urlRow))) — un escaneo llena la cola con miles de URLs, así que miles de nodos de fila (cada uno un div flex + <a> + dos botones) se construían sincrónicamente en cada tecla del filtro, saturando el DOM y el árbol de accesibilidad. Nueva virtualización vanilla-JS (equivalente a react-window, sin deps): por encima de VIRTUALIZE_THRESHOLD = 1000, #/pipeline pasa a ser un viewport con scroll de altura fija (70vh) con un espaciador no encogible (flex:0 0 auto, height = filas × 56px) que preserva la barra de scroll real de toda la lista, y un listener de scroll con rAF renderiza solo el viewport ± un búfer de 5 filas (~16–19 nodos a la vez en lugar de N). En/por debajo del umbral se mantiene el render simple original byte a byte, así que los pipelines típicos y todos los tests/e2e existentes no se ven afectados. Cada fila virtualizada conserva su aria-label ▶/✕ desambiguado por URL (F-V54-B bloqueado por regresión). El cálculo de ventana es un helper puro computeWindow(). test: tests/pipeline-virtualize.test.mjs (nuevo, 5 casos, CI-aislado, estático-de-fuente): umbral numérico ~1000; rama ≤umbral mantiene forEach→appendChild; rama >umbral renderiza slice(start,end) con listener de scroll rAF + espaciador; computeWindow() clampa [0,total] con ± búfer; filas conservan aria-labels ▶/✕. 788 → 793. Sonda Playwright en vivo (fixture de 1200 URLs): scrollHeight≈67248, solo ~16–19 nodos en el DOM, la ventana sigue el scroll de extremo a extremo, 0 errores de consola. feat(pipeline) · test: tests/pipeline-virtualize.test.mjs. Detalle en CHANGELOG.md.
[1.55.6] — 2026-05-19
feat(scan): ocultar filtros secundarios tras un desplegable “Filtros avanzados” (UX-4). #/scan apilaba todos los filtros — texto libre, remoto/híbrido/presencial, alcance, fuente y los chips de facetas stack/nivel/dinámicos posteriores al escaneo — con igual peso, un muro de controles. Ahora los filtros cotidianos quedan visibles (texto libre + Remoto/Híbrido/Presencial; el botón 🌐 Buscar ya está aparte en la tarjeta de controles) y los secundarios colapsan tras un <details class="scan-advanced"><summary>Filtros avanzados</summary>: los selects Alcance + Fuente, y —por separado— el grupo de chips de facetas (que ahora encabeza el resultado con la tabla, no con un muro de chips, y solo se renderiza si hay al menos una fila de chips). Nueva clave i18n scan.advancedFilters en los 8 locales; nuevo estilo .scan-advanced (afordancia ⚙ discreta, sin marcador, negrita al abrir). test: tests/scan-advanced-disclosure.test.mjs (nuevo, 6 casos, CI-aislado, estático-de-fuente): existe <details>/<summary> con hook .scan-advanced y etiqueta scan.advancedFilters; texto libre + remoto siguen visibles; alcance + fuente dentro del desplegable; chipsContainer es <details>; .scan-advanced summary con estilo; scan.advancedFilters ×8. 782 → 788. feat(scan) · test: tests/scan-advanced-disclosure.test.mjs. Detalle en CHANGELOG.md.
[1.55.5] — 2026-05-19
feat(dashboard): hero con los 2 CTA P0 + pista focal de actividad reciente (UX-3). #/dashboard abría con ~30 nodos de igual peso — sin un “qué sigue” claro. Un nuevo bloque .dash-hero ahora va justo bajo el encabezado: los dos recorridos P0 — ✨ Auto-pipeline para una URL y 🌐 Buscar ahora — se promueven a botones grandes .btn-hero, y una única pista focal de actividad reciente (“Última evaluación: <score> — <título>”, enlazada al informe; estado vacío guía en arranque en frío vía dash.heroNoEval) indica al usuario recurrente dónde se quedó y al nuevo la única acción que importa. Los dos botones primarios se quitaron del encabezado (solo queda el secundario “📋 Abrir pipeline”) para no duplicar la acción. Los contadores de estado se degradaron de .badge prominentes a píldoras .dash-chip discretas. Nuevas claves i18n dash.lastEval, dash.heroNoEval en los 8 locales; nuevo CSS .dash-hero/.btn-hero/.dash-chip. test: tests/dashboard-hero.test.mjs (nuevo, 5 casos, CI-aislado, estático-de-fuente): .dash-hero existe y precede la grilla Quick-actions; ambos CTA P0 son .btn-hero con rutas /auto+/scan; pista focal dash.lastEval + estado vacío dash.heroNoEval; buckets usan .dash-chip; CSS existe; dash.lastEval+dash.heroNoEval ×8. 777 → 782. feat(dashboard) · test: tests/dashboard-hero.test.mjs. Detalle en CHANGELOG.md.
[1.55.4] — 2026-05-19
feat(ux): ETA honesta del auto-pipeline junto a Run + Stop prominente durante un escaneo (UX-6). #/auto: nueva pista .auto-eta — “⏱ ~1–2 min” (clave auto.eta, title vía auto.etaTitle) — ahora junto al botón Run, para que la promesa de un clic sea honesta sobre la duración antes de comprometerse; el texto coincide con career-ops.org/docs (“pega una URL → informe completo en 1–2 minutos”). #/scan: mientras el rastreo de varios minutos está activo (aria-busy), Stop se promueve de botón fantasma de bajo contraste a botón destructivo prominente (nuevo .btn-danger — relleno, blanco sobre coral de alto contraste, peso 600). setScanRunning(running) alterna scan-stop-btn entre btn-danger (en ejecución) y btn-ghost (inactivo, oculto de todos modos), para que el usuario encuentre y confíe en Stop bajo carga. Nuevas claves i18n auto.eta, auto.etaTitle en los 8 locales; nuevo CSS .btn-danger/.auto-eta. test: tests/auto-eta-stop.test.mjs (nuevo, 4 casos, CI-aislado, estático-de-fuente): #/auto renderiza t('auto.eta') con clase .auto-eta junto a runBtn; auto.eta ×8; setScanRunning(running) promueve Stop a btn-danger; .btn-danger existe con texto blanco de alto contraste. 773 → 777. feat(ux) · test: tests/auto-eta-stop.test.mjs. Detalle en CHANGELOG.md.
[1.55.3] — 2026-05-19
feat(onboarding): estado OR de 4 proveedores en pantalla — banner de arranque en frío + chip de proveedor activo (UX-2, ALTA). Nuevo endpoint de solo lectura GET /api/status/providers → { activeProvider, activeModel, keysConfigured }. keysConfigured usa la misma vista de env efectiva que los gate sites de llm.mjs (process.env ∨ .env del padre); activeProvider es lo que el OR-router elegiría — selectActiveProvider(), nuevo helper puro en env-config.mjs que recorre providerOrder() (un pin LLM_PROVIDER sin clave correspondiente da null). No se devuelven secretos — solo nombres de proveedor + el id de modelo. El shell de la SPA ahora renderiza una región de onboarding global (#onboarding-banner, poblada por app.js, solo DOM seguro para CSP): 0 claves → banner rojo con CTA a #/config?tab=api-keys; ≥1 clave → chip discreto con el proveedor + modelo activo. Hace descubrible en pantalla el diferenciador estrella (“uno de Anthropic / Gemini / OpenAI / Qwen, auto-ordenado”) en vez de aprenderse por ensayo. Nuevas claves i18n onboarding.* en los 8 locales; nuevo CSS .onboarding-warn/.onboarding-ok. test: tests/onboarding-key-banner.test.mjs (nuevo, 9 casos, CI-aislado): semántica de selectActiveProvider; GET /api/status/providers en proceso (puerto efímero + .env en CAREER_OPS_ROOT temporal para nunca leer la clave real del padre — CLAUDE.md #2/#8); cableado SPA estático + cobertura onboarding.* ×8. 764 → 773. feat(onboarding) · test: tests/onboarding-key-banner.test.mjs. Detalle en CHANGELOG.md.
[1.55.2] — 2026-05-18
fix(cv): dar al editor markdown de #/cv un nombre accesible descriptivo y autónomo (F-V55-H / UX-5). El <textarea id="cv-editor"> del editor principal de #/cv ahora lleva un aria-label descriptivo mediante la nueva clave cv.editorAria — “Editor markdown del CV — tu currículum profesional en formato markdown” — en lugar del nombre escueto que heredaba del encabezado visible “Markdown”. Nota: al contrario que el síntoma de F-V55-H (que solo inspeccionó aria-label/labels), el campo no carecía de nombre — v1.47.0 (WS2 #16) ya lo había vinculado vía aria-labelledby → el <h3 id="cv-md-heading">Markdown</h3>, así que un lector de pantalla anunciaba “Markdown, edición, multilínea”. v1.55.2 mejora ese escueto “Markdown” a una etiqueta autónoma. El aria-labelledby redundante se elimina (sería markup muerto — aria-label gana por precedencia ARIA); el <h3>Markdown</h3> visible permanece para usuarios videntes. WCAG 1.3.1 + 4.1.2; paralelo al arreglo batch-tsv de v1.54.5 (F-V54-C). test: tests/cv-editor-a11y.test.mjs (nuevo, 3 casos, CI-aislado, estático-de-fuente como auto-stepper-prerender.test.mjs): #cv-editor se nombra vía t('cv.editorAria', …) con fallback no vacío; cv.editorAria presente y no vacío en los 8 locales; sin aria-labelledby redundante en el elemento. 761 → 764. fix(cv) · test: tests/cv-editor-a11y.test.mjs. Detalle en CHANGELOG.md.
[1.55.1] — 2026-05-18
fix(auto): prerenderizar el stepper de 5 etapas del pipeline al montar #/auto (F-V55-E / UX-1, obs. senior S-4 reabierta). #/auto ahora muestra el esquema documentado de cinco etapas — validar → obtener → evaluar → guardar informe → añadir al tracker — en el instante en que la pantalla se monta, en lugar de quedar en blanco hasta el primer evento SSE. Antes <ol class="auto-stepper"> se creaba display:none y renderStepper() solo se alcanzaba desde setStep() / run(), así que un usuario en arranque en frío nunca veía el pipeline que prometen los docs antes de pulsar Run. El stepper ahora es visible al montar con las cinco etapas en estado pending y lleva un aria-label (auto.stepperAria) para que la tecnología asistiva anuncie la región. Cierra F-V55-E (lente a11y/garantía estática) y UX-1 (lente fidelidad de promesa) — misma corrección, ambas lentes. test: tests/auto-stepper-prerender.test.mjs (nuevo, 4 casos, CI-aislado, estático-de-fuente como router.test.mjs): el array STEPS son exactamente las 5 etapas canónicas en orden; stepperEl no es display:none al montar y lleva auto.stepperAria; una llamada renderStepper() de ámbito de montaje precede a function setStep(; auto.stepperAria presente en los 8 locales. 757 → 761. fix(auto) · test: tests/auto-stepper-prerender.test.mjs. Detalle en CHANGELOG.md.
[1.55.0] — 2026-05-18
feat(llm): la live-eval headless funciona vía “OR” — Anthropic | Gemini | OpenAI | Qwen, autoseleccionado según qué clave esté definida. A petición del usuario, la ⚡ live-eval del web-ui ahora funciona con cualquier clave de API que esté definida, no solo Anthropic/Gemini. LLM_PROVIDER gana openai y qwen; auto (por defecto) usa el primer proveedor cuya clave esté presente, prefiriendo Anthropic → Gemini → OpenAI → Qwen. Un valor explícito fija uno; un proveedor forzado sin clave aún cae al camino del prompt manual. Nuevo server/lib/openai.mjs — un cliente Chat Completions compatible con OpenAI y sin dependencias (mismo patrón HTTPS directo seguro que anthropic.mjs: timeout AbortController, clave nunca registrada, resolución de clave effectiveEnv() para que una clave del .env del padre funcione sin reinicio). Un núcleo (runOpenAICompatible) respalda runOpenAI (api.openai.com) y runQwen (modo compatible con OpenAI de Alibaba DashScope; sobrescribe el endpoint con QWEN_BASE_URL en el .env crudo para el host de China continental). Sin SDKs, sin ejecución arbitraria de CLI — el proyecto padre sigue siendo agnóstico de CLI (Claude Code · Codex · Gemini · OpenCode · Qwen · Copilot · Kimi); esto solo extiende el camino headless por clave de API. La cola OpenAI/Qwen está cableada en todas las superficies de eval: /api/evaluate, /api/deep, /api/mode/:slug y el SSE de /api/auto-pipeline — consultada tras las ramas Anthropic (inline) + Gemini (subproceso) para preservar la preferencia auto, con el mismo inlining de contexto empaquetado que usa Anthropic. env-config.mjs: QWEN_API_KEY (secreto) + QWEN_MODEL (no secreto) añadidos a KNOWN_KEYS/KEY_GROUPS.core; LLM_PROVIDERS y providerOrder() extendidos; OPENAI_API_KEY es ahora una clave de proveedor headless de primera clase (antes solo almacenada). Pestaña de claves de #/config: el select LLM_PROVIDER gana openai/qwen; nuevos campos QWEN_API_KEY + QWEN_MODEL (lista curada qwen-max/qwen-plus/qwen-turbo/qwen2.5-*); una nueva nota al inicio de la pestaña explica el padre agnóstico de CLI vs la eval headless del web-ui y el orden OR. Nuevas claves i18n en los 8 locales. test: tests/openai.test.mjs (nuevo, 9 casos, CI-aislado): éxito OpenAI/Qwen + contenido en array de bloques, auth Bearer, endpoints por defecto y sobrescrito por QWEN_BASE_URL, 4xx/5xx/malformado, clamp de max_tokens, timeout, detección de clave effectiveEnv, canario de no-fuga de clave. tests/provider-selector.test.mjs actualizado para la superficie providerOrder/LLM_PROVIDERS/SECRET de v1.55.0 + el cableado de la cola OpenAI/Qwen. 748 → 757. feat(llm) · test: tests/openai.test.mjs · test: tests/provider-selector.test.mjs. Detalle en CHANGELOG.md.
[1.54.10] — 2026-05-18
fix(auto-pipeline): higiene de desconexión del cliente SSE — eliminar el job e2e de Playwright inestable. El job e2e de Playwright se ponía en rojo de forma intermitente (32/32 pruebas individuales pasan, pero not ok 2 - tests/playwright-smoke.mjs): cerrar una página mientras el stream SSE de #/auto estaba en curso hacía que el siguiente res.write() del servidor fuera rechazado con EPIPE/"aborted", y —sin un listener 'error' en la respuesta— Node lo escalaba a una uncaughtException que node:test reportaba como “asynchronous activity after the test ended”. openSse() en auto-pipeline.mjs ahora registra un res.on('error') no-op y protege send() con res.writableEnded || res.destroyed (envuelto en try/catch) — un cliente desaparecido es esperado, no excepcional. Esto es higiene SSE de producción correcta, no solo un arreglo de prueba. tests/playwright-smoke.mjs: la prueba de Cmd+K usaba una URL saliente real (https://example.com/jobs/123) pero solo esperaba a que apareciera el modal, así que closePage() abortaba el safeGet() en curso del servidor después de terminar la prueba. Ahora espera a que el pipeline alcance un estado terminal (para que el fetch se resuelva normalmente antes del cierre). Un helper compartido closePage() (window.stop() y luego cerrar) y el hook after con server.closeAllConnections() permanecen como defensa en profundidad. Verificado: 8/8 ejecuciones verdes consecutivas (6× node --test + 2× browser-smoke), antes ~1-de-2 en rojo. tests/auto-pipeline.test.mjs +1 caso estático que fija el contrato de higiene de desconexión de openSse (listener res.on('error') + guarda writableEnded||destroyed + escrituras envueltas en try). 747 → 748. fix(auto-pipeline) · test: tests/auto-pipeline.test.mjs. Detalle en CHANGELOG.md.
[1.54.9] — 2026-05-18
fix(llm): honrar las claves LLM del .env del proyecto padre en tiempo de petición — dejar de enrutar erróneamente a un proveedor obsoleto/inválido. La evaluación en vivo podía fallar con “Gemini API error: API key not valid” incluso cuando ANTHROPIC_API_KEY era el proveedor configurado. Causa raíz: hasAnthropicKey() / hasGeminiKey() (y la búsqueda de clave/modelo de runAnthropic) leían solo el snapshot de process.env del arranque. Si la clave Anthropic se añadía al .env del padre después de arrancar el servidor, el proceso en ejecución nunca la veía → la detección de Anthropic era falsa, y la evaluación caía a cualquier clave obsoleta que sí estuviera en process.env (a menudo un GEMINI_API_KEY antiguo e inválido). La ruta de ejecución de Gemini (un subproceso Node del padre) ya leía el .env vivo del padre, así que ambos proveedores resolvían claves de forma inconsistente. Nuevo effectiveEnv(key, envFilePath) en env-config.mjs: un valor no vacío de process.env gana (cubre exports de shell y el live-apply en POST /api/config); en otro caso se consulta el .env actual del padre. anthropic.mjs ahora resuelve ANTHROPIC_API_KEY, ANTHROPIC_MODEL y la verificación de la clave Gemini a través de él, de modo que una clave puesta en el .env del padre se honra sin reiniciar el servidor y la DETECCIÓN de clave siempre coincide con la clave que la petición realmente ENVÍA. El orden de proveedores no cambia (auto → Anthropic-luego-Gemini); esto solo arregla la detección. Las claves nunca se registran ni se devuelven (la prueba de no-fuga REVIEW-B4 sigue pasando). tests/anthropic.test.mjs reescrito para ser CI-aislado (temp CAREER_OPS_ROOT, import dinámico) con 2 casos nuevos que reproducen el bug exacto (clave solo en el .env del padre → detectada; runAnthropic envía la clave + modelo del .env del padre cuando process.env está sin definir). tests/env-config.test.mjs +3 casos effectiveEnv (precedencia de process.env, fallback al .env incl. cadena-vacía-como-no-definida, archivo-ausente / clave-ausente / sin-ruta → undefined) — 100% de la rama nueva. 742 → 747. fix(llm) · test: tests/anthropic.test.mjs · test: tests/env-config.test.mjs. Detalle en CHANGELOG.md.
[1.54.8] — 2026-05-18
feat(config): el formulario por campos de Modes siempre renderiza el esquema canónico (incluso en un archivo vacío/stub) con la guía de campos de career-ops.org. El formulario por campos de Modes de v1.54.3 solo renderizaba campos para secciones ## ya existentes — así que en un modes/_profile.md recién creado, vacío o no conforme al esquema (p. ej. el stub habitual de 1 línea) caía en “No ## sections found — use the raw editor below.” y el usuario no obtenía campos. A petición del usuario (“разбей по полям … описание полей возьми из career-ops.org/docs”), el formulario ahora siempre renderiza los 5 campos canónicos en el orden documentado (Target Roles, Adaptive Framing, Exit Narrative, Comp Targets, Location Policy), prerrellenados desde el archivo cuando existen y vacíos-pero-editables cuando no — de modo que un perfil totalmente nuevo puede rellenarse íntegramente a través del formulario. Cada campo muestra una descripción tomada del §Step-5 del Quick Start canónico de career-ops.org (qué poner en Target Roles / Adaptive Framing / Exit Narrative / Comp Targets / Location Policy), cableada vía aria-describedby para lectores de pantalla. Tolerante a variantes de encabezado: el ## Your Target Roles (etc.) de la plantilla mapea al mismo campo canónico que ## Target Roles, de modo que ni la plantilla ni la convención del andamiaje del servidor rompen el formulario. collect() es ahora una carga etiquetada: una fusión { sections } no destructiva cuando los encabezados renderizados coinciden exactamente con los del archivo (preámbulo + secciones intactas + personalizadas sobreviven byte-estables), o una reconstrucción de archivo completo { markdown } que arranca/normaliza un documento conforme al esquema cuando el archivo carecía de él. La ruta de reconstrucción está protegida por confirmación en config.js (reemplaza el archivo padre — invariante de guardado destructivo WS2 #4), preserva el preámbulo existente (o un valor por defecto documentado) y mantiene las secciones no canónicas verbatim. 6 nuevas claves i18n (config.modesDescTargetRoles … config.modesDescLocationPolicy + config.modesFormRebuildBody) en los 8 locales. tests/modes-form.test.mjs reescrito para el contrato v1.54.8: esquema + orden canónico, cableado de carga/confirmación de config.js, presencia de la descripción de cada campo proveniente de la documentación en los 8 locales, tolerancia canonicalKey “Your X”, estabilidad del round-trip de listas, la garantía de bootstrap-siempre-renderiza, y el collect() etiquetado secciones-vs-markdown con seguridad de datos. Verificado en vivo contra el archivo stub real del padre (5 campos + descripciones aparecen, 0 errores de consola) y un fixture stub aislado (rellenar → guardado protegido por confirmación → las 5 secciones canónicas persistidas). feat(config) · test: tests/modes-form.test.mjs. Detalle en CHANGELOG.md.
[1.54.7] — 2026-05-18
fix: W-001 — los assets de código/estilo + el shell de la SPA se servían con Cache-Control: no-store (higiene de despliegue). La SPA carga api.js / router.js / cada vista mediante <script src> plano sin query string de versión, y no hay paso de build (sin content hashing), por lo que tras un despliegue un navegador podía seguir sirviendo un bundle viejo cacheado durante horas → 404 de caché obsoleta en rutas con query string (observado en vivo durante la regresión v1.29.2; corrida de regresión W-001). server/index.mjs ahora fija Cache-Control: no-store en .js / .mjs / .css / .html vía el hook setHeaders de express.static, y explícitamente en el catch-all del shell de la SPA (que usa sendFile y evita setHeaders), de modo que el navegador siempre revalida el código que dirige el enrutado. Los assets estáticos no-código mantienen el caching por defecto de express.static. Las cabeceras de seguridad (CSP / nosniff / frame-deny / referrer-policy) no cambian — verificado por la suite security-headers existente (8 casos) corriendo en verde junto a la nueva prueba. +1 archivo de pruebas tests/asset-cache-control.test.mjs — 4 casos (assets JS no-store, CSS no-store, index.html estático no-store, shell de ruta profunda del catch-all de la SPA no-store), arrancando la app real contra un CAREER_OPS_ROOT aislado. Más un arreglo de teardown flaky en tests/playwright-smoke.mjs (commit test(e2e) aparte): la prueba de humo SSE del auto-pipeline ahora cancela el reader + aborta el fetch en un finally y el hook after cierra a la fuerza los sockets persistentes, eliminando el “Error: aborted” post-teardown que enrojecía el job Playwright e2e de v1.54.6. 738 → 742. fix · test: tests/asset-cache-control.test.mjs. Detalle en CHANGELOG.md.
[1.54.6] — 2026-05-18
fix(a11y): S-7 — el botón back-to-top de #/help lleva la clase selectora canónica back-to-top. El botón flotante back-to-top de #/help funcionaba correctamente (verificado en vivo) pero su lista de clases (btn btn-primary help-back-top) quedaba fuera de la convención del selector .back-to-top que apunta la prueba de spec §2 #28 — un selector más estricto habría flaqueado (corrida de regresión S-7, “victoria fácil”). El botón ahora lleva también la clase canónica back-to-top. Puramente aditivo y un no-op de CSS: help-back-top (el hook CSS existente) no cambia y back-to-top no tiene regla CSS — es solo un asa estable de prueba/automatización. Verificado en vivo: document.querySelector('.back-to-top') resuelve el botón, aria-label intacto, 0 errores de consola. Se extendió el caso #12 existente en tests/help-nav-a11y.test.mjs con una aserción de que la lista de clases del botón back-to-top incluye el selector canónico back-to-top (sin archivo nuevo). fix(a11y) · test: tests/help-nav-a11y.test.mjs. Detalle en CHANGELOG.md.
[1.54.5] — 2026-05-18
fix(a11y): F-V54-C — el editor TSV de #/batch tiene un nombre accesible. El <textarea> TSV de #/batch tenía una pista cableada vía aria-describedby pero ningún nombre accesible — sin <label htmlFor>, sin aria-label/aria-labelledby (corrida de regresión F-V54-C; WCAG 1.3.1 Info & Relationships / 4.1.2 Name, Role, Value). aria-describedby aporta una descripción, no un nombre, así que un lector de pantalla anunciaba un “edit text” sin etiqueta. El textarea ahora lleva un aria-label vía la nueva clave i18n batch.tsvAria, consistente con las entradas hermanas de control de corrida que ya usan claves *Aria; la pista describedby existente se conserva. Verificado en vivo: aria-label presente + localizado, aria-describedby intacto, 0 errores de consola. Nueva clave i18n batch.tsvAria en los 8 locales. +1 archivo de pruebas tests/batch-tsv-accessible-name.test.mjs (2 casos: el bloque batch-tsv tiene un aria-label vía t(batch.tsvAria) manteniendo su pista describedby; batch.tsvAria definida en los 8 locales); 736 → 738. fix(a11y) · test: tests/batch-tsv-accessible-name.test.mjs. Detalle en CHANGELOG.md.
[1.54.4] — 2026-05-18
fix(a11y): F-V54-B — los botones de acción por fila de #/pipeline tienen nombres accesibles. Los botones ▶ (evaluar) y ✕ (eliminar) por fila en #/pipeline eran solo iconos con únicamente un atributo title (corrida de regresión F-V54-B; WCAG 4.1.2 Name, Role, Value). title no es un nombre accesible fiable, así que un usuario de lector de pantalla oía una larga sucesión de “button” indistintos y no podía saber a qué fila afectaría un borrado. Ambos botones llevan ahora un aria-label explícito desambiguado por una URL compacta vía un nuevo helper shortUrl() (host + …/ + los 2 últimos segmentos de ruta; fallback de recorte final para entradas no parseables), de modo que el árbol de a11y lee p. ej. “Delete: hh.ru/…/vacancy/12345”. Sin nuevas claves i18n — reutiliza common.delete / pipe.evaluateBtn + la URL. Verificado en vivo: 1385 filas, cada nombre de botón único por fila, 0 errores de consola. +1 archivo de pruebas tests/pipeline-row-action-names.test.mjs (4 casos: ambos botones cableados con shortUrl(url) + exactamente dos de esas etiquetas, shortUrl declarado antes de su uso, URLs de mismo host pero distinto empleo no colapsan, fallbacks de host pelado / no parseable / vacío); 732 → 736. fix(a11y) · test: tests/pipeline-row-action-names.test.mjs. Detalle en CHANGELOG.md.
[1.54.3] — 2026-05-18
feat(config): formulario de campos estructurado para la pestaña “Modes” de #/config (sin más markdown crudo). La pestaña “Modes” editaba modes/_profile.md como un único <textarea> crudo por sección ## (granularidad a nivel de sección de v1.36.0). A petición del usuario, ahora renderiza un formulario de campos estructurado derivado del esquema documentado (career-ops.org Quick Start §Step-5): Target Roles / Adaptive Framing / Comp Targets → entradas de línea etiquetadas repetibles para añadir/quitar (una línea de rol/ángulo/comp por campo, + Add line / ✕ por fila con aria-label); Exit Narrative / Location Policy → un único <textarea> de prosa etiquetado. Cada campo es un control real vinculado con <label htmlFor> y un nombre de sección i18n. El nuevo public/js/lib/modes-form.js (window.ModesForm) posee la lógica parse → render → collect(); alimenta la ruta de merge existente PUT /api/modes/_profile { sections }, de modo que el preámbulo, el orden y cualquier sección que el formulario no toque sobreviven byte-estables (merge-no-reemplazo, aplicado por el servidor). Seguridad de datos: una sección de lista canónica cuyo cuerpo no sea una lista de viñetas pura (el usuario puso prosa ahí) y cualquier sección ## no canónica recurren a un <textarea> literal etiquetado con una nota explicativa — el contenido arbitrario hace round-trip intacto, nunca se reescribe ni se pierde silenciosamente. Estabilidad de round-trip probada: serialise(parse(body)) re-parsea idénticamente. El editor de markdown crudo de archivo completo permanece como la divulgación Advanced con confirmación para añadir/quitar sección y editar el preámbulo (puerta de guardado destructivo de WS2 #4 sin cambios). 10 nuevas claves i18n (config.modesTargetRoles … config.modesUnknownNote) en los 8 locales. +1 archivo de pruebas tests/modes-form.test.mjs (7 casos); 725 → 732. Verificado en vivo contra una fixture aislada CAREER_OPS_ROOT: 5 secciones canónicas renderizadas como campos + 1 sección personalizada como fallback etiquetado, round-trip de editar-y-guardar preservó el preámbulo + la sección personalizada, 0 errores de consola. feat(config) · test: tests/modes-form.test.mjs. Detalle en CHANGELOG.md.
[1.54.2] — 2026-05-18
feat(config): selector de modelo OpenAI / Codex en #/config. #/config no tenía forma de elegir el modelo OpenAI / Codex — solo ANTHROPIC_MODEL y GEMINI_MODEL tenían desplegables, aunque OPENAI_API_KEY ya estaba expuesta para el flujo multi-CLI del proyecto padre (Codex / OpenCode). Ahora OPENAI_MODEL es una clave de entorno de primera clase: añadida a KNOWN_KEYS de env-config.mjs (ordenada justo después de OPENAI_API_KEY) y al grupo de claves core, y deliberadamente no en SECRET_KEYS — es un id de modelo, no una credencial, así que nunca se enmascara. config.js gana una lista curada OPENAI_MODELS (gpt-5-codex por defecto, luego gpt-5 / gpt-5-mini / gpt-4.1 / o4-mini / o3) y un campo <select> OPENAI_MODEL renderizado justo después de la clave OpenAI, reflejando exactamente los campos de modelo Anthropic/Gemini. Nuevas claves i18n config.openaiModel + config.openaiModelHint en los 8 locales. +1 archivo de pruebas tests/openai-model-selector.test.mjs (4 casos); 721 → 725. Verificado en vivo: #/config → select OPENAI_MODEL con 6 opciones, por defecto gpt-5-codex, vinculado a etiqueta, 0 errores de consola. feat(config) · test: tests/openai-model-selector.test.mjs. Detalle en CHANGELOG.md.
[1.54.1] — 2026-05-18
fix(a11y): F-V54-A — un único <h1> en #/cv. El propio # Name del markdown del CV se renderizaba como un segundo <h1> de nivel superior junto al <h1>CV</h1> del título de página (corrida de regresión F-V54-A; WCAG 1.3.1 Información y relaciones / 2.4.6 Encabezados). Ahora cv.js canaliza cada punto de inyección de la vista previa del CV (render inicial, refresco al importar archivo, sincronización en vivo del editor) a través de un cvMd() con alcance acotado que baja los encabezados un nivel (h1→h2 … h6→role="heading" aria-level="7"), de modo que la página conserva exactamente un <h1>. Acotado a cv.js a propósito — UI.md lo comparten help/reports/deep/evaluate, que gestionan sus encabezados a su manera. +1 archivo de pruebas tests/cv-single-h1.test.mjs (4 casos); 717 → 721. Verificado en vivo: #/cv → 1 <h1>, el # Name del usuario ahora es <h2>, 0 errores de consola. fix(a11y): F-V54-A · test: tests/cv-single-h1.test.mjs. Detalle en CHANGELOG.md.
[1.54.0] — 2026-05-18
WS10 — re-validación de docs canónicos + paridad H3 del paquete de ayuda (la versión final de convergencia). El gate de CI de CHANGELOG/estructura solo comprobaba H2, así que docs/help/en.md había derivado en silencio hasta 70 subsecciones H3 mientras los 7 paquetes localizados se quedaban en 68 — la brecha era §17 (la tabla «Reference adapters» + la lista «Common pitfalls», solo en inglés). Ambas están ahora traducidas a los 7 idiomas (nombres de archivo / enlaces / identificadores de adaptadores mantenidos byte a byte idénticos); los 8 paquetes ahora suman 17 H2 / 70 H3. Un nuevo gate de paridad H3 en help-ru-config-section.test.mjs lo fija (716 → 717). canonical-docs-coverage.test.mjs 7/7 confirma que la ayuda sigue reflejando las 5 guías de career-ops.org/docs; la auditoría UX de WS2 (40 hallazgos v1.41→v1.52) validó cada pantalla frente a los docs — sin divergencia. docs/sdd/CONVENTIONS.md actualizado a v1.54.0 (totales de pruebas, gate de paridad H3, archivos atípicos por tamaño, nueva sección de convenciones de Accesibilidad). WS0–WS10 completos; solo queda WS11. fix(docs): WS10 canonical re-validation + H3 parity · test(help): H3-parity gate. Detalle en CHANGELOG.md.
[1.53.0] — 2026-05-18
WS9 — pirámide de pruebas de la superficie shell (la última capa sin probar). Los 4 scripts bin/*.sh y el hook .githooks/pre-commit tenían cobertura cero; el nuevo tests/sh-files.test.mjs añade 10 casos que fijan la sintaxis bash -n/sh -n, el shebang + bit ejecutable y los contratos de comportamiento de los que dependen otros workstreams: career-ops-ui.sh — help sale con 0 sin fuga de shell-source (guarda de regresión v1.40.0), un verbo desconocido sale con 2, y usage() es un heredoc; start.sh — respeta NO_OPEN, exige Node ≥ 18 y delega el levantamiento del navegador a scripts/open-dashboard.mjs (guarda v1.43.0); setup.sh — modo estricto, SKIP_START, clona ambos repos; run_all.sh — parseo de --quick/--no-e2e y las 4 suites; .githooks/pre-commit ejecuta el revisor de WS7 y ningún archivo shell invoca git --no-verify (guarda de la regla dura #7 de CLAUDE.md); install-hooks.mjs cablea core.hooksPath. docs/architecture/TESTING.md — se añadió la capa base de superficie shell al diagrama de la pirámide + una nota de totales v1.53.0 (716 casos node --test / 90 archivos + 4 superficies E2E). 706 → 716. Detalle en CHANGELOG.md.
[1.52.0] — 2026-05-18
WS2 LOWs #33–#40 — barrido de pulido por lotes (cierra la cola de la auditoría de UX). Ocho hallazgos de baja severidad. fix(a11y/i18n): WS2 LOW batch — #33: #/dashboard — los 3 CTA de cabecera eran inconsistentes (solo 2 tenían icono inicial); «Open Pipeline» ahora lleva 📋 y los tres coinciden. #34: #/profile — los fit/level del arquetipo se mostraban como dos chips ambiguos; ahora van prefijados (Fit: / Level:) con aria-label a juego. #35: #/health — los toasts de Run-doctor / verify mostraban cadenas crudas de doctor.mjs; ahora con clave i18n. #36: #/health — los resultados de las comprobaciones eran <div>s planos; ahora son un role=list <ul>/<li> y el badge de estado lleva aria-label="<check>: <status>". #37: #/reports — las tarjetas eran <div onClick> solo de ratón; ahora role=link + tabindex + manejador Enter/Espacio + aria-label. #38: #/activity — el comentario del paginador decía «200» mientras el código pedía 500; reconciliado a una constante CAP y un aviso role=note aflora cuando el límite de 500 trunca el historial antiguo. #39: #/batch — los placeholders en prosa estaban codificados en inglés mientras sus aria-labels estaban localizados; los cuatro ahora con clave i18n. #40: las páginas de modo relabelaban el botón primario en silencio tras la sonda asíncrona; ahora una región role=status cortés lo anuncia. 10 nuevas claves i18n × 8 idiomas ({n} preservado); +9 tests: test: tests/low-sweep.test.mjs. 697 → 706. Cierra la cola de la auditoría de UX de WS2 (#1–#40 de v1.41→v1.52); siguiente WS9 → WS10 → WS11. Detalle en CHANGELOG.md.
[1.51.0] — 2026-05-18
WS2 #13 + #14 + #18 + #19 + #20 — barrido de feedback/i18n en #/auto y #/evaluate. Cinco hallazgos de la auditoría de UX. fix(a11y/ux): auto+evaluate — busy state, actionable HTTP errors, clipboard fallback, aria-live result, spinner-guarded submit — #13: el botón Run de #/auto ahora muestra un estado ocupado (is-loading + aria-busy + «Running…») en lugar de solo deshabilitarse. #14: una petición HTTP fallida ahora aflora un mensaje i18n accionable sobre el paso Y un toast (auto.httpFail con {n}), en vez de un escueto «HTTP 500». #18: el «Copy prompt» del modo manual ahora usa la Clipboard API asíncrona con respaldo execCommand, y emite un toast de fallo real en lugar de un falso «Copied». #19: el contenedor del resultado de evaluate es ahora role=status aria-live=polite, de modo que la larga llamada al LLM se anuncia a los lectores de pantalla. #20: el botón Evaluate va envuelto en UI.withSpinner (era un onClick: run plano que permitía envíos duplicados). 3 nuevas claves i18n × 8 idiomas; +6 tests: 691 → 697. También una corrección solo de pruebas (commit 7f8e250): el teardown de e2e pipeline-delete estaba en la ruta del confirm nativo previo a v1.48; pasado a DELETE por API (fix(test): … — el Playwright-e2e de CI estaba en rojo; no es una regresión del producto). Detalle en CHANGELOG.md.
[1.50.0] — 2026-05-18
WS2 #12 + #27 + #28 — accesibilidad de la navegación de la ayuda. Tres hallazgos de la auditoría de UX en #/help sobre una guía de 17 secciones y 90+ encabezados, corregidos en help.js. fix(a11y): help — single h1, labelled+filterable TOC, focus-on-anchor, back-to-top — #28: el markdown del documento abría con su propio # Title, produciendo un SEGUNDO <h1> en una página cuyo encabezado ya aporta el h1 canónico; ahora se elimina todo <h1> del artículo, de modo que hay exactamente un h1 y la jerarquía arranca limpiamente en las secciones <h2>. #27: el <nav> del TOC era un punto de referencia sin nombre (dos <nav> sin etiqueta en la página); ahora tiene aria-label (help.toc), y al pulsar una entrada del TOC el foco se mueve al encabezado de la sección (tabindex=-1 + focus()), no solo el desplazamiento del viewport. #12: no había forma de encontrar nada en un documento largo; un filtro type=search sobre el TOC reduce las entradas por texto de encabezado en vivo, y un botón flotante con aria-label «Back to top» aparece tras desplazarse, vuelve al inicio y devuelve el foco al <h1> de la página; su listener de scroll se elimina en hashchange al salir de #/help. 2 nuevas claves i18n × 8 idiomas — help.tocFilter, help.backToTop; +6 tests: test: tests/help-nav-a11y.test.mjs. 685 → 691. Detalle en CHANGELOG.md.
[1.49.0] — 2026-05-18
WS2 #10 + #11 + #25 + #26 — accesibilidad y ordenación de la tabla del tracker. Cuatro hallazgos de la auditoría de UX en #/tracker, corregidos en tracker.js. fix(a11y): tracker headers, sortable table, localized fix labels, empty state — #10: el encabezado de la columna de acción era una cadena vacía y el botón Report por fila no tenía contexto; ahora cada <th> lleva scope=col, el encabezado de acción y los de Score/PDF están con clave i18n (estaban vacíos o en inglés codificado), y el botón Report gana un aria-label con la empresa (<report> — <company>). #11: un tracker sin forma de ordenar; los encabezados Date / Score / Status son ahora botones de ordenación operables por teclado dentro del <th> con aria-sort (none/ascending/descending); un comparador sorted() (numérico para score, comparación de locale para date/status) corre antes de la paginación, y el clic alterna la dirección y reinicia el paginador. #25: track.normalize/dedup/merge eran inglés idéntico en los 8 idiomas pese a ser los controles destructivos de mayor riesgo (reescriben data/applications.md) — ahora correctamente localizados, más un title de ayuda. #26: la primera ejecución con cero filas mostraba el mismo mensaje «no match» que una lista sobrefiltrada; rows.length === 0 ahora muestra un estado vacío distinto (título + cuerpo + CTA «Open pipeline»). 7 nuevas claves i18n × 8 idiomas + 3 relocalizadas; +6 tests: test: tests/tracker-a11y-sort.test.mjs. 677 → 683. Detalle en CHANGELOG.md.
[1.48.0] — 2026-05-18
WS2 #8 + #22 — pipeline: confirmación con foco atrapado + accesibilidad de la vista previa. Dos hallazgos de la auditoría de UX en #/pipeline, corregidos en pipeline.js. fix(a11y): pipeline UI.confirm() + live preview region — #8: las tres acciones de #/pipeline usaban confirm() nativo (no atrapaba el foco): el Delete del panel de vista previa, el ✕ de cada fila y “Evaluate first”; ahora todas pasan por el UI.confirm() con foco atrapado (infra v1.44.0) — los dos borrados danger:true (Cancelar por defecto), “Evaluate first” danger:false; ya no queda ningún confirm() nativo en pipeline.js. #22: previewPane no tenía rol en vivo y un fallo de fetch se metía en previewBody, renderizado como una <pre> “preview” engañosa; ahora es role=region aria-live=polite con un aria-label, y los fallos fijan un previewError aparte renderizado como un bloque role=alert distinto (se limpia al (re)seleccionar o al borrar la fila activa). 4 nuevas claves i18n × 8 idiomas; +5 tests: test: tests/pipeline-confirm-preview.test.mjs. 672 → 677. Detalle en CHANGELOG.md.
[1.47.0] — 2026-05-18
WS2 #7 + #30 + #31 + #16 — barrido de accesibilidad de etiquetas sin vincular. Cuatro hallazgos de la auditoría de UX donde controles de formulario no tenían etiqueta programática (WCAG 1.3.1 / 3.3.2 / 4.1.2), ahora vinculados. fix(a11y): bind every swept form control to an accessible name — #7 scan.js: el checkbox dry-run y el desplegable company-select tenían etiquetas sin for; se añadió htmlFor (con los id existentes). #30 deep.js: los inputs company / role tenían etiquetas sin vincular; se añadió id + htmlFor (deep-company, deep-role). #31 apply.js: url / jd tenían etiquetas sin vincular; se añadió id + htmlFor (apply-url, apply-jd). #16 cv.js: el <textarea> principal de markdown no tenía nombre accesible; se vinculó mediante aria-labelledby al encabezado visible “Markdown” — nombre para lectores de pantalla idéntico al título en pantalla, sin nueva clave i18n. Usa el patrón explícito label[for]↔control[id] ya estándar en batch.js / mode-page.js; sin nuevas claves i18n; cero cambio de comportamiento. +5 tests: test: 667 → 672. Detalle en CHANGELOG.md.
[1.46.0] — 2026-05-18
WS2 #5 + #6 + #21 + #24 — accesibilidad del SSE de scan. Cuatro hallazgos de la auditoría de UX en #/scan, corregidos en scan.js. fix(a11y): scan SSE — live-log region, Stop, run-state, error banner — #5: la consola de streaming ahora es role=log aria-live=polite (+ aria-label, tabindex=0, desplazable con teclado), con una región oculta visualmente aparte role=status asertiva que anuncia los eventos terminales (completado / fallido / detenido). #6: un botón Stop cierra el EventSource en vuelo (es.close()), cancela el sondeo de resultados y restablece el estado; solo se muestra mientras un scan corre. #21: el botón Scan se desactiva + recibe aria-busy mientras corre y Stop aparece, en ambas rutas de stream (streamTo de una fase y runScanAll multifase — esta última solo finaliza la ejecución en el done terminal, final !== false). #24: un fallo del SSE ya no es solo un toast de 3,5 s; ahora un banner persistente role=alert muestra el error con una acción de reintento (reinvoca la última función de ejecución), limpiado en la siguiente ejecución. 8 nuevas claves i18n × 8 idiomas; +7 tests: test: tests/scan-sse-a11y.test.mjs. 660 → 667. Detalle en CHANGELOG.md.
[1.45.0] — 2026-05-18
WS2 #3 — pestañas de #/config: patrón WAI-ARIA Tabs completo. Las tres pestañas de #/config (API keys / Profile / Modes) eran <button class="tab-btn"> simples con activación solo por clic: sin role, sin aria-selected, sin modelo de teclado (UX-audit HIGH #3, WCAG 4.1.2 / 2.1.1). fix(a11y): config.js tabs implement role=tablist/tab/tabpanel — ahora un contenedor role=tablist con aria-label; cada pestaña role=tab + id + aria-controls + aria-selected (sincronizado en activate()) + tabindex itinerante (0 activa / -1 resto); el panel role=tabpanel + tabindex=0 + aria-labelledby siguiendo a la pestaña activa. Navegación de teclado completa: ←/→/↑/↓ (con envolvente) + Home/End mueven el foco Y activan. El gancho CSS heredado .tab-btn.is-active se conserva. +1 clave i18n × 8 idiomas (config.tablistLabel); +7 tests: test: tests/config-tabs-aria.test.mjs. Además, una corrección solo de pruebas: fix(test): retarget 2 stale auto-pipeline smoke tests — dos smoke tests de Playwright-e2e anteriores a v1.34 afirmaban un modal transitorio que el botón “Auto-pipeline” del dashboard dejó de abrir en v1.34.0 (→ Router.go('/auto')); llevaban en rojo en el job de CI Playwright-e2e separado. Reorientados a la pantalla #/auto. 653 → 660. Detalle en CHANGELOG.md.
[1.44.0] — 2026-05-18
WS2 #4 + #9 — confirmación con foco atrapado antes de sobrescrituras destructivas de archivos del proyecto padre. Dos HIGH de la auditoría de UX, ambos con pérdida de datos: (#4) config.js saveProfileRaw/saveModesRaw reemplazaba el config/profile.yml / _profile.md completo del padre sin confirmación; (#9) tracker.js Normalize/Dedup/Merge reescribía data/applications.md del padre in situ sin confirmación. fix(a11y/safety): UI.confirm() gate before whole-file parent overwrites — nuevo UI.confirm() en public/js/api.js, un diálogo con foco atrapado que reutiliza la infraestructura modal WAI-ARIA existente (un gancho _onClose hace que Esc / backdrop / × / Cancel resuelvan todos false; el foco recae por defecto en Cancel; devuelve Promise<boolean>; NO el confirm() nativo). Las tres llamadas destructivas quedan ahora protegidas antes de su escritura. 8 nuevas claves i18n × 8 idiomas (el marcador {op} se preserva verbatim); +8 tests: test: tests/confirm-gate.test.mjs, 644 → 652. Detalle en CHANGELOG.md.
[1.43.0] — 2026-05-18
Solicitado por el usuario — career-ops-ui open + autostart que trae el navegador al frente. Tras setup/run, un open/xdg-open pelado dejaba la pestaña del dashboard en segundo plano cuando el navegador ya estaba abierto, obligando al usuario a buscarla. feat(cli): career-ops-ui open — open AND raise the dashboard tab — el nuevo scripts/open-dashboard.mjs construye la URL desde HOST/PORT (reescribiendo un bind 0.0.0.0 a loopback), opcionalmente espera a /api/health, abre el navegador por defecto y luego lo fuerza al frente — osascript en macOS activando el que esté corriendo de Chrome/Brave/Edge/Safari/Arc/Firefox, xdg-open+wmctrl en Linux, start en Windows. Expuesto como el verbo career-ops-ui open (alias dash, focus). El autostart de bin/start.sh ahora delega en él para que la pestaña se traiga al frente automáticamente; NO_OPEN=1 desactiva el auto-open en arranques headless/CI. README ×8 + help §1 ×8 actualizados; +8 tests: test: tests/open-dashboard.test.mjs, 636 → 644. Detalle en CHANGELOG.md.
[1.42.0] — 2026-05-18
WS2 corrección n.º 2 — ruta muerta #/portals → enlace profundo a config. #/portals era una ruta no registrada que renderizaba la vista 404, pese a ser una URL plausible de marcador/tecleo para gestionar fuentes de portales (HIGH n.º 2 de la auditoría UX). fix(router): #/portals 404 → alias to config + Regional-sources deep-link — se añadió portals: 'config' a ALIASES de router.js (mismo patrón de estabilidad de marcadores que settings→profile), así que ahora resuelve a la vista config con el ítem de navegación config activo. Cuando existe un grupo Regional-sources, la vista (config.js) detecta el hash #/portals, fuerza la apertura de ese grupo <details>, lo desplaza a la vista y mueve el foco a su summary (anulando el foco h1 por defecto), de modo que el usuario aterriza justo en los controles de fuentes de portales; nunca renderiza un grupo regional vacío solo por el alias. help-bundle §5 × 8 obtuvo una nota de atajo; +1 test de router: test(router): portals→config alias guarantee en router.test.mjs, 635 → 636. Detalle en CHANGELOG.md.
[1.41.0] — 2026-05-18
WS2 — auditoría sénior de UX/usabilidad + corrección transversal de gestión de foco. Una auditoría heurística de más de 10 años (Nielsen × WCAG 2.2 AA × convenciones del proyecto) de las 17 rutas produjo una cola de 40 hallazgos ordenada por severidad (.planning/.../UX-AUDIT.md); HIGH→MEDIUM→LOW se entregan ahora una corrección por release. Esta release aterriza el HIGH transversal n.º 1. Correcciones: fix(a11y): move focus to the new view on every route change — router.js render() reemplazaba #content en cada hashchange pero nunca movía el foco, así que los usuarios de teclado / lector de pantalla quedaban en el nodo destruido y perdían su sitio (WCAG 2.4.3 Focus Order / 4.1.3 Status Messages — transversal, afectaba las 17 pantallas); el nuevo focusNewView(content) enfoca el primer h1/.page-title de la vista nueva (anuncio SR conciso + orden de foco correcto), haciendo el encabezado enfocable (tabindex=-1) si hace falta y recurriendo a #content; el primer pintado se omite para no pelear con el skip-link; cableado en las rutas de render de éxito y error; verificado en vivo: tras navegar, document.activeElement es el H1.page-title de la vista nueva. Tests: test(router): focus-management static guarantees — 4 casos en router.test.mjs (helper definido, destino-encabezado + fallback a content, guarda de salto en primer pintado, ≥2 puntos de llamada); 631 → 635. Detalle en CHANGELOG.md.
[1.40.0] — 2026-05-18
WS8.3 — barrido de actualización de docs + corrección de career-ops-ui help + endurecimiento de askSecret. Correcciones: fix(cli): career-ops-ui help no longer leaks shell source — el dispatcher imprimía su comentario de cabecera con sed -n '2,12p', pero la línea 12 (set -euo pipefail) es código, no comentario, así que career-ops-ui help (y el texto de uso de verbo desconocido) terminaba con una línea set -euo pipefail perdida; acotado a 2,11p (el bloque de comentario) en los casos help y *); help sale 0, verbo desconocido sale 2 — verificado. fix(cli): scripts/init.mjs key entry never echoes — el seguimiento de v1.39.0 reemplazó la máscara cosmética de readline por un lector real en modo raw: setRawMode(true) + línea con búfer para que los bytes de clave tecleados/pegados nunca lleguen al terminal (sin fuga en scrollback / tmux / pantalla compartida); un FSM completo de escape VT consume cada secuencia CSI/SS3/OSC/DCS/SOS/PM/APC para que las teclas de flecha y función no corrompan el secreto; stdin se inyecta por dependencia, así que el fallback no-TTY se prueba unitariamente sin tocar el global; iterado hasta un LGTM limpio de la revisión IA. Documentación: README ×8 — la antigua sección “instalación en un comando” se reemplaza por una sección destacada “Lanzar e inicializar en un comando” (el one-liner de curl más la cadena explícita del CLI career-ops-ui: clone → npm link → setup → init → doctor → run → help, la explicación del asistente de proveedor, la forma CI --provider --anthropic-key --yes y la nota de LLM_PROVIDER); las 8 insignias de README actualizadas de v1.22–v1.24 / tests-461–474 a v1.40.0 / tests-631 (insignia e2e hecha no numérica para evitar un conteo inventado); help-bundle ×8 §1 — se añade un callout “Lanzamiento e init en un comando” al inicio del manual de inicio rápido (antes de “A. Setup”) en los 8 idiomas; paridad de secciones H2 preservada (17 cada uno — gate de CI verde). Tests: test(init): non-TTY askSecret fallback — provider-selector.test.mjs gana un caso de stdin por DI que comprueba que askSecret delega en ask() plano (paridad de trim) sin TTY y sin mutar el global compartido; 629 → 631. Detalle en CHANGELOG.md.
[1.39.0] — 2026-05-18
WS8.2 — selector de proveedor LLM + clave OpenAI/Codex + asistente init interactivo. LLM_PROVIDER (auto|claude|gemini) + OPENAI_API_KEY en env-config (secreto). providerOrder() consultado por los 6 gate-sites de llm.mjs vía _provGate(); sin cambio de comportamiento para auto. Select + campo en #/config. scripts/init.mjs ahora es un asistente real (escribe parent .env por la ruta validada). 7 tests. 622 → 629. README ×8 / fold canónico = WS8.3/WS10. Detalle en 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 el motor /api/health exacto (createApp in-process → reporte terminal); exit 0 sólo si todos los checks REQUERIDOS pasan. docs/sdd + help §1 ×8. 6 tests. 616 → 622. README quickstart ×8 = WS8.3 (paso final). Detalle en CHANGELOG.md.
[1.37.0] — 2026-05-17
WS7 — revisión AI pre-commit en el workflow git. Floor determinista (fail-HARD): bloquea .env/secretos staged, patrones de clave en el diff, .also( en vistas staged, fallo node --check. Capa AI (fail-SOFT): claude -p sobre el diff si el CLI está disponible y AI_REVIEW != off. .githooks/pre-commit + prepare cablea core.hooksPath. Nunca --no-verify. docs/sdd. 6 tests. 610 → 616. Detalle en CHANGELOG.md.
career-ops-ui v1.267.1 · page 7/9 · mirror of the project changelog for cvstart.org