feat(blocks): `contact` — el primer block que COORDINA (estado, datos y palabras)

WIP declarado: el block funciona y renderiza, pero el arco interactivo completo
no está verificado todavía. Lo que falta está en `CONTINUE-blocks.md`, en orden.

## El cambio de doctrina

Decisión del usuario, textual: «si al final es una lista de componentes… el bloque
es coordinación, estado, y data también». El motivo se veía en este mismo block:
con el estado fuera, cada `disabled` era una expresión distinta montada en el punto
de uso, y aparecían huecos — un envío que no se podía hacer y nadie decía por qué.

Eso cambia lo firmado en `docs/architecture/blocks.md` (B-5/B-7, D-BLK). Queda
ANOTADO como pendiente de escribir en el contrato: propagarlo sin actualizarlo
sería deriva.

## Lo que ahora posee el block

- **La máquina de estado** (`state.ts`): `incomplete · unverified · verifying ·
  rejected · ready · sending · sent`, derivada de la validez del `Form`, del
  veredicto de verificación y del envío. Con dos registros EXHAUSTIVOS —
  `CONTACT_REASON` y `CONTACT_ACTION`— que hacen imposible un estado mudo: no se
  puede añadir un estado que bloquee el envío sin darle su frase, porque el
  `Record` obliga.
- **La forma de los datos** (`schema.ts`): nombre · correo · mensaje, con las
  etiquetas como idlangref. El app lo sustituye pasando su `form` + `schema`.
- **Las palabras**, por el traductor (`#?blocks.contact.…|fallback`), la misma
  puerta que usa el canon para sus `texts:`.

Partes nuevas: `Contact.Fields` (los tres campos del esquema propio),
`Contact.Submit` (bloqueo Y etiqueta desde el estado, nunca desde una expresión en
el punto de uso) y `Contact.Reason` (la frase del estado). `Contact.Form` toma el
handle del contexto en vez de pedirlo otra vez.

La demo adelgaza en consecuencia: se le cayeron el esquema, las etiquetas, el
`disabled`, el flag de envío y la frase. Le queda lo que es de un app — las
palabras de la página, los datos de contacto, el reto que elige y qué significa
«enviar».

## De paso, dos arreglos de composición

- El `TextArea` iba dentro de `Field.Control` y salían DOS marcos: pinta su propia
  carcasa (borde, radio y relleno son de su recipe). `Field.Control` es la carcasa
  del `Field.Input` desnudo.
- El `Form.ErrorSummary` vacío dejaba una caja naranja: su raíz pinta borde de
  riesgo en cuanto el form es inválido, aunque la lista esté vacía.

## Dos hilos de canon abiertos (medidos, sin causa raíz)

- Con `progressive`/`onSubmit`, un envío inválido bloquea y mueve el foco pero no
  expone mensajes; con `onChange` sí, pero saltan los tres campos al escribir en
  uno. La máquina tapa el agujero de cara al usuario; el hueco sigue.
- El provider de `ProofOfHuman` escribe `status` él mismo, así que el veredicto que
  devuelve el app no se sostiene y los slots `stage*` no renderizaron nunca.

Gates: `blocks:check` verde (14 blocks) · `svelte-check` sin errores propios ·
prettier limpio. Verificado que la sección renderiza (section · form · 3 campos ·
submit · cero errores de página) en un dev server limpio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 2 months ago
parent 59bd5ffb71
commit 75c2602591

