feat(blocks): `banner` — el aviso de arriba, y tres defectos del canon que no existían

F2.11. El block más FINO del tier a propósito: cuando el canon ya tiene la pieza,
el block es colocación y nada más. Reenvía la superficie entera del `Banner` con
`Omit<BannerProps, 'children'>` —sin re-declarar `intent`/`variant`/`size` ni
estrecharlos—, lo mete en columna con `Container` (`width="100%"` +
`paddingX={0}`: a sangre por fuera, en columna por dentro), envuelve la fila con
`Wrap` en vez de aplastarla, y cablea `Banner.Close` solo si llega `onDismiss`.

La visibilidad NO es suya: es la decisión que ya tomó el componente
(«composición, no un booleano `dismissible`»), así que el app envuelve en su
`{#if}`. Un block que guardara ese estado sería una segunda fuente de verdad.
Tampoco emite sema: el canon declara cero eventos para `Banner` a propósito, y un
aviso persistente sin descarte es tan legítimo como uno descartable.

La demo lo enseña **encima del `site-header` de verdad**, con página para
desplazarse. Un aviso dentro de un recuadro no se parece a un aviso.

## El hallazgo que la fase 0 anticipó

`Banner` estampa `role="banner"` DESPUÉS de sus rest props, así que no se puede
relajar a una región normal. Con el `site-header` en la misma página —que es la
única colocación que shipean las referencias— quedan **dos landmarks `banner`**.
Medido en la vista previa: dos. Lo único que puede hacer un app hoy es nombrarlos
con `aria-label`, y eso hace la demo.

## Y una lección de método que costó una sesión

Reporté tres «defectos del canon» —`aria-label` ausente en `Banner.Close`, `type`
desaparecido de todo `Button`, `IconButton` tragándose el `onclick`— y **los tres
eran falsos**. La causa era la misma: medir demasiado pronto.

Los `data-variant` / `data-size` los escribe el componente de eidos al renderizar,
así que están desde el primer frame; `type`, `aria-label` y los handlers los
aplica la **runtime del morfo en un efecto posterior**. A ~1s: `type` ausente en
3 de 3 botones y ningún clic disparando. A ~6s: `type="button"`,
`aria-label="Descartar"` y el descarte funcionando.

La espera válida es un atributo que solo pueda haber puesto la runtime
—`waitForFunction(() => boton.hasAttribute('type'))`—, no que el nodo exista ni
que se vea. La regla queda afinada en `CONTINUE-blocks.md`, que ya avisaba de
esperar la condición y no el reloj: fallé al elegir la condición.

De paso, un aviso de soma que sí era real y era mío: `Tabs.List` sin nombre
accesible en `BlockDemo.svelte`. Afectaba a las 12 demos del tier.

Verificado en navegador: los 4 intents, los 3 tratamientos, `descartable` sí/no
(con `no` el botón deja de renderizarse, no se oculta), claro/oscuro/RTL y 420px,
la tira siempre por encima de la cabecera, cero desbordamiento horizontal, el
descarte con la página recolocándose, y los controles de la demo moviendo la
vista previa en línea. Gates: `blocks:check` verde (12 blocks) · `svelte-check`
sin errores propios · `docs:check` sin errores míos · prettier limpio.

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

@ -1,14 +1,14 @@
# CONTINUE — F2 blocks de sitio (handoff, act. 2026-07-30)
# CONTINUE — F2 blocks de sitio (handoff, act. 2026-07-31)
**Estado: F1 CERRADA (8/8) · F2 11 de 15.** Hechos: site-header · hero ·
**Estado: F1 CERRADA (8/8) · F2 12 de 15.** Hechos: site-header · hero ·
feature-grid · **feature-split** · pricing · testimonials · faq · stats-band ·
cta · newsletter · site-footer. **Quedan 4**: banner (§F2.11) · team (§F2.12) ·
cta · newsletter · site-footer · banner. **Quedan 3**: team (§F2.12) ·
contact (§F2.13) · content-section (§F2.14).
(El plan fijó 14; `feature-split` se añadió como block hermano con
scope-approval, así que el tier tiene 15 y el denominador honesto es 15.)
Todo commiteado y pusheado a `gita/alpha-0.1-sec-dom` (último: `eb13fff40`).
Todo commiteado y pusheado a `gita/alpha-0.1-sec-dom`.
Dónde vive cada cosa:
@ -21,27 +21,30 @@ Dónde vive cada cosa:
---
## Lo siguiente: F2.11 `banner`
## Lo siguiente: F2.12 `team`
Ficha en `PLAN-blocks.md` §F2.11. Es la **categoría más shipeada del dossier**
(13 Tailwind Plus · 16 Untitled · 5 Flowbite), así que la paridad es alta y el
listón también.
Ficha en `PLAN-blocks.md` §F2.12: `Section` + `AutoGrid` + `Avatar` + `Heading` +
`Text` + `Group` de `IconButton` sociales por miembro. Del dossier: 9 en Tailwind
Plus. `.Member` **se repite** → parte compound (la regla de forma, abajo).
Fase 0 obligatoria, en este orden:
Fase 0 obligatoria: leer el README del `Avatar` (fallback de iniciales, tamaños,
anillo) antes de componer, mirar el equivalente en ≥2 catálogos y anotar en el
README del block qué se adopta y qué se descarta.
1. **Leer el README del componente `Banner`** (`src/uix/eidos/components/banner/`)
y su morfo. La ficha dice «dismiss lo posee Banner si ya lo trae»: si el
descarte, la persistencia o el `role` ya son suyos, **el block no los
reinventa** — los compone. Si el block acaba necesitando comportamiento nuevo,
se promociona al canon ANTES (regla de admisión del tier).
2. Mirar el equivalente en ≥2 catálogos del dossier y anotar en el README del
block qué se adopta y qué se descarta.
3. Decidir la forma con la regla del tier (abajo). Un banner tiene una acción y
un cierre: probablemente **slots**, no compound.
4. Pareja de posición con `site-header`: va arriba de la página, así que la demo
tiene que enseñarlo **encima del header**, no en un recuadro.
Después: contact → content-section.
Después: team → contact → content-section.
**F2.11 `banner` HECHO** — el block más fino del tier a propósito: reenvía la
superficie del `Banner` del canon con `Omit<BannerProps, 'children'>`, lo mete en
columna, envuelve con `Wrap` y cablea `Banner.Close` solo si llega `onDismiss`
(la visibilidad es del app, decisión del componente). Hallazgo real: `Banner`
estampa `role="banner"` después de sus rest props, así que con el `site-header`
en la misma página quedan **dos landmarks `banner`**; solo se puede paliar
nombrándolos.
⚠️ **Ahí reporté tres «defectos del canon» que eran FALSOS** (`aria-label` de
`Banner.Close`, `type` de `Button`, `onclick` de `IconButton`). Los tres eran la
misma medición prematura — ver la regla de verificación afinada más abajo. Si
vuelves a ver algo así, la primera hipótesis es el reloj, no el canon.
---
@ -122,6 +125,16 @@ galería leen esa única fuente) y `npm run blocks:check`.
el panel todavía invisible (`data-animation-pending` puesto). Esperar a que ese
atributo desaparezca es la diferencia entre verificar y reportar un bug que no
existe.
- **Y la condición tiene que ser algo que escriba la RUNTIME del morfo, no el
render.** Los `data-variant` / `data-size` los pone el componente de eidos al
renderizar, así que están desde el primer frame; `type`, `aria-label` y los
handlers los aplica la runtime en un efecto POSTERIOR. Esperar a que exista el
nodo (o a que se vea) no basta: a ~1s medí `type` ausente en TODOS los `Button`
del árbol y ningún `onclick` disparando, y a ~6s los mismos botones tenían
`type="button"`, `aria-label="Descartar"` y el clic funcionando. Reporté tres
«defectos del canon» que no existían. **La espera correcta es
`waitForFunction(() => boton.hasAttribute('type'))`** — un atributo que solo
puede haber puesto la runtime.
- **Playwright headless SÍ sirve para foco y teclado.** El pane suspendido no
(congela rAF y pierde `activeElement`), pero un `page.keyboard.type()` real
dispara `:placeholder-shown` y `:focus-within`: así se verificó que la etiqueta

