Skip to content

chore: publica dev en main - #8

Merged
manuamest merged 31 commits into
mainfrom
dev
Sep 19, 2026
Merged

manuamest merged 31 commits into
mainfrom
dev

Conversation

@manuamest

Copy link
Copy Markdown
Contributor

Pone main al dia con dev, que es la rama de integracion del proyecto.

Avance limpio: main es ancestro directo de dev, 31 commits por detras y cero por delante, sin divergencia.

Lleva todo el trabajo acumulado en dev, no solo la documentacion: el esquema de Supabase y el SDK de telemetria, las tres pantallas del front, el flujo en vivo, la comparativa de ejecuciones y el CI, mas la pasada reciente de licencia MIT, guia de contribucion, dependencias clavadas y README con capturas reales.

manuamest and others added 30 commits September 19, 2026 12:02
Lo entrega el equipo de front y sirve de especificacion para endurecer el
backend. Se commitea sin tocar, para que los cambios que lo invaliden se
lean en el diff y no se confundan con lo que ya venia escrito.
El SDK sigue con cero dependencias de ejecucion: pytest y ruff van en el extra
[dev], que solo instalan CI y quien desarrolla. La simulacion importa
theker_telemetry sin arrastrar nada nuevo.

select explicito (E, F, I, UP) a proposito: el conjunto por defecto de ruff
cambia entre versiones y CI se pondria rojo por algo que nadie ha tocado.
line-length alto para que E501 solo cace lineas desbocadas y no obligue a
romper la tabla alineada de CAMPAIGN, donde la alineacion es lo que deja leer
la curva de mejora de un vistazo.

Los arreglos automaticos son los que pedia UP: datetime.UTC (ya exigimos
Python 3.11) y Iterable/Sequence desde collections.abc.
70 tests, ninguno toca la red. Cubren lo que hasta ahora solo se habia probado
a mano, empezando por la promesa dura de AGENTS.md 4: el disco manda, un fallo
de red avisa una vez y el episodio sigue. Sin eso, un benchmark de cincuenta
episodios se cae por el wifi de la sala y no queda ni el jsonl.

Tambien queda cubierto el formato viejo de episodes.jsonl (sin n_misrouted) y
la clasificacion de claves del backfill, que es lo que permite cargar los 665
episodios historicos sin perder las metricas de la rama de ordenacion.

Dos supuestos mios eran falsos y mando el codigo, no al reves: el apoyo mide
1180x380, asi que el borde que manda es el de Y y descentrar en X no cambia el
margen; y pallet_states guarda una fila por paquete INTENTADO, de modo que el
intento que derrumba el monton tambien deja traza.
AGENTS.md 5 dice que anadir una causa obliga a tocar tres sitios: FAILURES,
el CHECK de episodes.failure y FAILURE_TEXT. Nada lo comprobaba. Saltarse uno
falla tarde y mal: el episodio se escribe en disco, la subida entera se cae
por el CHECK y nadie se entera hasta ver la interfaz vacia; o la UI acaba
ensenando el identificador crudo, que es justo lo prohibido.

La asimetria entre lo almacenable (9, con el historico place_inaccurate) y lo
producible (8) es deliberada y queda fijada como subconjunto, no como
igualdad, para que no la 'arregle' nadie por error.

Sin base de datos: se leen los ficheros como texto. Comprobado por mutacion
que falla de verdad al quitar una traduccion.
episode() sube el episodio YA terminado, con todas sus filas hijas de golpe.
Para un benchmark esta bien, pero entonces la pantalla Live no tiene nada a
lo que suscribirse: nunca existe una fila con status running ni llega un
UPDATE de episodes, que son dos de las tres senales que escucha (API.md 4).
El jurado veria el pale ya hecho, aparecido de golpe, en vez de montandose
capa a capa. Es la pantalla que justifica la plataforma entera.

Ahora begin() abre el episodio en curso, event()/pallet_state()/placement()
van soltando filas segun ocurren, y end() escribe el jsonl y cierra con un
PATCH, que es ademas el UPDATE al que Live esta suscrita y la primera vez que
alguien rellena episodes.ended_at.