@ -1,9 +1,12 @@
# CONTINUE — F2 blocks de sitio (handoff, act. 2026-07-31)
**Estado: F1 CERRADA (8/8) · F2 13 de 15.** Hechos: site-header · hero ·
feature-grid · **feature-split** · pricing · testimonials · faq · stats-band ·
cta · newsletter · site-footer · banner · team. **Quedan 2**: contact (§F2.13) ·
content-section (§F2.14).
**Estado: F1 CERRADA (8/8) · F2 13 de 15 + `contact` A MEDIO REESCRIBIR.**
Hechos: site-header · hero · feature-grid · **feature-split** · pricing ·
testimonials · faq · stats-band · cta · newsletter · site-footer · banner · team.
**En curso**: contact (§F2.13). **Queda**: content-section (§F2.14).
> ⚠️ **CAMBIO DE DOCTRINA (decisión del usuario, 2026-07-31).** Lee la sección
> «La doctrina cambió» antes de tocar nada: un block ya no es solo colocación.
(El plan fijó 14; `feature-split` se añadió como block hermano con
scope-approval, así que el tier tiene 15 y el denominador honesto es 15.)
@ -21,28 +24,90 @@ Dónde vive cada cosa:
---
## Lo siguiente: F2.13 `contact`
## La doctrina cambió: un block coordina, tiene estado y trae datos
Decisión del usuario, textual: **«si al final es una lista de componentes… el
bloque es coordinación, estado, y data también»**. El motivo, con el `contact`
delante: cuando el estado vive fuera, cada `disabled` es una expresión distinta
montada en el punto de uso y aparecen **huecos** — un envío que no se puede hacer
y nadie dice por qué.
Lo que cambia respecto de lo firmado en `docs/architecture/blocks.md` (B-5/B-7 y
D-BLK: «todo el contenido por snippets, cero cadenas propias, sin servicios»):
Ficha en `PLAN-blocks.md` §F2.13. Del dossier: 7 en Tailwind Plus. Es
**`newsletter` a lo grande**: `Form` + varios `Field` (nombre, correo, mensaje) +
`Form.Submit`, más una columna de datos de contacto al lado.
1. **El block posee el estado de su sección.** Una máquina única y EXHAUSTIVA,
derivada, en contexto. Sus partes la leen; ninguna la recalcula ni inventa un
`disabled`.
2. **El block trae la forma de sus datos** (el esquema por defecto). El app lo
sustituye pasando el suyo.
3. **El block habla**, con idlangrefs por el traductor
(`#?blocks.<block>.<clave>|fallback`), la misma puerta que usa el canon para
sus `texts:`. Fallback en inglés; el app traduce registrando `blocks.*`.
Lo que ya sabemos y no hay que volver a descubrir (está en el README del
`newsletter`): el handler va SIEMPRE en `createForm` —`Form.Provider` ignora
`onValidSubmit` sobre un `form` ya construido—, los mensajes de SIUM son
idlangref y se resuelven con `uix.langs.t(issue.message, issue.params)`, y con
más de un campo entra en juego `Form.ErrorSummary`, que el `newsletter` no
necesitaba. Fase 0: leer su README y el del `Form` antes de componer.
**Pendiente de escribir**: esto NO está en `docs/architecture/blocks.md` todavía.
Hay que actualizar el contrato B antes de propagarlo, o quedará como deriva.
Después: content-section (composición de `prose` + `Section`), y F2 queda cerrada.
**Alcance acordado**: `contact` primero como prueba de la forma; después revisar
los que tienen estado real — `pricing` (periodo), `newsletter` (alta),
`site-footer` (idioma), `banner` (descarte), `team` (eje). El resto es layout puro
y ahí no hay coordinación que mover.
---
**F2.12 `team` HECHO** — compound (`Member` se repite) y el primero cuyo contexto
sirve para UNA cosa: el `align` de la sección viaja a cada miembro y a su fila de
enlaces. Sin `.MemberAvatar`: el app compone el `Avatar` del canon directamente
(envolver lo que solo se reenvía esconde la API). La fila de enlaces va pegada al
fondo para que biografías de distinto largo no descuadren la retícula. Encontrado
al verificar: **un `columns` fijo no colapsa** — a 420px cuatro columnas dejan
celdas de ~90px; la demo pasa `columns={{ base: 2, md: N }}`.
## Lo siguiente: terminar F2.13 `contact`
**Lo que YA está** (todo en `src/uix/blocks/contact/`):
- `state.ts` — la máquina: `incomplete · unverified · verifying · rejected ·
ready · sending · sent`, con `resolveContactState()`, `canSubmit()` y **dos
registros exhaustivos**: `CONTACT_REASON` (por qué está bloqueado) y
`CONTACT_ACTION` (qué dice el botón). Un estado mudo es imposible por
construcción: el `Record` obliga a darle frase.
- `schema.ts` — el esquema por defecto (nombre · correo · mensaje) con las
etiquetas como idlangref.
- `context.ts` — lo que la raíz comparte: `state`, `form` y `t(ref)`.
- `contact.svelte` — la raíz crea el form si el app no trae uno, deriva el estado
y lo publica.
- Partes nuevas: `Contact.Fields` (los tres campos del esquema propio),
`Contact.Submit` (bloqueo y etiqueta DESDE el estado) y `Contact.Reason` (la
frase del estado). `Contact.Form` ya toma el handle del contexto.
- La demo (`web/routes/blocks/contact/ContactSite.svelte`) reescrita a la API
nueva: se le cayó el esquema, las etiquetas, el `disabled`, el flag de envío y
la frase. Le queda lo del app: las palabras de la página, los datos de contacto,
el reto y qué significa «enviar».
**Lo que FALTA, en orden**:
1. **Verificar el arco completo en navegador**: vacío → relleno → reto resuelto →
enviando → enviado, comprobando que `Contact.Reason` dice algo en cada estado
bloqueado. Sin esto no está hecho. (Se verificó que la sección RENDERIZA —
`section`, `form`, 3 campos, submit, cero errores de página — pero no el arco.)
2. **README del block**: describe la doctrina VIEJA («no posee estado»). Rehacerlo.
3. **Pestañas de doc de la demo** (`+page.svelte`): igual, hablan del reparto
anterior.
4. **Catálogo de idiomas**: los fallbacks son ingleses y la demo es castellana.
Falta registrar `blocks.contact.*` en el arnés (`_lib/BootUix.svelte`) para ver
la vía de i18n funcionando de verdad.
5. **Ficha §F2.13 y bitácora** en `PLAN-blocks.md`.
6. **`docs/architecture/blocks.md`**: escribir el cambio de contrato.
⚠️ **Ojo con el dev server de otra sesión**: el de `:5173` sirvió un módulo VACÍO
para `blocks/contact/index.ts` (500 «does not provide an export named Contact»)
porque tenía el grafo caducado tras crear ficheros nuevos. Arrancado uno propio en
otro puerto, la página va perfecta. Si mañana ves ese 500, es eso: no persigas el
código.
## Dos hilos de canon abiertos (medidos, sin causa raíz)
1. **`Form`/SIUM**: con `progressive`/`onSubmit`, un envío inválido bloquea y
mueve el foco pero **no expone mensajes** (`form.errors` vacío). Con `onChange`
sí aparecen —y traducidos—, pero saltan en los tres campos al escribir en uno;
con `onBlur` aparecen desde la carga. La máquina de estado del block TAPA el
agujero de cara al usuario (el botón dice qué falta), pero el hueco sigue ahí.
2. **`ProofOfHuman`**: el provider de soma escribe `status` él mismo
(`writableActive`), así que el veredicto que devuelve el app tras consultar a su
servidor no se sostiene, y los slots `stage*` no renderizaron en ningún estado.
El camino «rechazado» no es contable hoy desde el app.
---

