feat(blocks): `newsletter` — el alta al boletín, y el reset que el framework daba por hecho

F2.9. Slots de snippet (eyebrow · title · description · field · submit · note ·
children), `center` y `justified`, y `panel` como interruptor real: el panel de
marca es la cara de las referencias, pero el control, la ayuda y el error están
calibrados para la superficie de página, así que apagarlo es un estado de primera
y no un fallback.

La fila es `Grid templateColumns={{ base: '1fr', sm: '1fr auto' }}`: el campo se
queda la pista libre y la acción abraza su contenido —eso es lo que hace que un
alta se lea como UN gesto— y bajo `sm` pasa a una columna, porque un botón al lado
de un campo de correo deja inservibles a los dos.

El block NO valida y NO emite sema. `Form` posee el runtime, el esquema, dirty/
touched, la agregación de errores y el foco al primer error; `Field` posee el
cableado ARIA; el morfo del `Form` ya declara `commit-submit` y `signal-invalid`.
El app posee esquema, valores y handler. No hay ni un `if` sobre un correo aquí.

## Superación del dossier: la etiqueta

El único hueco que el dossier nombraba era la nota de privacidad, y está. Pero la
diferencia de verdad es la etiqueta: las referencias shipean la fila escondiéndola
con `sr-only` o dejando solo un placeholder. El canon tiene `Field floatingLabel`
—arranca dentro del control y sube al borde al enfocar o rellenar—, así que la fila
queda alineada CON etiqueta real y asociada. Verificado con pulsaciones de teclado
de verdad: 10px dentro en reposo → −11px sobre el borde al enfocar y al rellenar.

## Tres hallazgos de canon más, medidos

- **La fundación de eidos no trae reset de modelo de caja y lo asume del app.**
  `[data-field-control]` declara `inline-size: 100%` + padding, así que bajo
  `content-box` el control mide 30px más que su contenedor: el campo se metía por
  debajo del botón de envío. Campo 480 / control 510 en la galería frente a
  502 / 502 en los docs de componentes, que sí resetean (igual que
  `web/routes/active/styles.css`). Arreglado en app-land con
  `web/routes/blocks/_lib/reset.css`, con A/B sobre los 10 previews y 5 páginas de
  shell: cambia el newsletter y NADA más. Hay que importarlo dos veces porque la
  galería arranca UIX en línea en vez de pasar por `BootUix` — deuda del arnés,
  anotada en el handoff.
- **`onValidSubmit` es un no-op silencioso** cuando se pasa un `form` ya
  construido: el componente solo lo reenvía al `createForm` que hace él mismo. El
  envío validaba, limpiaba el error y no anunciaba nada. Por eso el block no
  expone el prop: el handler va en el `createForm` del app.
- **Los mensajes de SIUM son idlangref.** La vía correcta es
  `uix.langs.t(issue.message, issue.params)` —verificado, sale «Debe ser una
  dirección de correo válida»—, pero la demo de docs del propio `Form` parte la
  cadena a mano tras el `|`, así que el único ejemplo del repo enseña el patrón
  equivocado y siempre muestra inglés.

Los tres quedan en los gaps del README y en `PLAN-blocks-quality.md` §6
(F18/F19/F20), sin tocar nada fuera del tier.

Verificado en navegador el arco completo: correo inválido → error traducido con
`role="alert"`, `aria-invalid`, `aria-describedby` y foco al primer error; correo
válido → confirmación del app (`Callout` afirmativo con la dirección) y error
limpio. Claro/oscuro/RTL, tres colores, `panel` sí/no, 420px. Cero errores de
página. Gates: `blocks:check` verde (10 blocks) · `svelte-check` sin errores
propios · prettier limpio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 2 months ago
parent 25f8126e0a
commit f69cf76cfd