episode() se queda igual: backfill, sembrado y la simulacion de hoy siguen
funcionando sin tocar una linea. Todo pasa por _try, asi que un Supabase
caido sigue degradando a solo-disco.

Pendiente en el repo de la simulacion: el productor tiene que llamar begin()
y end() o esto queda construido y sin usar.
fetchLatestEpisode ordena por started_at sobre la vista, y la vista no
proyectaba esa columna: PostgREST devuelve 400 42703, load() en page.tsx no
tiene catch y Live cae a 'todavia no hay episodios'. Solo se salvaba si habia
un episodio running, que es justo lo que no hay antes de arrancar la demo.

Lo detecto el equipo de front y lo esquivo con una consulta en dos pasos
(API.md 2.2); esto lo arregla de raiz, asi que ese rodeo ya puede colapsarse
a una sola consulta.

De paso, indice parcial sobre episodes(started_at) where status='running':
es la otra consulta que Live hace en cada recarga.
_try solo capturaba RuntimeError, que es lo que lanza el cliente a proposito.
Pero habia dos caminos por los que una respuesta rara se llevaba por delante
el benchmark entero, justo lo que este modulo existe para evitar:

- Un 200 con cuerpo que no es JSON (el HTML de un proxy, por ejemplo) revienta
  en json.loads con un ValueError.
- Una fila devuelta sin 'id' reventaba al leerla con un KeyError. Y esta
  ocurria FUERA de _try, ya de vuelta de la red, asi que ampliar el except no
  bastaba: la lectura pasa ahora por _id_of, donde confluyen los tres sitios
  que sacaban rows[0]['id'].

Un episodio cuya fila no se sabe identificar deja de subir sus hijas pero no
apaga el remoto: el siguiente episodio puede ir bien, y el jsonl esta escrito
de todas formas.
rng.seed(hash((sha, seed))) no era determinista: CPython aleatoriza el hash
de las cadenas por proceso desde la 3.3, asi que cada ejecucion del sembrado
producia un historico distinto mientras el comentario de encima prometia que
relanzarlo daba el mismo. Un historico que cambia solo no sirve para ensenar
una curva de mejora, que es exactamente para lo que existe.

Medido con PYTHONHASHSEED 0/1/2 sobre ('8c5cbf4', 37):
  antes  1069116207, 3997445009, 1533800031
  ahora  3273588617, 3273588617, 3273588617

El test lo comprueba lanzando tres procesos de verdad: dentro de uno solo el
hash es estable y el fallo no se ve.

Va tambien Supabase.delete, para que wipe() deje de construir el DELETE a
mano con client._headers y urlopen: asi respeta el timeout de la clase y un
borrado fallido cuenta lo que dijo PostgREST en vez de un traceback pelado.
19 tests que levantan el esquema de verdad en postgres:16 y comprueban lo que
ningun parser de SQL puede: que los .sql aplican, que aplican dos veces y en
cualquier orden, y que las vistas calculan lo que dicen con filas dentro (1,
2 y 90 segundos dan mediana 2, no media 31).

El assert que importa es otro y conviene no confundirlo: comparar columnas
contra tipos TS NO caza el bug de Live, porque Episode tampoco declaraba
started_at y los dos lados coincidian. Lo que lo caza es recorrer los .from()
del front y exigir que cada columna de un .order()/.eq() exista en su
relacion. Comprobado por mutacion: al quitar e.started_at de la vista, falla
diciendo 'columnas que no existen: v_episode_summary.started_at'.

Los tipos se comparan como subconjunto, no igualdad: mean_score esta en la
vista y no en Run a proposito (API.md 2.1). Lo que rompe la UI es una columna
que falta, que llega como undefined y se pinta mal en silencio.

