feat(blocks): `cta` — el panel que pide el siguiente paso, y tres mentiras que destapó al medirlo

F2.8. Slots de snippet (eyebrow · title · description · actions · children),
misma forma que `hero`: las partes de un CTA no repiten ni coordinan, así que un
compound no se gana nada. Dos disposiciones: `center` para cerrar una página y
`justified` —copia al inicio, acciones al final— que es el hueco que nombraba el
dossier. Un solo `Motion trigger="viewport"`: el panel llega ENTERO, porque un
CTA es una sola afirmación y repartir sus tres partes se leería como duda.

Lo que salió al verificarlo en navegador, medido y no a ojo:

- `variant='soft'` **fuera de la API**. Su track queda a `oklch(0.9932)` contra
  un `--color-surface-default` de `oklch(0.9911)`: 0.002 de luminancia, o sea
  ningún panel en claro. Y `Surface` no tiene borde al que caer. Cortar el prop
  es más barato que shipear un estado que se esfuma.
- La ranura `contrast` de la paleta es blanco en TODO escalón sólido, así que un
  lienzo de luminancia media deja el cuerpo por debajo de AA: `primary` 5.18 ·
  `indigo` 5.21 · `plum` 4.75 pasan en ambos modos; `neutral` 3.32 · `teal` 3.07
  fallan en claro. El block reenvía cualquier `color`; la demo solo ofrece los
  que pasan.
- `Text align` es inerte por defecto: renderiza un `span`, y `text-align` no hace
  nada sobre una caja inline. `align="center"` dejaba la copia a la izquierda
  dentro del layout centrado, sin avisar. Rodeado con `as="p"`.

Y `Group` no apila: a 420px la etiqueta de la acción secundaria se parte contra
el botón primario, así que las acciones van en `Flex direction={{ base:
'column', sm: 'row' }}`. `hero` compone las suyas con `Group` — anotado.

Los tres hallazgos de canon quedan en los gaps del README del block y en
`PLAN-blocks-quality.md` §6 (F15/F16/F17), sin tocar nada fuera del tier.

Verificado: `center` y `justified` en claro/oscuro/RTL y a 420px, tres colores,
entrada disparada, descripción en `<p>` centrada, cero errores de página.
Gates: `blocks:check` verde (9 blocks) · `svelte-check` sin errores propios.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 2 months ago
parent 3097cfcb69
commit 03d6796582

