fix(blocks,canon): el ledger dice la verdad y el arco perceptivo vuelve a sonar

La auditoria del tier tenia los veredictos desemparejados de sus hallazgos: un
join por POSICION, y el journal del workflow era de sesion. Reproducido en vivo
al re-verificar — el journal devuelve los resultados en otro orden que la
entrada. Ledger nuevo con 90 ids estables (AUDIT-blocks-ledger.md), union
SIEMPRE por id: 41 arreglados, 34 confirmados, 12 refutados con motivo escrito,
3 dato. La tasa real de refutacion es del 13%, no del 28%.

De las 12 ALTA de percepcion, 8 tenian la causa raiz en el CANON. Los blocks
componen bien; lo que estaba roto era el arco perceptivo.

CANON
- Link: la prop color era inerte en subtle/plain (inherit a 0-2-0 ganaba a la
  paleta a 0-1-0), el hover clavaba primary y el active quedaba tapado por el
  hover de variante. Medido: el enlace del footer pasa de la tinta del padre a
  la suya.
- Card: prometia BoxProps y no los aplicaba — height="100%" era un atributo
  inerte. El tipo dice la verdad y el eje de tamano se aplica con un helper
  compartido (buildSizeStyle). Tarjetas al fin de igual alto.
- Form.Submit / Form.Reset: componen el Button del canon, asi que la accion
  principal de un formulario recupera el contact-activate en el gesto. Y el
  aria-label generico deja de pisar el texto propio (WCAG 2.5.3 para todo
  consumidor), verificado con el AX tree de Chrome.
- Field: dejaba de duplicar commit-submit dentro de un Form — un Enter emitia
  dos commits con intents contradictorios y dos earcons.
- NavigationMenu: commit-select se mueve al Link (navegar es el acto
  evaluable), el despliegue habla como emerge-open/close en vez de fingir una
  seleccion por hover, y el pack casa por fin con la parte que recibe el
  estampado — antes sonaba a la ganancia base, 10x lo disenado.
- Badge: el boton de quitar compone IconButton; la altura del chip pasa a ser
  la del control, asi que md significa lo mismo en todo el sistema.
- Fundacion: [data-on] arrastra la propiedad color, no solo las variables.

BLOCKS
- hero: la CTA secundaria pasa de 1.61:1 a 6.61:1.
- contact: separa incomplete de invalid — el camino de error por campo era
  inalcanzable por construccion — y su frase llega a la AT.
- newsletter: coordina (fase 4 del plan). Maquina de cinco estados, palabras
  propias, Submit y Reason como partes que leen el contexto.
- site-header ya no congela la pagina al cruzar el breakpoint; site-footer
  emite su commit; pricing no se vacia; cta llega a sangre de verdad.

DOCTRINA
- La frontera dura 1 nombra $libs/forms como puerta sancionada.
- El contrato B admite un segundo servicio: el anunciador. Un block que posee
  las palabras de sus estados tiene que poder decirlas.

Gates: blocks:check 15/0 · vitest 20/20 en blocks y 402/402 en eidos+blocks ·
svelte-check 75/54 (linea base) · docs:check 0/0 · prettier limpio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-dir-prefs
dev 2 months ago
parent c46a955a0f
commit 866089a407

@ -17,17 +17,17 @@ a list in a doc.
Blocks exist so that **assembly knowledge does not dilute the canon**: the
catalog's value is contract density (events, ARIA, keyboard, tokens other
layers consume); a block's value is *correct assembly* — layout, landmarks,
layers consume); a block's value is _correct assembly_ — layout, landmarks,
heading hierarchy, responsive behavior, content slots. A block brings no new
primitives; it brings the right way to put existing ones together.
The tier map, for orientation:
| Tier | Value | Contract | Enters through |
| --- | --- | --- | --- |
| **Canon** (`src/uix/`) | contract density others consume | morfo + acceptance matrix | the 9-phase route ([`building-a-component.md`](../building-a-component.md)) |
| **Packs** (`src/packs/`) | parameterized leaf decoration | P contract ([`packs.md`](./packs.md)) | `packs:check` |
| **Blocks** (`src/uix/blocks/`) | page-function composition | B contract (below) | `blocks:check` |
| Tier | Value | Contract | Enters through |
| ------------------------------ | ------------------------------- | ------------------------------------- | --------------------------------------------------------------------------- |
| **Canon** (`src/uix/`) | contract density others consume | morfo + acceptance matrix | the 9-phase route ([`building-a-component.md`](../building-a-component.md)) |
| **Packs** (`src/packs/`) | parameterized leaf decoration | P contract ([`packs.md`](./packs.md)) | `packs:check` |
| **Blocks** (`src/uix/blocks/`) | page-function composition | B contract (below) | `blocks:check` |
## The admission rule (canon vs block)
@ -93,7 +93,10 @@ promoted — not grown inside the block.
## Hard boundaries
1. **Dependency direction is one-way**: `src/uix/blocks/*` may import `$uix`,
`$adom` and the public arts; nothing in the canon
`$adom`, the public arts and `$libs/forms` (the door to `createForm`, which
the canon `Form` does not re-export and soma consumes the same way — sanctioned
2026-08-05, when the audit found the contract and the code disagreeing about
`contact`); nothing in the canon
(`src/uix/{morfo,soma,sema,eidos,active-uix,langs}`) nor in
`src/{arts,libs,packs}` may import from `src/uix/blocks/`. Deleting the
tier must leave `npm run check` green — blocks is a tier under `src/uix/`,
@ -103,7 +106,7 @@ promoted — not grown inside the block.
below, guarded by `blocks:check`.
3. **Layout-components-first**: layout is composed from the canon layout
components (`Container / Section / Stack / Flex / Grid / AutoGrid / Wrap /
Group / Separator / AspectRatio / Surface`) and their props. A block ships
Group / Separator / AspectRatio / Surface`) and their props. A block ships
no `.css` file; a scoped `<style>` is the justified exception, never the
pattern.
4. **Blocks are removable by construction** — an app that never imports a
@ -114,19 +117,19 @@ promoted — not grown inside the block.
Guarded mechanically by `npm run blocks:check` (self-testing: the guard
asserts its own detectors against inline fixtures on every run).
| B | Obligation |
| --- | --- |
| B-1 | No morfo, no sema pack, no audit row. Behavior with contract surface is promoted to canon BEFORE the block composes it. |
| B-2 | Every interactive element is a canon eidos component (`Button`, `Link`, `Field`, …). Raw interactive natives (`button/input/select/textarea/a`) are an error. (Single exception: the already-rendered HTML that `Prose` receives — that content belongs to the app.) |
| B-3 | Composed components are consumed AS-IS through their public props (`variant/size/color/…`). Re-styling their internals from the block (CSS or `style=`) is forbidden. Colors are always roles/tokens via props. |
| B-4 | One-way imports (hard boundary 1). Deleting the tier leaves `check` green. Blocks do not import each other (see B-10). |
| B-5 | Content enters by composition (children/snippets) — never `root={tree}` data-tree props (`items={...}` only where the composed canon component is already data-driven). A coordinating block MAY ship the default **shape** of the data it coordinates (a schema), which the app replaces by passing its own — a shape is not content. |
| B-6 | Responsive via the framework's mechanisms (responsive props of the layout components, canonical breakpoints). No `matchMedia`/listeners of its own — needing to observe something is the admission rule firing. |
| B-7 | Everything the APP says arrives as children/props — a block writes none of it. **Amended 2026-07-31**: a block DOES own the words of the states it coordinates (and only those), as idlangrefs with an English fallback resolved through the translator (`#?blocks.<block>.<key>\|…`). The reason a state names itself is the same reason it exists: the record that blocks the action writes the sentence, so it cannot go silent. |
| B-8 | Correct landmarks: sectioning element + `aria-label`/`aria-labelledby` where landmarks repeat; heading hierarchy coherent and documented in the block README (which level it emits, how to adjust). |
| B-9 | Every block ships `README.md` (**Function · Composition map** — which canon components, which props — **· Decisions · Gaps-with-disposition**) and a live demo page under `web/routes/blocks/{kebab}/`, built on the shared demo shell (`web/routes/blocks/_lib/BlockDemo.svelte`): the block is shown FULL-BLEED on the page — never inside a padded frame or a scroll box, which would change what it does — with the device widths served by its own `preview` route. Anatomy in [`src/uix/blocks/README.md`](../../src/uix/blocks/README.md). |
| B-10 | A block does not import another block. Shared structure is either a canon component or a conscious duplication (recorded in Gaps). Declared exception: the shells (`app-shell`, `docs-shell`) compose F1 pieces and blocks by design — allow-listed in `blocks:check`. |
| B-11 | Motion only through the composed components' `motion` props/presets or `Cascade` for entrance choreography. No `@keyframes`/transitions of its own. |
| B | Obligation |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| B-1 | No morfo, no sema pack, no audit row. Behavior with contract surface is promoted to canon BEFORE the block composes it. |
| B-2 | Every interactive element is a canon eidos component (`Button`, `Link`, `Field`, …). Raw interactive natives (`button/input/select/textarea/a`) are an error. (Single exception: the already-rendered HTML that `Prose` receives — that content belongs to the app.) |
| B-3 | Composed components are consumed AS-IS through their public props (`variant/size/color/…`). Re-styling their internals from the block (CSS or `style=`) is forbidden. Colors are always roles/tokens via props. |
| B-4 | One-way imports (hard boundary 1). Deleting the tier leaves `check` green. Blocks do not import each other (see B-10). |
| B-5 | Content enters by composition (children/snippets) — never `root={tree}` data-tree props (`items={...}` only where the composed canon component is already data-driven). A coordinating block MAY ship the default **shape** of the data it coordinates (a schema), which the app replaces by passing its own — a shape is not content. |
| B-6 | Responsive via the framework's mechanisms (responsive props of the layout components, canonical breakpoints). No `matchMedia`/listeners of its own — needing to observe something is the admission rule firing. |
| B-7 | Everything the APP says arrives as children/props — a block writes none of it. **Amended 2026-07-31**: a block DOES own the words of the states it coordinates (and only those), as idlangrefs with an English fallback resolved through the translator (`#?blocks.<block>.<key>\|…`). The reason a state names itself is the same reason it exists: the record that blocks the action writes the sentence, so it cannot go silent. |
| B-8 | Correct landmarks: sectioning element + `aria-label`/`aria-labelledby` where landmarks repeat; heading hierarchy coherent and documented in the block README (which level it emits, how to adjust). |
| B-9 | Every block ships `README.md` (**Function · Composition map** — which canon components, which props — **· Decisions · Gaps-with-disposition**) and a live demo page under `web/routes/blocks/{kebab}/`, built on the shared demo shell (`web/routes/blocks/_lib/BlockDemo.svelte`): the block is shown FULL-BLEED on the page — never inside a padded frame or a scroll box, which would change what it does — with the device widths served by its own `preview` route. Anatomy in [`src/uix/blocks/README.md`](../../src/uix/blocks/README.md). |
| B-10 | A block does not import another block. Shared structure is either a canon component or a conscious duplication (recorded in Gaps). Declared exception: the shells (`app-shell`, `docs-shell`) compose F1 pieces and blocks by design — allow-listed in `blocks:check`. |
| B-11 | Motion only through the composed components' `motion` props/presets or `Cascade` for entrance choreography. No `@keyframes`/transitions of its own. |
## Conventions
@ -136,10 +139,17 @@ asserts its own detectors against inline fixtures on every run).
at `web/routes/blocks/`.
- **Services**: a block wires theme, transport and submits through
handlers/props from the app — it consumes no `uix.prefs`, no storage, no
http. **The single sanctioned service is the translator**
(`ActiveEidos.require().langs`), and only to resolve the block's own state
words (B-7). Anything beyond that is the app's, or the admission rule
firing.
http. **Two services are sanctioned, and only for the block's own state
words (B-7)**: the translator (`ActiveEidos.require().langs`), which resolves
them, and — since 2026-08-06 — the announcer (`uix.announce`), which delivers
them to assistive tech. The second entered for the same reason as the first:
a block that OWNS the words of its states has to be able to say them, or
owning them is half a job. Measured case: `Contact.Reason` rendered the
sentence that explains a blocked send and nobody who could not see it ever
received it. The alternative — a live region of the block's own —
re-implements `Announce`, the system's live-region pair, and puts a second
announcer on the page. Anything beyond these two is the app's, or the
admission rule firing.
## The promotion path (lived)

@ -1,7 +1,19 @@
# AUDIT — tier blocks (2026-08-01)
> Congelado desde el workflow `wf_31fd1de3-ed6` (50 agentes). El journal del workflow es de
> SESIÓN y desaparece: esta es la única copia. No re-reportar lo refutado.
> SESIÓN y desaparece: esta es la única copia.
> ⚠️ **SU CLASIFICACIÓN NO VALE — 2026-08-05.** Los campos «Veredicto» de este documento
> están **desemparejados de su hallazgo** (el de `cta`/`Container` lleva uno sobre
> `Card`/`BoxProps`; el del contraste de `hero`, uno sobre el drawer de `site-header`; la
> primera justificación de la lista de refutados refuta el hallazgo de `feature-grid` que la
> sección de confirmados declara CONFIRMADO). Con el veredicto viajó la etiqueta
> CONFIRMADO/REFUTADO, así que **ni los 29 «confirmados» ni los 11 «refutados» son
> evidencia de nada** y la instrucción anterior —«no re-reportar lo refutado»— queda
> retirada. Lo que SÍ es válido y por eso este fichero se conserva íntegro: la
> **reclamación**, la **evidencia** y la **sugerencia** de cada entrada, que sí casan entre sí.
> El estado vivo de cada hallazgo vive ahora en
> [`AUDIT-blocks-ledger.md`](./AUDIT-blocks-ledger.md), con id estable por fila.
## Método

File diff suppressed because it is too large Load Diff

@ -74,12 +74,33 @@ surfaces: it owns the `dismissed` state, as the canon requires.
## Gaps
| Gap | Disposition |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`role="banner"` cannot be overridden** | **canon** — the component stamps it AFTER its rest props, so a page that also has a header gets a second banner landmark. Measured in the preview: two. Mitigated only by naming both with `aria-label` |
| **Sticky / bottom notice** | **app-land** — compose `Sticky`; a notice that follows the reader spends the same space twice |
| **Dismissal persistence** (cookie / storage) | **app-land** — the component's README discarded it explicitly |
| **Animated entrance / exit** | **deferred** — the component deferred it until ≥2 cases; here it would shift the page on load |
| Gap | Disposition |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`role="banner"` cannot be overridden** | **canon** — the component stamps it AFTER its rest props, so a page that also has a header gets a second banner landmark. Measured in the preview: two. Mitigated only by naming both with `aria-label` |
| **Sticky / bottom notice** | **app-land** — compose `Sticky`; a notice that follows the reader spends the same space twice |
| **Dismissal persistence** (cookie / storage) | **app-land** — the component's README discarded it explicitly |
| **Animated entrance / exit** | **deferred** — the component deferred it until ≥2 cases; here it would shift the page on load |
| **The dismiss ✕ cannot match the strip on a solid canvas** | **canon** — see below. The block now passes `color="currentColor"`, which fixes three intents out of four |
### The ✕ ink, measured (2026-08-05, A-37)
The block wired the dismiss with `variant="ghost"` and no colour, so `Banner.Close`
fell through to `Button`'s canonical `primary`: **the ✕ came out purple on every
strip**, whatever the intent — 5.93:1 on the light canvases and **1.98:1 on
`affirm`**, **2.05:1 on `risk`**, under the 3:1 a control needs. The block picked
the canvas, so it owes the ink; it now passes `color="currentColor"` and the ✕
matches the strip's own text: 15.88:1 on `info`/`warn` and 5.78:1 on `risk`,
against the banner text's own 15.88 / 15.88 / 5.89.
**`affirm` is worse and it is NOT fixable from here**, so it is declared, not
papered over: the strip's own text measures **3.07:1** on that canvas (the known
F16 — the palette's `contrast` slot is white on every solid step, and teal is one
of the steps that fails), and the ✕ ends at **1.50:1** because the component
colour path mixes an explicit value with 30% black before painting, so
`currentColor` cannot reproduce the ink it inherits. Two canon fixes are needed:
the `affirm` solid step needs a contrast slot that passes, and a raw `color` on a
ghost `Button` should not be darkened. Until then this intent's dismiss is below
threshold either way — 1.98 before, 1.50 now.
## Found while composing