@ -1,7 +1,8 @@
# CONTINUE — F2 blocks de sitio (handoff, act. 2026-07-30)
Estado al parar: **F1 CERRADA (8/8)** y **F2 9/14** — site-header · hero ·
feature-grid · feature-split · pricing · testimonials · faq · stats-band · cta. Encima de eso se dio
Estado al parar: **F1 CERRADA (8/8)** y **F2 10/14** — site-header · hero ·
feature-grid · feature-split · pricing · testimonials · faq · stats-band · cta ·
newsletter. Encima de eso se dio
una **pasada de calidad** (`PLAN-blocks-quality.md`): movimiento en los 7 blocks,
vídeo, intents semánticos y el arco `contact → commit`; más tres arreglos de
canon (`Grid align`, herencia de `--flex-*`/`--grid-*`, archetype del
@ -16,12 +17,35 @@ Todo commiteado y pusheado a `gita/alpha-0.1-sec-dom`.
## Lo siguiente
**F2.9 `newsletter`** (ficha en `PLAN-blocks.md` §F2.9). Es el `cta` **más un
formulario**: `Form` + `Field` (email) + `Button` de submit. Lee el README de
`Form` antes de tocar nada — la validación la pone el sistema, el block no
inventa la suya, y el handler de submit es del app. Ojo al arco semántico: el
submit es `commit` con intent, no un `contact` como el clic del `cta`. Después:
site-footer → banner → team → contact → content-section.
**F2.10 `site-footer`** (ficha en `PLAN-blocks.md` §F2.10). `<footer>` +
`Container` + `Grid` de columnas (`Heading` sm + `Stack` de `Link`) +
`Separator` + `Group` de sociales + `Text` legal. Del dossier, dos slots que
recurren y no están: **el formulario de newsletter en el pie** (3 de 7 en TW —
es un slot del footer, NO otra disposición del block `newsletter`) y el
**selector de idioma** (encaja con nuestro `langs`). Después: banner → team →
contact → content-section.
**F2.9 `newsletter` HECHO** — slots de snippet, `panel` como interruptor real, y
la fila `Grid templateColumns={{ base: '1fr', sm: '1fr auto' }}`. El block no
valida ni emite sema (todo es del `Form`), y el handler va en el `createForm` del
app porque `Form.Provider` **ignora** `onValidSubmit` cuando recibe un `form` ya
construido. Superación del dossier: la nota de privacidad y, sobre todo, la
etiqueta — `Field floatingLabel` da la fila alineada CON etiqueta asociada, que
es lo que las referencias resuelven escondiéndola.
Tres hallazgos de canon nuevos (F18/F19/F20 en `PLAN-blocks-quality.md` §6): la
**fundación de eidos no trae reset de modelo de caja** y lo asume del app (bajo
`content-box` el control de un `Field` mide 30px más que su contenedor);
`onValidSubmit` es un no-op silencioso sobre un `form` externo; y los mensajes de
SIUM son idlangref que hay que pasar por `uix.langs.t` (la demo de docs del `Form`
los parte a mano y por eso siempre salen en inglés).
**Deuda del arnés de demos** (mía, sin tocar): la galería
(`web/routes/blocks/+layout@.svelte`) arranca UIX en línea en vez de usar
`_lib/BootUix.svelte`, que es lo que usan los previews — la misma cadena escrita
dos veces, y el motivo de que el reset haya que importarlo en los dos sitios. Y en
`BlockDemo`, a ~1400px, el conmutador de anchos de dispositivo se solapa con el
párrafo que lo precede (idéntico en los 10 blocks: es del arnés, no de un block).
**F2.8 `cta` HECHO** — slots de snippet, `center` + `justified`, un solo
`Motion` (el panel llega entero: un CTA es una sola afirmación). Dejó **tres

@ -197,3 +197,11 @@ tampoco se han tocado:
| F15 | `eidos/components/surface` | `variant='soft'` no puede acotar un panel: el track de `primary` mide `oklch(0.9932 0.0034 325.6)` contra un `--color-surface-default` de `oklch(0.9911 0 0)` — **0.002 L** — y `Surface` no tiene prop de borde. `Card outline` sí acota pero no acepta `gradient`, así que «panel sosegado con borde» no tiene primitivo. Por eso `cta` **eliminó** su prop `variant` |
| F16 | paleta (`contrast` slot) | la ranura de contraste es `#ffffff` en **todo** escalón sólido, así que un lienzo de luminancia media deja el cuerpo de texto por debajo de AA. Medido sobre el panel: `primary` 5.18 · `indigo` 5.21 · `plum` 4.75 (pasan en ambos modos) vs `neutral` 3.32 · `secondary` 3.30 · `slate` 3.30 · `teal` 3.07 (fallan en claro). La garantía de emparejamiento solo se cumple en los lienzos oscuros |
| F17 | `eidos/components/text` | `align` es **inerte** por defecto: el componente renderiza un `span` y `text-align` no hace nada sobre una caja inline. `align="center"` dejó la copia alineada a la izquierda dentro de un layout centrado sin avisar de nada. Se rodea con `as="p"`, pero el prop anuncia un efecto que no tiene hasta que el consumidor cambia el elemento |
### Añadidos al construir `newsletter` (2026-07-30) — el tier de formulario
| # | Dónde | Qué |
|---|---|---|
| F18 | fundación de `eidos` (`index.css`) | **No hay reset de modelo de caja.** Recetas como `[data-field-control]` declaran `inline-size: 100%` + `padding-inline`, así que bajo `content-box` el control mide **30px más que su contenedor**: en la fila `1fr auto` del newsletter el campo se metía por debajo del botón de envío. Medido: campo 480 / control 510 en la galería de blocks frente a 502 / 502 en los docs de componentes, cuyo `uix.css` resetea `box-sizing` bajo `[data-uix-docs]` (y `web/routes/active/styles.css` hace lo mismo). O sea: el framework **asume** que el app pone `border-box` y esa asunción solo está documentada porque app-land la cumple dos veces. Arreglado en mi lado con `web/routes/blocks/_lib/reset.css` (A/B sobre los 10 previews y 5 páginas de shell: cambia el newsletter y NADA más). La pregunta de canon es si la fundación debería poseerlo en vez de asumirlo |
| F19 | `soma/components/form` (`form.svelte` + `types.ts`) | **`onValidSubmit` / `onInvalidSubmit` son no-op silenciosos cuando se pasa un `form` ya construido.** El componente solo los reenvía al `createForm` que hace él mismo (la rama `defaults`); con un handle externo se ignoran sin aviso, aunque el tipo los documenta como «Called when validation passes». El envío validaba, limpiaba el error y no anunciaba nada. El block los quitó de su API: el handler va en `createForm` |
| F20 | `libs/forms` + `web/routes/uix/components/form` | Los mensajes de SIUM llegan como **idlangref** (`#?sium.errors.email|Must be a valid email address`). La vía soportada es `uix.langs.t(issue.message, issue.params)` — verificado, resuelve el catálogo («Debe ser una dirección de correo válida»), no el fallback inglés. Pero la demo de docs del propio `Form` **parte la cadena a mano** tras el `|` con un helper local `fallbackMessage`, así que el único ejemplo del repo enseña el patrón equivocado y siempre muestra inglés |

@ -539,13 +539,23 @@ ampliaciones = Gaps con disposición, no código especulativo.
coordinan, así que son slots posicionales (misma forma que `hero`).
`variant` NO existe: medido, el canvas `soft` no acota panel (bitácora).
### F2.9 `newsletter`
- **Compone**: como F2.8 + `Form` + `Field` (email) + `Button`.
- **API**: `<Newsletter>` + `.Title` + `.Description` + `.Form` (submit
handler del app; validación por el sistema de `Form` — leer su README:
el block no inventa validación).
### F2.9 `newsletter` — HECHO (2026-07-30)
- **Compone**: como F2.8 + `Form` (`variant="plain"`) + `Grid` de fila
`1fr auto`; el `Field` de correo y el `Form.Submit` los compone el app en
sus slots.
- **API**: `<Newsletter layout panel color gradient level container size>` +
`form` (OBLIGATORIO, de `createForm`) + `schema` (opcional) + slots
`eyebrow` · `title` · `description` · `field` · `submit` · `note` ·
`children`. **Desviación del plan**: slots, no `.Title`/`.Description`/
`.Form` (las partes no repiten ni coordinan). **No expone
`onValidSubmit`**: `Form.Provider` lo ignora cuando recibe un `form` ya
construido, así que el handler va en el `createForm` del app.
- **Nota**: separado de `cta` porque el form introduce a11y y estados
(invalid/submitting) que el CTA puro no tiene.
- El block **no inventa validación**: `Form` posee runtime, esquema, dirty/
touched, agregación de errores y foco al primer error; `Field` posee el
cableado ARIA. Tampoco emite sema: `commit-submit` / `signal-invalid` ya
los declara el morfo del `Form`.
### F2.10 `site-footer`
- **Compone**: `<footer>` + `Container` + `Grid` (columnas: `Heading` sm +
@ -1265,3 +1275,48 @@ Blocks diferidos: `scheduler` (bloqueado por `chronos`) · `logo-cloud`
(`data-animation-pending` fuera), descripción en `<p>` centrada, cero errores de
página. Gates: `blocks:check` verde (9 blocks) · `svelte-check` sin errores
propios. **Siguiente**: F2.9 `newsletter`.
- 2026-07-30 — **F2.9 `newsletter` HECHO**. Slots de snippet; `center` +
`justified`; `panel` es un interruptor real (no un fallback). La fila es
`Grid templateColumns={{ base: '1fr', sm: '1fr auto' }}`: el campo se queda la
pista libre y la acción abraza su contenido, y bajo `sm` pasa a una columna
porque un botón al lado de un campo de correo deja inservibles a los dos. El
block **no valida ni emite sema**: `Form` posee el runtime y ya declara
`commit-submit`/`signal-invalid`; el app posee esquema, valores y handler.
**Superación del dossier**: la nota de privacidad (el único hueco que el
dossier nombraba para este block) **y la etiqueta**. Las referencias shipean la
fila escondiendo la etiqueta con `sr-only` o dejando solo un placeholder;
aquí se compone `Field floatingLabel` —la etiqueta arranca dentro del control y
sube al borde al enfocar o rellenar—, así que la fila queda alineada CON
etiqueta real y asociada. Verificado con pulsaciones reales en Playwright: 10px
dentro en reposo → −11px sobre el borde al enfocar y al rellenar. (Esto corrige
la advertencia de 2026-07-05 de que el flotado no era verificable sin navegador
real: el pane suspendido no valía, Playwright headless con teclas de verdad sí.)
**Tres hallazgos de canon más, medidos** (F18/F19/F20 en
`PLAN-blocks-quality.md` §6):
1. **La fundación de eidos no trae reset de modelo de caja.** Recetas como
`[data-field-control]` declaran `inline-size: 100%` + padding, así que bajo
`content-box` el control mide 30px más que su contenedor y se metía por
debajo del botón: campo 480 / control 510 en la galería frente a 502 / 502
en los docs de componentes, que sí resetean. El framework lo **asume** del
app. Arreglado en app-land (`web/routes/blocks/_lib/reset.css`), con A/B
sobre los 10 previews y 5 páginas de shell: cambia el newsletter y nada más.
Hubo que importarlo DOS veces porque la galería arranca UIX en línea en vez
de pasar por `BootUix` (deuda del arnés, anotada).
2. **`onValidSubmit` es un no-op silencioso** cuando se pasa un `form` ya
construido: el componente solo lo reenvía al `createForm` que hace él mismo.
El envío validaba, limpiaba y no anunciaba nada.
3. **Los mensajes de SIUM son idlangref**; la vía correcta es
`uix.langs.t(issue.message, issue.params)` (verificado: sale «Debe ser una
dirección de correo válida»), pero la demo de docs del propio `Form` parte la
cadena a mano tras el `|` y por eso siempre muestra inglés.
Verificado en navegador el arco completo: correo inválido → error TRADUCIDO con
`role="alert"`, `aria-invalid`, `aria-describedby` y foco al primer error;
correo válido → confirmación del app (`Callout` afirmativo con la dirección) y
error limpio. Claro/oscuro/RTL, tres colores, `panel` sí/no y 420px (fila
apilada). Cero errores de página. Gates: `blocks:check` verde (10 blocks) ·
`svelte-check` sin errores propios · prettier limpio en mis ficheros.
**Siguiente**: F2.10 `site-footer`.

@ -0,0 +1,129 @@
# Newsletter
## Function
The sign-up section: a title, a supporting line, one email field and its action,
plus the privacy note under them. Either the closing beat of a page (`center`) or
a mid-page arrangement with the copy beside the form (`justified`).
Separate from `cta` because the form brings a11y wiring and states (invalid,
submitting) a pure CTA does not have — and because the app has to own a schema
and a handler here.
## Composition map
| Slot | Composes | Notes |
| ------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------- |
| root | bare `<section aria-labelledby>` | landmark; `id` names it from the title the block renders |
| padding · measure | `Section` + `Container` | `size` / `container` |
| entrance | `Motion` (`trigger="viewport"`, `scale-fade`) | one entrance for the whole section — a sign-up is a single ask |
| panel | `Surface` (always `solid` · `color` · `gradient` · `rounded`) | `panel={false}` drops it and the form returns to the page surface |
| layout | `Stack` (center) · `Grid` 2-col (justified) | |
| form root | `Form` (`variant="plain"`) | `form` (required) + `schema` forwarded verbatim; nothing else |
| the row | `Grid templateColumns={{ base: '1fr', sm: '1fr auto' }}` | field takes the free track, action hugs; one column below `sm` |
| `title` | `Heading` at `level` (default 2) | owns the landmark `id`; on-solid ink on the panel |
| `description` | `Text` `as="p"` (`60ch` measure) | a `span` would ignore `align` — `text-align` is inert on an inline box |
| `field` · `submit` | — (app: `Field` / `Form.Submit`) | separate slots so the block can arrange the row |
| `note` | `Text` `as="p"` `size="sm"` | quieter by SIZE, same ink: `muted` would vanish on a solid panel |
| measure of the form | `Container size="sm"` (center only) | a field line as wide as the prose is unusable |
## What it does NOT do
**It does not validate.** `Form` owns the runtime, the Standard Schema / SIUM
validation, dirty/touched, error aggregation and first-error focus; `Field` owns
the label↔control↔error ARIA wiring. The app owns the schema, the values and the
handler — all three live in its `createForm`. The block forwards the handle and
arranges a row: there is not one `if` about an email address in this file. If a
sign-up ever needs new behaviour it goes to `$libs/forms` → soma `Form` → morfo,
which is the decision the Form README already wrote down.
**It emits no semantic event.** `Form`'s morfo already declares `commit-submit`
(fulfill) and `signal-invalid` (risk). A block emitting its own would give one
submit two voices.
## Form: snippet slots, not sub-components
The tier's rule is that a compound API is earned when parts **repeat**
(`feature-grid.Item`) or **coordinate** (`pricing.Switch` ↔ `PlanPrice`). A
sign-up's parts do neither. _(The plan sketched `.Title`/`.Description`/`.Form`;
this is the same registered deviation as `hero` and `cta`.)_
`field` and `submit` are two slots rather than one, because the block's whole
layout job is the relationship between them.
## Decisions
**2026-07-30 — reference floor** (dossier: Tailwind Plus «Newsletter» 6 ·
Flowbite Blocks · Untitled UI):
- **Adopted**: the centred panel with an inline row (parity) **and the privacy
note slot** — the one gap the dossier names for this block (_"Añadir slot de
nota de privacidad bajo el field (convergente)"_).
- **Adopted**: the side-by-side arrangement as `layout="justified"`, reusing
`cta`'s vocabulary so the two read as one family.
- **Surpassed — the label**: the references ship the inline row by hiding the
label with `sr-only` (or dropping it for a bare placeholder). The canon has
`Field floatingLabel`: the label starts inside the control and floats to the
border on focus or fill, so the row stays aligned **with** a real, associated,
visible label. Nothing in the catalogs can ship that from a static dump.
- **Not adopted — the panel by default is a switch, not a law.** `panel={true}`
gives the brand band the references show, but the control, helper and error
inks are calibrated for the page surface, so `panel={false}` is a first-class
state and not a fallback.
- **Discarded — success feedback.** The Form README defers it to the consumer;
the demo shows it with a `Callout` in `children`.
- **It does not compose `cta`** (B-10: no block imports another). The panel
composition is a deliberate duplication — same shape, same defaults, two files.
## Demo
`web/routes/blocks/newsletter/` — the block full-bleed on the page, with live
control of layout, panel, colour, finish and direction, and the device widths
served from `preview/`. Mini-page in `NewsletterSite.svelte`, shared by both
surfaces: it owns the SIUM schema, `createForm` (with
`validationBehaviour: 'progressive'` — a field that starts empty must not be
flagged on first paint) and what subscribing means.
## Gaps
| Gap | Disposition |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Confirmation after sign-up** | **app-land** — `Form` already emits `commit-submit`; what subscribing means belongs to whoever receives the values |
| **More than one field** (name + email) | **deferred** — the row is single-field by design; two fields want `Form.ErrorSummary` and another arrangement |
| **`VisuallyHidden` primitive** | **canon** — there is none, so the references' "input only, label for AT" row cannot be reproduced with an associated label. Not needed here (`floatingLabel` is the better answer), but it is a hole in the vocabulary |
| **Newsletter inside the footer** | **F2.10** — 3 of 7 in Tailwind Plus put it in the footer; that is a `site-footer` slot, not another arrangement of this block |
| **Error ink on a solid panel** | **canon, low** — `Field.ErrorText` is `risk` ink, calibrated against the page surface. Legible on the panel, but that is not the background it was designed for |
## Found while composing
Four things, all measured in the browser rather than reasoned about. The three
canon ones are frozen (the user's standing constraint: nothing outside `blocks/`)
and registered in `docs/process/PLAN-blocks-quality.md` §6.
- **No box-model reset in the foundation.** `[data-field-control]` declares
`inline-size: 100%` + `padding-inline`, so under `content-box` the control
renders **30px wider than its container** — in this block's `1fr auto` row it
slid straight under the submit button. Measured: field 480 / control 510 in the
blocks gallery versus 502 / 502 on the component docs, whose `uix.css` resets
`box-sizing` under `[data-uix-docs]`. `web/routes/active/styles.css` does the
same. So the framework _assumes_ the app provides `border-box` and only
documents that assumption by doing it twice in app-land. **Fixed on my side**:
`web/routes/blocks/_lib/reset.css`, A/B'd across all ten block previews and five
shell pages — it changes this block and **nothing else**.
- **`onValidSubmit` is a silent no-op on a prebuilt form.** `Form.Provider` only
forwards `onValidSubmit`/`onInvalidSubmit` into the form it creates itself (the
`defaults` path); pass a `form` from `createForm` and the props are ignored
without a warning. Cost me a submit that validated, cleared and announced
nothing. That is why this block does not expose them: the handler goes in
`createForm`.
- **SIUM messages are idlangrefs and the only example hand-parses them.** An
invalid email comes back as `#?sium.errors.email|Must be a valid email address`.
The supported path is `uix.langs.t(issue.message, issue.params)`, which resolves
the catalog (verified: it renders _"Debe ser una dirección de correo válida"_,
not the English fallback). The `Form`'s own docs demo instead slices the string
after the `|` with a local `fallbackMessage` helper — so the repo's one example
teaches the wrong pattern and always shows English.
- **The blocks gallery has two composition roots.** `web/routes/blocks/+layout@.svelte`
boots UIX inline instead of using `_lib/BootUix.svelte`, which the previews use —
the same chain written twice, and the reason the reset needed importing in both
places. Demo-harness debt, noted in the handoff.

@ -0,0 +1,22 @@
// Newsletter — the sign-up section: copy, one email field, one action.
//
// import { Newsletter } from '$blocks/newsletter';
//
// <Newsletter {form} {schema} onValidSubmit={subscribe}>
// {#snippet title()}Una carta cada martes{/snippet}
// {#snippet description()}Lo que cambió en el producto, sin relleno.{/snippet}
// {#snippet field()}<Field name="email" …>…</Field>{/snippet}
// {#snippet submit()}<Form.Submit color="fulfill">Suscribirme</Form.Submit>{/snippet}
// {#snippet note()}Nada de spam. Te das de baja en un clic.{/snippet}
// </Newsletter>
//
// <Newsletter layout="justified" panel={false}>…</Newsletter>
//
// The block arranges; it does NOT validate. `Form` owns the runtime and the
// semantic arc (`commit-submit` / `signal-invalid`), the app owns the schema and
// the handler, and every visible word arrives through a snippet (B-7).
import Newsletter from './newsletter.svelte';
export { Newsletter };
export default Newsletter;
export type { NewsletterProps, NewsletterLayout } from './types';

@ -0,0 +1,176 @@
<script lang="ts">
/**
* Newsletter — the sign-up section: a title, a supporting line, one email field
* and its action. `center` stacks the copy over the form for the closing beat of
* a page; `justified` puts the copy at the inline-start and the form at the end.
*
* FORM: snippet slots, not sub-components — the tier's rule (compound is earned
* when parts REPEAT or COORDINATE, and a sign-up's parts do neither).
*
* WHAT IT DOES NOT DO: validation. `Form` owns the runtime, the schema, the
* dirty/touched state, the error aggregation and the first-error focus; the app
* owns the schema, the values and the submit handler — all three live in its
* `createForm`. The block forwards the handle and arranges the row. Same for
* the semantic arc: `Form`'s morfo already declares `commit-submit` (fulfill)
* and `signal-invalid` (risk) — a block that emitted its own would be inventing
* a second voice for one event.
*
* B contract: canon components only — no `.css`, no morfo, no strings of its
* own. It does NOT compose the `cta` block (B-10: no block imports another), so
* the panel composition is deliberately duplicated from it.
*/
import { Section } from '$uix/eidos/components/section';
import { Container } from '$uix/eidos/components/container';
import { Surface } from '$uix/eidos/components/surface';
import { Stack } from '$uix/eidos/components/stack';
import { Grid } from '$uix/eidos/components/grid';
import { Box } from '$uix/eidos/components/box';
import { Heading } from '$uix/eidos/components/heading';
import { Text } from '$uix/eidos/components/text';
import { Form } from '$uix/eidos/components/form';
import { Motion } from '$uix/eidos/components/motion';
import type { NewsletterProps } from './types';
let {
layout = 'center',
panel = true,
color = 'primary',
gradient = true,
level = 2,
container = 'lg',
size = 'lg',
form,
schema,
eyebrow,
title,
description,
field,
submit,
note,
children,
...rest
}: NewsletterProps = $props();
const uid = $props.id();
const titleId = `${uid}-title`;
const centered = $derived(layout === 'center');
// On the panel the ink is the contrast one the palette guarantees for that
// canvas; off the panel the normal content inks read.
const ink = $derived(panel ? 'on-solid' : undefined);
</script>
<!-- The copy cluster: identical in both layouts, only its alignment changes. -->
{#snippet copy()}
<Stack gap={4} align={centered ? 'center' : 'start'}>
{#if eyebrow}
{@render eyebrow()}
{/if}
{#if title}
<Heading
id={titleId}
{level}
align={centered ? 'center' : 'start'}
wrap="balance"
color={ink}
>
{@render title()}
</Heading>
{/if}
{#if description}
<Box maxWidth="60ch">
<!-- `as="p"`: Text renders a span by default, and `text-align` is inert on
an inline box — the lines would keep their `start` alignment. -->
<Text
as="p"
size="lg"
color={ink ?? 'muted'}
align={centered ? 'center' : 'start'}
wrap="pretty"
>
{@render description()}
</Text>
</Box>
{/if}
</Stack>
{/snippet}
<!--
The row: the field takes the free track and the action hugs its content, which
is what makes a sign-up read as ONE gesture. It becomes a single column below
`sm`, where a button beside an email input leaves neither of them usable.
`templateColumns` rather than a flex `grow` on the field — `Box`/`Surface`
`flex` does not grow a child today (gap registered in the hero's README).
-->
{#snippet formBlock()}
<Form {form} {schema} variant="plain">
<Stack gap={3} align={centered ? 'center' : 'stretch'} width="100%">
<Grid templateColumns={{ base: '1fr', sm: '1fr auto' }} gap={3} align="start" width="100%">
{#if field}
{@render field()}
{/if}
{#if submit}
{@render submit()}
{/if}
</Grid>
{#if note}
<!-- Quieter by SIZE, not by ink: `muted` would vanish on a solid panel. -->
<Text as="p" size="sm" color={ink} align={centered ? 'center' : 'start'}>
{@render note()}
</Text>
{/if}
</Stack>
</Form>
{/snippet}
{#snippet body()}
{#if centered}
<Stack gap={6} align="center" width="100%">
{@render copy()}
<!-- A form line as wide as the prose is unusable: the field gets its own,
narrower measure. -->
<Container size="sm" paddingX={0}>
{@render formBlock()}
</Container>
{#if children}
{@render children()}
{/if}
</Stack>
{:else}
<Grid columns={{ base: 1, md: 2 }} gap={{ base: 6, md: 10 }} align="center">
{@render copy()}
<Box>
{@render formBlock()}
{#if children}
{@render children()}
{/if}
</Box>
</Grid>
{/if}
{/snippet}
<section aria-labelledby={title ? titleId : undefined} {...rest}>
<Section {size}>
<Container size={container}>
<!-- One entrance for the whole section: a sign-up is a single ask. -->
<Motion trigger="viewport" motion="scale-fade">
{#if panel}
<!-- Always the SOLID canvas — the soft track measures 0.002 L from
`--color-surface-default` in light mode, so it does not read as a
panel (measured in `cta`). -->
<Surface
variant="solid"
{color}
{gradient}
rounded
padding={{ base: 6, md: 10 }}
width="100%"
>
{@render body()}
</Surface>
{:else}
{@render body()}
{/if}
</Motion>
</Container>
</Section>
</section>

@ -0,0 +1,93 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { ContainerSize } from '$uix/eidos/components/container';
import type { SectionSize } from '$uix/eidos/components/section';
import type { HeadingLevel } from '$uix/eidos/components/heading';
import type { FormProps } from '$uix/eidos/components/form';
import type { SurfaceProps } from '$uix/eidos/components/surface';
import type { ComponentColorProp } from '$uix/eidos/lib/types';
/** How the panel arranges its copy and its form. */
export type NewsletterLayout = 'center' | 'justified';
// `title` is omitted from the element attributes because the block reuses the
// name for the title snippet (the HTML `title` tooltip attribute would otherwise
// intersect the Snippet into `string & Snippet` — the hero lesson).
export type NewsletterProps = Omit<HTMLAttributes<HTMLElement>, 'children' | 'title'> & {
/**
* `center` stacks the copy over the form, centred — the closing beat of a
* page. `justified` puts the copy at the inline-start and the form at the end:
* the mid-page or in-footer arrangement.
* @default 'center'
*/
layout?: NewsletterLayout;
/**
* Paint the section as a panel (`Surface`, the system's own finish). Turn it
* off for a newsletter that sits on the page surface — the form's own controls,
* helper text and error text are calibrated for that background.
* @default true
*/
panel?: boolean;
/**
* Panel accent — the full colour system. Pick a DARK canvas: the palette's
* contrast slot is white on every solid step, so a mid-lightness canvas puts
* the copy below AA (measured in `cta`: `primary` 5.18 · `indigo` 5.21 ·
* `plum` 4.75 pass, `neutral` 3.32 · `teal` 3.07 fail in light).
* Ignored when `panel` is false.
* @default 'primary'
*/
color?: ComponentColorProp;
/**
* Gradient finish on the panel — the system's treatment, derived from this
* instance's palette. Ignored when `panel` is false.
* @default true
*/
gradient?: SurfaceProps['gradient'];
/** Semantic level of the title heading. @default 2 */
level?: HeadingLevel;
/** Content measure. Passed straight to `Container`. @default 'lg' */
container?: ContainerSize;
/** Block-axis padding of the SECTION (around the panel). @default 'lg' */
size?: SectionSize;
/**
* The form handle from `createForm` — REQUIRED. The app owns the schema, the
* values, the defaults, the validation timing AND the submit handler, because
* `createForm` is where they all live:
*
* createForm({ schema, defaults: { email: '' }, onValidSubmit, … })
*
* The block deliberately does NOT expose `onValidSubmit`: `Form.Provider`
* ignores that prop whenever a prebuilt `form` is passed (it only forwards it
* into the form it creates itself), so a block accepting it would advertise a
* callback that silently never fires. Registered in the README's findings.
*/
form: NonNullable<FormProps['form']>;
/**
* Standard Schema / SIUM schema — optional override; `Form` falls back to the
* one the form was built with (or the `SiumProvider`).
*/
schema?: FormProps['schema'];
/** A chip or label above the title. */
eyebrow?: Snippet;
/** The title's words — the block wraps them in a `Heading` and owns the
* landmark `id`. */
title?: Snippet;
/** The supporting line — the block wraps it in a `Text`. */
description?: Snippet;
/**
* The email field — the app composes its own `Field` (name, label,
* placeholder and validation message are all its words, B-7). It lands in the
* growing column of the row.
*/
field?: Snippet;
/** The submit action — the app's `Form.Submit`. It hugs its content. */
submit?: Snippet;
/**
* The privacy note under the row — the slot the reference catalogs converge
* on. Small, same ink as the copy: a note is quieter by SIZE, because a muted
* ink would disappear on a solid panel.
*/
note?: Snippet;
/** Anything below the note (a logo strip, a subscriber count). */
children?: Snippet;
};

@ -29,6 +29,10 @@
import * as Icon from '$uix/eidos/components/icon';
import { BLOCK_CATALOG } from './_lib/catalog';
import '@/uix/eidos/index.css';
// The box-model reset the section needs. Imported HERE as well as in `BootUix`
// because this shell boots UIX inline instead of going through it — the
// previews get it from `BootUix`, the gallery from this line.
import './_lib/reset.css';
import './blocks.css';
let { children } = $props();

@ -8,6 +8,7 @@
* The axes arrive as props so the preview can be driven from its URL:
* `?mode=dark&dir=rtl&lang=en` is what the demo's iframe passes.
*/
import './reset.css';
import { onDestroy } from 'svelte';
import { createActiveUix, setActiveUix } from '$active-uix';
import { createActivePrefsDomProjection, standardPrefsDimensions } from '$prefs';

@ -28,7 +28,7 @@ export const BLOCK_CATALOG: BlockGroup[] = [
{ slug: 'faq', label: 'FAQ', shipped: true },
{ slug: 'stats-band', label: 'Stats band', shipped: true },
{ slug: 'cta', label: 'CTA', shipped: true },
{ slug: 'newsletter', label: 'Newsletter', shipped: false },
{ slug: 'newsletter', label: 'Newsletter', shipped: true },
{ slug: 'site-footer', label: 'Site footer', shipped: false },
{ slug: 'banner', label: 'Banner', shipped: false },
{ slug: 'team', label: 'Team', shipped: false },

@ -0,0 +1,26 @@
/*
* The one reset the blocks section needs, for BOTH of its surfaces (the gallery
* shell and every block's standalone `preview` page) — which is why it is
* imported from `BootUix`, the bootstrap they share.
*
* WHY: the eidos foundation ships no box-model reset; recipes assume the APP
* provides one. Every other surface in this repo does — `web/routes/uix/uix.css`
* under `[data-uix-docs]`, `web/routes/active/styles.css` for the app — and the
* blocks gallery was the one harness built without it (F0).
*
* The cost of that omission is not cosmetic. A recipe like `[data-field-control]`
* declares `inline-size: 100%` + `padding-inline`, so under `content-box` the
* control renders 30px WIDER than its container: in the newsletter's
* `1fr auto` row the email control grew past its track and slid under the submit
* button. Measured, not guessed — field 480 / control 510 here versus 502 / 502
* on the component docs, which have the reset.
*
* The canon-side question (should the foundation own this rather than assume it?)
* is registered in `docs/process/PLAN-blocks-quality.md` §6.
*/
*,
*::before,
*::after {
box-sizing: border-box;
}

@ -0,0 +1,261 @@
<script lang="ts">
/**
* Newsletter demo. Full-bleed on the page; device widths from `./preview`.
*/
import { Stack } from '$uix/eidos/components/stack';
import { Group } from '$uix/eidos/components/group';
import { Wrap } from '$uix/eidos/components/wrap';
import { Text } from '$uix/eidos/components/text';
import { Code } from '$uix/eidos/components/code';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import BlockDemo from '../_lib/BlockDemo.svelte';
import NewsletterSite from './NewsletterSite.svelte';
import DocRow from '../_lib/DocRow.svelte';
let layout = $state<'center' | 'justified'>('center');
let panel = $state(true);
let color = $state('primary');
let gradient = $state(true);
let dir = $state<'ltr' | 'rtl'>('ltr');
const previewSrc = $derived(
`/blocks/newsletter/preview?layout=${layout}&panel=${panel}&color=${color}&gradient=${gradient}&dir=${dir}`
);
</script>
<BlockDemo
name="Newsletter"
slug="$blocks/newsletter"
{previewSrc}
previewHeight="700px"
meta={[
{
key: 'compone',
value: 'Section · Container · Surface · Heading · Text · Form · Grid · Motion'
},
{ key: 'landmark', value: 'section aria-labelledby' },
{ key: 'forma', value: 'slots de snippet' },
{ key: 'validación', value: 'del Form — el block no inventa ninguna' }
]}
>
{#snippet preview()}
<NewsletterSite {layout} {panel} {color} {gradient} />
{/snippet}
{#snippet lede()}
El alta al boletín: una línea de copia, un campo y una acción. El block <strong>coloca</strong>
— la validación, el foco al primer error y el arco semántico son del <Code>Form</Code>, y el
esquema y el handler son del app. Escríbelo al revés y tendrías dos voces para un mismo envío.
{/snippet}
{#snippet controls()}
<Wrap gap={5}>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">layout</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[layout]}
onValueChange={(v) => (layout = (v[0] ?? layout) as typeof layout)}
aria-label="layout"
>
<ToggleGroup.Item value="center">center</ToggleGroup.Item>
<ToggleGroup.Item value="justified">justified</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">panel</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[panel ? 'sí' : 'no']}
onValueChange={(v) => (panel = (v[0] ?? 'sí') === 'sí')}
aria-label="panel"
>
<ToggleGroup.Item value="sí">sí</ToggleGroup.Item>
<ToggleGroup.Item value="no">no</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">color</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[color]}
onValueChange={(v) => (color = v[0] ?? color)}
aria-label="color del panel"
>
<ToggleGroup.Item value="primary">primary</ToggleGroup.Item>
<ToggleGroup.Item value="indigo">indigo</ToggleGroup.Item>
<ToggleGroup.Item value="plum">plum</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">acabado</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[gradient ? 'sí' : 'no']}
onValueChange={(v) => (gradient = (v[0] ?? 'sí') === 'sí')}
aria-label="degradado del panel"
>
<ToggleGroup.Item value="sí">sí</ToggleGroup.Item>
<ToggleGroup.Item value="no">no</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">dir</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[dir]}
onValueChange={(v) => (dir = (v[0] ?? dir) as typeof dir)}
aria-label="dirección (solo la vista previa)"
>
<ToggleGroup.Item value="ltr">ltr</ToggleGroup.Item>
<ToggleGroup.Item value="rtl">rtl</ToggleGroup.Item>
</ToggleGroup>
</Group>
</Wrap>
{/snippet}
{#snippet composition()}
<Stack gap={4}>
<Text color="muted">
El block pone el panel, la medida y la fila; el formulario entero es del canon y las
palabras del app.
</Text>
<Stack gap={3}>
<DocRow term="la fila">
<Code>Grid templateColumns={"{{ base: '1fr', sm: '1fr auto' }}"}</Code>: el campo se queda
la pista libre y la acción abraza su contenido — eso es lo que hace que un alta se lea
como UN gesto. Bajo <Code>sm</Code> pasa a una columna, porque un botón al lado de un campo
de correo deja inservibles a los dos. Se usa <Code>templateColumns</Code> y no un
<Code>grow</Code> porque el <Code>flex</Code> de <Code>Box</Code> no hace crecer a un hijo (hueco
registrado en el <Code>hero</Code>).
</DocRow>
<DocRow term="la etiqueta">
La demo compone <Code>Field floatingLabel</Code>: la etiqueta arranca dentro del control y
sube al borde al enfocar o rellenar. Así la fila queda alineada <em>sin</em> sacrificar la
etiqueta — que es lo que hacen las referencias, que la esconden con
<Code>sr-only</Code>. Exige <Code>placeholder=" "</Code> en el input.
</DocRow>
<DocRow term="la nota">
Bajo la fila, más pequeña y con <strong>la misma tinta</strong>: una nota se apaga por
TAMAÑO, porque una tinta <Code>muted</Code> desaparecería sobre el panel sólido. Es el slot
en el que converge el catálogo de referencia.
</DocRow>
<DocRow term="la medida">
En <Code>center</Code> el formulario recibe su propio <Code>Container size="sm"</Code>:
una línea de campo tan ancha como la prosa es inusable.
</DocRow>
<DocRow term="entrada">
Un solo <Code>Motion trigger="viewport"</Code>: un alta es una sola petición.
</DocRow>
</Stack>
</Stack>
{/snippet}
{#snippet api()}
<Stack gap={3}>
<DocRow term="Newsletter — disposición">
<Code>layout</Code> (<Code>'center' | 'justified'</Code>, def. <Code>center</Code>) ·
<Code>panel</Code> (def. <Code>true</Code>) · <Code>color</Code> (def.
<Code>primary</Code>) · <Code>gradient</Code> (def. <Code>true</Code>) ·
<Code>level</Code> (def. <Code>2</Code>) · <Code>container</Code> · <Code>size</Code>.
</DocRow>
<DocRow term="Newsletter — formulario">
<Code>form</Code> (obligatorio) y <Code>schema</Code> (opcional): se reenvían tal cual al
<Code>Form</Code>. Todo lo demás —<Code>defaults</Code>,
<Code>validationBehaviour</Code> y el <strong>handler de envío</strong>— vive en el
<Code>createForm</Code> del app, que es donde el framework lo lee.
</DocRow>
<DocRow term="eyebrow · title · description · field · submit · note · children">
Snippets: todo el contenido entra por composición (B-7). <Code>field</Code> y
<Code>submit</Code> van separados para que el block pueda arreglar la fila.
</DocRow>
</Stack>
{/snippet}
{#snippet a11y()}
<Stack gap={3}>
<DocRow term="Landmark">
Un <Code>section</Code> nombrado por su título vía <Code>aria-labelledby</Code>. Sin título
no se emite el atributo.
</DocRow>
<DocRow term="El campo lo cablea el Field">
<Code>for</Code> ↔ <Code>id</Code>, <Code>aria-describedby</Code> al error,
<Code>aria-invalid</Code>, <Code>role="alert"</Code> en el <Code>ErrorText</Code> y el foco al
primer error tras un envío inválido: todo del <Code>Field</Code>/<Code>Form</Code>. El block
no toca ni un atributo.
</DocRow>
<DocRow term="Sema">
<Code>commit-submit</Code> (fulfill) y <Code>signal-invalid</Code> (risk) los declara el morfo
del <Code>Form</Code>. El block no emite nada: dos voces para un mismo envío serían un error
de doctrina, no una redundancia.
</DocRow>
</Stack>
{/snippet}
{#snippet gaps()}
<Stack gap={3}>
<DocRow term="Confirmación tras el alta">
<strong>App-land</strong> — el <Code>Form</Code> ya emite <Code>commit-submit</Code>; qué
significa suscribirse lo decide quien recibe los valores. La demo lo enseña con un
<Code>Callout</Code> en el slot <Code>children</Code>.
</DocRow>
<DocRow term="Más de un campo (nombre + correo)">
<strong>Diferido</strong> — la fila es de un campo por diseño. Dos ya piden
<Code>Form.ErrorSummary</Code> y otra disposición; entra cuando una demo lo pida.
</DocRow>
<DocRow term="Etiqueta solo para lectores">
<strong>Canon</strong> — no hay primitivo <Code>VisuallyHidden</Code> en el ecosistema, así que
la fila «solo input» de las referencias no se puede reproducir con etiqueta asociada. Aquí no
hizo falta: <Code>floatingLabel</Code> resuelve el caso mejor.
</DocRow>
<DocRow term="Newsletter dentro del footer">
<strong>F2.10</strong> — 3 de 7 en Tailwind Plus lo llevan en el pie. Es un slot del
<Code>site-footer</Code>, no otra disposición de este block.
</DocRow>
</Stack>
{/snippet}
{#snippet notes()}
<Stack gap={3}>
<DocRow term="No compone el `cta`">
B-10: ningún block importa otro. El panel es una duplicación consciente de la composición
del <Code>cta</Code> — misma forma, mismos defaults, dos ficheros.
</DocRow>
<DocRow term="`panel` es un interruptor real">
Apágalo y el formulario vuelve a la superficie de página, que es para la que están
calibrados el control, el texto de ayuda y el de error. Sobre panel sólido el error sigue
siendo tinta <Code>risk</Code>: legible, pero no es su fondo de diseño.
</DocRow>
<DocRow term="El block no valida">
Ni un <Code>if</Code> sobre el correo. Si hiciera falta lógica nueva, iría a
<Code>$libs/forms</Code> → soma <Code>Form</Code> → morfo, nunca aquí — es la decisión que ya
tiene escrita el README del <Code>Form</Code>.
</DocRow>
<DocRow term="Por qué el handler va en `createForm`">
Porque en el otro sitio no se ejecuta: <Code>Form.Provider</Code> solo reenvía
<Code>onValidSubmit</Code> al form que crea él mismo, así que con un handle ya construido ese
prop es un no-op silencioso. Medido, no supuesto. Por eso el block no lo expone.
</DocRow>
<DocRow term="El mensaje pasa por el traductor">
SIUM devuelve un idlangref (<Code>#?sium.errors.email|…</Code>). La demo lo resuelve con
<Code>uix.langs.t(issue.message, issue.params)</Code> y sale en castellano. Partir la cadena tras
el <Code>|</Code> —lo que hace la demo de docs del <Code>Form</Code>— deja siempre el
inglés.
</DocRow>
</Stack>
{/snippet}
</BlockDemo>

@ -0,0 +1,145 @@
<script lang="ts">
/**
* The newsletter mini-page: the block with real product copy plus enough page
* around it to read as a page — so the demo can render it FULL-BLEED and the
* `preview` route can serve it as a standalone document.
*
* The APP owns the schema, the values and the handler. That is the whole point
* of the block's form pass-through: `Form` validates, this file decides what
* subscribing means.
*/
import { Newsletter } from '$blocks/newsletter';
import { Section } from '$uix/eidos/components/section';
import { Container } from '$uix/eidos/components/container';
import { Stack } from '$uix/eidos/components/stack';
import { Heading } from '$uix/eidos/components/heading';
import { Text } from '$uix/eidos/components/text';
import { Badge } from '$uix/eidos/components/badge';
import { Link } from '$uix/eidos/components/link';
import { Callout } from '$uix/eidos/components/callout';
import { Form } from '$uix/eidos/components/form';
import { Field } from '$uix/eidos/components/field';
import { getActiveUix } from '$active-uix';
import { createForm, type FormIssue } from '$libs/forms';
import { object, string, pipe, email, meta } from '$sium/core';
const uix = getActiveUix();
type Layout = 'center' | 'justified';
let {
layout = 'center' as Layout,
panel = true,
color = 'primary',
gradient = true
}: {
layout?: Layout;
panel?: boolean;
color?: string;
gradient?: boolean;
} = $props();
// The app's data contract — SIUM, so the message travels through the catalog
// and the field registration knows its label.
const schema = object({
email: pipe(string(), email(), meta({ label: 'Correo electrónico' }))
});
let subscribed = $state<string | null>(null);
// `progressive` (the Form's own default) is the right timing for a field that
// STARTS empty: `onChange`/`onBlur` would flag an untouched, necessarily invalid
// input on first paint — the noise the Form README warns about.
//
// The handler lives HERE, in `createForm`, not on the block: `Form.Provider`
// only forwards `onValidSubmit` into a form it creates itself, so on a prebuilt
// handle that prop is a silent no-op (measured — see the block's README).
const form = createForm({
schema,
defaults: { email: '' },
validationBehaviour: 'progressive',
onValidSubmit: (values) => {
subscribed = (values as { email: string }).email;
}
});
// SIUM hands back an idlangref (`#?sium.errors.email|…`) plus its params, so the
// message goes through the TRANSLATOR. `uix.langs.t` accepts the full ref and
// interpolates — no string surgery on the `#?…|` prefix, which is what the
// Form's own docs demo resorts to.
const emailError = $derived.by(() => {
const issue = (form.issues.email as FormIssue[] | undefined)?.[0];
if (issue) return uix.langs.t(issue.message, issue.params);
const raw = form.errors.email?.[0];
return raw ? uix.langs.t(raw) : undefined;
});
</script>
<!-- Some page before the sign-up, so it lands where a sign-up lands. -->
<Section size="lg">
<Container size="lg">
<Stack gap={4}>
<Heading level={2}>Lo que publicamos cada semana</Heading>
<Text color="muted" measure="wide">
Un desglose de lo que cambió en el producto, con el porqué detrás de cada decisión. Escrito
por quien lo construyó, no por marketing.
</Text>
</Stack>
</Container>
</Section>
<Newsletter {layout} {panel} {color} {gradient} {form} {schema}>
{#snippet eyebrow()}
<!-- `soft` on the panel: the primary track is near-white in light mode, so the
chip reads as a pale pill against the saturated canvas. OFF the panel that
same track IS the page, so the pill disappears and the chip needs its
border — there is no on-solid/on-surface chip treatment in the canon. -->
<Badge size="sm" variant={panel ? 'soft' : 'outline'} color="primary">Cada martes</Badge>
{/snippet}
{#snippet title()}Una carta cada martes{/snippet}
{#snippet description()}
Cambios del producto, decisiones de diseño y lo que aprendimos rompiéndolo. Sin relleno y sin
promociones.
{/snippet}
{#snippet field()}
<!-- `floatingLabel`: la etiqueta arranca DENTRO del control y sube al borde
al enfocar o rellenar, así que la fila queda alineada sin sacrificar la
etiqueta. Exige `placeholder=" "` en el input. -->
<Field name="email" label="Correo electrónico" required floatingLabel size="lg">
<Field.Label>Correo electrónico</Field.Label>
<Field.Control>
<Field.Input type="email" placeholder=" " bind:value={form.values.email} />
</Field.Control>
{#if emailError}
<Field.ErrorText>{emailError}</Field.ErrorText>
{/if}
</Field>
{/snippet}
{#snippet submit()}
<Form.Submit size="lg" color="fulfill">Suscribirme</Form.Submit>
{/snippet}
{#snippet note()}
Nada de spam. Te das de baja en un clic y no compartimos tu correo con nadie. Lee la
<Link href="#privacidad" variant="subtle" color={panel ? 'currentColor' : undefined}>
política de privacidad
</Link>.
{/snippet}
{#snippet children()}
{#if subscribed}
<!-- La confirmación es del APP: `Form` ya emitió `commit-submit`; qué
significa suscribirse lo decide quien recibe los valores. -->
<Callout intent="affirm">
<Callout.Icon />
<Callout.Title>Confirmado</Callout.Title>
<Callout.Content>
Te hemos enviado un correo a <strong>{subscribed}</strong> para confirmar la suscripción.
</Callout.Content>
</Callout>
{/if}
{/snippet}
</Newsletter>

@ -0,0 +1,30 @@
<script lang="ts">
/**
* The preview is its OWN page — `@` resets the layout. Axes arrive via the URL
* so the demo's iframe can drive them.
*/
import { page } from '$app/state';
import BootUix, { type BootLanguage } from '../../_lib/BootUix.svelte';
import '@/uix/eidos/index.css';
let { children } = $props();
const mode = $derived(page.url.searchParams.get('mode') === 'dark' ? 'dark' : 'light');
const dir = $derived(page.url.searchParams.get('dir') === 'rtl' ? 'rtl' : 'ltr');
const language = $derived((page.url.searchParams.get('lang') ?? 'es') as BootLanguage);
</script>
<svelte:head>
<meta name="color-scheme" content="light dark" />
</svelte:head>
<BootUix {mode} {dir} {language}>
<div
data-theme={mode}
data-mode={mode}
{dir}
style="min-block-size: 100dvh; background: var(--color-surface-default); color: var(--color-content-primary);"
>
{@render children?.()}
</div>
</BootUix>

@ -0,0 +1,16 @@
<script lang="ts">
/**
* Standalone page for the newsletter mini-site — same component the demo
* renders inline, served as its OWN document for the device-width frame.
*/
import { page } from '$app/state';
import NewsletterSite from '../NewsletterSite.svelte';
const params = $derived(page.url.searchParams);
const layout = $derived((params.get('layout') ?? 'center') as 'center' | 'justified');
const panel = $derived(params.get('panel') !== 'false');
const color = $derived(params.get('color') ?? 'primary');
const gradient = $derived(params.get('gradient') !== 'false');
</script>
<NewsletterSite {layout} {panel} {color} {gradient} />
Loading…
Cancel
Save

Powered by TurnKey Linux.