Trabaja en un esquema propio y solo contra localhost: apuntar DATABASE_URL a
un Supabase de verdad no puede llevarse por delante los datos. Sin
DATABASE_URL se salta entero.
Dos jobs. El backend levanta postgres:16 como servicio, porque el Postgres no
es decoracion: el ultimo bug de esquema que se colo (round() sobre double
precision) solo aparece EJECUTANDO el SQL, y el de started_at solo comparando
las consultas del front contra las columnas de verdad. El montaje del esquema
lo hace el propio test, asi que en local y en CI se corre exactamente lo
mismo.

El front hace lint y build. build y no tsc --noEmit porque Next 16 genera
durante el build los tipos de PageProps<'/runs/[id]'>, que es justo el gate
que van a necesitar las rutas pendientes.

Van dos arreglos de lint en app/page.tsx sin los que el CI nacia en rojo, y
los dos eran reales aunque menores:

- useRef(Date.now()) lee el reloj durante el render, que es impuro: al
  re-renderizar daba otro valor y el latido medía contra un instante que
  nunca ocurrio. Ahora arranca a null y no marca 'sin datos nuevos' hasta que
  ha llegado algo de verdad.
- La carga inicial silencia react-hooks/set-state-in-effect con su porque:
  sincronizar con Supabase al montar es lo que se quiere, y load tiene
  dependencias vacias, asi que no hay cascada de renders.
API.md describia dos cosas que este trabajo ha dejado de ser ciertas:

- El aviso de que v_episode_summary no tiene started_at. Ya la tiene, y el
  rodeo en dos consultas de getLatestEpisode puede colapsarse a una.
- El parrafo de que el SDK no produce episodios running ni UPDATEs. Ahora si:
  se documenta el ciclo begin/event/end y se anade el PATCH /episodes a la
  tabla de escritura.

Referencias muertas al repo de la simulacion, que mandaban a editar ficheros
que aqui no existen: AGENTS.md decia que el CHECK de episodes.failure estaba
en 002_design.sql cuando esta en 001, y 001 citaba scripts/backfill.py y
src/metrics.py. Quien siguiera esas instrucciones para anadir una causa de
fallo editaba el fichero equivocado.

AGENTS.md 3 documenta como lanzar los tests, y 9 avisa de lo unico que queda
pendiente fuera de este repo: que la simulacion llame a begin()/end().
Las tres suscripciones de Realtime van filtradas al episodio que se esta
enseñando, y no hay polling. La unica forma de que Live volviera a preguntar
cual es el ultimo era recibir un UPDATE del episodio que YA tenia en
pantalla.

Consecuencia en la demo: abres Live, te enseña el ultimo terminado, arrancas
la simulacion y Live no se entera. El episodio nuevo nace running pero nadie
escucha INSERT sobre episodes, asi que la pantalla se queda en el anterior
hasta recargar a mano. Con el ciclo begin()/end() recien añadido esto pasaba
de ser teorico a ser lo que iba a ocurrir delante del jurado.

Se añade una suscripcion a INSERT sobre episodes, sin filtro, que solo
recarga si la fila nueva viene con status running: un backfill inserta
cientos de episodios ya terminados y no hay por que recargar con cada uno.

API.md documenta ademas que Live elige un EPISODIO y no una ejecucion, y que
started_at es la hora de insercion para lo backfilleado y la de arranque real
solo para lo que produce begin().
Medido contra Supabase real: a mitad de un episodio en vivo, la pantalla
enseñaba 'Colocados 0 ud de 10' con tres paquetes ya en el pale, y el tiempo
de ciclo en blanco.

La causa es que episodes.n_placed vale 0 mientras el episodio corre: lo deja
asi begin() y solo lo actualiza end(). Los otros dos KPIs (margen y
utilizacion) no lo notaban porque leen de pallet_states, que si va llegando.

Se cuenta de las colocaciones ya recibidas, que estan en el cliente por
Realtime, en vez de añadir un PATCH por paquete. Tras el arreglo, el mismo
instante enseña 'Colocados 3 ud de 10' y 'Ciclo 2.20 s/paquete'.