@ -1,7 +1,7 @@
# CONTINUE — F2 blocks de sitio (handoff, act. 2026-07-29)
# CONTINUE — F2 blocks de sitio (handoff, act. 2026-07-30)
Estado al parar: **F1 CERRADA (8/8)** y **F2 8/14** — site-header · hero ·
feature-grid · feature-split · pricing · testimonials · faq · stats-band. Encima de eso se dio
Estado al parar: **F1 CERRADA (8/8)** y **F2 9/14** — site-header · hero ·
feature-grid · feature-split · pricing · testimonials · faq · stats-band · cta. Encima de eso se dio
una **pasada de calidad** (`PLAN-blocks-quality.md`): movimiento en los 7 blocks,
vídeo, intents semánticos y el arco `contact → commit`; más tres arreglos de
canon (`Grid align`, herencia de `--flex-*`/`--grid-*`, archetype del
@ -16,16 +16,23 @@ Todo commiteado y pusheado a `gita/alpha-0.1-sec-dom`.
## Lo siguiente
**F2.8 `cta`** (ficha en `PLAN-blocks.md` §F2.8). Compone `Section` + `Surface`
(el CTA se distingue por ACABADO, y eso ya es vocabulario del framework) +
`Heading` + `Text` + `Group` de `Button`s. Slots de snippet (es una sección de
layout, no partes que se repitan). Del dossier: paridad con el centrado; añadir
el arreglo **justified** (texto izq., botones dcha.) y valorar split-with-media.
Después: newsletter → site-footer → banner → team → contact → content-section.
**F2.7 `stats-band` HECHO** — compound sobre `Metrics` + `CountUp`. El `count` es
opt-in y el block no formatea ni escribe nada: el separador lo pone
`uix.format` dentro del `CountUp`. Es la superación literal del dossier.
**F2.9 `newsletter`** (ficha en `PLAN-blocks.md` §F2.9). Es el `cta` **más un
formulario**: `Form` + `Field` (email) + `Button` de submit. Lee el README de
`Form` antes de tocar nada — la validación la pone el sistema, el block no
inventa la suya, y el handler de submit es del app. Ojo al arco semántico: el
submit es `commit` con intent, no un `contact` como el clic del `cta`. Después:
site-footer → banner → team → contact → content-section.
**F2.8 `cta` HECHO** — slots de snippet, `center` + `justified`, un solo
`Motion` (el panel llega entero: un CTA es una sola afirmación). Dejó **tres
hallazgos de canon medidos**, no a ojo, que están en los gaps de su README y en
`PLAN-blocks-quality.md` §6: (1) `Surface soft` no acota panel — 0.002 L del
fondo en claro — y no tiene borde, así que se **quitó** el prop `variant`;
(2) la ranura `contrast` es blanca en todo escalón sólido, así que los lienzos
de luminancia media dejan el cuerpo por debajo de AA (`neutral` 3.32 · `teal`
3.07 vs `primary` 5.18); (3) `Text align` es inerte sobre el `span` por defecto
— hay que pedir `as="p"`. Y `Group` no apila: las acciones van en `Flex
direction={{ base: 'column', sm: 'row' }}`, cosa que `hero` aún no hace.
## Deuda a decisión tuya

@ -185,3 +185,15 @@ registrado aquí como deuda, sin tocar:
| C10 | `docs/theming/motion.md` | no documenta el `trigger` nuevo ni la llamada al motor |
| D12 | `morfo/components/backdrop.ts` · `cascade.css` | citan `reveal.ts`, borrado; y `Cascade` emite `data-reveal-pending` — nombre de un componente que ya no existe — para el mismo concepto que `Motion` llama `data-animation-pending` |
| E14 | `motion.css:42` | `--motion-stagger-each-default` no existe en ninguna parte: el knob es ficción y siempre vale el literal 70ms. Ya existe el canónico `--motion-stagger` |
### Añadidos al construir `cta` (2026-07-30) — medidos en navegador
Tres cosas que salieron al componer F2.8, todas verificadas con sonda de
luminancia / `getComputedStyle`, no a ojo. Caen fuera de `blocks/`, así que
tampoco se han tocado:
| # | Dónde | Qué |
|---|---|---|
| F15 | `eidos/components/surface` | `variant='soft'` no puede acotar un panel: el track de `primary` mide `oklch(0.9932 0.0034 325.6)` contra un `--color-surface-default` de `oklch(0.9911 0 0)` — **0.002 L** — y `Surface` no tiene prop de borde. `Card outline` sí acota pero no acepta `gradient`, así que «panel sosegado con borde» no tiene primitivo. Por eso `cta` **eliminó** su prop `variant` |
| F16 | paleta (`contrast` slot) | la ranura de contraste es `#ffffff` en **todo** escalón sólido, así que un lienzo de luminancia media deja el cuerpo de texto por debajo de AA. Medido sobre el panel: `primary` 5.18 · `indigo` 5.21 · `plum` 4.75 (pasan en ambos modos) vs `neutral` 3.32 · `secondary` 3.30 · `slate` 3.30 · `teal` 3.07 (fallan en claro). La garantía de emparejamiento solo se cumple en los lienzos oscuros |
| F17 | `eidos/components/text` | `align` es **inerte** por defecto: el componente renderiza un `span` y `text-align` no hace nada sobre una caja inline. `align="center"` dejó la copia alineada a la izquierda dentro de un layout centrado sin avisar de nada. Se rodea con `as="p"`, pero el prop anuncia un efecto que no tiene hasta que el consumidor cambia el elemento |

@ -529,11 +529,15 @@ ampliaciones = Gaps con disposición, no código especulativo.
- **v1**: banda horizontal 2–4 stats; los formatos numéricos llegan ya
formateados o vía `FormatNumber` compuesto por el app.
### F2.8 `cta`
- **Compone**: `Section` + `Surface` (tratamiento de fondo del sistema — un
CTA se distingue por acabado, y eso ya es vocabulario del framework) +
`Heading` + `Text` + `Group` (`Button`s).
- **API**: `<Cta>` + `.Title` + `.Description` + `.Actions`.
### F2.8 `cta` — HECHO (2026-07-30)
- **Compone**: `Section` + `Container` + `Motion` + `Surface` (tratamiento de
fondo del sistema — un CTA se distingue por acabado, y eso ya es vocabulario
del framework) + `Heading` + `Text` + `Flex` (`Button`s).
- **API**: `<Cta layout color gradient level container size>` + slots de snippet
`eyebrow` · `title` · `description` · `actions` · `children`. **Desviación del
plan**: no `.Title`/`.Description`/`.Actions` — las partes no repiten ni
coordinan, así que son slots posicionales (misma forma que `hero`).
`variant` NO existe: medido, el canvas `soft` no acota panel (bitácora).
### F2.9 `newsletter`
- **Compone**: como F2.8 + `Form` + `Field` (email) + `Button`.
@ -1223,3 +1227,41 @@ Blocks diferidos: `scheduler` (bloqueado por `chronos`) · `logo-cloud`
con índices 0,1,2,3 contando (9377→11.678 · 255→318 · 36→45), el porcentaje
quieto, separador por locale, cero errores de página. Gates: `blocks:check`
verde (8 blocks) · `svelte-check` sin errores propios. **Siguiente**: F2.8 `cta`.
- 2026-07-30 — **F2.8 `cta` HECHO**. Slots de snippet (`eyebrow` · `title` ·
`description` · `actions` · `children`), misma forma que `hero`: las partes de
un CTA no repiten ni coordinan. Compone `Section` + `Container` + `Motion` +
`Surface` + `Heading` + `Text` + `Flex`. Dos disposiciones: `center` (`Stack`,
cierre de página) y **`justified`** (`Grid` 2 col., el hueco que nombraba el
dossier), que apila en estrecho. Un solo `Motion trigger="viewport"` con
`scale-fade`: el panel llega ENTERO, porque un CTA es una sola afirmación y
repartir sus tres partes se leería como duda. Landmark `section aria-labelledby`
al título que el propio block escribe.
**Tres hallazgos medidos en navegador, no a ojo** (los tres van a los gaps del
README y a §6 de `PLAN-blocks-quality.md`):
1. `variant='soft'` **eliminado de la API**: su track queda a `oklch(0.9932)`
contra un `--color-surface-default` de `oklch(0.9911)` — 0.002 L, o sea
ningún panel en claro — y `Surface` no tiene borde al que caer. Un panel
sosegado *con borde* no tiene primitivo (`Card outline` acota pero no acepta
acabado de degradado).
2. La ranura `contrast` de la paleta es `#ffffff` en **todo** escalón sólido, así
que un lienzo de luminancia media deja el cuerpo por debajo de AA: medido,
`primary` 5.18 · `indigo` 5.21 · `plum` 4.75 pasan en ambos modos, mientras
`neutral` 3.32 · `secondary` 3.30 · `slate` 3.30 · `teal` 3.07 fallan en
claro. El block reenvía cualquier `color`; la demo solo ofrece los que pasan.
3. `Text align` es **inerte** por defecto: `Text` renderiza un `span` y
`text-align` no hace nada sobre una caja inline, así que `align="center"`
dejaba la copia alineada a la izquierda dentro del layout centrado. Resuelto
con `as="p"` (que es lo que ese contenido es).
También: `Group` no apila — las acciones necesitaron
`Flex direction={{ base: 'column', sm: 'row' }}` porque a 420px la etiqueta de
la acción secundaria se parte contra el botón primario (`hero` compone sus
acciones con `Group`, así que arrastra el mismo comportamiento — anotado para su
siguiente pasada). El CTA que navega usa el patrón `Button child` + `content`
con `intent="fulfill"`, igual que `hero`. Verificado en navegador: `center` y
`justified` en claro/oscuro/RTL + 420px, tres colores, entrada disparada
(`data-animation-pending` fuera), descripción en `<p>` centrada, cero errores de
página. Gates: `blocks:check` verde (9 blocks) · `svelte-check` sin errores
propios. **Siguiente**: F2.9 `newsletter`.

@ -0,0 +1,90 @@
# Cta
## Function
The panel that asks for the next step: a title, a supporting line and the
buttons, on a finished surface that lifts it off the page. Either the closing
beat of a page (`center`) or a mid-page nudge that must not stop the reading
(`justified`).
## 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`) | the panel arrives WHOLE — it is one statement, not a list, so it does not stagger its parts |
| panel | `Surface` (always `solid` · `color` · `gradient` · `rounded`) | the look is the system's finish, not paint of the block's own |
| 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 |
| `actions` | `Flex` (column → row at `sm`) | the app drops `Button`s / `Link`s |
## Form: snippet slots, not sub-components
The tier's rule is that a compound API is earned when parts **repeat**
(`feature-grid.Item`) or **coordinate** (`pricing.Switch` ↔ `PlanPrice`). A CTA's
parts do neither: they are fixed positional slots the root arranges. So it takes
the same shape as `hero`. *(The plan sketched `.Title`/`.Description`/`.Actions`;
this is the same registered deviation as `hero`.)*
## Decisions
**2026-07-29 — reference floor** (dossier §P1: Tailwind Plus «CTA 11» ·
PrimeBlocks «CTA 12» · Untitled UI):
- **Adopted**: the centred panel (parity) **and the `justified` arrangement** —
copy at the inline-start, actions at the end — which the dossier names as the
gap to close.
- **The finish is the system's**: a CTA is distinguished by *treatment*, and
treatment is already framework vocabulary. `Surface` with `gradient` on by
default (the one section that earns it), so a theme or palette swap carries the
panel along. The block paints nothing of its own.
- **Ink follows the panel**: the title and description take on-solid, the
contrast ink the palette guarantees for that canvas. No hand-picked colour, so
a `color` or theme swap carries them along.
- **It reveals whole**: one `Motion`, no stagger. A CTA is a single statement;
dealing its three parts in one by one would read as hesitation.
- **No `variant` prop — the panel is always solid.** It shipped as
`'solid' | 'soft'` and the soft state was measured out in the browser: the
primary track lands at `oklch(0.9932 …)` against a `--color-surface-default` of
`oklch(0.9911 …)` — 0.002 L apart, i.e. no panel at all in light mode, and
`Surface` has no border to fall back on. Cutting the prop is cheaper than
shipping a state that vanishes.
- **The demo offers only dark canvases** (`primary` · `indigo` · `plum`).
Measured body-copy contrast on the panel: 5.18 · 5.21 · 4.75 in both modes,
versus `neutral` 3.32 / `secondary` 3.30 / `slate` 3.30 / `teal` 3.07 in light.
The palette's contrast slot is `#ffffff` for every solid step, so a
mid-lightness canvas cannot carry body copy at AA. The block forwards any
`color` the app passes — it just does not advertise the ones that fail.
## Demo
`web/routes/blocks/cta/` — the block full-bleed on the page, with live control of
layout, colour, finish and direction, and the device widths served from
`preview/`. Mini-page in `CtaSite.svelte`, shared by both surfaces.
## Gaps
| 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"` |
| **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 |
| **On-solid chip treatment** | **canon** — `Badge` has no on-solid variant; the demo uses `variant="soft"`, whose near-white track happens to read against a saturated canvas. A `Badge` that knows it sits on a solid panel would be the real answer |
## Found while composing
- **`Text align` is inert by default.** `Text` renders a `span`; `text-align`
does nothing on an inline box, so `align="center"` silently kept the copy
left-aligned inside the centred layout. Fixed here with `as="p"` (which is
what a paragraph is anyway), but the prop advertises an effect it does not have
until the consumer changes the element. Canon candidate.
- **`Surface soft` cannot bound a panel** — measured above; see Gaps.
- **`Group` does not stack.** The actions row needed `Flex direction={{ base:
'column', sm: 'row' }}`: in a `Group` at 420px the secondary action's label
breaks mid-phrase against the primary button. `hero` composes its actions with
`Group` too, so it has the same narrow behaviour — noted for its next pass.

@ -0,0 +1,137 @@
<script lang="ts">
/**
* Cta — the panel that asks for the next step. `center` stacks the copy and
* the actions for the closing beat of a page; `justified` puts the copy at the
* inline-start and the actions at the end, for a mid-page nudge that must not
* stop the reading.
*
* FORM: snippet slots, not sub-components. The tier's rule is that compound is
* earned when parts REPEAT (feature-grid.Item) or COORDINATE (pricing.Switch ↔
* PlanPrice). A CTA's parts do neither — they are fixed positional slots the
* root arranges, like `hero`. (The plan sketched `.Title`/`.Description`/
* `.Actions`; this is the same deviation already registered for `hero`.)
*
* B contract: composes canon components only — no `.css`, no morfo, no
* strings. The panel's look is the system's own `Surface` treatment: a CTA is
* distinguished by FINISH, and finish is already framework vocabulary.
*
* The panel is always the SOLID canvas. `variant='soft'` was measured out: its
* track lands 0.002 L from `--color-surface-default` in light mode, so the panel
* disappears. The quiet register has no answer either — the palette's contrast
* slot is white for every solid step, so a mid-lightness canvas (`neutral`,
* `teal`) puts body copy at ~3.1:1. Both are in the README's gaps; the app is
* expected to pass a dark canvas.
*/
import { Section } from '$uix/eidos/components/section';
import { Container } from '$uix/eidos/components/container';
import { Surface } from '$uix/eidos/components/surface';
import { Stack } from '$uix/eidos/components/stack';
import { Flex } from '$uix/eidos/components/flex';
import { Grid } from '$uix/eidos/components/grid';
import { Box } from '$uix/eidos/components/box';
import { Heading } from '$uix/eidos/components/heading';
import { Text } from '$uix/eidos/components/text';
import { Motion } from '$uix/eidos/components/motion';
import type { CtaProps } from './types';
let {
layout = 'center',
color = 'primary',
gradient = true,
level = 2,
container = 'lg',
size = 'lg',
eyebrow,
title,
description,
actions,
children,
...rest
}: CtaProps = $props();
const uid = $props.id();
const titleId = `${uid}-title`;
const centered = $derived(layout === 'center');
</script>
<!-- The copy cluster: identical in both layouts, only its alignment changes. -->
{#snippet copy()}
<Stack gap={4} align={centered ? 'center' : 'start'}>
{#if eyebrow}
{@render eyebrow()}
{/if}
{#if title}
<Heading id={titleId} {level} align={centered ? 'center' : 'start'} wrap="balance" color="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>
{/if}
</Stack>
{/snippet}
{#snippet cluster()}
{#if actions}
<!-- The actions stack on narrow: kept in a row, the secondary action's label
wraps mid-word against the primary button. -->
<Flex
direction={{ base: 'column', sm: 'row' }}
gap={3}
align={{ base: centered ? 'center' : 'start', sm: 'center' }}
justify={centered ? 'center' : 'end'}
>
{@render actions()}
</Flex>
{/if}
{/snippet}
<section aria-labelledby={title ? titleId : undefined} {...rest}>
<Section {size}>
<Container size={container}>
<!-- 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%">
{#if centered}
<Stack gap={6} align="center">
{@render copy()}
{@render cluster()}
{#if children}
{@render children()}
{/if}
</Stack>
{:else}
<!-- Justified: copy at the inline-start, actions at the end, on one
line — stacking on narrow, where a single row would crush both. -->
<Grid columns={{ base: 1, md: 2 }} gap={{ base: 6, md: 10 }} align="center">
{@render copy()}
<Box>
{@render cluster()}
{#if children}
{@render children()}
{/if}
</Box>
</Grid>
{/if}
</Surface>
</Motion>
</Container>
</Section>
</section>

@ -0,0 +1,21 @@
// Cta — the panel that asks for the next step, in the system's own finish.
//
// import { Cta } from '$blocks/cta';
//
// <Cta>
// {#snippet title()}Empieza a medir hoy{/snippet}
// {#snippet description()}Conecta el SDK y ve tu primer evento en minutos.{/snippet}
// {#snippet actions()}<Button>…</Button>{/snippet}
// </Cta>
//
// <Cta layout="justified" variant="soft">…</Cta>
//
// 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
// `hero`). Every visible word is the app's (B-7); the panel's look is `Surface`
// — a CTA is distinguished by FINISH, which is already framework vocabulary.
import Cta from './cta.svelte';
export { Cta };
export default Cta;
export type { CtaProps, CtaLayout } from './types';

@ -0,0 +1,61 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { ContainerSize } from '$uix/eidos/components/container';
import type { SectionSize } from '$uix/eidos/components/section';
import type { HeadingLevel } from '$uix/eidos/components/heading';
import type { SurfaceProps } from '$uix/eidos/components/surface';
import type { ComponentColorProp } from '$uix/eidos/lib/types';
/** How the panel arranges its copy and its actions. */
export type CtaLayout = 'center' | 'justified';
// `title` is omitted from the element attributes because the block reuses the
// name for the title snippet (the HTML `title` tooltip attribute would otherwise
// intersect the Snippet into `string & Snippet` — the hero lesson).
export type CtaProps = Omit<HTMLAttributes<HTMLElement>, 'children' | 'title'> & {
/**
* `center` stacks the copy and the actions, centred — the closing beat of a
* page. `justified` puts the copy at the inline-start and the actions at the
* end, on one line: the arrangement the references ship for a mid-page nudge
* that must not stop the reading.
* @default 'center'
*/
layout?: CtaLayout;
/**
* Panel accent — the full colour system (role · intent · any of the 33 donor
* scales · a raw CSS value). The panel is always the SOLID canvas: measured
* against `--color-surface-default`, the soft track sits 0.002 L away in light
* mode, so it cannot bound a panel.
*
* Pick a DARK canvas. The palette's contrast slot is `#ffffff` for every solid
* step, so a mid-lightness canvas puts white body copy at ~3.1–3.3:1 (below
* AA): measured, `primary` 5.18 · `indigo` 5.21 · `plum` 4.75 pass in both
* modes, while `neutral` / `secondary` / `slate` / `teal` fail in light. Both
* findings are in the README's gaps.
* @default 'primary'
*/
color?: ComponentColorProp;
/**
* Gradient finish on the panel — the system's own treatment (a ramp derived
* from this instance's palette). Passed straight to `Surface`.
* @default true — a CTA is the one section that earns the finish
*/
gradient?: SurfaceProps['gradient'];
/** Semantic level of the title heading. @default 2 */
level?: HeadingLevel;
/** Content measure. Passed straight to `Container`. @default 'lg' */
container?: ContainerSize;
/** Block-axis padding of the SECTION (around the panel). @default 'lg' */
size?: SectionSize;
/** A chip or label above the title. */
eyebrow?: Snippet;
/** The title's words — the block wraps them in a `Heading` and owns the
* landmark `id`. */
title?: Snippet;
/** The supporting line — the block wraps it in a `Text`. */
description?: Snippet;
/** The buttons — the block lays them in a row that stacks on narrow. */
actions?: Snippet;
/** Anything below the actions (a note, a logo strip). */
children?: Snippet;
};

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

@ -0,0 +1,227 @@
<script lang="ts">
/**
* Cta demo. Full-bleed on the page; device widths from `./preview`.
*/
import { Stack } from '$uix/eidos/components/stack';
import { Group } from '$uix/eidos/components/group';
import { Wrap } from '$uix/eidos/components/wrap';
import { Text } from '$uix/eidos/components/text';
import { Code } from '$uix/eidos/components/code';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import BlockDemo from '../_lib/BlockDemo.svelte';
import CtaSite from './CtaSite.svelte';
import DocRow from '../_lib/DocRow.svelte';
let layout = $state<'center' | 'justified'>('center');
let color = $state('primary');
let gradient = $state(true);
let dir = $state<'ltr' | 'rtl'>('ltr');
const previewSrc = $derived(
`/blocks/cta/preview?layout=${layout}&color=${color}&gradient=${gradient}&dir=${dir}`
);
</script>
<BlockDemo
name="CTA"
slug="$blocks/cta"
{previewSrc}
previewHeight="660px"
meta={[
{ key: 'compone', value: 'Section · Container · Surface · Heading · Text · Flex · Motion' },
{ key: 'landmark', value: 'section aria-labelledby' },
{ key: 'forma', value: 'slots de snippet' },
{ key: 'strings propias', value: '0' }
]}
>
{#snippet preview()}
<CtaSite {layout} {color} {gradient} />
{/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>
para un empujón a media lectura.
{/snippet}
{#snippet controls()}
<Wrap gap={5}>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">layout</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[layout]}
onValueChange={(v) => (layout = (v[0] ?? layout) as typeof layout)}
aria-label="layout"
>
<ToggleGroup.Item value="center">center</ToggleGroup.Item>
<ToggleGroup.Item value="justified">justified</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">color</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[color]}
onValueChange={(v) => (color = v[0] ?? color)}
aria-label="color del panel"
>
<ToggleGroup.Item value="primary">primary</ToggleGroup.Item>
<ToggleGroup.Item value="indigo">indigo</ToggleGroup.Item>
<ToggleGroup.Item value="plum">plum</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">acabado</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[gradient ? 'sí' : 'no']}
onValueChange={(v) => (gradient = (v[0] ?? 'sí') === 'sí')}
aria-label="degradado del panel"
>
<ToggleGroup.Item value="sí">sí</ToggleGroup.Item>
<ToggleGroup.Item value="no">no</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">dir</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[dir]}
onValueChange={(v) => (dir = (v[0] ?? dir) as typeof dir)}
aria-label="dirección (solo la vista previa)"
>
<ToggleGroup.Item value="ltr">ltr</ToggleGroup.Item>
<ToggleGroup.Item value="rtl">rtl</ToggleGroup.Item>
</ToggleGroup>
</Group>
</Wrap>
{/snippet}
{#snippet composition()}
<Stack gap={4}>
<Text color="muted">
El block coloca y da el acabado con componentes del canon; las palabras y los botones son de
la app.
</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.
</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.
</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.
</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.
</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
<Code>color</Code> o tema la arrastra.
</DocRow>
</Stack>
</Stack>
{/snippet}
{#snippet api()}
<Stack gap={3}>
<DocRow term="Cta">
<Code>layout</Code> (<Code>'center' | 'justified'</Code>, def. <Code>center</Code>) ·
<Code>color</Code> (rol · intent · cualquiera de las 33 escalas · un valor CSS; def.
<Code>primary</Code>) · <Code>gradient</Code> (def. <Code>true</Code>) ·
<Code>level</Code> (def. <Code>2</Code>) · <Code>container</Code> · <Code>size</Code>.
</DocRow>
<DocRow term="eyebrow · title · description · actions · children">
Snippets: todo el contenido entra por composición (B-7). <Code>children</Code> se renderiza
bajo las acciones.
</DocRow>
</Stack>
{/snippet}
{#snippet a11y()}
<Stack gap={3}>
<DocRow term="Landmark">
Un <Code>section</Code> nombrado por su título vía <Code>aria-labelledby</Code>. Sin título
no se emite el atributo: 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.
</DocRow>
<DocRow term="Encabezado">
<Code>h2</Code> por defecto: un CTA no compite con el <Code>h1</Code> de la página.
</DocRow>
</Stack>
{/snippet}
{#snippet gaps()}
<Stack gap={3}>
<DocRow term="Split con media">
<strong>Diferido</strong> — panel con screenshot al lado; es una tercera disposición y el
<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>.
</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.
</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.
</DocRow>
<DocRow term="Chip sobre panel sólido">
<strong>Canon</strong> — no hay tratamiento on-solid para <Code>Badge</Code>: la demo usa
<Code>variant="soft"</Code>, cuyo track casi blanco funciona por casualidad de contraste.
</DocRow>
</Stack>
{/snippet}
{#snippet notes()}
<Stack gap={3}>
<DocRow term="Slots, no sub-componentes">
La regla del tier: compound cuando las partes SE REPITEN (<Code>feature-grid.Item</Code>) o
COORDINAN (<Code>pricing</Code>). Las de un CTA no hacen ninguna de las dos, así que son
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.
</DocRow>
</Stack>
{/snippet}
</BlockDemo>

@ -0,0 +1,76 @@
<script lang="ts">
/**
* The cta mini-page: the panel with real product copy, plus enough page around
* it to read as a page — so the demo can render it FULL-BLEED and the
* `preview` route can serve it as a standalone document.
*/
import { Cta } from '$blocks/cta';
import { Section } from '$uix/eidos/components/section';
import { Container } from '$uix/eidos/components/container';
import { Stack } from '$uix/eidos/components/stack';
import { Heading } from '$uix/eidos/components/heading';
import { Text } from '$uix/eidos/components/text';
import { Badge } from '$uix/eidos/components/badge';
import { Button } from '$uix/eidos/components/button';
import { Link } from '$uix/eidos/components/link';
import * as Icon from '$uix/eidos/components/icon';
type Layout = 'center' | 'justified';
let {
layout = 'center' as Layout,
color = 'primary',
gradient = true
}: {
layout?: Layout;
color?: string;
gradient?: boolean;
} = $props();
</script>
<!-- Some page before the CTA, so it lands where a CTA lands. -->
<Section size="lg">
<Container size="lg">
<Stack gap={4}>
<Heading level={2}>Todo lo que hace falta para medir de verdad</Heading>
<Text color="muted" measure="wide">
Eventos crudos, cohortes al día y automatizaciones que reaccionan solas. Sin muestreo y sin
un contrato que crece con cada pico de tráfico.
</Text>
</Stack>
</Container>
</Section>
<Cta {layout} {color} {gradient}>
{#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
treatment in the canon — gap noted in the block's README. -->
<Badge size="sm" variant="soft" color="primary">Sin tarjeta</Badge>
{/snippet}
{#snippet title()}Empieza a medir esta misma tarde{/snippet}
{#snippet description()}
Conecta el SDK, envía tu primer evento y ve la cohorte formarse en directo. Catorce días de Pro,
sin compromiso.
{/snippet}
{#snippet actions()}
<!-- Un CTA que NAVEGA y parece botón: Link posee la navegación, Button
presta la pintura vía `child` + `content`. -->
<Button variant="solid" size="lg" intent="fulfill">
{#snippet endIcon()}<Icon.ArrowRight size="xs" />{/snippet}
{#snippet child({ props, content })}
<a href="#empezar" {...props}>{@render content()}</a>
{/snippet}
Empezar gratis
</Button>
<!-- `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>
{/snippet}
</Cta>

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

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

Powered by TurnKey Linux.