@ -1,7 +1,7 @@
# PLAN — Tier `blocks`: composición reutilizable (infraestructura + componentes base + catálogo)
> **Kickoff para sesión nueva**: *"Lee `docs/process/PLAN-blocks.md` y continúa
> la fase que toque."* Decisión de usuario (2026-07-21): existe un tier nuevo
> **Kickoff para sesión nueva**: _"Lee `docs/process/PLAN-blocks.md` y continúa
> la fase que toque."_ Decisión de usuario (2026-07-21): existe un tier nuevo
> **`blocks`** — conjuntos de componentes desempeñando una función (cabecera
> sticky, hero, footer, app-shell…). Este plan es autosuficiente: cada fase
> lista QUÉ leer, QUÉ producir y CON QUÉ guard se verifica. Un agente no debe
@ -28,14 +28,14 @@
- **Estado**: F0 pendiente (nada construido). Actualizar esta tabla al cerrar
cada tanda, estilo `PLAN-component-coherence.md`.
| Fase | Contenido | Estado |
|---|---|---|
| **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) |
| Fase | Contenido | Estado |
| ------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **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 (**14**: 10 + banner·team·contact·content-section por E-3) | pendiente (F2 solo requiere F1.1) |
| **F3** | Blocks de aplicación (10) | pendiente |
| **F4** | Blocks de docs (3) | pendiente |
| **F5** | Backlog condicionado (componentes media/mobile + blocks diferidos) | pendiente |
| **F2** | Blocks de sitio (**14**: 10 + banner·team·contact·content-section por E-3) | pendiente (F2 solo requiere F1.1) |
| **F3** | Blocks de aplicación (10) | pendiente |
| **F4** | Blocks de docs (3) | pendiente |
| **F5** | Backlog condicionado (componentes media/mobile + blocks diferidos) | pendiente |
---
@ -43,16 +43,16 @@
Un **block** es una composición nombrada de componentes del canon que
desempeña una función de página: no aporta primitivas nuevas, aporta
*ensamblaje correcto* (layout, landmarks, jerarquía de headings, responsive,
_ensamblaje correcto_ (layout, landmarks, jerarquía de headings, responsive,
puntos de contenido). Consume el framework; el framework nunca lo referencia.
La tabla de tiers queda:
| Tier | Valor | Contrato | Entra por |
|---|---|---|---|
| **Canon** (`src/uix/`) | densidad de contrato (eventos, ARIA, teclado, tokens que otros consumen) | morfo + matriz de aceptación | ruta de 9 fases (`docs/building-a-component.md`) |
| **Packs** (`src/packs/`) | decoración parametrizada de hoja | contrato P | `packs-check` |
| **Blocks** (`src/uix/blocks/`) | composición de función de página | contrato B (§3) | `blocks-check` |
| Tier | Valor | Contrato | Entra por |
| ------------------------------ | ------------------------------------------------------------------------ | ---------------------------- | ------------------------------------------------ |
| **Canon** (`src/uix/`) | densidad de contrato (eventos, ARIA, teclado, tokens que otros consumen) | morfo + matriz de aceptación | ruta de 9 fases (`docs/building-a-component.md`) |
| **Packs** (`src/packs/`) | decoración parametrizada de hoja | contrato P | `packs-check` |
| **Blocks** (`src/uix/blocks/`) | composición de función de página | contrato B (§3) | `blocks-check` |
**Regla de admisión (espejo de la de packs)**: si al construir un block hace
falta comportamiento nuevo con superficie de contrato — un evento real, una
@ -83,15 +83,15 @@ quedó enmendada** respecto a la propuesta original del plan (que proponía
`src/blocks/`); el resto se firmó tal como estaba propuesto. D-BLK.3/4/5
derivan de doctrina ya vigente y se firmaron por no-objeción.
| # | Decisión | FIRMADO |
|---|---|---|
| # | Decisión | FIRMADO |
| ----------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **D-BLK.1** | Ubicación y alias | **`src/uix/blocks/`** + alias `$blocks` (decisión de usuario: el tier es UI y vive junto a las capas). La prueba de encapsulación se conserva íntegra: borrar `src/uix/blocks/` deja `npm run check` verde y NADA del canon (`morfo/soma/sema/eidos/active-uix/langs`) lo importa — blocks es un tier bajo `src/uix/`, no una quinta capa. |
| **D-BLK.2** | Estilos | **Layout-components-first**: el layout se hace componiendo `Container/Section/Stack/Flex/Grid/AutoGrid/Wrap/Group/Separator/AspectRatio/Surface` y sus props. Un block NO trae `.css` propio; un `<style>` scoped puntual exige justificación en su README y nunca selecciona internals de componentes compuestos. |
| **D-BLK.3** | API | Componente compuesto con partes anidadas: `<SiteHeader>` / `<SiteHeader.Nav>` / `<SiteHeader.Actions>`; contenido SIEMPRE por children, nunca árboles de datos (`items={...}` solo donde el componente canónico compuesto ya es data-driven). (Derivada de la regla compositional-not-data-driven ya vigente.) |
| **D-BLK.4** | Demos | `web/routes/blocks/{kebab}/+page.svelte` + galería índice en `web/routes/blocks/`. (La ruta `web/routes/alpha/` sigue TERMINADA y prohibida — no tocarla.) |
| **D-BLK.5** | Idiomas/strings | Un block no posee NINGÚN string visible: todo texto llega del app como children/props. Si un string parece inevitable, es superficie de contrato → lo posee el componente canónico subyacente (vía `texts:` del morfo + langs). (Consecuencia mecánica de B-1: sin morfo no hay `texts:`.) |
| **D-BLK.6** | Servicios | **v1 sin servicios**: los blocks NO consumen `uix.prefs`/langs/eidos directamente; el cableado (tema/idioma, submit de auth, transporte) llega como handlers/props del app. Revisable si ≥2 blocks demuestran necesidad real (misma vara que la 2-de-3). |
| **D-BLK.7** | Naming F1 | `sticky` · `anchor-nav` · `empty-state` · `result` · `callout` · `prose` · `sidebar` — confirmados. (Los matices de naming siguen revisables en la fase 0 de cada uno, como toda fase 0.) |
| **D-BLK.2** | Estilos | **Layout-components-first**: el layout se hace componiendo `Container/Section/Stack/Flex/Grid/AutoGrid/Wrap/Group/Separator/AspectRatio/Surface` y sus props. Un block NO trae `.css` propio; un `<style>` scoped puntual exige justificación en su README y nunca selecciona internals de componentes compuestos. |
| **D-BLK.3** | API | Componente compuesto con partes anidadas: `<SiteHeader>` / `<SiteHeader.Nav>` / `<SiteHeader.Actions>`; contenido SIEMPRE por children, nunca árboles de datos (`items={...}` solo donde el componente canónico compuesto ya es data-driven). (Derivada de la regla compositional-not-data-driven ya vigente.) |
| **D-BLK.4** | Demos | `web/routes/blocks/{kebab}/+page.svelte` + galería índice en `web/routes/blocks/`. (La ruta `web/routes/alpha/` sigue TERMINADA y prohibida — no tocarla.) |
| **D-BLK.5** | Idiomas/strings | Un block no posee NINGÚN string visible: todo texto llega del app como children/props. Si un string parece inevitable, es superficie de contrato → lo posee el componente canónico subyacente (vía `texts:` del morfo + langs). (Consecuencia mecánica de B-1: sin morfo no hay `texts:`.) |
| **D-BLK.6** | Servicios | **v1 sin servicios**: los blocks NO consumen `uix.prefs`/langs/eidos directamente; el cableado (tema/idioma, submit de auth, transporte) llega como handlers/props del app. Revisable si ≥2 blocks demuestran necesidad real (misma vara que la 2-de-3). |
| **D-BLK.7** | Naming F1 | `sticky` · `anchor-nav` · `empty-state` · `result` · `callout` · `prose` · `sidebar` — confirmados. (Los matices de naming siguen revisables en la fase 0 de cada uno, como toda fase 0.) |
Cualquier enmienda futura a una D-BLK se registra aquí con fecha ANTES de
seguir construyendo (regla dura: los desvíos de alcance se declaran, nunca en
@ -101,19 +101,19 @@ silencio).
## 3. El contrato B (suelo de calidad de un block — guard: `blocks-check`)
| B | Obligación |
|---|---|
| **B-1** | Sin morfo, sin pack sema, sin fila en `component:audit`. Un block es composición; el comportamiento con contrato se promociona al canon ANTES (regla de admisión §1). |
| **B-2** | Todo elemento interactivo es un componente eidos del catálogo (`Button`, `Link`, `Field`, …). Elementos nativos interactivos crudos (`button/input/select/textarea/a`) = **error** de `blocks-check`. (Excepción única: el HTML que `Prose` recibe ya renderizado — ese contenido es del app.) |
| **B-3** | Los componentes compuestos se consumen AS-IS por sus props públicos (`variant/size/color/…`). Prohibido re-estilizar sus internals desde el block (ni CSS ni `style=`). Los colores son siempre roles/tokens vía props — un block no decide color fuera del sistema. |
| **B-4** | Dependencia unidireccional: `src/uix/blocks/*` importa `$uix`, `$adom` y arts públicos; nada del canon (`src/uix/{morfo,soma,sema,eidos,active-uix,langs}`) ni de `src/{arts,libs,packs}` importa de `src/uix/blocks/`. Borrar el tier deja `check` verde — la prueba de encapsulación se mantiene aunque viva bajo `src/uix/`. Entre blocks tampoco se importa (B-10). |
| **B-5** | Contenido por composición (children/snippets). Nunca `root={tree}` ni props-árbol propias. |
| **B-6** | Responsive con los mecanismos del framework (props responsive de los componentes de layout, breakpoints canónicos). Cero `matchMedia`/listeners propios — si hiciera falta observar algo, es señal de componente canónico (→ admisión). |
| **B-7** | Cero strings propios (D-BLK.5). |
| **B-8** | Landmarks correctos: elemento sectioning + `aria-label`/`aria-labelledby` cuando hay más de un landmark del mismo tipo; jerarquía de headings coherente y documentada en el README del block (qué nivel emite y cómo se ajusta). |
| **B-9** | Cada block: `README.md` (secciones: **Función · Mapa de composición** — qué componentes canónicos usa y con qué props — **· Decisiones · Gaps-con-disposición**) + demo con profundidad de testbed (cada prop pública = control vivo; guía: `docs/guides/demo-authoring.md`, adaptada — sin las 9 tabs completas de componente, mínimo: escena realista + panel de props + código copiable). |
| **B-10** | Un block no importa otro block. Si dos blocks comparten estructura, la pieza compartida o es un componente canónico o se duplica conscientemente (anotado en Gaps). Excepción declarada: los shells (`app-shell`, `docs-shell`) SÍ componen blocks/componentes de F1 por diseño — se lista explícitamente en su README. |
| **B-11** | Motion: solo vía los props `motion`/presets de los componentes compuestos o `Cascade` para coreografía de entrada. Cero `@keyframes`/transitions propias (la regla R-4.5 del canon aplica moralmente aunque el audit no corra aquí). |
| B | Obligación |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **B-1** | Sin morfo, sin pack sema, sin fila en `component:audit`. Un block es composición; el comportamiento con contrato se promociona al canon ANTES (regla de admisión §1). |
| **B-2** | Todo elemento interactivo es un componente eidos del catálogo (`Button`, `Link`, `Field`, …). Elementos nativos interactivos crudos (`button/input/select/textarea/a`) = **error** de `blocks-check`. (Excepción única: el HTML que `Prose` recibe ya renderizado — ese contenido es del app.) |
| **B-3** | Los componentes compuestos se consumen AS-IS por sus props públicos (`variant/size/color/…`). Prohibido re-estilizar sus internals desde el block (ni CSS ni `style=`). Los colores son siempre roles/tokens vía props — un block no decide color fuera del sistema. |
| **B-4** | Dependencia unidireccional: `src/uix/blocks/*` importa `$uix`, `$adom` y arts públicos; nada del canon (`src/uix/{morfo,soma,sema,eidos,active-uix,langs}`) ni de `src/{arts,libs,packs}` importa de `src/uix/blocks/`. Borrar el tier deja `check` verde — la prueba de encapsulación se mantiene aunque viva bajo `src/uix/`. Entre blocks tampoco se importa (B-10). |
| **B-5** | Contenido por composición (children/snippets). Nunca `root={tree}` ni props-árbol propias. |
| **B-6** | Responsive con los mecanismos del framework (props responsive de los componentes de layout, breakpoints canónicos). Cero `matchMedia`/listeners propios — si hiciera falta observar algo, es señal de componente canónico (→ admisión). |
| **B-7** | Cero strings propios (D-BLK.5). |
| **B-8** | Landmarks correctos: elemento sectioning + `aria-label`/`aria-labelledby` cuando hay más de un landmark del mismo tipo; jerarquía de headings coherente y documentada en el README del block (qué nivel emite y cómo se ajusta). |
| **B-9** | Cada block: `README.md` (secciones: **Función · Mapa de composición** — qué componentes canónicos usa y con qué props — **· Decisiones · Gaps-con-disposición**) + demo con profundidad de testbed (cada prop pública = control vivo; guía: `docs/guides/demo-authoring.md`, adaptada — sin las 9 tabs completas de componente, mínimo: escena realista + panel de props + código copiable). |
| **B-10** | Un block no importa otro block. Si dos blocks comparten estructura, la pieza compartida o es un componente canónico o se duplica conscientemente (anotado en Gaps). Excepción declarada: los shells (`app-shell`, `docs-shell`) SÍ componen blocks/componentes de F1 por diseño — se lista explícitamente en su README. |
| **B-11** | Motion: solo vía los props `motion`/presets de los componentes compuestos o `Cascade` para coreografía de entrada. Cero `@keyframes`/transitions propias (la regla R-4.5 del canon aplica moralmente aunque el audit no corra aquí). |
`blocks-check` (F0.5) verifica mecánicamente: B-2 (AST/regex de elementos
nativos interactivos), B-4 (dirección de imports), B-1 (no hay ficheros bajo
@ -178,15 +178,15 @@ explícita; responder en castellano, código y docs en inglés.
**Objetivo**: que exista el tier con doctrina, guard y sitio donde vivir —
vacío pero verde.
| Paso | Producir | Verificación |
|---|---|---|
| F0.1 | **Firma D-BLK** (§2) con el usuario; enmendar el plan si procede | **HECHA 2026-07-21** — firmas en §2 (D-BLK.1 enmendada: `src/uix/blocks/`) |
| F0.2 | `docs/architecture/blocks.md` — la doctrina de §1 + §3 en formato espejo de `packs.md` (frontmatter E1, regla de admisión, hard boundaries, contrato B, "promotion path" = la F1 como ejemplo vivido). Enlazar sin copiar: canon → `CANON.md`, ruta → `building-a-component.md` | `npm run docs:check` (links) |
| F0.3 | Alias `$blocks` → `src/uix/blocks` en el const `aliases` de `vite.config.ts` (fuente de verdad) + `svelte.config.js` en sync + fila en la tabla de aliases de `CLAUDE.md` | `npm run check` |
| F0.4 | `src/uix/blocks/README.md` — mapa del tier (inventario vivo = el árbol, como packs) + template de README de block (B-9) | — |
| F0.5 | `scripts/blocks-check.ts` + npm script `blocks:check` — los checks mecánicos listados en §3. Espejo estructural de `packs-check`. Test negativo: un fixture con `<button>` crudo debe fallar | `npm run blocks:check` verde en tier vacío + test negativo rojo |
| F0.6 | `web/routes/blocks/+page.svelte` — galería índice (dogfooding: componer `Container/Section/Card/…` del propio catálogo para la galería) | navegador claro/oscuro |
| F0.7 | Cablear docs: fila E1 en el mapa de `docs/README.md` (architecture/blocks.md) + nota del tier en el diagrama de arquitectura de `CLAUDE.md` (línea `blocks/ → …`) + cross-ref en `docs/comparison.md` si aporta | `npm run docs:check` |
| Paso | Producir | Verificación |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| F0.1 | **Firma D-BLK** (§2) con el usuario; enmendar el plan si procede | **HECHA 2026-07-21** — firmas en §2 (D-BLK.1 enmendada: `src/uix/blocks/`) |
| F0.2 | `docs/architecture/blocks.md` — la doctrina de §1 + §3 en formato espejo de `packs.md` (frontmatter E1, regla de admisión, hard boundaries, contrato B, "promotion path" = la F1 como ejemplo vivido). Enlazar sin copiar: canon → `CANON.md`, ruta → `building-a-component.md` | `npm run docs:check` (links) |
| F0.3 | Alias `$blocks` → `src/uix/blocks` en el const `aliases` de `vite.config.ts` (fuente de verdad) + `svelte.config.js` en sync + fila en la tabla de aliases de `CLAUDE.md` | `npm run check` |
| F0.4 | `src/uix/blocks/README.md` — mapa del tier (inventario vivo = el árbol, como packs) + template de README de block (B-9) | — |
| F0.5 | `scripts/blocks-check.ts` + npm script `blocks:check` — los checks mecánicos listados en §3. Espejo estructural de `packs-check`. Test negativo: un fixture con `<button>` crudo debe fallar | `npm run blocks:check` verde en tier vacío + test negativo rojo |
| F0.6 | `web/routes/blocks/+page.svelte` — galería índice (dogfooding: componer `Container/Section/Card/…` del propio catálogo para la galería) | navegador claro/oscuro |
| F0.7 | Cablear docs: fila E1 en el mapa de `docs/README.md` (architecture/blocks.md) + nota del tier en el diagrama de arquitectura de `CLAUDE.md` (línea `blocks/ → …`) + cross-ref en `docs/comparison.md` si aporta | `npm run docs:check` |
**Cierre F0**: `check` + `docs:check` + `blocks:check` verdes; galería
renderiza vacía con mensaje de "en construcción" compuesto con el catálogo.
@ -378,6 +378,7 @@ registrada, nunca omisión.
> este sidebar queda app-céntrico. Además: el dossier (P4) fija el suelo
> shadcn (23 partes, dos ejes estado/modo, costuras open/onOpenChange/
> toggle expuestas desde v1) — la fase 0 dimensiona contra él por E-2.
- **Membresía**: soma + eidos. Es el componente más pesado de F1 —
reservarle tanda propia.
- **Fase 0, comparar**: shadcn `Sidebar` (el patrón de referencia actual) ·
@ -399,7 +400,7 @@ registrada, nunca omisión.
COMPONE `Drawer` (no lo reimplementa) — el provider decide qué montar por
breakpoint responsive del sistema, no matchMedia propio.
- **Eidos**: raíl con `will-change` cuidado (memoria: `will-change:
transform` produce jitter en raíles finos con DPR≠1 — override a `auto`);
transform` produce jitter en raíles finos con DPR≠1 — override a `auto`);
tooltips de item en modo raíl componen `Tooltip`; tokens `--sidebar-*`
(width, rail-width, paddings).
- **Demo**: shell de app simulada, toggle colapso, grupos, móvil (375px) con
@ -426,9 +427,9 @@ registrada, nunca omisión.
- **Morfo (boceto)**: parts `provider` (nav landmark, `aria-label` vía
`texts:`) + `list` + `item` + `trigger` (grupo colapsable) + `link`
(compone `Link`; `aria-current="page"` del MISMO estado que `data-active`)
+ slot badge (compone `Badge`). Data: `data-active`, `data-expanded`,
profundidad como CSS var tokenizada (no attr por nivel). Keyboard: patrón
disclosure; roving opcional opt-in.
- slot badge (compone `Badge`). Data: `data-active`, `data-expanded`,
profundidad como CSS var tokenizada (no attr por nivel). Keyboard: patrón
disclosure; roving opcional opt-in.
- **Soma**: árbol como datos; el activo llega por costura del app (matcher
de URL — el componente NO conoce el router); active-trail expande
ancestros; colapso de grupos evalúa componer `collapsible`
@ -439,7 +440,7 @@ registrada, nunca omisión.
docs), active-trail vivo, móvil 375px.
**Cierre F1**: los 8 con `component:audit --only` PASS (o NEEDS-WORK
únicamente por reglas D-* de demos v3 si esa fase global sigue abierta —
únicamente por reglas D-\* de demos v3 si esa fase global sigue abierta —
anotar en la tabla), `morfo:check`, `eidos-lint` por componente, suite
`npx vitest run src/uix/eidos` verde, `npm run check` sin regresión sobre
baseline.
@ -457,6 +458,7 @@ ampliaciones = Gaps con disposición, no código especulativo.
**Depende de**: F1.1 (`sticky`) para F2.1; el resto de F2 no depende de F1.
### F2.1 `site-header`
- **Función**: cabecera de sitio con afijado y cambio de elevación al pegarse;
colapso a menú móvil.
- **Compone**: `Sticky` (F1.1) + `Container` + `NavigationMenu` + `Button` +
@ -472,6 +474,7 @@ ampliaciones = Gaps con disposición, no código especulativo.
- **Demo**: página con scroll largo, claro/oscuro, 375/1280, RTL.
### F2.2 `hero`
- **Función**: sección de apertura con titular, subtítulo, acciones y media.
- **Compone**: `Section` + `Container` + `Stack`/`Grid` + `Heading`
(nivel configurable, default h1) + `Text` + `Group` (acciones con
@ -485,6 +488,7 @@ ampliaciones = Gaps con disposición, no código especulativo.
- **Demo**: ambos layouts, con/sin media, con badge, dark.
### F2.3 `feature-grid`
- **Compone**: `Section` + `Container` + `AutoGrid` + `Stack` + `Icon` +
`Heading` + `Text`.
- **API**: `<FeatureGrid>` + `.Header` (title+description de sección) +
@ -493,12 +497,13 @@ ampliaciones = Gaps con disposición, no código especulativo.
- **Demo**: 3/6 items, columnas responsive del AutoGrid.
### F2.4 `pricing`
- **Compone**: `Section` + `CardGroup`/`Card` + `Heading` + `Text` + `Badge`
(plan destacado) + `Button` + `ToggleGroup` (mensual/anual) + filas de
features (`Stack` + `Group` + `Icon` check + `Text`).
- **API**: `<Pricing>` + `.Switch` (billing toggle; estado de vista local
permitido §1) + `.Plan` (prop `featured?`) + `.PlanPrice` + `.PlanFeatures`
+ `.PlanAction`.
- `.PlanAction`.
- **v1**: 2–4 planes en fila responsive; el precio mostrado por periodo lo
resuelve el app con el valor del toggle (block emite el cambio vía prop
callback del ToggleGroup — sin formatear moneda: eso es `FormatNumber`
@ -506,6 +511,7 @@ ampliaciones = Gaps con disposición, no código especulativo.
- **Demo**: 3 planes, featured al centro, toggle vivo.
### F2.5 `testimonials`
- **Compone**: `Section` + `AutoGrid` + `Card` + `Avatar` + `Text` +
`Group`.
- **API**: `<Testimonials>` + `.Header` + `.Item` (+`.ItemAuthor` con
@ -514,6 +520,7 @@ ampliaciones = Gaps con disposición, no código especulativo.
cuando una demo real la pida).
### F2.6 `faq`
- **Compone**: `Section` + `Container` (medida estrecha) + `Heading` +
`Accordion`.
- **API**: `<Faq>` + `.Header` + `.Item` (proxy fino de Accordion.Item con
@ -522,6 +529,7 @@ ampliaciones = Gaps con disposición, no código especulativo.
- **v1**: una columna; `type` del accordion expuesto tal cual.
### F2.7 `stats-band`
- **Compone**: `Section` + `Group`/`AutoGrid` + `Metrics` + `CountUp` +
`Text`.
- **API**: `<StatsBand>` + `.Stat` (valor + etiqueta; `CountUp` opt-in por
@ -530,6 +538,7 @@ ampliaciones = Gaps con disposición, no código especulativo.
formateados o vía `FormatNumber` compuesto por el app.
### 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).
@ -540,6 +549,7 @@ ampliaciones = Gaps con disposición, no código especulativo.
`variant` NO existe: medido, el canvas `soft` no acota panel (bitácora).
### F2.9 `newsletter` — HECHO (2026-07-30)
- **Compone**: como F2.8 + `Form` (`variant="plain"`) + `Grid` de fila
`1fr auto`; el `Field` de correo y el `Form.Submit` los compone el app en
sus slots.
@ -558,6 +568,7 @@ ampliaciones = Gaps con disposición, no código especulativo.
los declara el morfo del `Form`.
### F2.10 `site-footer` — HECHO (2026-07-30)
- **Compone**: `<footer>` + `Section` + `Container` + `Motion` + `Grid`
(`1fr 3fr`: marca | columnas) + `AutoGrid` (las columnas) + `Separator` +
`Flex` (barra inferior).
@ -568,20 +579,26 @@ ampliaciones = Gaps con disposición, no código especulativo.
que es compound. `extra` es el slot libre de D-BLK.6.
- **v1**: 2–5 columnas fluidas (`minChildWidth`, sin breakpoints) → 2 en móvil.
### F2.11 `banner` (E-3)
- **Compone**: el componente `Banner` existente + `Container` + `Link` +
`Button` (dismiss lo posee Banner si ya lo trae — leer su README).
- **API**: `<SiteBanner>` + children; posición top pareja de `site-header`.
### F2.11 `banner` — HECHO (2026-07-31)
- **Compone**: el componente `Banner` del canon (reenviando su superficie
entera vía `Omit<BannerProps, 'children'>`) + `Container` + `Wrap` +
`Banner.Close`.
- **API**: `<SiteBanner container onDismiss>` + slots `badge` · `children` ·
`action`. El dismiss lo posee el `Banner` (composición, no booleano), así que
el block solo cablea el clic y **la visibilidad es del app**.
- **Nota dossier (P1)**: 13 TW · 16 Untitled · 5 Flowbite — la categoría
convergente más barata de cubrir (el componente ya existe).
### F2.12 `team` (E-3)
- **Compone**: `Section` + `AutoGrid` + `Avatar` + `Heading` + `Text` +
`Group` (`IconButton` sociales por miembro).
- **API**: `<Team>` + `.Header` + `.Member` (+`.MemberAvatar`/`.MemberName`/
`.MemberRole`/`.MemberLinks`).
### F2.13 `contact` (E-3)
- **Compone**: `Section` + `Grid` (info + form) + `Form` + `Field` +
`Textarea` + `Button` + filas de datos de contacto (`Icon` + `Text` +
`Link`).
@ -589,15 +606,16 @@ ampliaciones = Gaps con disposición, no código especulativo.
`Form` con handlers del app (misma regla que `newsletter`).
### F2.14 `content-section` (E-3)
- **Compone**: `Section` + `Container` (medida estrecha) + **`Prose`
(F1.5)** + slots de media (`Image`/`Figure` fuera del flujo prose).
- **API**: `<ContentSection>` + children (el HTML renderizado va al Prose).
- **Depende de**: F1.5.
*(`logo-cloud` sigue en F5: su versión honesta pide `Marquee` o queda en un
_(`logo-cloud` sigue en F5: su versión honesta pide `Marquee` o queda en un
`Wrap` trivial que no justifica block todavía — decisión anotada, revisable.
El resto de la unión del dossier — bento, gallery, cookie-consent, popups,
careers, events, comparison, timeline — queda en F5 con disparador, E-3.)*
careers, events, comparison, timeline — queda en F5 con disparador, E-3.)_
**Cierre F2**: `blocks-check` verde; galería `web/routes/blocks/` con los 14;
una página compuesta de PRUEBA (header + hero + features + pricing + faq +
@ -612,6 +630,7 @@ Mismas reglas y ficha que F2. **Depende de**: F1.2/F1.3/F1.4 (estados),
F1.7 (`sidebar`) para F3.1.
### F3.1 `app-shell`
- **Función**: esqueleto de aplicación: sidebar + topbar + contenido (+ aside
opcional).
- **Compone**: `Sidebar` (F1.7) + `Sticky` + `Container`/`Grid` +
@ -625,6 +644,7 @@ F1.7 (`sidebar`) para F3.1.
blocks — allowlist en `blocks-check`.
### F3.2 `auth`
- **Función**: familia de formularios de identidad. Sub-blocks: `sign-in` ·
`sign-up` · `recover` · `otp`.
- **Compone**: `Card` + `Form` + `Field` + `PasswordField` + `PinInput`
@ -640,6 +660,7 @@ F1.7 (`sidebar`) para F3.1.
- **Demo**: los 4 kinds, estados invalid/submitting, claro/oscuro.
### F3.3 `data-table`
- **Función**: la tabla de trabajo completa: toolbar (búsqueda + filtros +
visibilidad de columnas + bulk actions con selección) + `Table` +
`Pagination` + `EmptyState` integrado.
@ -658,6 +679,7 @@ F1.7 (`sidebar`) para F3.1.
estado vacío tras filtrar.
### F3.4 `dashboard`
- **Compone**: `AutoGrid`/`Grid` + `Card` + `Metrics`/`CountUp` + `Chart`
(tipos ya existentes) + `Feed` + `EmptyState`.
- **API**: `<Dashboard>` + `.Stat` + `.Panel` (card con `.PanelHeader` +
@ -666,15 +688,17 @@ F1.7 (`sidebar`) para F3.1.
ni drag (Gap explícito; si un día entra, el drag es de `drag-drop`).
### F3.5 `settings`
- **Compone**: `Container` (medida estrecha) + `Section` + `Heading` +
`Separator` + filas `Field`/controles + `Callout` (F1.4, intent `threat`)
+ `AlertDialog` (confirmación destructiva) + `Button`.
- `AlertDialog` (confirmación destructiva) + `Button`.
- **API**: `<Settings>` + `.Section` (+`.SectionTitle`/`.SectionDescription`)
+ `.Row` (label+control+ayuda) + `.DangerZone`.
- `.Row` (label+control+ayuda) + `.DangerZone`.
- **Regla**: cada control es el componente del ecosistema (nunca nativos —
B-2); el guardado (por fila o global) lo decide el app vía `Form`.
### F3.6 `user-menu`
- **Compone**: `Avatar` + `DropdownMenu` (+ items con `Icon`, `Separator`,
`Kbd` opcional).
- **API**: `<UserMenu>` + `.Trigger` (Avatar + nombre) + `.Items` (children
@ -683,6 +707,7 @@ F1.7 (`sidebar`) para F3.1.
da la estructura y los puntos de montaje.
### F3.7 `notifications`
- **Compone**: `Popover` (desktop) / `Drawer` (móvil) + `Badge` (contador en
el trigger `IconButton`) + `Feed` + `EmptyState` + `Button` («marcar
leídas» — string del app).
@ -693,6 +718,7 @@ F1.7 (`sidebar`) para F3.1.
lección ya está aprendida — no re-descubrirla).
### F3.8 `wizard`
- **Compone**: `Stepper` + `Form` + `Group` de `Button`s (atrás/siguiente/
finalizar) + `Progress` opcional.
- **API**: `<Wizard>` + `.Steps` (Stepper compuesto) + `.Step` (contenido) +
@ -702,12 +728,14 @@ F1.7 (`sidebar`) para F3.1.
estado de validez lo dicta `Form`.
### F3.9 `error-page`
- **Compone**: `Result` (F1.3) + `Group` de `Button`/`Link` + `SearchField`
opcional (404).
- **API**: `<ErrorPage status=…>` + children de acciones.
- **v1**: 404 · 403 · 500 · offline.
### F3.10 `kanban`
- **Compone**: `DragDrop` + `Grid`/`Group` de columnas + `Card` +
`VirtualList` (columnas largas) + `Badge` + `EmptyState` por columna.
- **⚠️ Fase 0 OBLIGADA**: leer `soma/components/drag-drop` a fondo; el
@ -717,9 +745,9 @@ F1.7 (`sidebar`) para F3.1.
libres). El modelo de datos y la persistencia del orden son del app.
- **Demo**: 3 columnas, drag entre columnas, columna vacía, teclado.
*(Diferidos de F3, registrados en F5: `scheduler` — bloqueado hasta que
`chronos` aterrice; `chat-room` — NO va aquí: la familia `chat-*` es canon y
su roadmap vive en `next-features.md` §7.)*
_(Diferidos de F3, registrados en F5: `scheduler` — bloqueado hasta que
`chronos` aterrice; `chat-room` — NO va aquí: la familia `chat-_`es canon y
su roadmap vive en`next-features.md` §7.)\*
**Cierre F3**: `blocks-check` verde; demo de `app-shell` montando dentro
`data-table` + `dashboard` + `notifications` + `user-menu` como página de
@ -735,6 +763,7 @@ F2.1. Conecta con la iniciativa docs-corpus=site (el corpus es la semilla
del sitio); estos blocks son su vehículo de UI.
### F4.1 `docs-shell`
- **Compone**: `AppShell` (F3.1, excepción B-10) especializado: **`NavTree`
(F1.8)** como árbol de docs + `Prose` (main) + `AnchorNav` (TOC derecha) +
`Breadcrumb` + trigger de búsqueda (`Command` — ya existe) + prev/next
@ -743,10 +772,11 @@ del sitio); estos blocks son su vehículo de UI.
documentado en el README del block; el art `shortcuts` conserva su
disparador F5 (≥2 consumidores reales).
- **API**: `<DocsShell>` + `.Nav` + `.Article` (prose) + `.Toc` + `.Search`
+ `.PrevNext`.
- `.PrevNext`.
- **v1**: 3 columnas desktop → TOC colapsada y sidebar-drawer en móvil.
### F4.2 `code-showcase`
- **Compone**: `Tabs` (Preview/Code) + `Surface` (lienzo de preview) +
`CodeBlock` + `Clipboard` + `Toolbar` opcional.
- **API**: `<CodeShowcase>` + `.Preview` (children vivos) + `.Code`
@ -755,6 +785,7 @@ del sitio); estos blocks son su vehículo de UI.
lienzo = Gap, notado — pediría servicios, D-BLK.6.)
### F4.3 `props-table`
- **Función**: tabla de referencia de un componente **generada del morfo** —
parts, data-attrs, ARIA, keyboard y eventos salen de `compileMorfo`
(ventaja estructural única de este framework: el contrato ES
@ -776,17 +807,17 @@ PASS), verificada en navegador.
Componentes canon candidatos (cada uno entraría por la ruta de 9 fases):
| Pieza | Disparador |
|---|---|
| `lightbox` (visor imagen zoom/galería) | cuando un block de media/galería real lo pida |
| `tour` (onboarding spotlight) | cuando el app-shell tenga consumidor real con onboarding |
| `hover-card` genérico | cuando un tercer caso no-URL aparezca (hoy `link-preview` cubre) |
| `description-list` | primer detail-view real (pareja natural de `data-table`) |
| `loading-overlay` | primera pantalla con carga bloqueante real |
| `transfer-list` | primer admin real con asignación dual |
| `mention` | ya registrado en `next-features.md` §7 (chat) — no duplicar aquí |
| Pieza | Disparador |
| ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `lightbox` (visor imagen zoom/galería) | cuando un block de media/galería real lo pida |
| `tour` (onboarding spotlight) | cuando el app-shell tenga consumidor real con onboarding |
| `hover-card` genérico | cuando un tercer caso no-URL aparezca (hoy `link-preview` cubre) |
| `description-list` | primer detail-view real (pareja natural de `data-table`) |
| `loading-overlay` | primera pantalla con carga bloqueante real |
| `transfer-list` | primer admin real con asignación dual |
| `mention` | ya registrado en `next-features.md` §7 (chat) — no duplicar aquí |
| `masonry` · `bottom-nav` · `swipe-actions` · `pull-to-refresh` · `image-compare` · `marquee` (¿pack?) · `watermark` · `signature-pad` | demanda real; varios son candidatos a pack, no a canon — decidir con la regla de admisión de packs |
| art `shortcuts` (registro global de atajos + cheat-sheet con `Kbd`) | cuando ≥2 consumidores reales (command palette global + docs) lo pidan |
| art `shortcuts` (registro global de atajos + cheat-sheet con `Kbd`) | cuando ≥2 consumidores reales (command palette global + docs) lo pidan |
Blocks diferidos: `scheduler` (bloqueado por `chronos`) · `logo-cloud`
(bloqueado por decisión marquee) · `billing` · `file-manager` ·
@ -797,7 +828,7 @@ Blocks diferidos: `scheduler` (bloqueado por `chronos`) · `logo-cloud`
## 6. Verificación de cierre del plan (definition of done global)
1. `npm run check` sin regresión; `npm run blocks:check` verde; `npm run
component:audit` → los 7 de F1 PASS (o NEEDS-WORK solo por D-* de la fase
component:audit` → los 7 de F1 PASS (o NEEDS-WORK solo por D-\* de la fase
demos global); `docs:check` verde.
2. Las tres páginas de integración (F2 landing · F3 app · F4 docs) renderizan
compuestas SOLO de canon+blocks, verificadas visualmente en claro/oscuro,
@ -1078,7 +1109,7 @@ Blocks diferidos: `scheduler` (bloqueado por `chronos`) · `logo-cloud`
pintura sólida completa y conserva icono/etiqueta/spinner (antes el `child`
los sustituía: por eso la flecha del sitio alpha estaba escrita a mano); y
soma deja de estampar `type` en un elemento que no es suyo (`<a
type="button">` es una pista de MIME falsa) — el morfo lo declara
type="button">` es una pista de MIME falsa) — el morfo lo declara
condicional. `Button` sigue sin `href` y `Link` sigue poseyendo la
navegación: las dos decisiones firmadas se mantienen. Doctrina en el README
de eidos Button §«CTA que navega»; en uso en la demo del block.
@ -1146,8 +1177,8 @@ Blocks diferidos: `scheduler` (bloqueado por `chronos`) · `logo-cloud`
- 2026-07-23 — **Primitivo canon `Mockup` + F2.3b `feature-split` HECHOS** (mismo
día; scope-approval del usuario: «split + primitivo de media reutilizable»).
La clave del dossier: *la brecha del hero/features es el TRATAMIENTO DE MEDIA,
no el conteo de layouts*. En vez de falsear un screenshot por demo, se cierra
La clave del dossier: _la brecha del hero/features es el TRATAMIENTO DE MEDIA,
no el conteo de layouts_. En vez de falsear un screenshot por demo, se cierra
de raíz con un componente del canon.
- **`Mockup`** (`src/uix/eidos/components/mockup/` + morfo eidos-only, 0-event,
como `aspect-ratio`): enmarca media en cromo de dispositivo —
@ -1168,9 +1199,9 @@ Blocks diferidos: `scheduler` (bloqueado por `chronos`) · `logo-cloud`
`string & Snippet`. Fix: `Omit<HTMLAttributes, 'children' | 'title'>` +
renombrar el slot `background`→`backdrop`. Lección para blocks: **un slot de
snippet cuyo nombre sea un atributo HTML necesita Omit o un nombre distinto**.
Gates: `blocks:check` verde (4 blocks) · `svelte-check` sin errores propios
(73 = deuda ajena) · eidos lint.test + morfo 131/131. **Siguiente**: F2.4
`pricing`.
Gates: `blocks:check` verde (4 blocks) · `svelte-check` sin errores propios
(73 = deuda ajena) · eidos lint.test + morfo 131/131. **Siguiente**: F2.4
`pricing`.
- 2026-07-24 — **Fix de `Grid` + F2.4 `pricing` HECHOS**.
- **Fix canon `Grid`** (theming/changelog §47): `align`/`justify`/`alignContent`
@ -1192,8 +1223,8 @@ Blocks diferidos: `scheduler` (bloqueado por `chronos`) · `logo-cloud`
precios a la vez (0/29/99 → 0/23/79), featured con acento+elevación, claro/
oscuro/RTL. **Hueco a decisión del usuario**: tabla de comparación
(features × planes) — otra disposición, candidato a hermano `pricing-table`.
Gates: `blocks:check` verde (5 blocks) · `svelte-check` sin errores propios.
**Siguiente**: F2.5 `testimonials`.
Gates: `blocks:check` verde (5 blocks) · `svelte-check` sin errores propios.
**Siguiente**: F2.5 `testimonials`.
- 2026-07-24 — **F2.5 `testimonials` HECHO**. Compound (`.Item` repite, SIN
contexto — como feature-grid): `<Testimonials>` + `.Header` + `.Items` +
@ -1256,7 +1287,7 @@ Blocks diferidos: `scheduler` (bloqueado por `chronos`) · `logo-cloud`
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
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,
@ -1360,3 +1391,39 @@ Blocks diferidos: `scheduler` (bloqueado por `chronos`) · `logo-cloud`
idioma cambiando el idioma de verdad. Cero errores de página. Gates:
`blocks:check` verde (11 blocks) · `svelte-check` sin errores propios · prettier
limpio. **Siguiente**: F2.11 `banner`.
- 2026-07-31 — **F2.11 `banner` HECHO**. El block más FINO del tier a propósito:
cuando el canon ya tiene la pieza, el block es colocación y nada más. Reenvía la
superficie del `Banner` con `Omit<BannerProps, 'children'>` (no re-declara
`intent`/`variant`/`size` ni los estrecha), lo mete en columna con `Container`
(`width="100%"` + `paddingX={0}`: a sangre por fuera, en columna por dentro),
envuelve con `Wrap` en vez de una fila rígida, y cablea `Banner.Close` solo si
llega `onDismiss`. La demo lo enseña **encima del `site-header` de verdad**, con
página para desplazarse: un aviso dentro de un recuadro no se parece a un aviso.
**Hallazgo real (fase 0 lo anticipó)**: `Banner` estampa `role="banner"` DESPUÉS
de sus rest props, así que no se puede relajar; con el `site-header` en la misma
página quedan **dos landmarks `banner`** (medido). Único paliativo hoy:
nombrarlos con `aria-label`.
**Y una lección de método que costó una sesión**: reporté tres «defectos del
canon» (`aria-label` ausente en `Banner.Close`, `type` desaparecido de todo
`Button`, `IconButton` tragándose el `onclick`) y **los tres eran falsos**. La
causa: medía demasiado pronto. Los `data-variant`/`data-size` los escribe el
componente de eidos al renderizar —están desde el primer frame—, pero `type`,
`aria-label` y los handlers los aplica la **runtime del morfo en un efecto
posterior**. A ~1s: `type` ausente en 3 de 3 botones y ningún clic disparando; a
~6s: `type="button"`, `aria-label="Descartar"` y el descarte funcionando. La
espera válida es un atributo que solo pueda haber puesto la runtime
(`waitForFunction(() => boton.hasAttribute('type'))`), no que el nodo exista ni
que se vea. Regla afinada en `CONTINUE-blocks.md`.
De paso, un aviso de soma que sí era real y era mío: `Tabs.List` sin nombre
accesible en `BlockDemo.svelte` — afectaba a las 12 demos del tier.
Verificado en navegador: los 4 intents, los 3 tratamientos, `descartable` sí/no
(con `no` el botón **deja de renderizarse**, no se oculta), claro/oscuro/RTL y
420px, la tira siempre por encima de la cabecera, cero desbordamiento
horizontal, y los controles de la demo moviendo la vista previa en línea. Gates:
`blocks:check` verde (12 blocks) · `svelte-check` sin errores propios ·
`docs:check` sin errores míos · prettier limpio. **Siguiente**: F2.12 `team`.

@ -0,0 +1,81 @@
# SiteBanner
## Function
The announcement strip at the very top of the page, above the header: a message,
an optional pill, an inline action and an optional dismiss.
Named `SiteBanner`, not `Banner`, because the canon component it composes owns
that name — same convention as `site-header` / `site-footer`.
## Composition map
| Slot | Composes | Notes |
| ------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| root | `Banner` (canon) | the whole visual surface — `intent` (the canonical color roles) · `variant` · `size` · landmark · recipe — is forwarded, not re-declared |
| measure | `Container` (`width="100%"`, `paddingX={0}`) | full-bleed outside, in-column inside |
| the row | `Wrap` (`gap 3`, centred) | wraps instead of crushing |
| `badge` · `children` · `action` | — (app) | pill · message · inline `Link`/`Button` |
| dismiss | `Banner.Close` (canon) | rendered only when `onDismiss` is passed |
## What it does NOT do
**It does not own the visibility.** `Banner`'s README settled that —
_«composition, not a boolean `dismissible`»_ — so the app wraps the block in its
own `{#if}` and passes `onDismiss`. A block that kept the state would be a second
source of truth for it.
**It emits no semantic event.** The canon declares zero for `Banner`, on purpose:
a persistent notice with no dismiss is as legitimate as a dismissible one, so the
verb belongs to whoever closes it.
**It adds no variant of its own.** When the canon already has the piece, the
block is placement and nothing else — a private treatment here would compete with
`Banner` instead of composing it.
## Form: snippet slots
A banner's parts neither repeat nor coordinate, so there is no compound API —
the tier's admission rule. _(The plan sketched `<SiteBanner>` + children; the
`badge` and `action` slots are the two positions the references converge on.)_
## Decisions
**2026-07-30 — reference floor** (dossier: Tailwind Plus «Banners» 13 · Untitled
UI 16 · Flowbite 5 — the most-shipped category of the whole dossier):
- **Adopted**: the full-bleed strip with a centred message, an inline action and
a dismiss at the end; the "New" pill before the message; wrapping on narrow.
- **Adopted**: the placement — above the header. The demo shows it there, with
the real `site-header` block underneath and enough page to scroll, because a
banner inside a framed box does not look like a banner.
- **Not adopted — sticky / bottom variants** (cookie-consent shape): app-land.
The canon already has `Sticky`; whether a notice should follow the reader is a
product decision, not the block's.
- **No entrance motion.** The component deferred owned motion until ≥2 cases, and
a strip that fades in above the header shifts the whole page on load.
- **`aria-label` on the strip**, because the page ends up with two `banner`
landmarks (see Gaps) and naming them is all an app can do today.
## Demo
`web/routes/blocks/banner/` — the strip above the real `site-header`, with live
control of intent, variant, dismissibility and direction, and the device widths
served from `preview/`. Mini-page in `BannerSite.svelte`, shared by both
surfaces: it owns the `dismissed` state, as the canon requires.
## Gaps
| Gap | Disposition |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`role="banner"` cannot be overridden** | **canon** — the component stamps it AFTER its rest props, so a page that also has a header gets a second banner landmark. Measured in the preview: two. Mitigated only by naming both with `aria-label` |
| **Sticky / bottom notice** | **app-land** — compose `Sticky`; a notice that follows the reader spends the same space twice |
| **Dismissal persistence** (cookie / storage) | **app-land** — the component's README discarded it explicitly |
| **Animated entrance / exit** | **deferred** — the component deferred it until ≥2 cases; here it would shift the page on load |
## Found while composing
- **`Banner` hard-codes `role="banner"` after `{...restProps}`**, so no consumer
can relax it to a plain region. The strip + `site-header` combination — which
is the only placement the references ship — therefore always produces two
`banner` landmarks. Registered in `docs/process/PLAN-blocks-quality.md` §6.

@ -0,0 +1,61 @@
<script lang="ts">
/**
* SiteBanner — the announcement strip at the top of the page, above the header.
*
* It is a thin PLACEMENT over the canon `Banner`: the strip's paint (intent ·
* variant · size), its landmark and its dismiss button already exist there, so
* the block adds only what a page-level announcement needs — a content measure
* so the message lines up with the page's column, a row that wraps instead of
* crushing, and the dismiss wiring.
*
* WHAT IT DOES NOT DO: own the visibility. `Banner`'s README settled that
* («composition, not a boolean `dismissible`»); the app wraps this in its own
* `{#if}` and passes `onDismiss`. It emits no semantic event either — the canon
* declares zero for `Banner`, on purpose: a persistent notice with no dismiss is
* as legitimate as a dismissible one, so the verb belongs to whoever closes it.
*
* FORM: snippet slots. A banner's parts neither repeat nor coordinate.
*
* ⚠️ TWO BANNER LANDMARKS: `Banner` hard-codes `role="banner"` AFTER its rest
* props, so it cannot be overridden — and a page that also has the `site-header`
* block gets a second one (a top-level `<header>` is a banner by default).
* Pass `aria-label` to tell them apart. Registered in the README's gaps.
*
* B contract: canon components only — no `.css`, no morfo, no strings of its own.
*/
import { Banner } from '$uix/eidos/components/banner';
import { Container } from '$uix/eidos/components/container';
import { Wrap } from '$uix/eidos/components/wrap';
import type { SiteBannerProps } from './types';
let { container = 'xl', onDismiss, badge, action, children, ...rest }: SiteBannerProps = $props();
</script>
<Banner {...rest}>
<!--
`width="100%"` so the container takes the strip's free space and its own
max-width does the capping (as a flex item it would otherwise shrink to its
content); `paddingX={0}` because the gutter is already the Banner's padding,
and doubling it would push the message off its column on a phone.
-->
<Container size={container} width="100%" paddingX={0}>
<!-- Wraps instead of crushing: an announcement plus its action does not fit
on one line of a phone, and a strip that clips its own CTA is worse than
a two-line strip. -->
<Wrap gap={3} align="center" justify="center">
{#if badge}
{@render badge()}
{/if}
{@render children?.()}
{#if action}
{@render action()}
{/if}
</Wrap>
</Container>
{#if onDismiss}
<!-- `Banner.Close` composes the canon `IconButton` and localises its own
accessible name; the recipe pushes it to the end. The block only wires
the click. -->
<Banner.Close variant="ghost" onclick={onDismiss} />
{/if}
</Banner>

@ -0,0 +1,23 @@
// SiteBanner — the announcement strip at the top of the page, above the header.
//
// import { SiteBanner } from '$blocks/banner';
//
// {#if showNotice}
// <SiteBanner intent="primary" variant="solid" onDismiss={() => (showNotice = false)}>
// {#snippet badge()}<Badge size="sm">Nuevo</Badge>{/snippet}
// Las cohortes en directo ya están disponibles para todos los planes.
// {#snippet action()}<Link href="/novedades">Ver qué cambia</Link>{/snippet}
// </SiteBanner>
// {/if}
//
// Named `SiteBanner`, not `Banner`, because the canon component it composes owns
// that name — same convention as `site-header` / `site-footer`.
//
// The app owns the visibility (the canon's decision, not the block's) and every
// visible word (B-7). Snippet slots: a banner's parts neither repeat nor
// coordinate.
import SiteBanner from './banner.svelte';
export { SiteBanner };
export default SiteBanner;
export type { SiteBannerProps } from './types';

@ -0,0 +1,38 @@
import type { Snippet } from 'svelte';
import type { BannerProps } from '$uix/eidos/components/banner';
import type { ContainerSize } from '$uix/eidos/components/container';
/**
* Props for the `site-banner` block.
*
* It extends the canon `Banner`'s own surface rather than re-declaring it, so
* `intent` (the canonical color roles), `variant` (soft / solid / outline / ghost),
* `size`, `aria-label` and every HTML attribute pass straight through to the
* strip. The block adds only what a PAGE-level announcement needs: a content
* measure, the dismiss wiring and the slots.
*/
export type SiteBannerProps = Omit<BannerProps, 'children'> & {
/**
* Content measure inside the full-bleed strip — the message lines up with the
* page's content column instead of running edge to edge on a wide screen.
* Passed straight to `Container`. @default 'xl'
*/
container?: ContainerSize;
/**
* Wire the dismiss. When present the block renders `Banner.Close` (which
* composes the canon `IconButton` and localises its own accessible name) and
* calls this on click.
*
* The block does NOT own the visibility, exactly like the component: the app
* wraps it in its own `{#if}`. That is the canon's decision — «composition,
* not a boolean `dismissible`» — and a block that kept the state would be
* inventing a second source of truth for it.
*/
onDismiss?: () => void;
/** A chip before the message — the "New" / "Beta" pill the references ship. */
badge?: Snippet;
/** The inline action: a `Link` or a `Button`, after the message. */
action?: Snippet;
/** The announcement itself. */
children?: Snippet;
};

@ -167,7 +167,9 @@
<Stack gap={3}>
<Separator />
<Tabs bind:value={tab} variant="line">
<Tabs.List>
<!-- Un tablist necesita nombre: sin él, un lector de pantalla anuncia
«lista de pestañas» y ya. Lo avisa el propio soma por consola. -->
<Tabs.List aria-label="Documentación del block">
{#each tabs as t (t.value)}
<Tabs.Trigger value={t.value}>{t.label}</Tabs.Trigger>
{/each}

@ -30,7 +30,7 @@ export const BLOCK_CATALOG: BlockGroup[] = [
{ slug: 'cta', label: 'CTA', shipped: true },
{ slug: 'newsletter', label: 'Newsletter', shipped: true },
{ slug: 'site-footer', label: 'Site footer', shipped: true },
{ slug: 'banner', label: 'Banner', shipped: false },
{ slug: 'banner', label: 'Banner', shipped: true },
{ slug: 'team', label: 'Team', shipped: false },
{ slug: 'contact', label: 'Contact', shipped: false },
{ slug: 'content-section', label: 'Content section', shipped: false }

@ -0,0 +1,225 @@
<script lang="ts">
/**
* SiteBanner 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 BannerSite from './BannerSite.svelte';
import DocRow from '../_lib/DocRow.svelte';
import type { BannerIntent, BannerVariant } from '$uix/eidos/components/banner';
let intent = $state<BannerIntent>('primary');
let variant = $state<BannerVariant>('solid');
let dismissible = $state(true);
let dir = $state<'ltr' | 'rtl'>('ltr');
const previewSrc = $derived(
`/blocks/banner/preview?intent=${intent}&variant=${variant}&dismissible=${dismissible}&dir=${dir}`
);
</script>
<BlockDemo
name="Banner"
slug="$blocks/banner"
{previewSrc}
previewHeight="720px"
meta={[
{ key: 'compone', value: 'Banner (canon) · Container · Wrap · Banner.Close' },
{ key: 'landmark', value: 'header role=banner (del componente)' },
{ key: 'forma', value: 'slots de snippet' },
{ key: 'visibilidad', value: 'del app — el block no la posee' }
]}
>
{#snippet preview()}
<BannerSite {intent} {variant} {dismissible} />
{/snippet}
{#snippet lede()}
El aviso de arriba del todo. Es el block más <strong>fino</strong> del tier a propósito: el
componente <Code>Banner</Code> del canon ya trae la pintura, el landmark y el botón de cierre, así
que aquí solo se añade lo que un aviso de PÁGINA necesita — una medida para que el mensaje caiga en
la columna de la página, una fila que envuelve en vez de aplastar, y el cableado del descarte.
{/snippet}
{#snippet controls()}
<Wrap gap={5}>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">intent</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[intent]}
onValueChange={(v) => (intent = (v[0] ?? intent) as BannerIntent)}
aria-label="intent del aviso"
>
<ToggleGroup.Item value="primary">primary</ToggleGroup.Item>
<ToggleGroup.Item value="affirm">affirm</ToggleGroup.Item>
<ToggleGroup.Item value="risk">risk</ToggleGroup.Item>
<ToggleGroup.Item value="threat">threat</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">variant</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[variant]}
onValueChange={(v) => (variant = (v[0] ?? variant) as BannerVariant)}
aria-label="tratamiento del aviso"
>
<ToggleGroup.Item value="solid">solid</ToggleGroup.Item>
<ToggleGroup.Item value="soft">soft</ToggleGroup.Item>
<ToggleGroup.Item value="outline">outline</ToggleGroup.Item>
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">descartable</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[dismissible ? 'sí' : 'no']}
onValueChange={(v) => (dismissible = (v[0] ?? 'sí') === 'sí')}
aria-label="descartable"
>
<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 escenario es el de verdad: el aviso, el block <Code>site-header</Code> debajo y página suficiente
para desplazarse. Un aviso dentro de un recuadro no se parece a un aviso.
</Text>
<Stack gap={3}>
<DocRow term="la tira">
El componente <Code>Banner</Code> del canon, tal cual: <Code>intent</Code> (los roles de color
canónicos),
<Code>variant</Code>, <Code>size</Code>, su landmark y su recipe. El block reenvía toda
esa superficie en vez de re-declararla.
</DocRow>
<DocRow term="la medida">
<Code>Container width="100%" paddingX={0}</Code>: a sangre por fuera, en columna por
dentro. El <Code>width</Code> es necesario porque como ítem flex el contenedor se encogería
a su contenido; el <Code>paddingX={0}</Code>, porque el margen ya lo pone el propio <Code
>Banner</Code
> y doblarlo saca el mensaje de la columna en un móvil.
</DocRow>
<DocRow term="la fila">
<Code>Wrap</Code>, no una fila rígida: un anuncio con su acción no cabe en una línea de
teléfono, y una tira que recorta su propio CTA es peor que una tira de dos líneas.
</DocRow>
<DocRow term="el descarte">
<Code>Banner.Close</Code> del canon —que compone el <Code>IconButton</Code> y localiza su propio
nombre accesible—, y el block solo cablea el clic.
</DocRow>
</Stack>
</Stack>
{/snippet}
{#snippet api()}
<Stack gap={3}>
<DocRow term="SiteBanner">
Extiende los props del <Code>Banner</Code> del canon (<Code>intent</Code> ·
<Code>variant</Code> · <Code>size</Code> · <Code>aria-label</Code> · atributos HTML) y añade
<Code>container</Code> (def. <Code>xl</Code>) y <Code>onDismiss</Code>.
</DocRow>
<DocRow term="badge · children · action">
Snippets: la píldora, el mensaje y la acción en línea. Todo el contenido entra por
composición (B-7).
</DocRow>
<DocRow term="onDismiss, no `dismissible`">
Pasa un handler y aparece el botón; no lo pases y no hay botón. La visibilidad la posee el
app: es la decisión que ya tomó el componente («composición, no un booleano») y un block que
guardara el estado sería una segunda fuente de verdad.
</DocRow>
</Stack>
{/snippet}
{#snippet a11y()}
<Stack gap={3}>
<DocRow term="Dos landmarks `banner`">
<Code>Banner</Code> fija <Code>role="banner"</Code> DESPUÉS de sus rest props, así que no se puede
sobreescribir; y una página con <Code>site-header</Code> ya tiene otro (un
<Code>header</Code> de primer nivel lo es por defecto). Medido en la vista previa: dos. Lo único
que puede hacer el app hoy es <strong>nombrarlos</strong> con
<Code>aria-label</Code>, que es lo que hace la demo.
</DocRow>
<DocRow term="No es una región viva">
Un aviso persistente no interrumpe al lector: <Code>Banner</Code> es landmark, no
<Code>role="alert"</Code>. Para lo que sí interrumpe está <Code>Toast</Code>, con su
<Code>aria-live</Code>. Es una decisión del canon y el block no la toca.
</DocRow>
<DocRow term="El cierre se nombra solo">
<Code>Banner.Close</Code> resuelve su etiqueta por el catálogo de idiomas (<Code
>#?common.buttons.dismiss</Code
>), así que el aviso no necesita que el app escriba «cerrar» en cuatro idiomas.
</DocRow>
</Stack>
{/snippet}
{#snippet gaps()}
<Stack gap={3}>
<DocRow term="`role=banner` no se puede sobreescribir">
<strong>Canon</strong> — el componente lo estampa después de los rest props. Un aviso sobre una
página con cabecera crea un segundo landmark; hoy solo se puede mitigar nombrándolos.
</DocRow>
<DocRow term="Pegajoso o al pie">
<strong>App-land</strong> — el canon ya trae <Code>Sticky</Code>; el app envuelve. No se
mete como prop porque un aviso que persigue al lector ocupa dos veces el mismo espacio y esa
es una decisión de producto, no del block.
</DocRow>
<DocRow term="Persistencia del descarte">
<strong>App-land</strong> — cookie o <Code>localStorage</Code> es del app, y el README del componente
ya lo descartó explícitamente.
</DocRow>
<DocRow term="Entrada / salida animada">
<strong>Diferido</strong> — el componente lo dejó para cuando haya ≥2 casos. Aquí no se
añade un <Code>Motion</Code>: un aviso que aparece con fundido por encima de la cabecera
mueve la página entera al cargar.
</DocRow>
</Stack>
{/snippet}
{#snippet notes()}
<Stack gap={3}>
<DocRow term="El block más fino del tier, a propósito">
Cuando el canon ya tiene la pieza, el block es colocación y nada más. Añadir aquí una
variante propia sería competir con <Code>Banner</Code>, no componerlo.
</DocRow>
<DocRow term="El link hereda la tinta">
Sobre una tira sólida la acción va con <Code>color="currentColor"</Code>: el
<Code>Banner</Code> ya puso la tinta de contraste que garantiza su intent, así que heredarla funciona
en todos los roles y en los dos modos.
</DocRow>
</Stack>
{/snippet}
</BlockDemo>

@ -0,0 +1,114 @@
<script lang="ts">
/**
* The banner mini-page. A banner only makes sense ABOVE the page's header, so
* the scenario is the real thing: the strip, the `site-header` block under it,
* and enough page below to scroll.
*
* The APP owns the visibility — that is the canon's decision for `Banner` and
* the block keeps it — so the `{#if}` and the `dismissed` state live here.
*/
import { SiteBanner } from '$blocks/banner';
import { SiteHeader } from '$blocks/site-header';
import { Section } from '$uix/eidos/components/section';
import { Container } from '$uix/eidos/components/container';
import { Stack } from '$uix/eidos/components/stack';
import { Group } from '$uix/eidos/components/group';
import { Heading } from '$uix/eidos/components/heading';
import { Text } from '$uix/eidos/components/text';
import { Link } from '$uix/eidos/components/link';
import { Badge } from '$uix/eidos/components/badge';
import { Button } from '$uix/eidos/components/button';
import * as Icon from '$uix/eidos/components/icon';
import type { BannerIntent, BannerVariant } from '$uix/eidos/components/banner';
let {
intent = 'primary' as BannerIntent,
variant = 'solid' as BannerVariant,
dismissible = true
}: {
intent?: BannerIntent;
variant?: BannerVariant;
dismissible?: boolean;
} = $props();
let dismissed = $state(false);
</script>
{#if !dismissed}
<!--
`aria-label` porque la página tiene DOS landmarks `banner`: este strip (que
fija `role="banner"` y no deja sobreescribirlo) y el `<header>` del
`site-header`. Nombrarlo es lo único que puede hacer el app hoy.
-->
<SiteBanner
{intent}
{variant}
aria-label="Aviso del producto"
onDismiss={dismissible ? () => (dismissed = true) : undefined}
>
{#snippet badge()}
<Badge size="sm" variant="solid" color="neutral">Nuevo</Badge>
{/snippet}
Las cohortes en directo ya están disponibles en todos los planes.
{#snippet action()}
<Link href="#novedades" size="sm" color="currentColor">Ver qué cambia →</Link>
{/snippet}
</SiteBanner>
{/if}
<!-- El `site-header` no pinta (es un block de colocación), así que la superficie y
el borde los pone el app, igual que en su propia demo. -->
<SiteHeader
sticky
offset={0}
aria-label="Principal"
style="background: var(--color-surface-default); border-block-end: var(--border-width) solid var(--color-border-subtle);"
>
{#snippet brand()}
<Group gap={2} align="center" justify="start">
<Icon.Activity size="md" />
<Text size="lg" weight="semibold">Vicen</Text>
</Group>
{/snippet}
{#snippet nav()}
<Group gap={5} align="center" justify="center">
<Link href="#producto" variant="subtle" size="sm">Producto</Link>
<Link href="#precios" variant="subtle" size="sm">Precios</Link>
<Link href="#docs" variant="subtle" size="sm">Documentación</Link>
</Group>
{/snippet}
{#snippet actions()}
<Group gap={2} align="center" justify="end">
<Link href="#entrar" variant="subtle" size="sm">Entrar</Link>
<Button size="sm" intent="fulfill">Empezar gratis</Button>
</Group>
{/snippet}
</SiteHeader>
<Section size="lg">
<Container size="lg">
<Stack gap={4}>
<Heading level={1}>Todo lo que ocurre en tu producto, medido</Heading>
<Text color="muted" measure="wide">
El aviso de arriba es el block. Va antes que la cabecera, a sangre, y cuando se descarta la
página se recoloca sola — la visibilidad la posee esta página, no el block.
</Text>
</Stack>
</Container>
</Section>
<Section size="lg">
<Container size="lg">
<Stack gap={4}>
<Heading level={2}>Suficiente página para poder desplazarse</Heading>
<Text color="muted" measure="wide">
La cabecera se ancla al desplazar; el aviso no, porque un aviso que persigue al lector ocupa
dos veces el mismo espacio. Si hiciera falta, el canon ya trae Sticky.
</Text>
</Stack>
</Container>
</Section>

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

@ -0,0 +1,16 @@
<script lang="ts">
/**
* Standalone page for the banner mini-site — same component the demo renders
* inline, served as its OWN document for the device-width frame.
*/
import { page } from '$app/state';
import BannerSite from '../BannerSite.svelte';
import type { BannerIntent, BannerVariant } from '$uix/eidos/components/banner';
const params = $derived(page.url.searchParams);
const intent = $derived((params.get('intent') ?? 'primary') as BannerIntent);
const variant = $derived((params.get('variant') ?? 'solid') as BannerVariant);
const dismissible = $derived(params.get('dismissible') !== 'false');
</script>
<BannerSite {intent} {variant} {dismissible} />
Loading…
Cancel
Save

Powered by TurnKey Linux.