@ -16,13 +16,20 @@
import { Field } from '$uix/eidos/components/field';
import { TextArea } from '$uix/eidos/components/textarea';
import { getContactContext } from './context';
import { CONTACT_LABEL } from './schema';
import { CONTACT_LABEL, type ContactValues } from './schema';
import type { ContactFieldsProps } from './types';
let { gap = 4, ...rest }: ContactFieldsProps = $props();
const contact = getContactContext();
const form = $derived(contact?.form);
// El handle es genérico (`values: Record<string, unknown>`); esta parte solo
// existe para el esquema por defecto del block, así que aquí SÍ se conoce la
// forma. Si el app trajo otro esquema, compone sus propios `Field`.
const form = $derived(
contact?.form as unknown as
| { values: ContactValues; errors?: Record<string, string[] | undefined> }
| undefined
);
const t = (ref: string) => contact?.t(ref) ?? '';
/** SIUM hands back an idlangref plus its params: the message goes through the

@ -8,7 +8,7 @@ import type { StackProps } from '$uix/eidos/components/stack';
import type { GroupProps } from '$uix/eidos/components/group';
import type { GridProps } from '$uix/eidos/components/grid';
import type { TextProps } from '$uix/eidos/components/text';
import type { FormProps, FormSubmitProps } from '$uix/eidos/components/form';
import type { FormProps, SubmitProps as FormSubmitProps } from '$uix/eidos/components/form';
import type { ContactState, ContactVerification } from './state';
/** What the root hands to its children — the state, and the form it coordinates. */

Loading…
Cancel
Save

Powered by TurnKey Linux.