blocks(landing): las piezas se prueban juntas, y ahí sale lo que ninguna enseñaba sola

Cierra el tramo D y con él F2b entera. Una landing verosímil con 14 de los 18
blocks, a sangre y sin marco de demo, porque el claim del tier —que sus blocks
ensamblan sin fricción— es indemostrable block a block: el outline de
encabezados sólo existe cruzando secciones, dos landmarks sólo chocan cuando hay
dos, y una costura vertical sólo aparece cuando algo se pone debajo.

Lo que dice la medida: un h1 y 22 encabezados sin un solo salto de nivel; ningún
landmark repetido sin nombre; 0px de hueco entre secciones vecinas —el aire es
siempre el padding de cada Section, costuras de 112 a 192px—; cuatro de las cinco
cascadas esperando a que su sección entre en viewport; un solo commit-submit en
el formulario; setenta tabulaciones sin una trampa de foco y un drawer que a
375px devuelve el foco a su disparador.

A-95 vuelve confirmado, ahora con cifras: con affix="top" la tira y la cabecera
solapan 49px —la altura entera de la cabecera— y el hit-test en su centro cae en
la tira, así que la navegación es inalcanzable. La pieza que falta sigue siendo
app-shell. La página compone la tira en flujo, que es lo correcto, y deja el
escenario del defecto detrás de ?banner=affix para poder medirlo en vez de
citarlo.

Un defecto arreglado, y era de los que importan: Newsletter.Reason medía 2,33:1
sobre el panel mientras la nota a su lado medía 8,5:1. Es la frase que explica
por qué la acción está bloqueada —la que la doctrina de coordinación obliga a
tener— y no se podía leer: la doctrina cumplida en la estructura y derrotada en
la percepción. El block ya tenía el mecanismo y ya lo había escrito para su nota
(«más callado por TAMAÑO, no por tinta»); su razón no lo seguía. Ahora 8,83:1
sobre el panel, y muted fuera de él.

Dos gaps nuevos, ninguno tocado aquí: los DOS landmarks banner —el canon estampa
role="banner" DESPUÉS de sus rest props, así que nombrar ambos es todo lo que
puede hacer la app, y esta página lo hace— y feature-split sin parte .Header, el
único block de sección que no la tiene: sin ella emite tres h2 hermanos y la
sección se queda sin nombre.

El catálogo aprendió a publicar páginas: kind: 'page' en BlockEntry y el guard lo
salta en las dos direcciones, con self-test propio probado por mutación.

Y una lección de instrumento que costó un susto: una captura fullPage no hace
scroll, así que toda cascada bajo el pliegue se queda en opacidad 0 y la imagen
sale EN BLANCO. La primera medida dijo «la landing está vacía». Saltar de una vez
al final del documento tampoco vale: las secciones pasan de debajo a encima en un
frame y el observador nunca las ve entrar. Se recorre en pasos.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-background
dev 2 months ago
parent 06f3c26400
commit c786d0f804

@ -263,7 +263,7 @@ demostradas en demo (el entregable central: responde a la brecha de
cardinalidad sin competir en dumps) · **C** ✅ **CERRADO 2026-08-18** (~~`logo-cloud`~~ ✅ con el eje `data-ink` en la fundación · ~~`blog`~~ ✅ como **`article-grid`** — el nombre se decidió en su fase 0 — · ~~`cookie-consent`~~ ✅ con su fase 0 legal: las 5 reglas son el TIPO y 8 tests, y **una sola llamada requerida `onDecision(consent, via)` en vez de las dos de la ficha** — es MÁS fuerte, porque el camino de rechazo lo renderiza el block y no el consumidor, que con dos callbacks podía pasar un no-op) **C** tres blocks nuevos — `logo-cloud`
(reabierto: las refs lo shippean estático), `blog`, `cookie-consent` (fase 0
legal profunda; la ley vive en el TIPO) — y `onboarding` NO entra (receta de
`wizard`) · **D** la página compuesta como puerta de cierre.
`wizard`) · **D** ✅ **CERRADO 2026-08-18** — `web/routes/blocks/landing/`, 14 de 18 blocks a sangre, medidas en su propio README. **F2b QUEDA CERRADA ENTERA.** Lo que dejó: A-95 re-confirmado con cifras (solape 49px = la cabecera entera; falta `app-shell` F3.1) · `Newsletter.Reason` arreglado (2,33 → 8,83:1 sobre el panel: la frase del bloqueo no se leía) · dos gaps nuevos (los DOS landmarks `banner` del canon · `feature-split` sin `.Header`, el único block de sección sin ella) · el catálogo aprendió `kind: 'page'` con guard probado por mutación. ⚠️ **medir una página con motion de viewport**: `fullPage` no hace scroll y sale EN BLANCO, y saltar al final tampoco dispara el observador — se recorre en pasos.
Las 4 filas que la sección «Deuda a decisión tuya» de este handoff listaba
llevan ahí su firma (V1 parte, no hermano · V2/V3 `layout` · V4 slot). V6/V7