Dos de los cuatro KPIs clavados en cero justo mientras el pale se monta es
exactamente cuando el jurado esta mirando.
ejemplo_contrato.py deja UNA entrada que recorre el contrato entero de
API.md: las cinco tablas, los seis tipos de evento con las claves exactas que
lee EventFeed, cuatro causas de fallo con stack_collapse dominante, y los
casos limite que el front tiene que saber pintar (0 colocados, cycle_time_s
nulo, overhang positivo, planned_pose distinta de actual_pose). Sirve de
fixture para las rutas pendientes sin depender de la simulacion.

Va marcada synthetic=true, y no es negociable: son numeros inventados y la
regla del proyecto es que nunca se confundan con medidos ni entren en una
comparacion.

humo_live.py monta un pale de verdad con pausas, usando begin()/event()/
end(). Es lo que hay que lanzar antes de una demo para saber si Live esta
viva; con el salieron los dos fallos de la pantalla que se arreglaron en
af7ec99 y 46fde4c.

Va tambien runs/ al .gitignore, y se sacan del control de versiones dos
episodes.jsonl que se me colaron en 46fde4c con un git add -A. Son datos que
produce RunLog en la maquina que lo ejecuta, no codigo.
Los cinco puntos que pide la simulacion, por gravedad.

1. RunLog acepta config y lo escribe en runs.config, y las dos vistas lo
   exponen. El pale es una maqueta 1:5.7 (la pinza del Panda abre 80 mm, un
   europeo es inagarrable) y sin el tamano real la pantalla lo dibuja a
   1200x800: todas las cotas salen 5.7 veces mal. Pallet.tsx deja de tener
   PALLET_X a fuego y recibe el tamano; palletSize() en ui.ts lo busca en
   config, luego en metrics (donde iba mientras tanto) y cae al europeo.

2. Imagenes: tabla snapshots en 003, bucket publico, RunLog.snapshot() sobre
   _push y Supabase.upload_png(). Los dos avisos, atendidos: test_contrato
   aplica ahora sorted(glob('*.sql')) en vez de 001 y 002 a mano, y el bucket
   va guardado con to_regclass, comprobado contra un postgres:16 pelado.

3. stability_margin y support_polygon se mueven a theker_telemetry/pallet.py.
   Estaban duplicadas porque seed/ no es importable; ahora hay UNA
   implementacion y el sembrado la reexporta. Es el indicador que define toda
   la interfaz: dos versiones acabarian discrepando.

4. Se fusiona main, que traia 30cfc2b (_aligned). Sin el, seed y backfill
   estaban rotos en esta rama: PostgREST rechaza un lote cuyas filas no
   tienen las mismas claves.

5. end() manda solo lo que cambia al cerrar; seed, task, level y n_objects se
   fijaron en begin(). API.md deja de citar frontend/api/clients y getRuns,
   que nunca existieron, y documenta config, snapshots y el PATCH.

116 tests en verde con Postgres.
Con DATABASE_URL puesto y sin el binario salian veinte FileNotFoundError
identicos. Es la dependencia del job de backend en CI que no he podido
verificar hasta que corra alli, asi que conviene que falle diciendo que hacer.
Su reescritura manda en todo lo estructural: atoms/organisms/screens,
frontend/api/ con TanStack Query y lib/pallet.ts. Se resuelven a su favor los
dos conflictos (app/page.tsx y el borrado de components/Pallet.tsx).

Pero su front traia los tres mismos huecos de Live que ya estaban arreglados
en esta rama, asi que se reaplican sobre su estructura:

- lib/pallet.ts tenia PALLET_X = 1.2 a fuego. Era la peticion numero 1 de la
  simulacion: el pale es una maqueta 1:5.7 y dibujarlo a 1200x800 deja todas
  las cotas 5.7 veces mal. Ahora buildTopView/buildSideView reciben el tamano
  y LiveScreen se lo pasa desde config.pallet_size_m.
- buildKpis leia episode.n_placed, que vale 0 mientras el episodio corre: los
  KPIs de Colocados y Ciclo se quedaban clavados en cero justo mientras el
  pale se monta. Se cuentan de las colocaciones ya recibidas.
