diff --git a/.cofoundy/README.md b/.cofoundy/archive/2026-05-16-atelier-components/README.md similarity index 100% rename from .cofoundy/README.md rename to .cofoundy/archive/2026-05-16-atelier-components/README.md diff --git a/.cofoundy/archive/2026-05-16-atelier-components/brief.yaml b/.cofoundy/archive/2026-05-16-atelier-components/brief.yaml new file mode 100644 index 0000000..3410ca1 --- /dev/null +++ b/.cofoundy/archive/2026-05-16-atelier-components/brief.yaml @@ -0,0 +1,170 @@ +schema_version: 1.0 +created_at: 2026-05-16T11:18:00-05:00 +created_by: cto-loop (Andre Pacheco, Atelier-components session) +domain: software +name: atelier-components-xgodel-dogfood +title: "Atelier components — XGodel v1 dogfood (riding V2.6)" + +owner: + name: André Pacheco + tier: Partner + role: CEO + CTO of this cycle + +parallel_cto: + scope: V2.6 Chrome System Phase 1 + repo: ~/cofoundy/products/cofoundy-platform/docs-ai/ + branch: feat/v2.6-chrome-system + contract: file-ownership-matrix + serialized merge order (chrome PR first, atelier PR second) + session_kind: paralelo (otra sesión Claude fresh, mismo Andre) + handoff_file: /tmp/handoff-v2.6-chrome-cto-handoff-20260516-111230.md + coordination_collision_points: + - docs-ai/mdx-components.tsx (serializado: their PR first, mine additive single-line later) + - docs-ai/content/client/xgodel/*.mdx (naming-disjoint: they own propuesta.mdx stub, + I own personas/sitemap/moodboard/cotizacion/cronograma) + +scope: + done_definition: | + XGodel client portal renderea /client/xgodel/{propuesta, cotizacion, cronograma, + personas, brand-moodboard} con Deliverable chrome (CTO #2 provee) + componentes + Atelier desde @cofoundy/ui/docs/. Cada componente exportado con Zod prop schema + + Storybook story + entry en ATELIER_COMPONENTS registry. AGENTS.md auto-generado + desde registry build script. PR mergea a packages/ui main; follow-up PR a docs-ai + main agrega single import + spread en mdx-components.tsx. + + out_of_scope: + - ContractTimeline (XGodel sin contrato firmado, excluded per Andre 2026-05-16) + - UserFlow, MockupShowcase, DeployRecord, PersonaGrid, TimelineGantt, + FaqAccordion, HandoffChecklist, BeforeAfterSlider (defer al segundo cliente) + - 10 hard-floor gates production (parten con artifact-render skill, Atelier M1) + - artifact-render skill itself (XGodel v1 = MDX manual authoring) + - Promotion a otros design-system targets (focus en @cofoundy/ui/docs/) + + mvp_scope: + components_new: + - { name: Sitemap, source: ~/cofoundy/projects/xgodel-landing/research/07-information-architecture.md, atelier_spec: "Tree/graph de páginas con depth, intent, nav grouping" } + - { name: QuoteCard, source: ~/cofoundy/deals/clients/XGodel/propuesta.html, atelier_spec: "Cotización pretty-render con hitos table, totals, payment terms" } + components_audit_and_patch: + - { name: PersonaCard, source: ~/cofoundy/deals/clients/XGodel/ux-research/02-user-personas.md, gap: "props existentes no incluyen jtbd/objections/journey_stage del spec Atelier §6.2" } + - { name: MoodBoard, source: ~/cofoundy/deals/clients/XGodel/visual-design/concept-*, gap: "verificar fit con concepts A/B/C/D + final" } + - { name: BuildProgress, source: ~/cofoundy/deals/clients/XGodel/cronograma.tex + Vikunja delivery_project_id=22, gap: "verificar phase model L0-L9 vs phase actual de XGodel" } + - { name: ComparisonMatrix, source: ~/cofoundy/deals/clients/XGodel/ux-research/competitive-research, gap: "audit shape" } + - { name: KPIBoard, source: ~/cofoundy/projects/xgodel-landing/research/16-cro-plan.md, gap: "audit shape" } + - { name: DesignSystemPanel, source: ~/cofoundy/deals/clients/XGodel/work/brand-paletas-v2/, gap: "audit token-display shape" } + - { name: TestimonialCard, source: deferred (XGodel sin testimonials públicos todavía), gap: "audit y skip render para XGodel v1" } + infra: + - lib/atelier-registry.ts (TypeScript Record) + - scripts/gen-atelier-agents-md.ts (build script → AGENTS.md auto-export del registry) + - Zod schemas por componente (props validation runtime + DX) + - Storybook stories nuevas (Sitemap, QuoteCard) + audit-update de las 7 existentes + +discovery: + components_existing_in_repo: + location: packages/ui/src/components/docs/ + list: + - AuthorNote, BuildProgress, ComparisonMatrix, DesignSystemPanel, + InfoBox, KPIBoard, MetadataCard, MoodBoard, NextStepCallout, + PersonaCard, ScopeList, TestimonialCard + finding: | + 7 de 12 components Atelier §6.2 YA EXISTEN. AuthorNote/InfoBox/ + MetadataCard/NextStepCallout/ScopeList son del mail/email skill, + útiles tangencialmente (recipient-block alternativo). + + external_research_referenced: + - ~/cofoundy/products/atelier/research/track-a/R-A7-portal-documentation-ux-2026.md + - ~/cofoundy/products/atelier/research/track-a/R-A8-md-component-compiler-patterns.md + - ~/cofoundy/products/atelier/PRD.md §6.2 + §7 (artifact-render) + - ~/cofoundy/products/cofoundy-platform/docs-ai/PRD.md §V2.1 ("Atelier IS DocsAI" decision) + +architecture_decisions_locked: + d1_location: | + packages/ui/src/components/docs/ (NO docs-ai/components/atelier/). + DocsAI PRD V2.1 decisión 2026-05-13 "Atelier IS DocsAI" override al + Atelier PRD §6.2 "first pass docs-ai, promote later". Ventajas: + cero substrate collision con CTO #2, Storybook auto, reusable en + landing-pages futuros. + + d2_no_gates_in_this_cycle: | + 10 hard-floor gates DIFERIDOS a artifact-render skill (Atelier M1). + Razón: gates protegen agent-emitted content. Sin artifact-render, + autoría MDX manual por humano = revisión humana ES el gate. Zod + prop schemas SÍ se incluyen (cheap, valor inmediato). + + d3_no_atelier_subfolder: | + NO crear packages/ui/src/components/atelier/. Los Atelier-domain + components viven en docs/ junto a los existentes (consistencia con + estado actual + AuthorNote/MetadataCard etc. son tangencialmente + Atelier-style). + + d4_audit_before_patch: | + Antes de tocar los 7 existentes, audit cada uno contra Atelier §6.2 + spec. Output: matriz por componente {props_actuales, props_spec, + delta, plan}. Solo después patch. + +interrogation: + q1_done_in_90d: | + XGodel portal renderiza /client/xgodel/{5 docs} con Deliverable chrome + + componentes Atelier funcionando. Andre comparte URL con John + Medina, John responde con engagement (no solo "ok"). + + q2_real_user: | + Cliente final visible: John Medina (XGodel cofundador). Operador + inmediato: Andre dogfoodea, Percy review visual. Consumidor de mi + registry: CTO #2 (chrome) + futuro artifact-render skill. + + q3_painful_workflow: | + XGodel tiene 13 ux-research docs + 17 research files + brand v2 + + cotización + cronograma — todo invisible al cliente. Hoy ve solo PDF + propuesta + WhatsApp + URL landing. Cero portal. Resto del trabajo + de profundidad estratégica = invisible. + + q4_already_tried: | + docs-ai V2.1 shipped 32 componentes genéricos (StatCard, BarChart, + etc.). Sirven para reports internos, NO específicos para portal de + cliente con personas/IA/moodboard. 7 ya existen en packages/ui pero + sin Zod schemas + sin registry + sin uso en docs-ai todavía. + + q5_failure_risk: | + Drift entre Atelier PRD §6.2 spec y componentes existentes (props no + calzan con shape real de ux-research artifacts) → component existe + pero unusable. Mitigación: D4 (audit antes de patch). + + q6_reframe_check: | + Reframe alternativa: "saltarse Atelier components y usar solo V2.1 + generic primitives (Callout + MetadataCard + table) para XGodel". + Rechazado: undersella portal, no diferencia vs Mintlify, no resuelve + central commercial gap del Atelier PRD §1. + +constraints: + timeline: "5-7 días wall-clock (audit + 2 new + registry + AGENTS.md + storybook)" + iron_rule: | + Andre es CTO, no IC. Dispatch via Agent tool / TeamCreate. + IC exceptions únicas: Phase 1 substrate (este file), Phase 10 docs/bitácora. + serialization_with_cto2: | + Mi PR a docs-ai/mdx-components.tsx (single import+spread line) merge + DESPUÉS del PR de CTO #2 (chrome). Mi PR a packages/ui main puede + merge cualquier momento (sin dependencias). + +success_signals: + - 9 components exportados desde @cofoundy/ui/docs/ con Zod schemas (2 new + 7 audited) + - lib/atelier-registry.ts existe como SSOT registry + - AGENTS.md auto-generado desde build script (verificable: rm AGENTS.md + run script + git diff vacío) + - 2 Storybook stories nuevas + 7 audit-updated + - **Coverage ≥80%** sobre src/components/docs/ + src/lib/atelier-registry.ts (Vitest --coverage report) + - **QA visual:** Chrome MCP screenshot diff por componente storybook ≤2% pixel delta vs baseline + - XGodel MDX v1 (5 docs) renderea sin warnings/errors en dev + prod + - **Deploy:** packages/ui PR mergea a main + docs-ai PR mergea a main + Cloudflare Worker auto-deploya + /canary-monitor verde por ventana de 30 min post-deploy + - **Post-deploy validation:** Lighthouse ≥90 sobre /client/xgodel/* (5 docs), axe-core a11y clean, smoke test E2E para los 5 URLs + - Andre share URL con John Medina dentro 48h post-merge + John responde engagement + - cycle_completed_at registered en cto-loop.yaml + +user_authorization_2026-05-16: + source: "/goal directive from Andre, 11:50 Lima" + text: "do all including deployment with proper validation (software, ceo, qa visual, etc, validate on deployment too); >80 coverage" + implications: + - Phase 4-12 ALL autonomous; no per-phase check-ins required + - Phase 11 main-branch deploy auto-approved (override default escalation_threshold) + - Phase 9 QA gate uses qa-driven-iteration + Chrome MCP visual diff + - Coverage ≥80% is HARD constraint (task acceptance fails if below) + - ceo-agent gates Phase 5 (task graph) + Phase 11 (deploy) still resolve autonomously + +bant: n/a (cycle interno) diff --git a/.cofoundy/archive/2026-05-16-atelier-components/context/agent-floor.md b/.cofoundy/archive/2026-05-16-atelier-components/context/agent-floor.md new file mode 100644 index 0000000..6840972 --- /dev/null +++ b/.cofoundy/archive/2026-05-16-atelier-components/context/agent-floor.md @@ -0,0 +1,64 @@ +# Agent Floor — universal teammate contract + +> Scaffolded into `.cofoundy/context/agent-floor.md` by `/cofoundy-init` (one-time per project). +> Read once by every teammate at spawn. Replaces the per-dispatch boilerplate copy-paste. +> If your dispatch prompt restates anything below, you're duplicating the floor — point here instead. + +## Identity + substrate + +- You are a teammate on team `{{team_name}}`. Your role and task are in your spawn prompt. +- **Substrate is SSOT.** Your task spec lives at `.cofoundy/tasks/T-NNN.md`. Read it first; every acceptance line is a hard gate. +- **Architecture, contracts, conventions** live in `.cofoundy/specs/*.md` and project `CLAUDE.md` / `.claude/rules/`. Read what your task's `refs:` block points at. +- **Don't re-prose what you read.** Apply it. + +## Scope discipline + +- **Stay in `scope.write`** from your task spec. Anything you want to touch outside that = file an escalation in `.cofoundy/state/escalation-queue.yaml` and stop. Do NOT silently expand scope. +- **`scope.read` is permissive** but doesn't authorize edits. Read freely; write only inside the matrix. +- **No new files outside scope.write.** If you need one (new module, new test file, new doc), it must already be listed in `scope.write` (glob match counts). + +## Git contract + +- **No commits, no push.** The orchestrator commits at Phase 8. You only mutate the working tree. +- **No branch operations.** You are on the feature branch the orchestrator created. +- **Stash is ok** if you need to checkpoint mid-task — clean up before signaling done. + +## Test + quality discipline + +- **Run tests yourself before signaling done.** Each acceptance line should map to a runnable check; run it. +- **Logger discipline** per `.claude/rules/backend-quality.md` (Python backend) or repo-specific rules: entry/exit/error logs with structured `extra={}`, `exc_info=True` on errors, `time.perf_counter()` around external HTTP. +- **Coverage gates** if listed in acceptance — run with `--cov-fail-under=N`, save report to `docs/qa//` if your role is QA. +- **`pytest | tail` deadlocks.** Always `pytest ... > /tmp/out.txt 2>&1` then `tail /tmp/out.txt`. The pipe-to-tail pattern hangs in this harness. + +## Termination signal + +When you've self-verified all acceptance criteria pass: + +1. Append one event line to `.cofoundy/state/history.jsonl`: + ```json + {"ts":"","event":"task_completed","task":"T-NNN","agent":"","cycle":"","summary":""} + ``` +2. Return a structured summary (under 250 words): + - Files created / modified (paths) + - Acceptance criteria status (each line passed / partial / blocked) + - Coverage % if relevant + - Deviations from spec (if any) with rationale + - Flagged issues / fix-tasks filed for orchestrator + +3. **Do NOT mark TaskUpdate completed yourself if you're a teammate** — the orchestrator marks based on your termination signal. (If you're a standalone subagent, you don't see TaskList anyway.) + +## Escalation path + +- **Substrate ambiguity** (spec contradicts itself or contract) → append to `.cofoundy/state/escalation-queue.yaml`, halt. +- **Capability gap** (you need to do X but lack tool / credential) → escalation queue + halt. +- **Blocking bug found in another role's deliverable** → file new task `.cofoundy/tasks/T-XXX.md` (role_owner = that role) + flag in your termination summary. Don't try to fix outside your scope. + +## What's NOT here (because it's role/task-specific) + +The dispatch prompt provides ONLY: +- Your role + task ID + branch (1 line) +- Read pointers to your task spec + relevant spec files (1 line) +- Delta-not-in-substrate: any context, debug hints, or decisions made by orchestrator that aren't in the .md files (≤2 lines) +- Termination signal reminder if non-standard (1 line) + +If your dispatch prompt says more than ~80 words, the orchestrator is over-prescribing. Read the spec files; that's where the answers live. diff --git a/.cofoundy/context/decisions/2026-05-16-phase-2-architecture-gate.md b/.cofoundy/archive/2026-05-16-atelier-components/context/decisions/2026-05-16-phase-2-architecture-gate.md similarity index 100% rename from .cofoundy/context/decisions/2026-05-16-phase-2-architecture-gate.md rename to .cofoundy/archive/2026-05-16-atelier-components/context/decisions/2026-05-16-phase-2-architecture-gate.md diff --git a/.cofoundy/context/decisions/2026-05-16-phase-5-task-graph-gate.md b/.cofoundy/archive/2026-05-16-atelier-components/context/decisions/2026-05-16-phase-5-task-graph-gate.md similarity index 100% rename from .cofoundy/context/decisions/2026-05-16-phase-5-task-graph-gate.md rename to .cofoundy/archive/2026-05-16-atelier-components/context/decisions/2026-05-16-phase-5-task-graph-gate.md diff --git a/.cofoundy/state/cto-loop.yaml b/.cofoundy/archive/2026-05-16-atelier-components/cto-loop.yaml similarity index 100% rename from .cofoundy/state/cto-loop.yaml rename to .cofoundy/archive/2026-05-16-atelier-components/cto-loop.yaml diff --git a/.cofoundy/state/dogfood-backup/brand-moodboard.mdx b/.cofoundy/archive/2026-05-16-atelier-components/dogfood-backup/brand-moodboard.mdx similarity index 100% rename from .cofoundy/state/dogfood-backup/brand-moodboard.mdx rename to .cofoundy/archive/2026-05-16-atelier-components/dogfood-backup/brand-moodboard.mdx diff --git a/.cofoundy/state/dogfood-backup/cotizacion.mdx b/.cofoundy/archive/2026-05-16-atelier-components/dogfood-backup/cotizacion.mdx similarity index 100% rename from .cofoundy/state/dogfood-backup/cotizacion.mdx rename to .cofoundy/archive/2026-05-16-atelier-components/dogfood-backup/cotizacion.mdx diff --git a/.cofoundy/state/dogfood-backup/cronograma.mdx b/.cofoundy/archive/2026-05-16-atelier-components/dogfood-backup/cronograma.mdx similarity index 100% rename from .cofoundy/state/dogfood-backup/cronograma.mdx rename to .cofoundy/archive/2026-05-16-atelier-components/dogfood-backup/cronograma.mdx diff --git a/.cofoundy/state/dogfood-backup/personas.mdx b/.cofoundy/archive/2026-05-16-atelier-components/dogfood-backup/personas.mdx similarity index 100% rename from .cofoundy/state/dogfood-backup/personas.mdx rename to .cofoundy/archive/2026-05-16-atelier-components/dogfood-backup/personas.mdx diff --git a/.cofoundy/state/dogfood-backup/sitemap.mdx b/.cofoundy/archive/2026-05-16-atelier-components/dogfood-backup/sitemap.mdx similarity index 100% rename from .cofoundy/state/dogfood-backup/sitemap.mdx rename to .cofoundy/archive/2026-05-16-atelier-components/dogfood-backup/sitemap.mdx diff --git a/.cofoundy/state/escalation-thresholds.yaml b/.cofoundy/archive/2026-05-16-atelier-components/escalation-thresholds.yaml similarity index 100% rename from .cofoundy/state/escalation-thresholds.yaml rename to .cofoundy/archive/2026-05-16-atelier-components/escalation-thresholds.yaml diff --git a/.cofoundy/archive/2026-05-16-atelier-components/specs/api-contract.md b/.cofoundy/archive/2026-05-16-atelier-components/specs/api-contract.md new file mode 100644 index 0000000..c27090b --- /dev/null +++ b/.cofoundy/archive/2026-05-16-atelier-components/specs/api-contract.md @@ -0,0 +1,184 @@ +# API Contract — atelier-components-xgodel-dogfood + +**Phase:** 3 (Contract) +**Authored:** 2026-05-16 by /cto (Phase 1 IC continuation) +**Domain note:** este cycle es `software (frontend-only)` — sin REST endpoints. El "API contract" equivalente para una component-library es **public exports + per-component prop schemas + registry shape + AGENTS.md output format**. Todos son contract surfaces que downstream consumers (docs-ai, artifact-render M1, future landing-pages) acoplan. + +--- + +## 1. Public exports from `@cofoundy/ui` + +**Additive only this cycle.** No breaking changes to existing exports. + +### NEW exports + +```ts +// From packages/ui/src/index.ts (additive, end-of-file section) +export { ATELIER_COMPONENTS } from './lib/atelier-registry'; +export type { AtelierEntry, AtelierComponentName } from './lib/atelier-registry'; +export { Sitemap, type SitemapProps } from './components/docs/Sitemap'; +export { QuoteCard, type QuoteCardProps } from './components/docs/QuoteCard'; +``` + +### PATCH'd exports (additive prop types only — non-breaking) + +```ts +// Existing exports unchanged; TS interfaces accept new optional fields: +PersonaCard // + jtbd, objections, journeyStage, age, incomeRange, source +MoodBoard // + items[].source_url, items[].concept_tag +BuildProgress // + steps[].phase, owner, started_at, completed_at, vikunja_project_id +ComparisonMatrix // + cells[].traffic_light, rows[].source +KPIBoard // + kpis[].baseline, kpis[].source +DesignSystemPanel // + direction, colors[].usage_note +``` + +### KEEP (no signature change) + +```ts +TestimonialCard // ships schema-only this cycle; story refresh + Zod parse-validation test +``` + +### Internal (NOT public per ceo-agent Q1=B decision) + +- Per-component Zod schemas (`personaCardSchema`, etc.) — accessible ONLY via `ATELIER_COMPONENTS[name].schema`. Not named-exported from package root. +- Schema barrel `lib/atelier-schemas.ts` — package-internal import surface for registry consumption. + +**Versioning:** this cycle ships as a `packages/ui` minor bump (additive). No breaking changes. Semver patch if any audit reveals an unintended prop rename. + +--- + +## 2. Registry contract + +**Shape:** see `architecture-v1.md` §2 (canonical). + +**Contract guarantees:** +- `ATELIER_COMPONENTS` is a TypeScript `Record` with `satisfies` constraint — type-checked at compile time. +- Each entry has shape `{ component, schema, description, example }`. Adding a field requires bumping `AtelierEntry` interface + updating all entries (compile error catches drift). +- Order of entries is NOT semantically meaningful, but kept alphabetical by convention for diff stability. + +**Consumer integration (docs-ai `mdx-components.tsx`, single-line append):** + +```ts +// Future-state (post both PRs merged): +import { ATELIER_COMPONENTS } from '@cofoundy/ui'; + +const atelierForMdx = Object.fromEntries( + Object.entries(ATELIER_COMPONENTS).map(([name, entry]) => [name, entry.component]) +); + +export function useMDXComponents(components: MDXComponents): MDXComponents { + return { + ...components, + ...atelierForMdx, // <-- mi single-line spread + // ... chrome-cto's existing components stay + }; +} +``` + +--- + +## 3. AGENTS.md output format contract + +**File:** `packages/ui/AGENTS.md` at repo root of packages/ui. +**Generator:** `packages/ui/scripts/gen-atelier-agents-md.ts`. +**Drift gate:** `.github/workflows/verify-agents-md.yml` runs `pnpm gen:agents && git diff --exit-code AGENTS.md`. + +**Output structure (deterministic, alphabetical by component name):** + +```md +# Atelier Components — Agent Allowlist + + + + +## Available components +- `` — +... (9 entries, alphabetical) + +## + +**Description:** + +**Props (from Zod):** + +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| ... + +**Example MDX:** + +\`\`\`mdx + +\`\`\` + +--- +``` + +**Hash-stability:** generator must produce byte-identical output for unchanged registry input (no timestamps in body content; ISO timestamp only in top comment). The drift gate depends on this — fluctuating output would fail CI on unrelated PRs. + +--- + +## 4. Per-component Zod schema contract + +**File location:** `packages/ui/src/components/docs/.schema.ts` (peer file to `.tsx`). + +**Export shape:** + +```ts +import { z } from 'zod'; + +export const Schema = z.object({ ... }); +export type Input = z.inferSchema>; +``` + +**Constraints:** +- All schema fields use Zod primitives + `.optional()` / `.max()` / `.min()` / `.enum()`. No custom refinements unless documented per-component. +- TS `interface Props` (in `.tsx`) and `Input` (in `.schema.ts`) MUST be type-compatible. Enforced by Vitest test: + +```ts +import { expectTypeOf } from 'vitest'; +import type { Props } from './'; +import type { Input } from './.schema'; + +it('Zod schema matches TS interface', () => { + expectTypeOf<Props>().toMatchTypeOf<Input>(); +}); +``` + +**Canonical example:** every schema test imports the `example` field from `ATELIER_COMPONENTS[name].example` and asserts `schema.parse(example)` succeeds. Drift between registry example and schema = test failure. + +--- + +## 5. XGodel MDX dogfood contract + +**Path:** `~/cofoundy/products/cofoundy-platform/docs-ai/content/client/xgodel/` + +**Files I author (5):** `personas.mdx`, `sitemap.mdx`, `brand-moodboard.mdx`, `cotizacion.mdx`, `cronograma.mdx`. + +**Frontmatter contract per doc:** +- `role: client` (gated by docs-ai middleware) +- `chrome: deliverable` (set by chrome-cto's selector or explicit override) +- `kind: report | proposal | reference | quote | timeline` (per doc) +- `recipient: { name, company, email? }` (only on cotizacion.mdx) +- `expires_at: YYYY-MM-DD` (only on cotizacion.mdx) +- `source_freshness: manual-snapshot-YYYY-MM-DD` (cronograma.mdx convention — encodes Vikunja-manual decision) + +**Render verification:** dev-server (`pnpm dev` in docs-ai) + prod Cloudflare — both render without warnings/errors. Snapshot the 5 URLs into `.cofoundy/state/dogfood-snapshots/` Phase 9. + +--- + +## 6. Out-of-contract (not shipped this cycle) + +| Surface | Status | Where it ships | +|---|---|---| +| `ContractTimeline` component | excluded | future cycle (post contract signed) | +| `UserFlow`, `MockupShowcase`, `DeployRecord`, `PersonaGrid`, `TimelineGantt`, `FaqAccordion`, `HandoffChecklist`, `BeforeAfterSlider` | deferred | second client cycle | +| 10 hard-floor production gates (Atelier PRD §3 P6) | deferred | `artifact-render` skill (Atelier M1) | +| `artifact-render` skill (md → MDX agent) | deferred | Atelier M1 — XGodel v1 = MDX manual authoring | +| Direct named Zod schema exports | deferred | promote on real consumer ask (per Q1=B) | +| Tailwind migration of 7 existing components | deferred | tagged `atelier-tech-debt`, separate cycle (per Q2=SKIP) | +| Build-time Vikunja fetch in BuildProgress | deferred | M1+ (per Q3=MANUAL) | + +--- + +**Contract version:** v1.0 (this cycle ships first stable). Future cycles bump per semver against this document. diff --git a/.cofoundy/archive/2026-05-16-atelier-components/specs/architecture-v1.md b/.cofoundy/archive/2026-05-16-atelier-components/specs/architecture-v1.md new file mode 100644 index 0000000..a309d31 --- /dev/null +++ b/.cofoundy/archive/2026-05-16-atelier-components/specs/architecture-v1.md @@ -0,0 +1,280 @@ +# architecture-v1.md — atelier-components-xgodel-dogfood + +**Cycle:** atelier-components-xgodel-dogfood +**Phase:** 2b → 2c (architecture draft, pending ceo-agent gate) +**Author:** Plan-agent (subagent, READ-ONLY) + /cto synthesis +**Date:** 2026-05-16 +**Locked decisions inherited from brief.yaml:** D1 (location `packages/ui/src/components/docs/`), D2 (no hard-floor gates this cycle), D3 (no `atelier/` subfolder), D4 (audit-before-patch) +**Authoritative research:** R-A7 (portal UX 2026), R-A8 (registry pattern 2026) + +--- + +## 1. Component audit matrix + +| # | Component | Exists? | Props actuales | Props spec Atelier §6.2 / data shape XGodel | Delta | Verdict | +|---|---|---|---|---|---|---| +| 1 | **Sitemap** | NO | — | `nodes[]: { path, label, depth, intent?, nav_group?, children? }`; tree rendering (R-A7 §navigation: collapsible tree, current-path highlight); source: `07-information-architecture.md` | NEW — clean-slate | **NEW** | +| 2 | **QuoteCard** | NO | — | `client_name`, `prepared_for`, `valid_until`, `milestones[]: { label, deliverable, amount, due }`, `total`, `payment_terms`, `notes?`; source: `propuesta.html` | NEW — clean-slate | **NEW** | +| 3 | **PersonaCard** | YES (101 LOC) | `name`, `role`, `avatar?`, `demographics[]`, `painPoints[]`, `goals[]`, `quote?` | spec adds: `jtbd: string`, `objections[]`, `journey_stage: 'awareness'\|'research'\|'decision'\|'retention'`, `age?`, `income_range?`, `source?` (provenance — future gate `unverified_persona`) | Missing 5 fields, no breaking (all additive optional) | **PATCH** | +| 4 | **MoodBoard** | YES (58 LOC) | `items[]: { src, alt, caption? }`, `columns?` | spec adds: `items[].source_url?` (R-A7 §provenance), `items[].concept_tag?` (XGodel has 4 concepts A/B/C/D + final → grouping) | Fit OK for v1; add 2 optional fields | **PATCH** | +| 5 | **BuildProgress** | YES (104 LOC) | `steps[]: { label, status?, body? }` where status = `done\|current\|pending` | spec adds: `steps[].phase?: 'L0'..'L9'`, `steps[].owner?`, `steps[].started_at?`, `steps[].completed_at?`, `steps[].vikunja_project_id?` | Phase model L0-L9 not encoded; dates/owner missing | **PATCH** | +| 6 | **ComparisonMatrix** | YES (107 LOC) | `columns[]`, `rows[]: { feature, options[]: { name, value, highlight? } }` | spec needs: `traffic_light?: 'green'\|'yellow'\|'red'` per cell (R-A7 §comparison convergent pattern), `source?` per row | Shape solid; add traffic-light enum + per-row source | **PATCH** | +| 7 | **KPIBoard** | YES (96 LOC) | `kpis[]: { label, value, trend?, target?, status? }`, `columns?` | spec needs: `kpis[].baseline?`, `kpis[].source?: string` (16-cro-plan.md cites benchmarks — provenance required) | Almost ideal; add baseline + source | **PATCH** | +| 8 | **DesignSystemPanel** | YES (109 LOC) | `colors?`, `typography?`, `spacing?`, `radius?` (token arrays) | XGodel has 3 brand directions (emerald-academic, bordeaux-historic, editorial-premium); add `direction?: string` label + `usage_note?` per color | Render shape good; add direction grouping for A/B compare | **PATCH** | +| 9 | **TestimonialCard** | YES (80 LOC) | `quote`, `author`, `role?`, `avatar?`, `source?`, `sourceUrl?` | Spec match — XGodel sin testimonials públicos todavía → no rendered en v1, schema + story refresh still in scope | None | **KEEP** (schema only) | + +**Out-of-scope (per brief.yaml D2 / scope):** +- `ContractTimeline` — excluded (XGodel sin contrato firmado, future gate `unsigned_contract` would block anyway) +- `UserFlow`, `MockupShowcase`, `DeployRecord`, `PersonaGrid`, `TimelineGantt`, `FaqAccordion`, `HandoffChecklist`, `BeforeAfterSlider` — deferred al segundo cliente +- Tangential email-derived (`AuthorNote`, `InfoBox`, `MetadataCard`, `NextStepCallout`, `ScopeList`) — shipped, NOT en registry (son docs-ai V2.1 generic, no Atelier-domain; leave exports untouched) + +**Workload:** 2 NEW + 7 PATCH (6 light-additive + 1 schema-only) = 9 components en registry. + +--- + +## 2. Registry shape + +**Location:** `packages/ui/src/lib/atelier-registry.ts` (NEW dir `src/lib/`). + +Rationale: +- No en `components/docs/` — no es componente, es manifest +- No en root `src/atelier-registry.ts` — pollutes package root; `services/`, `hooks/`, `stores/` siguen convención de subdir; `lib/` la sigue +- No en `docs-ai/lib/atelier-registry.ts` (donde Atelier PRD §7.2c originalmente lo puso) — D1 puso component SSOT en packages/ui, registry sigue components; CTO #2 `docs-ai/lib/chrome.ts` es para chrome routing, separado + +**Shape (per R-A8 §registry-pattern recommendation — static `satisfies Record`):** + +```ts +// packages/ui/src/lib/atelier-registry.ts +import type { ComponentType } from 'react'; +import { + PersonaCard, MoodBoard, BuildProgress, ComparisonMatrix, + KPIBoard, DesignSystemPanel, TestimonialCard +} from '../components/docs'; +import { Sitemap } from '../components/docs/Sitemap'; +import { QuoteCard } from '../components/docs/QuoteCard'; +import { + personaCardSchema, moodBoardSchema, buildProgressSchema, + comparisonMatrixSchema, kpiBoardSchema, designSystemPanelSchema, + testimonialCardSchema, sitemapSchema, quoteCardSchema, +} from './atelier-schemas'; +import type { ZodTypeAny } from 'zod'; + +export interface AtelierEntry { + component: ComponentType; + schema: ZodTypeAny; // Zod for prop validation (DX + future gate path) + description: string; // surfaces in AGENTS.md + example: Record; // canonical MDX example for AGENTS.md +} + +export const ATELIER_COMPONENTS = { + PersonaCard: { component: PersonaCard, schema: personaCardSchema, description: '...', example: {...} }, + MoodBoard: { component: MoodBoard, schema: moodBoardSchema, description: '...', example: {...} }, + BuildProgress: { component: BuildProgress, schema: buildProgressSchema, description: '...', example: {...} }, + ComparisonMatrix: { component: ComparisonMatrix, schema: comparisonMatrixSchema, description: '...', example: {...} }, + KPIBoard: { component: KPIBoard, schema: kpiBoardSchema, description: '...', example: {...} }, + DesignSystemPanel: { component: DesignSystemPanel, schema: designSystemPanelSchema, description: '...', example: {...} }, + TestimonialCard: { component: TestimonialCard, schema: testimonialCardSchema, description: '...', example: {...} }, + Sitemap: { component: Sitemap, schema: sitemapSchema, description: '...', example: {...} }, + QuoteCard: { component: QuoteCard, schema: quoteCardSchema, description: '...', example: {...} }, +} satisfies Record; + +export type AtelierComponentName = keyof typeof ATELIER_COMPONENTS; +``` + +**Exports add to `packages/ui/src/index.ts`** (additive, follows existing pattern): + +```ts +export { ATELIER_COMPONENTS } from './lib/atelier-registry'; +export type { AtelierEntry, AtelierComponentName } from './lib/atelier-registry'; +``` + +**CTO #2 consumes via:** `import { ATELIER_COMPONENTS } from '@cofoundy/ui'` en su nuevo `docs-ai/mdx-components.tsx` spread; o artifact-render (M1) para tag-config generation. Mi PR ships ÚNICAMENTE la line addition a `mdx-components.tsx` después de su chrome PR merges (per brief.yaml `serialization_with_cto2`). + +--- + +## 3. AGENTS.md auto-gen + +**Script:** `packages/ui/scripts/gen-atelier-agents-md.ts`. + +**Why (R-A8 §recommendation):** single static registry must drive LLM allowlist. Hand-edited markdown drifts on first PR; auto-gen makes drift impossible (CI gate verifies `git diff --exit-code AGENTS.md` post-script-run). + +**Inputs (read-only):** +- `src/lib/atelier-registry.ts` (component list + descriptions + examples) +- Per-component Zod schemas (introspected via `zod-to-json-schema` to render prop tables) + +**Outputs (single file):** `packages/ui/AGENTS.md`. Structure: + +```md +# Atelier Components — Agent Allowlist + + + +## Available components +- `PersonaCard` — Target persona for UX research deliverables +- `Sitemap` — Hierarchical site map for landing-build clients +... (9 entries) + +## PersonaCard +**Description:** ... +**Props (from Zod):** +| Name | Type | Required | Default | Description | +| name | string | yes | — | Persona display name | +... +**Example MDX:** +\`\`\`mdx + +\`\`\` +``` + +**Invocation:** +- Local dev: `pnpm gen:agents` (npm script alias) +- CI gate: workflow `verify-agents-md.yml` runs `pnpm gen:agents && git diff --exit-code AGENTS.md` on every PR → fails si registry edits olvidaron regenerate +- NOT pre-commit hook (R-A8 §dx anti-pattern: pre-commit hooks fight the dev; CI gate is cheap + visible) + +**Tech:** plain TS script, `tsx` runner. Deps: `zod`, `zod-to-json-schema` (R-A8 hybrid recommendation; both small). + +--- + +## 4. Zod schema strategy + +**Decision: per-component `.schema.ts` peer file, NOT collocated en `.tsx`, NOT centralized.** + +| Option | Pros | Cons | +|---|---|---| +| A. Inline en `.tsx` | Locality, single file | Bundle pollution (zod pulled en every consumer chunk); test isolation harder | +| B. Single `lib/atelier-schemas.ts` | One file scans whole shape | God-file; merge conflicts on simultaneous edits; harder to delete | +| **C. Per-component `.schema.ts` peer** | Tree-shakeable; per-file ownership matches per-file PR review; registry imports explicit | Slightly more files | + +**Chosen: C.** Pattern: + +``` +src/components/docs/ +├── PersonaCard.tsx # JSX + TS interface (unchanged exports) +├── PersonaCard.schema.ts # NEW: exports `personaCardSchema` +├── PersonaCard.test.ts # NEW: parse + validate canonical examples +├── MoodBoard.tsx +├── MoodBoard.schema.ts +... (9 components × 2 new files = 18 schema/test files) +``` + +**Convention (mirrors `render-email.ts` pattern):** + +```ts +// PersonaCard.schema.ts +import { z } from 'zod'; + +export const personaCardSchema = z.object({ + name: z.string().min(1), + role: z.string().min(1), + avatar: z.string().url().optional(), + demographics: z.array(z.string()).max(8).optional(), + painPoints: z.array(z.string()).max(8).optional(), + goals: z.array(z.string()).max(8).optional(), + quote: z.string().max(500).optional(), + // PATCH additions (Atelier §6.2): + jtbd: z.string().max(280).optional(), + objections: z.array(z.string()).max(6).optional(), + journeyStage: z.enum(['awareness', 'research', 'decision', 'retention']).optional(), + age: z.string().optional(), + incomeRange: z.string().optional(), + source: z.string().optional(), // provenance — future gate `unverified_persona` +}); + +export type PersonaCardInput = z.infer; +``` + +**Important:** TS `interface PersonaCardProps` (component-facing) stays en `.tsx`. Zod schema is separate runtime contract used by (a) registry (b) AGENTS.md generator (c) future artifact-render gates (M1). Both stay in sync via Vitest test per component: `expectTypeOf().toMatchTypeOf()`. + +**Dependency add:** `zod` a `dependencies` (ships en package consumed by docs-ai). `zod-to-json-schema` a devDependencies (build script only). + +--- + +## 5. File ownership matrix + +W = Write, R = Read-only, A = Append-only (single-line additive) + +| Path-glob | Mi cycle (atelier-components) | CTO #2 (V2.6 chrome) | Collision risk | +|---|---|---|---| +| `packages/ui/src/components/docs/*.tsx` | **W** (patch 6, keep 1, add 2) | — | none | +| `packages/ui/src/components/docs/*.schema.ts` | **W** (9 NEW) | — | none | +| `packages/ui/src/components/docs/*.test.ts` | **W** (9 NEW) | — | none | +| `packages/ui/src/components/docs/index.ts` | **W** (add Sitemap, QuoteCard exports) | — | none | +| `packages/ui/src/lib/atelier-registry.ts` | **W** (NEW) | — | none | +| `packages/ui/src/lib/atelier-schemas.ts` | **W** (NEW barrel re-export) | — | none | +| `packages/ui/src/stories/docs/*.stories.tsx` | **W** (2 NEW + 7 audit-refresh) | — | none | +| `packages/ui/src/index.ts` | **A** (2 export lines) | — | none | +| `packages/ui/scripts/gen-atelier-agents-md.ts` | **W** (NEW) | — | none | +| `packages/ui/AGENTS.md` | **W** (auto-gen output, committed) | — | none | +| `packages/ui/package.json` | **W** (add zod, zod-to-json-schema, gen:agents) | — | none | +| `packages/ui/.github/workflows/verify-agents-md.yml` | **W** (NEW CI gate) | — | none | +| `docs-ai/components/{Deliverable,Vault,Reader,CreatorRibbon,RecipientStrip,ApprovalBlock,...}.tsx` | **R** | **W** | none — they own | +| `docs-ai/lib/chrome.ts` | **R** | **W** | none — they own | +| `docs-ai/lib/frontmatter-zod.ts` (chrome/kind/recipient/expires_at) | **R** | **W** | none — they own | +| `docs-ai/mdx-components.tsx` | **A** (single import + spread line, follow-up PR) | **W** (chrome PR) | **SERIALIZED** — mi PR después de su | +| `docs-ai/content/client/xgodel/propuesta.mdx` | **R** | **W** (stub) | none — naming-disjoint | +| `docs-ai/content/client/xgodel/{personas,sitemap,brand-moodboard,cotizacion,cronograma}.mdx` | **W** (5 docs) | **R** | none — naming-disjoint | +| `docs-ai/content/client/xgodel/vault.yaml` | **A** (toc entries for mis 5 docs) | **W** (initial) | **SERIALIZED** — append después de su initial commit | + +**Zero 2W collisions confirmed.** Dos **2A serialization points** (`mdx-components.tsx`, `vault.yaml`) — ambos single-line append, ambos wait on CTO #2 PR merge primero. + +--- + +## 6. XGodel dogfood plan + +5 MDX docs bajo `~/cofoundy/products/cofoundy-platform/docs-ai/content/client/xgodel/`. CTO #2 owns `propuesta.mdx`. + +| MDX doc | Components consumed | Source artifact (paths absolutas) | Notes | +|---|---|---|---| +| **`personas.mdx`** | `` ×3 | `~/cofoundy/deals/clients/XGodel/ux-research/02-user-personas.md` + `03-empathy-map.md` (JTBD/objections enrichment) | Hand-author MDX `chrome: deliverable`, `kind: report`. Cada persona = 1 PersonaCard con los 12 patched props. `source` field → research doc path | +| **`sitemap.mdx`** | `` ×1 | `~/cofoundy/projects/xgodel-landing/research/07-information-architecture.md` + `06-userflow.md` (intent labels) | Tree built from IA section structure. `nav_group` → landing primary nav buckets | +| **`brand-moodboard.mdx`** | `` (4 XGodel concepts) + `` ×3 (one per direction) + `` ×1 (trade-off) | `~/cofoundy/deals/clients/XGodel/visual-design/concept-{A,B,C,D}/`, `final/` (PNG refs); `~/cofoundy/deals/clients/XGodel/work/brand-paletas-v2/{direccion-A-emerald-academic, direccion-B-bordeaux-historic, direccion-C-editorial-premium}/`; `visual-design/DECISIONS.md` | Heaviest doc — exercises 3 de 9 components. ComparisonMatrix rows = trade-off axes (academic-feel, mobile-perf, dev-cost); columns = 3 direcciones; cells nuevo `traffic_light` enum | +| **`cotizacion.mdx`** | `` ×1 | `~/cofoundy/deals/clients/XGodel/propuesta.html` (table extraction) + `addendum-pago-comprobante.md` (payment terms) | Frontmatter `recipient: { name: "John Medina", company: "XGodel" }`, `expires_at: 2026-06-30`. Primera production exercise QuoteCard | +| **`cronograma.mdx`** | `` ×1 + `` ×1 (delivery KPIs) | `~/cofoundy/deals/clients/XGodel/cronograma.tex` (parse phases L0-L9); Vikunja `delivery_project_id=22` (manual snapshot v1); `roadmap-hitos.md` | BuildProgress uses patched phase + owner + dates. KPIBoard: % phases done, days-elapsed-vs-planned, deliverables-shipped count | + +**Components touched en dogfood:** 8 de 9 en registry (todos excepto TestimonialCard — sin testimonials XGodel; ships schema+story, exercised por segundo cliente). + +**Render verification:** dev-server + prod Cloudflare — ambos sin warnings/errors. Andre shares URL con John Medina post-merge (success-signal en brief.yaml). + +--- + +## 7. Open architectural questions — RESOLVED 2026-05-16 by ceo-agent (Phase 2c) + +**Resolutions (locked):** +- **Q1 → Option B** (registry-only access, no public schema named-exports). Promote on demand. +- **Q2 → SKIP** Tailwind migration this cycle. NEW components follow CVA + Tailwind; 7 existing stay inline-style. File tag `atelier-tech-debt`. +- **Q3 → MANUAL snapshot** for BuildProgress / Vikunja. Add frontmatter convention `source_freshness: manual-snapshot-YYYY-MM-DD` en `cronograma.mdx` para encoded upgrade path. + +Full decision rationale: `.cofoundy/context/decisions/2026-05-16-phase-2-architecture-gate.md`. + +--- + +### Original questions (for historical reference) + + +**Q1. Zod schema export surface — public o internal?** +Should `personaCardSchema` (y los 8 otros) be exportados desde `@cofoundy/ui` para downstream consumers (docs-ai, artifact-render M1, future landing-pages), o kept package-internal y solo surfaced via `ATELIER_COMPONENTS[name].schema`? +- **Option A (export each schema):** more flexible; pollutes public API surface con 9 new named exports +- **Option B (registry-only access):** cleaner public API; consumers go through `ATELIER_COMPONENTS.PersonaCard.schema`; couples consumers tightly to registry shape +- **Plan-agent lean:** B (smaller blast radius); promote individual exports si real consumer asks + +**Q2. JSX `style={{}}` legacy vs Tailwind migration scope?** +Los 7 existing `components/docs/*.tsx` usan inline `style={{}}` objects (verificado en PersonaCard, MoodBoard, etc.). Resto de `packages/ui` sigue CVA + Tailwind per CLAUDE.md "Component Patterns" §1-4. ¿Migrar los 7 a CVA + Tailwind while patching props, o leave inline-style as-is y solo follow CVA for los 2 NEW? +- **Cost migration:** ~2 días extra; visual regression risk on already-shipped +- **Cost skip:** registry components inconsistent con resto de `@cofoundy/ui`; future Atelier debe decidir cuál pattern +- **Plan-agent lean:** SKIP migration this cycle (scope creep — brief timeline 5-7 días). Nuevos (Sitemap, QuoteCard) follow CVA. File follow-up issue + +**Q3. `BuildProgress` Vikunja integration — manual snapshot vs read-time fetch?** +`cronograma.mdx` needs current phase status. Two paths: +- **Manual snapshot:** human edita MDX cuando cambia (simple, stale; aligned con brief "MDX manual authoring") +- **Build-time fetch:** Next.js RSC fetches Vikunja API at build time, injects into BuildProgress (fresher; needs Vikunja token en docs-ai env; scope creep) +- **Plan-agent lean:** manual snapshot v1 (consistent con no-artifact-render); document upgrade path + +--- + +### Critical Files for Implementation + +- `/Users/styreep/cofoundy/packages/ui/src/lib/atelier-registry.ts` (NEW — SSOT) +- `/Users/styreep/cofoundy/packages/ui/scripts/gen-atelier-agents-md.ts` (NEW — build script) +- `/Users/styreep/cofoundy/packages/ui/src/components/docs/PersonaCard.tsx` (PATCH — reference shape for los otros 5) +- `/Users/styreep/cofoundy/packages/ui/src/components/docs/index.ts` (extend — add Sitemap + QuoteCard exports) +- `/Users/styreep/cofoundy/products/cofoundy-platform/docs-ai/mdx-components.tsx` (single-line append AFTER CTO #2 chrome PR merges) diff --git a/.cofoundy/archive/2026-05-16-atelier-components/specs/file-ownership-matrix.md b/.cofoundy/archive/2026-05-16-atelier-components/specs/file-ownership-matrix.md new file mode 100644 index 0000000..78758a3 --- /dev/null +++ b/.cofoundy/archive/2026-05-16-atelier-components/specs/file-ownership-matrix.md @@ -0,0 +1,80 @@ +# File Ownership Matrix — atelier-components-xgodel-dogfood + +**Phase:** 3 (Contract + ownership matrix) +**Authored:** 2026-05-16 by /cto (Phase 1 IC continuation) +**Source:** extracted + canonicalized from `architecture-v1.md` §5 + +**Legend:** +- **W** = Write (create/edit, owner) +- **R** = Read-only (consumer) +- **A** = Append-only (single-line additive, serialized) +- **—** = out of scope for that role + +**Roles:** +- `atelier-cto` = this cycle (Andre, packages/ui-driven) +- `chrome-cto` = parallel CTO #2 (V2.6 chrome system, docs-ai-driven) + +--- + +## packages/ui scope (mine, no collisions) + +| Path-glob | atelier-cto | chrome-cto | Notes | +|---|---|---|---| +| `packages/ui/src/components/docs/PersonaCard.tsx` | W | — | PATCH (+5 props) | +| `packages/ui/src/components/docs/MoodBoard.tsx` | W | — | PATCH (+2 props) | +| `packages/ui/src/components/docs/BuildProgress.tsx` | W | — | PATCH (+phase/owner/dates) | +| `packages/ui/src/components/docs/ComparisonMatrix.tsx` | W | — | PATCH (+traffic_light, +row.source) | +| `packages/ui/src/components/docs/KPIBoard.tsx` | W | — | PATCH (+baseline, +source) | +| `packages/ui/src/components/docs/DesignSystemPanel.tsx` | W | — | PATCH (+direction, +usage_note) | +| `packages/ui/src/components/docs/TestimonialCard.tsx` | R | — | KEEP (schema-only ship; no XGodel render) | +| `packages/ui/src/components/docs/Sitemap.tsx` | W | — | NEW | +| `packages/ui/src/components/docs/QuoteCard.tsx` | W | — | NEW | +| `packages/ui/src/components/docs/*.schema.ts` | W | — | 9 NEW per-component Zod schemas | +| `packages/ui/src/components/docs/*.test.ts` | W | — | 9 NEW (parse + canonical example + type-match) | +| `packages/ui/src/components/docs/index.ts` | W | — | extend (add Sitemap, QuoteCard exports) | +| `packages/ui/src/lib/atelier-registry.ts` | W | — | NEW SSOT registry | +| `packages/ui/src/lib/atelier-schemas.ts` | W | — | NEW barrel re-export | +| `packages/ui/src/stories/docs/PersonaCard.stories.tsx` | W | — | refresh (audit + new props) | +| `packages/ui/src/stories/docs/MoodBoard.stories.tsx` | W | — | refresh | +| `packages/ui/src/stories/docs/BuildProgress.stories.tsx` | W | — | refresh | +| `packages/ui/src/stories/docs/ComparisonMatrix.stories.tsx` | W | — | refresh | +| `packages/ui/src/stories/docs/KPIBoard.stories.tsx` | W | — | refresh | +| `packages/ui/src/stories/docs/DesignSystemPanel.stories.tsx` | W | — | refresh | +| `packages/ui/src/stories/docs/TestimonialCard.stories.tsx` | W | — | refresh (Zod parse-validation test added) | +| `packages/ui/src/stories/docs/Sitemap.stories.tsx` | W | — | NEW | +| `packages/ui/src/stories/docs/QuoteCard.stories.tsx` | W | — | NEW | +| `packages/ui/src/index.ts` | A | — | 2-line append (registry export + type export) | +| `packages/ui/scripts/gen-atelier-agents-md.ts` | W | — | NEW build script | +| `packages/ui/AGENTS.md` | W | — | auto-gen output, committed | +| `packages/ui/package.json` | W | — | + zod (deps), + zod-to-json-schema (devDeps), + gen:agents script | +| `packages/ui/.github/workflows/verify-agents-md.yml` | W | — | NEW CI gate (auto-gen drift check) | + +## docs-ai scope (their primary, my single-line touches at end) + +| Path-glob | atelier-cto | chrome-cto | Notes | +|---|---|---|---| +| `docs-ai/components/{DeliverableLayout,VaultLayout,Reader*}.tsx` | R | W | their chrome PR | +| `docs-ai/components/{CreatorRibbon,RecipientStrip,ApprovalBlock,ReaderToggle,ComingSoonModal}.tsx` | R | W | their chrome PR | +| `docs-ai/lib/chrome.ts` | R | W | their selector | +| `docs-ai/lib/frontmatter-zod.ts` (chrome/kind/recipient/expires_at) | R | W | their extension | +| `docs-ai/app/[project]/[...slug]/page.tsx` | R | W | their dispatch | +| `docs-ai/app/globals.css` (chrome layouts + Reader mode) | R | W | their styles | +| `docs-ai/mdx-components.tsx` | **A** (1-line, serialized) | W | **SERIALIZED:** my PR ships single `import { ATELIER_COMPONENTS } from '@cofoundy/ui'` + spread, AFTER chrome PR merges | +| `docs-ai/content/client/xgodel/propuesta.mdx` | R | W | their stub | +| `docs-ai/content/client/xgodel/personas.mdx` | W | R | mine (3× PersonaCard) | +| `docs-ai/content/client/xgodel/sitemap.mdx` | W | R | mine (Sitemap) | +| `docs-ai/content/client/xgodel/brand-moodboard.mdx` | W | R | mine (MoodBoard + DesignSystemPanel + ComparisonMatrix) | +| `docs-ai/content/client/xgodel/cotizacion.mdx` | W | R | mine (QuoteCard) | +| `docs-ai/content/client/xgodel/cronograma.mdx` | W | R | mine (BuildProgress + KPIBoard) | +| `docs-ai/content/client/xgodel/vault.yaml` | **A** (toc entries, serialized) | W | **SERIALIZED:** I append my 5 doc toc entries after their initial commit | + +--- + +## Collision summary + +- **2W cells:** 0 (zero direct write collisions) +- **2A serialization points:** 2 (`docs-ai/mdx-components.tsx`, `docs-ai/content/client/xgodel/vault.yaml`) — both single-line append, both gated on chrome PR merge first + +## Validation + +Per /cto Phase 3 spec: *"every cell with 2+ W = halt and serialize (carve up the paths or sequence the roles)."* ✅ ZERO 2W cells. No halt required. Two 2A cells already serialized by `serialization_with_cto2` contract in brief.yaml. diff --git a/.cofoundy/archive/2026-05-16-atelier-components/tasks/T-001.md b/.cofoundy/archive/2026-05-16-atelier-components/tasks/T-001.md new file mode 100644 index 0000000..82f3150 --- /dev/null +++ b/.cofoundy/archive/2026-05-16-atelier-components/tasks/T-001.md @@ -0,0 +1,39 @@ +--- +id: T-001 +title: "Audit + patch 7 existing components — props/schemas/tests" +status: ready +role_owner: component-author +depends_on: [] +priority: P0 +scope: + write: + - packages/ui/src/components/docs/PersonaCard.tsx + - packages/ui/src/components/docs/MoodBoard.tsx + - packages/ui/src/components/docs/BuildProgress.tsx + - packages/ui/src/components/docs/ComparisonMatrix.tsx + - packages/ui/src/components/docs/KPIBoard.tsx + - packages/ui/src/components/docs/DesignSystemPanel.tsx + - packages/ui/src/components/docs/TestimonialCard.tsx + - packages/ui/src/components/docs/{PersonaCard,MoodBoard,BuildProgress,ComparisonMatrix,KPIBoard,DesignSystemPanel,TestimonialCard}.schema.ts + - packages/ui/src/components/docs/{PersonaCard,MoodBoard,BuildProgress,ComparisonMatrix,KPIBoard,DesignSystemPanel,TestimonialCard}.test.ts + read: + - .cofoundy/specs/architecture-v1.md (§1 audit matrix, §4 Zod strategy) + - .cofoundy/specs/api-contract.md (§4 schema contract) + - packages/ui/CLAUDE.md + - cofoundy/products/atelier/PRD.md §6.2 +--- + +## Goal +Patch 6 components (PersonaCard, MoodBoard, BuildProgress, ComparisonMatrix, KPIBoard, DesignSystemPanel) per architecture-v1.md §1 audit matrix. Keep TestimonialCard signature (schema-only ship). Author per-component `.schema.ts` peer file + `.test.ts` for all 7. + +## Acceptance criteria +- [ ] Each PATCH adds ONLY optional props from arch §1 (additive, non-breaking) +- [ ] `.schema.ts` exports `Schema: z.ZodObject` + `Input = z.inferSchema>` +- [ ] `.test.ts` includes: (a) schema.parse(canonical_example) succeeds, (b) `expectTypeOf<Props>().toMatchTypeOf<Input>()`, (c) one negative case per required field +- [ ] `pnpm test --coverage` on this glob ≥80% line coverage per file +- [ ] **Visual regression:** existing Storybook story renders identical to pre-patch baseline (Chrome MCP screenshot diff ≤2%) +- [ ] No bundle-size regression on `@cofoundy/ui` (`pnpm build` size delta <2%) +- [ ] TestimonialCard: schema + test file authored even though .tsx unchanged + +## Termination signal +Append `T-001 completed` to .cofoundy/state/history.jsonl with file list + coverage %. diff --git a/.cofoundy/archive/2026-05-16-atelier-components/tasks/T-002.md b/.cofoundy/archive/2026-05-16-atelier-components/tasks/T-002.md new file mode 100644 index 0000000..1765035 --- /dev/null +++ b/.cofoundy/archive/2026-05-16-atelier-components/tasks/T-002.md @@ -0,0 +1,37 @@ +--- +id: T-002 +title: "Build Sitemap + QuoteCard NEW (CVA + Tailwind)" +status: ready +role_owner: component-author +depends_on: [] +priority: P0 +scope: + write: + - packages/ui/src/components/docs/Sitemap.tsx + - packages/ui/src/components/docs/Sitemap.schema.ts + - packages/ui/src/components/docs/Sitemap.test.ts + - packages/ui/src/components/docs/QuoteCard.tsx + - packages/ui/src/components/docs/QuoteCard.schema.ts + - packages/ui/src/components/docs/QuoteCard.test.ts + - packages/ui/src/components/docs/index.ts (append exports) + read: + - .cofoundy/specs/architecture-v1.md (§1 rows 1-2) + - cofoundy/projects/xgodel-landing/research/07-information-architecture.md (Sitemap source shape) + - cofoundy/deals/clients/XGodel/propuesta.html (QuoteCard source shape) + - packages/ui/CLAUDE.md (§Component Patterns 1-4 CVA + Tailwind) +--- + +## Goal +Build Sitemap and QuoteCard components from scratch following CVA + Tailwind convention (per Q2=SKIP decision: only NEW components follow the new pattern). Props per arch §1 spec. + +## Acceptance criteria +- [ ] Sitemap renders nested tree from `nodes[]: { path, label, depth, intent?, nav_group?, children? }` +- [ ] QuoteCard renders cotización with hitos table + totals + payment_terms +- [ ] Both follow CVA + Tailwind (NO inline `style={{}}`) +- [ ] Both export `interface Props` + matching `Schema` (Zod) + `Input` +- [ ] `.test.ts` ≥80% line coverage, includes schema parse + TS-Zod type compat + negative cases +- [ ] Accessible: ARIA roles correct, keyboard nav for Sitemap tree +- [ ] index.ts appends both exports (named + types) + +## Termination signal +Append `T-002 completed` to .cofoundy/state/history.jsonl. diff --git a/.cofoundy/archive/2026-05-16-atelier-components/tasks/T-003.md b/.cofoundy/archive/2026-05-16-atelier-components/tasks/T-003.md new file mode 100644 index 0000000..f5adea7 --- /dev/null +++ b/.cofoundy/archive/2026-05-16-atelier-components/tasks/T-003.md @@ -0,0 +1,33 @@ +--- +id: T-003 +title: "Build atelier-registry.ts SSOT + barrel + index.ts exports" +status: blocked +role_owner: infra-author +depends_on: [T-001, T-002] +priority: P1 +scope: + write: + - packages/ui/src/lib/atelier-registry.ts + - packages/ui/src/lib/atelier-schemas.ts + - packages/ui/src/index.ts (2-line append in atelier exports section) + read: + - .cofoundy/specs/architecture-v1.md (§2 registry shape, §4 Zod strategy) + - .cofoundy/specs/api-contract.md (§1 exports, §2 registry contract) + - All 9 .schema.ts files from T-001+T-002 +--- + +## Goal +Author registry SSOT + schemas barrel. Registry exposes 9 entries each `{ component, schema, description, example }`. Index.ts adds 2 public exports (`ATELIER_COMPONENTS`, types). + +## Acceptance criteria +- [ ] `ATELIER_COMPONENTS` satisfies `Record` (compile error catches drift) +- [ ] 9 entries: PersonaCard, MoodBoard, BuildProgress, ComparisonMatrix, KPIBoard, DesignSystemPanel, TestimonialCard, Sitemap, QuoteCard +- [ ] Alphabetical order for diff stability +- [ ] Each entry has non-empty `description` (string) + valid `example` (parses via its schema) +- [ ] `lib/atelier-schemas.ts` re-exports all 9 schemas (barrel for internal use) +- [ ] `src/index.ts` adds: `export { ATELIER_COMPONENTS }`, `export type { AtelierEntry, AtelierComponentName }` +- [ ] Per Q1=B: NO public named exports of individual schemas +- [ ] `pnpm build` succeeds; `pnpm test --coverage` ≥80% on lib/atelier-* + +## Termination signal +Append `T-003 completed` to history.jsonl. diff --git a/.cofoundy/archive/2026-05-16-atelier-components/tasks/T-004.md b/.cofoundy/archive/2026-05-16-atelier-components/tasks/T-004.md new file mode 100644 index 0000000..d5c30ce --- /dev/null +++ b/.cofoundy/archive/2026-05-16-atelier-components/tasks/T-004.md @@ -0,0 +1,34 @@ +--- +id: T-004 +title: "gen-atelier-agents-md.ts + AGENTS.md + CI gate" +status: blocked +role_owner: infra-author +depends_on: [T-003] +priority: P1 +scope: + write: + - packages/ui/scripts/gen-atelier-agents-md.ts + - packages/ui/AGENTS.md (auto-generated, committed) + - packages/ui/.github/workflows/verify-agents-md.yml + - packages/ui/package.json (deps + script) + read: + - .cofoundy/specs/architecture-v1.md §3 + - .cofoundy/specs/api-contract.md §3 (output format contract) + - packages/ui/src/lib/atelier-registry.ts (input source) +--- + +## Goal +Build deterministic AGENTS.md auto-generator from registry. Wire CI gate that fails on drift. + +## Acceptance criteria +- [ ] Script reads `ATELIER_COMPONENTS` + uses `zod-to-json-schema` to introspect props +- [ ] Output AGENTS.md byte-deterministic (only top ISO timestamp varies; body identical for unchanged input) +- [ ] AGENTS.md structure per api-contract.md §3 (alphabetical, per-component prop table + MDX example) +- [ ] `package.json` adds `zod` to dependencies, `zod-to-json-schema` + `tsx` to devDependencies (pinned caret), `gen:agents` npm script +- [ ] CI workflow runs `pnpm gen:agents && git diff --exit-code AGENTS.md` on PR; fails with message "Run `pnpm gen:agents` and commit updated AGENTS.md" +- [ ] Test: `rm AGENTS.md && pnpm gen:agents && git diff` produces empty diff +- [ ] Coverage ≥80% on `scripts/gen-atelier-agents-md.ts` +- [ ] **`package.json` `version` field bumped MINOR** (per api-contract.md §1 commitment: "this cycle ships as a packages/ui minor bump"). Amendment per ceo-agent 2026-05-16 Phase 5 gate. + +## Termination signal +Append `T-004 completed` to history.jsonl. diff --git a/.cofoundy/archive/2026-05-16-atelier-components/tasks/T-005.md b/.cofoundy/archive/2026-05-16-atelier-components/tasks/T-005.md new file mode 100644 index 0000000..930a162 --- /dev/null +++ b/.cofoundy/archive/2026-05-16-atelier-components/tasks/T-005.md @@ -0,0 +1,35 @@ +--- +id: T-005 +title: "Storybook stories — refresh 7 + new 2 + visual snapshots" +status: blocked +role_owner: infra-author +depends_on: [T-001, T-002] +priority: P1 +scope: + write: + - packages/ui/src/stories/docs/PersonaCard.stories.tsx + - packages/ui/src/stories/docs/MoodBoard.stories.tsx + - packages/ui/src/stories/docs/BuildProgress.stories.tsx + - packages/ui/src/stories/docs/ComparisonMatrix.stories.tsx + - packages/ui/src/stories/docs/KPIBoard.stories.tsx + - packages/ui/src/stories/docs/DesignSystemPanel.stories.tsx + - packages/ui/src/stories/docs/TestimonialCard.stories.tsx + - packages/ui/src/stories/docs/Sitemap.stories.tsx (NEW) + - packages/ui/src/stories/docs/QuoteCard.stories.tsx (NEW) + read: + - All 9 .tsx + .schema.ts from T-001/T-002 + - architecture-v1.md §1 + §6 (XGodel example data) +--- + +## Goal +Refresh 7 existing stories to exercise new patched props + canonical XGodel example data. Author 2 NEW stories. Capture baseline visual snapshots via Chrome MCP. + +## Acceptance criteria +- [ ] Each story: default variant + ≥1 variant exercising new props (for PATCH'd) or full range (for NEW) +- [ ] XGodel example data used where possible (PersonaCard: 3 personas from ux-research; MoodBoard: 4 concepts; etc.) +- [ ] Chrome MCP screenshot per story captured to `.cofoundy/state/visual-baselines/..png` +- [ ] Storybook builds clean: `pnpm storybook:build` succeeds +- [ ] All 9 stories visible at ui.cofoundy.dev after publish + +## Termination signal +Append `T-005 completed` to history.jsonl with snapshot file list. diff --git a/.cofoundy/archive/2026-05-16-atelier-components/tasks/T-006.md b/.cofoundy/archive/2026-05-16-atelier-components/tasks/T-006.md new file mode 100644 index 0000000..b73708a --- /dev/null +++ b/.cofoundy/archive/2026-05-16-atelier-components/tasks/T-006.md @@ -0,0 +1,40 @@ +--- +id: T-006 +title: "XGodel MDX dogfood — 5 client docs" +status: blocked +role_owner: mdx-author +depends_on: [T-003, T-004, chrome-cto:vault-init, chrome-cto:propuesta-mdx] +priority: P1 +scope: + write: + - cofoundy/products/cofoundy-platform/docs-ai/content/client/xgodel/personas.mdx + - cofoundy/products/cofoundy-platform/docs-ai/content/client/xgodel/sitemap.mdx + - cofoundy/products/cofoundy-platform/docs-ai/content/client/xgodel/brand-moodboard.mdx + - cofoundy/products/cofoundy-platform/docs-ai/content/client/xgodel/cotizacion.mdx + - cofoundy/products/cofoundy-platform/docs-ai/content/client/xgodel/cronograma.mdx + - cofoundy/products/cofoundy-platform/docs-ai/content/client/xgodel/vault.yaml (append toc entries, serialized) + read: + - architecture-v1.md §6 (5-doc dogfood plan with absolute source paths) + - api-contract.md §5 (frontmatter contract) + - cofoundy/deals/clients/XGodel/ux-research/02-user-personas.md + 03-empathy-map.md + - cofoundy/deals/clients/XGodel/visual-design/concept-* + work/brand-paletas-v2/* + - cofoundy/deals/clients/XGodel/propuesta.html + addendum-pago-comprobante.md + - cofoundy/deals/clients/XGodel/cronograma.tex + roadmap-hitos.md + - cofoundy/projects/xgodel-landing/research/07-information-architecture.md + 06-userflow.md + 16-cro-plan.md +--- + +## Goal +Author 5 MDX docs that exercise 8 of 9 Atelier components on real XGodel content. Frontmatter per api-contract.md §5. + +## Acceptance criteria +- [ ] All 5 docs include `role: client`, `chrome: deliverable`, appropriate `kind:` +- [ ] cotizacion.mdx includes `recipient: { name: "John Medina", company: "XGodel", email: "medinadiazmeyor@gmail.com" }`, `expires_at: 2026-06-30` +- [ ] cronograma.mdx includes `source_freshness: manual-snapshot-2026-05-16` convention +- [ ] No Atelier component is referenced without matching registry entry (validated by build) +- [ ] No raw ` + + +
+
Telegram · fidelity
+ + + +
+ + +

chrome="consistent" — cromo Cofoundy fijo; solo el mensaje cambia por canal (la app)

+
+
+
WhatsApp · consistent
+ + + +
+
+
Telegram · consistent
+ + + +
+
+ +

chrome="branded" (T-027 E) — consumer palette, channel structure (WhatsApp/Telegram still differ)

+
+
+
WhatsApp · branded (Fovente red)
+ + + +
+
+
Telegram · branded (Fovente red) — same accent, still tails LAST + single-tick
+ + + +
+
+ + + + + diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 0000000..7ea9977 --- /dev/null +++ b/demo/index.html @@ -0,0 +1,129 @@ + + + + + + chat-sim — demo (wave 1: core + element + WhatsApp values) + + + + + +

cf-chat-sim — WhatsApp, guion real, seed determinista

+ + + + + + +
+ + + 9 +
+ + + + + + + + diff --git a/demo/parity-t027.html b/demo/parity-t027.html new file mode 100644 index 0000000..a0d3f00 --- /dev/null +++ b/demo/parity-t027.html @@ -0,0 +1,165 @@ + + + + + + chat-sim — T-027 paridad (scroll, loop, service, receipts, branded) + + + + + +

T-027 — scroll(scrollId) + loop + service + receipts + chrome="branded" + reply-in + link

+
+
+
WhatsApp · chrome="branded" (Fovente red) · loop
+ + + +
+
+
Telegram · chrome="fidelity" · mismo guion
+ + + +
+
+ + + + + + + + diff --git a/demo/rotation-badge-tag.html b/demo/rotation-badge-tag.html new file mode 100644 index 0000000..843037a --- /dev/null +++ b/demo/rotation-badge-tag.html @@ -0,0 +1,170 @@ + + + + + + chat-sim — T-031 (alto fijo, rotación, badge, etiqueta) + + + + + +

T-031 — alto fijo (gemelo: 2 guiones, mismo alto) + rotación + badge + etiqueta

+
+
+
default height (440px) · loop rotates catering ⇄ eventos
+ + + + +
+
+
height="300" (override) · mismos dos guiones
+ + + + +
+
+ + + + + + + + diff --git a/demo/telegram-vs-whatsapp.html b/demo/telegram-vs-whatsapp.html new file mode 100644 index 0000000..7225e65 --- /dev/null +++ b/demo/telegram-vs-whatsapp.html @@ -0,0 +1,129 @@ + + + + + + chat-sim — Telegram vs WhatsApp fidelity (T-013) + + + + + +
+
+
WhatsApp · claro
+ + + +
+
+
Telegram · claro
+ + + +
+
+ +
+
+
WhatsApp · oscuro
+ + + +
+
+
Telegram · oscuro
+ + + +
+
+ + + + + diff --git a/docs/reports/chat-sim-rewrite.mdx b/docs/reports/chat-sim-rewrite.mdx new file mode 100644 index 0000000..88cfc04 --- /dev/null +++ b/docs/reports/chat-sim-rewrite.mdx @@ -0,0 +1,221 @@ +# chat-sim — simulador de chat multi-canal + +**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. 33+ commits, ~13.6k líneas, 6 lanes en paralelo. + +**Estado final: 83 commits · 23.4k líneas · 361/362 · preview de la landing de Fovente regenerado y +verificado en navegador, sin mergear.** El único rojo del gate es ajeno y está fileado +(`cofoundy/ui#25`); se probó ajeno corriendo el control a HEAD sin el commit sospechoso. + +## Segunda mitad del ciclo: lo que encontró el operador mirando la pantalla + +Tras el cierre inicial, el operador revisó el render y encontró **ocho defectos que ningún test +cazó**. Es el dato más importante de todo el ciclo: + +| Reportó | Causa | +|---|---| +| El typing no se repetía | `play()` reescribía `data-step` ~60×/s ⇒ el re-render mataba el ciclo CSS | +| Telegram no se parece a Telegram | derivamos el lenguaje visual del modelo de **backend** (qué observa un bot), no del cliente | +| Telegram sí tiene doble tick | `receiptGlyph: 'single-tick'` era del transporte, no de la UI | +| ¿Por qué hay emojis? | `glyph: '🕐'` literal ⇒ la fuente del sistema ⇒ **PNG distinto por máquina** | +| Los colores de oscuro | `#005c4b`/`#2b5278` de la spec vs `#154D38`/`#3E6AA7` de sus clientes reales | +| Se fueron los doodles | trazo negro al 4.5% sobre fondo oscuro = invisible | +| No hay auto-scroll ni loop | `core` exponía `scrollId` y **ningún renderer lo consumía** | +| El reloj nunca transiciona | los guiones de demo tenían **0 pasos `receipt`** | +| La tarjeta no es nativa | el CTO racionalizó un primitivo inventado y salteó el real (`BUTTONS`/`LIST`) | + +**El patrón:** nuestros 9 gates verifican **consistencia interna** — que el renderer obedezca al +adapter. **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 **el baseline contra referencia externa era la tarea más importante que quedaba**, y no una +más de la lista. **Se hizo** (T-032, `819490f`): fidelidad medida contra fuentes primarias de +Telegram, no contra nuestra propia spec. + +Su hallazgo más incómodo: **Telegram real falla WCAG AA** en el autor de la cita en modo oscuro +(2.56:1, su color contra su propio fondo). Se resolvió por el eje `chrome` en vez de elegir entre +fidelidad y accesibilidad — `fidelity` reproduce el fallo porque está retratando una captura de otra +app; `consistent`/`branded` lo corrige porque ahí hay personas leyendo contenido real (T-025, +`7e64b90`). + +## Fallas de método del CTO, con su recurrencia + +| Falla | Veces | Diagnóstico | +|---|---|---| +| Cambio de contrato sin asignar consumidores | **5** | escribí la regla del `grep -rl` y no la corrí 4 veces; a la 5ª sí, y predijo los 3 rotos | +| Agentes vivos después de entregar | **2** | escribí "el shutdown es parte de aceptar la tarea" y dejé 2 idle una hora | +| Sondas propias que no medían | **6** | `eval` vacío, mutaciones que no aplicaban, rebuild sobre árbol sucio | +| Verificar contra el documento y no el código | **3** | máquina de entrega, `Int32Array`, y el wallpaper "no verificado" | + +**Una regla que depende de que el orquestador se acuerde no es una regla: es una intención.** Lo que +las haría reales es un gate — que el task graph no se pueda cerrar sin el output del grep adentro. + +## Límite conocido de la regla del grep + +Busca **símbolos**. No ve el grafo de **valores**: cambiar `#53bdeb` por +`var(--channel-whatsapp-read, #53bdeb)` rompió un test que afirmaba el color resuelto, y ningún +grep de símbolos lo habría anticipado. Hace falta una **segunda** herramienta, de estilo de test +(`grep` de `.style.color).toBe(` cruzado contra diffs que toquen `color:` en adapters), no una +extensión de la primera. + +--- + +## Qué se puede hacer con esto hoy + +| Capacidad | Cómo se comprueba | +|---|---| +| Un guion → PNG reproducible | `sha256` de dos corridas coincide; cambiar la seed lo rompe | +| El mismo guion en dos canales, con diferencias reales | colita en la primera vs la última de la racha; reacciones overlay vs fila propia; **Telegram sin estado "entregado"** | +| Montar en una landing Astro sin React | assert sobre el grafo del bundle: `react`/`react-dom` ausentes | +| Montar en la app de Fovente | `` React sobre el mismo `SimState` | +| Sonido por canal | digest PCM distinto por canal, con gemelo de determinismo | + +## Las 7 sondas que se ponen rojas de verdad + +Cada una verificada por el CTO **inyectando el defecto exacto**, no leyendo el reporte de la lane. + +| Sonda | Se pone roja cuando | +|---|---| +| Pureza de `core/` | aparece `Math.random`/`Date`/`fetch`/`window` — nombra archivo y token | +| Prohibición de Tailwind | una clase de utilidad entra a `chat-sim/**` | +| Cero React en `element/` | el grafo del bundle importa `react` | +| Frescura de bundles (×3) | el commiteado difiere del rebuild — **imprime el comando de fix** | +| Cero ciclos de imports | reproduce el ciclo exacto por nombre | +| Guion inválido por canal | `receipt:'delivered'` en Telegram ⇒ no compila | +| `seek` O(log n + 64) | un fold lineal rompe el contador **y** el escalado 500↔5000 | + +## El número, medido y no afirmado + +**N = 4,976 bytes (4.85 KB) = 7.3%** del fork de 66.1 KB de `inbox-ai/MessageBubble.tsx`. + +Cubre `ReactionPill` + `Ticks` + `DeliveryStatus`. **Excluye** el tombstone de borrado y el chequeo +inline de editado: están enredados en una función de render de 636 líneas y no tienen rango de bytes +limpio. Incluirlos habría inflado el número sin poder defenderlo. + +⚠️ **Esto corrige el encuadre que el CTO venía usando.** "Les devolvemos 68 KB" era falso. Lo real: +**se unifican las primitivas de presentación**; el resto de esos 66 KB es lógica de dominio de un +inbox real (adjuntos, plantillas, tombstones, edición) que un simulador no tiene por qué cubrir. + +## Los 5 bugs serios vivieron en costuras entre lanes + +Ninguno dentro de una tarea. En los cinco, **el que encontró no era el dueño del código**. + +| Bug | Encontró | Dueño | +|---|---|---| +| `channel="telegram"` renderizaba chrome de WhatsApp | `app` | `skin` | +| `Frame.t` absoluto ⇒ `t0` contado dos veces en cada timestamp | `core` (al promover) | `core` | +| `Int32Array` desborda con `t0` real ⇒ `seek` siempre devuelve el final | `capture` | `core` | +| Ciclo de imports por el barrel | `core` (al exportar) | `app` | +| Reacciones **nunca** renderizan, en ningún canal | `qa` | `app` | + +Los cinco pasan un code review. Se necesitaba que **otra lane los usara**. + +**Lección para el próximo ciclo:** las acceptance por tarea no cubren costuras por construcción. +Los dos instrumentos que sí las cubrieron fueron cruzados — el snapshot `element`↔`react` y el +cross-check `stateAtStep`↔`seek`. Exigir al menos un test cruzado por par de lanes que comparta +contrato. + +## Abierto — requiere decisión humana + +1. **`UNVERIFIED`: que la suite de captura pase de punta a punta en esta máquina.** + + Lo que **sí** está verificado: + - Los PNGs de evidencia son válidos — `sha256` re-computados por el CTO: runA == runB, + seed7 ≠ seed99. + - La fuga de sesiones está **arreglada y medida en vivo 3 veces** por `capture`, contando + sesiones antes/después, incluyendo el caso de captura forzada a fallar. + - La causa raíz resultó distinta de la que el CTO planteó: el `finally` sí corría; era el + `catch {}` de `close()` tragándose su propia falla contra el daemon degradado. + + Lo que **no** pude verificar: que los tests corran verdes de punta a punta. Fallan por + `CDP command timed out` — Chrome no alcanza a responder una llamada de DevTools. + + **Causa: presión de memoria de la máquina, no del código.** 252 MB libres de 24 GB, load ~3.8, + con **30 sesiones de otros proyectos** corriendo en paralelo. Contribuí al problema dejando 8 + agentes vivos después de que terminaran; liberarlos subió la memoria de 200 MB a 996 MB, y + volvió a bajar por carga ajena. + + **Intentos hechos:** cerrar mis sesiones de navegador · liberar los 8 agentes terminados · + re-correr con timeout de 580s · correr la suite aislada. + + **Para cerrarlo:** re-correr `npx vitest run src/components/chat-sim/capture` en una máquina + con headroom. Es una corrida, no trabajo. +2. **D-1 — línea de prior art en el README** al abrir el repo. No hay copia literal de `whatsimule` + (restricción con tripwire), así que MIT no impone atribución en build-time: es decisión + reputacional, no legal. Recomendación del CTO: incluirla. +3. **Merge a `main`.** No auto-aprobado 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. Cuando alguien re-resuelva ese lockfile —justo lo que adoptar el skin requiere— salta + de golpe cruzando un major de Tailwind. +4. **D-2 — deadline de la app de Fovente.** Sin respuesta se asumió que no hay. Si aparece, la + secuencia se invierte y es otra arquitectura, no un reordenamiento. + +## Deuda declarada, no reparada + +- Dos modelos de `Message` en el paquete, sin conversor. **Anotada en los propios archivos de tipos**, + no solo en `COMPONENTS.md`. +- Branch coverage 79.63% contra 80%: las ramas faltantes son inalcanzables sin el adapter de iMessage, + fuera de alcance por diseño. No se bajó el umbral para pintar verde. +- `main` de `packages/ui` ya estaba rojo antes del ciclo (`cofoundy/ui#21`). +- `cofoundy-orchestrator#138`: el gate de blast-radius falla **abierto** con un contexto mal tipado. + +## Cierre del ciclo — lo que entró después del primer "estado final" + +**Los dos defectos que reportó el operador mirando la pantalla, cerrados y con sonda:** + +- **El alto del mock se movía.** Fijado en `.cf-frame` (T-031). La sonda **en navegador real** mide + `getBoundingClientRect().height` avanzando **un mismo elemento** por toda su timeline y comparando + en cada `data-step` (`651b245` + `7b4d5ec`). El gemelo negativo quedó **permanente en la suite**: + con `height:auto` la lista de muestras a la deriva no está vacía — exactamente la lista que el test + positivo afirma vacía. Se re-demuestra en cada corrida en vez de depender de que alguien repita un + ritual manual. +- **El reloj no transicionaba.** No estaba roto: los guiones no tenían pasos de acuse y un mensaje + nace en `queued`. Sobre el mismo mensaje, scrubbeando `data-step`: reloj → ✓ → ✓✓ → ✓✓ azul. + +**El preview de la landing pasó de 2/10 a 8 completas + 1 parcial + 1 cortada** (`97f4646` en +`feat/chat-sim-preview`), con cada `sí` probado renderizándolo en navegador, no leyendo `types.ts`. +Lo parcial: la rotación cambia guion y etiqueta pero **no el contacto** (`#buildHead` lee +`contact-name` una sola vez al montar). Lo cortado: la card del brief, que no es primitiva nativa de +WhatsApp. + +### La sonda que no medía, y por qué importa más que el bug + +El test que supuestamente cubría el alto afirmaba que la **custom property estaba seteada**. Mutar el +default a `auto` lo dejaba 16/16 en verde: jsdom no hace layout, así que no puede medir un alto +renderizado. Es la 7ª sonda del ciclo que no medía nada. + +**La regla que sale de acá:** un gemelo demostrado una vez a mano es un ritual que nadie repite. El +gemelo tiene que vivir **dentro de la suite** como su propio `it()`, afirmando lo contrario del test +positivo sobre la misma medición. Patrón adoptado para el resto del paquete. + +### Tres gates del repo que no miden nada (fileados, no arreglados) + +Encontrados de paso al cerrar, verificados corriendo el comando: + +- **`npm run lint` sale 127** — eslint no instalado, **cero** archivos de config. Los invariantes 4 y + 5 de esta misma arquitectura ("purity by lint": `core/` prohíbe `Math.random`, `Date`, `fetch`, + `window`, `document`) están especificados como aplicados por lint. **El lint nunca corrió.** + `cofoundy/ui#24`. + **Mitigante medido:** `core/` **sí es puro de hecho** — grep de los 5 símbolos da 6 hits y los 6 + son comentarios declarando el invariante. Aguantó por convención, que es justo lo que la + arquitectura decía no querer depender de. +- **`session-lifecycle.test.ts` compara el set global de sesiones de navegador** ⇒ rojo + **determinista**, no flaky, desde que existe un segundo archivo que abre sesión. Estaba registrado + como "contención intermitente"; un gate que lo trate así reintentaría para siempre. + `cofoundy/ui#25`. +- **`tsc --noEmit` arrastra 2 errores preexistentes** en `hero-shader/`, cero en chat-sim. Importa + porque `tsc` es el único gate contra una mutación pura de tipos — TS los borra en runtime y ningún + test los ve. `cofoundy/ui#26`. EPIC: `cofoundy/ui#23`. + +### Dos fallas más del CTO en esta mitad + +- **Dispatch duplicado por leer mal a una lane.** Una lane mandó una notificación de idle repitiendo + su reporte anterior; lo leí como "no recibió el encargo", pedí su shutdown y lancé una segunda lane + sobre el mismo archivo. La segunda **haltó antes de escribir** y trajo la evidencia medida. La + fricción de la matriz de propiedad es el instrumento, no el costo. +- **Un HTTP 200 que respondía la pregunta equivocada.** Reporté "el dev server responde" como si + fuera "sirve mi artefacto". Servía un borrador huérfano, sobre otra rama, **con los dos defectos + todavía adentro**. Misma clase que los tres errores de estado-reportado-vs-efecto que ya llevaba el + ciclo, esta vez en mi propia sonda. diff --git a/package.json b/package.json index c8d92fa..fae06d5 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "@cofoundy/ui", "version": "0.6.1", "private": true, + "license": "MIT", "type": "module", "main": "./src/index.ts", "exports": { @@ -10,6 +11,9 @@ "./utils": "./src/utils/index.ts", "./hero-shader": "./src/components/hero-shader/index.ts", "./scripts/reveal-on-scroll": "./src/scripts/reveal-on-scroll.ts", + "./chat-sim": "./src/components/chat-sim/index.ts", + "./chat-sim/styles.css": "./src/components/chat-sim/styles.css", + "./chat-sim/element": "./src/components/chat-sim/element/index.ts", "./iife": { "import": "./dist/chat-widget.iife.js", "require": "./dist/chat-widget.iife.js" diff --git a/scripts/capture-chat.mjs b/scripts/capture-chat.mjs new file mode 100644 index 0000000..8b41401 --- /dev/null +++ b/scripts/capture-chat.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +// scripts/capture-chat.mjs — CLI entry point (api-contract.md § "Árbol": `scripts/capture-chat.mjs +// [capture]`). Deliberately thin: this repo has no native way to run a .ts file directly from +// plain `node`, and capture's scope.write excludes package.json, so it can't add a runtime loader +// dependency. `tsx` is already a devDependency (see `gen:agents`/`verify:agents` in package.json +// for the identical existing pattern) — this just locates it and hands off. All the real logic +// (arg parsing, compile(), captureFrame()) lives in capture/cli.ts, typed and unit-testable. +// +// Usage: +// node scripts/capture-chat.mjs --script path/to/script.json --seed 7 --channel whatsapp \ +// --locale es-PE --tz America/Lima --t0 1767261600000 --width 380 --dpr 2 --out shot.png +// `--t ` captures an exact instant instead of the script's final state (the default). + +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(here, '..'); +const tsxBin = join(repoRoot, 'node_modules', '.bin', 'tsx'); +const cliEntry = join(repoRoot, 'src/components/chat-sim/capture/cli.ts'); + +if (!existsSync(tsxBin)) { + console.error( + `capture-chat: tsx not found at ${tsxBin} — run \`npm install\` (tsx is an existing devDependency, not something this script installs).`, + ); + process.exit(1); +} + +try { + execFileSync(tsxBin, [cliEntry, ...process.argv.slice(2)], { stdio: 'inherit' }); +} catch (err) { + process.exit(typeof err.status === 'number' ? err.status : 1); +} diff --git a/src/__tests__/chat-sim/chatsim-cross-channel-states.test.tsx b/src/__tests__/chat-sim/chatsim-cross-channel-states.test.tsx new file mode 100644 index 0000000..e4b564d --- /dev/null +++ b/src/__tests__/chat-sim/chatsim-cross-channel-states.test.tsx @@ -0,0 +1,123 @@ +// __tests__/chat-sim/chatsim-cross-channel-states.test.tsx — qa's own write cell. +// +// react/__tests__/ChatSim.test.tsx ([app]'s own suite, out of qa's write cell) imports `ChatSim` +// directly from '../ChatSim', never through the public subpath barrel (`@cofoundy/ui/chat-sim`, +// i.e. `chat-sim/index.ts`) — so nothing exercises that barrel's own `ChatSim` re-export, and +// nothing there posts a message with a reaction, an edited label, or drives the Telegram-only +// `views` counter / own-row reaction layout. This closes those gaps through the SAME public +// `` contract, imported the way a real `@cofoundy/ui/chat-sim` consumer would. + +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ChatSim } from '../../components/chat-sim'; +import type { SimScript } from '../../components/chat-sim/core/types'; + +describe(' — imported via the public barrel', () => { + it('is reachable as @cofoundy/ui/chat-sim\'s own export (not just react/ChatSim.tsx directly)', () => { + const script: SimScript = [{ k: 'post', by: 'in', text: 'hola' }]; + render(); + expect(screen.getByText('hola')).toBeInTheDocument(); + }); + + // Was a KNOWN BUG (.cofoundy/tasks/T-010.md, role_owner: app) — the `&&` operator-precedence + // defect in react/MessageThread.tsx:169-170 that made `reactionsInsideBubble`/`reactionsOwnRow` + // plain booleans instead of the JSX element. Fixed by [app] in `7f88aae`; team-lead verified + // with mutation (restoring the original `&&` breaks 3 of these 4 scenarios). Flipped from + // `it.fails` back to a normal `it` — the tripwire did its job. + it('WhatsApp: a reaction renders as an overlay pill anchored to its message', () => { + const script: SimScript = [ + { k: 'post', by: 'in', text: 'reservado!' }, + { k: 'react', id: 'm0', emoji: '❤', by: 'out:ai' }, + ]; + const { container } = render(); + const reactions = container.querySelector('.cf-reactions'); + expect(reactions).not.toBeNull(); + expect(reactions?.getAttribute('data-style')).toBe('overlay-below'); // adapters/whatsapp.ts + expect(reactions?.textContent).toContain('❤'); + }); + + it('an edited message carries the caller-supplied editedLabel', () => { + const script: SimScript = [ + { k: 'post', by: 'out:ai', text: 'reservado para 4' }, + { k: 'edit', id: 'm0', v: 1 }, + ]; + render(); + expect(screen.getByText('Editado')).toBeInTheDocument(); + }); + + it('a deleted message never renders in the thread', () => { + const script: SimScript = [ + { k: 'post', by: 'in', text: 'oops no debía salir' }, + { k: 'delete', id: 'm0', scope: 'all' }, + ]; + render(); + expect(screen.queryByText('oops no debía salir')).not.toBeInTheDocument(); + }); + + // Was: expected `counter:'views'` on Telegram's 1:1/group adapter. Retired by T-012 + // (adapters/telegram.ts:43 — 👁 N is broadcast-only, and this cycle's Telegram adapter models + // 1:1/group, so `counter` is 'none' there too, same as WhatsApp). Rewritten against the live + // contract: the ticks' GLYPH flips at read (color doesn't, telegram-fidelity-fix.md §F-2), and + // the `.cf-views` slot never renders through this adapter. + it('Telegram: tick glyph flips at read (color fixed) — no .cf-views, the slot is broadcast-only (T-012)', () => { + const sentScript: SimScript = [ + { k: 'post', by: 'out:human:agent_1', text: 'promo del finde' }, + { k: 'receipt', id: 'm0', to: 'sent' }, + ]; + const readScript: SimScript = [...sentScript, { k: 'receipt', id: 'm0', to: 'read' }]; + const { container: sentContainer } = render(); + const { container: readContainer } = render(); + const sentTick = sentContainer.querySelector('svg.cf-receipt')!; + const readTick = readContainer.querySelector('svg.cf-receipt')!; + expect(sentTick.querySelectorAll('path')).toHaveLength(1); // single tick + expect(readTick.querySelectorAll('path')).toHaveLength(2); // double tick — glyph flipped + expect(sentTick.style.color).toBe(readTick.style.color); // color fixed on Telegram + expect(sentContainer.querySelector('.cf-views')).toBeNull(); + expect(readContainer.querySelector('.cf-views')).toBeNull(); + }); + + // Same root cause as the WhatsApp case above (T-010, fixed in `7f88aae`) — the `own-row` + // branch was also a boolean, never the element. + it('Telegram: reactions render in their own row (own-row), not WhatsApp\'s overlay', () => { + const script: SimScript = [ + { k: 'post', by: 'out:human:agent_1', text: 'promo del finde' }, + { k: 'react', id: 'm0', emoji: '🔥', by: 'in' }, + ]; + const { container } = render(); + expect(container.querySelector('.cf-reactions')?.getAttribute('data-style')).toBe('own-row'); + }); + + // Was: expected a `[data-read]` attribute. Retired in the migration to `ReceiptModel` + // (adapters/whatsapp.ts:25 documents it) — color is now adapter data (`style.color`), not a DOM + // attribute. Rewritten against the live contract: WhatsApp keeps the glyph fixed (double-check) + // and flips COLOR to `#53bdeb` at read (telegram-fidelity-fix.md §F-2). + // + // Not a `rgb(83, 189, 235)` literal check: since `channel`'s e65f069, the `read` state's color + // is `var(--channel-whatsapp-read, #53bdeb)` (adapters/whatsapp.ts:40) so a `branded` chrome + // consumer can retint it — jsdom's CSSOM stores that string as-is and never resolves `var()` + // (a real browser does; team-lead/skin verified in Chrome). Asserting the resolved rgb() here + // would just be wrong in jsdom, not stale. So this checks the two things that ARE inspectable + // without a browser and that make up the actual contract: the color routes through the + // brand-override custom property, and its fallback still matches stock WhatsApp. The + // read-vs-delivered distinction (the axis this test exists for) is the `not.toBe` below, which + // holds on the raw strings regardless of var() resolution — different var name in, different + // string out. + it('WhatsApp: tick color flips to blue at read (glyph fixed at double-check) — the tick is ReceiptModel data', () => { + const deliveredScript: SimScript = [ + { k: 'post', by: 'out:ai', text: 'listo' }, + { k: 'receipt', id: 'm0', to: 'delivered' }, + ]; + const readScript: SimScript = [...deliveredScript, { k: 'receipt', id: 'm0', to: 'read' }]; + const { container: deliveredContainer } = render( + , + ); + const { container: readContainer } = render(); + const deliveredTick = deliveredContainer.querySelector('svg.cf-receipt')!; + const readTick = readContainer.querySelector('svg.cf-receipt')!; + expect(deliveredTick.querySelectorAll('path')).toHaveLength(2); + expect(readTick.querySelectorAll('path')).toHaveLength(2); // glyph fixed + expect(deliveredTick.style.color).not.toBe(readTick.style.color); // color flipped (twin axis: WhatsApp=color, Telegram=glyph) + expect(readTick.style.color).toContain('var(--channel-whatsapp-read'); // routes through the brand-override slot + expect(readTick.style.color).toContain('#53bdeb'); // fallback preserves stock WhatsApp fidelity unoverridden + }); +}); diff --git a/src/__tests__/chat-sim/fidelity-baseline.test.ts b/src/__tests__/chat-sim/fidelity-baseline.test.ts new file mode 100644 index 0000000..81e037f --- /dev/null +++ b/src/__tests__/chat-sim/fidelity-baseline.test.ts @@ -0,0 +1,163 @@ +// __tests__/chat-sim/fidelity-baseline.test.ts — qa's own write cell (T-032 Part A / T-014). +// +// Team-lead's framing: "los 8 defectos reales de este ciclo los encontró el operador mirando la +// pantalla. Ninguno lo cazó un test." Not for lack of tests — 302 green, 9 gates that redden under +// mutation. The problem is CLASS: every existing gate (receipt-model.test.ts, wallpaper-contrast +// .test.ts, caps.test.ts...) verifies that the renderer/type OBEYS the adapter — internal +// consistency. None of them verify that the adapter's VALUE is right. A wrong value passes every +// one of those gates, because the DOM still changes when the adapter changes — just toward the +// wrong thing. +// +// This file is that missing instrument: a table of assertions against `.cofoundy/specs +// /telegram-fidelity-fix.md`'s own findings, each citing the PRIMARY SOURCE the spec itself cites +// (tdesktop's `chat.style` / `colors.palette` / `night.tdesktop-theme`, or the operator's own +// real-client screenshot where the spec's inferred value was contradicted) — not the spec's prose, +// and never a value this file invents. Reads the REAL `ChannelAdapter` objects (`adapters/telegram +// .ts`, `adapters/whatsapp.ts`), never a local re-typed fixture — same discipline +// `core/__tests__/receipt-model.test.ts` already enforces for its two live rows. +// +// Mandatory twin (team-lead's explicit acceptance bar): reverting Telegram's `receipt.states` back +// to the old simple-tick shape (glyph fixed, color varies — the exact §F-1 bug that slipped +// through an entire cycle) MUST break this instrument. If it doesn't, the instrument doesn't earn +// its name. + +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { telegram } from '../../components/chat-sim/adapters/telegram'; +import { whatsapp } from '../../components/chat-sim/adapters/whatsapp'; +import type { ChannelAdapter, TicksReceiptModel } from '../../components/chat-sim/core/types'; + +const SPEC_PATH = join(__dirname, '..', '..', '..', '.cofoundy', 'specs', 'telegram-fidelity-fix.md'); +const SPEC = readFileSync(SPEC_PATH, 'utf8'); + +function asTicks(adapter: ChannelAdapter): TicksReceiptModel { + if (adapter.receipt.kind !== 'ticks') throw new Error('expected a kind:"ticks" receipt model'); + return adapter.receipt; +} + +// --------------------------------------------------------------------------------------------- +// External-reference baseline. Each entry documents, in prose, the primary-source citation next +// to the runnable assertion below it — the two are kept adjacent on purpose (a citation nobody can +// see next to the check it justifies is not a citation, it's a footnote). +// --------------------------------------------------------------------------------------------- + +describe('Fidelity baseline — WhatsApp receipt (telegram-fidelity-fix.md §F-1, row "WhatsApp")', () => { + // Source: §F-1 table, "WhatsApp | el color (✓✓ gris → ✓✓ azul) | glifo constante, color + // variable". Real WhatsApp: sending(clock) -> sent(1 check) -> delivered(2 check, gray) -> + // read(2 check, blue). The glyph is constant ACROSS delivered->read specifically (both + // double-check) — that pair is what T-011's contract had to be able to express, and what the + // old flat `receiptGlyph` enum could not. + it('glyph is fixed from delivered→read; color is the only thing that flips at read', () => { + const r = asTicks(whatsapp); + expect(r.states.delivered.glyph).toBe('double-check'); + expect(r.states.read.glyph).toBe('double-check'); + expect(r.states.delivered.color).not.toBe(r.states.read.color); + }); +}); + +describe('Fidelity baseline — Telegram 1:1/group receipt (telegram-fidelity-fix.md §F-1/§F-2)', () => { + // Source: §F-1 table, "Telegram 1:1 / grupo | el glifo (✓ → ✓✓), color fijo | color constante, + // glifo variable" — the exact inverse of WhatsApp's row above. This IS the bug the operator + // caught ("doble tick") after it survived a full cycle behind green gates. + it('glyph flips sent→read (single tick to double tick); color is constant', () => { + const r = asTicks(telegram); + expect(r.states.sent.glyph).toBe('check'); + expect(r.states.read.glyph).toBe('double-check'); + expect(r.states.sent.glyph).not.toBe(r.states.read.glyph); + expect(r.states.sent.color).toBe(r.states.read.color); + }); + + // Source: §F-2 table, "Telegram, contador de vistas | en todo mensaje | solo canales + // broadcast; en 1:1 el slot lo ocupan los ticks". This adapter models 1:1/group (no separate + // broadcast-channel adapter is wired this cycle — core/__tests__/receipt-model.test.ts's + // `metric` row is a paper-proof fixture, not a live adapter), so the real adapter's own + // `counter` field must read 'none', never 'views'. + it('has no view-counter — the 👁 N slot is broadcast-only, not this (1:1/group) adapter', () => { + expect(telegram.counter).toBe('none'); + }); + + // Source: §F-1 table, "Telegram 1:1 / grupo | ... | color fijo" — 1:1/group tickets, unlike + // WhatsApp, have no reachable `delivered` state (queued -> sent -> read only, per + // adapter-interface-draft.md's `deliveryStates` column). Asserted on the adapter's OWN field. + it('has no reachable delivered state (queued → sent → read only)', () => { + expect(telegram.deliveryStates).not.toContain('delivered'); + }); + + // MANDATORY TWIN (team-lead: "revertir receipt.states de Telegram al tick simple debe + // romperlo. Ese fue el bug original que se nos escapó; si el baseline no lo caza, no sirve."). + // Builds the OLD, wrong shape by hand — glyph pinned to a single tick across sent→read, color + // doing the varying instead (the WhatsApp-shaped answer, wrong on Telegram) — and proves the + // exact assertions above flip to failing on it. This is not a hypothetical: it is the literal + // regression a `git revert` of T-011/T-012 on this one field would reintroduce. + it('gemelo obligatorio: reverting to the old simple-tick shape (glyph fixed, color varies) breaks the checks above', () => { + const regressedTelegram: ChannelAdapter = { + ...telegram, + receipt: { + kind: 'ticks', + placement: telegram.receipt.placement, + scope: telegram.receipt.scope, + states: { + queued: { glyph: 'clock', color: 'var(--cf-cs-bubble-out-meta)' }, + sent: { glyph: 'check', color: 'var(--cf-cs-bubble-out-meta)' }, + delivered: { glyph: 'check', color: 'var(--cf-cs-bubble-out-meta)' }, + // old (wrong) model: glyph pinned to 'check', color flips instead of the glyph + read: { glyph: 'check', color: 'var(--channel-telegram-read, #37a1de)' }, + failed: { glyph: 'alert', color: '#e53935' }, + }, + }, + }; + const r = asTicks(regressedTelegram); + // The real baseline demands sent.glyph !== read.glyph AND sent.color === read.color. + // The regressed shape inverts BOTH — proving the real assertions above are falsifiable, + // not vacuously true. + expect(r.states.sent.glyph).toBe(r.states.read.glyph); // WRONG: glyph is now constant + expect(r.states.sent.color).not.toBe(r.states.read.color); // WRONG: color now varies + }); +}); + +describe('Fidelity baseline — structural facts that are adapter fields, not chrome (adapter-interface-draft.md)', () => { + // These four are load-bearing per-channel facts modeled as literal ChannelAdapter enum values + // (not brand color/texture, which the family deliberately keeps OUT of the 17-field contract — + // see styles.css's own header comment). Regression here means the wrong RENDER SHAPE, not just + // the wrong color — e.g. Telegram's reply quote losing its `thin-bar` treatment for WhatsApp's + // `color-bar` would be exactly this class of bug and none of the existing gates key on it. + it('WhatsApp: quote style is color-bar, timestamp sits inside the padded corner', () => { + expect(whatsapp.quote).toBe('color-bar'); + expect(whatsapp.timestamp).toBe('inside-pad'); + }); + + it('Telegram: quote style is thin-bar, timestamp sits inline/plain (no pad reservation)', () => { + expect(telegram.quote).toBe('thin-bar'); + expect(telegram.timestamp).toBe('inside-plain'); + }); + + it('both channels use a doodle-textured wallpaper (adapter.wallpaper === pattern)', () => { + expect(whatsapp.wallpaper).toBe('pattern'); + expect(telegram.wallpaper).toBe('pattern'); + }); +}); + +// --------------------------------------------------------------------------------------------- +// The 3 facts the research explicitly could NOT verify (telegram-fidelity-fix.md § "No +// verificado"). Team-lead: "quedan como tales, no rellenadas — ya cometimos el error de +// implementar un valor no-verificado como si lo estuviera (el wallpaper invisible salió de ahí)". +// This describe block does not assert a pixel value for any of the three — doing so would be +// exactly that mistake again. Instead it's a drift detector on the SPEC's own caveat: if someone +// edits telegram-fidelity-fix.md to quietly drop one of these three admissions (implying it got +// "verified" without anyone re-running this task), the test goes red and forces a look — the +// caveat can't silently rot into an implied verified fact. +// --------------------------------------------------------------------------------------------- +describe('No verificado — marcado como tal, nunca rellenado (T-032 delta)', () => { + it('spec still flags the exact read-tick color as unconfirmed (not measured byte-for-byte)', () => { + expect(SPEC).toContain('no confirmado byte a byte'); + }); + + it('spec still flags the live server wallpaper as possibly an animated gradient (not this static doodle)', () => { + expect(SPEC).toContain('gradiente animado de 4 colores'); + }); + + it('spec still flags the tail silhouette as described, not measured', () => { + expect(SPEC).toContain('descrita por silueta, no medida'); + }); +}); diff --git a/src/__tests__/chat-sim/inbox-message-adapter.test.ts b/src/__tests__/chat-sim/inbox-message-adapter.test.ts new file mode 100644 index 0000000..a05780f --- /dev/null +++ b/src/__tests__/chat-sim/inbox-message-adapter.test.ts @@ -0,0 +1,126 @@ +// __tests__/chat-sim/inbox-message-adapter.test.ts — qa's own write cell. +// +// Runtime behavior of stories/chat-sim/lib/inboxMessageAdapter.ts on top of the compile-time +// check T-008 acceptance #1 asks for (the fixtures themselves are typed against the REAL +// inbox-ai `Message`, not a hand-copied mirror — see inboxMessageFixtures.ts). A type that +// compiles proves the SHAPE lines up; these tests prove the VALUES map correctly. + +import { describe, expect, it } from 'vitest'; +import { compile } from '../../components/chat-sim/core/compile'; +import { seek } from '../../components/chat-sim/core/seek'; +import { getAdapter } from '../../components/chat-sim/adapters/registry'; +import { validateScript } from '../../components/chat-sim/adapters/validate'; +import { + isPostableDeliveryStatus, + messageToActorId, + messagesToScript, + messageToSteps, +} from '../../stories/chat-sim/lib/inboxMessageAdapter'; +import { + AI_DRAFT_PENDING, + ALL_FIXTURES, + INBOUND_TEXT, + OUTBOUND_AI_READ, + OUTBOUND_EDITED, + OUTBOUND_SOFT_DELETED, + TELEGRAM_CHANNEL_POST, +} from '../../stories/chat-sim/lib/inboxMessageFixtures'; + +describe('messageToActorId()', () => { + it('inbound -> "in", regardless of sender.type', () => { + expect(messageToActorId(INBOUND_TEXT)).toBe('in'); + }); + it('outbound + sender.type "ai" -> "out:ai"', () => { + expect(messageToActorId(OUTBOUND_AI_READ)).toBe('out:ai'); + }); + it('outbound + sender.type "agent" -> "out:human:" (never a bare "out")', () => { + expect(messageToActorId(OUTBOUND_EDITED)).toBe('out:human:agent_1'); + }); +}); + +describe('isPostableDeliveryStatus()', () => { + it('accepts the 5 real DeliveryState values', () => { + for (const s of ['queued', 'sent', 'delivered', 'read', 'failed']) { + expect(isPostableDeliveryStatus(s)).toBe(true); + } + }); + it('rejects "draft"/"discarded" — a different concept from chat-sim\'s own draft Ev', () => { + expect(isPostableDeliveryStatus('draft')).toBe(false); + expect(isPostableDeliveryStatus('discarded')).toBe(false); + }); +}); + +describe('messageToSteps()', () => { + it('a held-for-approval AI draft has no SimStep — excluded, not coerced', () => { + expect(messageToSteps(AI_DRAFT_PENDING)).toBeNull(); + }); + + it('reads metadata.edited (not edited_at) to decide the edit step', () => { + const steps = messageToSteps(OUTBOUND_EDITED)!; + expect(steps.some((s) => s.k === 'edit')).toBe(true); + // Same content, minus `metadata.edited` — edited_at is still set, proving the mapper + // really branches on metadata.edited and not the more-obvious-looking field. + const notFlagged = { ...OUTBOUND_EDITED, metadata: {} }; + expect(messageToSteps(notFlagged)!.some((s) => s.k === 'edit')).toBe(false); + }); + + it('a soft-deleted message gets post + delete, never a receipt step', () => { + const steps = messageToSteps(OUTBOUND_SOFT_DELETED)!; + expect(steps.map((s) => s.k)).toEqual(['post', 'delete']); + }); + + it('an ordinary posted message gets post + receipt, mapped 1:1 from delivery_status', () => { + const steps = messageToSteps(OUTBOUND_AI_READ)!; + expect(steps.map((s) => s.k)).toEqual(['post', 'receipt']); + const receipt = steps.find((s) => s.k === 'receipt'); + expect(receipt).toMatchObject({ to: 'read' }); + }); +}); + +describe('messagesToScript() — end to end through the real compile()/fold() pipeline', () => { + it('assembles a script that compiles clean for whatsapp and reproduces the mapped states', () => { + const script = messagesToScript(ALL_FIXTURES); + const diagnostics = validateScript(script, 'whatsapp'); + expect(diagnostics).toEqual([]); + + const tl = compile(script, { seed: 1, channel: 'whatsapp', locale: 'es-PE', tz: 'America/Lima', t0: 0 }); + const finalState = seek(tl, tl.duration); + + // AI_DRAFT_PENDING is excluded -> only 4 of the 5 fixtures ever get a MsgId. + expect(finalState.msgs.size).toBe(4); + + const read = finalState.msgs.get('m1')!; // OUTBOUND_AI_READ is the 2nd postable fixture + expect(read.receipt).toBe('read'); + expect(read.text).toBe(OUTBOUND_AI_READ.content); + + const edited = finalState.msgs.get('m2')!; // OUTBOUND_EDITED + expect(edited.v).toBeGreaterThan(0); + + const deleted = finalState.msgs.get('m3')!; // OUTBOUND_SOFT_DELETED + expect(deleted.deleted).toBe('all'); + // `SimState.order` itself still lists a deleted message — `delete` doesn't remove the entry, + // it flags it. The VISIBILITY filter (react/ChatSim.tsx's `visibleAt`, element/'s equivalent) + // is what drops it, via exactly this predicate — asserted here so the named divergence from + // the real product's tombstone (SystemPlaceholder) can't silently regress into "looks + // deleted" vs "actually still shows something". + expect(finalState.order.includes('m3')).toBe(true); + const visibleIds = finalState.order.filter((id) => finalState.msgs.get(id)?.deleted === null); + expect(visibleIds.includes('m3')).toBe(false); + }); + + it('Telegram: the same delivery_status vocabulary still validates (no "delivered" used)', () => { + const script = messagesToScript([TELEGRAM_CHANNEL_POST]); + expect(validateScript(script, 'telegram')).toEqual([]); + // Sanity: whatsapp's adapter has 'delivered', telegram's does not (adapters/telegram.ts) — + // proves this isn't a vacuously-true check against an adapter that accepts everything. + expect(getAdapter('telegram').deliveryStates).not.toContain('delivered'); + expect(getAdapter('whatsapp').deliveryStates).toContain('delivered'); + }); + + it('gemelo positivo: a script that DOES use an unsupported delivery state for the channel is caught', () => { + const invalidForTelegram = messagesToScript([{ ...OUTBOUND_AI_READ, delivery_status: 'delivered' }]); + const diagnostics = validateScript(invalidForTelegram, 'telegram'); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].code).toBe('unsupported-delivery-state'); + }); +}); diff --git a/src/__tests__/chat-sim/playhead-live-cycle.test.ts b/src/__tests__/chat-sim/playhead-live-cycle.test.ts new file mode 100644 index 0000000..f7c7bdd --- /dev/null +++ b/src/__tests__/chat-sim/playhead-live-cycle.test.ts @@ -0,0 +1,152 @@ +// __tests__/chat-sim/playhead-live-cycle.test.ts — qa's own write cell +// (file-ownership-matrix.md: `src/__tests__/chat-sim/**` is `W` for qa). +// +// core/__tests__/playhead.test.ts (T-001/[core]'s own suite) covers construction, +// onFrame unsubscribe, and pause-before-play — it never calls `.play()` and lets a real +// requestAnimationFrame tick fire, so `tick()`/`emit()`'s bodies (playhead.ts lines 31-47) never +// execute. This drives the full play -> tick -> pause / play -> tick -> completion cycle through +// `createPlayhead`'s PUBLIC API (read-only on `core/**`, same as every other lane) with a +// deterministic rAF stub instead of a real one, so the test doesn't depend on wall-clock timing. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { compile } from '../../components/chat-sim/core/compile'; +import { createPlayhead } from '../../components/chat-sim/core/playhead'; +import type { CompileOptions, SimScript } from '../../components/chat-sim/core/types'; + +const SCRIPT: SimScript = [ + { k: 'post', by: 'in', text: 'uno' }, + { k: 'post', by: 'out:ai', text: 'dos', delayMs: 100 }, +]; + +const OPTS: CompileOptions = { seed: 3, channel: 'whatsapp', locale: 'es-PE', tz: 'America/Lima', t0: 0 }; + +/** Deterministic rAF stub: queues callbacks, `flush(dtMs)` invokes the pending one with a + * caller-controlled wall time and returns whether another frame got (re)scheduled. */ +function stubRaf() { + let pending: ((t: number) => void) | null = null; + let lastEverScheduled: ((t: number) => void) | null = null; + let now = 0; + let cancelled = false; + vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => { + pending = cb; + lastEverScheduled = cb; + cancelled = false; + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', () => { + cancelled = true; + pending = null; + }); + return { + flush(dtMs: number): boolean { + now += dtMs; + const cb = pending; + pending = null; + if (!cb) return false; + cb(now); + return pending !== null; // true iff tick() rescheduled another frame + }, + get isCancelled() { + return cancelled; + }, + /** The last-scheduled callback, kept even across a `cancelAnimationFrame` — real browsers + * don't guarantee a frame already being dispatched observes a synchronous cancel either. + * Exists only to drive the race in the "stale tick fires after pause" test below. */ + get lastScheduled() { + return lastEverScheduled; + }, + }; +} + +describe('createPlayhead() — full play/tick/pause/completion cycle', () => { + let raf: ReturnType; + + beforeEach(() => { + raf = stubRaf(); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('play() schedules a frame; the first tick establishes lastWall without advancing virtualT', () => { + const tl = compile(SCRIPT, OPTS); + const ph = createPlayhead(tl); + const frames: number[] = []; + ph.onFrame((_s, t) => frames.push(t)); + + ph.play(); + raf.flush(16); // first tick: lastWall was null, so virtualT stays 0 (playhead.ts's own comment) + expect(frames).toEqual([0]); + }); + + it('subsequent ticks accumulate wall-delta * rate() — reactive, not frozen at play()', () => { + const tl = compile(SCRIPT, OPTS); + const ph = createPlayhead(tl); + const frames: number[] = []; + ph.onFrame((_s, t) => frames.push(t)); + + ph.play(); + raf.flush(0); // establishes lastWall + raf.flush(50); // +50ms * rate 1 + ph.rate(2); + raf.flush(50); // +50ms * rate 2 = +100 + expect(frames).toEqual([0, 50, 150]); + }); + + it('pause() mid-play cancels the scheduled frame and stops future accumulation', () => { + const tl = compile(SCRIPT, OPTS); + const ph = createPlayhead(tl); + const frames: number[] = []; + ph.onFrame((_s, t) => frames.push(t)); + + ph.play(); + raf.flush(0); + raf.flush(50); + ph.pause(); + expect(raf.isCancelled).toBe(true); + + // `raf.flush()` itself can't hit tick()'s `if (!playing) return` — my own + // `cancelAnimationFrame` stub clears `pending`, so `flush` sees nothing queued and never + // calls `cb` at all. A real browser gives no such guarantee (a frame already being + // dispatched can still run after a synchronous cancel) — invoking the STALE callback + // directly (`raf.lastScheduled`, kept regardless of cancellation) reproduces that race. + raf.lastScheduled?.(99999); + expect(frames).toEqual([0, 50]); // no third frame — tick()'s early return held + }); + + it('play() while already playing is a no-op (does not reset lastWall or reschedule twice)', () => { + const tl = compile(SCRIPT, OPTS); + const ph = createPlayhead(tl); + const frames: number[] = []; + ph.onFrame((_s, t) => frames.push(t)); + + ph.play(); + raf.flush(0); + raf.flush(50); + ph.play(); // already playing — `if (playing) return` — must NOT clear lastWall + const scheduledAnother = raf.flush(25); + expect(scheduledAnother).toBe(true); + expect(frames).toEqual([0, 50, 75]); // 75, not 25 — proves lastWall survived the 2nd play() + }); + + it('reaching tl.duration stops the loop: no further frame is scheduled, playing flips false', () => { + const tl = compile(SCRIPT, OPTS); + const ph = createPlayhead(tl); + const frames: number[] = []; + ph.onFrame((_s, t) => frames.push(t)); + + ph.play(); + raf.flush(0); + const scheduledMore = raf.flush(tl.duration + 1000); // overshoot past the end, clamped by emit() + expect(scheduledMore).toBe(false); // tick() took the `else` branch: playing=false, no reschedule + expect(frames.at(-1)).toBe(tl.duration); // emit() clamps virtualT to tl.duration (Math.min) + + // play() again after natural completion re-arms scheduling (lastWall reset to null) but + // does NOT reset virtualT — so the very next tick immediately re-completes (virtualT is + // already >= tl.duration), a real quirk of this playhead, not a test mistake: "replay" + // is `element/chat-sim-element.ts`'s job (it re-seeks to 0 itself before calling play()). + ph.play(); + expect(raf.flush(0)).toBe(false); + expect(frames.at(-1)).toBe(tl.duration); + }); +}); diff --git a/src/__tests__/chat-sim/stories-smoke.test.tsx b/src/__tests__/chat-sim/stories-smoke.test.tsx new file mode 100644 index 0000000..6d4a3e2 --- /dev/null +++ b/src/__tests__/chat-sim/stories-smoke.test.tsx @@ -0,0 +1,39 @@ +// __tests__/chat-sim/stories-smoke.test.tsx — qa's own write cell. +// +// Storybook itself isn't booted in this test run, so nothing else catches a story that throws +// at render time (a bad script, a channel/adapter mismatch, a typo in an id reference). This +// renders every exported story from every chat-sim story file, with the SAME arg-merging +// Storybook does (meta.args + story.args), so a broken story fails `vitest run`, not just a +// human clicking through Storybook later. + +import { describe, expect, it } from 'vitest'; +import { render } from '@testing-library/react'; +import { createElement } from 'react'; +import { ChatSim } from '../../components/chat-sim'; +import * as InboxAIReplacement from '../../stories/chat-sim/InboxAIReplacement.stories'; +import * as ChatSimStates from '../../stories/chat-sim/ChatSimStates.stories'; +import * as ChatSimChannels from '../../stories/chat-sim/ChatSimChannels.stories'; + +type StoryModule = { + default: { args?: Record }; + [key: string]: unknown; +}; + +function storiesOf(mod: StoryModule): Array<[string, Record]> { + const metaArgs = mod.default.args ?? {}; + return Object.entries(mod) + .filter(([name]) => name !== 'default') + .map(([name, story]) => [name, { ...metaArgs, ...(story as { args?: Record }).args }]); +} + +describe.each([ + ['InboxAIReplacement', InboxAIReplacement as unknown as StoryModule], + ['ChatSimStates', ChatSimStates as unknown as StoryModule], + ['ChatSimChannels', ChatSimChannels as unknown as StoryModule], +])('%s stories render without throwing', (_fileName, mod) => { + for (const [name, args] of storiesOf(mod)) { + it(name, () => { + expect(() => render(createElement(ChatSim, args as never))).not.toThrow(); + }); + } +}); diff --git a/src/__tests__/chat-sim/telegram-quote-contrast.test.ts b/src/__tests__/chat-sim/telegram-quote-contrast.test.ts new file mode 100644 index 0000000..2bcce5d --- /dev/null +++ b/src/__tests__/chat-sim/telegram-quote-contrast.test.ts @@ -0,0 +1,194 @@ +// __tests__/chat-sim/telegram-quote-contrast.test.ts — qa's own write cell (T-032 Part B / T-025). +// +// T-025 (role: skin, filed by skin during T-023) already reproduces this exact finding — +// `.cf-quote-author`'s Telegram-scoped rules (`--channel-telegram` / `--channel-telegram-out`) +// fail WCAG AA in 3 of 4 real combinations — with `styles.css` + `element/__tests__/ +// wallpaper-contrast.test.ts` as its `scope.write`. Both files are `skin`'s single-writer cell +// (`file-ownership-matrix.md`: `styles.css` row has exactly one `W`, `qa` gets `–`) — this task +// asks qa to extend "the test that already exists" for the same finding, which is a real +// collision: qa cannot write into `wallpaper-contrast.test.ts` without creating a second writer +// on a file the matrix says must have exactly one. Resolved by building an equivalent instrument +// here, in qa's own cell, that (a) proves the same 3/4 failure against the REAL, live styles.css +// (not a hand-typed copy of the hex values), (b) proves the check is falsifiable both ways, and +// (c) evaluates a proposed fix in isolation — never applied to styles.css, since that write +// belongs to skin/T-025. Team-lead's instruction stands: where the honest fix would compromise +// Telegram's brand identity, say so and let it be escalated rather than forcing a value. + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const STYLES_PATH = join(__dirname, '..', '..', 'components', 'chat-sim', 'styles.css'); +const css = readFileSync(STYLES_PATH, 'utf8'); + +type RGB = readonly [number, number, number]; + +// Same WCAG math as element/__tests__/wallpaper-contrast.test.ts, duplicated rather than +// imported: that file is outside qa's write cell (skin's), and its helpers are module-local, not +// exported — importing internals across an ownership boundary would silently couple two lanes' +// files. Ownership discipline over DRY here (agent-floor.md "Scope discipline"). +function hexToRgb(hex: string): RGB { + const h = hex.replace('#', ''); + const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h; + const n = parseInt(full, 16); + return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]; +} + +function channelLuminance(c: number): number { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; +} + +function relativeLuminance([r, g, b]: RGB): number { + return 0.2126 * channelLuminance(r) + 0.7152 * channelLuminance(g) + 0.0722 * channelLuminance(b); +} + +function contrastRatio(a: RGB, b: RGB): number { + const la = relativeLuminance(a); + const lb = relativeLuminance(b); + const lighter = Math.max(la, lb); + const darker = Math.min(la, lb); + return (lighter + 0.05) / (darker + 0.05); +} + +const TEXT_AA_THRESHOLD = 4.5; + +function extractRuleBlock(source: string, selectorLiteral: string): string { + const idx = source.indexOf(selectorLiteral); + if (idx === -1) throw new Error(`Selector not found in styles.css: ${selectorLiteral}`); + const closeIdx = source.indexOf('}', idx); + return source.slice(idx, closeIdx === -1 ? undefined : closeIdx); +} + +function extractCustomProperty(cssChunk: string, prop: string): RGB { + const m = cssChunk.match(new RegExp(`${prop}:\\s*(#[0-9a-fA-F]{3,6})`)); + if (!m) throw new Error(`${prop} not found in chunk: ${cssChunk.slice(0, 200)}`); + return hexToRgb(m[1]); +} + +function extractPropertyRaw(cssChunk: string, prop: string): string { + const m = cssChunk.match(new RegExp(`${prop.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&')}:\\s*([^;]+);`)); + if (!m) throw new Error(`${prop} not found in chunk: ${cssChunk.slice(0, 200)}`); + return m[1].trim(); +} + +// The two live tokens the Telegram `.cf-quote-author` rules resolve to (styles.css:103-104, +// 178-185) — read from the real sheet, never re-typed as bare literals. +const TELEGRAM_ROOT_RULE = "[data-channel='telegram'] {"; +const telegramAccentIn = extractCustomProperty(extractRuleBlock(css, TELEGRAM_ROOT_RULE), '--channel-telegram'); +const telegramAccentOut = extractCustomProperty(extractRuleBlock(css, TELEGRAM_ROOT_RULE), '--channel-telegram-out'); + +// The 4 real backgrounds those two colors actually sit on (styles.css: root bubble-in #fff, +// Telegram-light out-bubble #effdde at line ~120, Telegram-dark in-bubble #182533 via the +// generic dark block, Telegram-dark out-bubble #3e6aa7 — the operator-corrected value at +// line ~208). +const LIGHT_BUBBLE_IN = extractCustomProperty(extractRuleBlock(css, '.cf-chat-sim {'), '--cf-cs-bubble-in'); +const TELEGRAM_LIGHT_OUT_BUBBLE = extractCustomProperty( + extractRuleBlock(css, "[data-channel='telegram'] .cf-msg[data-dir='out'] .cf-bubble {"), + 'background', +); +const DARK_BUBBLE_IN = extractCustomProperty(extractRuleBlock(css, "[data-theme='dark'] {"), '--cf-cs-bubble-in'); +const TELEGRAM_DARK_OUT_BUBBLE = extractCustomProperty( + extractRuleBlock(css, "[data-channel='telegram'][data-theme='dark'] .cf-msg[data-dir='out'] .cf-bubble {"), + 'background', +); + +describe('Telegram .cf-quote-author text contrast — current, live styles.css (T-025 finding, extended per T-032)', () => { + // Confirms the rules actually route through a token, not a hardcoded literal — if skin ever + // inlines the hex directly, this (not the ratio checks) is what would catch the drift first. + // + // T-025 landed and moved the target: `.cf-quote-author` now routes through DEDICATED text-only + // tokens instead of the plain brand accents. That split is the fix, not drift — the accents also + // paint the avatar, `.cf-composer-send` and the reply-bar border, which T-023 ruled can keep the + // real brand hue; only the author TEXT had to clear AA. So these two flip from finding-tests + // (proving the failure existed) to regression-tests (pinning the fix's wiring). The anti-literal + // intent above is unchanged — only the token name they expect moved. + // Cell reassigned team-lead → (qa terminated); see file-ownership-matrix.md. + it('.cf-quote-author (dir=in) resolves through --channel-telegram-quote-text', () => { + const raw = extractPropertyRaw( + extractRuleBlock(css, "[data-channel='telegram'] .cf-msg[data-dir='in'] .cf-quote-author {"), + 'color', + ); + expect(raw).toBe('var(--channel-telegram-quote-text)'); + }); + + it('.cf-quote-author (dir=out) resolves through --channel-telegram-out-quote-text', () => { + const raw = extractPropertyRaw( + extractRuleBlock(css, "[data-channel='telegram'] .cf-msg[data-dir='out'] .cf-quote-author {"), + 'color', + ); + expect(raw).toBe('var(--channel-telegram-out-quote-text)'); + }); + + // The one combination that already passes — regression guard so a future change can't quietly + // take this one below AA while "fixing" the other three. + it('IN, dark bubble (#182533): already clears AA — must stay that way', () => { + expect(contrastRatio(telegramAccentIn, DARK_BUBBLE_IN)).toBeGreaterThanOrEqual(TEXT_AA_THRESHOLD); + }); + + // The 3 open failures (T-025's own numbers, reproduced here against the live sheet rather than + // trusted as prose). Written as "this currently fails" — an honest defect probe, not a green + // gate someone could point to as "already handled". Flips to a real pass once skin applies a + // fix under T-025; until then this is the tracked, falsifiable state of the bug. + it('IN, light bubble (#ffffff): still fails AA — open (T-025)', () => { + expect(contrastRatio(telegramAccentIn, LIGHT_BUBBLE_IN)).toBeLessThan(TEXT_AA_THRESHOLD); + }); + + it('OUT, light bubble (#effdde): still fails AA — open (T-025)', () => { + expect(contrastRatio(telegramAccentOut, TELEGRAM_LIGHT_OUT_BUBBLE)).toBeLessThan(TEXT_AA_THRESHOLD); + }); + + it('OUT, dark bubble (#3e6aa7): still fails AA — open (T-025)', () => { + expect(contrastRatio(telegramAccentOut, TELEGRAM_DARK_OUT_BUBBLE)).toBeLessThan(TEXT_AA_THRESHOLD); + }); + + it('gemelo: the check is falsifiable — a value that DOES clear AA passes the same assertion shape', () => { + // Proves the "fails AA" checks above aren't vacuous (e.g. threshold typo'd backwards) by + // running the identical comparison against a color known to pass. + const knownGood = hexToRgb('0d7a3f'); // --cf-cs-accent-text, already proven >=4.5:1 elsewhere + expect(contrastRatio(knownGood, LIGHT_BUBBLE_IN)).toBeGreaterThanOrEqual(TEXT_AA_THRESHOLD); + }); +}); + +describe('Proposed values (for skin/T-025 to apply — NOT written to styles.css from this cell)', () => { + // Methodology: scale each brand hue toward black by a factor `k`, same derivation T-023 used + // for --cf-cs-accent-text (#25d366 -> #0d7a3f). Search performed once, offline, to find the + // minimal darkening that clears 4.5:1 with a safety margin; the two SAFE proposals below are + // pinned as literals and asserted, so this file also serves as the acceptance test for T-025's + // eventual token values (swap the literal for `getComputedStyle`/the real token once it lands). + + it('PROPOSED — IN, light theme only: darken --channel-telegram from #37a1de to #2979a7 (k=0.75)', () => { + // Cannot be a single non-theme-aware swap of --channel-telegram itself: the SAME token also + // backs the reply-bar border and avatar fill (decorative, T-023's "pueden quedar" precedent), + // and re-checked against the dark bubble this darkened value would UNDERSHOOT dark's own + // needs less than the original does — still clears it, but by less margin. A dedicated + // `--channel-telegram-quote-text` token, overridden back to the plain `--channel-telegram` in + // `[data-theme='dark']` (dark already clears AA at the undarkened value — no need to touch + // it there), is the shape that avoids collateral changes to the avatar/reply-bar. This is a + // safe, brand-preserving fix: same hue, same channel, no cross-theme conflict. + const proposed = hexToRgb('2979a7'); + expect(contrastRatio(proposed, LIGHT_BUBBLE_IN)).toBeGreaterThanOrEqual(TEXT_AA_THRESHOLD); + expect(contrastRatio(telegramAccentIn, DARK_BUBBLE_IN)).toBeGreaterThanOrEqual(TEXT_AA_THRESHOLD); // dark: leave as-is + }); + + it('PROPOSED — OUT, light theme only: darken --channel-telegram-out from #5eb854 to #417f3a (k=0.69)', () => { + const proposed = hexToRgb('417f3a'); + expect(contrastRatio(proposed, TELEGRAM_LIGHT_OUT_BUBBLE)).toBeGreaterThanOrEqual(TEXT_AA_THRESHOLD); + }); + + // NOT proposed — this is the case to escalate, not force. See ESCALATE-2026-09-07-telegram- + // quote-dark-out in qa's report / the message to team-lead. + it('ESCALATE, not proposed — OUT, dark theme (text on #3e6aa7): no same-hue darkening reaches AA', () => { + // Unlike the two cases above, Telegram's dark-mode OUT bubble (#3e6aa7) is BLUE, not a + // darkened green — T-013's dark palette inverts the bubble's hue between themes on purpose + // (styles.css:205-210). Darkening #5eb854 toward black REDUCES its contrast against a + // medium-luminance blue background (both approach the same luminance band) instead of + // increasing it — verified across the full darkening range, worst case documented below. + // The only same-hue values that clear 4.5:1 are pastel/near-white greens (>=80% lightened + // toward #fff) that no longer read as Telegram's outbound green at all. That trade — AA vs. + // legible brand color — is a brand decision, not a mechanical fix, exactly the class of call + // team-lead asked to be escalated rather than forced. + const darkestReasonableGreen = hexToRgb('2a5326'); // k=0.45, as dark as T-023's own darkening ever went + expect(contrastRatio(darkestReasonableGreen, TELEGRAM_DARK_OUT_BUBBLE)).toBeLessThan(TEXT_AA_THRESHOLD); + }); +}); diff --git a/src/components/chat-sim/README.md b/src/components/chat-sim/README.md new file mode 100644 index 0000000..2dee589 --- /dev/null +++ b/src/components/chat-sim/README.md @@ -0,0 +1,400 @@ +# chat-sim + +A deterministic, seeded chat-conversation simulator. You write a script — posts, typing drafts, +reactions, read receipts — and `chat-sim` renders it as a WhatsApp or Telegram conversation, in +React or with no framework at all, and screenshots it to **byte-identical PNGs** run after run. + +It is built for two jobs that usually get solved twice: the animated chat mock on a marketing +landing page, and the real message thread inside a product. Both read the same `SimState` out of +the same pure core. + +Three properties are the point of the thing: + +- **Deterministic.** `(script, seed, channel, locale, tz)` fully determines the output, down to the + bytes of a screenshot. There is no `Math.random`, no `Date.now`, no network call anywhere in the + core. +- **The timeline is a fold, not an append.** Messages get edited, deleted, pinned and reacted to + after they are posted. That is a state machine over stable ids, not a list you push onto. +- **A channel is an adapter, not a theme.** WhatsApp and Telegram differ *structurally* — where the + tail sits, whether reactions overlay the bubble, whether a "delivered" state exists at all — and + those differences live in data, not in `if (channel === 'telegram')` branches scattered through a + renderer. + +--- + +## Install + +```bash +npm install github:cofoundy/ui +``` + +Everything lives under the `chat-sim` subpath. The main `@cofoundy/ui` barrel deliberately does not +re-export it. + +| Subpath | What it is | +| --- | --- | +| `@cofoundy/ui/chat-sim` | Core + adapters + the React component | +| `@cofoundy/ui/chat-sim/styles.css` | The stylesheet. Required by both renderers | +| `@cofoundy/ui/chat-sim/element` | Importing it registers the `` custom element | + +React 18 or 19 is a peer dependency, and only for the React entry point. `@cofoundy/ui/chat-sim/element` +pulls in no framework at all — enforced by a check that bundles the element entry point and fails +if `react` or `react-dom` shows up anywhere in the resulting import graph +(`node src/components/chat-sim/element/scripts/assert-no-react.mjs`, which carries its own positive +twin: the same probe run against a deliberately React-importing bundle must come back positive). + +--- + +## Quick start — React + +```tsx +import { ChatSim } from '@cofoundy/ui/chat-sim'; +import type { SimScript } from '@cofoundy/ui/chat-sim'; +import '@cofoundy/ui/chat-sim/styles.css'; + +const script: SimScript = [ + { k: 'draft', by: 'in', chars: 12, delayMs: 400 }, + { k: 'post', by: 'in', text: '¿Tienen mesa para 4 hoy 8pm?', delayMs: 900 }, + { k: 'post', by: 'out:ai', text: 'Sí — te la reservo. ¿A nombre de quién?', delayMs: 1200 }, + { k: 'receipt', id: 'm1', to: 'delivered', delayMs: 300 }, + { k: 'receipt', id: 'm1', to: 'read', delayMs: 800 }, + { k: 'react', id: 'm1', emoji: '👍', by: 'in', delayMs: 500 }, +]; + +export function Demo() { + return ( + + ); +} +``` + +`mode` picks between the two jobs: + +- `mode="demo"` plays the script itself on an rAF playhead. The composer is visual only — nothing is + listening for input. This is the landing-page mock. +- `mode="live"` renders the script's full history frozen at its last step, and swaps in a real + `