@ -33,7 +33,7 @@
| **F0** | Infraestructura del tier: doctrina + alias + guard + rutas demo | **HECHA 2026-07-21** (F0.1–F0.7; cross-ref en `comparison.md` omitido a propósito — sin aporte hasta que exista catálogo) |
| **F1** | **8** componentes base del CANON que los blocks necesitan (7 + `nav-tree` por E-1) | **CERRADA — 8/8 HECHAS** (empty-state · result · callout · **sticky** · **prose** · **anchor-nav** · **nav-tree** · **sidebar**, PASS las ocho; ver registro) |
| **F2** | Blocks de sitio (**15**: 10 + banner·team·contact·content-section por E-3 + feature-split) | **CERRADA EN UNIDADES — 15/15** (+ saneamiento, guard endurecido y contraste doctrinal 15/15; ledger `AUDIT-blocks-ledger.md`). La página compuesta que su cierre exigía NO se construyó: la integración vive en F2b |
| **F2b** | **Ola de paridad de variedad**: variantes de suelo (A) + matriz de equivalencia (B) + `logo-cloud`·`blog`·`cookie-consent` (C) + página compuesta (D) | **FIRMADA 2026-08-17** — pendiente de ejecutar |
| **F2b** | **Ola de paridad de variedad**: variantes de suelo (A) + matriz de equivalencia (B) + `logo-cloud`·`blog`·`cookie-consent` (C) + página compuesta (D) | ✅ **CERRADA 2026-08-18** — los cuatro tramos ejecutados |
| **F3** | Blocks de aplicación (10) | pendiente |
| **F4** | Blocks de docs (3) | pendiente |
| **F5** | Backlog condicionado (componentes media/mobile + blocks diferidos) | pendiente |
@ -1012,7 +1012,29 @@ veces — es el vehículo natural de la matriz del tramo B, y se promociona a
demo pública (P1: todas las referencias shippean páginas compuestas como
artefacto de prueba; TW 10 · Untitled 105).
#### Ficha — la página compuesta
#### Ficha — la página compuesta — ✅ EJECUTADA 2026-08-18
> **Cerrada.** `web/routes/blocks/landing/` con 14 de los 18 blocks, a sangre.
> Las medidas viven en su README con fecha e instrumento (Playwright headless).
> Lo que salió, resumido:
>
> - **Outline** 1 `h1`, 22 encabezados, 0 saltos · **landmarks** sin duplicado
> anónimo · **ritmo** 0px de hueco entre secciones (el aire es el `padding` de
> cada `Section`; costuras 112–192px) · **motion** 4 de 5 cascadas esperan a su
> sección · **sema** un solo `commit-submit` · **teclado** 70 tabulaciones sin
> trampa y el drawer devuelve el foco.
> - **A-95 RE-CONFIRMADO con cifras**: con `affix="top"` el solape es 49px — la
> altura entera de la cabecera — y el hit-test cae en la tira. `app-shell`
> (F3.1) sigue siendo la pieza que falta; la página compone en flujo.
> - **Un defecto arreglado**: `Newsletter.Reason` medía **2,33:1** sobre el panel
> (la nota a su lado, 8,5:1) — la frase que explica por qué la acción está
> bloqueada no se podía leer. Ahora toma la tinta del panel: **8,83:1**.
> - **Dos gaps nuevos**: los DOS landmarks `banner` (el canon estampa el `role`
> después de los rest props) y **`feature-split` sin parte `.Header`**, el único
> block de sección que no la tiene.
> - **El catálogo aprendió a publicar páginas**: `kind: 'page'` en `BlockEntry`, y
> el guard lo salta en las dos direcciones (self-test propio, probado por
> mutación).
- **Ruta**: `web/routes/blocks/landing/` con el patrón de las previews
(`+layout@.svelte` que resetea: la página se ve A SANGRE, sin marco de

@ -257,8 +257,14 @@ export function scanReadme(rel: string, src: string): Violation[] {
/** slug → shipped, read from the demo catalog. Pure. */
export function parseCatalog(src: string): Map<string, boolean> {
const out = new Map<string, boolean>();
for (const match of src.matchAll(/\{\s*slug:\s*'([^']+)'[^}]*?shipped:\s*(true|false)\s*\}/g)) {
out.set(match[1], match[2] === 'true');
for (const match of src.matchAll(
/\{\s*slug:\s*'([^']+)'([^}]*?)shipped:\s*(true|false)([^}]*)\}/g
)) {
// `kind: 'page'` is a COMPOSED page, not a block: it has a route and no
// `src/uix/blocks/{slug}`, so it is not this cross-check's business in
// either direction. Everything without the marker is a block.
if (/kind:\s*'page'/.test(match[2] + match[4])) continue;
out.set(match[1], match[3] === 'true');
}
return out;
}
@ -487,6 +493,16 @@ function selfTest(): string[] {
failures.push(`catalog parse: expected hero=true kanban=false, got ${[...catalog]}`);
if (parseCatalog("[{ label: 'Hero', shipped: true }]").size !== 0)
failures.push('catalog parse: an entry without a slug must not become a row');
// A composed page has a route and NO block, so it must not reach the
// cross-check in either direction — and a block must still parse when other
// fields follow `shipped`, which is the shape the marker introduced.
if (parseCatalog("[{ slug: 'landing', label: 'L', shipped: true, kind: 'page' }]").size !== 0)
failures.push('catalog parse: a kind: page entry must not become a block row');
if (
parseCatalog("[{ slug: 'hero', label: 'H', shipped: true, kind: 'block' }]").get('hero') !==
true
)
failures.push('catalog parse: an explicit kind: block entry must still parse');
expectRules(
'block without a catalog entry flagged',

@ -13,6 +13,14 @@ export interface NewsletterContext {
readonly reasonId: string;
/** Resolve an idlangref through the active translator. */
t(ref: string): string;
/**
* Whether the section is wearing the solid panel. The parts that live INSIDE
* it need it to pick their ink: on a saturated canvas the muted content ink
* is barely there. Measured on the composed page — the reason line read
* 2.33:1 against the panel while the note beside it read 8.5:1, because the
* note takes the panel's contrast ink and the reason did not.
*/
readonly panel: boolean;
}
const KEY = Symbol('uix.newsletter');

@ -26,7 +26,12 @@
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');
//
// ON the panel neither ink survives: `muted` measured 2.33:1 on the composed
// page and `affirm` is a green on a saturated canvas. There the sentence takes
// the panel's own contrast ink — the rule the block already applied to its note
// («quieter by SIZE, not by ink»), which this part was not following.
const color = $derived(newsletter?.panel ? 'on-solid' : state === 'sent' ? 'affirm' : 'muted');
let announced = '';
$effect(() => {

@ -90,7 +90,10 @@
return state;
},
reasonId,
t: (ref: string) => eidos.langs.t(ref)
t: (ref: string) => eidos.langs.t(ref),
get panel() {
return panel;
}
});
// On the panel the ink is the contrast one the palette guarantees for that
// canvas; off the panel the normal content inks read.

@ -8,6 +8,14 @@ export interface BlockEntry {
label: string;
/** A live demo exists at `/blocks/{slug}`. */
shipped: boolean;
/**
* What lives behind the slug. `block` (the default) means a block of the tier
* and `npm run blocks:check` demands one at `src/uix/blocks/{slug}`; `page` is
* a COMPOSED page — a route with no block behind it, which the rail publishes
* all the same because it is the artefact that proves the blocks assemble.
* @default 'block'
*/
kind?: 'block' | 'page';
}
export interface BlockGroup {
@ -16,6 +24,13 @@ export interface BlockGroup {
}
export const BLOCK_CATALOG: BlockGroup[] = [
{
// The composed page goes FIRST because it is the one artefact that proves
// what a block-by-block gallery cannot: that they assemble. Every reference
// catalog ships one for the same reason.
title: 'Compuesto',
items: [{ slug: 'landing', label: 'Landing (14 blocks)', shipped: true, kind: 'page' }]
},
{
title: 'Site',
items: [

@ -0,0 +1,35 @@
<script lang="ts">
/**
* The composed page is its OWN document — `@` resets the layout, so the demo
* section's topbar and rail are NOT here. That is the point: a landing judged
* inside a demo frame is a landing judged against the wrong viewport, and the
* two things this page exists to measure (a pinned header, an affixed strip)
* both anchor to the viewport of their own document.
*
* The axes come from the URL, same as the block previews.
*/
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,18 @@
<script lang="ts">
/**
* The composed landing — F2b's closing gate. Full-bleed, no demo frame.
*
* `?banner=affix` re-stages A-95 (the affixed strip against the pinned header)
* so the defect can be MEASURED here rather than cited.
*/
import { page } from '$app/state';
import LandingSite from './LandingSite.svelte';
const bannerAffix = $derived(page.url.searchParams.get('banner') === 'affix');
</script>
<svelte:head>
<title>Acme — analítica de producto</title>
</svelte:head>
<LandingSite {bannerAffix} />

@ -0,0 +1,760 @@
<script lang="ts">
/**
* The composed page — F2b's closing gate.
*
* The tier's claim is that its blocks assemble without friction, and that
* claim is **indemostrable block by block**: the heading outline only exists
* ACROSS sections, landmarks only collide when there are two, vertical rhythm
* only has a seam when something sits underneath. The 16px defect of
* `content-section` appeared the first time another section went below it, and
* A-95 (the affixed strip covering the pinned header) is a defect that only
* exists crossed.
*
* So this is not a parade of blocks: it is a believable landing for one
* product, with the content the demos already ship — same company, same
* customers, same prices — and the 14 blocks a real landing carries. The four
* that do not fit naturally (team · contact · content-section · article-grid)
* are registered as NOT composed in the page's README; a second composed page
* is a gap with a trigger, not this page's scope.
*
* The heading policy, which is the thing being demonstrated: ONE `h1` (the
* hero) and one `h2` per section. Where a block owns its title it takes
* `level`; where it does not, the app passes its own `Heading`.
*/
import { SiteBanner } from '$blocks/banner';
import { SiteHeader } from '$blocks/site-header';
import { Hero } from '$blocks/hero';
import { LogoCloud } from '$blocks/logo-cloud';
import { FeatureGrid } from '$blocks/feature-grid';
import { FeatureSplit } from '$blocks/feature-split';
import { StatsBand } from '$blocks/stats-band';
import { Testimonials } from '$blocks/testimonials';
import { Pricing } from '$blocks/pricing';
import { Faq } from '$blocks/faq';
import { Cta } from '$blocks/cta';
import { Newsletter } from '$blocks/newsletter';
import { SiteFooter } from '$blocks/site-footer';
import { CookieConsent, type CookieConsentValue } from '$blocks/cookie-consent';
import { NavigationMenu } from '$uix/eidos/components/navigation-menu';
import { Drawer } from '$uix/eidos/components/drawer';
import { Stack } from '$uix/eidos/components/stack';
import { Group } from '$uix/eidos/components/group';
import { Wrap } from '$uix/eidos/components/wrap';
import { Flex } from '$uix/eidos/components/flex';
import { Surface } from '$uix/eidos/components/surface';
import { Mockup } from '$uix/eidos/components/mockup';
import { AspectRatio } from '$uix/eidos/components/aspect-ratio';
import { Heading } from '$uix/eidos/components/heading';
import { Text } from '$uix/eidos/components/text';
import { Link } from '$uix/eidos/components/link';
import { Button } from '$uix/eidos/components/button';
import { IconButton } from '$uix/eidos/components/icon-button';
import { Badge } from '$uix/eidos/components/badge';
import { Avatar } from '$uix/eidos/components/avatar';
import { Separator } from '$uix/eidos/components/separator';
import { Callout } from '$uix/eidos/components/callout';
import { Field } from '$uix/eidos/components/field';
import * as Icon from '$uix/eidos/components/icon';
import { getActiveUix } from '$active-uix';
import { createForm, type FormIssue } from '$libs/forms';
import { object, string, pipe, email, meta } from '$sium/core';
let {
/**
* Re-confirmation vehicle for **A-95**, off by default. With `affix="top"`
* the strip pins to the same edge as the header and covers it — the
* defect's own scenario, kept reachable so this page can MEASURE it instead
* of citing it. The page's real composition is the strip in flow: it
* scrolls away and the header pins to a clear edge.
*/
bannerAffix = false
}: { bannerAffix?: boolean } = $props();
const uix = getActiveUix();
let noticeOpen = $state(true);
let mobileOpen = $state(false);
let period = $state<'monthly' | 'annual'>('monthly');
let consent = $state<CookieConsentValue | undefined>(undefined);
let cookiePanelOpen = $state(false);
// ---------------------------------------------------------------- content
const product = [
{
href: '#analitica',
title: 'Analítica',
blurb: 'Cohortes, embudos y retención sobre tus eventos.'
},
{
href: '#analitica',
title: 'Automatizaciones',
blurb: 'Reglas que reaccionan a lo que hacen tus clientes.'
},
{ href: '#novedades', title: 'Novedades', blurb: 'Lo que cambió cada semana.' }
];
const solutions = [
{ href: '#precios', title: 'Startups', blurb: 'Del primer cliente a los mil.' },
{ href: '#precios', title: 'Enterprise', blurb: 'SSO, auditoría y residencia de datos.' }
];
const marks = [
{ name: 'Northwind', w: 128 },
{ name: 'Umbra', w: 86 },
{ name: 'Kessel', w: 104 },
{ name: 'Volta', w: 72 },
{ name: 'Marisol', w: 118 },
{ name: 'Cobalt', w: 96 }
];
const features = [
{
icon: Icon.Gauge,
title: 'Sin muestreo',
text: 'Todos los eventos, no una fracción: los números cuadran con tu base de datos.'
},
{
icon: Icon.Users,
title: 'Cohortes al día',
text: 'Qué hicieron, cuándo dejaron de hacerlo y qué cambió la semana en que lanzaste.'
},
{
icon: Icon.Workflow,
title: 'Automatizaciones',
text: 'Reglas que reaccionan al flujo de eventos: avisar, mover, abrir un ticket.'
},
{
icon: Icon.BellRing,
title: 'Alertas que importan',
text: 'Un umbral sobre cualquier métrica y el canal donde de verdad lo lees.'
},
{
icon: Icon.ShieldCheck,
title: 'SSO y auditoría',
text: 'SAML, SCIM y un registro de quién vio qué, listo para tu equipo de seguridad.'
},
{
icon: Icon.Clock,
title: 'Sin reprocesar',
text: 'Consulta cualquier ventana temporal al instante: el motor mantiene los agregados.'
}
];
const chart = ['3rem', '5rem', '3.6rem', '6.4rem', '4.4rem', '7.4rem', '5.6rem'];
const stats = [
{ count: 12500, display: '12.500', label: 'Eventos por segundo' },
{ count: 340, display: '340', label: 'Equipos de producto' },
{ count: undefined, display: '99,98 %', label: 'Disponibilidad medida' },
{ count: 48, display: '48', label: 'Integraciones nativas' }
];
const quotes = [
{
quote: 'Migramos en una tarde y los números por fin cuadran con nuestra base de datos.',
name: 'Ana Ruiz',
role: 'CTO en Northwind',
initials: 'AR',
color: 'indigo'
},
{
quote: 'Las cohortes que antes tardaban un día ahora salen al instante, sin reprocesar.',
name: 'Marco Vidal',
role: 'Head of Growth en Umbra',
initials: 'MV',
color: 'teal'
},
{
quote: 'Configuramos una alerta y cazamos una fuga de conversión el mismo día.',
name: 'Lucía Peña',
role: 'Product Manager en Kessel',
initials: 'LP',
color: 'amber'
}
];
type Plan = {
name: string;
desc: string;
mo: string;
yr: string;
cta: string;
featured: boolean;
features: string[];
};
const plans: Plan[] = [
{
name: 'Starter',
desc: 'Para empezar a medir',
mo: '0 €',
yr: '0 €',
cta: 'Empezar gratis',
featured: false,
features: ['Hasta 10k eventos/mes', '1 proyecto', 'Retención 7 días']
},
{
name: 'Pro',
desc: 'Para equipos de producto',
mo: '29 €',
yr: '23 €',
cta: 'Probar 14 días',
featured: true,
features: [
'Eventos ilimitados',
'Proyectos ilimitados',
'Cohortes, embudos y retención',
'Alertas y automatizaciones',
'Retención de 12 meses'
]
},
{
name: 'Enterprise',
desc: 'SSO, auditoría y SLA',
mo: '99 €',
yr: '79 €',
cta: 'Hablar con ventas',
featured: false,
features: [
'Todo lo de Pro',
'SSO/SAML + SCIM',
'Residencia de datos',
'SLA 99,9 %',
'Soporte dedicado'
]
}
];
const faqs = [
{
q: '¿Cómo empiezo a medir?',
a: 'Instalas un único SDK, envías tu primer evento y ya lo ves en el panel. Sin configurar pipelines ni esperar a un procesamiento nocturno.'
},
{
q: '¿Cobráis por asiento?',
a: 'No. El plan se elige por volumen de eventos, no por número de personas, así que puedes invitar a todo el equipo sin que suba la factura.'
},
{
q: '¿Se muestrean los eventos?',
a: 'Nunca. Guardamos todos los eventos, así que los números cuadran con tu base de datos hasta el último registro.'
},
{
q: '¿Puedo consultar periodos antiguos sin reprocesar?',
a: 'Sí. El motor mantiene los agregados al día, así que cualquier ventana temporal responde al instante.'
},
{
q: '¿Ofrecéis SSO y auditoría?',
a: 'En el plan Enterprise: SAML, SCIM, registro de auditoría y residencia de datos, listos para tu equipo de seguridad.'
}
];
const footerGroups = [
{ title: 'Producto', links: ['Analítica', 'Automatizaciones', 'Integraciones', 'Novedades'] },
{ title: 'Recursos', links: ['Documentación', 'Guías', 'Estado del servicio'] },
{ title: 'Empresa', links: ['Sobre nosotros', 'Empleo', 'Contacto'] }
];
// ------------------------------------------------------------------ form
// The APP owns the schema and the handler — the block's form pass-through.
const schema = object({
email: pipe(string(), email(), meta({ label: 'Correo electrónico' }))
});
let subscribed = $state<string | null>(null);
const form = createForm({
schema,
defaults: { email: '' },
validationBehaviour: 'progressive',
onValidSubmit: (values) => {
subscribed = (values as { email: string }).email;
}
});
const emailError = $derived.by(() => {
const issue = (form.issues.email as FormIssue[] | undefined)?.[0];
if (issue) return uix.langs.t(issue.message, issue.params);
const raw = form.errors.email?.[0];
return raw ? uix.langs.t(raw) : undefined;
});
</script>
<!--
TWO banner landmarks, and only one of them is avoidable. The canon `Banner`
stamps `role="banner"` AFTER its rest props, so neither the block nor this page
can take it off, and a page that also has a `<header>` — every page — ends up
with two. Naming BOTH is the whole of what an app can do, and this page does it
(`aria-label` here, `aria-label="Acme"` on the header). Measured: without the
name, one of the two banners is anonymous in the accessibility tree.
The strip in FLOW, above the header: it scrolls away and the header pins to a
clear edge. `affix="top"` is A-95's scenario — both compete for the same edge
and the strip wins — and it stays reachable through `bannerAffix` so this page
can re-measure the defect rather than cite it.
-->
{#if noticeOpen}
<SiteBanner
intent="primary"
variant="solid"
affix={bannerAffix ? 'top' : undefined}
onDismiss={() => (noticeOpen = false)}
aria-label="Aviso del producto"
>
{#snippet badge()}<Badge size="sm" variant="soft">Nuevo</Badge>{/snippet}
Las cohortes en directo ya están disponibles en todos los planes.
{#snippet action()}<Link href="#novedades" color="currentColor">Ver qué cambia</Link>{/snippet}
</SiteBanner>
{/if}
<SiteHeader
bind:mobileOpen
aria-label="Acme"
style="background: var(--color-surface-default); border-block-end: var(--border-width) solid var(--color-border-subtle);"
>
{#snippet brand()}
<Link href="#top" variant="plain" underline="none">
<Group align="center" gap={2} justify="start">
<Icon.Boxes size="sm" />
<Text weight="semibold">Acme</Text>
</Group>
</Link>
{/snippet}
{#snippet nav()}
<NavigationMenu>
<NavigationMenu.List>
<NavigationMenu.Item value="product">
<NavigationMenu.Trigger>Producto</NavigationMenu.Trigger>
<NavigationMenu.Content>
<Stack gap={3} style="inline-size: 20rem;">
{#each product as entry (entry.title)}
<Stack gap={1}>
<Link href={entry.href}>{entry.title}</Link>
<Text size="sm" color="muted">{entry.blurb}</Text>
</Stack>
{/each}
</Stack>
</NavigationMenu.Content>
</NavigationMenu.Item>
<NavigationMenu.Item value="solutions">
<NavigationMenu.Trigger>Soluciones</NavigationMenu.Trigger>
<NavigationMenu.Content>
<Stack gap={3} style="inline-size: 20rem;">
{#each solutions as entry (entry.title)}
<Stack gap={1}>
<Link href={entry.href}>{entry.title}</Link>
<Text size="sm" color="muted">{entry.blurb}</Text>
</Stack>
{/each}
</Stack>
</NavigationMenu.Content>
</NavigationMenu.Item>
<NavigationMenu.Item value="pricing">
<NavigationMenu.Link href="#precios">Precios</NavigationMenu.Link>
</NavigationMenu.Item>
<NavigationMenu.Item value="faq">
<NavigationMenu.Link href="#preguntas">Preguntas</NavigationMenu.Link>
</NavigationMenu.Item>
</NavigationMenu.List>
</NavigationMenu>
{/snippet}
{#snippet actions()}
<Link href="#precios" variant="subtle">Entrar</Link>
<Button variant="solid" size="sm">
{#snippet endIcon()}<Icon.ArrowRight size="xs" />{/snippet}
{#snippet child({ props, content })}
<a href="#precios" {...props}>{@render content()}</a>
{/snippet}
Empezar gratis
</Button>
{/snippet}
{#snippet mobileTrigger()}
<Drawer.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="sm" aria-label="Abrir navegación">
<Icon.Menu size="sm" />
</Button>
{/snippet}
</Drawer.Trigger>
{/snippet}
{#snippet mobileNav()}
<Stack gap={3}>
<Text size="sm" weight="medium" color="muted">Producto</Text>
{#each product as entry (entry.title)}
<Link href={entry.href} onclick={() => (mobileOpen = false)}>{entry.title}</Link>
{/each}
<Separator />
<Text size="sm" weight="medium" color="muted">Soluciones</Text>
{#each solutions as entry (entry.title)}
<Link href={entry.href} onclick={() => (mobileOpen = false)}>{entry.title}</Link>
{/each}
<Separator />
<Button variant="solid" size="sm" block onclick={() => (mobileOpen = false)}>
{#snippet child({ props, content })}
<a href="#precios" {...props}>{@render content()}</a>
{/snippet}
Empezar gratis
</Button>
</Stack>
{/snippet}
</SiteHeader>
<!--
`<main>` is the APP's, not any block's: the tier ships sections, and only the
page knows which of them is the main content. Without it the sections between
the header and the footer belong to no landmark at all.
-->
<main id="top">
<Hero layout="split" decor="glow">
{#snippet eyebrow()}<Badge size="sm" variant="soft">Beta pública</Badge>{/snippet}
{#snippet title()}Analítica de producto sin sorpresas en la factura{/snippet}
{#snippet description()}
Eventos crudos, cohortes al día y automatizaciones que reaccionan solas. Sin muestreo, sin
reprocesar y sin un contrato que crece con cada pico de tráfico.
{/snippet}
{#snippet actions()}
<Button variant="solid" size="lg">
{#snippet endIcon()}<Icon.ArrowRight size="xs" />{/snippet}
{#snippet child({ props, content })}
<a href="#precios" {...props}>{@render content()}</a>
{/snippet}
Empezar gratis
</Button>
<Link href="#analitica" variant="subtle">Ver cómo funciona</Link>
{/snippet}
{#snippet media()}
<Mockup chrome="browser" url="acme.app/cohortes">
<AspectRatio ratio={16 / 10}>
<Surface color="primary" variant="soft" padding={6} height="100%">
<Stack gap={4} height="100%">
<Group gap={3} align="center">
<Icon.Activity size="sm" />
<Text size="sm" weight="medium">Cohorte de marzo</Text>
</Group>
<Flex align="end" gap={3} grow="1">
{#each chart as height, i (i)}
<Surface color="primary" variant="solid" width="100%" {height} rounded />
{/each}
</Flex>
</Stack>
</Surface>
</AspectRatio>
</Mockup>
{/snippet}
</Hero>
<LogoCloud>
{#snippet eyebrow()}
<Text size="sm" color="muted">Equipos de producto que ya no muestrean</Text>
{/snippet}
<LogoCloud.Items>
{#each marks as mark (mark.name)}
<LogoCloud.Item>
<svg
viewBox="0 0 {mark.w} 24"
style="height: 100%; width: auto;"
role="img"
aria-label={mark.name}
>
<title>{mark.name}</title>
<text x="0" y="18" font-size="18" font-weight="700" fill="currentColor">
{mark.name}
</text>
</svg>
</LogoCloud.Item>
{/each}
</LogoCloud.Items>
</LogoCloud>
<FeatureGrid id="analitica">
<FeatureGrid.Header>
<Heading level={2} align="center">Todo lo que necesitas para entender tu producto</Heading>
<Text size="lg" color="muted" align="center">
Sin pipelines que mantener y sin esperar al procesamiento de la noche.
</Text>
</FeatureGrid.Header>
<FeatureGrid.Items minChildWidth="17rem">
{#each features as feature (feature.title)}
{@const Glyph = feature.icon}
<FeatureGrid.Item>
<FeatureGrid.ItemIcon><Glyph size="sm" /></FeatureGrid.ItemIcon>
<FeatureGrid.ItemTitle>{feature.title}</FeatureGrid.ItemTitle>
<FeatureGrid.ItemText>{feature.text}</FeatureGrid.ItemText>
</FeatureGrid.Item>
{/each}
</FeatureGrid.Items>
</FeatureGrid>
<!--
`feature-split` is the one section block with no `.Header` part, so the
section heading is hand-composed as its first child and the rows drop to
`level={3}`. Left at their default the three rows emit three sibling `h2`
and the section has no name — recorded as a gap in this page's README.
-->
<FeatureSplit>
<Stack gap={3} align="center">
<Heading level={2} align="center">Del evento crudo a la decisión</Heading>
<Text size="lg" color="muted" align="center">
Dos piezas que trabajan sobre el mismo flujo, sin copiar datos de una a otra.
</Text>
</Stack>
<FeatureSplit.Row>
{#snippet media()}
<Mockup chrome="browser" url="acme.app/cohortes">
<AspectRatio ratio={4 / 3}>
<Surface color="teal" variant="soft" padding={6} height="100%">
<Flex align="end" gap={3} height="100%">
{#each chart as height, i (i)}
<Surface color="teal" variant="solid" width="100%" {height} rounded />
{/each}
</Flex>
</Surface>
</AspectRatio>
</Mockup>
{/snippet}
<FeatureSplit.Eyebrow>Analítica</FeatureSplit.Eyebrow>
<FeatureSplit.Title level={3}>Cohortes al día, sin configurar pipelines</FeatureSplit.Title>
<FeatureSplit.Text>
Los eventos llegan crudos y salen en cohortes: qué hicieron, cuándo dejaron de hacerlo y qué
cambió la semana en que lanzaste.
</FeatureSplit.Text>
<FeatureSplit.Features>
<FeatureSplit.Feature>Sin muestreo</FeatureSplit.Feature>
<FeatureSplit.Feature>Ventanas sin reprocesar</FeatureSplit.Feature>
</FeatureSplit.Features>
</FeatureSplit.Row>
<FeatureSplit.Row reversed>
{#snippet media()}
<!-- The phone's screen takes a FIXED height, never an `AspectRatio`: in a
split column ~560px wide a 9/16 ratio makes a phone 995px tall — taller
than the viewport, with an empty screen. Measured on this page. -->
<Mockup chrome="phone">
<Surface color="amber" variant="soft" padding={5} height="22rem">
<Stack gap={4}>
<Text size="sm" weight="semibold">09:41</Text>
<Stack gap={1}>
<Text size="sm" weight="medium">Conversión −18 %</Text>
<Text size="sm" color="muted">Umbral superado a las 09:14</Text>
</Stack>
<Surface color="amber" variant="solid" rounded height="2.5rem" />
<Surface color="amber" variant="soft" rounded height="2.5rem" />
</Stack>
</Surface>
</Mockup>
{/snippet}
<FeatureSplit.Eyebrow>En el bolsillo</FeatureSplit.Eyebrow>
<FeatureSplit.Title level={3}>Las alertas que importan, donde las lees</FeatureSplit.Title>
<FeatureSplit.Text>
Un umbral sobre cualquier métrica y el canal —o el móvil— donde de verdad lo ves. Sin abrir
el panel.
</FeatureSplit.Text>
<FeatureSplit.Features>
<FeatureSplit.Feature>Umbral por métrica</FeatureSplit.Feature>
<FeatureSplit.Feature>Al canal que ya usas</FeatureSplit.Feature>
</FeatureSplit.Features>
</FeatureSplit.Row>
</FeatureSplit>
<StatsBand>
{#each stats as stat (stat.label)}
<StatsBand.Stat>
{#if stat.count === undefined}
<StatsBand.Value>{stat.display}</StatsBand.Value>
{:else}
<StatsBand.Value count={stat.count} />
{/if}
<StatsBand.Label>{stat.label}</StatsBand.Label>
</StatsBand.Stat>
{/each}
</StatsBand>
<Testimonials>
<Testimonials.Header>
<Heading level={2} align="center">Lo que dicen los equipos que ya migraron</Heading>
</Testimonials.Header>
<Testimonials.Items>
{#each quotes as entry (entry.name)}
<Testimonials.Item>
<Testimonials.Quote>«{entry.quote}»</Testimonials.Quote>
<Testimonials.Author>
{#snippet avatar()}
<Avatar color={entry.color}>
<Avatar.Fallback>{entry.initials}</Avatar.Fallback>
</Avatar>
{/snippet}
<Testimonials.AuthorName>{entry.name}</Testimonials.AuthorName>
<Testimonials.AuthorRole>{entry.role}</Testimonials.AuthorRole>
</Testimonials.Author>
</Testimonials.Item>
{/each}
</Testimonials.Items>
</Testimonials>
<Pricing id="precios" bind:period>
<Pricing.Header>
<Heading level={2} align="center">Precios que siguen a tus eventos, no a tu plantilla</Heading
>
<Text size="lg" color="muted" align="center">
Sin coste por asiento: invita a todo el equipo.
</Text>
</Pricing.Header>
<Pricing.Switch
monthlyLabel="Mensual"
annualLabel="Anual (−20 %)"
aria-label="Periodo de facturación"
/>
<Pricing.Plans>
{#each plans as plan (plan.name)}
<Pricing.Plan featured={plan.featured}>
{#snippet badge()}
{#if plan.featured}<Badge color="primary" size="sm">Más popular</Badge>{/if}
{/snippet}
<Pricing.PlanName>{plan.name}</Pricing.PlanName>
<Pricing.PlanDescription>{plan.desc}</Pricing.PlanDescription>
<Pricing.PlanPrice>
{#snippet monthly()}{plan.mo}{/snippet}
{#snippet annual()}{plan.yr}{/snippet}
{#snippet suffix()}/mes{/snippet}
</Pricing.PlanPrice>
<Pricing.PlanFeatures>
{#each plan.features as feature (feature)}
<Pricing.PlanFeature>{feature}</Pricing.PlanFeature>
{/each}
</Pricing.PlanFeatures>
<Pricing.PlanAction>
<Button block variant={plan.featured ? 'solid' : 'surface'}>{plan.cta}</Button>
</Pricing.PlanAction>
</Pricing.Plan>
{/each}
</Pricing.Plans>
</Pricing>
<Faq id="preguntas">
<Faq.Header>
<Heading level={2} align="center">Preguntas frecuentes</Heading>
</Faq.Header>
<Faq.List type="single" collapsible>
{#each faqs as entry (entry.q)}
<Faq.Item>
{#snippet question()}{entry.q}{/snippet}
{entry.a}
</Faq.Item>
{/each}
</Faq.List>
</Faq>
<Cta id="novedades">
{#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()}
<Button variant="solid" size="lg" intent="fulfill">
{#snippet endIcon()}<Icon.ArrowRight size="xs" />{/snippet}
{#snippet child({ props, content })}
<a href="#precios" {...props}>{@render content()}</a>
{/snippet}
Empezar gratis
</Button>
{/snippet}
</Cta>
<Newsletter {form} {schema} sent={subscribed !== null}>
{#snippet eyebrow()}<Badge size="sm" variant="soft" color="primary">Cada martes</Badge
>{/snippet}
{#snippet title()}Una carta cada martes{/snippet}
{#snippet description()}
Cambios del producto, decisiones de diseño y lo que aprendimos rompiéndolo. Sin relleno y sin
promociones.
{/snippet}
{#snippet field()}
<Field name="email" label="Correo electrónico" required floatingLabel size="lg">
<Field.Label>Correo electrónico</Field.Label>
<Field.Control>
<Field.Input type="email" placeholder=" " bind:value={form.values.email} />
</Field.Control>
{#if emailError}
<Field.ErrorText>{emailError}</Field.ErrorText>
{/if}
</Field>
{/snippet}
{#snippet submit()}<Newsletter.Submit size="lg" />{/snippet}
{#snippet note()}Nada de spam. Te das de baja en un clic.{/snippet}
{#snippet children()}
<Newsletter.Reason />
{#if subscribed}
<Callout intent="affirm">
<Callout.Icon />
<Callout.Title>Confirmado</Callout.Title>
<Callout.Content>
Te hemos enviado un correo a <strong>{subscribed}</strong> para confirmar.
</Callout.Content>
</Callout>
{/if}
{/snippet}
</Newsletter>
</main>
{#snippet footerBrand()}
<Stack gap={3} align="start">
<Group align="center" gap={2} justify="start">
<Icon.Boxes size="sm" />
<Text weight="semibold">Acme</Text>
</Group>
<Text size="sm" color="muted">Analítica de producto sin muestreo.</Text>
</Stack>
{/snippet}
{#snippet footerLegal()}
<Wrap gap={4} align="center">
<Text size="sm" color="muted">© 2026 Acme. Todos los derechos reservados.</Text>
<Link href="#top" variant="subtle" size="sm" color="var(--color-content-muted)">Privacidad</Link
>
<Link href="#top" variant="subtle" size="sm" color="var(--color-content-muted)">Términos</Link>
</Wrap>
{/snippet}
{#snippet footerSocial()}
<Group gap={1} align="center" justify="end">
<IconButton aria-label="Boletín RSS" variant="ghost" size="sm">
<Icon.Rss size="sm" />
</IconButton>
<IconButton aria-label="Foro de la comunidad" variant="ghost" size="sm">
<Icon.MessageCircle size="sm" />
</IconButton>
</Group>
{/snippet}
{#snippet footerExtra()}
<!-- The «cookie settings» control every footer owes, and the one that proves
withdrawing consent is as easy as giving it: it reopens the same panel. -->
<Button variant="ghost" size="sm" onclick={() => (cookiePanelOpen = true)}>
Preferencias de cookies
</Button>
{/snippet}
<SiteFooter brand={footerBrand} legal={footerLegal} social={footerSocial} extra={footerExtra}>
{#each footerGroups as group (group.title)}
<SiteFooter.Column>
<SiteFooter.ColumnTitle>{group.title}</SiteFooter.ColumnTitle>
{#each group.links as label (label)}
<Link href="#top" variant="subtle" size="sm" color="var(--color-content-secondary)">
{label}
</Link>
{/each}
</SiteFooter.Column>
{/each}
</SiteFooter>
<CookieConsent {consent} bind:open={cookiePanelOpen} onDecision={(next) => (consent = next)}>
Usamos cookies necesarias para que el sitio funcione y, con tu permiso, otras para recordar tus
preferencias y medir el uso.
{#snippet policy()}
<Link href="#top">Leer la política de cookies</Link>
{/snippet}
</CookieConsent>

@ -0,0 +1,84 @@
# La página compuesta
`/blocks/landing` — una landing verosímil de un producto con **14 de los 18
blocks** del tier, a sangre y sin marco de demo.
Es la puerta de cierre de F2b y existe por una razón concreta: el claim del tier
es que sus blocks **ensamblan sin fricción**, y ese claim es indemostrable block
a block. El outline de encabezados sólo existe CRUZANDO secciones; dos landmarks
sólo chocan cuando hay dos; una costura vertical sólo aparece cuando algo se pone
debajo. El defecto de los 16px de `content-section` salió la primera vez que otra
sección fue debajo, y **A-95** sólo existe cruzado.
## Qué compone
`banner` · `site-header` · `hero` · `logo-cloud` · `feature-grid` ·
`feature-split` · `stats-band` · `testimonials` · `pricing` · `faq` · `cta` ·
`newsletter` · `site-footer` · `cookie-consent`.
**Los cuatro que NO entran**, y por qué — el criterio es la naturalidad, no la
cobertura:
| Block | Por qué no |
| ----------------- | ------------------------------------------------------------------------------------- |
| `team` | una landing de producto no presenta al equipo; vive en «sobre nosotros» |
| `contact` | la landing lleva a precios, no a un formulario largo; el contacto es su propia página |
| `content-section` | es prosa larga (legal, changelog): otro tipo de página |
| `article-grid` | la banda editorial pertenece al blog o a la home de contenidos |
Una **segunda página compuesta** (about/blog) los cubriría: es gap con
disparador, no alcance de ésta.
## Lo que verifica, con su medida
_Instrumento: Playwright headless (Chromium), 2026-08-18. El pane del navegador
no compone frames — con rAF e IntersectionObserver suspendidos, ni las cascadas
ni el foco se pueden medir allí._
| Qué | Medida | Estado |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| **Outline de encabezados** | 1 `h1` · 22 encabezados · **0 saltos de nivel** (`1233333323322333222333`) | ✅ |
| **Landmarks** | `banner` ×2 (**ambos nombrados**), `navigation` ×1, `main` ×1, `contentinfo` ×1, `region` ×6 (5 del FAQ + la barra de cookies) · 0 duplicados sin nombre | ✅ con un gap de canon abajo |
| **Ritmo vertical** | 0px de hueco entre secciones vecinas: el aire es SIEMPRE el `padding` de cada `Section` · costuras 112–192px según los `sectionSize` que se tocan | ✅ |
| **Motion sección a sección** | 5 portadoras `data-stagger`: 1 corre al cargar (está en el primer viewport), las otras 4 en `data-animation-pending` hasta que su sección entra — medido a 600/1200/2400/3600px | ✅ |
| **A-95** | en flujo: solape 0, cabecera fijada en `[0,49]`, hit-test dentro de la cabecera · con `?banner=affix`: solape **49px = la altura entera**, hit-test dentro de la tira | ⛔ **RE-CONFIRMADO** |
| **Sema en el formulario** | un `contact-activate` en el botón y **un solo** `commit-submit` en el `form` (familia `commit`, intent `fulfill`). Sin doble emisión | ✅ |
| **Teclado** | 70 tabulaciones sin trampa · a 375 el drawer abre con Enter, el foco entra, Escape cierra y **el foco vuelve al disparador** | ✅ |
| **Claro / oscuro / RTL** | sin desbordes horizontales en RTL (scrollWidth 1280 = innerWidth) · capturas en los tres | ✅ |
| **375 / 1280** | 9 capturas a 375, 8 a 1280 | ✅ |
### Cómo se mide una página con motion de viewport
`fullPage: true` **no sirve**: no hace scroll, así que toda cascada bajo el
pliegue se queda en opacidad 0 y la captura sale en blanco. La primera medida de
esta página dijo «la landing está vacía» y era el instrumento. Igual de falso es
saltar de una vez a `document.body.scrollHeight`: las secciones pasan de debajo a
encima del viewport en un frame y el observador nunca las ve entrar. Se recorre
en pasos y se captura por viewport.
## Lo que encontró
| Hallazgo | Dónde | Disposición |
| ------------------------------------------------------------------------------ | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`Newsletter.Reason` medía 2,33:1 sobre el panel** (la nota a su lado, 8,5:1) | block `newsletter` | **ARREGLADO aquí** — la razón toma la tinta del panel como el resto; 2,33 → **8,83:1**. Off-panel sigue `muted` |
| **DOS landmarks `banner`** | canon `Banner` | **gap de canon** — estampa `role="banner"` DESPUÉS de sus rest props; nombrar ambos es todo lo que puede la app |
| **A-95**: `affix="top"` tapa la cabecera fijada | canon `Affix` + tier | **re-confirmado** — la pieza que falta es `app-shell` (F3.1). La página compone en flujo, que es correcto |
| **`feature-split` no tiene parte `.Header`** | block `feature-split` | **gap** — es el único block de sección sin ella; aquí el encabezado va a mano como primer hijo y las filas bajan a `level={3}`. Sin eso emite tres `h2` hermanos y la sección no tiene nombre |
| Un `AspectRatio` 9/16 en una columna partida da un móvil de 995px | esta página | **arreglado aquí** — la pantalla del móvil lleva alto fijo, como ya hacía la demo del propio block |
## Cómo se ve
Las capturas del día viven en el scratchpad de la sesión (`e-1280-*`, `e-375-*`).
Para re-medir: `scripts` no; las sondas son de sesión y se re-escriben — lo que
persiste es esta tabla y su fecha.
## Notas de composición
- **`<main>` es del APP**, no de ningún block: el tier shippea secciones y sólo
la página sabe cuál es el contenido principal. Sin él, todo lo que hay entre la
cabecera y el pie no pertenece a ningún landmark.
- **La tira va en FLUJO**, encima de la cabecera: se va con el scroll y la
cabecera se fija contra un borde limpio. `?banner=affix` re-escenifica A-95 para
poder medirlo en vez de citarlo.
- **El pie reabre el panel de cookies** — el control que toda página debe, y la
prueba de que retirar el consentimiento es tan fácil como darlo.
Loading…
Cancel
Save

Powered by TurnKey Linux.