- El tercero, detectar un episodio nuevo, ya lo habian resuelto ellos por
  polling (LATEST_POLL_MS), asi que no se toca.

API.md estaba duplicado: el suyo en docs/ y el mio en la raiz. Se unifican en
docs/API.md, que gana la seccion 2.8 de totales que solo tenia el suyo.

116 tests en verde, y el test de contrato ve ahora sus 126 ficheros .ts y
valida 16 consultas, incluida v_episode_summary.started_at.
No hay servidor propio que arrancar, asi que 'todo' es: credenciales en su
sitio, dependencias puestas, esquema al dia y el front escuchando.

Lo que comprueba no es ceremonia. El esquema se pega a mano en el editor de
Supabase, asi que es normal que el repo vaya por delante de la base, y una
columna que el front consulta y la vista no proyecta devuelve 400 y deja la
pantalla vacia. En vez de 'hay conexion', se piden las cuatro columnas que de
verdad se han ido rompiendo (started_at, los dos config, snapshots.url) y si
falta alguna dice que .sql hay que pegar.

Tambien vigila que node_modules no vaya por detras de package-lock, que es lo
que pasa cada vez que alguien fusiona una rama con una dependencia nueva.

En su primera ejecucion ya sirvio para algo: frontend/.env.local estaba
vacio, o sea que el front nunca habia podido hablar con Supabase. Solo se
habia rellenado el .env de la raiz.
Habia dos ficheros de credenciales con los mismos valores, y una copia se
desincroniza sola: paso hoy, con el .env de la raiz relleno y el del front
vacio, asi que la interfaz llevaba todo el rato sin poder hablar con
Supabase y nadie se habia dado cuenta.

Next solo carga ficheros de env de su propia carpeta, asi que next.config.ts
lee el de arriba y traduce las dos claves publicas.

Por LISTA BLANCA, que es lo importante del fichero: el .env tambien lleva la
service_role, que se salta RLS, y todo lo que lleva NEXT_PUBLIC_ acaba en el
JavaScript que descarga cualquiera. Se nombran una a una las dos claves
publicas y no se recorre el fichero entero jamas. Ademas hay un guard que
tumba el build si una variable publica lleva un nombre que huela a secreto.

Comprobado sobre el bundle ya construido: la anon esta en .next/static (es su
funcion) y la service_role no aparece.
Dos constantes absolutas mas del mismo tipo que PALLET_X: PALLET_DECK_M
(0.144 m) y el suelo de zMax (0.30 m) suponian un europeo. Con la maqueta de
210 mm el pale se dibujaba 1.8 veces mas alto que TODA la carga, y el monton
quedaba en el 18% del alzado: por eso parecia que no llegaban dimensiones.

Ahora las dos se derivan del ancho del pale. Medido con los datos reales
(carga 0.0797 m, pale 0.21 m):

  maqueta  antes: carga 18.0% del alzado, pale 32.4%
  maqueta  ahora: carga 63.8% del alzado, pale 20.2%
  europeo  antes y ahora: 71.3% / 10.8%   (sin cambios)

Y las capturas del simulador, que ya se estaban subiendo y no las leia nadie:
cliente getSnapshots, hook useSnapshots y un conmutador en cada panel para
pasar del esquema a la foto. Arranca siempre en el esquema, que es el que
lleva cotas y poligono de soporte; la foto esta para comprobar que el dibujo
no miente, que hasta ahora no habia forma de saberlo.

Va en EpisodeDashboard, asi que lo ganan Live y el detalle de la ejecucion.
Se coge la captura de mayor after_seq: la del final del episodio.
# Conflicts:
#	frontend/api/hooks/index.ts
#	frontend/components/molecules/index.ts
#	frontend/components/screens/LiveScreen/useLiveScreen.ts
Alzado a escala del palé y captura real junto al esquema
Los tres sitios que exige AGENTS.md 5: TASKS en schema.py, el CHECK de SQL y
TASK_TEXT en ui.ts. El test de vocabulario los compara, y de hecho fallo dos
veces antes de cuadrar, que es justo para lo que esta.

