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
parent
3097cfcb69
commit
03d6796582
@ -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,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…
Reference in new issue