@ -54,8 +54,13 @@
</Container>
{#if onDismiss}
<!-- `Banner.Close` composes the canon `IconButton` and localises its own
accessible name; the recipe pushes it to the end. The block only wires
the click. -->
<Banner.Close variant="ghost" onclick={onDismiss} />
accessible name; the recipe pushes it to the end.
`color="currentColor"`: the canon defers the dismiss ink to the consumer
and falls back to Button's `primary`, so with only `variant="ghost"` the
✕ came out PURPLE on every strip regardless of intent — measured 1.98:1
on `affirm` and 2.05:1 on `risk`, under the 3:1 that a control needs.
The block already chose the canvas, so it owes the ink: inheriting the
strip's own text colour is the one value that works on all four. -->
<Banner.Close variant="ghost" color="currentColor" onclick={onDismiss} />
{/if}
</Banner>

@ -12,11 +12,11 @@ the words for every state it can be in.
## What the block owns
| Owns | Where | Why it is not the app's |
| ----------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **The section's state** | `state.ts` | `incomplete · unverified · verifying · rejected · ready · sending · sent`, derived ONCE at the root. Read the sources at the call site and each `disabled` drifts. |
| **The words** | `CONTACT_REASON` / `_ACTION` | Two exhaustive `Record`s: the same map that blocks the button writes the sentence, so a mute blocked state cannot be added. |
| **The default shape** | `schema.ts` | name · email · message. A contact section that cannot say what a contact form asks is a grid, not a block. |
| Owns | Where | Why it is not the app's |
| ----------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **The section's state** | `state.ts` | `incomplete · invalid · unverified · verifying · rejected · ready · sending · sent`, derived ONCE at the root. Read the sources at the call site and each `disabled` drifts. |
| **The words** | `CONTACT_REASON` / `_ACTION` | Two exhaustive `Record`s: the same map that blocks the button writes the sentence, so a mute blocked state cannot be added. |
| **The default shape** | `schema.ts` | name · email · message. A contact section that cannot say what a contact form asks is a grid, not a block. |
What stays the app's: the values, the handler (what "send" means), the
verification challenge itself, and any schema that is not the default one.
@ -31,26 +31,49 @@ first paint). Measured in the browser, both branches, 2026-07-31.
## Composition map
| Slot | Composes | Notes |
| ------------- | ------------------------------------------- | ------------------------------------------------------------------------ |
| root | bare `<section>` | `Section` + `Container` place it; creates the form and derives the state |
| `Header` | `Box` (measure `48rem`) + `Motion` | the app supplies a `Heading` (level 2) and a `Text` |
| `Body` | `Grid` `3fr / 2fr` → one column below `md` | which side the form falls on is the ORDER the app composes in |
| `Form` | `Motion` + canon `Form` + `Stack` (gap 4) | takes the handle from CONTEXT — the root already owns it |
| `Fields` | `Stack` + `Grid` + `Field` + `TextArea` | the block's own three fields, labels through the translator |
| `Submit` | `Form.Submit` | disabled AND labelled from the state — no call-site boolean |
| `Reason` | `Text` | the sentence of the state; renders nothing when there is none |
| `Details` | `Motion` + `Stack` (gap 5) | the column of contact rows |
| `Detail` | `Group` (`align="start"`) | so a two-line value keeps its icon at the first line |
| `DetailLabel` | `Text` `size="sm"` `weight="medium"` | |
| `DetailValue` | `Text` `as="p"` `size="sm"` `color="muted"` | a `span` would ignore `align`, and this IS a paragraph |
| Slot | Composes | Notes |
| ------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| root | bare `<section>` | `Section` + `Container` place it; creates the form and derives the state |
| `Header` | `Box` (measure `48rem`) + `Motion` | the app supplies a `Heading` (level 2) and a `Text` |
| `Body` | `Grid` `3fr / 2fr` → one column below `md` | which side the form falls on is the ORDER the app composes in |
| `Form` | `Motion` + canon `Form` + `Stack` (gap 4) | takes the handle from CONTEXT — the root already owns it |
| `Fields` | `Stack` + `Grid` + `Field` + `TextArea` | the block's own three fields, labels through the translator |
| `Submit` | `Form.Submit` | disabled AND labelled from the state; the state word also goes in `aria-label` and the reason in `aria-describedby` |
| `Reason` | `Text as="p"` `role="status"` | the sentence of the state — the node is ALWAYS there and only its text changes, so the live region can announce it |
| `Details` | `Motion` + `Stack` (gap 5) | the column of contact rows |
| `Detail` | `Group` (`align="start"`) | so a two-line value keeps its icon at the first line |
| `DetailLabel` | `Text` `size="sm"` `weight="medium"` | |
| `DetailValue` | `Text` `as="p"` `size="sm"` `color="muted"` | a `span` would ignore `align`, and this IS a paragraph |
**Landmark + headings**: a bare `<section>` with NO accessible name of its own,
so it is not an exposed landmark — the app names it by passing `aria-label` /
`aria-labelledby` through `...rest`. Every heading is the app's: `Contact.Header`
takes a `Heading` (level 2) and the block emits none.
## Two blockers, two sentences (2026-08-05)
`incomplete` and `invalid` are separate states, and the split is the whole point
of owning the machine. Emptiness is asked of the VALUES, wrongness of the SCHEMA:
telling someone to «fill in every field» in front of three full fields with a
broken email is a lie, and it was the one this section told.
**`invalid` can submit, on purpose.** Blocking it looks safer and is worse: with
`progressive` validation the form only writes field errors after a failed submit,
so gating the button on validity made the per-field error path unreachable by
construction — no marked control, no `aria-invalid`, and a sentence that
contradicted the screen. Letting the submit run hands the job to the canon
`Form`, which marks the offenders, focuses the first one and emits
`signal-warn-invalid`. Measured end to end: broken email → button enabled and
«Check the fields marked below» → press → `aria-invalid` on the email plus its
error text → fixed → back to `ready`.
## What it does NOT do
**It does not validate and it does not render fields' errors.** `Form` owns the
runtime, the error aggregation and the focus to the first offender; `Field` owns
the label↔control↔error wiring. The block coordinates AROUND them — it decides
whether a send can happen, and says why when it cannot.
**It does not aggregate errors and it does not decide what «wrong» means.**
`Form` owns the runtime, the error aggregation and the focus to the first
offender; `Field` owns the label↔control↔error wiring, and `Contact.Fields`
renders those error texts for the block's own three fields. The block coordinates
AROUND them — it decides whether a send can happen, and says why when it cannot.
**It does not talk to a server.** `onSend` receives the validated values; what
"send" means, and the verification verdict, are the app's. The block folds the