El CHECK de task sale del CREATE TABLE. Estaba dentro, y ahi no se puede
ampliar: 'create table if not exists' no toca una tabla que ya existe, asi
que el vocabulario solo se habria podido cambiar migrando a mano. Ahora se
recrea en cada pasada, igual que el de failure, y ampliarlo es repegar 001.

Los dos parsers del test tenian \w+, que no incluye el guion, y en TypeScript
una clave con guion va entre comillas. Ninguno de los dos casos existia hasta
ahora porque todos los valores eran palabras sueltas en ingles.

Nota: el valor rompe la convencion de AGENTS.md 8 (nombres de codigo en
ingles, textos en castellano); los otros dos son induction y palletizing. Se
hace asi porque se ha pedido explicitamente.
Dos causas distintas dando el mismo banner, diagnosticadas con los datos de
las ejecuciones reales.

1. Un episodio que llama a begin() y nunca a end() se queda en running para
   siempre, y getRunningEpisode le daba prioridad absoluta: Live enseñaba un
   episodio muerto con el aviso encima y no salia de ahi. Habia uno abierto
   7.4 min con 0/10 colocados. Ahora se descartan los que llevan mas de 15
   min abiertos, un orden de magnitud por encima de los 25-75 s que duran los
   medidos. Comprobado contra Supabase: un huerfano de 40 min queda fuera y
   el episodio vivo sigue pasando.

2. El umbral de 8 s era corto. El ritmo de subida es muy irregular: el mismo
   episodio de 204 s simulados tarda entre 26 y 71 s de reloj, o sea entre
   x2.9 y x7.9 con el mismo motion_speed declarado. Sube a 20 s.

Y el mensaje mentia. useEpisodeRealtime ya devolvia 'subscribed' y no lo
usaba nadie, asi que se decia 'conexion perdida' con el canal perfectamente
suscrito: eso manda a mirar el wifi cuando el problema esta en el productor,
que es el minuto que no sobra en una demo. Con el canal vivo ahora dice 'sin
datos nuevos'.
…#7)

* docs: README, licencia MIT y guía de contribución

El repo va a ser público y no tenía README en la raíz: el único era el andamio
de create-next-app bajo frontend/. Todo el conocimiento estaba en AGENTS.md,
docs/API.md y el briefing, los tres en castellano.

- README.md en inglés: qué es, requisitos, instalación derivada de dev.sh (las
  tres SQL y los tres guiones de seed), desarrollo, uso del SDK, dependencias
  con la auditoría de licencias, y la nota de que SUPABASE_SERVICE_KEY se salta
  RLS. Diagrama de arquitectura en Mermaid, que GitHub pinta sin herramientas.
- LICENSE MIT, 2026 STACKSPECT. La auditoría (npx license-checker sobre el
  árbol instalado, pip-licenses sobre un venv limpio de backend[dev]) no
  encuentra nada incompatible: 32 paquetes en el conjunto que se publica, todos
  permisivos salvo el libvips precompilado de sharp (LGPL-3.0-or-later), que es
  una dependencia OPCIONAL de next, se carga dinámicamente y no se empaqueta ni
  se vendoriza aquí, y caniuse-lite (CC-BY-4.0), que son datos de build.
- CONTRIBUTING.md con el flujo real: dev como rama de integración, los commits
  convencionales con asunto en castellano tal como se practican, dev.sh, las
  comprobaciones y lo que exige CI. Incluye las reglas duras de AGENTS.md que
  más caro salen en revisión: nada de Tailwind, colores solo desde
  styles/colors.css, y que `npm run lint` son tres comprobadores.
- Versiones clavadas: package.json cierra los rangos ^ a lo que el lockfile ya
  resolvía y declara engines.node 22 (el de CI); pyproject clava pytest y ruff
  en el extra dev. Las dependencias de ejecución del SDK siguen vacías a
  propósito. `npm install --package-lock-only` no cambia ni un `resolved`.
