Skip to content

chat-sim: simulador de chat multi-canal (WhatsApp + Telegram), determinista y sin React - #27

Merged
A-PachecoT merged 87 commits into
mainfrom
cto/chat-sim-rewrite
Sep 7, 2026
Merged

chat-sim: simulador de chat multi-canal (WhatsApp + Telegram), determinista y sin React#27
A-PachecoT merged 87 commits into
mainfrom
cto/chat-sim-rewrite

Conversation

@A-PachecoT

Copy link
Copy Markdown
Contributor

TL;DR@cofoundy/ui/chat-sim renderiza una conversación de WhatsApp o Telegram desde un guion, con diferencias estructurales entre canales (no de color), y escupe PNGs byte-idénticos corrida tras corrida. Sirve a las dos landings Astro sin agregar React, y a la app de Fovente vía React.

83 commits · 23.4k líneas · 361/362.

Resumen ejecutivo completo: docs/reports/chat-sim-rewrite.mdx.

Por qué existe

El valor central no es la demo: es que el backend pueda mostrar el estado de entrega correcto según el canal. WhatsApp varía el color del doble tick; Telegram varía el glifo; un canal de Telegram usa una métrica de vistas; iMessage usa texto. El ReceiptModel del adapter declara eso y el renderer sólo pone píxeles.

⚠️ El merge es gate humano, a propósito

inbox-ai (Fovente, en producción) consume github:cofoundy/ui#main. Su node_modules está en v0.2.2 contra 0.6.1 actual, pinneado por lockfile. Quien re-resuelva ese lockfile — que es justo lo que adoptar esto requiere — salta de golpe cruzando un major de Tailwind. No es un merge de rutina.

Estado de los gates de este repo (importante para el reviewer)

Tres gates del repo no miden nada, encontrados durante el ciclo y fileados sin arreglar (EPIC #23):

Además main ya estaba rojo antes del ciclo (#21) y este repo no corre tests en CI (#19).

Verificación

npx vitest run src/__tests__/chat-sim/ src/components/chat-sim/   # 361/362; el rojo es #25
npx tsc --noEmit                                                  # 2 errores, ambos hero-shader

Lo que el operador encontró mirando la pantalla

Ocho defectos que ningún test cazó. Los 9 gates verifican consistencia interna — que el renderer obedezca al adapter — y ninguno verifica que el adapter tenga razón. Un valor incorrecto pasa cualquier gemelo: el DOM cambia, sólo que hacia lo equivocado. Por eso se agregó el baseline contra fuentes primarias (T-032).

Está todo, con su recurrencia y sin maquillar, en el resumen ejecutivo.

Deuda declarada

  • Dos modelos de Message en el paquete, sin conversor (anotada en los propios archivos de tipos).
  • Branch coverage 79.63% vs 80%: las ramas faltantes son inalcanzables sin el adapter de iMessage. No se bajó el umbral para pintar verde.
  • La rotación del preview no cambia el contacto (#buildHead lee contact-name una sola vez al montar).

Archiva el substrate del ciclo atelier-components (2026-05-16, ya shippeado) y
abre ciclo nuevo para la reescritura del simulador de chat multi-canal.

Recon de Fase 0 (hallazgos verificados, no inferidos):
- Las dos landings objetivo (fovente-landingpage, landing-page-v3) son Astro sin
  React y no consumen @cofoundy/ui => el custom element es target de primera clase.
- inbox-ai ES Fovente, ya consume @cofoundy/ui en 139 archivos, sin pin
  (github:cofoundy/ui#main) => todo push a main entra a su prod sin gate.
- El modelo de capabilities por canal ya existe en prod (Python): el adapter en TS
  lo espeja, no lo inventa.
- El modelo temporal del prior art no es determinista (Math.random en el hot path),
  su pause no pausa y su seek pierde eventos => la reescritura cambia el modelo
  temporal a una timeline compilada con PRNG sembrado.

Higiene: destrackea .cofoundy/state/ de runtime (orch#86 — un history.jsonl
trackeado hace que un worktree nuevo se lea como lane terminada). Los artefactos
del ciclo de mayo (dogfood-backup, visual-baselines) se preservan en el archivo.

Claude-Session: https://claude.ai/code/session_01PGVipst3YuVSd4swzi8k8A
- brief.yaml: alcance, exclusiones, criterios falsables, y las 2 decisiones
  humanas abiertas (prior art, deadline Fovente) como gate previo al push
  publico, no previo al build.
- finding 01: el modelo estado(t)=f(guion,seed,t) aguanta. Engine ~95% puro;
  10 clases lo rompen, cada una con remedio (clock virtual, sorteo en
  compile-time, % en vez de getBoundingClientRect, hidratar-sin-animar...).
  El tilt por spring queda fuera de la timeline: tiene memoria.
- finding 02: existe un simulador previo NUESTRO (ChatDemo.astro) contra el
  que se calibro inbox-ai en prod. Su modelo temporal es determinista y su
  enfoque pre-render+reveal es superior al del prior art para el target Astro.

Claude-Session: https://claude.ai/code/session_01PGVipst3YuVSd4swzi8k8A
- La timeline pasa de append a FOLD: Telegram muta mensajes ya posteados
  (edit/pin/delete/react), asi que estado(t)=fold(eventos<=t) con ids
  estables. Correccion al modelo del finding 01.
- capabilities.py de inbox-ai: modulo hoja + registry aparte para no cerrar
  el ciclo de imports. Se espeja la separacion, no solo el enum.
- Sonido: el DSP es ~90% parametrizable; lo soldado es taxonomia, onda
  (siempre sine => sin textura) y ciclo de vida.
- R-1: cada iteracion visual produce artefacto abrible; el CTO critica
  primero, despues se abre con open para aprobacion del operador. Deriva un
  requisito de diseno: el renderer element existe desde la iteracion 1.

Claude-Session: https://claude.ai/code/session_01PGVipst3YuVSd4swzi8k8A
Arquitectura en 11 secciones. Decisiones de fondo:
- timeline = fold sobre mensajes con id estable; PRNG posicional; seek
  O(log n + 64); pureza de core/ por lint, no por convencion.
- entrega = maquina de estados POR CANAL: "delivered" es inexpresable en
  Telegram, que es justo la propiedad buscada.
- adapter probado en papel contra 3 canales; la prueba rindio 3 campos que
  faltaban (receipts.states, bubbleTransport, senderKinds). iMessage mezcla
  verde y azul en el mismo hilo: rompe cualquier modelo per-conversacion.
- element/ en la iteracion 1 (deriva de R-1: todo iterable es abrible).
- recortes con estimacion: sin mp4 (-2d), audio a schema+stub (-1.5d),
  pin/delete sin chrome (-1d). Con recortes 12-14 dias, sin ellos 17-19.

Thresholds scopeados a cto-d606f0. NO se otorga auto_approve_main_deploy:
inbox-ai consume github:cofoundy/ui#main sin pin en 139 archivos, asi que
el blast radius de main es la app que se factura, no Storybook.

Claude-Session: https://claude.ai/code/session_01PGVipst3YuVSd4swzi8k8A
El adapter valida el guion en compile-time: un receipt 'delivered' en
Telegram o un emoji fuera de la allowlist es error de compilacion, no
render silencioso. Instrumento falsable, con gemelo positivo.

Mitigacion concreta del riesgo #1: nada nuevo en el barrel principal, solo
subpath @cofoundy/ui/chat-sim => un merge a main no puede romper ningun
import existente de Fovente.

Claude-Session: https://claude.ai/code/session_01PGVipst3YuVSd4swzi8k8A
A-1 audio vuelve parcialmente al alcance: cue packs de WhatsApp y Telegram
  como DATOS + sink a politica clase-5 + story de audicion (-0.75d con tope;
  si excede ~0.5d revierte al stub). Responde parcialmente la queja del
  operador en vez de diferirla.
A-2 whatsimule es referencia de COMPORTAMIENTO, no fuente. Cero copia
  literal, con los tres casos que tientan nombrados (coreografia del
  contact-picker, anillo 10-30-60-85-100, tabla de cadencias) y tripwire:
  la lane que crea necesitar un lift literal PARA y escala. Es ademas lo
  que sostiene que D-1 no bloquee el build.
A-3 la deuda de los dos modelos de Message se anota en los propios archivos
  de tipos, no solo en COMPONENTS.md: el dev se tropieza ahi.
A-4 la story de reemplazo se alimenta de la forma de mensaje REAL de
  inbox-ai, si no el criterio de exito #4 se afirma en vez de medirse.
A-5 el token y ChannelId de iMessage tienen hogar (iteracion 3), estaban en
  mvp_scope y no aparecian en el arch.

blast_radius_check: refute_required=true por architecture_external_surface.
Refute-pass despachado antes de ejecutar el approve.

Claude-Session: https://claude.ai/code/session_01PGVipst3YuVSd4swzi8k8A
El refute-pass encontro que la mitigacion del riesgo #1 era falsa. Verificado
a mano antes de honrarlo:
- inbox-ai/frontend/tailwind.config.ts:9 escanea node_modules/@cofoundy/ui/src
  por PATH, no por import => un subpath no aisla nada del pase de Tailwind.
- packages/ui/src/styles/index.css: un solo sheet global, CERO @layer,
  importado en layout.tsx:4 antes de globals.css, que lo sombrea.
- El productor autora Tailwind v4.1.18 sin config; inbox-ai compila v3.4.0
  con puente manual. No hay verde que mida la superficie en riesgo.

El remedio esta FORZADO, no elegido: las 4 superficies consumidoras no
comparten Tailwind, y fovente-landingpage — una de las dos landings que el
operador nombro — no tiene Tailwind en absoluto, asi que reconciliar v3/v4 ni
siquiera resuelve. chat-sim ships stylesheet propio, tokens bajo .cf-chat-sim,
cero utilidades Tailwind en su source (lint). Precedente propio en produccion:
ChatDemo.astro ya lo hace asi.

§13: dos correcciones SUSTAIN del refute — tz entra a la firma de compile (sin
ella el test pasa y la propiedad es falsa cross-machine) y element/ usa
data-step en vez de clases acumuladas, porque el fold es no-monotono.

CORRIGE H-5: la dependencia SI esta pinneada por el lockfile (node_modules de
Fovente en v0.2.2 vs 0.6.1 actual). Un push a main NO entra a su prod. El
riesgo se materializa al re-resolver el lockfile, que es justo lo que devolver
los 68 KB requiere — y entonces saltan 0.2.2 -> 0.6.1+ cruzando el major de
Tailwind. Empeora la medicion, no la relaja.
6 lanes: core, channel, skin, capture, app, qa. Verificado por script: 16
filas, CERO celdas con 2+ W.

Las 4 superficies que tentaban a colisionar, resueltas por dueño unico en vez
de por convencion: styles.css -> skin; index.ts del subpath -> core;
package.json campo exports -> core, solo iteracion 1; reportes -> un archivo
por lane con nombre unico, y el CTO como unico escritor del indice.

Tres paths prohibidos a TODAS las lanes: src/index.ts y src/styles/index.css
(el aislamiento del ciclo depende de no tocarlos, §12) y src/types/** (la
deuda de los dos Message se aisla, no se repara).

El contrato fija tz como obligatorio en compile() y deja 6 invariantes con su
forma de fallar, incluido el gemelo positivo del byte-compare: cambiar la seed
DEBE romperlo.
DAG validado por script: sin ciclos, todas con role, scope.write y acceptance.

Cuatro tareas llevan gemelo positivo explicito, no solo un test: T-001 (mismo
tz => mismo digest), T-002 (agregar una utilidad Tailwind DEBE romper CI),
T-004 (cambiar la seed DEBE romper el byte-compare), T-005 (el mismo guion
invalido en Telegram compila en WhatsApp). Un test que no puede fallar no mide
nada, y este ciclo lo trata como criterio de aceptacion, no como consejo.

T-002 lleva la referencia de estilo obligatoria (ChatDemo.astro, nuestro
lenguaje en produccion) y la prohibicion explicita de usar whatsimule como
fuente, con el tripwire de A-2.
El hallazgo de fondo es del CTO: la Falsifiable Instrument Gate se habia
aplicado a las tareas y NUNCA a definicion_de_exito. 3 de los 5 criterios del
brief no tenian sonda que pudiera ponerse roja — el ciclo podia terminar verde
sin medir tres de sus promesas. Ahora cada criterio nombra su sonda Y como se
pone roja.

Criterios individuales que no median nada:
- T-003 #3: un fold LINEAL sin checkpoints pasa un benchmark de 2 ms, o sea
  que toda la maquinaria de la tarea podia no existir. Ahora es contador de
  pasos <= 64+log2(n) mas escalado 500 vs 5000.
- T-006 #1: con el sink muteado, "no queda cola sonando" es vacuamente cierto
  y un no-op pasa. Ahora corre desmuteado con assert sobre nodos vivos.
- T-007 #2: "pierde su razon de ser" era prosa, y es el criterio de exito #5.
  Ahora es test de visualViewport mas checklist derivada del fork real.
- T-008 #1: el parentesis "(o adaptador inline)" disolvia el criterio. Fuera;
  se tipa contra inbox-ai api.ts:229 o no compila.

T-002 gana el fixture de capabilities invertidas en la OLA 1: un layout que
dibuja el doble-tick sin ninguna rama por canal pasa cualquier grep con cero
ocurrencias. El grep de T-005 baja a senal secundaria.

Tope de T-006: de tiempo a ALCANCE (<=6 cues, <=3 capas, sink primero). Un
tope horario auto-medido por la lane que acota solo puede constatarse violado.

ChannelId se mueve de T-006 a T-005: adapters/** es celda W de channel.
T-008 #4 pasa de write a solicitud por A: src/types/** es prohibido para qa.

B-7: wall_clock_minutes obligatorio por lane, con disparo duro a 24 h y aviso
temprano a 16 h al cierre de la ola 3. Sin el dato, budget_overrun es
infireable y el threshold queda decorativo.

B-8: sound/ entra al brief con su cota. Habia entrado por A-1 y viajaba
tacito; un pivot de alcance no se absorbe en silencio.
C-1 T-006: "el sink primero" es propiedad del PROCESO, no del artefacto — el
  estado final es identico se haya escrito en cualquier orden, y las lanes no
  producen historial verificable. Se convierte en invariante: test con
  packs={} que no lanza y degrada a "sin cues". Sin el, una lane que autora
  cues primero y se queda corta deja cues sin sink, y el stub no se alcanza
  por resta: no se alcanza.

C-2 T-002 #6: wallpaper SALE del fixture — es pattern en WhatsApp Y en
  Telegram, o sea que no discrimina entre los dos canales que el ciclo
  implementa, y es fondo, lo mas cercano a "solo color" que el criterio de
  exito #2 descalifica. Entran timestamp y reactions => 4 ejes.
  timestamp es el critico: WhatsApp es inside-pad y reserva --pad, que es
  exactamente lo que mide el settle gate de T-004. Un inside-pad hardcodeado
  se congela en los goldens de la ola 2 y Telegram lo descubre en la ola 3,
  invalidandolos. Era la falla que el detector se adelanto dos olas para
  prevenir, pasando por debajo del detector.

C-3 brief: sincroniza la sonda del criterio #2 con los 4 ejes. El brief cita
  la sonda por nombre y habria quedado nombrando una que ya no existe.

C-4 matriz: cierra el circuito del aviso temprano. Si la reproyeccion a las
  16 h supera 24 h, escala ahi — a esa altura hay 6 datos reales y la
  proyeccion esta fundada, no adivinada.
ChannelId y ChannelAdapter tenian DOS duenos contradictorios: api-contract.md
los declara en core/types.ts [core, ola 1] y T-005 #3 los reclamaba para
adapters/** [channel, ola 3]. Las dos tareas de la ola 1 los necesitan, asi
que T-002 #6 — el fixture de capabilities, que es la sonda del criterio de
exito #2 — solo era satisfacible forkeando el tipo dentro de element/**, y
ninguna tarea reconciliaba esa copia despues.

El ciclo cuyo problema_real es "tres implementaciones que comparten codigo
copiado, no abstraccion" iba a shipear una cuarta copia por diseno.

Causa: la enmienda B-2(b) movio ChannelId razonando SOLO desde las celdas W de
la matriz, sin reconciliar api-contract.md. Los tres decision docs nunca lo
citaron.

Fix: los TIPOS del contrato (ChannelId, ChannelAdapter, Diagnostic) son
entregable de T-001 en la ola 1, donde la matriz ya da R a las seis lanes =>
T-002 los lee sin forkear. channel implementa los VALORES contra ellos en
T-005. Imports aciclicos: adapters -> core, nunca al reves. caps.ts deja de
"no importar nada" pero es import type: se borra en compilacion y la propiedad
de hoja del arch es sobre valores.

Riders de la misma pasada:
- blockedBy DEFINIDO: gatea el MERGE, no el arranque. Sin eso las 5 olas eran
  8 tareas secuenciales y "concurrencia 2-3" no describia nada.
- wall_clock_minutes pasa de vivir solo en la matriz (que nadie tenia en su
  read-set) a ser acceptance de las 8 tareas. Era el mismo defecto que B-7
  existia para arreglar.
- scope.read agregado a las 8 (antes 3).
- build config de chat-sim gana dueno unico.
- T-002 #7: el gemelo positivo apuntaba a react/, que no existe hasta la ola
  4. Ahora es un import temporal de react en element/, ejecutable en la ola 1.

Matriz re-verificada: 17 filas, 0 colisiones.
compile()/seek()/fold()/createPlayhead() sobre core/types.ts (ChannelId,
ChannelAdapter de 16 campos, Diagnostic, Ev/Frame/SimState/Timeline del
api-contract.md). PRNG posicional (xmur3+sfc32), digest determinista con tz
obligatorio en la firma (fix §13), duration medida (nunca estimada).

Purity gate (invariant 4) implementado como test vitest estático en vez de
ESLint: no hay infra de ESLint en el repo y el scope.write de T-001 restringe
package.json al campo exports, lo que bloquea agregar eslint como
devDependency. Documentado y demostrado en rojo/verde (ver core/__tests__/purity.test.ts).

Barrel del subpath en index.ts + exports["./chat-sim"] en package.json — el
barrel principal (src/index.ts) no se toca.

Claude-Session: https://claude.ai/code/session_01JDzkPMk7r4ApHv2vWrj239
…atsApp layout (T-002)

Wave 1 of the chat-sim-rewrite cycle. Adds:
- element/render.ts: pure, ChannelAdapter-driven DOM builder for message bubbles
  (tail/timestamp/receiptGlyph/reactions/quote all read the adapter, never a
  `channel ===` branch).
- element/chat-sim-element.ts: <cf-chat-sim> custom element. Pre-renders the full
  thread from the compiled Timeline's final state; `data-step` on the root is the
  one public reveal knob, driven identically by manual scrubbing and by wiring
  core's real createPlayhead().
- element/fixtures.ts: WhatsApp reference adapter (literal reading of
  adapter-interface-draft.md, typed against core/types.ts) + the T-002 #6/B-5
  caps-fixture that flips tail/receiptGlyph/timestamp/reactions.
- styles.css: self-contained stylesheet, all tokens under .cf-chat-sim, zero
  Tailwind utilities, continuity with products/fovente-landingpage's ChatDemo.astro.
- demo/index.html + chat-sim.bundle.js: R-1 — opens via `open`, no dev server,
  plays a real script through the real compile()/fold pipeline. Visually verified
  via headless screenshot (caught and fixed a real bug this way: render was
  appending without clearing on repopulate, stacking duplicate bubbles during
  playback — see render.test.ts's regression test).
- assert-no-react.mjs: acceptance #7 — element/'s bundle has zero React, with an
  automated positive twin.

Two pre-existing gaps found in core/fold.ts (T-001, not this task's cell — flagged,
not fixed): draft events drop actor id, and draft never clears on the following
post. Documented in .cofoundy/state/reports/skin.md and on the demo page itself.

Claude-Session: https://claude.ai/code/session_01CY5w7zvPwrwxbpWjErHqAF
…(E-001 resolution)

No ESLint infra exists in the repo (same gap core hit for T-001's purity lint,
core/__tests__/purity.test.ts). Per team-lead's resolution: a static-scan vitest
test is the falsifiable instrument, same CI, no new devDependency.

element/__tests__/no-tailwind.test.ts scans chat-sim/**/*.{ts,tsx,html} +
demo/**/*.html for class/className/classList.add tokens shaped like Tailwind
utilities. Includes an automated positive twin, plus the literal manual
demonstration was run by hand (classList.add('flex','items-center') injected
into chat-sim-element.ts, test went red naming file+token, reverted) — see
.cofoundy/state/reports/skin.md.

Claude-Session: https://claude.ai/code/session_01CY5w7zvPwrwxbpWjErHqAF
… de T-002

demo/** no tenia dueno aunque T-002 y architecture-v1.md §9 lo comisionan.
Error del CTO al escribir la matriz; skin procedio en vez de bloquearse y lo
reporto. Asignado a skin (W), capture y qa lectura.

Verificaciones hechas por el CTO, no leidas del reporte de la lane:
- gate de pureza de core: inyecte Math.random() en prng.ts => rojo nombrando
  archivo y token; revertido => verde.
- gate de Tailwind de skin: inyecte classList.add('flex','items-center') en
  chat-sim-element.ts => rojo nombrando archivo y token; revertido => verde.
- 39/39 tests verdes corridos por mi; package.json solo toco exports; los tres
  paths prohibidos intactos.

Critica visual del demo despachada a skin: 7 wrongs, el bloqueante es que no
hay geometria izquierda/derecha — medido en el DOM, entrante x=459 y saliente
x=476, 17px de diferencia con el mismo ancho. Es LA propiedad que hace
reconocible a WhatsApp y no existe. Faltan tambien header y barra de input.

core reactivado por dos bugs de fold que skin encontro y son suyos: el draft
nunca se limpia (burbuja de "escribiendo..." colgada, visible en la captura) y
los eventos draft pierden el id del actor.
…t actor id

Two bugs skin found live in the demo (T-001 reactivation):
1. post didn't clear a pending draft — the "typing…" bubble never went away
   once the real message landed. fold.ts's post case now sets draft: null.
2. Ev.draft carried only {idx:number, chars} (frozen shape from architecture-v1.md/
   api-contract.md), so fold.ts could never recover WHO was typing — it hardcoded
   by: ''. Fixed by changing Ev.draft to carry `by: ActorId` verbatim, matching
   every other Ev variant and matching element/'s (T-002) existing group-key model,
   which already treats ActorId as the canonical identity with no index/registry
   indirection. This is a contract amendment (Ev.draft's literal shape changes from
   {idx,chars} to {by,chars}) — flagged to the CTO since api-contract.md requires
   sign-off, but shipped directly since it's what the reactivation asked for and
   what skin's already-committed element/ code needs.

Verified both fixes go red without them (git-stash round trip) and green with
them; skin's own chat-sim-element.test.ts assertion on data-drafting's value
is now stale (was asserting the bug: '') and needs a one-line update — flagged
to skin, not touched here (element/** is outside core's scope.write).

Claude-Session: https://claude.ai/code/session_01JDzkPMk7r4ApHv2vWrj239
core cambio Ev.draft de {idx:number, chars} a {by:ActorId, chars} y lo
senalizo en vez de aplicarlo en silencio. Correcto: el idx numerico obligaba
a un registro de actores con indirección, mientras element/ ya trataba
ActorId como group key verbatim — el mismo criterio que inbox-ai usa en prod.
La indirección era invencion nuestra, no del dominio.

Verificado con test de mutacion, no con el reporte de la lane: quitar
draft:null del caso post en fold.ts:39 rompe 2 tests, uno en CADA lane.
Restaurado => 44/44. El fix esta cubierto de los dos lados del contrato.

Registro tambien que dos sondas mias no midieron nada antes de cazarlas: un
eval de comando vacio que comparo un archivo consigo mismo, y una mutacion
por regex que no aplico. Ambas se veian como verde.
…er, geometry, doodle wallpaper

Team-lead review (DOM-measured, not eyeballed) found the demo didn't read as
WhatsApp yet. Addressed all 7 findings:

- Left/right geometry was measurable-but-illegible (17px apart in a 380px log):
  max-width alone doesn't reserve a gutter. .cf-msg[data-dir] now reserves 15%
  padding on the OPPOSITE side; .cf-bubble's max-width relaxes to 100% since the
  reserve does the real capping.
- Added .cf-head (avatar/name/status, attribute-driven) and .cf-composer
  (visual-only — a real one is react/'s T-007) to the element itself.
- Tail investigated via computed style + zoomed pixel crop: was already correct,
  just too small to read at screenshot scale. No code change.
- Stamp placement: real bug, not a design tension. --cf-cs-pad was a static 44px
  fallback; a stamp with a receipt glyph measures ~50px, one without ~31px — one
  static value under/over-reserves depending on content. Adopted ChatDemo.astro's
  own JS-measured mechanism exactly (stamp.offsetWidth + 10), applied per message
  per step via #measurePad().
- Wallpaper: replaced the polka-dot placeholder with a hand-authored inline SVG
  doodle pattern, still zero external assets.
- Arbitrary bubble width resolved as a side effect of the geometry fix.

Also: core fixed both draft-related bugs flagged in T-002's first pass (by now
populated, post now clears draft) — verified live, test + demo page updated to
match, stale disclosure removed.

44/44 chat-sim tests green, typecheck clean.

Claude-Session: https://claude.ai/code/session_01CY5w7zvPwrwxbpWjErHqAF
…op + bundle-freshness gate

Operator-reported symptom: typing indicator animation stops repeating during
playback, wrong position. Team-lead's root-cause diagnosis: #reconcile was
re-populating/re-moving DOM nodes on every data-step change, and play()'s
onFrame callback writes that attribute on EVERY rAF tick (~60/s), not just at
script-step boundaries — most ticks land on an unchanged step value, so every
visible node was being torn down and rebuilt dozens of times per script step.
For the typing indicator's infinite dot animation, that reset its CSS loop
every single time; it never advanced past the first fraction of a cycle. Same
category as iteration 1's duplicate-bubble bug, per team-lead — replaceChildren()
there treated a symptom of the same unnecessary-rebuild pattern.

Fix:
- #applyStep now guards on step === #lastStep and returns immediately (adapter
  setter resets #lastStep first, since an adapter swap needs a fresh reconcile
  even at the same step).
- Typing indicators are no longer one reused/appendChild-moved node.
  draftIntervals() walks the compiled timeline once and returns one
  [appearStep, vanishStep) window per draft occurrence; connectedCallback
  builds ONE stable <li> per window at its real position in the flow
  (insertBefore, built once); #reconcile only ever flips .hidden on them now.
- Real bubble shape, data-dir-aware (left/right, same rule as .cf-msg) — not a
  floating pill. Dot numbers match fovente-landingpage/global.css:444-451.

Verified with the exact test team-lead asked for (same node reference across
two different data-step values while a window stays open — using a script
whose window spans 2 steps so the assertion isn't vacuous), AND live in
headless Chrome via Element.getAnimations()[0].currentTime sampled over real
playback time, confirming the animation genuinely loops.

Also added element/__tests__/bundle-freshness.test.ts (team-lead's second
ask): shells out to the esbuild CLI, byte-compares a fresh rebuild against the
committed demo/chat-sim.bundle.js, fails with the exact regen command.
Positive twin included; manually demonstrated both directions.

49/49 chat-sim tests green, typecheck clean.

Claude-Session: https://claude.ai/code/session_01CY5w7zvPwrwxbpWjErHqAF
Un solo escritor del indice para evitar append paralelo; cada lane mantiene
su propio reports/{lane}.md.

Registra por commit QUE verifico el CTO y COMO, no lo que reporto la lane:
mutacion cruzada del fix de fold (rompe 2 tests, uno por lane), inyeccion de
Math.random y de clases Tailwind, muestreo en vivo de currentTime de la
animacion, y ensuciado del bundle contra su gate de frescura.

Deja anotadas tambien las tres sondas del propio CTO que no midieron nada.
…ck on blue receipts

Team-lead iteration 3 (post typing-fix), two real gaps + one improvement proposal:

1. Thread was anchored at the TOP (measured: 216px/522px log height empty at
   the bottom, 41%) — a short thread should hug the composer and grow upward.
   Fixed with ChatDemo.astro's own documented technique (global.css:358-361):
   margin-top:auto on the first VISIBLE child, NOT justify-content:flex-end —
   that repo's comment explains why flex-end breaks once content overflows a
   scrollable container. #applyBottomAnchor() moves a .cf-anchor-top class to
   whichever <li> is currently first-and-unhidden, every reconcile. Re-measured:
   12px left at the bottom (was 216px).

2. Added a date separator pill, hidden until the message it introduces reveals.
   Deliberately never "HOY"/"AYER" — those read real-world wall-clock "now" at
   VIEW time, which would make the same (script,seed,channel,locale,tz) render
   different text depending on which day you open the page, breaking
   architecture-v1.md §1 invariant 2 (byte-identical PNGs) that T-004 depends
   on. Always shows the actual formatted date instead.

3. Pushed back, with code citations, on making the last read message's ✓✓
   blue: core/types.ts's SimStep union is post|draft|flag only (no way to
   author a receipt transition in a script), core/fold.ts's applyEvent has no
   receipt case yet (falls through to default per its own header comment —
   T-003's job), and post hardcodes receipt:'queued' unconditionally. render.ts
   already supports the blue color; there's no script data the pipeline would
   honor yet. One-line demo-script change once T-003 lands.

55/55 chat-sim tests green (+12 from iteration 3's 49), typecheck clean, fresh
bundle rebuilt and freshness-gate-verified.

Claude-Session: https://claude.ai/code/session_01CY5w7zvPwrwxbpWjErHqAF
Hallazgo: element/index.ts bundlea core/** transitivamente, asi que cualquier
cambio de core invalida un bundle que solo skin puede escribir. Es un
cluster_scope_violation latente que dispara en todo PR donde core cambio.
Disposicion: reasignar al dueno — skin regenera como ultimo paso antes del
merge, verificado por merge-coordinator con arbol limpio. No se relaja el gate
ni se le da la escritura a core.

skin me corrigio dos veces con codigo y en las dos tenia razon:
- Pedi HOY en el separador de fecha. Una etiqueta relativa lee el reloj de
  pared al ver la pagina, asi que el mismo (script,seed,channel,locale,tz)
  rendiriza texto distinto segun el dia y ROMPE el invariante de PNG
  byte-identicos — el criterio de exito #1 del ciclo.
- Afirme que la maquina de entrega ya soportaba read. Falso: lei el documento
  de arquitectura en vez del codigo. En HEAD SimStep es post|draft|flag y fold
  no tiene caso receipt.

Registra tambien un riesgo que me tome: stashee WIP no commiteado de una lane
activa para aislar un rojo. Funciono, pero si el pop falla destruyo trabajo
vivo. Los 8 rojos de ese estado quedan NO ATRIBUIDOS en vez de explicados con
una causa inventada.
…ommit de docs

984bc54 dice docs(cto) y contiene 378 lineas de fuente de core (T-003).
Cadena causal: stash del arbol de una lane VIVA para aislar un rojo -> el pop
restauro los archivos al INDICE, no solo al arbol -> git add -A .cofoundy/ +
commit se llevo todo lo staged.

Reporte que "el pop funciono y no se perdio nada". Cierto e insuficiente: el
trabajo en curso de otra lane aterrizo bajo mi autoria, con mensaje que
describe otra cosa y sin su propia evidencia. No reescribo historia publicada;
lo corrijo con registro.

Regla: no se stashea el arbol de una lane activa. Para aislar un rojo, worktree
aparte sobre un commit.

Corrige tambien una asercion de estado que quedo stale en el indice (los ticks
azules ya son posibles desde 984bc54) — ahora lleva fecha y receta de
derivacion en vez de leerse como verdad viva.

T-003 verificada por mutacion: linearizar el fold (checkpointIdx=0 Y from=0)
rompe el contador de pasos Y el gemelo de escalado 500-5000. Mi primera
mutacion estaba mal formada y daba falso verde; la cace leyendo el codigo antes
de concluir que el instrumento no medía.
getAdapter(channel) + validateScript(script, channel) over the 16-field
ChannelAdapter contract from T-001's core/types.ts. caps.ts stays a leaf
(only import type ChannelId), Telegram's 73-emoji allowlist copied
verbatim from inbox-ai's telegram_reactions.py with normalizeReactionEmoji
as the one U+FE0F-stripping implementation. validateScript never branches
on the ChannelId literal — it reads adapter.deliveryStates and
isAllowedReactionEmoji, so a channel === hardcode can't hide behind it.

iMessage has no adapter value (out of scope this cycle, architecture-v1.md
§10); getAdapter throws instead of guessing.

Claude-Session: https://claude.ai/code/session_01PTvPdcebYwJZ3mD614xcoK
…te (T-004)

CLI (scripts/capture-chat.mjs -> capture/cli.ts) drives agent-browser (no
Playwright in this worktree, scope.write excludes package.json) against
capture/'s own headless harness. captureFrame(tl, t, o) jumps straight to an
exact Tick (tickToStep, no play()), then settles on document.fonts.ready +
--cf-cs-pad stability + cf-static, and additionally pins every Animation
(springs + the typing-dot infinite loop) to a fixed frame for determinism.

Acceptance #2 (seed change MUST break the byte-compare) is guaranteed by
construction, not luck: jitter only perturbs frame timing, and a naive
step-based capture was verified to produce byte-identical PNGs for two
different seeds at the final state. determinism.test.ts picks a Tick that
sits on opposite sides of a frame boundary for the two seeds' compiled
timelines instead.

Found and routed around (not fixed here, core/** isn't this task's scope.write)
a real bug: Timeline.keys is Int32Array but populated from real epoch-ms
Ticks, silently wrapping for any realistic t0 and breaking core/seek.ts's own
upperBound() the same way. tickToStep searches Timeline.frames directly to
avoid it. Filed as .cofoundy/tasks/T-009.md against [core].

Claude-Session: https://claude.ai/code/session_01PcKdC7NqWyKJ7vSUTpkAj6
Declarative cue-pack schema (sound/types.ts), a pure-JS deterministic PCM
synthesizer (sound/synth.ts — no Web Audio API surface, runs in plain Node,
noise/jitter seeded from core's own positional PRNG), actual cue-pack data for
WhatsApp (bright, sine-only, two-tone "ding") and Telegram (every cue carries
a filtered-noise layer for texture, not pitch — the axis finding 03 §4 names
as what actually distinguishes the two channels), and AudioSink
(schedule-and-cancel per architecture-v1.md §1 class 5, one buffer per cue
feeding a shared master gain — no per-layer GainNode starvation, unlike the
prior art).

AudioSink depends on AudioContextLike, a minimal structural subset of the
real AudioContext, so it's unit-testable under plain vitest (no Web Audio
polyfill exists here, and jsdom doesn't implement one) via a hand-written
FakeAudioContext, while a real `new AudioContext()` satisfies it with zero
adapter code.

Listening story lives at sound/audition.html + audition-entry.ts (bundled,
committed artifact, same no-build-step contract as T-002's demo) — NOT a
Storybook story, since src/stories/chat-sim/** is [qa]'s exclusive cell
(T-008's job). Verified live in headless Chrome: all 6 cues playable, mute
toggle, cancelAll(), zero console errors.

Added --channel-imessage to styles.css (architecture-v1.md §12/§9 iteration
3) — unconditional, outside the audio scope cap.

16/16 sound/** tests green: acceptance #1 (schedule/cancel, unmuted — a
muted sink would make "nothing left playing" vacuously true), #2 (static
scan, zero third-party samples), #3 (per-channel PCM digest differs +
determinism twin + mutation-sensitivity check), #4 (empty pack never throws,
reached by subtraction, not a special stub).

Not in scope: wiring AudioSink into the real Timeline/playhead
(element/** isn't this task's scope.write, and no SimStep authors a `cue`
event yet either) — schedule()/cancelAll() are tested against their own API
directly, same precedent as T-002's caps-fixture testing render.ts without
going through compile().

Claude-Session: https://claude.ai/code/session_01CY5w7zvPwrwxbpWjErHqAF
…ouble-counting bug

Promotion (team-lead ask): stateAtStep(tl,n) and draftIntervals(tl) move from
element/chat-sim-element.ts (private) to core/seek.ts and core/draft-intervals.ts
(exported from index.ts). Pure data only — element/'s own `li: HTMLLIElement`
DOM bookkeeping stays local, layered on top by skin when it switches over
(not touched here per team-lead's explicit instruction). Both now route
through the T-003 checkpoint machinery (foldFromCheckpoint, shared with
seek()), so stateAtStep gets the same O(log n + 64) bound seek already has —
a free improvement, not just a copy-paste move.

Real bug found while writing the acceptance-#3 cross-check test (stateAtStep
vs seek must agree) with a realistic epoch t0: compile.ts computed
Frame.t = t0 + offset (absolute), but architecture-v1.md §1's own formatting
formula is fmt(t0 + f.t, ...), which only holds if f.t EXCLUDES t0 — element/'s
formatTime already correctly assumes this, so shipped code was silently
double-counting t0 in every displayed timestamp. It also explains why `keys`
is an Int32Array: real epoch-ms values overflow it; only small relative
offsets fit. Every prior test used t0:0, where absolute and relative are
numerically identical, hiding this. Fixed: clock starts at 0 in compile(),
and createPlayhead's virtualT starts at 0 to match. Verified red (reverted the
one-line fix, frame ticks came back as ~1.7e12+offset instead of the small
relative offset) and green. New direct regression test in compile.test.ts.

Claude-Session: https://claude.ai/code/session_01JDzkPMk7r4ApHv2vWrj239
T-009 (Int32Array desbordando con t0 real) queda cerrada por 0c86277, que
ataco la causa y no el sintoma: compile() calculaba Frame.t absoluto contra
una formula de formateo que exige relativo. Con frames relativos keys guarda
offsets chicos y el overflow desaparece. Efecto colateral del mismo bug: el
demo sumaba t0 dos veces en cada timestamp.

Verificado por mutacion: restaurar t: o.t0 + clock rompe 3 tests, incluido el
cross-check stateAtStep <-> seek.

Registra que la decision de promover en vez de duplicar fue la que mas rindio:
el bug vivia en la costura que la copia habria dejado sin coser, y aparecio
solo porque la version compartida obligo a un cross-check con t0 realista.
Ninguna lane lo encontraba sola — todas probaban con t0:0, donde absoluto y
relativo son el mismo numero.
<ChatSim script channel seed mode="demo"|"live" /> under src/components/chat-sim/react/**.
demo: playback-driven, visual-only composer (same DOM contract as <cf-chat-sim>). live: frozen
at the final step, a real operable composer (visualViewport + 100dvh + safe-area + >=44px tap
targets + >=16px input font-size) — closes the gap element/chat-sim-element.ts's own docstring
names as react/'s job.

Acceptance verified mechanically, not by inspection:
- #1 snapshot cruzado element-vs-react: recursive canonical DOM diff across whatsapp+telegram,
  10 (channel,step) cases + positive twin. Found+worked-around+flagged a real [skin] bug along
  the way: element/'s `channel` attribute never drives `#adapter` (rendering stays hard-defaulted
  to WhatsApp) — the settable `.adapter` property is the only wired path today.
- #2 visualViewport 375x(alto-336) mechanical test + twin (naive composer proves the assertions
  actually discriminate).
- #3 MOBILE-CHECKLIST.md: one row per reason MobileComposer.tsx (inbox-ai) itself declares for
  existing, verdicts checked mechanically against literal fingerprints. 4/7 covered — the other 3
  are real-messaging-composer capabilities (voice notes, async busy/stop, cross-package
  vocabulary parity) a demo/live simulator has no reason to replicate.

Mid-task: flagged stateAtStep/draftIntervals duplication (react/engine.ts vs element/'s private
copies) to team-lead; [core] promoted both to core/ before this landed — refactored engine.ts
onto the promoted exports instead of keeping a third copy.

31/31 tests green (npx vitest run src/components/chat-sim/react), tsc clean.

Claude-Session: https://claude.ai/code/session_01Ew9vYL29keQDCtmgEAFqG2
El operador: "en telegram no lo veo con background". Los valores de
telegram-fidelity-fix.md (stroke-opacity 0.03 claro, "sin patron" oscuro)
salian de esa misma investigacion marcada como NO VERIFICADA
("no lo pude verificar sin sesion"). La captura real del operador
muestra un wallpaper claramente visible en Telegram -- la spec no
verificada perdio contra el mundo.

- Claro: stroke-opacity 0.03 -> 0.12 (contrast ratio ~1.13, mismo
  orden que el fix de WhatsApp ~1.10).
- Oscuro: reemplaza `background-image: none` por la MISMA silueta
  re-inkeada claro-sobre-oscuro (stroke #7a95b0 @0.12, ratio ~1.18),
  mismo principio que T-020 aplico a WhatsApp.
- wallpaper-contrast.test.ts (T-020): extendido a Telegram con el
  mismo umbral WCAG (>=1.05). La asercion "Telegram-dark no debe
  tener imagen" se invierte -- el comentario documenta por que la
  vieja asercion estaba equivocada. Gemelo: revertir a 0.03 en
  cualquier tema rompe el check. Test nuevo: los background-image de
  Telegram y WhatsApp siguen siendo siluetas distintas (claro y
  oscuro).

9/9 tests en wallpaper-contrast.test.ts, 200/201 en chat-sim/** (el
unico rojo, capture/__tests__/session-lifecycle.test.ts, es Chrome
real headless -- confirmado pre-existente reproduciendo igual en
stash, fuera de scope.write). tsc/madge limpios (2 errores
pre-existentes de hero-shader, no relacionados). Render verificado
con agent-browser real sobre demo/telegram-vs-whatsapp.html.

Claude-Session: https://claude.ai/code/session_01MZJYpBuQY9Xb9vqN9awDo2
WhatsApp #005c4b -> #154d38 · Telegram #2b5278 -> #3e6aa7

Los anteriores salieron de night.tdesktop-theme (msgOutBg) y de la spec de
fidelidad. El operador los corrigio contra sus clientes reales.

Tercera vez en el ciclo que la verdad de campo del operador le gana a una
fuente documental: el doble tick de Telegram, el wallpaper visible, y ahora
los colores de oscuro. El patron es consistente — nuestras fuentes describen
defaults que los clientes reales no usan tal cual.

Bundles regenerados; su gate de frescura verde.
El audit separo las 13 sondas vacias en tres categorias que yo no distinguia:
6 verdes-bajo-mutacion (problemas reales), 3 estructuralmente inalcanzables, y
4 auto-validadores de bajo valor pero NO enganosos.

Y confirmo por mutacion que los 4 gates duros SI muerden. El problema era el
gate del wallpaper especificamente, no la disciplina.

Dos refinaciones suyas que corrigen mi lectura:
- session-lifecycle CUENTA en vez de IDENTIFICAR, asi que una sesion filtrada
  y otra cerrada se netean. Su rojo no informa en ninguna direccion — ni mi
  'flaky de entorno' ni 'hay fuga' eran defendibles. Comparar set de ids.
- role=log: el discriminador no es 'el dato es falso' sino 'una accion humana
  causo este mensaje'. En mode=live el visitante tipeo, asi que log es
  CORRECTO y se queda. En demo llegan solos y aria-live los narra sin que
  nadie los pida.

#18 es el mas serio de la cola: el invariante --cf-cs-pad = stamp.offsetWidth
+ 10 solo vive en comentarios. Cambiar la fuente o el padding de .cf-stamp
invalida los PNG byte-identicos EN SILENCIO, que es el criterio de exito #1.
…emplazadas por runtime real

§A: package.json exports le faltaban ./chat-sim/styles.css y ./chat-sim/element — Storybook
seguía verde porque las stories importan el CSS por ruta relativa, pero cualquier consumidor real
(require.resolve('@cofoundy/ui/chat-sim/...')) recibía ERR_PACKAGE_PATH_NOT_EXPORTED. Test nuevo
lee la lista de subpaths directo de package.json (no la duplica) y falla si alguno no resuelve —
borrar una entrada de exports rompe el test automáticamente.

§B: receipt-model.test.ts, exports.test.ts y chatsim-barrel-export.test.ts afirmaban contra
objetos que el propio test construía — un auditor mutó core/types.ts y 9 tests siguieron verdes.
Reescritos contra getAdapter()/validateScript() reales. La mutación de tipos puros es invisible en
runtime por diseño de TS (se borra al transpilar) — types-contract.typecheck.test.ts cierra ese
hueco corriendo tsc --noEmit dentro del suite y reproduce la mutación exacta del audit para probar
que sí enrojece (0 errores chat-sim antes, 157 después, verificado a mano).

§C: barrel nominaliza `export * from './core/types'` (ensanchaba en silencio), agrega
getAdapter/validateScript prometidos por api-contract.md, y retira `rand` (nadie lo consume vía el
barrel — todo el código real importa './core/prng' directo; exponerlo invitaría a depender del
stream posicional del PRNG).

No tocado: WIP sin commitear de otras lanes ya presente en este worktree compartido
(src/__tests__/chat-sim/**, sound/**, capture/**, react/__tests__/mobile-viewport.test.tsx,
styles.css) — fuera de scope.write de T-022, preservado tal cual se encontró.

Claude-Session: https://claude.ai/code/session_011LLWBdAuM5cBNFcmyxhNT4
…real

A: --cf-cs-bubble-out-meta (hora + ticks) nunca se redefinía en dark, quedaba
invisible (1.36:1 / 2.42:1) contra los colores de Telegram/WhatsApp que el
operador corrigió contra sus clientes reales. Redefinido por canal (#e9edef,
igual que --cf-cs-ink en dark) sin tocar el caso genérico/sin-adapter, que
sigue usando bubble-out claro.

B: el gemelo de wallpaper-contrast.test.ts (Telegram dark `background-image:
none`) afirmaba sobre un string local desconectado del sheet real — nunca
podía fallar. Reescrito para leer la regla real y mutarla; verificado que el
mismo auditor-regression ahora rompe el test (y 4 hermanos) en rojo.

C: cero `:focus-visible`/`:focus` en toda la familia. Agregado el piso CSS
(interactive elements bajo .cf-chat-sim) con var(--cf-cs-ink) — theme-adaptivo
por diseño en vez de --cf-cs-accent, que falla el 3:1 no-text en modo claro
(1.98:1). react/LiveComposer.tsx's `outline: none` inline queda fuera de
scope (`app`), flagueado, no arreglado acá.

D: `.cf-quote-author` y la inicial del avatar (default/WhatsApp) en 1.98:1 y
1.79:1 — nuevos tokens --cf-cs-accent-text / --cf-cs-avatar-ink, revertidos
a la barra/fondo del acceptance (quedan sin tocar). El avatar-ink es universal
y de paso arregla el avatar azul de Telegram (5.05:1) sin tocar su identidad
de marca.

Extendido wallpaper-contrast.test.ts (mismo instrumento, no uno nuevo) con
gemelo por caso para los 4 puntos. 23/23 verde.

Hallazgo NO resuelto acá (scope creep hacia la identidad Telegram): el
`.cf-quote-author` de Telegram (--channel-telegram / -out) también falla AA
en 3 de 4 combinaciones — filed como T-025.

Pre-existente, no tocado: demo/chat-sim.bundle.js sale STALE en bundle-
freshness.test.ts — verificado con git stash que falla igual sin este commit
(chat-sim-element.ts tiene WIP sin commitear de otra tarea en este worktree).

Claude-Session: https://claude.ai/code/session_01QuFUxet9SBTBz6mkcg17SK
…tante, a11y

A: chatsim-cross-channel-states.test.tsx reescrito contra el contrato vivo —
color del tick vía ReceiptModel en vez de counter:'views' (retirado, T-012
lo dejó broadcast-only) y [data-read] (retirado en la migración a
ReceiptModel).

B: dos sondas que no mordían, verificadas por mutación —
audio-sink.test.ts's caso degenerado ahora ejercita el AudioSink real (antes
solo tocaba un objeto plano); mobile-viewport.test.tsx's gemelo renderizaba
un NaiveComposer local que nunca entraba a useKeyboardInset — reescrito para
usar el LiveComposer/hook reales, vía la propia rama documentada del hook
para "sin Visual Viewport API".

C: session-lifecycle.test.ts comparaba longitud, así que una sesión filtrada
y otra cerrada se neteaban. Ahora compara el SET de ids, con backoff para el
cierre asíncrono.

D: story nueva para <cf-chat-sim> (el custom element), documentado antes
solo por demo/*.html — fuera de Storybook y Chromatic, sin cobertura visual.

E: role="log" (aria-live) narraba mensajes decorativos en demo/pre-render.
Discriminador correcto: ¿una acción humana causó el mensaje? live -> log,
demo y <cf-chat-sim> (sin mode="live") -> group. SVGs de recibo con
aria-hidden, <li> con aria-label por dirección (data-dir era invisible para
lectores de pantalla). Aplicado en paralelo a react/ y element/ para
mantener la paridad byte-a-byte que snapshot-cross-check.test.tsx exige;
bundles regenerados (comando documentado en bundle-freshness.test.ts).

F (#18, criterio de éxito #1 del ciclo): --cf-cs-pad = stamp.offsetWidth+10
vivía solo en comentarios. Test nuevo en react/ y element/ que fija el pad
calculado para un fixture conocido, con gemelo que cambia el ancho del
stamp y prueba que el valor cambia con él (no un hardcode).

Todas las reescrituras/nuevos tests verificados por mutación real contra el
código de producción (mutación aplicada -> rojo -> revertida -> verde).

Fuera de scope: src/__tests__/components/ChatInput.test.tsx falla
(pre-existente, commit 49ac52c le agregó aria-label al botón de enviar y
nunca actualizó el test) — no es de chat-sim/T-024, flageado para
seguimiento, no tocado.

Claude-Session: https://claude.ai/code/session_018CvAKnMCniqov1WcQu9ee9
…vice, card, branded

- Auto-scroll (A): #applyScroll reads core's scrollId (fold.ts, existed but unconsumed) —
  glide (rAF, 220ms cubic ease + 260ms catch-up) during playback, instant jump on any
  external/manual data-step write (seek/scrub).
- Loop (B): <cf-chat-sim loop loop-pause-ms="N"> restarts the SAME script on natural
  completion via a brand-new play()/createPlayhead() call — no DOM node recreated.
- service/card (C/D): generalizes the existing date-pill primitive into a `service`
  message (variant: neutral/warn/success) and adds a structured `card` (title+bullets+
  action), both riding the ordinary `post` event's `media: Json` field — zero core/
  changes. card's accent keys off adapter.quote (color-bar/thin-bar), not a channel
  branch.
- chrome="branded" (E): third ChromeMode, local to element/ (core's Chrome type
  untouched) — outbound palette follows consumer `--cf-cs-consumer-*` tokens, structure
  (tail/ticks/grouping) stays exactly the channel's.

Evidence: demo/parity-t027.html (WhatsApp branded+loop vs Telegram fidelity, same
script) + a branded row in demo/chrome-axis.html. 43 new tests across
autoscroll/loop/service-card + a branded gemelo in chat-sim-element.test.ts; full
chat-sim suite green (292 tests).

Flagged, not fixed here (outside element/**'s scope.write): WhatsApp's receipt tick at
'read' is a literal color baked into adapters/whatsapp.ts, unreachable from branded's
CSS tokens. Also found beyond the A-E list: `reply-in` and link-styled bubbles from
ChatDemo.astro — reported to team-lead, left out of scope pending a decision.

Claude-Session: https://claude.ai/code/session_01HNEFrFhwRdP96RCYW4X2Yv
… de su lista original)

Dos capacidades más de ChatDemo.astro, aditivas sobre lo ya shippeado:

- `reply-in` ("respondió en 4 s"): mismo slot/patrón que `editedLabel` — QUÉ mensaje lo
  muestra es autoría del guion (`media.replyFast`), el TEXTO es la prop del consumidor
  (`reply-label`, default "Respondió rápido"), nunca un literal en render.ts.
- `link`: `media.link` flaggea presentación (mono/underline/accent) en una burbuja
  NORMAL — el texto sigue siendo `msg.text` tal cual lo escribió el guion.

Ambos son flags ADITIVOS sobre `media` (asReplyFast/asLinkBubble), distintos de
`media.kind` (service/card, que reemplazan la burbuja entera) — pueden coexistir, y
service/card los ignoran por construcción (el short-circuit ocurre antes de tocar el
stamp). Cero cambios a core/types.ts, mismo patrón que el resto de T-027.

18 tests nuevos (asReplyFast/asLinkBubble, reply-in en ambos canales, coexistencia con
link, integración vía atributo reply-label). demo/parity-t027.html actualizado con
ambos en el guion real. Suite completa de chat-sim: 304/304 verde.

Claude-Session: https://claude.ai/code/session_01HNEFrFhwRdP96RCYW4X2Yv
Ports the Capability vocabulary from inbox-ai's channel_adapters/{capabilities,registry}.py
(model, not code): a per-channel CapabilitySet keyed by 'buttons'|'list', key presence =
supported (value is always null today — nothing to constrain yet). REACTION stays out of this
map — it already has its own reactions/reactionConstraint fields; folding it in would be a
second source of truth.

- whatsapp: buttons + list (each a native, distinct interactive message type, own chrome)
- telegram: buttons only (its inline keyboard — same capability, `adapter.keyboard` chrome
  difference, no channel branch); list deliberately absent — the Bot API has no separate
  list-message primitive, so declaring it would repeat T-028's own root cause one file over

`ChannelAdapterWithCapabilities extends ChannelAdapter` lives in adapters/caps.ts (not
core/types.ts — out of this task's scope.write) so getAdapter keeps returning the same
adapter object by reference. validateScript now rejects an unsupported interactive
`media.kind` with its positive twin, mirroring the existing delivery-state/reaction checks.

Known collision (flagged to team-lead, not forced): core/__tests__/exports.test.ts asserts
getAdapter('whatsapp') has exactly the 16 core ChannelAdapter fields — the new 17th
`capabilities` field breaks that count. Resolution is core's call (relax the test, or promote
`capabilities` into core/types.ts itself).

Claude-Session: https://claude.ai/code/session_01TNymqpjDp6hjBPSyQC1wRt
…sapp-read

The other 3 delivery states already use var(--cf-cs-bubble-out-meta), which resolves through
real CSSOM (icons.ts sets el.style.color, not an inert SVG attribute) — a branded chrome
consumer can already retint them. `read`'s bare #53bdeb literal couldn't. skin owns the
[data-chrome='branded'] override for the new var; this just stops read from being the one
state a brand can't reach. No type change — color stays a string.

Claude-Session: https://claude.ai/code/session_01TNymqpjDp6hjBPSyQC1wRt
…dapter

channel (T-028) paró antes de forzar: agregar `capabilities` al objeto de getAdapter
rompía exports.test.ts, que afirma exactamente 16 campos. Se elige crecer el contrato
en vez de relajar el guard — capabilities describe qué puede hacer el canal, misma
categoría que los otros 16. REACTION queda afuera a propósito (ya tiene sus campos
reactions/reactionConstraint). El shim ChannelAdapterWithCapabilities de adapters/caps.ts
deja de hacer falta (fuera de scope.write de esta tarea, no se toca).

exports.test.ts ahora afirma exactamente 17 (sigue siendo igualdad) + gemelo
@ts-expect-error probando que un adapter sin capabilities no compila.

tsc y madge limpios en core/**. Rompe deliberadamente element/fixtures.ts (skin,
avisada) y 2 tests en react/** (T-030) — consumidores ya identificados por grep
antes de este cambio, no después.

Claude-Session: https://claude.ai/code/session_01J6tNHtTSQpmzix1eF8Ljs9
…eipt steps reales en demo/**

Dos correcciones que reportó el operador vía team-lead:

- **B — card retirado.** La rationalización de que las tarjetas fijas de contacto/
  ubicación de WhatsApp hacían de un contenedor libre título+bullets+acción "la misma
  familia" era falsa — son estructuras FIJAS, no un contenedor genérico. Borrados
  `CardMedia`/`asCardMedia`/`populateCardElement` (render.ts) y todo `.cf-card*`
  (styles.css). El brief real de WhatsApp es texto plano con markdown (negrita + bullets
  vía `\n`), que `.cf-text` (white-space: pre-line) ya renderiza — cero componente
  nuevo. `service` queda (aviso de cifrado / divisor de no-leídos son reales).
  `service-card.test.ts` → `service-message.test.ts`, tests de card removidos +
  regresión (`card`-shaped media ya no dispara nada especial).

- **A — receipt steps reales en demo/parity-t027.html.** 0 pasos `receipt` antes: todo
  mensaje quedaba en `queued` (reloj) para siempre — el status del engine, invisible.
  Ahora WhatsApp corre `sent → delivered → read` (el COLOR flipea al final) y Telegram
  el mismo guion pero `sent → read` (el GLIFO flipea, sin `delivered`) — la diferencia
  entre canales se ve sola, cero rama por canal en el código.

De paso: `--channel-whatsapp-read` (adapters/whatsapp.ts, commit de [channel]) ya
resuelve vía CSSOM real — agregada su redefinición bajo `[data-chrome='branded']`
(mismo patrón var(fallback, ...) que el resto). Y `element/fixtures.ts` actualizado
para el 17º campo `capabilities` que T-029 (core) agregó a `ChannelAdapter` — mirror
del valor real de adapters/whatsapp.ts, avisado en su propio commit.

41 archivos / 260 tests verdes (chat-sim, excluyendo capture/determinism +
session-lifecycle — contención real de Chrome headless en este worktree compartido,
no relacionado a este diff). Flagueado a team-lead, no arreglado (fuera de scope.write):
src/__tests__/chat-sim/chatsim-cross-channel-states.test.tsx (qa) rompió por el commit
de [channel] del tick 'read' — nadie actualizó esa aserción.

Claude-Session: https://claude.ai/code/session_01HNEFrFhwRdP96RCYW4X2Yv
… dedupe against T-029

core landed capabilities as ChannelAdapter's real 17th field (517bd2f), with the exact same
Capability/CapabilitySet shape this task already used. Removes the redundant shim:

- caps.ts: Capability/CapabilitySet now imported from core/types.ts instead of redeclared
  locally — two identical definitions that could drift apart is the same problem this task
  exists to prevent, just moved one file over. Keeps hasCapability, the only piece adapters/**
  actually owns.
- whatsapp.ts / telegram.ts / registry.ts: back to plain ChannelAdapter (capabilities is part
  of the interface now, no extends needed).

Zero test edits — all 36 adapters/** tests and core/**'s own 17-field exports.test.ts pass
unmodified against the source-only change. tsc --noEmit clean under chat-sim/.

Claude-Session: https://claude.ai/code/session_01TNymqpjDp6hjBPSyQC1wRt
…ncontro el operador

Registra el dato mas importante del ciclo: los 8 defectos reales los encontro
el operador mirando la pantalla, ninguno lo cazo un test. Nuestros 9 gates
verifican consistencia interna — que el renderer obedezca al adapter — y
ninguno verifica que el adapter tenga RAZON. Por eso T-014, el baseline contra
referencia externa, es la tarea mas importante que quedo sin hacer.

Y registra las 4 fallas de metodo del CTO con su recurrencia: 5 cambios de
contrato sin asignar consumidores, 2 rondas de agentes vivos tras entregar, 6
sondas propias que no median, 3 verificaciones contra el documento en vez del
codigo. Todas por ejecucion, no por diseno: las reglas estaban escritas.

Mas el limite conocido de la regla del grep: ve simbolos, no valores.
…suelto

COMMITEADO POR EL CTO EN CELDA AJENA, con disclosure: la lane qa cerro antes
de commitear su propio arreglo, y dejarlo sin commitear lo perdia. El trabajo
es suyo, verificado 7/7 por mi antes de commitear.

El test exigia rgb(83, 189, 235). Desde e65f069 el color es
var(--channel-whatsapp-read, #53bdeb) para que un consumidor con chrome
branded pueda retintarlo. jsdom guarda ese string tal cual y NUNCA resuelve
var(); un browser real si (verificado en Chrome).

qa eligio un tercer camino sobre los dos que le propuse, con mejor argumento:
resolver desde el CSS no probaria el componente, reimplementaria el motor de
cascada y quedaria fragil a refactors de tokens sin relacion con el bug.
Afirma lo que SI es inspeccionable sin browser — que rutea por el slot de
override de marca y que el fallback preserva el azul stock — y el gemelo que
distingue delivered de read sobrevive porque son strings crudos distintos.

Esta clase de rotura no la caza la regla del grep -rl del CTO: busca simbolos
y este test afirma un VALOR. Hace falta una segunda herramienta, de estilo de
test, no una extension de la primera.
…quote Telegram

Parte A: fidelity-baseline.test.ts, el instrumento que faltó todo el ciclo (T-014).
Cada assert cita la fila exacta de telegram-fidelity-fix.md que justifica el valor
esperado y lee los adapters REALES, nunca un fixture re-tipeado — distinto en clase
de receipt-model.test.ts (consistencia interna) y de todo lo demás: verifica que el
adapter TENGA RAZÓN, no que el renderer lo obedezca. Gemelo obligatorio verificado
en vivo mutando adapters/telegram.ts (revertido después, git diff limpio): 1/11
rojo exactamente en el assert que exige el glifo variable. Las 3 "no verificadas"
de la spec quedan como drift-detector, nunca rellenadas con un valor inventado.

Parte B: telegram-quote-contrast.test.ts (T-025). T-025 (skin) ya tiene esta misma
tabla con scope.write sobre styles.css + wallpaper-contrast.test.ts — ninguno está
en mi scope.write y file-ownership-matrix.md fija styles.css a un solo writer.
Instrumento equivalente en mi propia celda, leyendo el styles.css real: confirma
3/4 combinaciones siguen fallando AA. Propone valores seguros para 2 (IN/OUT tema
claro, mismo hue, sin tocar avatar/reply-bar) y ESCALA el tercero (OUT tema oscuro)
en vez de forzarlo: el bubble oscuro de Telegram es azul, no verde oscurecido, y
ningún oscurecimiento del mismo hue llega a AA sin perder la identidad de marca —
decisión de marca, no arreglo mecánico, per instrucción explícita del team-lead.

Claude-Session: https://claude.ai/code/session_01LEtJxntscfeNwf83LGe3bC
…ueta de rubro

Operator report: el panel derecho crecía/encogía con el contenido en vez de tener
un marco de alto fijo como producción (ChatDemo.astro, 440px).

- A: `.cf-chat-sim` se separa en un `.cf-frame` interior que carga
  `--cf-cs-height` (default 440px), configurable vía el atributo `height`.
  El hilo sigue anclado abajo (T-002); alto igual sin importar el largo del
  guion (gemelo verificado por test + captura).
- B: `<cf-chat-sim>` acepta N `<script type="application/json">` (rotación
  entre rubros); `script=` sigue siendo el fallback de un guion. `loop`
  encadena al siguiente y vuelve al primero al terminar el último. Cada
  slide se pre-renderiza una sola vez — rotar nunca recrea nodos. N=1 es
  no-op total sobre el comportamiento anterior.
- C: badge de estado opt-in (`badge-flag`/`badge-on-label`/`badge-off-label`)
  consumiendo el evento `flag` que `core` ya emitía — cero lógica nueva.
- D: pill de rubro opt-in (`tag-icon`/`tag-label`, override per-slide vía
  `data-tag-*` en el `<script>`) — cromo del consumidor, cero vocabulario
  de Fovente en el renderer.

element/__tests__/t031.test.ts: 16 tests nuevos. Suite de element/: 106/106.
demo/rotation-badge-tag.html: evidencia visual (verificada con agent-browser,
capturas en .cofoundy/state/reports/renders/). Los 4 demos existentes ya no
hardcodean su propio alto de página.

Flag: capture/capture.bundle.js quedó stale (bundlea element/index.ts
transitivamente, celda W de [capture]) — filé T-033.md en vez de tocarlo
fuera de scope.

Claude-Session: https://claude.ai/code/session_0166fUq8QhUjL3GnFbSzJJP5
ESCRITO POR EL CTO EN CELDA AJENA con disclosure: capture/ ya cerro y el
rebuild es mecanico (esbuild, sin decisiones). Lo filo skin al notar que
capture.bundle.js bundlea element/index.ts transitivamente igual que
demo/chat-sim.bundle.js — mismo acoplamiento que _cto-index.md documento en la
ola 1, ahora del lado de capture.

Los dos gates de frescura verdes.
…ht + chrome-gated dark)

Team-lead decision (T-031 follow-up, 2026-09-07): the two brand-preserving cases
(IN light -> #2979a7, OUT light -> #417f3a) apply unconditionally. OUT in dark
mode (#3e6aa7, a blue bubble, not a darkened green) has no same-hue fix that
clears AA — qa/T-032 verified even the darkest reasonable green stays under
4.5:1. Resolved via the chrome axis: `fidelity` reproduces Telegram as it
really is (failing AA like the real app — we're portraying another app, nobody
reads real content there); `consistent`/`branded` is Fovente's own app with
real people reading real content, so it reuses `--cf-cs-bubble-out-meta`
(already #e9edef for this exact bubble, T-023 A) instead of a new literal.

New dedicated tokens (--channel-telegram-quote-text / -out-quote-text) keep the
avatar fill, composer-send, and reply-bar border on the real brand accent
(T-023's "pueden quedar") — only the author TEXT color changes.

element/__tests__/wallpaper-contrast.test.ts: 8 new tests (T-025's own
scope.write) covering all 4 combinations + both chrome branches + the
untouched-decorative-uses check + gemelo. Full chat-sim suite: 288/288.

Claude-Session: https://claude.ai/code/session_0166fUq8QhUjL3GnFbSzJJP5
`.cf-quote-author` ahora rutea por los tokens dedicados `--channel-telegram-quote-text` /
`--channel-telegram-out-quote-text` (7e64b90). Los dos asserts pineaban el cableado viejo, que
era exactamente su trabajo: probar que el fallo de AA existía. Con el fix adentro se invierten a
regresión y pinean el cableado nuevo. La intención anti-literal del archivo no cambia — solo el
nombre del token que esperan.

Gemelo: inlineando `#2979a7` en vez del token, el assert de `dir=in` se pone rojo (9/10);
restaurado, 10/10. La sonda distingue token de literal, que es lo que su comentario prometía.

Celda reasignada. `src/__tests__/chat-sim/**` era W de `qa`, que ya había terminado. La reasigna el
orquestador — una lane no puede autorizar a otra. Se intentó delegarla a `skin8` primero; dos
mensajes no llegaron a su contexto antes de que cerrara, así que se resolvió por
`orchestrator-resolves-and-discloses` (/cto Fase 7), acotada a los dos asserts que el propio
comentario del archivo anticipaba. Queda escrito en file-ownership-matrix.md.

Suite chat-sim: 356/356.
…ceptance

team-lead's finding: the jsdom test (t031.test.ts) only asserts --cf-cs-height
gets SET as a custom property -- jsdom does no layout, so mutating the
default to `auto` left all 16 assertions green. Adds a real-browser
instrument (agent-browser + a real headless Chrome, reusing capture/**'s own
harness -- openCaptureSession/closeCaptureSession are captureFrame.ts's own
public batching contract, skin only imports them (R), never edits capture/**)
measuring `.cf-frame`'s actual getBoundingClientRect().height:

- two scripts of very different length render the SAME real height (the
  acceptance itself, finally measured for real)
- the default matches styles.css's 440px token
- the `height` attribute override is honored end-to-end
- gemelo negativo: with height:auto (the pre-T-031 shape) the two scripts DO
  render different heights -- proves the instrument can go red

4/4 passing against a real browser.

Claude-Session: https://claude.ai/code/session_0166fUq8QhUjL3GnFbSzJJP5
…avanza

El gemelo real-browser que ya existía compara estados FINALES: dos guiones
revelados por completo. Una caja que crece de step 0 a step N y aterriza en el
mismo alto final para ambos guiones pasa todas esas aserciones y aun así
entrega el bug tal como lo reportó el operador — la proporción moviéndose
durante la reproducción.

Agrega un walk que avanza UN MISMO elemento por toda su timeline y mide
`.cf-frame` en cada `data-step`. Avanzar el mismo elemento (en vez de
remontarlo en cada paso) es el punto: remontar responde "cómo se ve la caja si
la construyo en el step N", que es otra pregunta.

El rango del walk lo da el propio componente — montado sin `data-step`, el
elemento siembra `dataset.step` con su total de frames — para que un rango
hardcodeado no deje de cubrir los pasos interesantes tras editar el guion.

Gemelo negativo permanente, en la suite y no como ritual manual: el mismo walk
con `height: auto` afirma que la lista de muestras a la deriva NO está vacía —
que es exactamente la lista que el test positivo afirma vacía. Así el verde es
evidencia y no supuesto, y se re-demuestra en cada corrida.

Gemelo demostrado (mutante = el default a `auto`): rojo nombrando el paso
exacto, 25/26 muestras a la deriva, 187→232→277→322→367→413px. Restaurado
byte-idéntico, verde.

Gate: `vitest run src/__tests__/chat-sim/ src/components/chat-sim/` → 362
tests, 51 files. El único rojo es `capture/__tests__/session-lifecycle.test.ts`,
que falla idéntico SIN este cambio (control corrido a HEAD: 360, mismo fallo):
afirma un conteo global de sesiones de agent-browser y no es concurrency-safe
cuando otro archivo mantiene una sesión abierta en un worker paralelo.

Claude-Session: https://claude.ai/code/session_013pjgYihRi7LCmx9bm5UfNi
…que no medían nada

Actualiza el estado final (83 commits, 23.4k líneas) y reconcilia el baseline
externo, que figuraba como pendiente y se hizo en T-032/819490f.

Suma la sección de cierre: los dos defectos del operador con su sonda en
navegador real, la regla del gemelo permanente (un ritual manual no se repite;
el gemelo vive dentro de la suite), los 3 gates rotos fileados como
#23-26, y las 2 fallas de método de esta mitad.
Los tres artefactos que faltaban para poder abrir chat-sim open source. Nada
de código: esto prepara la decisión, no la ejecuta.

- `src/components/chat-sim/README.md` — el documento para alguien de afuera.
  Qué es, por qué el timeline es un fold y no un append (los canales mutan
  mensajes ya posteados: edit/delete/pin/react), por qué el PRNG es posicional
  (editar el paso 3 no mueve el jitter del paso 40), cómo se agrega un canal
  por el adapter, y la garantía de determinismo con el comando que la demuestra.
- Prior art (D-1 del ciclo) — sección propia en el README. Se leyó
  `rrortega/whatsimule` (MIT, confirmado por la API de GitHub); cero copia
  literal, así que la cláusula de atribución de MIT nunca aplicó: el crédito es
  voluntario. Dice qué NO se tomó (coreografía del contact-picker, el anillo
  10→30→60→85→100, las tablas de cadencia, los 5 contactos ficticios) y en qué
  difiere el diseño.
- `LICENSE` — MIT, Cofoundy SAC, 2026. `package.json` declara `"license": "MIT"`.

Cada snippet del README está verificado contra los exports reales, no contra
`types.ts` leído: un arnés en tsx ejercitó compile/seek/stateAtStep/getAdapter/
validateScript/draftIntervals/digestOf/createPlayhead/initialState/applyEvent
por el barrel público, y los valores que el README imprime son los que salieron.
Cada comando citado se corrió acá y salió verde:

  npx vitest run src/components/chat-sim/core src/components/chat-sim/adapters   105/105
  npx vitest run src/components/chat-sim/capture/__tests__/determinism.test.ts      2/2
  node src/components/chat-sim/element/scripts/assert-no-react.mjs               exit 0

Colateral medido: el item `UNVERIFIED` #1 del reporte de ciclo (que la suite de
captura pase de punta a punta acá) ya pasa — `src/components/chat-sim/capture`
completo da 9/9 exit 0, session-lifecycle incluido, corrido solo.

Claude-Session: https://claude.ai/code/session_01VC4daXoYkSHexiv9zgw7bx
@A-PachecoT

Copy link
Copy Markdown
Contributor Author

Cuatro huecos de react/**, encontrados consumiéndolo desde la landing de Cofoundy

Contexto: cofoundy/landing-page-v2#5 monta <ChatSim> en Next 16 + React 19 (la landing de Fovente usó el custom element, así que este camino no lo había ejercitado nadie). Los cuatro se suplen hoy en el consumidor, lo cual es exactamente la señal de que faltan acá.

Encaja con el patrón que el propio reporte del ciclo documenta: "los 5 bugs serios vivieron en costuras entre lanes; en los cinco, el que encontró no era el dueño del código". Estos cuatro los encontró un sexto consumidor.

1. La raíz de <ChatSim> nunca escribe data-channel — falla ABIERTA

element/chat-sim-element.ts:259 lo pone y su comentario dice por qué: "data-channel is the one attribute styles.css keys those off". react/ChatSim.tsx escribe data-wallpaper, role y data-mode, y no data-channel (cero ocurrencias en todo react/).

Consecuencia: toda regla .cf-chat-sim[data-channel='whatsapp'] … / [data-channel='telegram'] … no matchea. El componente renderiza igual, sólo que con el chrome equivocado — no hay error, no hay warning. Verificado en navegador: sin el atributo la burbuja saliente no resuelve a #154D38; poniéndolo a mano, sí.

Es el mismo tipo de defecto que channel="telegram" renderizando chrome de WhatsApp, que ya se cazó una vez en este ciclo.

2. No hay forma de setear el tema

styles.css keyea [data-theme='dark'] sobre la raíz (~15 reglas de tokens), y ChatSimProps no tiene prop de tema ni pasa props sueltas al div. El element lo recibe como atributo. Hoy el consumidor lo setea con un useLayoutEffect + querySelector, que es precisamente lo que una prop evitaría.

3. react/ no renderiza .cf-frame

El marco de alto fijo (T-031 A: "el ratio del celular mocked tiene que ser fixed") sale de .cf-frame { height: var(--cf-cs-height) }. ChatSim.tsx renderiza .cf-chat-sim > MessageThread + composerno hay .cf-frame, así que --cf-cs-height no lo lee nadie y el alto queda a lo que dé el contenido. La decisión medida del ciclo simplemente no existe en este camino.

4. react/ no hace auto-scroll — los últimos mensajes no se ven nunca

.cf-anchor-top { margin-top: auto } cubre el sub-llenado. Para el over-llenado no hay nada: el único useLayoutEffect de MessageThread.tsx es el del padding del stamp.

Medido en navegador sobre un build de producción: .cf-log se queda en scrollTop = 0 con 102px de contenido por debajo del fold — los dos últimos mensajes de cada pasada son invisibles. 20 de 20 muestras con overflow quedaban fuera del fondo; anclando con un MutationObserver desde el consumidor, 0 de 40.

Es el defecto "no hay auto-scroll ni loop — core exponía scrollId y ningún renderer lo consumía" del reporte, todavía vivo en react/.


Menor, pero vale anotarlo

react/ tampoco consume media: asServiceMedia / asReplyFast / asLinkBubble están exportadas en element/render.ts y nadie las importa desde React. Tampoco hay badge ni .cf-tag. No estoy pidiendo que se implementen — pero hoy un guion con media.kind:'service' compila, valida y no renderiza nada, que es la misma clase de falla silenciosa que #1. Si se queda así, que lo diga el tipo o un Diagnostic, no el navegador.

Lo que sí verifiqué antes de afirmar

Nada de esto sale de leer types.ts. Cada punto se midió renderizando en un navegador real y leyendo el DOM/getComputedStyle/getBoundingClientRect, sobre npm run build && npm start (el dev server sirvió chunks viejos un buen rato y el fix del punto 4 se leía como que no funcionaba — el build de producción fue lo que dio la señal real). Evidencia y números completos en cofoundy/landing-page-v2#5.

`#buildHead` leía `contact-name`/`contact-status` UNA vez en `connectedCallback`, así
que el header conservaba un solo contacto mientras el guion y la etiqueta rotaban
debajo: la misma persona vendiendo catering, después con un restaurante, después con
una inmobiliaria. `ChatDemo.astro` (producción) rota `contact.name`/`contact.meta` por
rubro, así que era una regresión bloqueante para reemplazarlo en el hero de la landing.

`#applyContactForActiveSlide` copia la forma que `#applyTagForActiveSlide` ya usaba:
el `<script data-contact-name/-status>` del slide activo gana, el atributo del host
queda de fallback para el caso de guion único. Se aplica también al slide 0 en el
mount — si no, la primera pasada mostraría el contacto del host y las siguientes el
del slide 0, una diferencia que sólo aparece cuando el loop vuelve.

La inicial del avatar se deriva del nombre rotado. Rotar el nombre dejando la inicial
quieta —una "M" sobre "Sazón de Barranco"— se lee peor que no rotar.

El `<em>` del estado se crea si lo declara el host O CUALQUIER slide, leído de los
`<script>` capturados ANTES de que `this.textContent = ''` borre el DOM ligero. No es
incondicional a propósito: `react/__tests__/snapshot-cross-check.test.tsx` compara el
DOM del element contra el de React nodo por nodo, y React no emite `<em>` sin estado.
Un `<em>` vacío siempre presente rompía los 7 casos de whatsapp de ese cross-check
—telegram pasaba, porque su fixture sí trae estado, que es justo por qué una corrida
parcial lo habría ocultado.

Gemelo dentro de la suite (5 tests): sacando el applier de la rotación, 2 se ponen
rojos; restaurado, 5/5. Más el gemelo permanente que afirma lo contrario sobre la
misma medición para el caso sin datos por slide.

Bundles regenerados; los 3 gates de freshness verdes.
…30)

La etiqueta salía de `postedAt` —el tick de ANIMACIÓN— así que a la cadencia de un
hero (~1s por paso) los catorce pasos de una conversación imprimían el mismo minuto
catorce veces. `ChatDemo.astro`, el hero de producción que esto reemplaza, muestra
20:14 → 20:15 → 20:17 → 20:19. Ese lapso es la evidencia de que pasó tiempo, que es
sobre lo que se apoya el "respondió en 4 s": es contenido, no decoración. Sin esto,
reemplazar el hero era una regresión en el argumento de venta de la página.

`at` es un campo OPCIONAL del paso `post`, string verbatim del guion, que viaja por el
fold hasta `MsgState` y gana sobre la etiqueta derivada. Ausente, nada cambia.

Nunca una lectura de reloj: `core/` prohíbe `Date`/`Date.now` (invariantes 4 y 5)
justamente para que el mismo seed dé PNG byte-idénticos, y una etiqueta tomada de la
hora de pared rompería eso en la primera captura. Hay un test que lo fija: el mismo
guion montado dos veces da las mismas etiquetas.

Consumidores revisados ANTES de escribir, por una vez: `core/fold.ts` construye el
`MsgState`; `element/` y `react/MessageThread` pintan la etiqueta y se tocaron LOS DOS
en el mismo commit — `snapshot-cross-check` compara ambos DOM nodo por nodo, y
honrarlo de un solo lado habría roto sus 7 casos de whatsapp, que es exactamente el
error que cometí una hora antes con un `<em>`. `react/ChatSim.tsx:105` queda como está:
es el camino en vivo, donde la hora sí es el reloj real.

Gemelo dentro de la suite: dos guiones idénticos salvo el `at` dan etiquetas distintas.
Mutando el override, 2 de 4 se ponen rojos; restaurado, 120/120 con el cross-check.

Suite sin browser: 362/362, exit 0. Bundles regenerados.
@A-PachecoT
A-PachecoT merged commit 443a430 into main Sep 7, 2026
1 of 2 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.

1 participant