@ -2,11 +2,30 @@
/**
* Contact.Reason — why the section is where it is.
*
* Renders nothing in the states that need no explanation, and the reason comes
* from the SAME map that blocks the button. That is what makes a mute blocked
* state impossible: you cannot add a state that stops the send without giving
* it a sentence, because the record in `state.ts` is exhaustive.
* The reason comes from the SAME map that blocks the button. That is what
* makes a mute blocked state impossible: you cannot add a state that stops the
* send without giving it a sentence, because the record in `state.ts` is
* exhaustive.
*
* The sentence reaches assistive tech through the SYSTEM's announcer
* (`uix.announce`), not through a live region of our own. The audit found this
* part mute for anyone who cannot see it — the node was destroyed on the way to
* `ready` and a different one created on the way to `sent`, and a live region
* inserted together with its text announces nothing. The first fix put
* `role="status"` here, which works but re-implements a surface the framework
* already owns: `Announce` is the system's live-region pair, with its own morfo
* and its `signal-announce` verb. One announcer per page, and it is not this
* block's.
*
* That makes the announcer the SECOND service a block may consume, next to the
* translator — written into the B contract on 2026-08-06. The reason is the
* same one that let the translator in: a block that owns the words of its
* states has to be able to say them, or owning them is half a job.
*
* The node still carries an `id`, because `aria-describedby` from the submit is
* a different mechanism (association, not announcement) and both are wanted.
*/
import { ActiveEidos } from '$uix/eidos';
import { Text } from '$uix/eidos/components/text';
import { getContactContext } from './context';
import { CONTACT_REASON } from './state';
@ -15,12 +34,26 @@
let { size = 'sm', ...rest }: ContactReasonProps = $props();
const contact = getContactContext();
const eidos = ActiveEidos.require();
const state = $derived(contact?.state ?? 'incomplete');
const ref = $derived(CONTACT_REASON[state]);
const sentence = $derived(ref && contact ? contact.t(ref) : '');
// Announce the sentence whenever it CHANGES — polite, because it reports where
// the section stands; it never interrupts what the user is doing.
let announced = '';
$effect(() => {
const text = sentence;
if (!text || text === announced) return;
announced = text;
eidos.uix?.announce(text, 'polite');
});
// `sent` is the one reason that is good news; the rest are things still missing.
const color = $derived(state === 'sent' ? 'affirm' : 'muted');
</script>
{#if ref && contact}
<Text {size} {color} {...rest}>{contact.t(ref)}</Text>
{#if contact}
<Text as="p" id={contact.reasonId} {size} {color} {...rest}>
{sentence}
</Text>
{/if}

@ -6,6 +6,15 @@
* No `disabled` expression at the call site: whether a send can happen is one
* decision, taken once, in `state.ts`. The label follows the same state, so
* «Sending…» and «Sent» cannot drift from the thing they describe.
*
* The state word is passed as `aria-label` too, and that is not belt and
* braces: the canon `Form.Submit` always stamps its own generic `aria-label`
* («Enviar»), which wins over the button's own text in the accessible-name
* computation. Measured with Chrome's own AX tree, the name stayed «Enviar»
* through every state while the visible label said «Enviar mensaje» /
* «Enviando…» / «Enviado» — a WCAG 2.5.3 failure and, worse, the words this
* block exists to own never reached anyone using assistive tech. The canon
* does honour an explicit `aria-label`, so the block passes one.
*/
import { Form } from '$uix/eidos/components/form';
import { getContactContext } from './context';
@ -16,8 +25,16 @@
const contact = getContactContext();
const state = $derived(contact?.state ?? 'incomplete');
const label = $derived(contact?.t(CONTACT_ACTION[state]) ?? '');
</script>
<Form.Submit {...rest} {color} {size} disabled={!canSubmit(state)}>
{#if children}{@render children({ state })}{:else}{contact?.t(CONTACT_ACTION[state]) ?? ''}{/if}
<Form.Submit
{...rest}
{color}
{size}
disabled={!canSubmit(state)}
aria-label={label}
aria-describedby={contact?.reasonId}
>
{#if children}{@render children({ state })}{:else}{label}{/if}
</Form.Submit>

@ -46,6 +46,7 @@
...rest
}: ContactProps = $props();
const uid = $props.id();
const eidos = ActiveEidos.require();
// The block ships a working form. An app that asks for something else passes
@ -77,8 +78,19 @@
return check ? check.call(schema, ownForm.values).ok : ownForm.isValid;
});
// Emptiness is a different blocker from wrongness. Asked of the VALUES, not of
// the schema: «you have not typed here yet» is not «what you typed is wrong»,
// and telling someone to fill in a form they filled in is the hole this block
// exists to close.
const hasEveryField = $derived(
Object.values(ownForm.values as Record<string, unknown>).every(
(v) => v !== undefined && v !== null && String(v).trim() !== ''
)
);
const state = $derived(
resolveContactState({
hasEveryField,
isComplete,
isPending: ownForm.isPending,
verification,
@ -86,6 +98,11 @@
})
);
// The reason's id travels with the state so the submit can point at it with
// `aria-describedby`: the sentence that explains a blocked send has to reach
// the control it is about, not just the pixels next to it.
const reasonId = `${uid}-reason`;
setContactContext({
get state() {
return state;
@ -93,6 +110,7 @@
get form() {
return ownForm;
},
reasonId,
t: (ref: string) => eidos.langs.t(ref)
});
</script>

@ -12,6 +12,12 @@ export interface ContactContext {
readonly state: ContactState;
/** The form handle — the block's own when the app did not pass one. */
readonly form: Form;
/**
* The id of the reason line, so the submit can reference it with
* `aria-describedby`. A sentence that only exists next to the control does not
* reach anyone who cannot see it.
*/
readonly reasonId: string;
/** Resolve an idlangref through the active translator. */
t(ref: string): string;
}

@ -16,14 +16,19 @@ import {
*/
const base: ContactStateInput = {
hasEveryField: false,
isComplete: false,
isPending: false,
verification: undefined,
sent: false
};
/** Every field typed in, and what was typed passes the shape. */
const filled = { ...base, hasEveryField: true, isComplete: true } as const;
const ALL_STATES: ContactState[] = [
'incomplete',
'invalid',
'unverified',
'verifying',
'rejected',
@ -42,20 +47,36 @@ describe('resolveContactState', () => {
});
it('is ready only when the data is complete and there is no challenge', () => {
expect(resolveContactState({ ...base, isComplete: true })).toBe('ready');
expect(resolveContactState(filled)).toBe('ready');
});
it('holds at unverified while a challenge is pending', () => {
expect(resolveContactState({ ...base, isComplete: true, verification: 'idle' })).toBe(
'unverified'
it('separates «still empty» from «filled in wrong»', () => {
// The two blockers need different sentences: «fill in every field» in front
// of three full fields with a broken email is a lie, and it was the one the
// section told. Emptiness is asked of the values, wrongness of the schema.
expect(resolveContactState({ ...base, hasEveryField: false, isComplete: false })).toBe(
'incomplete'
);
expect(resolveContactState({ ...base, isComplete: true, verification: 'verified' })).toBe(
'ready'
expect(resolveContactState({ ...base, hasEveryField: true, isComplete: false })).toBe(
'invalid'
);
});
it('holds at unverified while a challenge is pending', () => {
expect(resolveContactState({ ...filled, verification: 'idle' })).toBe('unverified');
expect(resolveContactState({ ...filled, verification: 'verified' })).toBe('ready');
});
it('a wrong field outranks the challenge', () => {
// Solving a captcha to then be told the email is malformed is the wrong
// order: fix what you typed first.
expect(
resolveContactState({ ...base, hasEveryField: true, isComplete: false, verification: 'idle' })
).toBe('invalid');
});
it('what already happened wins over what is missing', () => {
const missing = { ...base, isComplete: false, verification: 'idle' } as const;
const missing = { ...base, verification: 'idle' } as const;
expect(resolveContactState({ ...missing, sent: true })).toBe('sent');
expect(resolveContactState({ ...missing, isPending: true })).toBe('sending');
});
@ -63,16 +84,23 @@ describe('resolveContactState', () => {
it('a negative verdict outranks «fill the fields»', () => {
// Telling someone to complete a form they completed is noise; the verdict
// is the thing that actually happened.
expect(resolveContactState({ ...base, isComplete: true, verification: 'failed' })).toBe(
'rejected'
);
expect(resolveContactState({ ...filled, verification: 'failed' })).toBe('rejected');
expect(resolveContactState({ ...base, verification: 'verifying' })).toBe('verifying');
});
});
describe('canSubmit', () => {
it('lets exactly one state through', () => {
expect(ALL_STATES.filter(canSubmit)).toEqual(['ready']);
it('lets the ready state and the invalid one through', () => {
// `invalid` is deliberate: with `progressive` validation the form only
// writes field errors after a failed submit, so gating the button on
// validity made the per-field error path unreachable by construction.
expect(ALL_STATES.filter(canSubmit)).toEqual(['invalid', 'ready']);
});
it('never lets a send through while something already happened', () => {
for (const state of ['sending', 'sent', 'verifying', 'rejected', 'unverified'] as const) {
expect(canSubmit(state), `${state} must not submit`).toBe(false);
}
});
});

@ -8,14 +8,22 @@
* saying why. Enumerating the states makes a mute one impossible — every state
* below has a reason key, and the same map that blocks the button writes it.
*/
import type { ProofOfHumanStatus } from '$uix/eidos/components/proof-of-human';
/** Where the app's human-verification challenge stands, if it uses one. */
export type ContactVerification = 'idle' | 'verifying' | 'verified' | 'failed';
/**
* Where the app's human-verification challenge stands, if it uses one. Taken
* from the canon rather than re-declared: a parallel copy of the same four words
* would not break at type level the day the component grows a fifth, and the new
* value would land silently in the `!== 'verified'` bucket.
*/
export type ContactVerification = ProofOfHumanStatus;
/** The section's state. Exhaustive by construction. */
export type ContactState =
/** Some field is missing or invalid. */
/** Some required field is still empty. */
| 'incomplete'
/** Every field has content, but something in it does not pass the shape. */
| 'invalid'
/** Data is fine; the verification challenge has not been solved yet. */
| 'unverified'
/** The challenge was solved and its verdict is in flight. */
@ -30,6 +38,12 @@ export type ContactState =
| 'sent';
export interface ContactStateInput {
/**
* Has every required field been filled in at all? Emptiness is a different
* blocker from wrongness, and they need different sentences: «fill in every
* field» is a lie in front of three full fields with a broken email.
*/
readonly hasEveryField: boolean;
/**
* Do the values satisfy the shape? NOT `form.isValid` — with progressive
* validation that one means «nothing has been found wrong YET», so an
@ -56,14 +70,25 @@ export function resolveContactState(input: ContactStateInput): ContactState {
if (input.isPending) return 'sending';
if (input.verification === 'failed') return 'rejected';
if (input.verification === 'verifying') return 'verifying';
if (!input.isComplete) return 'incomplete';
if (!input.hasEveryField) return 'incomplete';
if (!input.isComplete) return 'invalid';
if (input.verification !== undefined && input.verification !== 'verified') return 'unverified';
return 'ready';
}
/** Whether the section can be submitted in this state. One place decides it. */
/**
* Whether the section can be submitted in this state. One place decides it.
*
* `invalid` goes through ON PURPOSE. Blocking it looks safer and is worse: with
* `progressive` validation the form only writes field errors after a failed
* submit, so gating the button on validity made the per-field error path
* unreachable by construction — the user got «fill in every field» in front of
* three full fields and no marked control. Letting the submit run hands the job
* to the canon `Form`, which marks the offenders, focuses the first one and
* emits `signal-warn-invalid`.
*/
export function canSubmit(state: ContactState): boolean {
return state === 'ready';
return state === 'ready' || state === 'invalid';
}
/**
@ -75,6 +100,7 @@ export function canSubmit(state: ContactState): boolean {
*/
export const CONTACT_REASON: Record<ContactState, string | null> = {
incomplete: '#?blocks.contact.reason.incomplete|Fill in every field to send.',
invalid: '#?blocks.contact.reason.invalid|Check the fields marked below.',
unverified: '#?blocks.contact.reason.unverified|Solve the verification above.',
verifying: '#?blocks.contact.reason.verifying|Checking the verification…',
rejected: '#?blocks.contact.reason.rejected|We could not verify that. Try again.',
@ -86,6 +112,7 @@ export const CONTACT_REASON: Record<ContactState, string | null> = {
/** The submit label per state — the action names what is happening. */
export const CONTACT_ACTION: Record<ContactState, string> = {
incomplete: '#?blocks.contact.action.send|Send message',
invalid: '#?blocks.contact.action.send|Send message',
unverified: '#?blocks.contact.action.send|Send message',
verifying: '#?blocks.contact.action.send|Send message',
rejected: '#?blocks.contact.action.send|Send message',

@ -18,7 +18,7 @@ beat of a page (`center`) or a mid-page nudge that must not stop the reading
| layout | `Stack` (center) · `Grid` 2-col (justified) | `justified` stacks on narrow, where one row would crush both halves |
| `eyebrow` | — (app: `Badge` / text) | above the title |
| `title` | `Heading` at `level` (default 2) | the block wraps the app's words and owns the landmark `id`; on-solid ink |
| `description` | `Text` `as="p"` (`60ch` measure) | on-solid ink. A `span` would ignore `align` — `text-align` is inert on an inline box |
| `description` | `Text` `as="p"` (`measure="narrow"`) | on-solid ink. A `span` would ignore `align` — `text-align` is inert on an inline box |
| `actions` | `Flex` (column → row at `sm`) | the app drops `Button`s / `Link`s |
## Form: snippet slots, not sub-components
@ -78,7 +78,7 @@ layout, colour, finish and direction, and the device widths served from
| Gap | Disposition |
| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Split with media** (panel with a screenshot beside the copy) | **deferred** — recurs in the refs; it is a third arrangement, and `Mockup` already exists for the media. Enters when a demo asks |
| **Full-bleed panel** (edge to edge, no container) | **app-land** — the app sets `container="full"` |
| **Full-bleed panel** (edge to edge, no container) | **SHIPPED 2026-08-05** — the app sets `container="full"`, and the block drops the gutter with it. Until then `full` only lifted the width cap, so the panel stayed 16px short of both edges: measured 992px@x144 before, 1264px@x8 after |
| **Dismissible / sticky CTA** | **out** — that is the `banner` block's job (F2.11), not this one |
| **A quiet BOUNDED panel** | **canon** — `Surface soft` does not bound (0.002 L from the page in light) and has no border; `Card outline` bounds but takes no gradient finish. No primitive covers "quiet CTA panel with an edge" |
| **`contrast` slot is white on every solid step** | **canon** — so a mid-L solid canvas (`neutral` 3.32 · `secondary` 3.30 · `slate` 3.30 · `teal` 3.07 in light) puts body copy below AA. The pairing guarantee holds for the dark canvases only. Measured with a luminance probe, not eyeballed |

@ -61,25 +61,32 @@
{@render eyebrow()}
{/if}
{#if title}
<Heading id={titleId} {level} align={centered ? 'center' : 'start'} wrap="balance" color="on-solid">
<Heading
id={titleId}
{level}
align={centered ? 'center' : 'start'}
wrap="balance"
color="on-solid"
>
{@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. A block
paragraph is also what this content is. -->
<Text
as="p"
size="lg"
color="on-solid"
align={centered ? 'center' : 'start'}
wrap="pretty"
>
{@render description()}
</Text>
</Box>
<!-- `as="p"`: Text renders a span by default, and `text-align` is inert on
an inline box — the lines would keep their `start` alignment. A block
paragraph is also what this content is, and it is what `measure` needs.
The line length rides the canon's own scale instead of a hand-written
cap: `narrow` is 54ch, one step under the body default. -->
<Text
as="p"
size="lg"
color="on-solid"
measure="narrow"
align={centered ? 'center' : 'start'}
wrap="pretty"
>
{@render description()}
</Text>
{/if}
</Stack>
{/snippet}
@ -101,14 +108,26 @@
<section aria-labelledby={title ? titleId : undefined} {...rest}>
<Section {size}>
<Container size={container}>
<!-- `full` means EDGE TO EDGE here, which is what the gaps table promises the
app. `Container` always applies `--container-padding-inline`, and `full`
alone only lifts the width cap, so the panel stayed 16px short of both
edges — the one arrangement the block said it supported was the one it
could not do. Dropping the gutter is the whole difference. -->
<Container size={container} paddingX={container === 'full' ? 0 : undefined}>
<!-- The panel arrives as one piece: it is a single statement, not a list,
so it reveals whole instead of staggering its parts. -->
<Motion trigger="viewport" motion="scale-fade">
<!-- 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. Quieter CTA = a quieter `color`, not a quieter variant. -->
<Surface variant="solid" {color} {gradient} rounded padding={{ base: 6, md: 10 }} width="100%">
<Surface
variant="solid"
{color}
{gradient}
rounded
padding={{ base: 6, md: 10 }}
width="100%"
>
{#if centered}
<Stack gap={6} align="center">
{@render copy()}

@ -8,7 +8,12 @@
// {#snippet actions()}<Button>…</Button>{/snippet}
// </Cta>
//
// <Cta layout="justified" variant="soft">…</Cta>
// <Cta layout="justified" color="indigo">…</Cta>
// <Cta container="full">…</Cta> ← edge to edge, no gutter
//
// There is NO `variant`: the soft track was measured out (0.002 L from the page
// in light mode), so the panel is always the solid canvas and a quieter CTA is a
// quieter `color`.
//
// Snippet slots, not sub-components: a CTA's parts neither repeat nor
// coordinate, so they are fixed positional slots the root arranges (same form as

@ -14,6 +14,13 @@ narrow readable column. Usually near the end of a page, often after pricing.
| `.List` | `Motion` (`trigger="viewport"`) wrapping the `Accordion` | **is** the accordion — its whole API passes through |
| `.Item` | `Accordion.Item` + `.Header` + `.Trigger` + `.Content` | thin proxy: `question` snippet → trigger, children → content |
**Landmark + headings**: a bare `<section>` with NO accessible name of its own,
so it is not an exposed landmark — the app names it by passing `aria-label` /
`aria-labelledby` through `...rest`. The section heading is the app's (`h2`, in
`.Header`); every question is an `h3`, the `Accordion.Header` default. That level
is FIXED here: `.Item` does not forward `level` today (the canon does expose it),
so a page that needs another depth is a gap, recorded below.
## Why compound (a thin proxy)
Its `Item` **repeats**, so it earns a compound API like `feature-grid`. But the
@ -21,7 +28,8 @@ behaviour is the canon `Accordion`'s, not the block's — the block **reads that
API and passes it straight through** (`type`, `bind:value`, `collapsible`,
`variant`, `size` on `.List`) rather than reinventing disclosure. `.Item` is the
one convenience: it spares the app the `Accordion.Item > Header > Trigger` /
`Content` scaffolding and auto-generates the item `value` when omitted.
`Content` scaffolding. Omitting `value` is fine, but that convenience is the
canon's: `Accordion.Item` generates its own key, so the block does not.
## Coordination
@ -32,6 +40,13 @@ canon `Accordion`, which owns the disclosure state, its keyboard and its ARIA. A
seam is not ownership: the block never derives from it and never gates anything
on it.
`.Item`'s `disabled` is the second forwarded prop, and it is deliberate rather
than a generic passthrough (`.Item` spreads no `...rest`). It needs no sentence
of its own: a question the app closes off says why in its own question text —
the block never blocks anything by itself, so there is no state here that could
go mute. That is the test the doctrine sets, and this is the side of it where
inventing a machine would be the opposite mistake.
## Decisions
**2026-07-24 — reference floor** (dossier §P1: Tailwind Plus «FAQs 7» ·

@ -1,19 +1,17 @@
<script lang="ts">
/**
* Faq.Item — one question, a thin proxy over `Accordion.Item`: the `question`
* snippet becomes the trigger, the children become the content. The `value`
* (the accordion's open-state key) is auto-generated when the app omits it.
* snippet becomes the trigger, the children become the content. Omitting
* `value` (the accordion's open-state key) is fine — `Accordion.Item` already
* generates one, so the block does not duplicate that.
*/
import { Accordion } from '$uix/eidos/components/accordion';
import type { FaqItemProps } from './types';
let { value, disabled = false, question, children }: FaqItemProps = $props();
const uid = $props.id();
const itemValue = $derived(value ?? `faq-${uid}`);
</script>
<Accordion.Item value={itemValue} {disabled}>
<Accordion.Item {value} {disabled}>
<Accordion.Header>
<Accordion.Trigger>{@render question?.()}</Accordion.Trigger>
</Accordion.Header>

@ -22,7 +22,8 @@ export type FaqHeaderProps = BoxProps;
export type FaqListProps = AccordionProps;
export type FaqItemProps = {
/** Stable value for the accordion's open state. Auto-generated when omitted. */
/** Stable value for the accordion's open state. `Accordion.Item` generates one
* when omitted. */
value?: string;
/** Disable this question. @default false */
disabled?: boolean;

@ -18,6 +18,12 @@ after the hero.
| `.ItemTitle` | `Heading` (level 3) | the feature name |
| `.ItemText` | `Text` (muted) | the feature description |
**Landmark + headings**: a bare `<section>` with NO accessible name of its own,
so it is not an exposed landmark — the app names it by passing `aria-label` /
`aria-labelledby` through `...rest`. The section heading is the app's (`h2`, in
`.Header`); `.ItemTitle` emits `h3` and takes a `level` prop to move under a
different parent.
## Why compound (and not snippet slots)
`site-header` and `hero` are snippet-slot blocks — their parts are fixed layout

@ -37,7 +37,9 @@ export type FeatureGridItemsProps = AutoGridProps;
/** Wraps a `Stack`. `align` defaults to `start`, `gap` to `4`. */
export type FeatureGridItemProps = StackProps;
/** Wraps a soft `Surface` chip. `color` defaults to `primary`. */
/** Wraps a `Surface` chip — `solid` by default, so the glyph reads in light mode
* where the soft tint is near-white. `color` defaults to `primary`; pass
* `variant="soft"` for the subtler tinted chip. */
export type FeatureGridItemIconProps = SurfaceProps;
/** Wraps a `Heading`. `level` defaults to `3`. */

@ -20,6 +20,12 @@ beside a screenshot, repeated down the page.
| `.Features` / `.Feature` | `Stack` / `Group` + `Icon.Check` + `Text` | the checklist; the check is decorative (aria-hidden) |
| `.Actions` | `Group` | the CTA cluster |
**Landmark + headings**: a bare `<section>` with NO accessible name of its own,
so it is not an exposed landmark — the app names it by passing `aria-label` /
`aria-labelledby` through `...rest`. `.Title` emits `h2` by default (each row is a
section-level statement of its own) and takes `level` to move it — drop it to
`3` when the page already put an `h2` above the rows.
**Reversal keeps reading order**: the copy is always first in the DOM; `reversed`
only sets `grid-column` so the media moves to the inline-start side. Screen
readers and keyboard order are unaffected.

@ -81,6 +81,25 @@ surfaces.
## Found while composing
- **The inversion context does not carry `color`, and `Display` ignores it**
(2026-08-05, fixing A-02/A-83 of the tier audit): under `background` the block
now stamps `data-on='dark'` on the copy cluster, which is the foundation's
inversion context — but measured in Chrome it only redefines the
`--color-content-*` variables. Anything resolving to `color: inherit` (a `Link
variant='subtle'`, the most common secondary action) keeps the page's dark ink,
so the block pairs it with an explicit `color: var(--color-content-primary)`,
exactly as `Surface` does with `--_surface-fg`. Second half of the gap:
**`Display` does not follow the context at all** — inside the cluster
`--color-content-primary` already resolves to the on-solid white and `Text`
follows it through `--_text-color`, but `Display` still computes
`oklch(0.2435 0 0)`, so the title keeps its explicit `color` prop. Measured
before/after on the secondary CTA: **1.61:1 → 6.61:1**. Canon candidates: make
`[data-on]` carry the `color` declaration, and make `Display` read the content
token like `Text` does.
- **On-solid `muted` is not a safe step down** (same pass): with the inversion
context, `muted` resolves to on-solid at 64% alpha, which over the scrimmed
canvas measures **3.73:1** — below AA for 20px copy. The description keeps full
on-solid ink under `background`; the step down is worth less than legibility.
- **`Box` / `Surface` `flex` (and `grow`) prop did not grow a flex child**
(2026-07-23): building the demo's faux-chart, `<Surface flex={1}>` items in a
flex row stayed `0`-width (computed `flex: 0 1 auto` — the `--box-flex` var

@ -62,7 +62,27 @@
it. So the copy unfolds — eyebrow, title, subtitle, actions — with no
hand-written delays and no numbers in this file.
-->
<Stack gap={5} align={centered ? 'center' : 'start'} data-stagger>
<!--
Under `background` the ink is set ONCE, on the cluster, not piece by piece:
`data-on='dark'` is the foundation's inversion context (it redefines
`--color-content-*` to the on-solid scale), and the `color` declaration is
what anything inheriting picks up — a `Link variant='subtle'` resolves to
`color: inherit`, so without it the app's secondary action keeps the page's
dark ink over the dark canvas (measured at 1.61:1, now 6.61:1). `Surface`
pairs the two the same way.
The `Display` below still takes its ink by prop: measured inside this
cluster, `--color-content-primary` already resolves to the on-solid white and
`Text` follows it through `--_text-color`, but `Display` computes
`oklch(0.2435 0 0)` anyway — its recipe does not read that token, so the
context alone leaves the title at 2.46:1. Both gaps are in the README.
-->
<Stack
gap={5}
align={centered ? 'center' : 'start'}
data-stagger
data-on={onDark ? 'dark' : undefined}
>
{#if eyebrow}
<Motion trigger="viewport">{@render eyebrow()}</Motion>
{/if}
@ -87,6 +107,10 @@
{#if description}
<Motion trigger="viewport">
<Box maxWidth="60ch">
<!-- Full on-solid ink under `background`, NOT the inverted `muted`:
the context resolves `muted` to on-solid at 64% alpha, which over
the scrimmed canvas measures 3.73:1 — under AA for 20px copy.
The step down is worth less than legibility here. -->
<Text
size="lg"
color={onDark ? 'on-solid' : 'muted'}

@ -12,20 +12,20 @@ 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 |
| 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`) / `Newsletter.Submit` | separate slots so the block can arrange the row; the action is a PART because it coordinates |
| `note` | `Text` `as="p"` `size="sm"` | quieter by SIZE, same ink as the description — `on-solid` on the panel, `secondary` off it |
| measure of the form | `Container size="sm"` (center only) | a field line as wide as the prose is unusable |
## What it does NOT do
@ -41,12 +41,18 @@ which is the decision the Form README already wrote down.
(fulfill) and `signal-invalid` (risk). A block emitting its own would give one
submit two voices.
## Form: snippet slots, not sub-components
## Form: snippet slots for the copy, parts for what coordinates
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`.)_
(`feature-grid.Item`) or **coordinate** (`pricing.Switch` ↔ `PlanPrice`). The
copy does neither, so title, description, field and note stay positional snippet
slots — the same registered deviation as `hero` and `cta`, and the plan's
`.Title`/`.Description`/`.Form` still does not apply.
**`Submit` and `Reason` are parts**, because they coordinate: each reads the
section's state from context instead of taking a `disabled` the app assembled at
the call site. That is the rule doing its job, not an exception to it — the same
line `contact` walks.
`field` and `submit` are two slots rather than one, because the block's whole
layout job is the relationship between them.
@ -55,16 +61,36 @@ layout job is the relationship between them.
_Position under the 2026-07-31 doctrine ([`architecture/blocks.md`](../../../../docs/architecture/blocks.md) §«Coordination»)._
⚠️ **The tier's open candidate.** This block arranges a form and forwards the
handle, but the submit it places **can be blocked with nothing on screen saying
why** — the app assembles any `disabled` at the point of use, and an invalid or
in-flight signup has no sentence of its own. That is precisely the hole
[`contact`](../contact/README.md) closed by owning the section's state, its
default shape and its words.
It is not done here because this block predates the doctrine (built 2026-07-30,
doctrine signed 2026-07-31). Reviewing it is the agreed next step — recorded in
`docs/process/CONTINUE-blocks.md`, not silently assumed.
**It coordinates, since 2026-08-06.** It was the tier's open candidate: the block
arranged a form and forwarded the handle, but the submit it placed **could be
blocked with nothing on screen saying why** — every app assembled its own
`disabled` at the point of use. That is the hole [`contact`](../contact/README.md)
closed first, and this block now closes the same way.
| Owns | Where |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **The section's state** | `state.ts` — `incomplete · invalid · ready · sending · sent`, derived ONCE at the root |
| **The words** | `NEWSLETTER_REASON` / `NEWSLETTER_ACTION`, two exhaustive `Record`s: the map that blocks the button writes the sentence, so a mute blocked state cannot be added |
**Two blockers, two sentences.** Emptiness is asked of the VALUE and wrongness of
the SCHEMA (`validateSync`, never `form.isValid` — under `progressive` that one
means «nothing found wrong yet», so an untouched form would report valid and
`incomplete` would be unreachable at load). And `invalid` CAN submit on purpose:
gating on validity makes the field's own error unreachable, because `progressive`
only writes it after a failed submit.
`Submit` and `Reason` are parts, not slots, and that is the tier's rule applied:
compound is earned when parts repeat or **coordinate**, and these read the state
from context. The reason line reaches assistive tech through the system announcer
(`uix.announce`), the second service the B contract sanctions.
Measured end to end: empty → button disabled, «Escribe tu correo para
suscribirte» · `roto` → button ENABLED, «Eso no parece una dirección de correo»,
announced · valid → no sentence · sent → «Suscrito», disabled, «Ya estás dentro»,
announced. The `aria-label` follows the state in all four.
What stays the app's: the schema, the values, the handler (in its `createForm`)
and when to flip `sent`.
## Decisions
@ -115,6 +141,16 @@ 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.
**Added 2026-08-05 (A-57 of the tier audit).** Off the panel the two lines of
copy had drifted apart: the description carried `muted` and the note carried
nothing, so it fell back to the recipe's full `content-primary` — the privacy
line read STRONGER than the line it supports (measured 15.88:1 vs 3.70:1). Both
now take `secondary` when there is no panel, which is what this block always
documented (same ink, hierarchy by size) and puts both over AA: 5.77:1 each.
Canon datum from the same measurement: **`muted` on the page surface is 3.70:1**,
under AA for body copy — it is a token problem, not a block one, and any block
using `muted` for real prose inherits it.
- **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

@ -0,0 +1,26 @@
import { getContext, setContext } from 'svelte';
import type { NewsletterState } from './state';
/**
* What `Newsletter` shares with its parts. The section's state is derived ONCE at
* the root and read from here — no part recomputes it, and no part invents a
* `disabled` of its own.
*/
export interface NewsletterContext {
/** The section's state. A getter, so reads track the reactive source. */
readonly state: NewsletterState;
/** The id of the reason line, for `aria-describedby` from the action. */
readonly reasonId: string;
/** Resolve an idlangref through the active translator. */
t(ref: string): string;
}
const KEY = Symbol('uix.newsletter');
export function setNewsletterContext(ctx: NewsletterContext): void {
setContext(KEY, ctx);
}
export function getNewsletterContext(): NewsletterContext | undefined {
return getContext<NewsletterContext | undefined>(KEY);
}

@ -2,21 +2,52 @@
//
// import { Newsletter } from '$blocks/newsletter';
//
// <Newsletter {form} {schema} onValidSubmit={subscribe}>
// // the handler lives in createForm — the block deliberately does NOT expose
// // `onValidSubmit`, because `Form.Provider` ignores it when a prebuilt form
// // arrives, and a prop that silently never fires is worse than no prop.
// const form = createForm({ schema, defaults: { email: '' }, onValidSubmit: subscribe });
//
// <Newsletter {form} {schema} bind:sent>
// {#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 submit()}<Newsletter.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';
// The block COORDINATES its section (2026-08-06): it derives one state machine
// from the schema, the form's pending flag and `sent`, and owns the words for
// every state it can be in. `Submit` and `Reason` read that state from context —
// no `disabled` is assembled at the call site, and no blocked state can go mute.
// Validation itself is still `Form`'s, and every visible word of the COPY arrives
// through a snippet (B-7).
import NewsletterComponent from './newsletter.svelte';
import Submit from './newsletter-submit.svelte';
import Reason from './newsletter-reason.svelte';
type NewsletterNamespace = typeof NewsletterComponent & {
Submit: typeof Submit;
Reason: typeof Reason;
};
const Newsletter = NewsletterComponent as NewsletterNamespace;
Newsletter.Submit = Submit;
Newsletter.Reason = Reason;
export { Newsletter };
export default Newsletter;
export type { NewsletterProps, NewsletterLayout } from './types';
export type {
NewsletterProps,
NewsletterLayout,
NewsletterSubmitProps,
NewsletterReasonProps
} from './types';
export {
resolveNewsletterState,
canSubscribe,
NEWSLETTER_REASON,
NEWSLETTER_ACTION
} from './state';
export type { NewsletterState, NewsletterStateInput } from './state';

@ -0,0 +1,44 @@
<script lang="ts">
/**
* Newsletter.Reason — why the section is where it is.
*
* The sentence comes from the SAME map that blocks the action, so a state that
* stops the subscribe without saying why cannot be added: the record in
* `state.ts` is exhaustive.
*
* It reaches assistive tech through the system's announcer (`uix.announce`),
* the second service the B contract sanctions for a block — never through a
* live region of its own, which would re-implement `Announce` and put a second
* announcer on the page. Same shape as `Contact.Reason`.
*/
import { ActiveEidos } from '$uix/eidos';
import { Text } from '$uix/eidos/components/text';
import { getNewsletterContext } from './context';
import { NEWSLETTER_REASON } from './state';
import type { NewsletterReasonProps } from './types';
let { size = 'sm', ...rest }: NewsletterReasonProps = $props();
const newsletter = getNewsletterContext();
const eidos = ActiveEidos.require();
const state = $derived(newsletter?.state ?? 'incomplete');
const ref = $derived(NEWSLETTER_REASON[state]);
const sentence = $derived(ref && newsletter ? newsletter.t(ref) : '');
// `sent` is the one reason that is good news; the rest are things still missing.
const color = $derived(state === 'sent' ? 'affirm' : 'muted');
let announced = '';
$effect(() => {
const text = sentence;
if (!text || text === announced) return;
announced = text;
eidos.uix?.announce(text, 'polite');
});
</script>
{#if newsletter}
<Text as="p" id={newsletter.reasonId} {size} {color} {...rest}>
{sentence}
</Text>
{/if}

@ -0,0 +1,37 @@
<script lang="ts">
/**
* Newsletter.Submit — the subscribe action, disabled and labelled BY THE
* SECTION'S STATE.
*
* This is why the block earns a part here at all: the tier's rule says compound
* is earned when parts REPEAT or COORDINATE, and this one coordinates — it
* reads the state from context instead of taking a `disabled` the app assembled
* at the call site. That expression, re-derived by every consumer, is exactly
* what left an action blocked with nothing on screen saying why.
*
* The state word also goes in `aria-label`, and the reason in
* `aria-describedby`: the canon `Form.Submit` no longer stamps a generic label
* over the button's own text, but an explicit one still wins, and it is what
* carries «Subscribing…» / «Subscribed» to assistive tech.
*/
import { Form } from '$uix/eidos/components/form';
import { getNewsletterContext } from './context';
import { NEWSLETTER_ACTION, canSubscribe } from './state';
import type { NewsletterSubmitProps } from './types';
let { color = 'fulfill', children, ...rest }: NewsletterSubmitProps = $props();
const newsletter = getNewsletterContext();
const state = $derived(newsletter?.state ?? 'incomplete');
const label = $derived(newsletter?.t(NEWSLETTER_ACTION[state]) ?? '');
</script>
<Form.Submit
{...rest}
{color}
disabled={!canSubscribe(state)}
aria-label={label}
aria-describedby={newsletter?.reasonId}
>
{#if children}{@render children({ state })}{:else}{label}{/if}
</Form.Submit>

@ -29,6 +29,9 @@
import { Text } from '$uix/eidos/components/text';
import { Form } from '$uix/eidos/components/form';
import { Motion } from '$uix/eidos/components/motion';
import { ActiveEidos } from '$uix/eidos';
import { setNewsletterContext } from './context';
import { resolveNewsletterState } from './state';
import type { NewsletterProps } from './types';
let {
@ -41,6 +44,7 @@
size = 'lg',
form,
schema,
sent = $bindable(false),
eyebrow,
title,
description,
@ -53,7 +57,41 @@
const uid = $props.id();
const titleId = `${uid}-title`;
const reasonId = `${uid}-reason`;
const centered = $derived(layout === 'center');
const eidos = ActiveEidos.require();
/**
* The section's state, derived ONCE here and read by the parts through context.
*
* Asked of the SCHEMA (`validateSync`), never of `form.isValid`: under
* `progressive` that one means «nothing has been found wrong yet», so an
* untouched form reports valid and `incomplete` would be unreachable at load —
* the lesson `contact` paid for. And emptiness is asked of the VALUE, because
* «you have not typed yet» is a different blocker from «what you typed is
* wrong», and they need different sentences.
*/
const values = $derived(form.values as Record<string, unknown>);
const hasValue = $derived(
Object.values(values).some((v) => v !== undefined && v !== null && String(v).trim() !== '')
);
const isValid = $derived.by(() => {
const check = (schema as { validateSync?(v: unknown): { ok: boolean } } | undefined)
?.validateSync;
return check ? check.call(schema, values).ok : form.isValid;
});
const state = $derived(
resolveNewsletterState({ hasValue, isValid, isPending: form.isPending, sent })
);
setNewsletterContext({
get state() {
return state;
},
reasonId,
t: (ref: string) => eidos.langs.t(ref)
});
// 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);
@ -80,10 +118,14 @@
<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. -->
<!-- `secondary`, not `muted`, off the panel: measured on the page surface
`muted` lands at 3.70:1, under AA for 20px copy. Same ink as the note
below it, which is what this block documents — the hierarchy is the
SIZE step (20px vs 14px), never a second ink. -->
<Text
as="p"
size="lg"
color={ink ?? 'muted'}
color={ink ?? 'secondary'}
align={centered ? 'center' : 'start'}
wrap="pretty"
>
@ -113,8 +155,13 @@
{/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'}>
<!-- ON the panel: quieter by SIZE, not by ink — `muted` would vanish on a
solid canvas. OFF the panel there is no `ink`, and leaving it unset
let the note fall back to the recipe's full `content-primary` while
the description above it went `muted`: the privacy line then read
STRONGER than the line it supports (measured 15.88:1 vs 3.70:1).
`secondary` restores the step down without dropping under AA. -->
<Text as="p" size="sm" color={ink ?? 'secondary'} align={centered ? 'center' : 'start'}>
{@render note()}
</Text>
{/if}

@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import {
resolveNewsletterState,
canSubscribe,
NEWSLETTER_REASON,
NEWSLETTER_ACTION,
type NewsletterState,
type NewsletterStateInput
} from './state';
/**
* The second coordinating block of the tier, and the same two properties worth
* locking as in `contact`: the priority order, and the impossibility of a mute
* blocked state.
*/
const base: NewsletterStateInput = {
hasValue: false,
isValid: false,
isPending: false,
sent: false
};
const filled = { ...base, hasValue: true, isValid: true } as const;
const ALL_STATES: NewsletterState[] = ['incomplete', 'invalid', 'ready', 'sending', 'sent'];
describe('resolveNewsletterState', () => {
it('an untouched field is incomplete, not invalid', () => {
// Asking the schema about an empty string would call it invalid and the
// section would open by telling the reader they got it wrong before typing.
expect(resolveNewsletterState(base)).toBe('incomplete');
});
it('separates «nothing typed» from «typed wrong»', () => {
expect(resolveNewsletterState({ ...base, hasValue: true, isValid: false })).toBe('invalid');
expect(resolveNewsletterState(filled)).toBe('ready');
});
it('what already happened wins over what is missing', () => {
expect(resolveNewsletterState({ ...base, sent: true })).toBe('sent');
expect(resolveNewsletterState({ ...base, isPending: true })).toBe('sending');
// Even mid-flight with a value the machine reports the flight, not the value.
expect(resolveNewsletterState({ ...filled, isPending: true })).toBe('sending');
});
});
describe('canSubscribe', () => {
it('lets ready AND invalid through', () => {
// `invalid` is deliberate: gating on validity makes the field's own error
// unreachable, because `progressive` only writes it after a failed submit.
expect(ALL_STATES.filter(canSubscribe)).toEqual(['invalid', 'ready']);
});
it('never lets one through while something already happened', () => {
for (const state of ['sending', 'sent'] as const) {
expect(canSubscribe(state), `${state} must not submit`).toBe(false);
}
});
});
describe('the words', () => {
it('every blocked state says why', () => {
for (const state of ALL_STATES) {
if (canSubscribe(state) || state === 'sending') continue;
expect(NEWSLETTER_REASON[state], `${state} has no reason`).toBeTruthy();
}
});
it('every state names its action', () => {
for (const state of ALL_STATES) expect(NEWSLETTER_ACTION[state]).toBeTruthy();
});
it('the words are idlangrefs with an english fallback', () => {
const refs = [...Object.values(NEWSLETTER_REASON), ...Object.values(NEWSLETTER_ACTION)].filter(
(v): v is string => v !== null
);
for (const ref of refs) expect(ref).toMatch(/^#\?blocks\.newsletter\.[a-z.]+\|.+/);
});
});

@ -0,0 +1,90 @@
/**
* The sign-up section's state — ONE machine, derived from every source that can
* block a subscribe.
*
* Why this block coordinates at all: it places an action that can be blocked, and
* before this the `disabled` was assembled by each app at the point of use. That
* is how holes appear — a button that cannot be pressed and nothing on screen
* saying why. The same reading `contact` settled on 2026-07-31, applied to the
* block the doctrine explicitly left open.
*/
/** The section's state. Exhaustive by construction. */
export type NewsletterState =
/** Nothing typed yet. */
| 'incomplete'
/** Something typed, but it is not an address the schema accepts. */
| 'invalid'
/** Good to go. */
| 'ready'
/** The submit handler is running. */
| 'sending'
/** It went out. */
| 'sent';
export interface NewsletterStateInput {
/** Has the reader typed anything at all? Asked of the VALUE, not of the schema:
* «you have not written yet» and «what you wrote is wrong» are different
* blockers and need different sentences. */
readonly hasValue: boolean;
/**
* Does the value satisfy the shape? NOT `form.isValid` — under progressive
* validation that means «nothing has been found wrong YET», so an untouched
* empty form reports valid and `incomplete` would be unreachable at load. The
* root asks the schema (`validateSync`), which answers without writing errors
* and therefore without lighting the field red on first paint.
*/
readonly isValid: boolean;
/** `form.isPending` — true while an async `onValidSubmit` runs. */
readonly isPending: boolean;
/** The app flipped it after a successful subscribe. */
readonly sent: boolean;
}
/**
* The order is the priority: what already happened wins over what is missing.
*/
export function resolveNewsletterState(input: NewsletterStateInput): NewsletterState {
if (input.sent) return 'sent';
if (input.isPending) return 'sending';
if (!input.hasValue) return 'incomplete';
if (!input.isValid) return 'invalid';
return 'ready';
}
/**
* Whether the section can be submitted in this state.
*
* `invalid` goes through ON PURPOSE, same as in `contact`: with progressive
* validation the form only writes field errors after a failed submit, so gating
* the button on validity makes the per-field error path unreachable by
* construction — the reader gets a dead button and no marked field. Letting the
* submit run hands the job to the canon `Form`, which marks the offender, focuses
* it and emits `signal-warn-invalid`.
*/
export function canSubscribe(state: NewsletterState): boolean {
return state === 'ready' || state === 'invalid';
}
/**
* The idlangref for each state's reason. `null` means «nothing to explain».
*
* Fallbacks are English, like the canon's own `texts:`; an app translates by
* registering `blocks.newsletter.*` in its langs schema.
*/
export const NEWSLETTER_REASON: Record<NewsletterState, string | null> = {
incomplete: '#?blocks.newsletter.reason.incomplete|Enter your email to subscribe.',
invalid: '#?blocks.newsletter.reason.invalid|That does not look like an email address.',
ready: null,
sending: null,
sent: '#?blocks.newsletter.reason.sent|You are in. Check your inbox to confirm.'
};
/** The submit label per state — the action names what is happening. */
export const NEWSLETTER_ACTION: Record<NewsletterState, string> = {
incomplete: '#?blocks.newsletter.action.subscribe|Subscribe',
invalid: '#?blocks.newsletter.action.subscribe|Subscribe',
ready: '#?blocks.newsletter.action.subscribe|Subscribe',
sending: '#?blocks.newsletter.action.sending|Subscribing…',
sent: '#?blocks.newsletter.action.sent|Subscribed'
};

@ -6,6 +6,9 @@ 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';
import type { TextProps } from '$uix/eidos/components/text';
import type { SubmitProps } from '$uix/eidos/components/form';
import type { NewsletterState } from './state';
/** How the panel arranges its copy and its form. */
export type NewsletterLayout = 'center' | 'justified';
@ -65,8 +68,20 @@ export type NewsletterProps = Omit<HTMLAttributes<HTMLElement>, 'children' | 'ti
/**
* Standard Schema / SIUM schema — optional override; `Form` falls back to the
* one the form was built with (or the `SiumProvider`).
*
* The block ASKS it for the section's state (`validateSync`), so passing it is
* what lets `invalid` be told apart from `incomplete`. Without it the machine
* falls back to the form's own verdict, which under `progressive` only knows
* «nothing found wrong yet».
*/
schema?: FormProps['schema'];
/**
* The app flips it after a successful subscribe — bindable, so the section can
* be reopened. `sent` is a state of the SECTION, not of the form: the form may
* still hold the address that was just sent.
* @default false
*/
sent?: boolean;
/** A chip or label above the title. */
eyebrow?: Snippet;
/** The title's words — the block wraps them in a `Heading` and owns the
@ -91,3 +106,14 @@ export type NewsletterProps = Omit<HTMLAttributes<HTMLElement>, 'children' | 'ti
/** Anything below the note (a logo strip, a subscriber count). */
children?: Snippet;
};
/**
* Props for the parts that COORDINATE — the two that read the section's state
* from context instead of taking it at the call site.
*/
export type NewsletterReasonProps = Omit<TextProps, 'children'>;
export type NewsletterSubmitProps = Omit<SubmitProps, 'children' | 'disabled'> & {
/** Custom rendering; receives the state so the app can key off it. */
children?: Snippet<[{ state: NewsletterState }]>;
};

@ -21,8 +21,17 @@
selectionMode="single"
size="sm"
attached
deselectable={false}
value={ctx ? [ctx.period] : ['monthly']}
onValueChange={(v) => ctx?.setPeriod((v[0] ?? 'monthly') as BillingPeriod)}
onValueChange={(v) => {
// A period is a CHOICE BETWEEN TWO, never «none»: `deselectable={false}`
// makes the canon keep the active segment on a repeat press. The guard is
// the belt to that brace — before it, an empty array fell through the
// `?? 'monthly'` below and pressing the ACTIVE «annual» sent the whole
// section back to monthly, a period the user never picked.
const next = v[0];
if (next) ctx?.setPeriod(next as BillingPeriod);
}}
aria-label={ariaLabel}
>
<ToggleGroup.Item value="monthly">{monthlyLabel}</ToggleGroup.Item>

@ -8,6 +8,7 @@
* B contract: this file composes canon components only — no `.css`, no
* strings of its own (every visible word arrives as children), no morfo.
*/
import { ActiveEidos } from '$uix/eidos';
import { Sticky } from '$uix/eidos/components/sticky';
import { Container } from '$uix/eidos/components/container';
import { Group } from '$uix/eidos/components/group';
@ -29,6 +30,24 @@
children,
...rest
}: SiteHeaderProps = $props();
const dom = ActiveEidos.require().dom;
/**
* Close the drawer when the wide variant takes over.
*
* The block hides the whole `<Drawer>` behind a responsive `display` (below),
* so crossing the breakpoint with it open used to hide the panel, the overlay
* AND the trigger while leaving the drawer `open` and the body's scroll lock
* in place: a frozen page with nothing on screen to explain it, and no way out
* for a touch user who just rotated their tablet — only `Escape`. The block
* owns the `breakpoint` that causes the cut, so it owns the close. Read from
* the framework's own reactive breakpoint, never a `matchMedia` of our own
* (B-6).
*/
$effect(() => {
if (mobileOpen && dom.isAtLeast(breakpoint)) mobileOpen = false;
});
</script>
{#snippet bar()}

@ -15,6 +15,12 @@ the one section where the number itself is the content.
| `.Value` | `Metrics.Value` (+ `CountUp` when `count` is set) | the figure |
| `.Label` | `Metrics.Label` | what the figure counts |
**Landmark + headings**: a bare `<section>` with NO accessible name and NO
heading of its own — a band of figures is a supporting strip, not a navigable
region, so it is deliberately absent from both the landmark and the outline. An
app that wants it reachable passes `aria-label` through `...rest` and puts its
own `Heading` above the band.
The KPI semantics (label / value / delta) are the canon `Metrics`'s — the block
adds none of its own. `Metrics` is surfaceless, which is exactly right here: a
band is not a row of tiles.

@ -2,7 +2,11 @@ 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 { MetricsProps, MetricsLabelProps, MetricsValueProps } from '$uix/eidos/components/metrics';
import type {
MetricsProps,
MetricsLabelProps,
MetricsValueProps
} from '$uix/eidos/components/metrics';
import type { CountUpProps } from '$uix/eidos/components/count-up';
// Sub-parts extend the props of the canon component they wrap, never
@ -35,8 +39,8 @@ export type StatsBandValueProps = Omit<MetricsValueProps, 'children'> & {
* Omit it and the app renders the value itself through `children`.
*/
count?: number;
/** Everything `CountUp` takes (`from`, `duration`, `notation`, `style`…) when
* `count` is set. `to` comes from `count`. */
/** Everything `CountUp` takes (`from`, `duration`, `notation`, `formatStyle`…)
* when `count` is set. `to` comes from `count`. */
countOptions?: Omit<CountUpProps, 'to'>;
/** The value as the app renders it — used when `count` is not set. */
children?: Snippet;

@ -18,6 +18,12 @@ role and links.
| `MemberRole` | `Text` `size="sm"` `color="muted"` | quieter by size AND ink, without another heading level |
| `MemberLinks` | `Group` (`marginTop: auto`) | `justify` follows the section; pinned to the card bottom so uneven bios still line up |
**Landmark + headings**: a bare `<section>` with NO accessible name of its own,
so it is not an exposed landmark — the app names it by passing `aria-label` /
`aria-labelledby` through `...rest`. The section heading is the app's (`h2`, in
`.Header`); `.MemberName` emits `h3` and takes `level` to move it. A person's
name is quieted by `size`, never by lowering the level.
## Form: compound, and the one thing the parts coordinate
`Member` **repeats** (the app maps over N) → compound, same admission rule as

@ -12,9 +12,9 @@
gap = 1,
align = 'center',
justify,
// Pegada al fondo de la tarjeta: con biografías de distinto largo, las filas
// de enlaces quedarían a alturas distintas y la retícula se leería descuadrada.
// Sin biografías las tarjetas ya miden lo mismo y esto no cambia nada.
// Pinned to the card's bottom: with bios of different lengths the link rows
// would sit at different heights and the grid would read ragged. With no
// bios the cards already match, and this changes nothing.
marginTop = 'auto',
children,
...rest

@ -20,10 +20,21 @@
[data-badge] {
--_badge-padding-inline: var(--badge-padding-inline-md);
--_badge-padding-block: var(--badge-padding-block-md);
--_badge-gap: var(--badge-gap-md);
--_badge-font-size: var(--badge-font-size-md);
--_badge-radius: var(--badge-radius-full);
/*
* The chip's height is a CONTROL height, not a padded line of text.
*
* It used to be `line-height + padding-block`, which made `md` mean 30px here
* and 36px everywhere a control lives — two scales sharing one set of names.
* The moment the chip composes a real control (the removable ✕ is an
* `IconButton`), that gap surfaces: measured, the same `md` badge came out 30px
* without the button and 46px with it. Sizing from the control token makes `md`
* mean one thing, and the block padding stops deciding the height — the
* inline padding still does its job, which is the chip's own business.
*/
--_badge-min-block-size: var(--control-height-md);
/* Default variant = soft; overridden by data-variant below. */
--_badge-bg: var(--_badge-palette-track);
@ -32,9 +43,11 @@
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--_badge-gap);
min-block-size: var(--_badge-min-block-size);
padding-inline: var(--_badge-padding-inline);
padding-block: var(--_badge-padding-block);
padding-block: 0;
border: var(--badge-border-width) solid var(--_badge-border-color);
border-radius: var(--_badge-radius);
background: var(--_badge-bg);
@ -53,32 +66,39 @@
[data-badge][data-size='xs'] {
--_badge-padding-inline: var(--badge-padding-inline-xs);
--_badge-padding-block: var(--badge-padding-block-xs);
--_badge-min-block-size: var(--control-height-xs);
--_badge-gap: var(--badge-gap-xs);
--_badge-font-size: var(--badge-font-size-xs);
}
[data-badge][data-size='sm'] {
--_badge-padding-inline: var(--badge-padding-inline-sm);
--_badge-padding-block: var(--badge-padding-block-sm);
--_badge-min-block-size: var(--control-height-sm);
--_badge-gap: var(--badge-gap-sm);
--_badge-font-size: var(--badge-font-size-sm);
}
[data-badge][data-size='lg'] {
--_badge-padding-inline: var(--badge-padding-inline-lg);
--_badge-padding-block: var(--badge-padding-block-lg);
--_badge-min-block-size: var(--control-height-lg);
--_badge-gap: var(--badge-gap-lg);
--_badge-font-size: var(--badge-font-size-lg);
}
/* ── Rounded ─────────────────────────────────────────────────────────── */
[data-badge][data-rounded='sm'] { --_badge-radius: var(--badge-radius-sm); }
[data-badge][data-rounded='md'] { --_badge-radius: var(--badge-radius-md); }
[data-badge][data-rounded='lg'] { --_badge-radius: var(--badge-radius-lg); }
[data-badge][data-rounded='full'] { --_badge-radius: var(--badge-radius-full); }
[data-badge][data-rounded='sm'] {
--_badge-radius: var(--badge-radius-sm);
}
[data-badge][data-rounded='md'] {
--_badge-radius: var(--badge-radius-md);
}
[data-badge][data-rounded='lg'] {
--_badge-radius: var(--badge-radius-lg);
}
[data-badge][data-rounded='full'] {
--_badge-radius: var(--badge-radius-full);
}
/* ── Variant slices ──────────────────────────────────────────────────── */
@ -162,42 +182,20 @@
/* ── Remove button ───────────────────────────────────────────────────── */
[data-badge-remove] {
display: inline-flex;
align-items: center;
justify-content: center;
/*
* PLACEMENT ONLY. The affordance is a composed `IconButton`, so its size, ink,
* radius, hover, focus ring and glyph are ITS recipe's — consumed through public
* props from the Svelte file, never re-painted from here. Two recipes on one node
* both declaring size and colour at (0,1,0) is a specificity coin-flip, and the
* measurement showed the button winning: the ✕ came out at 36px inside a ~24px
* chip, in the button palette instead of the chip's ink.
*
* What stays is what the chip legitimately owns: how the piece is tucked into the
* row. The negative inline-end margin pulls it back over the chip's own padding
* so the glyph sits flush with the edge.
*/
[data-badge] [data-badge-remove] {
flex: 0 0 auto;
inline-size: var(--badge-remove-size);
block-size: var(--badge-remove-size);
margin-inline-start: var(--badge-remove-margin-start);
margin-inline-end: calc(var(--badge-remove-size) * -0.25);
padding: 0;
border: 0;
border-radius: var(--radius-full);
background: transparent;
color: inherit;
cursor: pointer;
opacity: var(--opacity-subtle);
transition:
opacity var(--duration-fast) var(--ease-default),
background var(--duration-fast) var(--ease-default);
}
[data-badge-remove]:hover {
opacity: 1;
/* Neutral hover = the MD3 state layer (§38): the veil composes over the
chip surface. The old hand-rolled 15% currentColor mix predated the
state-layer rollout. */
background-image: linear-gradient(var(--state-hover), var(--state-hover));
}
[data-badge-remove]:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: 1px;
opacity: 1;
}
[data-badge-remove] svg {
inline-size: 70%;
block-size: 70%;
}

@ -15,6 +15,8 @@
import { ActiveEidos, motionAttrs } from '$uix/eidos';
import { composeInlineStyle } from '$uix/eidos/lib/style';
import { resolveComponentColor } from '$uix/eidos/lib/component-color';
import { IconButton } from '$uix/eidos/components/icon-button';
import { SvgX } from '$uix/eidos/components/svg';
import { BADGE_LANGS } from './langs';
import type { BadgeProps } from './types';
@ -40,9 +42,7 @@
const resolvedSize = $derived(eidos.resolve(size, 'md'));
const resolvedRounded = $derived(eidos.resolve(rounded, 'full'));
const resolvedRemoveLabel = $derived(
removeLabel ?? eidos.langs.ts(BADGE_LANGS.REMOVE)
);
const resolvedRemoveLabel = $derived(removeLabel ?? eidos.langs.ts(BADGE_LANGS.REMOVE));
// `color` accepts the full system: a canonical name → `data-color`, a raw CSS
// value → `data-color-custom` + the `--color-custom` seed (shared derivation).
const colorAttrs = $derived(resolveComponentColor(color));
@ -74,26 +74,32 @@
<span data-badge-label="">{@render children()}</span>
{/if}
{#if removable}
<button
type="button"
<!--
The ✕ IS an icon button, so it composes the canon one instead of being
re-implemented inline. That is what brings the press tick
(`Button`'s `contact-activate`, `sequence: 'pre'`), the focus ring and a
real hit target — Badge is an eidos-only primitive with no provider, so a
verb declared here would have nobody to emit it.
The badge does NOT re-paint it: a composed component is consumed through
its public props, and the recipe only places the piece (the margins that
tuck it into the chip). Painting it from here is what makes two recipes
fight over the same node — measured: size and ink both flipped to the
button's.
-->
<IconButton
data-badge-remove=""
aria-label={resolvedRemoveLabel}
variant="plain"
size={resolvedSize}
rounded="full"
{color}
onclick={(e) => {
e.stopPropagation();
onRemove?.();
}}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
<SvgX />
</IconButton>
{/if}
</span>

@ -19,6 +19,7 @@
import { ActiveEidos } from '$uix/eidos';
import { composeInlineStyle } from '$uix/eidos/lib/style';
import { resolveComponentColor } from '$uix/eidos/lib/component-color';
import { buildSizeStyle } from '$uix/eidos/lib/layout-helpers';
import type { CardProps } from './types';
let {
@ -34,6 +35,12 @@
motion,
onSelect,
style,
width,
minWidth,
maxWidth,
height,
minHeight,
maxHeight,
children,
...rest
}: CardProps = $props();
@ -68,7 +75,20 @@
// (the same generic path every other component uses). Compose the seed with
// the consumer's `style` so `...rest` can't clobber it.
const colorAttrs = $derived(resolveComponentColor(color));
const mergedStyle = $derived(composeInlineStyle(style, colorAttrs.customStyle));
/**
* The size axis, applied for real. It used to arrive typed (`CardProps` claimed
* all of `BoxProps`) and land in `...rest` as a plain HTML attribute, which on
* a `<div>` does nothing: `height="100%"` was inert everywhere it was used.
* The translation lives in `layout-helpers`, not here — a private copy per
* component is how a system grows two ways of saying the same thing.
*/
const sizeStyle = $derived(
buildSizeStyle({ width, minWidth, maxWidth, height, minHeight, maxHeight }, (v) =>
eidos.resolve(v as never)
)
);
const mergedStyle = $derived(composeInlineStyle(style, colorAttrs.customStyle, sizeStyle));
const tag = $derived(interactive ? 'button' : 'div');

@ -58,79 +58,96 @@ export type CardRounded = 'sm' | 'md' | 'lg';
/**
* Props for `<Card>`.
*
* Composes through `<Box>` so consumers can apply layout primitives
* (gridColumn, margin, gap, etc.) without wrapping. When `interactive`
* is on, the Provider renders as `<button>` and the Card fires
* `commit-select` on click.
* ⚠️ It does NOT compose `<Box>`. It used to say so — and to type itself as
* `Omit<BoxProps, 'display'>` — while `card.svelte` spread everything it did not
* destructure straight onto its element, so every layout prop was a silently
* INERT HTML attribute. Measured: `height="100%"` on a plain Card did nothing at
* all, while the README and two blocks claimed it made cards equal-height.
* Card cannot compose `Box` either: `Box` is a fixed `<div>` and an interactive
* Card is a `<button>`.
*
* What it does support is the SIZE axis, which is the whole of what consumers
* were reaching for — applied for real, through the same layout helpers `Box`
* uses. Everything else (padding, margin, grid placement) belongs to a wrapper:
* the card's own padding is its recipe's, and a second owner would fight it.
*
* When `interactive` is on, the Provider renders as `<button>` and the Card
* fires `commit-select` on click.
*/
export type CardProps = Omit<BoxProps, 'display'> & {
/** Visual treatment. @default 'soft' */
variant?: CardVariant;
/** Sizing scale (padding / gap / radius). @default 'md' */
size?: ResponsiveProp<CardSize>;
/**
* Accent palette. The full system — any canonical role / intent / donor
* scale (`ComponentColor`) OR any raw CSS color string
* (hex / rgb / oklch / named). @default 'neutral'
*/
color?: CardColor;
/** Corner radius (magnitude). @default 'md' */
rounded?: ResponsiveProp<CardRounded>;
/**
* Corner shape family — `rounded` (arc) · `continuous` (squircle) · `cut` (bevel) · `scoop`
* (concave). Orthogonal to `rounded` (radius magnitude). Degrades to the arc where
* `corner-shape` is unsupported. For `cut`/`scoop` prefer the `soft`/`solid` variant — an
* `outline` border seams at concave/angular corners in current engines.
* @default 'rounded'
*/
shape?: ShapeFamily;
/**
* Make the card clickable. Promotes the Provider to `<button>` and
* enables hover / press / focus visual feedback. Fires
* `commit-select` on activation.
* @default false
*/
interactive?: boolean;
/**
* Mark the card as selected — adds `data-selected` and (when
* interactive) `aria-pressed="true"`. Composes with `interactive`
* for selectable grids.
* @default false
*/
selected?: boolean;
/**
* State-domain motion preset for the `selected` transition. When set
* (and not `'none'`), the card plays the preset once each time it
* BECOMES selected — driven by the dedicated `data-motion-state` so it
* never clobbers the card's semantic `data-state`. Animate-on /
* snap-off: deselecting and the initial mount don't animate (composes
* with — does not replace — the recipe's `box-shadow` ring transition).
* Use an EMPHASIS preset (`'select-pop'`); a presence preset
* (`'scale-fade'`…) would flash the still-visible card from invisible.
* Setting `motion` makes the motion system own the card's single
* `animation`, so it suppresses the default mount `card-emerge` fade
* (the entrance yields to the state emphasis).
* @default undefined
*/
motion?: MotionPresetName;
/**
* Disable interaction (only meaningful when `interactive`). Adds
* `data-disabled` + `aria-disabled` and suppresses the
* `commit-select` event.
* @default false
*/
disabled?: boolean;
/**
* Disable the mount fade-in animation. Set to `true` when the card
* is rendered as part of a long list (animation per row turns into
* jitter at scale).
* @default false
*/
noEmerge?: boolean;
/** Callback fired when the user activates an interactive card. */
onSelect?: (event: MouseEvent | KeyboardEvent) => void;
children?: Snippet;
};
export type CardProps = Pick<
BoxProps,
'width' | 'minWidth' | 'maxWidth' | 'height' | 'minHeight' | 'maxHeight'
> &
Omit<HTMLAttributes<HTMLElement>, 'children' | 'style' | 'color'> & {
/** Visual treatment. @default 'soft' */
variant?: CardVariant;
/** Sizing scale (padding / gap / radius). @default 'md' */
size?: ResponsiveProp<CardSize>;
/**
* Accent palette. The full system — any canonical role / intent / donor
* scale (`ComponentColor`) OR any raw CSS color string
* (hex / rgb / oklch / named). @default 'neutral'
*/
color?: CardColor;
/** Corner radius (magnitude). @default 'md' */
rounded?: ResponsiveProp<CardRounded>;
/**
* Corner shape family — `rounded` (arc) · `continuous` (squircle) · `cut` (bevel) · `scoop`
* (concave). Orthogonal to `rounded` (radius magnitude). Degrades to the arc where
* `corner-shape` is unsupported. For `cut`/`scoop` prefer the `soft`/`solid` variant — an
* `outline` border seams at concave/angular corners in current engines.
* @default 'rounded'
*/
shape?: ShapeFamily;
/**
* Make the card clickable. Promotes the Provider to `<button>` and
* enables hover / press / focus visual feedback. Fires
* `commit-select` on activation.
* @default false
*/
interactive?: boolean;
/**
* Mark the card as selected — adds `data-selected` and (when
* interactive) `aria-pressed="true"`. Composes with `interactive`
* for selectable grids.
* @default false
*/
selected?: boolean;
/**
* State-domain motion preset for the `selected` transition. When set
* (and not `'none'`), the card plays the preset once each time it
* BECOMES selected — driven by the dedicated `data-motion-state` so it
* never clobbers the card's semantic `data-state`. Animate-on /
* snap-off: deselecting and the initial mount don't animate (composes
* with — does not replace — the recipe's `box-shadow` ring transition).
* Use an EMPHASIS preset (`'select-pop'`); a presence preset
* (`'scale-fade'`…) would flash the still-visible card from invisible.
* Setting `motion` makes the motion system own the card's single
* `animation`, so it suppresses the default mount `card-emerge` fade
* (the entrance yields to the state emphasis).
* @default undefined
*/
motion?: MotionPresetName;
/**
* Disable interaction (only meaningful when `interactive`). Adds
* `data-disabled` + `aria-disabled` and suppresses the
* `commit-select` event.
* @default false
*/
disabled?: boolean;
/**
* Disable the mount fade-in animation. Set to `true` when the card
* is rendered as part of a long list (animation per row turns into
* jitter at scale).
* @default false
*/
noEmerge?: boolean;
/** Callback fired when the user activates an interactive card. */
onSelect?: (event: MouseEvent | KeyboardEvent) => void;
/** Inline style, composed with the card's own colour seed. */
style?: string;
children?: Snippet;
};
/**
* Header part — optional slot at the top of the card. Typically

@ -1,8 +1,14 @@
<script lang="ts">
import { ActiveEidos } from '$uix/eidos';
import { composeInlineStyle } from '$uix/eidos/lib/style';
import { resolveComponentColor } from '$uix/eidos/lib/component-color';
/**
* Composes the canon `<Button>` through soma's `child` snippet, like
* `form-submit`. The morfo of `Form` declares no event on this part — its three
* events all target the `<form>` — so a hand-painted button here would have no
* verb of its own: no `contact-activate` at the moment of the press, no press
* micro-compression, no focus ring. The gesture's verb belongs to the component
* that IS the action, and whoever composes it does not re-declare it.
*/
import * as Form from '$soma/components/form';
import { Button } from '$uix/eidos/components/button';
import type { FormResetProps } from './types';
let {
@ -13,34 +19,12 @@
children: bodyContent,
...rest
}: FormResetProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
// `color` accepts the full system: canonical → `data-color`, raw value →
// `data-color-custom` + the `--color-custom` seed. `'neutral'` (the default)
// stamps nothing — the base action look (element bg + text ink) is the neutral.
const colorAttrs = $derived(resolveComponentColor(color === 'neutral' ? undefined : color));
const mergedStyle = $derived(composeInlineStyle(style, colorAttrs.customStyle));
</script>
{#if bodyContent}
<Form.Reset
{...rest}
data-size={resolvedSize}
data-variant={variant}
data-color={colorAttrs.dataColor}
data-color-custom={colorAttrs.isCustom ? '' : undefined}
style={mergedStyle}
>
{@render bodyContent()}
</Form.Reset>
{:else}
<Form.Reset
{...rest}
data-size={resolvedSize}
data-variant={variant}
data-color={colorAttrs.dataColor}
data-color-custom={colorAttrs.isCustom ? '' : undefined}
style={mergedStyle}
/>
{/if}
<Form.Reset {...rest}>
{#snippet child({ props })}
<Button {...props} {size} {variant} {color} {style}>
{#if bodyContent}{@render bodyContent()}{/if}
</Button>
{/snippet}
</Form.Reset>

@ -1,8 +1,19 @@
<script lang="ts">
import { ActiveEidos } from '$uix/eidos';
import { composeInlineStyle } from '$uix/eidos/lib/style';
import { resolveComponentColor } from '$uix/eidos/lib/component-color';
/**
* Composes the canon `<Button>` through soma's `child` snippet — the Button
* consumer pattern, not a hand-painted `<button>`.
*
* It used to be a passthrough that stamped `data-variant` / `data-size` /
* `data-color` onto soma's own element. Visually that reads like a Button, but
* a form's main action then had NO `contact-activate`: the tick a Button emits
* at `sequence: 'pre'`, in the instant of the press, before any async work.
* Measured, pressing «Suscribirme» produced nothing at the moment of the
* gesture — the only emission was the form's `commit-submit`, a second later,
* once the handler had resolved. Every other action in the same page ticked on
* contact. It also missed the press micro-compression and the focus ring.
*/
import * as Form from '$soma/components/form';
import { Button } from '$uix/eidos/components/button';
import type { FormSubmitProps } from './types';
let {
@ -13,34 +24,12 @@
children: bodyContent,
...rest
}: FormSubmitProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
// `color` accepts the full system: canonical → `data-color`, raw value →
// `data-color-custom` + the `--color-custom` seed. `'neutral'` stamps
// nothing — the base action look (element bg + text ink) is the neutral.
const colorAttrs = $derived(resolveComponentColor(color === 'neutral' ? undefined : color));
const mergedStyle = $derived(composeInlineStyle(style, colorAttrs.customStyle));
</script>
{#if bodyContent}
<Form.Submit
{...rest}
data-size={resolvedSize}
data-variant={variant}
data-color={colorAttrs.dataColor}
data-color-custom={colorAttrs.isCustom ? '' : undefined}
style={mergedStyle}
>
{@render bodyContent()}
</Form.Submit>
{:else}
<Form.Submit
{...rest}
data-size={resolvedSize}
data-variant={variant}
data-color={colorAttrs.dataColor}
data-color-custom={colorAttrs.isCustom ? '' : undefined}
style={mergedStyle}
/>
{/if}
<Form.Submit {...rest}>
{#snippet child({ props })}
<Button {...props} {size} {variant} {color} {style}>
{#if bodyContent}{@render bodyContent()}{/if}
</Button>
{/snippet}
</Form.Submit>

@ -31,8 +31,16 @@
text-decoration-color var(--duration-fast) ease;
}
[data-link][data-variant='subtle'],
[data-link][data-variant='plain'] {
/*
* `subtle` / `plain` inherit the ambient ink — but only while the consumer has
* NOT asked for one. Before, the inherit was unconditional and at (0,2,0) it beat
* the palette rule above at (0,1,0), so a `<Link color="…">` in either variant
* silently painted the ambient colour: measured, three consumers in the blocks
* tier were passing an ink that never reached a pixel. A prop the recipe
* discards in silence is a trap, so the request now wins.
*/
[data-link][data-variant='subtle']:not([data-color]):not([data-color-custom]),
[data-link][data-variant='plain']:not([data-color]):not([data-color-custom]) {
color: inherit;
}
@ -59,17 +67,30 @@
text-decoration: none;
}
/*
* Hover rides the link's OWN palette. It used to be pinned to
* `--color-primary-solid-hover`, so any `<Link color="…">` lost its colour the
* moment the pointer touched it.
*/
[data-link]:hover {
color: var(--color-primary-solid-hover);
color: var(--_link-palette-solid-hover);
}
[data-link][data-variant='subtle']:hover,
[data-link][data-variant='plain']:hover {
[data-link][data-variant='subtle']:not([data-color]):not([data-color-custom]):hover,
[data-link][data-variant='plain']:not([data-color]):not([data-color-custom]):hover {
color: inherit;
opacity: var(--opacity-subtle);
}
[data-link]:active {
color: var(--color-primary-active);
/*
* Press. Declared LAST and at variant specificity on purpose: a pointer press is
* always also a hover, and the old `[data-link]:active` at (0,2,0) was shadowed
* by the variant hover rules at (0,3,0) — measured, `subtle` links had no press
* response at all with a mouse. Opacity rather than another ink, so the signal
* survives whatever colour the link ended up with.
*/
[data-link]:active,
[data-link][data-variant]:active {
opacity: var(--opacity-70);
}
[data-link]:focus-visible {

@ -34,9 +34,13 @@
const resolvedVariant = $derived(eidos.resolve(variant) ?? 'default');
const resolvedUnderline = $derived(eidos.resolve(underline) ?? 'hover');
const resolvedColor = $derived(eidos.resolve(color) ?? 'primary');
// `color` accepts the full system: canonical → `data-color`, raw value →
// `data-color-custom` + the `--color-custom` seed (folded into the inline style).
// NO `?? 'primary'` here. `color` accepts the full system: canonical →
// `data-color`, raw value → `data-color-custom` + the `--color-custom` seed
// (folded into the inline style). Leaving it undefined when the consumer asked
// for nothing is what lets the recipe tell «no colour requested» (inherit, for
// `subtle`/`plain`) apart from «primary requested» — the host rule in the
// generated layer already provides the primary default for the plain case.
const resolvedColor = $derived(eidos.resolve(color));
const colorAttrs = $derived(resolveComponentColor(resolvedColor));
function pushVar(decls: string[], name: string, value: string | undefined): void {
@ -55,15 +59,13 @@
return [inline, colorAttrs.customStyle, tagStyle].filter(Boolean).join(' ');
});
const externalAttrs = $derived(
external ? { target: '_blank', rel: 'noopener noreferrer' } : {}
);
const externalAttrs = $derived(external ? { target: '_blank', rel: 'noopener noreferrer' } : {});
</script>
{#if disabled}
<!-- Drop anchor-specific attributes when rendering the disabled span. -->
<span
{...(restProps as Record<string, unknown>)}
{...restProps as Record<string, unknown>}
class={className}
data-link=""
data-variant={resolvedVariant}
@ -93,9 +95,7 @@
{@render children?.()}
{#if external}
<svg data-link-external-icon viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
d="M14 3h7v7h-2V6.41l-9.29 9.3-1.42-1.42L17.59 5H14V3zM5 5h6v2H7v10h10v-4h2v6H5V5z"
/>
<path d="M14 3h7v7h-2V6.41l-9.29 9.3-1.42-1.42L17.59 5H14V3zM5 5h6v2H7v10h10v-4h2v6H5V5z" />
</svg>
<span data-link-sr-only>(opens in new tab)</span>
{/if}

@ -678,10 +678,6 @@
--badge-padding-inline-sm: var(--space-2);
--badge-padding-inline-md: var(--space-2-5);
--badge-padding-inline-lg: var(--space-3);
--badge-padding-block-xs: var(--space-0-5);
--badge-padding-block-sm: var(--space-0-5);
--badge-padding-block-md: var(--space-1);
--badge-padding-block-lg: var(--space-1-5);
--badge-gap-xs: var(--space-1);
--badge-gap-sm: var(--space-1);
--badge-gap-md: var(--space-1-5);
@ -4103,6 +4099,7 @@
--color-content-secondary: color-mix(in oklch, var(--color-content-on-solid) 82%, transparent);
--color-content-muted: color-mix(in oklch, var(--color-content-on-solid) 64%, transparent);
--color-border-default: color-mix(in oklch, var(--color-content-on-solid) 32%, transparent);
color: var(--color-content-on-solid)
}
[data-on='light'] {
@ -4110,6 +4107,7 @@
--color-content-secondary: color-mix(in oklch, var(--color-content-on-solid-contrast) 82%, transparent);
--color-content-muted: color-mix(in oklch, var(--color-content-on-solid-contrast) 64%, transparent);
--color-border-default: color-mix(in oklch, var(--color-content-on-solid-contrast) 32%, transparent);
color: var(--color-content-on-solid-contrast)
}
[data-avatar] {
@ -4775,9 +4773,11 @@
[data-link] {
--_link-palette-text: var(--color-primary-text);
--_link-palette-solid-hover: var(--color-primary-solid-hover);
}
[data-link][data-color], [data-link][data-color-custom] {
--_link-palette-solid-hover: var(--palette-solid-hover, var(--color-primary-solid-hover));
--_link-palette-text: var(--palette-text, var(--color-primary-text));
}

@ -143,3 +143,38 @@ export function composeStyle(declarations: string[], style: string | undefined):
if (!own) return style;
return `${own};${style}`;
}
/**
* The SIZE axis as real CSS declarations, for components that accept `BoxProps`'
* sizing but are not a `Box` — a `Card` is a `<button>` when interactive, and
* `Box` is a fixed `<div>`, so it cannot compose one.
*
* Written here, once, rather than inline in each component: a private copy per
* consumer is how a system ends up with two ways to say the same thing. Uses the
* logical properties directly (not `--box-*` variables) because the consumer does
* not load the Box recipe that would read them.
*/
export function buildSizeStyle(
values: {
width?: unknown;
minWidth?: unknown;
maxWidth?: unknown;
height?: unknown;
minHeight?: unknown;
maxHeight?: unknown;
},
resolve: (value: unknown) => unknown
): string {
const decls: string[] = [];
const push = (prop: string, raw: unknown) => {
const value = formatLayoutLength(resolve(raw) as never);
if (value) decls.push(`${prop}:${value};`);
};
push('inline-size', values.width);
push('min-inline-size', values.minWidth);
push('max-inline-size', values.maxWidth);
push('block-size', values.height);
push('min-block-size', values.minHeight);
push('max-block-size', values.maxHeight);
return decls.join(' ');
}

@ -357,10 +357,11 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
'padding-inline-sm': 'var(--space-2)',
'padding-inline-md': 'var(--space-2-5)',
'padding-inline-lg': 'var(--space-3)',
'padding-block-xs': 'var(--space-0-5)',
'padding-block-sm': 'var(--space-0-5)',
'padding-block-md': 'var(--space-1)',
'padding-block-lg': 'var(--space-1-5)',
// NO block padding: the chip's height is a CONTROL height
// (`--control-height-{size}` via `min-block-size`), not a padded line of
// text. Keeping these as tokens nobody reads is what the orphan guard
// exists to catch — and having two ways to set the same height is how `md`
// ended up meaning 30px in a chip and 36px in every control.
// ── Gap between dot / icon / label / remove ───────────────────────
'gap-xs': 'var(--space-1)',
'gap-sm': 'var(--space-1)',
@ -2001,9 +2002,12 @@ export const THEME_BASE_RECIPE_TOKENS = defineRecipes({
},
// Content-ink links — `color` tints the link text. Host = primary-text; the
// shared layer routes roles + 33 scales + custom. (Was a per-role `color:`
// cascade in link.css.) hover/active stay primary — pre-existing behaviour.
// cascade in link.css.) The hover ink rides the palette too since 2026-08-05:
// it was pinned to `--color-primary-solid-hover`, so a coloured link went
// purple under the pointer.
link: {
'_palette-text': { value: 'var(--color-primary-text)', scope: 'host' }
'_palette-text': { value: 'var(--color-primary-text)', scope: 'host' },
'_palette-solid-hover': { value: 'var(--color-primary-solid-hover)', scope: 'host' }
},
// Mark (inline highlight) — `element` tints the background (default = fulfill,
// the classic highlight), `text` tints the ink but ONLY when data-color is set

File diff suppressed because it is too large Load Diff

@ -15,15 +15,57 @@ export const navigationMenuMorfo = {
},
events: [
{
// The evaluable act is NAVIGATING, so the commit lives on the part that
// navigates. It used to sit on `item` and be emitted from `openNow`,
// which the pointer reaches by HOVER: a mouse sweep across the bar
// announced that something had been selected when nothing had, while
// pressing a plain link — the one gesture that does commit — said
// nothing at all.
name: 'commit-select',
semantic: {
family: 'commit',
verb: 'select',
target: v.partRef('item'),
target: v.partRef('link'),
intent: 'affirm',
sequence: 'post'
}
},
{
// Unfolding a panel is an appearance, not a decision — same reading as
// dropdown-menu / popover / tooltip. `post`: the provider flips the open
// state in the handler, so `pre` would gate the panel behind the hold.
// Target = the TRIGGER, not the content, and on purpose: the content
// mounts on open and unmounts on close, so a stamp aimed at it would land
// on a node that is about to leave (or does not exist yet) — the runtime
// resolves the target to null and the emission dies silently. The trigger
// is the stable node of the pair. Declaring here what the runtime really
// stamps is the whole lesson of this component's audit: the pack used to
// select `item` while the stamp fell on `trigger`, so it NEVER matched and
// the nav ran at the family's base gain instead of its own tuning.
name: 'emerge-open',
semantic: {
family: 'emerge',
verb: 'open',
target: v.partRef('trigger'),
sequence: 'post'
}
},
{
name: 'emerge-close',
semantic: {
family: 'emerge',
verb: 'close',
target: v.partRef('trigger'),
sequence: 'pre',
persistence: 'transient',
allowedFamilies: ['emerge', 'commit', 'signal']
}
}
// NO `contact-activate` here. The press on the trigger is a Button's verb,
// and `Button`'s morfo already declares it (`contact-activate`, target
// `provider`, `sequence: 'pre'`). The trigger gets it by COMPOSING the canon
// `Button` — re-declaring it here would put the same verb twice on the same
// node, which is exactly the defect this component's audit found in `Field`.
],
direction: {},
parts: [
@ -67,10 +109,7 @@ export const navigationMenuMorfo = {
defaultElement: 'li',
optional: false,
states: ['open', 'closed'],
data: [
{ attr: 'data-state', values: ['open', 'closed'] },
{ attr: 'data-value' }
],
data: [{ attr: 'data-state', values: ['open', 'closed'] }, { attr: 'data-value' }],
aria: []
},
{

@ -4,32 +4,49 @@ import { soundTuning } from '../sounds';
import type { Sema } from '../sema-map';
/**
* NavigationMenu perceptual defaults — SOFT COMMIT + TAP HAPTIC.
* NavigationMenu perceptual defaults — three moments, three voices, each on the
* part that actually receives the stamp.
*
* NavigationMenu emits `commit-select` per item (link or trigger).
* Browsing a navigation bar is high-frequency, low-intensity — same
* fatigue profile as Menubar.
* - `commit-select` on the LINK — the evaluable act: navigating. `commit.subtle`
* (gain 0.03) because browsing a bar is high-frequency and low-intensity, and
* a route change usually follows: the chime must not compete with whatever the
* next surface says. Light `tap` to confirm the click landed.
* - The press on the trigger is NOT here: it is a Button's verb. The eidos
* trigger composes the canon `Button`, so the tick, its tuning and its haptic
* all come from `Button`'s own pack. Re-declaring it would give one node the
* same verb twice.
* - `emerge-open` / `emerge-close` on the TRIGGER — a panel appearing is an
* appearance, not a decision. The stamp lands on the trigger, not on the
* content, because the content mounts and unmounts with the panel: the
* trigger is the stable node of the pair, and the morfo declares the same.
*
* Strategy (book cap. 22 §10):
* - SOUND: `commit.subtle` (gain 0.03). Selecting a nav link is
* usually followed by a route change / focus shift, so the chime
* must not compete with the next surface's signals.
* - HAPTIC: light `tap`. Confirms the click landed.
* - The morfo event carries `intent: 'affirm'`; the pack does NOT
* override pitch / contour — intent.deltas (capa 2) supply the
* positive lift.
* ⚠️ Until 2026-08-05 this pack was ONE rule matching `item` while the runtime
* stamped on the trigger, so the selector NEVER matched: the nav fell back to the
* commit family's base gain (0.30) instead of the 0.03 chosen here — ten times
* louder than designed. And the event it tuned was emitted from `openNow`, which
* hover reaches, so the bar announced selections nobody had made while pressing a
* link said nothing. Targeting the part the event declares is what keeps the two
* in step.
*
* The morfo events carry their own intents; the pack does NOT override pitch /
* contour — intent.deltas (capa 2) supply the evaluative profile.
*/
const onItem = (matchers?: Parameters<typeof semaSelector<typeof navigationMenuMorfo>>[2]) =>
semaSelector(navigationMenuMorfo, 'item', matchers);
export const navigationMenuSema: Sema = {
name: 'navigation-menu',
cascade: [
{
selector: onItem({ eventName: 'commit-select' }),
selector: semaSelector(navigationMenuMorfo, 'link', { eventName: 'commit-select' }),
sound: soundTuning('commit.subtle'),
haptic: { kind: 'tap', intensity: 0.3, duration: 12, delay: 0 }
},
{
selector: semaSelector(navigationMenuMorfo, 'trigger', { eventName: 'emerge-open' }),
sound: soundTuning('emerge.soft')
},
{
selector: semaSelector(navigationMenuMorfo, 'trigger', { eventName: 'emerge-close' }),
sound: soundTuning('emerge.exit.soft')
}
]
};

@ -358,10 +358,22 @@ export class FieldInputProvider {
* Enter → `commit-submit` (the morfo keyboard contract). Adoption of the
* text-input family doctrine (password-field precedent): typing is plain
* data flow; Enter is the terminal gesture.
*
* ⚠️ NOT when a `Form` owns this field. Enter inside a form triggers the
* browser's implicit submission, so the form emits its own `commit-submit`
* milliseconds later: measured, one keypress produced TWO commits 11 ms apart
* with contradictory intents (`neutral` here, `fulfill` there) and two
* overlapping earcons. Worse, when the submit is blocked this one fired alone
* — a commit announcing that something was fixed when nothing was.
*
* The form is the only party that knows whether the gesture had a consequence,
* so it keeps the verb. A field with no form parent still speaks: there, this
* stamp is the only acknowledgement the gesture gets.
*/
readonly onkeydown = (e: SomaEvent<KeyboardEvent, HTMLInputElement>) => {
if (this.provider.isDisabled || this.provider.isReadonly) return;
if (e.key !== 'Enter' || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return;
if (this.provider.form) return;
void this.provider.runtime.trigger('commit-submit');
};

@ -1,8 +1,5 @@
<script lang="ts">
import {
readableActive,
writableActive
} from '$libs/reactive';
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '$active-uix/id';
import { FormSubmitProvider } from '../form-provider.svelte';
@ -25,7 +22,11 @@
() => ref,
(v) => (ref = v)
),
ariaLabel: readableActive(() => ariaLabel)
ariaLabel: readableActive(() => ariaLabel),
// Only THIS layer knows whether the button carries its own words. A submit
// with content must not receive the generic localized `aria-label` — it
// would beat the visible text in the accessible-name computation.
hasContent: readableActive(() => children !== undefined || child !== undefined)
});
const mergedProps = $derived(mergeProps(restProps, state.props));

@ -163,10 +163,12 @@ describe('FormProvider', () => {
const { result, cleanup } = withEffectRoot(() => {
const provider = FormProvider.create(opts);
vi.spyOn(FormProvider, 'require').mockReturnValue(provider);
// No content and no explicit label: the localized fallback applies.
const submit = FormSubmitProvider.create({
id: state('form-submit'),
ref: state<HTMLElement | null>(document.createElement('button')),
ariaLabel: state<string | undefined>(undefined)
ariaLabel: state<string | undefined>(undefined),
hasContent: state(false)
});
const reset = FormResetProvider.create({
id: state('form-reset'),

@ -145,10 +145,7 @@ export class FormProvider<TForm extends Form = Form> {
this.focusFirstInvalid();
return;
}
void this.runtime.trigger(
'commit-submit',
target ? { fallbackTarget: target } : undefined
);
void this.runtime.trigger('commit-submit', target ? { fallbackTarget: target } : undefined);
};
readonly props = $derived.by(() =>
@ -163,7 +160,7 @@ export class FormProvider<TForm extends Form = Form> {
// ── Submit button ────────────────────────────────────────────────────────────
export interface FormSubmitOpts
extends WithRefOpts, ActiveProps<{ ariaLabel: string | undefined }> {}
extends WithRefOpts, ActiveProps<{ ariaLabel: string | undefined; hasContent: boolean }> {}
export class FormSubmitProvider {
readonly opts: FormSubmitOpts;
@ -184,16 +181,28 @@ export class FormSubmitProvider {
});
}
readonly resolvedLabel = $derived.by(
() => this.opts.ariaLabel.current ?? this.provider.soma.langs.ts(FORM_LANGS.SUBMIT) ?? 'Submit'
);
/**
* The accessible name. An explicit `aria-label` always wins; the localized
* fallback only applies to a submit with NO content of its own.
*
* It used to be stamped unconditionally, and `aria-label` beats contents in
* the accessible-name computation, so every `<Form.Submit>Send message</…>`
* announced the generic «Submit» instead — a WCAG 2.5.3 (Label in Name)
* failure for every consumer, measured with Chrome's own AX tree, and the
* reason a block that owns its state words could not get them to a screen
* reader.
*/
readonly resolvedLabel = $derived.by(() => {
const explicit = this.opts.ariaLabel.current;
if (explicit) return explicit;
if (this.opts.hasContent.current) return undefined;
return this.provider.soma.langs.ts(FORM_LANGS.SUBMIT) ?? 'Submit';
});
readonly props = $derived.by(() =>
this.runtimePart.assert({
// type, aria-busy (← isPending, ariaBoolean) are morfo-declared.
...this.runtimePart.renderProps(),
// aria-label honours the explicit `ariaLabel` prop (falls back to the
// same `buttons.submit` translation the morfo declares).
'aria-label': this.resolvedLabel,
disabled: this.provider.isPending || undefined,
// Morfo declares data-pending as presence-only (no value); soma owns

@ -11,12 +11,7 @@ import {
type StateProps,
type State
} from '$libs/reactive';
import type {
Orientation,
Direction,
SomaKeyboardEvent,
SomaMouseEvent
} from '../../types';
import type { Orientation, Direction, SomaKeyboardEvent, SomaMouseEvent } from '../../types';
import { KEYS, getDirectionalKeys } from '../../keyboard';
import { Soma } from '../../core/soma.svelte';
import { NAVIGATION_MENU_LANGS } from './langs';
@ -114,8 +109,9 @@ export class NavigationMenuProvider {
}
/** Concrete direction for this component's OWN math (arrow keys, placement). */
readonly resolvedDir: Active<Direction> = readableActive(() => resolveDir(this.opts.dir, this.soma));
readonly resolvedDir: Active<Direction> = readableActive(() =>
resolveDir(this.opts.dir, this.soma)
);
readonly resolvedAriaLabel: Active<string | undefined> = readableActive(
() =>
@ -142,16 +138,13 @@ export class NavigationMenuProvider {
this.clearTimers();
if (this.opts.value.current === value) return;
this.opts.value.current = value;
// Morfo declares `commit-select` on `item` (affirm). Centralising
// the emission here covers every path (trigger click, keyboard
// activate, hover-follow via scheduleOpen). The fallback target
// is the trigger element; the pack selector targets `item`, but
// `closest()` walks from the trigger up to the wrapping li with
// `data-navigation-menu-item`, matching correctly.
// Unfolding a panel speaks as `emerge-open`, NOT as a commit. This used to
// emit `commit-select` from here, and `here` is reached by hover
// (`scheduleOpen` ← `onpointerenter`): sweeping the pointer across the bar
// announced a selection that had not happened, while the gestures that DO
// commit — pressing a link — stayed silent.
const triggerEl = this.triggerRefs.get(value);
if (triggerEl) {
void this.runtime.trigger('commit-select', { fallbackTarget: triggerEl });
}
void this.runtime.trigger('emerge-open', triggerEl ? { fallbackTarget: triggerEl } : undefined);
}
/** Close whatever is open now. Arms the skip-delay window so the next
@ -159,7 +152,14 @@ export class NavigationMenuProvider {
closeNow() {
this.clearTimers();
if (this.opts.value.current === null) return;
const triggerEl = this.triggerRefs.get(this.opts.value.current);
this.opts.value.current = null;
// `pre`: the panel's disappearance is announced while it is still there to
// be announced against.
void this.runtime.trigger(
'emerge-close',
triggerEl ? { fallbackTarget: triggerEl } : undefined
);
this.armSkipDelay();
}
@ -535,6 +535,8 @@ export class NavigationMenuTriggerProvider {
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.opts.disabled.current) return;
// No `contact-activate` emitted here: the press is a Button's verb, and the
// eidos layer composes the canon `Button`, which declares and emits it.
if (this.isOpen) {
// Suppress the close if the open is fresh — the user likely
// clicked right after hover-open, intending to commit the
@ -707,6 +709,18 @@ export class NavigationMenuLinkProvider {
this.provider.scheduleClose();
};
/**
* Navigating IS the evaluable act of this component, and it had no voice: the
* Link provider registered no click handler at all, so the plain items — the
* ones that actually take you somewhere — were the silent ones while a hover
* over a disclosure sang. `commit-select` now targets this part.
*/
readonly onclick = (_e: SomaMouseEvent<HTMLAnchorElement>) => {
void this.provider.runtime.trigger('commit-select', {
fallbackTarget: this.opts.ref?.current ?? undefined
});
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
// aria-current (literal 'page' gated on prop-truthy:active) is
@ -716,6 +730,7 @@ export class NavigationMenuLinkProvider {
// supplies the presence value.
'data-active': boolToEmptyStrOrUndef(this.opts.active.current),
onkeydown: this.onkeydown,
onclick: this.onclick,
onpointerenter: this.onpointerenter
} as const)
);

@ -12,6 +12,27 @@ import type { LangNode } from '$libs/langs';
* Paths mirror the constants in `src/uix/blocks/contact/{state,schema}.ts`.
*/
export const blocksLangs = {
newsletter: {
reason: {
incomplete: {
es: 'Escribe tu correo para suscribirte.',
en: 'Enter your email to subscribe.'
},
invalid: {
es: 'Eso no parece una dirección de correo.',
en: 'That does not look like an email address.'
},
sent: {
es: 'Ya estás dentro. Revisa tu correo para confirmar.',
en: 'You are in. Check your inbox to confirm.'
}
},
action: {
subscribe: { es: 'Suscribirme', en: 'Subscribe' },
sending: { es: 'Suscribiendo…', en: 'Subscribing…' },
sent: { es: 'Suscrito', en: 'Subscribed' }
}
},
contact: {
field: {
name: { es: 'Nombre', en: 'Name' },
@ -23,6 +44,10 @@ export const blocksLangs = {
es: 'Completa todos los campos para enviar.',
en: 'Fill in every field to send.'
},
invalid: {
es: 'Revisa los campos marcados.',
en: 'Check the fields marked below.'
},
unverified: {
es: 'Resuelve la verificación de arriba.',
en: 'Solve the verification above.'

@ -190,9 +190,8 @@
que salirse.
</DocRow>
<DocRow term="ContentSection.Media">
<Code>width</Code> (def. <Code>wide</Code>) y el snippet <Code>caption</Code>. Renderiza un <Code
>figure</Code
> de verdad y el pie como
<Code>width</Code> (def. <Code>measure</Code> — la figura sigue al texto y salirse se pide) y
el snippet <Code>caption</Code>. Renderiza un <Code>figure</Code> de verdad y el pie como
<Code>figcaption</Code>: es la semántica de la cosa, no un párrafo que va debajo.
</DocRow>
</Stack>

@ -11,14 +11,17 @@
import BlockDemo from '../_lib/BlockDemo.svelte';
import CtaSite from './CtaSite.svelte';
import DocRow from '../_lib/DocRow.svelte';
import type { CtaLayout } from '$blocks/cta';
import type { ContainerSize } from '$uix/eidos/components/container';
let layout = $state<'center' | 'justified'>('center');
let layout = $state<CtaLayout>('center');
let color = $state('primary');
let gradient = $state(true);
let container = $state<ContainerSize>('lg');
let dir = $state<'ltr' | 'rtl'>('ltr');
const previewSrc = $derived(
`/blocks/cta/preview?layout=${layout}&color=${color}&gradient=${gradient}&dir=${dir}`
`/blocks/cta/preview?layout=${layout}&color=${color}&gradient=${gradient}&container=${container}&dir=${dir}`
);
</script>
@ -35,13 +38,13 @@
]}
>
{#snippet preview()}
<CtaSite {layout} {color} {gradient} />
<CtaSite {layout} {color} {gradient} {container} />
{/snippet}
{#snippet lede()}
El panel que pide el siguiente paso. Su distinción es el <strong>acabado</strong>, que ya es
vocabulario del framework: un <Code>Surface</Code> sólido con el degradado del sistema, no
pintura propia del block. <Code>center</Code> para cerrar una página, <Code>justified</Code>
vocabulario del framework: un <Code>Surface</Code> sólido con el degradado del sistema, no pintura
propia del block. <Code>center</Code> para cerrar una página, <Code>justified</Code>
para un empujón a media lectura.
{/snippet}
@ -62,6 +65,23 @@
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">container</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[container]}
onValueChange={(v) => (container = (v[0] ?? container) as ContainerSize)}
aria-label="container"
>
<ToggleGroup.Item value="md">md</ToggleGroup.Item>
<ToggleGroup.Item value="lg">lg</ToggleGroup.Item>
<ToggleGroup.Item value="xl">xl</ToggleGroup.Item>
<ToggleGroup.Item value="full">full</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">color</Text>
<ToggleGroup
@ -118,33 +138,35 @@
</Text>
<Stack gap={3}>
<DocRow term="panel">
<Code>Surface</Code> sólido con <Code>color</Code> y <Code>gradient</Code> — el
degradado del sistema, derivado de la paleta de la instancia. El block no pinta nada
propio. Siempre sólido: medido, el track <Code>soft</Code> queda a 0.002 de luminancia
del fondo de página en claro, así que no acota panel.
<Code>Surface</Code> sólido con <Code>color</Code> y <Code>gradient</Code> — el degradado del
sistema, derivado de la paleta de la instancia. El block no pinta nada propio. Siempre sólido:
medido, el track <Code>soft</Code> queda a 0.002 de luminancia del fondo de página en claro,
así que no acota panel.
</DocRow>
<DocRow term="color">
Los tres del control son lienzos OSCUROS a propósito. La ranura de contraste de la
paleta es blanco en todo escalón sólido, y medido eso deja el cuerpo de texto en 5.18
(<Code>primary</Code>) · 5.21 (<Code>indigo</Code>) · 4.75 (<Code>plum</Code>) frente a
3.07–3.32 en <Code>neutral</Code> / <Code>teal</Code> / <Code>slate</Code>. El block
reenvía cualquier <Code>color</Code>; la demo solo ofrece los que pasan AA.
Los tres del control son lienzos OSCUROS a propósito. La ranura de contraste de la paleta
es blanco en todo escalón sólido, y medido eso deja el cuerpo de texto en 5.18 (<Code
>primary</Code
>) · 5.21 (<Code>indigo</Code>) · 4.75 (<Code>plum</Code>) frente a 3.07–3.32 en <Code
>neutral</Code
> / <Code>teal</Code> / <Code>slate</Code>. El block reenvía cualquier <Code>color</Code>;
la demo solo ofrece los que pasan AA.
</DocRow>
<DocRow term="entrada">
Un solo <Code>Motion trigger="viewport"</Code> con <Code>scale-fade</Code>: el panel
llega ENTERO. Un CTA es una sola afirmación; repartir sus tres partes una a una se leería
como duda.
Un solo <Code>Motion trigger="viewport"</Code> con <Code>scale-fade</Code>: el panel llega
ENTERO. Un CTA es una sola afirmación; repartir sus tres partes una a una se leería como
duda.
</DocRow>
<DocRow term="disposición">
<Code>center</Code> = <Code>Stack</Code> centrado; <Code>justified</Code> =
<Code>Grid</Code> de dos columnas (copy al inicio, acciones al final) que apila en
estrecho, donde una sola fila aplastaría ambas mitades. Las acciones son un
<Code>Flex</Code> que pasa a columna en estrecho: en fila, la etiqueta de la acción
secundaria se parte contra el botón.
<Code>Grid</Code> de dos columnas (copy al inicio, acciones al final) que apila en estrecho,
donde una sola fila aplastaría ambas mitades. Las acciones son un
<Code>Flex</Code> que pasa a columna en estrecho: en fila, la etiqueta de la acción secundaria
se parte contra el botón.
</DocRow>
<DocRow term="tinta">
Título y descripción van en <Code>on-solid</Code> — la tinta de contraste que garantiza
la paleta para su canvas. Cero color elegido a mano, así que cambiar
Título y descripción van en <Code>on-solid</Code> — la tinta de contraste que garantiza la paleta
para su canvas. Cero color elegido a mano, así que cambiar
<Code>color</Code> o tema la arrastra.
</DocRow>
</Stack>
@ -160,8 +182,8 @@
<Code>level</Code> (def. <Code>2</Code>) · <Code>container</Code> · <Code>size</Code>.
</DocRow>
<DocRow term="eyebrow · title · description · actions · children">
Snippets: todo el contenido entra por composición (B-7). <Code>children</Code> se renderiza
bajo las acciones.
Snippets: todo el contenido entra por composición (B-7). <Code>children</Code> se renderiza bajo
las acciones.
</DocRow>
</Stack>
{/snippet}
@ -173,8 +195,8 @@
no se emite el atributo: no se nombra una región con un id que no existe.
</DocRow>
<DocRow term="Contraste">
La tinta sobre el panel sólido es la de contraste de la paleta (<Code>on-solid</Code>), no un
blanco elegido a ojo, así que el cambio de tema o de color la arrastra.
La tinta sobre el panel sólido es la de contraste de la paleta (<Code>on-solid</Code>), no
un blanco elegido a ojo, así que el cambio de tema o de color la arrastra.
</DocRow>
<DocRow term="Encabezado">
<Code>h2</Code> por defecto: un CTA no compite con el <Code>h1</Code> de la página.
@ -189,20 +211,22 @@
<Code>Mockup</Code> ya existe para la media. Entra cuando una demo lo pida.
</DocRow>
<DocRow term="Panel a sangre">
<strong>App-land</strong> — la app pone <Code>container="full"</Code>.
<strong>Hecho</strong> — la app pone <Code>container="full"</Code> y el block suelta también el
canalón. Antes <Code>full</Code> solo quitaba el tope de ancho y el panel se quedaba a 16px de
los bordes.
</DocRow>
<DocRow term="CTA descartable o pegajoso">
<strong>Fuera</strong> — eso es trabajo del block <Code>banner</Code> (F2.11).
</DocRow>
<DocRow term="Panel sosegado con borde">
<strong>Canon</strong> — <Code>Surface soft</Code> no acota (0.002 L del fondo en claro) y
no tiene borde; <Code>Card outline</Code> sí acota pero no acepta acabado de degradado.
Un CTA sosegado <em>con borde</em> no tiene primitivo hoy.
<strong>Canon</strong> — <Code>Surface soft</Code> no acota (0.002 L del fondo en claro) y no
tiene borde; <Code>Card outline</Code> sí acota pero no acepta acabado de degradado. Un CTA sosegado
<em>con borde</em> no tiene primitivo hoy.
</DocRow>
<DocRow term="Contraste en lienzos medios">
<strong>Canon</strong> — la ranura <Code>contrast</Code> es blanca en todo escalón sólido,
así que un lienzo de luminancia media deja el cuerpo por debajo de AA. La garantía de
emparejamiento se cumple solo en los lienzos oscuros.
<strong>Canon</strong> — la ranura <Code>contrast</Code> es blanca en todo escalón sólido, así
que un lienzo de luminancia media deja el cuerpo por debajo de AA. La garantía de emparejamiento
se cumple solo en los lienzos oscuros.
</DocRow>
<DocRow term="Chip sobre panel sólido">
<strong>Canon</strong> — no hay tratamiento on-solid para <Code>Badge</Code>: la demo usa
@ -219,8 +243,8 @@
slots posicionales que el root arregla — misma forma que el <Code>hero</Code>.
</DocRow>
<DocRow term="El acabado es del sistema">
El degradado sale de la paleta de la instancia, así que cambiar tema o color lo arrastra.
Es lo que separa esto de un panel con un color pintado a mano.
El degradado sale de la paleta de la instancia, así que cambiar tema o color lo arrastra. Es
lo que separa esto de un panel con un color pintado a mano.
</DocRow>
</Stack>
{/snippet}

@ -15,16 +15,19 @@
import { Link } from '$uix/eidos/components/link';
import * as Icon from '$uix/eidos/components/icon';
type Layout = 'center' | 'justified';
import type { CtaLayout } from '$blocks/cta';
import type { ContainerSize } from '$uix/eidos/components/container';
let {
layout = 'center' as Layout,
layout = 'center' as CtaLayout,
color = 'primary',
gradient = true
gradient = true,
container = 'lg' as ContainerSize
}: {
layout?: Layout;
layout?: CtaLayout;
color?: string;
gradient?: boolean;
container?: ContainerSize;
} = $props();
</script>
@ -41,7 +44,7 @@
</Container>
</Section>
<Cta {layout} {color} {gradient}>
<Cta {layout} {color} {gradient} {container}>
{#snippet eyebrow()}
<!-- `soft`: the primary track is near-white in light mode, so the chip reads
as a pale pill against the saturated canvas. There is no on-solid chip
@ -69,8 +72,6 @@
<!-- `currentColor`, not `--color-content-on-solid`: the panel's ink is whatever
contrast slot ITS colour guarantees, and the Surface already sets it on the
subtree. A global on-solid token only happens to match the primary panel. -->
<Link href="#ventas" variant="subtle" size="lg" color="currentColor">
Hablar con ventas
</Link>
<Link href="#ventas" variant="subtle" size="lg" color="currentColor">Hablar con ventas</Link>
{/snippet}
</Cta>

@ -5,11 +5,14 @@
*/
import { page } from '$app/state';
import CtaSite from '../CtaSite.svelte';
import type { CtaLayout } from '$blocks/cta';
import type { ContainerSize } from '$uix/eidos/components/container';
const params = $derived(page.url.searchParams);
const layout = $derived((params.get('layout') ?? 'center') as 'center' | 'justified');
const layout = $derived((params.get('layout') ?? 'center') as CtaLayout);
const color = $derived(params.get('color') ?? 'primary');
const gradient = $derived(params.get('gradient') !== 'false');
const container = $derived((params.get('container') ?? 'lg') as ContainerSize);
</script>
<CtaSite {layout} {color} {gradient} />
<CtaSite {layout} {color} {gradient} {container} />

@ -30,8 +30,8 @@
{#snippet lede()}
La sección de preguntas: una cabecera sobre un acordeón de preguntas y respuestas, en columna
estrecha. Es un <strong>proxy fino</strong> del <Code>Accordion</Code> del canon — el block lee
su API y la pasa tal cual, no reinventa el disclosure.
estrecha. Es un <strong>proxy fino</strong> del <Code>Accordion</Code> del canon — el block lee su
API y la pasa tal cual, no reinventa el disclosure.
{/snippet}
{#snippet composition()}
@ -46,9 +46,9 @@
<Code>variant</Code>, <Code>size</Code>. Por defecto: single, collapsible, outline.
</DocRow>
<DocRow term=".Item">
Proxy de <Code>Accordion.Item</Code>: el snippet <Code>question</Code> es el trigger, los
children son el contenido. El <Code>value</Code> (clave de estado) se autogenera si no lo
pasas.
Proxy de <Code>Accordion.Item</Code>: el snippet <Code>question</Code> es el trigger, los children
son el contenido. El <Code>value</Code> (clave de estado) puedes omitirlo: lo genera el propio
<Code>Accordion.Item</Code> del canon.
</DocRow>
</Stack>
</Stack>
@ -75,8 +75,8 @@
<Stack gap={3}>
<DocRow term="Disclosure">
El teclado (flechas, Home/End, Enter/Espacio), <Code>aria-expanded</Code> y
<Code>aria-controls</Code> salen del <Code>Accordion</Code> del canon — el block no toca la
a11y del disclosure.
<Code>aria-controls</Code> salen del <Code>Accordion</Code> del canon — el block no toca la a11y
del disclosure.
</DocRow>
<DocRow term="Encabezados">
La sección un <Code>h2</Code>; cada pregunta un <Code>h3</Code> (nivel del
@ -93,7 +93,9 @@
<Code>layout</Code> o hermano.
</DocRow>
<DocRow term="Cola «¿Aún tienes dudas?»">
<strong>App-land</strong> — la app pone un <Code>Text</Code> + <Code>Link</Code>/<Code>Button</Code>
<strong>App-land</strong> — la app pone un <Code>Text</Code> + <Code>Link</Code>/<Code
>Button</Code
>
tras <Code>.List</Code> (como en esta demo); un slot propio solo si una demo lo pide.
</DocRow>
</Stack>

@ -39,8 +39,8 @@
{/snippet}
{#snippet lede()}
La sección que responde «qué hace»: una cabecera sobre una rejilla responsive de features
(icono · título · texto). Es el primer block <strong>compound</strong> del tier — su
La sección que responde «qué hace»: una cabecera sobre una rejilla responsive de features (icono
· título · texto). Es el primer block <strong>compound</strong> del tier — su
<Code>.Item</Code> se repite, y por eso gana sub-componentes donde un hero usaría slots.
{/snippet}
@ -114,8 +114,8 @@
{#snippet composition()}
<Stack gap={4}>
<Text color="muted">
El block coloca; el contenido es del app. Es compound porque el <Code>.Item</Code> se
repite — la regla de admisión del tier.
El block coloca; el contenido es del app. Es compound porque el <Code>.Item</Code> se repite —
la regla de admisión del tier.
</Text>
<Stack gap={3}>
<DocRow term="raíz">
@ -127,12 +127,13 @@
<Code>Text</Code>. La alineación la elige el app.
</DocRow>
<DocRow term=".Items">
<Code>AutoGrid</Code> — fluido por <Code>minChildWidth</Code> (tantas columnas como
quepan) o <Code>columns</Code> fijas. Es el contenedor honesto de la rejilla.
<Code>AutoGrid</Code> — fluido por <Code>minChildWidth</Code> (tantas columnas como quepan)
o <Code>columns</Code> fijas. Es el contenedor honesto de la rejilla.
</DocRow>
<DocRow term=".Item · .ItemIcon · .ItemTitle · .ItemText">
<Code>Stack</Code> con el chip (<Code>Surface</Code> soft), el <Code>Heading</Code> (h3) y
el <Code>Text</Code> apagado. O children libres.
<Code>Stack</Code> con el chip (<Code>Surface</Code> solid — el tinte soft es casi blanco en
claro; <Code>variant="soft"</Code> si lo quieres apagado), el
<Code>Heading</Code> (h3) y el <Code>Text</Code> apagado. O children libres.
</DocRow>
</Stack>
</Stack>
@ -167,13 +168,12 @@
feature — el esquema de encabezados sale del contenido, no de estilos.
</DocRow>
<DocRow term="Iconos">
Decorativos: el glifo va dentro de <Code>.ItemIcon</Code> y el título lo nombra. Si un icono
fuese informativo, el app le pone su <Code>aria-label</Code>.
Decorativos: el glifo va dentro de <Code>.ItemIcon</Code> y el título lo nombra. Si un icono fuese
informativo, el app le pone su <Code>aria-label</Code>.
</DocRow>
<DocRow term="Landmark">
Sección sin nombre por ahora (un <Code>&lt;section&gt;</Code> a secas). Nombrarla con
<Code>aria-labelledby</Code> a la cabecera es un candidato, cuando el contexto compound lo
pida.
<Code>aria-labelledby</Code> a la cabecera es un candidato, cuando el contexto compound lo pida.
</DocRow>
</Stack>
{/snippet}
@ -183,8 +183,8 @@
<DocRow term="Feature-split / alternante">
<strong>Scope-approval pendiente</strong> — la <strong>brecha nº1 de F2</strong> del dossier:
un bloque de texto junto a un screenshot, alternando lados. Es otra disposición (filas de dos
columnas, no una rejilla de iconos), así que probablemente sea su propio block o hermano —
decisión tuya, no un añadido en silencio aquí.
columnas, no una rejilla de iconos), así que probablemente sea su propio block o hermano — decisión
tuya, no un añadido en silencio aquí.
</DocRow>
<DocRow term="Variante en Card">
<strong>Diferido</strong> — cada item en una <Code>Card</Code>: está a una prop (envolver el
@ -199,12 +199,14 @@
{#snippet notes()}
<Stack gap={3}>
<DocRow term="Por qué compound y no slots">
<Code>site-header</Code> y <Code>hero</Code> son slots de snippet (partes fijas de layout).
Aquí el <Code>.Item</Code> se repite: el app mapea sobre N features. Esa es la regla del tier
— compound cuando una parte es una lista que el app itera, no un slot posicional único.
<Code>site-header</Code> y <Code>hero</Code> son slots de snippet (partes fijas de layout). Aquí
el <Code>.Item</Code> se repite: el app mapea sobre N features. Esa es la regla del tier — compound
cuando una parte es una lista que el app itera, no un slot posicional único.
</DocRow>
<DocRow term=".Items existe a propósito">
Envuelve la rejilla para que la cabecera quede fuera de ella sin un <Code>grid-column: 1 / -1</Code>
Envuelve la rejilla para que la cabecera quede fuera de ella sin un <Code
>grid-column: 1 / -1</Code
>
a pelo. Es el contenedor honesto, como el <Code>List</Code> de una lista.
</DocRow>
</Stack>

@ -96,14 +96,10 @@
{/snippet}
Empezar gratis
</Button>
<Link
href="#docs"
variant="subtle"
size="lg"
color={layout === 'background' ? 'var(--color-content-on-solid)' : undefined}
>
Ver la documentación
</Link>
<!-- No ink override here: the block sets it for the whole copy cluster under
`background`. This used to pass `--color-content-on-solid` by hand, which
was inert anyway (`subtle` resolves to `color: inherit`). -->
<Link href="#docs" variant="subtle" size="lg">Ver la documentación</Link>
{/snippet}
{#snippet media()}
@ -135,7 +131,12 @@
<!-- Grid gives each bar an equal 1fr track — no reliance on flex-grow. -->
<Grid columns={7} gap={3} align="end">
{#each bars as h, i (i)}
<Surface variant="solid" color={i === 5 ? 'primary' : 'neutral'} rounded height={h} />
<Surface
variant="solid"
color={i === 5 ? 'primary' : 'neutral'}
rounded
height={h}
/>
{/each}
</Grid>
<Grid templateColumns="1fr 20%" gap={3} align="center">
@ -165,8 +166,8 @@
Del evento crudo a la decisión, sin escala intermedia
</Heading>
<Text color="muted" measure="wide">
Los eventos llegan tal cual y salen en cohortes: quién hizo qué, cuándo dejó de hacerlo y qué
cambió la semana en que lanzaste. Consulta cualquier ventana sin reprocesar.
Los eventos llegan tal cual y salen en cohortes: quién hizo qué, cuándo dejó de hacerlo y
qué cambió la semana en que lanzaste. Consulta cualquier ventana sin reprocesar.
</Text>
</Stack>
</Container>

@ -87,7 +87,7 @@
</Container>
</Section>
<Newsletter {layout} {panel} {color} {gradient} {form} {schema}>
<Newsletter {layout} {panel} {color} {gradient} {form} {schema} sent={subscribed !== null}>
{#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
@ -119,7 +119,9 @@
{/snippet}
{#snippet submit()}
<Form.Submit size="lg" color="fulfill">Suscribirme</Form.Submit>
<!-- The action reads the section state from context: no `disabled` assembled
here, and its label follows the state. -->
<Newsletter.Submit size="lg" />
{/snippet}
{#snippet note()}
@ -130,6 +132,9 @@
{/snippet}
{#snippet children()}
<!-- The sentence of the section state. It reaches assistive tech through the
system announcer too — the block announces, not this page. -->
<Newsletter.Reason />
{#if subscribed}
<!-- La confirmación es del APP: `Form` ya emitió `commit-submit`; qué
significa suscribirse lo decide quien recibe los valores. -->

@ -102,26 +102,35 @@
<Text size="sm" weight="semibold">Una carta cada martes</Text>
<Text size="sm" color="muted">Cambios del producto y decisiones de diseño. Sin relleno.</Text>
</Stack>
<!--
The confirmation is a SIBLING of the form, never a replacement for it.
Swapping the `<Form>` out on success unmounted the provider inside the
same await that ran the handler, so the `commit-submit` — declared
`sequence:'post'` — resolved its target to a detached node and threw
`SomaRuntimeTargetError` silently: the one moment that earned a
`commit + fulfill` emitted through NO channel at all (measured: 0 WebAudio
nodes, 0 `data-event` mutations). Keeping the form mounted is what makes
the success audible, and it is what `NewsletterSite` already does.
-->
{#if subscribed}
<Text size="sm" color="affirm">Listo — revisa tu correo para confirmar.</Text>
{:else}
<Form {form} {schema} variant="plain">
<Flex direction={{ base: 'column', sm: 'row' }} gap={3} align="start">
<Box width={{ base: '100%', sm: '20rem' }}>
<Field name="email" label="Correo electrónico" required floatingLabel>
<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>
</Box>
<Form.Submit color="fulfill">Suscribirme</Form.Submit>
</Flex>
</Form>
{/if}
<Form {form} {schema} variant="plain">
<Flex direction={{ base: 'column', sm: 'row' }} gap={3} align="start">
<Box width={{ base: '100%', sm: '20rem' }}>
<Field name="email" label="Correo electrónico" required floatingLabel>
<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>
</Box>
<Form.Submit color="fulfill">Suscribirme</Form.Submit>
</Flex>
</Form>
</Stack>
{/snippet}

Loading…
Cancel
Save

Powered by TurnKey Linux.