- .gitignore: .claude/settings.local.json solo estaba ignorado por el
  .git/info/exclude de este clon, que no viaja con el repo.
- .gitmessage listaba los ámbitos del repo de simulación, no los de este.

Verificado: npm ci + lint + build en verde; ruff y 116 tests en verde con
DATABASE_URL contra un postgres:16 desechable; el árbol queda limpio tras un
build completo; todos los enlaces del README resuelven.

Faltan las capturas de pantalla: hay un hueco marcado en la sección
Screenshots hasta que haya credenciales de Supabase con datos.

* docs: AGENTS.md al dia con las rutas de detalle y los nuevos documentos

Decia que /runs/[id] y /runs/[id]/[seed] estaban pendientes y llevan hechas
desde el commit de la pantalla de detalle; el build las lista. Faltaban ademas
003_snapshots.sql, que dev.sh comprueba, y los tres documentos en ingles que
entran en este PR.

AGENTS.md pide corregir lo desactualizado en el mismo PR (preambulo), y sin
esto el README y AGENTS.md se contradicen sobre que rutas existen.

* docs: humo_live no marca synthetic, y el README lo decia mal

palletizing.py y ejemplo_contrato.py ponen synthetic = true explicitamente;
humo_live.py va por RunLog, que no toca la columna, y el default de
002_design.sql es false. Su run se lee como MEDIDO. El README decia que los
tres marcaban synthetic, que es justo la confusion que el proyecto entero
existe para evitar.

* docs: capturas de las dos pantallas en el README

Cuatro imagenes, 1440x1000, de la instancia corriendo contra el proyecto real:
Ejecuciones y el detalle de un episodio, cada una en tema claro y oscuro. Van
como <picture> para que sigan el tema de quien mira, igual que la cabecera de
marca.

En la captura de Ejecuciones hay dos ejecuciones seleccionadas a proposito: el
panel sale con «No comparable» y los motivos. Un panel vacio no ensena nada, y
bloquear la comparacion es justo lo que distingue a esta plataforma.

El episodio es medido, con oracle, y el pie de la seccion lo dice: esos numeros
sustituyen la percepcion por poses reales y no se pueden leer como los de un
pipeline completo.

* docs: optimizar los PNG del README, 726 KB -> 224 KB (-69 %)

Mismos pixeles: 1440x1000 las capturas, sin recortar y sin reescalar. GitHub
las pinta a ~890 px, asi que 1440 es lo que las mantiene nitidas en pantallas
de alta densidad.

  runs-light          173,518 -> 50,559   -70.9 %
  runs-dark           173,367 -> 49,838   -71.3 %
  episode-light       175,181 -> 50,185   -71.4 %
  episode-dark        173,950 -> 48,824   -71.9 %
  mark-light           39,762 -> 24,772   -37.7 %
  wordmark-light        7,390 ->  5,038   -31.8 %

Las cuatro capturas: oxipng al maximo y despues paleta de 256 por corte de
medianas, sin difuminado (en una interfaz plana el difuminado emborrona el
texto pequeno y encima engorda el fichero). Los dos de marca van SOLO sin
perdida: oxipng ya los habia dejado en modo paleta con 256 y 166 colores, asi
que cuantizar no tenia nada que rascar y solo podia estropear el borde.

La primera tanda fue con maxima cobertura, que salia mas pequena (~44 KB), pero
al mirarla de cerca viraba los grises neutros a rosa: el fondo de pagina pasaba
de (245,245,243) a (249,233,242). Se nota en areas planas grandes. El corte de
medianas conserva los neutros con una desviacion de 0 a 1.

Comprobado a 3x sobre el texto pequeno y los acentos de color en los cuatro
ficheros y en los dos temas: numeros en verde, insignia Oracle con su trama,
"No comparable", el feed de eventos y las cotas del pale salen igual. Error
maximo por canal 20/255 y ningun pixel por encima de 16.

---------

Co-authored-by: manuamest <[email protected]>
@manuamest
manuamest merged commit b6980b7 into main Sep 19, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants